4. 向量量化与缓存压缩:PQ量化、标量量化对缓存大小的影响

各位同学,咱们接着聊。上一章我们讲了缓存淘汰策略,但有个根本问题没解决——缓存容量是物理限制。你内存就那么大,能塞多少向量?

我早年做过一个项目,单机存 500 万条 128 维的 float 向量。你算算,光原始数据就 2.5 GB。再加上索引结构、倒排列表……内存直接爆了。后来怎么解决的?量化。

说白了,量化就是给向量“降精度”。把 32 位浮点数变成 8 位甚至更少。代价是精度损失,但换来的是缓存容量翻几倍。这堂课我们就来掰扯掰扯,PQ 量化和标量量化到底怎么选,怎么在精度和缓存效率之间找平衡。

核心观点:量化不是“要不要做”的问题,而是“做到什么程度”的问题。缓存场景下,量化后的向量大小直接决定了你能缓存多少条。

4.1 标量量化:最简单粗暴的压缩

标量量化,名字听着唬人,其实原理特简单。就是把每个维度上的浮点数,映射到一个更小的整数范围。

举个例子。原始向量每个维度是 float32,范围在 [-1.0, 1.0] 之间。我把它映射到 int8,范围 [-128, 127]。怎么做?

// 标量量化伪代码
float min_val = -1.0f;
float max_val = 1.0f;
float scale = (max_val - min_val) / 255.0f;  // 255 = 127 - (-128)

// 量化:float -> int8
int8_t quantized = (int8_t)((original - min_val) / scale - 128);

// 反量化:int8 -> float
float recovered = (quantized + 128) * scale + min_val;

你看,就这么简单。每个维度从 4 字节变成 1 字节,压缩比 4:1。缓存容量直接翻 4 倍。

但代价呢?精度损失。我实测过,在 SIFT1M 数据集上,标量量化后 Recall@10 从 0.95 掉到了 0.88 左右。嗯,这个损失能不能接受,得看你的业务场景。

我的经验:标量量化最适合那些向量分布比较均匀的场景。如果数据分布极度不均匀(比如大部分向量挤在一个小区域),量化误差会变大。我曾经在一个推荐系统项目里踩过这个坑,后来加了 per-dimension 的 min/max 统计才解决。

4.2 PQ 量化:更精细的压缩方案

标量量化虽然简单,但精度损失有点大。有没有更好的办法?有,乘积量化(Product Quantization,PQ)。

PQ 的思路很巧妙。它不直接压缩每个维度,而是把向量切分成若干子向量,然后对每个子空间单独做聚类。

具体步骤是这样的:

  1. 切分:把 d 维向量切成 m 段,每段 d/m 维。
  2. 聚类:对每段子向量,用 k-means 聚成 k 个簇(通常 k=256)。
  3. 编码:每个子向量用其所属簇的 ID 表示(8 bit)。
  4. 存储:最终向量用 m 个簇 ID 表示,总共 m 字节。

举个例子。128 维向量,切成 8 段,每段 16 维。每段聚成 256 个簇。那么每个向量只需要 8 个字节就能表示。原始是 512 字节(128 × 4),压缩比 64:1。

// PQ 量化伪代码
int d = 128;          // 原始维度
int m = 8;            // 子空间数量
int k = 256;          // 每个子空间的簇数
int sub_dim = d / m;  // 16

// 训练:对每个子空间做 k-means
for (int i = 0; i < m; i++) {
    centroids[i] = kmeans(sub_vectors[i], k);
}

// 量化:每个子向量找最近质心
uint8_t code[m];
for (int i = 0; i < m; i++) {
    code[i] = find_nearest_centroid(sub_vector[i], centroids[i]);
}

// 存储:code 就是量化后的向量,共 m 字节

注意:PQ 的训练过程需要扫描全量数据做 k-means,耗时较长。我建议在离线阶段完成训练,在线阶段只做查表和编码。另外,k 值选 256 是因为 8 bit 刚好能表示,再大就需要 16 bit,压缩比会下降。

4.3 两种量化方式的对比

好了,两种方案都讲完了。咱们来做个对比,看看它们各自适合什么场景。

