土法炼钢 · 系统与基础设施

乘积量化与 IVF-PQ:压缩域里的近似最近邻

文章导航

分类入口
algorithms
标签入口
#product-quantization#ivf#pq#faiss#vector-search#compression

目录

把十亿个 128 维 float32 向量直接放进内存,原始数据就是 \(10^9 \times 128 \times 4\) bytes,约 512 GB(十进制)。图索引还要额外保存邻接边;暴力扫描则要为每次查询读完整数组。乘积量化(Product Quantization,PQ)给出的不是“免费加速”,而是一种更明确的交易:用有损压缩和近似距离,换每个向量几个字节的常驻内存。

本文按“PQ 压缩 → SDC/ADC 距离估计 → IVF 候选过滤 → residual IVF-PQ → Faiss v1.8.0 源码 → 可复现实验”的顺序展开。所有自测数字来自同目录 reproduce/ivfpq_experiment.py,只报告召回率、均方误差、候选数与字节数,不报告易受并行任务干扰的耗时。ScaNN 的各向异性量化与 DiskANN 的 SSD 图索引放到下一篇 ScaNN 与 DiskANN:量化损失、Vamana 与 SSD 图检索。

一、问题模型:内存、候选数与召回

给定数据库 \(X=\{x_1,\ldots,x_N\}\),\(x_i\in\mathbb{R}^D\),查询 \(q\) 的精确最近邻需要比较

\[ \operatorname{NN}(q)=\arg\min_{x_i\in X}\lVert q-x_i\rVert_2^2。 \]

近似最近邻(Approximate Nearest Neighbor,ANN)通常同时看三个量:

IVF-PQ 同时压这三个量:PQ 把每个向量编码成 \(M\) 个短码;IVF(Inverted File)只扫描少数倒排列表;必要时再用原始向量或更长码做 re-rank。代价也对应三处:PQ 产生量化误差,IVF 可能没探测到真正近邻所在的 coarse cell,re-rank 则需要额外存储或读取原始向量。

一个常用数量级可以先记住。\(D=128\)、\(M=8\)、每个子码 \(8\) bit 时:

项 字节数
原始向量 \(128\times4=512\) bytes / vector
PQ code \(8\) bytes / vector
id(若用 64-bit) \(8\) bytes / vector
\(65,536\) 个 coarse centroid \(65,536\times128\times4=32\) MiB
PQ codebook \(8\times256\times16\times4=128\) KiB

所以十亿向量的倒排列表主体大约是 \(8\) GB PQ code 加 \(8\) GB id,不含倒排列表偏移、allocator 元数据、可选的原始向量和 re-rank 存储。这个估算比“某个索引一定只要多少 GB”更可靠,因为真实系统是否保留原始向量、id 宽度、list 存储格式都会改变账本。

二、PQ:笛卡尔积码本,而不是逐维量化

Jégou、Douze、Schmid 在 2011 年 TPAMI 论文 Product Quantization for Nearest Neighbor Search 中把 PQ 用于最近邻搜索。它和逐维标量量化不同:PQ 先把 \(D\) 维空间拆成 \(M\) 个子空间,每个子空间维度为 \(d_s=D/M\),然后分别训练一个 \(K\) 类 k-means 码本:

\[ x=[u_1,\ldots,u_M],\qquad C_m=\{c_{m,0},\ldots,c_{m,K-1}\},\quad c_{m,j}\in\mathbb{R}^{d_s}。 \]

编码一个向量时,每个子向量只保存最近 centroid 的编号:

\[ i_m(x)=\arg\min_{0\le j<K}\lVert u_m-c_{m,j}\rVert_2^2, \qquad \operatorname{code}(x)=(i_1(x),\ldots,i_M(x))。 \]

若 \(K=256\),每个 \(i_m\) 正好是 1 byte。整体可表示的重构向量来自笛卡尔积 \(C_1\times\cdots\times C_M\),码字数是 \(K^M\),但实际只保存 \(M\times K\times d_s\) 个浮点 centroid。Faiss v1.8.0 的 faiss/impl/ProductQuantizer.cpp 也直接反映了这个定义:d % M == 0,ksub = 1 << nbits,code_size = (nbits * M + 7) / 8;compute_code() 对每个 subquantizer 扫 centroid 并写入编号。

