Към съдържанието
19/30Глава 19 от 30

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

Сляпо рязане на 512 знака убива 4 от 32 отговора. Само поправката на chunker мести ранг 115 на 3.

На тази страница

Ето реален въпрос от реален потребител на реален assistant: моят eval set има 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…

Три от четирите започват по средата на дума. Два са от различна глава за различна тема. А фрагментът, който отговаря на въпроса — този, който съдържа Седемнадесет от двадесет не могат да различат 85 % модел от 65 % модел — се върна с ранг 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, прага или броя слотове. Тази глава е за тази разлика и за още четири места, на които retrieval система тихо ви лъже.

Покажи подробности

Какво ѝ трябва на тази глава от предишните глави и едното място, на което сменя езика.

  • Глава 1 дефинира скаларното произведение и L2 нормата. Секцията за праговете по-долу е точно тези две неща и нищо друго.
  • Глава 8 отдели embedding таблицата на езиковия модел от retrieval embedding model, обучен контрастивно върху двойки, измери cosine similarity и завърши с обещание, че Глава 19 ще стигне до конкретна граница. Това обещание се изпълнява тук. Нищо от него не се повтаря.
  • Глава 4 изгради интервала на Wilson; Глава 15 изгради evaluation harness. Всяка таблица по-долу носи първото и е произведена от второто.
  • Глава 16 оцени цената на context window. Prompt, сглобен в края на тази глава, струва 591 tokens, и това е бюджетът, за който фрагментите се конкурират.

Всичко тук е TypeScript, както е от Глава 14, и тази глава е мястото, където правилото се доказва: ingestion са опашки и съхранение, search е мрежово извикване, а сглобяването на prompt с цитати е работа на сървъра. Измерването нарочно е същият код с табло за резултати около него — retriever, оценен от втора имплементация, е число за софтуер, който не пускате в production, а cosine threshold по-долу е правдоподобен само защото гледате как се sweep-ва от chunker, който ще работи в production.

Корпусът и какво се брои за правилен отговор

Връзка към раздела: Корпусът и какво се брои за правилен отговор

Всичко по-долу е измерено спрямо един корпус: първите тринадесет глави от този курс — 13 документа, 359 067 знака, 127 секции, с премахнати front matter и библиографии. Това е реален технически корпус, с проза, таблици, формули и code blocks, и е точно онзи тип нещо, което хората зареждат в knowledge base и после се оплакват от него.

Ground truth са 32 въпроса, всеки сдвоен с игла: кратко дословно изречение от корпуса, което му отговаря. Всяка игла се появява точно веднъж в 359 067-те знака и никоя не е заглавие на секция — тази проверка е важна, защото chunker, който копира заглавия във всеки chunk, иначе би си вдигнал резултата. Всеки въпрос се задава два пъти, веднъж на езика на курса и веднъж както би го формулирал support ticket: 64 queries върху 32 ground truths.

Retrieval е правилен, когато върнат chunk съдържа иглата цяла. Това е единствената дефиниция, която съответства на това, от което се нуждае генераторът: половин изречение в prompt не е отговор, а риск.

Embedding model е all-MiniLM-L6-v2 — 384 dimensions, mean-pooled и normalised, контрастивно обученият модел, който Глава 8 измери. Индексирането на корпуса отнема 20,8 секунди на CPU, 22 ms на chunk; embedding на една query отнема 13 ms.

Шест стратегии от три независими съставки. 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 queries 95 % интервал на Wilson за R@20 е [0.471, 0.705] за A и [0.718, 0.901] за E — те не се припокриват, но повечето други колони се припокриват, а несдвоена таблица не може да ги отдели. Всяка стратегия отговаря на същите queries, така че честният тест е сдвоен: броите победите и загубите на всяка стратегия спрямо друга и пускате sign test върху discordant pairs. Три резултата го преживяват.

