RAG i produktion: Chunking, retrieval og ærlige kildehenvisninger
Skær blindt ved 512 tegn, og 4 af 32 svar dør før retrieveren ser dem. Kun chunkeren flytter rank 115 til 3.
På denne side
Her er et rigtigt spørgsmål fra en rigtig bruger af en rigtig assistant: mit eval-sæt har 20 elementer, er det nok til at stole på scoren. Korpuset indeholder svaret — en hel sektion af det. Her er de fire fragmenter, retrieveren faktisk lagde i prompten.
[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…Tre af de fire begynder midt i et ord. To er fra et andet kapitel om et andet emne. Og fragmentet, der besvarer spørgsmålet — det med Sytten ud af tyve kan ikke skelne en 85 % model fra en 65 % model — kom tilbage på rank 115.
Nu det samme spørgsmål, den samme embedding model, den samme prompt template. Én ting ændrede sig: hvordan dokumenterne blev skåret.
[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 til rank 3. Ingen rørte modellen, prompten, tærsklen eller antallet af pladser. Dette kapitel handler om det spring og om de fire andre steder, hvor et retrieval-system stille lyver for dig.
Vis detaljer
Hvad dette kapitel kræver fra tidligere kapitler, og det ene sted hvor det skifter sprog.
- Kapitel 1 definerede prikproduktet og L2-normen. Tærskelsektionen nedenfor er de to ting og intet andet.
- Kapitel 8 adskilte en sprogmodels embedding-tabel fra en retrieval embedding model, der er trænet kontrastivt på par, målte cosine similarity og sluttede med at love, at Kapitel 19 ville nå frem til en konkret cut-off. Det løfte indfries her. Intet af det gentages.
- Kapitel 4 byggede Wilson-intervallet; Kapitel 15 byggede evaluation harness. Hver tabel nedenfor bærer det første og blev produceret af det andet.
- Kapitel 16 prissatte context window. Den prompt, der samles i slutningen af dette kapitel, koster 591 tokens, og det er det budget, fragmenterne konkurrerer om.
Alt her er TypeScript, som siden Kapitel 14, og dette kapitel er stedet, hvor reglen gør sig fortjent til sig selv: ingestion er køer og storage, søgning er et netværkskald, og det er serverens job at samle en prompt med kildehenvisninger. Målingen er med vilje den samme kode med en resultattavle omkring — en retriever scoret af en anden implementering er et tal om software, du ikke shipper, og cosine-tærsklen nedenfor er kun troværdig, fordi du ser den blive sweepet af den chunker, der skal køre i produktion.
Korpuset, og hvad der tæller som et rigtigt svar
Link til afsnittet: Korpuset, og hvad der tæller som et rigtigt svarAlt nedenfor måles mod ét korpus: de første tretten kapitler af dette kursus — 13 dokumenter, 359.067 tegn, 127 sektioner, med front matter og bibliografier fjernet. Det er et rigtigt teknisk korpus med prosa, tabeller, formler og code blocks, og det er præcis den slags, folk lægger i en knowledge base og derefter klager over.
Ground truth er 32 spørgsmål, hvert parret med en needle: en kort ordret sætning fra korpuset, der besvarer det. Hver needle optræder præcis én gang i de 359.067 tegn, og ingen er en sektionsoverskrift — den kontrol betyder noget, fordi en chunker, der kopierer overskrifter ind i hver chunk, ellers ville score sig selv. Hvert spørgsmål stilles to gange, én gang på kursets engelsk og én gang som en supportbillet ville formulere det: 64 queries over 32 ground truths.
En retrieval er korrekt, når en returneret chunk indeholder hele needle intakt. Det er den eneste definition, der matcher det, generatoren har brug for: en halv sætning i prompten er ikke et svar, det er en risiko.
Embedding model er all-MiniLM-L6-v2 — 384 dimensioner, mean-pooled og normaliseret, den kontrastivt trænede model, Kapitel 8 målte. Indeksering af korpuset tager 20,8 sekunder på en CPU, 22 ms pr. chunk; embedding af én query tager 13 ms.
Chunking, målt på seks måder
Link til afsnittet: Chunking, målt på seks måderSeks strategier fra tre uafhængige ingredienser. Blind skærer for hver 512 tegn uden at se på teksten. Boundaries skærer aldrig inde i et afsnit og falder kun tilbage til en sætningsgrænse, når ét afsnit er over budget. Header sætter dokumentets titel og sektionssti foran hver chunk. Overlap kopierer de sidste 64 tegn fra den forrige chunk ind i den næste.
| 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 |
Med 64 queries er 95 % Wilson-intervallet på R@20 [0,471, 0,705] for A og [0,718, 0,901] for E — de overlapper ikke, men de fleste andre kolonner gør, og en uparret tabel kan ikke adskille dem. Hver strategi besvarer de samme queries, så den ærlige test er parret: tæl hver strategis sejre og nederlag mod en anden, og kør en fortegnstest på de diskordante par. Tre resultater overlever den.
Blind chunking ødelægger fire af de toogtredive svar direkte. Ikke ranker dem dårligt — ødelægger dem. Needle ligger hen over en 512-tegns grænse, så ingen chunk i indekset indeholder den, og recall-loftet for de queries er nul. Ingen reranker kan redde dem, ingen tærskel hjælper, ingen større model hjælper. Du kan ikke retrieve tekst, der ikke findes i ét stykke noget sted i dit index. Det er den mest underrapporterede fejl i RAG, fordi den ligner en dårlig retriever på en prik.
Overlap fikser det og intet andet. Hver strategi med overlap mister nul svar, hvilket er, hvad overlap er til for. Det forbedrer ikke ranking: B mod A ved R@8 er +8/−6, p = 0,79; ved R@20 er det +9/−7, p = 0,80. Værre endnu: at tilføje overlap oven på header skader aktivt — F mod E er +2/−6 ved R@20 — og årsagen er mekanisk. En chunks vektor er et gennemsnit over dens tokens, så 64 tegn fra den forrige chunk trækker gennemsnittet mod naboens emne. Overlap er en forsikring mod et splittet svar, betalt i precision.
Den kontekstuelle header er det, der køber retrieval. E mod A er +18/−3 ved R@20, p = 0,0015. Og ablationen siger, at det ikke er boundaries, der gør det: E mod C — samme snit, header som eneste forskel — er +12/−2, p = 0,0129. At sætte »Classification, Cross-Entropy, and How Not to Fool Yourself > How many test examples do I need?« foran et afsnit fortæller embedding model, hvad afsnittet handler om, hvilket afsnittet selv ofte ikke siger. Det er en pronomen-opløser for dokumenter.
Det giver chunkeren dens form og én regel, der er let at få galt afsted:
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;
}To tekster, ikke én. text er det, der bliver embedded, header og det hele. content er kun denne chunks egne ord, og det er det, der citeres tilbage til brugeren. Citer text, og kildehenvisningen viser en header, der ikke findes i dokumentet på det sted — og, med overlap, en gentaget hale, der hører til det forrige fragment. Den viser så tekst, der ikke er dér, hvor den siger, den er, hvilket er værre end ikke at vise noget.
Headeren er ikke gratis. På tværs af de 940 chunks koster den 24.213 af indeksets 114.275 embedded tokens: 21,2 % af det, du betaler for at embedde, er en header, du selv skrev. Den presser også chunks op mod encoderens window. all-MiniLM-L6-v2 accepterer 256 word-pieces; strategi E har 17 chunks over den grænse og F har 28, alle sammen tavst truncated uden advarsel fra noget. Din effektive chunk size er ikke tallet i din config — det er det mindste af det og din encoders window.
Tyve linjer BM25, som alle springer over
Link til afsnittet: Tyve linjer BM25, som alle springer overDense retrieval har én systematisk svaghed, og den er ikke subtil: den matcher betydning, så den er ligeglad med præcis hvilken streng du skrev. Et varenummer, en fejlkode, et akronym, et efternavn — ingen af dem har en nyttig betydning at embedde, og den nærmeste nabo til en fejlkode er alle andre fejlkoder i dit korpus.
Det klassiske svar er ældre end alt dette og tager tyve linjer. BM25 scorer et dokument efter, hvor ofte queryens termer optræder i det, dæmper hver term, når dens frekvens stiger, og straffer lange dokumenter, der samler matches op alene på grund af længde.1 Termen bidrager med
hvor er termens antal i dokumentet, dets længde, gennemsnitslængden, og og de to konventionelle konstanter — sætter, hvor hurtigt gentagelse holder op med at hjælpe, hvor hårdt længde straffes.
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;
});
}
}Over 940 chunks scorer det en query på 1,14 ms uden noget index overhovedet ud over to hash maps. Og det er ikke et museumsstykke:
| 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 mere end fordobler den dense retrievers top-1-nøjagtighed på dette korpus og taber klart til den ved rank 8. De fejler på forskellige queries, hvilket er hele argumentet for at køre begge.
At flette dem er det ene sted, hvor den oplagte tilgang er forkert. Cosine distances og BM25-scores er ikke på samme skala, er ikke afgrænset på samme måde, og normalisering pr. query får vægten til at afhænge af, hvor godt det bedste hit tilfældigvis var. Reciprocal rank fusion smider scorerne væk og beholder kun 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);
}Og her betyder den ærlige læsning af tabellen mere end tabellen. Hybrid slår BM25 ved R@4 med +10/−3, p = 0,09. Den slår dense med +10/−6, p = 0,45. På dette korpus, med 64 queries, kan hybrid retrieval ikke skelnes fra dense retrieval. Den er bedre på både punktestimater og hver recall-kolonne, og evidensen når ikke signifikans. Næsten alle blogindlæg om hybrid search på internettet rapporterer en tabel som den ovenfor og intet interval; det er dette, intervallet siger.
Bi-encoder, cross-encoder, og hvor løftet faktisk ligger
Link til afsnittet: Bi-encoder, cross-encoder, og hvor løftet faktisk liggerAlt indtil nu er en bi-encoder: queryen går gennem modellen alene, hver chunk gik gennem den alene for måneder siden, og de to mødes aldrig undtagen som et prikprodukt. Det er det, der gør et index muligt — embed én gang, genbrug for evigt — og det er også loftet. Modellen ser aldrig på queryen og chunken sammen.
En cross-encoder gør præcis det: den tager parret som ét input og returnerer en relevansscore. Intet kan forudberegnes, så den kan ikke ranke et index — men den kan reranke en shortlist. Reranking af hybrid top 25 med ms-marco-MiniLM-L-6-v2 flytter R@1 fra 0,094 (dense) til 0,312 og MRR fra 0,280 til 0,447: den største enkeltstående forbedring i dette kapitel, og den eneste, der rammer toppen af listen i stedet for halen.
Det koster 569 ms pr. query på en CPU, mod 1,14 ms for BM25 og 0,3 ms for vectorscannet. Omtrent to tusind gange retrieval-omkostningen for femogtyve dokumenter. Det er hele bi-encoder/cross-encoder-handlen i ét tal, og det er derfor arkitekturen altid har samme form: en billig retriever med bred recall, derefter en dyr scorer på en shortlist, du har råd til. ColBERT ligger mellem de to, forudberegner per-token vectors og laver en late interaction, der er billigere end en cross-encoder og skarpere end et prikprodukt.3
L2, cosine og en tærskel, du ikke har gjort dig fortjent til
Link til afsnittet: L2, cosine og en tærskel, du ikke har gjort dig fortjent tilVector databases rapporterer afstande, og hvilken afstand er en konfigurationsmulighed. På normaliserede vectors er valget kosmetisk, og identiteten er værd at gennemgå én gang, fordi alt efter den afhænger af, at vektorerne virkelig er unit. For :
så cosine distance er præcis . Det er Kapitel 1's prikprodukt og norm indløst. Tjekket på to rigtige chunk vectors fra indekset ovenfor og derefter over 40.000 par:
||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-07Præcis ned til floating-point-støj — og kun fordi vektorerne er normaliserede. Spring normaliseringen over, og identiteten er falsk, din tærskel betyder ingenting, og den afstand et dokument rapporterer, afhænger af hvor lang dets tekst var.
Nu til tallet, ingen udleder. En retriever returnerer altid noget: den sorterer hele indekset og giver dig toppen af listen, uanset om svaret findes nogen steder i korpuset. Tærsklen er den eneste del af systemet, der kan sige nej — og for at sætte en skal du bruge queries, der ikke bør få noget tilbage. Her er tredive: enogtyve om ting, dette korpus reelt ikke dækker — streaming, rate limits, prompt caching, JSON schemas, agent loops, vector databases, prompt injection, image generation — og ni om paella, pas og refusionspolitikker. Mod samme 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 |
Fordelingerne adskiller sig, og de overlapper. Den værste in-domain query er længere fra sit svar (0,721), end den bedste out-of-domain query er fra et irrelevant afsnit (0,497), så ingen tærskel får begge rigtigt. Sweep den over den rigtige gate — behold højst fire chunks, og kun dem under cuttet:
| 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 |
Læs den sidste kolonne som bluffs. Uden tærskel producerer assistenten et selvsikkert, velciteret svar på »hvordan fornyer jeg mit spanske pas« fra et korpus om backpropagation, tredive ud af tredive gange. Ved 0,675 gør den det ti gange ud af tredive. Ved 0,525 gør den det to gange og giver op på tolv spørgsmål, den kunne have besvaret.
Den afvejning er en produktbeslutning, og den rigtige ende af den afhænger af, hvad et forkert svar koster dig. Det, der ikke er til forhandling, er at den sidste kolonne overhovedet findes. Hvis du aldrig har målt din retriever mod spørgsmål, den bør afvise, har du ikke en tærskel — du har et tal.
To af de ti bluffs ved 0,675 viser de to måder, det fejler på.
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."Den første er et near miss: korpuset forklarer KV cache i detaljer, queryen handler om prompt cache, ordene er de samme ord, og 0,497 er tættere end de fleste korrekte in-domain retrievals i hele eksperimentet. En embedding ved ikke, at to caches med samme navn er forskellige maskiner. Den anden er et bogstaveligt match uden svar: korpuset indeholder den præcise frase »what is the capital of France«, brugt som eksempel på et spørgsmål, der ikke kræver ræsonnement. Retrieveren har ret; svaret er der ikke. Ethvert system, der læser »jeg fandt noget lignende« som »jeg fandt svaret«, vil hævde Paris på det grundlag — eller værre, vil ikke.
Hvorfor kildehenvisningen ikke skrives af modellen
Link til afsnittet: Hvorfor kildehenvisningen ikke skrives af modellenEn model har ingen separat evne til fakta. At producere en sand sætning og at producere en plausibel er den samme operation — Kapitel 8's next-token prediction — og intet i den operation markerer, hvad der er hvad. Analysen fra 2025, der omrammede dette, argumenterer for, at trænings- og evalueringspipelinen aktivt belønner gæt: benchmarks scorer med binær nøjagtighed og giver ingen kredit for at afstå, så en model, der altid svarer, scorer bedre end en identisk model, der siger »det ved jeg ikke«, når den ikke ved det, og post-training optimerer tilsvarende.6 Hallucination i den læsning er ikke en mystisk defekt. Det er, hvad du får, når du retter en multiple-choice-eksamen uden straf for et forkert svar.
Se formen på det. Bedt om otte papers om kontrastive sentence embeddings, med identifiers, producerede Qwen2.5-0.5B-Instruct otte linjer i perfekt format. Alle otte identifiers er velformede. Alle otte resolver til rigtige papers på arXiv. Nul af de otte er det paper, der blev påstået.
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 SomaliDet er en lille model, og raten er dens egen; en frontier model opfinder langt færre. Mekanismen generaliserer, og den er årsagen til reglen, der følger. En validator, der tjekker »findes denne identifier«, godkender alle otte, og en bruger, der klikker på en, lander på en rigtig side fra et rigtigt arkiv uden mulighed for at se, at mappingen var opfundet. Fejlen ligger ikke i identifieren eller formatet. Den ligger i associationen — præcis det, en sprogmodel producerer efter plausibilitet.
Så: modellen skriver [1] og [2], og skriver aldrig linket. Tallene henviser til fragmenter, serveren retrievede, og serveren — som ved præcis hvilket dokument og hvilke offsets hvert tal kom fra — vedhæfter dokumentet, labelen og URL'en bagefter. Der er intet for modellen at opfinde, fordi den aldrig bliver bedt om den ene ting, den ville opfinde.
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 };
}Kør det på åbningsspørgsmålet, og de fire chunks bliver til en 591-token prompt og en tabel, modellen aldrig ser:
[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.605Locatoren er den del, folk springer over og så ikke kan tilføje senere. #char=25873,26272 er et interval i dokumentets kanoniske tekst; for en PDF er ækvivalenten #page=12, for audio eller video #t=132.4,158.9, for et regneark et ark og et A1-interval. De to er ikke opfindelser — #page= er PDF Open Parameters og #t= er W3C Media Fragments, som browsere respekterer nativt på video- og audioelementer. En kildehenvisning uden locator er et dokumentnavn, og et dokumentnavn er ikke en kildehenvisning; det er et forslag om, at brugeren selv går på jagt.
Og når intet passerer tærsklen, når pipelinen aldrig frem til modellen overhovedet:
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"Det er en billigere og mere pålidelig afvisning end nogen instruktion i en system prompt, fordi det er en sammenligning mellem to tal i stedet for en anmodning til et probabilistisk system.
Evaluer retrieveren adskilt fra generatoren
Link til afsnittet: Evaluer retrieveren adskilt fra generatorenHver måling i dette kapitel scorer retrieveren og beder ikke en eneste gang en model om at skrive et svar. Det er bevidst, og det er den del, de fleste teams springer over.
Et RAG-system har to fejltilstande, der ser identiske ud udefra. Retrieveren fandt ikke passagen; eller den fandt den, og generatoren ignorerede den, modsagde den eller blandede den med noget, den allerede troede. Scor kun det endelige svar, og de to kan ikke skelnes, så du tuner prompts mod et problem, der bor i din chunker. Recall@k, MRR og answer-destroyed-tællingen kræver slet ikke noget generation call, de er billige nok til at køre ved hvert deploy, og de er harness fra Kapitel 15 med en anden scoring function — samme request, deadline, concurrency og optælling, over et fast spørgsmålssæt i stedet for en live samtale.
Rapportér dem med intervaller. Kapitel 4's aritmetik gælder uændret: ved 64 queries har en recall på 0,5 et 95 % Wilson-interval på omtrent ±0,12, så en strategi fire point foran en anden har ikke fortalt dig noget. Brug den parrede test, hver gang begge strategier besvarer de samme spørgsmål, hvilket de altid gør her — det er det, der forvandlede »E ser bedre ud end A« til p = 0,0015.
Og den sidste ærlighed: RAG reducerer hallucination og fjerner det ikke. At lægge den rigtige passage i prompten tvinger ikke modellen til at bruge den, og litteraturen har sagt det siden den oprindelige paper.7 To ting gør det værre i produktion. Lange contexts degraderer — en model finder information i starten og slutningen af en lang prompt mere pålideligt end i midten, så tyve chunks i stedet for fire kan sænke nøjagtigheden, mens regningen stiger, en effekt målt i Kapitel 24. Og retrieval kan være rigtig og stadig utilstrækkelig, som de to caches ovenfor viste. SelfCheckGPT flagger claims, der ikke overlever resampling;8 Self-RAG træner modellen til at udsende sine egne retrieve-and-critique tokens;9 TruthfulQA gjorde fejltilstanden læselig i første omgang.10 Ingen lukker hullet, og et system, der præsenterer retrieved tekst som bevis, har forvekslet kildedækket med sandt.
Den halvdel af systemet, der kører før nogen query
Link til afsnittet: Den halvdel af systemet, der kører før nogen queryEn retriever er den synlige del af en pipeline, hvis fejl alle sker tidligere, i mørket. Tre af dem går igen.
Extraction er dér, indholdet dør. En PDF er ikke tekst; den er tegneinstruktioner. Layouts med to kolonner flettes sammen, tabeller bliver ordsuppe, sidehoveder gentages ind i hver chunk, og en scannet side har slet ingen tekst, før OCR giver den noget, med en confidence. Alt målt ovenfor antog, at extractoren gjorde sit job; i produktion gør den det ofte ikke, og symptomet viser sig som dårlig retrieval tre lag væk.
Indekset er stemplet med den model, der byggede det. Embeddings fra to modeller er ikke sammenlignelige — ikke »mindre præcise«, ikke sammenlignelige, fordi de er punkter i forskellige rum. Skift embedding model, og hver vector i storen er skrald, indtil den er genopbygget. Derfor skrives modelnavn, dimension count, pipeline version og extractor version ved siden af hvert dokument ved index time. Uden dem kan du på opgraderingsdagen ikke se, hvilke dokumenter der er forældede, og hvilke der er aktuelle, og et halvmigreret index returnerer selvsikkert nonsens uden fejl nogen steder.
Ét ødelagt dokument må ikke ødelægge mappen, og tællerne skal tælle det, der skete. Et dokument, hvor extraction fejler, ender i en failed state med sin reason, synlig og retryable, mens de andre nioghalvfems forbliver searchable; og antallet af indexed chunks skrives af serveren, når den er færdig, ikke erklæret af klienten, når den uploader. En mappe, der rapporterer 400 fragmenter og indeholder 40, er en løgn, der først dukker op som et spørgsmål, der ikke kan besvares.
Hvor det går hen herfra
Link til afsnittet: Hvor det går hen herfraSystemet i dette kapitel besvarer spørgsmål, hvis svar er skrevet ned. Det retriever dem, ranker dem, afviser når det ikke kan, og citerer, hvor det kiggede. Det er det meste af det, folk ønsker fra en assistant over deres egne dokumenter, og det er afgrænset på én bestemt måde: retrieval kan kun returnere det, nogen har skrevet.
Det efterlader den anden halvdel. Noget af det, du vil have en model til at gøre, er slet ikke en kendsgerning i et dokument — et format, den skal holde, en tone, en taksonomi med fire hundrede labels, en måde at beslutte på, der lever i ti tusind tidligere eksempler og i intet afsnit nogen steder. Retrieval kan ikke levere det, fordi der ikke er noget at retrieve; en længere prompt betaler kun Kapitel 16's regning for en beskrivelse af en skill i stedet for skillen.
Kapitel 20 er den beslutning — fine-tune, retrieve eller prompt — og dens fund er, at beslutningen er økonomisk, før den er teknisk: de tre prissættes end to end på det samme spørgsmål, og crossoveret er et token count. Spørgsmålet, der åbner det, er det, dette kapitel ikke kan besvare. Ikke hvor er svaret skrevet, men hvad gør du, når det aldrig blev skrevet.
Kilder og metode
Link til afsnittet: Kilder og metodeAlt målt i dette kapitel brugte ét korpus og ét instrument, og begge kan reproduceres. Korpuset er kapitel 1 til 13 af dette kursus, som de stod den 7. september 2026 — 13 dokumenter, 359.067 tegn, 127 sektioner, front matter og bibliografier fjernet. De kapitler bliver stadig redigeret, så at anvende samme regel i dag tæller et par tusind tegn mere: sektionsantallet er uændret, og det samme gælder hver konklusion nedenfor, men tegntotalen er et snapshot og er markeret som et. Ground truth er 32 spørgsmål, hvert parret med en ordret sætning, der forekommer præcis én gang i korpuset og aldrig er en sektionsoverskrift, stillet i to formuleringer for 64 queries. Retrieval embeddings er sentence-transformers/all-MiniLM-L6-v2 (384 dimensioner, mean-pooled, L2-normalised, 256-token window); reranking er cross-encoder/ms-marco-MiniLM-L-6-v2 over top 25; generation-eksemplet er Qwen/Qwen2.5-0.5B-Instruct med greedy decoding. Alle timings er single-threaded CPU. Ingen betalt API blev kaldt for at producere dette kapitel, hvilket også er grunden til, at hver latency her er lokal og er markeret som sådan.
Chunkeren vist i TypeScript er den chunker, der blev målt: Python-instrumentet, der implementerer samme regel, og ts/chunk.ts blev sammenlignet chunk for chunk over hele korpuset og stemmer overens på alle 940 chunks, tekster og offsets. Intervaller er Wilson ved 95 %; parrede sammenligninger er tosidede eksakte fortegnstests på de diskordante par.
Alle fjorten identifiers citeret ovenfor blev slået op mod arXiv API'et og tjekket titel for titel den 7. september 2026 — hvilket, givet de otte der ikke var, virkede som det mindste, netop dette kapitel kunne gøre.
Referencer
Link til afsnittet: Referencer-
Robertson, S. and Zaragoza, H. The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval 3(4), pp. 333–389 (2009). Kilden til saturationsfunktionen og til de to konstanter brugt ovenfor, og stedet at læse, hvorfor overhovedet findes. ↩
-
Cormack, G. V., Clarke, C. L. A. and Büttcher, S. Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods. SIGIR 2009. er deres, og pointen med metoden er, at den ikke kræver kalibrering mellem de score scales, den fusionerer. ↩
-
Khattab, O. and Zaharia, M. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT. arXiv:2004.12832 (2020). Mellemvejen mellem et prikprodukt og en cross-encoder. Reimers, N. and Gurevych, I., Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks, arXiv:1908.10084 (2019), er den bi-encoder, dette kapitels index er bygget på, og blev målt i Kapitel 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). Graph index bag de fleste af de vector databases, der sælges i dag. ↩
-
Johnson, J., Douze, M. and Jégou, H. Billion-scale Similarity Search with GPUs. arXiv:1702.08734 (2017). FAISS, og referenceimplementeringen af den IVF, der blev målt i boksen ovenfor. ↩
-
Kalai, A. T., Nachum, O., Vempala, S. S. and Zhang, E. Why Language Models Hallucinate. arXiv:2509.04664 (2025). Argumentet om, at hallucination produceres af binary-accuracy grading, der aldrig belønner abstention, og derfor er et evalueringsproblem, før det er et modelleringsproblem. ↩
-
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). Paperen, der navngav mønstret, og den du skal læse for, hvad det gør og ikke gør. Guu et al., REALM: Retrieval-Augmented Language Model Pre-Training, arXiv:2002.08909 (2020), er det samtidige arbejde, der træner retrieveren sammen med modellen i stedet for at bolte den på; Karpukhin et al., Dense Passage Retrieval for Open-Domain Question Answering, arXiv:2004.04906 (2020), er der, den two-encoder dense retriever, der bruges gennem hele dette kapitel, kommer fra; og Izacard and Grave, Leveraging Passage Retrieval with Generative Models for Open Domain Question Answering, arXiv:2007.01282 (2020), er fusion-in-decoder-arrangementet til at fodre mange passager til én generator. Gao et al., Retrieval-Augmented Generation for Large Language Models: A Survey, arXiv:2312.10997 (2023), er kortet over alt, der kom bagefter, inklusive HyDE (Gao et al., Precise Zero-Shot Dense Retrieval without Relevance Labels, arXiv:2212.10496, 2022), som embedder et hypotetisk svar i stedet for spørgsmålet. ↩
-
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). Detection ved resampling, uden adgang til modellens indre og uden ekstern 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). Træning af modellen til at beslutte, hvornår den skal retrieve, i stedet for at retrieve på hver turn. ↩
-
Lin, S., Hilton, J. and Evans, O. TruthfulQA: Measuring How Models Mimic Human Falsehoods. arXiv:2109.07958 (2021). Benchmarket bygget af spørgsmål, hvor det plausible svar og det sande svar er forskellige, hvilket er hele vanskeligheden i én sætning. ↩