Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Hybrid Retrieval:BM25 和 Dense 到底怎么分工

前阵子一个小团队的朋友问我:库里就几百份财报 PDF,都是同一类文档,query 基本是“腾讯今年赚了多少“这类语义型问题,要不要上 Hybrid?我说先别上。

第 8 篇刚说过单路向量的相似度不可靠,很多人的本能反应是“那两路一起上“。这个反应很正常,但在这个场景里是浪费:两路排序结果会高度重叠,BM25 加进来没有增量,白白多维护一套索引和一段融合逻辑。混合不是银弹,先想清楚什么时候不需要它,再谈怎么分工。

先说什么时候不需要 Hybrid

我朋友那个库,恰好把“不需要 Hybrid“的三个条件占全了:

  • 文档库同质:几百份全是财报 PDF,结构、语言、写法高度一致;
  • query 模式单一:基本都是事实型语义查询,“赚了多少”“毛利率多少”,没有精确编号、代码这类必须字面命中的东西;
  • 文档语言和 query 语言一致:中文问、中文答,跨语言匹配用不上。

这三个条件凑齐时,Dense(向量检索)和 BM25(词频检索)返回的排序列表会高度重叠——语义型 query 里没什么必须字面命中的 token,BM25 的看家本领用不上,把两路融合起来自然没有增量。

没有增量还不算完,成本是实打实的:向量索引之外要再维护一套倒排索引,外加一段融合逻辑。多一个部件,就多一个会坏、要调参的地方。对于一个几百份文档的小库,这笔账怎么算都不划算。

那什么时候回报最大?文档类型多样(PDF、网页、PPT 混在一起)、中英混合、数字密集(财报、技术规范)、精确查询和语义查询各占一半的场景。这几条,一个正经的财报 RAG 几乎全占——所以分工这件事,还是得展开讲。

两路各吃哪碗饭

先交代一句背景:BM25 按词频、IDF(词的区分度)和文档长度归一化打分,Dense 基于语义向量,社区的主流叙事是“Dense 更强,全用 Dense“。生产环境不是这么回事,两路各有各的优势区。

BM25 吃三摊:

  • 精确术语。用户搜“所得税费用“,要的就是包含这五个字的 chunk,不是“tax expense“,也不是“税收支出“。Dense 会把这几种说法映射到相近的向量,语义上它没错,但用户查的是那个确切的科目名。这类词在财报里一抓一把:资本开支、毛利率、非国际财务报告准则、回购、股息。
  • 数字和编号。embedding 模型不是为数值型 token 优化的,“184,504“对它来说是个弱信号;产品型号、条文编号这类东西同理。BM25 直接做字符串匹配,数字不会被弱化。
  • 领域术语。“递延所得税资产”“商誉减值测试“这种词,出现本身就是强信号——它们在通用语料里出现频率低,恰恰因为稀缺才有区分度。硬要做语义扩展,反而可能把噪音引进来。

Dense 吃另外三摊:

  • 同义改写。用户问“腾讯赚了多少钱“,chunk 里写的是“本公司权益持有人应佔盈利“——两边词表零重合,BM25 一个 token 都匹配不上,Dense 能对上。财报里“换说法不换意思“是常态,这正是 Dense 的主场。
  • 跨语言。中文 query 配英文 chunk,multilingual 的 embedding 能匹配,BM25 完全做不到。
  • 长 query 配短 chunk。问题拖到 20 多个词时,BM25 容易在短 chunk 上过度匹配其中几个高频词——撞上不代表相关;Dense 的语义编码对长短文本的组合更均衡。

所以这不是谁强谁弱的问题,是匹配机制不同:一个数词频,一个算语义。

纯 Dense 翻车的两个现场

光列优势区有点干,讲两个翻车现场,都是“现象 → 为什么“的路子。

第一个,数字查询。问“腾讯 2025 H1 经营盈利是多少“,正确的 chunk 里写着“經營盈利 54,943 百萬元“。但 Dense 很可能把另一条排在前面:“經營盈利較去年同期增長 32%,達到近年來新高”。

为什么会这样?query 问“是多少“,“增長 32%、達到新高“这种描述性语句,措辞上和 query 天然更接近,文字也更丰富;而“54,943“对 embedding 是弱信号——模型对数值的理解远不如对自然语言,干巴巴的数字陈述在语义空间里没什么可抓的。真正有答案的那条 chunk,反而被挤下去了。BM25 不吃这个亏:在它眼里“經營盈利”“54,943”“百萬元“是平等的 token,直接匹配,数字不被弱化。类似的还有:不同年份的管理层讨论都在讲“增长、投入、挑战”,Dense 会把语气相似的段落拉得很近——不过年份误召回是第 6 篇的地盘,这里只提一句。