Blind chunking унищожава директно четири от тридесет и двата отговора. Не ги ранкира зле — унищожава ги. Иглата пресича граница от 512 знака, така че в индекса няма chunk, който я съдържа, и recall ceiling за тези queries е нула. Никакъв reranker не ги възстановява, никакъв threshold не помага, никакъв по-голям модел не помага. Не можете да retrieve-нете текст, който не е на едно парче никъде в индекса ви. Това е най-недостатъчно докладваният failure в RAG, защото изглежда точно като лош retriever.

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

Контекстният header е това, което купува retrieval. E срещу A е +18/−3 при R@20, p = 0.0015. А ablation казва, че boundaries не са причината: E срещу C — същите разрези, header е единствената разлика — е +12/−2, p = 0.0129. Добавянето на "Classification, Cross-Entropy, and How Not to Fool Yourself > How many test examples do I need?" пред абзац казва на embedding model за какво е абзацът, което самият абзац често не казва. Това е resolver на местоимения за документи.

Това дава формата на chunker и едно правило, което лесно се бърка:

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, цитатът показва header, който не е в документа на това място — а с overlap и повторена опашка, принадлежаща на предишния фрагмент. Тогава се показва текст, който не е там, където твърди, че е, което е по-лошо от това да не се показва нищо.

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

Двадесет реда BM25, които всички пропускат

Връзка към раздела: Двадесет реда BM25, които всички пропускат

Dense retrieval има една системна слабост и тя не е фина: съвпада по смисъл, така че е безразличен към точно кой низ сте въвели. Номер на част, код на грешка, акроним, фамилия — никое от тях няма полезен смисъл за embedding, а nearest neighbour на код на грешка е всеки друг код на грешка във вашия корпус.

Класическият отговор е по-стар от всичко това и отнема двадесет реда. BM25 оценява документ според това колко често terms от query се появяват в него, като потиска всеки term с нарастването на честотата му и наказва дълги документи, които натрупват съвпадения само заради дължината си.1 Term 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 са двете конвенционални константи — 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@8MRRцена на query
dense (cosine)0.0940.4220.5780.28013 ms за embedding + 0,3 ms за scan
lexical (BM25)0.2190.3750.4690.3131,14 ms
hybrid (RRF)0.2030.4840.6090.346и двете
hybrid + cross-encoder0.3120.5780.7030.447+ 569 ms

BM25 повече от удвоява top-1 точността на dense retriever върху този корпус и губи лошо от него до ранг 8. Те се провалят на различни queries, което е целият аргумент да пускате и двата.

Сливането им е едното място, където очевидният подход е грешен. Cosine distances и BM25 scores не са в една и съща скала, не са ограничени по един и същи начин, а normalising им per query прави теглото зависимо от това колко добър случайно е бил най-добрият 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, cross-encoder и къде всъщност е подобрението

Връзка към раздела: Bi-encoder, cross-encoder и къде всъщност е подобрението

Всичко досега е bi-encoder: query минава през модела сама, всеки chunk е минал през него сам преди месеци, и двете никога не се срещат освен като dot product. Това прави индекса възможен — embed веднъж, reuse завинаги — и това е и таванът. Моделът никога не гледа query и chunk заедно.

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

Струва 569 ms на query на CPU, срещу 1,14 ms за BM25 и 0,3 ms за vector scan. Приблизително две хиляди пъти цената на retrieval, за двадесет и пет документа. Това е целият trade-off bi-encoder/cross-encoder в едно число и затова архитектурата винаги има една и съща форма: евтин retriever с широк recall, после скъп scorer върху shortlist, който можете да си позволите. ColBERT стои между двете, като precompute-ва per-token vectors и прави late interaction, което е по-евтино от cross-encoder и по-точно от dot product.3

Vector databases докладват distances, а коя distance е конфигурационна опция. При normalised vectors изборът е козметичен, а идентичността си струва да се направи веднъж, защото всичко след нея зависи от това 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 и идентичността е невярна, threshold ви не означава нищо, а distance, която документ докладва, зависи от това колко дълъг е бил текстът му.

