コンテンツへスキップ
19/30第19章 / 全30章

本番RAG:chunking、retrieval、正直な引用

512文字で盲目的に切ると32件中4件の答えがretriever前に失われます。chunkerだけ直すとrank 115が3に。

このページの内容

実在するassistantの実在するユーザーからの実在する質問です。評価セットは20件あるが、このスコアを信頼するには十分か。corpusには答えがあります。その答えを含むセクションが丸ごとあります。ところがretrieverが実際にpromptへ入れた4つの断片はこれでした。

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…

4つのうち3つは単語の途中から始まっています。2つは別の主題を扱う別の章から来ています。そして質問に答える断片、つまり Seventeen out of twenty cannot distinguish an 85 % model from a 65 % one を含む断片は、rank 115 で返ってきました。

同じ質問、同じembedding model、同じpromptテンプレートで試します。変えたのは1つだけ、documentの切り方です。

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にもthresholdにもslot数にも触れていません。この章で扱うのはその差であり、retrieval systemが静かにあなたに嘘をつく、ほかの4つの場所です。

詳細を表示

この章が前の章から必要とするもの、そして1か所だけ用語を変える場所。

  • 第1章 ではdot productとL2 normを定義しました。下のthresholdのセクションはこの2つだけで、それ以外ではありません。
  • 第8章 では、language modelのembedding tableと、ペアでcontrastiveに訓練されたretrieval embedding modelを分け、cosine similarityを測定し、第19章で具体的なcut-offに到達すると約束して終わりました。その約束をここで果たします。内容は繰り返しません。
  • 第4章 ではWilson intervalを構築し、第15章 では評価harnessを構築しました。以下のすべての表は前者を含み、後者によって作られています。
  • 第16章 ではcontext windowの価格を扱いました。この章の最後で組み立てるpromptは591 tokensで、その予算を断片同士が奪い合います。

ここでの実装はすべて第14章以降と同じくTypeScriptです。そしてこの章でそのルールの価値が出ます。ingestionはqueueとstorageであり、searchはnetwork callであり、引用付きのpromptを組み立てるのはserverの仕事です。測定は意図的に、同じコードの周りにscoreboardを置いたものです。別実装で採点されたretrieverは、あなたが出荷していないsoftwareについての数字でしかありません。そして下のcosine thresholdが信じられるのは、本番で動くchunkerによってsweepされる様子を見ているからです。

以下のすべては1つのcorpusに対して測定しています。このコースの最初の13章、13 documents、359,067文字、127 sectionsで、front matterとbibliographyは除外しています。これは実際のtechnical corpusであり、文章、表、数式、code blocksを含みます。そして人々がknowledge baseに読み込ませてから不満を言う、まさにその種のものです。

ground truthは32の質問で、それぞれにneedleが対応します。corpus内で答えになる短い逐語文です。各needleは359,067文字の中にちょうど1回だけ現れ、section headingではありません。この確認は重要です。headingをすべてのchunkにコピーするchunkerなら、そうでなければ自分自身を高く採点できてしまうからです。各質問は2回尋ねられます。1回はコースらしい英語で、もう1回はsupport ticketの言い方で。つまり32 ground truthsに対して64 queriesです。

retrievalが正しいとは、返されたchunkがneedleを丸ごと含んでいることです。generatorが必要とするものに一致する定義はこれだけです。prompt内の半分の文は答えではなく、危険物です。

embedding modelはall-MiniLM-L6-v2です。384 dimensions、mean-pooledかつnormalisedで、第8章で測定したcontrastively trained modelです。corpusのindexingはCPUで20.8秒、chunkあたり22 msです。1つのqueryのembeddingは13 msです。

