第 4 周 · Day 1:检索增强生成(RAG)与稠密检索

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

课程源码 ZIP ↓

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

本篇目录

时长 7 小时:§1 为什么需要 RAG(1h)· §2 知识库:收集、切分、嵌入(1.5h)· §3 稠密检索:双塔与对比学习(1.5h)· §4 向量检索的工程(1h)· §5 把检索结果交给模型(0.5h)· §A–§C(1.5h)

对应正课 Lecture 7 的第一部分:RAG 的动机、检索–增强–生成三步、建知识库(收集、切分、嵌入;块大小、重叠、嵌入维数这几个超参)、候选检索的第一种方法(基于嵌入的语义检索、双塔)。学习计划点名要会的:“RAG 基本管线:切分 → 嵌入 → 向量库 → 检索 → 重排 → 拼进上下文 → 生成。每一环的失败模式要能对应上。”

配套代码 w4day1_checks.py(纯 numpy):长上下文与 RAG 的成本账、整篇嵌入的稀释、块重叠与答案被切断的概率、InfoNCE 的梯度与温度、InfoNCE 的 log B 上界、三种相似度的排序关系、IVF 的召回与计算量、四种向量压缩、存储账

§B 参考实现(code/ 目录):w4d1_rag_basics.py(把 Week 0–3 的讲义做成知识库:按标题切小节、按长度切块、TF-IDF / LSA / ICT 对比学习 / sentence-transformers 四种嵌入、精确与 IVF 检索、在 50 道黄金题上评测);w4_golden.py(黄金问答集,本周共用)。

今天的目标:能说清 RAG 解决的是哪几堵墙、和长上下文与微调怎么取舍;能推出”块越大、嵌入越被稀释”和”重叠多少才不会切断答案”;能写出 InfoNCE 损失和梯度,解释温度和 batch 大小的作用;能说清双塔为什么能建索引、IVF 与乘积量化在用什么换什么;在自己的讲义上建出第一个检索器,并用数字说话。


§0 这一周在做什么

Lecture 7 开场的那张表把 LLM 的弱点列成四条:推理有限、知识是静态的、不能行动、难以评测。第 3 周的 RL 和测试时缩放处理了第一条。这一周处理剩下三条:

Day 1  RAG 与稠密检索        四堵墙 · 切块与稀释 · 双塔与 InfoNCE · IVF / HNSW / 乘积量化 · 提示怎么拼
Day 2  稀疏、混合与重排       BM25 推导 · 稀疏与稠密的互补 · RRF · 检索指标(NDCG 等)· 交叉编码器重排 · HyDE · 上下文检索
Day 3  函数调用与 MCP         工具调用三步 · 怎么教模型用工具 · 约束解码 · 工具太多怎么办 · MCP · 安全
Day 4  智能体与 ReAct         智能体的定义 · ReAct 循环 · 错误累积 · 预算与护栏 · 记忆 · 多智能体与 A2A
Day 5  评测基础              人工评分与 κ · 一个分数有多可信 · 基准与数据污染 · pass^k · BLEU / ROUGE 为什么失效
Day 6  LLM 当评委            三种评委 · 位置 / 冗长 / 自我偏好偏差 · Elo 与 Bradley-Terry · 事实性 · 智能体失败模式 · Week 4 出关测试

这条链的主线:前三周都在改变模型内部(权重);这一周改变的是模型外部,也就是上下文窗口里放什么。RAG 把检索到的文字放进去,工具调用把函数的返回值放进去,智能体让这件事循环起来。它们共用一个接口(上下文),也共用一个难题:怎么知道这样做真的更好。后两天的评测就是回答它。

这一周的语料:学习计划要求”选一份你自己真正需要的语料”。我们就用这门课自己的讲义(Week 0–3 的 23 篇文档,约 40 万字符)。做完这一周,你会有一个能回答自己课程笔记的助手,以及一套能判断它好不好的评测。

今天要用到的旧结论:双塔与交叉编码器、Sentence-BERT(Week 1 Day 6 §4.6、§A.5);SVD 分解共现矩阵(Week 0 Day 3 §2.3);负采样(Week 0 Day 3 §3.3);softmax 交叉熵的梯度 p−yp-y(Week 0 Day 1 §5.2);余弦与欧氏距离(Week 0 Day 3 §5.3);KV Cache 的显存公式(Week 1 Day 4 §1.4);迷失在中间(Week 2 Day 3 §3.2);仿射量化(Week 2 Day 5 §2.1)。


§1 为什么需要 RAG

1.1 四堵墙

Lecture 7 列出的动机,每一条都可以算成数字。

第一堵:知识停在预训练数据里。 模型的知识有截止日期;你公司的内部文档、你自己的笔记,它从来没见过。改变它的知识只有两条路:改权重(微调,§1.3 会说它为什么不可靠),或者把知识放进上下文。

第二堵:上下文有限,而且很贵。 把全部资料塞进上下文行不行?w4day1_checks.py 第 1 组用 Llama 3 8B 的配置(32 层、GQA 8 个 KV 头、头维 128)算了三种做法:

场景输入 tokenprefill FLOPs其中注意力占比KV Cache
RAG:提问 200 + 5 块 × 4002,2003.66e133.5%0.27 GiB
整本资料 128K131,0726.61e1568.1%16 GiB
整本资料 1M1,048,5763.05e1794.5%128 GiB

prefill 的计算量是 2NT2NT(矩阵乘,Week 1 Day 2 §5.2)加上因果注意力的 2LdT22LdT^2。长到一定程度后,T2T^2 项压过了参数项:1M 的输入 token 是 RAG 的 477 倍,prefill 计算量却是 8,335 倍。KV Cache 每 token 128 KiB(Week 1 Day 4 §1.4 的公式),1M token 要 128 GiB,一张 80 GB 的卡放不下。