第二个,目录污染。目录页把“收入、毛利、现金流、风险因素“全抄了一遍。Dense 会认为它和任何包含这些关键词的查询都高度相关——目录的语义覆盖了整个 PDF,而 embedding 是整段语义的压缩,“什么都有“压缩出来的结果,自然和谁都像。BM25 反而能看出差别:目录页全是孤立的名词性短语,没有完整句子,没有实质数值;正文讲“收入“的段落是带数字、有动词的长句。两种文本的词频分布完全不同。

两个现场说的是一回事,和第 8 篇呼应:相似度看不见“哪条 chunk 真有答案“。要说明的是,这两个案例都是定性复盘,我没有在自有库上做过“纯 Dense vs Hybrid 召回率“的对照实测【待补:自有库上纯 Dense 与 Hybrid 的召回率对比实测数据,目前只有定性结论】,所以这里只给方向,不给百分点。

三种混合实现,和各自的代价

真要上混合,有三种做法,按我个人的推荐程度从低到高排。

方式一,分数线性加权,最简单:

final_score = alpha * dense_score + (1 - alpha) * bm25_score

alpha 的经验区间是 0.6–0.8——注意这是经验起点,不是标准值,得按自己的场景调。代价在于两个分数的量纲不同:余弦相似度理论区间是 [-1, 1](现代模型的向量实际多为非负,分数集中在正区间),BM25 的分数没有固定上界——k1 参数让单个词的词频贡献饱和收敛,但总分仍随命中词数和文档长度漂移。所以线性加权有个前提经常被略过:两路分数必须先各自归一化(min-max 或 z-score 都行),不归一化,alpha 的经验值没有任何参考意义。就算归一化了,换个 embedding 模型、换个文档库,alpha 可能也都得重调。

方式二,RRF(倒数排名融合):

rrf_score = 1/(k + rank_dense) + 1/(k + rank_bm25)

思路是扔掉原始分数,只按两路各自的排名融合,k 常取 60(同样是经验起点)。量纲问题直接消失,alpha 也不用调了,而且天然对异常排名不敏感:某一路失手把一条无关 chunk 打到第 1 名,它的贡献也会被排名衰减按住——k 控制的就是这个衰减速度,第 1 名和第 2 名的贡献差距明显,第 59 名和第 60 名几乎没差。代价是排名把分数里的信息量一起扔了:两路都信心十足,和两路都勉强及格,在 RRF 眼里长得一样。

方式三,合并去重 + 精排收口,也是我最推荐的:两路各取 top-k,合并去重后交给 reranker 精排。流程用伪代码表示:

dense_hits = dense_search(query, top_k=100)
bm25_hits  = bm25_search(query, top_k=100)
candidates = merge_dedupe(dense_hits, bm25_hits)
final      = rerank(query, candidates)   # top-100 同样是经验起点

前两种方式是在分数层面融合,绕不开“两路分数可比“这个前提;方式三干脆不比分数,在候选集层面融合——两路各自把自家最好的 top-100 交上来,合并去重(重叠的条目正好说明两路都认可它),排序的事整个推给精排。这个定位下,BM25 是 Dense 的“补充“而不是“竞争对手“:BM25 抓到的可能是 Dense 漏掉的精确匹配,Dense 抓到的可能是 BM25 漏掉的语义改写。

代价有两笔:一是两套索引都得维护,向量库加倒排,构建和存储成本翻倍;二是合并之后必须靠 reranker 收口——两堆分数不可比的结果混在一起,总得有人把它们排成一个顺序。我的全流程笔记里记过一个估数:不加 reranker 的检索结果通常有 30–50% 不相关(这是笔记里的估计,不是本篇的实测)。

另外提一句变体:也有人做“先一路粗筛、再另一路精排“的级联,比如先 BM25 筛出候选再用 Dense 排。这是另一种思路,别和上面这种并行合并混为一谈。

收尾:分工的依据是 query 类型,不是信仰

带走一个判断顺序就够。先看自己的 query 分布:精确术语、数字、编号占比高,BM25 必须上桌;同义改写、跨语言的多,Dense 为主;两头都多,上混合——优先 RRF 或“合并 + 精排“,别费心调 alpha。要是文档同质、query 单一、语言一致,回到第 1 节,纯 Dense 就够了。

BM25 和 Dense 各管一摊,按 query 类型分工,不按社区热度站队。

最后留个引子。混合检索做到最后你会发现,它只是把问题往后推了一层:合并去重之后,两堆结果总得排成一个顺序,这个活天然落在精排层。很多人看到合并后效果一般,第一反应是换个更强的 reranker——且慢,先看看你喂给它的是什么。这是下一篇《Rerank 不是瓶颈,前面的脏数据才是》要聊的。