Сега числото, което никой не извежда. Retriever винаги връща нещо: сортира целия индекс и ви подава върха на списъка, независимо дали отговорът изобщо е някъде в корпуса. Threshold е единствената част от системата, която може да каже не — а за да зададете такъв, ви трябват queries, които трябва да не получават нищо обратно. Ето тридесет: двадесет и една за неща, които този корпус наистина не покрива — streaming, rate limits, prompt caching, JSON schemas, agent loops, vector databases, prompt injection, image generation — и девет за паеля, паспорти и policies за възстановяване на суми. Срещу същия индекс:

top-1 cosine distance
in-domain queries, всички 64mean 0.445, range 0.270 – 0.721
in-domain, top-1 реално правиленmean 0.370
in-domain, top-1 грешенmean 0.452
out-of-domain, всички 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 answeredот тях с отговора вътреout-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 е продуктово решение, а правилният му край зависи от това колко ви струва грешен отговор. Това, което не подлежи на договаряне, е последната колона изобщо да съществува. Ако никога не сте измервали 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 не знае, че два кеша със същото име са различни машини. Вторият е буквално съвпадение без отговор: корпусът съдържа точната фраза "what is the capital of France", използвана като пример за въпрос, който не изисква reasoning. Retriever е прав; отговорът не е там. Всяка система, която чете "намерих нещо подобно" като "намерих отговора", ще твърди Paris на базата на това доказателство — или, по-лошо, няма да го направи.

Моделът няма отделна способност за факти. Произвеждането на вярно изречение и произвеждането на правдоподобно изречение са една и съща операция — next-token prediction от Глава 8 — и нищо в тази операция не маркира кое кое е. Анализът от 2025 г., който преформулира това, твърди, че training и evaluation pipeline активно възнаграждава guessing: benchmarks оценяват с binary accuracy и не дават кредит за abstention, така че модел, който винаги отговаря, изпреварва идентичен модел, който казва "I don't know", когато не знае, и post-training optimises accordingly.6 Hallucination в този прочит не е мистериозен дефект. Това е резултатът, когато оценявате тест с multiple-choice без наказание за грешен отговор.

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

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

Това е малък модел и rate е негов собствен; frontier model измисля много по-малко. Механизмът се обобщава и е причината за правилото, което следва. Validator, който проверява "съществува ли този identifier", пропуска всичките осем, а потребител, който кликне един, попада на реална страница от реален archive без начин да разбере, че mapping е измислен. Провалът не е в identifier или във формата. Той е в асоциацията — точно нещото, което езиковият модел произвежда чрез plausibility.

И така: моделът пише [1] и [2], и никога не пише link. Числата се отнасят до фрагменти, които сървърът е retrieve-нал, а сървърът — който знае точно от кой документ и кои offsets идва всяко число — добавя документа, етикета и 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 стават prompt от 591 tokens и таблица, която моделът никога не вижда:

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, поддържани нативно от browsers върху video и audio elements. Цитат без locator е име на документ, а име на документ не е цитат; то е предложение потребителят да отиде и да търси.

А когато нищо не мине threshold, pipeline изобщо не стига до модела:

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 и нито веднъж не моли модел да напише отговор. Това е умишлено и е частта, която повечето екипи пропускат.

RAG система има два failure modes, които отвън изглеждат еднакво. Retriever не е намерил пасажа; или го е намерил, а генераторът го е игнорирал, противоречал му е или го е смесил с нещо, което вече е вярвал. Оценявате ли само финалния отговор, двете са неразличими, така че tune-вате prompts срещу проблем, който живее във вашия chunker. Recall@k, MRR и броят answer-destroyed не се нуждаят от generation call изобщо, достатъчно евтини са да се пускат при всеки deploy, и са harness от Глава 15 с различна scoring function — същата request, deadline, concurrency и tally, върху фиксиран набор въпроси вместо live conversation.

