用 BM25 与 Dense Embedding 做 Hybrid Retrieval
Lexical 检索和 semantic 检索分别在两类相反的 query 分布上翻车。用 reciprocal rank fusion 的 hybrid retrieval 不是在做插值,它是在投票——而这一票,能在每一类 query 上都赢。
类型: Build 语言: Python 前置要求: 阶段 11 第 04 课(embedding)、第 06 课(RAG);阶段 19 Track B 基础(第 20-29 课);阶段 19 第 64 课(chunking 策略) 预计时间: ~90 分钟
学习目标
- 从 Robertson 和 Sparck Jones 的公式出发,从零实现 BM25,带字段加权、文档长度归一化,以及可调的 k1 和 b。
- 在一个确定性 mock embedding 之上构建一个 dense retriever,让整个循环离线就能跑。
- 严格按照 Cormack、Clarke、Buettcher 于 2009 年发表的形式实现 reciprocal rank fusion,并解释为什么它碾压基于分数加权的插值。
- 调 RRF 的 k 常数和各 modality 的权重,在一个小 fixture corpus 上读出其中的权衡。
问题背景
当 query 携带着一个 corpus 里逐字存在的字面标识符时,lexical 搜索赢。对 AbortMultipartOnFail 的一次查询,BM25 在微秒级就返回了那个正确的 Go 函数。同一个 query 被 embed 之后,却落在三个相似度 cluster 的交界处,于是 dense retriever 把错误的文件排到了第一。
当 query 被改写、偏离了 corpus 的字面 token 时,dense 搜索赢。一个用户问"我们怎么处理被取消的上传",他压根没敲过 abort 或 multipart 这两个词。BM25 返回了那篇讲"上传大文件"的文档 chunk,因为那一页里有 uploads 这个词。Dense retrieval 找到的,是那个 summary 里提到了 cancellation 的 abort 函数。
二选一不是一个静态决策。变量是 query 分布。一个生产级 RAG 系统从同一个 endpoint 同时处理这两类,所以检索必须一次性把两类都接住。这就是 hybrid retrieval。而那个 merge 步骤,正是必须做对的那部分。
核心概念
flowchart LR
Query[Query] --> BM25[BM25 索引]
Query --> Dense[Dense 索引]
BM25 --> RanksA[排序列表 A]
Dense --> RanksB[排序列表 B]
RanksA --> RRF[Reciprocal Rank Fusion]
RanksB --> RRF
RRF --> Top[Top-k Chunks]一段话讲清 BM25
BM25 给一对 query-document 打分的方式是:在 query 的各个词项上求和,每一项都是一个 inverse document frequency 因子乘以一个会饱和的 term-frequency 因子,后者里还含一个长度归一化的修正。两个旋钮。k1 控制 term-frequency 的饱和速度;默认的 1.5 是论文推荐值,没有 benchmark 就别去动它。b 控制文档长度有多重要;默认的 0.75 表示长文档会被惩罚,但不是线性的。
IDF 公式用的是平滑后的 Robertson 和 Sparck Jones 定义,即 log((N - df + 0.5) / (df + 0.5) + 1)。log 里头那个加一,能在某个词出现在超过半数 corpus 时把 IDF 保持为正。在 stopword 技术上罕见的小 corpus 里,这一点很重要。
字段加权让你能告诉 BM25:命中符号名比命中正文更值钱。它的实现是在建索引时给词项计数乘上一个倍数,而不是在打分时。这让数学保持不变,也避免了为每个字段单独算一份分数。
一段话讲清 dense retrieval
用一个 embedding 模型把每个 chunk 编码成固定维度的向量。query 时刻,把 query embed 出来,按余弦相似度给每个 chunk 排序,返回 top-k。决定质量的变量是模型。检索算法本身就两行:点积加排序。
本课用一个确定性的、基于 hash 的 embedding,让你不用网络调用就能读懂 fusion 的数学。这个 hash 把 token 为键的偏移量累加进一个 96 维向量并归一化。余弦排序在多次运行间是确定的,这正是测试套件所要求的。
Reciprocal rank fusion,论文里的公式
两个排序列表。对于出现在任一列表里的每个候选,把它的 reciprocal-rank 贡献加起来。2009 年的论文用的是 1 / (k + rank),k 默认取 60。按总分排序。这就是整个算法。
论文里的常数 k = 60 不是随便定的。当 k = 60 时,rank-1 的贡献是 1 / 61,rank-10 的贡献是 1 / 70。贡献衰减得很慢,所以靠后的候选仍然能投上票。k 更小,会让头部结果一家独大;k 更大,则把贡献曲线压平。
我们的实现里有两个可调旋钮。一个是 k 常数。另一个是一对各 modality 的权重,这样当你有先验证据表明某一种在你 corpus 上更好时,可以把 BM25 或 dense 抬上去。把 rank 贡献乘以权重,是最简单且有原则的实现;它保住了 rank 衰减的形状,也保持了无量纲。
为什么 fusion 打得过基于分数加权的插值
BM25 分数无上界,且依赖 corpus。余弦相似度被限定在 -1 到 1 之间。一个线性组合 alpha * bm25 + (1 - alpha) * cosine 要求按 corpus 调 alpha,而且每次重建索引都会崩。基于 rank 的 fusion 不会。两个 rank 在跨 modality 时是可比的。自 2010 年起,论文里的 RRF baseline 在每一届公开的 TREC track 上都打赢了分数插值。
这跟你在 Vespa 和 Weaviate 文档里听到的关于 RankFusion vs RRF 的说法是同一套论证。它们得出了同一个结论:除非你有非常强的证据要去插值分数,否则就老老实实留在 rank 这一边。
rrf-fusion动手构建
code/main.py 实现了:
tokenize(text)—— 一个快速的正则 tokenizer。BM25Index—— 带字段加权,含add和search,k1、b 可调。mock_embed、DenseIndex—— 跟第 64 课同款的确定性 embedding,这样 chunk 之间可比。rrf(rankings, k, weights)—— 论文里那套 fusion,带多 modality 权重。HybridRetriever—— 把 BM25 和 dense 合到一起。- 一个演示用的
main(),加载一个小 fixture corpus,跑三个分别针对每个 retriever 强项和弱项的 query,打印每个 modality 产出的排序以及融合后的列表。
运行:
python3 code/main.py把演示输出并排着读。字面标识符那个 query 落在 BM25 rank 1、dense rank 4、RRF rank 1。改写过的 query 落在 BM25 rank 6、dense rank 1、RRF rank 1。歧义那个 query 落在 BM25 rank 3、dense rank 3、RRF rank 1。fusion 不是一个用来打破平局的东西;它是那个在每一类 query 上都赢的系统。
调旋钮
| 旋钮 | 默认值 | 什么时候往上调 | 什么时候往下调 |
|---|---|---|---|
| BM25 k1 | 1.5 | 词项在文档里重复出现,而你想让频率更重要 | 文档很短,词项重复是噪声 |
| BM25 b | 0.75 | 长文档确实每个词承载的信息更少 | 文档长度跟主题不相关 |
| RRF k | 60 | 靠后的候选应该继续投票 | top-1 应该一家独大 |
| BM25 weight | 1.0 | 你的 corpus 含字面标识符,且 query 能精确命中 | 你的 query 是用户改写过的 |
| Dense weight | 1.0 | query 是改写过的 | query 是字面的 |
调参靠的是在你留出的 query 集上重跑第 68 课的评测框架,不是靠直觉。
演示会藏起来的失败模式
Out-of-vocabulary token。 BM25 的 IDF 是从 corpus 算出来的,所以只在 query 里出现的词项贡献为零。Dense embedding 却会为同一个词凭空捏一个向量出来。在 corpus 之外的标识符上,dense modality 会返回看似合理实则错误的近邻。fusion 能吸收掉这个,因为 BM25 啥也不返回、rank 贡献直接掉出去——但前提是你按 document 去重,而不是按 chunk。
Stop-token 一家独大。 BM25 对 "the" 这个词会在整个 corpus 上产出一个均匀的排序。要么在索引器里过滤 stop token,要么接受高 IDF 词项自然占主导这个事实。
跨 modality 的内容完全相同。 如果你的 corpus 小到 BM25 的 top-1 也正好是 dense 的 top-1,那 RRF 给你的就是同一个 top-1、同一批近邻。这是正确行为,不是失败,但它会让 fusion 看起来像隐形了一样。在你的评测里加一对对抗性的 query,来验证 fusion 真的在起作用。
投入使用
生产实践:
- BM25 在进程内建索引;瓶颈是 term-frequency 词典,不是向量。
- dense 向量放在单独的存储里(本课用一个扁平列表;生产里你会用 HNSW)。
- 两路查询并行跑;fusion 是在并集上的一次常数时间 merge。
- 把每个检索命中的 modality 持久化下来,好让下游 reranker 看到是哪个 modality 给它投了票。
交付上线
第 66 课接过本课融合后的 top-k,用一个 cross-encoder 重排。第 68 课用 precision、recall、MRR 和 nDCG 评测整条 pipeline。本课的 hybrid retriever 是第 69 课端到端系统的第一级。
练习
- 把
mock_embed换成你 provider 的真实模型。重跑演示,报告改写过的 query 上、纯 dense 排序发生了怎样的变化。 - 加上第三个 modality:把 chunk 的 summary 单独建索引,作为第三个排序列表参与融合。测量增益。
- 把 RRF k 在 10、30、60、100、200 上扫一遍。画出第 68 课的 recall@k 曲线。报告曲线在你 corpus 上达到峰值时的 k 值。
- 把 BM25F 正经地实现出来(按字段做长度归一化,而不是那个倍数小技巧),在一个符号命中最重要的 corpus 上对比。
关键术语
| 术语 | 大家嘴上怎么说 | 它实际指什么 |
|---|---|---|
| BM25 | "lexical 搜索" | 概率排序:idf x 饱和 tf x 长度归一化 |
| RRF | "rank fusion" | 在各排序列表上求 1 / (k + rank) 之和;k 默认 60 |
| k1 | "TF 饱和" | 控制一个重复词项多快就停止再加分 |
| b | "长度惩罚" | 0 表示忽略文档长度,1 表示完全归一化 |
| Field weighting | "符号加权" | 建索引时重复 token,以抬高对该字段的命中 |
| 基于 rank vs 基于 score 的 fusion | "为什么 RRF 打得过线性" | rank 跨 modality 可比;score 不可比 |
延伸阅读
- Cormack, Clarke, Buettcher, "Reciprocal Rank Fusion outperforms Condorcet and individual rank learning methods", SIGIR 2009
- Robertson, Walker, Beaulieu, Gatford, Payne, "Okapi at TREC-3"(最初的 BM25 论文)
- Vespa: Hybrid Retrieval with BM25 and Embeddings
- Weaviate: Hybrid Search
- 阶段 11 第 06 课 —— RAG 基础
- 阶段 19 第 64 课 —— 其产出在这里被建索引的 chunker
- 阶段 19 第 66 课 —— 消费融合后 top-k 的 cross-encoder reranker