Перейти к содержимому
19/30Глава 19 из 30

RAG в production: chunking, поиск и честные цитаты

Слепой разрез на 512 символах ломает 4 из 32 ответов до retriever. Один chunker переносит ранг 115 на 3.

На этой странице

Вот реальный вопрос от реального пользователя реального ассистента: в моем eval-наборе 20 пунктов, достаточно ли этого, чтобы доверять оценке. В корпусе есть ответ — целый раздел с ним. Вот четыре фрагмента, которые retriever на самом деле положил в prompt.

four fragments, chunked blind at 512 charactersTEXT
[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. Изменилось одно: как нарезали документы.

four fragments, cut on section boundaries with a contextual headerTEXT
[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 мс.

Шесть стратегий из трех независимых ингредиентов. Blind режет каждые 512 символов, не глядя на текст. Boundaries никогда не режет внутри абзаца и откатывается к границе предложения только тогда, когда один абзац превышает бюджет. Header добавляет к каждому chunk префикс с названием документа и путем раздела. Overlap копирует последние 64 символа предыдущего chunk в следующий.

стратегияchunksуничтоженные ответыR@1R@4R@8R@20MRR
A blind 5127084 / 320.1250.2970.4220.5940.241
B blind + overlap80900.1720.3910.4530.6250.286
C boundaries94000.1560.4220.5310.6720.293
D boundaries + overlap94000.1560.3590.5160.6560.277
E boundaries + header94000.0940.4220.5780.8280.280
F boundaries + header + overlap94000.1560.3910.5620.7660.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 и одно правило, в котором легко ошибиться:

chunk.tsTS
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 Термин tt дает вклад

idf(t)ft,d(k1+1)ft,d+k1(1b+bdd)\mathrm{idf}(t)\cdot\frac{f_{t,d}\,(k_1+1)}{f_{t,d} + k_1\left(1 - b + b\,\frac{|d|}{\overline{|d|}}\right)}

где ft,df_{t,d} — число вхождений термина в документ, d|d| — его длина, d\overline{|d|} — средняя длина, а k1=1.2k_1 = 1.2 и b=0.75b = 0.75 — две общепринятые константы: k1k_1 задает, как быстро повторение перестает помогать, bb — насколько сильно штрафуется длина.

bm25.tsTS
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. И это не музейный экспонат:

retrieverR@1R@4R@8MRRстоимость на запрос
dense (cosine)0.0940.4220.5780.28013 мс на embedding + 0,3 мс на сканирование
lexical (BM25)0.2190.3750.4690.3131,14 мс
hybrid (RRF)0.2030.4840.6090.346оба
hybrid + cross-encoder0.3120.5780.7030.447+ 569 мс

BM25 более чем удваивает top-1 точность dense retriever на этом корпусе и сильно проигрывает ему к рангу 8. Они ошибаются на разных запросах — это и есть весь аргумент за то, чтобы запускать оба.

Объединение — единственное место, где очевидный подход неверен. Cosine distances и оценки BM25 не на одной шкале, не ограничены одинаково, а нормализация по запросу заставляет вес зависеть от того, насколько хорошим случайно оказался лучший hit. Reciprocal rank fusion выбрасывает оценки и оставляет только ранги:2

RRF(d)=lists1k+rank(d),k=60\mathrm{RRF}(d) = \sum_{\text{lists}} \frac{1}{k + \mathrm{rank}(d)}, \qquad k = 60
retrieve.tsTS
/** 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 — это опция конфигурации. На нормализованных векторах выбор косметический, а тождество стоит проделать один раз, потому что все дальнейшее зависит от того, действительно ли векторы единичные. Для a=b=1\lVert a \rVert = \lVert b \rVert = 1:

ab2=a2+b22ab=22cosθ\lVert a - b \rVert^2 = \lVert a \rVert^2 + \lVert b \rVert^2 - 2\,a \cdot b = 2 - 2\cos\theta

поэтому cosine distance 1cosθ1 - \cos\theta ровно равен d2/2d^2/2. Это скалярное произведение и норма из главы 1, предъявленные к оплате. Проверено на двух реальных векторах chunk из индекса выше, а затем на 40 000 парах:

TEXT
||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.40017 / 6460 / 30
0.45038 / 64130 / 30
0.50050 / 64191 / 30
0.52552 / 64202 / 30
0.55055 / 64213 / 30
0.60060 / 64255 / 30
0.67562 / 642710 / 30
0.80064 / 642726 / 30
нет64 / 642730 / 30

Читайте последний столбец как блефы. Без порога ассистент выдает уверенный, хорошо процитированный ответ на "how do I renew my Spanish passport" из корпуса про backpropagation тридцать раз из тридцати. При 0.675 он делает это десять раз из тридцати. При 0.525 — два раза, и отказывается от двенадцати вопросов, на которые мог бы ответить.

Этот компромисс — продуктовое решение, и правильная его точка зависит от того, сколько вам стоит неправильный ответ. Что не обсуждается — это само существование последнего столбца. Если вы никогда не измеряли retriever на вопросах, от которых он должен отказаться, у вас нет порога — у вас есть число.

Два из десяти блефов при 0.675 показывают два типа отказа.

the two shapes of a confident wrong retrievalTEXT
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 прав; ответа там нет. Любая система, которая читает "я нашла что-то похожее" как "я нашла ответ", на таком доказательстве заявит Париж — или, что хуже, не заявит.

У модели нет отдельной способности для фактов. Производство истинного предложения и производство правдоподобного — это одна и та же операция, 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. Ноль из восьми — та статья, которой они названы.

8 references, checked one by one against the arXiv APITEXT
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. Модели нечего выдумывать, потому что ее никогда не просят о той вещи, которую она бы выдумала.

prompt.tsTS
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 и таблицу, которую модель никогда не видит:

TEXT
[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.605

Locator — часть, которую люди пропускают, а потом не могут добавить. #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; это предложение пользователю пойти и поискать.

А когда через порог ничего не проходит, пайплайн вообще не доходит до модели:

TEXT
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 года — что, с учетом тех восьми, которые не были, казалось минимумом, который эта конкретная глава могла сделать.

  1. Robertson, S. and Zaragoza, H. The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval 3(4), pp. 333–389 (2009). Источник функции насыщения и двух констант, использованных выше, а также место, где стоит читать, почему bb вообще существует.

  2. Cormack, G. V., Clarke, C. L. A. and Büttcher, S. Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods. SIGIR 2009. k=60k = 60 принадлежит им, а смысл метода в том, что ему не нужна калибровка между шкалами оценок, которые он объединяет.

  3. 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.

  4. 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, лежащий в основе большинства векторных баз данных, которые сейчас продаются.

  5. Johnson, J., Douze, M. and Jégou, H. Billion-scale Similarity Search with GPUs. arXiv:1702.08734 (2017). FAISS и эталонная реализация IVF, измеренная во вставке выше.

  6. Kalai, A. T., Nachum, O., Vempala, S. S. and Zhang, E. Why Language Models Hallucinate. arXiv:2509.04664 (2025). Аргумент о том, что hallucination порождается оцениванием binary accuracy, которое никогда не вознаграждает abstention, и поэтому является проблемой оценки раньше, чем проблемой моделирования.

  7. 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-ит гипотетический ответ, а не вопрос.

  8. 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.

  9. 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 на каждом ходе.

  10. Lin, S., Hilton, J. and Evans, O. TruthfulQA: Measuring How Models Mimic Human Falsehoods. arXiv:2109.07958 (2021). Benchmark, построенный из вопросов, где правдоподобный ответ и истинный ответ различаются, то есть вся сложность в одном предложении.

Готовы доверить выбор модели LIA?

Создавайте со всеми ИИ-моделями в одном месте — начните бесплатно уже сегодня.