Sari la conținut
19/30Capitolul 19 din 30

RAG în producție: chunking, retrieval și citări oneste

Tăiat orbește la 512 caractere, patru din 32 de răspunsuri mor înainte de retrieval. Repari doar chunkerul: rank 115 devine 3.

Pe această pagină

Iată o întrebare reală de la un utilizator real al unui asistent real: setul meu de evaluare are 20 de itemi, e suficient ca să am încredere în scor. Corpusul conține răspunsul — o secțiune întreagă din el. Iată cele patru fragmente pe care retrieverul le-a pus efectiv în 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…

Trei dintre cele patru încep în mijlocul unui cuvânt. Două sunt dintr-un alt capitol, despre un alt subiect. Iar fragmentul care răspunde la întrebare — cel care conține Seventeen out of twenty cannot distinguish an 85 % model from a 65 % one — a revenit pe rank 115.

Acum aceeași întrebare, același embedding model, același template de prompt. Un singur lucru s-a schimbat: felul în care au fost tăiate documentele.

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]

De la rank 115 la rank 3. Nimeni nu a atins modelul, prompt, pragul sau numărul de sloturi. Acest capitol este despre acel gol și despre celelalte patru locuri în care un sistem de retrieval te minte în liniște.

Afișează detaliile

De ce are nevoie acest capitol din capitolele anterioare și singurul loc în care schimbă limbajul.

  • Capitolul 1 a definit produsul scalar și norma L2. Secțiunea despre prag de mai jos este alcătuită din acestea două și nimic altceva.
  • Capitolul 8 a separat tabela de embedding a unui model lingvistic de un retrieval embedding model antrenat contrastiv pe perechi, a măsurat similaritatea cosinus și a încheiat promițând că în Capitolul 19 vom ajunge la un prag concret. Promisiunea ajunge aici la scadență. Nimic din asta nu este repetat.
  • Capitolul 4 a construit intervalul Wilson; Capitolul 15 a construit harnessul de evaluare. Fiecare tabel de mai jos îl poartă pe primul și a fost produs de al doilea.
  • Capitolul 16 a pus preț pe context window. Prompt asamblat la finalul acestui capitol costă 591 tokens, iar acesta este bugetul pentru care concurează fragmentele.

Totul aici este TypeScript, ca din Capitolul 14 încoace, iar acesta este capitolul în care regula își câștigă rostul: ingestia înseamnă cozi și stocare, căutarea este un apel de rețea, iar asamblarea unui prompt cu citări este treaba serverului. Măsurarea este intenționat același cod cu un tablou de scor în jurul lui — un retriever evaluat de o a doua implementare este un număr despre software pe care nu îl livrezi, iar pragul cosinus de mai jos este credibil doar pentru că îl vezi parcurs de chunkerul care va rula în producție.

Tot ce urmează este măsurat pe un singur corpus: primele treisprezece capitole ale acestui curs — 13 documente, 359.067 de caractere, 127 de secțiuni, cu front matterul și bibliografiile eliminate. Este un corpus tehnic real, cu proză, tabele, formule și blocuri de cod în el, și este exact genul de lucru pe care oamenii îl încarcă într-o bază de cunoștințe și apoi se plâng de el.

Ground truth-ul este format din 32 de întrebări, fiecare asociată cu un ac: o propoziție scurtă, verbatim, din corpus, care îi răspunde. Fiecare ac apare exact o dată în cele 359.067 de caractere și niciunul nu este titlu de secțiune — verificarea contează, fiindcă un chunker care copiază titlurile în fiecare fragment și-ar umfla altfel scorul. Fiecare întrebare este pusă de două ori, o dată în engleza cursului și o dată așa cum ar formula-o un tichet de suport: 64 de queries peste 32 de ground truths.

Un retrieval este corect când un fragment returnat conține acul întreg. Aceasta este singura definiție care se potrivește cu ce are nevoie generatorul: jumătate de propoziție în prompt nu este un răspuns, este un pericol.