Докладвайте ги с интервали. Аритметиката от Глава 4 важи без промяна: при 64 queries recall от 0.5 носи 95 % Wilson interval от приблизително ±0.12, така че стратегия с четири пункта пред друга не ви е казала нищо. Използвайте paired test винаги когато и двете стратегии отговарят на същите въпроси, което тук винаги е така — именно това превърна "E изглежда по-добра от A" в p = 0.0015.

И последната честност: RAG намалява hallucination и не я премахва. Поставянето на правилния пасаж в prompt не задължава модела да го използва, а литературата го казва още от оригиналната статия.7 Две неща го влошават в production. Дългите contexts деградират — модел намира информация в началото и края на дълъг prompt по-надеждно, отколкото в средата, така че двадесет chunks вместо четири могат да понижат accuracy, докато вдигат сметката, ефект, измерен в Глава 24. И retrieval може да е правилен и пак недостатъчен, както показаха двата кеша по-горе. SelfCheckGPT маркира claims, които не оцеляват при resampling;8 Self-RAG обучава модела да emit-ва собствените си retrieve-and-critique tokens;9 TruthfulQA направи failure mode четим на първо място.10 Никое не затваря пропастта, а система, която представя retrieved text като доказателство, е объркала sourced с true.

Половината от системата, която работи преди всяка query

Връзка към раздела: Половината от системата, която работи преди всяка query

Retriever е видимата част от pipeline, чиито failures всички се случват по-рано, на тъмно. Три от тях се повтарят.

Extraction е мястото, където content умира. PDF не е текст; той е инструкции за рисуване. Двуколонните layouts се преплитат, tables стават word soup, page headers се повтарят във всеки chunk, а scanned page няма текст изобщо, докато OCR не му даде такъв, с confidence. Всичко измерено по-горе предполагаше, че extractor е свършил работата си; в production често не е, а симптомът се появява като лош retrieval три слоя по-нататък.

Индексът е подпечатан с модела, който го е построил. Embeddings от два модела не са сравними — не "по-малко точни", а несравними, защото са точки в различни пространства. Промените ли embedding model, всеки vector в store е боклук, докато не бъде построен отново. Затова model name, dimension count, pipeline version и extractor version се записват до всеки документ при index time. Без тях, в деня на upgrade, не можете да кажете кои документи са stale и кои са current, а half-migrated index връща уверена безсмислица без грешка никъде.

Един broken document не трябва да чупи folder, а counters трябва да броят какво се е случило. Документ, който fails extraction, завършва в състояние failed с причината си, видим и retryable, докато останалите деветдесет и девет остават searchable; а броят на indexed chunks се записва от сървъра, когато приключи, не се декларира от клиента при upload. Folder, който докладва 400 фрагмента и държи 40, е лъжа, която изплува само като въпрос без отговор.

Системата в тази глава отговаря на въпроси, чиито отговори са записани. Тя ги retrieve-ва, ранкира ги, отказва, когато не може, и цитира къде е гледала. Това е по-голямата част от онова, което хората искат от assistant върху собствените си документи, и е ограничено по един конкретен начин: retrieval може да върне само това, което някой е написал.

Което оставя другата половина. Част от това, което искате модел да прави, изобщо не е факт в документ — формат, който трябва да държи, тон, taxonomy с четиристотин labels, начин на решаване, който живее в десет хиляди минали examples и в нито един абзац никъде. Retrieval не може да достави тези неща, защото няма какво да retrieve-не; по-дълъг prompt само плаща сметката от Глава 16 за описание на skill вместо самия skill.

Глава 20 е това решение — fine-tune, retrieve или prompt — и нейният извод е, че решението е икономическо, преди да е техническо: трите са priced end to end върху един и същ въпрос, а crossover е token count. Въпросът, с който започва, е този, на който тази глава не може да отговори. Не къде е написан отговорът, а какво правите, когато никога не е бил написан.


