Naar inhoud springen
19/30Hoofdstuk 19 van 30

RAG in productie: chunking, retrieval en eerlijke citaties

Knip blind op 512 tekens en 4 van 32 antwoorden zijn weg voordat een retriever ze ziet. Alleen de chunker fixen brengt rank 115 naar 3.

Op deze pagina

Hier is een echte vraag van een echte gebruiker van een echte assistant: mijn eval-set heeft 20 items, is dat genoeg om de score te vertrouwen. Het corpus bevat het antwoord — een hele sectie ervan. Dit zijn de vier fragmenten die de retriever daadwerkelijk in de prompt zette.

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…

Drie van de vier beginnen midden in een woord. Twee komen uit een ander hoofdstuk over een ander onderwerp. En het fragment dat de vraag beantwoordt — het fragment met Zeventien van de twintig kunnen een model van 85% niet onderscheiden van een van 65% — kwam terug op rank 115.

Nu dezelfde vraag, hetzelfde embedding model, dezelfde prompt-template. Eén ding veranderde: hoe de documenten werden geknipt.

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]

Van rank 115 naar rank 3. Niemand raakte het model, de prompt, de threshold of het aantal slots aan. Dit hoofdstuk gaat over dat gat, en over de vier andere plekken waar een retrieval-systeem stilletjes tegen je liegt.

Details tonen

Wat dit hoofdstuk nodig heeft uit eerdere hoofdstukken, en de ene plek waar het van taal verandert.

  • Hoofdstuk 1 definieerde het dot product en de L2-norm. De threshold-sectie hieronder is die twee, en niets anders.
  • Hoofdstuk 8 scheidde de embedding-tabel van een language model van een retrieval embedding model dat contrastief op paren is getraind, mat cosine similarity en eindigde met de belofte dat hoofdstuk 19 bij een concrete cut-off zou uitkomen. Die belofte wordt hier ingelost. Niets daarvan wordt herhaald.
  • Hoofdstuk 4 bouwde het Wilson-interval; Hoofdstuk 15 bouwde de evaluatie-harness. Elke tabel hieronder draagt het eerste en is geproduceerd door de tweede.
  • Hoofdstuk 16 prijsde de context window. De prompt die aan het einde van dit hoofdstuk wordt samengesteld kost 591 tokens, en dat is het budget waar de fragmenten om concurreren.

Alles hier is TypeScript, zoals sinds Hoofdstuk 14, en dit hoofdstuk is waar die regel zichzelf verdient: ingestion is queues en opslag, search is een netwerkcall, en een prompt met citaties samenstellen is werk voor de server. De meting is expres dezelfde code met een scorebord eromheen — een retriever die door een tweede implementatie wordt gescoord, is een getal over software die je niet shipt, en de cosine threshold hieronder is alleen geloofwaardig omdat je ziet hoe hij wordt gesweept door de chunker die in productie draait.

Het corpus, en wat telt als een goed antwoord

Link naar de sectie: Het corpus, en wat telt als een goed antwoord

Alles hieronder is gemeten tegen één corpus: de eerste dertien hoofdstukken van deze cursus — 13 documenten, 359.067 tekens, 127 secties, met front matter en bibliografieën verwijderd. Het is een echt technisch corpus, met proza, tabellen, formules en code blocks erin, en het is precies het soort materiaal dat mensen in een knowledge base laden en daarna over klagen.

De ground truth bestaat uit 32 vragen, elk gekoppeld aan een needle: een korte, letterlijke zin uit het corpus die de vraag beantwoordt. Elke needle komt precies één keer voor in de 359.067 tekens, en geen enkele is een sectiekop — die check is belangrijk, omdat een chunker die koppen naar elke chunk kopieert zichzelf anders zou scoren. Elke vraag wordt twee keer gesteld, één keer in cursus-Engels en één keer zoals een supportticket hem formuleert: 64 queries over 32 ground truths.

