Chuyển đến nội dung
19/30Chương 19 trên 30

RAG trong production: chunking, retrieval và trích dẫn trung thực

Cắt mù ở 512 ký tự làm hỏng 4/32 câu trả lời trước khi retriever thấy chúng. Chỉ sửa chunker đã đưa rank 115 lên 3.

Trên trang này

Đây là một câu hỏi thật từ một người dùng thật của một assistant thật: eval set của tôi có 20 mục, vậy đã đủ để tin điểm số chưa. Corpus có chứa câu trả lời — cả một phần đầy đủ về nó. Đây là bốn mảnh mà retriever thực sự đưa vào 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…

Ba trong bốn mảnh bắt đầu giữa một từ. Hai mảnh đến từ một chương khác về một chủ đề khác. Và mảnh trả lời câu hỏi — mảnh chứa Seventeen out of twenty cannot distinguish an 85 % model from a 65 % one — quay lại ở rank 115.

Giờ vẫn câu hỏi đó, cùng embedding model, cùng prompt template. Chỉ một thứ thay đổi: cách tài liệu được cắt.

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]

Từ rank 115 lên rank 3. Không ai chạm vào model, prompt, threshold hay số slot. Chương này nói về khoảng cách đó, và về bốn nơi khác nơi một hệ thống retrieval âm thầm nói dối bạn.

Hiện chi tiết

Chương này cần gì từ các chương trước, và một nơi duy nhất nó đổi ngôn ngữ.

  • Chương 1 đã định nghĩa dot product và L2 norm. Phần threshold bên dưới chỉ là hai thứ đó, không thêm gì khác.
  • Chương 8 đã tách embedding table của language model khỏi một retrieval embedding model được huấn luyện contrastive trên các cặp, đo cosine similarity, và kết thúc bằng lời hứa rằng Chương 19 sẽ đi đến một ngưỡng cắt cụ thể. Lời hứa đó đến hạn ở đây. Không phần nào được lặp lại.
  • Chương 4 đã xây dựng Wilson interval; Chương 15 đã xây dựng evaluation harness. Mọi bảng bên dưới mang theo cái thứ nhất và được tạo bởi cái thứ hai.
  • Chương 16 đã định giá context window. prompt được lắp ráp ở cuối chương này tốn 591 token, và đó là ngân sách mà các mảnh cạnh tranh với nhau.

Mọi thứ ở đây là TypeScript, như từ Chương 14, và đây là chương nơi quy tắc tự chứng minh giá trị: ingestion là queue và storage, search là một network call, và lắp ráp prompt có trích dẫn là việc của server. Phép đo là cùng một code với scoreboard bao quanh, có chủ ý — một retriever được chấm bởi implementation thứ hai là một con số về phần mềm bạn không ship, và cosine threshold bên dưới chỉ đáng tin vì bạn thấy nó được sweep bởi chính chunker sẽ chạy trong production.

Corpus, và thế nào được tính là câu trả lời đúng

Liên kết đến mục: Corpus, và thế nào được tính là câu trả lời đúng

Mọi thứ bên dưới được đo trên một corpus: mười ba chương đầu của khóa học này — 13 tài liệu, 359.067 ký tự, 127 section, đã bỏ front matter và bibliography. Đây là một corpus kỹ thuật thật, có văn xuôi, bảng, công thức và code block, và nó đúng là kiểu thứ mà mọi người nạp vào knowledge base rồi sau đó phàn nàn.

Ground truth là 32 câu hỏi, mỗi câu đi kèm một needle: một câu ngắn nguyên văn từ corpus có thể trả lời nó. Mỗi needle xuất hiện đúng một lần trong 359.067 ký tự, và không needle nào là section heading — kiểm tra đó quan trọng, vì nếu không một chunker copy heading vào mọi chunk sẽ tự ghi điểm cho mình. Mỗi câu hỏi được hỏi hai lần, một lần bằng tiếng Anh của khóa học và một lần theo cách một support ticket diễn đạt: 64 query trên 32 ground truth.

Một retrieval được xem là đúng khi một chunk được trả về chứa needle nguyên vẹn. Đó là định nghĩa duy nhất khớp với điều generator cần: nửa câu trong prompt không phải là câu trả lời, mà là một nguy cơ.

