Lecture 3 · 检索增强生成 · Retrieval-Augmented Generation (RAG)

Engineering AI Agents · Lecture 03。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

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

本篇目录

对应 Maps to:Stanford CS329Z Engineering AI Agents(Fall 2026),Lecture 3 · Wed Sep 30 · Building Blocks: Retrieval-Augmented Generation (RAG)(slides 标题 “RAG + Agents”,Diyi Yang)。阅读材料:Lewis et al., Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks(NeurIPS 2020);补充阅读:Khattab & Zaharia, ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT(SIGIR 2020)。 依据说明:本讲以原课第 3 讲的公开 slides 为主线(为什么检索、RAG 流水线、索引、检索、智能体式 RAG、失败模式六部分),结合两篇阅读材料和 slides 引用的论文(DrQA、DPR、REALM、Contextual Retrieval、late chunking、RAPTOR、GraphRAG、HNSW、BM25、RRF、cross-encoder、Self-RAG、CRAG、Search-R1、Lost in the Middle;凡是引用数字的都核对过原文)。原课这一讲还包括一个”从零搭 RAG 流水线”的动手环节;本讲义的代码就是我们的版本。虚构公司的语料和全部实验是本讲义自己设计的,不代表课堂上讲了什么。 配套代码 Code:code/lecture03/(只用 Python 标准库;7 个脚本 + 3 个模块;不需要 API key;最慢的脚本约 9 秒) 预计阅读时间:4–5 小时(含运行代码)


0. 本讲导航 · How to read this lecture

0.1 学习目标 Learning goals

slides 给这一讲定的目标是:“学会 RAG 流水线是怎样搭起来的、在哪里会坏,以及索引什么、怎样搜索。“展开来说,学完这一讲你应该能够:

  1. 说清楚为什么要给 LLM 接检索(知识截止、长尾、幻觉、可溯源、可更新),以及 retriever–reader 和 RAG 两种框架的区别。
  2. 设计索引:选嵌入、选分块方式(固定大小、重叠、按结构、上下文化、late chunking),知道 RAPTOR、GraphRAG 解决什么问题,知道近似最近邻用召回换了速度。
  3. 从零实现 BM25,并解释 IDF、词频饱和、长度归一化各自的作用;会算 Hit@k、MRR@k、nDCG@k、Recall@k。
  4. 说清楚稀疏检索和稠密检索各自在哪类查询上失败,以及为什么要混合(RRF)、为什么混合之后还要重排(cross-encoder),ColBERT 的 late interaction 在两者之间处于什么位置。
  5. 把检索变成智能体的一个动作:拆子问题、逐跳检索、评估检索结果、改写查询(Self-RAG、CRAG、Search-R1 的思路),并能解释多跳问题为什么必须这样做。
  6. 列出 RAG 在检索端、阅读端、循环端的典型失败(召回天花板、索引过期、权限泄露、lost in the middle、干扰、证据冲突、错误累积、没有停止规则、注入),并为每一种给出工程对策。

0.2 先修与本讲范围 Prerequisites & scope

默认你已经会 Assumed本讲只给直觉,后面再展开 Previewed only
第 1 讲:智能体循环、ReAct(search / lookup / finish)、pass@k工具调用协议、把检索做成一个 MCP 工具(第 4 讲)
第 2 讲:上下文工程(“用检索找回最相关的东西”)、嵌入的直觉、交叉熵DSPy 里的检索模块与优化(第 5 讲)
向量的点积和余弦相似度;对数长期记忆的检索(第 7 讲);评估的统计(第 12 讲)
权限与信任边界的概念(前置讲 P4)间接提示词注入的系统性防御(第 14 讲)

0.3 本讲地图 Map

§1  为什么要检索:LLM 的短板;retriever–reader;RAG 论文;RAG 的变体与流水线全景      ← 代码 05(闭卷 vs RAG)
§2  索引:嵌入、分块、上下文化检索与 late chunking、向量搜索、RAPTOR 与 GraphRAG       ← 代码 03、02
§3  检索:BM25、检索指标、稠密检索(DPR)、混合与 RRF、cross-encoder 重排、ColBERT    ← 代码 01、02、04
§4  从检索到答案:端到端的 RAG                                                       ← 代码 05
§5  智能体式 RAG:三个决定、Self-RAG、CRAG、Search-R1、多跳、查询改写                 ← 代码 06
§6  RAG 的问题:检索端、阅读端、循环端                                               ← 代码 05
§7  用真实模型当阅读器                                                              ← 代码 07(可选)
§8  小结   §9 常见误区   §10 练习   §11 延伸阅读   §12 下一讲预告

0.4 记号与术语 Notation & terms

记号 / 术语意思
语料 D、块(chunk)d、查询 q被检索的文本集合;检索的基本单位;用户的问题(或智能体写的检索词)
retriever / reader检索器(从语料里找出前 k 块)/ 阅读器(读这 k 块、写答案;今天通常是 LLM)
稀疏检索 / 稠密检索按词匹配(BM25、TF-IDF)/ 按向量相似度匹配(DPR 等)
bi-encoder / cross-encoder问题和段落分别编码再比较 / 问题和段落放在一起编码、直接打分
top-k、Hit@k、Recall@k、MRR@k、nDCG@k前 k 个结果;四个检索指标(§3.2)
ANN近似最近邻(approximate nearest neighbor):用一点召回换大量速度
相关(relevant)本讲代码里的定义:块来自证据所在的文档,并且完整地包含证据句

0.5 本讲的语料:一家虚构的能源公司

原课 HW1 要你给一家虚构的能源公司搭内部助手,数据是真实的 Enron 邮件里混进少量虚构的”针”。本讲义也虚构了一家能源公司 Orrin Grid(奥林电网),它的内部知识库有 70 份文档、96 道题(orrin_corpus.py)。文档和题目是专门设计来暴露 slides 里讲的每一种难点的:

难点文档题型(题数)例子
罕见的字面量24 份事故报告、16 个变电站页面、IT 文档literal(35)“What was the root cause of incident E-1474?”
换了说法10 篇 IT 帮助文档、变电站页面paraphrase(18)“My remote access login code stopped working. How do I get a new one?”(文档写的是 “reset your SecureLink token”)
块里缺上下文6 份结构平行的报销政策(审批、收据、上限、例外四节,句子里不写政策名)section(24)“What is the deadline for submitting travel receipts?”(那一节只写着 “Submit receipts within 30 days of the expense.”)
多跳变电站 → 维护团队(10 个团队页面)→ 负责人multihop(16)“Who leads the team that maintains substation SS-339?”
新旧版本远程办公政策 2024 版与 2026 修订版versioned(1)“How many days per week can employees work remotely?”
权限薪资带宽、调查记录(仅 HR 可见)restricted(2)“What is the salary range for a senior engineer?”

每个含答案的句子都标注了它携带的事实(例如 ("root_cause", "E-1474", "an expired TLS certificate on host mq-38")),所以检索的”相关”和阅读的”答对”都能机械地判定。判分要求答案对、并且依据来自正确的文档——从另一个变电站的页面里抄来一个碰巧相同的团队名,不算对。

两个替身,要先说清楚:

  • 稠密编码器(rag_lab.ToyEncoder):真实的稠密检索器是在大量”问题–段落”对上训练出来的神经网络。我们用一张手写的同义词表扮演它学到的知识:同一个概念组里的词得到同一个”概念向量”;表外的词(人名、错误码、编号)只得到一个由字符片段哈希出来、权重较小的”表面向量”——模仿真实编码器对罕见字面量不敏感的毛病。所以”稠密检索擅长同义改写、不擅长错误码”在这里是由设计决定的;要看的是它和 BM25 互补的结构,以及融合、重排、分块时会发生什么。
  • 阅读器(rag_agent.Reader):一个确定性的抽取式阅读器——在检索到的块里找”携带所问事实、并且完整出现”的句子,按”问题里的实体有没有出现在这句话 / 这块里”挑答案;上下文里有这一类事实、却没有一个对得上实体时,它会猜排名最高的那个(真实 LLM 常常这样)。“lost in the middle”另用一个显式的旋钮模拟。这样做是为了把检索的影响干干净净地分离出来。

0.6 slides 开头:关于课程项目