Een retrieval is correct wanneer een teruggegeven chunk de needle volledig bevat. Dat is de enige definitie die past bij wat de generator nodig heeft: een halve zin in de prompt is geen antwoord, maar een gevaar.

Het embedding model is all-MiniLM-L6-v2 — 384 dimensies, mean-pooled en genormaliseerd, het contrastief getrainde model dat hoofdstuk 8 mat. Het corpus indexeren duurt 20,8 seconden op een CPU, 22 ms per chunk; één query embedden duurt 13 ms.

Zes strategieën uit drie onafhankelijke ingrediënten. Blind knipt elke 512 tekens zonder naar de tekst te kijken. Boundaries knipt nooit binnen een alinea, en valt alleen terug op een zinsgrens wanneer één alinea over budget gaat. Header zet voor elke chunk de documenttitel en het sectiepad. Overlap kopieert de laatste 64 tekens van de vorige chunk naar de volgende.

strategiechunksantwoorden vernietigdR@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

Met 64 queries is het 95%-Wilson-interval op R@20 [0,471, 0,705] voor A en [0,718, 0,901] voor E — die overlappen niet, maar de meeste andere kolommen wel, en een ongepaarde tabel kan ze niet scheiden. Elke strategie beantwoordt dezelfde queries, dus de eerlijke test is gepaard: tel per strategie de wins en losses tegen een andere en voer een sign test uit op de discordante paren. Drie resultaten overleven dat.

Blind chunking vernietigt vier van de tweeëndertig antwoorden volledig. Niet slecht ranken — vernietigen. De needle valt over een grens van 512 tekens, waardoor geen enkele chunk in de index hem bevat, en de recall-ceiling voor die queries nul is. Geen reranker haalt ze terug, geen threshold helpt, geen groter model helpt. Je kunt geen tekst retrieve-en die nergens in je index als één stuk bestaat. Dit is de meest ondergerapporteerde failure in RAG, omdat hij er precies uitziet als een slechte retriever.

Overlap fixt dat en niets anders. Elke strategie met overlap verliest nul antwoorden, en daarvoor is overlap bedoeld. Hij verbetert ranking niet: B tegen A op R@8 is +8/−6, p = 0,79; op R@20 is het +9/−7, p = 0,80. Erger nog: overlap bovenop de header toevoegen schaadt actief — F tegen E is +2/−6 op R@20 — en de reden is mechanisch. De vector van een chunk is een gemiddelde over zijn tokens, dus 64 tekens van de vorige chunk trekken dat gemiddelde richting het onderwerp van de buur. Overlap is een verzekering tegen een gesplitst antwoord, betaald in precision.

De contextual header koopt retrieval. E tegen A is +18/−3 op R@20, p = 0,0015. En de ablation zegt dat de boundaries het niet doen: E tegen C — dezelfde knips, alleen de header is anders — is +12/−2, p = 0,0129. ‘Classification, Cross-Entropy, and How Not to Fool Yourself > How many test examples do I need?’ vóór een alinea zetten vertelt het embedding model waar de alinea over gaat, iets wat de alinea zelf vaak niet zegt. Het is een voornaamwoordoplosser voor documenten.

Dat geeft de chunker zijn vorm, en één regel die makkelijk fout gaat:

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

Twee teksten, niet één. text is wat wordt ge-embed, header en al. content bestaat alleen uit de eigen woorden van deze chunk, en is wat aan de gebruiker wordt geciteerd. Citeer text en de citatie toont een header die op dat punt niet in het document staat — en, met overlap, een herhaalde staart die bij het vorige fragment hoort. Dan toont hij tekst die niet staat waar hij zegt dat die staat, en dat is erger dan niets tonen.

