第 4 周 · Day 2:稀疏检索、混合检索与重排

Transformers and Large Language Models · Week 4 · Day 2。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

个人整理的学习笔记,非官方讲义;课程来源与整理说明见课程介绍。配套示例未逐一运行验证。

本篇目录

时长 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,说清 k1k_1、bb 在做什么;能解释稀疏和稠密各自在哪赢、为什么融合要用 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:

score(q,d)=∑t∈qtf(t,d)⋅idf(t).\text{score}(q,d)=\sum_{t\in q}\text{tf}(t,d)\cdot\text{idf}(t).

它有三个问题,BM25(Robertson et al.,1990 年代的 Okapi 系统)逐个回答:

  1. idf 为什么是 log⁡(N/n)\log(N/n) 这个形状? 有没有一个原理推出它?(§1.2)
  2. 词频为什么是线性的? 一个词出现 40 次,真的比出现 1 次相关 40 倍吗?(§1.3)
  3. 长文档天然词频高,怎么公平地和短文档比?(§1.3)

1.2 IDF 从哪来:Robertson–Spärck Jones 权重

动机:我们想给”查询词 tt 出现在文档里”这件事打一个分:它让”文档相关”的可能性增加了多少?

推导(二元独立模型):设 p=P(t∈d∣相关)p=P(t\in d\mid\text{相关}),q=P(t∈d∣不相关)q=P(t\in d\mid\text{不相关})。假设各个词独立,用”相关 vs 不相关”的对数几率给文档排序,展开后每个出现的词贡献一项

wt=log⁡p(1−q)q(1−p).w_t=\log\frac{p(1-q)}{q(1-p)}.

没有任何相关性标注时,做两个近似:p=0.5p=0.5(对相关文档一无所知,取最无信息的值);q≈nt/Nq\approx n_t/N(绝大多数文档都不相关,所以”不相关文档里含 tt 的比例”约等于”全体文档里含 tt 的比例”)。代入:

wt=log⁡N−ntnt → 加 0.5 平滑  log⁡N−nt+0.5nt+0.5.w_t=\log\frac{N-n_t}{n_t}\ \xrightarrow{\ \text{加 0.5 平滑}\ }\ \log\frac{N-n_t+0.5}{n_t+0.5}.

这就是 BM25 的 IDF(RSJ 权重)。直觉:一个词如果几乎所有文档都有(nt→Nn_t\to N),它出现与否不说明任何问题;如果只有少数文档有,它的出现是强信号。

一个不报错的坑:负 IDF。 当 nt>N/2n_t>N/2 时上式为负:一个出现在大多数文档里的词,出现反而”扣分”。w4day2_checks.py 第 1 组(N=100N=100):

词出现在几篇里RSJ 原式Lucene:log⁡(1+⋅)\log(1+\cdot)rank_bm25 的兜底
14.1954.2104.195
500.0000.6930.000
60−0.4010.512−0.013(兜底值)
90−2.1540.110−0.013(兜底值)

小语料里(几十篇文档)很多常用词都会超过一半,结果是”越相关分数越低”。Lucene / Elasticsearch 用 log⁡(1+N−n+0.5n+0.5)\log\big(1+\frac{N-n+0.5}{n+0.5}\big) 保证为正;Python 的 rank_bm25 把负值换成”平均 IDF 的 0.25 倍”(这里平均 IDF 本身是负的,兜底值也是负的)。参考代码用 Lucene 的写法。

1.3 词频饱和与长度归一化

词频饱和。BM25 把线性的 tf 换成

tf⋅(k1+1)tf+k1⋅L,L=1−b+b⋅∣d∣avgdl.\frac{\text{tf}\cdot(k_1+1)}{\text{tf}+k_1\cdot L},\qquad L=1-b+b\cdot\frac{|d|}{\text{avgdl}}.

直觉(“精英度”模型,Harter 1975;Robertson & Walker 1994):一个词和一篇文档的关系本质上是二元的,这篇文档”是不是在讲这个词”。词频只是这个隐藏状态的证据:出现第 1 次是很强的证据,第 2 次加一点,到第 10 次时我们已经很确定了,再多几次几乎不增加信息。这个”先快后慢、有上限”的形状,就是 tf/(tf+k1)\text{tf}/(\text{tf}+k_1):tf → ∞ 时趋于 k1+1k_1+1。第 2 组(文档长度 = 平均长度):

