Hoppa till innehållet
19/30Kapitel 19 av 30

RAG i produktion: chunking, retrieval och ärliga källhänvisningar

Skär blint vid 512 tecken och fyra av trettiotvå svar dör innan retriever ser dem. Bara chunkern flyttar rank 115 till 3.

På den här sidan

Här är en riktig fråga från en riktig användare av en riktig assistant: min eval-uppsättning har 20 poster, räcker det för att lita på poängen. Korpusen innehåller svaret — en hel sektion av det. Här är de fyra fragment som retrievern faktiskt lade i prompt.

four fragments, chunked blind at 512 charactersTEXT
[1] d=0.578  ship — that set has been used for fitting, and its score stops being
             unbiased. Measured on this belt: sweeping the threshold on the
             validation set picks 0.196, and the model then scores F1 = 0.4122…

[2] d=0.602  ng when the model is confidently **wrong**. Evaluate both at a few
             scores, for an example whose true label is 1: | score | p | …

[3] d=0.613  ard and watch both numbers: | | reward model's score | true quality
             | length produced | … The reward went up by a factor of 2.5. The…

[4] d=0.617  | 0.6 | +0.97 | +1.00 | +0.27 | … The reward model is working
             perfectly. It has faithfully learned the preferences it was shown…

Tre av de fyra börjar mitt i ett ord. Två kommer från ett annat kapitel om ett annat ämne. Och fragmentet som besvarar frågan — det som innehåller Seventeen out of twenty cannot distinguish an 85 % model from a 65 % one — kom tillbaka på rank 115.

Nu samma fråga, samma embedding model, samma prompt-mall. En sak ändrades: hur dokumenten skars upp.

four fragments, cut on section boundaries with a contextual headerTEXT
[1] d=0.594  [Classification, Cross-Entropy… > How many test examples do I need?]
             Read it backwards, which is how you will use it: ±5 points needs
             about 200 examples. ±2 points needs about 1,230…

[2] d=0.598  [Classification, Cross-Entropy… > Three splits, and the leak…]
             Why three splits and not two? Because the moment you use a set of
             examples to *choose* anything…

[3] d=0.600  [Classification, Cross-Entropy… > How many test examples do I need?]
             The honest reading of 17/20 is *somewhere between 64 % and 95 %*.
             …Seventeen out of twenty cannot distinguish an 85 % model from a 65 % one.

[4] d=0.605  [Classification, Cross-Entropy… > How many test examples do I need?]
             Suppose you score a model on 20 examples and it gets 17 right. You
             report 85 %. …Wilson 95% CI : [0.6396, 0.9476]

Rank 115 till rank 3. Ingen rörde modellen, prompt, tröskeln eller antalet platser. Det här kapitlet handlar om det gapet, och om de fyra andra ställena där ett retrieval-system tyst ljuger för dig.

Visa detaljer

Vad det här kapitlet behöver från tidigare kapitel, och den enda plats där det ändrar språk.

  • Kapitel 1 definierade skalärprodukten och L2-normen. Tröskelavsnittet nedan är de två sakerna, och inget annat.
  • Kapitel 8 skilde en språkmodells embedding-tabell från en retrieval embedding model som tränats kontrastivt på par, mätte cosinuslikhet och slutade med ett löfte om att Kapitel 19 skulle landa i en konkret cut-off. Det löftet infrias här. Inget av det upprepas.
  • Kapitel 4 byggde Wilson-intervallet; Kapitel 15 byggde utvärderings-harness. Varje tabell nedan bär med sig det första och producerades av det andra.
  • Kapitel 16 prissatte context window. Den prompt som sätts ihop i slutet av det här kapitlet kostar 591 tokens, och det är budgeten fragmenten konkurrerar om.

Allt här är TypeScript, som sedan Kapitel 14, och det här kapitlet är där regeln förtjänar sig själv: ingestion är köer och lagring, search är ett nätverksanrop, och att sätta ihop en prompt med källhänvisningar är ett serverjobb. Mätningen är avsiktligt samma kod med en resultattavla runt sig — en retriever som poängsätts av en andra implementation är ett tal om programvara du inte skeppar, och cosinuströskeln nedan är bara trovärdig eftersom du ser den svepas av den chunker som kommer att köras i produktion.