De header is niet gratis. Over de 940 chunks kost hij 24.213 van de 114.275 embedded tokens van de index: 21,2% van wat je betaalt om te embedden is een header die je zelf hebt geschreven. Hij duwt chunks ook tegen de window van de encoder. all-MiniLM-L6-v2 accepteert 256 word-pieces; strategie E heeft 17 chunks boven die grens en F heeft er 28, allemaal stilzwijgend truncated zonder waarschuwing van iets. Je effectieve chunk size is niet het getal in je config — het is het minimum van dat getal en de window van je encoder.

Dense retrieval heeft één systematische zwakte en die is niet subtiel: hij matcht betekenis, dus het maakt hem niet uit welke exacte string je typte. Een onderdeelnummer, een foutcode, een acroniem, een achternaam — niets daarvan heeft een bruikbare betekenis om te embedden, en de nearest neighbour van een foutcode is elke andere foutcode in je corpus.

Het klassieke antwoord is ouder dan dit alles en kost twintig regels. BM25 scoort een document op hoe vaak de termen van de query erin voorkomen, dempt elke term naarmate de frequentie stijgt en straft lange documenten die matches puur door lengte opstapelen.1 Term tt draagt bij

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

waar ft,df_{t,d} de telling van de term in het document is, d|d| de lengte ervan, d\overline{|d|} de gemiddelde lengte, en k1=1.2k_1 = 1.2 en b=0.75b = 0.75 de twee conventionele constanten — k1k_1 bepaalt hoe snel herhaling stopt met helpen, bb hoe hard lengte wordt bestraft.

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

Over 940 chunks scoort dat een query in 1,14 ms, zonder index behalve twee hash maps. En het is geen museumstuk:

retrieverR@1R@4R@8MRRkosten per query
dense (cosine)0.0940.4220.5780.28013 ms om te embedden + 0,3 ms om te scannen
lexical (BM25)0.2190.3750.4690.3131,14 ms
hybrid (RRF)0.2030.4840.6090.346beide
hybrid + cross-encoder0.3120.5780.7030.447+ 569 ms

BM25 verdubbelt de top-1-accuracy van de dense retriever op dit corpus ruimschoots, en verliest rond rank 8 flink. Ze falen op verschillende queries, en dat is het hele argument om beide te draaien.

Ze fuseren is de ene plek waar de voor de hand liggende aanpak fout is. Cosine-afstanden en BM25-scores staan niet op dezelfde schaal, zijn niet op dezelfde manier begrensd, en per query normaliseren maakt het gewicht afhankelijk van hoe goed de beste hit toevallig was. Reciprocal rank fusion gooit de scores weg en bewaart alleen de 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);
}

En hier is de eerlijke lezing van de tabel belangrijker dan de tabel. Hybrid verslaat BM25 op R@4 met +10/−3, p = 0,09. Het verslaat dense met +10/−6, p = 0,45. Op dit corpus, met 64 queries, is hybrid retrieval niet te onderscheiden van dense retrieval. Het is beter op beide puntschattingen en elke recall-kolom, en het bewijs haalt significantie niet. Bijna elke blogpost over hybrid search op internet rapporteert een tabel zoals hierboven en geen interval; dit is wat het interval zegt.

Bi-encoder, cross-encoder, en waar de lift echt zit

Link naar de sectie: Bi-encoder, cross-encoder, en waar de lift echt zit

Alles tot nu toe is een bi-encoder: de query gaat alleen door het model, elke chunk ging er maanden geleden alleen doorheen, en de twee ontmoeten elkaar nooit behalve als dot product. Dat maakt een index mogelijk — één keer embedden, voor altijd hergebruiken — en het is ook het plafond. Het model bekijkt de query en de chunk nooit samen.

Een cross-encoder doet precies dat: hij neemt het paar als één input en geeft een relevantiescore terug. Niets kan vooraf worden berekend, dus hij kan geen index ranken — maar hij kan wel een shortlist reranken. De hybrid top 25 reranken met ms-marco-MiniLM-L-6-v2 brengt R@1 van 0,094 (dense) naar 0,312 en MRR van 0,280 naar 0,447: de grootste losse verbetering in dit hoofdstuk, en de enige die de bovenkant van de lijst raakt in plaats van de staart.

