跳至主要内容
BM25 和向量不是竞争,是互补

BM25 和向量不是竞争,是互补

最开始我不信这个说法。既然已经有 embedding 了,为什么还要留一条老掉牙的词频通道?直到我自己的一堆内部文档检索被一个错误码打穿。

那个查不出来的字符串

场景很简单:一个内部知识库,几千份文档,Markdown 和工单记录混着放,向量库用的是一套还算主流的中文 embedding 模型,chunk 大概 500 字重叠 80。日常查询表现挺好,员工问「报销流程要几天」「测试环境怎么申请权限」,基本都能命中。

然后有人搜 ERR_SSL_PROTOCOL_ERROR。

返回的前十条全是讲「HTTPS 配置」「证书更新」的泛泛之谈,没有一条精确指向真的写了这个错误码的那份排查记录。我手动在库里 grep 了一下,那份文档就在那儿,出现频率还挺高。

我一开始以为是切块的问题,把 chunk 调小、把重叠加大,没救。后来才想明白:这个字符串进 embedding 模型之前先过 BPE 分词,会被切成 ERR _S SL _PRO TOCOL 之类一堆碎片,模型没见过这种组合,编码出来的向量和「SSL 证书报错怎么办」这种自然语言句子的向量距离非常近——语义上确实近,但用户要的是精确匹配那一条。

向量检索解决的是「意思相近」,而这个查询要的是「字面相同」。这两件事不是一个模型能同时做好的。

两条通道失败的样子不一样

这是我现在理解混合检索的核心:不是谁更准,是它们错得不一样。

BM25 的失效模式很明确——它只看词。用户搜「怎么让测试环境不要自动发邮件」,如果文档里写的是「关闭通知推送」,词对不上,BM25 一条都召回不了。中文还得先分词,jieba 或者 ES 的 ik,分词粒度本身就会丢信息。

向量的失效模式更隐蔽——它对长尾 token 几乎没有分辨力。我陆续收集了一下去重后的失败查询,大概可以归成几类:

  • 错误码、异常类名:NullPointerException、E1004
  • 内部编号:工单号、需求号,形如 REQ-2024-0871
  • 型号与版本号:v2.3.1-rc2
  • 生僻专有名词:某个内部系统的代号
  • 带否定的查询:「不包含附件的邮件怎么归档」,向量容易只抓住「归档邮件」

反过来的情况同样成立。用户把一个问题换了三种说法,或者用的是口语,比如「东西传上去之后没反应」,文档里写的是「上传超时」,BM25 的词汇鸿沟就露出来了。

这两组失效集合交叠的部分其实不大。我粗粗统计过自己的失败样本,纯向量漏掉的那批里,BM25 能捞回来的超过一半;BM25 漏的,向量也能补上大半。这就是「几乎总是优于」的实际来源——不是每一路都强,是两路的错误不相关。

融合这一步,RRF 比调权重省事

双路召回之后怎么合并,我试过两种。

第一种是分数加权,final = α * bm25_score + (1 - α) * cosine。理论上是通的,实操上很烦:BM25 的分数没有上界,一条命中多个词的文档能到十几二十分,cosine 通常挤在 0.5 到 0.9 这个窄区间里。不做归一化直接加,等于全听 BM25 的;做了归一化,min-max 又会被极端值带偏,而且每次换语料都得重调 α。我调了两天,感觉在给自己制造工作。

后来换成 RRF(Reciprocal Rank Fusion),只吃排名不吃分数:

