Перейти до вмісту
19/30Розділ 19 з 30

RAG у production: chunking, retrieval і чесні цитування

Ріжте наосліп по 512 символів — і 4 з 32 відповідей гинуть ще до retriever. Лише chunker піднімає rank 115 до 3.

На цій сторінці

Ось реальне запитання від реального користувача реального assistant: у моєму eval set 20 пунктів, чи цього достатньо, щоб довіряти score. У корпусі є відповідь — цілий розділ про це. Ось чотири фрагменти, які 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, — повернувся на rank 115.

Тепер те саме запитання, та сама embedding model, той самий prompt template. Змінилася одна річ: як були порізані документи.

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]

Rank 115 до rank 3. Ніхто не чіпав model, prompt, threshold чи кількість слотів. Цей розділ — про цей розрив і про ще чотири місця, де retrieval system тихо вам бреше.

Показати подробиці

Що цьому розділу потрібно з попередніх розділів і одне місце, де він змінює мову.

  • Розділ 1 визначив dot product і L2 norm. Секція про threshold нижче — це саме ці два поняття і нічого більше.
  • Розділ 8 відокремив embedding table мовної model від retrieval embedding model, контрастивно навченої на парах, виміряв cosine similarity і завершився обіцянкою, що Розділ 19 дійде до конкретного cut-off. Тут ця обіцянка виконується. Нічого з цього не повторюється.
  • Розділ 4 побудував інтервал Вілсона; Розділ 15 побудував evaluation harness. Кожна таблиця нижче має перше й була створена другим.
  • Розділ 16 порахував вартість context window. Prompt, зібраний наприкінці цього розділу, коштує 591 tokens, і саме за цей бюджет змагаються фрагменти.

Усе тут — TypeScript, як і від Розділу 14, і саме в цьому розділі правило себе виправдовує: ingestion — це черги й сховище, search — це network call, а збирання prompt із citations — робота сервера. Вимірювання — це той самий код із scoreboard навколо, навмисно: retriever, оцінений другою реалізацією, дає число про software, який ви не ship, а cosine threshold нижче переконливий лише тому, що ви бачите, як його sweep виконує chunker, який працюватиме в production.

Корпус і що вважається правильною відповіддю

Посилання на розділ: Корпус і що вважається правильною відповіддю

Усе нижче вимірюється на одному корпусі: перших тринадцяти розділах цього курсу — 13 документів, 359 067 символів, 127 секцій, без front matter і бібліографій. Це реальний технічний корпус із прозою, таблицями, формулами й code blocks, і це саме той тип матеріалу, який люди завантажують у knowledge base, а потім скаржаться.

Ground truth — це 32 запитання, кожне в парі з needle: коротким дослівним реченням із корпусу, яке на нього відповідає. Кожен needle трапляється рівно один раз у 359 067 символах, і жоден не є заголовком секції — ця перевірка важлива, бо chunker, який копіює заголовки в кожен chunk, інакше сам себе переоцінив би. Кожне запитання ставиться двічі: один раз мовою курсу, інший — так, як його формулює support ticket: 64 queries на 32 ground truths.

Retrieval правильний, коли повернений chunk містить needle цілком. Це єдине визначення, яке відповідає потребам generator: половина речення в prompt — не відповідь, а небезпека.

Embedding model — all-MiniLM-L6-v2: 384 виміри, mean-pooled і normalized, контрастивно навчена model, яку вимірював Розділ 8. Індексація корпусу займає 20,8 секунди на CPU, 22 ms на chunk; embedding одного query займає 13 ms.

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

strategychunksanswers destroyedR@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 queries 95 % інтервал Вілсона на R@20 становить [0.471, 0.705] для A і [0.718, 0.901] для E — вони не перекриваються, але більшість інших колонок перекривається, і непарна таблиця не може їх розділити. Кожна стратегія відповідає на ті самі queries, тож чесний тест — парний: порахувати wins і losses кожної стратегії проти іншої та запустити sign test на discordant pairs. Три результати його переживають.