Embedding model este all-MiniLM-L6-v2 — 384 de dimensiuni, mean-pooled și normalizat, modelul antrenat contrastiv pe care l-a măsurat Capitolul 8. Indexarea corpusului durează 20,8 secunde pe CPU, 22 ms per fragment; embedding pentru o query durează 13 ms.

Șase strategii din trei ingrediente independente. Blind taie la fiecare 512 caractere fără să se uite la text. Boundaries nu taie niciodată în interiorul unui paragraf, revenind la o limită de propoziție doar când un paragraf depășește bugetul. Header prefixează fiecare fragment cu titlul documentului și calea secțiunii. Overlap copiază ultimele 64 de caractere ale fragmentului anterior în următorul.

strategiefragmenterăspunsuri distruseR@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

Cu 64 de queries, intervalul Wilson de 95 % pentru R@20 este [0.471, 0.705] pentru A și [0.718, 0.901] pentru E — acestea nu se suprapun, dar majoritatea celorlalte coloane se suprapun, iar un tabel neîmperecheat nu le poate separa. Fiecare strategie răspunde la aceleași queries, deci testul onest este împerecheat: numeri câștigurile și pierderile fiecărei strategii față de alta și rulezi un test al semnului pe perechile discordante. Trei rezultate îi supraviețuiesc.

Blind chunking distruge pur și simplu patru dintre cele treizeci și două de răspunsuri. Nu le clasează prost — le distruge. Acul traversează o limită de 512 de caractere, așa că niciun fragment din index nu îl conține, iar plafonul de recall pentru acele queries este zero. Niciun reranker nu le recuperează, niciun prag nu ajută, niciun model mai mare nu ajută. Nu poți recupera text care nu există într-o singură bucată nicăieri în index. Acesta este cel mai subraportat eșec din RAG, fiindcă arată exact ca un retriever prost.

Overlap repară asta și nimic altceva. Fiecare strategie cu overlap pierde zero răspunsuri, fiindcă pentru asta există overlap. Nu îmbunătățește rankingul: B față de A la R@8 este +8/−6, p = 0.79; la R@20 este +9/−7, p = 0.80. Mai rău, adăugarea de overlap peste header chiar dăunează — F față de E este +2/−6 la R@20 — iar motivul este mecanic. Vectorul unui fragment este o medie peste tokens săi, așa că 64 de caractere din fragmentul anterior trag acea medie spre subiectul vecinului. Overlap este asigurare împotriva unui răspuns tăiat, plătită în precizie.

Headerul contextual este ceea ce cumpără retrieval. E față de A este +18/−3 la R@20, p = 0.0015. Iar ablația spune că nu boundaries fac asta: E față de C — aceleași tăieturi, headerul fiind singura diferență — este +12/−2, p = 0.0129. Prefixarea unui paragraf cu „Classification, Cross-Entropy, and How Not to Fool Yourself > How many test examples do I need?” îi spune embedding model despre ce este paragraful, lucru pe care paragraful însuși adesea nu îl spune. Este un resolver de pronume pentru documente.

Ceea ce îi dă chunkerului forma și o regulă ușor de greșit:

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

Două texte, nu unul. text este ceea ce primește embedding, cu header cu tot. content este doar textul propriu al acestui fragment și este ceea ce se citează înapoi utilizatorului. Citează text și citarea arată un header care nu se află în document în acel punct — și, cu overlap, o coadă repetată care aparține fragmentului anterior. Afișează atunci text care nu este acolo unde spune că este, ceea ce este mai rău decât să nu arate nimic.