PQ 将向量切成多个子空间,每个子空间独立选择最近的码本中心,最后得到一个短 PQ code

PQ 的压缩比来自码率,而不是来自“所有数据都可无损恢复”。重构向量

\[ \hat{x}=q(x)=[c_{1,i_1(x)},\ldots,c_{M,i_M(x)}] \]

只是一组 centroid 的拼接。量化失真

\[ \lVert x-\hat{x}\rVert_2^2=\sum_{m=1}^M \lVert u_m-c_{m,i_m(x)}\rVert_2^2 \]

会进入后续排序误差。增大 \(M\) 或 \(K\) 往往能降低失真,但会增加 code bytes、训练成本或查表大小。

三、SDC 与 ADC:为什么查询通常不量化

PQ code 存起来以后,距离估计有两种基本做法。

对称距离计算(Symmetric Distance Computation,SDC)把查询 \(q\) 和数据库向量 \(x\) 都量化:

\[ d_{\mathrm{SDC}}(q,x)=\lVert \hat{q}-\hat{x}\rVert_2^2。 \]

它可以预计算各子空间 centroid 两两之间的距离表,但查询本身也被量化,误差来自两边。

非对称距离计算(Asymmetric Distance Computation,ADC)保留查询的原始 float32,只用数据库的 PQ 重构:

\[ d_{\mathrm{ADC}}(q,x)=\lVert q-\hat{x}\rVert_2^2=\sum_{m=1}^M \lVert q_m-c_{m,i_m(x)}\rVert_2^2。 \]

ADC 的关键是查找表。对每个查询,先计算

\[ \operatorname{LUT}[m,j]=\lVert q_m-c_{m,j}\rVert_2^2, \qquad 1\le m\le M, \quad 0\le j<K。 \]

之后扫描一个 PQ code 只需要按 \(M\) 个编号查表并相加。Faiss v1.8.0 中 ProductQuantizer::compute_distance_table() 正是按子量化器生成这个表;compute_inner_prod_table() 则服务内积度量。

ADC 保留查询向量,先为每个子空间建立到全部 centroid 的距离表,再对每个 PQ code 执行查表求和

ADC 仍然是有偏近似。令 \(e=x-\hat{x}\),则

\[ \lVert q-\hat{x}\rVert_2^2 =\lVert q-x\rVert_2^2 + 2\langle q-x,e\rangle + \lVert e\rVert_2^2。 \]

最后一项是量化失真,交叉项会因查询位置和残差方向而改变排序。k-means centroid 是训练集 cell 内的均值,并不保证对所有查询交叉项为零。因此工程上不能只看 MSE,还要实测 recall。OPQ 和 ScaNN 的出发点也在这里:更小的重构误差通常有帮助,但“重构误差最小”不等于“排序错误最少”。

四、IVF:先缩小候选集合

仅用 PQ,搜索仍要扫描全部 \(N\) 个 code。IVF 在 PQ 前面加一个粗量化器(coarse quantizer):训练 \(L\) 个 coarse centroid \(\mu_0,\ldots,\mu_{L-1}\),把每个数据库向量分配到最近的 cell,并在对应倒排列表中保存 id 与压缩表示。

查询时先找离 \(q\) 最近的 nprobe 个 coarse centroid,只扫描这些倒排列表。若各 list 大小均匀,期望候选比例约为 \(\mathrm{nprobe}/L\)。这个近似只说明扫描量,不保证召回:真实近邻可能落在第 nprobe+1 个 cell,或者数据分布倾斜导致某些 list 远大于平均。

IVF 有两个容易混淆的参数:

参数 作用 常见失误
nlist / \(L\) 训练时 coarse cell 数 太小则 list 长、扫描多;太大则训练和 list 管理成本上升,且 list 可能很短
nprobe 查询时探测的 cell 数 太小漏掉近邻;太大接近全扫描

