RAG בפרודקשן: chunking, אחזור וציטוטים אמינים
חיתוך עיוור ב-512 תווים מחסל 4 מ-32 תשובות לפני האחזור. תיקון ה-chunker בלבד מעביר דירוג 115 ל-3.
בעמוד הזה
הנה שאלה אמיתית ממשתמש אמיתי של assistant אמיתי: לסט ה-eval שלי יש 20 פריטים, זה מספיק כדי לסמוך על הציון. הקורפוס מכיל את התשובה — מקטע שלם שלה. הנה ארבעת הפרגמנטים שה-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…שלושה מתוך הארבעה מתחילים באמצע מילה. שניים מפרק אחר על נושא אחר. והפרגמנט שעונה על השאלה — זה שמכיל את שבעה-עשר מתוך עשרים לא יכולים להבחין בין מודל של 85% לבין מודל של 65% — חזר ב-דירוג 115.
עכשיו אותה שאלה, אותו embedding model, אותה תבנית prompt. דבר אחד השתנה: איך המסמכים נחתכו.
[1] d=0.594 [Classification, Cross-Entropy… > How many test examples do I need?]
Read it backwards, which is how you will use it: ±5 points needs
about 200 examples. ±2 points needs about 1,230…
[2] d=0.598 [Classification, Cross-Entropy… > Three splits, and the leak…]
Why three splits and not two? Because the moment you use a set of
examples to *choose* anything…
[3] d=0.600 [Classification, Cross-Entropy… > How many test examples do I need?]
The honest reading of 17/20 is *somewhere between 64 % and 95 %*.
…Seventeen out of twenty cannot distinguish an 85 % model from a 65 % one.
[4] d=0.605 [Classification, Cross-Entropy… > How many test examples do I need?]
Suppose you score a model on 20 examples and it gets 17 right. You
report 85 %. …Wilson 95% CI : [0.6396, 0.9476]מדירוג 115 לדירוג 3. אף אחד לא נגע במודל, ב-prompt, בסף או במספר המקומות. הפרק הזה עוסק בפער הזה, ובארבעת המקומות האחרים שבהם מערכת אחזור משקרת לך בשקט.
הצגת פרטים
מה הפרק הזה צריך מפרקים קודמים, והמקום היחיד שבו הוא משנה שפה.
- פרק 1 הגדיר את המכפלה הסקלרית ואת נורמת L2. סעיף הסף בהמשך הוא שני אלה, ושום דבר מעבר.
- פרק 8 הפריד בין טבלת ה-embedding של מודל שפה לבין מודל retrieval embedding שאומן קונטרסטיבית על זוגות, מדד דמיון קוסינוס, וסיים בהבטחה שפרק 19 יגיע לסף קונקרטי. ההבטחה הזו נפרעת כאן. שום דבר מזה לא חוזר.
- פרק 4 בנה את מרווח Wilson; פרק 15 בנה את ה-harness להערכה. כל טבלה בהמשך נושאת את הראשון ונוצרה על ידי השני.
- פרק 16 תמחר את ה-context window. ה-prompt שמורכב בסוף הפרק הזה עולה 591 tokens, וזה התקציב שעליו הפרגמנטים מתחרים.
הכול כאן TypeScript, כמו מאז פרק 14, והפרק הזה הוא המקום שבו הכלל מצדיק את עצמו: ingestion הוא תורים ואחסון, חיפוש הוא קריאת רשת, והרכבת prompt עם ציטוטים היא עבודה של שרת. המדידה היא אותו קוד עם לוח תוצאות סביבו, בכוונה — retriever שמדורג על ידי מימוש שני הוא מספר על תוכנה שאינך משחרר, וסף הקוסינוס בהמשך אמין רק כי אתה רואה אותו נסרק על ידי ה-chunker שירוץ בפרודקשן.
הקורפוס, ומה נחשב תשובה נכונה
קישור למקטע: הקורפוס, ומה נחשב תשובה נכונהכל מה שמופיע בהמשך נמדד מול קורפוס אחד: שלושה-עשר הפרקים הראשונים של הקורס הזה — 13 מסמכים, 359,067 תווים, 127 מקטעים, בלי front matter וביבליוגרפיות. זה קורפוס טכני אמיתי, עם פרוזה, טבלאות, נוסחאות ובלוקי קוד, וזה בדיוק סוג הדבר שאנשים טוענים ל-knowledge base ואז מתלוננים עליו.
האמת הקרקעית היא 32 שאלות, וכל אחת משויכת ל-needle: משפט קצר מילולי מתוך הקורפוס שעונה עליה. כל needle מופיע בדיוק פעם אחת ב-359,067 התווים, ואף אחד מהם אינו כותרת מקטע — הבדיקה הזו חשובה, כי chunker שמעתיק כותרות לכל chunk היה מדרג את עצמו אחרת. כל שאלה נשאלת פעמיים, פעם באנגלית של הקורס ופעם כפי שכרטיס תמיכה היה מנסח אותה: 64 שאילתות על פני 32 אמיתות קרקע.
אחזור נכון כאשר chunk שחזר מכיל את ה-needle בשלמותו. זו ההגדרה היחידה שמתאימה למה שהמחולל צריך: חצי משפט בתוך ה-prompt אינו תשובה, הוא סיכון.
ה-embedding model הוא all-MiniLM-L6-v2 — 384 ממדים, mean-pooled ומנורמל, המודל שאומן קונטרסטיבית ונמדד בפרק 8. אינדוקס הקורפוס לוקח 20.8 שניות על CPU, 22 ms לכל chunk; embedding של שאילתה אחת לוקח 13 ms.
Chunking, נמדד בשש דרכים
קישור למקטע: Chunking, נמדד בשש דרכיםשש אסטרטגיות משלושה מרכיבים בלתי תלויים. Blind חותך כל 512 תווים בלי להסתכל על הטקסט. Boundaries לעולם לא חותך בתוך פסקה, וחוזר לגבול משפט רק כשפסקה אחת חורגת מהתקציב. Header מוסיף לפני כל chunk את כותרת המסמך ואת נתיב המקטע. Overlap מעתיק את 64 התווים האחרונים של ה-chunk הקודם אל הבא.
| אסטרטגיה | chunks | תשובות שנהרסו | 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 שאילתות, מרווח Wilson של 95% על R@20 הוא [0.471, 0.705] עבור A ו-[0.718, 0.901] עבור E — אלה אינם חופפים, אבל רוב העמודות האחרות כן, וטבלה לא מזווגת לא יכולה להפריד ביניהן. כל אסטרטגיה עונה על אותן שאילתות, ולכן המבחן ההוגן הוא מזווג: סופרים את הניצחונות וההפסדים של כל אסטרטגיה מול אחרת ומריצים מבחן סימן על הזוגות הדיסקורדנטיים. שלוש תוצאות שורדות אותו.
Blind chunking הורס ארבע מתוך שלושים ושתיים התשובות לחלוטין. לא מדרג אותן גרוע — הורס אותן. ה-needle חוצה גבול של 512 תווים, כך שאין ב-index שום chunk שמכיל אותו, ותקרת ה-recall עבור השאילתות האלה היא אפס. שום reranker לא מציל אותן, שום סף לא עוזר, שום מודל גדול יותר לא עוזר. אי אפשר לאחזר טקסט שאינו נמצא כמקשה אחת בשום מקום ב-index שלך. זה הכשל הכי פחות מדווח ב-RAG, כי הוא נראה בדיוק כמו retriever גרוע.
Overlap מתקן את זה ולא שום דבר אחר. כל אסטרטגיה עם overlap מאבדת אפס תשובות, וזה תפקידו של overlap. הוא לא משפר דירוג: B מול A ב-R@8 הוא +8/−6, p = 0.79; ב-R@20 הוא +9/−7, p = 0.80. גרוע מזה, הוספת overlap מעל header ממש פוגעת — F מול E הוא +2/−6 ב-R@20 — והסיבה מכנית. הווקטור של chunk הוא ממוצע על פני ה-tokens שלו, ולכן 64 תווים מה-chunk הקודם גוררים את הממוצע הזה לכיוון הנושא של השכן. Overlap הוא ביטוח מפני תשובה שנחתכה, והוא משולם בדיוק.
ה-header ההקשרי הוא מה שקונה את האחזור. E מול A הוא +18/−3 ב-R@20, p = 0.0015. וה-ablation אומר שלא ה-boundaries עושים את זה: E מול C — אותם חיתוכים, header הוא ההבדל היחיד — הוא +12/−2, p = 0.0129. הוספת ״Classification, Cross-Entropy, and How Not to Fool Yourself > How many test examples do I need?״ לפני פסקה מספרת ל-embedding model על מה הפסקה, דבר שהפסקה עצמה לעיתים קרובות לא אומרת. זה פותר כינויי גוף למסמכים.
וזה נותן ל-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 עצמו, וזה מה שמצטטים חזרה למשתמש. אם מצטטים את text, הציטוט מציג header שאינו נמצא במסמך בנקודה הזו — ועם overlap, גם זנב חוזר ששייך לפרגמנט הקודם. אז הוא מציג טקסט שאינו נמצא במקום שבו הוא טוען שהוא נמצא, וזה גרוע יותר מלא להציג כלום.
ה-header אינו בחינם. על פני 940 chunks הוא עולה 24,213 מתוך 114,275 ה-tokens המוטמעים של ה-index: 21.2% ממה שאתה משלם כדי לעשות embedding הוא header שכתבת בעצמך. הוא גם דוחף chunks אל מול חלון ה-encoder. all-MiniLM-L6-v2 מקבל 256 word-pieces; לאסטרטגיה E יש 17 chunks מעבר לקו הזה ול-F יש 28, וכל אחד מהם נחתך בשקט בלי שום אזהרה מכלום. גודל ה-chunk האפקטיבי שלך אינו המספר בקונפיגורציה — הוא הקטן מבין המספר הזה לבין החלון של ה-encoder.
עשרים שורות של BM25, שכולם מדלגים עליהן
קישור למקטע: עשרים שורות של BM25, שכולם מדלגים עליהןלאחזור dense יש חולשה שיטתית אחת והיא לא עדינה: הוא מתאים משמעות, ולכן הוא אדיש ל-מחרוזת המדויקת שהקלדת. מספר חלק, קוד שגיאה, ראשי תיבות, שם משפחה — לאף אחד מהם אין משמעות מועילה ל-embedding, והשכן הקרוב ביותר של קוד שגיאה הוא כל קוד שגיאה אחר בקורפוס שלך.
התשובה הקלאסית ישנה יותר מכל זה ולוקחת עשרים שורות. BM25 מדרג מסמך לפי התדירות שבה מונחי השאילתה מופיעים בו, מדכא כל מונח ככל שהתדירות שלו עולה ומעניש מסמכים ארוכים שצוברים התאמות רק בגלל האורך.1 המונח תורם
כאשר הוא ספירת המונח במסמך, אורכו, האורך הממוצע, ו- ו- הם שני הקבועים המקובלים — קובע כמה מהר חזרה מפסיקה לעזור, כמה חזק אורך נענש.
const toks = (s: string) => s.toLowerCase().match(/[a-z0-9]+/g) ?? [];
export class BM25 {
private tf: Map<string, number>[] = [];
private len: number[] = [];
private idf = new Map<string, number>();
private avg = 0;
private k1: number; private b: number;
constructor(docs: string[], k1 = 1.2, b = 0.75) {
this.k1 = k1; this.b = b;
const df = new Map<string, number>();
for (const d of docs) {
const t = new Map<string, number>(); const ws = toks(d);
for (const w of ws) t.set(w, (t.get(w) ?? 0) + 1);
for (const w of t.keys()) df.set(w, (df.get(w) ?? 0) + 1);
this.tf.push(t); this.len.push(ws.length);
}
this.avg = this.len.reduce((a, b) => a + b, 0) / this.len.length;
const N = docs.length;
for (const [w, n] of df) this.idf.set(w, Math.log(1 + (N - n + 0.5) / (n + 0.5)));
}
scores(query: string): number[] {
const q = toks(query);
return this.tf.map((tf, i) => {
const L = this.len[i]; let s = 0;
for (const w of q) {
const f = tf.get(w); if (!f) continue;
s += (this.idf.get(w) ?? 0) * (f * (this.k1 + 1)) /
(f + this.k1 * (1 - this.b + (this.b * L) / this.avg));
}
return s;
});
}
}על פני 940 chunks זה מדרג שאילתה ב-1.14 ms בלי index כלל מעבר לשתי מפות hash. וזה לא מוצג מוזיאוני:
| retriever | R@1 | R@4 | R@8 | MRR | עלות לכל שאילתה |
|---|---|---|---|---|---|
| dense (cosine) | 0.094 | 0.422 | 0.578 | 0.280 | 13 ms ל-embedding + 0.3 ms לסריקה |
| 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 בקורפוס הזה, ומפסיד לו חזק עד דירוג 8. הם נכשלים בשאילתות שונות, וזה כל הטיעון להרצת שניהם.
מיזוג שלהם הוא המקום היחיד שבו הגישה הברורה שגויה. מרחקי קוסינוס וציוני BM25 אינם באותו סולם, אינם חסומים באותו אופן, ונרמול שלהם לכל שאילתה גורם למשקל להיות תלוי בכמה טוב ה-hit הטוב ביותר במקרה היה. Reciprocal rank fusion זורק את הציונים ושומר רק את הדירוגים: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 מנצח את BM25 ב-R@4 ב-+10/−3, p = 0.09. הוא מנצח את dense ב-+10/−6, p = 0.45. בקורפוס הזה, עם 64 שאילתות, hybrid retrieval אינו ניתן להבחנה מ-dense retrieval. הוא טוב יותר בשתי ההערכות הנקודתיות ובכל עמודת recall, והראיות לא מגיעות למובהקות. כמעט כל פוסט בבלוג על חיפוש hybrid באינטרנט מדווח טבלה כמו זו שלמעלה בלי מרווח; זה מה שהמרווח אומר.
Bi-encoder, cross-encoder, ואיפה השיפור באמת נמצא
קישור למקטע: Bi-encoder, cross-encoder, ואיפה השיפור באמת נמצאכל מה שהיה עד עכשיו הוא bi-encoder: השאילתה עוברת דרך המודל לבדה, כל chunk עבר דרכו לבדו לפני חודשים, והשניים לא נפגשים אלא כמכפלה סקלרית. זה מה שמאפשר index — לעשות embedding פעם אחת, להשתמש לנצח — וזה גם התקרה. המודל אף פעם לא מסתכל על השאילתה ועל ה-chunk יחד.
cross-encoder עושה בדיוק את זה: הוא לוקח את הזוג כקלט אחד ומחזיר ציון רלוונטיות. אי אפשר לחשב שום דבר מראש, ולכן הוא לא יכול לדרג index — אבל הוא יכול לעשות reranking לרשימה קצרה. Reranking של ה-top 25 ההיברידי עם ms-marco-MiniLM-L-6-v2 מזיז את R@1 מ-0.094 (dense) ל-0.312 ואת MRR מ-0.280 ל-0.447: השיפור היחיד הגדול ביותר בפרק הזה, והיחיד שנוגע בראש הרשימה ולא בזנב.
זה עולה 569 ms לכל שאילתה על CPU, לעומת 1.14 ms ל-BM25 ו-0.3 ms לסריקת וקטורים. בערך פי אלפיים מעלות האחזור, עבור עשרים וחמישה מסמכים. זה כל הטרייד-אוף bi-encoder/cross-encoder במספר אחד, וזאת הסיבה שהארכיטקטורה תמיד באותה צורה: retriever זול עם recall רחב, ואז מדרג יקר על רשימה קצרה שאפשר להרשות. ColBERT יושב בין השניים, מחשב מראש וקטורים לכל token ועושה late interaction שזול יותר מ-cross-encoder וחד יותר ממכפלה סקלרית.3
L2, קוסינוס, וסף שעדיין לא הרווחת
קישור למקטע: L2, קוסינוס, וסף שעדיין לא הרווחתמסדי נתונים וקטוריים מדווחים מרחקים, ואיזה מרחק הוא אפשרות קונפיגורציה. על וקטורים מנורמלים הבחירה קוסמטית, וכדאי לעשות את הזהות פעם אחת כי כל מה שאחריה תלוי בכך שהווקטורים באמת באורך יחידה. עבור :
ולכן מרחק הקוסינוס הוא בדיוק . זו המכפלה הסקלרית והנורמה מפרק 1, כשהן נפרעות. נבדק על שני וקטורי chunk אמיתיים מה-index שלמעלה, ואז על פני 40,000 זוגות:
||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 — ו-רק כי הווקטורים מנורמלים. דלג על הנרמול והזהות שקרית, הסף שלך לא אומר כלום, והמרחק שמסמך מדווח תלוי בכמה ארוך היה הטקסט שלו.
עכשיו המספר שאף אחד לא גוזר. Retriever תמיד מחזיר משהו: הוא ממיין את כל ה-index ומוסר לך את ראש הרשימה, בין אם התשובה נמצאת בכלל בקורפוס ובין אם לא. הסף הוא החלק היחיד במערכת שיכול לומר לא — וכדי לקבוע אחד צריך שאילתות שאמורות לא להחזיר כלום. הנה שלושים: עשרים ואחת על דברים שהקורפוס הזה באמת לא מכסה — סטרימינג, מגבלות קצב, prompt caching, סכמות JSON, לולאות agent, מסדי נתונים וקטוריים, prompt injection, יצירת תמונות — ותשע על פאייה, דרכונים ומדיניות החזרים. מול אותו index:
| מרחק קוסינוס top-1 | |
|---|---|
| שאילתות in-domain, כל ה-64 | ממוצע 0.445, טווח 0.270 – 0.721 |
| in-domain, top-1 באמת נכון | ממוצע 0.370 |
| in-domain, top-1 שגוי | ממוצע 0.452 |
| out-of-domain, כל ה-30 | ממוצע 0.699, טווח 0.497 – 0.867 |
ההתפלגויות נפרדות, והן חופפות. שאילתת ה-in-domain הגרועה ביותר רחוקה יותר מהתשובה שלה (0.721) מאשר שאילתת ה-out-of-domain הטובה ביותר רחוקה מפסקה לא רלוונטית (0.497), ולכן שום סף לא מצליח בשני המקרים. סורקים אותו על השער האמיתי — לשמור לכל היותר ארבעה chunks, ורק את אלה שמתחת לחיתוך:
| סף | in-domain נענו | מתוכן התשובה הייתה בפנים | out-of-domain נענו |
|---|---|---|---|
| 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 |
| ללא | 64 / 64 | 27 | 30 / 30 |
קרא את העמודה האחרונה כ-בלופים. בלי סף, ה-assistant מפיק תשובה בטוחה ומצוטטת היטב ל-״איך מחדשים דרכון ספרדי״ מתוך קורפוס על backpropagation, שלושים פעמים מתוך שלושים. ב-0.675 הוא עושה את זה עשר פעמים מתוך שלושים. ב-0.525 הוא עושה את זה פעמיים, ומוותר על שתים-עשרה שאלות שיכול היה לענות עליהן.
הטרייד-אוף הזה הוא החלטת מוצר, והקצה הנכון שלו תלוי במה עולה לך תשובה שגויה. מה שאינו נתון למשא ומתן הוא עצם קיומה של העמודה האחרונה. אם מעולם לא מדדת את ה-retriever שלך מול שאלות שהוא צריך לסרב להן, אין לך סף — יש לך מספר.
שניים מעשרת הבלופים ב-0.675 מראים את שתי הדרכים שבהן זה נכשל.
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."הראשון הוא כמעט פגיעה: הקורפוס מסביר בפירוט את ה-KV cache, השאילתה היא על prompt cache, המילים הן אותן מילים, ו-0.497 קרוב יותר מרוב האחזורים הנכונים ב-in-domain בכל הניסוי. embedding לא יודע ששני caches עם אותו שם הם מכונות שונות. השני הוא התאמה מילולית בלי תשובה: הקורפוס מכיל את הביטוי המדויק ״what is the capital of France״, כדוגמה לשאלה שלא דורשת היסק. ה-retriever צודק; התשובה לא שם. כל מערכת שקוראת ״מצאתי משהו דומה״ כ-״מצאתי את התשובה״ תטען שפריז על בסיס הראיה הזו — או, גרוע יותר, לא תטען.
למה הציטוט לא נכתב על ידי המודל
קישור למקטע: למה הציטוט לא נכתב על ידי המודללמודל אין יכולת נפרדת לעובדות. הפקת משפט אמיתי והפקת משפט סביר הן אותה פעולה — חיזוי ה-token הבא מפרק 8 — ושום דבר בפעולה הזו לא מסמן מי מהם מי. הניתוח מ-2025 שניסח זאת מחדש טוען שצינור האימון וההערכה מתגמל ניחוש באופן פעיל: benchmarks מודדים בדיוק בינארי ולא נותנים קרדיט להימנעות, ולכן מודל שתמיד עונה מקבל ציון גבוה יותר ממודל זהה שאומר ״אני לא יודע״ כשהוא לא יודע, וה-post-training מתאופטם בהתאם.6 Hallucination בקריאה הזו אינו פגם מסתורי. זה מה שמקבלים כשבודקים מבחן אמריקאי בלי קנס על תשובה שגויה.
תראה את הצורה של זה. כשנתבקש לתת שמונה מאמרים על contrastive sentence embeddings, עם מזהים, Qwen2.5-0.5B-Instruct הפיק שמונה שורות בפורמט מושלם. כל שמונת המזהים תקינים צורנית. כל השמונה נפתחים למאמרים אמיתיים ב-arXiv. אפס מתוך השמונה הם המאמר שנטען.
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זה מודל קטן וה-שיעור הוא שלו; מודל frontier ממציא הרבה פחות. ה-מנגנון מכליל, והוא הסיבה לכלל שמגיע עכשיו. ולידטור שבודק ״האם המזהה הזה קיים״ מעביר את כל השמונה, ומשתמש שלוחץ על אחד נוחת בעמוד אמיתי מארכיון אמיתי בלי דרך לדעת שהמיפוי הומצא. הכשל אינו במזהה או בפורמט. הוא באסוציאציה — בדיוק הדבר שמודל שפה מפיק לפי סבירות.
אז: המודל כותב [1] ו-[2], ולעולם לא כותב את הקישור. המספרים מתייחסים לפרגמנטים שהשרת אחזר, והשרת — שיודע בדיוק מאיזה מסמך ומאילו offsets הגיע כל מספר — מצמיד אחר כך את המסמך, התווית וה-URL. אין למודל מה להמציא, כי אף פעם לא מבקשים ממנו את הדבר היחיד שהוא היה ממציא.
export function buildContext(question: string, hits: Scored[]) {
const citations: Citation[] = hits.map((h, i) => ({
index: i + 1,
documentId: h.chunk.documentId,
documentName: h.chunk.documentName,
locatorLabel: label(h.chunk),
fragment: `#char=${h.chunk.locator.flow.from},${h.chunk.locator.flow.to}`,
quote: h.chunk.content, // the OWN content, never `text`
cosineDistance: h.cosineDistance,
}));
const blocks = citations
.map((c) => `[${c.index}] ${c.documentName} - ${c.locatorLabel}\n${c.quote}`)
.join("\n\n");
const prompt =
`Answer using ONLY the numbered sources below. Cite every claim as [n].\n` +
`If the sources do not contain the answer, say so and stop.\n\n` +
`SOURCES\n${blocks}\n\nQUESTION\n${question}`;
return { prompt, citations };
}מריצים את זה על שאלת הפתיחה וארבעת ה-chunks הופכים ל-prompt בן 591 tokens ולטבלה שהמודל לעולם לא רואה:
[1] 04-classification How many test examples do I need? #char=28215,28701 d=0.594
[2] 04-classification Three splits, and the leak… #char=20329,20839 d=0.598
[3] 04-classification How many test examples do I need? #char=25873,26272 d=0.600
[4] 04-classification How many test examples do I need? #char=25554,25871 d=0.605ה-locator הוא החלק שאנשים מדלגים עליו ואז לא יכולים להוסיף מאוחר יותר. #char=25873,26272 הוא טווח בטקסט הקנוני של המסמך; עבור PDF המקבילה היא #page=12, עבור אודיו או וידאו #t=132.4,158.9, עבור גיליון אלקטרוני גיליון וטווח A1. שני אלה אינם המצאות — #page= הוא PDF Open Parameters ו-#t= הוא W3C Media Fragments, הנתמכים נייטיבית בדפדפנים על רכיבי וידאו ואודיו. ציטוט בלי locator הוא שם מסמך, ושם מסמך אינו ציטוט; הוא הצעה למשתמש ללכת לחפש.
וכששום דבר לא עובר את הסף, ה-pipeline כלל לא מגיע למודל:
NO ANSWER: nothing under cosine distance 0.675 for "what is the offside rule in football"
NO ANSWER: nothing under cosine distance 0.675 for "how do i renew my spanish passport"
NO ANSWER: nothing under cosine distance 0.675 for "how do i build an agent loop with tools"זה סירוב זול ואמין יותר מכל הוראה ב-system prompt, כי זו השוואה בין שני מספרים ולא בקשה למערכת הסתברותית.
להעריך את ה-retriever בנפרד מהמחולל
קישור למקטע: להעריך את ה-retriever בנפרד מהמחוללכל מדידה בפרק הזה מדרגת את ה-retriever ואף לא פעם אחת מבקשת ממודל לכתוב תשובה. זה מכוון, וזה החלק שרוב הצוותים מדלגים עליו.
למערכת RAG יש שני מצבי כשל שנראים זהים מבחוץ. ה-retriever לא מצא את הקטע; או שהוא מצא אותו והמחולל התעלם ממנו, סתר אותו, או ערבב אותו עם משהו שכבר האמין בו. אם מדרגים רק את התשובה הסופית, אי אפשר להבדיל ביניהם, ולכן מכווננים prompts מול בעיה שחיה ב-chunker שלך. Recall@k, MRR וספירת התשובות שנהרסו לא צריכים קריאת generation בכלל, הם זולים מספיק להרצה בכל deploy, והם ה-harness מפרק 15 עם פונקציית ניקוד אחרת — אותה בקשה, דדליין, מקביליות וספירה, על סט שאלות קבוע במקום שיחה חיה.
דווח אותם עם מרווחים. האריתמטיקה מפרק 4 חלה ללא שינוי: ב-64 שאילתות recall של 0.5 נושא מרווח Wilson של 95% של בערך ±0.12, כך שאסטרטגיה שמקדימה אחרת בארבע נקודות לא אמרה לך כלום. השתמש במבחן מזווג בכל פעם ששתי האסטרטגיות עונות על אותן שאלות, וזה תמיד המצב כאן — זה מה שהפך את ״E נראה טוב יותר מ-A״ ל-p = 0.0015.
והכנות האחרונה: RAG מפחית hallucination ולא מסיר אותו. הכנסת הקטע הנכון ל-prompt לא מחייבת את המודל להשתמש בו, והספרות אומרת זאת מאז המאמר המקורי.7 שני דברים מחמירים את זה בפרודקשן. הקשרים ארוכים מתדרדרים — מודל מוצא מידע בתחילת prompt ארוך ובסופו בצורה אמינה יותר מאשר באמצע, ולכן עשרים chunks במקום ארבעה יכולים להוריד דיוק תוך העלאת החשבון, אפקט שנמדד ב-פרק 24. ואחזור יכול להיות נכון ועדיין לא מספיק, כפי ששני ה-caches שלמעלה הראו. SelfCheckGPT מסמן טענות שלא שורדות דגימה מחדש;8 Self-RAG מאמן את המודל לפלוט tokens משלו של retrieve-and-critique;9 TruthfulQA הפך את מצב הכשל לקריא מלכתחילה.10 אף אחד מהם לא סוגר את הפער, ומערכת שמציגה טקסט מאוחזר כהוכחה בלבלה בין ממוסמך לבין נכון.
החצי של המערכת שרץ לפני כל שאילתה
קישור למקטע: החצי של המערכת שרץ לפני כל שאילתהRetriever הוא החלק הגלוי של pipeline שכל הכשלים שלו קורים מוקדם יותר, בחושך. שלושה מהם חוזרים שוב ושוב.
Extraction הוא המקום שבו התוכן מת. PDF אינו טקסט; הוא הוראות ציור. פריסות בשני טורים משתלבות זו בזו, טבלאות הופכות למרק מילים, כותרות עמוד חוזרות לכל chunk, ולעמוד סרוק אין טקסט כלל עד ש-OCR נותן לו כזה, עם confidence. כל מה שנמדד למעלה הניח שה-extractor עשה את עבודתו; בפרודקשן זה לעיתים קרובות לא קורה, והסימפטום נראה כמו אחזור גרוע שלוש שכבות משם.
ה-index מוחתם במודל שבנה אותו. Embeddings משני מודלים אינם בני השוואה — לא ״פחות מדויקים״, אלא לא בני השוואה, כי הם נקודות במרחבים שונים. שנה את ה-embedding model וכל וקטור בחנות הוא זבל עד שנבנה מחדש. לכן שם המודל, מספר הממדים, גרסת ה-pipeline וגרסת ה-extractor נכתבים ליד כל מסמך בזמן index. בלי זה, ביום השדרוג, אי אפשר לדעת אילו מסמכים מיושנים ואילו עדכניים, ו-index שעבר חצי מיגרציה מחזיר שטויות בטוחות בלי שגיאה בשום מקום.
מסמך שבור אחד לא צריך לשבור את התיקייה, והמונה צריך לספור את מה שקרה. מסמך שנכשל ב-extraction מסתיים במצב failed עם הסיבה שלו, גלוי וניתן לניסיון חוזר, בזמן שתשעים ותשעה האחרים נשארים בני חיפוש; ומספר ה-chunks שאונדקסו נכתב על ידי השרת כשהוא מסיים, לא מוכרז על ידי הלקוח בזמן העלאה. תיקייה שמדווחת על 400 פרגמנטים ומחזיקה 40 היא שקר שנחשף רק כשמגיעה שאלה שאי אפשר לענות עליה.
לאן זה ממשיך
קישור למקטע: לאן זה ממשיךהמערכת בפרק הזה עונה על שאלות שהתשובות שלהן כתובות. היא מאחזרת אותן, מדרגת אותן, מסרבת כשהיא לא יכולה, ומצטטת איפה היא חיפשה. זה רוב מה שאנשים רוצים מ-assistant על המסמכים שלהם, וזה מוגבל בדרך מסוימת אחת: אחזור יכול להחזיר רק מה שמישהו כתב.
מה שמשאיר את החצי השני. חלק ממה שאתה רוצה שמודל יעשה אינו עובדה במסמך בכלל — פורמט שהוא צריך להחזיק, טון, טקסונומיה עם ארבע מאות תוויות, דרך החלטה שחיה בעשרת אלפים דוגמאות עבר ולא בשום פסקה. אחזור לא יכול לספק את אלה, כי אין מה לאחזר; prompt ארוך יותר רק משלם את החשבון של פרק 16 על תיאור של skill במקום ה-skill.
פרק 20 הוא ההחלטה הזו — fine-tune, לאחזר או prompt — והממצא שלו הוא שההחלטה כלכלית לפני שהיא טכנית: השלושה מתומחרים מקצה לקצה על אותה שאלה, ונקודת החצייה היא ספירת tokens. השאלה שפותחת אותו היא זו שהפרק הזה לא יכול לענות עליה. לא איפה התשובה כתובה, אלא מה עושים כשהיא מעולם לא נכתבה.
מקורות ושיטה
קישור למקטע: מקורות ושיטהכל מה שנמדד בפרק הזה השתמש בקורפוס אחד ובמכשיר אחד, ושניהם ניתנים לשחזור. הקורפוס הוא פרקים 1 עד 13 של הקורס הזה כפי שהיו ב-7 בספטמבר 2026 — 13 מסמכים, 359,067 תווים, 127 מקטעים, בלי front matter וביבליוגרפיות. הפרקים האלה ממשיכים להיערך, ולכן יישום אותו כלל היום סופר עוד כמה אלפי תווים: מספר המקטעים לא השתנה וכך גם כל מסקנה בהמשך, אבל סכום התווים הוא snapshot ומסומן ככזה. האמת הקרקעית היא 32 שאלות, כל אחת משויכת למשפט מילולי שמופיע בדיוק פעם אחת בקורפוס ולעולם אינו כותרת מקטע, נשאלות בשני ניסוחים ל-64 שאילתות. Retrieval embeddings הם sentence-transformers/all-MiniLM-L6-v2 (384 ממדים, mean-pooled, מנורמל L2, חלון של 256 tokens); reranking הוא cross-encoder/ms-marco-MiniLM-L-6-v2 על top 25; דוגמת ה-generation היא Qwen/Qwen2.5-0.5B-Instruct עם greedy decoding. כל הזמנים הם CPU חד-תהליכי. לא נקראה שום API בתשלום כדי להפיק את הפרק הזה, וזו גם הסיבה שכל latency כאן הוא מקומי ומסומן ככזה.
ה-chunker שמוצג ב-TypeScript הוא ה-chunker שנמדד: המכשיר ב-Python שמממש את אותו כלל ו-ts/chunk.ts הושוו chunk מול chunk על פני כל הקורפוס ומסכימים על כל 940 ה-chunks, הטקסטים וה-offsets כאחד. המרווחים הם Wilson ב-95%; השוואות מזווגות הן מבחני סימן מדויקים דו-צדדיים על הזוגות הדיסקורדנטיים.
כל ארבעה-עשר המזהים שצוטטו למעלה נבדקו מול arXiv API ואומתו כותרת אחר כותרת ב-7 בספטמבר 2026 — מה שבהינתן השמונה שלא היו, נראה כמו המינימום שהפרק המסוים הזה יכול לעשות.
הפניות
קישור למקטע: הפניות-
Robertson, S. and Zaragoza, H. The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval 3(4), pp. 333–389 (2009). המקור של פונקציית הרוויה ושל שני הקבועים שהשתמשנו בהם למעלה, והמקום לקרוא למה בכלל קיים. ↩
-
Cormack, G. V., Clarke, C. L. A. and Büttcher, S. Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods. SIGIR 2009. ה- הוא שלהם, והעיקר בשיטה הוא שהיא לא צריכה כיול בין סולמות הציונים שהיא ממזגת. ↩
-
Khattab, O. and Zaharia, M. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT. arXiv:2004.12832 (2020). אמצע הדרך בין מכפלה סקלרית לבין 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 שמאחורי רוב מסדי הנתונים הווקטוריים שנמכרים כיום. ↩
-
Johnson, J., Douze, M. and Jégou, H. Billion-scale Similarity Search with GPUs. arXiv:1702.08734 (2017). FAISS, ומימוש הייחוס של ה-IVF שנמדד בתיבה שלמעלה. ↩
-
Kalai, A. T., Nachum, O., Vempala, S. S. and Zhang, E. Why Language Models Hallucinate. arXiv:2509.04664 (2025). הטיעון ש-hallucination נוצר מדירוג בדיוק בינארי שלעולם לא מתגמל הימנעות, ולכן הוא בעיית הערכה לפני שהוא בעיית מידול. ↩
-
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). המאמר שנתן שם לתבנית, וזה שכדאי לקרוא כדי להבין מה היא מתקנת ומה לא. Guu et al., REALM: Retrieval-Augmented Language Model Pre-Training, arXiv:2002.08909 (2020), הוא העבודה המקבילה שמאמנת את ה-retriever יחד עם המודל במקום לחבר אותו מבחוץ; Karpukhin et al., Dense Passage Retrieval for Open-Domain Question Answering, arXiv:2004.04906 (2020), הוא המקום שממנו מגיע ה-dense retriever בשני encoders שנעשה בו שימוש לאורך הפרק; ו-Izacard and Grave, Leveraging Passage Retrieval with Generative Models for Open Domain Question Answering, arXiv:2007.01282 (2020), הוא סידור fusion-in-decoder להזנת הרבה passages למחולל אחד. 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), שעושה embedding לתשובה היפותטית במקום לשאלה. ↩
-
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). זיהוי באמצעות דגימה מחדש, בלי גישה לפנימיות המודל ובלי 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). אימון המודל להחליט מתי לאחזר, במקום לאחזר בכל פנייה. ↩
-
Lin, S., Hilton, J. and Evans, O. TruthfulQA: Measuring How Models Mimic Human Falsehoods. arXiv:2109.07958 (2021). ה-benchmark שנבנה משאלות שבהן התשובה הסבירה והתשובה האמיתית שונות, שזה כל הקושי במשפט אחד. ↩