Headerul nu este gratuit. Peste cele 940 de fragmente, el costă 24.213 din cei 114.275 de tokens embedding ai indexului: 21,2 % din ce plătești ca să faci embedding este un header pe care l-ai scris singur. Împinge și fragmentele spre window-ul encoderului. all-MiniLM-L6-v2 acceptă 256 de word-pieces; strategia E are 17 fragmente peste acea linie, iar F are 28, fiecare trunchiat tăcut, fără niciun avertisment de nicăieri. Dimensiunea efectivă a fragmentului nu este numărul din configurarea ta — este minimul dintre acela și window-ul encoderului tău.

Douăzeci de linii de BM25, peste care sare toată lumea

Link către secțiunea: Douăzeci de linii de BM25, peste care sare toată lumea

Dense retrieval are o slăbiciune sistematică, și nu este subtilă: potrivește sensul, așa că este indiferent la exact șirul pe care l-ai tastat. Un cod de piesă, un cod de eroare, un acronim, un nume de familie — niciunul nu are un sens util de embeduit, iar cel mai apropiat vecin al unui cod de eroare este orice alt cod de eroare din corpusul tău.

Răspunsul clasic este mai vechi decât toate acestea și încape în douăzeci de linii. BM25 punctează un document după cât de des apar termenii query în el, amortizând fiecare termen pe măsură ce frecvența lui crește și penalizând documentele lungi care acumulează potriviri prin simpla lungime.1 Termenul tt contribuie

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

unde ft,df_{t,d} este numărul aparițiilor termenului în document, d|d| lungimea lui, d\overline{|d|} lungimea medie, iar k1=1.2k_1 = 1.2 și b=0.75b = 0.75 sunt cele două constante convenționale — k1k_1 setând cât de repede nu mai ajută repetiția, bb cât de dur este pedepsită lungimea.

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

Peste 940 de fragmente, asta punctează o query în 1,14 ms fără niciun index dincolo de două hash mapuri. Și nu este piesă de muzeu:

retrieverR@1R@4R@8MRRcost per query
dense (cosinus)0.0940.4220.5780.28013 ms pentru embedding + 0.3 ms pentru scanare
lexical (BM25)0.2190.3750.4690.3131.14 ms
hybrid (RRF)0.2030.4840.6090.346ambele
hybrid + cross-encoder0.3120.5780.7030.447+ 569 ms

BM25 mai mult decât dublează acuratețea top-1 a retrieverului dense pe acest corpus și pierde serios în fața lui până la rank 8. Eșuează pe queries diferite, iar acesta este întregul argument pentru a le rula pe amândouă.

Fuziunea lor este singurul loc în care abordarea evidentă este greșită. Distanțele cosinus și scorurile BM25 nu sunt pe aceeași scară, nu sunt limitate la fel, iar normalizarea lor per query face ca ponderea să depindă de cât de bun s-a întâmplat să fie cel mai bun rezultat. Reciprocal rank fusion aruncă scorurile și păstrează doar rankurile: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);
}

Iar aici citirea onestă a tabelului contează mai mult decât tabelul. Hybrid bate BM25 la R@4 cu +10/−3, p = 0.09. Bate dense cu +10/−6, p = 0.45. Pe acest corpus, cu 64 de queries, hybrid retrieval nu se distinge de dense retrieval. Este mai bun pe estimările punctuale și pe fiecare coloană de recall, iar dovezile nu ajung la semnificație. Aproape fiecare articol de blog despre hybrid search de pe internet raportează un tabel ca cel de mai sus și niciun interval; asta spune intervalul.

Bi-encoder, cross-encoder și unde este de fapt saltul

Link către secțiunea: Bi-encoder, cross-encoder și unde este de fapt saltul

Tot ce am discutat până acum este un bi-encoder: query trece singură prin model, fiecare fragment a trecut singur prin el cu luni în urmă, iar cele două nu se întâlnesc niciodată decât ca produs scalar. Asta face posibil un index — faci embedding o dată, refolosești mereu — și tot asta este plafonul. Modelul nu privește niciodată query și fragmentul împreună.

