RAG в production: chunking, поиск и честные цитаты
Слепой разрез на 512 символах ломает 4 из 32 ответов до retriever. Один chunker переносит ранг 115 на 3.
На этой странице
Вот реальный вопрос от реального пользователя реального ассистента: в моем eval-наборе 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…Три из четырех начинаются с середины слова. Два взяты из другой главы о другой теме. А фрагмент, который отвечает на вопрос — тот, где есть Seventeen out of twenty cannot distinguish an 85 % model from a 65 % one, — вернулся на ранге 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, порог или число слотов. Эта глава — об этом разрыве и еще о четырех местах, где система поиска тихо вам лжет.
Показать детали
Что этой главе нужно из предыдущих глав и одно место, где меняется язык.
- Глава 1 определила скалярное произведение и L2-норму. Раздел о порогах ниже — это ровно эти две вещи и ничего больше.
- Глава 8 отделила embedding-таблицу языковой модели от retrieval embedding model, обученной контрастивно на парах, измерила cosine similarity и закончилась обещанием, что глава 19 дойдет до конкретного порога отсечения. Здесь это обещание исполняется. Ничего из этого не повторяется.
- Глава 4 построила интервал Уилсона; глава 15 построила evaluation harness. Каждая таблица ниже несет первый и была получена вторым.
- Глава 16 оценила стоимость context window. Prompt, собранный в конце этой главы, стоит 591 tokens, и именно за этот бюджет конкурируют фрагменты.
Все здесь написано на TypeScript, как и с главы 14, и эта глава — та, где правило оправдывает себя: ingestion — это очереди и хранилище, поиск — сетевой вызов, а сборка prompt с цитатами — работа сервера. Измерение намеренно является тем же кодом с таблицей результатов вокруг него: retriever, оцененный второй реализацией, дает число о программе, которую вы не поставляете, а cosine threshold ниже заслуживает доверия только потому, что вы видите, как его прогоняет chunker, который будет работать в production.
Корпус и что считается правильным ответом
Ссылка на раздел: Корпус и что считается правильным ответомВсе ниже измеряется на одном корпусе: первые тринадцать глав этого курса — 13 документов, 359 067 символов, 127 разделов, без front matter и библиографий. Это реальный технический корпус с прозой, таблицами, формулами и блоками кода, и это ровно тот тип материала, который люди загружают в knowledge base, а потом жалуются.
Ground truth — 32 вопроса, каждый в паре с needle: коротким дословным предложением из корпуса, которое на него отвечает. Каждая needle встречается ровно один раз в 359 067 символах, и ни одна не является заголовком раздела — эта проверка важна, потому что chunker, который копирует заголовки в каждый chunk, иначе сам себе поднял бы оценку. Каждый вопрос задается дважды: один раз на языке курса и один раз так, как это сформулировал бы тикет в поддержку: 64 запроса на 32 ground truths.
Retrieval считается правильным, когда возвращенный chunk содержит needle целиком. Это единственное определение, совпадающее с тем, что нужно генератору: половина предложения в prompt — не ответ, а риск.
Embedding model — all-MiniLM-L6-v2: 384 измерения, mean-pooled и нормализованная, контрастивно обученная модель, которую измеряла глава 8. Индексация корпуса занимает 20,8 секунды на CPU, 22 мс на chunk; embedding одного запроса занимает 13 мс.
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 запросах 95 % интервал Уилсона для R@20 равен [0.471, 0.705] для A и [0.718, 0.901] для E — они не пересекаются, но большинство других столбцов пересекаются, а непарная таблица не может их разделить. Каждая стратегия отвечает на одни и те же запросы, поэтому честный тест — парный: посчитать победы и поражения каждой стратегии против другой и запустить знаковый тест на несовпадающих парах. Три результата его переживают.
Blind chunking полностью уничтожает четыре из тридцати двух ответов. Не плохо ранжирует — уничтожает. Needle пересекает границу в 512 символов, поэтому ни один chunk в индексе не содержит ее, а потолок recall для этих запросов равен нулю. Никакой reranker их не восстановит, никакой порог не поможет, более крупная модель не поможет. Нельзя извлечь текст, который нигде в вашем индексе не лежит одним куском. Это самый недооцененный отказ в RAG, потому что он выглядит точно как плохой retriever.
Overlap исправляет это и больше ничего. Все стратегии с overlap теряют ноль ответов — для этого overlap и нужен. Ранжирование он не улучшает: 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.
Contextual 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, о чем этот абзац, чего сам абзац часто не сообщает. Это разрешитель местоимений для документов.
Отсюда получается форма 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 — и citation покажет 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, и каждый из них молча обрезается без каких-либо предупреждений. Ваш эффективный размер chunk — не число в конфиге, а минимум между ним и окном вашего encoder.
Двадцать строк BM25, которые все пропускают
Ссылка на раздел: Двадцать строк BM25, которые все пропускаютУ dense retrieval есть одна системная слабость, и она не тонкая: он сопоставляет смысл, поэтому ему безразлично, какую именно строку вы ввели. Номер детали, код ошибки, акроним, фамилия — ничто из этого не имеет полезного смысла для embedding, а ближайший сосед кода ошибки — любой другой код ошибки в вашем корпусе.
Классический ответ старше всего этого и занимает двадцать строк. BM25 оценивает документ по тому, как часто в нем встречаются термины запроса, приглушая вклад каждого термина по мере роста его частоты и штрафуя длинные документы, которые набирают совпадения одной лишь длиной.1 Термин дает вклад
где — число вхождений термина в документ, — его длина, — средняя длина, а и — две общепринятые константы: задает, как быстро повторение перестает помогать, — насколько сильно штрафуется длина.
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 это оценивает запрос за 1,14 мс вообще без индекса, кроме двух hash map. И это не музейный экспонат:
| retriever | R@1 | R@4 | R@8 | MRR | стоимость на запрос |
|---|---|---|---|---|---|
| dense (cosine) | 0.094 | 0.422 | 0.578 | 0.280 | 13 мс на embedding + 0,3 мс на сканирование |
| lexical (BM25) | 0.219 | 0.375 | 0.469 | 0.313 | 1,14 мс |
| hybrid (RRF) | 0.203 | 0.484 | 0.609 | 0.346 | оба |
| hybrid + cross-encoder | 0.312 | 0.578 | 0.703 | 0.447 | + 569 мс |
BM25 более чем удваивает top-1 точность dense retriever на этом корпусе и сильно проигрывает ему к рангу 8. Они ошибаются на разных запросах — это и есть весь аргумент за то, чтобы запускать оба.
Объединение — единственное место, где очевидный подход неверен. Cosine distances и оценки BM25 не на одной шкале, не ограничены одинаково, а нормализация по запросу заставляет вес зависеть от того, насколько хорошим случайно оказался лучший hit. Reciprocal rank fusion выбрасывает оценки и оставляет только ранги: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 запросами, hybrid retrieval неотличим от dense retrieval. Он лучше по обеим точечным оценкам и в каждом столбце recall, но доказательств недостаточно для значимости. Почти каждый пост про hybrid search в интернете показывает таблицу вроде той, что выше, и не показывает интервал; вот что говорит интервал.
Bi-encoder, cross-encoder и где на самом деле появляется прирост
Ссылка на раздел: Bi-encoder, cross-encoder и где на самом деле появляется приростВсе до сих пор было bi-encoder: запрос проходит через модель один, каждый chunk прошел через нее один много месяцев назад, и они никогда не встречаются, кроме как в скалярном произведении. Именно это делает индекс возможным — embed один раз, переиспользуй всегда, — и это же является потолком. Модель никогда не смотрит на запрос и chunk вместе.
Cross-encoder делает ровно это: принимает пару как один вход и возвращает оценку релевантности. Ничего нельзя предвычислить, поэтому он не может ранжировать индекс, но может rerank короткий список. Reranking hybrid top 25 с ms-marco-MiniLM-L-6-v2 поднимает R@1 с 0.094 (dense) до 0.312, а MRR с 0.280 до 0.447: это крупнейшее отдельное улучшение в главе и единственное, которое касается вершины списка, а не хвоста.
Оно стоит 569 мс на запрос на CPU против 1,14 мс для BM25 и 0,3 мс для векторного сканирования. Примерно в две тысячи раз дороже retrieval, для двадцати пяти документов. Вся сделка bi-encoder/cross-encoder в одном числе, и поэтому архитектура всегда имеет одну и ту же форму: дешевый retriever с широким recall, затем дорогой scorer на коротком списке, который вы можете себе позволить. ColBERT находится между ними: предвычисляет векторы на token и делает late interaction, более дешевое, чем cross-encoder, и более точное, чем скалярное произведение.3
L2, cosine и порог, который вы еще не заслужили
Ссылка на раздел: L2, cosine и порог, который вы еще не заслужилиВекторные базы данных сообщают distances, и выбор distance — это опция конфигурации. На нормализованных векторах выбор косметический, а тождество стоит проделать один раз, потому что все дальнейшее зависит от того, действительно ли векторы единичные. Для :
поэтому cosine distance ровно равен . Это скалярное произведение и норма из главы 1, предъявленные к оплате. Проверено на двух реальных векторах chunk из индекса выше, а затем на 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 шума — и только потому, что векторы нормализованы. Пропустите нормализацию — и тождество ложно, ваш порог ничего не значит, а distance, который сообщает документ, зависит от длины его текста.
Теперь число, которое никто не выводит. Retriever всегда что-то возвращает: он сортирует весь индекс и отдает верх списка, независимо от того, есть ли ответ где-либо в корпусе. Порог — единственная часть системы, которая может сказать нет, а чтобы его поставить, нужны запросы, на которые не должно ничего возвращаться. Вот тридцать: двадцать один о вещах, которые этот корпус действительно не покрывает — streaming, rate limits, prompt caching, JSON schemas, agent loops, vector databases, prompt injection, image generation, — и девять о паэлье, паспортах и правилах возврата. На том же индексе:
| top-1 cosine distance | |
|---|---|
| in-domain queries, все 64 | среднее 0.445, диапазон 0.270 – 0.721 |
| in-domain, top-1 действительно правильный | среднее 0.370 |
| in-domain, top-1 неправильный | среднее 0.452 |
| out-of-domain, все 30 | среднее 0.699, диапазон 0.497 – 0.867 |
Распределения разделяются, и они пересекаются. Худший in-domain запрос дальше от своего ответа (0.721), чем лучший out-of-domain запрос от нерелевантного абзаца (0.497), поэтому ни один порог не делает оба случая правильными. Прогоняем его через реальный gate — оставить максимум четыре chunks и только те, что ниже отсечения:
| порог | in-domain отвечено | из них ответ был внутри | out-of-domain отвечено |
|---|---|---|---|
| 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 |
| нет | 64 / 64 | 27 | 30 / 30 |
Читайте последний столбец как блефы. Без порога ассистент выдает уверенный, хорошо процитированный ответ на "how do I renew my Spanish passport" из корпуса про backpropagation тридцать раз из тридцати. При 0.675 он делает это десять раз из тридцати. При 0.525 — два раза, и отказывается от двенадцати вопросов, на которые мог бы ответить.
Этот компромисс — продуктовое решение, и правильная его точка зависит от того, сколько вам стоит неправильный ответ. Что не обсуждается — это само существование последнего столбца. Если вы никогда не измеряли retriever на вопросах, от которых он должен отказаться, у вас нет порога — у вас есть число.
Два из десяти блефов при 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, запрос о prompt cache, слова те же самые, и 0.497 ближе, чем большинство правильных in-domain retrieval во всем эксперименте. Embedding не знает, что два cache с одинаковым именем — разные машины. Второй — буквальное совпадение без ответа: корпус содержит точную фразу "what is the capital of France", использованную как пример вопроса, которому не нужно рассуждение. Retriever прав; ответа там нет. Любая система, которая читает "я нашла что-то похожее" как "я нашла ответ", на таком доказательстве заявит Париж — или, что хуже, не заявит.
Почему citation не пишет модель
Ссылка на раздел: Почему citation не пишет модельУ модели нет отдельной способности для фактов. Производство истинного предложения и производство правдоподобного — это одна и та же операция, next-token prediction из главы 8, и ничто в этой операции не помечает, что есть что. Анализ 2025 года, переосмысливший это, утверждает, что пайплайн обучения и оценки активно вознаграждает угадывание: benchmark оценивают бинарной accuracy и не дают credit за abstention, поэтому модель, которая всегда отвечает, получает более высокий результат, чем идентичная модель, которая говорит "I don't know", когда не знает, а post-training оптимизирует именно это.6 В таком прочтении hallucination — не загадочный дефект. Это то, что получается, когда вы проверяете тест с несколькими вариантами без штрафа за неправильный ответ.
Посмотрите на форму. На просьбу назвать восемь статей о contrastive sentence embeddings, с идентификаторами, Qwen2.5-0.5B-Instruct выдала восемь строк в идеальном формате. Все восемь идентификаторов корректно сформированы. Все восемь открываются как реальные статьи на arXiv. Ноль из восьми — та статья, которой они названы.
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Это маленькая модель, и частота у нее своя; frontier model выдумывает гораздо меньше. Механизм обобщается, и именно он объясняет следующее правило. Валидатор, проверяющий "существует ли этот идентификатор", пропускает все восемь, а пользователь, который нажимает на один, попадает на реальную страницу реального архива без способа понять, что связь была выдумана. Сбой не в идентификаторе и не в формате. Он в ассоциации — ровно в том, что языковая модель производит по правдоподобию.
Итак: модель пишет [1] и [2], но никогда не пишет ссылку. Числа ссылаются на фрагменты, которые получил сервер, а сервер — который точно знает, из какого документа и каких offsets пришло каждое число, — потом прикрепляет документ, label и 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 превращаются в 591-token prompt и таблицу, которую модель никогда не видит:
[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 — диапазон в каноническом тексте документа; для PDF эквивалент — #page=12, для аудио или видео — #t=132.4,158.9, для таблицы — лист и диапазон A1. Эти две вещи не выдумки: #page= — это PDF Open Parameters, а #t= — W3C Media Fragments, которые браузеры нативно поддерживают на video и audio elements. Citation без locator — это название документа, а название документа не citation; это предложение пользователю пойти и поискать.
А когда через порог ничего не проходит, пайплайн вообще не доходит до модели:
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, потому что это сравнение двух чисел, а не просьба к вероятностной системе.
Оценивайте retriever отдельно от генератора
Ссылка на раздел: Оценивайте retriever отдельно от генератораКаждое измерение в этой главе оценивает retriever и ни разу не просит модель написать ответ. Это намеренно, и именно эту часть большинство команд пропускает.
У RAG-системы есть два режима отказа, которые снаружи выглядят одинаково. Retriever не нашел фрагмент; или нашел, но генератор проигнорировал его, противоречил ему или смешал его с тем, во что уже верил. Оценивайте только финальный ответ — и эти два случая неразличимы, поэтому вы настраиваете prompts против проблемы, которая живет в вашем chunker. Recall@k, MRR и счетчик уничтоженных ответов вообще не требуют вызова генерации, они достаточно дешевы, чтобы запускать их на каждом deploy, и это harness из главы 15 с другой scoring function — тот же request, deadline, concurrency и tally, но на фиксированном наборе вопросов вместо живого диалога.
Отчитывайтесь с интервалами. Арифметика главы 4 применима без изменений: при 64 запросах recall 0,5 несет 95 % интервал Уилсона примерно ±0,12, поэтому стратегия, опережающая другую на четыре пункта, не сказала вам ничего. Используйте парный тест всякий раз, когда обе стратегии отвечают на одни и те же вопросы, а здесь это всегда так — именно он превратил "E выглядит лучше A" в p = 0.0015.
И последняя честность: RAG снижает hallucination, но не устраняет ее. То, что правильный фрагмент попал в prompt, не обязывает модель его использовать, и литература говорит это со времен оригинальной статьи.7 В production две вещи делают ситуацию хуже. Длинные контексты деградируют — модель надежнее находит информацию в начале и конце длинного prompt, чем в середине, поэтому двадцать chunks вместо четырех могут снизить accuracy и поднять счет; этот эффект измерен в главе 24. И retrieval может быть правильным и все равно недостаточным, как показали два cache выше. SelfCheckGPT помечает утверждения, которые не выдерживают resampling;8 Self-RAG обучает модель выдавать собственные retrieve-and-critique tokens;9 TruthfulQA вообще сделала этот режим отказа видимым.10 Ничто не закрывает разрыв, а система, которая предъявляет retrieved text как доказательство, спутала снабжено источником с истинно.
Половина системы, которая работает до любого запроса
Ссылка на раздел: Половина системы, которая работает до любого запросаRetriever — видимая часть пайплайна, все отказы которого происходят раньше, в темноте. Три из них повторяются.
Extraction — место, где умирает контент. PDF — не текст; это инструкции рисования. Двухколоночные макеты перемешиваются, таблицы превращаются в словесную кашу, заголовки страниц повторяются в каждом chunk, а у сканированной страницы вообще нет текста, пока OCR не даст какой-то текст с confidence. Все измеренное выше предполагало, что extractor сделал свою работу; в production часто нет, и симптом проявляется как плохой retrieval тремя слоями дальше.
Индекс проштампован моделью, которая его построила. Embeddings от двух моделей несопоставимы — не "менее точны", а именно несопоставимы, потому что это точки в разных пространствах. Измените embedding model — и каждый вектор в хранилище мусор, пока индекс не будет перестроен. Поэтому имя модели, число измерений, версия пайплайна и версия extractor записываются рядом с каждым документом во время индексации. Без них в день обновления вы не сможете понять, какие документы устарели, а какие актуальны, и наполовину мигрированный индекс будет возвращать уверенную чушь без ошибки где-либо.
Один сломанный документ не должен ломать папку, а счетчики должны считать то, что произошло. Документ, на котором extraction не удалась, заканчивает в состоянии failed с причиной, видимый и доступный для повторной попытки, пока остальные девяносто девять остаются доступными для поиска; а число проиндексированных chunks записывает сервер, когда заканчивает работу, а не клиент, когда загружает файл. Папка, которая сообщает о 400 фрагментах, а содержит 40, — ложь, проявляющаяся только как вопрос без ответа.
Куда это ведет дальше
Ссылка на раздел: Куда это ведет дальшеСистема в этой главе отвечает на вопросы, ответы на которые записаны. Она извлекает их, ранжирует, отказывается, когда не может, и цитирует, где смотрела. Это большая часть того, что люди хотят от ассистента поверх собственных документов, и она ограничена одним конкретным образом: retrieval может вернуть только то, что кто-то написал.
Остается другая половина. Часть того, что вы хотите поручить модели, вообще не является фактом в документе — формат, который она должна удерживать, тон, таксономия с четырьмя сотнями labels, способ принимать решение, который живет в десяти тысячах прошлых примеров и ни в одном абзаце. Retrieval не может этого доставить, потому что нечего извлекать; более длинный prompt только оплачивает счет главы 16 за описание skill вместо самого skill.
Глава 20 — об этом выборе: fine-tune, retrieve или prompt, — и ее вывод в том, что решение экономическое раньше, чем техническое: все три подхода оценены end to end на одном вопросе, а точка пересечения — это число tokens. Вопрос, с которого она начинается, — тот, на который эта глава не может ответить. Не где записан ответ, а что делать, когда он никогда не был записан.
Источники и метод
Ссылка на раздел: Источники и методВсе измерения в этой главе использовали один корпус и один инструмент, и оба воспроизводимы. Корпус — главы 1–13 этого курса в состоянии на 7 сентября 2026 года: 13 документов, 359 067 символов, 127 разделов, front matter и библиографии удалены. Эти главы продолжают редактироваться, поэтому применение того же правила сегодня дает на несколько тысяч символов больше: число разделов не изменилось, как и все выводы ниже, но общий объем символов — snapshot и помечен как таковой. Ground truth — 32 вопроса, каждый в паре с дословным предложением, которое встречается в корпусе ровно один раз и никогда не является заголовком раздела; каждый задан в двух формулировках, всего 64 запроса. Retrieval embeddings — sentence-transformers/all-MiniLM-L6-v2 (384 измерения, mean-pooled, L2-normalised, 256-token window); reranking — cross-encoder/ms-marco-MiniLM-L-6-v2 по top 25; пример generation — Qwen/Qwen2.5-0.5B-Instruct с greedy decoding. Все timings — single-threaded CPU. Ни один платный API не был вызван для создания этой главы, поэтому каждая latency здесь локальная и помечена соответственно.
Chunker, показанный на TypeScript, — тот chunker, который измерялся: Python-инструмент, реализующий то же правило, и ts/chunk.ts были сравнены chunk за chunk по всему корпусу и совпадают по всем 940 chunks, включая texts и offsets. Интервалы — Wilson на 95 %; парные сравнения — двусторонние exact sign tests на несовпадающих парах.
Все четырнадцать идентификаторов, процитированных выше, были проверены через arXiv API и сверены по названиям 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). Источник функции насыщения и двух констант, использованных выше, а также место, где стоит читать, почему вообще существует. ↩
-
Cormack, G. V., Clarke, C. L. A. and Büttcher, S. Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods. SIGIR 2009. принадлежит им, а смысл метода в том, что ему не нужна калибровка между шкалами оценок, которые он объединяет. ↩
-
Khattab, O. and Zaharia, M. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT. arXiv:2004.12832 (2020). Середина между скалярным произведением и 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, лежащий в основе большинства векторных баз данных, которые сейчас продаются. ↩
-
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, которое никогда не вознаграждает abstention, и поэтому является проблемой оценки раньше, чем проблемой моделирования. ↩
-
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). Статья, которая дала имя паттерну, и та, которую стоит читать, чтобы понять, что он исправляет, а что нет. Guu et al., REALM: Retrieval-Augmented Language Model Pre-Training, arXiv:2002.08909 (2020), — современная ей работа, которая обучает retriever совместно с моделью, а не прикручивает его сбоку; Karpukhin et al., Dense Passage Retrieval for Open-Domain Question Answering, arXiv:2004.04906 (2020), — источник двух-encoder dense retriever, использованного во всей этой главе; а Izacard and Grave, Leveraging Passage Retrieval with Generative Models for Open Domain Question Answering, arXiv:2007.01282 (2020), — схема fusion-in-decoder для подачи множества 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, без доступа к внутренностям модели и без внешней 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). Обучение модели решать, когда делать retrieve, вместо retrieval на каждом ходе. ↩
-
Lin, S., Hilton, J. and Evans, O. TruthfulQA: Measuring How Models Mimic Human Falsehoods. arXiv:2109.07958 (2021). Benchmark, построенный из вопросов, где правдоподобный ответ и истинный ответ различаются, то есть вся сложность в одном предложении. ↩