۱۴۰۵ مهر ۶, دوشنبه
دلار آمریکا۲۴۳٬۴۱۵▲ ۳٫۵۸٪یورو۲۷۷٬۰۰۰▲ ۳٫۳۹٪درهم امارات۶۶٬۳۰۵▲ ۳٫۶۱٪سکه امامی۲۴۴٬۵۰۵٬۰۰۰▲ ۱٫۶۶٪طلای ۱۸ عیار (گرم)۲۴٬۳۱۹٬۰۰۰▲ ۱٫۷۵٪انس طلا۴٬۱۵۰ $▼ ۳٫۲۶٪تتر۲۴۴٬۴۵۶▲ ۳٫۲۴٪بیت‌کوین۸۲٬۶۵۳ $▼ ۲٫۴۱٪اتریوم۲٬۶۴۰ $▼ ۱٫۸۹٪سولانا۱۱۷٫۸۴ $▼ ۴٫۳۴٪اپل۳۴۱٫۰۷ $▲ ۱٫۵۳٪انویدیا۲۲۵٫۰۷ $▲ ۰٫۲۲٪مایکروسافت۵۱۶٫۱۷ $▲ ۳٫۶۶٪آلفابت (گوگل)۳۴۳٫۹۲ $▲ ۰٫۴۶٪تسلا۳۷۲٫۱۱ $▼ ۱٫۵۴٪شاخص نزدک۲۷٬۰۶۹ $▲ ۰٫۴۸٪
نرخ ارز

آموزش RAG با پایتون: چت‌بات هوشمند روی اسناد خودتان بسازید

آموزش RAG از صفر با پایتون: یک چت‌بات پرسش‌وپاسخ روی اسناد فارسی خودتان بسازید؛ با کد کامل و قابل اجرا، مدل‌های لوکال Ollama و ارجاع به منبع.

هوش مصنوعی۶ دقیقه مطالعه
آموزش RAG با پایتون: چت‌بات هوشمند روی اسناد خودتان بسازید
فهرست مطالب
  1. RAG چیست و چرا به آن نیاز داریم؟
  2. معماری RAG در چهار مرحله
  3. آموزش RAG قدم‌به‌قدم با پایتون
  4. کد چطور کار می‌کند؟
  5. از نمونه آموزشی تا محصول واقعی
  6. خطاهای رایج در پیاده‌سازی RAG
  7. جمع‌بندی

مدل‌های زبانی درباره همه‌چیز حرف می‌زنند، جز چیزهایی که برای شما مهم‌تر است: مستندات داخلی تیم، آیین‌نامه‌های شرکت یا دفترچه راهنمای محصولتان. RAG راه‌حل استاندارد این مشکل است. در این آموزش RAG را از صفر و با حدود ۱۰۰ خط پایتون پیاده می‌کنیم؛ کاملاً لوکال، رایگان و با کدی که با تغییر یک متغیر روی سرویس‌های ابری هم اجرا می‌شود.

RAG چیست و چرا به آن نیاز داریم؟

تولید تقویت‌شده با بازیابی (Retrieval-Augmented Generation یا RAG) یعنی پیش از اینکه از مدل زبانی جواب بخواهیم، بخش‌های مرتبط اسنادمان را پیدا کنیم و همراه سؤال به آن بدهیم. مدل دیگر از حافظه‌اش جواب نمی‌دهد؛ بلکه مثل یک دانشجو در امتحان کتاب‌باز، از روی متن جلوی چشمش پاسخ می‌سازد.

این ایده در سال ۲۰۲۰ در مقاله‌ای از پژوهشگران Facebook AI (متای امروز) نام‌گذاری شد و امروز پایه اغلب چت‌بات‌های سازمانی، دستیارهای پشتیبانی و ابزارهای جست‌وجوی هوشمند است. اگر با سازوکار پایه مدل‌ها آشنا نیستید، اول مقاله مدل زبانی بزرگ چیست را بخوانید.

RAG یا فاین‌تیون؟

معیار RAG فاین‌تیون (Fine-tuning)
افزودن دانش تازه عالی؛ کافی است سند اضافه کنید پرهزینه؛ باید دوباره آموزش دهید
ارجاع به منبع دارد ندارد
هزینه شروع کم بالا
تغییر لحن و قالب خروجی محدود عالی

خلاصه اینکه: برای «دانستن»، RAG؛ برای «رفتار کردن»، فاین‌تیون.

