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

فهرست مطالب
مدلهای زبانی درباره همهچیز حرف میزنند، جز چیزهایی که برای شما مهمتر است: مستندات داخلی تیم، آییننامههای شرکت یا دفترچه راهنمای محصولتان. RAG راهحل استاندارد این مشکل است. در این آموزش RAG را از صفر و با حدود ۱۰۰ خط پایتون پیاده میکنیم؛ کاملاً لوکال، رایگان و با کدی که با تغییر یک متغیر روی سرویسهای ابری هم اجرا میشود.
RAG چیست و چرا به آن نیاز داریم؟
تولید تقویتشده با بازیابی (Retrieval-Augmented Generation یا RAG) یعنی پیش از اینکه از مدل زبانی جواب بخواهیم، بخشهای مرتبط اسنادمان را پیدا کنیم و همراه سؤال به آن بدهیم. مدل دیگر از حافظهاش جواب نمیدهد؛ بلکه مثل یک دانشجو در امتحان کتابباز، از روی متن جلوی چشمش پاسخ میسازد.
این ایده در سال ۲۰۲۰ در مقالهای از پژوهشگران Facebook AI (متای امروز) نامگذاری شد و امروز پایه اغلب چتباتهای سازمانی، دستیارهای پشتیبانی و ابزارهای جستوجوی هوشمند است. اگر با سازوکار پایه مدلها آشنا نیستید، اول مقاله مدل زبانی بزرگ چیست را بخوانید.
RAG یا فاینتیون؟
| معیار | RAG | فاینتیون (Fine-tuning) |
|---|---|---|
| افزودن دانش تازه | عالی؛ کافی است سند اضافه کنید | پرهزینه؛ باید دوباره آموزش دهید |
| ارجاع به منبع | دارد | ندارد |
| هزینه شروع | کم | بالا |
| تغییر لحن و قالب خروجی | محدود | عالی |
خلاصه اینکه: برای «دانستن»، RAG؛ برای «رفتار کردن»، فاینتیون.
معماری RAG در چهار مرحله
- تکهتکه کردن (Chunking): اسناد به قطعههای کوچکتر شکسته میشوند تا هر قطعه یک موضوع مشخص داشته باشد.
- جاسازی (Embedding): هر قطعه با یک مدل جاسازی به یک بردار عددی تبدیل میشود. متنهای هممعنا بردارهای نزدیک به هم دارند؛ حتی اگر کلمه مشترکی نداشته باشند.
- بازیابی (Retrieval): سؤال کاربر هم به بردار تبدیل میشود و نزدیکترین قطعهها با شباهت کسینوسی پیدا میشوند.
- تولید (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 به زبان ساده سراغش رفتهایم.



