大规模语义检索的近似算法:Product Quantization 和 IVF 的实现原理
一、深度引言与场景痛点
向量检索系统初期总是美好的。几千条文档、几百维向量,用暴力计算做精确的 Top-K,延迟稳稳控制在几十毫秒。然后业务增长,文档量从几千涨到几万、几十万、几百万。暴力计算的时间线性增长,延迟从 50ms 到 500ms 到 5 秒——系统就这样在你眼皮底下慢慢"热死"了。
问题的本质是最近邻搜索的计算复杂度。给定一个查询向量 q 和 N 个数据库向量,精确的 Top-K 需要计算 q 和所有 N 个向量的距离,复杂度 O(N×D)(D 是向量维度)。当 N=100 万、D=1536 时,一次检索要进行 15 亿次浮点运算。即便用 GPU 加速,延迟也在百毫秒级别,对于需要实时响应的 RAG 系统来说是不可接受的。
这就是近似最近邻搜索(ANN)存在的意义:用可控的精度损失换取数量级的性能提升。Product Quantization(PQ)和 Inverted File Index(IVF)是两种最经典的近似算法,几乎所有现代向量数据库(Milvus、Faiss、Pinecone)的索引实现都建立在它们之上。
二、底层机制与原理深度剖析
PQ 和 IVF 解决的是不同维度的问题,但组合起来威力巨大。PQ 负责压缩——把高维向量压成紧凑码,大幅减少内存占用和距离计算量。IVF 负责剪枝——把搜索空间从"全量"缩小到"相关的那一小撮"。
PQ 的核心思想是把每个 D 维向量切成 S 段,每段 d=D/S 维。对每个子空间单独做 K-Means 聚类,生成 C 个聚类中心(码字),用码本的索引号(uint8)替代原始的 float32 值。原来一个向量要存 D×4 字节(float32),PQ 编码后只存 S×1 字节(uint8 索引),压缩比约 4×C/d。更重要的是距离计算——q到数据库中某向量的距离可以通过查表近似计算:先算q各子段到 C 个码字的距离,建一个 S×C 的查找表,然后对每个数据库向量做 S 次查表和累加。复杂度从 O(N×D) 降到 O(N×S + S×C×d),当 S << D 时速度提升非常显著。
IVF 的核心思想是在检索前先做粗聚类。构建阶段用 K-Means 把所有向量聚成 M 个簇(cluster),每个簇维护一个倒排列表(inverted list),记录属于该簇的向量。检索时,先算查询向量到 M 个聚类中心的距离,只去最近的 P 个(nprobe)簇里做精确检索。如果 P << M,候选集就从 N 缩到了约 P×(N/M),搜索空间大幅减小。nprobe 是速度-精度的调节旋钮:nprobe 越大越接近全量检索(精度高、速度慢),越小越快但精度越低。
PQ + IVF 的组合:先 IVF 粗筛缩小范围,再在候选集上用 PQ 做快速距离近似。两个"近似"叠加,精度损失可控(Recall@10 通常在 95%-99%),速度提升可以达到 10-100 倍。这就是为什么 Faiss 的IndexIVFPQ是生产环境中最常用的索引类型。
三、生产级代码实现
import logging from dataclasses import dataclass from typing import Any import numpy as np logger = logging.getLogger(__name__) @dataclass class PQCodec: """Product Quantization 编码器""" n_subspaces: int # S:子空间数 n_codewords: int # C:每个子空间的码字数 sub_dim: int # 每个子空间的维度 codebooks: list[np.ndarray] | None = None # S 个码本,每个形状 (C, sub_dim) class ProductQuantizer: """Product Quantization 实现""" def __init__(self, dim: int, n_subspaces: int = 32, n_codewords: int = 256) -> None: if dim % n_subspaces != 0: raise ValueError(f"dim ({dim}) must be divisible by n_subspaces ({n_subspaces})") self.dim = dim self.n_subspaces = n_subspaces self.n_codewords = n_codewords self.sub_dim = dim // n_subspaces self.codec = PQCodec( n_subspaces=n_subspaces, n_codewords=n_codewords, sub_dim=self.sub_dim, ) def train(self, vectors: np.ndarray) -> None: """训练码本:每个子空间独立做 K-Means""" n = vectors.shape[0] codebooks: list[np.ndarray] = [] for s in range(self.n_subspaces): start = s * self.sub_dim end = start + self.sub_dim sub_vectors = vectors[:, start:end] # 随机选 C 个点作为初始聚类中心(生产环境用 K-Means++) indices = np.random.choice(n, min(self.n_codewords, n), replace=False) centroids = sub_vectors[indices].copy() # 简化版 K-Means(10 次迭代) for _ in range(10): # 分配点到最近的中心 dists = np.sum((sub_vectors[:, np.newaxis, :] - centroids[np.newaxis, :, :]) ** 2, axis=2) assignments = np.argmin(dists, axis=1) # 更新中心 for c in range(self.n_codewords): members = sub_vectors[assignments == c] if len(members) > 0: centroids[c] = members.mean(axis=0) codebooks.append(centroids) self.codec.codebooks = codebooks def encode(self, vectors: np.ndarray) -> np.ndarray: """将向量编码为 PQ 码(uint8 索引数组)""" if self.codec.codebooks is None: raise RuntimeError("Codec not trained. Call train() first.") n = vectors.shape[0] codes = np.zeros((n, self.n_subspaces), dtype=np.uint8) for s in range(self.n_subspaces): start = s * self.sub_dim end = start + self.sub_dim sub_vectors = vectors[:, start:end] codebook = self.codec.codebooks[s] dists = np.sum( (sub_vectors[:, np.newaxis, :] - codebook[np.newaxis, :, :]) ** 2, axis=2, ) codes[:, s] = np.argmin(dists, axis=1).astype(np.uint8) return codes def decode(self, codes: np.ndarray) -> np.ndarray: """从 PQ 码解码回近似向量""" if self.codec.codebooks is None: raise RuntimeError("Codec not trained.") n = codes.shape[0] vectors = np.zeros((n, self.dim), dtype=np.float32) for s in range(self.n_subspaces): start = s * self.sub_dim end = start + self.sub_dim vectors[:, start:end] = self.codec.codebooks[s][codes[:, s]] return vectors class IVFIndex: """IVF 倒排索引""" def __init__(self, dim: int, n_clusters: int = 1024) -> None: self.dim = dim self.n_clusters = n_clusters self.centroids: np.ndarray | None = None # 倒排列表: cluster_id -> [vector_indices] self.inverted_lists: list[list[int]] = [] def train(self, vectors: np.ndarray) -> None: """K-Means 聚类生成簇中心""" n = vectors.shape[0] indices = np.random.choice(n, self.n_clusters, replace=False) self.centroids = vectors[indices].copy().astype(np.float32) for _ in range(15): dists = np.sum( (vectors[:, np.newaxis, :] - self.centroids[np.newaxis, :, :]) ** 2, axis=2, ) assignments = np.argmin(dists, axis=1) for c in range(self.n_clusters): members = vectors[assignments == c] if len(members) > 0: self.centroids[c] = members.mean(axis=0) def build(self, vectors: np.ndarray) -> None: """建立倒排列表""" self.inverted_lists = [[] for _ in range(self.n_clusters)] dists = np.sum( (vectors[:, np.newaxis, :] - self.centroids[np.newaxis, :, :]) ** 2, axis=2, ) assignments = np.argmin(dists, axis=1) for i, cluster_id in enumerate(assignments): self.inverted_lists[int(cluster_id)].append(i) def search( self, query: np.ndarray, vectors: np.ndarray, k: int = 10, nprobe: int = 16 ) -> tuple[np.ndarray, np.ndarray]: """检索 Top-K""" if self.centroids is None: raise RuntimeError("Index not trained.") # 1. 找最近的 nprobe 个聚类 cluster_dists = np.sum((self.centroids - query) ** 2, axis=1) nearest_clusters = np.argpartition(cluster_dists, nprobe)[:nprobe] # 2. 收集候选向量 candidate_ids: list[int] = [] for c_id in nearest_clusters: candidate_ids.extend(self.inverted_lists[int(c_id)]) if not candidate_ids: return np.array([], dtype=np.int64), np.array([], dtype=np.float32) candidate_ids = np.array(candidate_ids, dtype=np.int64) candidate_vectors = vectors[candidate_ids] # 3. 精确距离计算 dists = np.sum((candidate_vectors - query) ** 2, axis=1) # 4. Top-K top_k = min(k, len(dists)) top_indices = np.argpartition(dists, top_k)[:top_k] top_indices = top_indices[np.argsort(dists[top_indices])] return candidate_ids[top_indices], dists[top_indices] @property def avg_list_size(self) -> float: """平均倒排列表长度(用于评估索引质量)""" if not self.inverted_lists: return 0 return sum(len(lst) for lst in self.inverted_lists) / len(self.inverted_lists) def evaluate_recall( pq: ProductQuantizer, ivf: IVFIndex, vectors: np.ndarray, test_queries: np.ndarray, k: int = 10, ) -> dict[str, float]: """评估 PQ + IVF 的召回率""" exact_recalls = 0 approx_recalls = 0 for query in test_queries: # 精确检索(ground truth) exact_dists = np.sum((vectors - query) ** 2, axis=1) exact_top = np.argpartition(exact_dists, k)[:k] # PQ + IVF 近似检索 approx_ids, _ = ivf.search(query, vectors, k=k, nprobe=32) exact_recalls += len(set(exact_top) & set(approx_ids)) approx_recalls += len(approx_ids) return { "recall@k": exact_recalls / (k * len(test_queries)) if test_queries.shape[0] > 0 else 0, } def main() -> None: np.random.seed(42) n_vectors, dim = 10000, 128 vectors = np.random.randn(n_vectors, dim).astype(np.float32) # 归一化(余弦相似度检索需要) vectors = vectors / np.linalg.norm(vectors, axis=1, keepdims=True) # PQ 训练和编码 pq = ProductQuantizer(dim=dim, n_subspaces=16, n_codewords=256) pq.train(vectors) codes = pq.encode(vectors) decoded = pq.decode(codes) # 量化误差 mse = np.mean((vectors - decoded) ** 2) print(f"PQ 量化误差 (MSE): {mse:.6f}") # IVF 训练和检索 ivf = IVFIndex(dim=dim, n_clusters=100) ivf.train(vectors) ivf.build(vectors) print(f"IVF 平均倒排列表大小: {ivf.avg_list_size:.1f}") # 召回率评估 test_queries = np.random.randn(100, dim).astype(np.float32) test_queries = test_queries / np.linalg.norm(test_queries, axis=1, keepdims=True) recall = evaluate_recall(pq, ivf, vectors, test_queries, k=10) print(f"Recall@10: {recall['recall@k']:.3f}") if __name__ == "__main__": logging.basicConfig(level=logging.INFO) main()代码中值得关注的点:PQ 训练时每个子空间独立做 K-Means,这意味着码本之间没有交互。PQ 编码把 float32 压缩为 uint8(256 个码字只占 1 字节),内存从 512 字节/向量(128 维 × 4 字节)压缩到 16 字节/向量(16 个子空间 × 1 字节),压缩比 32 倍。IVF 的nprobe是速度和精度的直接调节旋钮。avg_list_size可以检测聚类是否均匀——如果标准差过大说明数据分布不均,需要调整 n_clusters。
四、边界分析与架构权衡
PQ 的精度损失主要来自量化误差——用离散码字替代连续向量值带来的信息丢失。子空间数 S 越大(每段越短),量化越精细,但编码也越长(更多字节)。子空间数 S 越小(每段越长),编码越短,但每段的信息损失越大。这里有个反直觉的现象:D 维向量切分成 S 段时,每段的维度 d=D/S。d 越大,K-Means 在 d 维空间里的聚类质量越差(维度诅咒),量化误差反而增大。所以 S 不是越大越好,需要根据数据分布做实验确定。
IVF 的精度损失来自"检索漏探"——如果目标向量的 true nearest neighbor 落在 nprobe 之外的簇里,它就永远被遗漏了。nprobe 太小漏掉关键结果,nprobe 太大退化为近似全量检索。一个实用的策略是设置nprobe = sqrt(n_clusters),兼顾速度和精度。
PQ + IVF 组合使用时,两个近似的误差会叠加。但实际测试表明两者有很好的互补性:PQ 的误差偏向低频分量(大方向对,细节不准),IVF 的误差偏向遗漏(某些簇搜不到),叠加后 Recall@10 通常能保持在 95% 以上,对于 RAG 场景完全够用。
适用场景的判断:如果向量库小于 10 万条,暴力精确检索就够了(GPU 加速下 10 万向量检索 < 10ms)。如果 10 万到 500 万之间,IVF 加精确距离计算是最佳平衡。如果超过 500 万或内存紧张,上 PQ + IVF 组合。如果超过 1 亿,还需要考虑 HNSW 图索引或分布式分片方案。
五、总结
PQ 和 IVF 是大规模向量检索的两大基石算法。PQ 用子空间量化和码本索引将向量压缩 20-50 倍,大幅节省内存和计算。IVF 用粗聚类将搜索空间缩小到原来的几分之一到几十分之一。组合使用实现了在百万至亿级向量规模下的毫秒级检索延迟,精度损失控制在 5% 以内。选择 nprobe 和 n_subspaces 这两个参数时,记住它们分别控制"搜多少"和"存多准",通过小样本 A/B 测试找到适合业务数据的 sweet spot。