原课这一讲的前五分钟讲的是课程项目,有几条对任何智能体项目都适用:选一个你兴奋的题目,先想清楚”智能体做什么任务、背后的研究问题是什么、需要什么数据或 API、用什么框架、怎样评估”;评估要严格——“只跑通一次的 demo 不会得高分”,要尽早定义任务、数据从哪来、什么算成功,用代码判分器、LLM-as-judge、人工评分或者组合来测,报告要有很多任务、基线,以及智能体在哪里、为什么失败;先拿到数据;每周都做一点,不要拖到最后。第 17 讲会专门讲怎样做一个智能体项目。


1. 为什么要检索 · Why retrieval

1.1 一个例子

slides 的开场问题是:“这周哪个答疑时段人最少?”

  • 只有 LLM:“试试周三下午——一周中间的时段通常最空。“——听起来很合理,但它是从”一般规律”里编出来的。
  • LLM + 检索:检索到”排队日志,最近 3 周:周二 4pm 平均 14 人排队;周四 9am 3 人。“——回答:“周四 9am,你前面大约 3 个人。”

后者用的是及时送进来的、具体的、可核对的信息。这就是检索的全部动机。

1.2 用检索弥补 LLM 的短板

slides 的说法:与其要求 LLM 把一切都记住,能不能在需要的时候把相关、有用的内容提供给它?检索就是找到这些信息的通用机制,它有两个额外的好处:

  • 动态:往检索系统里更新、增加文档很容易——不用重新训练模型(对比第 2 讲:知识截止、重新训练的成本)。
  • 可解释:LM 可以给出指向检索文档的引用,让人能核对它的生成。

再加上第 2 讲讲过的:模型对长尾知识记得不牢、不知道时倾向于编(训练目标是”像数据那样续写”)、对你公司的内部数据一无所知。代码 05 演示 1 的第一行是闭卷:对 Orrin Grid 这家虚构公司,模型一题都答不对(我们的阅读器老实地说”不知道”;真实模型常常会编一个——可选的代码 07 让你亲眼看看)。

1.3 retriever–reader:开放域问答的经典框架

slides 从 DrQA(Chen et al., 2017, Reading Wikipedia to Answer Open-Domain Questions)讲起。问题:“1933 年华沙有多少居民讲波兰语?” → 文档检索器从整个维基百科里找出相关文章 → 文档阅读器从中抽出答案 “833,500”。形式化:

  • 输入:一大批文档 D={d1,…,dN}D = \{d_1, \dots, d_N\} 和问题 qq;输出:答案字符串 aa。
  • 检索器:f(D,q)→d1,…,dKf(D, q) \to d_1, \dots, d_K(K 预先定好,例如 100)。
  • 阅读器:g(q,{d1,…,dK})→ag(q, \{d_1, \dots, d_K\}) \to a——这是一个阅读理解问题。

DrQA 的检索器是一个固定的 TF-IDF 稀疏模型;阅读器是在 SQuAD 等数据上训练的神经阅读理解模型——今天它可以直接是一个零样本的 LLM。这个两段式的结构就是今天所有 RAG 系统的骨架。

1.4 RAG:把检索和生成一起训练

Lewis et al.(2020)的 RAG 把检索器和生成器连起来,端到端地训练(slides 也提到同期的 REALM、DPR 等工作):

  • 检索器 pη(z∣x)p_\eta(z \mid x):给问题 xx 返回前 K 段 zz。用 DPR:pη(z∣x)∝exp⁡(d(z)⊤q(x))p_\eta(z \mid x) \propto \exp(\mathbf{d}(z)^\top \mathbf{q}(x)),其中 d(z)=BERTd(z)\mathbf{d}(z) = \text{BERT}_d(z)、q(x)=BERTq(x)\mathbf{q}(x) = \text{BERT}_q(x);前 K 个用最大内积搜索(MIPS)求出。
  • 生成器 pθ(yi∣x,z,y1:i−1)p_\theta(y_i \mid x, z, y_{1:i-1}):一个 seq2seq 模型(BART),把问题和检索到的段落拼在一起,一个 token 一个 token 地写答案。
  • 把检索到的段落当成隐变量,按检索器给它的概率加权: p(y∣x)≈∑z∈top-kpη(z∣x) pθ(y∣x,z)p(y \mid x) \approx \sum_{z \in \text{top-}k} p_\eta(z \mid x)\, p_\theta(y \mid x, z)
  • 两种变体:RAG-Sequence——挑一段,用它生成整个答案(对每段算一遍再加权);RAG-Token——每生成一个 token 都可以重新”挑”一段。
  • 训练:最小化负的边缘对数似然 ∑j−log⁡p(yj∣xj)\sum_j -\log p(y_j \mid x_j),同时更新查询编码器和生成器(文档编码器和索引保持不动——否则每一步都要重建索引)。

论文报告:在 Natural Questions、TriviaQA、WebQuestions、CuratedTrec 四个开放域问答数据集上,RAG 都达到了当时的最好水平(例如 NQ 的精确匹配 44.5,高于抽取式的 DPR 41.5 和闭卷的 T5-11B+SSM 的 36.6);在 Jeopardy 问题生成的人工评估里,评估者认为 RAG 的输出比 BART 更符合事实的情况占 42.7%,反过来只有 7.1%。

今天的 RAG 和论文里的 RAG 的区别:论文是”联合训练”一个检索器和一个生成器;今天大多数系统用的是现成的嵌入模型和现成的 LLM,不做联合训练,只把检索结果放进提示词里(Gao et al. 2024 的综述把它叫 “Naive RAG”),再在前后加各种改进(“Advanced RAG”:查询改写、重排、上下文压缩……)和模块化的组合(“Modular RAG”)。本讲的代码属于后者:每个组件可以单独换、单独评估。

1.5 流水线全景

           ┌──────────── 离线:建索引 ────────────┐      ┌──────────────── 在线:回答一个问题 ────────────────┐
文档 ──→ 分块 ──→ (加上下文) ──→ 编码 / 建倒排 ──→ 索引     问题 ──→ (改写 / 拆分) ──→ 检索 top-N ──→ 重排 top-k ──→ 阅读器 (LLM) ──→ 答案 + 引用
                                                                    ↑                                   │
                                                                    └──── 智能体:结果不够好?再查一次 ─┘

后面几节按这张图从左到右走:§2 是”建索引”,§3 是”检索与重排”,§4 是”阅读”,§5 是那条回环的箭头,§6 是每一段会怎样坏。


2. 索引:检索什么 · Indexing

2.1 好的检索器从哪里来:嵌入

slides 列了几类”嵌入”(embedding,把东西变成一个固定长度的向量):

类型例子一个向量代表
静态词向量word2vec、GloVe一个词(不管上下文)
上下文词向量BERT、RoBERTa一个词在这句话里的意思
段落向量Sentence-BERT、DPR、E5一块文字
多模态CLIP文字或图片(共享同一个空间)
图node2vec、GNN 编码器一个节点或关系

检索需要两样东西:每块一个固定维度的向量,和一个相似度度量(余弦或内积)。代码 02 演示 1 看我们的玩具编码器眼里的”相似”:

文字 A文字 B余弦相似度
my remote access login codereset your SecureLink token0.78
which crew looks after the siteit is maintained by the team0.75
get a new work computerlaptops are refreshed every 4 years0.39
travel receipts deadlineconference receipts deadline0.60
incident E-0283incident E-03820.94
incident E-0283substation SS-218−0.01

同义改写几乎没有共同的词,向量却比不相关的文字(约 0)近得多——这是 BM25 做不到的。但两个不同的错误码(由同样的数字组成)相似度高达 0.94,“差旅收据”和”会议收据”也很近:一个向量概括一整段,代价就是丢细节。

2.2 分块:检索的基本单位

文档往往太长,不能整个当检索单位(塞不进上下文,一个向量也概括不了)。slides 列了几种分块方式:

  • 固定大小:按长度切(例如 100–500 个 token)。太小,向量没有上下文;太大,一个向量平均掉很多主题。
  • 重叠:相邻两块重叠 10–20%,块边界上不丢句子。
  • 语义分块:按逻辑边界切(句子、小节)。
  • 带上下文的分块:每块附上元数据或摘要。
  • AI 驱动的动态分块:用 LLM 判断自然的断点。

slides 用一段课程说明(“Grading. The project milestone is due Week 6, Thursday 11:59pm. Late days may not be applied to the milestone. The final report is due Week 10.”)演示了”同一段文字,三种切法”。代码 03 演示 1 在我们的差旅政策上做了同样的事:固定 20 词时,“Requests must be approved by your manager at least 7 days” 和 “in advance.” 被劈在两块里;加 5 词重叠后,被劈开的句子至少在一块里是完整的;按结构切,每块正好一节——但 “Receipts: Submit receipts within 30 days of the expense.” 这一块里仍然没有 “Travel” 这个词。