3つの独立した材料から6つの戦略を作ります。Blindはtextを見ずに512文字ごとに切ります。Boundariesはparagraphの中では切らず、1つのparagraphが予算を超える場合だけsentence boundaryにfallbackします。Headerは各chunkにdocument titleとsection pathをprefixします。Overlapは前のchunkの最後の64文字を次へコピーします。

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 intervalは、Aが[0.471, 0.705]、Eが[0.718, 0.901]です。これは重なりませんが、ほかの列の多くは重なりますし、unpaired tableでは分離できません。すべてのstrategyは同じqueriesに答えるので、正直なtestはpairedです。あるstrategyが別のstrategyに対して勝った数と負けた数を数え、不一致ペアにsign testをかけます。生き残る結果は3つです。

Blind chunkingは32個中4個の答えを完全に破壊します。 rankが悪いのではありません。破壊します。needleが512文字のboundaryをまたぐため、index内のどのchunkにもそれが含まれず、そのqueriesのrecall ceilingはゼロになります。どんなrerankerも取り戻せず、thresholdも助けにならず、より大きなmodelも助けになりません。indexのどこにも一続きで存在しないtextはretrieveできません。これはRAGで最も過小報告されている失敗です。見た目が悪いretrieverとまったく同じだからです。

Overlapはそれだけを直し、それ以外は直しません。 overlapを持つすべてのstrategyは失う答えがゼロです。それがoverlapの目的です。ただしrankingは改善しません。R@8でB対Aは+8/−6、p = 0.79。R@20では+9/−7、p = 0.80です。さらに悪いことに、headerの上にoverlapを追加すると能動的に悪化します。R@20でF対Eは+2/−6です。理由は機械的です。chunkのvectorはそのtokensの平均なので、前のchunkの64文字がその平均を隣のtopicへ引っ張ります。Overlapは分割された答えに対する保険であり、precisionで支払います。

retrievalを買うのはcontextual headerです。 E対AはR@20で+18/−3、p = 0.0015です。そしてablationはboundariesが効いているのではないと言っています。E対C、つまり同じcutsでheaderだけが違う比較は、+12/−2、p = 0.0129です。「Classification, Cross-Entropy, and How Not to Fool Yourself > How many test examples do I need?」をparagraphの前に付けると、そのparagraph自体がしばしば言っていない「何についてのparagraphか」をembedding modelに伝えます。documentのpronoun resolverです。

これによってchunkerの形が決まり、間違えやすい1つのルールも決まります。

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は1つではなく2つ。 text はheaderを含めてembeddingされるものです。content はこのchunk自身の言葉だけで、ユーザーへ引用として返すものです。text をquoteすると、その時点のdocumentには存在しないheaderをcitationが表示します。そしてoverlapがある場合、前のfragmentに属する繰り返しのtailも表示します。つまり、そこにあると言っている場所にないtextを表示することになります。何も表示しないより悪いです。

headerは無料ではありません。940 chunks全体で、indexの114,275 embedded tokensのうち24,213を消費します。embeddingに支払うものの21.2 %は、自分で書いたheaderです。 encoderのwindowにchunkを押し込む効果もあります。all-MiniLM-L6-v2 は256 word-piecesを受け付けます。strategy Eにはその線を超えるchunkが17個、Fには28個あり、すべて何の警告もなく静かにtruncatedされます。実効chunk sizeはconfig内の数字ではありません。その数字とencoderのwindowの小さい方です。

Dense retrievalには1つの体系的な弱点があり、それは微妙なものではありません。意味をmatchするので、あなたが入力したstringが正確に何かには無関心です。部品番号、error code、acronym、surname。どれもembeddingする有用な意味を持たず、error codeのnearest neighbourはcorpus内のすべてのほかのerror codeです。

古典的な答えはこれらすべてより古く、20行で済みます。BM25はqueryのtermsがdocument内にどれだけ出現するかでdocumentをscoreし、各termはfrequencyが上がるほど効果を減衰させ、単に長さでmatchを蓄積する長いdocumentをpenaliseします。1 term 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} はdocument内のterm count、d|d| はそのlength、d\overline{|d|} はaverage length、k1=1.2k_1 = 1.2b=0.75b = 0.75 は2つの慣例的なconstantsです。k1k_1 は反復がどれだけ速く効かなくなるか、bb はlengthをどれだけ強く罰するかを決めます。

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では、2つのhash maps以外にindexなしでqueryを1.14 msでscoreします。そしてこれは博物館の展示物ではありません。

