RAG в production: chunking, retrieval и честни цитати
Сляпо рязане на 512 знака убива 4 от 32 отговора. Само поправката на chunker мести ранг 115 на 3.
На тази страница
Ето реален въпрос от реален потребител на реален assistant: моят eval set има 20 елемента, достатъчно ли е това, за да вярвам на резултата. Корпусът съдържа отговора — цяла негова секция. Ето четирите фрагмента, които retriever реално постави в 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…Три от четирите започват по средата на дума. Два са от различна глава за различна тема. А фрагментът, който отговаря на въпроса — този, който съдържа Седемнадесет от двадесет не могат да различат 85 % модел от 65 % модел — се върна с ранг 115.
Сега същият въпрос, същият embedding model, същият prompt шаблон. Промени се едно нещо: как са нарязани документите.
[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]От ранг 115 до ранг 3. Никой не е пипал модела, prompt, прага или броя слотове. Тази глава е за тази разлика и за още четири места, на които retrieval система тихо ви лъже.
Покажи подробности
Какво ѝ трябва на тази глава от предишните глави и едното място, на което сменя езика.
- Глава 1 дефинира скаларното произведение и L2 нормата. Секцията за праговете по-долу е точно тези две неща и нищо друго.
- Глава 8 отдели embedding таблицата на езиковия модел от retrieval embedding model, обучен контрастивно върху двойки, измери cosine similarity и завърши с обещание, че Глава 19 ще стигне до конкретна граница. Това обещание се изпълнява тук. Нищо от него не се повтаря.
- Глава 4 изгради интервала на Wilson; Глава 15 изгради evaluation harness. Всяка таблица по-долу носи първото и е произведена от второто.
- Глава 16 оцени цената на context window. Prompt, сглобен в края на тази глава, струва 591 tokens, и това е бюджетът, за който фрагментите се конкурират.
Всичко тук е TypeScript, както е от Глава 14, и тази глава е мястото, където правилото се доказва: ingestion са опашки и съхранение, search е мрежово извикване, а сглобяването на prompt с цитати е работа на сървъра. Измерването нарочно е същият код с табло за резултати около него — retriever, оценен от втора имплементация, е число за софтуер, който не пускате в production, а cosine threshold по-долу е правдоподобен само защото гледате как се sweep-ва от chunker, който ще работи в production.
Корпусът и какво се брои за правилен отговор
Връзка към раздела: Корпусът и какво се брои за правилен отговорВсичко по-долу е измерено спрямо един корпус: първите тринадесет глави от този курс — 13 документа, 359 067 знака, 127 секции, с премахнати front matter и библиографии. Това е реален технически корпус, с проза, таблици, формули и code blocks, и е точно онзи тип нещо, което хората зареждат в knowledge base и после се оплакват от него.
Ground truth са 32 въпроса, всеки сдвоен с игла: кратко дословно изречение от корпуса, което му отговаря. Всяка игла се появява точно веднъж в 359 067-те знака и никоя не е заглавие на секция — тази проверка е важна, защото chunker, който копира заглавия във всеки chunk, иначе би си вдигнал резултата. Всеки въпрос се задава два пъти, веднъж на езика на курса и веднъж както би го формулирал support ticket: 64 queries върху 32 ground truths.
Retrieval е правилен, когато върнат chunk съдържа иглата цяла. Това е единствената дефиниция, която съответства на това, от което се нуждае генераторът: половин изречение в prompt не е отговор, а риск.
Embedding model е all-MiniLM-L6-v2 — 384 dimensions, mean-pooled и normalised, контрастивно обученият модел, който Глава 8 измери. Индексирането на корпуса отнема 20,8 секунди на CPU, 22 ms на chunk; embedding на една query отнема 13 ms.
Chunking, измерен по шест начина
Връзка към раздела: Chunking, измерен по шест начинаШест стратегии от три независими съставки. Blind реже на всеки 512 знака, без да гледа текста. Boundaries никога не реже вътре в абзац, като се връща към граница на изречение само когато един абзац надхвърля бюджета. Header поставя пред всеки chunk заглавието на документа и пътя на секцията. Overlap копира последните 64 знака от предишния chunk в следващия.
| стратегия | chunks | унищожени отговори | 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 |
С 64 queries 95 % интервал на Wilson за R@20 е [0.471, 0.705] за A и [0.718, 0.901] за E — те не се припокриват, но повечето други колони се припокриват, а несдвоена таблица не може да ги отдели. Всяка стратегия отговаря на същите queries, така че честният тест е сдвоен: броите победите и загубите на всяка стратегия спрямо друга и пускате sign test върху discordant pairs. Три резултата го преживяват.
Blind chunking унищожава директно четири от тридесет и двата отговора. Не ги ранкира зле — унищожава ги. Иглата пресича граница от 512 знака, така че в индекса няма chunk, който я съдържа, и recall ceiling за тези queries е нула. Никакъв reranker не ги възстановява, никакъв threshold не помага, никакъв по-голям модел не помага. Не можете да retrieve-нете текст, който не е на едно парче никъде в индекса ви. Това е най-недостатъчно докладваният failure в RAG, защото изглежда точно като лош retriever.
Overlap поправя това и нищо друго. Всяка стратегия с overlap губи нула отговори, което е смисълът на overlap. Той не подобрява ranking: B срещу A при R@8 е +8/−6, p = 0.79; при R@20 е +9/−7, p = 0.80. По-лошо, добавянето на overlap върху header активно вреди — F срещу E е +2/−6 при R@20 — и причината е механична. Векторът на chunk е средна стойност върху неговите tokens, така че 64 знака от предишния chunk дърпат тази средна стойност към темата на съседа. Overlap е застраховка срещу разделен отговор, платена с precision.
Контекстният header е това, което купува retrieval. E срещу A е +18/−3 при R@20, p = 0.0015. А ablation казва, че boundaries не са причината: E срещу C — същите разрези, header е единствената разлика — е +12/−2, p = 0.0129. Добавянето на "Classification, Cross-Entropy, and How Not to Fool Yourself > How many test examples do I need?" пред абзац казва на embedding model за какво е абзацът, което самият абзац често не казва. Това е resolver на местоимения за документи.
Това дава формата на chunker и едно правило, което лесно се бърка:
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;
}Два текста, не един. text е това, което се embed-ва, с header и всичко останало. content са само собствените думи на този chunk и това е, което се цитира обратно на потребителя. Цитирате ли text, цитатът показва header, който не е в документа на това място — а с overlap и повторена опашка, принадлежаща на предишния фрагмент. Тогава се показва текст, който не е там, където твърди, че е, което е по-лошо от това да не се показва нищо.
Header не е безплатен. В 940-те chunks той струва 24 213 от 114 275-те embedded tokens на индекса: 21,2 % от това, за което плащате за embedding, е header, който сами сте написали. Той също притиска chunks към прозореца на encoder. all-MiniLM-L6-v2 приема 256 word-pieces; стратегия E има 17 chunks над тази линия, а F има 28, като всеки от тях се truncate-ва тихо без предупреждение от каквото и да било. Ефективният ви chunk size не е числото в config — той е по-малкото между него и прозореца на encoder.
Двадесет реда BM25, които всички пропускат
Връзка към раздела: Двадесет реда BM25, които всички пропускатDense retrieval има една системна слабост и тя не е фина: съвпада по смисъл, така че е безразличен към точно кой низ сте въвели. Номер на част, код на грешка, акроним, фамилия — никое от тях няма полезен смисъл за embedding, а nearest neighbour на код на грешка е всеки друг код на грешка във вашия корпус.
Класическият отговор е по-стар от всичко това и отнема двадесет реда. BM25 оценява документ според това колко често terms от query се появяват в него, като потиска всеки term с нарастването на честотата му и наказва дълги документи, които натрупват съвпадения само заради дължината си.1 Term допринася
където е броят на term в документа, неговата дължина, средната дължина, а и са двете конвенционални константи — задава колко бързо повторението спира да помага, колко силно се наказва дължината.
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;
});
}
}Върху 940 chunks това оценява query за 1,14 ms без никакъв индекс освен две hash maps. И не е музейна вещ:
| retriever | R@1 | R@4 | R@8 | MRR | цена на query |
|---|---|---|---|---|---|
| dense (cosine) | 0.094 | 0.422 | 0.578 | 0.280 | 13 ms за embedding + 0,3 ms за scan |
| lexical (BM25) | 0.219 | 0.375 | 0.469 | 0.313 | 1,14 ms |
| hybrid (RRF) | 0.203 | 0.484 | 0.609 | 0.346 | и двете |
| hybrid + cross-encoder | 0.312 | 0.578 | 0.703 | 0.447 | + 569 ms |
BM25 повече от удвоява top-1 точността на dense retriever върху този корпус и губи лошо от него до ранг 8. Те се провалят на различни queries, което е целият аргумент да пускате и двата.
Сливането им е едното място, където очевидният подход е грешен. Cosine distances и BM25 scores не са в една и съща скала, не са ограничени по един и същи начин, а normalising им per query прави теглото зависимо от това колко добър случайно е бил най-добрият hit. Reciprocal rank fusion изхвърля scores и пази само ranks: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);
}И тук честният прочит на таблицата е по-важен от таблицата. Hybrid побеждава BM25 при R@4 с +10/−3, p = 0.09. Побеждава dense с +10/−6, p = 0.45. Върху този корпус, с 64 queries, hybrid retrieval не е различим от dense retrieval. Той е по-добър по point estimates и във всяка recall колона, но доказателството не достига significance. Почти всеки blog post за hybrid search в интернет докладва таблица като горната и никакъв интервал; ето какво казва интервалът.
Bi-encoder, cross-encoder и къде всъщност е подобрението
Връзка към раздела: Bi-encoder, cross-encoder и къде всъщност е подобрениетоВсичко досега е bi-encoder: query минава през модела сама, всеки chunk е минал през него сам преди месеци, и двете никога не се срещат освен като dot product. Това прави индекса възможен — embed веднъж, reuse завинаги — и това е и таванът. Моделът никога не гледа query и chunk заедно.
Cross-encoder прави точно това: приема двойката като един вход и връща relevance score. Нищо не може да се precompute-не, така че не може да ранкира индекс — но може да rerank-не shortlist. Reranking на hybrid top 25 с ms-marco-MiniLM-L-6-v2 мести R@1 от 0.094 (dense) на 0.312 и MRR от 0.280 на 0.447: най-голямото единично подобрение в тази глава и единственото, което докосва върха на списъка, а не опашката.
Струва 569 ms на query на CPU, срещу 1,14 ms за BM25 и 0,3 ms за vector scan. Приблизително две хиляди пъти цената на retrieval, за двадесет и пет документа. Това е целият trade-off bi-encoder/cross-encoder в едно число и затова архитектурата винаги има една и съща форма: евтин retriever с широк recall, после скъп scorer върху shortlist, който можете да си позволите. ColBERT стои между двете, като precompute-ва per-token vectors и прави late interaction, което е по-евтино от cross-encoder и по-точно от dot product.3
L2, cosine и праг, който още не сте заслужили
Връзка към раздела: L2, cosine и праг, който още не сте заслужилиVector databases докладват distances, а коя distance е конфигурационна опция. При normalised vectors изборът е козметичен, а идентичността си струва да се направи веднъж, защото всичко след нея зависи от това vectors наистина да са unit. За :
така че cosine distance е точно . Това са dot product и norm от Глава 1, осребрени. Проверено върху два реални chunk vectors от индекса по-горе, а после върху 40 000 двойки:
||a|| = 1.000000 ||b|| = 1.000000
L2 = 0.795183 L2^2/2 = 0.316158 1 - cos = 0.316158 diff = 7.66e-08
max |L2^2/2 - (1 - cos)| over 200 x 200 pairs = 8.3e-07Точно до floating-point noise — и само защото vectors са normalised. Пропуснете normalisation и идентичността е невярна, threshold ви не означава нищо, а distance, която документ докладва, зависи от това колко дълъг е бил текстът му.
Сега числото, което никой не извежда. Retriever винаги връща нещо: сортира целия индекс и ви подава върха на списъка, независимо дали отговорът изобщо е някъде в корпуса. Threshold е единствената част от системата, която може да каже не — а за да зададете такъв, ви трябват queries, които трябва да не получават нищо обратно. Ето тридесет: двадесет и една за неща, които този корпус наистина не покрива — streaming, rate limits, prompt caching, JSON schemas, agent loops, vector databases, prompt injection, image generation — и девет за паеля, паспорти и policies за възстановяване на суми. Срещу същия индекс:
| top-1 cosine distance | |
|---|---|
| in-domain queries, всички 64 | mean 0.445, range 0.270 – 0.721 |
| in-domain, top-1 реално правилен | mean 0.370 |
| in-domain, top-1 грешен | mean 0.452 |
| out-of-domain, всички 30 | mean 0.699, range 0.497 – 0.867 |
Разпределенията се разделят и се припокриват. Най-лошата in-domain query е по-далеч от отговора си (0.721), отколкото най-добрата out-of-domain query е от нерелевантен абзац (0.497), така че няма threshold, който да направи и двете правилно. Sweep върху реалния gate — пазете най-много четири chunks и само тези под cut:
| threshold | in-domain answered | от тях с отговора вътре | 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 |
Четете последната колона като блъфове. Без threshold assistant произвежда уверен, добре цитиран отговор на "how do I renew my Spanish passport" от корпус за backpropagation, тридесет пъти от тридесет. При 0.675 го прави десет пъти от тридесет. При 0.525 го прави два пъти и се отказва от дванадесет въпроса, на които е можел да отговори.
Този trade-off е продуктово решение, а правилният му край зависи от това колко ви струва грешен отговор. Това, което не подлежи на договаряне, е последната колона изобщо да съществува. Ако никога не сте измервали retriever спрямо въпроси, които трябва да откаже, нямате threshold — имате число.
Два от десетте блъфа при 0.675 показват двата начина, по които това се проваля.
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."Първият е near miss: корпусът обяснява KV cache подробно, query е за prompt cache, думите са същите думи, а 0.497 е по-близо от повечето правилни in-domain retrievals в целия експеримент. Embedding не знае, че два кеша със същото име са различни машини. Вторият е буквално съвпадение без отговор: корпусът съдържа точната фраза "what is the capital of France", използвана като пример за въпрос, който не изисква reasoning. Retriever е прав; отговорът не е там. Всяка система, която чете "намерих нещо подобно" като "намерих отговора", ще твърди Paris на базата на това доказателство — или, по-лошо, няма да го направи.
Защо цитатът не се пише от модела
Връзка към раздела: Защо цитатът не се пише от моделаМоделът няма отделна способност за факти. Произвеждането на вярно изречение и произвеждането на правдоподобно изречение са една и съща операция — next-token prediction от Глава 8 — и нищо в тази операция не маркира кое кое е. Анализът от 2025 г., който преформулира това, твърди, че training и evaluation pipeline активно възнаграждава guessing: benchmarks оценяват с binary accuracy и не дават кредит за abstention, така че модел, който винаги отговаря, изпреварва идентичен модел, който казва "I don't know", когато не знае, и post-training optimises accordingly.6 Hallucination в този прочит не е мистериозен дефект. Това е резултатът, когато оценявате тест с multiple-choice без наказание за грешен отговор.
Вижте формата му. Попитан за осем papers върху contrastive sentence embeddings, с identifiers, Qwen2.5-0.5B-Instruct произведе осем реда в перфектен формат. Всичките осем identifiers са well-formed. Всичките осем сочат към реални papers в arXiv. Нула от осемте са твърденият paper.
claimed arXiv:1907.06432 - Contrastive Sentence Embeddings for Text Retrieval
actual A Neural Turing~Machine for Conditional Transition Graph Modeling
claimed arXiv:1809.08669 - Contrastive Learning of Sentence Representations…
actual Collapsing Superstring Conjecture
claimed arXiv:1807.08669 - Contrastive Learning of Sentence Representations…
actual Automatic Speech Recognition for Humanitarian Applications in SomaliТова е малък модел и rate е негов собствен; frontier model измисля много по-малко. Механизмът се обобщава и е причината за правилото, което следва. Validator, който проверява "съществува ли този identifier", пропуска всичките осем, а потребител, който кликне един, попада на реална страница от реален archive без начин да разбере, че mapping е измислен. Провалът не е в identifier или във формата. Той е в асоциацията — точно нещото, което езиковият модел произвежда чрез plausibility.
И така: моделът пише [1] и [2], и никога не пише link. Числата се отнасят до фрагменти, които сървърът е retrieve-нал, а сървърът — който знае точно от кой документ и кои offsets идва всяко число — добавя документа, етикета и URL след това. Няма какво моделът да измисли, защото никога не е помолен за единственото нещо, което би измислил.
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 };
}Пуснете го върху началния въпрос и четирите chunks стават prompt от 591 tokens и таблица, която моделът никога не вижда:
[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.605Locator е частта, която хората пропускат и после не могат да добавят. #char=25873,26272 е range в canonical text на документа; за PDF еквивалентът е #page=12, за audio или video #t=132.4,158.9, за spreadsheet — sheet и A1 range. Тези две не са измислици — #page= е PDF Open Parameters, а #t= е W3C Media Fragments, поддържани нативно от browsers върху video и audio elements. Цитат без locator е име на документ, а име на документ не е цитат; то е предложение потребителят да отиде и да търси.
А когато нищо не мине threshold, pipeline изобщо не стига до модела:
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"Това е по-евтин и по-надежден отказ от всяка инструкция в system prompt, защото е сравнение между две числа, а не молба към probabilistic system.
Оценявайте retriever отделно от генератора
Връзка към раздела: Оценявайте retriever отделно от генератораВсяко измерване в тази глава оценява retriever и нито веднъж не моли модел да напише отговор. Това е умишлено и е частта, която повечето екипи пропускат.
RAG система има два failure modes, които отвън изглеждат еднакво. Retriever не е намерил пасажа; или го е намерил, а генераторът го е игнорирал, противоречал му е или го е смесил с нещо, което вече е вярвал. Оценявате ли само финалния отговор, двете са неразличими, така че tune-вате prompts срещу проблем, който живее във вашия chunker. Recall@k, MRR и броят answer-destroyed не се нуждаят от generation call изобщо, достатъчно евтини са да се пускат при всеки deploy, и са harness от Глава 15 с различна scoring function — същата request, deadline, concurrency и tally, върху фиксиран набор въпроси вместо live conversation.
Докладвайте ги с интервали. Аритметиката от Глава 4 важи без промяна: при 64 queries recall от 0.5 носи 95 % Wilson interval от приблизително ±0.12, така че стратегия с четири пункта пред друга не ви е казала нищо. Използвайте paired test винаги когато и двете стратегии отговарят на същите въпроси, което тук винаги е така — именно това превърна "E изглежда по-добра от A" в p = 0.0015.
И последната честност: RAG намалява hallucination и не я премахва. Поставянето на правилния пасаж в prompt не задължава модела да го използва, а литературата го казва още от оригиналната статия.7 Две неща го влошават в production. Дългите contexts деградират — модел намира информация в началото и края на дълъг prompt по-надеждно, отколкото в средата, така че двадесет chunks вместо четири могат да понижат accuracy, докато вдигат сметката, ефект, измерен в Глава 24. И retrieval може да е правилен и пак недостатъчен, както показаха двата кеша по-горе. SelfCheckGPT маркира claims, които не оцеляват при resampling;8 Self-RAG обучава модела да emit-ва собствените си retrieve-and-critique tokens;9 TruthfulQA направи failure mode четим на първо място.10 Никое не затваря пропастта, а система, която представя retrieved text като доказателство, е объркала sourced с true.
Половината от системата, която работи преди всяка query
Връзка към раздела: Половината от системата, която работи преди всяка queryRetriever е видимата част от pipeline, чиито failures всички се случват по-рано, на тъмно. Три от тях се повтарят.
Extraction е мястото, където content умира. PDF не е текст; той е инструкции за рисуване. Двуколонните layouts се преплитат, tables стават word soup, page headers се повтарят във всеки chunk, а scanned page няма текст изобщо, докато OCR не му даде такъв, с confidence. Всичко измерено по-горе предполагаше, че extractor е свършил работата си; в production често не е, а симптомът се появява като лош retrieval три слоя по-нататък.
Индексът е подпечатан с модела, който го е построил. Embeddings от два модела не са сравними — не "по-малко точни", а несравними, защото са точки в различни пространства. Промените ли embedding model, всеки vector в store е боклук, докато не бъде построен отново. Затова model name, dimension count, pipeline version и extractor version се записват до всеки документ при index time. Без тях, в деня на upgrade, не можете да кажете кои документи са stale и кои са current, а half-migrated index връща уверена безсмислица без грешка никъде.
Един broken document не трябва да чупи folder, а counters трябва да броят какво се е случило. Документ, който fails extraction, завършва в състояние failed с причината си, видим и retryable, докато останалите деветдесет и девет остават searchable; а броят на indexed chunks се записва от сървъра, когато приключи, не се декларира от клиента при upload. Folder, който докладва 400 фрагмента и държи 40, е лъжа, която изплува само като въпрос без отговор.
Накъде продължава това
Връзка към раздела: Накъде продължава товаСистемата в тази глава отговаря на въпроси, чиито отговори са записани. Тя ги retrieve-ва, ранкира ги, отказва, когато не може, и цитира къде е гледала. Това е по-голямата част от онова, което хората искат от assistant върху собствените си документи, и е ограничено по един конкретен начин: retrieval може да върне само това, което някой е написал.
Което оставя другата половина. Част от това, което искате модел да прави, изобщо не е факт в документ — формат, който трябва да държи, тон, taxonomy с четиристотин labels, начин на решаване, който живее в десет хиляди минали examples и в нито един абзац никъде. Retrieval не може да достави тези неща, защото няма какво да retrieve-не; по-дълъг prompt само плаща сметката от Глава 16 за описание на skill вместо самия skill.
Глава 20 е това решение — fine-tune, retrieve или prompt — и нейният извод е, че решението е икономическо, преди да е техническо: трите са priced end to end върху един и същ въпрос, а crossover е token count. Въпросът, с който започва, е този, на който тази глава не може да отговори. Не къде е написан отговорът, а какво правите, когато никога не е бил написан.
Източници и метод
Връзка към раздела: Източници и методВсичко измерено в тази глава използва един корпус и един инструмент, и и двете са възпроизводими. Корпусът е глави 1 до 13 от този курс във вида им към 7 септември 2026 г. — 13 документа, 359 067 знака, 127 секции, с премахнати front matter и библиографии. Тези глави продължават да се редактират, така че прилагането на същото правило днес брои с няколко хиляди знака повече: броят секции е непроменен и така е и всеки извод по-долу, но общият брой знаци е snapshot и е обозначен като такъв. Ground truth са 32 въпроса, всеки сдвоен с дословно изречение, което се появява точно веднъж в корпуса и никога не е заглавие на секция, зададен в две формулировки за 64 queries. Retrieval embeddings са sentence-transformers/all-MiniLM-L6-v2 (384 dimensions, mean-pooled, L2-normalised, 256-token window); reranking е cross-encoder/ms-marco-MiniLM-L-6-v2 върху top 25; generation example е Qwen/Qwen2.5-0.5B-Instruct с greedy decoding. Всички timings са single-threaded CPU. Не е извикан платен API за създаването на тази глава, което е и причината всяка latency тук да е local и да е обозначена като такава.
Chunker, показан в TypeScript, е chunker, който беше измерен: Python instrument, имплементиращ същото правило и ts/chunk.ts, бяха сравнени chunk по chunk върху целия корпус и съвпадат за всичките 940 chunks, texts и offsets. Интервалите са Wilson при 95 %; paired comparisons са two-sided exact sign tests върху discordant pairs.
Всички четиринадесет identifiers, цитирани по-горе, бяха resolved срещу arXiv API и проверени title by title на 7 септември 2026 г. — което, предвид осемте, които не бяха, изглеждаше като най-малкото, което тази конкретна глава можеше да направи.
Препратки
Връзка към раздела: Препратки-
Robertson, S. and Zaragoza, H. The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval 3(4), pp. 333–389 (2009). Източникът на saturation function и на двете константи, използвани по-горе, и мястото да прочетете защо изобщо съществува. ↩
-
Cormack, G. V., Clarke, C. L. A. and Büttcher, S. Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods. SIGIR 2009. е тяхно, а смисълът на метода е, че не се нуждае от calibration между score scales, които слива. ↩
-
Khattab, O. and Zaharia, M. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT. arXiv:2004.12832 (2020). Средната позиция между dot product и cross-encoder. Reimers, N. and Gurevych, I., Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks, arXiv:1908.10084 (2019), е bi-encoder, върху който е построен индексът на тази глава и който беше измерен в Глава 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). Graph index зад повечето vector databases, които се продават в момента. ↩
-
Johnson, J., Douze, M. and Jégou, H. Billion-scale Similarity Search with GPUs. arXiv:1702.08734 (2017). FAISS и референтната имплементация на IVF, измерен в полето по-горе. ↩
-
Kalai, A. T., Nachum, O., Vempala, S. S. and Zhang, E. Why Language Models Hallucinate. arXiv:2509.04664 (2025). Аргументът, че hallucination се произвежда от binary-accuracy grading, който никога не възнаграждава abstention, и затова е evaluation problem, преди да е modelling problem. ↩
-
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). Статията, която даде име на pattern и която трябва да прочетете за това какво поправя и какво не. Guu et al., REALM: Retrieval-Augmented Language Model Pre-Training, arXiv:2002.08909 (2020), е съвременната работа, която обучава retriever заедно с модела, вместо да го bolt-on-ва; Karpukhin et al., Dense Passage Retrieval for Open-Domain Question Answering, arXiv:2004.04906 (2020), е мястото, откъдето идва two-encoder dense retriever, използван в цялата тази глава; а Izacard and Grave, Leveraging Passage Retrieval with Generative Models for Open Domain Question Answering, arXiv:2007.01282 (2020), е fusion-in-decoder arrangement за подаване на много passages към един generator. Gao et al., Retrieval-Augmented Generation for Large Language Models: A Survey, arXiv:2312.10997 (2023), е картата на всичко, което дойде след това, включително HyDE (Gao et al., Precise Zero-Shot Dense Retrieval without Relevance Labels, arXiv:2212.10496, 2022), който embed-ва хипотетичен отговор вместо въпроса. ↩
-
Manakul, P., Liusie, A. and Gales, M. J. F. SelfCheckGPT: Zero-Resource Black-Box Hallucination Detection for Generative Large Language Models. arXiv:2303.08896 (2023). Detection чрез resampling, без достъп до internals на модела и без external knowledge base. ↩
-
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). Training на модела да решава кога да retrieve-ва, вместо retrieval на всеки turn. ↩
-
Lin, S., Hilton, J. and Evans, O. TruthfulQA: Measuring How Models Mimic Human Falsehoods. arXiv:2109.07958 (2021). Benchmark, построен от въпроси, при които правдоподобният отговор и истинският отговор се различават, което е цялата трудност в едно изречение. ↩