embedding model là all-MiniLM-L6-v2 — 384 chiều, mean-pooled và normalised, model được huấn luyện contrastive mà Chương 8 đã đo. Index corpus mất 20,8 giây trên CPU, 22 ms mỗi chunk; embedding một query mất 13 ms.

Sáu chiến lược từ ba thành phần độc lập. Blind cắt mỗi 512 ký tự mà không nhìn vào văn bản. Boundaries không bao giờ cắt giữa một đoạn văn, chỉ fallback về ranh giới câu khi một đoạn vượt ngân sách. Header thêm tiền tố cho mỗi chunk bằng tiêu đề tài liệu và đường dẫn section. Overlap copy 64 ký tự cuối của chunk trước sang chunk tiếp theo.

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

Với 64 query, Wilson interval 95 % trên R@20 là [0.471, 0.705] cho A và [0.718, 0.901] cho E — chúng không chồng lấp, nhưng hầu hết các cột khác thì có, và một bảng unpaired không thể tách chúng ra. Mọi strategy trả lời cùng các query, nên bài test trung thực là paired: đếm số win và loss của mỗi strategy so với strategy khác rồi chạy sign test trên các cặp discordant. Ba kết quả còn đứng vững.

Blind chunking phá hỏng hẳn bốn trong ba mươi hai câu trả lời. Không phải rank chúng kém — mà phá hỏng chúng. Needle vắt qua một ranh giới 512 ký tự, nên không chunk nào trong index chứa nó, và recall ceiling cho các query đó là zero. Không reranker nào cứu được, không threshold nào giúp, không model lớn hơn nào giúp. Bạn không thể retrieve văn bản không tồn tại nguyên một mảnh ở bất kỳ đâu trong index. Đây là lỗi bị báo cáo thiếu nhiều nhất trong RAG, vì nhìn nó giống hệt một retriever tệ.

Overlap sửa lỗi đó và không sửa gì khác. Mọi strategy có overlap đều mất zero câu trả lời, đúng với mục đích của overlap. Nó không cải thiện ranking: B so với A ở R@8 là +8/−6, p = 0.79; ở R@20 là +9/−7, p = 0.80. Tệ hơn, thêm overlap lên trên header còn gây hại — F so với E là +2/−6 ở R@20 — và lý do là cơ học. Vector của một chunk là mean trên các token của nó, nên 64 ký tự của chunk trước kéo mean đó về phía chủ đề của hàng xóm. Overlap là bảo hiểm chống split answer, được trả bằng precision.

Contextual header mới là thứ mua được retrieval. E so với A là +18/−3 ở R@20, p = 0.0015. Và ablation nói rằng boundaries không phải nguyên nhân: E so với C — cùng cách cắt, chỉ khác header — là +12/−2, p = 0.0129. Thêm tiền tố "Classification, Cross-Entropy, and How Not to Fool Yourself > How many test examples do I need?" vào một đoạn văn nói cho embedding model biết đoạn văn đó nói về điều gì, điều mà bản thân đoạn văn thường không nói ra. Nó là bộ giải đại từ cho tài liệu.

Điều đó định hình chunker, và một quy tắc rất dễ làm sai:

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

Hai văn bản, không phải một. text là thứ được embedded, gồm cả header. content chỉ là lời của riêng chunk này, và là thứ được trích lại cho người dùng. Trích text và citation sẽ hiển thị một header không nằm trong tài liệu ở điểm đó — và, với overlap, một phần đuôi lặp lại thuộc về mảnh trước. Khi đó nó hiển thị văn bản không nằm ở nơi nó nói, tệ hơn là không hiển thị gì.

Header không miễn phí. Trên 940 chunk, nó tốn 24.213 trong 114.275 embedded token của index: 21,2 % chi phí embedding là một header bạn tự viết. Nó cũng đẩy các chunk sát cửa sổ encoder. all-MiniLM-L6-v2 nhận 256 word-piece; strategy E có 17 chunk vượt dòng đó và F có 28, tất cả đều bị truncate âm thầm mà không thứ gì cảnh báo. Kích thước chunk hiệu dụng của bạn không phải con số trong config — nó là số nhỏ hơn giữa con số đó và cửa sổ của encoder.