retrieverR@1R@4R@8MRRcost per query
dense (cosine)0.0940.4220.5780.280embedding 13 ms + scan 0.3 ms
lexical (BM25)0.2190.3750.4690.3131.14 ms
hybrid (RRF)0.2030.4840.6090.346両方
hybrid + cross-encoder0.3120.5780.7030.447+ 569 ms

このcorpusでは、BM25はdense retrieverのtop-1 accuracyを2倍以上にしますが、rank 8までには大きく負けます。両者は異なるqueriesで失敗します。それこそが両方を動かす議論のすべてです。

それらをfuseする場面では、明らかに見える方法が間違いです。Cosine distancesとBM25 scoresは同じscaleになく、同じようにboundedされておらず、queryごとにnormaliseするとweightがbest hitの出来に依存してしまいます。Reciprocal rank fusionはscoresを捨て、ranksだけを残します。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でBM25に+10/−3、p = 0.09で勝っています。denseには+10/−6、p = 0.45で勝っています。このcorpusで64 queriesでは、hybrid retrievalはdense retrievalと区別できません。 point estimateでもすべてのrecall列でも良いですが、証拠はsignificanceに届きません。インターネット上のほぼすべてのhybrid-search blog postは上のような表を報告し、intervalを出しません。intervalが言うのはこういうことです。

ここまでのすべてはbi-encoderです。queryはmodelを単独で通り、各chunkも何か月も前に単独で通り、両者が出会うのはdot productとしてだけです。これがindexを可能にします。一度embedして永遠に再利用できます。同時に、それが上限でもあります。modelはqueryとchunkを一緒に見ません。

cross-encoderはまさにそれをします。pairを1つのinputとして受け取り、relevance scoreを返します。何もprecomputeできないのでindexをrankできませんが、shortlistをrerankできます。hybrid top 25をms-marco-MiniLM-L-6-v2でrerankingすると、R@1は0.094(dense)から0.312へ、MRRは0.280から0.447へ動きます。この章で最大の単一改善であり、tailではなくlistのtopに触れる唯一の改善です。

CPUではqueryあたり569 msかかります。BM25は1.14 ms、vector scanは0.3 msです。25 documentsに対して、retrieval costのおよそ2,000倍です。 これがbi-encoder/cross-encoder trade-offの全体を1つの数字で表したものです。だからarchitectureは常に同じ形になります。広いrecallを持つ安価なretriever、その後に手の届くshortlist上で高価なscorerです。ColBERTはその中間に位置し、per-token vectorsをprecomputeしてlate interactionを行います。cross-encoderより安く、dot productより鋭い方法です。3

Vector databaseはdistancesを報告し、どのdistanceにするかはconfiguration optionです。normalised vectorsでは、その選択は見た目の違いにすぎません。そしてこのidentityは一度やる価値があります。以後すべてが、vectorsが本当にunitであることに依存するからです。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

したがってcosine distance 1cosθ1 - \cos\theta は正確に d2/2d^2/2 です。これは第1章のdot productとnormを現金化したものです。上のindexから実際の2つのchunk vectorsで確認し、さらに40,000 pairsで確認しました。

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

floating-point noiseまで正確です。そしてそれは、vectorsがnormalisedされている場合だけです。normalisationを省くとidentityは偽になり、thresholdは何の意味も持たず、documentが報告するdistanceはそのtextの長さに依存します。