Faiss v1.8.0 的 faiss/IndexIVF.h 中 nprobe 默认值是 1,这只是安全的最低默认,不是好配置。实际使用必须在验证集上扫描 nprobe,画 recall 与候选数曲线。

五、IVF-PQ:对 residual 编码

IVF-PQ 通常不直接对 \(x\) 做 PQ,而是对 residual 做 PQ:

\[ r=x-\mu_{\ell(x)}, \qquad \ell(x)=\arg\min_\ell \lVert x-\mu_\ell\rVert_2^2。 \]

查询扫描第 \(\ell\) 个倒排列表时,也用相对该 coarse centroid 的查询 residual:

\[ d(q,x)\approx \lVert (q-\mu_\ell)-\widehat{r}\rVert_2^2。 \]

这就是 Jégou 等人论文中的 IVFADC 路线:IVF 决定候选 cell,ADC 在压缩 residual 上估计距离。Residual 的分布通常比原始向量更集中,PQ 更容易用短码拟合;但它也带来一个实现细节:每个被探测 list 的查询 residual 不同,所以 L2 情况下 LUT 要按 list 准备,或者使用预计算表拆分计算。

IVF-PQ 先用 coarse centroid 选择倒排列表,再在每个被探测列表里用查询 residual 建 ADC 表并扫描 residual PQ code

Faiss v1.8.0 的 faiss/IndexIVFPQ.cpp 构造函数把 by_residual 设为 true,encode() 中会调用 coarse quantizer 的 compute_residual() 后再 pq.compute_code()。同文件 QueryTables 的 L2 路径在 by_residual 为真时,要么为当前 list 计算 q - centroid 的 distance table,要么使用预计算项把距离拆成源码注释中的三部分:

\[ \lVert x-y_C-y_R\rVert^2 =\lVert x-y_C\rVert^2 + \lVert y_R\rVert^2 + 2\langle y_C,y_R\rangle -2\langle x,y_R\rangle。 \]

这里 \(x\) 是查询,\(y_C\) 是 coarse centroid,\(y_R\) 是 PQ residual centroid。构造函数还把 use_precomputed_table 初始化为 0(自动选择)、polysemous_ht 初始化为 0、do_polysemous_training 初始化为 false。这些默认值说明一件事:IVF-PQ 的论文结构很简洁,生产实现却有许多可选快速路径和过滤器,不能把一个 index_factory 字符串当作完整配置。

六、OPQ、polysemous 与 fast scan:同一条压缩谱系

标准 PQ 默认按原始维度顺序切块。如果不同维度强相关,或者能量集中在少数方向,固定切块会把可压缩结构切碎。Ge、He、Ke、Sun 在 CVPR 2013 / TPAMI 2014 的 OPQ(Optimized Product Quantization)中学习一个正交变换 \(R\),在旋转后的空间训练 PQ:

\[ \min_{R,C_1,\ldots,C_M}\sum_i \lVert R x_i - q(R x_i)\rVert_2^2, \qquad R^\top R=I。 \]

OPQ 不改变 code 长度,而是改变子空间分解;它优化的是量化失真,不是直接优化 recall。ScaNN 后来把“排序目标和量化目标不一致”推得更远,提出各向异性量化;这部分留给下一篇。

还有两类工程优化经常和 IVF-PQ 一起出现:

这些改进不改变主线:先把候选集合压小,再在压缩域估计距离。它们改变的是误差、吞吐和实现复杂度之间的平衡。

七、可复现实验:MSE、码长、nprobe 与候选数

实验程序在同目录:

cd post/algorithms/40-ivfpq
taskset -c 6 python3 reproduce/ivfpq_experiment.py
cat reproduce/results.txt

环境与口径:NumPy 2.5.3、Matplotlib 3.11.2;固定随机种子 20260924;合成聚类数据,\(D=32\),训练集 4096,数据库 6000,查询 200,topk=10,IVF 的 nlist=64。这是机制实验,不代表 SIFT、Deep 或生产 embedding 的绝对数值。程序不下载数据,不报告耗时;所有图表可重画。

码长与量化误差