Dense retrieval có một điểm yếu có hệ thống và nó không hề tinh vi: nó khớp ý nghĩa, nên nó thờ ơ với việc bạn gõ chính xác chuỗi nào. Part number, error code, acronym, surname — không cái nào có ý nghĩa hữu ích để embed, và nearest neighbour của một error code là mọi error code khác trong corpus của bạn.

Câu trả lời cổ điển cũ hơn tất cả chuyện này và mất hai mươi dòng. BM25 chấm điểm một tài liệu theo tần suất các term của query xuất hiện trong đó, damping mỗi term khi tần suất tăng và phạt các tài liệu dài tích lũy match chỉ nhờ độ dài.1 Term tt đóng góp

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

trong đó ft,df_{t,d} là số lần xuất hiện của term trong tài liệu, d|d| là độ dài tài liệu, d\overline{|d|} là độ dài trung bình, và k1=1.2k_1 = 1.2 cùng b=0.75b = 0.75 là hai hằng số quy ước — k1k_1 đặt tốc độ lặp lại ngừng hữu ích, bb đặt mức phạt độ dài.

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

Trên 940 chunk, nó chấm một query trong 1,14 ms mà không cần index nào ngoài hai hash map. Và nó không phải món đồ bảo tàng:

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.346cả hai
hybrid + cross-encoder0.3120.5780.7030.447+ 569 ms

BM25 hơn gấp đôi độ chính xác top-1 của dense retriever trên corpus này, và thua nó nặng ở rank 8. Chúng fail trên các query khác nhau, đó là toàn bộ lý do để chạy cả hai.

Fusion chúng là nơi duy nhất cách hiển nhiên lại sai. Cosine distance và điểm BM25 không cùng thang đo, không được bound cùng cách, và normalising chúng theo từng query khiến weight phụ thuộc vào hit tốt nhất tình cờ tốt đến đâu. Reciprocal rank fusion vứt bỏ điểm số và chỉ giữ 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);
}

Và ở đây cách đọc trung thực của bảng quan trọng hơn chính bảng. Hybrid thắng BM25 ở R@4 với +10/−3, p = 0.09. Nó thắng dense với +10/−6, p = 0.45. Trên corpus này, với 64 query, hybrid retrieval không phân biệt được với dense retrieval. Nó tốt hơn ở cả point estimate và mọi cột recall, nhưng bằng chứng chưa đạt significance. Gần như mọi bài blog về hybrid-search trên internet đều báo một bảng như bảng trên và không có interval; interval nói như vậy.

Bi-encoder, cross-encoder, và lift thực sự nằm ở đâu

Liên kết đến mục: Bi-encoder, cross-encoder, và lift thực sự nằm ở đâu

Mọi thứ đến giờ là một bi-encoder: query đi qua model một mình, mỗi chunk đã đi qua nó một mình từ nhiều tháng trước, và cả hai không bao giờ gặp nhau ngoài một dot product. Đó là điều làm index khả thi — embed một lần, tái sử dụng mãi — và cũng là trần của nó. Model không bao giờ nhìn query và chunk cùng nhau.

Một cross-encoder làm đúng điều đó: nó nhận cặp này như một input duy nhất và trả về relevance score. Không gì có thể precompute, nên nó không thể rank một index — nhưng nó có thể rerank một shortlist. Reranking hybrid top 25 bằng ms-marco-MiniLM-L-6-v2 đẩy R@1 từ 0.094 (dense) lên 0.312 và MRR từ 0.280 lên 0.447: cải thiện đơn lẻ lớn nhất trong chương này, và là cái duy nhất chạm vào đầu danh sách thay vì phần đuôi.

Nó tốn 569 ms mỗi query trên CPU, so với 1,14 ms cho BM25 và 0,3 ms cho vector scan. Khoảng hai nghìn lần chi phí retrieval, cho hai mươi lăm tài liệu. Đó là toàn bộ trade-off bi-encoder/cross-encoder trong một con số, và đó là lý do architecture luôn có cùng hình dạng: một retriever rẻ với recall rộng, rồi một scorer đắt trên shortlist bạn có thể chi trả. ColBERT nằm giữa hai cái, precompute vector theo từng token và thực hiện late interaction rẻ hơn cross-encoder và sắc hơn dot product.3

L2, cosine, và một threshold bạn chưa giành được

Liên kết đến mục: L2, cosine, và một threshold bạn chưa giành được