代码 03 演示 2 统计了七种分块方式(112 条证据句):

分块方式块数平均词数证据句完整地在某一块里BM25 前 5 块的总词数
不分块(整份文档)7039.6112/112228
固定 40 词10626.199/112130
固定 40 词 + 重叠 1010629.5104/112137
固定 20 词17316.057/11274
固定 20 词 + 重叠 518717.999/11278
按结构(每节一块)9429.5112/112128
上下文化(结构 + 来源前缀)9430.5112/112132

块越小,被块边界劈开的证据句越多(固定 20 词时将近一半)——检索器再好,阅读器也读不到完整的事实;重叠直接救回了大部分。块越大,喂给阅读器的上下文越长。

2.3 上下文化检索与 late chunking

问题:slides 的例子——查询 “ACME 公司 2023 年第二季度的营收增长是多少?“,而那一块只写着 “The company’s revenue grew by 3% over the previous quarter.”——哪家公司?哪个季度?块被切出来之后,它缺了理解它所需的上下文。

上下文化检索(Anthropic, Contextual Retrieval, 2024):建索引时,用 LLM 读整份文档,为每一块写一两句”这一块在整份文档里讲什么”,拼在块前面再去建嵌入和 BM25 索引。slides 展示了那段提示词:把整份文档放在 {{WHOLE_DOCUMENT}},把这一块放在 {{CHUNK_CONTENT}},要求模型”给出一段简短的上下文,把这一块放回整份文档里,以改进这一块的检索;只回答这段上下文”。Anthropic 报告:上下文化的嵌入让前 20 块的检索失败率下降了 35%(5.7% → 3.7%);再加上上下文化的 BM25,下降 49%(→ 2.9%);再加重排,下降 67%(→ 1.9%)。他们的检索本身也是混合的:“嵌入抓意思,TF-IDF / BM25 抓精确的词,结果合并”。

我们的文档结构很规整,用”[标题 > 小标题]“这样确定性的前缀就够了(chunk_contextual)。代码 03 演示 4:问 “What is the deadline for submitting travel receipts?”,按结构分块时正确的块在 BM25 里排第 2、在稠密检索里排第 4,BM25 的第 1 名是差旅政策的开头那一块(它有 “travel”,却没有答案);加上 “[Travel Policy > Receipts]” 之后,两种检索器都把它排到了第 1。

late chunking(Günther et al., 2024)是另一条路:用一个长上下文编码器先编码整份文档,再把每一块范围内的 token 向量做平均池化,得到块向量。和上下文化检索相比:不需要 LLM 调用、不给每块增加 token;上下文是”带在向量里”的。我们的玩具版(DenseRetriever(late=0.5))把整份文档的平均向量按 0.5 的权重混进块向量里。

代码 03 演示 3 的大表(Hit@1;全部 = 78 道单跳题):

分块方式BM25:字面 / 节 / 全部稠密:字面 / 节 / 全部稠密 + late chunking:节混合(RRF):节 / 全部
不分块1.00 / 1.00 / 0.880.14 / 1.00 / 0.511.001.00 / 0.65
固定 40 词0.60 / 0.08 / 0.420.14 / 0.33 / 0.310.540.29 / 0.36
固定 20 词0.09 / 0.08 / 0.080.06 / 0.29 / 0.230.620.17 / 0.15
按结构1.00 / 0.08 / 0.600.14 / 0.42 / 0.330.880.21 / 0.41
上下文化1.00 / 0.83 / 0.830.14 / 0.88 / 0.470.880.92 / 0.63

读法:

  • 固定小块对字面量题是灾难:事故报告被切开后,“The root cause was …” 那一块里没有错误码(它在前一块),BM25 再擅长字面量也找不到。
  • section 题只有在块里带着”是哪份政策”时才能排对:不分块和上下文化都能做到;按结构切也救不了。重叠救回了句子的完整性,却没有提高 Hit@1——块里依然缺上下文。
  • late chunking 不改任何文字,就让稠密检索在 section 题上从 0.42 提到 0.88。
  • 不分块在 BM25 上总分最高——但那是因为这个语料的文档都很短(平均 40 词)。真实文档几十页:整份塞不进上下文,一个向量也概括不了,每次还要把整份文档喂给阅读器。

2.4 向量搜索:精确 vs 近似

任务:给定查询向量,从 N 个块向量里返回最近的 k 个。精确搜索是暴力扫描,每个查询 O(N⋅d)O(N \cdot d)。N 到了百万、上亿,就需要近似最近邻(ANN):“用一点召回换大量速度”。slides 列了三种:

  • IVF(倒排文件索引):先把所有向量聚成若干簇,查询时只在离查询最近的几个簇里扫描。
  • PQ(乘积量化):把每个向量压缩成一个很短的码,省内存(距离用码近似计算)。
  • HNSW(分层可导航小世界图):所有向量都在最底层,越往上层越稀疏;搜索时从顶层出发,贪心地往查询靠近,卡住了就下降一层;大约 log⁡N\log N 跳就到,召回很高。建图时一个一个插入,每个向量随机决定它最高到哪一层(大多数只在第 0 层),在每一层连上 M 个最近邻,再加一些跨簇的连接,免得贪心搜索被困在局部。

代码 02 演示 5 在合成数据(1,200 个 8 维向量、30 个簇)上实现了 IVF:

方法搜几个簇Recall@10(相对暴力扫描)每个查询算几次距离
暴力扫描–100.0%1,200
IVF191.0%81
IVF299.0%118
IVF499.7%205
IVF8100.0%395

只搜 1 个簇,距离计算少了一个数量级以上,召回掉到 91%(真正的近邻有一部分在相邻的簇里);搜 2 个簇就回到 99%。nprobe(HNSW 里是 efSearch)就是”速度 ↔ 召回”的旋钮,要在自己的数据上测。

2.5 层次化与图索引:RAPTOR、GraphRAG

有些问题的答案散落在整份文档里,任何单独的一块都回答不了——“灰姑娘这个故事的中心主题是什么?”

  • RAPTOR(Sarthi et al., ICLR 2024)自底向上建一棵树:把块编码 → 聚类 → 用 LLM 总结每个簇 → 把总结再编码 → 再聚类、再总结……检索时跨层查找:细节问题匹配到叶子,主题问题匹配到上层的总结节点。slides 用灰姑娘的两个问题(“故事的中心主题?”、“她是怎样得到幸福结局的?“)展示:RAPTOR 选中的上下文通常已经包含了 DPR 选中的叶子,要么直接包含,要么包含在某个总结里。
  • GraphRAG(Edge et al., 2024)用 LLM 从语料里抽出实体和关系、建成一张图,在图上检测”社区”、为每个社区预先写好总结,用来回答”关于整个语料的全局性问题”(query-focused summarization)。之后又出现了很多变体(slides 引用了 Han et al. 2025 的整理)。

两者的共同点是在建索引时多花算力(LLM 调用),换取对”需要汇总”的问题的检索能力。本讲的代码没有实现它们(需要 LLM 写总结),练习 8 让你设计一个最小版本。


3. 检索:怎样搜 · Retrieval

slides 把检索器分成三类:按词重合(BM25)、向量检索(DPR、句向量)、其他神经系统(ColBERT)。

3.1 BM25:至今最常用的检索器

slides 的标题是 “BM25: the most widely used (even today)“。公式:

score(q,d)=∑t∈qIDF(t)⋅f(t,d) (k1+1)f(t,d)+k1(1−b+b⋅∣d∣avgdl),IDF(t)=ln⁡N−n(t)+0.5n(t)+0.5\text{score}(q, d) = \sum_{t \in q} \text{IDF}(t) \cdot \frac{f(t, d)\,(k_1 + 1)}{f(t, d) + k_1 \left(1 - b + b \cdot \frac{|d|}{\text{avgdl}}\right)}, \qquad \text{IDF}(t) = \ln \frac{N - n(t) + 0.5}{n(t) + 0.5}

三个部分,各管一件事:

  • IDF(t):这个词有多罕见(N 是块数,n(t) 是含有这个词的块数)。罕见的词更能区分文档。原式在一个词出现在一半以上的块里时会变负;Lucene / Elasticsearch 在括号里加 1(ln⁡(1+N−n+0.5n+0.5)\ln(1 + \frac{N - n + 0.5}{n + 0.5}))保证非负,我们的代码默认用这个。
  • f(t, d) 与 k₁:词在块里出现了几次;k₁ 控制饱和——出现第 2、3 次还能加多少分。
  • |d| / avgdl 与 b:这块比平均长多少;b 控制要不要因为”长”而扣分(长块含有任何词的机会本来就多)。