Un cross-encoder face exact asta: ia perechea ca o singură intrare și returnează un scor de relevanță. Nimic nu poate fi precalculat, deci nu poate ranka un index — dar poate face reranking pe o listă scurtă. Reranking pentru top 25 hybrid cu ms-marco-MiniLM-L-6-v2 mută R@1 de la 0.094 (dense) la 0.312 și MRR de la 0.280 la 0.447: cea mai mare îmbunătățire singulară din acest capitol și singura care atinge vârful listei, nu coada.

Costă 569 ms per query pe CPU, față de 1,14 ms pentru BM25 și 0,3 ms pentru scanarea vectorială. Aproximativ de două mii de ori costul de retrieval, pentru douăzeci și cinci de documente. Acesta este întregul compromis bi-encoder/cross-encoder într-un singur număr și motivul pentru care arhitectura are mereu aceeași formă: un retriever ieftin cu recall larg, apoi un scorer scump pe o listă scurtă pe care ți-o permiți. ColBERT stă între cele două, precalculând vectori per-token și făcând o interacțiune târzie mai ieftină decât un cross-encoder și mai ascuțită decât un produs scalar.3

L2, cosinus și un prag pe care nu l-ai câștigat

Link către secțiunea: L2, cosinus și un prag pe care nu l-ai câștigat

Bazele de date vectoriale raportează distanțe, iar distanța folosită este o opțiune de configurare. Pe vectori normalizați, alegerea este cosmetică, iar identitatea merită făcută o dată fiindcă tot ce urmează depinde de faptul că vectorii sunt cu adevărat unitari. Pentru 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

deci distanța cosinus 1cosθ1 - \cos\theta este exact d2/2d^2/2. Acesta este produsul scalar și norma din Capitolul 1, încasați. Verificat pe doi vectori reali de fragment din indexul de mai sus, apoi peste 40.000 de perechi:

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 până la zgomotul floating-point — și doar pentru că vectorii sunt normalizați. Sari peste normalizare și identitatea este falsă, pragul tău nu înseamnă nimic, iar distanța raportată de un document depinde de cât de lung a fost textul lui.

Acum numărul pe care nu îl derivează nimeni. Un retriever returnează mereu ceva: sortează întregul index și îți dă vârful listei, indiferent dacă răspunsul este sau nu undeva în corpus. Pragul este singura parte a sistemului care poate spune nu — iar ca să setezi unul ai nevoie de queries care ar trebui să nu primească nimic înapoi. Iată treizeci: douăzeci și una despre lucruri pe care acest corpus chiar nu le acoperă — streaming, limite de rată, prompt caching, scheme JSON, bucle de agent, baze de date vectoriale, prompt injection, generare de imagini — și nouă despre paella, pașapoarte și politici de rambursare. Pe același index:

distanță cosinus top-1
queries in-domain, toate 64medie 0.445, interval 0.270 – 0.721
in-domain, top-1 efectiv corectmedie 0.370
in-domain, top-1 greșitmedie 0.452
out-of-domain, toate 30medie 0.699, interval 0.497 – 0.867

Distribuțiile se separă și se suprapun. Cea mai proastă query in-domain este mai departe de răspunsul ei (0.721) decât cea mai bună query out-of-domain de un paragraf irelevant (0.497), deci niciun prag nu le rezolvă pe amândouă. Parcurgând pragul peste poarta reală — păstrează cel mult patru fragmente și doar pe cele sub tăietură:

pragin-domain răspunsedintre care răspunsul era inclusout-of-domain răspunse
0.40017 / 6460 / 30
0.45038 / 64130 / 30
0.50050 / 64191 / 30
0.52552 / 64202 / 30
0.55055 / 64213 / 30
0.60060 / 64255 / 30
0.67562 / 642710 / 30
0.80064 / 642726 / 30
none64 / 642730 / 30

