יום 9 - חיפוש בבלוג באמצעות RAG וחיפוש טקסט מלא
הרבה אנשים שומעים RAG ומיד חושבים על חיפוש וקטורי, אבל האמת ש RAG הוא הרבה יותר מזה. היום נדבר על RAG דרך משימה של חיפוש פוסטים בבלוג ומחר נעבור לדוגמה עם חיפוש וקטורי. שתי הדוגמאות יחד יחשפו חלק מהמורכבות בכתיבת סוכנים שצריכים להחזיר תשובות מתוך מאגר ידע.
1. אתגר הקונטקסט
התחלנו את הסדרה עם הסוכן הבוחן, סוכן שמקבל מאמר ומייצר ממנו 10 שאלות. בהמשך ראינו את הסוכן שמייצר ניוזלטר בעזרת כלים, אותו סוכן חיפש בעצמו ברשת מאמרים מעניינים וחיבר אותם לניוזלטר. שני המקרים היו דוגמאות פשוטות לסוכנים שמחזירים תשובה על בסיס מאגר ידע ובשניהם העבודה היתה דומה: הסוכן קיבל את המידע יחד עם הבקשה ובסוף החזיר תשובה כשבחלון הקונטקסט שלו היו המידע הדרוש להחלטה והשאלה של המשתמש.
למנגנון הזה בדיוק אנחנו קוראים RAG שזה ראשי תיבות של Retrieval-augmented generation. מילת המפתח של RAG היא Retrieval, המערכת מושכת את המידע הרלוונטי, מוסיפה אותו לחלון הקונטקסט (זה ה Augmented) ומפעילה את המודל כדי לייצר תשובה.
בשתי הדוגמאות שהצגתי לא היינו צריכים להתלבט איזה מידע להכניס לחלון הקונטקסט. הסוכן הבוחן היה צריך את המאמר וזה מה שהוא קיבל. סוכן הניוזלטר היה צריך איזשהם מאמרים ומילא לעצמו את החלון. מערכות RAG יותר מעניינות הן מערכות שצריכות להחליט בצורה דינמית ולפי בקשת המשתמש איזה מידע להכניס לחלון הקונטקסט לפני שפונים למודל.
מערכת RAG מורכבת לכן תמיד ממספר חלקים - החלק של הסוכן והחלק שמחפש לעבות את בקשת המשתמש עם מידע ממאגר הידע. שימו לב שכל חלק הוא עצמאי, הסוכן הכי טוב בעולם לא יצליח לענות נכון אם לא תתנו לו את המידע, והמידע הכי רלוונטי לא יעזור אם הסוכן לא מצליח להבין אותו. הרבה פעמים נרצה לשלב גם אופציה לחיפוש המשך, כלומר ניתן לסוכן מידע ראשוני וכלים כדי למשוך מידע נוסף.
בדוגמה היום נבנה סוכן שיודע לענות על שאלות לפי פוסטים מהבלוג הזה. אנחנו נבנה:
סקריפט שמוריד את הפוסטים מהבלוג ומאנדקס אותם לבסיס נתונים המיועד לחיפוש בשם Meilisearch.
קוד פייתון שמקבל שאלת משתמש ומחפש ב Meilisearch איזה פוסטים עשויים להיות רלוונטים לשאלה זו (איזה פוסטים מכילים מילים מהשאלה).
קוד סוכן שמקבל פוסטים מהבלוג ושאלת משתמש ועונה על השאלה לפי מה שכתוב בפוסטים.
שלושת החלקים מהווים מערכת RAG וילמדו אותנו על האתגרים בבניית מערכת כזו.
2. יצירת המידע
שלב ראשון במערכת הוא יצירת מאגר המידע. בדוגמה שלנו נתחיל עם הסקריפט download-data.py שמוריד את כל הפוסטים מהאתר לקבצי markdown מקומיים. זה קל כי אני ממילא מפרסם את הפוסטים גם ב HTML וגם ב Markdown ולכן הקוד צריך לרוץ על דף או דפי אינדקס מהבלוג, לאסוף את הקישורים לפוסטים ואז להוריד אותם בלי להעמיס על השרת. זאת הפונקציה המרכזית:
async def run(count: int) -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
semaphore = asyncio.Semaphore(MAX_CONCURRENCY)
async with httpx.AsyncClient(
headers={"User-Agent": USER_AGENT},
follow_redirects=True,
timeout=30.0,
) as client:
slugs = await collect_slugs(client, count, semaphore)
if len(slugs) < count:
print(f"warning: only {len(slugs)} posts found (requested {count})")
# The slug list is the download queue; the semaphore enforces at most
# MAX_CONCURRENCY concurrent requests.
await asyncio.gather(
*[download_post(client, slug, semaphore) for slug in slugs]
)
print(f"done: downloaded {len(slugs)} posts to {DATA_DIR}/")
הסקריפט לוקח מהמשתמש מספר פוסטים להוריד ושומר את כולם בתיקיית data.
3. שמירת הפוסטים בבסיס נתונים לחיפוש
אחרי שיש לנו מאגר מידע אנחנו רוצים לאנדקס אותו כלומר לשמור את המידע בצורה שיהיה קל לאחזר את הפוסטים הרלוונטים לשאלות משתמשים. זה תפקידו של הסקריפט בקובץ index.py
מנוע החיפוש מיילי שומר אוביקטים ויודע לחפש בטקסט שלהם. הפונקציה הראשונה קוראת את הקבצים מהדיסק לרשימה בזכרון:
def build_documents() -> list[dict[str, str]]:
"""Read every ``.md`` file in ``data`` and build Meilisearch documents."""
docs: list[dict[str, str]] = []
for path in sorted(DATA_DIR.glob("*.md")):
content = path.read_text(encoding="utf-8")
docs.append(
{
"id": path.stem,
"filename": path.name,
"title": _extract_title(content),
"content": content,
}
)
return docs
ובהמשך אנחנו קוראים לפונקציה של מיילי שנקראת add_documents כדי לשמור את המסמכים בבסיס הנתונים:
docs = build_documents()
task = index.add_documents(docs)
ביצירת האינדקס אני שומר גם טבלה של מילים נרדפות. טבלה זו תעזור למצוא פוסטים קשורים גם כשלא השתמשו בשאלה באותן מילים בדיוק - וכן אתם כבר יכולים לראות את האתגר בבנייה, תחזוקה ושמירה של טבלה זו:
HEBREW_SYNONYMS: dict[str, list[str]] = {
# Singular ↔ plural
"טיפ": ["טיפים"],
"כלי": ["כלים"],
"שגיאה": ["שגיאות"],
"באג": ["באגים"],
"קוד": ["קודים"],
"פיצ׳ר": ["פיצ׳רים"],
"מודל": ["מודלים"],
"סוכן": ["סוכנים"],
"פרויקט": ["פרויקטים"],
"שאלה": ["שאלות"],
"תשובה": ["תשובות"],
"פתרון": ["פתרונות"],
"בעיה": ["בעיות"],
"דוגמה": ["דוגמאות"],
"קובץ": ["קבצים"],
"שפה": ["שפות"],
"כתבה": ["כתבות"],
"פוסט": ["פוסטים"],
# Common abbreviations / shortcuts
"בינה מלאכותית": ["AI", "ai", "ב״מ"],
"למידת מכונה": ["ML", "ml", "machine learning"],
"עיבוד שפה טבעית": ["NLP", "nlp"],
"ביג דאטה": ["big data"],
# English terms that appear in Hebrew text
"prompt": ["פרומפט", "פרומפטים", "הנדסת פרומפטים"],
"agent": ["אייג׳נט", "אייג׳נטים"],
"token": ["טוקן", "טוקנים"],
"API": ["api", "ממשק"],
"framework": ["פריימוורק", "פריימוורקים"],
"library": ["ספרייה", "ספריות"],
"debugging": ["דיבוג", "ניפוי שגיאות"],
"refactoring": ["ריפקטור", "ריפקטורינג", "שכתוב קוד"],
"testing": ["בדיקות", "טסטים", "טסט"],
"code review": ["קוד ריוויו", "סקירת קוד"],
"open source": ["קוד פתוח", "open-source"],
"CLI": ["cli", "שורת פקודה"],
"LLM": ["llm", "מודל שפה גדול", "מודלי שפה"],
"RAG": ["rag"],
"MCP": ["mcp"],
"VSCode": ["vs code", "ויזואל סטודיו קוד", "vs-code"],
"Git": ["git", "גיט"],
"GitHub": ["github", "גיטהאב"],
"Copilot": ["copilot", "קופיילוט"],
"Claude": ["claude", "קלוד"],
"Docker": ["docker", "דוקר"],
"Python": ["python", "פייתון"],
"JavaScript": ["javascript", "js", "ג׳אווה סקריפט"],
"TypeScript": ["typescript", "ts"],
"Ruby": ["ruby", "רובי"],
"Rails": ["rails", "ריילס"],
"React": ["react", "ריאקט"],
"Vue": ["vue", "ויו"],
}
4. חיפוש ושילוב התוצאות
ראינו בדוגמאות קודמות איך להגדיר לסוכן דף הוראות סטטי - כלומר טקסט של instructions שיסביר לסוכן מה צריך לעשות. בעבודה RAG דף ההוראות נבנה בצורה דינמית לפי השאילתה ולכן אנחנו משתמשים במנגנון נוסף של Pydantic AI Agents שנקרא Dynamic Instructions. מנגנון זה מאפשר לנו לשמור פונקציה בתור תוספת להוראות, פידנטיק יפעיל את הפונקציה ויוסיף את ערך ההחזר שלה לדף ההוראות. הפונקציה רצה בדיוק לפני שליחת ההודעה למודל ויש לה גישה לפרומפט ולתלויות של אותה בקשה.
נקרא את הקוד הבא מתוך הקובץ app.py:
@agent.instructions
async def inject_rag_context(ctx: RunContext[Deps]) -> str:
"""Search Meilisearch and attach the top matching posts to the prompt.
The filenames are also stored in ``ctx.metadata['rag_sources']`` so the
output validator can prepend them to the final answer.
"""
query = _extract_query(ctx.prompt)
if not query:
return ""
if ctx.metadata is None:
ctx.metadata = {}
results = ctx.deps.meili.index(INDEX_NAME).search(query, {"limit": ctx.deps.top_k})
hits = results.get("hits", [])
filenames = [hit["filename"] for hit in hits]
ctx.metadata["rag_sources"] = filenames
if not hits:
return (
"No relevant blog posts were found for this question. "
"Answer based on your general knowledge, but mention that no posts matched."
)
sections = [f"--- {hit['filename']} ---\n{hit['content']}" for hit in hits]
return (
f"Relevant blog posts found by Meilisearch: {', '.join(filenames)}. "
"Use the content below to answer the user's question. "
"Start your response by listing the filenames of the posts you used, "
"for example 'Based on: file1.md, file2.md'. Then answer the question.\n\n"
+ "\n\n".join(sections)
)
לוקחים את הפרומפט שעומד להישלח למודל.
מריצים חיפוש במיילי כדי למצוא פוסטים רלוונטים.
מוסיפים לדף ההוראות את תוכן כל הפוסטים הרלוונטים.
5. עכשיו אתם
בשביל להריץ את הדוגמה תצטרכו הפעם את דוקר מאחר ויש לנו גם את בסיס הנתונים meilisearch וגם את האפליקציה. צירפתי בתיקיית הדוגמה קובץ docker-compose.yml בו אפשר להשתמש.
הריצו את הדוגמה אצלכם אחרי הגדרת מפתח הגישה לג'מיני ב
.env. שאלו את הסוכן שאלות ושימו לב באיזה פוסטים הוא משתמש כדי לענות.חשבו: איך היינו מתחזקים את האינדקס במערכת פרודקשן? מה קורה אם תוכן פוסט מתעדכן? מה אם פוסט נמחק?
חשבו: מה קורה אם יש פוסט ארוך במיוחד - האם תמיד צריך לכלול את כל הפוסט כדי לענות על שאלת המשתמש? האם יהיה מספיק למודל להסתכל על פסקה בודדת?
החליפו את מנגנון ה RAG בגישה מבוססת כלים - תנו לסוכן כלי לחיפוש ב meilisearch ושאלו את אותן שאלות. האם קיבלתם תשובות טובות יותר? טובות פחות?
החליפו את מנגנון ה RAG בגישה מרובת סוכנים - בנו סוכן חיפוש שתפקידו לחפש ב meilisearch קטעים רלוונטים לשאלה (בעזרת כלי החיפוש). הפעילו את סוכן החיפוש במקום לבנות את השאילתה בעצמכם ואת התוצאות העבירו לסוכן השיחה. איך זה עבד?
בתיקיית docs של פידנטיק AI תוכלו למצוא את התיעוד של הספריה. כתבו סוכן שמחפש בתיעוד הספריה ועונה על שאלות לגביה.