پرش به محتویات

درس ۰۱ — RAG در ۵۰ خط

ماژول: parsrag.naive_rag · کد شروع: starter/naive_rag.py · تصحیح: python -m grader run 01 (۱۷ بررسی) · پیش‌نیاز: درس ۰۰ — آماده‌سازی محیط

در این درس کل چرخهٔ RAG را — ساده و بی‌تعارف — در حدود ۵۰ خط منطق می‌سازیم. هدف این نیست که بهترین سیستم را بسازیم؛ هدف این است که شکل کلی را ببینیم. درس‌های بعدی هر گام را جداگانه بهتر می‌کنند و هر بهبود را با عدد اندازه می‌گیرند.

RAG چیست؟

مدل‌های زبانی بزرگ (LLM) فقط چیزهایی را می‌دانند که در زمان آموزش دیده‌اند. اگر بپرسید «هزینهٔ پیک فروشگاه کهربا چقدر است؟» یا نمی‌دانند یا — بدتر — یک عدد از خودشان می‌سازند. به این پدیده توهم (Hallucination) می‌گویند.

تولید تقویت‌شده با بازیابی (Retrieval-Augmented Generation) راه‌حل ساده‌ای است: قبل از پرسیدن از مدل، متن‌های مرتبط را از اسناد خودمان بازیابی (Retrieval) می‌کنیم و همراه پرسش به مدل می‌دهیم تا پاسخ را بر اساس همان متن‌ها بنویسد.

flowchart LR
    subgraph Indexing["نمایه‌سازی (Indexing) — یک بار"]
        D[📄 اسناد] --> L[بارگذاری<br/>Load] --> C[قطعه‌بندی<br/>Chunk] --> E[برداری‌سازی<br/>Embed] --> S[(انبارهٔ برداری<br/>Vector Store)]
    end
    subgraph Query["پرسش — هر بار"]
        Q[❓ پرسش] --> QE[برداری‌سازی پرسش] --> R[بازیابی k قطعهٔ نزدیک<br/>Retrieve]
        S --> R
        R --> P[ساخت پرامپت<br/>Prompt] --> G[🤖 LLM<br/>Generate] --> A[💬 پاسخ + منابع]
    end

پنج گام

گام در این درس کجا بهترش می‌کنیم
بارگذاری (Load) خواندن فایل‌های .md درس ۰۳: PDF، DOCX، HTML و مشکلات راست‌به‌چپ
قطعه‌بندی (Chunking) پنجره‌های ثابتِ ۴۰۰ نویسه‌ای با ۵۰ نویسه هم‌پوشانی درس ۰۴: قطعه‌بندی آگاه از جمله و پاراگراف فارسی
برداری‌سازی (Embedding) HashingEmbedding درس ۰۵: مقایسهٔ مدل‌های واقعی
بازیابی (Retrieval) شباهت کسینوسی در حافظه درس‌های ۰۶ تا ۰۸: FAISS، جست‌وجوی ترکیبی، بازرتبه‌بندی
تولید (Generation) FakeLLM(mode="extractive") درس ۰۹: پاسخ مستند با ارجاع و «نمی‌دانم»ِ صادقانه

کد

تمام منطق در src/parsrag/naive_rag.py است. مهم‌ترین بخش‌هایش:

def chunk_text(text: str, size: int = 400, overlap: int = 50) -> list[str]:
    step = size - overlap
    return [text[i : i + size] for i in range(0, max(len(text) - overlap, 1), step) if text]

هم‌پوشانی (Overlap) باعث می‌شود جمله‌ای که روی مرز دو قطعه افتاده، دست‌کم در یکی از آن‌ها کامل بیاید.

def build_prompt(question, results):
    context = "\n\n".join(f"[{i}] ({r.chunk.doc_id}) {r.chunk.text}" for i, r in enumerate(results, 1))
    user = f"متن‌های بازیابی‌شده:\n{context}\n\nپرسش: {question}"
    return [Message(role="system", content=SYSTEM_PROMPT), Message(role="user", content=user)]

و خود پایپ‌لاین:

rag = NaiveRAG(HashingEmbedding(), FakeLLM(mode="extractive"), chunk_size=400, k=3)
rag.index(load_documents("data/fixtures/corpus"))
answer = rag.answer("هزینهٔ پیک در تهران چقدر است؟")
print(answer.text)                                  # هزینهٔ پیک در تهران 200 هزار تومان است.
print([s.chunk.id for s in answer.sources])         # ['kahroba-shipping#0', ...]

اجرا کنید:

python examples/rag_in_50_lines.py
python examples/rag_in_50_lines.py "تا چند روز بعد از تحویل می‌توانم کالا را برگردانم؟"

داده‌ها