Het kost 569 ms per query op een CPU, tegenover 1,14 ms voor BM25 en 0,3 ms voor de vectorscan. Ruwweg tweeduizend keer de retrieval-kosten, voor vijfentwintig documenten. Dat is de hele bi-encoder/cross-encoder-trade in één getal, en daarom heeft de architectuur altijd dezelfde vorm: een goedkope retriever met brede recall, daarna een dure scorer op een shortlist die je kunt betalen. ColBERT zit tussen de twee in, met vooraf berekende per-token vectors en een late interaction die goedkoper is dan een cross-encoder en scherper dan een dot product.3

L2, cosine en een threshold die je niet hebt verdiend

Link naar de sectie: L2, cosine en een threshold die je niet hebt verdiend

Vector databases rapporteren afstanden, en welke afstand is een configuratie-optie. Op genormaliseerde vectors is die keuze cosmetisch, en de identiteit is het waard om één keer uit te voeren omdat alles erna ervan afhangt dat de vectors echt unit zijn. Voor 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

dus de cosine distance 1cosθ1 - \cos\theta is exact d2/2d^2/2. Dat is hoofdstuk 1’s dot product en norm, geïnd. Gecheckt op twee echte chunk vectors uit de index hierboven, en daarna over 40.000 paren:

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

Exact tot floating-point-ruis — en alleen omdat de vectors genormaliseerd zijn. Sla de normalisatie over en de identiteit is onwaar, je threshold betekent niets, en de afstand die een document rapporteert hangt af van hoe lang de tekst was.

Nu het getal dat niemand afleidt. Een retriever geeft altijd iets terug: hij sorteert de hele index en geeft je de top van de lijst, of het antwoord nu ergens in het corpus staat of niet. De threshold is het enige deel van het systeem dat nee kan zeggen — en om er één te zetten heb je queries nodig die niets zouden moeten terugkrijgen. Hier zijn er dertig: eenentwintig over dingen die dit corpus echt niet behandelt — streaming, rate limits, prompt caching, JSON-schema’s, agent-loops, vector databases, prompt injection, image generation — en negen over paella, paspoorten en refund policies. Tegen dezelfde index:

top-1 cosine distance
in-domain queries, alle 64gemiddelde 0,445, bereik 0,270 – 0,721
in-domain, top-1 echt correctgemiddelde 0,370
in-domain, top-1 foutgemiddelde 0,452
out-of-domain, alle 30gemiddelde 0,699, bereik 0,497 – 0,867

De verdelingen scheiden, en ze overlappen. De slechtste in-domain query ligt verder van zijn antwoord (0,721) dan de beste out-of-domain query van een irrelevante alinea ligt (0,497), dus geen threshold krijgt beide goed. Sweep hem over de echte gate — houd maximaal vier chunks, en alleen die onder de cut:

thresholdin-domain beantwoordwaarvan het antwoord erin zatout-of-domain beantwoord
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
geen64 / 642730 / 30

Lees de laatste kolom als blufs. Zonder threshold produceert de assistant dertig van de dertig keer een zelfverzekerd, goed geciteerd antwoord op ‘hoe verleng ik mijn Spaanse paspoort’ uit een corpus over backpropagation. Op 0,675 doet hij dat tien van de dertig keer. Op 0,525 doet hij het twee keer, en geeft hij twaalf vragen op die hij had kunnen beantwoorden.

Die trade-off is een productbeslissing, en de juiste kant ervan hangt af van wat een fout antwoord je kost. Wat niet onderhandelbaar is, is dat de laatste kolom überhaupt bestaat. Als je je retriever nooit hebt gemeten tegen vragen die hij zou moeten weigeren, heb je geen threshold — je hebt een getal.

Twee van de tien blufs op 0,675 laten de twee manieren zien waarop dit faalt.

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