Blind chunking знищує чотири з тридцяти двох відповідей напряму. Не погано їх ранжує — знищує. Needle перетинає межу в 512 символів, тож жоден chunk в індексі не містить його, і recall ceiling для цих queries дорівнює нулю. Жоден reranker їх не відновить, жоден threshold не допоможе, жодна більша model не допоможе. Ви не можете retrieve текст, якого ніде в індексі немає одним шматком. Це найбільш недоговорений збій у RAG, бо він виглядає точнісінько як поганий retriever.

Overlap виправляє це і нічого більше. Кожна стратегія з overlap втрачає нуль відповідей — саме для цього overlap і потрібен. Він не покращує ranking: B проти A на R@8 — +8/−6, p = 0.79; на R@20 — +9/−7, p = 0.80. Гірше: додавання overlap поверх header активно шкодить — F проти E дає +2/−6 на R@20 — і причина механічна. Вектор chunk — це середнє за його tokens, тож 64 символи попереднього chunk тягнуть це середнє до теми сусіда. Overlap — це страховка від розрізаної відповіді, оплачена precision.

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, про що цей абзац, бо сам абзац часто цього не каже. Це pronoun resolver для документів.

Звідси форма 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 — це те, що отримує embedding, разом із header і всім іншим. content — лише власні слова цього chunk, і саме вони цитуються користувачу. Процитуйте text — і citation покаже header, якого в документі в цьому місці немає, а з overlap ще й повторений хвіст попереднього фрагмента. Тоді він показує текст не там, де стверджує, а це гірше, ніж не показувати нічого.

Header не безкоштовний. На 940 chunks він коштує 24 213 із 114 275 embedded tokens індексу: 21,2 % того, за що ви платите під час embedding, — це header, який ви написали самі. Він також притискає chunks до window encoder. all-MiniLM-L6-v2 приймає 256 word-pieces; у стратегії E 17 chunks за цією межею, у F — 28, і кожен тихо truncated без жодного попередження. Ваш ефективний розмір chunk — не число у config, а менше між ним і window вашого encoder.

Dense retrieval має одну системну слабкість, і вона зовсім не тонка: він зіставляє значення, тому байдужий до того, який саме рядок ви ввели. Номер деталі, код помилки, acronym, прізвище — нічого з цього не має корисного значення для embedding, а найближчий сусід коду помилки — кожен інший код помилки у вашому корпусі.

Класична відповідь старіша за все це й займає двадцять рядків. BM25 оцінює документ за тим, як часто terms query трапляються в ньому, приглушуючи кожен term зі зростанням частоти й караючи довгі документи, які накопичують збіги просто завдяки довжині.1 Term 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} — кількість term у документі, d|d| — його довжина, d\overline{|d|} — середня довжина, а k1=1.2k_1 = 1.2 і b=0.75b = 0.75 — дві conventional constants: 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 це оцінює query за 1,14 ms без жодного індексу, окрім двох hash maps. І це не музейний експонат:

retrieverR@1R@4R@8MRRcost per query
dense (cosine)0.0940.4220.5780.28013 ms to embed + 0.3 ms to scan
lexical (BM25)0.2190.3750.4690.3131.14 ms
hybrid (RRF)0.2030.4840.6090.346both
hybrid + cross-encoder0.3120.5780.7030.447+ 569 ms

BM25 більш ніж удвічі піднімає top-1 accuracy dense retriever на цьому корпусі й сильно програє йому до rank 8. Вони падають на різних queries — і це весь аргумент за те, щоб запускати обидва.

