RAG רב-שכבתי: למה חיפוש וקטורי הוא הכלי הלא נכון לחצי מהשאלות
רוב השאלות על מסמכים עסקיים הן שאלות על מטא-דאטה, לא על משמעות. הטמעה שלהן בכל זאת היא הדרך לקבל תשובה בטוחה בעצמה ושגויה בשקט.
- RAG
- אחזור מידע
- LLM
הגרסה הראשונה של מערכת ה-RAG שלי למסמכים עבדה יפה בהדגמה והתמוטטה בשאלה האמיתית השנייה.
שאלת ההדגמה הייתה "מה תנאי הסיום בהסכם הייעוץ?" — שאלת משמעות. מטמיעים אותה, מוצאים את הקטעים שמדברים על סיום, מעבירים למודל. זה עובד מפני שהשאלה והתשובה קרובות סמנטית.
השאלה השנייה הייתה "על כמה חוזים חתמנו ברבעון השלישי?"
חיפוש וקטורי עושה משהו עם השאלה הזאת. הוא מאחזר קטעים שנראים כמו חוזים ומזכירים תאריכים. המודל סופר אותם ועונה בביטחון מלא. המספר שגוי, מפני שהקבוצה שאוחזרה מעולם לא הייתה הקבוצה השלמה — היא הייתה k הקטעים הדומים ביותר, ולדמיון אין שום קשר לשלמות.
זו כל הבעיה בדוגמה אחת. חיפוש וקטורי מחזיר דברים דומים. שאלת ספירה צריכה דברים שלמים. שום שיפור בהטמעות לא פותר את זה, מפני שזה לא כשל בדירוג.
שני סוגי שאלות
כשמיינתי שאלות אמיתיות של משתמשים לקטגוריות, החלוקה הייתה כמעט שווה.
שאלות מטא-דאטה. כמה, ממתי, אילו, על ידי מי, מה הסכום הכולל. אלה ניתנות למענה מדויק משדות מובנים — תאריכים, צדדים, סוגים, סכומים — אם השדות האלה קיימים. יש להן תשובה נכונה, ולהיות בערך נכון זה בדיוק כמו להיות שגוי.
שאלות סמנטיות. מה הסעיף הזה אומר, האם הוא סותר את ההסכם האחר, מה היו התנאים בנוגע לאחריות. אלה צריכות את הטקסט, ודמיון הוא באמת אות האחזור הנכון.
הטעות הייתה לבנות צינור אחד ולכוון אליו כל שאלה. התיקון לא היה מאחזר טוב יותר. הוא היה הודאה בכך שיש שתי בעיות.
השכבות
שאלה
↓
סיווג ──→ מטא-דאטה? ──→ SQL מעל שדות שחולצו
│
└──────→ סמנטית? ──→ חיפוש היברידי (BM25 + וקטורים)
│
└──────→ שתיהן? ──→ SQL לסינון, ואז חיפוש בתוך התוצאההענף השלישי הוא זה שהפך את המערכת לשימושית באמת. "על מה הסכמנו בנוגע לאחריות בחוזים שחתמנו עם ספקים בשנה שעברה?" היא שתיהן: מסנן (type = vendor, year = 2025) ושאלת משמעות בתוך הקבוצה המסוננת. לענות עליה בחיפוש וקטורי טהור פירושו לקוות ש-k העליונים ייפלו במקרה בתוך תת-הקבוצה הנכונה. לענות עליה עם SQL קודם פירושו שמרחב החיפוש כבר נכון לפני שהדמיון נשאל בכלל.
החילוץ הוא העבודה האמיתית
שכבת ה-SQL קיימת רק אם השדות קיימים, ומכאן שצריך חילוץ בזמן הקליטה. לכל מסמך: סוג, צדדים, תאריכים, סכומים, הפניות למסמכים אחרים. זה לא זוהר וזה המקום שאליו הלך רוב המאמץ.
שני דברים שהיו חשובים יותר משציפיתי:
לחלץ לתוך סכמה ולאמת אותה. מודל שמתבקש להחזיר תאריך מחזיר 2025-03-14, March 14, 2025, 14/3/25, ולפעמים משפט שמסביר שהתאריך לא ברור. לאמת בגבול ולדחות במקום לשמור:
const extractedDocument = z.object({
documentType: z.enum(['contract', 'invoice', 'report', 'correspondence', 'other']),
parties: z.array(z.string().min(1)).max(20),
signedAt: z.iso.date().nullable(),
totalAmount: z.number().nonnegative().nullable(),
currency: z.string().length(3).nullable(),
});
const extract = async (text: string): Promise<IExtracted | null> => {
const parsed = extractedDocument.safeParse(await model.json(EXTRACTION_PROMPT, text));
if (!parsed.success) {
await quarantine.record(text, parsed.error.issues);
return null;
}
return parsed.data;
};מסמך שנכשל בחילוץ הולך לתור הסגר, לא לאינדקס עם שדות ריקים. שדה ריק אינו ניתן להבחנה מ״למסמך הזה באמת אין סכום״, וברגע שהעמימות הזאת נמצאת במסד הנתונים כל ספירה שבנויה עליו אינה אמינה.
לשמור את מה שלא הצלחתם לחלץ. תור ההסגר התברר כאות הטוב ביותר שהיה לי על המקומות שבהם הצינור חלש. מסמכים מצטברים שם באשכולות, והאשכולות הם קטגוריות אמיתיות שאיש לא סיפר לי עליהן.
העברית שברה לי את ההנחות
הקורפוס מעורב עברית ואנגלית, וזה שינה את השכבה הסמנטית.
העברית עשירה מורפולוגית — תחיליות נצמדות ישירות למילים, ולכן אותו שורש מופיע בצורות שטח רבות. BM25 טהור מתפקד פחות טוב מפני שהטוקן שתואם לשאילתה הוא מחרוזת שונה מהטוקן שבמסמך. הטמעות רב-לשוניות מתמודדות עם זה טוב יותר, אבל הן מטשטשות מזהים מדויקים: מספר חוזה או שם של צד הם מחרוזת שחייבת להתאים בדיוק, והטמעות טובות בדיוק בלא להתייחס למחרוזות מדויקות.
אחזור היברידי, משוקלל לפי שפת השאילתה, ניצח כל אחד מהם לבדו:
const search = async (query: string, filter: IFilter): Promise<IChunk[]> => {
const hebrew = HEBREW_PATTERN.test(query);
const weights = hebrew ? { lexical: 0.3, semantic: 0.7 } : { lexical: 0.5, semantic: 0.5 };
const [lexical, semantic] = await Promise.all([bm25.search(query, filter), vectors.search(await embed(query), filter)]);
return fuseReciprocalRank([lexical, semantic], weights).slice(0, TOP_K);
};אף אחד מהמספרים אינו עקרוני. הם הגיעו מקבוצה מתויגת של כ-200 שאלות אמיתיות ומסריקה, והם נבדקים מחדש כשהקורפוס משתנה.
הנתב הוא מסווג, לא פרומפט
הגרסה הראשונה המתבקשת מבקשת מהמודל לבחור מסלול באותה קריאה שבה הוא עונה על השאלה. אל תעשו את זה. זה מצמיד שני כשלים — המודל יכול לנתב נכון ולענות רע, או לנתב רע ולענות נכון, ואי אפשר לדעת מהפלט מה מהם קרה.
הניתוב הוא קריאה נפרדת עם פלט מוגבל, מה שהופך אותו למדיד בנפרד:
const route = async (question: string): Promise<Route> => {
const decision = await model.json(ROUTER_PROMPT, question);
const parsed = routeSchema.safeParse(decision);
if (!parsed.success) {
return 'hybrid';
}
return parsed.data.route;
};ברירת המחדל היא hybrid, במכוון. כשהנתב אינו בטוח, המסלול היקר יותר שמתייעץ עם שתי השכבות הוא הכשל הבטוח — תשובה שגויה יקרה הרבה יותר מהשאילתה הנוספת.
ברגע שהניתוב הופרד יכולתי למדוד אותו, ודיוק הניתוב היה המנוף הגדול ביותר על האיכות מקצה לקצה. הוא גם היה הדבר הזול ביותר לשיפור: קומץ דוגמאות מתויגות בפרומפט הזיז אותו יותר מכל שינוי במודל ההטמעה.
מה הייתי אומר לעצמי בהתחלה
מיינו את השאלות לפני שאתם בונים מאחזר. מאה שאלות אמיתיות, ממוינות ביד למטא-דאטה ולסמנטיקה, אומרות לכם מה לבנות. בניתי קודם את החצי הסמנטי מפני שהוא החצי המעניין, ואז גיליתי שחצי מהתעבורה שלי היא שאלות ספירה שעניתי עליהן לא נכון.
״מוגבר באחזור״ אינו אותו דבר כמו ״מסד נתונים וקטורי״. האחזור שעונה על שאלת ספירה הוא SELECT. זה עדיין RAG. שום דבר בתבנית לא מחייב הטמעות.
מספר שגוי שנאמר בביטחון הוא מצב הכשל הגרוע ביותר. מערכת שאומרת ״אני לא יכול לענות על זה במדויק״ שימושית יותר מאחת שמחזירה מספר שהוא בערך נכון. משתמשים בודקים הסתייגות. הם לא בודקים מספר.