Korpusen, och vad som räknas som ett rätt svar

Länk till avsnittet: Korpusen, och vad som räknas som ett rätt svar

Allt nedan mäts mot en korpus: de första tretton kapitlen i den här kursen — 13 dokument, 359 067 tecken, 127 sektioner, med front matter och bibliografier borttagna. Det är en riktig teknisk korpus, med prosa, tabeller, formler och code blocks i sig, och det är exakt den sorts sak folk laddar in i en knowledge base och sedan klagar på.

Grundsanningen är 32 frågor, var och en parad med en nål: en kort ordagrann mening från korpusen som besvarar den. Varje nål förekommer exakt en gång i de 359 067 tecknen, och ingen är en sektionsrubrik — den kontrollen spelar roll, eftersom en chunker som kopierar rubriker in i varje chunk annars skulle poängsätta sig själv. Varje fråga ställs två gånger, en gång på kursens engelska och en gång så som ett supportärende formulerar den: 64 frågor över 32 grundsanningar.

En retrieval är korrekt när en returnerad chunk innehåller hela nålen i sin helhet. Det är den enda definition som matchar vad generatorn behöver: en halv mening i prompt är inte ett svar, det är en risk.

Embedding model är all-MiniLM-L6-v2 — 384 dimensioner, mean-pooled och normaliserad, den kontrastivt tränade modell som Kapitel 8 mätte. Att indexera korpusen tar 20,8 sekunder på en CPU, 22 ms per chunk; att embedda en fråga tar 13 ms.

Sex strategier från tre oberoende ingredienser. Blind skär var 512:e tecken utan att titta på texten. Boundaries skär aldrig inne i ett stycke, och faller tillbaka till en meningsgräns endast när ett stycke är över budget. Header prefixar varje chunk med dess dokumenttitel och sektionssökväg. Overlap kopierar de sista 64 tecknen från föregående chunk in i nästa.

strategichunkssvar förstördaR@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

Med 64 frågor är 95 % Wilson-intervallet på R@20 [0.471, 0.705] för A och [0.718, 0.901] för E — de överlappar inte, men de flesta andra kolumner gör det, och en oparad tabell kan inte skilja dem åt. Varje strategi besvarar samma frågor, så det ärliga testet är parat: räkna varje strategis vinster och förluster mot en annan och kör ett teckentest på de avvikande paren. Tre resultat överlever det.

Blind chunking förstör fyra av de trettiotvå svaren rakt av. Inte rankar dem dåligt — förstör dem. Nålen korsar en gräns vid 512 tecken, så ingen chunk i indexet innehåller den, och recall-taket för de frågorna är noll. Ingen reranker räddar dem, ingen tröskel hjälper, ingen större modell hjälper. Du kan inte hämta text som inte finns i ett stycke någonstans i ditt index. Det här är det enskilt mest underrapporterade felet i RAG, eftersom det ser exakt ut som en dålig retriever.

Overlap fixar det och inget annat. Varje strategi med overlap förlorar noll svar, vilket är vad overlap är till för. Det förbättrar inte rankningen: B mot A vid R@8 är +8/−6, p = 0.79; vid R@20 är det +9/−7, p = 0.80. Än värre: att lägga overlap ovanpå header skadar aktivt — F mot E är +2/−6 vid R@20 — och skälet är mekaniskt. En chunks vektor är ett medelvärde över dess tokens, så 64 tecken från föregående chunk drar det medelvärdet mot grannens ämne. Overlap är en försäkring mot ett delat svar, betald i precision.

Den kontextuella headern är det som köper retrieval. E mot A är +18/−3 vid R@20, p = 0.0015. Och ablationen säger att boundaries inte är orsaken: E mot C — samma snitt, header är den enda skillnaden — är +12/−2, p = 0.0129. Att prefixa "Classification, Cross-Entropy, and How Not to Fool Yourself > How many test examples do I need?" till ett stycke talar om för embedding model vad stycket handlar om, vilket stycket självt ofta inte säger. Det är en pronomenlösare för dokument.