さて、誰も導出しない数字です。retrieverは常に何かを返します。corpus内のどこかに答えがあるかどうかに関係なく、index全体をsortしてlistの先頭を渡します。thresholdだけがsystem内でnoと言える部分です。そしてそれを設定するには、何も返すべきではないqueriesが必要です。ここでは30個用意しました。21個はこのcorpusが本当に扱っていないもの、streaming、rate limits、prompt caching、JSON schemas、agent loops、vector databases、prompt injection、image generationについてです。9個はpaella、passport、refund policiesについてです。同じindexに対してはこうなります。

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は、その答えからのdistanceが0.721で、最良の out-of-domain queryが無関係なparagraphから持つdistance 0.497より遠いです。したがって両方を正しく処理するthresholdはありません。実際のgate、つまり最大4 chunksを保持し、cut未満のものだけにする条件でsweepすると、

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

最後の列ははったりとして読んでください。thresholdがなければ、assistantは「スペインのpassportをどう更新するか」に対して、backpropagationについてのcorpusから、確信に満ち、引用付きの答えを30回中30回生成します。0.675では30回中10回そうします。0.525では2回だけそうし、答えられたはずの12問を諦めます。

このtrade-offはproduct decisionであり、正しい端は誤答のcostによって変わります。交渉できないのは、最後の列がそもそも存在することです。拒否すべき質問に対してretrieverを測ったことがないなら、あなたにはthresholdがあるのではありません。ただの数字があるだけです。

0.675での10個のbluffsのうち2つは、この失敗の2つの形を示しています。

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."

1つ目はnear missです。corpusはKV cacheを詳しく説明しており、queryはprompt cacheについてで、単語は同じ単語であり、0.497は実験全体の多くの正しいin-domain retrievalより近いです。embeddingは、同じ名前を持つ2つのcacheが別の機械であることを知りません。2つ目は答えのないliteral matchです。corpusには正確に「what is the capital of France」というphraseが含まれており、reasoningを必要としない質問の例として使われています。retrieverは正しいです。しかし答えはそこにありません。「似たものを見つけた」を「答えを見つけた」と読むsystemは、その証拠でParisと断言します。あるいは、もっと悪く、断言しません。

modelにはfactsのための独立した能力はありません。trueな文を生成することとplausibleな文を生成することは、同じ operationです。第8章のnext-token predictionです。そしてそのoperationのどこにも、どちらがどちらかを示す印はありません。2025年のanalysisはこれを捉え直し、trainingとevaluation pipelineがguessingを積極的にrewardしていると論じました。benchmarkはbinary accuracyでscoreし、abstentionにcreditを与えないため、常に答えるmodelは、知らないときに「I don't know」と言う同一のmodelを常に上回ります。そしてpost-trainingはそれに従ってoptimiseします。6 この読み方ではhallucinationは謎めいた欠陥ではありません。誤答のpenaltyがないmultiple-choice examを採点したときに得られるものです。

その形を見てください。contrastive sentence embeddingsに関する8本のpapersをidentifier付きで尋ねると、Qwen2.5-0.5B-Instruct は完璧なformatで8行を生成しました。8つのidentifierはすべてwell-formedです。8つすべてがarXiv上の実在するpapersへresolveします。8つのうち0個が、主張されたpaperでした。

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

これはsmall modelであり、そのrateはそのmodel固有です。frontier modelははるかに少なくinventします。しかしmechanismは一般化します。そしてそれが次のruleの理由です。「このidentifierは存在するか」をcheckするvalidatorは8つすべてをpassし、ユーザーがclickすると実在するarchive上の実在するpageに着きます。mappingがinventされたと見分ける方法はありません。失敗はidentifierやformatにありません。associationにあります。まさにlanguage modelがplausibilityによって生成するものです。

だから、modelが書くのは [1][2] であり、linkは決して書きません。 numbersはserverがretrievedしたfragmentsを指し、serverは各numberがどのdocumentのどのoffsetから来たかを正確に知っているので、後からdocument、label、URLを付与します。modelがinventする余地はありません。inventしそうな唯一のものを頼まれていないからです。

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 };
}