Всичко измерено в тази глава използва един корпус и един инструмент, и и двете са възпроизводими. Корпусът е глави 1 до 13 от този курс във вида им към 7 септември 2026 г. — 13 документа, 359 067 знака, 127 секции, с премахнати front matter и библиографии. Тези глави продължават да се редактират, така че прилагането на същото правило днес брои с няколко хиляди знака повече: броят секции е непроменен и така е и всеки извод по-долу, но общият брой знаци е snapshot и е обозначен като такъв. Ground truth са 32 въпроса, всеки сдвоен с дословно изречение, което се появява точно веднъж в корпуса и никога не е заглавие на секция, зададен в две формулировки за 64 queries. Retrieval embeddings са sentence-transformers/all-MiniLM-L6-v2 (384 dimensions, mean-pooled, L2-normalised, 256-token window); reranking е cross-encoder/ms-marco-MiniLM-L-6-v2 върху top 25; generation example е Qwen/Qwen2.5-0.5B-Instruct с greedy decoding. Всички timings са single-threaded CPU. Не е извикан платен API за създаването на тази глава, което е и причината всяка latency тук да е local и да е обозначена като такава.

Chunker, показан в TypeScript, е chunker, който беше измерен: Python instrument, имплементиращ същото правило и ts/chunk.ts, бяха сравнени chunk по chunk върху целия корпус и съвпадат за всичките 940 chunks, texts и offsets. Интервалите са Wilson при 95 %; paired comparisons са two-sided exact sign tests върху discordant pairs.

Всички четиринадесет identifiers, цитирани по-горе, бяха resolved срещу arXiv API и проверени title by title на 7 септември 2026 г. — което, предвид осемте, които не бяха, изглеждаше като най-малкото, което тази конкретна глава можеше да направи.

  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 и на двете константи, използвани по-горе, и мястото да прочетете защо 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 е тяхно, а смисълът на метода е, че не се нуждае от calibration между score scales, които слива.

  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, върху който е построен индексът на тази глава и който беше измерен в Глава 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 и референтната имплементация на IVF, измерен в полето по-горе.

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

  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, без достъп до internals на модела и без 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 на модела да решава кога да retrieve-ва, вместо retrieval на всеки turn.

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


Създадено от

David Vicente Campos

Основател на NeuraLIA Labs и съосновател на MyRealFood

Компютърен инженер съм, завършил Университета в Леон. Съосновах MyRealFood, където като CTO създадох приложението, което милиони хора са използвали, за да се хранят по-здравословно, и основах NeuraLIA Labs, където изграждам AI продукти. Тук пиша за това, което трябваше да разбера по пътя, така, както ми се иска някой да ми го беше обяснил.

Още за автора

Публикувано от NeuraLIA Labs.

Получавайте нови публикации във входящата си поща

Новини за AI, ръководства и продуктови обновления — кратък имейл, когато публикуваме нещо, което си заслужава.

Индекс на курса

Abstract software decision engine with branching paths, probability nodes, and glowing gates.
jev12 мин четене

AI моделът Jev е създаден за решения, не за проза

Jev на TypeSafe AI привлича внимание, защото разглежда софтуерната интелигентност като проблем на вероятностите: изберете правилния клон, добавете увереност и не плащайте на LLM да пише текст, когато кодът има нужда от решение.

Abstract legal research workspace with documents, search nodes and governance controls.
openai11 мин четене

Astra for Law на OpenAI е правна AI система, не нов модел

Правният старт на OpenAI е не толкова за нов базов модел, колкото за системата около него: домейн извличане, надеждни инструменти, права, бенчмаркове и пътища за преглед.

Abstract agent runtime sorting documents, memory blocks and pointer nodes inside a bounded context frame.
context-engineering12 мин четене

Инженеринг на контекста за AI агенти с дълъг хоризонт

Дълго работещите агенти не се провалят само защото прозорецът е малък. Те се провалят, когато файлове, изходи от инструменти и остаряла история изтласкат задачата, която агентът е трябвало да завърши.

Готови ли сте LIA да избира вместо вас?

Създавайте с всички AI модели на едно място — започнете безплатно още днес.