跳到主要内容

Embedding 进阶:Matryoshka 表示学习、量化向量与检索调优,把 RAG 的存储和延迟砍一个数量级

· 阅读需 10 分钟

前两篇分别聊了向量数据库选型(向量数据库选型对比)和检索链路的分块/重排序(RAG 检索优化深度拆解),但都把 Embedding 模型当成了黑盒——喂进去文本,吐出来一串浮点向量。这篇把盒子拆开,聚焦一个高级后端工程师做规模化 RAG 时迟早会撞上的问题:向量又大又多,存储和检索延迟怎么砍?

先算笔账。一个 100 万 chunk 的语料库,用 1536 维 float32 的 Embedding(比如 text-embedding-3-small),单是向量本体就是:

1,000,000 × 1536 × 4 字节 ≈ 6.1 GB

这还没算 HNSW 图索引的开销(通常再乘 1.2 到 1.5 倍)。规模涨到 1000 万 chunk,向量本体就 61GB,单机内存放不下,得上分布式。但真相是:绝大多数检索场景根本用不到 1536 维的全精度浮点。今天聊的三件事——对比学习怎么训出 Embedding、Matryoshka 怎么一个模型任意维度截断、量化怎么把向量压 8 到 32 倍——就是把这笔账砍下来的三条路。

一、Embedding 是怎么训出来的:对比学习 + InfoNCE​

Bi-Encoder(双塔)是目前检索向 Embedding 的主流架构:query 和 document 分别过同一个编码器,各输出一个向量,用余弦相似度(或内积)衡量相关度。训练它不靠"预测下一个词",而靠对比学习——让相关的 query/doc 对在向量空间里靠近,无关的拉开。

核心损失是 InfoNCE(Info Noise-Contrastive Estimation)。一个 batch 里的每个 query,把它的正例 doc 当要拉近的目标,把同 batch 里其他所有 doc 当负例(这就是著名的 in-batch negatives,省去显式挖掘负样本的成本):

import torch
import torch.nn.functional as F

def info_nce(query: torch.Tensor, doc: torch.Tensor, temperature: float = 0.05):
"""query/doc: (B, D),已 L2 归一化。对角是正例,其余是 in-batch 负例。"""
logits = query @ doc.T / temperature # (B, B) 相似度矩阵
labels = torch.arange(query.shape[0], device=query.device)
return F.cross_entropy(logits, labels) # 分类:第 i 行要预测到第 i 列

两个影响质量的工程细节:

  1. 温度 τ:τ 越小,相似度分布越尖锐,模型被逼着把正负例拉得越开,但对难负例更敏感、训练更不稳。0.02 到 0.1 是常见区间。
  2. 负样本质量:纯 in-batch negatives 有一个著名问题——batch 里那些"碰巧也相关"的 doc 被当负例,会引入假阴性。BGE、E5 这类模型的诀窍是额外挖难负例(hard negatives),把"看起来像但其实是错的"样本塞进去,召回才上得去。这也是为什么同一个 InfoNCE,BGE 就是比朴素双塔能打。

二、Matryoshka:一个模型,任意维度​

传统做法是"一个任务训一个维度":要 256 维就训 256 维的模型,要 1024 维再训一个。Matryoshka 表示学习(Matryoshka Representation Learning,MRL,Kusupati 等 2022)改变了这个:训一次,得到一组嵌套的、每个前缀维度都可用的向量。

原理极简单——训练时不再只算完整维度 D 的 InfoNCE,而是对一组从小到大排列的截断维度 M = {d1, d2, …, D} 分别算损失再求和:

def mrl_info_nce(query, doc, dims=(128, 256, 512, 768), temperature=0.05):
"""Matryoshka:对多个截断维度分别算 InfoNCE 再求平均。"""
total = 0.0
for d in dims:
q = F.normalize(query[:, :d], dim=-1) # 截断到前 d 维后重新归一化
p = F.normalize(doc[:, :d], dim=-1)
total += info_nce(q, p, temperature)
return total / len(dims)

为什么有效?因为模型为了在前 128 维就算出靠谱的相似度,被迫把最重要的判别信息压到向量的前缀维度里,越靠前的维度信息密度越高。这样推理时你截到 128 维、256 维,都不会崩,而是"优雅降级"。

benchmark 量级:MRL 原论文在 ImageNet 上,2048 维的向量截断到 256 维,精度损失约 1%(保留 99%);文本侧同样的趋势。落到产品:Cohere Embed v3 原生支持 Matryoshka 维度 64,OpenAI text-embedding-3-large 也支持 dimensions 参数从 3072 维往下截。这一条直接把"存储维度"变成了一个可以按流量和预算调的旋钮,而不用换模型。

三、量化 Embedding:int8 和二值化​

维度截断是"减长度",量化是"减每个数的精度"。两条路,代价和收益不同。

3.1 int8:4 倍压缩,几乎无损​

float32 的每个维度占 4 字节,量化到 int8 后只占 1 字节。做法是给整个向量算一个全局 scale,把浮点值映射到 [-127, 127]:

import numpy as np

def float32_to_int8(emb: np.ndarray):
"""float32 向量 -> int8,全局 scale,4x 压缩。"""
m = max(abs(emb.min()), abs(emb.max())) or 1e-9
scale = 127.0 / m
return (emb * scale).round().clip(-127, 127).astype(np.int8), scale