倒排索引让它很快:词 → 含有它的块(和次数)。查询时只遍历查询词的倒排列表,其余的块根本不看。代码 01 演示 1:94 块、442 个词;错误码 ‘e-1474’ 只出现在 1 块里,IDF 4.15;‘incident’ 出现在 24 块里,IDF 1.36。

一个查询的分数拆解(代码 01 演示 2,“What was the root cause of incident E-1474?” → 查询 token [‘root’, ‘cause’, ‘incident’, ‘e-1474’]):

名次总分相关各查询词的贡献
18.68是root 1.09、cause 1.09、incident 1.60、e-1474 4.90
23.90否root 1.13、cause 1.13、incident 1.64、e-1474 0
33.90否(同上,另一份事故报告)

第 1 名一大半的分数来自错误码这一个词。BM25 对罕见的字面量极其敏感——这是它最大的长处。

k₁ 和 b 在管什么(代码 01 演示 3,直接算公式):k₁ = 1.2 时,一个词出现 1 次给 1 倍 IDF、出现 10 次也只给约 1.96 倍——重复很多次并不说明”相关很多”;k₁ = 10 时接近线性(像原始词频)。b = 1 时,长 4 倍的块里出现一次,贡献只剩约 0.38 倍;b = 0 时不管长度。slides 里那张”IDF 曲线 + TF 饱和曲线”的图就是这两张表的图形版。

在 78 道单跳题上(代码 01 演示 5,按结构分块):数重合词、TF-IDF、BM25 三者在这个小语料上几乎一样(字面量题都是 1.00,换说法的题 0.56–0.61,section 题 0.08);9 种 k₁、b 组合的 Hit@1 完全相同(0.603)。这不是说 BM25 的设计没用——IDF、饱和、长度归一化的价值在文档长短不一、查询里常见词很多、只能部分匹配时才显出来,在标准 benchmark 上 BM25 稳定地好于 TF-IDF 和简单的词重合。它说的是另一件事:这个语料上的失败(换说法、缺上下文)不是调参能解决的。在调参之前,先看失败的题到底为什么失败。

3.2 怎样给检索器打分

slides 给了五个指标(Q 是题目集合,RqR_q 是题 q 的相关集合,rank 从 1 开始):

  • Hit@k =1∣Q∣∑q1[Rq∩Topk(q)≠∅]= \frac{1}{|Q|}\sum_q \mathbb{1}[R_q \cap \text{Top}_k(q) \ne \varnothing]:前 k 个里至少有一个相关的。名次不重要。
  • Failed@k =1−Hit@k= 1 - \text{Hit@}k:前 k 个里一个相关的都没有。错误分析常常从这里开始。
  • MRR@k =1∣Q∣∑q1rankq= \frac{1}{|Q|}\sum_q \frac{1}{\text{rank}_q}:第一个相关结果的名次的倒数,不在前 k 里记 0。
  • nDCG@k =DCG@kIDCG@k= \frac{\text{DCG@}k}{\text{IDCG@}k},DCG@k=∑i=1krelilog⁡2(i+1)\text{DCG@}k = \sum_{i=1}^{k} \frac{\text{rel}_i}{\log_2(i+1)}:越靠前权重越大,再除以理想排序的 DCG 归一化;可以用分级的相关度。
  • Recall@k =1∣Q∣∑q∣Rq∩Topk(q)∣∣Rq∣= \frac{1}{|Q|}\sum_q \frac{|R_q \cap \text{Top}_k(q)|}{|R_q|}:相关的东西找回来了多少。

代码 01 演示 4 用一道两条证据的多跳题手算:BM25 前 5 名里只有第 1 名(变电站页面)相关,团队页面没找到。于是 Hit@5 = 1、MRR@5 = 1,看起来完美;但 Recall@5 = 0.5、nDCG@5 = 0.613。Hit 对多跳题会说谎——需要多条证据的题必须看 Recall。MRR 和 nDCG 还看”排得靠不靠前”:阅读器对前面的内容更敏感(§6.2),上下文也有长度限制,名次很重要。

3.3 稠密检索:DPR

DPR(Karpukhin et al., 2020)是一个双编码器(bi-encoder):问题和段落各用一个 BERT 编码成向量,相似度是内积。训练用对比损失:一个问题、一个正确的段落、若干错误的段落,让正确的那个得分最高。两个关键技巧:批内负例(同一批里别的问题的正确段落,当作这个问题的错误段落——免费的负例)和 BM25 难负例(BM25 排得很高、但其实不含答案的段落)。slides 引用的结果:DPR 在 5 个数据集里的 4 个上胜过 BM25(例如 Natural Questions 的 top-20 检索准确率 78.4% vs 59.1%),只用 1,000 个问答对训练就能胜过 BM25,而且不需要额外的预训练,端到端也胜过 ORQA 和 REALM。

两种检索器以不同的方式失败(slides):BM25 漏掉同义改写;稠密检索漏掉罕见的字面量(错误码、产品 ID)。代码 02 演示 2–3 在按结构分块的索引上:

检索器字面量 Hit@1 / Hit@5换说法 Hit@1 / Hit@5section Hit@1 / Hit@5全部 Hit@1 / Hit@5
BM251.00 / 1.000.56 / 0.720.08 / 0.710.60 / 0.85
稠密0.14 / 0.400.56 / 0.720.42 / 0.830.33 / 0.62
混合(RRF, k = 60)0.34 / 0.830.83 / 0.940.21 / 0.790.41 / 0.85
混合(分数归一化相加)1.00 / 1.000.78 / 0.940.29 / 0.790.73 / 0.92

两个典型的失败:问 “How was incident E-0823 fixed?”,BM25 第 1 名就是 E-0823 的报告;稠密检索的前 3 名是三份别的事故报告——它们的内容几乎一样,错误码在向量里只占很小的分量。问 “How do I pick up a document I sent to the copier?”,BM25 什么都返回不了(查询里的词一个都不在语料里);稠密检索把 “pick up ↔ collect”、“copier ↔ printer” 对上了,第 1 名就是 “Badge release printing”。

3.4 混合检索与 Reciprocal Rank Fusion

既然两者以不同的方式失败,就两个都跑,再把结果合起来。怎么合?两种检索器的分数量纲完全不同(BM25 是 0–30,余弦在 −1 到 1),slides 推荐 RRF(Cormack et al., 2009)——合并名次而不是原始分数:

RRF(d)=∑r∈R1k+rankr(d),k=60\text{RRF}(d) = \sum_{r \in R} \frac{1}{k + \text{rank}_r(d)}, \quad k = 60

好处:不需要针对语料调参,对任意多个检索器都适用。

上表里有三个值得细看的现象:

  1. 换说法的题:两者的总分一样(Hit@1 都是 0.56),但赢的是不同的题——18 道题里,只有 BM25 排对第 1 的 8 道(带编号的变电站题:“which crew looks after site SS-…”,BM25 靠编号赢),只有稠密排对的 8 道(IT 帮助文档),两者都对的 2 道。RRF 合起来 0.83。互补才是混合的意义。
  2. 但 RRF 把字面量题的 Hit@1 从 1.00 拉到 0.34:稠密检索”很有把握地”把别的事故报告排在第 1,RRF 只看名次,于是正确答案被挤到后面(Hit@5 仍有 0.83)。融合提高的是召回,不保证第 1 名更准——所以后面还要接重排(§3.5)。
  3. 分数归一化相加在这个语料上反而好得多(全部 Hit@1 0.73 vs 0.41):它保留了”BM25 很有把握”的信息(错误码题第 1 名 8.7 分、第 2 名 3.9 分),而稠密检索的分数都挤在一起。代价是依赖分数的分布(异常值、不同查询的尺度、检索器的增减都会影响它)。RRF 更稳健——两种都值得在自己的评估集上试。

RRF 的常数 k(代码 02 演示 4):k 越小,第 1 名相对第 10 名的优势越大(k = 1 时 5.5 倍、k = 60 时 1.1 倍)。这个语料上 k 越小 Hit@1 越高(k = 1 时 0.526、k = 60 时 0.410),因为 BM25 的第 1 名在字面量题上几乎总是对的。k = 60 是原论文跨多个数据集的稳健选择,不一定是你语料上的最优。