Поєднання їх — одне з місць, де очевидний підхід неправильний. Cosine distances і BM25 scores не в одній шкалі, не обмежені однаково, а нормалізація per query змушує weight залежати від того, наскільки добрим випадково був найкращий hit. Reciprocal rank fusion викидає scores і залишає лише ranks: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 queries, hybrid retrieval не відрізняється від dense retrieval статистично. Він кращий у point estimates і в кожній колонці recall, але доказ не досягає significance. Майже кожен blog post про hybrid search в інтернеті показує таблицю на кшталт наведеної й жодного інтервалу; ось що каже інтервал.

Усе досі — це bi-encoder: query проходить через model сам, кожен chunk пройшов через неї сам місяці тому, і вони ніколи не зустрічаються, окрім dot product. Саме це робить індекс можливим — embed один раз, reuse forever — і це ж є стелею. Model ніколи не дивиться на query і chunk разом.

Cross-encoder робить саме це: бере пару як один input і повертає relevance score. Нічого не можна precompute, тому він не може rank індекс — але може rerank shortlist. Reranking hybrid top 25 за допомогою ms-marco-MiniLM-L-6-v2 рухає R@1 з 0.094 (dense) до 0.312, а MRR з 0.280 до 0.447: найбільше одиничне покращення в цьому розділі й єдине, яке торкається верхівки списку, а не хвоста.

Це коштує 569 ms на query на CPU, проти 1,14 ms для BM25 і 0,3 ms для vector scan. Приблизно у дві тисячі разів дорожче за retrieval cost для двадцяти п’яти документів. Уся trade-off bi-encoder/cross-encoder в одному числі, і саме тому architecture завжди має одну форму: дешевий retriever із широким recall, потім дорогий scorer на shortlist, який ви можете собі дозволити. ColBERT стоїть між ними: precomputing per-token vectors і late interaction, дешевший за cross-encoder і гостріший за dot product.3

Vector databases повідомляють distances, а яка саме distance — це option у config. На normalised vectors вибір косметичний, і identity варто зробити один раз, бо все далі залежить від того, що vectors справді unit. Для 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. Це dot product і norm з Розділу 1, переведені в готівку. Перевірено на двох реальних chunk vectors з індексу вище, а потім на 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 noise — і лише тому, що vectors normalised. Пропустіть normalisation — і identity хибна, ваш threshold нічого не означає, а distance, яку повідомляє документ, залежить від довжини його тексту.

Тепер число, яке ніхто не виводить. Retriever завжди щось повертає: він сортує весь індекс і дає вам верх списку, незалежно від того, чи відповідь взагалі є в корпусі. Threshold — єдина частина системи, яка може сказати ні; а щоб його встановити, потрібні queries, які мають нічого не повернути. Ось тридцять: двадцять одне про речі, яких цей корпус справді не покриває — streaming, rate limits, prompt caching, JSON schemas, agent loops, vector databases, prompt injection, image generation — і дев’ять про паелью, паспорти й refund policies. Проти того самого індексу:

top-1 cosine distance
in-domain queries, all 64mean 0.445, range 0.270 – 0.721
in-domain, top-1 actually correctmean 0.370
in-domain, top-1 wrongmean 0.452
out-of-domain, all 30mean 0.699, range 0.497 – 0.867

Розподіли розділяються — і перекриваються. Найгірший in-domain query далі від своєї відповіді (0.721), ніж найкращий out-of-domain query від нерелевантного абзацу (0.497), тож жоден threshold не робить правильними обидва. Sweep через реальний gate — залишити максимум чотири chunks і лише ті, що нижче cut:

thresholdin-domain answeredof which the answer was inout-of-domain answered
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
none64 / 642730 / 30

Читайте останню колонку як блеф. Без threshold assistant тридцять разів із тридцяти дає впевнену, добре процитовану відповідь на "how do I renew my Spanish passport" з корпусу про backpropagation. На 0.675 він робить це десять разів із тридцяти. На 0.525 — двічі, і здається на дванадцяти запитаннях, на які міг відповісти.

