RAG en production : chunking, retrieval et citations honnêtes
Coupez à 512 caractères et 4 réponses sur 32 meurent avant le retriever. Corriger le chunker fait passer le rang 115 à 3.
Dans cet article
Voici une vraie question posée par un vrai utilisateur d’un vrai assistant : mon jeu d’évaluation contient 20 éléments, est-ce suffisant pour faire confiance au score. Le corpus contient la réponse — toute une section, même. Voici les quatre fragments que le retriever a effectivement placés dans le 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…Trois des quatre commencent au milieu d’un mot. Deux viennent d’un autre chapitre sur un autre sujet. Et le fragment qui répond à la question — celui qui contient Seventeen out of twenty cannot distinguish an 85 % model from a 65 % one — est revenu au rang 115.
Maintenant la même question, le même embedding model, le même modèle de prompt. Une seule chose a changé : la façon dont les documents ont été découpés.
[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]Du rang 115 au rang 3. Personne n’a touché au modèle, au prompt, au seuil ni au nombre d’emplacements. Ce chapitre parle de cet écart, et des quatre autres endroits où un système de retrieval vous ment silencieusement.
Afficher les détails
Ce dont ce chapitre a besoin dans les chapitres précédents, et le seul endroit où il change de langage.
- Chapitre 1 a défini le produit scalaire et la norme L2. La section sur le seuil ci-dessous n’est que ces deux notions, et rien d’autre.
- Chapitre 8 a séparé la table d’embedding d’un modèle de langage d’un modèle d’embedding de retrieval entraîné contrastivement sur des paires, a mesuré la similarité cosinus, et s’est terminé en promettant que le chapitre 19 arriverait à un seuil concret. Cette promesse arrive ici à échéance. Rien de tout cela n’est répété.
- Chapitre 4 a construit l’intervalle de Wilson ; Chapitre 15 a construit le harness d’évaluation. Chaque tableau ci-dessous porte le premier et a été produit par le second.
- Chapitre 16 a chiffré le coût du context window. Le prompt assemblé à la fin de ce chapitre coûte 591 tokens, et c’est le budget pour lequel les fragments se disputent.
Tout ici est en TypeScript, comme depuis le Chapitre 14, et ce chapitre est celui où la règle se justifie : l’ingestion, ce sont des files et du stockage ; la recherche, c’est un appel réseau ; et assembler un prompt avec des citations est le travail d’un serveur. La mesure utilise volontairement le même code entouré d’un tableau de score — un retriever noté par une seconde implémentation est un nombre sur un logiciel que vous ne livrez pas, et le seuil cosinus ci-dessous n’est crédible que parce que vous le voyez balayé par le chunker qui tournera en production.
Le corpus, et ce qui compte comme une bonne réponse
Lien vers la section : Le corpus, et ce qui compte comme une bonne réponseTout ce qui suit est mesuré sur un seul corpus : les treize premiers chapitres de ce cours — 13 documents, 359 067 caractères, 127 sections, avec le front matter et les bibliographies retirés. C’est un vrai corpus technique, avec de la prose, des tableaux, des formules et des blocs de code, et c’est exactement le genre de chose que les gens chargent dans une base de connaissances avant de s’en plaindre.
La vérité terrain comporte 32 questions, chacune associée à une aiguille : une courte phrase textuelle du corpus qui y répond. Chaque aiguille apparaît exactement une fois dans les 359 067 caractères, et aucune n’est un titre de section — cette vérification compte, parce qu’un chunker qui copie les titres dans chaque chunk se noterait autrement lui-même. Chaque question est posée deux fois, une fois dans l’anglais du cours et une fois comme la formulerait un ticket de support : 64 requêtes pour 32 vérités terrain.
Un retrieval est correct quand un chunk retourné contient l’aiguille en entier. C’est la seule définition qui corresponde à ce dont le générateur a besoin : une demi-phrase dans le prompt n’est pas une réponse, c’est un danger.
L’embedding model est all-MiniLM-L6-v2 — 384 dimensions, mean-pooled et normalisé, le modèle entraîné contrastivement que le chapitre 8 a mesuré. Indexer le corpus prend 20,8 secondes sur CPU, 22 ms par chunk ; produire l’embedding d’une requête prend 13 ms.
Chunking, mesuré de six façons
Lien vers la section : Chunking, mesuré de six façonsSix stratégies issues de trois ingrédients indépendants. Blind coupe tous les 512 caractères sans regarder le texte. Boundaries ne coupe jamais à l’intérieur d’un paragraphe, avec retour à une limite de phrase seulement quand un paragraphe dépasse le budget. Header préfixe chaque chunk avec le titre du document et le chemin de section. Overlap copie les 64 derniers caractères du chunk précédent dans le suivant.
| stratégie | chunks | réponses détruites | 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 |
Avec 64 requêtes, l’intervalle de Wilson à 95 % sur R@20 est [0.471, 0.705] pour A et [0.718, 0.901] pour E — ils ne se recouvrent pas, mais la plupart des autres colonnes se recouvrent, et un tableau non apparié ne peut pas les séparer. Chaque stratégie répond aux mêmes requêtes, donc le test honnête est apparié : compter les victoires et les défaites de chaque stratégie face à une autre et exécuter un test des signes sur les paires discordantes. Trois résultats y survivent.
Le blind chunking détruit purement et simplement quatre des trente-deux réponses. Il ne les classe pas mal — il les détruit. L’aiguille chevauche une frontière de 512 caractères, donc aucun chunk de l’index ne la contient, et le plafond de recall pour ces requêtes est zéro. Aucun reranker ne les récupère, aucun seuil n’aide, aucun modèle plus grand n’aide. Vous ne pouvez pas retrouver un texte qui n’existe nulle part d’un seul tenant dans votre index. C’est l’échec le plus sous-estimé du RAG, parce qu’il ressemble exactement à un mauvais retriever.
L’overlap corrige cela, et rien d’autre. Chaque stratégie avec overlap perd zéro réponse, ce qui est la raison d’être de l’overlap. Il n’améliore pas le ranking : B contre A à R@8 donne +8/−6, p = 0.79 ; à R@20, +9/−7, p = 0.80. Pire, ajouter de l’overlap par-dessus le header nuit activement — F contre E donne +2/−6 à R@20 — et la raison est mécanique. Le vecteur d’un chunk est une moyenne sur ses tokens, donc 64 caractères du chunk précédent tirent cette moyenne vers le sujet du voisin. L’overlap est une assurance contre une réponse coupée, payée en précision.
Le header contextuel est ce qui achète le retrieval. E contre A donne +18/−3 à R@20, p = 0.0015. Et l’ablation dit que les boundaries n’en sont pas la cause : E contre C — mêmes découpes, header comme seule différence — donne +12/−2, p = 0.0129. Préfixer "Classification, Cross-Entropy, and How Not to Fool Yourself > How many test examples do I need?" à un paragraphe indique à l’embedding model de quoi parle le paragraphe, ce que le paragraphe lui-même ne dit souvent pas. C’est un résolveur de pronoms pour documents.
Ce qui donne sa forme au chunker, et une règle facile à se tromper :
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;
}Deux textes, pas un. text est ce qui reçoit un embedding, header inclus. content ne contient que les mots propres à ce chunk, et c’est ce qui est cité à l’utilisateur. Citez text et la citation affiche un header qui n’existe pas à cet endroit du document — et, avec overlap, une queue répétée qui appartient au fragment précédent. Elle affiche alors un texte qui n’est pas là où elle dit qu’il est, ce qui est pire que de ne rien afficher.
Le header n’est pas gratuit. Sur les 940 chunks, il coûte 24 213 des 114 275 tokens embedded de l’index : 21,2 % de ce que vous payez pour produire des embeddings est un header que vous avez écrit vous-même. Il pousse aussi les chunks contre la fenêtre de l’encoder. all-MiniLM-L6-v2 accepte 256 word-pieces ; la stratégie E a 17 chunks au-dessus de cette ligne et F en a 28, chacun tronqué silencieusement sans aucun avertissement. Votre taille effective de chunk n’est pas le nombre dans votre configuration — c’est le plus petit entre celui-ci et la fenêtre de votre encoder.
Vingt lignes de BM25, que tout le monde saute
Lien vers la section : Vingt lignes de BM25, que tout le monde sauteLe dense retrieval a une faiblesse systématique et elle n’est pas subtile : il matche le sens, donc il est indifférent à la chaîne exacte que vous avez tapée. Une référence de pièce, un code d’erreur, un acronyme, un nom de famille — rien de tout cela n’a un sens utile à embedder, et le nearest neighbour d’un code d’erreur est tous les autres codes d’erreur de votre corpus.
La réponse classique est plus ancienne que tout cela et tient en vingt lignes. BM25 note un document selon la fréquence à laquelle les termes de la requête y apparaissent, en amortissant chaque terme à mesure que sa fréquence augmente et en pénalisant les documents longs qui accumulent les matches par simple longueur.1 Le terme contribue
où est le nombre d’occurrences du terme dans le document, sa longueur, la longueur moyenne, et et les deux constantes conventionnelles — fixe la vitesse à laquelle la répétition cesse d’aider, la sévérité de la pénalité de longueur.
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;
});
}
}Sur 940 chunks, cela note une requête en 1,14 ms sans aucun index au-delà de deux hash maps. Et ce n’est pas une pièce de musée :
| retriever | R@1 | R@4 | R@8 | MRR | coût par requête |
|---|---|---|---|---|---|
| dense (cosine) | 0.094 | 0.422 | 0.578 | 0.280 | 13 ms pour embedder + 0,3 ms pour scanner |
| lexical (BM25) | 0.219 | 0.375 | 0.469 | 0.313 | 1,14 ms |
| hybrid (RRF) | 0.203 | 0.484 | 0.609 | 0.346 | les deux |
| hybrid + cross-encoder | 0.312 | 0.578 | 0.703 | 0.447 | + 569 ms |
BM25 fait plus que doubler l’exactitude top-1 du dense retriever sur ce corpus, et perd nettement face à lui au rang 8. Ils échouent sur des requêtes différentes, ce qui est tout l’argument pour exécuter les deux.
Les fusionner est le seul endroit où l’approche évidente est mauvaise. Les distances cosinus et les scores BM25 ne sont pas sur la même échelle, ne sont pas bornés de la même façon, et les normaliser par requête fait dépendre le poids de la qualité fortuite du meilleur résultat. Reciprocal rank fusion jette les scores et ne garde que les 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);
}Et ici, la lecture honnête du tableau compte plus que le tableau. Hybrid bat BM25 à R@4 par +10/−3, p = 0.09. Il bat dense par +10/−6, p = 0.45. Sur ce corpus, avec 64 requêtes, le hybrid retrieval ne se distingue pas du dense retrieval. Il est meilleur sur les estimations ponctuelles et dans chaque colonne de recall, mais les preuves n’atteignent pas la significativité. Presque chaque billet de blog sur la recherche hybrid sur internet rapporte un tableau comme celui ci-dessus sans intervalle ; voici ce que dit l’intervalle.
Bi-encoder, cross-encoder, et où se trouve vraiment le gain
Lien vers la section : Bi-encoder, cross-encoder, et où se trouve vraiment le gainTout jusqu’ici est un bi-encoder : la requête traverse le modèle seule, chaque chunk l’a traversé seul il y a des mois, et les deux ne se rencontrent jamais sauf sous forme de produit scalaire. C’est ce qui rend un index possible — embedder une fois, réutiliser pour toujours — et c’est aussi le plafond. Le modèle ne regarde jamais la requête et le chunk ensemble.
Un cross-encoder fait exactement cela : il prend la paire comme une seule entrée et renvoie un score de pertinence. Rien ne peut être pré-calculé, donc il ne peut pas classer un index — mais il peut reranker une shortlist. Reranker le top 25 hybrid avec ms-marco-MiniLM-L-6-v2 fait passer R@1 de 0.094 (dense) à 0.312 et MRR de 0.280 à 0.447 : la plus forte amélioration isolée de ce chapitre, et la seule qui touche le haut de la liste plutôt que la queue.
Cela coûte 569 ms par requête sur CPU, contre 1,14 ms pour BM25 et 0,3 ms pour le scan vectoriel. Environ deux mille fois le coût du retrieval, pour vingt-cinq documents. Voilà tout l’arbitrage bi-encoder/cross-encoder en un nombre, et c’est pourquoi l’architecture a toujours la même forme : un retriever bon marché avec un recall large, puis un scorer coûteux sur une shortlist que vous pouvez vous permettre. ColBERT se situe entre les deux, en pré-calculant des vecteurs par token et en faisant une interaction tardive, moins chère qu’un cross-encoder et plus précise qu’un produit scalaire.3
L2, cosinus, et un seuil que vous n’avez pas mérité
Lien vers la section : L2, cosinus, et un seuil que vous n’avez pas méritéLes bases vectorielles rapportent des distances, et la distance utilisée est une option de configuration. Sur des vecteurs normalisés, le choix est cosmétique, et l’identité mérite d’être faite une fois parce que tout ce qui suit dépend du fait que les vecteurs soient réellement unitaires. Pour :
la distance cosinus est donc exactement . C’est le produit scalaire et la norme du chapitre 1, encaissés. Vérifié sur deux vrais vecteurs de chunks de l’index ci-dessus, puis sur 40 000 paires :
||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-07Exact jusqu’au bruit de virgule flottante — et seulement parce que les vecteurs sont normalisés. Sautez la normalisation et l’identité est fausse, votre seuil ne signifie rien, et la distance rapportée par un document dépend de la longueur de son texte.
Maintenant le nombre que personne ne dérive. Un retriever retourne toujours quelque chose : il trie tout l’index et vous donne le haut de la liste, que la réponse soit ou non quelque part dans le corpus. Le seuil est la seule partie du système qui peut dire non — et pour en fixer un, il vous faut des requêtes qui devraient ne rien récupérer. En voici trente : vingt et une sur des sujets que ce corpus ne couvre vraiment pas — streaming, rate limits, prompt caching, schémas JSON, boucles d’agents, bases vectorielles, prompt injection, génération d’images — et neuf sur la paella, les passeports et les politiques de remboursement. Contre le même index :
| distance cosinus top-1 | |
|---|---|
| requêtes in-domain, les 64 | moyenne 0.445, plage 0.270 – 0.721 |
| in-domain, top-1 réellement correct | moyenne 0.370 |
| in-domain, top-1 faux | moyenne 0.452 |
| out-of-domain, les 30 | moyenne 0.699, plage 0.497 – 0.867 |
Les distributions se séparent, et elles se recouvrent. La pire requête in-domain est plus éloignée de sa réponse (0.721) que la meilleure requête out-of-domain ne l’est d’un paragraphe non pertinent (0.497), donc aucun seuil ne règle les deux correctement. En le balayant sur la vraie porte — garder au maximum quatre chunks, et seulement ceux sous la coupe :
| seuil | in-domain répondues | dont réponse présente | out-of-domain répondues |
|---|---|---|---|
| 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 |
| aucun | 64 / 64 | 27 | 30 / 30 |
Lisez la dernière colonne comme des bluffs. Sans seuil, l’assistant produit une réponse confiante et bien citée à « comment renouveler mon passeport espagnol » à partir d’un corpus sur backpropagation, trente fois sur trente. À 0.675, il le fait dix fois sur trente. À 0.525, il le fait deux fois, et abandonne douze questions auxquelles il aurait pu répondre.
Cet arbitrage est une décision produit, et le bon bout dépend du coût d’une mauvaise réponse pour vous. Ce qui n’est pas négociable, c’est que la dernière colonne existe. Si vous n’avez jamais mesuré votre retriever contre des questions qu’il devrait refuser, vous n’avez pas un seuil — vous avez un nombre.
Deux des dix bluffs à 0.675 montrent les deux modes d’échec.
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."Le premier est un quasi-échec : le corpus explique le KV cache en détail, la requête porte sur le prompt cache, les mots sont les mêmes, et 0.497 est plus proche que la plupart des retrievals in-domain corrects de toute l’expérience. Un embedding ne sait pas que deux caches au même nom sont des machines différentes. Le second est un match littéral sans réponse : le corpus contient l’expression exacte « what is the capital of France », utilisée comme exemple de question qui ne demande aucun raisonnement. Le retriever a raison ; la réponse n’est pas là. Tout système qui lit « j’ai trouvé quelque chose de similaire » comme « j’ai trouvé la réponse » affirmera Paris sur cette preuve — ou, pire, ne l’affirmera pas.
Pourquoi la citation n’est pas écrite par le modèle
Lien vers la section : Pourquoi la citation n’est pas écrite par le modèleUn modèle n’a pas de faculté séparée pour les faits. Produire une phrase vraie et produire une phrase plausible sont la même opération — la prédiction du token suivant du chapitre 8 — et rien dans cette opération ne marque laquelle est laquelle. L’analyse de 2025 qui a reformulé cela soutient que le pipeline d’entraînement et d’évaluation récompense activement la devinette : les benchmarks notent avec une exactitude binaire et ne donnent aucun crédit à l’abstention, donc un modèle qui répond toujours surclasse un modèle identique qui dit « je ne sais pas » quand il ne sait pas, et le post-training optimise en conséquence.6 L’hallucination, dans cette lecture, n’est pas un défaut mystérieux. C’est ce que vous obtenez quand vous notez un QCM sans pénalité pour mauvaise réponse.
Regardez sa forme. Sollicité pour huit articles sur les embeddings de phrases contrastifs, avec identifiants, Qwen2.5-0.5B-Instruct a produit huit lignes au format parfait. Les huit identifiants sont bien formés. Les huit résolvent vers de vrais articles sur arXiv. Aucun des huit n’est l’article annoncé.
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 SomaliC’est un petit modèle et le taux lui est propre ; un modèle frontier en invente beaucoup moins. Le mécanisme se généralise, et c’est la raison de la règle qui suit. Un validateur qui vérifie « cet identifiant existe-t-il » valide les huit, et un utilisateur qui clique sur l’un d’eux arrive sur une vraie page d’une vraie archive sans moyen de savoir que l’association a été inventée. L’échec n’est pas dans l’identifiant ni dans le format. Il est dans l’association — précisément ce qu’un modèle de langage produit par plausibilité.
Donc : le modèle écrit [1] et [2], et n’écrit jamais le lien. Les nombres renvoient aux fragments récupérés par le serveur, et le serveur — qui sait exactement de quel document et de quels offsets vient chaque nombre — attache ensuite le document, le libellé et l’URL. Le modèle n’a rien à inventer parce qu’on ne lui demande jamais la seule chose qu’il inventerait.
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 };
}Exécutez cela sur la question d’ouverture et les quatre chunks deviennent un prompt de 591 tokens et un tableau que le modèle ne voit jamais :
[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.605Le localisateur est la partie que les gens sautent et ne peuvent ensuite plus ajouter. #char=25873,26272 est une plage dans le texte canonique du document ; pour un PDF, l’équivalent est #page=12, pour l’audio ou la vidéo #t=132.4,158.9, pour une feuille de calcul une feuille et une plage A1. Ces deux-là ne sont pas des inventions — #page= est PDF Open Parameters et #t= est W3C Media Fragments, pris en charge nativement par les navigateurs sur les éléments vidéo et audio. Une citation sans localisateur est un nom de document, et un nom de document n’est pas une citation ; c’est une suggestion faite à l’utilisateur d’aller chercher.
Et quand rien ne passe le seuil, le pipeline n’atteint jamais le modèle :
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"C’est un refus moins cher et plus fiable que n’importe quelle instruction dans un system prompt, parce que c’est une comparaison entre deux nombres plutôt qu’une demande adressée à un système probabiliste.
Évaluer le retriever séparément du générateur
Lien vers la section : Évaluer le retriever séparément du générateurChaque mesure de ce chapitre note le retriever et ne demande jamais à un modèle d’écrire une réponse. C’est délibéré, et c’est la pièce que la plupart des équipes sautent.
Un système RAG a deux modes d’échec qui paraissent identiques de l’extérieur. Le retriever n’a pas trouvé le passage ; ou bien il l’a trouvé et le générateur l’a ignoré, l’a contredit, ou l’a mélangé à quelque chose qu’il croyait déjà. Ne notez que la réponse finale et les deux deviennent indiscernables, donc vous réglez des prompts contre un problème qui vit dans votre chunker. Recall@k, MRR et le compteur de réponses détruites n’ont besoin d’aucun appel de génération, ils sont assez bon marché pour tourner à chaque déploiement, et ils sont le harness du chapitre 15 avec une fonction de scoring différente — la même requête, deadline, concurrence et comptage, sur un jeu fixe de questions au lieu d’une conversation live.
Rapportez-les avec des intervalles. L’arithmétique du chapitre 4 s’applique sans changement : à 64 requêtes, un recall de 0.5 porte un intervalle de Wilson à 95 % d’environ ±0.12, donc une stratégie quatre points devant une autre ne vous a rien dit. Utilisez le test apparié chaque fois que les deux stratégies répondent aux mêmes questions, ce qui est toujours le cas ici — c’est ce qui a transformé « E semble meilleur que A » en p = 0.0015.
Et la dernière honnêteté : le RAG réduit l’hallucination et ne la supprime pas. Mettre le bon passage dans le prompt n’oblige pas le modèle à l’utiliser, et la littérature le dit depuis l’article original.7 Deux choses l’aggravent en production. Les contextes longs se dégradent — un modèle trouve l’information au début et à la fin d’un long prompt plus fiablement qu’au milieu, donc vingt chunks au lieu de quatre peuvent réduire l’exactitude tout en augmentant la facture, un effet mesuré au Chapitre 24. Et le retrieval peut être correct tout en restant insuffisant, comme l’ont montré les deux caches ci-dessus. SelfCheckGPT signale les affirmations qui ne survivent pas au rééchantillonnage ;8 Self-RAG entraîne le modèle à émettre ses propres tokens retrieve-and-critique ;9 TruthfulQA a rendu le mode d’échec lisible dès le départ.10 Aucun ne ferme l’écart, et un système qui présente le texte récupéré comme une preuve a confondu sourcé avec vrai.
La moitié du système qui tourne avant toute requête
Lien vers la section : La moitié du système qui tourne avant toute requêteUn retriever est la partie visible d’un pipeline dont les échecs se produisent tous plus tôt, dans l’ombre. Trois reviennent sans cesse.
L’extraction est l’endroit où le contenu meurt. Un PDF n’est pas du texte ; ce sont des instructions de dessin. Les mises en page en deux colonnes s’entrelacent, les tableaux deviennent une soupe de mots, les en-têtes de page se répètent dans chaque chunk, et une page scannée n’a aucun texte jusqu’à ce que l’OCR lui en donne, avec une confiance. Tout ce qui a été mesuré ci-dessus supposait que l’extracteur avait fait son travail ; en production, ce n’est souvent pas le cas, et le symptôme apparaît comme un mauvais retrieval trois couches plus loin.
L’index est estampillé avec le modèle qui l’a construit. Les embeddings de deux modèles ne sont pas comparables — pas « moins précis », pas comparables, parce qu’ils sont des points dans des espaces différents. Changez l’embedding model et chaque vecteur du store est inutilisable jusqu’à reconstruction. Le nom du modèle, le nombre de dimensions, la version du pipeline et la version de l’extracteur sont donc écrits à côté de chaque document au moment de l’indexation. Sans cela, le jour de la mise à niveau, vous ne pouvez pas dire quels documents sont obsolètes et lesquels sont à jour, et un index à moitié migré retourne des absurdités confiantes sans erreur nulle part.
Un document cassé ne doit pas casser le dossier, et les compteurs doivent compter ce qui s’est passé. Un document dont l’extraction échoue finit dans un état failed avec sa raison, visible et réessayable, tandis que les quatre-vingt-dix-neuf autres restent recherchables ; et le nombre de chunks indexés est écrit par le serveur quand il termine, pas déclaré par le client au moment de l’upload. Un dossier qui annonce 400 fragments et en contient 40 est un mensonge qui ne refait surface que sous forme de question sans réponse.
Où cela mène ensuite
Lien vers la section : Où cela mène ensuiteLe système de ce chapitre répond à des questions dont les réponses sont écrites. Il les récupère, les classe, refuse quand il ne peut pas, et cite où il a regardé. C’est l’essentiel de ce que les gens attendent d’un assistant sur leurs propres documents, et c’est borné d’une façon précise : le retrieval ne peut retourner que ce que quelqu’un a écrit.
Ce qui laisse l’autre moitié. Une partie de ce que vous voulez qu’un modèle fasse n’est pas du tout un fait dans un document — un format qu’il doit maintenir, un ton, une taxonomie à quatre cents labels, une manière de décider qui vit dans dix mille exemples passés et dans aucun paragraphe. Le retrieval ne peut pas livrer cela, parce qu’il n’y a rien à récupérer ; un prompt plus long ne fait que payer la facture du chapitre 16 pour une description d’un skill plutôt que pour le skill.
Le Chapitre 20 est cette décision — fine-tune, retrieve ou prompt — et sa conclusion est que la décision est économique avant d’être technique : les trois sont chiffrés de bout en bout sur la même question, et le point de croisement est un nombre de tokens. La question qui l’ouvre est celle à laquelle ce chapitre ne peut pas répondre. Non pas où la réponse est-elle écrite, mais que faites-vous quand elle ne l’a jamais été.
Sources et méthode
Lien vers la section : Sources et méthodeTout ce qui a été mesuré dans ce chapitre utilise un corpus et un instrument, et les deux sont reproductibles. Le corpus correspond aux chapitres 1 à 13 de ce cours tels qu’ils existaient le 7 septembre 2026 — 13 documents, 359 067 caractères, 127 sections, front matter et bibliographies retirés. Ces chapitres continuent d’être édités, donc appliquer la même règle aujourd’hui compte quelques milliers de caractères en plus : le nombre de sections est inchangé, de même que chaque conclusion ci-dessous, mais le total de caractères est un instantané et est étiqueté comme tel. La vérité terrain comporte 32 questions, chacune associée à une phrase textuelle qui apparaît exactement une fois dans le corpus et n’est jamais un titre de section, posée en deux formulations pour 64 requêtes. Les retrieval embeddings sont sentence-transformers/all-MiniLM-L6-v2 (384 dimensions, mean-pooled, normalisés L2, fenêtre de 256 tokens) ; le reranking est cross-encoder/ms-marco-MiniLM-L-6-v2 sur le top 25 ; l’exemple de génération est Qwen/Qwen2.5-0.5B-Instruct avec greedy decoding. Tous les timings sont CPU single-thread. Aucune API payante n’a été appelée pour produire ce chapitre, ce qui explique aussi pourquoi chaque latence ici est locale et étiquetée comme telle.
Le chunker montré en TypeScript est le chunker qui a été mesuré : l’instrument Python implémentant la même règle et ts/chunk.ts ont été comparés chunk par chunk sur tout le corpus et concordent sur les 940 chunks, textes et offsets compris. Les intervalles sont des Wilson à 95 % ; les comparaisons appariées sont des tests exacts des signes bilatéraux sur les paires discordantes.
Les quatorze identifiants cités ci-dessus ont été résolus via l’API arXiv et vérifiés titre par titre le 7 septembre 2026 — ce qui, étant donné les huit qui ne l’étaient pas, semblait le minimum que ce chapitre particulier pouvait faire.
Références
Lien vers la section : Références-
Robertson, S. et Zaragoza, H. The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval 3(4), pp. 333–389 (2009). La source de la fonction de saturation et des deux constantes utilisées ci-dessus, et l’endroit où lire pourquoi existe. ↩
-
Cormack, G. V., Clarke, C. L. A. et Büttcher, S. Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods. SIGIR 2009. Le est le leur, et l’intérêt de la méthode est qu’elle n’a besoin d’aucune calibration entre les échelles de score qu’elle fusionne. ↩
-
Khattab, O. et Zaharia, M. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT. arXiv:2004.12832 (2020). Le juste milieu entre un produit scalaire et un cross-encoder. Reimers, N. et Gurevych, I., Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks, arXiv:1908.10084 (2019), est le bi-encoder sur lequel l’index de ce chapitre est construit et a été mesuré au chapitre 8. ↩
-
Malkov, Yu. A. et Yashunin, D. A. Efficient and Robust Approximate Nearest Neighbor Search using Hierarchical Navigable Small World Graphs. arXiv:1603.09320 (2016). L’index graphe derrière la plupart des bases vectorielles actuellement vendues. ↩
-
Johnson, J., Douze, M. et Jégou, H. Billion-scale Similarity Search with GPUs. arXiv:1702.08734 (2017). FAISS, et l’implémentation de référence de l’IVF mesuré dans l’encadré ci-dessus. ↩
-
Kalai, A. T., Nachum, O., Vempala, S. S. et Zhang, E. Why Language Models Hallucinate. arXiv:2509.04664 (2025). L’argument selon lequel l’hallucination est produite par une notation à exactitude binaire qui ne récompense jamais l’abstention, et constitue donc un problème d’évaluation avant d’être un problème de modélisation. ↩
-
Lewis, P., Perez, E., Piktus, A., Petroni, F., Karpukhin, V., Goyal, N., Küttler, H., Lewis, M., Yih, W., Rocktäschel, T., Riedel, S. et Kiela, D. Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks. arXiv:2005.11401 (2020). L’article qui a nommé le pattern, et celui à lire pour comprendre ce qu’il corrige et ne corrige pas. Guu et al., REALM: Retrieval-Augmented Language Model Pre-Training, arXiv:2002.08909 (2020), est le travail contemporain qui entraîne le retriever conjointement avec le modèle plutôt que de le boulonner dessus ; Karpukhin et al., Dense Passage Retrieval for Open-Domain Question Answering, arXiv:2004.04906 (2020), est l’origine du dense retriever à deux encoders utilisé tout au long de ce chapitre ; et Izacard et Grave, Leveraging Passage Retrieval with Generative Models for Open Domain Question Answering, arXiv:2007.01282 (2020), est l’arrangement fusion-in-decoder pour fournir de nombreux passages à un seul générateur. Gao et al., Retrieval-Augmented Generation for Large Language Models: A Survey, arXiv:2312.10997 (2023), est la carte de tout ce qui est venu après, y compris HyDE (Gao et al., Precise Zero-Shot Dense Retrieval without Relevance Labels, arXiv:2212.10496, 2022), qui embed une réponse hypothétique plutôt que la question. ↩
-
Manakul, P., Liusie, A. et Gales, M. J. F. SelfCheckGPT: Zero-Resource Black-Box Hallucination Detection for Generative Large Language Models. arXiv:2303.08896 (2023). Détection par rééchantillonnage, sans accès aux internes du modèle et sans base de connaissances externe. ↩
-
Asai, A., Wu, Z., Wang, Y., Sil, A. et Hajishirzi, H. Self-RAG: Learning to Retrieve, Generate, and Critique through Self-Reflection. arXiv:2310.11511 (2023). Entraîner le modèle à décider quand récupérer, plutôt que de récupérer à chaque tour. ↩
-
Lin, S., Hilton, J. et Evans, O. TruthfulQA: Measuring How Models Mimic Human Falsehoods. arXiv:2109.07958 (2021). Le benchmark construit à partir de questions où la réponse plausible et la vraie réponse diffèrent, soit toute la difficulté en une phrase. ↩