Det ger chunkern dess form, och en regel som är lätt att göra fel:

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

Två texter, inte en. text är det som blir embedded, header och allt. content är bara denna chunks egna ord, och det är det som citeras tillbaka till användaren. Citera text och källhänvisningen visar en header som inte finns i dokumentet vid den punkten — och, med overlap, en upprepad svans som tillhör föregående fragment. Då visar den text som inte är där den säger att den är, vilket är värre än att inte visa något.

Headern är inte gratis. Över de 940 chunks kostar den 24 213 av indexets 114 275 embedded tokens: 21,2 % av det du betalar för att embedda är en header du skrev själv. Den pressar också chunks mot encoderns window. all-MiniLM-L6-v2 accepterar 256 word-pieces; strategi E har 17 chunks över den linjen och F har 28, alla tyst trunkerade utan varning från något. Din effektiva chunk-storlek är inte talet i din config — det är det minsta av det och din encoders window.

Dense retrieval har en systematisk svaghet och den är inte subtil: den matchar betydelse, så den är likgiltig inför exakt vilken sträng du skrev. Ett artikelnummer, en felkod, en akronym, ett efternamn — inget av dem har en användbar betydelse att embedda, och närmaste granne till en felkod är varje annan felkod i din korpus.

Det klassiska svaret är äldre än allt detta och tar tjugo rader. BM25 poängsätter ett dokument efter hur ofta frågans termer förekommer i det, dämpar varje term när dess frekvens ökar och straffar långa dokument som samlar träffar bara genom sin längd.1 Termen tt bidrar med

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

där ft,df_{t,d} är termens antal i dokumentet, d|d| dess längd, d\overline{|d|} den genomsnittliga längden, och k1=1.2k_1 = 1.2 och b=0.75b = 0.75 de två konventionella konstanterna — k1k_1 anger hur snabbt repetition slutar hjälpa, bb hur hårt längd bestraffas.

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

Över 940 chunks poängsätter det en fråga på 1,14 ms utan något index alls utöver två hash maps. Och det är inte ett museiföremål:

retrieverR@1R@4R@8MRRkostnad per fråga
dense (cosine)0.0940.4220.5780.28013 ms för embedding + 0,3 ms för scan
lexical (BM25)0.2190.3750.4690.3131,14 ms
hybrid (RRF)0.2030.4840.6090.346båda
hybrid + cross-encoder0.3120.5780.7030.447+ 569 ms

BM25 mer än fördubblar den dense retrieverns top-1-noggrannhet på den här korpusen, och förlorar stort mot den vid rank 8. De misslyckas på olika frågor, vilket är hela argumentet för att köra båda.

Att slå ihop dem är det enda stället där det uppenbara angreppssättet är fel. Cosinusavstånd och BM25-poäng ligger inte på samma skala, är inte begränsade på samma sätt, och att normalisera dem per fråga gör att vikten beror på hur bra den bästa träffen råkade vara. Reciprocal rank fusion kastar bort poängen och behåller bara rankerna: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);
}

Och här spelar den ärliga läsningen av tabellen större roll än tabellen. Hybrid slår BM25 vid R@4 med +10/−3, p = 0.09. Det slår dense med +10/−6, p = 0.45. På den här korpusen, med 64 frågor, går hybrid retrieval inte att skilja från dense retrieval. Det är bättre i båda punktskattningarna och varje recall-kolumn, och evidensen når inte signifikans. Nästan varje blogginlägg om hybrid search på internet rapporterar en tabell som den ovan och inget intervall; det här är vad intervallet säger.

Bi-encoder, cross-encoder och var lyftet faktiskt finns

Länk till avsnittet: Bi-encoder, cross-encoder och var lyftet faktiskt finns

Allt hittills är en bi-encoder: frågan går genom modellen ensam, varje chunk gick genom den ensam för månader sedan, och de två möts aldrig utom som en skalärprodukt. Det är det som gör ett index möjligt — embedda en gång, återanvänd för alltid — och det är också taket. Modellen tittar aldrig på frågan och chunken tillsammans.