PYTHON7 行
1
2
3
4
5
6
7
def rrf_fuse(rankings, k=60, top_n=20):
# rankings: [[doc_id, doc_id, ...], ...] 每路召回一个有序列表
scores = {}
for ranked in rankings:
for rank, doc_id in enumerate(ranked, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
return sorted(scores.items(), key=lambda x: -x[1])[:top_n]

k 取 60 是文献里的常见默认值,我用 30 和 60 各跑了一遍评测集,差别在我这个规模上不到两个点,就沿用了 60。RRF 的好处是不用管两路分数尺度不一致,也不用为换模型重调参数。坏处是丢掉了分数的绝对信息——比如向量相似度 0.95 和 0.62 的差别被抹平了。在我这个场景里这不算问题,因为向量分数本来分布就窄,区分度有限。

真正需要调的是两路各召回多少条。我现在的配置是每路召回 50 条,融合后取前 20,再交给后面的 rerank。一开始两路都取 20,发现有些文档在单路里排 30 多名,融合之后就消失了——融合的优势恰恰来自「一路排得靠后、另一路排得靠前」,召回深度不够等于自己把这条路堵死。

分词和参数上踩过的坑

BM25 这块看着简单,中文场景还是有几个地方值得说。

分词器我用 ik_max_word 建索引、ik_smart 查询,这是 ES 里的常规做法,前者切得细、召回高,后者切得粗、精度高。但内部代号这类词它不认识,会被拆碎。我维护了一份自定义词典,把系统代号、常见错误码前缀、产品名塞进去,效果立竿见影——大概三十来个词,把那类查询的召回率拉上来一大截。

参数上,ES 默认 k1=1.2、b=0.75。b 控制文档长度归一化的强度,b=1 时完全按长度惩罚长文档。我的库里文档长度差异很大,短的工单记录两三百字,长的操作手册上万字,默认值下长文档吃亏吃得很厉害。把 b 调到 0.5 左右之后好了一些,理由是长手册里出现目标词,往往确实相关,不该那么狠地压。这个值我没做系统寻优,属于拍脑袋加验证,谁有更靠谱的方法可以告诉我。

还有一个坑是字段。一开始我把标题和正文拼成一个字段做 BM25,结果标题里的词权重被正文稀释了。后来拆成 title 和 body 两个字段,查询时给 title 加个 boost,大概 2 到 3 倍,效果比调 k1 明显得多。

我到底怎么判断它变好了

这部分我觉得比选模型重要。

我攒了一个 120 条查询的小评测集,来源是真实用户的搜索日志加上我自己造的边界样本,其中专门留了 30 条是上面说的那几类「向量必然失手」的查询。每条查询我人工标了相关的文档 ID,只标前 20 个位置里该出现哪些。然后在脚本里跑三种配置:纯 BM25、纯向量、混合,看 recall@20 和 MRR。

结果大致是这样:纯向量 recall@20 在 0.72 上下,纯 BM25 在 0.65 上下,混合到 0.89 左右。分查询类型看更有意思——在那 30 条「向量失手」的样本上,纯向量只有 0.3 出头,混合能到 0.85 以上。而纯 BM25 在整体上输给向量,主要输在口语化查询那一档。

这套评测不严谨,样本量小,标注也是我一个人做的,会有偏好。但它至少让我能回答一个问题:加这一路 BM25,维护成本值不值。对我来说值,因为多出来的是一个 ES 索引和一份自定义词典,没有额外的模型服务。

什么情况下我会不加

说句公道话,也有场景我觉得纯向量就够了。

如果语料全是自然语言长文——比如一批散文式的会议纪要、访谈记录——查询也全是口语描述,那 BM25 的边际贡献会很小,多加一路只是增加运维面。反过来,如果语料是结构化的短条目,编号、代码、参数满天飞,那 BM25 甚至可以是主力,向量只做兜底。

判断方法不复杂:从日志里抽一百条真实查询,先跑纯向量,把没命中的挑出来,看看里面有多少是「字面对得上但语义没对上」的。如果这个比例超过两三成,基本就可以确定该补一路词频检索了。

我现在的默认做法是只要建检索就上双路,不再先做纯向量再补——补的那一次,往往是在用户已经骂过一轮之后。

关于本文

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

#BM25 #RAG #检索

评论