معماری RAG در چهار مرحله

  1. تکه‌تکه کردن (Chunking): اسناد به قطعه‌های کوچک‌تر شکسته می‌شوند تا هر قطعه یک موضوع مشخص داشته باشد.
  2. جاسازی (Embedding): هر قطعه با یک مدل جاسازی به یک بردار عددی تبدیل می‌شود. متن‌های هم‌معنا بردارهای نزدیک به هم دارند؛ حتی اگر کلمه مشترکی نداشته باشند.
  3. بازیابی (Retrieval): سؤال کاربر هم به بردار تبدیل می‌شود و نزدیک‌ترین قطعه‌ها با شباهت کسینوسی پیدا می‌شوند.
  4. تولید (Generation): قطعه‌های پیداشده به‌عنوان «زمینه» همراه سؤال به مدل زبانی می‌روند و مدل پاسخ را با ارجاع به همان قطعه‌ها می‌نویسد.

مرحله ۱ و ۲ یک بار برای ساخت ایندکس انجام می‌شود؛ مرحله ۳ و ۴ برای هر سؤال.

آموزش RAG قدم‌به‌قدم با پایتون

پیش‌نیازها

از Ollama برای اجرای مدل‌ها روی سیستم خودمان استفاده می‌کنیم. اگر نصبش نکرده‌اید، آموزش Ollama را ببینید. سپس دو مدل لازم را دانلود کنید؛ یکی برای جاسازی و یکی برای تولید پاسخ:

ollama pull qwen3-embedding:0.6b
ollama pull qwen3.5:4b

مدل qwen3-embedding طبق صفحه‌اش در کتابخانه Ollama بیش از ۱۰۰ زبان را پشتیبانی می‌کند و نسخه ۰٫۶ میلیارد پارامتری آن حدود ۶۴۰ مگابایت حجم دارد. حالا کتابخانه‌های پایتون را نصب کنید (پایتون ۳٫۱۰ یا جدیدتر):

python -m venv .venv
source .venv/bin/activate      # Windows: .venv\Scripts\activate
pip install openai numpy

کد کامل

فایلی به نام rag.py بسازید:

import json
import os
import sys
from pathlib import Path

import numpy as np
from openai import OpenAI

client = OpenAI(
    base_url=os.getenv("LLM_BASE_URL", "http://localhost:11434/v1"),
    api_key=os.getenv("LLM_API_KEY", "ollama"),
)
EMBED_MODEL = os.getenv("EMBED_MODEL", "qwen3-embedding:0.6b")
CHAT_MODEL = os.getenv("CHAT_MODEL", "qwen3.5:4b")
INDEX_FILE = Path("index.json")
VECTORS_FILE = Path("vectors.npy")


def chunk_text(text: str, size: int = 800, overlap: int = 150) -> list[str]:
    """Split text into overlapping chunks of roughly `size` characters."""
    text = " ".join(text.split())  # normalize whitespace
    step = size - overlap
    chunks = []
    for start in range(0, len(text), step):
        chunk = text[start:start + size]
        if chunk.strip():
            chunks.append(chunk)
        if start + size >= len(text):
            break
    return chunks


def embed(texts: list[str], batch_size: int = 32) -> np.ndarray:
    """Return L2-normalized embedding vectors, one row per text."""
    vectors = []
    for i in range(0, len(texts), batch_size):
        response = client.embeddings.create(
            model=EMBED_MODEL,
            input=texts[i:i + batch_size],
            encoding_format="float",
        )
        vectors.extend(item.embedding for item in response.data)
    matrix = np.array(vectors, dtype=np.float32)
    return matrix / np.linalg.norm(matrix, axis=1, keepdims=True)


def build_index(docs_dir: str) -> None:
    records = []
    for path in sorted(Path(docs_dir).rglob("*")):
        if path.suffix.lower() not in {".md", ".txt"}:
            continue
        text = path.read_text(encoding="utf-8")
        for n, chunk in enumerate(chunk_text(text)):
            records.append({"source": f"{path.name}#{n}", "text": chunk})
    if not records:
        sys.exit(f"No .md or .txt files found in {docs_dir}")

    vectors = embed([r["text"] for r in records])
    np.save(VECTORS_FILE, vectors)
    INDEX_FILE.write_text(json.dumps(records, ensure_ascii=False), encoding="utf-8")
    print(f"Indexed {len(records)} chunks from {docs_dir}")