对比维度 标量量化 PQ 量化
压缩比 4:1(float32 → int8) 16:1 ~ 64:1(可配置)
精度损失 中等(Recall 下降 5-10%) 较小(Recall 下降 2-5%)
计算复杂度 极低(线性映射) 中等(查表 + 距离计算)
训练开销 无(只需统计 min/max) 高(需要 k-means 聚类)
适用场景 缓存容量紧张,精度要求不高 缓存容量极紧张,精度要求较高

你可能会问,为什么 PQ 精度损失反而更小?明明压缩比更高啊。

原因在于,PQ 保留了每个子空间的分布特征。标量量化是“一刀切”,所有维度用同一个缩放因子。而 PQ 对每个子空间单独建模,能更好地拟合数据分布。

我举个例子你就明白了。假设向量有两个维度,x 轴范围 [-100, 100],y 轴范围 [-0.1, 0.1]。标量量化会把两个维度缩到同一个范围,y 轴的细节全丢了。但 PQ 把两个维度分到不同子空间,各自聚类,y 轴的微小差异也能保留。

4.4 精度与缓存效率的权衡

讲到这里,核心问题来了:怎么选?

我个人习惯,先看缓存容量缺口有多大。

  • 缺口在 2-4 倍以内:用标量量化。实现简单,计算快,没有训练开销。
  • 缺口在 4-16 倍:用 PQ,m 值选小一点(比如 4 或 8),精度损失可控。
  • 缺口超过 16 倍:PQ 加粗粒度量化。比如 PQ 用 int4 甚至 binary 编码。

但这里有个陷阱。压缩比越高,距离计算的误差越大。你想想看,原始向量之间的距离是 0.5,量化后可能变成 0.3 或 0.7。这会导致排序结果错乱。

怎么解决?我常用的方法是两级缓存:

  1. 第一级:用量化后的向量做粗排,快速筛选出 top-N 候选。
  2. 第二级:对候选集用原始向量做精排,保证最终精度。

这样既利用了量化节省缓存,又保住了精度。我在一个日活千万的图片搜索项目里用过这套方案,效果很好。

经验之谈:不要追求极致压缩比。我曾经试过把 128 维向量压到 2 字节(binary PQ),结果 Recall 掉到 0.6 以下,业务方直接炸了。后来老老实实回到 8 字节,Recall 回到 0.92,缓存容量也省了 64 倍,够用了。

4.5 量化对缓存系统的影响

最后,咱们从缓存系统的角度看看,量化到底带来了什么。

我画了一张图,帮你理解量化在整个缓存链路中的位置。

量化在缓存链路中的位置 原始向量 float32 × 128维 量化模块 标量量化 / PQ 量化后向量 int8 / PQ编码 缓存存储 内存 / SSD 查询向量 原始精度 查询量化 同量化方式 距离计算 查表 / 对称距离 Top-K 结果 返回给业务层 关键点: 量化后的向量直接存入缓存,查询时也做同样量化, 在量化空间计算距离,避免反量化开销。

从图上你能看到,量化后的向量直接存入缓存。查询时,查询向量也做同样的量化,然后在量化空间里算距离。这样做的好处是,全程不需要反量化,计算效率很高。

但这里有个细节要注意。缓存命中后,返回的是量化后的向量。如果业务层需要原始精度,你得在缓存外再存一份原始向量,或者用我前面说的两级缓存方案。

我的建议:在缓存系统里,量化后的向量只用于距离计算和排序。最终返回给用户的,还是原始向量或者原始向量的 ID。这样既享受了量化的好处,又不影响用户体验。

4.6 实战中的量化参数选择

最后,我分享几个实战中常用的参数配置,供你参考。

场景 向量维度 推荐方案 压缩后大小 预期 Recall
图片检索(低精度) 128 标量量化 int8 128 字节 0.88 - 0.92
图片检索(高精度) 128 PQ m=8, k=256 8 字节 0.92 - 0.95
文本嵌入 768 PQ m=32, k=256 32 字节 0.90 - 0.94
推荐系统 64 标量量化 int8 64 字节 0.85 - 0.90
极致压缩 128 PQ m=16, k=16 4 字节 0.75 - 0.85

这些参数不是的。我建议你在自己的数据集上先跑一轮 benchmark,看看 Recall 和 QPS 的 trade-off。毕竟,没有银弹,只有最适合你业务的方案。

好了,这一章就讲到这里。量化是个好东西,但别贪心。压缩比太高,精度崩了,缓存再大也没用。记住这个原则:够用就好,留有余量。