第三堵:无关信息会分散注意力。 “大海捞针”测试(Kamradt 2023)把一句话藏进长文里让模型找;“迷失在中间”(Week 2 Day 3 §3.2)说明信息放在中间时模型用得最差。装得下,不等于用得好。

第四堵:按 token 计费。 API 按输入和输出 token 收费,上表第一列就是账单的比例。每次提问都重新发送 100 万 token,比发送 2200 token 贵 477 倍(提示缓存能打折,但不能免单)。

1.2 RAG 的想法:检索、增强、生成

RAG(Retrieval-Augmented Generation,Lewis et al. 2020)的想法一句话:用相关的资料片段来增强提示。流程(Lecture 7 的三步):

离线(建库):  文档 ──收集──> 切块 ──嵌入──> 向量库(每块一个向量 + 原文 + 来源)
在线(每次提问):
  1. 检索 Retrieve:  问题 ──嵌入──> 向量 ──相似度──> 最相关的 k 块         (§3、§4;Day 2 加上重排)
  2. 增强 Augment:   问题 + 这 k 块 ──拼成──> 提示                         (§5)
  3. 生成 Generate:  提示 ──LLM──> 回答(带出处)

原始论文里的 RAG 是一个概率模型,这个视角值得知道,因为它说清了检索在整个系统里的地位。把检索到的文档 zz 看成一个隐变量:

p(y∣x)=∑z∈top-kpη(z∣x)  pθ(y∣x,z),pη(z∣x)∝exp⁡(⟨f(x),g(z)⟩).p(y\mid x)=\sum_{z\in\text{top-}k}p_\eta(z\mid x)\;p_\theta(y\mid x,z),\qquad p_\eta(z\mid x)\propto\exp\big(\langle f(x),g(z)\rangle\big).

检索器 pηp_\eta 给每篇文档一个概率,生成器 pθp_\theta 在每篇文档的条件下生成,最后按概率加权(论文里的 RAG-Sequence;RAG-Token 允许每个 token 换一篇文档)。Lewis et al. 用 DPR(§3.3)做检索器、BART 做生成器,把维基百科切成 2100 万个 100 词的段落,端到端地训练查询编码器和生成器(文档编码器冻结,否则要不停地重建索引)。

今天工业界说的 RAG 通常简单得多:检索器和生成器都是现成的、冻结的,检索到的 kk 块直接拼进提示,由 LLM 在一次生成里”自己去加权”。好处是模块化,每一环都可以单独换、单独评;代价是两者没有一起优化,检索错了,生成器只能在错的资料上尽力。上面那个求和式提醒我们:答案的质量被检索的质量乘了一道。

1.3 RAG、长上下文与微调:怎么选

期末卷问过:“模型有 1M 的上下文窗口,为什么还要 RAG?“(Lecture 7 与期末 III.9)

RAG长上下文(整本塞进去)微调(把知识训进权重)
知识更新改库即可,秒级改输入即可要重新训练
每次调用的成本低(只送 k 块)高(§1.1,可用提示缓存打折)低
能引用出处、能审计能勉强(模型不一定说得清用了哪段)不能
权限控制、删除数据按块过滤即可按请求拼装很难从权重里删掉
跨很多文档的综合推理弱(只看到 k 块)强看训练
主要风险检索没找到(§1.4)贵、慢、迷失在中间记不牢、幻觉增加

为什么不靠微调注入知识? 两个实证:Ovadia et al.(2023)比较了在同样的新知识上做无监督微调和做 RAG,RAG 一致更好;Gekhman et al.(2024)发现,SFT 数据里模型”原本不知道”的新知识学得很慢,而且学进去之后模型更容易产生幻觉。微调擅长改变行为(格式、风格、任务能力),不擅长可靠地写入事实。 两者常常一起用:微调让模型学会”读资料、引用资料、资料不够就说不知道”,RAG 负责提供资料。

长上下文什么时候更好:资料不多(几万 token)、调用量不大、问题需要通读全部资料做综合(“总结这份合同的所有风险”)。这时检索反而可能漏掉关键段落。

1.4 故障地图:先知道会坏在哪

RAG 的每一环都会坏,而且坏的方式不同。最重要的一条:检索没找到的东西,生成器没法补回来(除非它本来就知道)。所以”答案所在的段落有没有进入前 kk 块”(hit@k,§3.4)是 RAG 答对的上限。

环节典型故障症状在哪里修
收集文档没收进来、版本过期、PDF 解析成乱码“资料里没有”,其实有数据管线
切分块太碎丢了主语;答案被切成两半;表格被拆散找到了相关的块,但信息不全§2.2–2.3
嵌入模型不懂你的领域;专有名词、代码、编号被”语义化”相关的块排不进前 kk§3.5;Day 2 的混合检索
检索 / 排序kk 太小漏掉;kk 太大塞进太多噪声答案在第 12 名Day 2 的重排
拼接关键块放在中间;块没有来源信息找到了,但模型没用上§5
生成不看资料自己编;资料互相矛盾时乱选答案与资料不符(不忠实)提示、模型;Day 6 的忠实度评测

诊断的方法(Day 6 会系统地做):把”检索找没找到”和”找到了答没答对”分开测。前者用 hit@k,后者在”给定正确资料”的条件下测生成。两个数一乘,就知道该先修哪一环。


§2 知识库:收集、切分、嵌入

2.1 为什么要切分:稀释

动机:为什么不把整篇文档嵌入成一个向量?

直觉:一个向量的”容量”有限。一篇讲了 16 件事的文档,压成一个向量后,它离每一件事都不远也不近,任何只问其中一件事的查询都很难把它排在前面。

推导:把块里的每个知识点看成一个单位向量 f1,…,fmf_1,\dots,f_m(高维空间里的随机方向几乎两两正交),块的嵌入近似为它们的和再归一化。一个只问 f1f_1 的查询与块的余弦是