def search(question: str, k: int = 4) -> list[dict]:
    records = json.loads(INDEX_FILE.read_text(encoding="utf-8"))
    vectors = np.load(VECTORS_FILE)
    # Qwen3-Embedding works best when queries carry a short task instruction.
    query = f"Instruct: Given a question, retrieve passages that answer it\nQuery: {question}"
    q = embed([query])[0]
    scores = vectors @ q  # cosine similarity, since all vectors are normalized
    top = np.argsort(-scores)[:k]
    return [{**records[i], "score": float(scores[i])} for i in top]


SYSTEM_PROMPT = """You answer questions using ONLY the numbered context passages.
Cite passages like [1] or [2] after each claim.
If the answer is not in the context, say you could not find it in the documents.
Answer in the same language as the question."""


def answer(question: str) -> str:
    hits = search(question)
    context = "\n\n".join(f"[{i}] ({h['source']})\n{h['text']}" for i, h in enumerate(hits, 1))
    completion = client.chat.completions.create(
        model=CHAT_MODEL,
        temperature=0.2,
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": f"Context:\n{context}\n\nQuestion: {question}"},
        ],
    )
    sources = ", ".join(f"[{i}] {h['source']} ({h['score']:.2f})" for i, h in enumerate(hits, 1))
    return f"{completion.choices[0].message.content}\n\nSources: {sources}"


if __name__ == "__main__":
    sys.stdout.reconfigure(encoding="utf-8")  # Persian output on Windows consoles
    if len(sys.argv) >= 3 and sys.argv[1] == "index":
        build_index(sys.argv[2])
    elif len(sys.argv) >= 3 and sys.argv[1] == "ask":
        print(answer(" ".join(sys.argv[2:])))
    else:
        print('Usage: python rag.py index <docs_dir> | python rag.py ask "<question>"')

اجرا

چند فایل .md یا .txt در پوشه‌ای به نام docs بگذارید؛ مثلاً آیین‌نامه‌های داخلی یا مستندات یک پروژه. بعد ایندکس را بسازید و سؤال بپرسید:

python rag.py index docs
python rag.py ask "سقف مرخصی سالانه کارکنان چند روز است؟"

خروجی شامل پاسخ مدل با ارجاع‌هایی مثل [1] و در انتها فهرست قطعه‌های استفاده‌شده با امتیاز شباهت‌شان است. همین ارجاع‌ها مهم‌ترین مزیت RAG‌اند: کاربر می‌تواند ادعای مدل را با منبع تطبیق دهد.

کد چطور کار می‌کند؟

تکه‌تکه کردن با هم‌پوشانی

تابع chunk_text متن را به قطعه‌های ۸۰۰ کاراکتری می‌شکند که ۱۵۰ کاراکتر با هم هم‌پوشانی دارند. هم‌پوشانی باعث می‌شود جمله‌ای که روی مرز دو قطعه افتاده، کامل در دست‌کم یکی از آن‌ها باشد. اندازه قطعه یک مصالحه است: قطعه بزرگ زمینه بیشتری دارد اما دقت جست‌وجو را پایین می‌آورد.

نرمال‌سازی بردارها

در تابع embed همه بردارها را به طول ۱ نرمال می‌کنیم. با این کار ضرب داخلی دو بردار دقیقاً برابر شباهت کسینوسی آن‌ها می‌شود و جست‌وجو فقط یک ضرب ماتریسی است: vectors @ q. برای چند هزار قطعه، این کار روی یک لپ‌تاپ معمولی در کسری از ثانیه انجام می‌شود.

پارامتر encoding_format="float" را عمداً صریح گذاشته‌ایم تا کد با همه سرویس‌های سازگار با OpenAI، از جمله Ollama، بدون دردسر کار کند.

دستورالعمل جست‌وجو

مدل‌های خانواده Qwen3-Embedding وقتی بهتر کار می‌کنند که پیش از پرسش، یک دستورالعمل کوتاه درباره نوع کار بیاید. این پیشوند فقط روی سؤال اعمال می‌شود، نه روی اسناد.

پرامپت سیستمی سخت‌گیر

پرامپت سیستمی به مدل می‌گوید فقط از زمینه استفاده کند، برای هر ادعا ارجاع بیاورد و اگر پاسخ در اسناد نیست، صادقانه بگوید. دمای پایین (0.2) هم خلاقیت نالازم را کم می‌کند. همین چند خط، نرخ توهم را به‌طور محسوسی پایین می‌آورد.