De eerste is een near miss: het corpus legt de KV cache uitgebreid uit, de query gaat over de prompt cache, de woorden zijn dezelfde woorden, en 0,497 is dichterbij dan de meeste correcte in-domain retrievals in het hele experiment. Een embedding weet niet dat twee caches met dezelfde naam verschillende machines zijn. De tweede is een letterlijke match zonder antwoord: het corpus bevat de exacte frase ‘wat is de hoofdstad van Frankrijk’, gebruikt als voorbeeld van een vraag die geen reasoning nodig heeft. De retriever heeft gelijk; het antwoord staat er niet. Elk systeem dat ‘ik heb iets vergelijkbaars gevonden’ leest als ‘ik heb het antwoord gevonden’ zal op basis van dat bewijs Parijs beweren — of, erger, niet.

Waarom de citatie niet door het model wordt geschreven

Link naar de sectie: Waarom de citatie niet door het model wordt geschreven

Een model heeft geen apart vermogen voor feiten. Een ware zin produceren en een plausibele zin produceren zijn dezelfde operatie — hoofdstuk 8’s next-token prediction — en niets in die operatie markeert welke welke is. De analyse uit 2025 die dit herkadert stelt dat de training- en evaluatiepipeline gokken actief beloont: benchmarks scoren met binaire accuracy en geven geen credit voor onthouding, dus een model dat altijd antwoordt scoort hoger dan een identiek model dat ‘ik weet het niet’ zegt wanneer het dat niet weet, en post-training optimaliseert daarnaar.6 Hallucinatie is in die lezing geen mysterieus defect. Het is wat je krijgt als je een multiplechoice-examen nakijkt zonder straf voor een fout antwoord.

Kijk naar de vorm ervan. Gevraagd om acht papers over contrastive sentence embeddings, met identifiers, produceerde Qwen2.5-0.5B-Instruct acht regels in perfect format. Alle acht identifiers zijn goed gevormd. Alle acht resolven naar echte papers op arXiv. Nul van de acht zijn de paper die werd beweerd.

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

Dit is een klein model en de rate is van zichzelf; een frontier model verzint veel minder. Het mechanisme generaliseert, en dat is de reden voor de regel die volgt. Een validator die checkt ‘bestaat deze identifier’ laat alle acht door, en een gebruiker die erop klikt landt op een echte pagina van een echt archief zonder manier om te zien dat de mapping is verzonnen. De failure zit niet in de identifier of het format. Hij zit in de associatie — precies datgene wat een language model via plausibiliteit produceert.

Dus: het model schrijft [1] en [2], en schrijft nooit de link. De nummers verwijzen naar fragmenten die de server heeft retrieved, en de server — die exact weet uit welk document en welke offsets elk nummer kwam — voegt daarna het document, het label en de URL toe. Er is niets voor het model om te verzinnen, omdat het nooit wordt gevraagd naar het ene ding dat het zou verzinnen.

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

Draai het op de openingsvraag en de vier chunks worden een prompt van 591 tokens en een tabel die het model nooit ziet:

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

De locator is het deel dat mensen overslaan en later niet meer kunnen toevoegen. #char=25873,26272 is een bereik in de canonieke tekst van het document; voor een PDF is het equivalent #page=12, voor audio of video #t=132.4,158.9, voor een spreadsheet een sheet en een A1-bereik. Die twee zijn geen verzinsels — #page= is PDF Open Parameters en #t= is W3C Media Fragments, native ondersteund door browsers op video- en audio-elementen. Een citatie zonder locator is een documentnaam, en een documentnaam is geen citatie; het is een suggestie dat de gebruiker zelf gaat zoeken.

En wanneer niets de threshold passeert, bereikt de pipeline het model helemaal niet:

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"

Dat is een goedkopere en betrouwbaardere weigering dan welke instructie in een system prompt ook, omdat het een vergelijking tussen twee getallen is in plaats van een verzoek aan een probabilistisch systeem.