Vector database báo distance, và distance nào là một tùy chọn config. Trên vector normalised, lựa chọn chỉ mang tính thẩm mỹ, và identity này đáng làm một lần vì mọi thứ sau đó phụ thuộc vào việc vector thực sự là unit. Với 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

nên cosine distance 1cosθ1 - \cos\theta đúng bằng d2/2d^2/2. Đó là dot product và norm của Chương 1, được quy đổi. Kiểm tra trên hai vector chunk thật từ index ở trên, rồi trên 40.000 cặp:

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

Chính xác đến mức nhiễu floating-point — và chỉ vì vector được normalised. Bỏ normalisation thì identity sai, threshold của bạn vô nghĩa, và distance mà một tài liệu báo phụ thuộc vào độ dài văn bản của nó.

Giờ đến con số không ai derive. Một retriever luôn trả về thứ gì đó: nó sort toàn bộ index và đưa cho bạn phần đầu danh sách, dù câu trả lời có nằm trong corpus hay không. Threshold là phần duy nhất của hệ thống có thể nói không — và để đặt nó, bạn cần những query đáng lẽ không nhận lại gì. Đây là ba mươi query: hai mươi mốt về những thứ corpus này thật sự không bao phủ — streaming, rate limit, prompt caching, JSON schema, agent loop, vector database, prompt injection, image generation — và chín query về paella, passport và refund policy. Trên cùng index:

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

Các distribution tách ra, và chúng chồng lấp. Query in-domain tệ nhất xa câu trả lời của nó hơn (0.721) so với query out-of-domain tốt nhất gần một đoạn không liên quan (0.497), nên không threshold nào làm đúng cả hai. Sweep nó trên cổng thật — giữ nhiều nhất bốn chunk, và chỉ những chunk dưới ngưỡng cắt:

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

Hãy đọc cột cuối là bluffs. Không có threshold, assistant tạo ra một câu trả lời tự tin, trích dẫn đầy đủ cho "how do I renew my Spanish passport" từ một corpus về backpropagation, ba mươi lần trên ba mươi. Ở 0.675, nó làm vậy mười lần trên ba mươi. Ở 0.525, nó làm vậy hai lần, và bỏ cuộc ở mười hai câu hỏi mà nó lẽ ra trả lời được.

Trade-off đó là một quyết định sản phẩm, và đầu đúng của nó phụ thuộc vào chi phí của một câu trả lời sai đối với bạn. Điều không thể thương lượng là cột cuối phải tồn tại. Nếu bạn chưa từng đo retriever của mình trên các câu hỏi nó nên từ chối, bạn không có threshold — bạn có một con số.

Hai trong mười bluff ở 0.675 cho thấy hai cách lỗi này xảy ra.

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

Cái đầu là một near miss: corpus giải thích KV cache chi tiết, query nói về prompt cache, các từ là cùng những từ đó, và 0.497 gần hơn hầu hết retrieval in-domain đúng trong toàn bộ thí nghiệm. Một embedding không biết rằng hai cache cùng tên là hai cỗ máy khác nhau. Cái thứ hai là một literal match without answer: corpus chứa cụm chính xác "what is the capital of France", dùng làm ví dụ về một câu hỏi không cần reasoning. retriever đúng; câu trả lời không có ở đó. Bất kỳ hệ thống nào đọc "tôi tìm thấy thứ tương tự" thành "tôi tìm thấy câu trả lời" sẽ khẳng định Paris trên bằng chứng đó — hoặc, tệ hơn, sẽ không.

Một model không có năng lực riêng biệt cho fact. Tạo một câu đúng và tạo một câu có vẻ hợp lý là cùng một operation — next-token prediction của Chương 8 — và không gì trong operation đó đánh dấu cái nào là cái nào. Phân tích năm 2025 đã reframing điều này lập luận rằng pipeline huấn luyện và evaluation chủ động thưởng cho việc đoán: benchmark chấm bằng binary accuracy và không cho credit cho abstention, nên một model luôn trả lời sẽ vượt điểm một model giống hệt nói "I don't know" khi nó không biết, và post-training tối ưu theo đó.6 Hallucination theo cách đọc đó không phải khiếm khuyết bí ẩn. Nó là thứ bạn nhận được khi chấm một bài thi trắc nghiệm mà không phạt câu trả lời sai.