En cross-encoder gör exakt det: den tar paret som en enda input och returnerar en relevanspoäng. Inget kan förberäknas, så den kan inte ranka ett index — men den kan reranka en shortlist. Reranking av hybrid top 25 med ms-marco-MiniLM-L-6-v2 flyttar R@1 från 0.094 (dense) till 0.312 och MRR från 0.280 till 0.447: den största enskilda förbättringen i det här kapitlet, och den enda som rör toppen av listan i stället för svansen.

Det kostar 569 ms per fråga på en CPU, mot 1,14 ms för BM25 och 0,3 ms för vektorscanningen. Ungefär två tusen gånger retrieval-kostnaden, för tjugofem dokument. Det är hela bi-encoder/cross-encoder-affären i ett tal, och det är därför arkitekturen alltid har samma form: en billig retriever med bred recall, sedan en dyr poängsättare på en shortlist du har råd med. ColBERT sitter mellan de två, förberäknar per-token-vektorer och gör en sen interaktion som är billigare än en cross-encoder och skarpare än en skalärprodukt.3

L2, cosinus och en tröskel du inte har förtjänat

Länk till avsnittet: L2, cosinus och en tröskel du inte har förtjänat

Vektordatabaser rapporterar avstånd, och vilket avstånd är en config-fråga. På normaliserade vektorer är valet kosmetiskt, och identiteten är värd att göra en gång eftersom allt efter den beror på att vektorerna verkligen är enhetsvektorer. För 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

så cosinus-avståndet 1cosθ1 - \cos\theta är exakt d2/2d^2/2. Det är Kapitel 1:s skalärprodukt och norm, inlöst. Kontrollerat på två riktiga chunk-vektorer från indexet ovan, och sedan över 40 000 par:

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

Exakt ned till floating-point-brus — och bara därför att vektorerna är normaliserade. Hoppa över normaliseringen och identiteten är falsk, din tröskel betyder ingenting, och avståndet ett dokument rapporterar beror på hur lång dess text var.

Nu talet ingen härleder. En retriever returnerar alltid något: den sorterar hela indexet och räcker dig toppen av listan, oavsett om svaret finns någonstans i korpusen eller inte. Tröskeln är den enda delen av systemet som kan säga nej — och för att sätta en behöver du frågor som bör få tillbaka ingenting. Här är trettio: tjugoen om saker den här korpusen faktiskt inte täcker — streaming, rate limits, prompt caching, JSON-scheman, agent loops, vektordatabaser, prompt injection, bildgenerering — och nio om paella, pass och återbetalningspolicyer. Mot samma index:

top-1 cosinusavstånd
frågor inom domänen, alla 64medel 0.445, intervall 0.270 – 0.721
inom domänen, top-1 faktiskt korrektmedel 0.370
inom domänen, top-1 felmedel 0.452
utanför domänen, alla 30medel 0.699, intervall 0.497 – 0.867

Fördelningarna separerar, och de överlappar. Den sämsta frågan inom domänen ligger längre från sitt svar (0.721) än den bästa frågan utanför domänen ligger från ett irrelevant stycke (0.497), så ingen tröskel får båda rätt. Sveper vi den över den verkliga grinden — behåll högst fyra chunks, och bara de under snittet:

tröskelinom domänen besvaradevarav svaret fanns medutanför domänen besvarade
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
ingen64 / 642730 / 30

Läs sista kolumnen som bluffar. Utan tröskel producerar assistenten ett självsäkert, välciterat svar på "how do I renew my Spanish passport" från en korpus om backpropagation, trettio gånger av trettio. Vid 0.675 gör den det tio gånger av trettio. Vid 0.525 gör den det två gånger, och ger upp på tolv frågor den hade kunnat besvara.

Den avvägningen är ett produktbeslut, och rätt ände av den beror på vad ett fel svar kostar dig. Det som inte är förhandlingsbart är att sista kolumnen finns över huvud taget. Om du aldrig har mätt din retriever mot frågor den borde vägra, har du inte en tröskel — du har ett tal.