Elke meting in dit hoofdstuk scoort de retriever en vraagt nooit één keer een model om een antwoord te schrijven. Dat is bewust, en het is het stuk dat de meeste teams overslaan.

Een RAG-systeem heeft twee failure modes die er van buiten identiek uitzien. De retriever vond de passage niet; of hij vond hem en de generator negeerde hem, sprak hem tegen, of mengde hem met iets wat hij al geloofde. Score alleen het eindantwoord en de twee zijn niet te onderscheiden, dus tune je prompts tegen een probleem dat in je chunker zit. Recall@k, MRR en de answer-destroyed-telling hebben helemaal geen generatiecall nodig, zijn goedkoop genoeg om op elke deploy te draaien, en zijn de harness uit hoofdstuk 15 met een andere scoring function — dezelfde request, deadline, concurrency en tally, over een vaste vragenset in plaats van een live gesprek.

Rapporteer ze met intervallen. De rekenkunde van hoofdstuk 4 geldt onveranderd: bij 64 queries heeft een recall van 0,5 een 95%-Wilson-interval van grofweg ±0,12, dus een strategie die vier punten voorligt op een andere heeft je niets verteld. Gebruik de gepaarde test wanneer beide strategieën dezelfde vragen beantwoorden, wat ze hier altijd doen — dat is wat ‘E lijkt beter dan A’ veranderde in p = 0,0015.

En de laatste eerlijkheid: RAG vermindert hallucinatie en verwijdert haar niet. De juiste passage in de prompt zetten verplicht het model niet om die te gebruiken, en de literatuur zegt dat al sinds de oorspronkelijke paper.7 Twee dingen maken het erger in productie. Lange contexts degraderen — een model vindt informatie aan het begin en einde van een lange prompt betrouwbaarder dan in het midden, dus twintig chunks in plaats van vier kunnen accuracy verlagen terwijl ze de rekening verhogen, een effect gemeten in Hoofdstuk 24. En retrieval kan juist zijn en toch onvoldoende, zoals de twee caches hierboven lieten zien. SelfCheckGPT markeert claims die resampling niet overleven;8 Self-RAG traint het model om zijn eigen retrieve-and-critique tokens uit te geven;9 TruthfulQA maakte de failure mode überhaupt leesbaar.10 Geen daarvan sluit het gat, en een systeem dat retrieved tekst als bewijs presenteert heeft van bronnen voorzien verward met waar.

De helft van het systeem die vóór elke query draait

Link naar de sectie: De helft van het systeem die vóór elke query draait

Een retriever is het zichtbare deel van een pipeline waarvan alle failures eerder gebeuren, in het donker. Drie ervan keren steeds terug.

Extraction is waar de content sterft. Een PDF is geen tekst; het zijn tekeninstructies. Lay-outs met twee kolommen lopen door elkaar, tabellen worden woordsoep, paginakoppen herhalen zich in elke chunk, en een gescande pagina heeft helemaal geen tekst totdat OCR die geeft, met een confidence. Alles wat hierboven is gemeten nam aan dat de extractor zijn werk deed; in productie doet hij dat vaak niet, en het symptoom verschijnt drie lagen verder als slechte retrieval.

De index is gestempeld met het model dat hem bouwde. Embeddings van twee modellen zijn niet vergelijkbaar — niet ‘minder accuraat’, niet vergelijkbaar, omdat het punten in verschillende ruimtes zijn. Verander het embedding model en elke vector in de store is afval totdat hij opnieuw is gebouwd. Daarom worden de modelnaam, het aantal dimensies, de pipeline-versie en de extractor-versie bij index time naast elk document geschreven. Zonder die gegevens kun je op upgradedag niet zien welke documenten stale zijn en welke actueel, en een half gemigreerde index geeft zelfverzekerde onzin terug zonder ergens een error.

