对应 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 流水线是怎样搭起来的、在哪里会坏,以及索引什么、怎样搜索。“展开来说,学完这一讲你应该能够:
- 说清楚为什么要给 LLM 接检索(知识截止、长尾、幻觉、可溯源、可更新),以及 retriever–reader 和 RAG 两种框架的区别。
- 设计索引:选嵌入、选分块方式(固定大小、重叠、按结构、上下文化、late chunking),知道 RAPTOR、GraphRAG 解决什么问题,知道近似最近邻用召回换了速度。
- 从零实现 BM25,并解释 IDF、词频饱和、长度归一化各自的作用;会算 Hit@k、MRR@k、nDCG@k、Recall@k。
- 说清楚稀疏检索和稠密检索各自在哪类查询上失败,以及为什么要混合(RRF)、为什么混合之后还要重排(cross-encoder),ColBERT 的 late interaction 在两者之间处于什么位置。
- 把检索变成智能体的一个动作:拆子问题、逐跳检索、评估检索结果、改写查询(Self-RAG、CRAG、Search-R1 的思路),并能解释多跳问题为什么必须这样做。
- 列出 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”。形式化:
- 输入:一大批文档 和问题 ;输出:答案字符串 。
- 检索器:(K 预先定好,例如 100)。
- 阅读器:——这是一个阅读理解问题。
DrQA 的检索器是一个固定的 TF-IDF 稀疏模型;阅读器是在 SQuAD 等数据上训练的神经阅读理解模型——今天它可以直接是一个零样本的 LLM。这个两段式的结构就是今天所有 RAG 系统的骨架。
1.4 RAG:把检索和生成一起训练
Lewis et al.(2020)的 RAG 把检索器和生成器连起来,端到端地训练(slides 也提到同期的 REALM、DPR 等工作):
- 检索器 :给问题 返回前 K 段 。用 DPR:,其中 、;前 K 个用最大内积搜索(MIPS)求出。
- 生成器 :一个 seq2seq 模型(BART),把问题和检索到的段落拼在一起,一个 token 一个 token 地写答案。
- 把检索到的段落当成隐变量,按检索器给它的概率加权:
- 两种变体:RAG-Sequence——挑一段,用它生成整个答案(对每段算一遍再加权);RAG-Token——每生成一个 token 都可以重新”挑”一段。
- 训练:最小化负的边缘对数似然 ,同时更新查询编码器和生成器(文档编码器和索引保持不动——否则每一步都要重建索引)。
论文报告:在 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 code | reset your SecureLink token | 0.78 |
| which crew looks after the site | it is maintained by the team | 0.75 |
| get a new work computer | laptops are refreshed every 4 years | 0.39 |
| travel receipts deadline | conference receipts deadline | 0.60 |
| incident E-0283 | incident E-0382 | 0.94 |
| incident E-0283 | substation 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 块的总词数 |
|---|---|---|---|---|
| 不分块(整份文档) | 70 | 39.6 | 112/112 | 228 |
| 固定 40 词 | 106 | 26.1 | 99/112 | 130 |
| 固定 40 词 + 重叠 10 | 106 | 29.5 | 104/112 | 137 |
| 固定 20 词 | 173 | 16.0 | 57/112 | 74 |
| 固定 20 词 + 重叠 5 | 187 | 17.9 | 99/112 | 78 |
| 按结构(每节一块) | 94 | 29.5 | 112/112 | 128 |
| 上下文化(结构 + 来源前缀) | 94 | 30.5 | 112/112 | 132 |
块越小,被块边界劈开的证据句越多(固定 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.88 | 0.14 / 1.00 / 0.51 | 1.00 | 1.00 / 0.65 |
| 固定 40 词 | 0.60 / 0.08 / 0.42 | 0.14 / 0.33 / 0.31 | 0.54 | 0.29 / 0.36 |
| 固定 20 词 | 0.09 / 0.08 / 0.08 | 0.06 / 0.29 / 0.23 | 0.62 | 0.17 / 0.15 |
| 按结构 | 1.00 / 0.08 / 0.60 | 0.14 / 0.42 / 0.33 | 0.88 | 0.21 / 0.41 |
| 上下文化 | 1.00 / 0.83 / 0.83 | 0.14 / 0.88 / 0.47 | 0.88 | 0.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 个。精确搜索是暴力扫描,每个查询 。N 到了百万、上亿,就需要近似最近邻(ANN):“用一点召回换大量速度”。slides 列了三种:
- IVF(倒排文件索引):先把所有向量聚成若干簇,查询时只在离查询最近的几个簇里扫描。
- PQ(乘积量化):把每个向量压缩成一个很短的码,省内存(距离用码近似计算)。
- HNSW(分层可导航小世界图):所有向量都在最底层,越往上层越稀疏;搜索时从顶层出发,贪心地往查询靠近,卡住了就下降一层;大约 跳就到,召回很高。建图时一个一个插入,每个向量随机决定它最高到哪一层(大多数只在第 0 层),在每一层连上 M 个最近邻,再加一些跨簇的连接,免得贪心搜索被困在局部。
代码 02 演示 5 在合成数据(1,200 个 8 维向量、30 个簇)上实现了 IVF:
| 方法 | 搜几个簇 | Recall@10(相对暴力扫描) | 每个查询算几次距离 |
|---|---|---|---|
| 暴力扫描 | – | 100.0% | 1,200 |
| IVF | 1 | 91.0% | 81 |
| IVF | 2 | 99.0% | 118 |
| IVF | 4 | 99.7% | 205 |
| IVF | 8 | 100.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)“。公式:
三个部分,各管一件事:
- IDF(t):这个词有多罕见(N 是块数,n(t) 是含有这个词的块数)。罕见的词更能区分文档。原式在一个词出现在一半以上的块里时会变负;Lucene / Elasticsearch 在括号里加 1()保证非负,我们的代码默认用这个。
- 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’]):
| 名次 | 总分 | 相关 | 各查询词的贡献 |
|---|---|---|---|
| 1 | 8.68 | 是 | root 1.09、cause 1.09、incident 1.60、e-1474 4.90 |
| 2 | 3.90 | 否 | root 1.13、cause 1.13、incident 1.64、e-1474 0 |
| 3 | 3.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 是题目集合, 是题 q 的相关集合,rank 从 1 开始):
- Hit@k :前 k 个里至少有一个相关的。名次不重要。
- Failed@k :前 k 个里一个相关的都没有。错误分析常常从这里开始。
- MRR@k :第一个相关结果的名次的倒数,不在前 k 里记 0。
- nDCG@k ,:越靠前权重越大,再除以理想排序的 DCG 归一化;可以用分级的相关度。
- Recall@k :相关的东西找回来了多少。
代码 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@5 | section Hit@1 / Hit@5 | 全部 Hit@1 / Hit@5 |
|---|---|---|---|---|
| BM25 | 1.00 / 1.00 | 0.56 / 0.72 | 0.08 / 0.71 | 0.60 / 0.85 |
| 稠密 | 0.14 / 0.40 | 0.56 / 0.72 | 0.42 / 0.83 | 0.33 / 0.62 |
| 混合(RRF, k = 60) | 0.34 / 0.83 | 0.83 / 0.94 | 0.21 / 0.79 | 0.41 / 0.85 |
| 混合(分数归一化相加) | 1.00 / 1.00 | 0.78 / 0.94 | 0.29 / 0.79 | 0.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)——合并名次而不是原始分数:
好处:不需要针对语料调参,对任意多个检索器都适用。
上表里有三个值得细看的现象:
- 换说法的题:两者的总分一样(Hit@1 都是 0.56),但赢的是不同的题——18 道题里,只有 BM25 排对第 1 的 8 道(带编号的变电站题:“which crew looks after site SS-…”,BM25 靠编号赢),只有稠密排对的 8 道(IT 帮助文档),两者都对的 2 道。RRF 合起来 0.83。互补才是混合的意义。
- 但 RRF 把字面量题的 Hit@1 从 1.00 拉到 0.34:稠密检索”很有把握地”把别的事故报告排在第 1,RRF 只看名次,于是正确答案被挤到后面(Hit@5 仍有 0.83)。融合提高的是召回,不保证第 1 名更准——所以后面还要接重排(§3.5)。
- 分数归一化相加在这个语料上反而好得多(全部 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 |
|---|---|---|---|
| BM25 | 1.00 / 1.00 → 1.00 | 0.72 / 0.56 → 0.67 | 1.00 / 0.83 → 1.00 |
| 稠密 | 0.97 / 0.14 → 0.97 | 1.00 / 0.56 → 0.89 | 1.00 / 0.88 → 1.00 |
| 混合(RRF) | 1.00 / 0.34 → 1.00 | 1.00 / 0.83 → 0.94 | 1.00 / 0.92 → 1.00 |
重排只能重新排序第一阶段给它的候选:Hit@20 是它的天花板(BM25 在换说法题上只召回了 72%,重排也就到 0.67)。第一阶段的任务是”别漏”,第二阶段的任务是”排准”。 召回深度(混合 RRF 召回 N 个再重排,全部 78 题):
| 召回 N | 1 | 3 | 5 | 10 | 20 | 50 |
|---|---|---|---|---|---|---|
| Hit@N(重排的天花板) | 0.628 | 0.872 | 0.910 | 1.000 | 1.000 | 1.000 |
| 重排后 Hit@1 | 0.628 | 0.859 | 0.897 | 0.974 | 0.974 | 0.962 |
召回 10–20 个之后就饱和了;召回 50 个反而略降——候选越多,重排器把某个干扰项排到第 1 的机会也越多。重排器也会犯错,它不是”真相”。
3.6 late interaction:ColBERT
ColBERT(Khattab & Zaharia, 2020)走在两者之间:每个 token 一个向量,问题和段落分别编码(所以段落的 token 向量可以预先算好),只在打分的最后一步才交互——这就是 “late interaction”。打分用 MaxSim:
问题里的每个 token 各自去找段落里最像的那个 token,再把这些最大值加起来。它避免了”一个向量平均掉很多主题”,又保留了”预先计算、能扫描整个语料”的能力。代码 04 演示 2(同样的玩具 token 向量,只是不先求平均):
| 检索器 | 字面量 Hit@1 | 换说法 Hit@1 | section Hit@1 | 全部 Hit@1 |
|---|---|---|---|---|
| BM25 | 1.00 | 0.56 | 0.83 | 0.83 |
| 稠密(一段一个向量) | 0.14 | 0.56 | 0.88 | 0.47 |
| ColBERT(每个 token 一个向量) | 0.97 | 1.00 | 1.00 | 0.99 |
| 混合 + cross-encoder 重排 | 1.00 | 0.94 | 1.00 | 0.97 |
错误码能和段落里同一个错误码精确对上(余弦为 1),不会被一段话里几十个别的词平均掉;同义的 token 向量也相近,所以换说法的题也保持了稠密检索的长处。(真实 ColBERT 的 token 向量是 BERT 结合上下文算出来的,我们的玩具版不看上下文。)
3.7 快与慢的检索器
代码 04 演示 3 统计了每种方法的代价(78 道单跳题的平均):
| 方法 | 每个查询的计算 | 索引里存什么 |
|---|---|---|
| BM25 | 35 条倒排记录 | 倒排索引 1,577 条记录 |
| 稠密(单向量,暴力扫描) | 94 次向量比较 | 94 × 64 = 6,016 个数 |
| ColBERT | 6,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 / 35 | 0 / 0 / 18 | 0 / 0 / 24 | 0 / 0 / 16 | 0.0% |
| BM25 · 上下文化分块 | 35 / 0 / 0 | 13 / 0 / 5 | 24 / 0 / 0 | 2 / 14 / 0 | 79.6% |
| 稠密 · 上下文化分块 | 14 / 21 / 0 | 13 / 5 / 0 | 24 / 0 / 0 | 2 / 14 / 0 | 57.0% |
| 混合 + 重排 · 按结构分块 | 35 / 0 / 0 | 18 / 0 / 0 | 9 / 15 / 0 | 7 / 9 / 0 | 74.2% |
| 混合 + 重排 · 上下文化分块 | 35 / 0 / 0 | 18 / 0 / 0 | 24 / 0 / 0 | 7 / 9 / 0 | 90.3% |
几个要点:
- 检索决定上限。 阅读器再完美,也读不到没被检索到的东西。
- “答错”比”答不出”多得多,而且更危险。 稠密检索在字面量题上找到的是别的事故报告,阅读器就从那里拿了一个答案(21 道答错,没有一道说”不知道”);按结构分块时,阅读器在上下文里看到好几份政策的 Receipts 节,分不清哪份是差旅的,就挑了排名最高的那个(15 道答错)。上下文里有一个”长得像答案”的东西时,阅读器很少拒答——真实的 LLM 也是这样。自信地答错比答不出更危险,因为用户无从察觉。
- 上下文化分块不只帮了检索器,也帮了阅读器:块里带着”[Travel Policy > Receipts]“,阅读器才能确认这一节说的是差旅。
- 多跳题大多答错:问题里没有团队名,单次检索很难把对的团队页面捞进来。这是 §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 道多跳题):
| 做法 | 答对 | 第一跳找对 | 平均检索次数 | 平均上下文词数 |
|---|---|---|---|---|
| 单次检索 · BM25 | 2/16 | 16/16 | 1.00 | 137 |
| 单次检索 · 混合 + 重排 | 7/16 | 16/16 | 1.00 | 128 |
| 单次检索 · 混合 + 重排,k = 20 | 12/16 | 16/16 | 1.00 | 580 |
| 逐跳检索 · BM25 | 16/16 | 16/16 | 2.00 | 284 |
| 逐跳检索 · 稠密 | 10/16 | 10/16 | 2.00 | 278 |
| CRAG 式(评估 + 改写 + 换检索器) | 16/16 | 16/16 | 2.00 | 284 |
单次检索几乎总能找到变电站那一块,却很难找到团队那一块。把 k 从 5 加到 20 能多”碰”到几个团队页面(这个语料里只有 10 个团队),但上下文翻了四倍多——在团队成百上千的真实语料里,靠多塞来碰运气不可行。逐跳检索两次就几乎全部答对;稠密检索的逐跳版本失败在第一跳——它认不准变电站编号(又是”罕见字面量”)。原课 HW1 的多跳 RAG 练的正是这个循环。
5.6 查什么:查询改写
换说法的题是另一类”查询本身不好”的情况。最简单的改写是查询扩展:把查询里每个认得的词换成它所有的同义词一起查(rag_agent.expand_query)。代码 06 演示 2(18 道换说法的题):
| 检索 | Hit@1 | Hit@5 | 端到端答对 |
|---|---|---|---|
| BM25 | 0.56 | 0.72 | 13/18 |
| BM25 + 查询扩展 | 1.00 | 1.00 | 18/18 |
| 稠密 | 0.56 | 0.72 | 13/18 |
| 混合 + 重排 | 0.94 | 1.00 | 18/18 |
扩展是把”同义词知识”放在查询端,稠密检索是把它放在编码器里——两条路解决的是同一个问题。真实系统里常让 LLM 来改写:多写几种问法、把口语换成文档里的术语,或者 HyDE——先让模型写一段”假想的答案”,再拿它去检索(假想答案和真实文档的用词更接近)。代价是扩展会引入噪声词(多义词),在字面量题上可能把别的文档排上来——所以最好只在第一次检索效果不好时才用。
5.7 结果够不够好:一个 CRAG 式的智能体
rag_agent.crag_answer 把三件事串起来:先拆子问题;每一跳先用便宜的 BM25 检索;用 cross-encoder 给结果和子问题的相关度打分(拿整个多跳问题去评第一跳的结果是不公平的——第一跳的文档本来就回答不了整个问题;我们第一版代码就犯了这个错,评估器把所有第一跳都判成”不够好”);分数低于阈值就改写查询(同义词扩展)、换更贵的检索器(混合,再到混合 + 重排)再来,最多 3 次;然后读、进入下一跳。代码 06 演示 3(94 道题,含版本题):
| 做法 | literal | paraphrase | section | multihop | versioned | 总准确率 | 平均检索次数 |
|---|---|---|---|---|---|---|---|
| 单次 · BM25 | 35/35 | 13/18 | 24/24 | 2/16 | 0/1 | 78.7% | 1.00 |
| 单次 · 混合 + 重排 | 35/35 | 18/18 | 24/24 | 7/16 | 0/1 | 89.4% | 1.00 |
| 逐跳 · 混合 + 重排 | 35/35 | 18/18 | 24/24 | 16/16 | 0/1 | 98.9% | 1.17 |
| CRAG 式(BM25 起步) | 35/35 | 18/18 | 24/24 | 16/16 | 0/1 | 98.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 week | Remote Work Policy (2024) |
| 重建索引:两个版本都在 | 2 days per week | Remote Work Policy (2024) |
| 重建索引 + 只保留每个版本族的最新版 | 3 days per week | Remote 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 用一个显式的旋钮模拟它(离上下文两头越远的块越可能被阅读器忽略,最多忽略一半):
| k | Recall@k(检索) | 准确率:没有位置偏差 | 准确率:位置偏差 0.5 | 上下文词数 |
|---|---|---|---|---|
| 1 | 50.9% | 50.9% | 50.9% | 35 |
| 3 | 81.1% | 81.1% | 73.6% | 107 |
| 5 | 86.8% | 86.8% | 77.4% | 183 |
| 10 | 100.0% | 100.0% | 83.0% | 377 |
| 20 | 100.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 当阅读器:
- 闭卷问 10 道 Orrin Grid 的题:它是老实说”不知道”,还是编一个?
- RAG:给它混合 + 重排检索到的 5 块(带编号),要求一句话回答并用 [编号] 引用;判分看答案字符串和引用的块是否来自正确的文档。
- 检索失败时:故意用 BM25 去答几道它找不到的换说法题,提示词要求”找不到就说 I don’t know”——它是照做,还是从不相关的块里拼出一个答案?
pip install anthropic
python3 07_real_llm.py --n 10
--toy 不联网,用我们的确定性阅读器冒充模型跑同一段代码。真实模型每次的回答可能不同;值得记录的是三列的分布,而不是某一次的结果。引用不只是给人看的——有了引用,你可以自动检查”答案有没有依据”(第 13 讲的判分器会用到)。
8. 小结 · Summary
- 为什么检索:把模型没有的、会变的、需要核对的知识及时送进上下文;retriever–reader 是骨架,RAG(Lewis et al.)把检索当隐变量和生成器一起训练;今天的系统多用现成的组件拼装,每个组件可以单独评估。
- 索引:块太小丢上下文、劈开证据句;块太大平均掉主题、拖长上下文。重叠救回被劈开的句子;上下文化检索(给块加上来源)和 late chunking(把文档上下文带进向量)解决”块里缺上下文”;RAPTOR、GraphRAG 为”需要汇总”的问题在建索引时多花算力;ANN 用一点召回换大量速度。
- 检索:BM25 对罕见字面量极其敏感、对同义改写无能为力;稠密检索正好相反。混合检索(RRF 或分数融合)提高召回,但可能把第 1 名弄错——所以两阶段:广撒网 + cross-encoder 精排。ColBERT 用”每个 token 一个向量”在两者之间取得平衡,代价是索引大小。指标要选对:多跳题看 Recall,不只看 Hit。
- 端到端:检索决定上限;阅读器面对”长得像答案”的干扰时会自信地答错,这比答不出更危险。
- 智能体式 RAG:检索是一个动作,有三个决定——要不要检索、查什么、结果够不够好(Self-RAG、CRAG、Search-R1 各有侧重)。多跳问题必须逐跳检索;查询改写救回换说法的题;检索评估器把昂贵的操作留给需要的题,但它本身也会错,而且”相关”不等于”正确”。
- 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)。配套代码会写一个”校验与执行分离”的工具注册表、一个能抵抗截断和嵌套的文本工具调用协议,并把本讲的检索器包装成一个工具。