در data/fixtures/ ۲۵ سند هست که مخصوص همین دوره نوشته شده‌اند: قوانین یک فروشگاه اینترنتی خیالی به نام «کهربا» و یک کتابخانهٔ عمومی خیالی به نام «مهرگان». بیشترشان کوتاه‌اند، ولی چهار سند بلند و چندبخشی هم هست که در درس ۰۴ به کارمان می‌آیند. کنار آن‌ها ۶۶ پرسش ارزیابی در qa.jsonl داریم (هفت پرسش عمداً پاسخی در اسناد ندارند).

متن اسناد عمداً شلخته است — دقیقاً مثل متن فارسیِ دنیای واقعی. 😅

چه چیزی هنوز خراب است؟ (و این خوب است!)

خروجی examples/rag_in_50_lines.py را با دقت نگاه کنید:

❓ مرکز خدمات پس از فروش کهربا در کدام شهر است؟
💬 مرکز خدمات پس از فروش کهربا در شهر اصفهان است و دَست

پاسخ وسط کلمه قطع شده است، چون قطعه‌بندی ما به مرز کلمه و جمله اهمیتی نمی‌دهد (درس ۰۴).

❓ جریمهٔ دیرکرد کتاب در کتابخانهٔ مهرگان چقدر است؟
💬 فرم درخواست همکاری در میز اطلاعات و وب‌سایت کتابخانه در دسترس است.
   📄 mehregan-volunteers#1  ·  mehregan-children#8  ·  mehregan-interlibrary#0

سند درست (mehregan-rules) حتی در سه نتیجهٔ اول هم نیست. چرا؟ چون آن سند با حروف عربی (ي و ك) نوشته شده و برای کامپیوتر «كتاب» و «کتاب» دو کلمهٔ کاملاً متفاوت‌اند. این دقیقاً مسئلهٔ درس ۰۲ — نرمال‌سازی فارسی است.

روی ۵۹ پرسشِ پاسخ‌دار، این پایپ‌لاین ساده در ۷۶٪ موارد سند درست را در رتبهٔ اول و در ۸۶٪ موارد در سه رتبهٔ اول پیدا می‌کند (hit@1 = 0.763، hit@3 = 0.864). این عددها خط پایهٔ ما هستند؛ هر درس باید آن‌ها را بهتر کند.

تمرین

فایل lessons/01-rag-in-50-lines/starter/naive_rag.py را کامل کنید:

  1. chunk_text — پنجره‌های ثابت با هم‌پوشانی (و ValueError برای ورودی نامعتبر).
  2. build_prompt — پیام سیستم (system) و پیام کاربر (user) با متن‌های شماره‌دار و «پرسش:» در انتها.
  3. NaiveRAG.index — هر سند را قطعه‌بندی کنید و Chunk(id=f"{doc.id}#{i}", doc_id=doc.id, text=...) بسازید.
  4. NaiveRAG.retrieve و NaiveRAG.answer.
python -m grader run 01

برای کنجکاوها

  • اگر chunk_size را ۱۰۰ یا ۱۰۰۰ کنید، hit@1 چه تغییری می‌کند؟ (راهنمایی: parsrag.eval.hit_rate)
  • چرا k=3 و نه k=10؟ هزینهٔ متن بیشتر در پرامپت چیست؟
  • اگر پرسش پاسخی در اسناد نداشته باشد (مثل q25 در qa.jsonl) چه اتفاقی می‌افتد؟ (درس ۰۹)

مطالعهٔ بیشتر

  • Lewis et al.، Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks (NeurIPS 2020) — مقاله‌ای که نام RAG را رایج کرد: arXiv.
  • Gao et al.، Retrieval-Augmented Generation for Large Language Models: A Survey (2023) — مروری بر گونه‌های RAG که نشان می‌دهد هر درس این دوره کجای این نقشه قرار می‌گیرد: arXiv.

چالش‌ها (اختیاری)

تمرین‌هایی باز، بدون پاسخ مرجع و بدون بررسی Grader، برای وقتی که می‌خواهید جلوتر بروید:

  1. ده پرسش خودتان. ده پرسش دربارهٔ اسناد نمونه بنویسید که پایپ‌لاین ساده به آن‌ها جواب غلط می‌دهد. خطاها را دسته‌بندی کنید (قطعه‌بندی، حروف، واژهٔ متفاوت، پرسش چندبخشی). کدام درس‌های بعدی کدام دسته را حل می‌کنند؟
  2. کمترین تغییر، بیشترین اثر. فقط با عوض کردن اندازه و هم‌پوشانی قطعه‌ها، hit@1 را تا جایی که می‌توانید بالا ببرید. بعد بررسی کنید آیا همان تنظیم روی پرسش‌های qa_paraphrase.jsonl هم بهترین است.

درس بعد

درس ۰۲ — نرمال‌سازی متن فارسی: می‌بینیم چرا «كتاب» و «کتاب» برای کامپیوتر دو کلمه‌اند و با یک نرمال‌ساز فارسی نرخ اصابت را بالا می‌بریم و بهبود را با عدد نشان می‌دهیم.