Två av de tio bluffarna vid 0.675 visar de två sätt detta misslyckas på.

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

Den första är en nära miss: korpusen förklarar KV cache i detalj, frågan handlar om prompt cache, orden är samma ord, och 0.497 är närmare än de flesta korrekta retrievals inom domänen i hela experimentet. En embedding vet inte att två cacher med samma namn är olika maskiner. Den andra är en bokstavlig match utan svar: korpusen innehåller den exakta frasen "what is the capital of France", använd som ett exempel på en fråga som inte kräver resonemang. Retrievern har rätt; svaret finns inte där. Alla system som läser "jag hittade något liknande" som "jag hittade svaret" kommer att hävda Paris på den evidensen — eller, värre, låta bli.

Varför källhänvisningen inte skrivs av modellen

Länk till avsnittet: Varför källhänvisningen inte skrivs av modellen

En modell har ingen separat fakultet för fakta. Att producera en sann mening och att producera en plausibel är samma operation — Kapitel 8:s next-token prediction — och inget i den operationen markerar vilken som är vilken. Analysen från 2025 som omramade detta hävdar att tränings- och utvärderingspipeline aktivt belönar gissningar: benchmarks poängsätter med binär accuracy och ger ingen credit för att avstå, så en modell som alltid svarar slår en identisk modell som säger "jag vet inte" när den inte vet, och post-training optimerar därefter.6 Hallucination i den läsningen är ingen mystisk defekt. Det är vad du får när du rättar ett flervalsprov utan straff för fel svar.

Titta på formen. Ombedd att ge åtta papers om contrastive sentence embeddings, med identifierare, producerade Qwen2.5-0.5B-Instruct åtta rader i perfekt format. Alla åtta identifierare är välformade. Alla åtta leder till riktiga papers på arXiv. Noll av de åtta är det paper som påstås.

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

Det här är en liten modell och frekvensen är dess egen; en frontier model hittar på mycket färre. Mekanismen generaliserar, och den är skälet till regeln som följer. En validator som kontrollerar "finns den här identifieraren" godkänner alla åtta, och en användare som klickar på en landar på en riktig sida från ett riktigt arkiv utan något sätt att se att kopplingen var påhittad. Felet ligger inte i identifieraren eller formatet. Det ligger i associationen — exakt det en språkmodell producerar genom plausibilitet.

Alltså: modellen skriver [1] och [2], och skriver aldrig länken. Siffrorna hänvisar till fragment som servern hämtade, och servern — som vet exakt vilket dokument och vilka offsets varje nummer kom från — fäster dokumentet, etiketten och URL:en efteråt. Det finns inget för modellen att hitta på eftersom den aldrig ombeds om den enda sak den skulle hitta på.

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

Kör det på öppningsfrågan och de fyra chunks blir en 591-token prompt och en tabell modellen aldrig ser:

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

Locatorn är delen folk hoppar över och sedan inte kan lägga till senare. #char=25873,26272 är ett intervall i dokumentets kanoniska text; för en PDF är motsvarigheten #page=12, för ljud eller video #t=132.4,158.9, för ett kalkylblad ett blad och ett A1-intervall. De två är inte påhitt — #page= är PDF Open Parameters och #t= är W3C Media Fragments, som stöds nativt av webbläsare på video- och ljudelement. En källhänvisning utan en locator är ett dokumentnamn, och ett dokumentnamn är inte en källhänvisning; det är ett förslag om att användaren ska gå och leta.

Och när ingenting passerar tröskeln når pipelinen aldrig modellen alls:

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"

Det är en billigare och mer tillförlitlig vägran än någon instruktion i en system prompt, eftersom det är en jämförelse mellan två tal snarare än en begäran till ett probabilistiskt system.

Utvärdera retrievern separat från generatorn

Länk till avsnittet: Utvärdera retrievern separat från generatorn

Varje mätning i det här kapitlet poängsätter retrievern och ber inte en enda gång en modell skriva ett svar. Det är avsiktligt, och det är den del de flesta team hoppar över.