冒頭の質問で実行すると、4つのchunksは591-token promptになり、modelが決して見ないtableになります。

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 はdocumentのcanonical text内のrangeです。PDFなら同等物は #page=12、audioやvideoなら #t=132.4,158.9、spreadsheetならsheetとA1 rangeです。この2つは発明ではありません。#page= はPDF Open Parameters、#t= はW3C Media Fragmentsで、videoとaudio elementsではbrowserがnativeに尊重します。locatorのないcitationはdocument nameであり、document nameはcitationではありません。ユーザーに見に行けと勧めているだけです。

そして何もthresholdを通らない場合、pipelineは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内のどんなinstructionよりも安価で信頼できるrefusalです。probabilistic systemへの依頼ではなく、2つの数字の比較だからです。

この章のすべての測定はretrieverを採点しており、modelにanswerを書かせたことは一度もありません。これは意図的であり、ほとんどのteamが飛ばす部分です。

RAG systemには、外から見ると同じに見える2つのfailure modesがあります。retrieverがpassageを見つけられなかった。あるいは見つけたがgeneratorが無視し、矛盾し、すでに信じているものと混ぜた。final answerだけをscoreするとこの2つは区別できません。だからchunkerにある問題に対してpromptsをtuneすることになります。Recall@k、MRR、answer-destroyed countにはgeneration callがまったく不要です。すべてのdeployで走らせられるほど安価であり、第15章のharnessに別のscoring functionを付けたものです。同じrequest、deadline、concurrency、tallyを、live conversationではなく固定question setに対して行います。

interval付きで報告してください。第4章の算術はそのまま適用できます。64 queriesではrecall 0.5に約±0.12の95 % Wilson intervalが付きます。つまり別strategyより4 points上のstrategyは、何も語っていません。両strategyが同じquestionsに答えるときはpaired testを使います。ここでは常にそうです。それが「EはAより良さそう」をp = 0.0015に変えました。

そして最後の正直さです。RAGはhallucinationを減らしますが、取り除きません。 正しいpassageをpromptに入れても、modelにそれを使う義務は生じません。literatureはoriginal paper以来そう言っています。7 productionでは2つのことが悪化させます。長いcontextは劣化します。modelは長いpromptの中央より、先頭と末尾のinformationをより確実に見つけます。したがって4 chunksではなく20 chunksにすると、billを上げながらaccuracyを下げることがあります。この効果は第24章で測定されています。そしてretrievalが正しいにもかかわらず不十分なことがあります。上の2つのcacheが示した通りです。SelfCheckGPTはresamplingに耐えないclaimsをflagします。8 Self-RAGはmodelに自身のretrieve-and-critique tokensを出すようtrainします。9 TruthfulQAはそもそもfailure modeを読み取れるようにしました。10 どれもgapを閉じません。そしてretrieved textをproofとして提示するsystemは、sourcedtrue を混同しています。

retrieverはpipelineの見える部分ですが、その失敗はすべてもっと前の暗闇で起きています。繰り返し起きるものが3つあります。

Extractionはcontentが死ぬ場所です。 PDFはtextではありません。drawing instructionsです。2-column layoutsはinterleaveし、tablesはword soupになり、page headersはすべてのchunkに繰り返され、scanned pageにはOCRがconfidence付きでtextを与えるまでtextがまったくありません。上で測定したすべてはextractorが仕事をしたと仮定しています。productionではしばしばそうではなく、症状は3 layers離れたbad retrievalとして現れます。

indexには、それを作ったmodelの刻印があります。 2つのmodelsのembeddingsは比較できません。「精度が低い」のではなく、比較不能です。異なるspace内のpointsだからです。embedding modelを変えると、store内のすべてのvectorはrebuildされるまでgarbageです。だからmodel name、dimension count、pipeline version、extractor versionをindex timeに各documentの横へ書きます。それがなければ、upgrade dayにどのdocumentsがstaleでどれがcurrentかを判別できません。そして半分だけmigratedされたindexは、どこにもerrorを出さずに自信満々のnonsenseを返します。

