RAG en producció: chunking, recuperació i cites honestes
Talla a cegues a 512 caràcters i 4 de 32 respostes moren abans del recuperador. Només arreglar el chunker mou el rang 115 al 3.
En aquesta pàgina
Aquí tens una pregunta real d’un usuari real d’un assistent real: el meu conjunt d’avaluació té 20 elements, n’hi ha prou per confiar en la puntuació. El corpus conté la resposta — una secció sencera. Aquests són els quatre fragments que el recuperador va posar realment al 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…Tres dels quatre comencen a mitja paraula. Dos són d’un capítol diferent sobre un tema diferent. I el fragment que respon la pregunta — el que conté Seventeen out of twenty cannot distinguish an 85 % model from a 65 % one — va tornar al rang 115.
Ara la mateixa pregunta, el mateix model d’embedding, la mateixa plantilla de prompt. Només va canviar una cosa: com es tallaven els documents.
[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]Del rang 115 al rang 3. Ningú no va tocar el model, el prompt, el llindar ni el nombre de ranures. Aquest capítol parla d’aquesta bretxa i dels quatre altres llocs on un sistema de recuperació et menteix en silenci.
Mostra els detalls
Què necessita aquest capítol dels capítols anteriors, i l’únic lloc on canvia de llenguatge.
- Capítol 1 va definir el producte escalar i la norma L2. La secció del llindar de sota és això mateix, i res més.
- Capítol 8 va separar la taula d’embedding d’un model de llenguatge d’un model d’embedding de recuperació entrenat de manera contrastiva amb parelles, va mesurar la similitud del cosinus i va acabar prometent que el capítol 19 arribaria a un tall concret. Aquesta promesa es paga aquí. No se’n repeteix res.
- Capítol 4 va construir l’interval de Wilson; Capítol 15 va construir el harness d’avaluació. Totes les taules de sota porten el primer i van ser produïdes pel segon.
- Capítol 16 va posar preu al context window. El prompt muntat al final d’aquest capítol costa 591 tokens, i aquest és el pressupost pel qual competeixen els fragments.
Tot aquí és TypeScript, com des del Capítol 14, i aquest capítol és on la regla es justifica: la ingesta són cues i emmagatzematge, la cerca és una crida de xarxa, i muntar un prompt amb cites és feina d’un servidor. La mesura és el mateix codi amb un marcador al voltant, a propòsit: un recuperador puntuat per una segona implementació és un número sobre software que no estàs enviant, i el llindar del cosinus de sota només és creïble perquè el veus escombrat pel chunker que s’executarà en producció.
El corpus, i què compta com a resposta correcta
Enllaç a la secció: El corpus, i què compta com a resposta correctaTot el que segueix es mesura contra un únic corpus: els tretze primers capítols d’aquest curs — 13 documents, 359.067 caràcters, 127 seccions, amb el front matter i les bibliografies eliminats. És un corpus tècnic real, amb prosa, taules, fórmules i blocs de codi, i és exactament el tipus de cosa que la gent carrega en una base de coneixement i després en critica.
La veritat de referència són 32 preguntes, cadascuna aparellada amb una agulla: una frase breu i literal del corpus que la respon. Cada agulla apareix exactament una vegada dins dels 359.067 caràcters, i cap és un encapçalament de secció — aquesta comprovació importa, perquè un chunker que copiés encapçalaments dins de cada fragment, si no, es puntuaria a si mateix. Cada pregunta es fa dues vegades, una en l’anglès del curs i una tal com la formularia un tiquet de suport: 64 consultes sobre 32 veritats de referència.
Una recuperació és correcta quan un fragment retornat conté l’agulla sencera. Aquesta és l’única definició que encaixa amb el que necessita el generador: mitja frase dins del prompt no és una resposta, és un perill.
El model d’embedding és all-MiniLM-L6-v2 — 384 dimensions, mean-pooled i normalitzat, el model entrenat contrastivament que va mesurar el capítol 8. Indexar el corpus triga 20,8 segons en una CPU, 22 ms per fragment; fer l’embedding d’una consulta triga 13 ms.
Chunking, mesurat de sis maneres
Enllaç a la secció: Chunking, mesurat de sis maneresSis estratègies a partir de tres ingredients independents. Cec talla cada 512 caràcters sense mirar el text. Límits no talla mai dins d’un paràgraf i recorre a un límit de frase només quan un paràgraf supera el pressupost. Capçalera prefixa cada fragment amb el títol del document i el camí de secció. Solapament copia els últims 64 caràcters del fragment anterior dins del següent.
| strategy | chunks | answers destroyed | R@1 | R@4 | R@8 | R@20 | MRR |
|---|---|---|---|---|---|---|---|
| A blind 512 | 708 | 4 / 32 | 0.125 | 0.297 | 0.422 | 0.594 | 0.241 |
| B blind + overlap | 809 | 0 | 0.172 | 0.391 | 0.453 | 0.625 | 0.286 |
| C boundaries | 940 | 0 | 0.156 | 0.422 | 0.531 | 0.672 | 0.293 |
| D boundaries + overlap | 940 | 0 | 0.156 | 0.359 | 0.516 | 0.656 | 0.277 |
| E boundaries + header | 940 | 0 | 0.094 | 0.422 | 0.578 | 0.828 | 0.280 |
| F boundaries + header + overlap | 940 | 0 | 0.156 | 0.391 | 0.562 | 0.766 | 0.298 |
Amb 64 consultes, l’interval de Wilson del 95 % sobre R@20 és [0,471, 0,705] per a A i [0,718, 0,901] per a E — no se solapen, però la majoria de les altres columnes sí, i una taula no aparellada no les pot separar. Cada estratègia respon les mateixes consultes, així que la prova honesta és aparellada: compta les victòries i derrotes de cada estratègia contra una altra i aplica una prova de signes sobre els parells discordants. Tres resultats la sobreviuen.
El blind chunking destrueix directament quatre de les trenta-dues respostes. No les classifica malament: les destrueix. L’agulla travessa un límit de 512 caràcters, així que cap fragment de l’índex la conté, i el sostre de recall per a aquestes consultes és zero. Cap reranker les recupera, cap llindar ajuda, cap model més gran ajuda. No pots recuperar text que no és sencer enlloc del teu índex. Aquesta és la fallada més infrareportada en RAG, perquè s’assembla exactament a un mal recuperador.
El solapament arregla això i res més. Tota estratègia amb solapament perd zero respostes, que és per a això que serveix el solapament. No millora el rànquing: B contra A a R@8 és +8/−6, p = 0,79; a R@20 és +9/−7, p = 0,80. Pitjor encara, afegir solapament a sobre de la capçalera perjudica activament — F contra E és +2/−6 a R@20 — i el motiu és mecànic. El vector d’un fragment és una mitjana sobre els seus tokens, de manera que 64 caràcters del fragment anterior arrosseguen aquesta mitjana cap al tema del veí. El solapament és una assegurança contra una resposta partida, pagada en precisió.
La capçalera contextual és el que compra la recuperació. E contra A és +18/−3 a R@20, p = 0,0015. I l’ablation diu que els límits no ho expliquen: E contra C — mateixos talls, la capçalera és l’única diferència — és +12/−2, p = 0,0129. Prefixar «Classification, Cross-Entropy, and How Not to Fool Yourself > How many test examples do I need?» a un paràgraf diu al model d’embedding de què tracta el paràgraf, cosa que sovint el paràgraf mateix no diu. És un resolutor de pronoms per a documents.
Això dona forma al chunker, i una regla que és fàcil d’equivocar:
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;
}Dos textos, no un. text és el que es converteix en embedding, capçalera inclosa. content són només les paraules pròpies d’aquest fragment, i és el que se cita de tornada a l’usuari. Cita text i la cita mostra una capçalera que no és al document en aquell punt — i, amb solapament, una cua repetida que pertany al fragment anterior. Llavors mostra text que no és on diu que és, cosa pitjor que no mostrar res.
La capçalera no és gratis. Al llarg dels 940 fragments costa 24.213 dels 114.275 tokens en embedding de l’índex: el 21,2 % del que pagues per fer embedding és una capçalera que has escrit tu mateix. També empeny fragments contra la window de l’encoder. all-MiniLM-L6-v2 accepta 256 word-pieces; l’estratègia E té 17 fragments per sobre d’aquesta línia i F en té 28, tots truncats en silenci sense cap advertiment. La teva mida efectiva de fragment no és el número de la configuració: és el més petit entre aquest número i la window de l’encoder.
Vint línies de BM25, que tothom se salta
Enllaç a la secció: Vint línies de BM25, que tothom se saltaLa recuperació densa té una feblesa sistemàtica i no és subtil: fa coincidir significat, així que li és indiferent exactament quina cadena has escrit. Un número de peça, un codi d’error, un acrònim, un cognom: cap té un significat útil per convertir-lo en embedding, i el veí més proper d’un codi d’error és qualsevol altre codi d’error del teu corpus.
La resposta clàssica és més antiga que tot això i ocupa vint línies. BM25 puntua un document segons la freqüència amb què hi apareixen els termes de la consulta, esmorteint cada terme a mesura que en puja la freqüència i penalitzant documents llargs que acumulen coincidències només per longitud.1 El terme contribueix
on és el recompte del terme al document, la seva longitud, la longitud mitjana, i i les dues constants convencionals — fixa com de ràpid deixa d’ajudar la repetició, com de durament es castiga la longitud.
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;
});
}
}Sobre 940 fragments, això puntua una consulta en 1,14 ms sense cap índex més enllà de dos hash maps. I no és cap peça de museu:
| retriever | R@1 | R@4 | R@8 | MRR | cost per query |
|---|---|---|---|---|---|
| dense (cosine) | 0.094 | 0.422 | 0.578 | 0.280 | 13 ms to embed + 0.3 ms to scan |
| lexical (BM25) | 0.219 | 0.375 | 0.469 | 0.313 | 1.14 ms |
| hybrid (RRF) | 0.203 | 0.484 | 0.609 | 0.346 | both |
| hybrid + cross-encoder | 0.312 | 0.578 | 0.703 | 0.447 | + 569 ms |
BM25 més que duplica l’exactitud top-1 del recuperador dens en aquest corpus, i hi perd clarament al rang 8. Fallen en consultes diferents, que és tot l’argument per executar tots dos.
Fusionar-los és l’únic lloc on l’enfocament obvi és erroni. Les distàncies de cosinus i les puntuacions BM25 no són a la mateixa escala, no estan acotades de la mateixa manera, i normalitzar-les per consulta fa que el pes depengui de com de bo va resultar ser el millor encert. Reciprocal rank fusion llença les puntuacions i conserva només els rangs: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);
}I aquí la lectura honesta de la taula importa més que la taula. L’híbrid supera BM25 a R@4 per +10/−3, p = 0,09. Supera el dens per +10/−6, p = 0,45. En aquest corpus, amb 64 consultes, la recuperació híbrida no es distingeix de la recuperació densa. És millor en ambdues estimacions puntuals i en totes les columnes de recall, i l’evidència no arriba a ser significativa. Gairebé tots els articles de blog sobre cerca híbrida a internet informen una taula com l’anterior i cap interval; això és el que diu l’interval.
Bi-encoder, cross-encoder, i on és realment el guany
Enllaç a la secció: Bi-encoder, cross-encoder, i on és realment el guanyTot fins ara és un bi-encoder: la consulta passa pel model tota sola, cada fragment hi va passar tot sol fa mesos, i tots dos no es troben mai excepte com a producte escalar. Això és el que fa possible un índex — fes embedding una vegada, reutilitza-ho per sempre — i també és el sostre. El model no mira mai la consulta i el fragment junts.
Un cross-encoder fa exactament això: pren la parella com una única entrada i retorna una puntuació de rellevància. No es pot precomputar res, així que no pot classificar un índex — però pot rerank una llista curta. Fer reranking del top 25 híbrid amb ms-marco-MiniLM-L-6-v2 mou R@1 de 0,094 (dens) a 0,312 i MRR de 0,280 a 0,447: la millora individual més gran d’aquest capítol, i l’única que toca el cap de la llista en lloc de la cua.
Costa 569 ms per consulta en una CPU, contra 1,14 ms per a BM25 i 0,3 ms per a l’escaneig vectorial. Aproximadament dues mil vegades el cost de recuperació, per a vint-i-cinc documents. Aquest és tot l’intercanvi bi-encoder/cross-encoder en un sol número, i és per això que l’arquitectura sempre té la mateixa forma: un recuperador barat amb recall ampli, i després un puntuador car sobre una llista curta que et pots permetre. ColBERT se situa entre tots dos, precomputant vectors per token i fent una interacció tardana que és més barata que un cross-encoder i més precisa que un producte escalar.3
L2, cosinus, i un llindar que no t’has guanyat
Enllaç a la secció: L2, cosinus, i un llindar que no t’has guanyatLes bases de dades vectorials informen distàncies, i quina distància és una opció de configuració. Sobre vectors normalitzats l’elecció és cosmètica, i val la pena fer la identitat una vegada perquè tot el que ve després depèn que els vectors siguin realment unitaris. Per a :
així que la distància de cosinus és exactament . Aquest és el producte escalar i la norma del capítol 1, cobrats. Comprovat en dos vectors de fragment reals de l’índex anterior, i després sobre 40.000 parelles:
||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-07Exacte fins al soroll de coma flotant — i només perquè els vectors estan normalitzats. Salta’t la normalització i la identitat és falsa, el teu llindar no vol dir res, i la distància que informa un document depèn de com de llarg era el seu text.
Ara, el número que ningú deriva. Un recuperador sempre retorna alguna cosa: ordena tot l’índex i et dona el cap de la llista, tant si la resposta és al corpus com si no. El llindar és l’única part del sistema que pot dir no — i per fixar-ne un necessites consultes que haurien de no obtenir res. Aquí n’hi ha trenta: vint-i-una sobre coses que aquest corpus realment no cobreix — streaming, límits de taxa, prompt caching, esquemes JSON, bucles d’agent, bases de dades vectorials, prompt injection, generació d’imatges — i nou sobre paella, passaports i polítiques de reembossament. Contra el mateix índex:
| top-1 cosine distance | |
|---|---|
| in-domain queries, all 64 | mean 0.445, range 0.270 – 0.721 |
| in-domain, top-1 actually correct | mean 0.370 |
| in-domain, top-1 wrong | mean 0.452 |
| out-of-domain, all 30 | mean 0.699, range 0.497 – 0.867 |
Les distribucions se separen, i se solapen. La pitjor consulta dins del domini és més lluny de la seva resposta (0,721) que la millor consulta fora de domini d’un paràgraf irrellevant (0,497), així que cap llindar encerta totes dues. Escombrant-lo sobre la porta real — conserva com a màxim quatre fragments, i només els que són sota el tall:
| threshold | in-domain answered | of which the answer was in | out-of-domain answered |
|---|---|---|---|
| 0.400 | 17 / 64 | 6 | 0 / 30 |
| 0.450 | 38 / 64 | 13 | 0 / 30 |
| 0.500 | 50 / 64 | 19 | 1 / 30 |
| 0.525 | 52 / 64 | 20 | 2 / 30 |
| 0.550 | 55 / 64 | 21 | 3 / 30 |
| 0.600 | 60 / 64 | 25 | 5 / 30 |
| 0.675 | 62 / 64 | 27 | 10 / 30 |
| 0.800 | 64 / 64 | 27 | 26 / 30 |
| none | 64 / 64 | 27 | 30 / 30 |
Llegeix l’última columna com a farols. Sense llindar, l’assistent produeix una resposta confiada i ben citada a «com renovo el meu passaport espanyol» a partir d’un corpus sobre backpropagation, trenta vegades de trenta. A 0,675 ho fa deu vegades de trenta. A 0,525 ho fa dues vegades, i renuncia a dotze preguntes que hauria pogut respondre.
Aquest intercanvi és una decisió de producte, i el costat correcte depèn de què et costa una resposta equivocada. El que no és negociable és que l’última columna existeixi. Si mai no has mesurat el teu recuperador contra preguntes que hauria de rebutjar, no tens un llindar: tens un número.
Dos dels deu farols a 0,675 mostren les dues maneres com això falla.
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."El primer és un quasi encert: el corpus explica el KV cache amb detall, la consulta és sobre el prompt cache, les paraules són les mateixes paraules, i 0,497 és més a prop que la majoria de recuperacions correctes dins del domini de tot l’experiment. Un embedding no sap que dues caches amb el mateix nom són màquines diferents. El segon és una coincidència literal sense resposta: el corpus conté la frase exacta «what is the capital of France», usada com a exemple d’una pregunta que no necessita raonament. El recuperador té raó; la resposta no hi és. Qualsevol sistema que llegeixi «he trobat una cosa similar» com «he trobat la resposta» afirmarà París sobre aquesta evidència — o, pitjor, no ho farà.
Per què la cita no l’escriu el model
Enllaç a la secció: Per què la cita no l’escriu el modelUn model no té cap facultat separada per als fets. Produir una frase certa i produir-ne una de plausible són la mateixa operació — la predicció del token següent del capítol 8 — i res en aquesta operació marca quina és quina. L’anàlisi de 2025 que ho va reformular argumenta que el pipeline d’entrenament i avaluació recompensa activament endevinar: els benchmarks puntuen amb exactitud binària i no donen cap crèdit per abstenir-se, així que un model que sempre respon supera un model idèntic que diu «no ho sé» quan no ho sap, i el post-entrenament optimitza en conseqüència.6 La hallucination, llegida així, no és un defecte misteriós. És el que obtens quan puntues un examen de resposta múltiple sense penalització per una resposta incorrecta.
Mira’n la forma. Demanat per vuit articles sobre embeddings contrastius de frases, amb identificadors, Qwen2.5-0.5B-Instruct va produir vuit línies amb format perfecte. Tots vuit identificadors estan ben formats. Tots vuit resolen a articles reals a arXiv. Cap dels vuit és l’article afirmat.
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 SomaliAquest és un model petit i la taxa és la seva; un model de frontera n’inventa molt menys. El mecanisme generalitza, i és el motiu de la regla següent. Un validador que comprova «aquest identificador existeix?» aprova tots vuit, i un usuari que en clica un aterra en una pàgina real d’un arxiu real sense cap manera de saber que el mapatge s’ha inventat. La fallada no és en l’identificador ni en el format. És en l’associació — exactament allò que un model de llenguatge produeix per plausibilitat.
Així doncs: el model escriu [1] i [2], i mai no escriu l’enllaç. Els números fan referència a fragments que el servidor ha recuperat, i el servidor — que sap exactament de quin document i de quins offsets prové cada número — adjunta després el document, l’etiqueta i l’URL. No hi ha res perquè el model s’inventi, perquè mai no se li demana l’única cosa que inventaria.
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 };
}Executa-ho sobre la pregunta inicial i els quatre fragments es converteixen en un prompt de 591 tokens i una taula que el model no veu 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.605El localitzador és la part que la gent se salta i després ja no pot afegir. #char=25873,26272 és un rang dins del text canònic del document; per a un PDF l’equivalent és #page=12, per a àudio o vídeo #t=132.4,158.9, per a un full de càlcul un full i un rang A1. Aquests dos no són invencions — #page= és PDF Open Parameters i #t= és W3C Media Fragments, respectats nativament pels navegadors en elements de vídeo i àudio. Una cita sense localitzador és un nom de document, i un nom de document no és una cita; és un suggeriment perquè l’usuari vagi a mirar.
I quan res no passa el llindar, el pipeline no arriba 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"Això és una negativa més barata i més fiable que qualsevol instrucció en un system prompt, perquè és una comparació entre dos números en lloc d’una petició a un sistema probabilístic.
Avalua el recuperador separadament del generador
Enllaç a la secció: Avalua el recuperador separadament del generadorCada mesura d’aquest capítol puntua el recuperador i no demana ni una sola vegada a un model que escrigui una resposta. És deliberat, i és la peça que la majoria d’equips se salten.
Un sistema RAG té dues modalitats de fallada que des de fora semblen idèntiques. El recuperador no ha trobat el passatge; o l’ha trobat i el generador l’ha ignorat, l’ha contradit o l’ha barrejat amb alguna cosa que ja creia. Puntua només la resposta final i totes dues són indistingibles, així que afines prompts contra un problema que viu al teu chunker. Recall@k, MRR i el recompte de respostes destruïdes no necessiten cap crida de generació, són prou barats per executar-los en cada deploy, i són el harness del capítol 15 amb una funció de puntuació diferent — la mateixa petició, termini, concurrència i recompte, sobre un conjunt fix de preguntes en lloc d’una conversa en directe.
Informa’n amb intervals. L’aritmètica del capítol 4 s’aplica sense canvis: amb 64 consultes, un recall de 0,5 porta un interval de Wilson del 95 % d’aproximadament ±0,12, així que una estratègia quatre punts per davant d’una altra no t’ha dit res. Fes servir la prova aparellada sempre que totes dues estratègies responguin les mateixes preguntes, que aquí sempre ho fan — és el que va convertir «E sembla millor que A» en p = 0,0015.
I l’última honestedat: RAG redueix la hallucination i no l’elimina. Posar el passatge correcte dins del prompt no obliga el model a usar-lo, i la literatura ho diu des de l’article original.7 Dues coses ho empitjoren en producció. Els contextos llargs es degraden — un model troba informació al principi i al final d’un prompt llarg amb més fiabilitat que al mig, així que vint fragments en lloc de quatre poden reduir l’exactitud mentre apugen la factura, un efecte mesurat al Capítol 24. I la recuperació pot ser correcta i encara insuficient, com han mostrat les dues caches de dalt. SelfCheckGPT marca afirmacions que no sobreviuen al resampling;8 Self-RAG entrena el model per emetre els seus propis tokens de retrieve-and-critique;9 TruthfulQA va fer llegible la modalitat de fallada des del principi.10 Cap tanca la bretxa, i un sistema que presenta text recuperat com a prova ha confós amb font amb cert.
La meitat del sistema que s’executa abans de qualsevol consulta
Enllaç a la secció: La meitat del sistema que s’executa abans de qualsevol consultaUn recuperador és la part visible d’un pipeline les fallades del qual passen totes abans, a les fosques. Tres es repeteixen.
L’extracció és on mor el contingut. Un PDF no és text; són instruccions de dibuix. Les maquetacions a dues columnes s’entrellacen, les taules es converteixen en sopa de paraules, les capçaleres de pàgina es repeteixen dins de cada fragment, i una pàgina escanejada no té cap text fins que l’OCR n’hi dona, amb una confiança. Tot el que s’ha mesurat més amunt assumia que l’extractor feia la seva feina; en producció sovint no la fa, i el símptoma apareix com a mala recuperació tres capes més enllà.
L’índex queda segellat amb el model que l’ha construït. Els embeddings de dos models no són comparables — no «menys precisos», no comparables, perquè són punts en espais diferents. Canvia el model d’embedding i cada vector del magatzem és brossa fins que es reconstrueixi. Per això el nom del model, el recompte de dimensions, la versió del pipeline i la versió de l’extractor s’escriuen al costat de cada document en el moment d’indexar. Sense això, el dia de l’actualització, no pots saber quins documents són obsolets i quins són actuals, i un índex migrat a mitges retorna disbarats confiats sense cap error enlloc.
Un document trencat no ha de trencar la carpeta, i els comptadors han de comptar què ha passat. Un document que falla l’extracció acaba en un estat failed amb el seu motiu, visible i reintentable, mentre els altres noranta-nou continuen sent cercables; i el nombre de fragments indexats l’escriu el servidor quan acaba, no el declara el client quan puja el fitxer. Una carpeta que informa 400 fragments i en conté 40 és una mentida que només aflora com una pregunta irresponible.
Cap on va això ara
Enllaç a la secció: Cap on va això araEl sistema d’aquest capítol respon preguntes les respostes de les quals estan escrites. Les recupera, les classifica, refusa quan no pot, i cita on ha mirat. Això és la major part del que la gent vol d’un assistent sobre els seus propis documents, i està acotat d’una manera concreta: la recuperació només pot retornar el que algú va escriure.
Això deixa l’altra meitat. Part del que vols que faci un model no és cap fet dins d’un document — un format que ha de mantenir, un to, una taxonomia amb quatre-centes etiquetes, una manera de decidir que viu en deu mil exemples passats i en cap paràgraf enlloc. La recuperació no pot lliurar això, perquè no hi ha res a recuperar; un prompt més llarg només paga la factura del capítol 16 per una descripció d’una skill en lloc de la skill.
El Capítol 20 és aquesta decisió — fine-tune, recupera o prompt — i la seva conclusió és que la decisió és econòmica abans que tècnica: les tres opcions es preuen d’extrem a extrem sobre la mateixa pregunta, i el punt d’encreuament és un recompte de tokens. La pregunta que l’obre és la que aquest capítol no pot respondre. No on és escrita la resposta, sinó què fas quan mai no ho va estar.
Fonts i mètode
Enllaç a la secció: Fonts i mètodeTot el que s’ha mesurat en aquest capítol va fer servir un corpus i un instrument, i tots dos són reproduïbles. El corpus són els capítols 1 a 13 d’aquest curs tal com eren el 7 de setembre de 2026 — 13 documents, 359.067 caràcters, 127 seccions, front matter i bibliografies eliminats. Aquests capítols es continuen editant, així que aplicar la mateixa regla avui compta uns quants milers de caràcters més: el recompte de seccions no canvia i tampoc cap conclusió de sota, però el total de caràcters és una instantània i s’etiqueta com a tal. La veritat de referència són 32 preguntes, cadascuna aparellada amb una frase literal que apareix exactament una vegada al corpus i mai no és un encapçalament de secció, preguntades en dues formulacions per a 64 consultes. Els embeddings de recuperació són sentence-transformers/all-MiniLM-L6-v2 (384 dimensions, mean-pooled, normalitzats L2, window de 256 tokens); el reranking és cross-encoder/ms-marco-MiniLM-L-6-v2 sobre el top 25; l’exemple de generació és Qwen/Qwen2.5-0.5B-Instruct amb greedy decoding. Tots els temps són en CPU d’un sol fil. No s’ha cridat cap API de pagament per produir aquest capítol, que també és per això que tota la latència d’aquí és local i s’etiqueta com a tal.
El chunker mostrat en TypeScript és el chunker que s’ha mesurat: l’instrument Python que implementa la mateixa regla i ts/chunk.ts es van comparar fragment per fragment sobre tot el corpus i coincideixen en tots els 940 fragments, textos i offsets inclosos. Els intervals són Wilson al 95 %; les comparacions aparellades són proves de signes exactes bilaterals sobre els parells discordants.
Els catorze identificadors citats més amunt es van resoldre contra l’API d’arXiv i es van comprovar títol per títol el 7 de setembre de 2026 — cosa que, vistos els vuit que no ho eren, semblava el mínim que aquest capítol concret podia fer.
Referències
Enllaç a la secció: Referències-
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 font de la funció de saturació i de les dues constants usades més amunt, i el lloc on llegir per què existeix. ↩
-
Cormack, G. V., Clarke, C. L. A. and Büttcher, S. Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods. SIGIR 2009. El és seu, i el sentit del mètode és que no necessita calibratge entre les escales de puntuació que fusiona. ↩
-
Khattab, O. and Zaharia, M. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT. arXiv:2004.12832 (2020). El punt intermedi entre un producte escalar i un cross-encoder. Reimers, N. and Gurevych, I., Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks, arXiv:1908.10084 (2019), és el bi-encoder sobre el qual es construeix l’índex d’aquest capítol i es va mesurar al capítol 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’índex de graf que hi ha darrere de la majoria de bases de dades vectorials que es venen avui. ↩
-
Johnson, J., Douze, M. and Jégou, H. Billion-scale Similarity Search with GPUs. arXiv:1702.08734 (2017). FAISS, i la implementació de referència de l’IVF mesurat al requadre anterior. ↩
-
Kalai, A. T., Nachum, O., Vempala, S. S. and Zhang, E. Why Language Models Hallucinate. arXiv:2509.04664 (2025). L’argument que la hallucination és produïda per una puntuació d’exactitud binària que mai no recompensa l’abstenció, i per tant és un problema d’avaluació abans que de modelatge. ↩
-
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). L’article que va donar nom al patró i el que cal llegir per saber què arregla i què no. Guu et al., REALM: Retrieval-Augmented Language Model Pre-Training, arXiv:2002.08909 (2020), és el treball contemporani que entrena el recuperador conjuntament amb el model en lloc d’afegir-lo després; Karpukhin et al., Dense Passage Retrieval for Open-Domain Question Answering, arXiv:2004.04906 (2020), és d’on ve el recuperador dens de dos encoders usat al llarg d’aquest capítol; i Izacard and Grave, Leveraging Passage Retrieval with Generative Models for Open Domain Question Answering, arXiv:2007.01282 (2020), és l’arranjament fusion-in-decoder per alimentar molts passatges a un sol generador. Gao et al., Retrieval-Augmented Generation for Large Language Models: A Survey, arXiv:2312.10997 (2023), és el mapa de tot el que va venir després, inclòs HyDE (Gao et al., Precise Zero-Shot Dense Retrieval without Relevance Labels, arXiv:2212.10496, 2022), que embedeix una resposta hipotètica en lloc de la pregunta. ↩
-
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). Detecció per resampling, sense accés als interns del model i sense base de coneixement externa. ↩
-
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). Entrenar el model per decidir quan recuperar, en lloc de recuperar en cada torn. ↩
-
Lin, S., Hilton, J. and Evans, O. TruthfulQA: Measuring How Models Mimic Human Falsehoods. arXiv:2109.07958 (2021). El benchmark construït a partir de preguntes on la resposta plausible i la resposta certa difereixen, que és tota la dificultat en una frase. ↩