Citește ultima coloană ca blufuri. Fără prag, asistentul produce un răspuns încrezător și bine citat la „cum îmi reînnoiesc pașaportul spaniol” dintr-un corpus despre backpropagation, de treizeci de ori din treizeci. La 0.675 o face de zece ori din treizeci. La 0.525 o face de două ori și renunță la douăsprezece întrebări la care ar fi putut răspunde.

Acel compromis este o decizie de produs, iar capătul corect depinde de cât te costă un răspuns greșit. Ce nu este negociabil este existența ultimei coloane. Dacă nu ți-ai măsurat niciodată retrieverul pe întrebări pe care ar trebui să le refuze, nu ai un prag — ai un număr.

Două dintre cele zece blufuri la 0.675 arată cele două feluri în care eșuează asta.

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

Primul este un near miss: corpusul explică KV cache în detaliu, query este despre prompt cache, cuvintele sunt aceleași cuvinte, iar 0.497 este mai aproape decât majoritatea retrievalurilor in-domain corecte din întregul experiment. Un embedding nu știe că două cache-uri cu același nume sunt mecanisme diferite. Al doilea este o potrivire literală fără răspuns: corpusul conține exact fraza „what is the capital of France”, folosită ca exemplu de întrebare care nu cere raționament. Retrieverul are dreptate; răspunsul nu este acolo. Orice sistem care citește „am găsit ceva similar” ca „am găsit răspunsul” va afirma Paris pe baza acelei dovezi — sau, mai rău, nu o va face.

Un model nu are o facultate separată pentru fapte. Producerea unei propoziții adevărate și producerea uneia plauzibile sunt aceeași operație — predicția next-token din Capitolul 8 — și nimic din acea operație nu marchează care este care. Analiza din 2025 care a reîncadrat asta susține că pipeline-ul de antrenare și evaluare recompensează activ ghicitul: benchmarkurile punctează cu acuratețe binară și nu acordă credit pentru abținere, deci un model care răspunde mereu depășește un model identic care spune „nu știu” când nu știe, iar post-training optimizează în consecință.6 Halucinația, citită astfel, nu este un defect misterios. Este ceea ce primești când notezi un examen grilă fără penalizare pentru răspuns greșit.

Privește forma ei. Rugat să dea opt lucrări despre embeddinguri contrastive de propoziții, cu identificatori, Qwen2.5-0.5B-Instruct a produs opt linii în format perfect. Toți cei opt identificatori sunt bine formați. Toți cei opt duc la lucrări reale pe arXiv. Zero din opt sunt lucrarea pretinsă.

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

Acesta este un model mic, iar rata îi aparține; un model frontier inventează mult mai puține. Mecanismul se generalizează, iar acesta este motivul regulii care urmează. Un validator care verifică „există acest identificator” le acceptă pe toate opt, iar un utilizator care dă clic pe unul ajunge pe o pagină reală dintr-o arhivă reală, fără nicio cale să vadă că maparea a fost inventată. Eșecul nu este în identificator sau în format. Este în asociere — exact lucrul pe care un model lingvistic îl produce prin plauzibilitate.

Deci: modelul scrie [1] și [2] și nu scrie niciodată linkul. Numerele se referă la fragmentele recuperate de server, iar serverul — care știe exact din ce document și din ce offseturi a venit fiecare număr — atașează apoi documentul, eticheta și URL. Nu există nimic de inventat pentru model, pentru că nu i se cere niciodată singurul lucru pe care l-ar inventa.

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

Rulează asta pe întrebarea de deschidere, iar cele patru fragmente devin un prompt de 591 tokens și un tabel pe care modelul nu îl vede niciodată:

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

Locatorul este partea peste care oamenii sar și apoi nu o mai pot adăuga. #char=25873,26272 este un interval în textul canonic al documentului; pentru un PDF echivalentul este #page=12, pentru audio sau video #t=132.4,158.9, pentru un spreadsheet o foaie și un interval A1. Acestea două nu sunt invenții — #page= este PDF Open Parameters și #t= este W3C Media Fragments, respectate nativ de browsere pe elemente video și audio. O citare fără locator este un nume de document, iar un nume de document nu este o citare; este o sugestie ca utilizatorul să meargă să caute.

