RAG in produzione: chunking, retrieval e citazioni oneste
Un taglio cieco a 512 caratteri distrugge 4 risposte su 32. Basta correggere il chunker per passare dal rank 115 al 3.
In questa pagina
Ecco una domanda reale di un utente reale a un assistant reale: il mio eval set ha 20 elementi, basta per fidarsi del punteggio. Il corpus contiene la risposta — un’intera sezione. Ecco i quattro frammenti che il retriever ha effettivamente inserito nel prompt.
[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 dei quattro iniziano a metà parola. Due provengono da un altro capitolo, su un altro argomento. E il frammento che risponde alla domanda — quello che contiene Diciassette su venti non possono distinguere un modello all’85 % da uno al 65 % — è tornato al rank 115.
Ora la stessa domanda, lo stesso embedding model, lo stesso template di prompt. È cambiata una cosa sola: come sono stati tagliati i documenti.
[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]Dal rank 115 al rank 3. Nessuno ha toccato il model, il prompt, la soglia o il numero di slot. Questo capitolo parla di quel divario, e degli altri quattro punti in cui un sistema di retrieval ti mente in silenzio.
Mostra dettagli
Cosa serve a questo capitolo dai capitoli precedenti, e l’unico punto in cui cambia linguaggio.
- Capitolo 1 ha definito il prodotto scalare e la norma L2. La sezione sulla soglia qui sotto è fatta di queste due cose, e nient’altro.
- Capitolo 8 ha separato la tabella degli embedding di un language model da un embedding model di retrieval addestrato contrastivamente su coppie, ha misurato la cosine similarity e si è chiuso promettendo che il Capitolo 19 sarebbe arrivato a un cut-off concreto. Qui quella promessa viene mantenuta. Nulla viene ripetuto.
- Capitolo 4 ha costruito l’intervallo di Wilson; Capitolo 15 ha costruito l’harness di valutazione. Ogni tabella qui sotto usa il primo ed è stata prodotta dal secondo.
- Capitolo 16 ha dato un prezzo alla context window. Il prompt assemblato alla fine di questo capitolo costa 591 token, ed è questo il budget per cui competono i frammenti.
Qui è tutto TypeScript, come dal Capitolo 14, e questo è il capitolo in cui la regola si guadagna il posto: l’ingestion è fatta di code e storage, la ricerca è una chiamata di rete, e assemblare un prompt con citazioni è lavoro da server. La misurazione è intenzionalmente lo stesso codice con uno scoreboard attorno — un retriever valutato da una seconda implementazione è un numero su software che non stai distribuendo, e la soglia cosine qui sotto è credibile solo perché la vedi essere sweepata dal chunker che girerà in produzione.
Il corpus, e cosa conta come risposta corretta
Link alla sezione: Il corpus, e cosa conta come risposta correttaTutto ciò che segue è misurato su un solo corpus: i primi tredici capitoli di questo corso — 13 documenti, 359.067 caratteri, 127 sezioni, con front matter e bibliografie rimossi. È un corpus tecnico reale, con prosa, tabelle, formule e code block, ed è esattamente il tipo di cosa che le persone caricano in una knowledge base per poi lamentarsene.
La ground truth è composta da 32 domande, ognuna associata a un needle: una breve frase letterale del corpus che risponde alla domanda. Ogni needle compare esattamente una volta nei 359.067 caratteri, e nessuno è un titolo di sezione — quel controllo conta, perché altrimenti un chunker che copia i titoli in ogni chunk si assegnerebbe punti da solo. Ogni domanda viene posta due volte, una nell’inglese del corso e una nel modo in cui la formulerebbe un ticket di supporto: 64 query su 32 ground truth.
Un retrieval è corretto quando un chunk restituito contiene il needle intero. È l’unica definizione che corrisponde a ciò di cui il generatore ha bisogno: mezza frase nel prompt non è una risposta, è un pericolo.
L’embedding model è all-MiniLM-L6-v2 — 384 dimensioni, mean-pooled e normalizzato, il modello addestrato contrastivamente misurato nel Capitolo 8. Indicizzare il corpus richiede 20,8 secondi su CPU, 22 ms per chunk; fare embedding di una query richiede 13 ms.
Chunking, misurato in sei modi
Link alla sezione: Chunking, misurato in sei modiSei strategie da tre ingredienti indipendenti. Blind taglia ogni 512 caratteri senza guardare il testo. Boundaries non taglia mai dentro un paragrafo, ripiegando sul confine di frase solo quando un paragrafo supera il budget. Header antepone a ogni chunk il titolo del documento e il percorso della sezione. Overlap copia gli ultimi 64 caratteri del chunk precedente nel successivo.
| strategia | chunk | risposte distrutte | R@1 | R@4 | R@8 | R@20 | MRR |
|---|---|---|---|---|---|---|---|
| A blind 512 | 708 | 4 / 32 | 0.125 | 0.297 | 0.422 | 0.594 | 0.241 |
| B blind + overlap | 809 | 0 | 0.172 | 0.391 | 0.453 | 0.625 | 0.286 |
| C boundaries | 940 | 0 | 0.156 | 0.422 | 0.531 | 0.672 | 0.293 |
| D boundaries + overlap | 940 | 0 | 0.156 | 0.359 | 0.516 | 0.656 | 0.277 |
| E boundaries + header | 940 | 0 | 0.094 | 0.422 | 0.578 | 0.828 | 0.280 |
| F boundaries + header + overlap | 940 | 0 | 0.156 | 0.391 | 0.562 | 0.766 | 0.298 |
Con 64 query, l’intervallo di Wilson al 95 % su R@20 è [0.471, 0.705] per A e [0.718, 0.901] per E — non si sovrappongono, ma la maggior parte delle altre colonne sì, e una tabella non appaiata non può separarle. Ogni strategia risponde alle stesse query, quindi il test onesto è appaiato: conta vittorie e sconfitte di ogni strategia rispetto a un’altra ed esegui un sign test sulle coppie discordanti. Tre risultati sopravvivono.
Il blind chunking distrugge direttamente quattro delle trentadue risposte. Non le classifica male: le distrugge. Il needle attraversa un confine da 512 caratteri, quindi nessun chunk nell’indice lo contiene, e il tetto di recall per quelle query è zero. Nessun reranker le recupera, nessuna soglia aiuta, nessun model più grande aiuta. Non puoi recuperare testo che non esiste intero da nessuna parte nel tuo indice. È il fallimento più sottostimato nel RAG, perché sembra identico a un cattivo retriever.
L’overlap corregge questo e nient’altro. Ogni strategia con overlap perde zero risposte, che è il motivo per cui l’overlap esiste. Non migliora il ranking: B contro A a R@8 è +8/−6, p = 0,79; a R@20 è +9/−7, p = 0,80. Peggio, aggiungere overlap sopra l’header danneggia attivamente — F contro E è +2/−6 a R@20 — e il motivo è meccanico. Il vector di un chunk è una media sui suoi token, quindi 64 caratteri del chunk precedente trascinano quella media verso l’argomento del vicino. L’overlap è un’assicurazione contro una risposta spezzata, pagata in precisione.
È l’header contestuale a comprare retrieval. E contro A è +18/−3 a R@20, p = 0,0015. E l’ablation dice che non sono i boundaries a farlo: E contro C — stessi tagli, header unica differenza — è +12/−2, p = 0,0129. Anteporre «Classification, Cross-Entropy, and How Not to Fool Yourself > How many test examples do I need?» a un paragrafo dice all’embedding model di cosa parla il paragrafo, cosa che spesso il paragrafo stesso non dice. È un risolutore di pronomi per documenti.
Questo dà forma al chunker, e a una regola facile da sbagliare:
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;
}Due testi, non uno. text è ciò di cui viene fatto embedding, header incluso. content contiene solo le parole proprie di questo chunk, ed è ciò che viene citato all’utente. Cita text e la citazione mostra un header che in quel punto non è nel documento — e, con overlap, una coda ripetuta che appartiene al frammento precedente. Mostra quindi testo che non si trova dove dice di trovarsi, il che è peggio che non mostrare nulla.
L’header non è gratis. Sui 940 chunk costa 24.213 dei 114.275 token embedded dell’indice: il 21,2 % di ciò che paghi per fare embedding è un header che hai scritto tu. Spinge anche i chunk contro la window dell’encoder. all-MiniLM-L6-v2 accetta 256 word-piece; la strategia E ha 17 chunk oltre quella linea e F ne ha 28, tutti troncati silenziosamente senza alcun avviso. La dimensione effettiva del chunk non è il numero nella tua config — è il minore tra quel numero e la window del tuo encoder.
Venti righe di BM25, che tutti saltano
Link alla sezione: Venti righe di BM25, che tutti saltanoIl dense retrieval ha una debolezza sistematica, e non è sottile: abbina significati, quindi è indifferente a quale stringa esatta hai digitato. Un codice prodotto, un codice errore, un acronimo, un cognome — nessuno ha un significato utile da embedded, e il nearest neighbour di un codice errore è ogni altro codice errore nel tuo corpus.
La risposta classica è più vecchia di tutto questo e richiede venti righe. BM25 assegna punteggio a un documento in base a quante volte i termini della query compaiono al suo interno, attenuando ogni termine man mano che la sua frequenza cresce e penalizzando i documenti lunghi che accumulano match per pura lunghezza.1 Il termine contribuisce
dove è il conteggio del termine nel documento, la sua lunghezza, la lunghezza media, e e le due costanti convenzionali — imposta quanto rapidamente la ripetizione smette di aiutare, quanto duramente viene punita la lunghezza.
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;
});
}
}Su 940 chunk, assegna punteggio a una query in 1,14 ms senza alcun indice oltre a due hash map. E non è un pezzo da museo:
| retriever | R@1 | R@4 | R@8 | MRR | costo per query |
|---|---|---|---|---|---|
| dense (cosine) | 0.094 | 0.422 | 0.578 | 0.280 | 13 ms per embedded + 0,3 ms per scan |
| lexical (BM25) | 0.219 | 0.375 | 0.469 | 0.313 | 1,14 ms |
| hybrid (RRF) | 0.203 | 0.484 | 0.609 | 0.346 | entrambi |
| hybrid + cross-encoder | 0.312 | 0.578 | 0.703 | 0.447 | + 569 ms |
BM25 più che raddoppia l’accuratezza top-1 del dense retriever su questo corpus, e perde nettamente entro il rank 8. Falliscono su query diverse, che è l’intero argomento per eseguirli entrambi.
Fonderli è l’unico punto in cui l’approccio ovvio è sbagliato. Le distanze cosine e i punteggi BM25 non sono sulla stessa scala, non sono limitati allo stesso modo, e normalizzarli per query fa dipendere il peso da quanto sia capitato buono il miglior hit. Reciprocal rank fusion butta via i punteggi e conserva solo i rank:2
/** Reciprocal rank fusion: ranks, not scores. Nothing to calibrate. */
export function rrf(lists: number[][], k = 60): number[] {
const acc = new Map<number, number>();
for (const list of lists)
list.forEach((id, r) => acc.set(id, (acc.get(id) ?? 0) + 1 / (k + r + 1)));
return [...acc.entries()].sort((a, b) => b[1] - a[1]).map(([id]) => id);
}E qui la lettura onesta della tabella conta più della tabella. Hybrid batte BM25 a R@4 di +10/−3, p = 0,09. Batte dense di +10/−6, p = 0,45. Su questo corpus, con 64 query, hybrid retrieval non è distinguibile dal dense retrieval. È migliore nelle stime puntuali e in ogni colonna di recall, ma l’evidenza non raggiunge la significatività. Quasi ogni post su hybrid search su internet riporta una tabella come quella sopra e nessun intervallo; ecco cosa dice l’intervallo.
Bi-encoder, cross-encoder, e dov’è davvero il salto
Link alla sezione: Bi-encoder, cross-encoder, e dov’è davvero il saltoFin qui tutto è un bi-encoder: la query passa nel model da sola, ogni chunk ci è passato da solo mesi fa, e i due non si incontrano mai se non come prodotto scalare. Questo rende possibile un indice — fai embedding una volta, riusa per sempre — ed è anche il suo tetto. Il model non guarda mai query e chunk insieme.
Un cross-encoder fa esattamente questo: prende la coppia come unico input e restituisce un punteggio di rilevanza. Nulla può essere precalcolato, quindi non può ordinare un indice — ma può rerankare una shortlist. Rerankare la top 25 hybrid con ms-marco-MiniLM-L-6-v2 porta R@1 da 0.094 (dense) a 0.312 e MRR da 0.280 a 0.447: il più grande miglioramento singolo di questo capitolo, e l’unico che tocca la cima della lista invece della coda.
Costa 569 ms per query su CPU, contro 1,14 ms per BM25 e 0,3 ms per lo scan vettoriale. Circa duemila volte il costo di retrieval, per venticinque documenti. Questo è l’intero trade-off bi-encoder/cross-encoder in un numero, ed è per questo che l’architettura ha sempre la stessa forma: un retriever economico con ampia recall, poi uno scorer costoso su una shortlist che puoi permetterti. ColBERT sta tra i due, precalcolando vector per-token e facendo una late interaction più economica di un cross-encoder e più precisa di un prodotto scalare.3
L2, cosine, e una soglia che non ti sei guadagnato
Link alla sezione: L2, cosine, e una soglia che non ti sei guadagnatoI vector database riportano distanze, e quale distanza usare è un’opzione di configurazione. Su vector normalizzati la scelta è cosmetica, e vale la pena fare l’identità una volta perché tutto ciò che segue dipende dal fatto che i vector siano davvero unitari. Per :
quindi la distanza cosine è esattamente . È il prodotto scalare e la norma del Capitolo 1, incassati. Verificato su due veri vector di chunk dell’indice sopra, e poi su 40.000 coppie:
||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-07Esatto fino al rumore floating-point — e solo perché i vector sono normalizzati. Salta la normalizzazione e l’identità è falsa, la tua soglia non significa nulla, e la distanza riportata da un documento dipende da quanto era lungo il suo testo.
Ora il numero che nessuno deriva. Un retriever restituisce sempre qualcosa: ordina l’intero indice e ti consegna la cima della lista, che la risposta sia nel corpus o meno. La soglia è l’unica parte del sistema che può dire no — e per impostarne una servono query che dovrebbero non restituire nulla. Eccone trenta: ventuno su cose che questo corpus davvero non copre — streaming, rate limit, prompt caching, schemi JSON, loop di agent, vector database, prompt injection, generazione di immagini — e nove su paella, passaporti e politiche di rimborso. Contro lo stesso indice:
| distanza cosine top-1 | |
|---|---|
| query in-domain, tutte e 64 | media 0.445, range 0.270 – 0.721 |
| in-domain, top-1 effettivamente corretta | media 0.370 |
| in-domain, top-1 sbagliata | media 0.452 |
| out-of-domain, tutte e 30 | media 0.699, range 0.497 – 0.867 |
Le distribuzioni si separano, e si sovrappongono. La peggior query in-domain è più lontana dalla sua risposta (0.721) di quanto la migliore query out-of-domain sia vicina a un paragrafo irrilevante (0.497), quindi nessuna soglia le azzecca entrambe. Sweepandola sul gate reale — conserva al massimo quattro chunk, e solo quelli sotto il cut:
| soglia | in-domain risposte | di cui con risposta dentro | out-of-domain risposte |
|---|---|---|---|
| 0.400 | 17 / 64 | 6 | 0 / 30 |
| 0.450 | 38 / 64 | 13 | 0 / 30 |
| 0.500 | 50 / 64 | 19 | 1 / 30 |
| 0.525 | 52 / 64 | 20 | 2 / 30 |
| 0.550 | 55 / 64 | 21 | 3 / 30 |
| 0.600 | 60 / 64 | 25 | 5 / 30 |
| 0.675 | 62 / 64 | 27 | 10 / 30 |
| 0.800 | 64 / 64 | 27 | 26 / 30 |
| nessuna | 64 / 64 | 27 | 30 / 30 |
Leggi l’ultima colonna come bluff. Senza soglia, l’assistant produce una risposta sicura e ben citata a «come rinnovo il passaporto spagnolo» da un corpus su backpropagation, trenta volte su trenta. A 0.675 lo fa dieci volte su trenta. A 0.525 lo fa due volte, e rinuncia a dodici domande a cui avrebbe potuto rispondere.
Quel trade-off è una decisione di prodotto, e il lato giusto dipende da quanto ti costa una risposta sbagliata. Ciò che non è negoziabile è l’esistenza dell’ultima colonna. Se non hai mai misurato il tuo retriever contro domande che dovrebbe rifiutare, non hai una soglia — hai un numero.
Due dei dieci bluff a 0.675 mostrano i due modi in cui fallisce.
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."Il primo è un near miss: il corpus spiega in dettaglio la KV cache, la query parla di prompt cache, le parole sono le stesse, e 0.497 è più vicino della maggior parte dei retrieval in-domain corretti dell’intero esperimento. Un embedding non sa che due cache con lo stesso nome sono macchine diverse. Il secondo è un match letterale senza risposta: il corpus contiene la frase esatta «what is the capital of France», usata come esempio di domanda che non richiede ragionamento. Il retriever ha ragione; la risposta non c’è. Qualunque sistema che legga «ho trovato qualcosa di simile» come «ho trovato la risposta» affermerà Parigi su quella prova — o, peggio, non lo farà.
Perché la citazione non la scrive il model
Link alla sezione: Perché la citazione non la scrive il modelUn model non ha una facoltà separata per i fatti. Produrre una frase vera e produrne una plausibile sono la stessa operazione — la next-token prediction del Capitolo 8 — e nulla in quell’operazione marca quale sia quale. L’analisi del 2025 che ha riformulato il problema sostiene che la pipeline di training e valutazione premi attivamente l’indovinare: i benchmark valutano con accuratezza binaria e non danno credito all’astensione, quindi un model che risponde sempre supera un model identico che dice «non lo so» quando non lo sa, e il post-training ottimizza di conseguenza.6 In questa lettura, l’hallucination non è un difetto misterioso. È ciò che ottieni quando valuti un esame a risposta multipla senza penalità per le risposte sbagliate.
Guarda la forma. Alla richiesta di otto paper su contrastive sentence embeddings, con identificatori, Qwen2.5-0.5B-Instruct ha prodotto otto righe in formato perfetto. Tutti e otto gli identificatori sono ben formati. Tutti e otto risolvono a paper reali su arXiv. Zero degli otto sono il paper dichiarato.
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 SomaliQuesto è un model piccolo e il tasso è suo; un frontier model inventa molto meno. Il meccanismo generalizza, ed è la ragione della regola che segue. Un validatore che controlla «questo identificatore esiste?» passa tutti e otto, e un utente che clicca arriva su una pagina reale di un archivio reale senza modo di capire che la mappatura è stata inventata. Il fallimento non è nell’identificatore o nel formato. È nell’associazione — precisamente la cosa che un language model produce per plausibilità.
Quindi: il model scrive [1] e [2], e non scrive mai il link. I numeri si riferiscono ai frammenti recuperati dal server, e il server — che sa esattamente da quale documento e da quali offset proviene ogni numero — allega dopo il documento, l’etichetta e l’URL. Non c’è nulla che il model possa inventare perché non gli viene mai chiesta l’unica cosa che inventerebbe.
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 };
}Eseguendolo sulla domanda iniziale, i quattro chunk diventano un prompt da 591 token e una tabella che il model non vede mai:
[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.605Il locator è la parte che le persone saltano e poi non riescono più ad aggiungere. #char=25873,26272 è un range nel testo canonico del documento; per un PDF l’equivalente è #page=12, per audio o video #t=132.4,158.9, per un foglio di calcolo un foglio e un range A1. Queste due cose non sono invenzioni — #page= è PDF Open Parameters e #t= è W3C Media Fragments, rispettati nativamente dai browser sugli elementi video e audio. Una citazione senza locator è un nome di documento, e un nome di documento non è una citazione; è un suggerimento all’utente di andare a cercare.
E quando nulla passa la soglia, la pipeline non arriva mai al model:
NO ANSWER: nothing under cosine distance 0.675 for "what is the offside rule in football"
NO ANSWER: nothing under cosine distance 0.675 for "how do i renew my spanish passport"
NO ANSWER: nothing under cosine distance 0.675 for "how do i build an agent loop with tools"È un rifiuto più economico e affidabile di qualunque istruzione in un system prompt, perché è un confronto tra due numeri invece di una richiesta a un sistema probabilistico.
Valuta il retriever separatamente dal generatore
Link alla sezione: Valuta il retriever separatamente dal generatoreOgni misurazione in questo capitolo valuta il retriever e non chiede mai a un model di scrivere una risposta. È deliberato, ed è la parte che la maggior parte dei team salta.
Un sistema RAG ha due modalità di fallimento che dall’esterno sembrano identiche. Il retriever non ha trovato il passaggio; oppure l’ha trovato e il generatore l’ha ignorato, contraddetto o fuso con qualcosa che già credeva. Valuta solo la risposta finale e le due cose sono indistinguibili, quindi ottimizzi prompt contro un problema che vive nel tuo chunker. Recall@k, MRR e il conteggio delle risposte distrutte non richiedono alcuna chiamata di generazione, sono abbastanza economici da girare a ogni deploy, e sono l’harness del Capitolo 15 con una funzione di scoring diversa — la stessa richiesta, deadline, concorrenza e tally, su un set fisso di domande invece che su una conversazione live.
Riportali con intervalli. L’aritmetica del Capitolo 4 si applica invariata: a 64 query una recall di 0,5 porta un intervallo di Wilson al 95 % di circa ±0,12, quindi una strategia avanti di quattro punti rispetto a un’altra non ti ha detto nulla. Usa il test appaiato ogni volta che entrambe le strategie rispondono alle stesse domande, cosa che qui succede sempre — è ciò che ha trasformato «E sembra migliore di A» in p = 0,0015.
E l’ultima onestà: il RAG riduce l’hallucination e non la elimina. Mettere il passaggio giusto nel prompt non obbliga il model a usarlo, e la letteratura lo dice dal paper originale.7 Due cose lo peggiorano in produzione. I contesti lunghi degradano — un model trova informazioni all’inizio e alla fine di un prompt lungo in modo più affidabile che nel mezzo, quindi venti chunk invece di quattro possono abbassare l’accuratezza mentre aumentano il conto, un effetto misurato nel Capitolo 24. E il retrieval può essere corretto e comunque insufficiente, come hanno mostrato le due cache sopra. SelfCheckGPT segnala claim che non sopravvivono al resampling;8 Self-RAG addestra il model a emettere i propri token di retrieve-and-critique;9 TruthfulQA ha reso leggibile il failure mode fin dall’inizio.10 Nessuno chiude il divario, e un sistema che presenta testo recuperato come prova ha confuso con fonte con vero.
La metà del sistema che gira prima di qualunque query
Link alla sezione: La metà del sistema che gira prima di qualunque queryUn retriever è la parte visibile di una pipeline i cui fallimenti avvengono tutti prima, al buio. Tre ricorrono.
Extraction è dove muore il contenuto. Un PDF non è testo; sono istruzioni di disegno. I layout a due colonne si intrecciano, le tabelle diventano zuppa di parole, gli header di pagina si ripetono in ogni chunk, e una pagina scansionata non ha proprio testo finché l’OCR non gliene dà, con una confidence. Tutto ciò che è stato misurato sopra presupponeva che l’extractor avesse fatto il suo lavoro; in produzione spesso non lo fa, e il sintomo appare come cattivo retrieval tre livelli più in là.
L’indice è timbrato con il model che l’ha costruito. Gli embedding di due model non sono confrontabili — non «meno accurati», non confrontabili, perché sono punti in spazi diversi. Cambia l’embedding model e ogni vector nello store è spazzatura finché non viene ricostruito. Perciò il nome del model, il conteggio delle dimensioni, la versione della pipeline e la versione dell’extractor vengono scritti accanto a ogni documento al momento dell’indicizzazione. Senza di loro, il giorno dell’upgrade, non puoi dire quali documenti sono stale e quali sono correnti, e un indice migrato a metà restituisce assurdità sicure senza errori da nessuna parte.
Un documento rotto non deve rompere la cartella, e i contatori devono contare ciò che è successo. Un documento che fallisce extraction finisce in stato failed con la sua ragione, visibile e ritentabile, mentre gli altri novantanove restano ricercabili; e il numero di chunk indicizzati viene scritto dal server quando finisce, non dichiarato dal client quando carica. Una cartella che riporta 400 frammenti e ne contiene 40 è una bugia che emerge solo come domanda senza risposta.
Dove si va adesso
Link alla sezione: Dove si va adessoIl sistema di questo capitolo risponde a domande le cui risposte sono scritte. Le recupera, le ordina, rifiuta quando non può, e cita dove ha guardato. È la maggior parte di ciò che le persone vogliono da un assistant sui propri documenti, ed è limitato in un modo specifico: il retrieval può restituire solo ciò che qualcuno ha scritto.
Resta quindi l’altra metà. Parte di ciò che vuoi che un model faccia non è affatto un fatto in un documento — un formato che deve mantenere, un tono, una tassonomia con quattrocento label, un modo di decidere che vive in diecimila esempi passati e in nessun paragrafo. Il retrieval non può fornirli, perché non c’è nulla da recuperare; un prompt più lungo paga solo il conto del Capitolo 16 per la descrizione di una skill invece della skill.
Il Capitolo 20 è quella decisione — fine-tune, retrieve o prompt — e la sua conclusione è che la decisione è economica prima che tecnica: i tre approcci vengono prezzati end to end sulla stessa domanda, e il punto di crossover è un conteggio di token. La domanda che lo apre è quella a cui questo capitolo non può rispondere. Non dove è scritta la risposta, ma cosa fai quando non lo è mai stata.
Fonti e metodo
Link alla sezione: Fonti e metodoTutto ciò che è stato misurato in questo capitolo ha usato un corpus e uno strumento, entrambi riproducibili. Il corpus sono i capitoli da 1 a 13 di questo corso così com’erano il 7 settembre 2026 — 13 documenti, 359.067 caratteri, 127 sezioni, front matter e bibliografie rimossi. Quei capitoli continuano a essere modificati, quindi applicare oggi la stessa regola conta qualche migliaio di caratteri in più: il conteggio delle sezioni è invariato e così ogni conclusione qui sotto, ma il totale dei caratteri è uno snapshot ed è etichettato come tale. La ground truth è composta da 32 domande, ognuna associata a una frase letterale che compare esattamente una volta nel corpus e non è mai un titolo di sezione, posta in due formulazioni per 64 query. Gli embedding di retrieval sono sentence-transformers/all-MiniLM-L6-v2 (384 dimensioni, mean-pooled, normalizzati L2, window da 256 token); il reranking è cross-encoder/ms-marco-MiniLM-L-6-v2 sulla top 25; l’esempio di generazione è Qwen/Qwen2.5-0.5B-Instruct con greedy decoding. Tutti i tempi sono su CPU single-thread. Nessuna API a pagamento è stata chiamata per produrre questo capitolo, motivo per cui anche ogni latenza qui è locale ed è etichettata come tale.
Il chunker mostrato in TypeScript è il chunker che è stato misurato: lo strumento Python che implementa la stessa regola e ts/chunk.ts sono stati confrontati chunk per chunk su tutto il corpus e concordano su tutti i 940 chunk, testi e offset inclusi. Gli intervalli sono Wilson al 95 %; i confronti appaiati sono sign test esatti a due code sulle coppie discordanti.
Tutti i quattordici identificatori citati sopra sono stati risolti contro l’API di arXiv e controllati titolo per titolo il 7 settembre 2026 — cosa che, visti gli otto che non lo erano, sembrava il minimo che questo capitolo potesse fare.
Riferimenti
Link alla sezione: Riferimenti-
Robertson, S. and Zaragoza, H. The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval 3(4), pp. 333–389 (2009). La fonte della funzione di saturazione e delle due costanti usate sopra, e il posto dove leggere perché esiste. ↩
-
Cormack, G. V., Clarke, C. L. A. and Büttcher, S. Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods. SIGIR 2009. Il è loro, e il punto del metodo è che non richiede calibrazione tra le scale di punteggio che fonde. ↩
-
Khattab, O. and Zaharia, M. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT. arXiv:2004.12832 (2020). La via di mezzo tra un prodotto scalare e un cross-encoder. Reimers, N. and Gurevych, I., Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks, arXiv:1908.10084 (2019), è il bi-encoder su cui è costruito l’indice di questo capitolo ed è stato misurato nel Capitolo 8. ↩
-
Malkov, Yu. A. and Yashunin, D. A. Efficient and Robust Approximate Nearest Neighbor Search using Hierarchical Navigable Small World Graphs. arXiv:1603.09320 (2016). L’indice a grafo dietro la maggior parte dei vector database attualmente venduti. ↩
-
Johnson, J., Douze, M. and Jégou, H. Billion-scale Similarity Search with GPUs. arXiv:1702.08734 (2017). FAISS, e l’implementazione di riferimento dell’IVF misurato nel riquadro sopra. ↩
-
Kalai, A. T., Nachum, O., Vempala, S. S. and Zhang, E. Why Language Models Hallucinate. arXiv:2509.04664 (2025). L’argomento secondo cui l’hallucination è prodotta da una valutazione ad accuratezza binaria che non premia mai l’astensione, e quindi è un problema di valutazione prima che di modellazione. ↩
-
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). Il paper che ha dato il nome al pattern e quello da leggere per capire cosa risolve e cosa no. Guu et al., REALM: Retrieval-Augmented Language Model Pre-Training, arXiv:2002.08909 (2020), è il lavoro contemporaneo che addestra il retriever insieme al model invece di imbullonarlo sopra; Karpukhin et al., Dense Passage Retrieval for Open-Domain Question Answering, arXiv:2004.04906 (2020), è l’origine del dense retriever a due encoder usato in tutto questo capitolo; e Izacard and Grave, Leveraging Passage Retrieval with Generative Models for Open Domain Question Answering, arXiv:2007.01282 (2020), è l’architettura fusion-in-decoder per alimentare molti passaggi a un solo generatore. Gao et al., Retrieval-Augmented Generation for Large Language Models: A Survey, arXiv:2312.10997 (2023), è la mappa di tutto ciò che è venuto dopo, incluso HyDE (Gao et al., Precise Zero-Shot Dense Retrieval without Relevance Labels, arXiv:2212.10496, 2022), che fa embedding di una risposta ipotetica invece della domanda. ↩
-
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). Rilevazione tramite resampling, senza accesso agli interni del model e senza knowledge base esterna. ↩
-
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). Addestrare il model a decidere quando fare retrieval, invece di fare retrieval a ogni turno. ↩
-
Lin, S., Hilton, J. and Evans, O. TruthfulQA: Measuring How Models Mimic Human Falsehoods. arXiv:2109.07958 (2021). Il benchmark costruito con domande in cui la risposta plausibile e quella vera differiscono, che è tutta la difficoltà in una frase. ↩