اجرا روی سرویس ابری

چون کد با SDK رسمی OpenAI نوشته شده، برای استفاده از یک سرویس ابری کافی است متغیرهای محیطی را عوض کنید:

export LLM_BASE_URL="https://api.openai.com/v1"
export LLM_API_KEY="sk-..."
export EMBED_MODEL="text-embedding-3-small"
export CHAT_MODEL="gpt-6-luna"

دقت کنید که پیشوند Instruct: مخصوص Qwen3-Embedding است؛ با مدل جاسازی دیگر، بهتر است آن را حذف کنید. همچنین هر بار که مدل جاسازی را عوض می‌کنید، ایندکس را از نو بسازید؛ بردارهای دو مدل مختلف با هم قابل مقایسه نیستند.

از نمونه آموزشی تا محصول واقعی

کدی که نوشتیم برای یادگیری و پروژه‌های کوچک کافی است. برای یک سیستم جدی، این موارد را اضافه کنید:

  • تکه‌تکه کردن هوشمند: به‌جای شمارش کاراکتر، متن را بر اساس تیترها، پاراگراف‌ها یا ساختار سند بشکنید و عنوان بخش را به هر قطعه ضمیمه کنید.
  • جست‌وجوی ترکیبی (Hybrid Search): جست‌وجوی برداری در کدهای خطا، شناسه‌ها و نام‌های خاص ضعیف است. ترکیب آن با جست‌وجوی کلیدواژه‌ای مثل BM25 نتیجه را به‌طور محسوسی بهتر می‌کند.
  • ری‌رنکر (Reranker): اول ۲۰ تا ۵۰ قطعه را با جست‌وجوی سریع بیاورید و بعد با یک مدل ری‌رنکر، دقیق‌ترین‌ها را انتخاب کنید.
  • پایگاه داده برداری: وقتی حجم داده بالا رفت یا به فیلتر بر اساس متادیتا نیاز داشتید، سراغ ابزارهایی مثل pgvector (افزونه PostgreSQL)، Qdrant یا Chroma بروید.
  • کنترل دسترسی: اگر همه کاربران نباید همه اسناد را ببینند، فیلتر دسترسی را در مرحله بازیابی اعمال کنید، نه بعد از آن.
  • ارزیابی منظم: یک مجموعه ۵۰ تا ۱۰۰ سؤالی با پاسخ درست بسازید و بعد از هر تغییر، کیفیت بازیابی و پاسخ را دوباره بسنجید.

خطاهای رایج در پیاده‌سازی RAG

قطعه‌های خیلی بزرگ یا خیلی کوچک: قطعه ۵۰ کلمه‌ای زمینه کافی ندارد و قطعه ۵ صفحه‌ای دقت جست‌وجو را از بین می‌برد. از حدود ۱۵۰ تا ۴۰۰ کلمه شروع کنید (۸۰۰ کاراکتر کد ما تقریباً ۱۵۰ کلمه فارسی است) و با داده خودتان تنظیمش کنید.

نادیده گرفتن کیفیت متن ورودی: PDFهای اسکن‌شده، جدول‌های به‌هم‌ریخته و متن فارسی با نویسه‌های عربی (مثل «ي» و «ك» به‌جای «ی» و «ک») کیفیت جست‌وجو را خراب می‌کنند. پیش از ایندکس، متن را پاک‌سازی و یکدست کنید.

اعتماد کامل به اسناد: متن اسناد هم به مدل می‌رود. اگر سندی از منبع نامطمئن باشد، ممکن است دستورهای مخرب (Prompt Injection) در آن جاسازی شده باشد. منابع ایندکس را کنترل کنید.

جمع‌بندی

RAG ساده‌ترین و مقرون‌به‌صرفه‌ترین راه برای وصل کردن مدل‌های زبانی به دانش اختصاصی شماست. با همین کد می‌توانید امروز یک دستیار پرسش‌وپاسخ روی مستندات تیمتان بسازید و بعد قدم‌به‌قدم آن را حرفه‌ای‌تر کنید. قدم بعدی؟ تبدیل این دستیار به یک ایجنت که بتواند ابزار صدا بزند؛ موضوعی که در مقاله پروتکل MCP به زبان ساده سراغش رفته‌ایم.

مطالب مرتبط