Faiss 乘积量化 (PQ) 算法详解:从向量压缩原理到实战应用
在大规模向量搜索场景中,内存和带宽往往成为性能瓶颈。Faiss 提供的乘积量化(Product Quantization, PQ)算法是解决这一问题的核心技术之一。PQ 算法的核心目标是以牺牲少量搜索精度的代价,将高维向量表示为极短的编码(通常只有几十个字节),从而实现惊人的数据压缩和查询加速。
1. PQ 算法压缩原理
乘积量化不是简单地截断向量,而是基于“子向量”的量化思想:
- 向量分割(Division):将原始 $D$ 维向量 $\mathbf{x}$ 平均分割成 $M$ 个 $D/M$ 维的子向量 $\mathbf{x}_1, \mathbf{x}_2, \ldots, \mathbf{x}_M$。
- 独立训练(Training):对每个子向量空间,独立地使用 $k$ 均值聚类(K-Means)训练出一个包含 $k$ 个中心点的码本(Codebook)。如果使用 $B$ 比特来编码,那么 $k = 2^B$。
- 编码替换(Encoding):对于数据库中的任意向量 $\mathbf{x}$,其每个子向量 $\mathbf{x}_i$ 被替换为其在对应子码本中最接近的中心点的索引 $c_i$。
最终,原始的 $D$ 维浮点向量被 $M$ 个整数索引(即 $M \times B$ 比特的编码)所取代,极大地减少了存储空间。例如,一个 128 维的 float32 向量(512 字节)使用 $M=8, B=8$ 的 PQ 编码后,仅需 8 字节。
2. 实操:使用 Faiss IndexPQ
在 Faiss 中,纯粹的 PQ 索引由 IndexPQ 实现。注意:PQ 索引必须经过训练才能使用。
环境准备
pip install faiss-gpu # 或 faiss-cpu
Python 代码示例
我们将创建一个 128 维,包含 100,000 个向量的数据库,并使用 8 个子向量进行 8 bit 编码。
import faiss
import numpy as np
import time
# --- 配置参数 ---
D = 128 # 向量维度
N = 100000 # 数据库向量数量
Nt = 20000 # 训练向量数量 (必须足够大)
# PQ 参数
M = 8 # 乘积量化的子向量数量 (Sub-vectors)
B = 8 # 每个子向量使用 8 bits (即 256 个质心)
# --- 1. 生成数据 ---
np.random.seed(1234)
# 数据库向量 (float32)
x_db = np.random.random((N, D)).astype('float32')
# 训练向量 (必须使用训练集来学习码本)
x_train = np.random.random((Nt, D)).astype('float32')
# 查询向量
x_query = np.random.random((10, D)).astype('float32')
print(f"原始数据大小: {N * D * 4 / (1024*1024):.2f} MB")
print(f"压缩后数据大小估算: {N * M / (1024*1024):.2f} MB (8字节编码)")
# --- 2. 创建并训练 IndexPQ ---
start_time = time.time()
# D: 维度, M: 子向量数量, B: 比特数/子向量
index_pq = faiss.IndexPQ(D, M, B)
# 训练是 PQ 算法的关键步骤,用于生成 M 个子码本
index_pq.train(x_train)
print(f"训练耗时: {time.time() - start_time:.2f} 秒")
# --- 3. 添加向量 ---
index_pq.add(x_db)
print(f"向量添加完成, 索引大小: {index_pq.ntotal}")
# --- 4. 搜索和精度分析 ---
k = 10 # 搜索最近的 10 个邻居
start_search = time.time()
D_pq, I_pq = index_pq.search(x_query, k)
end_search = time.time()
print(f"PQ 搜索耗时: {end_search - start_search:.4f} 秒")
print("PQ 搜索结果 (索引):")
print(I_pq)
# --- 5. 精度损失对比 (可选:创建精确索引进行对比) ---
index_flat = faiss.IndexFlatL2(D)
index_flat.add(x_db)
D_flat, I_flat = index_flat.search(x_query, k)
# 比较第一个查询的 Top-K 结果
hit_count = len(np.intersect1d(I_pq[0], I_flat[0]))
print(f"\n第一个查询 Top-{k} 结果在 PQ 和 Flat 索引中的重合数量: {hit_count}/{k}")
3. 精度损失深度分析 (HNSW-PQ 与 IVFPQ)
纯粹的 IndexPQ 在搜索时使用的是基于近似距离计算的查找表(Look-up Table),它避免了计算原始向量间的欧氏距离,这是它速度快的原因。但这种距离是量化误差后的距离,导致精度(Recall)下降。
如何缓解精度损失?
- 提高 $B$(比特数):将 8 bit 提高到 12 bit 或 16 bit,虽然压缩率下降,但量化误差减少,精度提高。
- 增加 $M$(子向量数量):增加子向量数量可以使每个子向量的维度 $D/M$ 更小,从而使 K-Means 训练效果更好,减少局部量化误差。
- 使用混合索引 (Hybrid Indexes):
- IVFPQ (IndexIVFPQ):这是工业界最常用的配置。它首先使用倒排索引(IVF)将搜索范围缩小到几个聚类中心,然后再在这些小簇内使用 PQ 编码进行精细化搜索。这大幅提升了召回率。
- HNSW + PQ:将 HNSW 用于粗略搜索,然后使用 PQ 来存储和计算距离,平衡了搜索速度、内存占用和召回率。
总结: 当内存是绝对瓶颈,并且对搜索精度要求不是极致 100% 召回时,PQ 是向量搜索中最强大、最实用的压缩手段。但在实际应用中,通常推荐使用 IndexIVFPQ 或 IndexHNSWPQ 来平衡性能和精度。
汤不热吧