Ett RAG-system har två felmoder som ser identiska ut utifrån. Retrievern hittade inte passagen; eller så hittade den den och generatorn ignorerade den, motsade den eller blandade den med något den redan trodde. Poängsätt bara det slutliga svaret och de två går inte att skilja åt, så du justerar prompts mot ett problem som bor i din chunker. Recall@k, MRR och antalet förstörda svar behöver inget generation-anrop alls, de är billiga nog att köra vid varje deploy, och de är harness från Kapitel 15 med en annan scoring-funktion — samma request, deadline, concurrency och tally, över en fast frågeuppsättning i stället för en livekonversation.

Rapportera dem med intervall. Kapitel 4:s aritmetik gäller oförändrat: vid 64 frågor har en recall på 0,5 ett 95 % Wilson-intervall på ungefär ±0,12, så en strategi som ligger fyra punkter före en annan har inte sagt dig någonting. Använd det parade testet när båda strategierna besvarar samma frågor, vilket de alltid gör här — det var det som gjorde "E ser bättre ut än A" till p = 0.0015.

Och den sista ärligheten: RAG minskar hallucination och tar inte bort den. Att lägga rätt passage i prompt tvingar inte modellen att använda den, och litteraturen har sagt det sedan originalartikeln.7 Två saker gör det värre i produktion. Långa kontexter degraderar — en modell hittar information i början och slutet av en lång prompt mer tillförlitligt än i mitten, så tjugo chunks i stället för fyra kan sänka accuracy samtidigt som fakturan stiger, en effekt som mäts i Kapitel 24. Och retrieval kan ha rätt och ändå vara otillräcklig, som de två cacherna ovan visade. SelfCheckGPT flaggar påståenden som inte överlever resampling;8 Self-RAG tränar modellen att emittera sina egna retrieve-and-critique tokens;9 TruthfulQA gjorde felmoden begriplig från första början.10 Ingen stänger gapet, och ett system som presenterar retrieved text som bevis har blandat ihop källbelagt med sant.

Halvan av systemet som körs före varje fråga

Länk till avsnittet: Halvan av systemet som körs före varje fråga

En retriever är den synliga delen av en pipeline vars fel alla händer tidigare, i mörkret. Tre av dem återkommer.

Extraction är där innehållet dör. En PDF är inte text; den är ritinstruktioner. Tvåspaltiga layouter flätas samman, tabeller blir ordsoppa, sidhuvuden upprepas in i varje chunk, och en skannad sida har ingen text alls förrän OCR ger den någon, med en confidence. Allt som mättes ovan antog att extractorn gjorde sitt jobb; i produktion gör den ofta inte det, och symptomet dyker upp som dålig retrieval tre lager bort.

Indexet stämplas med modellen som byggde det. Embeddings från två modeller är inte jämförbara — inte "mindre accurate", inte jämförbara, eftersom de är punkter i olika rum. Byt embedding model och varje vektor i lagret är skräp tills den byggs om. Så modellnamnet, dimensionsantalet, pipeline-versionen och extractor-versionen skrivs bredvid varje dokument vid indexering. Utan dem kan du på uppgraderingsdagen inte se vilka dokument som är stale och vilka som är aktuella, och ett halvmigrerat index returnerar självsäkert nonsens utan fel någonstans.

Ett trasigt dokument får inte knäcka mappen, och räknarna måste räkna vad som hände. Ett dokument som misslyckas med extraction hamnar i ett failed-tillstånd med sin orsak, synligt och retryable, medan de andra nittionio förblir searchable; och antalet indexerade chunks skrivs av servern när den är klar, inte deklareras av klienten när den laddar upp. En mapp som rapporterar 400 fragment och innehåller 40 är en lögn som bara dyker upp som en obesvarbar fråga.

Systemet i det här kapitlet besvarar frågor vars svar är nedskrivna. Det hämtar dem, rankar dem, vägrar när det inte kan, och citerar var det tittade. Det är det mesta folk vill ha av en assistant över sina egna dokument, och det är avgränsat på ett specifikt sätt: retrieval kan bara returnera det någon skrev.