Iar când nimic nu trece pragul, pipeline-ul nu ajunge deloc la model:

TEXT
NO ANSWER: nothing under cosine distance 0.675 for "what is the offside rule in football"
NO ANSWER: nothing under cosine distance 0.675 for "how do i renew my spanish passport"
NO ANSWER: nothing under cosine distance 0.675 for "how do i build an agent loop with tools"

Aceasta este o refuzare mai ieftină și mai fiabilă decât orice instrucțiune dintr-un system prompt, fiindcă este o comparație între două numere, nu o cerere către un sistem probabilistic.

Fiecare măsurătoare din acest capitol punctează retrieverul și nu cere niciodată unui model să scrie un răspuns. Este intenționat, iar aceasta este piesa peste care sar cele mai multe echipe.

Un sistem RAG are două moduri de eșec care arată identic din exterior. Retrieverul nu a găsit pasajul; sau l-a găsit, iar generatorul l-a ignorat, l-a contrazis ori l-a amestecat cu ceva ce credea deja. Punctează doar răspunsul final și cele două devin imposibil de distins, așa că tunezi prompts pentru o problemă care trăiește în chunkerul tău. Recall@k, MRR și numărul de răspunsuri distruse nu au nevoie de niciun apel de generare, sunt destul de ieftine ca să ruleze la fiecare deploy și sunt harnessul din Capitolul 15 cu o funcție de scor diferită — aceeași cerere, același deadline, aceeași concurență și aceeași numărătoare, peste un set fix de întrebări în locul unei conversații live.

Raportează-le cu intervale. Aritmetica din Capitolul 4 se aplică neschimbată: la 64 de queries, un recall de 0,5 are un interval Wilson de 95 % de aproximativ ±0,12, deci o strategie cu patru puncte înaintea alteia nu ți-a spus nimic. Folosește testul împerecheat ori de câte ori ambele strategii răspund la aceleași întrebări, ceea ce fac mereu aici — el a transformat „E pare mai bun decât A” în p = 0.0015.

Și ultima onestitate: RAG reduce halucinația și nu o elimină. Faptul că pui pasajul corect în prompt nu obligă modelul să îl folosească, iar literatura spune asta încă de la lucrarea originală.7 Două lucruri o înrăutățesc în producție. Contextele lungi degradează — un model găsește informația de la începutul și de la finalul unui prompt lung mai fiabil decât pe cea din mijloc, deci douăzeci de fragmente în loc de patru pot scădea acuratețea în timp ce cresc factura, efect măsurat în Capitolul 24. Iar retrieval poate fi corect și totuși insuficient, cum au arătat cele două cache-uri de mai sus. SelfCheckGPT marchează afirmații care nu supraviețuiesc resamplingului;8 Self-RAG antrenează modelul să emită propriile sale tokens de retrieve-and-critique;9 TruthfulQA a făcut modul de eșec lizibil de la început.10 Niciuna nu închide golul, iar un sistem care prezintă text recuperat drept dovadă a confundat cu sursă cu adevărat.

Jumătatea sistemului care rulează înainte de orice query

Link către secțiunea: Jumătatea sistemului care rulează înainte de orice query

Un retriever este partea vizibilă a unui pipeline ale cărui eșecuri se întâmplă toate mai devreme, în întuneric. Trei dintre ele revin.

Extragerea este locul în care conținutul moare. Un PDF nu este text; este instrucțiuni de desenare. Layouturile pe două coloane se întrețes, tabelele devin supă de cuvinte, antetele de pagină se repetă în fiecare fragment, iar o pagină scanată nu are deloc text până când OCR îi dă unul, cu o încredere. Tot ce a fost măsurat mai sus a presupus că extractorul și-a făcut treaba; în producție adesea nu și-o face, iar simptomul apare ca retrieval prost la trei straturi distanță.

