RAG در Production: chunking، بازیابی و ارجاعهای صادقانه
chunking کور پاسخ را قبل از بازیابی نابود میکند؛ اصلاح chunker بهتنهایی رتبه 115 را به 3 میرساند.
در این صفحه
این یک پرسش واقعی از یک کاربر واقعیِ یک assistant واقعی است: مجموعه eval من 20 مورد دارد، آیا برای اعتماد به score کافی است. corpus پاسخ را دارد — یک بخش کامل درباره آن. اینها چهار قطعهای هستند که retriever واقعاً داخل prompt گذاشته است.
[1] d=0.578 ship — that set has been used for fitting, and its score stops being
unbiased. Measured on this belt: sweeping the threshold on the
validation set picks 0.196, and the model then scores F1 = 0.4122…
[2] d=0.602 ng when the model is confidently **wrong**. Evaluate both at a few
scores, for an example whose true label is 1: | score | p | …
[3] d=0.613 ard and watch both numbers: | | reward model's score | true quality
| length produced | … The reward went up by a factor of 2.5. The…
[4] d=0.617 | 0.6 | +0.97 | +1.00 | +0.27 | … The reward model is working
perfectly. It has faithfully learned the preferences it was shown…سهتا از چهار قطعه از وسطِ کلمه شروع میشوند. دو قطعه از فصلی دیگر درباره موضوعی دیگرند. و قطعهای که به پرسش پاسخ میدهد — همان که شامل Seventeen out of twenty cannot distinguish an 85 % model from a 65 % one است — در رتبه 115 برگشت.
حالا همان پرسش، همان embedding model، همان prompt template. فقط یک چیز عوض شد: اینکه سندها چطور بریده شدند.
[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. هیچکس به model، prompt، threshold یا تعداد slotها دست نزد. این فصل درباره همین فاصله است، و درباره چهار جای دیگر که یک سیستم بازیابی بیسروصدا به شما دروغ میگوید.
نمایش جزئیات
این فصل از فصلهای قبلی به چه نیاز دارد، و تنها جایی که زبان را عوض میکند.
- فصل 1 dot product و L2 norm را تعریف کرد. بخش threshold پایین همین دو است، و هیچ چیز دیگر.
- فصل 8 جدول embedding یک language model را از یک embedding model برای بازیابی که بهصورت contrastive روی pairها آموزش دیده جدا کرد، cosine similarity را سنجید، و با این وعده تمام شد که فصل 19 به یک cut-off مشخص میرسد. اینجا موعد آن وعده است. هیچکدام تکرار نمیشود.
- فصل 4 Wilson interval را ساخت؛ فصل 15 harness ارزیابی را ساخت. هر جدول پایین اولی را همراه دارد و با دومی تولید شده است.
- فصل 16 قیمت context window را محاسبه کرد. prompt که در پایان این فصل سرهم میشود 591 token هزینه دارد، و این همان بودجهای است که قطعهها برایش رقابت میکنند.
همهچیز اینجا TypeScript است، مثل بعد از فصل 14، و این همان فصلی است که قاعده ارزش خودش را ثابت میکند: ingestion یعنی queue و storage، search یک network call است، و سرهمکردن prompt با ارجاعها کار server است. اندازهگیری عمداً همان code است با یک scoreboard دورش — retrieverی که با پیادهسازی دوم score شود عددی درباره نرمافزاری است که قرار نیست ship کنید، و threshold مربوط به cosine در پایین فقط چون میبینید توسط همان chunker که در production اجرا میشود sweep میگردد، باورکردنی است.
Corpus، و اینکه پاسخ درست یعنی چه
لینک به بخش: Corpus، و اینکه پاسخ درست یعنی چههمهچیز پایین نسبت به یک corpus سنجیده شده است: سیزده فصل اول این دوره — 13 سند، 359,067 کاراکتر، 127 بخش، با حذف front matter و کتابنامهها. این یک corpus فنی واقعی است، با نثر، جدول، فرمول و code block، و دقیقاً همان چیزی است که مردم داخل knowledge base میریزند و بعد از آن شکایت میکنند.
ground truth شامل 32 پرسش است، هرکدام همراه با یک needle: یک جمله کوتاه عیناً از corpus که پاسخ آن را میدهد. هر needle دقیقاً یکبار در آن 359,067 کاراکتر ظاهر میشود، و هیچکدام heading بخش نیست — این check مهم است، چون chunkerی که headingها را داخل هر chunk کپی کند وگرنه خودش را score میکند. هر پرسش دو بار پرسیده میشود، یکبار به انگلیسیِ دوره و یکبار همانطور که یک support ticket آن را مینویسد: 64 query روی 32 ground truth.
بازیابی وقتی درست است که chunk برگشتی needle را کامل داشته باشد. این تنها تعریفی است که با نیاز generator میخواند: نصف جمله در prompt پاسخ نیست، خطر است.
embedding model برابر است با all-MiniLM-L6-v2 — 384 dimension، mean-pooled و نرمالشده، همان model آموزشدیده contrastively که فصل 8 اندازه گرفت. index کردن corpus روی CPU 20.8 ثانیه طول میکشد، 22 ms برای هر chunk؛ embedding کردن یک query 13 ms طول میکشد.
Chunking، با شش اندازهگیری
لینک به بخش: Chunking، با شش اندازهگیریشش strategy از سه جزء مستقل. Blind هر 512 کاراکتر را بدون نگاهکردن به متن میبُرد. Boundaries هرگز داخل paragraph نمیبُرد، و فقط وقتی یک paragraph از بودجه بزرگتر باشد به مرز sentence fallback میکند. Header عنوان سند و مسیر بخش را به ابتدای هر chunk اضافه میکند. Overlap آخرین 64 کاراکتر chunk قبلی را در chunk بعدی کپی میکند.
| strategy | chunks | answers destroyed | R@1 | R@4 | R@8 | R@20 | MRR |
|---|---|---|---|---|---|---|---|
| A blind 512 | 708 | 4 / 32 | 0.125 | 0.297 | 0.422 | 0.594 | 0.241 |
| B blind + overlap | 809 | 0 | 0.172 | 0.391 | 0.453 | 0.625 | 0.286 |
| C boundaries | 940 | 0 | 0.156 | 0.422 | 0.531 | 0.672 | 0.293 |
| D boundaries + overlap | 940 | 0 | 0.156 | 0.359 | 0.516 | 0.656 | 0.277 |
| E boundaries + header | 940 | 0 | 0.094 | 0.422 | 0.578 | 0.828 | 0.280 |
| F boundaries + header + overlap | 940 | 0 | 0.156 | 0.391 | 0.562 | 0.766 | 0.298 |
با 64 query، Wilson interval 95٪ برای R@20 در A برابر [0.471, 0.705] و در E برابر [0.718, 0.901] است — اینها همپوشانی ندارند، اما بیشتر ستونهای دیگر دارند، و یک جدول unpaired نمیتواند جدایشان کند. هر strategy به همان queryها پاسخ میدهد، پس test صادقانه paired است: بردها و باختهای هر strategy را در برابر دیگری بشمارید و روی pairهای discordant یک sign test اجرا کنید. سه نتیجه از آن جان سالم به در میبرند.
Blind chunking چهار پاسخ از سیودو پاسخ را تماماً نابود میکند. نه اینکه بد rank کند — نابودشان میکند. needle از مرز 512 کاراکتری رد میشود، پس هیچ chunkای در index آن را ندارد، و سقف recall برای آن queryها صفر است. هیچ reranker آنها را برنمیگرداند، هیچ threshold کمکی نمیکند، هیچ model بزرگتری کمک نمیکند. نمیتوانید متنی را retrieve کنید که هیچ جای index شما یکتکه وجود ندارد. این کمگزارششدهترین شکست در RAG است، چون دقیقاً شبیه retriever بد به نظر میرسد.
Overlap این را درست میکند و هیچ چیز دیگر را نه. هر strategy با overlap صفر پاسخ را از دست میدهد، و overlap برای همین است. ranking را بهتر نمیکند: B در برابر A روی R@8 برابر +8/−6 است، p = 0.79؛ روی R@20 برابر +9/−7 است، p = 0.80. بدتر اینکه اضافهکردن overlap روی header فعالانه آسیب میزند — F در برابر E روی R@20 برابر +2/−6 است — و دلیلش مکانیکی است. vector یک chunk میانگین tokenهای آن است، پس 64 کاراکتر از chunk قبلی آن میانگین را به سمت موضوع همسایه میکشد. Overlap بیمهای است در برابر پاسخ splitشده، که بهایش را با precision میپردازید.
Contextual header چیزی است که retrieval میخرد. E در برابر A برابر +18/−3 روی R@20، p = 0.0015 است. و ablation میگوید boundaries عاملش نیستند: E در برابر C — همان cutها، header تنها تفاوت — برابر +12/−2، p = 0.0129 است. اضافهکردن «Classification, Cross-Entropy, and How Not to Fool Yourself > How many test examples do I need?» به ابتدای یک paragraph به embedding model میگوید paragraph درباره چیست، چیزی که خود paragraph اغلب نمیگوید. این pronoun resolver برای سندهاست.
این به chunker شکلش را میدهد، و یک قاعده که اشتباهگرفتنش آسان است:
export interface Chunked {
/** What gets EMBEDDED: contextual header + this chunk's own content. */
text: string;
/** ONLY this chunk's own content: what is quoted back to the user. */
content: string;
section: string;
/** Character range in the document's canonical text. Sliceable. */
from: number;
to: number;
}
export function chunkDocument(doc: string, docTitle: string, target = 512): Chunked[] {
const out: Chunked[] = [];
const heads = [...doc.matchAll(/^## (.+)$/gm)].map((m) => ({ at: m.index!, title: m[1].trim() }));
const spans = heads.length
? heads.map((h, i) => ({ ...h, end: i + 1 < heads.length ? heads[i + 1].at : doc.length }))
: [{ at: 0, title: "", end: doc.length }];
for (const s of spans) {
const header = s.title ? `${docTitle} > ${s.title}` : docTitle;
const skip = /^## .+\n/.exec(doc.slice(s.at, s.end))?.[0].length ?? 0;
const body = doc.slice(s.at + skip, s.end);
const origin = s.at + skip;
// The offset is FOUND in the document, never accumulated: adding up
// lengths drifts by a character wherever a separator was normalised,
// and a citation anchor off by one points at the wrong line.
const emit = (from: number, to: number) => {
const raw = body.slice(from, to);
const lead = raw.length - raw.trimStart().length;
const content = raw.trim();
if (!content) return;
out.push({ text: `[${header}]\n${content}`, content, section: s.title,
from: origin + from + lead, to: origin + from + lead + content.length });
};
let open: [number, number] | null = null;
for (const m of body.matchAll(/[^\n]([^\n]|\n(?!\n))*/g)) { // paragraphs
const [pf, pt] = [m.index!, m.index! + m[0].length];
if (pt - pf > target) { // one huge paragraph
if (open) { emit(open[0], open[1]); open = null; }
let cur: [number, number] | null = null;
for (const sm of body.slice(pf, pt).matchAll(/[^.!?]*[.!?]*\s*/g)) {
if (!sm[0]) continue;
const [sf, st] = [pf + sm.index!, pf + sm.index! + sm[0].length];
if (cur && st - cur[0] > target) { emit(cur[0], cur[1]); cur = null; }
cur = cur ? [cur[0], st] : [sf, st];
}
if (cur) emit(cur[0], cur[1]);
continue;
}
if (open && pt - open[0] > target) { emit(open[0], open[1]); open = null; }
open = open ? [open[0], pt] : [pf, pt];
}
if (open) emit(open[0], open[1]);
}
return out;
}دو متن، نه یکی. text همان چیزی است که embedding میشود، با header و همه چیز. content فقط کلمات خودِ این chunk است، و همان چیزی است که به کاربر quote میشود. اگر text را quote کنید، citation یک header را نشان میدهد که در آن نقطه از سند وجود ندارد — و با overlap، یک tail تکراری که به قطعه قبلی تعلق دارد. آنوقت متنی را نمایش میدهد که جایی که ادعا میکند نیست، و این از نشانندادن هیچ چیز بدتر است.
header رایگان نیست. در 940 chunk، 24,213 token از 114,275 embedded tokenِ index را مصرف میکند: 21.2٪ چیزی که برای embedding آن پول میدهید headerی است که خودتان نوشتهاید. همچنین chunkها را به windowِ encoder فشار میدهد. all-MiniLM-L6-v2 256 word-piece میپذیرد؛ strategy E تعداد 17 chunk بالای این خط دارد و F تعداد 28، و تکتکشان بیهیچ هشداری بیصدا truncated میشوند. اندازه مؤثر chunk شما عدد داخل config نیست — کوچکترِ آن عدد و windowِ encoder شماست.
بیست خط BM25 که همه از آن میگذرند
لینک به بخش: بیست خط BM25 که همه از آن میگذرندDense retrieval یک ضعف نظاممند دارد و ظریف هم نیست: معنی را match میکند، بنابراین نسبت به اینکه دقیقاً کدام string را typed کردهاید بیتفاوت است. شماره قطعه، کد خطا، acronym، نام خانوادگی — هیچکدام معنای مفیدی برای embedding ندارند، و nearest neighbour یک کد خطا، هر کد خطای دیگری در corpus شماست.
پاسخ کلاسیک از همه اینها قدیمیتر است و بیست خط زمان میبرد. BM25 یک سند را بر اساس اینکه termهای query چند بار در آن ظاهر میشوند score میکند، هر term را وقتی frequency آن بالا میرود damp میکند و سندهای بلند را که صرفاً به خاطر طولشان match جمع میکنند penalise میکند.1 term چنین contribute میکند
که در آن شمارش term در سند است، طول آن، میانگین طول، و و دو constant متعارف — تعیین میکند repetition با چه سرعتی دیگر کمک نکند، تعیین میکند length چقدر سخت مجازات شود.
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 chunk، یک query را در 1.14 ms score میکند، بدون هیچ indexای فراتر از دو hash map. و یک شیء موزهای نیست:
| retriever | R@1 | R@4 | R@8 | MRR | cost per query |
|---|---|---|---|---|---|
| dense (cosine) | 0.094 | 0.422 | 0.578 | 0.280 | 13 ms برای embed + 0.3 ms برای scan |
| lexical (BM25) | 0.219 | 0.375 | 0.469 | 0.313 | 1.14 ms |
| hybrid (RRF) | 0.203 | 0.484 | 0.609 | 0.346 | هر دو |
| hybrid + cross-encoder | 0.312 | 0.578 | 0.703 | 0.447 | + 569 ms |
BM25 دقت top-1 در dense retriever را روی این corpus بیش از دو برابر میکند، و تا rank 8 بهشدت از آن میبازد. آنها روی queryهای متفاوتی fail میکنند، و کل استدلال برای اجرای هر دو همین است.
ترکیبکردنشان همان جایی است که راه بدیهی غلط است. فاصلههای cosine و scoreهای BM25 روی یک scale نیستند، به یک شکل bounded نیستند، و normalise کردنشان بهازای هر query باعث میشود weight به اینکه بهترین hit اتفاقاً چقدر خوب بوده وابسته شود. Reciprocal rank fusion scoreها را دور میاندازد و فقط rankها را نگه میدارد:2
/** Reciprocal rank fusion: ranks, not scores. Nothing to calibrate. */
export function rrf(lists: number[][], k = 60): number[] {
const acc = new Map<number, number>();
for (const list of lists)
list.forEach((id, r) => acc.set(id, (acc.get(id) ?? 0) + 1 / (k + r + 1)));
return [...acc.entries()].sort((a, b) => b[1] - a[1]).map(([id]) => id);
}و اینجا خوانش صادقانه جدول از خود جدول مهمتر است. Hybrid در R@4 با +10/−3 از BM25 جلو میزند، p = 0.09. با +10/−6 از dense جلو میزند، p = 0.45. روی این corpus، با 64 query، hybrid retrieval از dense retrieval قابل تفکیک نیست. هم در point estimateها و هم در هر ستون recall بهتر است، و شواهد به significance نمیرسند. تقریباً هر پست وبلاگی درباره hybrid-search در اینترنت جدولی مثل جدول بالا و بدون interval گزارش میکند؛ interval این را میگوید.
Bi-encoder، cross-encoder، و اینکه lift واقعاً کجاست
لینک به بخش: Bi-encoder، cross-encoder، و اینکه lift واقعاً کجاستهمهچیز تا اینجا یک bi-encoder است: query بهتنهایی از model عبور میکند، هر chunk ماهها پیش بهتنهایی از آن عبور کرده، و این دو جز بهصورت dot product هرگز همدیگر را نمیبینند. همین index را ممکن میکند — یکبار embed کن، تا ابد reuse کن — و همین سقف آن هم هست. model هرگز query و chunk را با هم نمیبیند.
یک cross-encoder دقیقاً همین کار را میکند: pair را بهعنوان یک input میگیرد و relevance score برمیگرداند. هیچ چیز قابل precompute نیست، پس نمیتواند یک index را rank کند — اما میتواند یک shortlist را rerank کند. Rerankingِ top 25 در hybrid با ms-marco-MiniLM-L-6-v2، R@1 را از 0.094 (dense) به 0.312 و MRR را از 0.280 به 0.447 میرساند: بزرگترین improvement منفرد در این فصل، و تنها موردی که به بالای list دست میزند نه tail آن.
روی CPU برای هر query 569 ms هزینه دارد، در برابر 1.14 ms برای BM25 و 0.3 ms برای vector scan. تقریباً دو هزار برابر هزینه retrieval، برای بیستوپنج document. کل trade-off بین bi-encoder/cross-encoder در یک عدد همین است، و به همین دلیل architecture همیشه همان شکل را دارد: یک retriever ارزان با recall گسترده، بعد یک scorer گران روی shortlistای که از پسش برمیآیید. ColBERT بین این دو مینشیند، vectorهای per-token را precompute میکند و late interaction انجام میدهد که از cross-encoder ارزانتر و از dot product تیزتر است.3
L2، cosine، و thresholdی که به دست نیاوردهاید
لینک به بخش: L2، cosine، و thresholdی که به دست نیاوردهایدVector databaseها distance گزارش میکنند، و اینکه کدام distance باشد یک گزینه config است. روی vectorهای نرمالشده انتخاب cosmetic است، و این identity ارزش یکبار انجامدادن را دارد چون هرچیز بعد از آن به این وابسته است که vectorها واقعاً unit باشند. برای :
پس cosine distance یعنی دقیقاً است. این همان dot product و norm فصل 1 است که نقد میشود. روی دو vector واقعی chunk از index بالا، و سپس روی 40,000 pair check شد:
||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 — و فقط چون vectorها نرمالشدهاند. نرمالسازی را skip کنید و identity غلط است، threshold شما هیچ معنایی ندارد، و distanceی که یک document گزارش میکند به طول متن آن بستگی دارد.
حالا عددی که هیچکس derivation نمیکند. retriever همیشه چیزی برمیگرداند: کل index را sort میکند و بالای list را تحویل میدهد، چه پاسخ جایی در corpus باشد چه نباشد. threshold تنها بخش سیستم است که میتواند بگوید نه — و برای تنظیم آن به queryهایی نیاز دارید که باید هیچ چیز برنگردانند. اینجا سیتا هستند: بیستویکتا درباره چیزهایی که این corpus واقعاً پوشش نمیدهد — streaming، rate limitها، prompt caching، JSON schemaها، agent loopها، vector databaseها، prompt injection، image generation — و نهتا درباره paella، passport و refund policy. در برابر همان index:
| top-1 cosine distance | |
|---|---|
| in-domain queries، همه 64 تا | mean 0.445, range 0.270 – 0.721 |
| in-domain، top-1 واقعاً correct | mean 0.370 |
| in-domain، top-1 wrong | mean 0.452 |
| out-of-domain، همه 30 تا | mean 0.699, range 0.497 – 0.867 |
توزیعها جدا میشوند، و همپوشانی هم دارند. بدترین query داخل دامنه از پاسخ خودش دورتر است (0.721) تا بهترین query خارج از دامنه از یک paragraph بیربط (0.497)، پس هیچ thresholdی هر دو را درست نمیکند. Sweep کردن آن روی gate واقعی — حداکثر چهار chunk نگه دار، و فقط آنهایی که زیر cut هستند:
| threshold | in-domain answered | of which the answer was in | out-of-domain answered |
|---|---|---|---|
| 0.400 | 17 / 64 | 6 | 0 / 30 |
| 0.450 | 38 / 64 | 13 | 0 / 30 |
| 0.500 | 50 / 64 | 19 | 1 / 30 |
| 0.525 | 52 / 64 | 20 | 2 / 30 |
| 0.550 | 55 / 64 | 21 | 3 / 30 |
| 0.600 | 60 / 64 | 25 | 5 / 30 |
| 0.675 | 62 / 64 | 27 | 10 / 30 |
| 0.800 | 64 / 64 | 27 | 26 / 30 |
| none | 64 / 64 | 27 | 30 / 30 |
ستون آخر را بهعنوان لافها بخوانید. بدون threshold، assistant به «چطور passport اسپانیاییام را تمدید کنم» از corpusی درباره backpropagation، سی بار از سی بار یک پاسخ مطمئن و با citation تولید میکند. در 0.675 این کار را ده بار از سی بار انجام میدهد. در 0.525 دو بار انجام میدهد، و از دوازده پرسشی که میتوانست جواب دهد صرفنظر میکند.
این trade یک تصمیم محصولی است، و سمت درست آن به هزینه پاسخ غلط برای شما بستگی دارد. چیزی که قابل مذاکره نیست این است که ستون آخر اصلاً وجود داشته باشد. اگر هرگز retriever خود را در برابر پرسشهایی که باید رد کند نسنجیدهاید، threshold ندارید — یک عدد دارید.
دو تا از ده لاف در 0.675 دو شکل failure را نشان میدهند.
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 است: corpus، KV cache را مفصل توضیح میدهد، query درباره prompt cache است، کلمات همان کلماتاند، و 0.497 از بیشتر retrievalهای درست داخل دامنه در کل experiment نزدیکتر است. embedding نمیداند دو cache با نام مشابه machineهای متفاوتی هستند. دومی یک literal match بدون پاسخ است: corpus عبارت دقیق «what is the capital of France» را دارد، بهعنوان مثالِ پرسشی که هیچ reasoning نمیخواهد. retriever درست میگوید؛ پاسخ آنجا نیست. هر سیستمی که «چیزی مشابه پیدا کردم» را بهعنوان «پاسخ را پیدا کردم» بخواند، بر اساس همین evidence پاریس را assert میکند — یا بدتر، نمیکند.
چرا citation را model نمینویسد
لینک به بخش: چرا citation را model نمینویسدیک model توانایی جداگانهای برای factها ندارد. تولید یک جمله true و تولید یک جمله plausible، همان operation است — next-token prediction فصل 8 — و هیچ چیز در آن operation مشخص نمیکند کدامیک کدام است. تحلیل 2025 که این را از نو frame کرد استدلال میکند pipeline آموزش و ارزیابی فعالانه guessing را reward میکند: benchmarkها با binary accuracy score میدهند و برای abstention هیچ creditی نمیدهند، پس modelی که همیشه پاسخ میدهد از model یکسانی که وقتی نمیداند میگوید «نمیدانم» بهتر score میگیرد، و post-training هم مطابق آن optimise میشود.6 hallucination در این خوانش عیب مرموزی نیست. همان چیزی است که وقتی امتحان چندگزینهای را بدون penalty برای پاسخ غلط grade کنید به دست میآورید.
شکلش را ببینید. وقتی از Qwen2.5-0.5B-Instruct خواسته شد هشت مقاله درباره contrastive sentence embeddings با identifierها بدهد، هشت خط با format کامل تولید کرد. هر هشت identifier well-formed هستند. هر هشت به paperهای واقعی در arXiv resolve میشوند. صفر تا از هشتتا همان paper ادعاشدهاند.
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ای که check کند «آیا این identifier وجود دارد» هر هشتتا را pass میکند، و کاربری که روی یکی کلیک کند روی صفحهای واقعی از archive واقعی فرود میآید، بیهیچ راهی برای اینکه بفهمد mapping اختراع شده. failure در identifier یا format نیست. در association است — دقیقاً همان چیزی که language model با plausibility تولید میکند.
پس: model، [1] و [2] را مینویسد، و هرگز link را نمینویسد. عددها به fragmentهایی اشاره میکنند که server retrieve کرده، و server — که دقیقاً میداند هر عدد از کدام document و کدام offset آمده — بعداً document، label و URL را attach میکند. چیزی برای اختراعکردن model وجود ندارد، چون هرگز از آن تنها چیزی که اختراع میکرد خواسته نمیشود.
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 };
}آن را روی پرسش آغازین اجرا کنید و چهار chunk تبدیل میشوند به یک prompt با 591 token و جدولی که model هرگز نمیبیند:
[1] 04-classification How many test examples do I need? #char=28215,28701 d=0.594
[2] 04-classification Three splits, and the leak… #char=20329,20839 d=0.598
[3] 04-classification How many test examples do I need? #char=25873,26272 d=0.600
[4] 04-classification How many test examples do I need? #char=25554,25871 d=0.605locator همان بخشی است که مردم skip میکنند و بعداً نمیتوانند اضافه کنند. #char=25873,26272 یک range در متن canonical سند است؛ برای PDF معادلش #page=12 است، برای audio یا video #t=132.4,158.9، برای spreadsheet یک sheet و A1 range. این دو اختراع نیستند — #page= PDF Open Parameters است و #t= W3C Media Fragments، که browserها بهصورت native روی video و audio elementها رعایت میکنند. citation بدون locator نام document است، و نام document citation نیست؛ پیشنهادی است به کاربر که برود و خودش نگاه کند.
و وقتی هیچچیز از threshold عبور نمیکند، pipeline اصلاً به model نمیرسد:
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"این refusal ارزانتر و قابلاعتمادتر از هر instruction در system prompt است، چون مقایسه بین دو عدد است نه درخواست از یک سیستم probabilistic.
Retriever را جدا از generator ارزیابی کنید
لینک به بخش: Retriever را جدا از generator ارزیابی کنیدهر اندازهگیری در این فصل retriever را score میکند و حتی یکبار هم از model نمیخواهد پاسخ بنویسد. این deliberate است، و همان قطعهای است که بیشتر teamها skip میکنند.
یک سیستم RAG دو failure mode دارد که از بیرون یکسان به نظر میرسند. retriever passage را پیدا نکرده؛ یا پیدایش کرده و generator نادیدهاش گرفته، با آن contradiction ساخته، یا با چیزی که از قبل باور داشته blended کرده. اگر فقط final answer را score کنید، این دو قابل تشخیص نیستند، پس promptها را علیه مسئلهای tune میکنید که در chunker شما زندگی میکند. Recall@k، MRR و answer-destroyed count اصلاً generation call نمیخواهند، بهاندازه کافی ارزاناند که روی هر deploy اجرا شوند، و همان harness فصل 15 با یک scoring function متفاوتاند — همان request، deadline، concurrency و tally، روی question set ثابت بهجای live conversation.
آنها را با interval گزارش کنید. arithmetic فصل 4 بدون تغییر apply میشود: با 64 query، recall برابر 0.5 یک Wilson interval 95٪ تقریباً ±0.12 دارد، پس strategyای که چهار point جلوتر از دیگری است هیچ چیزی به شما نگفته. هر وقت هر دو strategy به همان پرسشها پاسخ میدهند از paired test استفاده کنید، که اینجا همیشه همینطور است — همان چیزی که «E بهتر از A به نظر میرسد» را به p = 0.0015 تبدیل کرد.
و آخرین صداقت: RAG hallucination را کم میکند و حذف نمیکند. گذاشتن passage درست داخل prompt model را مجبور به استفاده از آن نمیکند، و literature از paper اصلی همین را گفته است.7 دو چیز در production بدترش میکند. contextهای بلند degrade میشوند — model اطلاعات را در شروع و پایان یک prompt بلند قابلاعتمادتر از وسط آن پیدا میکند، پس بیست chunk بهجای چهار میتواند accuracy را پایین بیاورد و bill را بالا ببرد، اثری که در فصل 24 اندازهگیری شده است. و retrieval میتواند درست باشد و همچنان insufficient، همانطور که دو cache بالا نشان دادند. SelfCheckGPT claimهایی را flag میکند که از resampling جان سالم به در نمیبرند؛8 Self-RAG model را آموزش میدهد که retrieve-and-critique tokenهای خودش را emit کند؛9 TruthfulQA اصلاً failure mode را خوانا کرد.10 هیچکدام gap را نمیبندد، و سیستمی که متن retrieveشده را بهعنوان proof ارائه میکند sourced را با true اشتباه گرفته است.
نیمی از سیستم که پیش از هر query اجرا میشود
لینک به بخش: نیمی از سیستم که پیش از هر query اجرا میشودretriever بخش قابلدیدن pipelineای است که failureهایش همه زودتر، در تاریکی، رخ میدهند. سهتایشان تکرار میشوند.
Extraction جایی است که content میمیرد. PDF متن نیست؛ دستورهای drawing است. layoutهای دو ستونه interleave میشوند، tableها به word soup تبدیل میشوند، page headerها داخل هر chunk تکرار میشوند، و صفحه scanned اصلاً متن ندارد تا وقتی OCR چیزی به آن بدهد، همراه با confidence. هرچیزی که بالا اندازهگیری شد فرض کرده extractor کارش را درست انجام داده؛ در production اغلب اینطور نیست، و symptom سه لایه آنطرفتر بهصورت retrieval بد ظاهر میشود.
Index با modelای که آن را ساخته stamped میشود. Embeddingهای دو model قابل مقایسه نیستند — نه «کمدقتتر»، قابل مقایسه نیستند، چون pointهایی در spaceهای متفاوتاند. embedding model را عوض کنید و هر vector در store تا وقتی rebuild نشود garbage است. بنابراین نام model، dimension count، pipeline version و extractor version کنار هر document در زمان index نوشته میشود. بدون آنها، روز upgrade، نمیتوانید بفهمید کدام document stale است و کدام current، و یک index نیمه-migrated با confidence nonsense برمیگرداند بیآنکه هیچجا errorی باشد.
یک document خراب نباید folder را خراب کند، و counterها باید چیزی را که رخ داده بشمارند. سندی که extraction آن fail میشود در stateِ failed با reason خودش تمام میشود، visible و retryable، در حالی که نودونه سند دیگر searchable میمانند؛ و تعداد chunkهای indexed را server وقتی finish میکند مینویسد، نه client وقتی upload میکند declare کند. folderی که 400 fragment گزارش میکند و 40 تا دارد، دروغی است که فقط بهشکل پرسشی بیپاسخ surface میشود.
بعدش به کجا میرسد
لینک به بخش: بعدش به کجا میرسدسیستم این فصل به پرسشهایی پاسخ میدهد که پاسخهایشان نوشته شدهاند. آنها را retrieve میکند، rank میکند، وقتی نمیتواند refuse میکند، و cite میکند کجا را نگاه کرده. این بیشتر چیزی است که مردم از یک assistant روی سندهای خودشان میخواهند، و یک محدودیت مشخص دارد: retrieval فقط میتواند چیزی را برگرداند که کسی نوشته باشد.
پس نیمه دیگر باقی میماند. بخشی از چیزی که میخواهید model انجام دهد اصلاً factی در document نیست — formatی که باید نگه دارد، tone، taxonomy با چهارصد label، شیوه تصمیمگیریای که در دههزار مثال گذشته زندگی میکند و در هیچ paragraphی نیست. Retrieval نمیتواند اینها را deliver کند، چون چیزی برای retrieve وجود ندارد؛ prompt بلندتر فقط bill فصل 16 را برای توضیح یک skill میپردازد نه خود skill.
فصل 20 همان تصمیم است — fine-tune، retrieve یا prompt — و یافتهاش این است که تصمیم قبل از technical بودن economic است: هر سه end to end روی همان پرسش price میشوند، و crossover یک token count است. پرسشی که آن را باز میکند همان پرسشی است که این فصل نمیتواند پاسخ دهد. نه پاسخ کجا نوشته شده، بلکه وقتی هرگز نوشته نشده بود چه میکنید.
منابع و روش
لینک به بخش: منابع و روشهرچه در این فصل اندازهگیری شد از یک corpus و یک ابزار استفاده کرد، و هر دو reproducible هستند. corpus فصلهای 1 تا 13 این دوره است همانطور که در 7 سپتامبر 2026 بودند — 13 سند، 359,067 کاراکتر، 127 بخش، front matter و کتابنامهها حذف شده. آن فصلها همچنان edit میشوند، پس apply کردن همان rule امروز چند هزار کاراکتر بیشتر میشمارد: section count بیتغییر است و هر conclusion پایین هم همینطور، اما character total یک snapshot است و بهعنوان همین label شده. ground truth شامل 32 پرسش است، هرکدام همراه با جملهای عیناً که دقیقاً یکبار در corpus رخ میدهد و هرگز heading بخش نیست، در دو phrasing برای 64 query پرسیده شده. Retrieval embeddingها sentence-transformers/all-MiniLM-L6-v2 هستند (384 dimension، 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 است. همه timingها single-threaded CPU هستند. هیچ paid API برای تولید این فصل call نشده، و به همین دلیل هر latency اینجا local است و به همین صورت label شده.
chunker نشاندادهشده در TypeScript همان chunkerی است که اندازهگیری شد: ابزار Python که همان rule و ts/chunk.ts را پیاده میکند، chunk به chunk روی کل corpus با آن مقایسه شد و روی همه 940 chunk، متنها و offsetها یکسان است. intervalها Wilson در 95٪ هستند؛ comparisonهای paired، exact sign test دوطرفه روی pairهای discordant هستند.
تمام چهارده identifier بالا در برابر arXiv API resolve شدند و title به title در 7 سپتامبر 2026 check شدند — که با توجه به آن هشتتایی که نبودند، کمترین کاری بود که این فصل خاص میتوانست انجام دهد.
ارجاعات
لینک به بخش: ارجاعات-
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 و دو constant استفادهشده در بالا، و جایی برای خواندن اینکه اصلاً چرا وجود دارد. ↩
-
Cormack, G. V., Clarke, C. L. A. and Büttcher, S. Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods. SIGIR 2009. مال آنهاست، و نکته method این است که بین scaleهای score که fuse میکند هیچ calibration لازم ندارد. ↩
-
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 اندازهگیری شد. ↩
-
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 databaseهایی که اکنون فروخته میشوند. ↩
-
Johnson, J., Douze, M. and Jégou, H. Billion-scale Similarity Search with GPUs. arXiv:1702.08734 (2017). FAISS، و پیادهسازی reference برای IVF اندازهگیریشده در box بالا. ↩
-
Kalai, A. T., Nachum, O., Vempala, S. S. and Zhang, E. Why Language Models Hallucinate. arXiv:2509.04664 (2025). استدلال اینکه hallucination توسط grading با binary-accuracy تولید میشود که هرگز abstention را reward نمیکند، و بنابراین پیش از آنکه مسئله modelling باشد مسئله evaluation است. ↩
-
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 را نامگذاری کرد و همان چیزی که برای دانستن اینکه چه چیزهایی را fix میکند و چه چیزهایی را نه باید خواند. Guu et al., REALM: Retrieval-Augmented Language Model Pre-Training, arXiv:2002.08909 (2020)، کار همزمانی است که retriever را joint با model آموزش میدهد نه اینکه آن را bolt-on کند؛ Karpukhin et al., Dense Passage Retrieval for Open-Domain Question Answering, arXiv:2004.04906 (2020)، جایی است که dense retriever دو-encoderی که در سراسر این فصل استفاده شده از آن میآید؛ و Izacard and Grave, Leveraging Passage Retrieval with Generative Models for Open Domain Question Answering, arXiv:2007.01282 (2020)، arrangementِ fusion-in-decoder برای feed کردن passageهای زیاد به یک 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)، که یک پاسخ hypothetical را بهجای question embed میکند. ↩
-
Manakul, P., Liusie, A. and Gales, M. J. F. SelfCheckGPT: Zero-Resource Black-Box Hallucination Detection for Generative Large Language Models. arXiv:2303.08896 (2023). detection با resampling، بدون access به internalsِ model و بدون external knowledge base. ↩
-
Asai, A., Wu, Z., Wang, Y., Sil, A. and Hajishirzi, H. Self-RAG: Learning to Retrieve, Generate, and Critique through Self-Reflection. arXiv:2310.11511 (2023). آموزش model برای اینکه تصمیم بگیرد چه زمانی retrieve کند، بهجای retrieval در هر turn. ↩
-
Lin, S., Hilton, J. and Evans, O. TruthfulQA: Measuring How Models Mimic Human Falsehoods. arXiv:2109.07958 (2021). benchmarkی ساختهشده از پرسشهایی که در آنها پاسخ plausible و پاسخ true متفاوتاند، یعنی کل دشواری در یک جمله. ↩