Цей trade-off — product decision, і правильний його край залежить від того, скільки вам коштує неправильна відповідь. Що не підлягає переговорам — це існування останньої колонки. Якщо ви ніколи не вимірювали retriever на запитаннях, від яких він має відмовитися, у вас немає threshold — у вас є число.

Два з десяти блефів на 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, query про prompt cache, слова ті самі, і 0.497 ближче, ніж більшість правильних in-domain retrievals у всьому експерименті. Embedding не знає, що два caches з однаковою назвою — різні машини. Другий — literal match без відповіді: корпус містить точну фразу "what is the capital of France", використану як приклад запитання, що не потребує reasoning. Retriever має рацію; відповіді там немає. Будь-яка система, яка читає "I found something similar" як "I found the answer", на цій підставі заявить Paris — або, що гірше, не заявить.

Model не має окремої здатності для фактів. Створити істинне речення й створити правдоподібне — це та сама операція: next-token prediction з Розділу 8, і ніщо в цій операції не позначає, що є чим. Аналіз 2025 року, який переформулював це, стверджує, що training and evaluation pipeline активно винагороджує вгадування: benchmarks оцінюють binary accuracy і не дають credit for abstention, тож model, яка завжди відповідає, випереджає ідентичну model, що каже "I don't know", коли не знає, а post-training оптимізує саме це.6 Hallucination у такому читанні — не загадковий дефект. Це те, що ви отримуєте, коли оцінюєте multiple-choice exam без штрафу за неправильну відповідь.

Подивіться на форму. На прохання назвати вісім papers про contrastive sentence embeddings, з identifiers, Qwen2.5-0.5B-Instruct створив вісім рядків у ідеальному форматі. Усі вісім identifiers well-formed. Усі вісім ведуть до реальних papers на arXiv. Нуль із восьми — це заявлена paper.

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

Це мала model, і її rate — її власний; frontier model вигадує значно менше. Mechanism узагальнюється, і це причина правила нижче. Validator, який перевіряє "does this identifier exist", пропускає всі вісім, а користувач, натиснувши один, потрапляє на реальну сторінку реального архіву без способу зрозуміти, що mapping вигаданий. Збій не в identifier і не у format. Він в association — саме тій речі, яку language model створює через plausibility.

Отже: model пише [1] і [2], але ніколи не пише link. Числа посилаються на фрагменти, які retrieved сервер, а сервер — який точно знає, з якого документа й яких offsets походить кожне число, — потім додає document, label і URL. Model немає чого вигадувати, бо її ніколи не просять про єдину річ, яку вона вигадала б.

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 і таблицею, яку model ніколи не бачить:

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 — це range у canonical text документа; для PDF еквівалент — #page=12, для audio або video — #t=132.4,158.9, для spreadsheet — sheet і A1 range. Ці дві речі не вигадки: #page= — це PDF Open Parameters, а #t= — W3C Media Fragments, які браузери нативно підтримують для video й audio elements. Citation без locator — це назва документа, а назва документа не є citation; це пропозиція користувачу піти й пошукати.

І коли нічого не проходить threshold, pipeline взагалі не доходить до model:

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, бо це порівняння двох чисел, а не прохання до probabilistic system.

Кожне вимірювання в цьому розділі оцінює retriever і жодного разу не просить model написати відповідь. Це навмисно, і саме цей шматок більшість команд пропускає.

RAG system має два failure modes, які зовні виглядають однаково. Retriever не знайшов passage; або знайшов, але generator його проігнорував, суперечив йому чи змішав із тим, у що вже вірив. Оцінюйте лише final answer — і ці два випадки нерозрізненні, тож ви tuning prompts проти проблеми, яка живе у вашому chunker. Recall@k, MRR і answer-destroyed count не потребують generation call, вони достатньо дешеві, щоб запускати їх на кожному deploy, і це harness із Розділу 15 з іншою scoring function — той самий request, deadline, concurrency і tally, на фіксованому question set замість live conversation.