def int8_dot(a: np.ndarray, b: np.ndarray, sa: float, sb: float):
"""反量化后算内积:真实内积 ≈ (a/sa) · (b/sb)。"""
return (a.astype(np.float32) @ b.astype(np.float32)) / (sa * sb)

官方报告:Cohere 的 int8 Embedding 在 8 倍压缩下保持约 99% 的检索质量。这条几乎无脑上,是性价比最高的一档。

3.2 二值化:32 倍压缩,性价比天花板​

更激进的做法是每个维度只留一个符号位——正变 1,负变 0。768 维的 float32 向量压成 768 bit = 96 字节,整整 32 倍。相似度改用汉明距离(Hamming Distance,即两个二值向量不同位的个数),在 CPU 上就是一个 XOR + popcount,快得离谱:

def float32_to_binary(emb: np.ndarray) -> np.ndarray:
"""float32 (..., D) -> packed uint8 (..., D//8),D 需为 8 的倍数。"""
return np.packbits((emb > 0).astype(np.uint8), axis=-1)

def binary_similarity(a: np.ndarray, b: np.ndarray) -> np.ndarray:
"""a:(N,B) b:(M,B),B 为字节数。返回 (N,M) 的二值余弦相似度。"""
xor = np.bitwise_xor(a[:, None, :], b[None, :, :]) # 不同位 = 1
hamming = np.unpackbits(xor, axis=-1).sum(-1) # 汉明距离
return 1.0 - 2.0 * hamming / (a.shape[-1] * 8) # 换算回 [-1, 1]

代价是精度。二值化把每个维度的幅度信息全丢了,官方报告(Cohere Embed v3 binary)是约 96% 的检索质量,32 倍更小;Qdrant 在自己的 binary quantization 基准里给出约 95% 以上的 recall、内存降到 1/32、检索提速近一个数量级。注意"96%"这个数是有条件的:它统计的是"二值化检索 + 一定 oversampling(多召回一些候选)"之后的结果。二值化真正的用法不是直接当最终结果,而是做第一跳粗筛,后面接重排或对候选做原始浮点精排,才能把损失的 4% 找回来。

一个更精细的技巧是训练时就面向二值化:在损失里加一项,逼模型把向量推向 +1/-1 两个极端,而不是简单的"事后取符号"。简单取符号会把大量接近 0 的维度(信息量本来就低)硬分到某一侧,引入噪声;训练时约束过的二值向量质量明显更高。

四、检索侧的最后一公里:HNSW 参数与重排​

向量压得再小,检索那一步的调优也躲不掉。HNSW 是主流 ANN 索引,三个参数直接决定召回和延迟:

  • M(每个节点的边数):越大召回越高、内存越大。16 到 64 常见。
  • ef_construction:建图时的搜索宽度,越大图质量越好、建索引越慢。
  • ef_search:查询时的搜索宽度。这是运行时唯一的召回/延迟旋钮——调大它,召回上升、QPS 下降;调小则反过来。

生产里的标准做法是:先跑一组 (ef_search, Recall@10) 曲线,找到"召回刚好达标"的最小 ef_search,把这个值写死进配置,而不是拍脑袋。指标要盯着 Recall@k 和 nDCG@k,别只看 QPS。

二值化/int8 检索天然适合"粗排 + 精排"两段式:用压缩向量(甚至上更激进的 oversampling)召回候选,再用 Cross-Encoder(bge-reranker、Cohere Rerank 这类)或原始浮点向量对候选精排。这一套和我在 RAG 检索那篇里写的两段式范式完全一致——粗排求全,精排求准,压缩向量把粗排的成本压到了可以忽略的量级。

五、踩坑记录​

  1. 截断维度别忘重新归一化。MRL 截断到前 d 维后,如果不做 L2 归一化,内积的尺度会随 d 变化,和存好的全维向量根本不在一个可比空间里。代码里 F.normalize(query[:, :d]) 这步不能省。
  2. int8 内积要除以 scale 的乘积。量化向量直接拿 int8 内积当相似度,scale 信息就丢了,排序会乱。要么反量化算内积,要么存 scale 一起算,别偷懒。
  3. 二值化别事后取符号,尽量训练时约束。对现成 float 向量直接 (emb > 0) 是最快的路,但对"维度值本来就接近 0"的向量,噪声很大。若质量是硬指标,优先选厂商已经训好的 binary 版本(Cohere、Mixedbread 都有),而不是自己拿 float 向量切。
  4. 二值化不能当最终答案。96% 是"带 oversampling"的数,直接 top-1 会掉点。务必接精排或浮点 rescore。
  5. ef_search 别设太大。有人为追求召回把 ef_search 拉到 1024,结果 QPS 掉一个数量级,召回却没涨多少——曲线在某个点之后是平的,找到拐点就停。

六、总结​

把 Embedding 从黑盒拆开,规模化 RAG 的存储/延迟优化其实就三张牌,且能叠着打:

  1. Matryoshka 截断:一个模型任意维度,按预算选维度,存储线性降。
  2. int8 量化:4 倍压缩、约 99% 质量,无脑上。
  3. 二值化:32 倍压缩、约 96% 质量,配 oversampling + 精排用。

三者叠加,理论上能把向量存储从 6.1GB 压到几百 MB,检索延迟砍一个数量级,而端到端召回损失控制在个位数百分比。下次 RAG 撑爆内存,先别急着加机器——问问自己:这 1536 维的 float32,真的都需要吗?