cos⁡(f1, f1+⋯+fm∥f1+⋯+fm∥)=1∥f1+⋯+fm∥=1m(正交时 ∥f1+⋯+fm∥2=m).\cos\Big(f_1,\ \frac{f_1+\cdots+f_m}{\|f_1+\cdots+f_m\|}\Big)=\frac{1}{\|f_1+\cdots+f_m\|}=\frac1{\sqrt m}\qquad(\text{正交时 }\|f_1+\cdots+f_m\|^2=m).

w4day1_checks.py 第 2 组:蒙特卡洛与 1/m1/\sqrt m 吻合到小数点后两位(m=16m=16 时 0.250)。再做一个检索实验:500 篇文档、每篇 16 个知识点,查询是某个知识点加噪声(余弦约 0.71),按每块 gg 个知识点切:

每块知识点数 gg块数recall@1recall@10正确块的平均余弦
180001.0001.0000.706
420001.0001.0000.350
810000.9550.9970.248
16(整篇一个向量)5000.6390.9210.174

正确块的分数按 1/g1/\sqrt g 下降,逐渐淹没在几百上千个无关块的”噪声最大值”里:dd 维空间里随机方向的余弦约为 N(0,1/d)\mathcal N(0,1/d),库越大,其中最大的那个就越大(约 2ln⁡N/d\sqrt{2\ln N/d})。

切分的第二个理由:块是送进提示的单位。检索到一篇 2 万字的文档,要么整篇塞进去(§1.1 的成本),要么还得再切一次。

意义与代价:块越小,嵌入越”专一”,但每块的信息越少,而且会丢上下文(“它”指谁?“该方法”是哪个?)。这是一个要在自己的数据上调的超参(§2.2)。玩具里只有稀释的一面,没有”丢上下文”的一面,所以它会误导你”越小越好”。

2.2 块的大小与重叠

重叠解决的问题:固定长度切块时,一段答案可能正好跨在两块的边界上,哪一块都不完整。

推导:块长 CC、重叠 oo,步长 s=C−os=C-o,块从 0,s,2s,…0,s,2s,\dots 开始。一段长 ℓ≤C\ell\le C 的答案随机落在文中,设它的起点是 aa,令 r=a mod sr=a\bmod s。不晚于 aa 的最后一个块从 a−ra-r 开始、覆盖到 a−r+Ca-r+C;答案完整落入它 ⇔r+ℓ≤C\Leftrightarrow r+\ell\le C(更早的块结束得更早,不会更好)。rr 在 {0,…,s−1}\{0,\dots,s-1\} 上均匀,所以

P(答案完整)=min⁡(s, C−ℓ+1)s,o≥ℓ−1 ⇒ P=1.P(\text{答案完整})=\frac{\min(s,\ C-\ell+1)}{s},\qquad o\ge\ell-1\ \Rightarrow\ P=1.

代价是块数变成 C/sC/s 倍。第 3 组(C=512C=512,公式与 20 万次蒙特卡洛逐格吻合):

重叠 oo块数倍数ℓ=64\ell=64ℓ=128\ell=128ℓ=256\ell=256
01.00×0.8770.7520.502
641.14×1.0000.8590.574
1281.33×1.0001.0000.669
2562.00×1.0001.0001.000

读法:不重叠时,128 token 的答案有四分之一的概率被切断;重叠 128 就能保证完整,代价是多 1/3 的块。常见的经验值”重叠 10%–20%“只适用于”答案比块短得多”的情形。

在真实讲义上扫一遍(w4d1_rag_basics.py --chunk-sweep,带面包屑,50 道黄金题;“上下文字数”是前 5 块的总字符数,也就是送进 LLM 的成本):

嵌入块大小重叠块数hit@1hit@5MRR@10上下文字数
LSA200026410.7000.8400.760690
LSA400013940.5800.8600.6861316
LSA4008014900.5400.8200.6641402
LSA80009100.4200.7800.5881955
LSA160007860.4200.8200.5752286

三个观察:

  1. 在这份语料上,小块更划算:200 字的块用一半的上下文拿到了和 400 字差不多的 hit@5,排名还更靠前(MRR 0.76)。
  2. 重叠没有帮上忙。原因是我们先按小节切、再在小节内按长度切(§2.3):小节本身就是天然的语义边界,大部分答案不会跨块。重叠只是在复制内容。
  3. 块到 1600 字时块数几乎不再减少(786 块):大多数小节本来就不到 1600 字,一节就是一块。

但要小心读这张表:50 道题的 hit@5 的标准误约 0.06、95% 区间约 ±0.11(Day 5 §3.1),0.82 和 0.86 分不出来。另外”命中”只要求块属于答案小节,大块天然更容易”命中”,所以一定要和上下文字数一起看。这就是”每次改动都要有数字支撑”的真正含义:不只要有数字,还要知道数字的误差有多大。

2.3 怎么切:四种策略

策略做法优点缺点
固定长度每 CC 个 token 一块,重叠 oo简单,块长可控无视句子和段落,最容易切断答案
递归按分隔符先按段落切,太长再按句子,再按词尽量保留完整段落块长不均匀
按文档结构Markdown 按标题、代码按函数、表格整体保留块对应一个完整的语义单位;还能拿到”章节路径”这个元数据需要针对格式写解析;小节长短悬殊
语义切分相邻句子嵌入的相似度骤降处切开按话题边界切要嵌入每个句子,贵;阈值难调

