跳至内容
19/30第 19 章,共 30 章

生产环境中的 RAG:chunking、检索与诚实引用

盲切 512 字符会让答案在检索前就消失。只修 chunker,就能把排名从 115 提到 3。

本页内容

这里有一个真实助手的真实用户提出的真实问题:我的 eval set 有 20 项,这够不够让我信任分数。语料库里包含答案——而且是完整一节。下面是检索器实际放进 prompt 的四个片段。

four fragments, chunked blind at 512 charactersTEXT
[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。只改了一件事:文档如何被切分。

four fragments, cut on section boundaries with a contextual headerTEXT
[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。

六种策略来自三个独立成分。Blind 不看文本,每 512 个字符切一次。Boundaries 从不在段落中间切,只在某段超过预算时退回到句子边界。Header 给每个 chunk 加上文档标题和章节路径前缀。Overlap 把上一个 chunk 的最后 64 个字符复制到下一个 chunk。

strategychunksanswers destroyedR@1R@4R@8R@20MRR
A blind 5127084 / 320.1250.2970.4220.5940.241
B blind + overlap80900.1720.3910.4530.6250.286
C boundaries94000.1560.4220.5310.6720.293
D boundaries + overlap94000.1560.3590.5160.6560.277
E boundaries + header94000.0940.4220.5780.8280.280
F boundaries + header + overlap94000.1560.3910.5620.7660.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 形状,也给了一条很容易弄错的规则:

chunk.tsTS
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 窗口两者中较小的那个。

稠密检索有一个系统性弱点,而且一点也不微妙:它匹配含义,因此对你到底输入了哪个字符串并不在意。零件号、错误码、首字母缩写、姓氏——这些都没有有用的含义可供 embedding,而一个错误码的最近邻就是语料库里的其他所有错误码。

经典答案比这一切都更早,而且只要二十行。BM25 根据 query 的词项在文档中出现的频率给文档打分,随着每个词项频率升高而削弱其增益,并惩罚那些仅仅因为篇幅很长而累积匹配的长文档。1 词项 tt 的贡献是

idf(t)ft,d(k1+1)ft,d+k1(1b+bdd)\mathrm{idf}(t)\cdot\frac{f_{t,d}\,(k_1+1)}{f_{t,d} + k_1\left(1 - b + b\,\frac{|d|}{\overline{|d|}}\right)}

其中 ft,df_{t,d} 是词项在文档中的计数,d|d| 是文档长度,d\overline{|d|} 是平均长度,k1=1.2k_1 = 1.2b=0.75b = 0.75 是两个惯用常数——k1k_1 决定重复多快停止带来帮助,bb 决定长度惩罚有多重。

bm25.tsTS
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,除了两个哈希表之外完全不需要索引。而且它不是博物馆展品:

retrieverR@1R@4R@8MRRcost per query
dense (cosine)0.0940.4220.5780.28013 ms to embed + 0.3 ms to scan
lexical (BM25)0.2190.3750.4690.3131.14 ms
hybrid (RRF)0.2030.4840.6090.346both
hybrid + cross-encoder0.3120.5780.7030.447+ 569 ms

在这个语料库上,BM25 让稠密检索器的 top-1 准确率翻了一倍还多,而到 rank 8 时会明显输给它。它们失败在不同的 queries 上,这就是同时运行两者的全部理由。

融合它们时,显而易见的方法恰恰是错的。余弦距离和 BM25 分数不在同一个尺度上,边界也不同;按 query 归一化会让权重取决于最佳命中恰好有多好。Reciprocal rank fusion 丢掉分数,只保留排名:2

RRF(d)=lists1k+rank(d),k=60\mathrm{RRF}(d) = \sum_{\text{lists}} \frac{1}{k + \mathrm{rank}(d)}, \qquad k = 60
retrieve.tsTS
/** 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、余弦,以及你还没资格拥有的阈值

向量数据库会报告距离,而选择哪种距离是一个配置项。在归一化向量上,这个选择只是表面差异;这个恒等式值得推一次,因为后面的一切都依赖向量确实是单位向量。对于 a=b=1\lVert a \rVert = \lVert b \rVert = 1

ab2=a2+b22ab=22cosθ\lVert a - b \rVert^2 = \lVert a \rVert^2 + \lVert b \rVert^2 - 2\,a \cdot b = 2 - 2\cos\theta

所以余弦距离 1cosθ1 - \cos\theta 恰好就是 d2/2d^2/2。这就是第 1 章的点积和范数兑现成现金。在上面索引里的两个真实 chunk 向量上检查,然后在 40,000 对向量上检查:

TEXT
||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 64mean 0.445, range 0.270 – 0.721
in-domain, top-1 actually correctmean 0.370
in-domain, top-1 wrongmean 0.452
out-of-domain, all 30mean 0.699, range 0.497 – 0.867

分布分开了,也重叠了。最差的 in-domain query 离它的答案(0.721)比最好的 out-of-domain query 离一个无关段落(0.497)还远,所以没有任何阈值能让两者都正确。把它在真实闸门上 sweep——最多保留四个 chunks,而且只保留低于 cut 的那些:

thresholdin-domain answeredof which the answer was inout-of-domain answered
0.40017 / 6460 / 30
0.45038 / 64130 / 30
0.50050 / 64191 / 30
0.52552 / 64202 / 30
0.55055 / 64213 / 30
0.60060 / 64255 / 30
0.67562 / 642710 / 30
0.80064 / 642726 / 30
none64 / 642730 / 30

把最后一列读成虚张声势。没有阈值时,助手会从一个关于 backpropagation 的语料库里,对“如何更新我的西班牙护照”产出自信且引用完备的答案,三十次里三十次都这样。0.675 时,它三十次里会这样做十次。0.525 时,它这样做两次,同时放弃十二个本可以回答的问题。

这种取舍是产品决策,而正确端点取决于错误答案会让你付出什么代价。不可谈判的是最后一列必须存在。如果你从未用检索器应该拒答的问题测过它,你就没有阈值——你只有一个数字。

0.675 下十次虚张声势中的两次,展示了这种失败的两种方式。

the two shapes of a confident wrong retrievalTEXT
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——或者更糟,不会断言。

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 上的真实论文。八个里有零个是它声称的那篇论文。

8 references, checked one by one against the arXiv APITEXT
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 发明,因为它从未被要求写出那个它会发明的东西。

prompt.tsTS
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 从未见过的表:

TEXT
[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.605

locator 是人们跳过、然后后来又加不回来的部分。#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:

TEXT
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 打分,从未要求 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 解析,并逐标题检查——考虑到那八个并不存在的对应关系,这似乎是本章至少该做到的事。

  1. Robertson, S. and Zaragoza, H. The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval 3(4), pp. 333–389 (2009)。这是饱和函数和上面两个常数的来源,也是在想知道 bb 为什么存在时该读的地方。

  2. Cormack, G. V., Clarke, C. L. A. and Büttcher, S. Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods. SIGIR 2009。k=60k = 60 是他们的,而这个方法的重点是,它不需要在被融合的 score scales 之间做校准。

  3. 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 章测量过的模型。

  4. Malkov, Yu. A. and Yashunin, D. A. Efficient and Robust Approximate Nearest Neighbor Search using Hierarchical Navigable Small World Graphs. arXiv:1603.09320 (2016)。这是目前销售的大多数向量数据库背后的图索引。

  5. Johnson, J., Douze, M. and Jégou, H. Billion-scale Similarity Search with GPUs. arXiv:1702.08734 (2017)。FAISS,以及上面框中测量的 IVF 的参考实现。

  6. Kalai, A. T., Nachum, O., Vempala, S. S. and Zhang, E. Why Language Models Hallucinate. arXiv:2509.04664 (2025)。这篇文章论证 hallucination 是由从不奖励弃答的二元准确率评分制造的,因此在成为建模问题之前,首先是一个评估问题。

  7. 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 的是假想答案而不是问题。

  8. 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。

  9. 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 决定何时检索,而不是每一轮都检索。

  10. Lin, S., Hilton, J. and Evans, O. TruthfulQA: Measuring How Models Mimic Human Falsehoods. arXiv:2109.07958 (2021)。这个 benchmark 由“貌似可信的答案”和“真实答案”不同的问题构成,一句话概括了全部难点。

准备好让 LIA 替你选模型了吗?

所有 AI 模型都在一处——今天就免费开始。