Vilket lämnar den andra halvan. En del av det du vill att en modell ska göra är inte ett faktum i ett dokument alls — ett format den måste hålla, en ton, en taxonomi med fyrahundra etiketter, ett sätt att fatta beslut som bor i tiotusen tidigare exempel och inte i något stycke någonstans. Retrieval kan inte leverera sådant, eftersom det inte finns något att hämta; en längre prompt betalar bara Kapitel 16:s faktura för en beskrivning av en skill i stället för skill.

Kapitel 20 är det beslutet — fine-tune, retrieve eller prompt — och dess fynd är att beslutet är ekonomiskt innan det är tekniskt: de tre prissätts från ände till ände på samma fråga, och brytpunkten är ett token-antal. Frågan som öppnar det är den som det här kapitlet inte kan besvara. Inte var är svaret skrivet, utan vad gör du när det aldrig var det.


Allt som mättes i det här kapitlet använde en korpus och ett instrument, och båda är reproducerbara. Korpusen är kapitel 1 till 13 i den här kursen som de såg ut den 7 september 2026 — 13 dokument, 359 067 tecken, 127 sektioner, front matter och bibliografier borttagna. De kapitlen fortsätter att redigeras, så att tillämpa samma regel i dag ger några tusen tecken till: sektionsantalet är oförändrat och det är även varje slutsats nedan, men teckenantalet är en ögonblicksbild och märks som en sådan. Grundsanningen är 32 frågor, var och en parad med en ordagrann mening som förekommer exakt en gång i korpusen och aldrig är en sektionsrubrik, ställd i två formuleringar för 64 frågor. Retrieval embeddings är sentence-transformers/all-MiniLM-L6-v2 (384 dimensioner, mean-pooled, L2-normaliserad, 256-token window); reranking är cross-encoder/ms-marco-MiniLM-L-6-v2 över top 25; generationsexemplet är Qwen/Qwen2.5-0.5B-Instruct med greedy decoding. Alla tider är enkeltrådad CPU. Ingen betald API anropades för att producera det här kapitlet, vilket också är varför varje latency här är lokal och märkt som sådan.

Chunkern som visas i TypeScript är chunkern som mättes: Python-instrumentet som implementerar samma regel och ts/chunk.ts jämfördes chunk för chunk över hela korpusen och är överens om alla 940 chunks, texter och offsets. Intervall är Wilson vid 95 %; parade jämförelser är tvåsidiga exakta teckentester på de avvikande paren.

