LLM-Wiki לספר של 640 עמודים
השתמשתי ב-LLM-wiki של Karpathy על הפתקים האישיים שלי, ואז התאמתי אותו ל-Q&A על ספר של 640 עמודים שלא משתנה. אותו pattern, מספר שכבות שונה.
ה-LLM-wiki pattern של Karpathy רץ נקי על הפתקים האישיים שלי. אחר כך היה לי צורך אחר — Q&A על ספר של 640 עמודים שלא משתנה — והתאמתי את אותו pattern לסוג כזה של מקור.
ל-pattern יש שלוש שכבות. המקורות הגולמיים יושבים למטה — ה-LLM קורא אותם אבל לא נוגע בהם. מעליהם ה-LLM מתחזק wiki של סיכומים, דפי entities, דפי קונספטים, והפניות צולבות. ומסמך config מגדיר איך ה-wiki מבונה ומתעדכן. בזמן query המודל קורא את ה-wiki, לא את המקורות. דחוס, מקושר, תמיד עדכני. על הפתקים שלי — קורפוס שאני ממשיכה לגדל — זה בדיוק מה שאני רוצה. ה-wiki המקומפל של ה-LLM נקי יותר מכל chunk מהפתקים הגולמיים שלי, והוא נשאר עדכני ככל שהפתקים מצטברים.
הספר שבניתי עליו Q&A לא גדל — שלושים פרקים, קבועים מרגע הפרסום. ומשתמשים ששואלים על פרק רצו את המילים של המחבר עצמו עם ציטוט עמוד, לא פרפרזה. אז ההתאמה היתה שתי שכבות במקום שלוש: מקורות גולמיים ו-routing index, בלי שכבת פרפרזה אמצעית לשימוש הזה.
אותו pattern, מוגדר לפי המקור.
ה-routing index
ה-routing index הוא קטלוג, לא סיכום. ערך אחד לכל פרק עם ה-slug שלו, כותרת, טווח עמודים, הקונספטים והמונחים שבאמת מופיעים בפרק, ופסקה קצרה של “covers” שמתארת אילו נושאים נידונים. בלי טענות, בלי מסקנות. מטא-דאטה לניתוב במילים של המחבר עצמו.
זמן ה-query הופך לשתי קריאות LLM במקום retrieval אחד.
שאלת משתמש │ ▼ ┌──────────────────────────┐ │ קריאת Router │ קוראת את כל ה-index (~30KB) + שאלה │ output JSON מובנה │ מחזירה 1–3 slugs של פרקים └──────────────────────────┘ │ ▼ ולידציה של slugs — slugs לא מוכרים נופלים │ ▼ ┌──────────────────────────┐ │ קריאת Answer │ טוענת רק את ה-markdown של אותם פרקים │ SSE streaming │ משדרת תשובה עם ציטוטים ישירים + ציטוטי עמודים └──────────────────────────┘ │ ▼ תשובה עם ציטוטים מילוליים מהמקור
קריאת ה-router
ה-router קורא את כל ה-index בתוספת השאלה של המשתמש ומחזיר 1–3 slugs של פרקים. ה-index קטן מספיק — בסביבות 30KB — כך שכולו נכנס בנוחות ל-context. העבודה של ה-router זולה: לצמצם 29 פרקים לכמה בודדים.
שתי החלטות עיצוב חשובות כאן. ראשית, ה-router משתמש ב-responseSchema — מצב ה-structured-output — כדי להחזיר JSON במבנה { slugs: string[] }. זה מחסל את כל סוג הבאגים שבהם המודל עונה בפרוזה במקום ב-JSON, או עוטף את ה-JSON ב-code fence, או מחליט להיות מועיל ולהוסיף פתיחה בשפה טבעית. או שמקבלים חזרה JSON תקף או שזורקים שגיאה.
שנית, ה-slugs שחוזרים עוברים ולידציה מול סט מוכר שנטען מה-index עצמו. Slugs לא חוקיים — ה-router שהזה פרק שלא קיים — נופלים בשקט, לא מנתבים אליהם. אם המודל מחזיר שלושה slugs ואחד זבל, השניים האמיתיים נשמרים. רק אם כל ה-slugs שחזרו לא חוקיים ה-router זורק שגיאה. סלחני בכשל חלקי, מחמיר בכשל מלא.
Temperature אפס. זו החלטת ניתוב, לא החלטה יצירתית.
קריאת ה-answer
כל הפרקים שה-router בחר נטענים מהאחסון כ-markdown גולמי. ה-system prompt עוטף את הפרקים האלה עם הוראות לצטט ישירות מהמקור ולהוסיף ציטוט עמוד אחרי כל ציטוט. התשובה חוזרת ב-stream כ-Server-Sent Events — tokens זורמים ללקוח תוך כדי שהם נוצרים, ואירוע done סופי מעביר את ספירת ה-tokens לשתי הקריאות.
הספר המלא אף לא בפרומפט. 640 עמודים זה יותר מדי context גם לסטנדרטים של היום, והפיצול של router-ואז-answer אומר שקריאת ה-answer טוענת מקסימום 1–3 פרקים של טקסט. ה-router רואה מטא-דאטה, קריאת ה-answer רואה רק את חלק המקור שהיא צריכה, אף אחד מהם לא רואה את כל הספר.
פרט תפעולי אחד שכדאי לציין: ה-route של ה-answer מסתכל על req.signal.aborted בין tokens ויוצא נקי מהלולאה אם הלקוח מתנתק. ה-log של ה-query נכתב ב-block של finally — הצלחות, כשלים, ואבורטים כולם עוברים באותו נתיב logging. בלי ה-await על ה-insert של ה-log, ה-runtime של ה-serverless לפעמים מאבד את ה-write ל-DB כשה-request נסגר.
בניית ה-index: דק, עשיר, מאומת
המעבר הראשון על ה-index היה דק מדי. נתתי ל-LLM לכתוב descriptor קצר אחד לכל פרק — כותרת ומשפט או שניים על הנושא של הפרק. ה-router טיפל בשאלות ברורות אבל כל מה שלא טריוויאלי נפל בין הכסאות. אוצר המילים בשאלה אמיתית כמעט אף פעם לא חפף לתיאור נושא של משפט אחד. או שהפרק הלא נכון נבחר או שכלום לא נבחר.
אז נתתי למודל לקרוא כל פרק מחדש ולהרחיב את רשימת ה-concepts/terms — להפיק את המונחים שמופיעים מילולית בפרק כמילות מפתח לניתוב. ה-recall של הניתוב קפץ. שאלות לא טריוויאליות התחילו לנחות על הפרק הנכון. מתחת לזה צף mode כשל חדש.
כשבדקתי כמה תשובות שהרגישו קצת לא במקום, ה-router בחר פרק על בסיס מונח שלא באמת היה ב-markdown של הפרק הזה — המודל ייחס בטעות את המונח מפרק שכן שגם אותו הכיר. ניתוב בטוח, יעד שגוי.
התיקון היה מכני. לולאת bash: לכל מונח מוצע, להריץ grep על ה-markdown של הפרק שטוען עליו. אם המונח לא שם באמת, להוריד אותו מה-index.
# עבור כל ערך בפרק ב-index, להוציא את המונחים שהוא טוען עליהם
# ולאמת שכל אחד מהם מופיע מילולית ב-markdown של הפרק
for chapter in chapters/*.md; do
slug=$(basename "$chapter" .md)
for term in $(extract_terms_from_index "$slug"); do
if ! grep -qF "$term" "$chapter"; then
echo "DROP: '$term' from $slug (not in source)"
mark_for_removal "$slug" "$term"
fi
done
done
לאורך 29 פרקים ו-1038 מילות מפתח מוצעות, 791 שרדו. ה-247 הנותרות התחלקו לשתי קטגוריות — וריאציות פרפרזה מוזות שלא מופיעות מילולית באף פרק, ומונחים שהוכנסו בזליגה מפרקים שכנים שגם אותם המודל הכיר.
הצורה הקונקרטית של הכשל: לפרק אחד נוספו מילות מפתח לניתוב שקיימות רק בשני פרקים אחרים. אם משתמש היה שואל שאלה עם אחת ממילות המפתח האלה, ה-router היה בוחר את הפרק הלא נכון, קריאת ה-answer היתה טוענת עמוד שלא תומך בציטוט, והתגובה היתה פרוזה בטוחה בעצמה בלי מקור שתומך בה. מעבר האימות חיסל את כל סוג הכשל הזה בתוך אחר צהריים אחד.
הפסקה של ה-covers — התיאור בפרוזה — קשה יותר לאימות באותה שיטה. משפט יכול להזכיר מונח שלא מופיע מילולית אבל נוכח סמנטית. השארתי את הפסקאות האלה עם שאריות של רעש הפניה; מילות המפתח הן החלק הנושא במשקל של ה-index.
שני modes כשל הפוכים
מעבר האיטרציה הזה הוא מה שהייתי מציירת על לוח אם הייתי צריכה להסביר את זה למהנדסת ג’וניורית. שני modes כשל הפוכים, עם אימות כדבר שמאפשר לדחוף אגרסיבית על recall בלי לשלם את המס של ה-precision.
Index דק — כשל של precision מחוסר
מעט מדי מטא-דאטה לכל פרק אומר שה-router לא מוצא התאמה לשאלות ספציפיות. חוסר חפיפה של אוצר מילים בין השאלה ל-index, אין במה לאחוז. ה-recall יורד כי ל-router אין על מה להיתפס.
Index עשיר — כשל של precision מהלוצינציה
יש הרבה על מה להתאים, אבל חלק ממה ששם לא אמיתי. ה-recall גבוה אבל כל התאמה היא הטלת מטבע לשאלה אם היא מצביעה על פרק שבאמת יכול לתמוך בתשובה.
אימות זה הכפתור בין שני modes הכשל. עם בדיקה מכנית זולה — האם המונח הזה באמת קיים בפרק שטוענים עליו — אפשר לתת ל-LLM להיות אגרסיבי כמה שהוא רוצה במעבר ההעשרה, ואז לתת למאמת לזרוק הכל מה שלא מחזיק. ה-recall עולה, ה-precision נשמר על ידי המסנן, ולא צריך לתת ל-LLM לשטר על עצמו.
על מה הקונפיגורציה הזאת מוותרת
wiki מלא של שלוש שכבות נותן דברים ששתי שכבות לא נותנות. סינתזה על פני מקורות. זיהוי סתירות. דפי קונספטים שקושרים חומר מפרקים שונים. הכל שימושי.
לשימוש הזה לא הייתי צריכה אותם. משתמשים ששואלים על פרק ספציפי רוצים את המילים של המחבר מאותו פרק. ציטוט ישיר עובד טוב יותר מזרימה שמערבבת חומר לאורך הספר. שטף בלי ייחוס זה לא מה שהם משלמים עליו.
שתי הקונפיגורציות יכולות לדור בכפיפה אחת באותו פרויקט. סוגים מסוימים של מקורות זורמים דרך ה-wiki המלא של שלוש שכבות. אחרים זורמים דרך הווריאנט של הניתוב. אותה שדרה, מנותב לפי סוג המקור.
המסקנה הרחבה יותר
מטא-דאטה לניתוב שנוצרה על ידי LLM ונצרכת על ידי LLM אחר היא מקום שבו אימות מכני זול מצדיק את עצמו. המודל כותב את המטא-דאטה בשטף ובסבירות — זו כל המטרה, וכל הסיכון. המודל במורד הזרם קורא את המטא-דאטה ומנתב על בסיסה. שגיאות מצטברות בשקט כי שום דבר מבני לא בודק את המטא-דאטה מול המקור.
אותה צורה מופיעה בכל מקום שבו LLM יוצר מידע מובנה ש-LLM אחר אחר כך סומך עליו. ארגומנטים של function call שחולצו מקלט משתמש. תגיות שהוצמדו למסמכים. סיווגים שמפעילים ניתוב במורד הזרם. בכל מקרה יש בדרך כלל בדיקה מכנית זמינה — grep, regex, ולידציית schema, חיפוש “האם הדבר שהוזכר קיים”. הבדיקה מהירה ומשעממת והיא מצטברת.
איפה נחתתי
השתמשתי ב-LLM-wiki pattern על הפתקים שלי שממשיכים לגדול, ואז התאמתי אותו לספר שלא משתנה. אותו קונספט, שתי קונפיגורציות, אותו mindset הנדסי מתחת.
ומה שלא ה-LLM מייצר לשכבת הניתוב — להריץ עליו grep לפני שסומכים עליו. המעבר הזה זול והוא מצטבר.