RAG w produkcji: chunking, wyszukiwanie i uczciwe cytowania
Cięcie na ślepo co 512 znaków niszczy 4 z 32 odpowiedzi, zanim zobaczy je retriever. Sama poprawa chunkera przesuwa rank 115 na 3.
Na tej stronie
Oto prawdziwe pytanie od prawdziwego użytkownika prawdziwego asystenta: mój eval set ma 20 pozycji, czy to wystarczy, żeby ufać wynikowi. Corpus zawiera odpowiedź — całą jej sekcję. Oto cztery fragmenty, które retriever faktycznie włożył do 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…Trzy z czterech zaczynają się w środku słowa. Dwa pochodzą z innego rozdziału o innym temacie. A fragment, który odpowiada na pytanie — ten zawierający Siedemnaście z dwudziestu nie odróżni modelu 85% od modelu 65% — wrócił na rank 115.
Teraz to samo pytanie, ten sam embedding model, ten sam szablon prompt. Zmieniła się jedna rzecz: sposób cięcia dokumentów.
[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]Z rank 115 na rank 3. Nikt nie dotknął modelu, prompt, progu ani liczby slotów. Ten rozdział jest o tej luce — i o czterech innych miejscach, w których system retrieval po cichu cię okłamuje.
Pokaż szczegóły
Czego ten rozdział potrzebuje z wcześniejszych rozdziałów — i jedyne miejsce, w którym zmienia język.
- Rozdział 1 zdefiniował iloczyn skalarny i normę L2. Sekcja o progu poniżej to właśnie te dwie rzeczy i nic więcej.
- Rozdział 8 oddzielił tabelę embedding w modelu językowym od modelu embedding dla retrieval, trenowanego kontrastywnie na parach, mierzył podobieństwo cosinusowe i zakończył obietnicą, że rozdział 19 dojdzie do konkretnego progu odcięcia. Ta obietnica spełnia się tutaj. Nic z tego nie jest powtarzane.
- Rozdział 4 zbudował przedział Wilsona; Rozdział 15 zbudował evaluation harness. Każda tabela poniżej niesie pierwszy element i została wyprodukowana przez drugi.
- Rozdział 16 wycenił context window. Prompt złożony na końcu tego rozdziału kosztuje 591 tokens i to o ten budżet konkurują fragmenty.
Wszystko tutaj jest w TypeScript, jak od Rozdziału 14, i to ten rozdział jest miejscem, w którym reguła się zwraca: ingestion to kolejki i storage, search to wywołanie sieciowe, a składanie prompt z cytowaniami jest zadaniem serwera. Pomiar to celowo ten sam kod ze scoreboardem dookoła — retriever oceniany przez drugą implementację daje liczbę o oprogramowaniu, którego nie wysyłasz, a próg cosinusowy poniżej jest wiarygodny tylko dlatego, że widzisz, jak zamiata go chunker, który będzie działał w produkcji.
Corpus i to, co liczy się jako prawidłowa odpowiedź
Link do sekcji: Corpus i to, co liczy się jako prawidłowa odpowiedźWszystko poniżej jest mierzone na jednym corpusie: pierwszych trzynastu rozdziałach tego kursu — 13 dokumentów, 359 067 znaków, 127 sekcji, z usuniętym front matter i bibliografiami. To prawdziwy techniczny corpus, z prozą, tabelami, wzorami i blokami kodu, dokładnie taki, jaki ludzie ładują do knowledge base, a potem na niego narzekają.
Ground truth to 32 pytania, każde sparowane z igłą: krótkim dosłownym zdaniem z corpusu, które na nie odpowiada. Każda igła występuje dokładnie raz w 359 067 znakach i żadna nie jest nagłówkiem sekcji — ten check ma znaczenie, bo chunker, który kopiuje nagłówki do każdego chunk, w przeciwnym razie sam sobie nabiłby wynik. Każde pytanie zadano dwa razy: raz w kursowym angielskim i raz tak, jak sformułowałby je ticket do supportu: 64 queries dla 32 ground truths.
Retrieval jest poprawny, gdy zwrócony chunk zawiera igłę w całości. To jedyna definicja pasująca do tego, czego potrzebuje generator: pół zdania w prompt nie jest odpowiedzią, tylko zagrożeniem.
Embedding model to all-MiniLM-L6-v2 — 384 wymiary, mean-pooled i znormalizowany, kontrastywnie trenowany model zmierzony w rozdziale 8. Indeksowanie corpusu zajmuje 20,8 sekundy na CPU, 22 ms na chunk; embedding jednego query zajmuje 13 ms.
Chunking mierzony na sześć sposobów
Link do sekcji: Chunking mierzony na sześć sposobówSześć strategii z trzech niezależnych składników. Blind tnie co 512 znaków bez patrzenia na tekst. Boundaries nigdy nie tnie wewnątrz akapitu, wracając do granicy zdania tylko wtedy, gdy jeden akapit przekracza budżet. Header poprzedza każdy chunk tytułem dokumentu i ścieżką sekcji. Overlap kopiuje ostatnie 64 znaki poprzedniego chunk do następnego.
| 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 |
Przy 64 queries 95-procentowy przedział Wilsona dla R@20 wynosi [0.471, 0.705] dla A i [0.718, 0.901] dla E — te przedziały się nie nakładają, ale większość pozostałych kolumn już tak, a niesparowana tabela nie potrafi ich rozdzielić. Każda strategia odpowiada na te same queries, więc uczciwy test jest sparowany: policz zwycięstwa i porażki każdej strategii względem innej i uruchom test znaków na parach niezgodnych. Trzy wyniki to przechodzą.
Blind chunking niszczy cztery z trzydziestu dwóch odpowiedzi doszczętnie. Nie rankuje ich źle — niszczy je. Igła przecina granicę 512 znaków, więc żaden chunk w indeksie jej nie zawiera, a pułap recall dla tych queries wynosi zero. Żaden reranker ich nie odzyska, żaden próg nie pomoże, większy model nie pomoże. Nie możesz retrieve tekstu, który nigdzie w indeksie nie istnieje w jednym kawałku. To najbardziej niedoszacowana awaria w RAG, bo wygląda dokładnie jak zły retriever.
Overlap naprawia to i nic więcej. Każda strategia z overlap traci zero odpowiedzi — po to właśnie jest overlap. Nie poprawia rankingu: B przeciwko A przy R@8 to +8/−6, p = 0.79; przy R@20 to +9/−7, p = 0.80. Co gorsza, dodanie overlap na header aktywnie szkodzi — F przeciwko E to +2/−6 przy R@20 — a powód jest mechaniczny. Wektor chunk jest średnią po jego tokens, więc 64 znaki poprzedniego chunk ciągną tę średnią w stronę tematu sąsiada. Overlap to ubezpieczenie od rozciętej odpowiedzi, opłacone precision.
Kontekstowy header kupuje retrieval. E przeciwko A to +18/−3 przy R@20, p = 0.0015. A ablation mówi, że nie robią tego boundaries: E przeciwko C — te same cięcia, jedyną różnicą jest header — to +12/−2, p = 0.0129. Dodanie przed akapitem prefiksu „Klasyfikacja, entropia krzyżowa i jak nie oszukiwać samego siebie > Ilu przykładów testowych potrzebuję?” mówi embedding model, o czym jest akapit, czego sam akapit często nie mówi. To resolver zaimków dla dokumentów.
To nadaje chunkerowi kształt i jedną regułę, którą łatwo zepsuć:
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;
}Dwa teksty, nie jeden. text jest tym, co trafia do embedding — z headerem i całym overlap. content to tylko własne słowa tego chunk i to one są cytowane użytkownikowi. Zacytuj text, a cytowanie pokaże header, którego w tym miejscu dokumentu nie ma — oraz, przy overlap, powtórzony ogon należący do poprzedniego fragmentu. Wyświetli więc tekst, który nie znajduje się tam, gdzie twierdzi, że się znajduje, a to gorsze niż nie pokazać nic.
Header nie jest darmowy. W 940 chunks kosztuje 24 213 z 114 275 embedded tokens indeksu: 21,2% tego, za co płacisz przy embedding, to header napisany przez ciebie. Wypycha też chunks na granicę window encodera. all-MiniLM-L6-v2 przyjmuje 256 word-pieces; strategia E ma 17 chunks powyżej tej granicy, a F ma 28 — każdy z nich cicho obcięty, bez żadnego ostrzeżenia. Twój efektywny rozmiar chunk nie jest liczbą w configu — jest mniejszą wartością z tej liczby i window encodera.
Dwadzieścia linijek BM25, które wszyscy pomijają
Link do sekcji: Dwadzieścia linijek BM25, które wszyscy pomijająDense retrieval ma jedną systematyczną słabość i nie jest ona subtelna: dopasowuje znaczenie, więc jest obojętny na to, jaki dokładnie string wpisałeś. Numer części, kod błędu, akronim, nazwisko — żadne z nich nie ma użytecznego znaczenia do embedding, a najbliższym sąsiadem kodu błędu jest każdy inny kod błędu w twoim corpusie.
Klasyczna odpowiedź jest starsza niż to wszystko i zajmuje dwadzieścia linijek. BM25 ocenia dokument na podstawie tego, jak często występują w nim termy query, tłumiąc każdy term wraz ze wzrostem jego częstości i karząc długie dokumenty, które zbierają dopasowania samą długością.1 Term wnosi
gdzie to liczba wystąpień termu w dokumencie, jego długość, średnia długość, a i to dwie konwencjonalne stałe — ustawia, jak szybko powtórzenia przestają pomagać, jak mocno karana jest długość.
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;
});
}
}Na 940 chunks ocenia query w 1,14 ms, bez żadnego indeksu poza dwiema hash maps. I nie jest eksponatem muzealnym:
| retriever | R@1 | R@4 | R@8 | MRR | cost per query |
|---|---|---|---|---|---|
| dense (cosine) | 0.094 | 0.422 | 0.578 | 0.280 | 13 ms na embedding + 0.3 ms na scan |
| lexical (BM25) | 0.219 | 0.375 | 0.469 | 0.313 | 1.14 ms |
| hybrid (RRF) | 0.203 | 0.484 | 0.609 | 0.346 | oba |
| hybrid + cross-encoder | 0.312 | 0.578 | 0.703 | 0.447 | + 569 ms |
BM25 ponad dwukrotnie zwiększa top-1 accuracy dense retrievera na tym corpusie i mocno z nim przegrywa przy rank 8. Psują się na innych queries, i to jest cały argument za uruchamianiem obu.
Łączenie ich to jedno miejsce, w którym oczywiste podejście jest błędne. Odległości cosinusowe i wyniki BM25 nie są na tej samej skali, nie są tak samo ograniczone, a normalizowanie ich per query sprawia, że waga zależy od tego, jak dobry akurat był najlepszy hit. Reciprocal rank fusion wyrzuca scores i zostawia tylko ranki: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 tutaj uczciwe czytanie tabeli jest ważniejsze niż sama tabela. Hybrid bije BM25 przy R@4 o +10/−3, p = 0.09. Bije dense o +10/−6, p = 0.45. Na tym corpusie, przy 64 queries, hybrid retrieval nie da się odróżnić od dense retrieval. Jest lepszy w estymatach punktowych i w każdej kolumnie recall, ale dowód nie osiąga istotności. Prawie każdy wpis blogowy o hybrid search w internecie raportuje tabelę jak powyższa i żadnego przedziału; oto, co mówi przedział.
Bi-encoder, cross-encoder i gdzie naprawdę jest zysk
Link do sekcji: Bi-encoder, cross-encoder i gdzie naprawdę jest zyskWszystko dotąd było bi-encoderem: query przechodzi przez model samo, każdy chunk przeszedł przez niego sam miesiące temu, a oba nigdy się nie spotykają poza iloczynem skalarnym. To umożliwia indeks — embed raz, używaj zawsze — i to jest też sufit. Model nigdy nie patrzy na query i chunk razem.
Cross-encoder robi dokładnie to: bierze parę jako jedno wejście i zwraca score relewantności. Niczego nie da się wstępnie policzyć, więc nie może rankować indeksu — ale może rerankować shortlistę. Reranking hybrydowego top 25 z ms-marco-MiniLM-L-6-v2 przesuwa R@1 z 0.094 (dense) do 0.312, a MRR z 0.280 do 0.447: największa pojedyncza poprawa w tym rozdziale i jedyna, która dotyka góry listy zamiast ogona.
Kosztuje 569 ms na query na CPU, wobec 1,14 ms dla BM25 i 0,3 ms dla scan wektorowego. Około dwa tysiące razy koszt retrieval dla dwudziestu pięciu dokumentów. To cały trade-off bi-encoder/cross-encoder w jednej liczbie i dlatego architektura zawsze ma ten sam kształt: tani retriever z szerokim recall, potem drogi scorer na shortliście, na którą cię stać. ColBERT leży między nimi: wstępnie liczy wektory per-token i robi late interaction tańsze niż cross-encoder i ostrzejsze niż iloczyn skalarny.3
L2, cosinus i próg, na który jeszcze nie zasłużyłeś
Link do sekcji: L2, cosinus i próg, na który jeszcze nie zasłużyłeśVector databases raportują odległości, a wybór odległości jest opcją konfiguracji. Na znormalizowanych wektorach wybór jest kosmetyczny, a tożsamość warto zrobić raz, bo wszystko po niej zależy od tego, czy wektory naprawdę są jednostkowe. Dla :
więc cosinusowa odległość jest dokładnie . To iloczyn skalarny i norma z rozdziału 1, zrealizowane. Sprawdzone na dwóch prawdziwych wektorach chunk z indeksu powyżej, a potem na 40 000 par:
||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-07Dokładne do szumu floating-point — i tylko dlatego, że wektory są znormalizowane. Pomiń normalizację, a tożsamość jest fałszywa, twój próg nic nie znaczy, a odległość raportowana przez dokument zależy od tego, jak długi był jego tekst.
Teraz liczba, której nikt nie wyprowadza. Retriever zawsze coś zwraca: sortuje cały indeks i podaje górę listy, niezależnie od tego, czy odpowiedź w ogóle jest w corpusie. Próg jest jedyną częścią systemu, która może powiedzieć nie — a żeby go ustawić, potrzebujesz queries, które powinny nie zwrócić nic. Oto trzydzieści: dwadzieścia jeden o rzeczach, których ten corpus naprawdę nie obejmuje — streaming, rate limits, prompt caching, JSON schemas, pętle agent, vector databases, prompt injection, image generation — i dziewięć o paelli, paszportach i politykach zwrotów. Wobec tego samego indeksu:
| 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 |
Rozkłady się rozdzielają i nakładają. Najgorsze in-domain query jest dalej od swojej odpowiedzi (0.721) niż najlepsze out-of-domain query od nieistotnego akapitu (0.497), więc żaden próg nie zrobi dobrze obu rzeczy. Sweep po prawdziwej bramce — zachowaj najwyżej cztery chunks i tylko te pod progiem:
| 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 |
Ostatnią kolumnę czytaj jako blefy. Bez progu asystent produkuje pewną, dobrze cytowaną odpowiedź na „jak odnowić hiszpański paszport” z corpusu o backpropagation, trzydzieści razy na trzydzieści. Przy 0.675 robi to dziesięć razy na trzydzieści. Przy 0.525 robi to dwa razy i poddaje się przy dwunastu pytaniach, na które mógł odpowiedzieć.
Ten trade-off jest decyzją produktową, a właściwy koniec zależy od tego, ile kosztuje cię zła odpowiedź. Nienegocjowalne jest to, że ostatnia kolumna w ogóle istnieje. Jeśli nigdy nie mierzyłeś retrievera na pytaniach, których powinien odmówić, nie masz progu — masz liczbę.
Dwa z dziesięciu blefów przy 0.675 pokazują dwa sposoby tej porażki.
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."Pierwszy to near miss: corpus szczegółowo wyjaśnia KV cache, query dotyczy prompt cache, słowa są tymi samymi słowami, a 0.497 jest bliżej niż większość poprawnych in-domain retrievals w całym eksperymencie. Embedding nie wie, że dwa cache o tej samej nazwie są różnymi maszynami. Drugi to literal match bez odpowiedzi: corpus zawiera dokładną frazę „jaka jest stolica Francji”, używaną jako przykład pytania, które nie wymaga rozumowania. Retriever ma rację; odpowiedzi tam nie ma. Każdy system, który czyta „znalazłem coś podobnego” jako „znalazłem odpowiedź”, stwierdzi na tej podstawie Paryż — albo, co gorsza, nie stwierdzi.
Dlaczego cytowania nie pisze model
Link do sekcji: Dlaczego cytowania nie pisze modelModel nie ma osobnej zdolności do faktów. Produkowanie prawdziwego zdania i produkowanie wiarygodnie brzmiącego zdania to ta sama operacja — next-token prediction z rozdziału 8 — i nic w tej operacji nie oznacza, które jest które. Analiza z 2025 roku, która przeformułowała ten problem, argumentuje, że pipeline treningu i ewaluacji aktywnie nagradza zgadywanie: benchmarki oceniają binarną accuracy i nie dają punktów za wstrzymanie się, więc model, który odpowiada zawsze, bije identyczny model mówiący „nie wiem”, kiedy nie wie, a post-training optymalizuje się odpowiednio.6 Hallucination w tym ujęciu nie jest tajemniczą wadą. To wynik oceniania testu wyboru bez kary za złą odpowiedź.
Zobacz kształt tej awarii. Poproszony o osiem prac o kontrastywnych sentence embeddings, z identyfikatorami, Qwen2.5-0.5B-Instruct wyprodukował osiem linii w idealnym formacie. Wszystkie osiem identyfikatorów jest poprawnie sformatowanych. Wszystkie osiem prowadzi do prawdziwych prac na arXiv. Zero z ośmiu jest pracą, za którą się podaje.
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 SomaliTo mały model i częstość jest jego własna; frontier model wymyśla znacznie mniej. Mechanizm się uogólnia i jest powodem reguły, która następuje. Validator sprawdzający „czy ten identyfikator istnieje” przepuszcza wszystkie osiem, a użytkownik, który kliknie jeden z nich, ląduje na prawdziwej stronie z prawdziwego archiwum, bez sposobu, by zobaczyć, że mapowanie zostało wymyślone. Awaria nie jest w identyfikatorze ani formacie. Jest w skojarzeniu — dokładnie w tym, co model językowy produkuje przez prawdopodobieństwo.
A więc: model pisze [1] i [2], i nigdy nie pisze linku. Numery odnoszą się do fragmentów retrieved przez serwer, a serwer — który dokładnie wie, z jakiego dokumentu i jakich offsetów pochodzi każdy numer — dokleja później dokument, etykietę i URL. Model nie ma czego wymyślić, bo nigdy nie prosisz go o tę jedną rzecz, którą by wymyślił.
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 };
}Uruchom to na pytaniu otwierającym, a cztery chunks stają się prompt o długości 591 tokens i tabelą, której model nigdy nie widzi:
[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 to część, którą ludzie pomijają, a potem nie mogą jej dodać. #char=25873,26272 to zakres w kanonicznym tekście dokumentu; dla PDF odpowiednikiem jest #page=12, dla audio lub wideo #t=132.4,158.9, dla arkusza — sheet i zakres A1. Te dwa elementy nie są wymysłami — #page= to PDF Open Parameters, a #t= to W3C Media Fragments, natywnie obsługiwane przez przeglądarki na elementach wideo i audio. Cytowanie bez locatora jest nazwą dokumentu, a nazwa dokumentu nie jest cytowaniem; jest sugestią, żeby użytkownik poszedł i poszukał.
A gdy nic nie przechodzi progu, pipeline w ogóle nie dociera do modelu:
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"To tańsza i bardziej niezawodna odmowa niż jakakolwiek instrukcja w system prompt, bo jest porównaniem dwóch liczb, a nie prośbą do systemu probabilistycznego.
Ewaluuj retriever osobno od generatora
Link do sekcji: Ewaluuj retriever osobno od generatoraKażdy pomiar w tym rozdziale ocenia retriever i ani razu nie prosi modelu o napisanie odpowiedzi. To celowe i właśnie ten element pomija większość zespołów.
System RAG ma dwa tryby awarii, które z zewnątrz wyglądają identycznie. Retriever nie znalazł pasażu; albo znalazł go, a generator go zignorował, zaprzeczył mu lub zmieszał z czymś, w co już wierzył. Oceniaj tylko finalną odpowiedź, a tych dwóch rzeczy nie da się odróżnić, więc tune’ujesz prompts przeciwko problemowi, który mieszka w chunkerze. Recall@k, MRR i licznik answer-destroyed w ogóle nie potrzebują call do generation, są wystarczająco tanie, by uruchamiać je na każdym deploy, i są harness z rozdziału 15 z inną funkcją scoring — ten sam request, deadline, concurrency i tally, na stałym zestawie pytań zamiast live conversation.
Raportuj je z przedziałami. Arytmetyka z rozdziału 4 działa bez zmian: przy 64 queries recall 0,5 ma 95-procentowy przedział Wilsona około ±0,12, więc strategia wyprzedzająca inną o cztery punkty nie powiedziała ci nic. Używaj testu sparowanego zawsze, gdy obie strategie odpowiadają na te same pytania, co tutaj robią zawsze — to on zmienił „E wygląda lepiej niż A” w p = 0.0015.
I ostatnia uczciwość: RAG zmniejsza hallucination, ale jej nie usuwa. Włożenie właściwego pasażu do prompt nie zmusza modelu, by go użył, a literatura mówi o tym od oryginalnego paperu.7 Dwie rzeczy pogarszają to w produkcji. Długie contexts degradują — model znajduje informacje na początku i na końcu długiego prompt bardziej niezawodnie niż w środku, więc dwadzieścia chunks zamiast czterech może obniżyć accuracy i podnieść rachunek, efekt zmierzony w Rozdziale 24. A retrieval może być poprawny i nadal niewystarczający, jak pokazały dwa cache powyżej. SelfCheckGPT flaguje twierdzenia, które nie przeżywają resamplingu;8 Self-RAG trenuje model do emitowania własnych tokens retrieve-and-critique;9 TruthfulQA w ogóle uczyniło ten tryb awarii czytelnym.10 Żadne nie zamyka luki, a system prezentujący retrieved text jako dowód pomylił uźródłowione z prawdziwe.
Połowa systemu, która działa przed jakimkolwiek query
Link do sekcji: Połowa systemu, która działa przed jakimkolwiek queryRetriever jest widoczną częścią pipeline, którego awarie dzieją się wcześniej, po ciemku. Trzy z nich wracają regularnie.
Extraction to miejsce, gdzie treść umiera. PDF nie jest tekstem; jest instrukcjami rysowania. Układy dwukolumnowe się przeplatają, tabele zmieniają się w zupę słów, nagłówki stron powtarzają się w każdym chunk, a zeskanowana strona nie ma żadnego tekstu, dopóki OCR jej go nie da, wraz z confidence. Wszystko zmierzone powyżej zakładało, że extractor wykonał swoją pracę; w produkcji często tego nie robi, a symptom pojawia się trzy warstwy dalej jako zły retrieval.
Indeks jest opieczętowany modelem, który go zbudował. Embeddings z dwóch modeli nie są porównywalne — nie „mniej dokładne”, tylko nieporównywalne, bo są punktami w różnych przestrzeniach. Zmień embedding model, a każdy wektor w store jest śmieciem, dopóki nie zostanie przebudowany. Dlatego nazwa modelu, liczba wymiarów, wersja pipeline i wersja extractora są zapisywane przy każdym dokumencie w czasie indeksowania. Bez nich, w dniu upgrade, nie wiesz, które dokumenty są przestarzałe, a które aktualne, a indeks po połowicznej migracji zwraca pewne bzdury bez błędu gdziekolwiek.
Jeden zepsuty dokument nie może zepsuć folderu, a liczniki muszą liczyć to, co się stało. Dokument, którego extraction się nie udał, kończy w stanie failed ze swoim powodem, widoczny i możliwy do retry, podczas gdy pozostałe dziewięćdziesiąt dziewięć pozostaje searchable; a liczba zindeksowanych chunks jest zapisywana przez serwer, gdy skończy, nie deklarowana przez klienta przy upload. Folder, który raportuje 400 fragmentów i trzyma 40, jest kłamstwem, które wypływa dopiero jako pytanie bez odpowiedzi.
Dokąd to prowadzi dalej
Link do sekcji: Dokąd to prowadzi dalejSystem w tym rozdziale odpowiada na pytania, których odpowiedzi są zapisane. Retrieve je, rankuje, odmawia, gdy nie może, i cytuje miejsca, w których szukał. To większość tego, czego ludzie chcą od asystenta nad własnymi dokumentami, i ma jedno konkretne ograniczenie: retrieval może zwrócić tylko to, co ktoś napisał.
Zostaje więc druga połowa. Część tego, co chcesz, żeby model robił, wcale nie jest faktem w dokumencie — format, który musi utrzymać, ton, taksonomia z czterystoma etykietami, sposób decydowania żyjący w dziesięciu tysiącach dawnych przykładów i w żadnym akapicie. Retrieval tego nie dostarczy, bo nie ma czego retrieve; dłuższy prompt tylko płaci rachunek z rozdziału 16 za opis skill zamiast za skill.
Rozdział 20 jest tą decyzją — fine-tune, retrieve czy prompt — a jego wniosek brzmi, że decyzja jest ekonomiczna, zanim stanie się techniczna: wszystkie trzy są wycenione end to end na tym samym pytaniu, a punkt przecięcia jest liczbą tokens. Pytanie, które go otwiera, jest tym, na które ten rozdział nie potrafi odpowiedzieć. Nie gdzie odpowiedź jest zapisana, tylko co robisz, kiedy nigdy nie była.
Źródła i metoda
Link do sekcji: Źródła i metodaWszystko zmierzone w tym rozdziale używało jednego corpusu i jednego instrumentu, a oba są odtwarzalne. Corpus to rozdziały 1–13 tego kursu w stanie z 7 września 2026 — 13 dokumentów, 359 067 znaków, 127 sekcji, usunięty front matter i bibliografie. Te rozdziały są nadal edytowane, więc zastosowanie tej samej reguły dziś daje o kilka tysięcy znaków więcej: liczba sekcji jest bez zmian i każdy wniosek poniżej też, ale suma znaków jest migawką i jest tak oznaczona. Ground truth to 32 pytania, każde sparowane z dosłownym zdaniem występującym dokładnie raz w corpusie i nigdy jako nagłówek sekcji, zadane w dwóch sformułowaniach dla 64 queries. Retrieval embeddings to sentence-transformers/all-MiniLM-L6-v2 (384 wymiary, mean-pooled, L2-normalised, 256-token window); reranking to cross-encoder/ms-marco-MiniLM-L-6-v2 na top 25; przykład generation to Qwen/Qwen2.5-0.5B-Instruct z greedy decoding. Wszystkie timingi są single-threaded CPU. Nie wywołano żadnego płatnego API, aby stworzyć ten rozdział, dlatego każde latency tutaj jest lokalne i tak oznaczone.
Chunker pokazany w TypeScript to chunker, który był mierzony: instrument w Pythonie implementujący tę samą regułę i ts/chunk.ts porównano chunk po chunk na całym corpusie i zgadzają się dla wszystkich 940 chunks, tekstów i offsetów. Przedziały to Wilson 95%; porównania sparowane to dwustronne dokładne testy znaków na parach niezgodnych.
Wszystkie czternaście identyfikatorów cytowanych powyżej sprawdzono przez arXiv API i title by title 7 września 2026 — co, biorąc pod uwagę osiem, które nimi nie były, wydawało się minimum, jakie ten konkretny rozdział mógł zrobić.
Przypisy
Link do sekcji: Przypisy-
Robertson, S. and Zaragoza, H. The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval 3(4), pp. 333–389 (2009). Źródło funkcji saturacji i dwóch stałych użytych powyżej oraz miejsce, w którym warto przeczytać, po co w ogóle istnieje . ↩
-
Cormack, G. V., Clarke, C. L. A. and Büttcher, S. Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods. SIGIR 2009. jest ich, a sedno metody polega na tym, że nie potrzebuje kalibracji między skalami score, które łączy. ↩
-
Khattab, O. and Zaharia, M. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT. arXiv:2004.12832 (2020). Środek między iloczynem skalarnym a cross-encoderem. Reimers, N. and Gurevych, I., Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks, arXiv:1908.10084 (2019), to bi-encoder, na którym zbudowany jest indeks tego rozdziału i który mierzono w rozdziale 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). Indeks grafowy stojący za większością obecnie sprzedawanych vector databases. ↩
-
Johnson, J., Douze, M. and Jégou, H. Billion-scale Similarity Search with GPUs. arXiv:1702.08734 (2017). FAISS oraz referencyjna implementacja IVF mierzonego w ramce powyżej. ↩
-
Kalai, A. T., Nachum, O., Vempala, S. S. and Zhang, E. Why Language Models Hallucinate. arXiv:2509.04664 (2025). Argument, że hallucination produkuje ocenianie binarnej accuracy, które nigdy nie nagradza abstynencji, a więc jest to najpierw problem ewaluacji, dopiero potem modelowania. ↩
-
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). Paper, który nazwał ten wzorzec, i tekst, który warto przeczytać, żeby zrozumieć, co naprawia, a czego nie. Guu et al., REALM: Retrieval-Augmented Language Model Pre-Training, arXiv:2002.08909 (2020), to równoległa praca trenująca retriever wspólnie z modelem zamiast doklejać go później; Karpukhin et al., Dense Passage Retrieval for Open-Domain Question Answering, arXiv:2004.04906 (2020), to źródło dwuencoderowego dense retrievera używanego w całym tym rozdziale; a Izacard and Grave, Leveraging Passage Retrieval with Generative Models for Open Domain Question Answering, arXiv:2007.01282 (2020), to układ fusion-in-decoder do podawania wielu pasaży jednemu generatorowi. Gao et al., Retrieval-Augmented Generation for Large Language Models: A Survey, arXiv:2312.10997 (2023), to mapa wszystkiego, co przyszło później, w tym HyDE (Gao et al., Precise Zero-Shot Dense Retrieval without Relevance Labels, arXiv:2212.10496, 2022), który embed hipotetyczną odpowiedź zamiast pytania. ↩
-
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). Detekcja przez resampling, bez dostępu do wnętrza modelu i bez zewnętrznej 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). Trenowanie modelu, by decydował, kiedy retrieve, zamiast retrieve na każdym turn. ↩
-
Lin, S., Hilton, J. and Evans, O. TruthfulQA: Measuring How Models Mimic Human Falsehoods. arXiv:2109.07958 (2021). Benchmark zbudowany z pytań, w których wiarygodna odpowiedź i prawdziwa odpowiedź się różnią — cała trudność w jednym zdaniu. ↩