跳至主要内容
Embedding 选型:别只看榜单分数

Embedding 选型:别只看榜单分数

我换过一次嵌入模型,理由很充分:公开榜单上新模型比旧的高好几个点。全量重建索引、跑完评测集,整体 Recall@20 几乎没动,但在「错误码」和「内部代号」那一组查询上反而掉了。后来才反应过来,那批查询靠的是字面稀有 token,而新模型对长尾字符串的分辨更弱;榜单测的多是通用语义问答,和我的查询分布根本不是一回事。

从那以后我不再把榜单当选型依据,只当候选名单。

本系列第 6/15 篇 · 上一篇:分块不是切字符,是切语义边界 · 下一篇:向量数据库选型的四个坐标轴

双编码器到底在算什么

检索用的嵌入模型通常是双编码器(bi-encoder):查询和文档分别过同一个(或一对)编码器,各自压成一个固定维度向量,线上用一次向量相似度比较完成匹配。

TEXT3 行
1
2
3
q_vec = normalize(E_q(query))
d_vec = normalize(E_d(passage)) # 离线算好,存进索引
score = dot(q_vec, d_vec) # 归一化后 dot 等价于 cosine

关键在「分别」两个字。文档向量在索引时就算完了,查询时才算查询向量,两者在编码阶段没有任何交互。这让检索可以做到毫秒级、支持千万级候选,但也带来一个硬上限:所有交互信息必须被压进一个几百到几千维的稠密向量里。精细的词级匹配、否定、数值比较、多条件组合,本来就不适合这种表示。

理解这一点,就能理解后面几篇的很多设计:混合检索补的是字面匹配,重排序补的是查询与文档的深度交互(cross-encoder 把两段拼在一起过模型),它们不是锦上添花,而是在补双编码器结构性的短板。

工程上还有几个常被忽略的表示细节:

归一化与度量。 如果向量做了 L2 归一化,内积、余弦、以及(在单位球上的)欧氏距离排序一致;没归一化时直接算 cosine 和用索引默认的 IP 会得到不同结果。我的习惯是离线写入前统一归一化,在线查询同样归一化,索引配置固定用内积,并在 index_manifest 里写死这件事。

池化方式。 CLS、mean pooling、last-token pooling 各有偏好,模型文档会写明。用错池化不会报错,只会让所有相似度整体变差。

非对称检索。 很多模型要求查询和文档使用不同前缀,比如查询侧加「为这个句子检索相关段落:」,文档侧不加。这是训练任务决定的,少加或加反了,效果下降常常是静默的。我把前缀写进 index_manifest,在线侧从同一份配置读取,绝不两处硬编码。

截断。 嵌入模型有最大 token 数(常见 512 或 8192)。超长 embed_text 会被静默截尾,请求成功、向量正常,只是后半段从没参与编码。第 5 篇提到的索引构建时统计 token 分布,主要就是为了拦这个。

选型的六个维度

维度 要问的问题 常见陷阱
语言与领域 中文占比、是否含代码/方言/专有名词 英文榜单高,中文长尾差
最大输入长度 chunk 的 token 分布是否被截断 长文档尾部静默丢失
维度 存储、内存、索引构建成本 维度翻倍,索引体积和延迟一起涨
对称性 是否需要 query/passage 前缀 前缀漏加,效果静默下降
部署形态 API 还是本地推理 API 有数据出境与限流问题,本地要 GPU
版本稳定性 供应商会不会悄悄换权重 在线查询与离线索引模型不一致

维度值得单独说。很多模型支持 Matryoshka 表示学习,可以把 1024 维截成 256 维而损失有限。这对百万级以上语料很实在:向量存储、ANN 索引内存、构建时间几乎线性下降。我的用法是先用短维度做粗排召回,再用原维度(或重排模型)做精排——把维度当成召回阶段可调的旋钮,而不是一次性定死。

部署形态在企业场景常常一票否决。私有文档不能出内网就得本地部署,这时要考虑 GPU 显存、批处理吞吐、以及模型升级时的重启成本。API 方案省运维,但要处理限流、重试、以及「供应商悄悄更新了模型版本」这类最难排查的问题。我会在每次索引构建时记录模型标识和一个固定句子的向量哈希,在线启动时校验,不一致就报警。

领域微调是最后才考虑的手段,不是第一选择。它确实能改善行业术语和内部简写的表示,但需要正负样本对,而构造高质量样本对的成本很高;微调之后还要重新验证通用查询有没有退化。我的顺序永远是:先查语料清洗、再查切分、再查混合检索、最后才考虑微调。多数「嵌入不行」其实是前面几步的问题。

