Reranking — RAG מדויק יותר
השדרוג הכי משתלם ל-RAG: שכבה שנייה שמדרגת מחדש את התוצאות ומעלה את הרלוונטי באמת לראש. שיפור דיוק דרמטי במאמץ קטן.
קודם: לוודא שזו בכלל הבעיה
Reranking מוצג בדרך כלל כשיפור שכדאי תמיד, וזו עצה שגורמת להרבה אנשים להוסיף שכבה שלא פותרת להם כלום.
ב-RAG יש שלוש נקודות כשל נפרדות, ורק אחת מהן היא מה ש-reranker מתקן:
- אינדוקס. הקטע הנכון בכלל לא קיים במאגר, או שהוא חתוך באמצע כך שהתשובה מפוצלת בין שני קטעים.
- שליפה. הקטע קיים, אבל לא חוזר בין המועמדים — או שהוא חוזר במקום ה-40 ולא נכנס להקשר.
- יצירה. הקטע הנכון הגיע למודל, והמודל התעלם ממנו או סילף אותו.
Reranker נוגע רק בשני, וגם שם רק בחלק ממנו — הוא מסדר מחדש מועמדים שכבר חזרו, והוא לא יכול להעלות קטע שהשליפה הרחבה פספסה לגמרי.
הבדיקה שמכריעה לוקחת עשר דקות: קח עשרים שאלות אמיתיות שאתה יודע מה התשובה להן ובאיזה קטע היא נמצאת. הרץ שליפה עם top_k=50, ובדוק כמה פעמים הקטע הנכון נמצא ברשימה בכלל.
hits = 0
for q, expected_chunk_id in eval_set:
ids = [c.id for c in vector_search(q, top_k=50)]
if expected_chunk_id in ids:
hits += 1
print("recall@50: %d/%d" % (hits, len(eval_set)))
- אם המספר גבוה (נניח 18 מתוך 20) והתשובות עדיין גרועות — זו בדיוק הבעיה ש-reranker פותר. הקטע שם, הוא פשוט לא בחמישה הראשונים.
- אם המספר נמוך — reranker לא יעזור. הבעיה למעלה: בחיתוך, באינדוקס, או בניסוח השאילתה.
אז זה באמת השיפור המשתלם ביותר שאפשר לעשות ל-RAG — עלייה ניכרת בדיוק תמורת שכבה אחת ומעט קוד.
אחזור דו-שלבי
הרעיון פשוט: שני שלבים עם תפקידים הפוכים.
- שליפה רחבה. ה-Vector DB מחזיר הרבה מועמדים — 50, לפעמים 100. מהיר וזול, והמטרה שלו היא לא לפספס, לא לדייק.
- דירוג מחדש. מודל reranker בוחן כל מועמד מול השאילתה ומחזיר את המעטים שבאמת עונים. איטי בהרבה, ולכן רץ רק על מה שכבר סונן.
למודל השפה מזינים רק את התוצאה של השלב השני — הקשר קצר ומדויק. זה משפר גם את התשובה וגם את העלות, כי פחות טוקנים נכנסים. הרחבה: Context Engineering.
ההיגיון כאן זהה למיון בכל תחום אחר: מסנן זול ורחב, ואחריו מסנן יקר וצר. אף אחד לא היה מריץ את המודל היקר על מיליון מסמכים, ואף אחד לא היה מסתפק במסנן הגס.
Cross-encoder מול bi-encoder
ההבדל הטכני הוא מה שמסביר גם את הדיוק וגם את המחיר.
- Bi-encoder — מה שembeddings עושים. השאילתה והקטע מקודדים בנפרד לווקטורים, וההשוואה היא בין שני מספרים. אפשר לחשב את הווקטור של כל קטע מראש, פעם אחת, ולכן החיפוש מהיר גם על מיליוני פריטים.
- Cross-encoder — השאילתה והקטע נכנסים יחד למודל, שמחזיר ציון רלוונטיות אחד. המודל רואה את שניהם בבת אחת ויכול לשים לב שהמילה בשאילתה מופיעה בקטע בהקשר הפוך. מדויק בהרבה.
והמחיר: אי אפשר לחשב שום דבר מראש. כל צמד שאילתה-קטע דורש הרצה נפרדת. חמישים מועמדים פירושו חמישים הרצות, וזו הסיבה המדויקת שהשלב הזה בא רק אחרי סינון.
אותו הבדל מסביר גם את הכשל של שליפה וקטורית לבד: embedding לוכד "על מה הקטע מדבר", ולא "האם הוא עונה על השאלה הזאת". קטע שמזכיר את אותם מונחים בהקשר שונה לגמרי ידורג גבוה, וזה מקור נפוץ להזיות — המודל קיבל טקסט שנראה רלוונטי ואינו.
כמה מועמדים, וכמה להחזיר
שני מספרים שכדאי לכוון במודע ולא להשאיר בברירת מחדל.
כמה לשלוף — ככל שיותר, כך קטן הסיכוי לפספס, וכך גדל החביון והעלות. ההתנהגות אינה ליניארית: המעבר מ-10 ל-50 משפר משמעותית, המעבר מ-50 ל-200 בדרך כלל כמעט לא. נקודת פתיחה סבירה היא 50, ואת המספר הנכון אצלך מוצאים בבדיקת ה-recall מהסעיף הראשון — תעלה עד שהוא מפסיק להשתפר, ואז תעצור.
כמה להעביר למודל — כאן הנטייה היא להעביר יותר "ליתר ביטחון", וזו טעות. יותר קטעים פירושם יותר רעש, והמודל נוטה להתפזר. שלושה עד חמישה קטעים טובים מנצחים עשרה בינוניים כמעט תמיד.
ויש גם אפשרות שכדאי להכיר: סף ציון במקום מספר קבוע. אם ה-reranker נותן ציון נמוך לכל המועמדים, זה אות ששאלת משהו שאין עליו תשובה במאגר — וזה בדיוק המקרה שבו עדיף שהמערכת תגיד "לא מצאתי" במקום לענות מקטעים לא רלוונטיים.
ranked = reranker.rank(query, docs)
good = [r for r in ranked if r.score > 0.3][:5]
if not good:
return "לא מצאתי מידע רלוונטי במסמכים."
שלוש השורות האחרונות משפרות את אמון המשתמש יותר מכל שיפור בדיוק, כי הן מונעות את התשובה הבטוחה והשגויה.
ערכת הבדיקה: איך בונים אותה
כל מה שכתוב כאן נשען על אותה ערכה קטנה, ובלעדיה אין דרך לדעת אם משהו עזר. הבנייה שלה היא שעה, פעם אחת.
- אסוף שאלות אמיתיות. מה שמשתמשים באמת שאלו, לא מה שנחמד לבדוק. אם המערכת עוד לא בשימוש — קח את השאלות שאתה נשאל בעל פה.
- לכל שאלה, סמן את הקטע שעונה עליה. זו העבודה. אתה פותח את המסמכים ומוצא איפה התשובה, ורושם את המזהה.
- הכלל שקובע — כלול שאלות שאין להן תשובה. חמש מתוך עשרים. אלה הבודקות אם המערכת יודעת לומר "לא מצאתי", וזו התכונה שהכי משפיעה על אמון.
- הוסף שאלות שנוסחו רע. קצרות, מעורפלות, עם שגיאות כתיב, מעורבות עברית-אנגלית. ככה משתמשים באמת כותבים.
ומה שהופך אותה לנכס לאורך זמן: כל תלונה מוסיפה שאלה. אחרי חצי שנה יש לך אוסף של בדיוק המקרים שמפילים אותך, וכל שינוי עתידי נבדק מולם. זה גם מה שמאפשר לשדרג מודל בלי לפחד — מריצים, משווים, ויודעים.
חיפוש היברידי
שיפור משלים ולעיתים קרובות משמעותי יותר מה-reranker עצמו: לשלב חיפוש סמנטי עם חיפוש מילולי.
- סמנטי תופס משמעות — שאילתה שמנוסחת אחרת לגמרי מהמסמך עדיין תמצא אותו.
- מילולי (BM25 ודומיו) תופס מה שהסמנטי מפספס: שמות, קודים, מספרי דגם, ראשי תיבות. מספר קטלוגי אין לו "משמעות" שאפשר להטמיע, ולכן חיפוש וקטורי לבד כמעט אף פעם לא ימצא אותו.
איך משלבים: מריצים את שניהם, ואז מאחדים את שתי הרשימות. השיטה הנפוצה נקראת Reciprocal Rank Fusion, והיא פשוטה להפתיע — כל מסמך מקבל ציון לפי המיקום שלו בכל רשימה, בלי צורך לנרמל ציונים שאינם ברי השוואה:
def rrf(lists, k=60):
scores = {}
for ranked in lists: # כל רשימה מדורגת
for rank, doc_id in enumerate(ranked, start=1):
scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)
merged = rrf([semantic_ids, keyword_ids])[:50]
top = reranker.rank(query, fetch(merged))[:5]
היתרון של השיטה הזאת הוא שהיא לא דורשת לכייל כלום — ציוני דמיון וקטורי וציוני BM25 נמצאים בסולמות שונים לגמרי, ואיחוד לפי דירוג עוקף את הבעיה.
עברית: איפה זה נשבר
שני דברים שספציפיים לעברית ומשנים את התכנון.
החיפוש המילולי כמעט לא עובד בלי טיפול
בעברית מילות יחס וקישור נדבקות למילה: מערכת, המערכת, במערכת, שהמערכת, ומהמערכת. עבור BM25 אלה חמש מילים שונות לחלוטין. משתמש שיחפש "מערכת" לא ימצא מסמך שכתוב בו "במערכת", וזה קורה כל הזמן.
מה שצריך: נרמול לפני האינדוקס וגם לפני השאילתה — הסרת אותיות שימוש נפוצות, איחוד אותיות סופיות, והורדת ניקוד אם יש. גם טיפול פשוט משפר דרמטית. מנועי חיפוש רציניים מציעים מנתח מורפולוגי לעברית, ואם אתה משתמש באחד — שווה להפעיל אותו במפורש, כי הוא לא ברירת מחדל.
בלי זה, החצי המילולי של החיפוש ההיברידי לא תורם כמעט כלום, ואתה משלם על מורכבות בלי לקבל את היתרון.
Reranker רב-לשוני, ולא כל אחד
חלק מה-rerankers אומנו על אנגלית בלבד. הם יחזירו ציונים גם על עברית — פשוט ציונים גרועים, בלי שום שגיאה שתרמז לך על כך. תבדוק במפורש שהמודל תומך רב-לשונית, ועדיף לאמת את זה בעצמך על עשרים שאלות בעברית ולא להסתמך על הצהרה.
וכפי שנכתב במדריך לHuggingFace: אותו כלל תקף גם למודל ההטמעה. אותו מודל לאינדוקס ולשאילתה, ואם החלפת — צריך לבנות את האינדקס מחדש.
איך זה נראה בקוד
יש שתי אפשרויות: שירות מנוהל שמקבל שאילתה ורשימת מסמכים ומחזיר אותם מדורגים, או מודל פתוח שרץ אצלך — למשל ממשפחת ה-BGE, דרך HuggingFace.
# שלב 1 — שליפה רחבה
candidates = vector_search(query, top_k=50)
# שלב 2 — דירוג מחדש
ranked = reranker.rank(
query=query,
documents=[c.text for c in candidates],
)
# שלב 3 — סף, לא רק top-k
top = [r for r in ranked if r.score > 0.3][:5]
context = "\n\n".join(t.text for t in top)
ומה שכדאי להוסיף כבר עכשיו ולא אחר כך: לתעד את הציונים. כשמשתמש יתלונן על תשובה, ההבדל בין "אין לי מושג" לבין "הקטע הנכון היה במקום השביעי עם ציון 0.28" הוא כל ההבדל בין ניחוש לתיקון.
חיתוך: מה שקורה לפני הכל
הסעיף על החלופות הזכיר את זה בקצרה, ושווה להרחיב — כי זו הבעיה שהכי הרבה מערכות סובלות ממנה ושהכי קל לזהות לאחר מעשה.
חיתוך לפי מספר תווים קבוע הוא ברירת המחדל בכל כלי, והוא לא מודע למבנה. התוצאה: קטע נגמר באמצע טבלה, כותרת מנותקת מהתוכן שמתחתיה, ותשובה שמפוצלת בין שני קטעים כך שאף אחד מהם לא מספיק.
- חיתוך לפי מבנה. לפי כותרות, סעיפים, פסקאות. מסמך מסודר כמעט תמיד נותן גבולות טובים יותר ממספר תווים.
- חפיפה. שאיפה של עשרה עד עשרים אחוז מהקטע הקודם. עולה מעט אחסון ופותרת את המקרה של תשובה שיושבת על התפר.
- הקשר בכל קטע. להוסיף לתחילת כל קטע את הכותרת שממנה הוא בא — "מדריך החזרות > מדיניות זיכוי > ..." — כך שגם קטע שנשלף לבדו יודע איפה הוא. זה גם משפר את ההטמעה שלו וגם עוזר למודל בסוף.
- גודל. קטע קטן מדי מאבד הקשר, גדול מדי מכניס רעש. הטווח השימושי רחב, וכדאי לבדוק שניים או שלושה גדלים על ערכת הבדיקה ולא לנחש.
ובעברית יש הערה: אל תחתוך באמצע מילה, וזה נשמע מובן מאליו עד שרואים כלי שחותך לפי בייטים ולא לפי תווים. עברית ב-UTF-8 תופסת שני בייטים לתו, וחיתוך לפי בייטים מייצר תו שבור בקצה כל קטע.
ציטוט מקורות — למה זה שייך לכאן
נראה כמו נושא נפרד והוא בעצם המשך ישיר: ברגע שיש דירוג עם ציונים, אפשר לתת למשתמש לראות מאיפה התשובה הגיעה — וזה משנה את המערכת יותר מכל שיפור בדיוק.
שלוש סיבות מעשיות:
- המשתמש יכול לאמת. תשובה עם קישור לקטע המקור נבדקת בשתי שניות. תשובה בלי — או שסומכים עליה בעיוורון או שלא סומכים עליה בכלל, ושתיהן גרועות.
- אתה מקבל אבחון בחינם. משתמש שמדווח "התשובה שגויה" נותן לך מעט; משתמש שאומר "זה ציטט את המסמך הישן" נותן לך בדיוק את התיקון.
- המודל משתפר כשהוא מצטט. דרישה מפורשת לציין מאיזה קטע כל טענה הגיעה מקטינה המצאות, כי היא מכריחה את המודל להיצמד למה שיש בהקשר.
מבחינת מימוש זה זול: הקטעים כבר מגיעים עם מזהים מהשלב הקודם. מה שצריך זה למספר אותם בהקשר שנשלח למודל, ולבקש שיפנה אליהם.
context = "\n\n".join(
"[%d] %s" % (i, t.text) for i, t in enumerate(top, 1))
SYSTEM = """ענה רק על סמך הקטעים המצורפים.
בסוף כל טענה ציין את מספר הקטע: [1], [2].
אם התשובה לא נמצאת בקטעים — אמור זאת."""
ואז המספרים ממופים חזרה לקישורים בממשק. ההוראה האחרונה חשובה לא פחות מהראשונה — היא הופכת את "לא מצאתי" לתשובה לגיטימית במקום למצב שהמודל מנסה להימנע ממנו.
חביון ועלות
Reranking מוסיף שלב, והשלב הזה לא חינם — לא בזמן ולא בכסף. מה שקובע אם זה מקובל הוא איפה זה יושב:
- צ׳אט שמשתמש מחכה לו — כאן כל מאות מילי-שניות מורגשות. שווה להוריד את מספר המועמדים, לבחור מודל קטן יותר, או להריץ את השליפה הרחבה במקביל לדברים אחרים.
- עיבוד באצווה — דוחות, סיווג, אינדוקס לילי. כאן החביון לא מעניין ואפשר להיות נדיב במספר המועמדים.
- מודל מקומי מול שירות — מודל פתוח שרץ אצלך מוריד עלות לבקשה ומוסיף תחזוקה. השיקול זהה לזה שמפורט במדריך HuggingFace, והוא נכון במיוחד כשהנפח גבוה ויציב.
ופרט מעשי: מטמון. אם אותן שאילתות חוזרות — וברוב המערכות הן כן — שמירת התוצאה המדורגת חוסכת את השלב כולו. זה השיפור הזול ביותר ברשימה.
שווה גם לנרמל את מפתח המטמון: להוריד רווחים מיותרים, להאחיד אותיות ולהסיר סימני פיסוק לפני שמשתמשים בשאילתה כמפתח. בעברית זה משנה במיוחד, כי אותה שאלה נכתבת בכמה צורות — עם ובלי ה"א הידיעה, עם ובלי סימן שאלה — ובלי נרמול כל וריאציה מייצרת חישוב חדש. חמש שורות קוד שמכפילות את שיעור הפגיעות במטמון.
איך יודעים שזה עזר
"התשובות נראות טובות יותר" הוא לא מדד, והוא גם לא יחזיק כשתשנה משהו אחר בעוד חודש.
מה שצריך זה אותה ערכה מהסעיף הראשון — עשרים עד חמישים שאלות עם הקטע הנכון מסומן — ושני מספרים:
- Recall@5 — בכמה אחוז מהשאלות הקטע הנכון נמצא בחמישה שנשלחו למודל. זה המדד הישיר של מה ש-reranker אמור לשפר.
- המיקום הממוצע של הקטע הנכון. אם הוא ירד מ-9 ל-2, ה-reranker עובד גם אם ה-recall כמעט לא זז.
תמדוד לפני ואחרי. ואם השיפור זניח — זה מידע שווה, כי הוא אומר שהבעיה במקום אחר ושחסכת לעצמך שכבה שצריך לתחזק.
ושווה למדוד גם את הדבר ההפוך: כמה פעמים ה-reranker הוריד קטע נכון שהיה גבוה. זה קורה — במיוחד עם מודל שלא מתאים לשפה או לתחום — וזה הכשל היחיד כאן שגורם נזק ולא רק לא עוזר. אם המספר הזה גדול מאפס בערכה שלך, זה סימן להחליף מודל ולא לכוונן פרמטרים.
מה עוד לנסות לפני, או במקום
שלושה שיפורים שלעיתים קרובות תורמים יותר מ-reranker ועולים פחות:
- חיתוך טוב יותר. אם התשובה מפוצלת בין שני קטעים, שום דירוג לא יאחד אותה. חפיפה בין קטעים, או חיתוך לפי מבנה המסמך במקום לפי מספר תווים, פותר את זה מהשורש.
- שכתוב השאילתה. משתמשים שואלים קצר ועמום. מודל שמרחיב את השאילתה לפני החיפוש — או מפצל אותה לשתיים — משפר את השליפה הרחבה עצמה, כלומר מתקן את מה שה-reranker לא יכול.
- סינון לפי מטא-דאטה. אם השאלה נוגעת לשנה מסוימת או ללקוח מסוים, סינון לפני החיפוש מצמצם את המרחב ומייתר חלק גדול מהעבודה. זול, מדויק, וכמעט תמיד מוזנח.
שכתוב שאילתה
הטכניקה שהכי מוזנחת ביחס לתועלת. משתמשים כותבים שאילתות קצרות ועמומות — "החזרות?", "כמה זה עולה" — ומצפים שהמערכת תבין. שליפה וקטורית על שתי מילים מחזירה כמעט רעש.
שלוש גרסאות, מהזולה ליקרה:
- הרחבה. מודל קטן מרחיב את השאילתה לניסוח מלא לפני החיפוש. "החזרות?" הופך ל"מהי מדיניות ההחזרות והזיכויים ותוך כמה ימים אפשר להחזיר מוצר".
- פיצול. שאלה שמכילה שני דברים — "כמה זה עולה ומתי זה מגיע" — מפוצלת לשתי שליפות נפרדות, ושתי התוצאות מאוחדות. פותר מקרה שחיפוש אחד כמעט תמיד נכשל בו.
- שאילתה מהיסטוריה. בצ׳אט, "ומה לגבי החזרה?" חסר לחלוטין בלי ההודעה הקודמת. שכתוב שמשלב את ההקשר הופך אותה לשאילתה עצמאית — וזה כמעט חובה בכל ממשק שיחה.
המקרה השלישי הוא הנפוץ ביותר וגם המפתיע ביותר כשמגלים אותו: מערכת שעובדת מצוין על השאלה הראשונה ומתפרקת על השנייה, כי היא שולפת לפי הטקסט של השאלה בלבד.
ובעברית שווה להוסיף להנחיית השכתוב לשמור מונחים לועזיים כמו שהם — משתמש שכתב "deploy" לא צריך שהשכתוב יהפוך אותו ל"פריסה", כי במסמכים כתוב deploy.
מתי לא
- כשה-recall בשליפה הרחבה נמוך. הבעיה למעלה, ו-reranker לא ייגע בה.
- כשהמאגר קטן. אם יש מאה קטעים בסך הכל, אפשר פשוט להעביר יותר מהם למודל.
- כשהחביון קריטי. יש מקרים שבהם תשובה מהירה וטובה־מספיק עדיפה על מדויקת ואיטית.
- כשאין ערכת בדיקה. בלי מדידה, אתה מוסיף מורכבות שאתה לא יודע אם עזרה — וזו מורכבות שתישאר איתך.
טעויות שחוזרות
- להוסיף reranker בלי לבדוק מה שבור. שכבה שלא נוגעת בבעיה.
- לשלוף 200 מועמדים "ליתר ביטחון". עלות וחביון בלי שיפור.
- להעביר עשרה קטעים למודל. רעש שמפזר את התשובה.
- בלי סף ציון. ואז המערכת עונה בביטחון גם כשאין תשובה במאגר.
- BM25 בעברית בלי נרמול. החצי המילולי לא תורם כלום, ואתה משלם על המורכבות.
- reranker שאומן לאנגלית. מחזיר ציונים גרועים בלי שום שגיאה.
- לא לתעד ציונים. ואז אין דרך לאבחן תלונה.
- לשנות מודל הטמעה בלי לבנות אינדקס מחדש. לא שובר כלום, פשוט מדרדר בשקט.