Hãy nhìn hình dạng của nó. Khi được hỏi tám paper về contrastive sentence embeddings, kèm identifier, Qwen2.5-0.5B-Instruct tạo ra tám dòng có format hoàn hảo. Cả tám identifier đều well-formed. Cả tám đều resolve tới paper thật trên arXiv. Không dòng nào trong tám dòng là paper được tuyên bố.

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

Đây là một model nhỏ và rate là của riêng nó; frontier model bịa ít hơn nhiều. Mechanism thì generalise, và đó là lý do cho quy tắc tiếp theo. Một validator kiểm tra "identifier này có tồn tại không" sẽ pass cả tám, và một người dùng click vào sẽ đến một trang thật từ một archive thật mà không có cách nào biết mapping đã bị bịa. Lỗi không nằm ở identifier hay format. Nó nằm ở association — đúng thứ một language model tạo ra bằng plausibility.

Vậy: model viết [1][2], và không bao giờ viết link. Các số tham chiếu đến fragment mà server đã retrieve, và server — thứ biết chính xác mỗi số đến từ tài liệu nào và offset nào — gắn document, label và URL sau đó. Không có gì cho model bịa vì nó không bao giờ được hỏi thứ mà nó sẽ bịa.

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

Chạy nó trên câu hỏi mở đầu và bốn chunk trở thành một prompt 591-token cùng một bảng mà model không bao giờ thấy:

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 là phần mọi người bỏ qua rồi sau đó không thể thêm lại. #char=25873,26272 là một range trong canonical text của tài liệu; với PDF, tương đương là #page=12, với audio hoặc video là #t=132.4,158.9, với spreadsheet là một sheet và A1 range. Hai thứ đó không phải phát minh — #page= là PDF Open Parameters và #t= là W3C Media Fragments, được browser hỗ trợ native trên phần tử video và audio. Một citation không có locator là một tên tài liệu, và tên tài liệu không phải citation; nó là gợi ý rằng người dùng hãy tự đi tìm.

Và khi không có gì vượt threshold, pipeline không bao giờ tới 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"

Đó là một refusal rẻ hơn và đáng tin hơn bất kỳ instruction nào trong system prompt, vì nó là phép so sánh giữa hai con số thay vì một yêu cầu gửi tới hệ thống xác suất.

Mọi phép đo trong chương này chấm retriever và chưa một lần yêu cầu model viết câu trả lời. Điều đó là có chủ ý, và là mảnh mà hầu hết đội ngũ bỏ qua.

Một hệ thống RAG có hai failure mode nhìn từ ngoài giống hệt nhau. retriever không tìm thấy passage; hoặc nó tìm thấy nhưng generator phớt lờ, mâu thuẫn, hoặc trộn nó với thứ nó đã tin sẵn. Chỉ chấm câu trả lời cuối cùng thì hai cái không phân biệt được, nên bạn tune prompt cho một vấn đề nằm trong chunker. Recall@k, MRR và số answer-destroyed không cần generation call nào, chúng đủ rẻ để chạy trên mọi deploy, và chúng là harness từ Chương 15 với một scoring function khác — cùng request, deadline, concurrency và tally, trên một question set cố định thay vì live conversation.

Hãy báo cáo chúng kèm interval. Số học của Chương 4 áp dụng y nguyên: ở 64 query, recall 0.5 có Wilson interval 95 % khoảng ±0.12, nên một strategy hơn strategy khác bốn điểm không nói cho bạn gì cả. Dùng paired test bất cứ khi nào cả hai strategy trả lời cùng các câu hỏi, mà ở đây thì luôn vậy — đó là thứ biến "E có vẻ tốt hơn A" thành p = 0.0015.

Và sự trung thực cuối cùng: RAG giảm hallucination chứ không loại bỏ nó. Đưa đúng passage vào prompt không buộc model phải dùng nó, và literature đã nói vậy từ paper gốc.7 Hai thứ làm nó tệ hơn trong production. Context dài degrade — model tìm thông tin ở đầu và cuối một prompt dài đáng tin hơn ở giữa, nên hai mươi chunk thay vì bốn có thể hạ accuracy trong khi tăng hóa đơn, một hiệu ứng được đo trong Chương 24. Và retrieval có thể đúng mà vẫn không đủ, như hai cache ở trên đã cho thấy. SelfCheckGPT flag các claim không sống sót qua resampling;8 Self-RAG huấn luyện model emit các token retrieve-and-critique của riêng nó;9 TruthfulQA khiến failure mode này dễ đọc ngay từ đầu.10 Không cái nào khép khoảng cách, và một hệ thống trình bày retrieved text như proof đã nhầm có nguồn với đúng.

