生产环境中的 RAG:chunking、检索与诚实引用
盲切 512 字符会让答案在检索前就消失。只修 chunker,就能把排名从 115 提到 3。
本页内容
这里有一个真实助手的真实用户提出的真实问题:我的 eval set 有 20 项,这够不够让我信任分数。语料库里包含答案——而且是完整一节。下面是检索器实际放进 prompt 的四个片段。
[1] d=0.578 ship — that set has been used for fitting, and its score stops being
unbiased. Measured on this belt: sweeping the threshold on the
validation set picks 0.196, and the model then scores F1 = 0.4122…
[2] d=0.602 ng when the model is confidently **wrong**. Evaluate both at a few
scores, for an example whose true label is 1: | score | p | …
[3] d=0.613 ard and watch both numbers: | | reward model's score | true quality
| length produced | … The reward went up by a factor of 2.5. The…
[4] d=0.617 | 0.6 | +0.97 | +1.00 | +0.27 | … The reward model is working
perfectly. It has faithfully learned the preferences it was shown…四个里有三个从单词中间开始。两个来自另一个章节,讲的是另一个主题。而真正回答问题的片段——包含 Seventeen out of twenty cannot distinguish an 85 % model from a 65 % one 的那个——排在 rank 115。
现在还是同一个问题、同一个 embedding model、同一个 prompt template。只改了一件事:文档如何被切分。
[1] d=0.594 [Classification, Cross-Entropy… > How many test examples do I need?]
Read it backwards, which is how you will use it: ±5 points needs
about 200 examples. ±2 points needs about 1,230…
[2] d=0.598 [Classification, Cross-Entropy… > Three splits, and the leak…]
Why three splits and not two? Because the moment you use a set of
examples to *choose* anything…
[3] d=0.600 [Classification, Cross-Entropy… > How many test examples do I need?]
The honest reading of 17/20 is *somewhere between 64 % and 95 %*.
…Seventeen out of twenty cannot distinguish an 85 % model from a 65 % one.
[4] d=0.605 [Classification, Cross-Entropy… > How many test examples do I need?]
Suppose you score a model on 20 examples and it gets 17 right. You
report 85 %. …Wilson 95% CI : [0.6396, 0.9476]从 rank 115 到 rank 3。没人碰 model、prompt、阈值或槽位数量。本章讲的就是这个差距,以及检索系统悄悄对你撒谎的另外四个地方。
查看详情
本章需要前几章提供什么,以及唯一一个会改变语言的地方。
- 第 1 章 定义了点积和 L2 范数。下面的阈值部分只用到这两个,别无其他。
- 第 8 章 区分了语言模型的 embedding table 和在成对数据上以对比方式训练的 retrieval embedding model,测量了余弦相似度,并承诺第 19 章会给出一个具体 cut-off。这个承诺在这里兑现。那些内容不会重复。
- 第 4 章 构建了 Wilson 区间;第 15 章 构建了 evaluation harness。下面每张表都带有前者,并由后者生成。
- 第 16 章 给 context window 定了价格。本章末尾组装出的 prompt 成本是 591 tokens,而这就是片段们竞争的预算。
从 第 14 章 开始,所有内容都用 TypeScript;而本章正是这条规则证明自身价值的地方:摄取是队列和存储,搜索是一次网络调用,带引用组装 prompt 是服务器的工作。测量故意使用同一份代码,只在外面加一个记分板——用第二套实现来给检索器打分,得到的数字描述的是你并不会上线的软件;而下面的余弦阈值之所以可信,只是因为你看到它被即将在生产中运行的 chunker 一路 sweep。
语料库,以及什么算正确答案
链接到此部分:语料库,以及什么算正确答案下面所有测量都针对同一个语料库:本课程前十三章——13 份文档、359,067 个字符、127 个小节,已去掉 front matter 和参考书目。这是一个真实的技术语料库,里面有散文式说明、表格、公式和代码块,也正是人们会加载进 knowledge base 然后开始抱怨的那类东西。
ground truth 是 32 个问题,每个问题都配有一根 needle:语料库中能够回答它的一句简短逐字原文。每根 needle 在 359,067 个字符中只出现一次,而且都不是小节标题——这个检查很重要,因为如果一个 chunker 把标题复制进每个 chunk,它本来会给自己刷分。每个问题问两遍,一遍用课程里的英文表达,一遍用支持工单的说法:32 个 ground truths 上的 64 个 queries。
一次检索如果返回的 chunk 完整包含 needle,就算正确。这是唯一符合生成器需要的定义:prompt 里半句话不是答案,而是风险。
embedding model 是 all-MiniLM-L6-v2——384 维,mean-pooled 且已归一化,也就是第 8 章测过的对比训练模型。在 CPU 上索引语料库需要 20.8 秒,每个 chunk 22 ms;embedding 一个 query 需要 13 ms。
用六种方式测量 chunking
链接到此部分:用六种方式测量 chunking六种策略来自三个独立成分。Blind 不看文本,每 512 个字符切一次。Boundaries 从不在段落中间切,只在某段超过预算时退回到句子边界。Header 给每个 chunk 加上文档标题和章节路径前缀。Overlap 把上一个 chunk 的最后 64 个字符复制到下一个 chunk。
| strategy | chunks | answers destroyed | R@1 | R@4 | R@8 | R@20 | MRR |
|---|---|---|---|---|---|---|---|
| A blind 512 | 708 | 4 / 32 | 0.125 | 0.297 | 0.422 | 0.594 | 0.241 |
| B blind + overlap | 809 | 0 | 0.172 | 0.391 | 0.453 | 0.625 | 0.286 |
| C boundaries | 940 | 0 | 0.156 | 0.422 | 0.531 | 0.672 | 0.293 |
| D boundaries + overlap | 940 | 0 | 0.156 | 0.359 | 0.516 | 0.656 | 0.277 |
| E boundaries + header | 940 | 0 | 0.094 | 0.422 | 0.578 | 0.828 | 0.280 |
| F boundaries + header + overlap | 940 | 0 | 0.156 | 0.391 | 0.562 | 0.766 | 0.298 |
在 64 个 queries 上,R@20 的 95 % Wilson 区间,A 是 [0.471, 0.705],E 是 [0.718, 0.901]——它们不重叠,但其他大多数列会重叠,而且未配对的表无法区分它们。每种策略回答的是同一批 queries,所以诚实的检验是配对的:统计一种策略相对另一种策略的胜负,并在不一致的配对上做符号检验。有三个结果经受住了它。
Blind chunking 会直接毁掉三十二个答案中的四个。 不是让它们排名很差——是毁掉它们。needle 横跨 512 字符边界,因此索引中没有任何 chunk 包含它,对这些 queries 来说召回上限就是零。没有 reranker 能救回来,阈值无济于事,更大的 model 也无济于事。你无法检索在索引中任何地方都不是完整一段的文本。这是 RAG 中最少被报道的失败,因为它看起来和糟糕的检索器一模一样。
Overlap 修复了这一点,仅此而已。 所有带 overlap 的策略都会丢失零个答案,这正是 overlap 的用途。它不会提升排序:B 对 A 在 R@8 上是 +8/−6,p = 0.79;在 R@20 上是 +9/−7,p = 0.80。更糟的是,在 header 之上再加 overlap 会主动伤害效果——F 对 E 在 R@20 上是 +2/−6——原因是机械性的。一个 chunk 的向量是其 tokens 的平均值,因此上一个 chunk 的 64 个字符会把这个平均值拖向邻居的主题。Overlap 是对切断答案的保险,代价是 precision。
上下文 header 才是买来检索效果的东西。 E 对 A 是 R@20 上 +18/−3,p = 0.0015。而消融说明,起作用的不是 boundaries:E 对 C——切法相同,唯一差异是 header——是 +12/−2,p = 0.0129。给一个段落加上“Classification, Cross-Entropy, and How Not to Fool Yourself > How many test examples do I need?”这个前缀,会告诉 embedding model 这个段落关于什么,而段落本身常常并不会明说。它是文档里的代词解析器。
这给了 chunker 形状,也给了一条很容易弄错的规则:
export interface Chunked {
/** What gets EMBEDDED: contextual header + this chunk's own content. */
text: string;
/** ONLY this chunk's own content: what is quoted back to the user. */
content: string;
section: string;
/** Character range in the document's canonical text. Sliceable. */
from: number;
to: number;
}
export function chunkDocument(doc: string, docTitle: string, target = 512): Chunked[] {
const out: Chunked[] = [];
const heads = [...doc.matchAll(/^## (.+)$/gm)].map((m) => ({ at: m.index!, title: m[1].trim() }));
const spans = heads.length
? heads.map((h, i) => ({ ...h, end: i + 1 < heads.length ? heads[i + 1].at : doc.length }))
: [{ at: 0, title: "", end: doc.length }];
for (const s of spans) {
const header = s.title ? `${docTitle} > ${s.title}` : docTitle;
const skip = /^## .+\n/.exec(doc.slice(s.at, s.end))?.[0].length ?? 0;
const body = doc.slice(s.at + skip, s.end);
const origin = s.at + skip;
// The offset is FOUND in the document, never accumulated: adding up
// lengths drifts by a character wherever a separator was normalised,
// and a citation anchor off by one points at the wrong line.
const emit = (from: number, to: number) => {
const raw = body.slice(from, to);
const lead = raw.length - raw.trimStart().length;
const content = raw.trim();
if (!content) return;
out.push({ text: `[${header}]\n${content}`, content, section: s.title,
from: origin + from + lead, to: origin + from + lead + content.length });
};
let open: [number, number] | null = null;
for (const m of body.matchAll(/[^\n]([^\n]|\n(?!\n))*/g)) { // paragraphs
const [pf, pt] = [m.index!, m.index! + m[0].length];
if (pt - pf > target) { // one huge paragraph
if (open) { emit(open[0], open[1]); open = null; }
let cur: [number, number] | null = null;
for (const sm of body.slice(pf, pt).matchAll(/[^.!?]*[.!?]*\s*/g)) {
if (!sm[0]) continue;
const [sf, st] = [pf + sm.index!, pf + sm.index! + sm[0].length];
if (cur && st - cur[0] > target) { emit(cur[0], cur[1]); cur = null; }
cur = cur ? [cur[0], st] : [sf, st];
}
if (cur) emit(cur[0], cur[1]);
continue;
}
if (open && pt - open[0] > target) { emit(open[0], open[1]); open = null; }
open = open ? [open[0], pt] : [pf, pt];
}
if (open) emit(open[0], open[1]);
}
return out;
}两份文本,而不是一份。 text 是被 embedding 的内容,连同 header 全部在内。content 只包含这个 chunk 自己的话,也是回引给用户的内容。如果引用 text,citation 会显示一个在文档那个位置并不存在的 header——而且在有 overlap 时,还会显示属于前一个片段的重复尾巴。它展示的文本并不在它声称的位置,这比什么都不展示更糟。
header 并不免费。在 940 个 chunks 上,它消耗了索引 114,275 个 embedded tokens 中的 24,213 个:你为 embedding 付费的内容里,有 21.2 % 是你自己写的 header。 它也会把 chunks 推向 encoder 的窗口上限。all-MiniLM-L6-v2 接受 256 个 word-pieces;策略 E 有 17 个 chunks 超过这条线,F 有 28 个,而且每一个都会被静默截断,没有任何东西发出警告。你的有效 chunk size 不是配置里的数字——而是它和 encoder 窗口两者中较小的那个。
人人都会跳过的二十行 BM25
链接到此部分:人人都会跳过的二十行 BM25稠密检索有一个系统性弱点,而且一点也不微妙:它匹配含义,因此对你到底输入了哪个字符串并不在意。零件号、错误码、首字母缩写、姓氏——这些都没有有用的含义可供 embedding,而一个错误码的最近邻就是语料库里的其他所有错误码。
经典答案比这一切都更早,而且只要二十行。BM25 根据 query 的词项在文档中出现的频率给文档打分,随着每个词项频率升高而削弱其增益,并惩罚那些仅仅因为篇幅很长而累积匹配的长文档。1 词项 的贡献是
其中 是词项在文档中的计数, 是文档长度, 是平均长度, 和 是两个惯用常数—— 决定重复多快停止带来帮助, 决定长度惩罚有多重。
const toks = (s: string) => s.toLowerCase().match(/[a-z0-9]+/g) ?? [];
export class BM25 {
private tf: Map<string, number>[] = [];
private len: number[] = [];
private idf = new Map<string, number>();
private avg = 0;
private k1: number; private b: number;
constructor(docs: string[], k1 = 1.2, b = 0.75) {
this.k1 = k1; this.b = b;
const df = new Map<string, number>();
for (const d of docs) {
const t = new Map<string, number>(); const ws = toks(d);
for (const w of ws) t.set(w, (t.get(w) ?? 0) + 1);
for (const w of t.keys()) df.set(w, (df.get(w) ?? 0) + 1);
this.tf.push(t); this.len.push(ws.length);
}
this.avg = this.len.reduce((a, b) => a + b, 0) / this.len.length;
const N = docs.length;
for (const [w, n] of df) this.idf.set(w, Math.log(1 + (N - n + 0.5) / (n + 0.5)));
}
scores(query: string): number[] {
const q = toks(query);
return this.tf.map((tf, i) => {
const L = this.len[i]; let s = 0;
for (const w of q) {
const f = tf.get(w); if (!f) continue;
s += (this.idf.get(w) ?? 0) * (f * (this.k1 + 1)) /
(f + this.k1 * (1 - this.b + (this.b * L) / this.avg));
}
return s;
});
}
}在 940 个 chunks 上,它给一个 query 打分需要 1.14 ms,除了两个哈希表之外完全不需要索引。而且它不是博物馆展品:
| retriever | R@1 | R@4 | R@8 | MRR | cost per query |
|---|---|---|---|---|---|
| dense (cosine) | 0.094 | 0.422 | 0.578 | 0.280 | 13 ms to embed + 0.3 ms to scan |
| lexical (BM25) | 0.219 | 0.375 | 0.469 | 0.313 | 1.14 ms |
| hybrid (RRF) | 0.203 | 0.484 | 0.609 | 0.346 | both |
| hybrid + cross-encoder | 0.312 | 0.578 | 0.703 | 0.447 | + 569 ms |
在这个语料库上,BM25 让稠密检索器的 top-1 准确率翻了一倍还多,而到 rank 8 时会明显输给它。它们失败在不同的 queries 上,这就是同时运行两者的全部理由。
融合它们时,显而易见的方法恰恰是错的。余弦距离和 BM25 分数不在同一个尺度上,边界也不同;按 query 归一化会让权重取决于最佳命中恰好有多好。Reciprocal rank fusion 丢掉分数,只保留排名:2
/** Reciprocal rank fusion: ranks, not scores. Nothing to calibrate. */
export function rrf(lists: number[][], k = 60): number[] {
const acc = new Map<number, number>();
for (const list of lists)
list.forEach((id, r) => acc.set(id, (acc.get(id) ?? 0) + 1 / (k + r + 1)));
return [...acc.entries()].sort((a, b) => b[1] - a[1]).map(([id]) => id);
}这里,对表的诚实解读比表本身更重要。Hybrid 在 R@4 上以 +10/−3 胜过 BM25,p = 0.09。它以 +10/−6 胜过 dense,p = 0.45。在这个语料库上,使用 64 个 queries,hybrid retrieval 与 dense retrieval 无法区分。 它在点估计和每个 recall 列上都更好,但证据没有达到显著性。互联网上几乎每篇 hybrid-search 博文都会报告一张类似上面的表,却不给区间;这就是区间说的话。
Bi-encoder、cross-encoder,以及提升到底来自哪里
链接到此部分:Bi-encoder、cross-encoder,以及提升到底来自哪里到目前为止,一切都是 bi-encoder:query 单独通过 model,每个 chunk 早在几个月前也单独通过了它,两者除了点积之外从不相遇。这使得索引成为可能——embed 一次,永久复用——同时也是上限。model 从未把 query 和 chunk 放在一起看。
cross-encoder 正是这么做的:它把这一对作为一个输入,并返回相关性分数。没有任何东西可以预计算,所以它无法给整个索引排序——但它可以 rerank 一个候选短名单。用 ms-marco-MiniLM-L-6-v2 对 hybrid top 25 做 reranking,会把 R@1 从 0.094(dense)提高到 0.312,把 MRR 从 0.280 提高到 0.447:这是本章最大的单项改进,也是唯一一个触及列表顶部而不是尾部的改进。
在 CPU 上,每个 query 成本是 569 ms,而 BM25 是 1.14 ms,向量扫描是 0.3 ms。为二十五篇文档付出大约两千倍的检索成本。 这一个数字就是完整的 bi-encoder/cross-encoder 取舍,也解释了为什么架构总是同一种形状:一个便宜的检索器负责宽召回,然后一个昂贵的打分器处理你负担得起的短名单。ColBERT 处在两者之间,预计算每个 token 的向量,并做一种 late interaction:比 cross-encoder 便宜,比点积更锋利。3
L2、余弦,以及你还没资格拥有的阈值
链接到此部分:L2、余弦,以及你还没资格拥有的阈值向量数据库会报告距离,而选择哪种距离是一个配置项。在归一化向量上,这个选择只是表面差异;这个恒等式值得推一次,因为后面的一切都依赖向量确实是单位向量。对于 :
所以余弦距离 恰好就是 。这就是第 1 章的点积和范数兑现成现金。在上面索引里的两个真实 chunk 向量上检查,然后在 40,000 对向量上检查:
||a|| = 1.000000 ||b|| = 1.000000
L2 = 0.795183 L2^2/2 = 0.316158 1 - cos = 0.316158 diff = 7.66e-08
max |L2^2/2 - (1 - cos)| over 200 x 200 pairs = 8.3e-07精确到浮点噪声——而且只因为向量被归一化了。跳过归一化,这个恒等式就是假的,你的阈值没有意义,文档报告的距离会取决于它的文本有多长。
现在说那个没人推导的数字。检索器总会返回一些东西:它会给整个索引排序,把列表顶部交给你,不管答案到底在不在语料库里。阈值是系统里唯一能说 no 的部分——而要设置阈值,你需要一些应该什么都拿不到的 queries。这里有三十个:二十一个关于这个语料库确实不覆盖的东西——streaming、rate limits、prompt caching、JSON schemas、agent loops、vector databases、prompt injection、image generation——还有九个关于海鲜饭、护照和退款政策。对同一个索引:
| top-1 cosine distance | |
|---|---|
| in-domain queries, all 64 | mean 0.445, range 0.270 – 0.721 |
| in-domain, top-1 actually correct | mean 0.370 |
| in-domain, top-1 wrong | mean 0.452 |
| out-of-domain, all 30 | mean 0.699, range 0.497 – 0.867 |
分布分开了,也重叠了。最差的 in-domain query 离它的答案(0.721)比最好的 out-of-domain query 离一个无关段落(0.497)还远,所以没有任何阈值能让两者都正确。把它在真实闸门上 sweep——最多保留四个 chunks,而且只保留低于 cut 的那些:
| threshold | in-domain answered | of which the answer was in | out-of-domain answered |
|---|---|---|---|
| 0.400 | 17 / 64 | 6 | 0 / 30 |
| 0.450 | 38 / 64 | 13 | 0 / 30 |
| 0.500 | 50 / 64 | 19 | 1 / 30 |
| 0.525 | 52 / 64 | 20 | 2 / 30 |
| 0.550 | 55 / 64 | 21 | 3 / 30 |
| 0.600 | 60 / 64 | 25 | 5 / 30 |
| 0.675 | 62 / 64 | 27 | 10 / 30 |
| 0.800 | 64 / 64 | 27 | 26 / 30 |
| none | 64 / 64 | 27 | 30 / 30 |
把最后一列读成虚张声势。没有阈值时,助手会从一个关于 backpropagation 的语料库里,对“如何更新我的西班牙护照”产出自信且引用完备的答案,三十次里三十次都这样。0.675 时,它三十次里会这样做十次。0.525 时,它这样做两次,同时放弃十二个本可以回答的问题。
这种取舍是产品决策,而正确端点取决于错误答案会让你付出什么代价。不可谈判的是最后一列必须存在。如果你从未用检索器应该拒答的问题测过它,你就没有阈值——你只有一个数字。
0.675 下十次虚张声势中的两次,展示了这种失败的两种方式。
query: "how much does prompt caching save on a long conversation"
[1] d=0.497 13-inference-optimization > Prefill and decode are two different machines
[2] d=0.532 13-inference-optimization > The cache is also the bill
query: "what is the capital of france"
[1] d=0.671 12-reasoning > The model does not think. It computes for longer.
"…it is why 'think step by step' does nothing for what is the capital of France."第一个是 near miss:语料库详细解释了 KV cache,query 问的是 prompt cache,词都是同一批词,而 0.497 比整个实验里大多数正确的 in-domain retrieval 都更近。embedding 不知道两个同名 cache 是不同机器。第二个是 literal match with no answer:语料库包含精确短语“what is the capital of France”,但它是作为一个不需要推理的问题示例出现的。检索器是对的;答案并不在那里。任何把“我找到了相似内容”读成“我找到了答案”的系统,都会基于这条证据断言 Paris——或者更糟,不会断言。
为什么 citation 不是由 model 写的
链接到此部分:为什么 citation 不是由 model 写的model 没有独立的事实能力。生成真实句子和生成貌似可信的句子是同一个操作——第 8 章的 next-token prediction——而这个操作里没有任何东西会标记哪句是哪种。2025 年那篇重新框定这个问题的分析认为,训练和评估流水线会主动奖励猜测:benchmark 用二元准确率打分,不给弃答任何奖励,因此一个总是作答的 model,在不知道时也会比一个说“I don't know”的相同 model 得分更高,post-training 也会相应优化。按这种理解,hallucination 不是神秘缺陷。它就是在没有错误惩罚的情况下批改选择题会得到的东西。6
看它的形状。让 Qwen2.5-0.5B-Instruct 给出八篇关于对比句子 embeddings 的论文,并带上 identifiers,它产出了八行格式完美的内容。八个 identifiers 全都格式合法。八个都能解析到 arXiv 上的真实论文。八个里有零个是它声称的那篇论文。
claimed arXiv:1907.06432 - Contrastive Sentence Embeddings for Text Retrieval
actual A Neural Turing~Machine for Conditional Transition Graph Modeling
claimed arXiv:1809.08669 - Contrastive Learning of Sentence Representations…
actual Collapsing Superstring Conjecture
claimed arXiv:1807.08669 - Contrastive Learning of Sentence Representations…
actual Automatic Speech Recognition for Humanitarian Applications in Somali这是一个小 model,发生率 属于它自己;frontier model 会少发明得多。但机制是通用的,这也是下面规则的原因。一个检查“这个 identifier 是否存在”的验证器会让八个全通过,而点击它的用户会落到真实 archive 的真实页面上,却无从知道映射是编造的。失败不在 identifier,也不在格式。失败在关联关系——而这正是语言模型靠 plausibility 生成的东西。
所以:model 写 [1] 和 [2],永远不写链接。 数字指向服务器检索到的片段,而服务器——它确切知道每个数字来自哪份文档和哪些 offsets——随后附上文档、标签和 URL。没有东西可供 model 发明,因为它从未被要求写出那个它会发明的东西。
export function buildContext(question: string, hits: Scored[]) {
const citations: Citation[] = hits.map((h, i) => ({
index: i + 1,
documentId: h.chunk.documentId,
documentName: h.chunk.documentName,
locatorLabel: label(h.chunk),
fragment: `#char=${h.chunk.locator.flow.from},${h.chunk.locator.flow.to}`,
quote: h.chunk.content, // the OWN content, never `text`
cosineDistance: h.cosineDistance,
}));
const blocks = citations
.map((c) => `[${c.index}] ${c.documentName} - ${c.locatorLabel}\n${c.quote}`)
.join("\n\n");
const prompt =
`Answer using ONLY the numbered sources below. Cite every claim as [n].\n` +
`If the sources do not contain the answer, say so and stop.\n\n` +
`SOURCES\n${blocks}\n\nQUESTION\n${question}`;
return { prompt, citations };
}在开头那个问题上运行它,四个 chunks 会变成一个 591-token prompt,以及一张 model 从未见过的表:
[1] 04-classification How many test examples do I need? #char=28215,28701 d=0.594
[2] 04-classification Three splits, and the leak… #char=20329,20839 d=0.598
[3] 04-classification How many test examples do I need? #char=25873,26272 d=0.600
[4] 04-classification How many test examples do I need? #char=25554,25871 d=0.605locator 是人们跳过、然后后来又加不回来的部分。#char=25873,26272 是文档 canonical text 中的一个范围;对 PDF 来说,等价物是 #page=12,对音频或视频是 #t=132.4,158.9,对电子表格是一张 sheet 和一个 A1 range。这两者不是发明——#page= 是 PDF Open Parameters,#t= 是 W3C Media Fragments,并且浏览器在 video 和 audio 元素上原生支持。没有 locator 的 citation 只是一个文档名,而文档名不是 citation;它只是建议用户自己去找。
当没有任何内容通过阈值时,流水线根本不会到达 model:
NO ANSWER: nothing under cosine distance 0.675 for "what is the offside rule in football"
NO ANSWER: nothing under cosine distance 0.675 for "how do i renew my spanish passport"
NO ANSWER: nothing under cosine distance 0.675 for "how do i build an agent loop with tools"这比 system prompt 里的任何指令都更便宜、更可靠,因为它是两个数字之间的比较,而不是对概率系统的一次请求。
把 retriever 和 generator 分开评估
链接到此部分:把 retriever 和 generator 分开评估本章的每一次测量都在给 retriever 打分,从未要求 model 写答案。这是有意为之,也是多数团队会跳过的部分。
一个 RAG 系统有两种从外部看一模一样的失败模式。检索器没有找到段落;或者它找到了,但生成器忽略它、反驳它,或把它和自己原本相信的东西混在一起。只给最终答案打分,这两者就无法区分,于是你会针对一个其实存在于 chunker 里的问题调 prompts。Recall@k、MRR 和 answer-destroyed 计数完全不需要 generation call,它们便宜到足以在每次 deploy 上运行,而且它们就是第 15 章的 harness,只是换了 scoring function——同样的请求、deadline、并发和 tally,只不过在固定问题集上运行,而不是在实时对话上运行。
报告它们时要带区间。第 4 章的算术原封不动适用:64 个 queries 下,0.5 的 recall 会有一个大约 ±0.12 的 95 % Wilson 区间,所以一种策略领先另一种 4 个点并没有告诉你任何事。只要两种策略回答的是同一批问题,就用配对检验;这里它们总是如此——正是它把“E 看起来比 A 好”变成了 p = 0.0015。
最后的诚实是:RAG 会减少 hallucination,但不会消除它。 把正确段落放进 prompt,并不会强迫 model 使用它,而原始论文以来的文献一直都这么说。7 生产中有两件事会让它更糟。长上下文会退化——model 在长 prompt 的开头和结尾比中间更可靠地找到信息,所以二十个 chunks 而不是四个,可能在提高账单的同时降低准确率,这个效应在 第 24 章 测过。而检索可能是正确的,但仍然不充分,就像上面两个 caches 所展示的那样。SelfCheckGPT 标记那些经不起重采样的 claims;8 Self-RAG 训练 model 发出自己的 retrieve-and-critique tokens;9 TruthfulQA 最先让这种失败模式变得清晰可见。10 没有任何一个能弥合缺口,而把检索到的文本当作证明来展示的系统,混淆了有来源和真实。
任何 query 发生前就已经运行的那一半系统
链接到此部分:任何 query 发生前就已经运行的那一半系统检索器是流水线可见的一部分,而它的失败全都更早发生在暗处。有三类会反复出现。
Extraction 是内容死亡的地方。 PDF 不是文本;它是绘图指令。双栏版式会交错,表格会变成词语汤,页眉会重复进每个 chunk,扫描页在 OCR 给它文本之前根本没有文本,而且 OCR 还带置信度。上面所有测量都假设 extractor 做好了它的工作;生产中它常常没有,而症状会在三层之外表现为糟糕的 retrieval。
索引被构建它的 model 盖了章。 来自两个 models 的 embeddings 不可比较——不是“不太准确”,而是不可比较,因为它们是不同空间里的点。更换 embedding model 后,store 里的每个向量在重建之前都是垃圾。所以 model 名称、维度数、pipeline 版本和 extractor 版本,会在索引时写在每份文档旁边。没有它们,升级那天你无法判断哪些文档过期、哪些是当前版本,而半迁移的索引会返回自信的胡话,且任何地方都没有错误。
一个坏文档不能拖垮整个文件夹,计数器必须统计实际发生了什么。 提取失败的文档会进入 failed 状态,带着原因,可见且可重试,而其他九十九份仍然可搜索;索引完成时写入的 chunks 数量由服务器负责,而不是由客户端在上传时声明。一个报告有 400 个片段却只持有 40 个的文件夹,是一个只会以无法回答的问题形式浮出水面的谎言。
接下来去哪里
链接到此部分:接下来去哪里本章的系统回答那些答案已经被写下来的问题。它检索它们、排序它们,在无法回答时拒答,并引用它查看过的位置。这就是多数人希望自己的文档助手完成的大部分事情,而它有一个非常具体的边界:retrieval 只能返回某个人写过的东西。
这就留下了另一半。你希望 model 做的一些事根本不是文档里的事实——它必须保持的一种格式、一种语气、一个有四百个标签的 taxonomy、一种存在于一万条历史样例中却不在任何段落里的决策方式。retrieval 无法交付这些,因为没有东西可检索;更长的 prompt 只是为 skill 的描述支付第 16 章的账单,而不是为 skill 本身付费。
第 20 章 讨论的就是这个决策——fine-tune、retrieve 还是 prompt——其发现是:这个决策先是经济问题,然后才是技术问题。三者会在同一个问题上端到端定价,而交叉点是 token 数。它开头的问题正是本章无法回答的问题。不是答案写在哪里,而是当它从未被写下来时,你该怎么办。
来源与方法
链接到此部分:来源与方法本章所有测量都使用同一个语料库和同一个工具,二者都可复现。语料库是本课程第 1 到第 13 章,截至 2026 年 9 月 7 日的状态——13 份文档、359,067 个字符、127 个小节,去掉了 front matter 和参考书目。这些章节仍在编辑,所以今天按同一规则统计会多出几千个字符:小节数不变,下面每个结论也不变,但字符总数是一个快照,并已这样标注。ground truth 是 32 个问题,每个问题配有一句逐字原文,该句在语料库中恰好出现一次且从不是小节标题,并用两种表述提问,共 64 个 queries。Retrieval embeddings 是 sentence-transformers/all-MiniLM-L6-v2(384 维、mean-pooled、L2-normalised、256-token window);reranking 在 top 25 上使用 cross-encoder/ms-marco-MiniLM-L-6-v2;generation 示例使用 Qwen/Qwen2.5-0.5B-Instruct,并采用 greedy decoding。所有 timings 都是单线程 CPU。本章生成过程中没有调用任何付费 API,这也是为什么这里每个 latency 都是本地 latency,并且都如此标注。
TypeScript 中展示的 chunker 就是被测量的 chunker:实现同一规则的 Python 工具与 ts/chunk.ts 在整个语料库上逐 chunk 比较,所有 940 个 chunks、texts 和 offsets 都一致。区间是 95 % Wilson;配对比较是在不一致配对上进行的双侧精确符号检验。
上面引用的全部十四个 identifiers 都在 2026 年 9 月 7 日通过 arXiv API 解析,并逐标题检查——考虑到那八个并不存在的对应关系,这似乎是本章至少该做到的事。
参考资料
链接到此部分:参考资料-
Robertson, S. and Zaragoza, H. The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval 3(4), pp. 333–389 (2009)。这是饱和函数和上面两个常数的来源,也是在想知道 为什么存在时该读的地方。 ↩
-
Cormack, G. V., Clarke, C. L. A. and Büttcher, S. Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods. SIGIR 2009。 是他们的,而这个方法的重点是,它不需要在被融合的 score scales 之间做校准。 ↩
-
Khattab, O. and Zaharia, M. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT. arXiv:2004.12832 (2020)。这是点积和 cross-encoder 之间的中间地带。Reimers, N. and Gurevych, I., Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks, arXiv:1908.10084 (2019),是本章索引所基于的 bi-encoder,也是在第 8 章测量过的模型。 ↩
-
Malkov, Yu. A. and Yashunin, D. A. Efficient and Robust Approximate Nearest Neighbor Search using Hierarchical Navigable Small World Graphs. arXiv:1603.09320 (2016)。这是目前销售的大多数向量数据库背后的图索引。 ↩
-
Johnson, J., Douze, M. and Jégou, H. Billion-scale Similarity Search with GPUs. arXiv:1702.08734 (2017)。FAISS,以及上面框中测量的 IVF 的参考实现。 ↩
-
Kalai, A. T., Nachum, O., Vempala, S. S. and Zhang, E. Why Language Models Hallucinate. arXiv:2509.04664 (2025)。这篇文章论证 hallucination 是由从不奖励弃答的二元准确率评分制造的,因此在成为建模问题之前,首先是一个评估问题。 ↩
-
Lewis, P., Perez, E., Piktus, A., Petroni, F., Karpukhin, V., Goyal, N., Küttler, H., Lewis, M., Yih, W., Rocktäschel, T., Riedel, S. and Kiela, D. Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks. arXiv:2005.11401 (2020)。这篇论文命名了这个模式,也最值得阅读以了解它能修复什么、不能修复什么。Guu et al., REALM: Retrieval-Augmented Language Model Pre-Training, arXiv:2002.08909 (2020),是同期工作,它把 retriever 与 model 联合训练,而不是把它外挂上去;Karpukhin et al., Dense Passage Retrieval for Open-Domain Question Answering, arXiv:2004.04906 (2020),是本章一直使用的双编码器 dense retriever 的来源;Izacard and Grave, Leveraging Passage Retrieval with Generative Models for Open Domain Question Answering, arXiv:2007.01282 (2020),是把许多 passages 喂给一个 generator 的 fusion-in-decoder 方案。Gao et al., Retrieval-Augmented Generation for Large Language Models: A Survey, arXiv:2312.10997 (2023),是后来所有内容的地图,包括 HyDE (Gao et al., Precise Zero-Shot Dense Retrieval without Relevance Labels, arXiv:2212.10496, 2022),后者 embedding 的是假想答案而不是问题。 ↩
-
Manakul, P., Liusie, A. and Gales, M. J. F. SelfCheckGPT: Zero-Resource Black-Box Hallucination Detection for Generative Large Language Models. arXiv:2303.08896 (2023)。通过重采样检测,不访问 model 内部,也不使用外部 knowledge base。 ↩
-
Asai, A., Wu, Z., Wang, Y., Sil, A. and Hajishirzi, H. Self-RAG: Learning to Retrieve, Generate, and Critique through Self-Reflection. arXiv:2310.11511 (2023)。训练 model 决定何时检索,而不是每一轮都检索。 ↩
-
Lin, S., Hilton, J. and Evans, O. TruthfulQA: Measuring How Models Mimic Human Falsehoods. arXiv:2109.07958 (2021)。这个 benchmark 由“貌似可信的答案”和“真实答案”不同的问题构成,一句话概括了全部难点。 ↩