tfk1=0.5k_1=0.5k1=1.2k_1=1.2k1=2.0k_1=2.0线性
11.0001.0001.0001
31.2861.5711.8003
101.4291.9642.50010
201.4632.0752.72720

k1k_1 越小饱和越快(k1=0k_1=0 时只看”有没有”);越大越接近线性。

长度归一化:同样出现 3 次,出现在一篇 8 倍长的文档里,说明这个词在那里没那么”集中”。bb 控制惩罚力度:b=0b=0 不管长度,b=1b=1 完全按相对长度缩放。k1=1.2k_1=1.2、tf = 3 时,8 倍长的文档得分从 1.571 降到 0.629(b=0.75b=0.75)。

饱和的实际意义:抵抗关键词堆砌。第 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 完整公式与倒排索引

BM25(q,d)=∑t∈qidf(t)⋅tf(t,d)⋅(k1+1)tf(t,d)+k1(1−b+b ∣d∣/avgdl),k1∈[1.2,2.0], b=0.75.\text{BM25}(q,d)=\sum_{t\in q}\text{idf}(t)\cdot\frac{\text{tf}(t,d)\cdot(k_1+1)}{\text{tf}(t,d)+k_1\big(1-b+b\,|d|/\text{avgdl}\big)},\qquad k_1\in[1.2,2.0],\ b=0.75.

Lucene / Elasticsearch 的默认值是 k1=1.2k_1=1.2、b=0.75b=0.75。

