第 5 章:检索召回,混合检索(BM25 + 向量)
能亲手写出 BM25,讲清为什么要"向量 + 关键词"混合检索,以及分数怎么融合
📊 学习时长:50-65 分钟 🎯 完成后能力:能亲手写出 BM25,讲清为什么要"向量 + 关键词"混合检索,以及分数怎么融合 🔗 关联面试题:5 道(覆盖 5 个不同角度,见 Part 3)
这一章你会学到什么
- ✓ 零基础也能跟着做:用纯 Python 实现关键词检索的标准算法 BM25,并看清它的强项和硬伤
- ✓ 看懂为什么真实系统要"双路召回":向量检索 + 关键词检索一起上
- ✓ 理解 BM25 相比 TF-IDF 强在哪(词频饱和、文档长度归一)
- ✓ 学会两套分数怎么融合:归一化、加权、RRF
- ✓ 理解"动态权重":不同查询信任不同的那条腿
- ✓ 知道高效召回的三个旋钮:候选池 N、top-k、元数据过滤
本章按三段式组织,不同基础的读者各取所需:
段落 给谁看 内容 占比 🚀 Part 1 · 主线实战 零基础,想先跑起来 概念白话 + 纯 Python BM25 ~50% 🎯 Part 2 · 面试深度 想讲透、备战面试 BM25原理 + 混合检索 + 融合 ~40% 🏆 Part 3 · 验收串题 检验学到位没 关联面试题 + 自检清单 ~10%
🚀 Part 1 · 主线实战 | ~50% 这部分零基础也能跟着做。你会亲手写出搜索引擎里用了二十年的经典算法 BM25,并看清它什么时候灵、什么时候抓瞎。
在开始之前
你需要:
- ✅ 装好 Python(3.9+),能看懂基础 Python(列表、字典、循环)
- ✅ Part 1 的 BM25 demo 零依赖(连第三方库都不用)
- ✅ 最好先读过第 4 章,那章讲了"向量检索"(懂语义),这章讲"关键词检索"(精确命中),以及怎么把两者合起来
3 个核心概念(先白话,再术语)
概念 1:召回(recall)和"检索召回模块"
第 3 章我们用"召回率"衡量切分好坏;这里的召回是动词:指从知识库里把可能相关的 chunk 捞出来这个动作。负责这件事的就是"检索召回模块",它是 RAG 的"搜索引擎"。
概念 2:稀疏检索 vs 稠密检索
- 稠密检索(Dense) = 第 4 章的向量检索:把文字变成稠密向量,靠"意思相近"找。懂语义。
- 稀疏检索(Sparse) = 关键词检索(BM25):靠"词命中"找,代表向量大部分位置是 0(稀疏)。精确。
"稀疏/稠密"说的就是向量里 0 多不多,不用纠结这个词,记住"关键词 vs 语义"即可。
概念 3:BM25 是什么
BM25 是关键词检索的标准打分算法(搜索引擎、Elasticsearch 里都用它)。一句话:query 里的词在某个文档里命中得越多、命中的词越稀有,这个文档分越高,再叠加两个修正(下面 Part 2 讲)。你马上会亲手写一遍。
🛠️ 跟着做:30 行纯 Python 实现 BM25
Step 1:写出 BM25
新建 bm25_demo.py(完整文件见 code-snippets/bm25_demo.py,这里是核心):
import math, re
docs = [ # 三条玩具"文档",当作我们的知识库
"等待期说明:本产品等待期为90天,等待期内出险不予赔付",
"理赔需要的材料清单与销售流程介绍",
"本保险承保意外伤害导致的身故或残疾",
]
def tokenize(s): return list(re.sub(r"\s", "", s)) # 中文按"字"切(真实系统用 jieba 等做词级切分)
corpus = [tokenize(d) for d in docs] # 把每条文档切成字的列表
N = len(corpus) # 文档总数
avgdl = sum(len(d) for d in corpus) / N # 平均文档长度(给"长度归一"用)
df = {} # df[字] = 有多少篇文档出现过这个字
for d in corpus:
for t in set(d): # set 去重:同一篇里出现几次只算 1
df[t] = df.get(t, 0) + 1
def idf(t): # IDF:越稀有的字越值钱(命中它几乎锁定答案)
n = df.get(t, 0) # 出现过这个字的文档数
return math.log(1 + (N - n + 0.5) / (n + 0.5)) # 标准 BM25 的 IDF 公式
def bm25(query, doc, k1=1.5, b=0.75): # k1 控饱和速度, b 控长度归一强度
score, dl = 0.0, len(doc) # score 累加分, dl 当前文档长度
for t in tokenize(query): # 逐个看 query 里的字
if t not in doc: continue # 这个字文档里没有 → 不贡献分
f = doc.count(t) # 词频 TF:这个字在文档里出现几次
tf_sat = f * (k1 + 1) / (f + k1 * (1 - b + b * dl / avgdl)) # 词频饱和 + 文档长度归一
score += idf(t) * tf_sat # 稀有度(IDF) × 饱和后的词频,累加
return score
Step 2:跑 "等待期多久"
q = "等待期多久"
for i in sorted(range(N), key=lambda i: bm25(q, corpus[i]), reverse=True): # 按分数从高到低排
print(f" score={bm25(q, corpus[i]):.2f} {docs[i]}")
你应该看到(数字固定,纯靠字面命中算出来):
score=4.51 等待期说明:本产品等待期为90天,等待期内出险不予赔付
score=0.00 理赔需要的材料清单与销售流程介绍
score=0.00 本保险承保意外伤害导致的身故或残疾
🐛 跑不通看这里
- 报
NameError: avgdl?这些是模块级变量,确认corpus / N / avgdl / df那几行在bm25()定义之前就跑过(整段按顺序粘进同一个文件即可)。- 三条全是
0.00?多半 query 和文档一个相同字都没有:BM25 靠字面命中,没共享字就是 0,这正常。- 分数和这里对不上?只要你没改
docs、q、k1/b,中文按字切的结果就是确定的;改了任意一个,分数就会变。
🎉 BM25 完美命中:"等待期"是个稀有又精确的术语,命中它的文档分数一骑绝尘(4.51),其余为 0。这就是关键词检索的强项:专业术语、编号、数字,字面对上就稳。
Step 3:再跑 "推销保险",看它抓瞎
score=1.98 本保险承保意外伤害导致的身故或残疾
score=1.08 理赔需要的材料清单与销售流程介绍
score=0.41 等待期说明:本产品等待期为90天……
这次结果很乱:排第一的"本保险承保……"其实跟"推销"毫无关系,它只是字面蹭到了"保""险"两个字。而真正相关的"销售流程介绍"反而排第二。
为什么?因为 BM25 只会数"字面命中",它根本不懂"推销"和"销售"是一个意思。 (这里按字切还蹭到了几个字;真实系统按词切,"推销"会直接匹配不到只写了"销售"的文档,漏得更干脆。)
👉 第 4 章的向量检索懂语义但对专有名词不精,这一章的 BM25 精确但不懂近义:两者的短板正好相反。 把它俩合起来,就是 Part 2 的主角:混合检索。
这一节你掌握了什么?
- ✅ 亲手实现了 BM25,理解它"命中越多、词越稀有,分越高"
- ✅ 看到了 BM25 的强项(精确命中术语)和硬伤(不懂近义词)
- ✅ 想通了为什么需要把"关键词 + 向量"两条腿合起来
🧭 你刚才做的,等于真实系统的什么?
- 你写的
bm25()= 生产里 Elasticsearch / OpenSearch 的打分内核,公式一模一样,只是真实系统按词切、还建了倒排索引来加速;- 你这个"对每篇算分再排序" = 检索召回模块里稀疏检索那条腿;
- 把它和第 4 章的向量检索(稠密那条腿)并起来,再融合排序,就是 Part 2 要讲的混合检索,也就是真实 RAG 系统在线召回的主干。 你已经手搓出了"两条腿"里的一条,下面把另一条接上。
🎯 Part 2 · 面试深度 | ~40% 这部分讲透 BM25 为什么强、混合检索怎么搭、两套分数怎么融合。
2.1 为什么单一检索不够:两类查询
真实用户的问题分两类:精确型("等待期是多久":靠字面命中,BM25 强)和语义型("孩子摔伤能赔吗":靠理解意思,向量强)。一个系统两类都要接,单用任何一种都会漏。所以生产里几乎都用混合检索:两路一起召回。
2.2 BM25 凭什么比 TF-IDF 强
你可能听过更老的 TF-IDF。BM25 在它基础上加了两个关键修正:
- ① 稀有词更值钱(IDF):"的""是"到处都有不值钱,"核辐射"很少见、命中它几乎锁定答案。(这点 TF-IDF 也有)
- ② 词频饱和:一个词出现 10 次,不代表就比出现 5 次相关 2 倍:BM25 让得分随词频增长越来越慢(饱和),TF-IDF 则是线性增长,容易被"刷词"骗到。
- ③ 文档长度归一:长文档天然含更多词,不该因为"长"就占便宜:BM25 按长度做惩罚,让长短文档公平比较。
"词频饱和"这一条用一张图最直观:
业内行话:面试被问"BM25 和 TF-IDF 区别",别只说"BM25 更好"。说到点子上的是这两句:① 词频饱和(避免高频词刷分),② 文档长度归一(避免长文档占便宜)。这两个修正就是 BM25 二十年不过时的原因。代码里那两个参数你最好也能讲:
k1控饱和速度(越大越接近线性)、b控长度归一强度(b=0完全不归一,b=1完全归一),工程默认k1≈1.2~2.0、b≈0.75。
2.3 两套分数怎么融合
向量和 BM25 各召回一批,但两套分数量纲完全不同(余弦相似度在 0~1,BM25 分可能是 4.51 这种):不能直接相加:
标准做法:
- 分数归一化:先把两套分各自拉到同一区间(如 0~1);
- 加权融合 + 去重:按权重合并,重复的合并。
最常用、最省心的融合法是 RRF(Reciprocal Rank Fusion,倒数排名融合),它干脆不比分数,只比排名:
每个文档在每个排序里贡献 1/(k + 排名)(k 常取 60),两边的"排名分"加起来,两条腿都靠前的文档自然冒头。好处是完全绕开了"分数量纲不同"的麻烦,工程上又简单又稳。
2.4 动态权重:不同查询信任不同的腿
固定权重(比如各 0.5)能用,但更聪明的做法是动态权重:
精确型查询("等待期")多信 BM25,语义型查询("孩子摔伤")多信向量。怎么判断查询类型?用第 7 章的意图识别给查询分个类,再据此调权重。
业内行话:别一上来就追求动态权重。先用固定权重 + RRF 把混合检索跑通,再拿你的 QA 评估集(第 3 章那套)看哪类查询拉胯,才有依据去调成动态。具体权重取多少,永远是实测出来的,不是拍脑袋定的。
2.5 高效召回的三个旋钮
混合检索搭好后,"召回又快又全"还落在几个工程旋钮上。面试问"如何高效召回、有哪些影响因素",答这三个最稳:
- 候选池 N(每条腿先召回多少):这是召回率的上限:正确 chunk 没进候选池,后面重排再准也救不回来。所以 N 先调大保召回,精度交给后面的重排(第 6 章)收。
- top-k(最终给下游几条):太小会漏,太大把噪音和无关内容一起喂给 LLM,既增加成本又干扰生成。常用 top-3 ~ top-10,具体值用评估集找平衡点。
- 元数据过滤(先缩范围再检索):用第 3 章给 chunk 打的标签(
section_path/ 产品 / 时间)先把范围圈小,再做向量/BM25:既提精度又降延迟,是被低估的一招。
讲师私货:很多人把"召回不行"全甩锅给 Embedding 模型,然后一头扎进微调。其实先把候选池 N 调大、加上元数据过滤,往往比微调见效快得多。记住那条铁律:召回率是天花板,排序只能在天花板下面腾挪。
🏆 Part 3 · 验收串题 | ~10% 学到这一步,做几道题验证一下,知道自己学到位没。
关联面试题(5 道,覆盖 5 个角度)
- 【BM25 原理】 阐述 BM25 的原理,以及它相较 TF-IDF 的改进(词频饱和、长度归一)。
- 【为什么混合检索】 解释混合检索的设计动机,以及 Sparse / Dense 各自的作用。
- 【稀疏 vs 稠密 + 融合】 稀疏检索与稠密检索的区别,以及为什么要结合、怎么结合。
- 【方法对比】 BM25 / 向量检索 / 混合检索的对比与各自适用场景。
- 【高效召回】 如何基于用户查询从向量库中高效召回相关文本块?影响因素有哪些?
学完这一章你应该能干嘛
跟着做(Part 1):
- 能用纯 Python 写出 BM25,讲清它"命中越多、词越稀有,分越高"
- 能演示 BM25 精确命中术语,也能说清它不懂近义词
讲深度(Part 2):
- 能讲清为什么要混合检索(两类查询、两套短板互补)
- 能说出 BM25 相比 TF-IDF 的两个关键改进,以及
k1/b各管什么 - 能讲清 RRF 融合的思路(不比分数比排名),以及为什么先固定权重再动态
- 能说出高效召回的三个旋钮,并解释"召回率是天花板"
4 项以下 → 回去 Part 1 重做;4–5 项 → Part 2 再看一遍;6 项以上 → 进入下一章。
完整代码 + 数据集
本书每章的完整可运行代码 + 测试样本,都在配套 GitHub 仓库:
🔗 github.com/MisterBooo/rag-from-zero
- 跟着教程 clone 下来就能跑
- 本章的纯 Python BM25(零依赖)也在里面
- 欢迎 Star ⭐ / Issue 反馈
导航