Nửa hệ thống chạy trước bất kỳ query nào

Liên kết đến mục: Nửa hệ thống chạy trước bất kỳ query nào

Retriever là phần nhìn thấy được của một pipeline mà lỗi đều xảy ra sớm hơn, trong bóng tối. Ba lỗi lặp lại.

Extraction là nơi content chết. PDF không phải text; nó là drawing instructions. Layout hai cột bị interleave, table thành word soup, page header lặp vào mọi chunk, và trang scan không có text nào cho đến khi OCR cung cấp, kèm confidence. Mọi thứ đo ở trên giả định extractor đã làm đúng việc; trong production thường không vậy, và symptom xuất hiện như retrieval tệ ở cách đó ba layer.

Index được đóng dấu bằng model đã xây nó. Embedding từ hai model không comparable — không phải "kém chính xác hơn", mà không comparable, vì chúng là điểm trong các không gian khác nhau. Đổi embedding model và mọi vector trong store là rác cho đến khi được rebuild. Vì vậy model name, dimension count, pipeline version và extractor version được ghi cạnh mỗi tài liệu tại thời điểm index. Không có chúng, vào ngày upgrade, bạn không thể biết tài liệu nào stale và tài liệu nào current, và một index migrate dở trả về nonsense tự tin mà không có error ở đâu cả.

Một tài liệu hỏng không được làm hỏng cả folder, và counter phải đếm điều đã xảy ra. Một tài liệu fail extraction kết thúc ở trạng thái failed với reason, visible và retryable, trong khi chín mươi chín tài liệu khác vẫn searchable; và số chunk indexed được server ghi khi hoàn tất, không phải do client tuyên bố khi upload. Một folder báo 400 fragment nhưng chỉ giữ 40 là một lời nói dối chỉ nổi lên dưới dạng một câu hỏi không trả lời được.

Hệ thống trong chương này trả lời các câu hỏi mà câu trả lời đã được viết ra. Nó retrieve chúng, rank chúng, từ chối khi không thể, và cite nơi nó đã nhìn. Đó là phần lớn điều mọi người muốn từ một assistant trên tài liệu của chính họ, và nó bị bound theo một cách cụ thể: retrieval chỉ có thể trả về thứ ai đó đã viết.

Điều còn lại là nửa kia. Một số thứ bạn muốn model làm hoàn toàn không phải fact trong tài liệu — một format nó phải giữ, một tone, một taxonomy với bốn trăm label, một cách quyết định sống trong mười nghìn ví dụ quá khứ và không nằm ở đoạn văn nào. Retrieval không thể deliver những thứ đó, vì không có gì để retrieve; prompt dài hơn chỉ trả hóa đơn của Chương 16 cho một mô tả về skill thay vì chính skill.

Chương 20 là quyết định đó — fine-tune, retrieve hay prompt — và phát hiện của nó là quyết định này mang tính kinh tế trước khi mang tính kỹ thuật: cả ba được định giá end to end trên cùng một câu hỏi, và crossover là một token count. Câu hỏi mở đầu nó là câu mà chương này không trả lời được. Không phải câu trả lời được viết ở đâu, mà là bạn làm gì khi nó chưa từng được viết.


Mọi thứ được đo trong chương này dùng một corpus và một instrument, cả hai đều reproducible. Corpus là các chương 1 đến 13 của khóa học này như chúng tồn tại vào ngày 7 tháng 9 năm 2026 — 13 tài liệu, 359.067 ký tự, 127 section, đã bỏ front matter và bibliography. Các chương đó vẫn được chỉnh sửa, nên áp dụng cùng rule hôm nay sẽ đếm thêm vài nghìn ký tự: số section không đổi và mọi kết luận bên dưới cũng vậy, nhưng tổng ký tự là một snapshot và được gắn nhãn như vậy. Ground truth là 32 câu hỏi, mỗi câu đi kèm một câu nguyên văn xuất hiện đúng một lần trong corpus và không bao giờ là section heading, được hỏi theo hai cách diễn đạt cho 64 query. Retrieval embeddings là sentence-transformers/all-MiniLM-L6-v2 (384 chiều, mean-pooled, L2-normalised, cửa sổ 256-token); reranking là cross-encoder/ms-marco-MiniLM-L-6-v2 trên top 25; ví dụ generation là Qwen/Qwen2.5-0.5B-Instruct với greedy decoding. Mọi timing là CPU single-threaded. Không paid API nào được gọi để tạo chương này, đó cũng là lý do mọi latency ở đây đều là local và được gắn nhãn như vậy.