Indexul este ștampilat cu modelul care l-a construit. Embeddingurile din două modele nu sunt comparabile — nu „mai puțin exacte”, ci necomparabile, fiindcă sunt puncte în spații diferite. Schimbă embedding model și fiecare vector din store este gunoi până este reconstruit. Așa că numele modelului, numărul de dimensiuni, versiunea pipeline-ului și versiunea extractorului sunt scrise lângă fiecare document la momentul indexării. Fără ele, în ziua upgrade-ului, nu poți spune care documente sunt vechi și care sunt curente, iar un index migrat pe jumătate returnează nonsens încrezător fără nicio eroare nicăieri.

Un document stricat nu trebuie să strice folderul, iar contoarele trebuie să numere ce s-a întâmplat. Un document la care extragerea eșuează ajunge într-o stare failed cu motivul său, vizibilă și reîncercabilă, în timp ce celelalte nouăzeci și nouă rămân căutabile; iar numărul de fragmente indexate este scris de server când termină, nu declarat de client când încarcă. Un folder care raportează 400 de fragmente și conține 40 este o minciună care iese la suprafață doar ca o întrebare fără răspuns.

Sistemul din acest capitol răspunde la întrebări ale căror răspunsuri sunt scrise undeva. Le recuperează, le rankează, refuză când nu poate și citează unde s-a uitat. Asta este cea mai mare parte din ce vor oamenii de la un asistent peste propriile documente și este limitat într-un fel foarte precis: retrieval poate returna doar ce a scris cineva.

Ceea ce lasă cealaltă jumătate. O parte din ce vrei să facă un model nu este deloc un fapt într-un document — un format pe care trebuie să îl țină, un ton, o taxonomie cu patru sute de etichete, un mod de a decide care trăiește în zece mii de exemple trecute și în niciun paragraf de nicăieri. Retrieval nu le poate livra, fiindcă nu există nimic de recuperat; un prompt mai lung plătește doar factura din Capitolul 16 pentru descrierea unui skill în locul skillului.

Capitolul 20 este acea decizie — fine-tune, retrieve sau prompt — iar constatarea lui este că decizia este economică înainte să fie tehnică: cele trei sunt evaluate ca preț end-to-end pe aceeași întrebare, iar punctul de crossover este un număr de token. Întrebarea care îl deschide este cea la care acest capitol nu poate răspunde. Nu unde este scris răspunsul, ci ce faci când nu a fost niciodată.


Tot ce a fost măsurat în acest capitol a folosit un singur corpus și un singur instrument, iar ambele sunt reproductibile. Corpusul este format din capitolele 1 până la 13 ale acestui curs așa cum erau pe 7 septembrie 2026 — 13 documente, 359.067 de caractere, 127 de secțiuni, front matterul și bibliografiile eliminate. Acele capitole continuă să fie editate, așa că aplicarea aceleiași reguli astăzi numără cu câteva mii de caractere mai mult: numărul de secțiuni este neschimbat și la fel fiecare concluzie de mai jos, dar totalul de caractere este un instantaneu și este etichetat ca atare. Ground truth-ul este format din 32 de întrebări, fiecare asociată cu o propoziție verbatim care apare exact o dată în corpus și nu este niciodată titlu de secțiune, pusă în două formulări pentru 64 de queries. Embeddingurile de retrieval sunt sentence-transformers/all-MiniLM-L6-v2 (384 de dimensiuni, mean-pooled, normalizate L2, window de 256 token); reranking este cross-encoder/ms-marco-MiniLM-L-6-v2 peste top 25; exemplul de generare este Qwen/Qwen2.5-0.5B-Instruct cu greedy decoding. Toate timpii sunt pe CPU single-threaded. Niciun API plătit nu a fost apelat pentru a produce acest capitol, motiv pentru care fiecare latență de aici este locală și este etichetată ca atare.

