混合检索:BM25 与稠密嵌入的倒数排名融合 本节摘要:词法检索与语义检索在相反的查询分布上失败。词法擅长字面标识符,语义擅长改写过的口语化查询,生产 RAG 必须同时处理两类。本节从零实现 BM25(Robertson 与 Sparck Jones 公式,带字段加权与长度归一)、一个确定性 mock 嵌入的稠密检索器,以及 2009 年 Cormack 等人发表的倒数排名融合(Reciprocal Rank Fusion, RRF)的精确公式,并解释为什么它碾压分数加权插值。两个旋钮(k=60 的衰减常数、每路权重)在小固定语料上读取权衡。读完本节,你能说清为什么「排名可比、分数不可比」是融合的根基,以及为什么自 2010 年起 RRF 在每个公开 TREC 赛道上都击败了线性插值。
本节摘要:词法检索与语义检索在相反的查询分布上失败。词法擅长字面标识符,语义擅长改写过的口语化查询,生产 RAG 必须同时处理两类。本节从零实现 BM25(Robertson 与 Sparck Jones 公式,带字段加权与长度归一)、一个确定性 mock 嵌入的稠密检索器,以及 2009 年 Cormack 等人发表的**倒数排名融合(Reciprocal Rank Fusion, RRF)**的精确公式,并解释为什么它碾压分数加权插值。两个旋钮(k=60 的衰减常数、每路权重)在小固定语料上读取权衡。读完本节,你能说清为什么「排名可比、分数不可比」是融合的根基,以及为什么自 2010 年起 RRF 在每个公开 TREC 赛道上都击败了线性插值。
对应原课程:Phase 19 · Lesson 65 ·
hybrid-retrieval-bm25-dense(原英文phases/19-capstone-projects/65-hybrid-retrieval-bm25-dense/docs/en.md)。本节属第 20 章「毕业项目」的进阶 RAG 赛道。
阅读完本节,你应当能够:
词法检索在查询携带语料中逐字出现的字面标识符时胜出。查询 AbortMultipartOnFail,BM25 在微秒内返回正确的 Go 函数。同一个查询嵌入后,落在三个相似簇的边界上,稠密检索器把错误的文件排第一。
稠密检索在查询被改写得远离语料字面 token 时胜出。用户问「我们怎么处理被取消的上传」,从未输入 abort 或 multipart。BM25 因为「上传大文件」页含 uploads 一词而返回它;稠密检索找到摘要提及 cancellation 的 abort 函数。
两者间的选择不是静态的——查询分布才是变量。生产 RAG 在同一端点上同时处理两类查询,所以检索必须同时搞定两类。这就是混合检索。合并步是必须做对的部分。
BM25 给一个查询-文档对打分,方法是:对每个查询词,把一个**逆文档频率(IDF)因子乘上一个饱和的词频(TF)**因子(后者含长度归一修正),再求和。两个旋钮:k1 控制词频饱和,默认 1.5 是发表推荐值,没有基准就不要动;b 控制文档长度有多大影响,默认 0.75 表示长文档被惩罚但非线性。
IDF 公式用平滑的 Robertson 与 Sparck Jones 定义:log((N - df + 0.5) / (df + 0.5) + 1)。log 内的 +1 保证当一个词出现在超过半数语料时 IDF 仍为正——在小语料里停用词技术上也算「稀有」,这点很重要。
字段加权让你告诉 BM25「符号名上的命中比正文里的命中更值钱」。实现是在索引期对词频计数乘上倍率,而非打分期,这样数学完全一致,避免每字段一个独立分数。
用嵌入模型把每块映成定维向量。查询时嵌入查询,按余弦相似度给所有块排序,返回 top-k。模型是决定质量的变量;检索算法本身只有两行:点积与排序。本节用确定性哈希嵌入,让你不联网就能读融合数学——哈希把 token 键控的偏移求和成 96 维向量并归一,余弦排名跨运行确定,正是测试套件所需。
两个排名列表。对出现在任一列表里的每个候选,求其倒数排名贡献之和。2009 年论文用 1 / (k + rank),k 默认 60。按总分排序。这就是全部算法。
发表的常数 k=60 不是随意的:k=60 时 rank-1 贡献 1/61,rank-10 贡献 1/70,贡献衰减慢,深位候选仍能投票。k 越小,top 越主导;k 越大,贡献曲线越平。本节实现里两个可调旋钮:k 常数;一对每路权重,让你在有先验证据时增强 BM25 或稠密。把排名贡献乘以权重是最简单且有原则的实现——它保住了排名衰减形状且与尺度无关。
BM25 分数无界且依赖语料;余弦相似度有界于 [-1, 1]。线性组合 alpha * bm25 + (1 - alpha) * cosine 需要按语料调 alpha,每次重索引都崩。基于排名的融合不会。两个排名可跨模态比较,分数不可。发表的 RRF 基线自 2010 年起在每个公开 TREC 赛道上都击败分数插值。Vespa 与 Weaviate 的文档里 RankFusion 对 RRF 的争论得出同样结论:除非有非常强的证据,否则坚持基于排名。
code/main.py 实现:
tokenize(text) —— 快速正则分词器。BM25Index —— 字段加权,带 add、search、可调 k1/b。mock_embed、DenseIndex —— 与第 64 节同形的确定性嵌入。rrf(rankings, k, weights) —— 带多路权重的发表版融合。HybridRetriever —— 组合 BM25 与稠密。main(),加载小固定语料,跑三个分别针对各路强弱的查询,打印每路排名与融合后列表。import math class BM25Index: def __init__(self, k1=1.5, b=0.75): self.k1, self.b = k1, b self.docs = [] # 每文档的 token 列表 self.df = {} # 词 -> 出现该词的文档数 self.avg_len = 0 def add(self, tokens, field_weight=1.0): self.docs.append(list(tokens) * int(field_weight)) # 字段加权:索引期复制 for term in set(tokens): self.df[term] = self.df.get(term, 0) + 1 self.avg_len = sum(len(d) for d in self.docs) / len(self.docs) def search(self, query_terms, top_k=10): scores = [] N = len(self.docs) for i, doc in enumerate(self.docs): s, tf_map = 0, {} for t in doc: tf_map[t] = tf_map.get(t, 0) + 1 for term in query_terms: if term not in tf_map: continue df = self.df.get(term, 0) idf = math.log((N - df + 0.5) / (df + 0.5) + 1) tf = tf_map[term] norm = (1 - self.b) + self.b * (len(doc) / self.avg_len) s += idf * (tf * (self.k1 + 1)) / (tf + self.k1 * norm) scores.append((i, s)) scores.sort(key=lambda x: -x[1]) return [i for i, _ in scores[:top_k]]
def rrf(rankings, k=60, weights=None): """rankings: 多个排名列表(每个是 doc_id 序列)。weights: 每路权重。""" if weights is None: weights = [1.0] * len(rankings) fused = {} for ranking, w in zip(rankings, weights): for rank, doc_id in enumerate(ranking, start=1): fused[doc_id] = fused.get(doc_id, 0) + w / (k + rank) return sorted(fused, key=lambda d: -fused[d])
运行:
python3 code/main.py
并排读 demo 输出:字面标识符查询落在 BM25 rank 1、稠密 rank 4、RRF rank 1;改写查询落在 BM25 rank 6、稠密 rank 1、RRF rank 1;歧义查询落在 BM25 rank 3、稠密 rank 3、RRF rank 1。融合不是平局打破器,而是在每个查询类别上都赢的系统。
| 旋钮 | 默认 | 调高当… | 调低当… |
|---|---|---|---|
| BM25 k1 | 1.5 | 词在文档里重复,你想让频率更重要 | 文档短,词重复是噪声 |
| BM25 b | 0.75 | 长文档确实每词信息更少 | 文档长度与主题无关 |
| RRF k | 60 | 深位候选应继续投票 | top-1 应主导 |
| BM25 权重 | 1.0 | 语料含字面标识符且查询匹配它们 | 查询是用户改写的 |
| 稠密权重 | 1.0 | 查询被改写 | 查询是字面的 |
⚠️ 在留出查询集上重跑第 68 节的评估框架来调旋钮,别凭直觉。
业界对比:Vespa、Weaviate、Elasticsearch 的混合检索都采用 RRF 或其变体;OpenSearch 的 neural-search 插件同样。它们的共识与本节一致——基于排名融合,除非有强证据才用分数插值。LangChain/LlamaIndex 的高层 EnsembleRetriever 默认也是 RRF。
词表外 token(Out-of-vocabulary)。 BM25 的 IDF 从语料算,只出现在查询里的词贡献为零。稠密嵌入为同一个词幻觉一个向量,在语料外标识符上返回看起来合理但错误的邻居。融合能吸收这点(因为 BM25 返回空,排名贡献消失),但前提是你按文档而非按块去重。
停用词主导。 BM25 对「the」这个词会产生语料上的均匀排名。在索引器里过滤停用词,或接受高 IDF 词自然主导。
跨模态内容雷同。 若语料小到 BM25 的 top-1 也是稠密的 top-1,RRF 给你同样的 top-1 与同样的邻居——这是正确行为,不是失败,但会让融合看起来隐形。在评估里加一对对抗性查询对,验证融合确实在工作。
HybridRetriever:本节的混合检索器是第 69 节「端到端 RAG 系统」的第一阶段。rrf 函数:纯函数、无状态、可独立复用于任何多路排名融合场景(如多模型重排、多语种索引合并)。生产模式:
mock_embed 换成你提供商的真实模型,重跑 demo,报告改写查询上稠密单路排名如何变化。下一节,我们将进入「交叉编码器重排」——用双塔之外的交叉编码器对本节融合出的 top-k 做精排,把噪声排在答案之后。