参考代码用的是结构 + 递归:先按标题把讲义切成小节(parse_markdown,注意代码块里的 # 不是标题),小节内部按空行分段、段落装箱,超长段落再按句子切(split_text)。

两个值得知道的新做法:

  • 给块加上下文:切出来的块常常没有主语(“该公司第二季度营收增长 3%”)。最便宜的做法是在每块前面拼上”文档名 > 章节名”(参考代码的 header,我们叫它面包屑);更贵但更好的做法是让 LLM 为每块写一句概括,这就是 Anthropic 的”上下文检索”,Day 2 §5.3 细讲。§3.4 会看到,面包屑在我们的讲义上效果惊人。
  • 迟切分(late chunking):先用长上下文的嵌入模型把整篇文档编码一遍,再按块的范围把 token 向量平均成块向量。这样每块的向量”看过”全文,代词也能被正确地理解。

2.4 嵌入模型

它是什么:一个把文本映射成定长向量的编码器,训练目标是”相关的文本向量接近”。现代的检索嵌入模型(bge、e5、gte 等)是 Sentence-BERT(Week 1 Day 6 §4.6)的后代:Transformer 编码器 + 池化(平均或取 [CLS]),先在海量的弱监督文本对(标题–正文、问题–回答)上做对比学习(§3.3),再在人工标注的检索数据上微调。

选型要看的几件事:

  • 语言与领域:英文模型处理中文会很差;通用模型处理代码、法律、医疗可能差。在自己的数据上测,排行榜(MTEB)只能帮你缩小范围。
  • 最大长度:超过就被静默截断(bge-small 系列是 512 token)。块比这个长,后半部分等于没被嵌入。
  • 维数与成本:常见 384–1024 维。第 9 组:1000 万块、1024 维、float32 要 41 GB 内存。Matryoshka 表示学习(Kusupati et al. 2022)训练时让前 64、128、256…维都能单独使用,部署时可以直接截断维数,用一点精度换存储和速度。
  • 查询前缀:很多模型要求查询和文档加不同的前缀(§A.1),不加会掉点。

§3 稠密检索:双塔与对比学习

3.1 双塔为什么能建索引:打分可以分解

Week 1 Day 6 §4.6 比较过两种打分方式:

  • 交叉编码器:把查询和文档拼在一起送进一个 Transformer,输出相关性分数 s(q,d)=h([q;d])s(q,d)=h([q;d])。两边的 token 能互相注意,最准。
  • 双塔(bi-encoder):查询和文档分别编码,分数是两个向量的点积 s(q,d)=⟨f(q),g(d)⟩s(q,d)=\langle f(q),g(d)\rangle。

关键区别不是准不准,而是分数能不能分解。 双塔的分数是”只依赖 qq 的东西”和”只依赖 dd 的东西”的内积,所以 g(d)g(d) 可以离线算好、存起来;每次查询只要算一次 f(q)f(q),剩下的是 NN 次点积,还可以用 §4 的近似索引加速到远少于 NN 次。交叉编码器的分数不能分解,每个查询都要把每篇文档重新过一遍模型:Sentence-BERT 的论文估算,在 1 万个句子里找最相似的一对,交叉编码器要约 65 小时,双塔约 5 秒。

所以现代检索系统是两阶段的:双塔(和 Day 2 的 BM25)负责从百万级的库里召回几十到几百个候选,追求不漏(recall);交叉编码器负责给这些候选重排,追求排准(precision)。这正是 Lecture 7 的 “Step 1 候选检索 / Step 2 排序”。今天讲第一步,Day 2 讲第二步。

3.2 相似度:点积、余弦与 L2

三种常见的相似度:点积 q⋅dq\cdot d、余弦 q⋅d∥q∥∥d∥\frac{q\cdot d}{\|q\|\|d\|}、L2 距离 ∥q−d∥\|q-d\|。它们的关系:

∥q−d∥2=∥q∥2+∥d∥2−2 q⋅d.\|q-d\|^2=\|q\|^2+\|d\|^2-2\,q\cdot d.

向量都归一化时,∥q−d∥2=2−2cos⁡(q,d)\|q-d\|^2=2-2\cos(q,d),三者给出完全相同的排序(第 6 组:top-10 一致)。不归一化时,点积 =∥q∥∥d∥cos⁡=\|q\|\|d\|\cos,范数大的文档占便宜。第 6 组让文档范数服从 LogNormal(0, 0.5):点积 top-10 与余弦 top-10 一个都不重合,点积 top-10 的平均范数是 3.80(全体平均 1.13)。

意义:用哪种相似度要跟着模型训练时用的走。大多数句向量模型用余弦训练,检索时要归一化(归一化后就可以用最快的内积索引);有的模型用点积训练,范数本身带信息,擅自归一化反而会掉点。这是一个”不报错、但结果错”的坑(§A.1)。

3.3 训练双塔:InfoNCE

动机:嵌入模型怎么学会”相关的文本向量接近”?我们有 (查询, 相关文档) 对,想让查询和它的正例的相似度,比和其他文档都高。

直觉:把检索看成一个分类问题:给定查询 qq,从 BB 个候选文档里挑出正确的那一个。分类最自然的损失是 softmax 交叉熵。

推导:设候选是 d1,…,dBd_1,\dots,d_B,其中 d+d_+ 是正例,打分 sj=cos⁡(q,dj)/τs_j=\cos(q,d_j)/\tau,

L=−log⁡es+∑j=1Besj,∂L∂sj=pj−1[j=+],pj=esj∑kesk.\mathcal L=-\log\frac{e^{s_+}}{\sum_{j=1}^B e^{s_j}},\qquad\frac{\partial\mathcal L}{\partial s_j}=p_j-\mathbb 1[j=+],\quad p_j=\frac{e^{s_j}}{\sum_k e^{s_k}}.

梯度就是 Week 0 Day 1 §5.2 的"p−yp-y":正例被往上拉(力度 1−p+1-p_+),每个负例被往下压(力度 pjp_j)。这个损失叫 InfoNCE(van den Oord et al. 2018)。

负例从哪来:in-batch negatives(DPR,Karpukhin et al. 2020)。一个 batch 有 BB 对 (qi,di)(q_i,d_i),算出 B×BB\times B 的相似度矩阵 S=QD⊤/τS=QD^\top/\tau,第 ii 行的正确”类别”是 ii,同一 batch 里其他查询的正例就是 qiq_i 的负例:

L=−1B∑i=1Blog⁡eSii∑jeSij,∂L∂Q=(P−I) DτB.\mathcal L=-\frac1B\sum_{i=1}^B\log\frac{e^{S_{ii}}}{\sum_j e^{S_{ij}}},\qquad\frac{\partial\mathcal L}{\partial Q}=\frac{(P-I)\,D}{\tau B}.

一次矩阵乘就得到 B(B−1)B(B-1) 个负例,负例是免费的。第 4 组用有限差分核对了这个梯度(最大差 2.4×10−102.4\times10^{-10})。DPR 在此之外还给每个问题加了一个 BM25 检索到的”难负例”(和问题字面很像、但不含答案的段落),在开放域问答上 top-20 段落准确率比 BM25 高 9–19 个百分点。

温度 τ\tau 决定梯度落在哪。负例 jj 拿到的梯度正比于 pjp_j。第 4 组:一个查询,正例余弦 0.6,127 个负例余弦 ~ N(0.2,0.12)\mathcal N(0.2,0.1^2):

τ\tau正例概率最难 5 个负例分到的负梯度占比
1.00.0124.9%
0.20.04810.9%
0.050.73661.8%
0.020.99698.6%

τ\tau 大时所有负例平分梯度(大部分浪费在早已分开的简单负例上);τ\tau 小时梯度几乎全部压在最难的几个负例上。常见取值 0.01–0.1。τ\tau 太小的风险:最难的”负例”常常其实是没被标注的正例(假负例),模型会被逼着把真正相关的文档推开。

batch 为什么要大:InfoNCE 的 log B 上界。van den Oord et al. 证明了 log⁡B−L\log B-\mathcal L 是查询与文档之间互信息 I(q;d)I(q;d) 的一个下界,而且这个下界永远不超过 log⁡B\log B。第 5 组用互信息已知的高斯对和理论最优的打分函数验证:

真实互信息B = 8B = 32B = 128B = 512B = 2048
log⁡B\log B(上限)2.0793.4664.8526.2387.625
1 nat0.7680.9250.9651.0160.996
4 nat1.8472.7853.4183.7243.907
8 nat2.0713.4264.7175.8486.740

互信息小时 B=128B=128 就测满了;互信息大时,B=2048B=2048 的下界仍然被 log⁡B\log B 压着。直觉:从 8 个候选里挑对,最多只能证明你掌握了 3 bit 的区分能力。检索要在百万级的库里区分细微的差别,训练时就需要很多负例:这就是 DPR 用 in-batch 负例、CLIP 用 32768 的 batch、MoCo 用队列攒负例的原因。

和 word2vec 的关系:Week 0 Day 3 §3.3 的负采样是它的”二分类版本”:每个 (中心词, 上下文词) 对和 kk 个噪声词分别做逻辑回归。InfoNCE 是”多分类版本”:正例和所有负例放在同一个 softmax 里竞争。两者都属于噪声对比估计(NCE)这个家族:用”和噪声比较”代替”在整个词表 / 整个语料上归一化”。

3.4 在讲义上试一试:LSA、ICT 与面包屑

w4d1_rag_basics.py --eval 在 1465 块(400 字、重叠 60)上比较了三种嵌入:

  • TF-IDF:稀疏的词袋(中文取单字 + 双字),只有共享词元才有相似度。它其实是”稀疏检索”,放在这里当对照。
  • LSA(潜在语义分析,1990):对”块 × 词”的 TF-IDF 矩阵做截断 SVD,保留 256 维。它和 Week 0 Day 3 §2.3 用 SVD 分解共现矩阵是同一个想法:经常一起出现的词被压到相近的方向,所以没有共同词的两段话也可能很接近。这是最早的稠密检索。
  • ICT:用 LSA 初始化一个线性编码器,再用对比学习(InfoNCE,τ=0.05\tau=0.05,B=64B=64)微调。训练对从哪来?我们没有人工标注的问答对,于是用 ICT(Inverse Cloze Task,Lee et al. 2019):从一块里随机抽出一句话当”伪查询”,剩下的部分当”伪文档”。

每种都比较”块前加不加面包屑”(文档名 > 节名):

嵌入hit@1hit@5hit@10MRR@10
TF-IDF0.2400.5800.7200.371
TF-IDF + 面包屑0.4800.7600.9000.607
LSA0.2200.5400.7000.361
LSA + 面包屑0.5800.8200.9200.681
ICT(种子 0 / 1 / 2)0.200 / 0.220 / 0.1400.540 / 0.560 / 0.5000.680 / 0.800 / 0.6600.337 / 0.368 / 0.286
ICT + 面包屑(种子 0 / 1 / 2)0.460 / 0.480 / 0.5000.740 / 0.760 / 0.8600.840 / 0.820 / 0.9400.594 / 0.593 / 0.622

另一个自动造的测试集(404 条”小节标题 → 该节正文”,块不带面包屑,否则泄题)上,hit@5:TF-IDF 0.460、LSA 0.433、ICT 三个种子 0.364 / 0.349 / 0.347。

三个结论,以及它们的可信度:

  1. 面包屑的收益远大于换嵌入方法:LSA 的 hit@5 从 0.54 到 0.82,MRR 几乎翻倍。原因很直接:很多块本身没有提到它在讲什么(“这一项的梯度是……”),而问题问的正是那个主题;面包屑把主题补了回来。这个差距(0.28)远大于标准误(约 0.06),Day 5 §3.4 的配对检验也确认了它。
  2. LSA 和 TF-IDF 差不多:带面包屑时 0.82 vs 0.76,差距在误差范围内。LSA 能捕捉”同义”,但也会把”注意力""注意事项”这类共享字的词拉近,两种效应在这份语料上大致抵消。
  3. ICT 没有稳定的收益。黄金集上三个种子的 hit@5 在 0.74–0.86 之间摇摆,和 LSA 的 0.82 分不开;标题查询(404 条,误差更小)上三个种子都低于 LSA。为什么对比学习没帮上忙? 训练 loss 从 log 64 ≈ 4.16 很快降到 0.4 左右:从同一块里抽出的句子和剩下的部分共享大量字面词元,这个任务太容易,模型学到的主要是”字面匹配”,而 TF-IDF 本来就会。训练对的分布要和真实查询的分布匹配:真实的问题是用自己的话问的(“LoRA 的 B 为什么初始化为零”),而伪查询是原文里的句子。这是 ICT 在论文里只被当作”预训练”、后面还要在真实问答对上微调的原因。

这张表还没有 sentence-transformers:编写环境没有安装它。你装上之后跑 --eval --embed st(默认 BAAI/bge-small-zh-v1.5),把这一行补上。按经验,一个在海量真实问答对上训练过的中文嵌入模型应该明显好于 LSA,但请用你跑出来的数字来判断。

3.5 稠密检索的软肋

跑一下 --query "KV Cache 的显存怎么算"(LSA):前三名是 Week 1 Day 4 §3.1 的两段代码和那一天的”出关标准”,真正讲显存公式的 §1.4 排在第 6。代码块里 KVCache、cache 这些词元密集地出现,把”KV Cache”这个主题的重心拉到了代码上。这个例子里 Day 2 的 BM25 也好不到哪去(第 7 名),问题一半出在问法上:换成黄金集里的问法”怎么快速估算 KV Cache 占多少显存?“,两种方法都把 §1.4 排第 1。检索对问法非常敏感,这是 Day 2 §5.1 “查询改写”的出发点。

除了问法,稠密表示还有几个通病:

  • 稀有的专有名词、编号、代码标识符:它们在训练数据里出现得少,被压进低维空间时和别的词混在一起。Lecture 7 的例子:查询 “Where is Cuddly?”(Cuddly 是一只泰迪熊的名字),语义检索找到了 “he was in a cuddly mood”。
  • 精确匹配的需求:错误码、型号、人名,用户要的是字面上一模一样的那一段。
  • 领域外:BEIR 基准(Thakur et al. 2021)发现,在一个领域训练的稠密检索器换到另一个领域时,常常打不过几十年前的 BM25。

解决办法不是放弃稠密检索,而是和关键词检索结合、再加上重排和查询改写:这是 Day 2 的内容。


§4 向量检索的工程

4.1 精确检索:什么时候就够了

精确检索每个查询算 N⋅dN\cdot d 次乘加。100 万块 × 768 维 ≈ 7.7 亿次乘加,CPU 上几十毫秒,GPU 上更快。几十万块以内,精确检索通常就够了,没有召回损失,也不用调参。参考代码的 DenseIndex 就是一个矩阵乘加 argpartition:我们的讲义只有 1465 块,毫秒级。

需要近似索引的场景:库到千万、亿级;延迟要求极严;或者内存放不下(§4.4)。

4.2 IVF:先找簇,再在簇里找

思路:用 k-means 把库分成 nlistn_{list} 个簇(“倒排文件”:每个簇一个列表)。查询时先和 nlistn_{list} 个簇中心比,只进最近的 nproben_{probe} 个簇精确搜。簇大小均匀时,计算量约 nlist+nprobe⋅N/nlistn_{list}+n_{probe}\cdot N/n_{list},nlist≈Nn_{list}\approx\sqrt N 时接近最省。

代价:真正的近邻可能落在没被探查的簇里(尤其是在簇的边界附近)。第 7 组(2 万个 64 维向量,簇之间有重叠,nlist=128n_{list}=128):

nproben_{probe}recall@10计算量占全量
10.5981.4%
40.7403.8%
80.8106.9%
160.88513.2%
320.94325.7%

召回随 nproben_{probe} 单调上升,但越往后越贵。在讲义嵌入上(--ivf,1465 块,nlist=38n_{list}=38):nprobe=1n_{probe}=1 时 recall@10 只有 0.538,黄金集 hit@5 从 0.82 掉到 0.66;nprobe=8n_{probe}=8 时 recall@10 = 0.906、hit@5 回到 0.82。近似索引的召回损失会直接传到最终答案上,而 faiss 的 nproben_{probe} 默认是 1(§A.2)。

4.3 HNSW:在图上贪心地走

HNSW(Hierarchical Navigable Small World,Malkov & Yashunin 2016)是目前最常用的近似索引。

直觉:把每个向量和它的几个近邻连成一张图。查询时从某个点出发,每一步走到”邻居里离查询最近的那个”,直到没有更近的邻居为止。问题是:图上走得太慢(每步只能挪一小段)。解决办法借鉴了跳表:建多层图,上层只有少数点、边很长(高速公路),下层点多、边短(小路)。查询从最上层开始粗找,逐层下降细找。

三个参数:每个点连几条边(M,影响内存和召回)、建图时的搜索宽度(efConstruction,影响建库时间和图的质量)、查询时的候选队列长度(efSearch,召回与延迟的主要旋钮)。

和 IVF 比:HNSW 通常在同样的召回下更快,不需要训练(IVF 要跑 k-means),支持增量插入;代价是内存大(除了向量还要存图的边)、删除麻烦。

4.4 压缩:用更少的字节存向量

内存常常比计算先撞墙(第 9 组:1000 万 × 1024 维 × float32 = 41 GB)。和 Week 2 Day 5 量化权重一样,可以量化向量:

  • float16:减半,几乎无损。
  • int8 标量量化:每一维按 min/max 映射到 256 个等级(Week 2 Day 5 §2.1 的仿射量化)。
  • 乘积量化(PQ,Jégou et al. 2011):把 dd 维切成 mm 段,每段单独做 k-means 得到 256 个码字,一个向量存成 mm 个码字编号(mm 字节)。打分时查询不量化,每段先算查询与 256 个码字的内积(一张 m×256m\times256 的表),库里每个向量的分数就是 mm 次查表相加:几乎不用乘法。
  • 二值量化:每维只存符号(1 bit),用汉明距离比较。

第 8 组(同样 2 万个 64 维向量):

方法字节 / 向量recall@10先取前 100 再用原向量重排
float322561.000—
float161280.999—
int8640.987—
PQ 16 段160.6240.994
PQ 8 段80.3770.850
二值80.1550.472

粗排 + 精排:压缩向量单独用召回明显下降,但用它取前 100、再用原始向量(可以放在磁盘上)重排,能拿回大部分。二值量化在 64 维时太粗(只有 64 bit),维数越高越好用:同样的数据生成方式下,1024 维的二值向量(128 字节,压缩 32 倍)配合前 100 的精排,recall@10 = 0.999。

这和 Week 2 Day 5 是同一个思想:把精度花在最终决定结果的地方。 faiss 的 IVFPQ(IVF 选簇 + PQ 压缩)、各家向量库的”二值 + 重打分”,都是这个结构。

4.5 向量数据库做了什么

向量数据库(Milvus、Qdrant、Weaviate、pgvector 等)= 近似索引 + 数据库该有的东西:

  • 元数据过滤:“只搜 2024 年之后的、用户有权限看的文档”。先过滤还是后过滤是个坑:先用 ANN 取前 10 再过滤,可能一个都不剩;先过滤再搜,过滤条件很严时 ANN 的图结构可能失效。好的实现会在索引内部结合过滤。
  • 增删改:文档更新时要删掉旧块、插入新块;IVF 的簇中心、PQ 的码本会随数据漂移而变旧,要定期重建。
  • 混合检索:同时存稀疏向量(BM25)和稠密向量,一次查询两路召回(Day 2)。
  • 持久化、分片、多租户。

库小的时候不需要它:几万块的知识库,numpy 矩阵加一个 JSON 文件就够了,参考代码就是这样做的。


§5 把检索结果交给模型

5.1 提示怎么拼

参考代码的 PROMPT_TEMPLATE:

你是 CME295 课程的助教。请只根据下面的资料回答问题。
如果资料里没有答案,就回答"资料中没有找到"。回答时在相关句子后面用 [编号] 标出出处。

[1] 来源:第 1 周 · Day 4:KV Cache 与 MQA / GQA > §1.4 显存:一个 30 秒的公式
……
[2] 来源:……

问题:KV Cache 的显存怎么算?
回答:

每一行都有理由:

  • “只根据资料”:否则模型会混用自己的记忆,答案与资料不一致时你无法判断它从哪来。
  • “没有就说没有”:给模型一个体面的退路,减少编造。检索失败时(§1.4),这是唯一正确的回答。
  • 编号与来源:让回答可以引用、可以核对(Day 6 的”忠实度”评测就靠它);来源里的章节名也帮助模型理解每块在讲什么(和面包屑一个道理)。
  • 问题放在最后:离生成最近。资料放在前面还有一个工程上的好处:同一批资料问多个问题时,前缀可以被提示缓存复用。

5.2 放几块、按什么顺序

放几块:kk 大,召回高、答案更可能在里面;kk 小,噪声少、成本低、不容易迷失在中间。Anthropic 在上下文检索的实验里比较了 5、10、20 块,在他们的数据上 20 块最好;但这取决于块的大小、模型的长上下文能力和任务,要在自己的评测上扫。

按什么顺序:迷失在中间(Week 2 Day 3 §3.2)说明模型对开头和结尾的信息用得最好。参考代码的 sandwich 顺序把排名第 1、3、5…的块从前往后放,第 2、4、6…的块从后往前放,最相关的两块分居两端:5 块时放置顺序是 [1, 3, 5, 4, 2]。引用编号仍按相关性给,含义不变。它有没有用,同样要测。

5.3 今天的全景

环节参考代码要调的旋钮用什么判断
收集load_sections、parse_markdown收哪些文档;解析质量抽查
切分split_text、build_chunks块大小、重叠、按结构切、面包屑hit@k 与上下文字数(§2.2)
嵌入TfidfEmbedder、LsaEmbedder、IctEmbedder、STEmbedder模型、前缀、维数hit@k、MRR(§3.4)
索引DenseIndex、IVFIndex精确 / 近似;nproben_{probe}与精确检索的 recall(§4.2)
拼接assemble_promptkk、顺序、指令端到端答对率(Day 6)

§A RAG 基础:API 用法与边界

A.1 sentence-transformers

from sentence_transformers import SentenceTransformer
m = SentenceTransformer("BAAI/bge-small-zh-v1.5")
D = m.encode(chunk_texts, batch_size=64, normalize_embeddings=True)                  # 文档:不加前缀
Q = m.encode(["为这个句子生成表示以用于检索相关文章:" + q], normalize_embeddings=True)  # 查询:bge 中文的前缀
scores = Q @ D.T                                                                       # 归一化后,点积 = 余弦
  • 前缀:bge 中文系列建议给短查询加上面这句指令(v1.5 不加也能用,但检索短查询时加上更稳);e5 系列要求查询加 "query: "、文档加 "passage: "。看模型卡,不同模型的约定不同,用错不报错、只掉点。
  • normalize_embeddings:模型用余弦训练时一定要打开(§3.2)。打开后用内积索引即可。
  • 最大长度:m.max_seq_length 之外的部分被静默截断。块很长时要么切小,要么换长上下文模型。
  • 查询和文档要用同一个模型(Week 1 Day 6 §A.5)。换了嵌入模型,整个库要重新嵌入。

A.2 faiss

import faiss, numpy as np
D = np.ascontiguousarray(D, dtype="float32")        # faiss 只收 float32、C 连续的数组
faiss.normalize_L2(D)                               # 原地归一化(会改掉 D 本身)
index = faiss.IndexFlatIP(D.shape[1])               # 精确的内积检索
index.add(D)
scores, ids = index.search(Q, 10)                   # ids 是 add 的顺序,不是你的文档 id

quant = faiss.IndexFlatIP(d)
ivf = faiss.IndexIVFFlat(quant, d, 1024, faiss.METRIC_INNER_PRODUCT)
ivf.train(D)                                        # IVF 必须先 train(跑 k-means),否则 add 会报错
ivf.add(D)
ivf.nprobe = 16                                     # ★ 默认是 1
  • IndexFlatIP 算的是内积,不是余弦:不归一化就是点积检索(§3.2 的坑)。IndexFlatL2 返回的是平方 L2 距离,越小越好。
  • nprobe 默认 1(§4.2):不调它,召回可能只有一半。HNSW 对应的旋钮是 index.hnsw.efSearch。
  • id 映射:search 返回的是插入顺序的下标。要用自己的 id,用 IndexIDMap 包一层,或者自己维护一个下标 → 块的数组。
  • 没找够时:结果不足 kk 个的位置 id 是 −1。

A.3 切块与 token 计数

  • 按目标模型的 tokenizer 计长度:len(tok(text).input_ids)。中文一个字大约 0.6–1.5 个 token(依分词器而定),按字符数切只是近似。参考代码为了不依赖 tokenizer 按字符切。
  • LangChain 的 RecursiveCharacterTextSplitter 就是”按分隔符递归切”。它的 chunk_size 默认按字符计;要按 token 计,用 from_tiktoken_encoder 或 from_huggingface_tokenizer。
  • 代码块和表格不要从中间切开:参考代码按空行分段,代码块里没有空行时会被当成一段整体保留;超长时仍会被硬切,这是它的已知缺陷。
  • 保存元数据:来源文件、章节路径、在原文中的位置。它们是面包屑、引用、权限过滤、更新删除的基础。

A.4 用 numpy 手写检索

S = Q @ E.T                                     # (查询数, 块数)
top = np.argpartition(-S, k - 1, axis=1)[:, :k] # 只保证前 k 个是最大的 k 个,内部无序
top = np.take_along_axis(top, np.argsort(-np.take_along_axis(S, top, 1), 1), 1)   # 再对这 k 个排序
  • argpartition 是 O(N)O(N),完整排序 argsort 是 O(Nlog⁡N)O(N\log N)。库大、kk 小时差别明显。
  • argpartition 的结果内部无序,忘了第二步排序,排名指标(MRR、NDCG)就全错了,而且不会报错。
  • 参数写 k - 1:argpartition(a, kth) 保证第 kth 位放的是”排序后第 kth 小的元素”,前面都不比它大。

§B 实操

  1. 跑通 w4day1_checks.py,9 组结论都看懂。第 3 组换成你的真实参数(你的块长、你的答案通常多长),算出需要多少重叠;第 5 组把 BB 继续加大,看 8 nat 那一行什么时候追上真实值。
  2. 推导(1h,纸笔):(a) 稀释的 1/m1/\sqrt m;(b) 重叠公式 min⁡(s,C−ℓ+1)/s\min(s,C-\ell+1)/s;(c) InfoNCE 对 QQ 的梯度 (P−I)D/(τB)(P-I)D/(\tau B);(d) 归一化后 L2、余弦、点积排序相同。
  3. 建自己的知识库(1.5h):python code/w4d1_rag_basics.py --stats,然后 --query 问三个你真正关心的问题,逐条看返回的块对不对。再跑 --eval 和 --chunk-sweep,把 §3.4、§2.2 的表格复现出来。
  4. 换一个真正的嵌入模型(1h):pip install sentence-transformers,跑 --eval --embed st,把结果补进 §3.4 的表。它比 LSA 好多少?面包屑对它还有没有那么大的帮助?
  5. 读论文:RAG(Lewis et al. 2020)的第 2 节;DPR(Karpukhin et al. 2020)的第 3 节(in-batch 负例与难负例);挑读 Sentence-BERT。

参考实现:code/w4d1_rag_basics.py、code/w4_golden.py。


§C 自测

动机与直觉层

  1. LLM 的哪几个局限让 RAG 成为必要?每一个用一个数字说明。
  2. 模型有 1M 的上下文,为什么还要 RAG?什么情况下反而应该用长上下文?
  3. 为什么说”检索没找到的东西,生成器补不回来”?这对评测意味着什么?
  4. 为什么不能把整篇文档嵌入成一个向量?块是不是越小越好?
  5. 双塔和交叉编码器的根本区别是什么?为什么第一阶段检索只能用双塔(或 BM25)?

推导与计算层

  1. 推出”答案完整落在某一块里”的概率,并说明重叠多少时它等于 1。
  2. 写出 in-batch 负例的 InfoNCE 损失,求它对查询向量的梯度。温度调小会怎样?
  3. 为什么 log⁡B−LInfoNCE\log B-\mathcal L_{\text{InfoNCE}} 不会超过 log⁡B\log B?这对 batch 大小意味着什么?
  4. 1000 万块、768 维,float32、int8、PQ(96 段)、二值各要多少内存?
  5. IVF 的计算量怎么估算?nlistn_{list} 为什么常取 N\sqrt N 附近?

工程层

  1. 你的 RAG 系统答错了,怎么判断是检索的问题还是生成的问题?
  2. 用 faiss 的 IndexFlatIP 做余弦检索,要注意什么?IVF 的召回为什么可能远低于预期?
  3. 你换了一个更好的嵌入模型,要做哪几件事?(至少三件)
  4. 面包屑为什么在我们的讲义上效果这么大?什么样的语料上它帮助不大?

出关标准

  • 能用数字讲清 RAG 解决的四堵墙,以及 RAG / 长上下文 / 微调的取舍
  • 能画出 RAG 的离线与在线流程,并说出每一环的典型故障
  • 能推出稀释的 1/m1/\sqrt m 和重叠公式
  • 能写出 InfoNCE 与它的梯度,解释温度、in-batch 负例和 log⁡B\log B 上界
  • 能说清 IVF、HNSW、乘积量化各自用什么换什么
  • 在自己的讲义上跑通 --eval,能解释面包屑为什么有用、ICT 为什么没用

疑问清单

← 返回课程目录