先只看纯 PQ 全量 ADC,不使用 IVF 候选过滤。下表中的 M8x6 表示 \(M=8\)、每个子码 6 bit,code size 为 \(\lceil 8\times6/8\rceil=6\) bytes。

PQ code bytes / vector 重构 MSE full-scan ADC
M4x4 2 3.895021 0.1275
M4x6 3 2.333099 0.1480
M4x8 4 1.870491 0.2195
M8x4 4 2.687941 0.2035
M8x6 6 1.527004 0.3400
M8x8 8 0.901327 0.4590
PQ 码长增加时重构 MSE 下降,但召回仍需单独测量

这组数据展示两个事实。第一,更多 bit 通常降低 MSE。第二,MSE 和 recall 不是同一个指标:M8x4 比 M4x6 多 1 byte,MSE 反而更高(2.687941 vs 2.333099),recall 却更高(0.2035 vs 0.1480)。同为 4 bytes 的 M4x8 与 M8x4 也说明子空间切分会改变误差和排序结果,本次合成数据上 M4x8 两项都更好。

nprobe 与候选数

再固定 residual IVF-PQ 为 M8x6,改变 nprobe:

nprobe 平均候选数 扫描比例
1 156.28 2.60% 0.4460
2 278.27 4.64% 0.4895
4 471.98 7.87% 0.4995
8 892.25 14.87% 0.5000
16 1657.52 27.63% 0.5025
32 3260.93 54.35% 0.5025
64 6000.00 100.00% 0.5025
nprobe 增加会扫描更多候选,召回先上升后被 PQ 量化误差限制

曲线前半段受 IVF 候选截断限制:探测更多 cell 能找到更多真正近邻。后半段进入平台:nprobe=64 已经全扫描,recall 仍只有 0.5025,剩下的错误来自 6-byte residual PQ 的距离估计。想继续提高,需要更长 code、OPQ、更好的训练数据、polysemous/fast scan 之外的 rerank,或者保存原始向量做精排。

同一实验还给出字节账本:原始向量是 128 bytes / vector;M8x6 code 是 6 bytes / vector;coarse centroids 和 PQ codebook 在本实验里各 8192 bytes。这个账本不含 id 和 Python 对象开销,正文只用它说明趋势。

八、工程选型:什么时候该用 IVF-PQ

IVF-PQ 适合的约束很明确:原始向量或图索引放不进内存,但业务可以接受用 recall 换成本,并且有一套验证集来调参。选型时不要只问“PQ 快不快”,而要拆成下面几步:

问题 检查方法 可能动作
内存预算是否被原始向量压爆 先算 raw bytes、id bytes、list 元数据、是否保留原始向量 决定 code size、是否分片、是否外置原始向量
IVF 是否漏候选 扫 nprobe,画 recall 与候选数曲线 增大 nprobe、调 nlist、换 coarse quantizer
PQ 是否限制排序 把 nprobe 提到全扫描,看 recall 平台 增大 \(M\) / nbits、用 OPQ、做 rerank
训练集是否代表线上分布 固定参数,按时间片/业务片验证 recall 重新采样训练集,定期重训 coarse 和 PQ
延迟瓶颈在 LUT 还是扫描 用 profiler 看 distance table、list scan、top-k merge fast scan、precomputed table、并行 list scan

HNSW 和 IVF-PQ 不是谁淘汰谁。HNSW 用内存换高召回和较少调参;IVF-PQ 用压缩换容量和吞吐,但需要验证集和重排策略。更大的系统常把它们组合:例如用 HNSW 作为 coarse quantizer、用 PQ 存压缩向量、再从对象存储或 SSD 加载候选原文做精排。下一篇的 DiskANN/Vamana 会从“图结构 + SSD”侧继续这条线。

九、争论与开放问题

残差编码是否总是更好。 对 L2 IVF-PQ,residual coding 是 Faiss IndexIVFPQ 的默认路径,也符合 IVFADC 的经典设定;但 fast scan 为了 SIMD 友好可能默认关闭 residual。这里的取舍不是数学定理,而是数据分布、code 长度、CPU 指令和 list 长度共同决定。

