پرش به محتوا
19/30فصل 19 از 30

RAG در Production: chunking، بازیابی و ارجاع‌های صادقانه

chunking کور پاسخ را قبل از بازیابی نابود می‌کند؛ اصلاح chunker به‌تنهایی رتبه 115 را به 3 می‌رساند.

در این صفحه

این یک پرسش واقعی از یک کاربر واقعیِ یک assistant واقعی است: مجموعه eval من 20 مورد دارد، آیا برای اعتماد به score کافی است. corpus پاسخ را دارد — یک بخش کامل درباره آن. این‌ها چهار قطعه‌ای هستند که retriever واقعاً داخل prompt گذاشته است.

four fragments, chunked blind at 512 charactersTEXT
[1] d=0.578  ship — that set has been used for fitting, and its score stops being
             unbiased. Measured on this belt: sweeping the threshold on the
             validation set picks 0.196, and the model then scores F1 = 0.4122…

[2] d=0.602  ng when the model is confidently **wrong**. Evaluate both at a few
             scores, for an example whose true label is 1: | score | p | …

[3] d=0.613  ard and watch both numbers: | | reward model's score | true quality
             | length produced | … The reward went up by a factor of 2.5. The…

[4] d=0.617  | 0.6 | +0.97 | +1.00 | +0.27 | … The reward model is working
             perfectly. It has faithfully learned the preferences it was shown…

سه‌تا از چهار قطعه از وسطِ کلمه شروع می‌شوند. دو قطعه از فصلی دیگر درباره موضوعی دیگرند. و قطعه‌ای که به پرسش پاسخ می‌دهد — همان که شامل Seventeen out of twenty cannot distinguish an 85 % model from a 65 % one است — در رتبه 115 برگشت.

حالا همان پرسش، همان embedding model، همان prompt 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]

رتبه 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 طول می‌کشد.

شش strategy از سه جزء مستقل. Blind هر 512 کاراکتر را بدون نگاه‌کردن به متن می‌بُرد. Boundaries هرگز داخل paragraph نمی‌بُرد، و فقط وقتی یک paragraph از بودجه بزرگ‌تر باشد به مرز sentence fallback می‌کند. Header عنوان سند و مسیر بخش را به ابتدای هر chunk اضافه می‌کند. Overlap آخرین 64 کاراکتر chunk قبلی را در 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 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 شکلش را می‌دهد، و یک قاعده که اشتباه‌گرفتنش آسان است:

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 است، و همان چیزی است که به کاربر 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 tt چنین contribute می‌کند

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 دو constant متعارف — k1k_1 تعیین می‌کند repetition با چه سرعتی دیگر کمک نکند، bb تعیین می‌کند length چقدر سخت مجازات شود.

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 chunk، یک query را در 1.14 ms score می‌کند، بدون هیچ indexای فراتر از دو hash map. و یک شیء موزه‌ای نیست:

retrieverR@1R@4R@8MRRcost per query
dense (cosine)0.0940.4220.5780.28013 ms برای embed + 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 را روی این corpus بیش از دو برابر می‌کند، و تا rank 8 به‌شدت از آن می‌بازد. آن‌ها روی queryهای متفاوتی fail می‌کنند، و کل استدلال برای اجرای هر دو همین است.

ترکیب‌کردنشان همان جایی است که راه بدیهی غلط است. فاصله‌های cosine و scoreهای BM25 روی یک scale نیستند، به یک شکل bounded نیستند، و normalise کردنشان به‌ازای هر query باعث می‌شود weight به اینکه بهترین hit اتفاقاً چقدر خوب بوده وابسته شود. Reciprocal rank fusion scoreها را دور می‌اندازد و فقط rankها را نگه می‌دارد: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 در 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 باشند. برای 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 است که نقد می‌شود. روی دو vector واقعی chunk از index بالا، و سپس روی 40,000 pair check شد:

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 — و فقط چون 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 واقعاً correctmean 0.370
in-domain، top-1 wrongmean 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 هستند:

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 به «چطور passport اسپانیایی‌ام را تمدید کنم» از corpusی درباره backpropagation، سی بار از سی بار یک پاسخ مطمئن و با citation تولید می‌کند. در 0.675 این کار را ده بار از سی بار انجام می‌دهد. در 0.525 دو بار انجام می‌دهد، و از دوازده پرسشی که می‌توانست جواب دهد صرف‌نظر می‌کند.