3.5 cross-encoder 重排

slides(引用 Humeau et al., 2020)对比了两种结构:

  • bi-encoder(检索用):问题和段落分别编码,最后才比较。段落的向量可以预先算好,所以能扫描整个语料。
  • cross-encoder(重排用):问题和段落一起送进模型,两者之间有完整的注意力,直接输出一个相关度分数。更准,但每个 (问题, 段落) 对都要现算——只能给一小批候选重排。

于是有了两阶段检索:第一阶段(BM25 + 稠密)广撒网,召回几十个候选;第二阶段 cross-encoder 精排,取前几个。我们的玩具 cross-encoder 逐句、逐词地比对问题和段落(相同的词 1 分、同一概念 0.8 分,按 IDF 加权),并要求问题里的字面量(带数字的词)原样出现在那一句里。代码 04 演示 1(上下文化分块):

第一阶段字面量:Hit@20 / Hit@1 重排前 → 后换说法section
BM251.00 / 1.00 → 1.000.72 / 0.56 → 0.671.00 / 0.83 → 1.00
稠密0.97 / 0.14 → 0.971.00 / 0.56 → 0.891.00 / 0.88 → 1.00
混合(RRF)1.00 / 0.34 → 1.001.00 / 0.83 → 0.941.00 / 0.92 → 1.00

重排只能重新排序第一阶段给它的候选:Hit@20 是它的天花板(BM25 在换说法题上只召回了 72%,重排也就到 0.67)。第一阶段的任务是”别漏”,第二阶段的任务是”排准”。 召回深度(混合 RRF 召回 N 个再重排,全部 78 题):

召回 N135102050
Hit@N(重排的天花板)0.6280.8720.9101.0001.0001.000
重排后 Hit@10.6280.8590.8970.9740.9740.962

召回 10–20 个之后就饱和了;召回 50 个反而略降——候选越多,重排器把某个干扰项排到第 1 的机会也越多。重排器也会犯错,它不是”真相”。

3.6 late interaction:ColBERT

ColBERT(Khattab & Zaharia, 2020)走在两者之间:每个 token 一个向量,问题和段落分别编码(所以段落的 token 向量可以预先算好),只在打分的最后一步才交互——这就是 “late interaction”。打分用 MaxSim:

Sq,d=∑i∈qmax⁡j∈d  qi⋅djS_{q,d} = \sum_{i \in q} \max_{j \in d} \; \mathbf{q}_i \cdot \mathbf{d}_j

问题里的每个 token 各自去找段落里最像的那个 token,再把这些最大值加起来。它避免了”一个向量平均掉很多主题”,又保留了”预先计算、能扫描整个语料”的能力。代码 04 演示 2(同样的玩具 token 向量,只是不先求平均):

检索器字面量 Hit@1换说法 Hit@1section Hit@1全部 Hit@1
BM251.000.560.830.83
稠密(一段一个向量)0.140.560.880.47
ColBERT(每个 token 一个向量)0.971.001.000.99
混合 + cross-encoder 重排1.000.941.000.97

错误码能和段落里同一个错误码精确对上(余弦为 1),不会被一段话里几十个别的词平均掉;同义的 token 向量也相近,所以换说法的题也保持了稠密检索的长处。(真实 ColBERT 的 token 向量是 BERT 结合上下文算出来的,我们的玩具版不看上下文。)

3.7 快与慢的检索器

代码 04 演示 3 统计了每种方法的代价(78 道单跳题的平均):

方法每个查询的计算索引里存什么
BM2535 条倒排记录倒排索引 1,577 条记录
稠密(单向量,暴力扫描)94 次向量比较94 × 64 = 6,016 个数
ColBERT6,783 次 token 向量比较1,850 个 token × 64 = 118,400 个数(20 倍)
cross-encoder 重排 20 个1,354 次词对比较不需要索引
cross-encoder 扫全部块7,417 次词对比较不需要索引

slides 的表(MS MARCO;延迟来自 Khattab & Zaharia 2020,DPR 来自 Karpukhin et al. 2020,硬件不同):

检索器预先计算查询延迟擅长不擅长
BM25(稀疏)倒排索引62 ms(快)精确的词:ID、名字、错误码同义改写
DPR(双编码器)每段一个向量几十 ms(快)大规模的语义匹配罕见字面量;需要标注的问答对
ColBERT(late interaction)每个 token 一个向量端到端 458 ms(中等)大规模的 token 级证据索引大小(按 token 计)
cross-encoder(BERT-base)无每 1,000 段 10,700 ms(慢)短名单上的准确率不能扫描整个语料

这就是工业界常见的三段式:便宜的检索器广撒网(BM25 + 稠密,配合 ANN)→ 中等代价的精排(ColBERT / 小的 cross-encoder)→ 昂贵的阅读器(LLM)只看前几块。


4. 从检索到答案:端到端的 RAG · End to end

检索指标只说明”对的块有没有被找到”。用户关心的是答案。代码 05 演示 1 把检索器和阅读器接起来,在 93 道题(单跳三类 + 多跳)上跑单次 RAG(前 5 块),每格是”答对 / 答错 / 答不出”:

流水线literal(35)paraphrase(18)section(24)multihop(16)总准确率
闭卷(不检索)0 / 0 / 350 / 0 / 180 / 0 / 240 / 0 / 160.0%
BM25 · 上下文化分块35 / 0 / 013 / 0 / 524 / 0 / 02 / 14 / 079.6%
稠密 · 上下文化分块14 / 21 / 013 / 5 / 024 / 0 / 02 / 14 / 057.0%
混合 + 重排 · 按结构分块35 / 0 / 018 / 0 / 09 / 15 / 07 / 9 / 074.2%
混合 + 重排 · 上下文化分块35 / 0 / 018 / 0 / 024 / 0 / 07 / 9 / 090.3%

几个要点:

  1. 检索决定上限。 阅读器再完美,也读不到没被检索到的东西。
  2. “答错”比”答不出”多得多,而且更危险。 稠密检索在字面量题上找到的是别的事故报告,阅读器就从那里拿了一个答案(21 道答错,没有一道说”不知道”);按结构分块时,阅读器在上下文里看到好几份政策的 Receipts 节,分不清哪份是差旅的,就挑了排名最高的那个(15 道答错)。上下文里有一个”长得像答案”的东西时,阅读器很少拒答——真实的 LLM 也是这样。自信地答错比答不出更危险,因为用户无从察觉。
  3. 上下文化分块不只帮了检索器,也帮了阅读器:块里带着”[Travel Policy > Receipts]“,阅读器才能确认这一节说的是差旅。
  4. 多跳题大多答错:问题里没有团队名,单次检索很难把对的团队页面捞进来。这是 §5 的主题。

5. 智能体式 RAG:检索在循环里 · Agentic RAG

5.1 三个决定

slides 在这里转了个弯:“到目前为止的一切,都只是把一个问题答好。没有一样东西决定要不要检索、查什么、什么时候停。“于是:

  • 检索应该是智能体采取的一个动作(第 1 讲的 ReAct 里,search 就是一个动作)。
  • 三个决定:(1) 检索还是直接回答;(2) 发出什么查询;(3) 结果是否足够好。

slides 介绍了三项代表性的工作,分别侧重不同的决定。

5.2 Self-RAG:模型自己决定、自己评判

Self-RAG(Asai et al., ICLR 2024)训练一个模型在生成的过程中输出特殊的反思 token:要不要检索(Retrieve)、这段检索结果相关吗(IsRel)、这句生成的话有检索结果支持吗(IsSup)、这个回答整体有用吗(IsUse)。于是模型能按需检索(不需要时不检索)、批评检索到的段落和自己的生成,提高回答的质量、事实性和可验证性。它把三个决定都训练进了模型本身。

5.3 CRAG:给检索结果打分

CRAG(Corrective RAG,Yan et al., 2024)在检索之后加了一个轻量的检索评估器:评估检索到的文档和问题的相关度,给出一个置信度,据此触发不同的动作——“正确”时提炼检索到的知识,“不正确”时丢掉它、改用网页搜索,“模棱两可”时两者结合。它专注于第 3 个决定:结果够不够好。

5.4 Search-R1:用强化学习学会怎样搜

Search-R1(Jin et al., 2025)用强化学习训练 LLM 在推理过程中多轮地调用搜索引擎:模型自己决定什么时候写一个搜索查询、搜什么,检索结果插回推理过程中,最后只按答案对不对给奖励(检索到的 token 不参与损失计算)。它把第 1、2 个决定都交给了一个学出来的策略——和第 2 讲 §4.4 的 RLVR 是同一个思路。