Alla fjorton identifierare som citeras ovan kontrollerades mot arXiv API och granskades titel för titel den 7 september 2026 — vilket, med tanke på de åtta som inte var det, kändes som det minsta just det här kapitlet kunde göra.

  1. Robertson, S. and Zaragoza, H. The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval 3(4), pp. 333–389 (2009). Källan till mättnadsfunktionen och till de två konstanter som används ovan, och platsen att läsa varför bb över huvud taget finns.

  2. Cormack, G. V., Clarke, C. L. A. and Büttcher, S. Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods. SIGIR 2009. k=60k = 60 är deras, och poängen med metoden är att den inte behöver någon kalibrering mellan de poängskalor den slår ihop.

  3. Khattab, O. and Zaharia, M. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT. arXiv:2004.12832 (2020). Mellanläget mellan en skalärprodukt och en cross-encoder. Reimers, N. and Gurevych, I., Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks, arXiv:1908.10084 (2019), är den bi-encoder som kapitlets index bygger på och som mättes i Kapitel 8.

  4. Malkov, Yu. A. and Yashunin, D. A. Efficient and Robust Approximate Nearest Neighbor Search using Hierarchical Navigable Small World Graphs. arXiv:1603.09320 (2016). Grafindexet bakom de flesta vektordatabaser som säljs i dag.

  5. Johnson, J., Douze, M. and Jégou, H. Billion-scale Similarity Search with GPUs. arXiv:1702.08734 (2017). FAISS, och referensimplementationen av IVF som mättes i rutan ovan.

  6. Kalai, A. T., Nachum, O., Vempala, S. S. and Zhang, E. Why Language Models Hallucinate. arXiv:2509.04664 (2025). Argumentet att hallucination produceras av binär-accuracy-bedömning som aldrig belönar avstående, och därför är ett utvärderingsproblem innan det är ett modelleringsproblem.

  7. Lewis, P., Perez, E., Piktus, A., Petroni, F., Karpukhin, V., Goyal, N., Küttler, H., Lewis, M., Yih, W., Rocktäschel, T., Riedel, S. and Kiela, D. Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks. arXiv:2005.11401 (2020). Artikeln som namngav mönstret och den du ska läsa för vad det gör och inte gör. Guu et al., REALM: Retrieval-Augmented Language Model Pre-Training, arXiv:2002.08909 (2020), är det samtida arbetet som tränar retrievern tillsammans med modellen i stället för att bulta på den; Karpukhin et al., Dense Passage Retrieval for Open-Domain Question Answering, arXiv:2004.04906 (2020), är därifrån den två-encoder dense retriever som används genom hela kapitlet kommer; och Izacard and Grave, Leveraging Passage Retrieval with Generative Models for Open Domain Question Answering, arXiv:2007.01282 (2020), är fusion-in-decoder-upplägget för att mata många passager till en generator. Gao et al., Retrieval-Augmented Generation for Large Language Models: A Survey, arXiv:2312.10997 (2023), är kartan över allt som kom efter, inklusive HyDE (Gao et al., Precise Zero-Shot Dense Retrieval without Relevance Labels, arXiv:2212.10496, 2022), som embeddar ett hypotetiskt svar snarare än frågan.

  8. Manakul, P., Liusie, A. and Gales, M. J. F. SelfCheckGPT: Zero-Resource Black-Box Hallucination Detection for Generative Large Language Models. arXiv:2303.08896 (2023). Detection genom resampling, utan åtkomst till modellens interna delar och utan extern knowledge base.

  9. Asai, A., Wu, Z., Wang, Y., Sil, A. and Hajishirzi, H. Self-RAG: Learning to Retrieve, Generate, and Critique through Self-Reflection. arXiv:2310.11511 (2023). Att träna modellen att avgöra när den ska retrieve, i stället för att retrieve på varje tur.

  10. Lin, S., Hilton, J. and Evans, O. TruthfulQA: Measuring How Models Mimic Human Falsehoods. arXiv:2109.07958 (2021). Benchmarken byggd av frågor där det plausibla svaret och det sanna svaret skiljer sig, vilket är hela svårigheten i en mening.


Skapad av

David Vicente Campos

Grundare av NeuraLIA Labs och medgrundare av MyRealFood

Jag är dataingenjör från Universitetet i León. Jag var med och grundade MyRealFood, där jag som CTO byggde appen som miljontals människor har använt för att äta bättre, och jag grundade NeuraLIA Labs, där jag bygger AI-produkter. Här skriver jag om det jag har behövt förstå längs vägen, så som jag önskar att någon hade förklarat det för mig.

Mer om författaren

Publicerad av NeuraLIA Labs.

Få nya inlägg i din inkorg

AI-nyheter, guider och produktuppdateringar — ett kort mejl när vi publicerar något som är värt din tid.

Kursindex

Abstract software decision engine with branching paths, probability nodes, and glowing gates.
jevLästid 11 min

Jevs AI-modell är byggd för beslut, inte prosa

TypeSafe AI:s Jev väcker uppmärksamhet eftersom den behandlar mjukvaruintelligens som ett sannolikhetsproblem: välj rätt gren, lägg till konfidens och undvik att betala en LLM för att skriva text när koden behöver ett beslut.

Abstract agent runtime sorting documents, memory blocks and pointer nodes inside a bounded context frame.
context-engineeringLästid 11 min

Kontextteknik för AI-agenter med lång horisont

Långkörande agenter misslyckas inte bara för att fönstret är litet. De misslyckas när filer, verktygsutdata och gammal historik tränger undan uppgiften agenten skulle slutföra.

Redo att låta LIA välja åt dig?

Bygg med alla AI-modeller på ett ställe – kom igång gratis i dag.