Публікуйте їх з інтервалами. Арифметика Розділу 4 застосовується без змін: для 64 queries recall 0.5 має 95 % інтервал Вілсона приблизно ±0.12, тож стратегія, яка випереджає іншу на чотири пункти, ще нічого вам не сказала. Використовуйте paired test щоразу, коли обидві стратегії відповідають на ті самі запитання, а тут це завжди так — саме він перетворив "E виглядає кращою за A" на p = 0.0015.

І остання чесність: RAG зменшує hallucination, але не прибирає її. Покласти правильний passage у prompt не змушує model ним користуватися, і literature говорить це ще з original paper.7 Дві речі погіршують це в production. Long contexts degrade — model надійніше знаходить інформацію на початку й наприкінці довгого prompt, ніж у середині, тож двадцять chunks замість чотирьох можуть знизити accuracy й підняти рахунок; цей ефект виміряно в Розділі 24. А retrieval може бути правильним і все одно недостатнім, як показали два caches вище. SelfCheckGPT позначає claims, які не переживають resampling;8 Self-RAG навчає model emit власні retrieve-and-critique tokens;9 TruthfulQA зробив failure mode зрозумілим із самого початку.10 Жоден не закриває gap, а система, яка подає retrieved text як proof, переплутала sourced із true.

Половина системи, яка працює до будь-якого query

Посилання на розділ: Половина системи, яка працює до будь-якого query

Retriever — видима частина pipeline, чиї збої всі стаються раніше, у темряві. Три з них повторюються.

Extraction — місце, де content помирає. PDF — це не текст; це drawing instructions. Two-column layouts перемішуються, tables стають word soup, page headers повторюються в кожному chunk, а scanned page не має тексту взагалі, доки OCR не дасть його, з confidence. Усе виміряне вище припускало, що extractor виконав свою роботу; у production він часто цього не робить, і симптом виглядає як поганий retrieval на три шари далі.

Index stamped із model, яка його створила. Embeddings із двох models не порівнювані — не "менш точні", а саме непорівнювані, бо це points у різних spaces. Змініть embedding model — і кожен vector у store стає сміттям, доки його не перебудують. Тому model name, dimension count, pipeline version і extractor version записуються поруч із кожним документом під час indexing. Без них у день upgrade ви не можете сказати, які документи stale, а які current, і напівмігрований index повертає впевнену нісенітницю без жодної помилки.

Один зламаний документ не має ламати folder, а counters мають рахувати те, що сталося. Документ, у якого впав extraction, завершується в state failed із причиною, видимою й retryable, тоді як інші дев’яносто дев’ять лишаються searchable; а кількість indexed chunks записує сервер, коли завершує роботу, а не client, коли uploads. Folder, який reports 400 fragments, а має 40, — брехня, що проявляється лише як unanswerable question.

Система в цьому розділі відповідає на запитання, відповіді на які записані. Вона retrieves їх, ranks їх, відмовляє, коли не може, і cites, де шукала. Це більшість того, чого люди хочуть від assistant над власними документами, і це обмежено одним конкретним способом: retrieval може повернути лише те, що хтось написав.

Залишається інша половина. Частина того, що ви хочете, аби model робила, взагалі не є фактом у документі — format, який вона має тримати, tone, taxonomy з чотирма сотнями labels, спосіб вирішувати, що живе в десяти тисячах минулих прикладів і в жодному абзаці. Retrieval не може це доставити, бо retrieve нічого; довший prompt лише платить рахунок Розділу 16 за опис skill замість самого skill.

Розділ 20 — про це рішення: fine-tune, retrieve чи prompt — і його висновок у тому, що рішення економічне раніше, ніж технічне: усі три оцінюються end to end на одному запитанні, а crossover — це token count. Запитання, з якого він починається, — те, на яке цей розділ не може відповісти. Не де записана відповідь, а що робити, коли її ніколи не було.