5.5 多跳:为什么必须逐跳检索

回到我们的多跳题:“Who leads the team that maintains substation SS-339?”——答案需要两个事实:SS-339 由哪个团队维护(在变电站页面上),这个团队由谁领导(在团队页面上)。问题里没有团队名,所以第二个事实无法用原问题检索到。

逐跳检索:先把问题拆成子问题,第一跳问 “Which team maintains substation SS-339?”,读到 “Grid Ops East”;第二跳用它填空,问 “Who leads the Grid Ops East team?”。每一跳的查询都由上一步读到的东西决定。代码 06 演示 1(16 道多跳题):

做法答对第一跳找对平均检索次数平均上下文词数
单次检索 · BM252/1616/161.00137
单次检索 · 混合 + 重排7/1616/161.00128
单次检索 · 混合 + 重排,k = 2012/1616/161.00580
逐跳检索 · BM2516/1616/162.00284
逐跳检索 · 稠密10/1610/162.00278
CRAG 式(评估 + 改写 + 换检索器)16/1616/162.00284

单次检索几乎总能找到变电站那一块,却很难找到团队那一块。把 k 从 5 加到 20 能多”碰”到几个团队页面(这个语料里只有 10 个团队),但上下文翻了四倍多——在团队成百上千的真实语料里,靠多塞来碰运气不可行。逐跳检索两次就几乎全部答对;稠密检索的逐跳版本失败在第一跳——它认不准变电站编号(又是”罕见字面量”)。原课 HW1 的多跳 RAG 练的正是这个循环。

5.6 查什么:查询改写

换说法的题是另一类”查询本身不好”的情况。最简单的改写是查询扩展:把查询里每个认得的词换成它所有的同义词一起查(rag_agent.expand_query)。代码 06 演示 2(18 道换说法的题):

检索Hit@1Hit@5端到端答对
BM250.560.7213/18
BM25 + 查询扩展1.001.0018/18
稠密0.560.7213/18
混合 + 重排0.941.0018/18

扩展是把”同义词知识”放在查询端,稠密检索是把它放在编码器里——两条路解决的是同一个问题。真实系统里常让 LLM 来改写:多写几种问法、把口语换成文档里的术语,或者 HyDE——先让模型写一段”假想的答案”,再拿它去检索(假想答案和真实文档的用词更接近)。代价是扩展会引入噪声词(多义词),在字面量题上可能把别的文档排上来——所以最好只在第一次检索效果不好时才用。

5.7 结果够不够好:一个 CRAG 式的智能体

rag_agent.crag_answer 把三件事串起来:先拆子问题;每一跳先用便宜的 BM25 检索;用 cross-encoder 给结果和子问题的相关度打分(拿整个多跳问题去评第一跳的结果是不公平的——第一跳的文档本来就回答不了整个问题;我们第一版代码就犯了这个错,评估器把所有第一跳都判成”不够好”);分数低于阈值就改写查询(同义词扩展)、换更贵的检索器(混合,再到混合 + 重排)再来,最多 3 次;然后读、进入下一跳。代码 06 演示 3(94 道题,含版本题):

做法literalparaphrasesectionmultihopversioned总准确率平均检索次数
单次 · BM2535/3513/1824/242/160/178.7%1.00
单次 · 混合 + 重排35/3518/1824/247/160/189.4%1.00
逐跳 · 混合 + 重排35/3518/1824/2416/160/198.9%1.17
CRAG 式(BM25 起步)35/3518/1824/2416/160/198.9%1.31

评估器只在换说法的题上触发了重试(19 次),字面量和 section 题第一次就”够好”。CRAG 式的准确率和”每次都用最贵的流水线”一样,平均检索次数略多,但只有那 19 次重试用到了更贵的检索器,其余都是便宜的 BM25——评估器的价值是把昂贵的操作留给需要它的题。

版本题它救不了:评估器只判断”相关不相关”,2024 版和 2026 版都相关——相关不等于正确。这类问题要靠元数据(§6.1),不是靠更多的检索。

代码 06 演示 4 打印了两条轨迹,形状和第 1 讲的 ReAct 一模一样:

Question: How do I pick up a document I sent to the copier?
  Hop 1 | Search[BM25]: How do I pick up a document I sent to the copier?
        | Grade: best relevance 0.00 -> not good enough, rewrite and retry
  Hop 1 | Search[混合]: How do I pick up ... (+同义词扩展)
        | Grade: best relevance 0.40 -> not good enough, rewrite and retry
  Hop 1 | Search[混合+重排]: How do I pick up ... (+同义词扩展)
        | Grade: best relevance 0.40 -> not good enough, rewrite and retry
  Read  | hop 1 answer: tap your badge on the printer  (from: Badge release printing)
  Finish[tap your badge on the printer]  -> correct

注意第二、三次:评估器始终觉得”不够好”(0.40),智能体用完重试次数后照样去读——而读到的恰恰是对的文档。评估器也会错;“最多重试几次、阈值多少”这些停止规则本身就是要用评估集来调的设计参数(§6.3 的”没有停止规则”说的就是它)。


6. RAG 的问题 · Issues with RAG

slides 把 RAG 的问题按发生的位置分成三类:

检索端阅读端循环端
召回天花板:缺失的证据限制了智能体lost in the middle:位置偏差错误累积:早期的错误影响后面的决定
索引过期:嵌入没有更新干扰:相似但无关的内容没有停止规则:缺少明确的结束条件
权限泄露:用了不该访问的内容证据冲突:来源或知识互相矛盾提示词注入:检索到的文字操纵了智能体

6.1 检索端

召回天花板:§4 的表已经说明,检索没找到的,阅读器无从读起;§3.5 说明,重排也越不过第一阶段的召回。工程对策:用 Recall@k(而不是只看 Hit@1)评估第一阶段;混合检索;逐跳检索。

索引过期与证据冲突(代码 05 演示 3)。远程办公政策 2026 年修订了(从每周 2 天改成 3 天):

情况答案依据
2026 版发布了,但索引没有重建2 days per weekRemote Work Policy (2024)
重建索引:两个版本都在2 days per weekRemote Work Policy (2024)
重建索引 + 只保留每个版本族的最新版3 days per weekRemote Work Policy (2026 revision)

索引过期时,系统自信地给出旧答案,而且看起来有理有据(引用了一份真实存在的文档)。两版都在时,检索结果是 [2024 版, 2026 版, Equipment Policy],阅读器用了排名更高的那个——排名高低和”哪个是现行的”毫无关系。对策:文档变更时增量重建索引(记录每份文档的版本或哈希);给块带上元数据(生效日期、是否被取代),检索时过滤,或者让阅读器在提示词里看到日期、被要求以最新的为准;评估集里放”刚改过的事实”。

权限泄露(代码 05 演示 3):普通员工问 “What is the salary range for a senior engineer?”——不做权限过滤时,检索器把仅 HR 可见的薪资文档捞了出来,系统答出了 “140,000 to 175,000 dollars”;检索前按 ACL 过滤,答 “unknown”;HR 用户问,正常答出。不做过滤时,94 道普通问题里也有 1 道的上下文混进了受限文档。检索器不知道谁在问。ACL 过滤要在检索之前做:用户看不到的块根本不进候选集——而不是先检索、再指望阅读器”别说出来”(那时候受限内容已经进了上下文、日志和缓存)。这是 前置讲 P4 里”最小权限”在 RAG 里的样子;第 14 讲的隐私部分会再展开。

6.2 阅读端

lost in the middle(Liu et al., TACL 2024):在多文档问答里,真实 LLM 在答案位于上下文开头或结尾时最准,在中间时明显变差(某些设置下甚至不如完全不给文档);而且检索更多文档时,检索器的召回还在涨,阅读器的准确率却很快饱和。代码 05 演示 2 用一个显式的旋钮模拟它(离上下文两头越远的块越可能被阅读器忽略,最多忽略一半):

kRecall@k(检索)准确率:没有位置偏差准确率:位置偏差 0.5上下文词数
150.9%50.9%50.9%35
381.1%81.1%73.6%107
586.8%86.8%77.4%183
10100.0%100.0%83.0%377
20100.0%100.0%81.1%770

