我之前写过一篇短文,讲一个错误码 ERR_SSL_PROTOCOL_ERROR 把纯向量检索打穿的经过(BM25 和向量不是竞争,是互补)。这篇想把背后的机制讲透一点:为什么这种失手是结构性的、融合的几种做法各自丢掉了什么信息、以及什么时候该按查询类型动态改权重。
一句话概括:混合检索的收益不来自「两路都强」,来自两路的失效集合交叠很小。
本系列第 8/15 篇 · 上一篇:向量数据库选型的四个坐标轴 · 下一篇:重排序:两阶段架构的收益与代价
稠密检索为什么会漏掉字面完全匹配的字符串
第 6 篇说过双编码器把整段文本压成一个固定维度向量。这个压缩过程对高频语义极其有效,对稀有字面 token 却很不利:
错误码、工单号、型号这类字符串进 BPE 分词后会被切成 ERR _S SL _PRO TOCOL 这样的碎片,模型在训练语料里几乎没见过这种组合。编码出来的向量会落在「SSL 证书报错」这类自然语言的附近——语义上确实近,但用户要的是精确命中那一份写着这个错误码的文档。向量空间里,「意思相近」和「字面相同」不是一回事,前者是连续相似度,后者是集合成员判断,稠密向量没有能力表达后者。
反过来的失效同样明确。BM25 只看词,用户说「东西传上去之后没反应」,文档写「上传超时」,词汇鸿沟一露出来,词频通道一条都召回不了。我把自己攒的失败查询归过类:
| 查询类型 | 稠密 | BM25 |
|---|---|---|
| 错误码、异常类名、编号、版本号 | 弱 | 强 |
| 生僻专有名词、内部系统代号 | 弱 | 中(取决于分词) |
| 口语化描述、换说法、概念性提问 | 强 | 弱 |
| 带否定、多条件组合 | 弱(易只抓主要名词) | 中 |
| 短查询(2–4 字) | 中 | 中(歧义大) |
这张表比任何「混合检索提升 X%」的结论都有用,因为它能指导路由:看到查询里含大写字母串、连字符编号或版本号模式,就直接提高稀疏通道的权重,不用等两路都跑完再猜。
BM25 的公式里藏着哪些旋钮
BM25 对每个查询词 t 在文档 d 上的打分大致是:
1 | score(t, d) = IDF(t) · [ f(t,d) · (k1 + 1) ] / [ f(t,d) + k1 · (1 - b + b · |d| / avgdl) ] |
IDF 压低常见词;f(t,d) 是词频,有饱和效应;k1 控制词频饱和速度(常用 1.2 左右);b 控制文档长度归一化的强度(常用 0.75 左右,b=1 完全按长度惩罚长文档,b=0 不看长度)。
几个工程上的体感:
中文分词是第一道门槛。jieba 或 ES 的 ik,建索引用细粒度(ik_max_word)提高召回、查询用粗粒度(ik_smart)提高精度,是常见组合。但内部代号、错误码前缀这类词分词器不认识,会被拆碎。我维护过一份三十来个词的自定义词典,把系统代号、错误码前缀、产品名塞进去,这类查询的召回立刻不一样。这份词典得跟着业务走,新系统上线忘了加词,检索就悄悄退化。
b 要按语料长度分布调。库里短工单两三百字、长手册上万字时,默认 b=0.75 会把长文档压得很狠。我试过降到 0.5 左右,理由是长手册里出现目标词往往确实相关,不该罚那么重。这类判断没有普适最优值,只能拿评测集试。
字段比参数重要。把标题和正文拼成一个字段,标题里的词会被正文稀释。拆成 title 和 body 两个字段、给 title 加 2–3 倍 boost,效果通常比调 k1 明显得多。同理,第 5 篇挂的 section_path 也可以单独作为一个可加权字段。
学习式稀疏表示是这条路上更进一步的做法:用模型把查询和文档扩展成带权重的稀疏词项向量(SPLADE 一类),既保留字面可解释性,又能做查询词扩展,缓解一部分词汇鸿沟。代价是需要额外的模型推理、索引体积更大、且对中文分词与词表设计更敏感。我在通用文档库上没默认启用,只在「术语多、同义改写多、又必须可解释」的场景试过。
融合:分数尺度不一致是核心麻烦
双路召回之后要合并。最直觉的做法是加权求和:
1 | final(d) = α · norm(bm25_score(d)) + (1 - α) · norm(cosine(d)) |
问题全在 norm 上。BM25 分数没有上界,一个命中多个词的文档能到十几二十分;cosine 通常挤在 0.5–0.9 的窄区间。不做归一化直接加,等于全听 BM25 的。做归一化又有新麻烦:
- min-max 会被极端值带偏,而且归一化依赖当前这批候选,同一文档在不同查询下分数不可比;
- z-score 假设分数分布近似稳定,实际上不同查询的分数方差差很远;
- 每换一次语料或 embedding 模型,
α就得重新调一遍。
我调过两天权重,感觉是在给自己制造长期维护工作,后来换成 RRF(Reciprocal Rank Fusion)。它只吃排名,不吃分数:
1 | def rrf_fuse(rankings, k=60, top_n=30, weights=None): |
k 用来压低头部排名的绝对优势,60 是文献里常见的默认值。我在自己的评测集上试过 30 和 60,差别不到两个点,就沿用了 60。
RRF 的好处是完全绕开分数尺度问题,换 embedding 模型、换 BM25 参数都不用重调融合系数,而且天然容忍某一路完全失效(比如向量服务超时返回空列表,加权另一路仍能得到结果)。代价是丢掉了分数的绝对信息:向量相似度 0.95 与 0.62 的差别被抹平。在向量分数分布本来就窄的场景里这不算损失;如果分数确实携带置信度(比如后面要做拒答阈值),就得保留原始分数另做一路判断,别指望融合后的 RRF 分数当置信度用——它只在同一次查询内部可比,跨查询不可比。
weights 那个参数是我后来加的,用来做按查询类型路由:
1 | def route_weights(query): |
looks_like_code 就是几个正则加一张白名单,很土但很稳。比起训练一个分类器,这种规则可解释、可回滚、出错时能一眼看出为什么。注意别把「查询改写」放在这之前——无条件改写最容易把精确的错误码「优化」成自然语言,把唯一能命中的字面线索洗掉,第 4 篇提过这一点。
召回深度与工程细节
融合的优势恰恰来自「一路排得靠后、另一路排得靠前」。所以每一路的召回深度必须明显大于最终候选数。我现在的配置是每路取 50,融合后取 30 进重排,最终送 5–8 个片段。一开始两路都取 20,发现有些文档在单路里排 30 多名,融合后就消失了——等于自己把互补性堵死了。
两路要并发执行,并设置独立超时。向量服务抖动时,不应该让 BM25 那一路也跟着超时;反过来也一样。降级策略很直接:一路超时就用另一路的排名做单路 RRF,并在 trace 里标记 degraded=true,让后续评测能把这类请求单独看。
还有个一致性问题:两路必须用同一个 chunk_id 空间。如果 BM25 索引按文档建、向量索引按 chunk 建,融合时 ID 对不上,看起来融合成功了,实际上同一内容被当成两条不同记录。我会在索引发布时校验两边的 ID 集合一致,不一致就拒绝发布。
评测必须分类型。整体 Recall@20 提升几个点,可能掩盖「编号类涨了、口语类跌了」这种此消彼长。我的评测集里专门留一组「向量必然失手」的样本,单独统计;融合配置每改一次,这几组都要一起看。第 11 篇会把这套分层评测展开成完整体系。
优劣与边界
收益:对稀有字面串和精确匹配的鲁棒性是质变,不是渐变;两路失效不相关,整体召回上限更高;RRF 让系统对模型和参数变化不敏感,维护成本低;一路故障时天然可降级。
代价:多一套索引(ES/OpenSearch 或稀疏索引)、一套分词器和自定义词典;两路并发带来额外延迟与资源;融合层多一个可调参数空间;排查问题时要同时看两路 trace。对个人或小团队,多出来的运维面是真实的成本。
什么时候不加:语料全是自然语言长文、查询也全是口语描述,且没有编号类查询时,BM25 的边际贡献很小。判断方法不复杂——从日志抽一百条真实查询跑纯向量,把没命中的挑出来,看有多少是「字面对得上但语义没对上」。低于一两成,先别上双路;超过三成,基本可以确定该补。反过来,如果语料是结构化短条目、编号满天飞,BM25 甚至应该是主力,稠密只做兜底。
混合召回解决的是「候选里有没有正确答案」,没解决「正确答案排第几」。把 30 条候选压成最终 5 条,需要一次真正看过查询和文档交互的排序,是下一篇的事。你现在这次查询,两路召回的前十名里有几条是重叠的?重叠越少,融合的价值越大。
评论