1つのbroken documentがfolderを壊してはいけません。そしてcountersは起きたことを数えるべきです。 extractionに失敗したdocumentは理由付きで failed stateに入り、visibleでretryableになり、残りの99個はsearchableのままです。そしてindexed chunksの数は、upload時にclientが宣言するのではなく、serverが完了時に書きます。400 fragmentsと報告しながら40しか保持していないfolderは、答えられない質問として初めて表面化する嘘です。

この章のsystemは、答えが書かれている質問に答えます。それをretrieveし、rankし、できないときはrefuseし、どこを見たかをciteします。これは人々が自分のdocuments上のassistantに求めることの大部分です。そして1つの具体的な制約があります。retrievalが返せるのは、誰かが書いたものだけです。

すると残るのはもう半分です。modelにやってほしいことの一部は、document内のfactではまったくありません。保持しなければならないformat、tone、400 labelsを持つtaxonomy、10,000の過去例の中にはあるがどのparagraphにもない判断の仕方です。retrievalはそれらを届けられません。retrieveするものがないからです。長いpromptは、skillそのものではなくskillの説明に第16章のbillを払うだけです。

第20章はそのdecision、つまりfine-tune、retrieve、またはpromptです。その発見は、decisionはtechnicalである前にeconomicだということです。3つは同じ質問でend to endにpriceされ、crossoverはtoken countです。冒頭の質問は、この章が答えられない問いです。答えはどこに書かれているかではなく、それが一度も書かれていなかったらどうするかです。


この章で測定したすべては、1つのcorpusと1つのinstrumentを使っており、どちらもreproducibleです。corpusは2026年9月7日時点のこのコースの第1章から第13章です。13 documents、359,067文字、127 sections、front matterとbibliographiesは除外しています。これらの章は編集され続けているため、同じruleを今日適用すると数千文字増えます。section countは変わらず、以下の結論もすべて変わりませんが、character totalはsnapshotであり、そのようにlabelされています。ground truthは32 questionsで、それぞれcorpus内にちょうど1回だけ現れ、section headingではない逐語文とpairになっており、2つの言い回しで尋ねられて64 queriesになります。Retrieval embeddingsは sentence-transformers/all-MiniLM-L6-v2(384 dimensions、mean-pooled、L2-normalised、256-token window)です。rerankingはtop 25上の cross-encoder/ms-marco-MiniLM-L-6-v2、generation exampleはgreedy decodingの Qwen/Qwen2.5-0.5B-Instruct です。timingsはすべてsingle-threaded CPUです。この章を作るためにpaid APIは呼んでいません。だからここでのlatencyはすべてlocalなものであり、そのようにlabelされています。

TypeScriptで示したchunkerは、測定されたchunkerです。同じruleと ts/chunk.ts を実装するPython instrumentをcorpus全体でchunkごとに比較し、940 chunksすべてについてtextsとoffsetsが一致しています。Intervalsは95 %のWilson、paired comparisonsはdiscordant pairsに対するtwo-sided exact sign testsです。