对一个完美的读者,多放几块只有好处;有位置偏差时,准确率始终明显低于召回,k 从 10 加到 20,召回不再变,准确率反而下降、上下文翻倍。把正确的块固定放在 10 块上下文的第 j 个位置,准确率是 100%、84.9%、73.6%、62.3%、54.7%、45.3%、66.0%、75.5%、86.8%、100%——一条 U 形曲线(形状由旋钮决定,对应论文里真实模型的现象)。对策:用重排把最好的放在最前;k 不要贪多;最重要的内容放在两头;必要时先压缩 / 摘要上下文。

干扰:相似但无关的内容。§4 的稠密检索和按结构分块两行就是它——上下文里的”别的事故报告”、“别的政策的 Receipts 节”让阅读器自信地答错。对策:更准的检索(重排)、带上下文的块、提示词里要求”只用和问题实体一致的证据,否则说不知道”、在评估里专门统计”答错”和”答不出”。

证据冲突:来源之间、或者检索结果和模型自己的参数知识之间互相矛盾(上面的 2024 vs 2026 就是一例)。模型该信谁?没有通用的答案——要靠元数据(日期、来源的权威性)和明确的提示词规则。

6.3 循环端

错误累积:智能体式 RAG 里,第一跳读错了团队名,第二跳就会一本正经地查错的团队(第 1 讲 §6.1)。没有停止规则:什么时候算”查够了”?§5.7 的评估器阈值、最大重试次数就是停止规则,它们本身需要评估和调整。提示词注入:检索到的文字可能包含写给 AI 的指令(“忽略之前的指令……”),而检索结果是不可信的数据——前置讲 P4 的间接注入实验在 RAG 里原样成立;第 14 讲会系统地讲防御。


7. 用真实模型当阅读器 · A real reader

代码 07(可选)让真实的 Claude 当阅读器:

  1. 闭卷问 10 道 Orrin Grid 的题:它是老实说”不知道”,还是编一个?
  2. RAG:给它混合 + 重排检索到的 5 块(带编号),要求一句话回答并用 [编号] 引用;判分看答案字符串和引用的块是否来自正确的文档。
  3. 检索失败时:故意用 BM25 去答几道它找不到的换说法题,提示词要求”找不到就说 I don’t know”——它是照做,还是从不相关的块里拼出一个答案?
pip install anthropic
python3 07_real_llm.py --n 10

--toy 不联网,用我们的确定性阅读器冒充模型跑同一段代码。真实模型每次的回答可能不同;值得记录的是三列的分布,而不是某一次的结果。引用不只是给人看的——有了引用,你可以自动检查”答案有没有依据”(第 13 讲的判分器会用到)。


8. 小结 · Summary

  1. 为什么检索:把模型没有的、会变的、需要核对的知识及时送进上下文;retriever–reader 是骨架,RAG(Lewis et al.)把检索当隐变量和生成器一起训练;今天的系统多用现成的组件拼装,每个组件可以单独评估。
  2. 索引:块太小丢上下文、劈开证据句;块太大平均掉主题、拖长上下文。重叠救回被劈开的句子;上下文化检索(给块加上来源)和 late chunking(把文档上下文带进向量)解决”块里缺上下文”;RAPTOR、GraphRAG 为”需要汇总”的问题在建索引时多花算力;ANN 用一点召回换大量速度。
  3. 检索:BM25 对罕见字面量极其敏感、对同义改写无能为力;稠密检索正好相反。混合检索(RRF 或分数融合)提高召回,但可能把第 1 名弄错——所以两阶段:广撒网 + cross-encoder 精排。ColBERT 用”每个 token 一个向量”在两者之间取得平衡,代价是索引大小。指标要选对:多跳题看 Recall,不只看 Hit。
  4. 端到端:检索决定上限;阅读器面对”长得像答案”的干扰时会自信地答错,这比答不出更危险。
  5. 智能体式 RAG:检索是一个动作,有三个决定——要不要检索、查什么、结果够不够好(Self-RAG、CRAG、Search-R1 各有侧重)。多跳问题必须逐跳检索;查询改写救回换说法的题;检索评估器把昂贵的操作留给需要的题,但它本身也会错,而且”相关”不等于”正确”。
  6. RAG 会坏在三处:检索端(召回天花板、索引过期、权限泄露)、阅读端(lost in the middle、干扰、证据冲突)、循环端(错误累积、没有停止规则、注入)。每一种都有工程对策,而且都应该进评估集。

9. 常见误区 · Common pitfalls

  • “稠密检索比 BM25 先进,直接换掉就行。” 它们以不同的方式失败。错误码、产品 ID、人名、精确的术语,BM25 往往更好;生产系统几乎都用混合检索。
  • “混合检索一定比单个检索器好。” 它提高召回,但可能让第 1 名变差(代码 02:字面量题 1.00 → 0.34)。后面要接重排,或者认真比较融合方式。
  • “重排器说相关的就是对的。” 重排器只能重排候选,而且也会犯错(召回 50 个反而略降)。
  • “Hit@5 很高,检索没问题。” 多跳、汇总类的问题需要多条证据,要看 Recall;端到端还要看阅读器有没有被干扰。
  • “块越小越精确。” 太小会把证据句劈开、把上下文切掉(固定 20 词时字面量题 Hit@1 只有 0.09)。
  • “上下文窗口很大,把前 20 块都塞进去更保险。” lost in the middle、干扰、成本都会随 k 增长;召回涨了,准确率不一定涨。
  • “权限可以交给提示词:告诉模型别泄露。” 受限内容一旦进了上下文,就已经泄露给了日志、缓存和任何能操纵模型的人。过滤要在检索之前做。
  • “索引建好就完事了。” 文档会变。索引过期时系统会自信地给出旧答案,而且看起来有理有据。
  • “智能体式 RAG 就是多检索几次。” 关键在于每次检索的查询由上一步读到的东西决定(逐跳),以及有一个判断”够不够好”和”什么时候停”的机制。
  • “检索到的文档是可信的。” 它们是数据,不是指令;可能过期、可能互相矛盾、可能被人植入了注入文字。

10. 练习 · Exercises

练习 1(BM25 手算):语料有 N = 100 块,平均长度 avgdl = 20。查询是 “outage report”。块 A 长 10 个词,含 “outage” 1 次、“report” 2 次;块 B 长 40 个词,含 “outage” 2 次、“report” 0 次。“outage” 出现在 5 块里,“report” 出现在 50 块里。用 Lucene 的 IDF、k₁ = 1.2、b = 0.75 算两块的分数。哪块更高?

参考答案 IDF(outage) = ln(1 + 95.5/5.5) = ln(18.36) ≈ 2.91;IDF(report) = ln(1 + 50.5/50.5) = ln 2 ≈ 0.69。 块 A:norm = 1.2 × (1 − 0.75 + 0.75 × 0.5) = 0.75。outage:2.91 × 1 × 2.2 / (1 + 0.75) = 3.66;report:0.69 × 2 × 2.2 / (2 + 0.75) = 1.11。合计约 4.77。 块 B:norm = 1.2 × (0.25 + 0.75 × 2) = 2.1。outage:2.91 × 2 × 2.2 / (2 + 2.1) = 3.12。合计约 3.12。 块 A 更高:它短,又同时含两个词;块 B 里 "outage" 出现两次,但饱和 + 长度惩罚让第二次的价值很有限。

练习 2(指标):一道题有 3 条证据。检索结果前 5 名的相关度是 [0, 1, 0, 1, 0]。算 Hit@5、MRR@5、Recall@5、nDCG@5(二值相关度)。

参考答案 Hit@5 = 1;MRR@5 = 1/2 = 0.5;Recall@5 = 2/3 ≈ 0.67。DCG = 1/log₂3 + 1/log₂5 = 0.631 + 0.431 = 1.062;理想排序是三条证据排在前三:IDCG = 1 + 0.631 + 0.5 = 2.131;nDCG@5 ≈ 0.50。可以用 `rag_lab.metrics_at_k([0, 1, 0, 1, 0], 3, 5)` 验证。

练习 3(RRF):两个检索器的结果:BM25 = [A, B, C],稠密 = [C, D, A]。用 k = 60 算 RRF,排出前 4 名。再用 k = 1 算一遍,排名变了吗?

参考答案 k = 60:A = 1/61 + 1/63 = 0.03227;C = 1/63 + 1/61 = 0.03227(和 A 一样);B = 1/62 = 0.01613;D = 1/62 = 0.01613。前 4 名:A、C(并列)、B、D(并列)。k = 1:A = 1/2 + 1/4 = 0.75;C = 1/4 + 1/2 = 0.75;B = 1/3;D = 1/3——排名不变,但分差被放大。两个检索器各把对方的第 1 名排在第 3,RRF 认为它们同样好;平局怎么破(例如按某个检索器的名次)是实现细节,值得写清楚。