Усе виміряне в цьому розділі використовувало один корпус і один інструмент, і обидва reproducible. Корпус — розділи 1–13 цього курсу станом на 7 вересня 2026 року: 13 документів, 359 067 символів, 127 секцій, front matter і bibliographies removed. Ці розділи продовжують редагуватися, тож застосування того самого правила сьогодні дає на кілька тисяч символів більше: section count не змінився, як і кожен висновок нижче, але total characters — snapshot і позначений як такий. Ground truth — 32 запитання, кожне в парі з дослівним реченням, яке трапляється рівно один раз у корпусі й ніколи не є section heading, поставлені у двох phrasing для 64 queries. Retrieval embeddings — sentence-transformers/all-MiniLM-L6-v2 (384 dimensions, mean-pooled, L2-normalised, 256-token window); reranking — cross-encoder/ms-marco-MiniLM-L-6-v2 over the top 25; generation example — Qwen/Qwen2.5-0.5B-Instruct with greedy decoding. Усі timings — single-threaded CPU. Жоден paid API не викликався для створення цього розділу, тому кожна latency тут локальна й позначена як така.

Chunker, показаний у TypeScript, — той самий chunker, який вимірювався: Python instrument, що реалізує те саме правило, і ts/chunk.ts були порівняні chunk за chunk на всьому корпусі й збігаються на всіх 940 chunks, texts і offsets. Intervals — Wilson at 95 %; paired comparisons — two-sided exact sign tests on the discordant pairs.

Усі чотирнадцять identifiers, процитовані вище, були resolved через arXiv API і перевірені title by title 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). Джерело saturation function і двох constants, використаних вище, і місце, де варто прочитати, чому 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 — їхній, а сенс method у тому, що йому не потрібна calibration між score scales, які він fusing.

  3. Khattab, O. and Zaharia, M. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT. arXiv:2004.12832 (2020). Середина між dot product і cross-encoder. Reimers, N. and Gurevych, I., Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks, arXiv:1908.10084 (2019), — bi-encoder, на якому побудовано index цього розділу і який вимірювався в Розділі 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 за більшістю vector databases, які продаються сьогодні.

  5. Johnson, J., Douze, M. and Jégou, H. Billion-scale Similarity Search with GPUs. arXiv:1702.08734 (2017). FAISS і reference implementation IVF, виміряного в box вище.

  6. Kalai, A. T., Nachum, O., Vempala, S. S. and Zhang, E. Why Language Models Hallucinate. arXiv:2509.04664 (2025). Аргумент, що hallucination створюється binary-accuracy grading, який ніколи не винагороджує abstention, а отже є evaluation problem раніше, ніж modelling problem.

  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). Paper, яка назвала pattern, і саме її варто читати про те, що він виправляє, а що ні. Guu et al., REALM: Retrieval-Augmented Language Model Pre-Training, arXiv:2002.08909 (2020), — contemporaneous work, що навчає retriever разом із model, а не прикручує його збоку; Karpukhin et al., Dense Passage Retrieval for Open-Domain Question Answering, arXiv:2004.04906 (2020), — джерело two-encoder dense retriever, використаного в цьому розділі; а Izacard and Grave, Leveraging Passage Retrieval with Generative Models for Open Domain Question Answering, arXiv:2007.01282 (2020), — fusion-in-decoder arrangement для подавання багатьох passages одному generator. Gao et al., Retrieval-Augmented Generation for Large Language Models: A Survey, arXiv:2312.10997 (2023), — карта всього, що прийшло після, включно з HyDE (Gao et al., Precise Zero-Shot Dense Retrieval without Relevance Labels, arXiv:2212.10496, 2022), який embeds hypothetical answer, а не question.

  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 by resampling, без доступу до internals model і без external 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). Training model вирішувати, коли retrieve, а не retrieving на кожному turn.

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

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

Створюйте з усіма моделями ШІ в одному місці — почніть безкоштовно вже сьогодні.