上で引用した14個のidentifierはすべて、2026年9月7日にarXiv APIに対してresolveし、titleごとにcheckしました。そうでなかった8個を考えると、この章で少なくともそれくらいはするべきだと思えたからです。

  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). 上で使ったsaturation functionと2つのconstantsの出典であり、そもそも 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 は彼らのものであり、このmethodの要点は、fuseするscore scales間のcalibrationを必要としないことです。

  3. Khattab, O. and Zaharia, M. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT. arXiv:2004.12832 (2020). dot productとcross-encoderの中間地点です。Reimers, N. and Gurevych, I., Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks, arXiv:1908.10084 (2019), はこの章のindexが基づく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). 現在販売されているほとんどのvector databasesの背後にあるgraph indexです。

  5. Johnson, J., Douze, M. and Jégou, H. Billion-scale Similarity Search with GPUs. arXiv:1702.08734 (2017). FAISSであり、上のboxで測定したIVFのreference implementationです。

  6. Kalai, A. T., Nachum, O., Vempala, S. S. and Zhang, E. Why Language Models Hallucinate. arXiv:2509.04664 (2025). hallucinationはabstentionをrewardしないbinary-accuracy gradingによって生まれ、したがってmodellingの問題である前にevaluationの問題である、という議論です。

  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). このpatternに名前を付けたpaperであり、何を直し何を直さないかを読むべきものです。Guu et al., REALM: Retrieval-Augmented Language Model Pre-Training, arXiv:2002.08909 (2020), は同時期のworkで、retrieverをmodelに後付けするのではなくjointlyにtrainします。Karpukhin et al., Dense Passage Retrieval for Open-Domain Question Answering, arXiv:2004.04906 (2020), はこの章全体で使うtwo-encoder dense retrieverの出どころです。Izacard and Grave, Leveraging Passage Retrieval with Generative Models for Open Domain Question Answering, arXiv:2007.01282 (2020), は多くのpassagesを1つのgeneratorへ渡すfusion-in-decoder arrangementです。Gao et al., Retrieval-Augmented Generation for Large Language Models: A Survey, arXiv:2312.10997 (2023), はその後に来たすべてのmapで、HyDE(Gao et al., Precise Zero-Shot Dense Retrieval without Relevance Labels, arXiv:2212.10496, 2022)も含みます。HyDEはquestionではなくhypothetical answerをembedします。

  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内部へのaccessもexternal knowledge baseもなしに、resamplingでdetectします。

  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). すべてのturnでretrieveするのではなく、いつretrieveするかをmodelに決めさせるtrainingです。

  10. Lin, S., Hilton, J. and Evans, O. TruthfulQA: Measuring How Models Mimic Human Falsehoods. arXiv:2109.07958 (2021). plausibleなanswerとtrueなanswerが異なるquestionsで構成されたbenchmarkであり、難しさ全体を一文で表しています。


作成者

David Vicente Campos

NeuraLIA Labs創業者、MyRealFood共同創業者

レオン大学出身のコンピューターエンジニアです。MyRealFoodを共同創業し、CTOとして、何百万人もの人がより良い食生活のために使ってきたアプリを開発しました。また、NeuraLIA Labsを創業し、そこでAIプロダクトを開発しています。ここでは、私がその過程で理解する必要があったことを、誰かにこう説明してほしかったと思う形で書いています。

著者について詳しく

NeuraLIA Labsが公開しています。

新着記事を受信トレイにお届け

AIニュース、ガイド、プロダクトアップデートを、読む価値のある記事を公開したときだけ短いメールでお送りします。

コース目次

Abstract software decision engine with branching paths, probability nodes, and glowing gates.
jev読了15分

Jev AIモデルは文章ではなく意思決定のために作られている

TypeSafe AIのJevが注目されているのは、ソフトウェアの知能を確率の問題として扱うからです。適切な分岐を選び、信頼度を添え、コードが必要としているのが意思決定であるときに、LLMに文章を書かせるためのコストを避けます。

Abstract legal research workspace with documents, search nodes and governance controls.
openai読了14分

OpenAIのAstra for Lawは新モデルではなく、法律AIシステム

OpenAIの法律分野での発表の本質は、新しい基盤モデルそのものではなく、その周辺にあるシステムです。ドメイン検索、信頼できるツール、権限、ベンチマーク、レビュー経路が重要になります。

Abstract agent runtime sorting documents, memory blocks and pointer nodes inside a bounded context frame.
context-engineering読了12分

Context engineering for long-horizon AI agents

Long-running agents do not fail only because the window is small. They fail when files, tool outputs and stale history crowd out the task the agent was supposed to finish.

モデル選びは、LIAにおまかせ。

すべてのAIモデルをひとつの場所で。今日から無料で。