时长 7 小时:§1 BM25(1.5h)· §2 稀疏与稠密:互补与融合(1h)· §3 检索指标(1h)· §4 两阶段:召回与重排(1h)· §5 查询端与文档端的增强(1h)· §6 在讲义上逐项升级(0.5h)· §A–§C(1h)
对应正课 Lecture 7 的 RAG 后半部分:候选检索的第二、三种方法(BM25 关键词检索、语义 + BM25 的混合)、扩展(HyDE 缓解”嵌入性质不一致”、Anthropic 的上下文检索)、第二步排序(交叉编码器重排)、检索的量化指标(Precision@k、Recall@k、RR@k、NDCG@k)。学习计划点名要会的:“稀疏 vs 稠密 vs 混合 + RRF""查询改写 / 多查询、HyDE、重排、父子块、上下文压缩、Self-RAG、GraphRAG""为什么 cross-encoder 重排不能直接用来做第一层检索”。
配套代码
w4day2_checks.py(纯 numpy):BM25 手算与三种 IDF、词频饱和与长度归一化、稀疏与稠密的互补(机制模拟)、四种分数融合、检索指标手算、两阶段检索的候选数、HyDE(机制模拟)、上下文检索§B 参考实现(
code/目录):w4d2_hybrid_rerank.py(倒排索引 BM25、RRF / min-max / z-score 融合、完整检索指标、父子块扩展、交叉编码器重排、LLM 查询改写与 HyDE);w4_llm.py(本周共用的 LLM 后端:模拟 / transformers / OpenAI 兼容服务)。今天的目标:能从概率模型推出 BM25 的 IDF,说清 、 在做什么;能解释稀疏和稠密各自在哪赢、为什么融合要用 RRF;能手算 P@k、R@k、MRR、AP、NDCG,并知道 RAG 该看哪个;能说清两阶段检索的分工和候选数怎么选;能说出 HyDE、上下文检索、父子块各自修的是哪一类故障;在讲义上用数字判断每项升级到底有没有用。
§0 今天的主线
Day 1 结尾留下了两个问题:稠密检索对专有名词、编号、代码不敏感(Day 1 §3.5);检索结果对问法非常敏感。Lecture 7 的回答是一套组合拳:
┌─ BM25(关键词)──┐
问题 ──(改写 / HyDE)──┤ ├── 融合(RRF)── 前 50 个候选 ── 交叉编码器重排 ── 前 5 块 ──> 提示
└─ 稠密(语义)────┘ ↑ ↑
第一阶段:追求召回 第二阶段:追求精确
文档端:切块时补上下文(上下文检索、面包屑);检索小块、阅读大块(父子块)
今天逐个讲清这些部件:它们各自修的是哪一类故障、代价是什么;然后在讲义上一项一项地加上去,看数字。今天最重要的一课可能是 §6:不是每一项”公认有效”的升级,在你的数据上都有效。
今天要用到的旧结论:TF-IDF 与 SVD(Week 0 Day 3 §2.2–2.3);InfoNCE 与双塔(Day 1 §3.3);hit@k 与”检索是答对的上限”(Day 1 §1.4);交叉编码器 vs 双塔(Week 1 Day 6 §4.6);迷失在中间(Week 2 Day 3 §3.2);Bradley-Terry 与逻辑回归(Week 3 Day 2 §2.2,重排器的成对训练会用到)。
§1 BM25:把关键词检索做对
1.1 从 TF-IDF 出发:三个问题
最朴素的关键词检索:查询词在文档里出现得越多,文档越相关;越稀有的词越重要。于是 TF-IDF:
它有三个问题,BM25(Robertson et al.,1990 年代的 Okapi 系统)逐个回答:
- idf 为什么是 这个形状? 有没有一个原理推出它?(§1.2)
- 词频为什么是线性的? 一个词出现 40 次,真的比出现 1 次相关 40 倍吗?(§1.3)
- 长文档天然词频高,怎么公平地和短文档比?(§1.3)
1.2 IDF 从哪来:Robertson–Spärck Jones 权重
动机:我们想给”查询词 出现在文档里”这件事打一个分:它让”文档相关”的可能性增加了多少?
推导(二元独立模型):设 ,。假设各个词独立,用”相关 vs 不相关”的对数几率给文档排序,展开后每个出现的词贡献一项
没有任何相关性标注时,做两个近似:(对相关文档一无所知,取最无信息的值);(绝大多数文档都不相关,所以”不相关文档里含 的比例”约等于”全体文档里含 的比例”)。代入:
这就是 BM25 的 IDF(RSJ 权重)。直觉:一个词如果几乎所有文档都有(),它出现与否不说明任何问题;如果只有少数文档有,它的出现是强信号。
一个不报错的坑:负 IDF。 当 时上式为负:一个出现在大多数文档里的词,出现反而”扣分”。w4day2_checks.py 第 1 组():
| 词出现在几篇里 | RSJ 原式 | Lucene: | rank_bm25 的兜底 |
|---|---|---|---|
| 1 | 4.195 | 4.210 | 4.195 |
| 50 | 0.000 | 0.693 | 0.000 |
| 60 | −0.401 | 0.512 | −0.013(兜底值) |
| 90 | −2.154 | 0.110 | −0.013(兜底值) |
小语料里(几十篇文档)很多常用词都会超过一半,结果是”越相关分数越低”。Lucene / Elasticsearch 用 保证为正;Python 的 rank_bm25 把负值换成”平均 IDF 的 0.25 倍”(这里平均 IDF 本身是负的,兜底值也是负的)。参考代码用 Lucene 的写法。
1.3 词频饱和与长度归一化
词频饱和。BM25 把线性的 tf 换成
直觉(“精英度”模型,Harter 1975;Robertson & Walker 1994):一个词和一篇文档的关系本质上是二元的,这篇文档”是不是在讲这个词”。词频只是这个隐藏状态的证据:出现第 1 次是很强的证据,第 2 次加一点,到第 10 次时我们已经很确定了,再多几次几乎不增加信息。这个”先快后慢、有上限”的形状,就是 :tf → ∞ 时趋于 。第 2 组(文档长度 = 平均长度):
| tf | 线性 | |||
|---|---|---|---|---|
| 1 | 1.000 | 1.000 | 1.000 | 1 |
| 3 | 1.286 | 1.571 | 1.800 | 3 |
| 10 | 1.429 | 1.964 | 2.500 | 10 |
| 20 | 1.463 | 2.075 | 2.727 | 20 |
越小饱和越快( 时只看”有没有”);越大越接近线性。
长度归一化:同样出现 3 次,出现在一篇 8 倍长的文档里,说明这个词在那里没那么”集中”。 控制惩罚力度: 不管长度, 完全按相对长度缩放。、tf = 3 时,8 倍长的文档得分从 1.571 降到 0.629()。
饱和的实际意义:抵抗关键词堆砌。第 2 组:查询”KV Cache 显存”,文档 A 是正常的一段(三个词各出现一次),文档 B 把”Cache”重复 40 遍。线性 tf × idf:A = 14.21,B = 175.88;BM25:A = 14.24,B = 9.19。覆盖查询里更多不同的词,比把一个词重复很多遍更值钱。这一点在对抗 SEO 垃圾网页的年代至关重要,在 RAG 里同样有用:讲义里的代码块会把 cache 重复十几次(Day 1 §3.5 的例子)。
1.4 完整公式与倒排索引
Lucene / Elasticsearch 的默认值是 、。
为什么它快:倒排索引。对每个词记下”出现在哪些文档里、各几次”(posting list)。打分时只遍历查询词的那几个列表,没出现查询词的文档分数自动是 0,根本不用碰。这就是搜索引擎能在几十亿网页里毫秒级返回的原因。参考代码的 BM25Index 就是一个用字典实现的倒排索引。
BM25 不懂的东西:同义词(“显存”和”GPU 内存”)、改写(问法和原文用词完全不同)、语义上的相关(“为什么训练会发散” vs “loss spike”)。这正是稠密检索擅长的。
§2 稀疏与稠密:互补与融合
2.1 各自在哪赢
Lecture 7 的例子:查询 “Where is Cuddly?”(Cuddly 是一只熊的名字)。语义检索找到了 “he was in a cuddly mood”(意思像),BM25 找到了 “Cuddly spends most days surrounded by books”(字面对上了那个名字)。
w4day2_checks.py 第 3 组把这个论断变成一个可检验的机制模拟:3000 篇文档,150 个小主题 × 20 篇;每篇有 6 个主题词、一个独有的名字、10 个通用词。模拟的稠密编码器”懂同义词”(主题的文档用语和提问用语都映射到主题中心附近),但对罕见的名字只有很弱的表示。两类查询:
- 名字查询:“某篇文档的名字 + 2 个主题词”,只有那 1 篇相关(同主题的另外 19 篇是干扰);
- 改写查询:4 个”提问用语”(从不出现在文档里),这个主题的 20 篇都相关。
| 方法 | 名字 hit@1 | 名字 hit@10 | 改写 hit@1 | 改写 hit@10 |
|---|---|---|---|---|
| BM25 | 0.977 | 0.987 | 0.000 | 0.087 |
| 稠密 | 0.107 | 0.573 | 1.000 | 1.000 |
| RRF 融合 | 0.333 | 0.953 | 0.493 | 1.000 |
读法:两者的强项和弱项正好互补。融合之后 hit@10 两类都高,该找的都进了前 10;但 hit@1 只是折中,排准第 1 名是重排器的活(§4)。这个分工就是”混合检索 + 重排”成为默认架构的原因。
这个模拟的前提:稠密编码器的”同义词知识”来自语料之外(在海量数据上预训练过)。如果稠密那一路只是从同一份语料的词频里算出来的(比如 Day 1 的 LSA),两路的互补性会小得多。§6 会看到这一点。
2.2 分数融合的陷阱
有了两路分数,最直接的想法是加起来。问题是量纲不同:BM25 的分数是 0 到几十,余弦挤在 0.3 附近。第 4 组造了一个更贴近真实的场景:每个查询 1000 个候选、10 篇相关;两路都有分、各自只”看得见”60% 的相关文档(盲区互相独立);第二种情形再加上 0.3% 的”关键词堆砌”文档(不相关,但 BM25 分数奇高)。NDCG@10:
| 融合方式 | 没有离群值 | 有 0.3% 关键词堆砌 |
|---|---|---|
| 只用 A(BM25 式) | 0.563 | 0.335 |
| 只用 B(稠密式) | 0.526 | 0.527 |
| 直接相加 | 0.572 | 0.337 |
| min-max 后加权(B 占 0.5) | 0.627 | 0.407 |
| min-max 后加权(B 占 0.7) | 0.633 | 0.545 |
| z-score 后等权 | 0.625 | 0.360 |
| RRF,k = 10 | 0.683 | 0.592 |
| RRF,k = 60 | 0.553 | 0.532 |
| RRF,k = 600 | 0.510 | 0.497 |
三个结论:
- 直接相加几乎等于只用量纲大的那一路(0.572 vs 0.563):余弦的起伏对 BM25 来说是四舍五入的误差。
- 归一化后加权比单路好,但要调权重,而且怕离群值:min-max 的缩放由最高分决定,一个堆砌关键词的文档就能把其他所有文档的 BM25 分数压扁;z-score 同样被拉坏。
- RRF 最稳:它只看名次,一个离群的高分最多占据一个第 1 名。
2.3 RRF:只看名次
倒数排名融合(Reciprocal Rank Fusion,Cormack et al. 2009):
为什么是这个形状: 让排名靠前的文档贡献大(第 1 名和第 2 名差很多,第 50 名和第 51 名几乎一样),这符合”用户主要看前几名”的直觉; 是平滑常数,防止某一路的第 1 名一手遮天。它不用分数,只用名次,所以天然不怕量纲不同和离群值,也不用训练任何权重。
真的不重要吗? 常见的说法是”RRF 对 不敏感”。第 4 组的数据说:不一定。 是 0.683, 是 0.553。原因可以算出来:一篇相关文档只被一路看见(那一路排第 1,另一路排第 500),它的分数是 ;一篇不相关、但两路都排第 30 的”共识”文档,分数是 。
- :,共识文档赢;
- :,独家发现赢。
两路盲区不同时,小 让”某一路的独家发现”能进前 10;大 偏向两路都说得过去的文档。 是 Cormack 等人在 TREC 数据上调出来的默认值,换了数据应该在自己的评测上扫一下(§6:在讲义上, 从 1 到 300,黄金集 hit@5 全是 0.800)。
§3 检索指标
3.1 定义
Lecture 7 列了四个;再加上两个常用的。设系统返回的排序中,第 名的相关度是 (二值时 0/1),库里一共有 篇相关文档:
| 指标 | 公式 | 回答的问题 |
|---|---|---|
| Precision@k | 前 名中相关的比例 | 送进去的东西里,有多少是有用的 |
| Recall@k | 前 名中相关的数量 ÷ | 有用的东西,送进去了几成 |
| hit@k | 前 名里有没有相关的(0/1) | 至少送进去了一个吗(Day 1 用的就是它,RAG 文献里常被叫作 recall@k) |
| RR@k / MRR | 第一个相关文档排第 名时为 ;对查询平均为 MRR | 第一个有用的排得多靠前 |
| AP / MAP | 在每个相关文档的位置算 P@i,再对 个相关文档平均 | 又全又靠前 |
| NDCG@k | ÷ 理想排序的同一个量 | 分级相关度下的排序质量 |
注意 hit@k 和 Recall@k 的区别:一道题的答案小节被切成 4 块,前 5 名里有 1 块,hit@5 = 1,Recall@5 = 0.25。对 RAG 来说,如果回答需要整节的信息,Recall 更诚实。
3.2 手算一遍,以及 NDCG 的折扣从哪来
第 5 组:库里 4 篇相关文档(相关度 3、3、1、1)。两个系统的前 10 名:
系统 A:[3, 0, 3, 0, 1, 0, 0, 0, 0, 0]
系统 B:[0, 1, 1, 0, 3, 3, 0, 0, 0, 0]
| P@5 | R@5 | hit@5 | RR | AP | NDCG@5 | NDCG@10 | |
|---|---|---|---|---|---|---|---|
| A | 0.600 | 0.750 | 1 | 1.000 | 0.567 | 0.882 | 0.882 |
| B | 0.600 | 0.750 | 1 | 0.500 | 0.608 | 0.311 | 0.513 |
前 5 名都有 3 篇相关文档,P@5、R@5、hit@5 完全一样,看不出差别。A 把最重要的两篇放在第 1、3 名,B 放在第 5、6 名:RR 和 NDCG 都说 A 好得多。AP 反而说 B 略好(0.608 vs 0.567):AP 把所有相关文档一视同仁,B 在前 6 名里找全了 4 篇,而 A 的第 4 篇相关文档没进前 10。不同的指标在问不同的问题,这就是为什么要知道每个指标的定义,而不是只看一个数。
NDCG 的三个设计:
- 增益 :相关度 3 的增益是 7,相关度 1 的是 1。“完全相关”比”部分相关”重要得多。
- 折扣 :第 1 名 1.000、第 2 名 0.631、第 5 名 0.387、第 10 名 0.289。它模拟一个”越往下越不太会看,但还是可能看”的用户。对数折扣衰减得比 (RR 的隐含折扣)慢,所以 NDCG 比 MRR 更在乎前 10 名整体。
- 除以 IDCG:理想排序的 DCG。不同查询的相关文档数不同,不归一化就没法平均。
3.3 RAG 该看哪个
- LLM 会读完送进去的全部 块:它不像人那样”只看前两条”。所以在”前 名内部的顺序”上,RAG 不像网页搜索那么敏感,Recall@k(或 hit@k)是最直接的指标:答案进没进上下文。
- 但顺序不是完全无关:迷失在中间(Week 2 Day 3 §3.2)说明模型对开头和结尾用得更好;而且 往往受预算限制,排得靠前才能挤进去。所以同时看一个排序指标(MRR 或 NDCG)。
- 给用户展示结果列表(搜索框):NDCG、MRR。
- 只有一个正确答案(FAQ 匹配):MRR 或 hit@1。
参考代码的 evaluate_full 同时报告 hit@1、hit@5、R@5、R@20、MRR@10、NDCG@10、MAP。黄金集的相关度是分级的:主要答案小节的块记 2,次要小节记 1。
§4 两阶段:召回与重排
4.1 交叉编码器重排
做法(monoBERT,Nogueira & Cho 2019):把 [CLS] 查询 [SEP] 候选块 [SEP] 送进一个 BERT 类编码器,从 [CLS] 输出一个相关性分数。查询和文档的每个 token 都能互相注意,所以它能判断”这段话是不是真的回答了这个问题”,而不只是”主题像不像”。
训练:和奖励模型很像(Week 3 Day 2 §2.3:主干 + 标量头)。可以逐点训练(相关 / 不相关的二分类),也可以成对训练(相关的分数要比不相关的高,损失就是 Bradley-Terry 的 ),或者列表式(一个正例和多个负例做 softmax,就是 Day 1 §3.3 的 InfoNCE)。
为什么它不能当第一阶段(学习计划的自测题):分数 不能分解成”只依赖 “和”只依赖 “的两部分(Day 1 §3.1),没法预先算好存起来。每个查询都要对库里的每个块跑一次完整前向:百万块就是百万次前向。所以它只能给第一阶段送来的几十到几百个候选打分。
4.2 召回多少个候选再重排
第 6 组:10 万篇文档,每个查询 10 篇相关。第一阶段粗(相关文档的分数与噪声重叠不少);第二阶段准(噪声小得多),但每个候选都要一次交叉编码器调用。
| 候选数 K | 第一阶段 recall@K | 重排后 NDCG@10 | 交叉编码器调用 |
|---|---|---|---|
| 10 | 0.657 | 0.886 | 10 |
| 20 | 0.738 | 0.925 | 20 |
| 50 | 0.817 | 0.952 | 50 |
| 100 | 0.859 | 0.962 | 100 |
| 500 | 0.938 | 0.973 | 500 |
| 1000 | 0.958 | 0.974 | 1000 |
(只用第一阶段:NDCG@10 = 0.832。)
两条规律:重排后的上限被第一阶段的 recall@K 卡住(没召回的,重排器看不到);K 到 50–100 之后收益递减,成本却线性增长。这就是 Lecture 7 的分工:第一阶段最大化召回,第二阶段最大化精确。 实践中 K 常取 20–100,由延迟预算决定:一个 base 大小的交叉编码器在 GPU 上给 100 个几百 token 的候选打分,通常是几十到几百毫秒。
4.3 ColBERT:晚交互
双塔太粗(整段压成一个向量),交叉编码器太贵(不能预计算)。ColBERT(Khattab & Zaharia 2020)走中间:查询和文档分别编码,但保留每个 token 的向量;打分时每个查询 token 找文档里最像它的 token,再求和:
文档的 token 向量可以预计算(能建索引),又保留了 token 级的匹配(比单向量准)。代价是存储:每个 token 一个向量,比单向量大几十到上百倍,ColBERTv2 用残差压缩把它降下来。
4.4 用 LLM 重排
也可以直接让 LLM 判断相关性:逐个问”这段话能回答这个问题吗”(逐点),或者把 20 个候选一起给它、让它输出排序(列表式,RankGPT,Sun et al. 2023,用滑动窗口处理更多候选)。效果常常很好,但贵、慢,而且 LLM 有自己的偏差:列表式排序会受候选的摆放顺序影响,这和 Day 6 要讲的评委”位置偏差”是同一件事。
§5 查询端与文档端的增强
§1–§4 解决的是”怎么打分”。还有一类故障来自输入本身:问题太短、太模糊、和文档的文体不一样;块里缺主语、缺上下文。
5.1 查询改写与多查询
问题:检索对问法非常敏感。Day 1 §3.5 的例子:“KV Cache 的显存怎么算”让正确的小节排在第 5–7 名,换成”怎么快速估算 KV Cache 占多少显存?“就排第 1。用户不会替你想最好的问法。
做法:
- 改写:让 LLM 把口语化、有错别字、带指代(“那它呢?“要结合对话历史)的问题改写成一个清楚的搜索查询。
- 多查询(RAG-Fusion 等):让 LLM 生成几种不同的问法,各检索一次,用 RRF 合并。覆盖了用户没想到的说法,代价是 LLM 调用和检索次数翻几倍。
- 分解:学习计划的自测题”对比 A 和 B 的第三季度数据”,朴素 RAG 为什么大概率失败?因为答案在两处,一个查询向量要同时靠近”A 的 Q3”和”B 的 Q3”,结果两边都不够近,前 名可能全是 A 或全是 B。把它分解成两个子查询(“A 的第三季度数据""B 的第三季度数据”),分别检索、合并上下文。多跳问题(“LoRA 论文的第一作者还提出了什么方法?“)要迭代地检索:先找到作者,再用作者名去搜。
参考代码:llm_rewrites(多查询);--hyde 会同时评测多查询。
5.2 HyDE:用假设的答案去检索
动机(Lecture 7:“缓解嵌入性质的不一致”):问题和答案长得不一样。问题短、是疑问句;文档长、是陈述句。嵌入模型会把这种文体差异也编进向量里,于是问题的向量可能更靠近其他问题(FAQ、论坛帖子),而不是真正的答案。
做法(HyDE,Gao et al. 2022):先让 LLM 不看任何资料写一段”假设的答案”,用这段文字(而不是问题)去检索。假设答案里的事实可能是错的,没关系:我们要的只是它的文体和用词像一篇真正的答案,好让它落在答案所在的那片向量空间里。论文的做法是采几个假设答案,把它们的向量和原问题的向量平均。
第 7 组的机制模拟:库里 5000 篇答案 + 500 个”提问文体”的 FAQ 片段(主题都不对,但文体和问题一样)。hit@10:
| 跑题概率 π = 0 | π = 0.2 | π = 0.5 | |
|---|---|---|---|
| 直接用问题的向量 | 0.369 | — | — |
| HyDE(1 个假设) | 1.000 | 0.791 | 0.501 |
| HyDE(4 个假设 + 原查询,平均) | 1.000 | 0.996 | 0.935 |
收益:问题的向量被同文体的无关片段吸走(0.369);假设答案和真答案文体相同,一下就找到了。风险:LLM 对这个领域一无所知时,假设答案会跑题,把检索带到错误的主题上(π = 0.5 时降到 0.501)。缓解:多采几个假设再和原查询平均:跑题的假设彼此方向不同,平均之后被削弱。
什么时候别用:领域很偏(LLM 编不出像样的假设);专有名词查询(LLM 会编一个错的名字);延迟敏感(多一次生成)。
5.3 上下文检索:给块补上主语
问题:切出来的块常常缺少上下文。“本公司第二季度营收增长 3%“:哪家公司?哪一年?这样的块,BM25 和嵌入都没法把它和”X 公司 2023 年 Q2 营收”对上。
第 8 组:30 家公司结构相同的年报,每家切 8 块,只有第 1 块写了公司名。查询”某公司 + 研发”:
| hit@1 | hit@5 | 第 1 名是”这家公司的第 1 块”的比例 | |
|---|---|---|---|
| 原始块 | 0.000 | 0.123 | 1.000 |
| 加上下文的块 | 1.000 | 1.000 | 0.000 |
原始块里,公司名的 IDF 很高,BM25 总是把这家公司唯一写了名字的第 1 块(讲营收的)排第一;真正讲研发的块和另外 29 家的一模一样,只能在它们中间随机排。
Anthropic 的上下文检索(2024):对每一块,把整篇文档和这一块一起交给 LLM,让它写一句 50–100 token 的”这一块在全文中讲什么”,拼在块的前面,再做嵌入和 BM25 索引。Lecture 7 展示的提示大意是:“这是整篇文档……这是我们想在全文中定位的那一块……请给出一句简短的上下文,用于改善这一块的检索,只回答这句上下文。“他们报告的效果(top-20 检索失败率,基线 5.7%):
| 做法 | 失败率 | 相对降低 |
|---|---|---|
| 上下文嵌入 | 3.7% | 35% |
| 上下文嵌入 + 上下文 BM25 | 2.9% | 49% |
| 再加重排 | 1.9% | 67% |
成本:每一块都要把整篇文档送一次 LLM。提示缓存让它变得可行:同一篇文档的所有块共用”整篇文档”这个前缀,缓存之后只有块本身是新 token。他们估算一次性成本约为每百万文档 token 1.02 美元。
最便宜的版本就是 Day 1 的面包屑:不调 LLM,直接把”文档名 > 章节名”拼在块前面。Day 1 §3.4:它让 LSA 的 hit@5 从 0.54 升到 0.82。结构化的文档(讲义、手册、法规)有现成的标题层级,面包屑就够好;没有结构的文档(聊天记录、扫描件),才值得花钱让 LLM 写上下文。
5.4 父子块:检索小块,阅读大块
矛盾:Day 1 §2.1 说小块检索更准(嵌入更专一);但回答需要上下文,小块不够读。
做法(small-to-big;LangChain 的 ParentDocumentRetriever、LlamaIndex 的 sentence window):用小块建索引、做检索;命中之后,把它的”父亲”(所在的段落、小节,或者前后各几句)送进上下文。参考代码的 expand_to_sections:命中哪一块,就把整个小节按原文顺序放进去。
代价:同样的上下文预算,整节会挤掉其他候选。§6 会看到这个权衡。
5.5 其他值得知道的方向
- 上下文压缩:检索到的块里只有一两句有用,让一个小模型把无关的句子删掉再送进去,省 token、减少干扰。
- Self-RAG(Asai et al. 2023):训练模型自己判断”要不要检索""检索到的这段相关吗""我的回答被它支持吗”,在生成过程中输出这些特殊的反思 token。它把”检索”从固定的前处理变成了模型的决策,这正是 Day 4 智能体的思路。
- GraphRAG(Microsoft,Edge et al. 2024):先用 LLM 从语料里抽取实体和关系建成图,再对图做社区划分、为每个社区写摘要。擅长”全局性”问题(“这批文档的主要主题是什么?”),这类问题的答案不在任何一个块里,普通 RAG 无能为力。代价是建图非常贵。
- 迭代检索:检索 → 生成一部分 → 发现还缺什么 → 再检索。多跳问题需要它(§5.1),它也是智能体的一个特例(Day 4)。
§6 在讲义上逐项升级
学习计划的要求:“逐项升级并测量每一项的增益……每次改动都要有数字支撑。” w4d2_hybrid_rerank.py --eval(1465 块;黄金集 50 题,分级相关度;标题查询 404 条):
| 方法 | hit@1 | hit@5 | R@5 | R@20 | MRR@10 | NDCG@10 | MAP |
|---|---|---|---|---|---|---|---|
| BM25(无面包屑) | 0.280 | 0.540 | 0.295 | 0.484 | 0.391 | 0.296 | 0.236 |
| LSA(无面包屑) | 0.220 | 0.540 | 0.313 | 0.498 | 0.361 | 0.307 | 0.250 |
| BM25 + 面包屑 | 0.460 | 0.840 | 0.516 | 0.761 | 0.609 | 0.545 | 0.461 |
| LSA + 面包屑 | 0.580 | 0.820 | 0.550 | 0.794 | 0.681 | 0.639 | 0.572 |
| 混合 RRF k=60(都带面包屑) | 0.520 | 0.800 | 0.533 | 0.788 | 0.649 | 0.599 | 0.523 |
| 混合 RRF k=10 | 0.520 | 0.800 | 0.531 | 0.793 | 0.652 | 0.598 | 0.528 |
| 混合 min-max 等权 | 0.560 | 0.800 | 0.529 | 0.795 | 0.675 | 0.607 | 0.540 |
| 混合 z-score 等权 | 0.560 | 0.800 | 0.529 | 0.784 | 0.676 | 0.607 | 0.538 |
标题查询上(404 条,误差更小),hit@5:BM25 0.453、LSA 0.433、RRF k=60 0.436、RRF k=10 0.436。RRF 的 从 1 扫到 300(--rrf-sweep),黄金集 hit@5 全是 0.800,MRR 在 0.649–0.666 之间。
怎么读这张表:
- 面包屑仍然是最大的一项:BM25 和 LSA 都从 0.54 升到 0.82–0.84。
- 混合检索在这里没有带来收益:所有混合方式都在 0.80 左右,和单路分不开(50 道题的标准误约 0.06,Day 5 §3.1)。这和 §2 的结论矛盾吗?不矛盾。§2.1 已经说了前提:稠密那一路要有”语料之外”的知识。LSA 是从同一份 TF-IDF 矩阵里算出来的,它知道的”同义”只来自这份语料的共现,和 BM25 高度相关:两路犯的是同样的错,融合就没什么可补的。
- 这正是今天最重要的一课:一个”公认有效”的升级,在你的数据、你的组件组合上不一定有效。不测,你就会把它当成有效的写进报告。
请你补上的实验:装上 sentence-transformers 后跑 --eval --dense st。一个在海量真实问答对上训练过的嵌入模型,和 BM25 的互补性应该大得多:它能处理改写,BM25 能处理术语。按 §2 的机制,混合检索这时才应该显出收益。用你跑出来的数字来判断,而不是用这段话。 同样地,--rerank 需要 sentence-transformers,--hyde 需要一个 LLM 后端。
父子块的权衡(--expand,混合 RRF,同样的字符预算):
| 预算(字符) | 方式 | 命中 | 答案覆盖率 |
|---|---|---|---|
| 700 | 按块 | 0.640 | 0.308 |
| 700 | 整节 | 0.640 | 0.385 |
| 1400 | 按块 | 0.800 | 0.561 |
| 1400 | 整节 | 0.700 | 0.629 |
| 2800 | 按块 | 0.900 | 0.718 |
| 2800 | 整节 | 0.840 | 0.813 |
“命中”是主要答案小节至少有一块进了上下文;“答案覆盖率”是主要答案小节的块有几成进了上下文。整节放入让找到的那一节更完整(覆盖率 +7 到 +10 个百分点),代价是能放进去的小节变少了(命中 −6 到 −10 个百分点)。 哪个更重要取决于问题:答案集中在一两句话里,按块更好;答案需要读完整节(推导、步骤),整节更好。Day 6 的端到端评测才能最终回答”哪个让答案更对”。
§A 检索工具:API 用法与边界
A.1 rank_bm25
from rank_bm25 import BM25Okapi
corpus_tokens = [tokenize(t) for t in texts] # ★ 必须传"分好词的列表的列表"
bm25 = BM25Okapi(corpus_tokens, k1=1.5, b=0.75)
scores = bm25.get_scores(tokenize(query)) # 查询也要用同一个分词函数
- 传字符串会被当成字符序列:
BM25Okapi(["注意力 缩放", …])不报错,但每个文档被拆成单个字符(包括空格)。 - 中文要自己分词:jieba 之类的词级分词,或者参考代码的”单字 + 双字”。查询和文档必须用同一个分词函数。
- IDF 的兜底(§1.2):
BM25Okapi把负 IDF 换成epsilon × 平均 IDF(默认 epsilon = 0.25),平均 IDF 本身可能是负的。小语料里要留意;BM25Plus、BM25L是另外两个变体。 - 默认 ,Lucene / Elasticsearch 默认 。换库时结果对不上,先查参数。
A.2 Elasticsearch / OpenSearch 与向量库的混合检索
- ES 的
match查询默认就是 BM25(k1=1.2,b=0.75,可在索引的similarity设置里改)。中文要配分词插件(如 IK),否则按单字切。 - 大多数向量库(Qdrant、Weaviate、Milvus、ES 8.x 等)都内置了”稀疏 + 稠密”的混合检索,融合方式通常可选 RRF 或加权。看清默认用的是哪种、RRF 的 k 是多少、加权前有没有归一化(§2.2 的坑)。
A.3 交叉编码器
from sentence_transformers import CrossEncoder
ce = CrossEncoder("BAAI/bge-reranker-base") # 支持中文
scores = ce.predict([(query, t) for t in candidate_texts], batch_size=16)
order = np.argsort(-scores)
- 输入是 (查询, 文档) 对的列表,不是两个列表。顺序要和训练时一致(先查询后文档)。
- 分数的尺度因模型而异:有的输出 logit,有的过了 sigmoid。只能用来排序,不要跨模型比较分数,也不要随便设阈值。
- 最大长度:候选太长会被截断,截掉的部分等于没被看到。
- 只对第一阶段的前 K 个打分(§4.2);K 和延迟一起调。
A.4 自己写评测时的坑
- 相关性标注要到块级别:参考代码用”块属于答案小节”来判断,块切法变了标注自动跟着变;如果你的标注是”第 37 号块”,换了切法就全错了。
- 平分:BM25 对不相关的查询全是 0 分,
argsort会返回下标最小的文档。结果看起来是”确定的”,其实没有意义。 - 不要在测试集上调参:RRF 的 k、融合权重、块大小都在黄金集上调,报告的数字就偏乐观了(Day 5 §3.3 的”胜者的诅咒”)。严格的做法是分出一个调参集。
§B 实操
- 跑通
w4day2_checks.py,8 组结论都看懂。第 4 组把”盲区”的比例改一改(比如一路看得见 90%、另一路 30%),看 RRF 的最佳 怎么变。 - 推导(1h,纸笔):(a) 从二元独立模型推出 RSJ 权重;(b) BM25 的词频项在 tf → ∞ 时的极限,以及 、 两个特例;(c) 手算第 5 组两个系统的 AP 和 NDCG@5;(d) 解释 RRF 的 为什么影响”独家发现 vs 共识”。
- 在讲义上复现 §6(1h):
python code/w4d2_hybrid_rerank.py --eval、--rrf-sweep、--expand,再用--query对比三种检索在你自己的问题上的表现。 - 补上真正的稠密检索与重排(1.5h):装 sentence-transformers,跑
--eval --dense st和--rerank。混合检索这时有没有收益?重排前 20 个和前 50 个差多少?把结果补进 §6 的表。 - HyDE 与多查询(1h,需要一个 LLM):用 Ollama 或 vLLM 起一个小模型,跑
--hyde --backend openai --model …。看看小模型写的假设答案有多少是跑题的,它们对检索是帮忙还是添乱。 - 读论文:Robertson & Zaragoza(2009)“The Probabilistic Relevance Framework: BM25 and Beyond”的第 2–3 节;HyDE(Gao et al. 2022);Anthropic 的”Introducing Contextual Retrieval”。
参考实现:code/w4d2_hybrid_rerank.py、code/w4_llm.py。
§C 自测
动机与直觉层
- BM25 在 TF-IDF 的基础上改了哪三件事?各自的理由是什么?
- 稀疏检索和稠密检索各自在什么查询上赢?举两个例子。
- 为什么交叉编码器不能用来做第一阶段检索?两阶段检索里两个阶段各自优化什么?
- HyDE 为什么有效?它的风险是什么?什么时候不该用?
- 用户问”对比 A 和 B 的第三季度数据”,朴素 RAG 为什么大概率失败?怎么改?
推导与计算层
- 从”相关 vs 不相关的对数几率”推出 BM25 的 IDF。什么时候它会变成负数?
- 写出 BM25 的词频项,求它在 tf → ∞ 时的极限。 意味着什么?
- 手算一个排序的 P@5、R@5、RR、AP、NDCG@5(自己出一个例子)。什么情况下 AP 和 NDCG 会给出相反的判断?
- 写出 RRF 的公式。算一算:一篇只被一路排第 1、另一路排第 500 的文档,和一篇两路都排第 30 的文档,在 和 时谁的分数高?
工程层
- 为什么不能把 BM25 分数和余弦直接相加?有哪几种修法,各自的缺点是什么?
- 你的 RAG 系统加上混合检索之后指标没有变化,可能是什么原因?你会做什么实验来确认?
- 上下文检索要为每一块调用一次 LLM,怎么把成本降下来?什么样的语料用面包屑就够了?
- 用 rank_bm25 时有哪两个不报错但结果错的坑?
出关标准
- 能从二元独立模型推出 BM25 的 IDF,说清 、 的作用和”饱和”的直觉
- 能用例子说明稀疏和稠密的互补,并解释 RRF 为什么比分数相加稳、 在什么情况下重要
- 能手算 P@k、R@k、MRR、AP、NDCG,说出 RAG 该看哪个、为什么
- 能说清两阶段检索的分工、候选数的权衡,以及交叉编码器为什么不能做第一阶段
- 能说出查询改写、HyDE、上下文检索、父子块各修的是哪一类故障
- 在讲义上跑通
--eval,能解释”混合检索为什么在这里没有收益”