为什么它快:倒排索引。对每个词记下”出现在哪些文档里、各几次”(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
BM250.9770.9870.0000.087
稠密0.1070.5731.0001.000
RRF 融合0.3330.9530.4931.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.5630.335
只用 B(稠密式)0.5260.527
直接相加0.5720.337
min-max 后加权(B 占 0.5)0.6270.407
min-max 后加权(B 占 0.7)0.6330.545
z-score 后等权0.6250.360
RRF,k = 100.6830.592
RRF,k = 600.5530.532
RRF,k = 6000.5100.497

三个结论:

  1. 直接相加几乎等于只用量纲大的那一路(0.572 vs 0.563):余弦的起伏对 BM25 来说是四舍五入的误差。
  2. 归一化后加权比单路好,但要调权重,而且怕离群值:min-max 的缩放由最高分决定,一个堆砌关键词的文档就能把其他所有文档的 BM25 分数压扁;z-score 同样被拉坏。
  3. RRF 最稳:它只看名次,一个离群的高分最多占据一个第 1 名。

2.3 RRF:只看名次

倒数排名融合(Reciprocal Rank Fusion,Cormack et al. 2009):

RRF(d)=∑每一路 i1k+ranki(d),k=60.\text{RRF}(d)=\sum_{\text{每一路 }i}\frac{1}{k+\text{rank}_i(d)},\qquad k=60.

为什么是这个形状:1/rank1/\text{rank} 让排名靠前的文档贡献大(第 1 名和第 2 名差很多,第 50 名和第 51 名几乎一样),这符合”用户主要看前几名”的直觉;kk 是平滑常数,防止某一路的第 1 名一手遮天。它不用分数,只用名次,所以天然不怕量纲不同和离群值,也不用训练任何权重。

kk 真的不重要吗? 常见的说法是”RRF 对 kk 不敏感”。第 4 组的数据说:不一定。k=10k=10 是 0.683,k=60k=60 是 0.553。原因可以算出来:一篇相关文档只被一路看见(那一路排第 1,另一路排第 500),它的分数是 1k+1+1k+500\frac1{k+1}+\frac1{k+500};一篇不相关、但两路都排第 30 的”共识”文档,分数是 2k+30\frac2{k+30}。

  • k=60k=60:161+1560=0.0182<290=0.0222\frac1{61}+\frac1{560}=0.0182<\frac2{90}=0.0222,共识文档赢;
  • k=10k=10:111+1510=0.093>240=0.050\frac1{11}+\frac1{510}=0.093>\frac2{40}=0.050,独家发现赢。

两路盲区不同时,小 kk 让”某一路的独家发现”能进前 10;大 kk 偏向两路都说得过去的文档。 k=60k=60 是 Cormack 等人在 TREC 数据上调出来的默认值,换了数据应该在自己的评测上扫一下(§6:在讲义上,kk 从 1 到 300,黄金集 hit@5 全是 0.800)。


§3 检索指标

3.1 定义

Lecture 7 列了四个;再加上两个常用的。设系统返回的排序中,第 ii 名的相关度是 reli\text{rel}_i(二值时 0/1),库里一共有 RR 篇相关文档:

指标公式回答的问题
Precision@k前 kk 名中相关的比例送进去的东西里,有多少是有用的
Recall@k前 kk 名中相关的数量 ÷ RR有用的东西,送进去了几成
hit@k前 kk 名里有没有相关的(0/1)至少送进去了一个吗(Day 1 用的就是它,RAG 文献里常被叫作 recall@k)
RR@k / MRR第一个相关文档排第 rr 名时为 1/r1/r;对查询平均为 MRR第一个有用的排得多靠前
AP / MAP在每个相关文档的位置算 P@i,再对 RR 个相关文档平均又全又靠前
NDCG@k∑i≤k2reli−1log⁡2(i+1)\sum_{i\le k}\frac{2^{\text{rel}_i}-1}{\log_2(i+1)} ÷ 理想排序的同一个量分级相关度下的排序质量

注意 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@5R@5hit@5RRAPNDCG@5NDCG@10
A0.6000.75011.0000.5670.8820.882
B0.6000.75010.5000.6080.3110.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 的三个设计:

  • 增益 2rel−12^{\text{rel}}-1:相关度 3 的增益是 7,相关度 1 的是 1。“完全相关”比”部分相关”重要得多。
  • 折扣 1/log⁡2(i+1)1/\log_2(i+1):第 1 名 1.000、第 2 名 0.631、第 5 名 0.387、第 10 名 0.289。它模拟一个”越往下越不太会看,但还是可能看”的用户。对数折扣衰减得比 1/i1/i(RR 的隐含折扣)慢,所以 NDCG 比 MRR 更在乎前 10 名整体。
  • 除以 IDCG:理想排序的 DCG。不同查询的相关文档数不同,不归一化就没法平均。

3.3 RAG 该看哪个

  • LLM 会读完送进去的全部 kk 块:它不像人那样”只看前两条”。所以在”前 kk 名内部的顺序”上,RAG 不像网页搜索那么敏感,Recall@k(或 hit@k)是最直接的指标:答案进没进上下文。
  • 但顺序不是完全无关:迷失在中间(Week 2 Day 3 §3.2)说明模型对开头和结尾用得更好;而且 kk 往往受预算限制,排得靠前才能挤进去。所以同时看一个排序指标(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 的 −log⁡σ(s+−s−)-\log\sigma(s^+-s^-)),或者列表式(一个正例和多个负例做 softmax,就是 Day 1 §3.3 的 InfoNCE)。

为什么它不能当第一阶段(学习计划的自测题):分数 s(q,d)=h([q;d])s(q,d)=h([q;d]) 不能分解成”只依赖 qq“和”只依赖 dd“的两部分(Day 1 §3.1),没法预先算好存起来。每个查询都要对库里的每个块跑一次完整前向:百万块就是百万次前向。所以它只能给第一阶段送来的几十到几百个候选打分。

4.2 召回多少个候选再重排

第 6 组:10 万篇文档,每个查询 10 篇相关。第一阶段粗(相关文档的分数与噪声重叠不少);第二阶段准(噪声小得多),但每个候选都要一次交叉编码器调用。

候选数 K第一阶段 recall@K重排后 NDCG@10交叉编码器调用
100.6570.88610
200.7380.92520
500.8170.95250
1000.8590.962100
5000.9380.973500
10000.9580.9741000

(只用第一阶段: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,再求和:

s(q,d)=∑i∈qmax⁡j∈d ⟨Eqi,Edj⟩(MaxSim).s(q,d)=\sum_{i\in q}\max_{j\in d}\ \langle E_{q_i},E_{d_j}\rangle\qquad(\text{MaxSim}).

文档的 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”,结果两边都不够近,前 kk 名可能全是 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.0000.7910.501
HyDE(4 个假设 + 原查询,平均)1.0000.9960.935

收益:问题的向量被同文体的无关片段吸走(0.369);假设答案和真答案文体相同,一下就找到了。风险:LLM 对这个领域一无所知时,假设答案会跑题,把检索带到错误的主题上(π = 0.5 时降到 0.501)。缓解:多采几个假设再和原查询平均:跑题的假设彼此方向不同,平均之后被削弱。

什么时候别用:领域很偏(LLM 编不出像样的假设);专有名词查询(LLM 会编一个错的名字);延迟敏感(多一次生成)。

5.3 上下文检索:给块补上主语

问题:切出来的块常常缺少上下文。“本公司第二季度营收增长 3%“:哪家公司?哪一年?这样的块,BM25 和嵌入都没法把它和”X 公司 2023 年 Q2 营收”对上。

第 8 组:30 家公司结构相同的年报,每家切 8 块,只有第 1 块写了公司名。查询”某公司 + 研发”:

hit@1hit@5第 1 名是”这家公司的第 1 块”的比例
原始块0.0000.1231.000
加上下文的块1.0001.0000.000

原始块里,公司名的 IDF 很高,BM25 总是把这家公司唯一写了名字的第 1 块(讲营收的)排第一;真正讲研发的块和另外 29 家的一模一样,只能在它们中间随机排。

Anthropic 的上下文检索(2024):对每一块,把整篇文档和这一块一起交给 LLM,让它写一句 50–100 token 的”这一块在全文中讲什么”,拼在块的前面,再做嵌入和 BM25 索引。Lecture 7 展示的提示大意是:“这是整篇文档……这是我们想在全文中定位的那一块……请给出一句简短的上下文,用于改善这一块的检索,只回答这句上下文。“他们报告的效果(top-20 检索失败率,基线 5.7%):

做法失败率相对降低
上下文嵌入3.7%35%
上下文嵌入 + 上下文 BM252.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@1hit@5R@5R@20MRR@10NDCG@10MAP
BM25(无面包屑)0.2800.5400.2950.4840.3910.2960.236
LSA(无面包屑)0.2200.5400.3130.4980.3610.3070.250
BM25 + 面包屑0.4600.8400.5160.7610.6090.5450.461
LSA + 面包屑0.5800.8200.5500.7940.6810.6390.572
混合 RRF k=60(都带面包屑)0.5200.8000.5330.7880.6490.5990.523
混合 RRF k=100.5200.8000.5310.7930.6520.5980.528
混合 min-max 等权0.5600.8000.5290.7950.6750.6070.540
混合 z-score 等权0.5600.8000.5290.7840.6760.6070.538

标题查询上(404 条,误差更小),hit@5:BM25 0.453、LSA 0.433、RRF k=60 0.436、RRF k=10 0.436。RRF 的 kk 从 1 扫到 300(--rrf-sweep),黄金集 hit@5 全是 0.800,MRR 在 0.649–0.666 之间。

怎么读这张表:

  1. 面包屑仍然是最大的一项:BM25 和 LSA 都从 0.54 升到 0.82–0.84。
  2. 混合检索在这里没有带来收益:所有混合方式都在 0.80 左右,和单路分不开(50 道题的标准误约 0.06,Day 5 §3.1)。这和 §2 的结论矛盾吗?不矛盾。§2.1 已经说了前提:稠密那一路要有”语料之外”的知识。LSA 是从同一份 TF-IDF 矩阵里算出来的,它知道的”同义”只来自这份语料的共现,和 BM25 高度相关:两路犯的是同样的错,融合就没什么可补的。
  3. 这正是今天最重要的一课:一个”公认有效”的升级,在你的数据、你的组件组合上不一定有效。不测,你就会把它当成有效的写进报告。

请你补上的实验:装上 sentence-transformers 后跑 --eval --dense st。一个在海量真实问答对上训练过的嵌入模型,和 BM25 的互补性应该大得多:它能处理改写,BM25 能处理术语。按 §2 的机制,混合检索这时才应该显出收益。用你跑出来的数字来判断,而不是用这段话。 同样地,--rerank 需要 sentence-transformers,--hyde 需要一个 LLM 后端。

父子块的权衡(--expand,混合 RRF,同样的字符预算):

预算(字符)方式命中答案覆盖率
700按块0.6400.308
700整节0.6400.385
1400按块0.8000.561
1400整节0.7000.629
2800按块0.9000.718
2800整节0.8400.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 是另外两个变体。
  • 默认 k1=1.5k_1=1.5,Lucene / Elasticsearch 默认 k1=1.2k_1=1.2。换库时结果对不上,先查参数。

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 实操

  1. 跑通 w4day2_checks.py,8 组结论都看懂。第 4 组把”盲区”的比例改一改(比如一路看得见 90%、另一路 30%),看 RRF 的最佳 kk 怎么变。
  2. 推导(1h,纸笔):(a) 从二元独立模型推出 RSJ 权重;(b) BM25 的词频项在 tf → ∞ 时的极限,以及 b=0b=0、b=1b=1 两个特例;(c) 手算第 5 组两个系统的 AP 和 NDCG@5;(d) 解释 RRF 的 kk 为什么影响”独家发现 vs 共识”。
  3. 在讲义上复现 §6(1h):python code/w4d2_hybrid_rerank.py --eval、--rrf-sweep、--expand,再用 --query 对比三种检索在你自己的问题上的表现。
  4. 补上真正的稠密检索与重排(1.5h):装 sentence-transformers,跑 --eval --dense st 和 --rerank。混合检索这时有没有收益?重排前 20 个和前 50 个差多少?把结果补进 §6 的表。
  5. HyDE 与多查询(1h,需要一个 LLM):用 Ollama 或 vLLM 起一个小模型,跑 --hyde --backend openai --model …。看看小模型写的假设答案有多少是跑题的,它们对检索是帮忙还是添乱。
  6. 读论文: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 自测

动机与直觉层

  1. BM25 在 TF-IDF 的基础上改了哪三件事?各自的理由是什么?
  2. 稀疏检索和稠密检索各自在什么查询上赢?举两个例子。
  3. 为什么交叉编码器不能用来做第一阶段检索?两阶段检索里两个阶段各自优化什么?
  4. HyDE 为什么有效?它的风险是什么?什么时候不该用?
  5. 用户问”对比 A 和 B 的第三季度数据”,朴素 RAG 为什么大概率失败?怎么改?

推导与计算层

  1. 从”相关 vs 不相关的对数几率”推出 BM25 的 IDF。什么时候它会变成负数?
  2. 写出 BM25 的词频项,求它在 tf → ∞ 时的极限。k1=0k_1=0 意味着什么?
  3. 手算一个排序的 P@5、R@5、RR、AP、NDCG@5(自己出一个例子)。什么情况下 AP 和 NDCG 会给出相反的判断?
  4. 写出 RRF 的公式。算一算:一篇只被一路排第 1、另一路排第 500 的文档,和一篇两路都排第 30 的文档,在 k=10k=10 和 k=60k=60 时谁的分数高?

工程层

  1. 为什么不能把 BM25 分数和余弦直接相加?有哪几种修法,各自的缺点是什么?
  2. 你的 RAG 系统加上混合检索之后指标没有变化,可能是什么原因?你会做什么实验来确认?
  3. 上下文检索要为每一块调用一次 LLM,怎么把成本降下来?什么样的语料用面包屑就够了?
  4. 用 rank_bm25 时有哪两个不报错但结果错的坑?

出关标准

  • 能从二元独立模型推出 BM25 的 IDF,说清 k1k_1、bb 的作用和”饱和”的直觉
  • 能用例子说明稀疏和稠密的互补,并解释 RRF 为什么比分数相加稳、kk 在什么情况下重要
  • 能手算 P@k、R@k、MRR、AP、NDCG,说出 RAG 该看哪个、为什么
  • 能说清两阶段检索的分工、候选数的权衡,以及交叉编码器为什么不能做第一阶段
  • 能说出查询改写、HyDE、上下文检索、父子块各修的是哪一类故障
  • 在讲义上跑通 --eval,能解释”混合检索为什么在这里没有收益”

疑问清单

← 返回课程目录