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 的思路很巧妙。它不直接压缩每个维度,而是把向量切分成若干子向量,然后对每个子空间单独做聚类。
具体步骤是这样的:
- 切分:把 d 维向量切成 m 段,每段 d/m 维。
- 聚类:对每段子向量,用 k-means 聚成 k 个簇(通常 k=256)。
- 编码:每个子向量用其所属簇的 ID 表示(8 bit)。
- 存储:最终向量用 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。这会导致排序结果错乱。
怎么解决?我常用的方法是两级缓存:
- 第一级:用量化后的向量做粗排,快速筛选出 top-N 候选。
- 第二级:对候选集用原始向量做精排,保证最终精度。
这样既利用了量化节省缓存,又保住了精度。我在一个日活千万的图片搜索项目里用过这套方案,效果很好。
经验之谈:不要追求极致压缩比。我曾经试过把 128 维向量压到 2 字节(binary PQ),结果 Recall 掉到 0.6 以下,业务方直接炸了。后来老老实实回到 8 字节,Recall 回到 0.92,缓存容量也省了 64 倍,够用了。
4.5 量化对缓存系统的影响
最后,咱们从缓存系统的角度看看,量化到底带来了什么。
我画了一张图,帮你理解量化在整个缓存链路中的位置。
从图上你能看到,量化后的向量直接存入缓存。查询时,查询向量也做同样的量化,然后在量化空间里算距离。这样做的好处是,全程不需要反量化,计算效率很高。
但这里有个细节要注意。缓存命中后,返回的是量化后的向量。如果业务层需要原始精度,你得在缓存外再存一份原始向量,或者用我前面说的两级缓存方案。
我的建议:在缓存系统里,量化后的向量只用于距离计算和排序。最终返回给用户的,还是原始向量或者原始向量的 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。毕竟,没有银弹,只有最适合你业务的方案。
好了,这一章就讲到这里。量化是个好东西,但别贪心。压缩比太高,精度崩了,缓存再大也没用。记住这个原则:够用就好,留有余量。