Eén kapot document mag de folder niet breken, en de tellers moeten tellen wat er gebeurde. Een document waarvan extraction faalt eindigt in een failed-state met de reden, zichtbaar en opnieuw te proberen, terwijl de andere negenennegentig doorzoekbaar blijven; en het aantal geïndexeerde chunks wordt door de server geschreven wanneer die klaar is, niet gedeclareerd door de client bij upload. Een folder die 400 fragmenten rapporteert en er 40 bevat is een leugen die pas zichtbaar wordt als een onbeantwoordbare vraag.

Het systeem in dit hoofdstuk beantwoordt vragen waarvan de antwoorden zijn opgeschreven. Het retrieved ze, rankt ze, weigert wanneer het niet kan, en citeert waar het keek. Dat is het grootste deel van wat mensen willen van een assistant boven hun eigen documenten, en het is op één specifieke manier begrensd: retrieval kan alleen teruggeven wat iemand heeft geschreven.

Dat laat de andere helft over. Een deel van wat je een model wilt laten doen is helemaal geen feit in een document — een format dat het moet vasthouden, een toon, een taxonomie met vierhonderd labels, een manier van beslissen die leeft in tienduizend eerdere voorbeelden en in geen enkele alinea. Retrieval kan die niet leveren, omdat er niets te retrieve-en valt; een langere prompt betaalt alleen de rekening van hoofdstuk 16 voor een beschrijving van een skill in plaats van de skill.

Hoofdstuk 20 is die beslissing — fine-tune, retrieve of prompt — en de bevinding is dat de beslissing economisch is voordat hij technisch is: de drie worden end-to-end geprijsd op dezelfde vraag, en het crossover-punt is een token-aantal. De vraag waarmee het opent is de vraag die dit hoofdstuk niet kan beantwoorden. Niet waar staat het antwoord geschreven, maar wat doe je wanneer het nooit geschreven was.


Alles wat in dit hoofdstuk is gemeten gebruikte één corpus en één instrument, en beide zijn reproduceerbaar. Het corpus bestaat uit hoofdstuk 1 tot en met 13 van deze cursus zoals ze waren op 7 september 2026 — 13 documenten, 359.067 tekens, 127 secties, front matter en bibliografieën verwijderd. Die hoofdstukken worden nog steeds bewerkt, dus dezelfde regel vandaag toepassen telt een paar duizend tekens meer: het aantal secties is onveranderd en elke conclusie hieronder ook, maar het tekentotaal is een snapshot en als zodanig gelabeld. De ground truth bestaat uit 32 vragen, elk gekoppeld aan een letterlijke zin die precies één keer in het corpus voorkomt en nooit een sectiekop is, gesteld in twee formuleringen voor 64 queries. Retrieval-embeddings zijn sentence-transformers/all-MiniLM-L6-v2 (384 dimensies, mean-pooled, L2-genormaliseerd, 256-token window); reranking is cross-encoder/ms-marco-MiniLM-L-6-v2 over de top 25; het generatievoorbeeld is Qwen/Qwen2.5-0.5B-Instruct met greedy decoding. Alle timings zijn single-threaded CPU. Er is geen betaalde API aangeroepen om dit hoofdstuk te produceren, en daarom is elke latency hier ook lokaal en als zodanig gelabeld.

De chunker die in TypeScript wordt getoond is de chunker die is gemeten: het Python-instrument dat dezelfde regel implementeert en ts/chunk.ts zijn chunk voor chunk over het hele corpus vergeleken en komen overeen op alle 940 chunks, zowel teksten als offsets. Intervallen zijn Wilson op 95%; gepaarde vergelijkingen zijn tweezijdige exacte sign tests op de discordante paren.