این trade یک تصمیم محصولی است، و سمت درست آن به هزینه پاسخ غلط برای شما بستگی دارد. چیزی که قابل مذاکره نیست این است که ستون آخر اصلاً وجود داشته باشد. اگر هرگز retriever خود را در برابر پرسش‌هایی که باید رد کند نسنجیده‌اید، threshold ندارید — یک عدد دارید.

دو تا از ده لاف در 0.675 دو شکل failure را نشان می‌دهند.

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 است: 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 ادعاشده‌اند.

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ای که 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 وجود ندارد، چون هرگز از آن تنها چیزی که اختراع می‌کرد خواسته نمی‌شود.

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 };
}

آن را روی پرسش آغازین اجرا کنید و چهار chunk تبدیل می‌شوند به یک prompt با 591 token و جدولی که 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 همان بخشی است که مردم 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 نمی‌رسد:

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"

این 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 شدند — که با توجه به آن هشت‌تایی که نبودند، کمترین کاری بود که این فصل خاص می‌توانست انجام دهد.

  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 و دو constant استفاده‌شده در بالا، و جایی برای خواندن اینکه اصلاً چرا 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 این است که بین scaleهای score که fuse می‌کند هیچ calibration لازم ندارد.

  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 databaseهایی که اکنون فروخته می‌شوند.

  5. Johnson, J., Douze, M. and Jégou, H. Billion-scale Similarity Search with GPUs. arXiv:1702.08734 (2017). FAISS، و پیاده‌سازی reference برای IVF اندازه‌گیری‌شده در box بالا.

  6. 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 است.

  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 را نام‌گذاری کرد و همان چیزی که برای دانستن اینکه چه چیزهایی را 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 می‌کند.

  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، بدون access به 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). آموزش model برای اینکه تصمیم بگیرد چه زمانی retrieve کند، به‌جای retrieval در هر turn.

  10. Lin, S., Hilton, J. and Evans, O. TruthfulQA: Measuring How Models Mimic Human Falsehoods. arXiv:2109.07958 (2021). benchmarkی ساخته‌شده از پرسش‌هایی که در آن‌ها پاسخ plausible و پاسخ true متفاوت‌اند، یعنی کل دشواری در یک جمله.


تهیه‌شده توسط

David Vicente Campos

بنیان‌گذار NeuraLIA Labs و هم‌بنیان‌گذار MyRealFood

من مهندس کامپیوتر و فارغ‌التحصیل دانشگاه لئون هستم. هم‌بنیان‌گذار MyRealFood بودم، جایی که به‌عنوان مدیر ارشد فناوری اپلیکیشنی را ساختم که میلیون‌ها نفر برای سالم‌تر غذا خوردن از آن استفاده کرده‌اند، و NeuraLIA Labs را بنیان‌گذاری کردم؛ جایی که محصولات هوش مصنوعی می‌سازم. اینجا از چیزهایی می‌نویسم که در طول مسیر باید می‌فهمیدم، همان‌طور که دوست داشتم کسی برایم توضیح می‌داد.

بیشتر درباره نویسنده

منتشرشده توسط NeuraLIA Labs.

پست‌های جدید را در ایمیل خود دریافت کنید

اخبار AI، راهنماها و به‌روزرسانی‌های محصول — هر وقت چیزی ارزشمند منتشر کنیم، یک ایمیل کوتاه می‌فرستیم.

فهرست دوره

Abstract software decision engine with branching paths, probability nodes, and glowing gates.
jev12 دقیقه مطالعه

مدل هوش مصنوعی Jev برای تصمیم ساخته شده، نه نثر

Jev از TypeSafe AI توجه‌ها را جلب کرده چون هوشمندی نرم‌افزار را مسئله‌ای احتمالاتی می‌بیند: شاخه درست را انتخاب کنید، میزان اطمینان را کنار آن بگذارید، و وقتی کد به یک تصمیم نیاز دارد برای نوشتن متن به یک LLM پول ندهید.

Abstract agent runtime sorting documents, memory blocks and pointer nodes inside a bounded context frame.
context-engineering13 دقیقه مطالعه

مهندسی کانتکست برای عامل‌های AI بلندافق

عامل‌های طولانی‌اجرا فقط به‌خاطر کوچک بودن پنجره شکست نمی‌خورند. وقتی فایل‌ها، خروجی ابزارها و تاریخچهٔ کهنه وظیفه‌ای را که عامل قرار بود تمام کند کنار می‌زنند، شکست رخ می‌دهد.

آماده‌اید انتخاب مدل را به LIA بسپارید؟

با همه مدل‌های هوش مصنوعی در یک جا بسازید — همین امروز رایگان شروع کنید.