练习 4(分块):你要给一份 200 页的设备维修手册建索引,手册有章、节、编号的步骤(“Step 4: Torque the M8 bolts to 25 N·m”)。用户常问 “What torque for the M8 bolts on the pump housing?”。你会怎样分块?为什么?

参考答案(之一) 按结构分块(以"节"或"一组步骤"为单位),每块加上"手册 > 章 > 节"的上下文前缀(上下文化检索),因为 "Step 4: Torque the M8 bolts…" 这一块里可能没有 "pump housing"。步骤编号和规格(M8、25 N·m)是字面量,必须保留 BM25(混合检索)。一个步骤跨多段时允许适度重叠。表格单独成块并保留表头。评估集里要有"只在章标题里出现的部件名"这类题——代码 03 的 section 题就是它的缩小版。

练习 5(混合检索的失败):代码 02 里 RRF 把字面量题的 Hit@1 从 1.00 拉到 0.34。不加重排器,你能想到哪些改法?各有什么代价?

参考答案 (a) 用分数融合代替 RRF(代码 02 里全部 Hit@1 0.73),代价是对分数分布敏感;(b) 加权 RRF,或者查询路由:查询里有错误码 / 编号这类字面量时提高 BM25 的权重、甚至只用 BM25(一个规则或小分类器就能判断),代价是多了一个需要维护的路由规则;(c) 减小 RRF 的 k(k = 1 时全部 Hit@1 0.526),代价是更依赖单个检索器的第 1 名。所有改法都要在评估集上看对**其他**题型有没有伤害。

练习 6(多跳题的设计):有人想给评估集加一道反方向的多跳题:“Where is the substation maintained by the team that Mara Quell leads?”(Mara Quell 领导的团队维护的那个变电站在哪个县?)这道题有什么问题?

参考答案 "Mara Quell 领导的团队"维护的变电站不止一个(16 个变电站分给 10 个团队),所以这道题**有多个正确答案**,"那个变电站"根本不存在。多跳问题的每一跳最好是"一对一"的;否则要么给问题加限定,要么让智能体返回一个列表并检查是否找全(这时 Recall 比 Hit 更关键)。这是构建评估集时的常见陷阱:标准答案要唯一且可验证(第 12 讲)。

练习 7(权限):有人提议”先不做过滤地检索前 20 个,再把用户无权看的删掉,剩下的取前 5 个给阅读器”。这和”检索前过滤”有什么区别?举一个它会出问题的情形。

参考答案 功能上,如果删掉之后剩下的还够 5 个,答案可能一样;但 (1) 受限的块已经被打分、可能进了日志、缓存、监控或重排模型的输入;(2) 如果前 20 个里有很多受限块,删完可能不够 5 个,有用的公开内容反而被挤掉了;(3) 有些系统会把"被删掉了几个"之类的信息暴露出来,本身就泄露了"存在相关的受限文档"。检索前过滤让受限内容从一开始就不在候选集里。

练习 8(设计:一个最小的 RAPTOR):用本讲的代码,怎样为六份报销政策建一个两层的 RAPTOR 式索引,让 “Which policy has the highest spending limit?” 这类需要汇总的问题能被检索到?

参考答案(之一) 第一层:现有的块(每节一块)。第二层:把所有政策的 Limits 节按"同一节"聚在一起(这里聚类可以直接按小标题分组),为这一组写一段总结(真实系统用 LLM;这里可以手写或用模板:"Spending limits: Travel 150 dollars per day; Procurement 5,000 dollars per order; …"),编码后加入索引,元数据标明它来自哪些块。检索时两层一起搜:问单个政策细节的题匹配叶子,"哪个最高"这类题匹配总结节点。评估时要比较:加了总结节点之后,单跳题的准确率有没有下降(总结节点可能成为新的干扰项)。

练习 9(停止规则):在 crag_answer 里把阈值 threshold 从 0.6 改到 0.3 和 0.9,先预测准确率和平均检索次数会怎样变,再运行验证。

参考答案 阈值低:更多检索结果被判为"够好",重试变少;第一次检索(BM25)效果不好却被接受的题变多,准确率可能下降。阈值高:几乎每次都判"不够好",检索次数大增;准确率不一定更高(最后一次的结果未必更好,而且评估器本身会错)。编写本讲时的实际结果(94 道题):阈值 0.3 → 92 题答对、平均 1.21 次检索;0.6 → 93 题、1.31 次;0.9 → 93 题、2.66 次。阈值从 0.6 提到 0.9,检索次数翻倍,准确率一点没变。停止规则是一个要在评估集上调的旋钮,必须同时看准确率和代价。

练习 10(开放题):原课 HW1 要你在 Enron 邮件档案(混进了少量虚构的”针”)上做多跳 RAG。结合本讲,列出你会做的三个检索决策和三个评估决策。

参考答案(之一) 检索:(1) 混合检索——邮件里有大量人名、邮箱、日期、项目代号(字面量),也有大量口语化的改写;(2) 以邮件(或邮件 + 线程上下文)为块,块前加"发件人 / 收件人 / 日期 / 主题"作为上下文前缀;(3) 多跳题用逐跳检索 + 子问题,必要时用结果评估决定是否改写。评估:(1) 检索和端到端分开评:Recall@k 看证据有没有找全,端到端看答案和引用;(2) 统计"答错"和"答不出",特别关注被相似邮件干扰的情况;(3) 专门为"针"(只在虚构邮件里出现的事实)建题,确保模型不能靠参数知识或通用常识答对。

11. 延伸阅读 · Further reading

本讲的阅读材料

  • Lewis et al., Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks, NeurIPS 2020.
  • Khattab & Zaharia, ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT, SIGIR 2020.

检索的基础

  • Chen et al., Reading Wikipedia to Answer Open-Domain Questions(DrQA), ACL 2017.
  • Robertson & Zaragoza, The Probabilistic Relevance Framework: BM25 and Beyond, 2009.
  • Karpukhin et al., Dense Passage Retrieval for Open-Domain Question Answering(DPR), EMNLP 2020;Guu et al., REALM, ICML 2020.
  • Cormack, Clarke & Büttcher, Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods, SIGIR 2009.
  • Humeau et al., Poly-encoders: Architectures and Pre-training Strategies for Fast and Accurate Multi-sentence Scoring, ICLR 2020.
  • Malkov & Yashunin, Efficient and Robust Approximate Nearest Neighbor Search Using Hierarchical Navigable Small World Graphs(HNSW), TPAMI 2020.

索引

  • Anthropic, Introducing Contextual Retrieval, 2024.
  • Günther et al., Late Chunking: Contextual Chunk Embeddings Using Long-Context Embedding Models, 2024.
  • Sarthi et al., RAPTOR: Recursive Abstractive Processing for Tree-Organized Retrieval, ICLR 2024.
  • Edge et al., From Local to Global: A Graph RAG Approach to Query-Focused Summarization, 2024.
  • Gao et al., Retrieval-Augmented Generation for Large Language Models: A Survey, 2024.

智能体式 RAG 与失败模式

  • Asai et al., Self-RAG: Learning to Retrieve, Generate, and Critique through Self-Reflection, ICLR 2024.
  • Yan et al., Corrective Retrieval Augmented Generation, 2024.
  • Jin et al., Search-R1: Training LLMs to Reason and Leverage Search Engines with Reinforcement Learning, 2025.
  • Liu et al., Lost in the Middle: How Language Models Use Long Contexts, TACL 2024.

12. 下一讲预告 · Next lecture

第 4 讲 工具使用与函数调用:本讲的检索在 §5 里已经变成了智能体的一个”动作”。下一讲把”动作”推广到任意的工具:模型怎样学会用工具(Toolformer、ToolLLM、Gorilla);工具怎样被描述给模型(从 REST API 到函数调用的 JSON Schema,再到 MCP 的 tools/list 与 tools/call——前置讲 P2 已经从零实现过一个迷你 MCP);什么是好的工具设计(智能体–计算机接口 ACI、防呆的参数);怎样校验、执行、处理错误和重试;代码执行与沙箱(前置讲 P1、P4);以及怎样评估工具使用(BFCL、τ-bench 与 pass^k)。配套代码会写一个”校验与执行分离”的工具注册表、一个能抵抗截断和嵌套的文本工具调用协议,并把本讲的检索器包装成一个工具。

← 返回课程目录