Alle veertien identifiers die hierboven worden geciteerd zijn op 7 september 2026 tegen de arXiv API resolved en titel voor titel gecheckt — wat, gezien de acht die dat niet waren, het minste leek dat dit specifieke hoofdstuk kon doen.

  1. Robertson, S. en Zaragoza, H. The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval 3(4), pp. 333–389 (2009). De bron van de saturatiefunctie en van de twee constanten die hierboven worden gebruikt, en de plek om te lezen waarom bb überhaupt bestaat.

  2. Cormack, G. V., Clarke, C. L. A. en Büttcher, S. Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods. SIGIR 2009. De k=60k = 60 is van hen, en het punt van de methode is dat hij geen calibratie nodig heeft tussen de score-schalen die hij fuseert.

  3. Khattab, O. en Zaharia, M. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT. arXiv:2004.12832 (2020). De middenweg tussen een dot product en een cross-encoder. Reimers, N. en Gurevych, I., Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks, arXiv:1908.10084 (2019), is de bi-encoder waarop de index van dit hoofdstuk is gebouwd en die in hoofdstuk 8 is gemeten.

  4. Malkov, Yu. A. en Yashunin, D. A. Efficient and Robust Approximate Nearest Neighbor Search using Hierarchical Navigable Small World Graphs. arXiv:1603.09320 (2016). De graph index achter de meeste vector databases die nu worden verkocht.

  5. Johnson, J., Douze, M. en Jégou, H. Billion-scale Similarity Search with GPUs. arXiv:1702.08734 (2017). FAISS, en de referentie-implementatie van de IVF die in de box hierboven is gemeten.

  6. Kalai, A. T., Nachum, O., Vempala, S. S. en Zhang, E. Why Language Models Hallucinate. arXiv:2509.04664 (2025). Het argument dat hallucinatie wordt geproduceerd door binary-accuracy grading die onthouding nooit beloont, en daarom eerst een evaluatieprobleem is voordat het een modelling-probleem is.

  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. en Kiela, D. Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks. arXiv:2005.11401 (2020). De paper die het patroon een naam gaf en degene om te lezen voor wat het wel en niet fixt. Guu et al., REALM: Retrieval-Augmented Language Model Pre-Training, arXiv:2002.08909 (2020), is het gelijktijdige werk dat de retriever samen met het model traint in plaats van hem eraan vast te schroeven; Karpukhin et al., Dense Passage Retrieval for Open-Domain Question Answering, arXiv:2004.04906 (2020), is waar de two-encoder dense retriever vandaan komt die in dit hoofdstuk wordt gebruikt; en Izacard en Grave, Leveraging Passage Retrieval with Generative Models for Open Domain Question Answering, arXiv:2007.01282 (2020), is de fusion-in-decoder-opzet om veel passages aan één generator te voeren. Gao et al., Retrieval-Augmented Generation for Large Language Models: A Survey, arXiv:2312.10997 (2023), is de kaart van alles wat daarna kwam, inclusief HyDE (Gao et al., Precise Zero-Shot Dense Retrieval without Relevance Labels, arXiv:2212.10496, 2022), dat een hypothetisch antwoord embedt in plaats van de vraag.

  8. Manakul, P., Liusie, A. en Gales, M. J. F. SelfCheckGPT: Zero-Resource Black-Box Hallucination Detection for Generative Large Language Models. arXiv:2303.08896 (2023). Detectie via resampling, zonder toegang tot de internals van het model en zonder externe knowledge base.

  9. Asai, A., Wu, Z., Wang, Y., Sil, A. en Hajishirzi, H. Self-RAG: Learning to Retrieve, Generate, and Critique through Self-Reflection. arXiv:2310.11511 (2023). Het model trainen om te beslissen wanneer het moet retrieve-en, in plaats van op elke turn te retrieve-en.

  10. Lin, S., Hilton, J. en Evans, O. TruthfulQA: Measuring How Models Mimic Human Falsehoods. arXiv:2109.07958 (2021). De benchmark opgebouwd uit vragen waarbij het plausibele antwoord en het ware antwoord verschillen, wat de hele moeilijkheid in één zin is.

Klaar om LIA te laten kiezen?

Bouw met elk AI-model op één plek — begin vandaag nog gratis.