Chunker được trình bày bằng TypeScript là chunker đã được đo: instrument Python implement cùng rule và ts/chunk.ts được so sánh chunk by chunk trên toàn bộ corpus và khớp trên tất cả 940 chunk, cả text lẫn offset. Interval là Wilson ở 95 %; paired comparison là exact sign test two-sided trên các cặp discordant.

Tất cả mười bốn identifier được cite ở trên đã được resolve qua arXiv API và kiểm tra từng title vào ngày 7 tháng 9 năm 2026 — điều mà, xét tám identifier không đúng kia, dường như là điều tối thiểu chương đặc biệt này nên làm.

  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). Nguồn của saturation function và hai hằng số dùng ở trên, và là nơi nên đọc để biết vì sao bb tồn tại.

  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 là của họ, và điểm cốt lõi của phương pháp là nó không cần calibration giữa các thang điểm mà nó fusion.

  3. Khattab, O. and Zaharia, M. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT. arXiv:2004.12832 (2020). Điểm giữa giữa dot product và cross-encoder. Reimers, N. and Gurevych, I., Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks, arXiv:1908.10084 (2019), là bi-encoder mà index của chương này được xây trên đó và đã được đo trong Chương 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 đứng sau hầu hết vector database đang được bán hiện nay.

  5. Johnson, J., Douze, M. and Jégou, H. Billion-scale Similarity Search with GPUs. arXiv:1702.08734 (2017). FAISS, và implementation tham chiếu của IVF được đo trong box ở trên.

  6. Kalai, A. T., Nachum, O., Vempala, S. S. and Zhang, E. Why Language Models Hallucinate. arXiv:2509.04664 (2025). Lập luận rằng hallucination được tạo ra bởi cách chấm binary-accuracy không bao giờ thưởng cho abstention, và vì vậy là một vấn đề evaluation trước khi là vấn đề modelling.

  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 đã đặt tên pattern này và là thứ nên đọc để biết nó sửa và không sửa điều gì. Guu et al., REALM: Retrieval-Augmented Language Model Pre-Training, arXiv:2002.08909 (2020), là công trình cùng thời huấn luyện retriever jointly với model thay vì gắn thêm vào; Karpukhin et al., Dense Passage Retrieval for Open-Domain Question Answering, arXiv:2004.04906 (2020), là nơi two-encoder dense retriever dùng xuyên suốt chương này xuất phát; và Izacard and Grave, Leveraging Passage Retrieval with Generative Models for Open Domain Question Answering, arXiv:2007.01282 (2020), là bố trí fusion-in-decoder để feed nhiều passage cho một generator. Gao et al., Retrieval-Augmented Generation for Large Language Models: A Survey, arXiv:2312.10997 (2023), là bản đồ của mọi thứ đến sau, gồm HyDE (Gao et al., Precise Zero-Shot Dense Retrieval without Relevance Labels, arXiv:2212.10496, 2022), embed một câu trả lời giả định thay vì câu hỏi.

  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 bằng resampling, không cần truy cập nội bộ model và không cần knowledge base bên ngoài.

  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). Huấn luyện model quyết định khi nào retrieve, thay vì retrieve ở mọi turn.

  10. Lin, S., Hilton, J. and Evans, O. TruthfulQA: Measuring How Models Mimic Human Falsehoods. arXiv:2109.07958 (2021). Benchmark được xây từ những câu hỏi nơi câu trả lời plausible và câu trả lời true khác nhau, tức toàn bộ độ khó gói trong một câu.

Sẵn sàng để LIA chọn giúp bạn chưa?

Xây dựng cùng mọi mô hình AI ở một nơi — bắt đầu miễn phí ngay hôm nay.