一张「5 种 embedding 压缩技术」的信息图,每种都标着一个诱人的倍数:MRL「最多小 8 倍」、int8「小 4 倍」、二值化「小 32 倍」、PQ「最多压 97%」、PCA「灵活降维」。五个倍数,零个质量数字。
所以我把它们全跑了一遍。1000 条真实文本、真实的 embedding 模型(nomic-embed-text-v1.5,f16)、18 组配置,指标是「压缩后的 top-10 邻居里,还剩多少是 float32 的 top-10」。
结果里有三个数字把这张图的排序整个掀翻了:
- int8 几乎是白拿的:小 4 倍,recall@10 = 0.9950。
- 二值化单独用只有 0.4673;同样 96 字节,加一次 float 重排就变成 0.9524。 同样的存储,两倍多的召回。
- 信息图里最风光的 MRL,「小 8 倍」那一档我实测 recall@10 = 0.4830——而 96 字节的 PQ 是 0.7796。MRL 花了 4 倍的存储,召回反而更低。
这篇不讲「五种方法各有什么特点」。它回答一个问题:为什么「能不能补救」比「压多少倍」重要一个量级。
名词速查
| 术语 | 一句话解释 |
|---|---|
| embedding | 把一段文本变成一串数字(这里是 768 个浮点数),意思相近的文本数字也相近 |
| recall@10 | 本文的指标:压缩后算出的 10 个最近邻里,有几个也在 float32 算出的 10 个最近邻里,除以 10 |
| MRL(Matryoshka) | 训练时就让「前 64 维」「前 128 维」各自都能单独用的一种损失函数,于是推理时可以直接把向量截短 |
| 标量量化 | 每一维单独从 float32 压成 int8(256 个档位),查询时再解回浮点 |
| 二值化 | 每一维只留正负号,768 维变成 768 个比特 = 96 字节 |
| 汉明距离 | 两串比特有多少位不同。CPU 有专门指令,比浮点点积便宜得多 |
| PQ(乘积量化) | 把 768 维切成若干段,每段先聚类出 256 个「代表向量」(码本),然后每段只存一个 0-255 的编号 |
| 码本 | PQ 聚类出来的那张「代表向量表」,查询时靠它把编号还原成近似向量 |
| 重排(rescore) | 先用便宜的方法粗筛出一批候选,再把这批候选的原始向量读回来精确算一遍 |
| 方差解释率 | PCA 里「前 d 个方向保住了原数据多少波动」的比例 |
一、先说清我在量什么,以及这个数不能怎么用
这一节是为了后面所有数字都能被正确使用,请别跳。
数据:《傲慢与偏见》正文按 60 词一段切成 1000 段(真实英文散文,不是随机噪声),用 nomic-embed-text-v1.5.f16.gguf 经 llama-server 的 /v1/embeddings 取 768 维向量,全文加了官方要求的 search_document: 任务前缀。1000 条向量 2.8 秒跑完。
指标:拿每条向量当查询,在其余 999 条里取 top-10。float32 的结果当作基准答案,压缩后的结果和它求交集除以 10。
为什么用这个指标:它不需要人工标注,而且它就是上线时最直接的问题——「我把向量压了,检索结果会变吗?」 相关性标注数据集换一个领域就得重做,但「和未压缩版本的一致程度」在任何自建库上都能当天算出来。
这个数不能怎么用(三条,都是真的软处):
- 它不是「检索质量下降了多少」。丢掉 float32 的第 8、9、10 名邻居,在 RAG 里常常一点影响都没有——那几条往往可以互换。recall@10 = 0.68 不等于「答案质量降三成」,它等于「返回的文档换掉了三成」。
- 语料窄。1000 段同一本小说,风格高度同质。真实库跨领域、长度不均,各方法的相对位置可能移动。
- PQ 的码本是在这 1000 条自己身上训的,属于 PQ 的最优情形(生产里码本用海量样本训练后要泛化到新数据)。它的分数该往下打折看。
所有数字来自我本机(M5 Pro / 24 GB 统一内存)的一个 numpy 脚本,随机种子固定为 7。
二、根本约束:压缩买的不是硬盘,是带宽
先算一笔账,看清压缩到底在解决什么问题。1 亿条 768 维向量:
| 表示 | 每条字节 | 1 亿条总量 |
|---|---|---|
| float32 768 维 | 3072 | 286.1 GiB |
| int8 768 维 | 768 | 71.5 GiB |
| PQ96 / 二值化 | 96 | 8.9 GiB |
| PQ48 | 48 | 4.5 GiB |
286 GiB 装不进任何一台普通机器的内存;8.9 GiB 随便装。这就是全部动机——不是为了省硬盘(硬盘从来不贵),是为了让向量待在一级更快的存储里。
反过来说:如果随机访问免费,压缩就完全没有意义。 我实测本机用 numpy 对 20 万条 768 维 float32 做一次全量点积只要 4.31 ms,一台笔记本就能扛住一个小型知识库的暴力检索。所以十万条以内的库别压,直接 float32 全扫——压缩的复杂度在这个规模上是净负债。这是这篇文章最先该说的边界。
崩点在量级上:当条数从 涨到 (三个数量级),float32 从 0.3 GiB 涨到 286 GiB,从「内存里随便放」变成「跨机分片 + 磁盘随机 IO」。压缩要做的就是把这个跳变推后两个数量级。
三、实测总表
基准是 float32 768 维 = 3072 字节 = recall@10 1.0000。
| 方法 | 每条字节 | 压缩比 | recall@10 |
|---|---|---|---|
| 标量量化 int8(逐维 min/max) | 768 | 4× | 0.9950 |
| 二值化 + 用 float 重排前 100 | 96 | 32× | 0.9524 |
| 二值化 + 用 float 重排前 50 | 96 | 32× | 0.8590 |
| MRL 截断到 512 维 | 2048 | 2× | 0.8192 |
| 乘积量化 PQ96×8bit | 96 | 32× | 0.7796 |
| MRL 截断到 256 维 | 1024 | 3× | 0.6832 |
| 乘积量化 PQ48×8bit | 48 | 64× | 0.6686 |
| PCA 降到 256 维 | 1024 | 3× | 0.6276 |
| PCA 降到 128 维 | 512 | 6× | 0.6250 |
| 随机挑 256 个维度(对照组) | 1024 | 3× | 0.6234 |
| PCA 降到 64 维 | 256 | 12× | 0.5870 |
| MRL 截断到 128 维 | 512 | 6× | 0.5491 |
| PCA 降到 32 维 | 128 | 24× | 0.4909 |
| MRL 截断到 96 维(信息图标的「8 倍」) | 384 | 8× | 0.4830 |
| 二值化(符号位)+ 汉明距离,不重排 | 96 | 32× | 0.4673 |
| MRL 截断到 64 维 | 256 | 12× | 0.4036 |
| 随机挑 64 个维度(对照组) | 256 | 12× | 0.2855 |
按 recall 排序之后,那张信息图的叙事就散了:压缩比和召回率之间没有一条单调的曲线。 96 字节的两行分别排在第 2 和第 15。
四、三个反直觉的读数
1. int8 是这张表里唯一接近免费的一档
4 倍压缩,recall@10 = 0.9950——1000 次查询里平均只有 5% 的一个邻居位次发生变化。信息图说 int8 是「最安全的量化取舍」,这一条我实测完全站得住。
原因也不神秘:逐维用 min/max 定标之后,每一维有 256 个档位,而 embedding 每一维的有效动态范围本来就不大。量化误差远小于「第 10 名和第 11 名邻居」之间的相似度差。
可操作的结论:如果你还没做任何压缩,int8 是第一件该做的事,几乎没有理由不做。
2. 同样 96 字节,差别全在「有没有第二步」
这是全表最大的信息量:
| 96 字节的三种用法 | recall@10 |
|---|---|
| 二值化,只用汉明距离排序 | 0.4673 |
| 二值化,粗筛 100 条后用 float 重排 | 0.9524 |
| PQ96,用码本还原后排序 | 0.7796 |
索引大小一模一样,召回从 0.47 到 0.95。 差别是查询时多做了一步:用汉明距离取出 100 个候选,再把这 100 条的原始 float32 向量读回来精确排一遍。
代价很具体:每次查询多 100 次随机读(如果原向量在 SSD 或对象存储上),以及多 100 × 3072 字节的传输。而收益是把召回从「差一半」拉到「几乎不变」。信息图上那句「Rescoring is needed to recover full quality」是对的,但它没说清这不是可选的润色——不重排的二值化基本不能直接用于终端结果。
把候选从 100 降到 50,recall 掉到 0.8590。所以候选数是一个实打实可调的旋钮:50 → 100 让我这里多了 0.09 的召回,代价是两倍的读回量。
3. MRL 的「小 8 倍」,代价是一半的邻居
信息图给 MRL 标的是「Up to 8x smaller」,并且强调「truncate to any size at inference without retraining」。「不用重训」是真的,「小 8 倍」也是真的,但那一档我实测 recall@10 = 0.4830。
更刺眼的是同表对比:
- MRL 256 维 = 1024 字节 → 0.6832
- PQ96 = 96 字节 → 0.7796
十倍的存储,更低的召回。 我第一次看到这两行并排时回头检查了三遍脚本——MRL 是专门为「可截断」训练出来的,PQ 只是事后聚类,结果事后聚类赢了。
想清楚其实是合理的:MRL 截断是把维度整条扔掉(信息永久消失),PQ 是把所有维度都保留、只降低每一段的精度。 前者丢的是坐标轴,后者丢的是刻度。在固定字节预算下,「所有轴都粗一点」通常比「留一半轴、另一半归零」保留更多结构。
这不是说 MRL 没用——它的卖点是同一份向量能在查询时临时决定用多少维,不需要存多份、不需要训码本、不需要额外的还原步骤。那是运维上的自由度,不是召回上的优势。我的判断是:如果你的字节预算是死的,别用 MRL 截断;如果你需要「同一个索引服务不同精度的场景」,MRL 是唯一能做到的。
五、MRL 到底买到了什么:和随机挑维度比
既然 MRL 截断掉了这么多召回,那它相对「随便挑几个维度」到底强在哪?我加了一组对照:
| 取 256 维的方式 | recall@10 |
|---|---|
| MRL 前缀(前 256 维) | 0.6832 |
| 随机挑 256 个维度 | 0.6234 |
| 取 64 维的方式 | recall@10 |
|---|---|
| MRL 前缀(前 64 维) | 0.4036 |
| 随机挑 64 个维度 | 0.2855 |
MRL 的训练确实把信息往前挤了:256 维时前缀比随机子集高 0.06(相对 +10%),64 维时高 0.12(相对 +41%)。而且越短越明显——这正是 Matryoshka 损失的设计意图,压到 64 维时前缀的优势最大。
顺手核了一件事:nomic 的模型卡明确要求截断前先做一次 F.layer_norm(layer_norm → 切片 → L2 归一化)。我第一次写脚本时漏了这一步,直接切了 L2 归一化后的向量。回头按官方配方重跑,两者的 recall@10 是 0.6832 和 0.6831(256 维)、0.4036 和 0.4036(64 维)。
也就是说,在这个语料上那一行 layer_norm 对 recall 的影响小到量不出来。原因是 layer_norm 相对 L2 归一化只多了一步「减去该向量各维的均值」,而这等于把向量沿全 1 方向平移了一点点——对 768 维向量的方向影响微乎其微。我不是说这行代码可以删(它是官方配方,别的语料上未必如此),而是说如果你在排查召回问题,这一行不是嫌疑犯。这类「文档要求但实测无差别」的步骤最容易变成代代相传的仪式,值得自己量一次。
六、PCA 什么时候赢:交叉点在 128 维附近
MRL 和 PCA 是同一类操作(都降维),但一个在训练时决定、一个在你自己的语料上事后拟合:
| 目标维度 | MRL 前缀 | PCA |
|---|---|---|
| 256 | 0.6832 | 0.6276 |
| 128 | 0.5491 | 0.6250 |
| 64 | 0.4036 | 0.5870 |
交叉点在 128 维附近:维度给得多时 MRL 赢,压得狠时 PCA 反超,而且越狠差得越多(64 维时 PCA 高出 0.18,相对 +45%)。
原因在方差账上。我算了 PCA 的累计方差解释率:32 维 49.9%、64 维 66.4%、128 维 82.5%、256 维 94.7%。PCA 是在这 1000 条向量上现场拟合的,它知道这批数据的主轴在哪;MRL 的前缀次序是训练时对着整个互联网语料定下的,对我这本小说不是最优。
这里必须打折:PCA 的投影矩阵是在被检索的那批向量上拟合的(术语叫 in-domain / 转导设定),这是 PCA 的最好情形。不过对大量 RAG 场景来说,这个最好情形恰好是真实情形——语料是你自己的、基本静态的,完全可以先跑一次 PCA。但语料会持续新增时 PCA 就麻烦了:新数据分布漂移,投影矩阵要重拟合,重拟合就得重算全库的向量。这是 MRL 真正的用武之地。
七、压缩不等于变快:我实测二值化慢了 3.8 倍
到这里全是空间账。我顺手量了时间,结果打了我一个耳光。
20 万条 768 维向量,一次全量扫描(本机,numpy):
| 表示 | 数据量 | 一次全扫 |
|---|---|---|
| float32 点积(走 BLAS) | 586 MiB | 4.31 ms |
| 二值化 XOR + 查表 popcount | 18 MiB | 16.57 ms |
数据量小 32 倍,耗时反而是 3.8 倍。
我一开始以为这是测错了,检查后确认机制很清楚:float32 点积在 macOS 上被 numpy 交给了 Accelerate 的 BLAS——那是一条手写 SIMD、寄存器分块、完全流式的内核。而我的「二值化」实现是 LUT[np.bitwise_xor(A, q)].sum(1):先物化一个 20 万 × 96 的异或结果,再做一次查表 gather,再做一次按行求和,三趟内存、外加一个大临时数组。数据小了 32 倍,但内核从「一条 FMA 流水线」换成了「三趟带 gather 的通用循环」。
真正的向量库不会这么干——它用 CPU 的硬件 popcount 指令(x86 的 VPOPCNTQ、ARM 的 CNT)把整条 96 字节在几个周期内数完。所以这个数不能读成「二值化比 float32 慢」,它只能读成一条警告:
换掉数据类型就等于换掉了距离内核。 压缩省下的带宽,只有在你有一个同样优化过的内核时才能兑现成时间。用 numpy 手搓一个压缩检索,很可能比直接 float32 全扫更慢。
(同一台机器上我还量到一个同源现象:int8 数组的 .sum() 比 float32 的 .sum() 更慢——17.90 ms vs 15.47 ms,尽管数据少 4 倍。numpy 的整数归约会升位到更宽的类型,走的不是同一条优化路径。)
这条经验的共同祖先,是 AI 顶级原理望远镜 里那条影子原理「瓶颈塑造设计」:你必须先确认瓶颈真的是带宽,压缩才是解法;如果瓶颈是内核质量,压缩只会让它更糟。
八、我踩的坑:llama-embedding 会静默丢掉大部分输出
这个坑值得单独写,因为它不报错。
我最初用 llama-embedding -f corpus.txt --embd-output-format raw 批量取向量。1000 行输入,输出文件里只有 11 行、总共 8829 个浮点数——不到 12 条完整向量,而且最后一条从中间断掉。退出码 0,stderr 里 168 次 batch_decode 全部正常。 我先怀疑是上下文长度,把 -c 从 512 调到 2048 并加 -np 1,还是只有 15.6 条。
顺着源码就明白了。raw 格式的打印是每个浮点数一次 LOG():
// examples/embedding/embedding.cpp:85-92(llama.cpp master @ 41abbfd)
for (int j = 0; j < n_embd_count; ++j) {
for (int i = 0; i < cols; ++i) {
LOG("%1.7f%s", emb[j * n_embd + i], (i + 1 < cols ? " " : ""));
}
LOG("\n");
}
而 LOG() 进的是一个异步日志线程的环形队列,容量默认 512 条:
// common/log.cpp:172
common_log(size_t capacity = 512) {
队列满时生产者会阻塞等待(所以不是这里丢的),但程序退出时:
// common/log.cpp:371-392
void pause() {
running = false;
auto & entry = queue[tail]; // 直接覆写 tail 位置
entry.is_end = true; // 塞一个哨兵让 worker 停下
tail = (tail + 1) % queue.size();
...
thrd.join();
}
pause() 把哨兵写在 tail 上并推进 tail,worker 读到哨兵就停——排在后面还没打印的条目全部丢掉,被覆写的那一条也没了。 768 个 LOG() 才凑出一条向量,队列只有 512 格,于是输出必然在十几条向量处断掉。
绕法:不要用 llama-embedding 做批量取向量,改用 llama-server --embeddings 的 /v1/embeddings——它走 HTTP JSON,不经过这条日志队列。我 1000 条向量一次请求 25 条,2.8 秒全部拿到,零丢失(我在脚本里加了 assert len(d["data"]) == len(batch) 逐批断言)。
这是我认为最值得记的一条:一个「退出码 0 + 日志正常 + 输出静默截断」的组合,比任何崩溃都危险。 如果我没有顺手 assert 一下矩阵形状,这篇文章的所有数字都会建立在 12 条向量上。
九、选型:先问「能不能读回原向量」
把上面所有数字压成一张决策图。第一个分叉不是「要压多少」,而是「查询时能不能把原向量读回来」——因为这一个问题决定了你能不能拿到 0.95 这一档。
flowchart TD
A["要压缩向量库"] --> Z{"库有多大"}
Z -->|"十万条以内"| Y["别压<br>float32 全量扫描"]
Z -->|"更大"| B{"查询时能读回<br>原始向量吗"}
B -->|"能"| C["二值化 96 字节粗筛<br>再用 float 重排前 100"]
B -->|"不能"| D{"字节预算"}
D -->|"768 字节够用"| E["int8 标量量化<br>实测 0.9950"]
D -->|"要压到 100 字节内"| F["PQ 乘积量化<br>实测 0.7796"]
C --> G{"还想再省算力"}
G -->|"语料基本静态"| H["在自己语料上拟合 PCA"]
G -->|"语料持续新增"| I["MRL 前缀截断<br>需要模型支持"]
小结
- 根本约束:向量检索的成本是「字节数 × 候选数」,压缩存在的唯一理由是把这个乘积压进一级更快的存储。所以库小到能全量扫描时(我实测 20 万条 float32 点积 4.31 ms),压缩是净负债;而 1 亿条 float32 要 286 GiB,压到 8.9 GiB 才是从「跨机分片」回到「单机内存」。
- 崩点:最朴素的做法是 float32 全存,它在条数涨三个数量级时从 0.3 GiB 变成 286 GiB——不是变慢,是换一种部署形态。
- 可带走的动作:先问「能不能读回原向量重排」,再问「压多少倍」。 同样 96 字节,能重排的话 recall@10 是 0.9524,不能重排只有 0.4673。如果还没做任何压缩,第一件事是 int8(4× 压缩,我实测 0.9950),不是 MRL 截断(8× 压缩,我实测 0.4830)。
- 共同祖先:「便宜的近似缩小候选集 → 昂贵的精确重排」不是向量检索的发明,它是数据库的粗筛+精排、搜索引擎的召回+排序、CPU 的 cache 分级,同一个模式的第 N 次实例化。这也解释了为什么它是这张表里性价比最高的一档——它不是在压缩信息,是在分配精度。
五分钟在你自己的库上重跑一遍
上面所有数字都带着「1000 段同一本小说」的偏见,而这个实验最值得的地方是它在你自己的语料上五分钟就能重做。不用下载模型,不用标注数据——你只要有一个已经算好的 embedding 矩阵。
- 把你线上库里的 embedding 抽 2000 条存成一个
(2000, D)的 numpy 数组(1 分钟)。 - 算出 float32 的 top-10 当基准答案:
S = E @ E.T,对角线填-inf,np.argsort(-S)[:, :10](1 分钟)。 - 挨个试:int8 逐维 min/max、符号位二值化、二值化后取 100 个候选再用原向量重排。每种算一次 top-10,和基准答案求交集除以 10(3 分钟)。
要记录的数字就一个:你自己语料上「二值化 + 重排」和「二值化不重排」的差值。我这里是 0.4673 → 0.9524。如果你那边的差值也这么大,那你的压缩方案里就必须留出重排这一步的预算;如果差值很小(说明你的向量分布比我这本小说更「各向同性」),那可以省掉重排的随机读。
我接下来想补的是跨领域语料下 PCA 的失效点——第六节那个「PCA 在 64 维反超 MRL 0.18」的结论,我怀疑一旦语料从一本小说换成多领域混合就会翻转,但这需要一个带标注的多领域检索集才能测干净。有结果我会回来更新这一篇。
参考来源
工程实践
- nomic-ai/nomic-embed-text-v1.5 模型卡 —— Matryoshka 截断的官方配方(
layer_norm → 切片 → L2)与search_document:任务前缀要求 examples/embedding/embedding.cpp:85-92、common/log.cpp:172与common/log.cpp:371-392(ggml-org/llama.cppmaster @41abbfd)—— raw 输出逐个浮点走异步日志队列,以及pause()丢尾的机制- 语料:Project Gutenberg 第 1342 号电子书(《傲慢与偏见》,公共领域)正文前 6 万词
arXiv 论文
- Matryoshka Representation Learning, 2205.13147 —— MRL 的原始论文(ID 与标题我当场回查核实,论文正文未通读)
- Nomic Embed: Training a Reproducible Long Context Text Embedder, 2402.01613 —— 本文所用 embedding 模型的技术报告
站内相关
- AI 顶级原理望远镜 —— 「瓶颈塑造设计」与「表示决定成败」两条镜头
- 六种位置编码,其实只在争两个插入点 —— 同一张信息图系列的上一篇,同样是「先跑数字再讲方法」