Chunkerul arătat în TypeScript este chunkerul care a fost măsurat: instrumentul Python care implementează aceeași regulă și ts/chunk.ts au fost comparate fragment cu fragment peste întregul corpus și coincid pe toate cele 940 de fragmente, texte și offseturi deopotrivă. Intervalele sunt Wilson la 95 %; comparațiile împerecheate sunt teste exacte bilaterale ale semnului pe perechile discordante.

Toți cei paisprezece identificatori citați mai sus au fost rezolvați prin API-ul arXiv și verificați titlu cu titlu pe 7 septembrie 2026 — ceea ce, având în vedere cei opt care nu erau, a părut minimul pe care acest capitol anume îl putea face.

  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). Sursa funcției de saturație și a celor două constante folosite mai sus, precum și locul de citit pentru de ce există bb.

  2. Cormack, G. V., Clarke, C. L. A. and Büttcher, S. Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods. SIGIR 2009. k=60k = 60 este al lor, iar ideea metodei este că nu are nevoie de calibrare între scările de scor pe care le fuzionează.

  3. Khattab, O. and Zaharia, M. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT. arXiv:2004.12832 (2020). Calea de mijloc dintre un produs scalar și un cross-encoder. Reimers, N. and Gurevych, I., Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks, arXiv:1908.10084 (2019), este bi-encoderul pe care este construit indexul acestui capitol și a fost măsurat în Capitolul 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). Indexul grafic din spatele majorității bazelor de date vectoriale vândute în prezent.

  5. Johnson, J., Douze, M. and Jégou, H. Billion-scale Similarity Search with GPUs. arXiv:1702.08734 (2017). FAISS și implementarea de referință a IVF măsurat în caseta de mai sus.

  6. Kalai, A. T., Nachum, O., Vempala, S. S. and Zhang, E. Why Language Models Hallucinate. arXiv:2509.04664 (2025). Argumentul că halucinația este produsă de evaluarea cu acuratețe binară care nu recompensează niciodată abținerea și, prin urmare, este o problemă de evaluare înainte să fie una de modelare.

  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). Lucrarea care a numit patternul și cea de citit pentru ce repară și ce nu repară. Guu et al., REALM: Retrieval-Augmented Language Model Pre-Training, arXiv:2002.08909 (2020), este lucrarea contemporană care antrenează retrieverul împreună cu modelul în loc să îl atașeze ulterior; Karpukhin et al., Dense Passage Retrieval for Open-Domain Question Answering, arXiv:2004.04906 (2020), este sursa retrieverului dense cu doi encoderi folosit în tot acest capitol; iar Izacard and Grave, Leveraging Passage Retrieval with Generative Models for Open Domain Question Answering, arXiv:2007.01282 (2020), este aranjamentul fusion-in-decoder pentru a alimenta multe pasaje într-un singur generator. Gao et al., Retrieval-Augmented Generation for Large Language Models: A Survey, arXiv:2312.10997 (2023), este harta a tot ce a venit după, inclusiv HyDE (Gao et al., Precise Zero-Shot Dense Retrieval without Relevance Labels, arXiv:2212.10496, 2022), care face embedding pentru un răspuns ipotetic în locul întrebării.

  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). Detecție prin resampling, fără acces la internele modelului și fără bază de cunoștințe externă.

  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). Antrenarea modelului să decidă când să facă retrieve, în loc să recupereze la fiecare tură.

  10. Lin, S., Hilton, J. and Evans, O. TruthfulQA: Measuring How Models Mimic Human Falsehoods. arXiv:2109.07958 (2021). Benchmarkul construit din întrebări în care răspunsul plauzibil și răspunsul adevărat diferă, adică întreaga dificultate într-o singură propoziție.

Gata să lași LIA să aleagă?

Construiește cu toate modelele AI într-un singur loc — începe gratuit azi.