量化误差与排序质量不一致。 PQ、OPQ 主要最小化重构失真;近邻搜索关心的是 top-\(k\) 排序是否反转。ScaNN 的各向异性量化、各种 learned quantizer 和 re-rank 策略,都是围绕这条缝隙展开。本文只写到 OPQ,避免和下一篇重复。

PQ 与图索引的边界。 图索引在高召回低延迟上强,代价是邻接表和构建维护;量化索引在内存受限时强,代价是调参和近似误差。Billion-scale 系统往往不是纯算法竞赛,而是在“内存、SSD、训练时间、更新方式、召回 SLA”之间分配预算。

AQ 与 residual vector quantization。 Babenko 和 Lempitsky 的 Additive Quantization(CVPR 2014)用多个 codeword 相加表达向量;Chen、Guan、Wang 的 residual vector quantization(Sensors 2010)则逐级量化残差。它们都放松 PQ 的正交子空间假设,通常能降低失真,但编码和搜索更复杂。它们说明 PQ 不是量化研究的终点,只是工程上非常稳的基线。

十、工程陷阱

陷阱 后果 做法
用 Faiss 默认 nprobe=1 直接上线 候选 cell 太少,recall 可能远低于预期 用验证集扫描 nprobe,报告候选数与 recall 曲线
只报告 QPS,不报告 recall 和候选数 无法判断快是因为算法好,还是因为漏掉了近邻 固定 ground truth,至少报告 、平均候选数、code bytes
把 IVF-PQ 的内存估算写成只有 PQ code 忽略 id、倒排列表、可选原始向量、重排存储 分项列出 raw、code、id、centroid、list 元数据
训练集太小或偏离线上分布 coarse cell 倾斜,PQ codebook 失真,线上 recall 掉 从线上分布抽样训练,按业务片验证
以为 OPQ 不增加任何成本 code 长度不变,但训练、旋转矩阵和查询变换都有成本 在同一验证集上比较 PQ 与 OPQ 的 recall / 构建成本
cosine 场景忘记归一化 L2、内积、cosine 的排序语义混乱 明确度量:单位向量上 cosine 可转内积或 L2 排序
nlist 越大越好 list 过短、训练成本高,coarse assignment 可能不稳 同时扫 nlist 与 nprobe,看候选数而非只看参数

十一、参考资料

源码与文档

核心论文

其他论文

工程资料

实验


系列导航: - 上一篇:HNSW:分层小世界图的近似近邻搜索 - 下一篇:ScaNN 与 DiskANN:量化损失、Vamana 与 SSD 图检索

相关阅读: - 局部敏感哈希:从概率保证到多探针近邻检索 - 从零实现一个向量搜索引擎

读完这篇,下一步读什么

优先读同系列或同问题的下一篇,把单篇消费变成主题集群。

2026-05-30 · algorithms

HNSW:分层小世界图的近似近邻搜索

从 NSW 到 HNSW,拆解随机层数、SEARCH-LAYER、启发式邻居选择与参数边界;对照 hnswlib、Faiss、Lucene、pgvector 源码默认值,并用可复现实验比较 simple 与 heuristic 邻居选择的召回成本。

2026-06-01 · algorithms

ScaNN 与 DiskANN:量化损失、Vamana 与 SSD 图检索

从 ScaNN 的 score-aware 各向异性量化到 DiskANN 的 Vamana 图与 SSD beam search,核对论文公式、开源参数和可复现实验,说明内存量化与磁盘图检索各自解决什么问题。

2026-06-02 · algorithms / database

从零实现一个向量搜索引擎

不重复 HNSW、PQ 和 DiskANN 细节,而是把向量检索放回引擎层:段式存储、墓碑删除、过滤搜索、mmap、并发快照与可复现召回评测。

2026-05-29 · algorithms

局部敏感哈希:从概率保证到多探针近邻检索

从 (c,r)-ANN 与 LSH 的 rho 参数出发,推导 AND-OR 放大、随机超平面、p-stable 与 multi-probe,复现实测 SIFT1M 上召回率和候选数,并说明为什么理论保证不等于工程排名。


By .