本番RAG:chunking、retrieval、正直な引用
512文字で盲目的に切ると32件中4件の答えがretriever前に失われます。chunkerだけ直すとrank 115が3に。
このページの内容
実在するassistantの実在するユーザーからの実在する質問です。評価セットは20件あるが、このスコアを信頼するには十分か。corpusには答えがあります。その答えを含むセクションが丸ごとあります。ところがretrieverが実際にpromptへ入れた4つの断片はこれでした。
[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の切り方です。
[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される様子を見ているからです。
corpus、そして正しい答えとは何か
セクション「corpus、そして正しい答えとは何か」へのリンク以下のすべては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です。
chunkingを6通りで測る
セクション「chunkingを6通りで測る」へのリンク3つの独立した材料から6つの戦略を作ります。Blindはtextを見ずに512文字ごとに切ります。Boundariesはparagraphの中では切らず、1つのparagraphが予算を超える場合だけsentence boundaryにfallbackします。Headerは各chunkにdocument titleとsection pathをprefixします。Overlapは前のchunkの最後の64文字を次へコピーします。
| 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 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つのルールも決まります。
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の小さい方です。
誰もが飛ばす20行のBM25
セクション「誰もが飛ばす20行のBM25」へのリンク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 の寄与は
ここで はdocument内のterm count、 はそのlength、 はaverage length、 と は2つの慣例的なconstantsです。 は反復がどれだけ速く効かなくなるか、 はlengthをどれだけ強く罰するかを決めます。
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します。そしてこれは博物館の展示物ではありません。
| retriever | R@1 | R@4 | R@8 | MRR | cost per query |
|---|---|---|---|---|---|
| dense (cosine) | 0.094 | 0.422 | 0.578 | 0.280 | embedding 13 ms + scan 0.3 ms |
| lexical (BM25) | 0.219 | 0.375 | 0.469 | 0.313 | 1.14 ms |
| hybrid (RRF) | 0.203 | 0.484 | 0.609 | 0.346 | 両方 |
| hybrid + cross-encoder | 0.312 | 0.578 | 0.703 | 0.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
/** 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、cross-encoder、そしてliftの実体
セクション「bi-encoder、cross-encoder、そしてliftの実体」へのリンクここまでのすべては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
L2、cosine、そしてまだ獲得していないthreshold
セクション「L2、cosine、そしてまだ獲得していないthreshold」へのリンクVector databaseはdistancesを報告し、どのdistanceにするかはconfiguration optionです。normalised vectorsでは、その選択は見た目の違いにすぎません。そしてこのidentityは一度やる価値があります。以後すべてが、vectorsが本当にunitであることに依存するからです。 については、
したがってcosine distance は正確に です。これは第1章のdot productとnormを現金化したものです。上のindexから実際の2つのchunk vectorsで確認し、さらに40,000 pairsで確認しました。
||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-07floating-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 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は、その答えからのdistanceが0.721で、最良の out-of-domain queryが無関係なparagraphから持つdistance 0.497より遠いです。したがって両方を正しく処理するthresholdはありません。実際のgate、つまり最大4 chunksを保持し、cut未満のものだけにする条件でsweepすると、
| 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 |
最後の列ははったりとして読んでください。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つの形を示しています。
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と断言します。あるいは、もっと悪く、断言しません。
なぜcitationはmodelが書かないのか
セクション「なぜcitationはmodelが書かないのか」へのリンク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でした。
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しそうな唯一のものを頼まれていないからです。
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になります。
[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 は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に到達すらしません。
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つの数字の比較だからです。
generatorから切り離してretrieverを評価する
セクション「generatorから切り離してretrieverを評価する」へのリンクこの章のすべての測定は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は、sourced と true を混同しています。
queryより前に動くsystemの半分
セクション「queryより前に動くsystemの半分」へのリンク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です。冒頭の質問は、この章が答えられない問いです。答えはどこに書かれているかではなく、それが一度も書かれていなかったらどうするかです。
Sources and method
セクション「Sources and method」へのリンクこの章で測定したすべては、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個を考えると、この章で少なくともそれくらいはするべきだと思えたからです。
参考文献
セクション「参考文献」へのリンク-
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の出典であり、そもそも が存在する理由を読む場所です。 ↩
-
Cormack, G. V., Clarke, C. L. A. and Büttcher, S. Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods. SIGIR 2009. は彼らのものであり、このmethodの要点は、fuseするscore scales間のcalibrationを必要としないことです。 ↩
-
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章で測定されました。 ↩
-
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です。 ↩
-
Johnson, J., Douze, M. and Jégou, H. Billion-scale Similarity Search with GPUs. arXiv:1702.08734 (2017). FAISSであり、上のboxで測定したIVFのreference implementationです。 ↩
-
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の問題である、という議論です。 ↩
-
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します。 ↩
-
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します。 ↩
-
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です。 ↩
-
Lin, S., Hilton, J. and Evans, O. TruthfulQA: Measuring How Models Mimic Human Falsehoods. arXiv:2109.07958 (2021). plausibleなanswerとtrueなanswerが異なるquestionsで構成されたbenchmarkであり、難しさ全体を一文で表しています。 ↩