自建评测:一百条真实查询就够起步

选型不能靠感觉,但也不需要一上来就建大规模基准。我做的最小可用评测集是这样构成的:

  • 从真实日志抽 60–80 条查询,按频次分层,别只抽高频;
  • 手写 20–30 条边界查询:错误码、编号、型号、口语化改写、含否定、多条件组合;
  • 每条标注相关的 doc_id 或 chunk_id,只标「前 20 个位置里该出现哪些」;
  • 单独留一组「库里没有答案」的查询,测拒答。

指标用三个就够:Recall@K(标准答案有没有被召回,K 取最终重排的候选深度)、MRR(第一个正确答案排第几)、以及分类型拆解的召回率。整体指标容易被高频简单查询拉高,分类型看才知道新模型到底改了什么。

PYTHON12 行
1
2
3
4
5
6
7
8
9
10
11
12
def evaluate(model, cases, k=20):
per_group, mrr = defaultdict(list), []
for c in cases:
qv = model.encode_query(c["query"]) # 前缀在这里统一处理
hits = index.search(qv, top_k=k)
got = {h.chunk_id for h in hits}
per_group[c["group"]].append(len(got & set(c["relevant"])) > 0)
mrr.append(first_hit_rank(hits, c["relevant"]))
flat = [x for group in per_group.values() for x in group]
return {"recall@%d" % k: mean(flat),
"by_group": {g: mean(v) for g, v in per_group.items()},
"mrr": mean(mrr)}

评测时有两点容易自欺。一是用模型生成的查询去评测:合成查询的措辞往往和文档高度相似,召回率虚高。二是换模型时忘了重建全部索引:新旧向量混在一个空间里,分数不可比,结果完全没有意义。第 4 篇说的双索引影子切换,在这里就是必需的基础设施。

还有一类结果要看懂:换模型后整体指标涨了 2 个点,但「编号类」掉了一截。这种时候我通常不换,或者换的同时补一路 BM25。平均分掩盖分布变化,是选型里最典型的坑。

换模型的迁移清单

一次嵌入模型替换,实际要动的东西比「改个模型名」多得多:

  1. 用新模型全量重建 chunk 的 embed_text 向量,生成新的 corpus_version;
  2. 确认 query 前缀、归一化、池化、维度、度量与索引配置一致;
  3. 新索引在影子环境跑同一份评测集,按查询类型对比,不只看总分;
  4. 检查维度变化对 ANN 索引参数(HNSW 的 M、efConstruction)和存储的影响;
  5. 原子切换 active_version,保留旧索引至少一个回滚周期;
  6. 监控线上查询相似度分布——整体下移往往意味着在线侧配置没同步,而不是模型变差。

第 6 步是我后来补的。有一次切换后线上平均相似度从 0.7 掉到 0.4,没有报错、没有超时,只是答案开始变泛。查了半天是在线服务漏加了 query 前缀。这类错误任何告警都抓不到,只有分布监控能发现。

优劣与边界

稠密向量的强项是语义泛化:用户换了说法、用了口语、描述的是概念而不是原词,它仍能召回。这正好补上第 8 篇里 BM25 的词汇鸿沟。

它的结构性短板同样明确:稀有字面串(错误码、编号、版本号的 BPE 碎片化)、精确否定、数值比较、以及需要词级对齐的场景;此外向量是黑盒,召回理由不可解释,出问题时很难向用户说明为什么这段话被选中。多向量表示(如按 token 保留一组向量再做后期交互)能缓解一部分,代价是存储和计算都涨一个量级。

还有一个不常被提的边界:语料高度同质时,所有文档的向量会挤在一个很窄的区域里,相似度区分度极低。几百篇同主题文档,top1 和 top20 的分数可能只差零点零几。这时候换更大的模型收益有限,更该做的是加元数据过滤、加结构化字段、或者干脆上 BM25 做主键检索。判断方法很简单:把一批查询的相似度分数画出来看分布宽度,窄得离谱就别在模型上继续投入了。

模型选完、向量算完,它们得有个地方存、还得能被高效地找出来。下一篇聊向量库和 ANN 索引:HNSW、IVF-PQ 各自在牺牲什么,以及为什么「带过滤条件的检索」才是选型时最该实测的一项。你现在这次查询的 top1 和 top20,相似度差多少?

关于本文

由 Lu's Blog 撰写,采用 CC BY-NC 4.0 许可协议。

#Embedding #RAG #评测

评论