ساخت چتبات RAG روی اسناد فارسی؛ از تکهبندی تا پاسخ
یک پوشه دارید با صد فایل PDF: آییننامهٔ داخلی، دفترچهٔ راهنمای محصول، سؤالات پرتکرار پشتیبانی. میخواهید کارمند تازهوارد بهجای گشتن در پوشه، سؤالش را فارسی بپرسد و جواب بگیرد. اولین ایدهای که به ذهن همه میرسد این است که کل متن را داخل پرامپت بگذاریم، و همانجا هم به دیوار میخوریم: پنجرهٔ مدل پر میشود، هزینه چند برابر میشود و کیفیت پاسخ پایین میآید. راهحل درست، RAG است؛ یعنی بهجای فرستادن همهچیز، فقط چند تکهٔ مرتبط را پیدا کنیم و همانها را به مدل بدهیم. در این مقاله خط لولهٔ کامل را میسازیم و روی نکاتی تمرکز میکنیم که فقط در فارسی پیش میآید.
RAG مخفف «تولید همراه با بازیابی» است و پنج مرحله دارد که هر کدام میتواند مستقل خراب شود: ورود سند (متن را از فایل بیرون بکش)، تکهبندی (متن را به قطعههای کوچک و معنادار بشکن)، امبدینگ (هر قطعه را به یک بردار عددی تبدیل کن)، بازیابی (برای هر پرسش، نزدیکترین قطعهها را پیدا کن) و تولید پاسخ (قطعهها را به مدل بده و بخواه فقط بر اساس آنها جواب بدهد).
یک نکتهٔ مهم پیش از شروع: RAG جادو نیست. اگر بازیابی تکههای غلط را بیاورد، بهترین مدل دنیا هم پاسخ غلط میسازد. تجربهٔ عملی میگوید بیش از هشتاد درصد کیفیت خروجی در سه مرحلهٔ اول تعیین میشود، نه در انتخاب مدل. به همین دلیل بیشتر این مقاله دربارهٔ آمادهسازی متن است و نه دربارهٔ پرامپت.
مرحلهٔ اول: بیرون کشیدن متن از PDF فارسی
اینجا اولین جایی است که فارسی اذیت میکند. فایلهای PDF فارسی سه دستهٔ کاملاً متفاوت هستند و برای هرکدام ابزار جدا لازم دارید:
- PDF متنی سالم: فایلی که از Word یا InDesign خروجی گرفته شده. متن قابل انتخاب است و کتابخانههایی مثل PyMuPDF آن را درست بیرون میکشند.
- PDF متنی با ترتیب بههمریخته: متن قابل انتخاب است اما چون سند راستبهچپ بوده، ترتیب کاراکترها یا جای اعداد و علائم جابهجا میشود. اینجا باید خروجی چند صفحهٔ نمونه را حتماً با چشم ببینید، نه اینکه به کتابخانه اعتماد کنید.
- PDF اسکنشده: هیچ متنی داخلش نیست، فقط تصویر. اینجا نیاز به OCR فارسی دارید یا میتوانید هر صفحه را بهعنوان تصویر به یک مدل ویژن بدهید و متن را بگیرید.
import fitz # pip install pymupdf
def extract_pages(path):
"""متن هر صفحه را جدا نگه میداریم تا بعداً بتوانیم منبع را به کاربر نشان دهیم."""
document = fitz.open(path)
pages = []
for number, page in enumerate(document, start=1):
text = page.get_text("text")
if text.strip():
pages.append({"source": f"{path} صفحهٔ {number}", "text": text})
return pages
توجه کنید که شمارهٔ صفحه را از همینجا نگه میداریم. اگر بعداً بخواهید در پاسخ چتبات بنویسید «طبق صفحهٔ ۱۴ دفترچهٔ راهنما»، باید این اطلاعات را از ابتدای مسیر حمل کنید؛ اضافهکردنش در مراحل بعد تقریباً غیرممکن است.
مرحلهٔ دوم: نرمالسازی متن فارسی
متن فارسی روی وب و در اسناد اداری به شکلهای مختلفی نوشته میشود و همین تنوع، بازیابی را خراب میکند. سه مشکل تکراری داریم:
- حرفهای عربی بهجای فارسی: «ي» و «ك» عربی در بسیاری از فایلها بهجای «ی» و «ک» فارسی نشستهاند. برای انسان یکسان دیده میشوند، برای ماشین دو کاراکتر متفاوتاند.
- نیمفاصله یا همان ZWNJ: کاراکتر نامرئی
U+200C. «میرود»، «می رود» و «میرود» سه رشتهٔ متفاوتاند. هدف نرمالسازی این است که نوشتار یکدست شود، نه اینکه نیمفاصله حذف شود؛ حذف کامل آن معنی بعضی کلمات را خراب میکند. - اعراب و کاراکترهای جهتدهی: فتحه و کسره و همچنین علامتهای
U+200EوU+200Fکه ویرایشگرها اضافه میکنند و هیچ ارزش معنایی ندارند.
import re
ZWNJ = ""
def normalize_fa(text: str) -> str:
text = text.replace("ي", "ی").replace("ك", "ک")
text = text.replace("", "").replace("", "")
text = re.sub(r"[ً-ْٰ]", "", text) # حذف اعراب
text = re.sub(ZWNJ + "+", ZWNJ, text) # یکدستکردن نیمفاصله
text = re.sub(r"[ \t ]+", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
یک هشدار مهم: وسوسه نشوید که ریشهیابی سنگین انجام دهید یا همهٔ کلمات را به شکل پایه برگردانید. مدلهای امبدینگ امروزی خودشان با شکل طبیعی کلمه بهتر کار میکنند و پیشپردازش افراطی معمولاً کیفیت بازیابی را پایین میآورد. نرمالسازی باید فقط نویز را بردارد، نه معنا را.
مرحلهٔ سوم: تکهبندی؛ مهمترین تصمیم کل پروژه
تکهبندی یعنی شکستن متن به قطعاتی که هرکدام یک واحد معنایی کامل باشند. دو خطای رایج وجود دارد: تکههای خیلی کوچک که جمله را از زمینهاش جدا میکنند، و تکههای خیلی بزرگ که چند موضوع نامرتبط را در یک بردار مخلوط میکنند و بازیابی را کور میکنند.
قاعدهٔ عملی برای فارسی: بین ۶۰۰ تا ۱٬۰۰۰ کاراکتر با یک جملهٔ همپوشانی بین تکههای پشتسرهم. همپوشانی برای این است که وقتی یک مفهوم دقیقاً روی مرز دو تکه میافتد، کاملاً گم نشود. همچنین باید روی مرز جمله بشکنید نه وسط جمله؛ در فارسی علاوه بر نقطه، علامت سؤال و علامت تعجب هم مرز جملهاند.
def split_sentences(text: str):
parts = re.split(r"(?<=[.؟!])\s+|\n+", text)
return [p.strip() for p in parts if p.strip()]
def chunk_text(text: str, max_chars: int = 900, overlap_sentences: int = 1):
sentences = split_sentences(normalize_fa(text))
chunks, current, size = [], [], 0
for sentence in sentences:
if size + len(sentence) > max_chars and current:
chunks.append(" ".join(current))
current = current[-overlap_sentences:] if overlap_sentences else []
size = sum(len(s) + 1 for s in current)
current.append(sentence)
size += len(sentence) + 1
if current:
chunks.append(" ".join(current))
return chunks
در اسناد فارسی معمولاً متن انگلیسی هم قاطی است: نام محصول، کد خطا، آدرس ایمیل. آنها را دستنخورده بگذارید. کاربر دقیقاً همانها را جستجو میکند و اگر در نرمالسازی خرابشان کنید، بازیابی روی مهمترین پرسشها شکست میخورد.
مرحلهٔ چهارم: امبدینگ و ذخیرهسازی
هر تکه را به یک بردار عددی تبدیل میکنیم تا بتوانیم «شباهت معنایی» را حساب کنیم. در این مقاله عمداً وارد جزئیات نمیشویم؛ انتخاب مدل امبدینگ، تعداد ابعاد، ذخیرهسازی در PostgreSQL با pgvector و روش سنجش کیفیت بازیابی را بهطور کامل در راهنمای امبدینگ فارسی و جستجوی معنایی نوشتهایم. اینجا فقط حداقل لازم برای کارکردن خط لوله را میآوریم:
import numpy as np
from openai import OpenAI
client = OpenAI(base_url="https://netarz.ir/api/ai/v1", api_key="sk-ntz-v1-...")
def embed(texts):
response = client.embeddings.create(model="text-embedding-3-small", input=texts)
return [item.embedding for item in response.data]
def build_index(chunks):
"""برای نمونهٔ کوچک، ایندکس در حافظه کافی است."""
vectors = np.array(embed(chunks), dtype=np.float32)
vectors /= np.linalg.norm(vectors, axis=1, keepdims=True)
return vectors
برای چند هزار تکه، همین ایندکس در حافظه کافی است. از چند ده هزار تکه به بالا باید سراغ یک پایگاه داده با پشتیبانی برداری بروید؛ اگر روی سرور خودتان کار میکنید، PostgreSQL با افزونهٔ pgvector سادهترین و پایدارترین گزینه است و روی یک سرور مجازی معمولی هم بهخوبی اجرا میشود.
مرحلهٔ پنجم: بازیابی و ساخت پرامپت
برای هر پرسش، پرسش را هم امبد میکنیم و نزدیکترین تکهها را برمیداریم. عدد k معمولاً بین سه تا هشت است؛ کمتر از سه ریسک جا انداختن پاسخ دارد و بیشتر از هشت هم هزینه را بالا میبرد و هم مدل را گیج میکند.
def retrieve(question, chunks, index, k=5):
query = np.array(embed([normalize_fa(question)])[0], dtype=np.float32)
query /= np.linalg.norm(query)
scores = index @ query
best = np.argsort(-scores)[:k]
return [{"text": chunks[i], "score": float(scores[i])} for i in best]
نکتهٔ کلیدی در ساخت پرامپت این است که به مدل صریح بگویید فقط از متن مرجع استفاده کند و اگر پاسخ آنجا نبود، صادقانه بگوید نمیداند. این تنها راه عملی برای کمکردن پاسخهای ساختگی است. اگر با نوشتن دستور سیستمی راحت نیستید، راهنمای پرامپتنویسی نقطهٔ شروع خوبی است.
SYSTEM = (
"تو دستیار پشتیبانی هستی و فقط بر اساس «متن مرجع» پاسخ میدهی. "
"اگر پاسخ در متن مرجع نبود، صریح بگو در مستندات پیدا نشد و حدس نزن. "
"در پایان شمارهٔ منبعهایی را که استفاده کردی داخل کروشه بیاور."
)
def answer(question, hits):
context = "\n\n".join(
"[" + str(i + 1) + "] " + hit["text"] for i, hit in enumerate(hits)
)
response = client.chat.completions.create(
model="gpt-5-mini",
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": "متن مرجع:\n" + context + "\n\nپرسش: " + question},
],
)
return response.choices[0].message.content
همین منطق در سمت Node، برای وقتی که چتبات را داخل سایت خودتان اجرا میکنید:
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://netarz.ir/api/ai/v1",
apiKey: process.env.NETARZ_AI_KEY,
});
export async function answer(question, hits) {
const context = hits.map((hit, i) => `[${i + 1}] ${hit.text}`).join("\n\n");
const completion = await client.chat.completions.create({
model: "gpt-5-mini",
messages: [
{ role: "system", content: SYSTEM },
{ role: "user", content: `متن مرجع:\n${context}\n\nپرسش: ${question}` },
],
});
return completion.choices[0].message.content;
}
هزینهٔ هر پرسش را چطور حساب کنیم؟
خبر خوب این است که گرانترین بخش RAG، یعنی امبدینگ کل اسناد، فقط یک بار انجام میشود. جدول زیر مصرف تقریبی یک پرسش را در یک نمونهٔ واقعی نشان میدهد؛ فرض کردهایم دویست صفحه سند فارسی دارید و برای هر پرسش پنج تکه بازیابی میشود:
| مرحله | چه چیزی پردازش میشود | توکن تقریبی | تکرار | سهم از هزینه |
|---|---|---|---|---|
| امبدینگ اسناد | کل متن دویست صفحه | حدود ۱۵۰٬۰۰۰ | یکبار در هر بهروزرسانی | ناچیز؛ نرخ رسمی مدلهای امبدینگ حدود دو صدم دلار برای هر یک میلیون توکن است |
| امبدینگ پرسش | یک جملهٔ کاربر | حدود ۳۰ | هر پرسش | عملاً صفر |
| ورودی تولید پاسخ | پنج تکه بهعلاوهٔ دستور سیستمی | حدود ۲٬۰۰۰ تا ۳٬۰۰۰ | هر پرسش | بیشترین سهم |
| خروجی تولید پاسخ | پاسخ نهایی | حدود ۱۵۰ تا ۴۰۰ | هر پرسش | گرانتر از ورودی به ازای هر توکن |
برای رسیدن به عدد ریالی، همین توکنها را در نرخ مدل انتخابی ضرب کنید؛ نرخ لحظهای هر مدل در صفحهٔ مدلهای نِت اَرز منتشر میشود و روش محاسبه را قدمبهقدم در راهنمای محاسبهٔ توکن توضیح دادهایم. یک نکتهٔ فارسی را فراموش نکنید: متن فارسی به ازای هر کلمه توکن بیشتری از انگلیسی مصرف میکند، پس در برآورد بودجه حاشیهٔ اطمینان بگذارید.
چه چیزهایی خراب میشود و چطور بفهمیم
- پاسخهای ساختگی: معمولاً یعنی بازیابی چیز مرتبطی پیدا نکرده و مدل خودش را نجات داده است. امتیاز شباهت را چاپ کنید؛ اگر بهترین تکه امتیاز پایینی داشت، بهتر است اصلاً به مدل نروید و بگویید پاسخ در مستندات نیست.
- جواب ناقص: تکهها کوچکاند یا k کم است. اندازهٔ تکه را کمی بالا ببرید و همپوشانی را نگه دارید.
- سردرگمی روی جدولها: جدولهای PDF پس از استخراج معمولاً به هم میریزند. جدولهای مهم را دستی به متن ساده تبدیل کنید؛ همین یک کار کیفیت پاسخ اسناد اداری را بهشکل محسوسی بالا میبرد.
- خطای طول زمینه: اگر k را زیاد کردید ممکن است به سقف پنجرهٔ مدل بخورید. علت و درمانش را در راهنمای خطاهای رایج API آوردهایم.
برای سنجش جدی، سی تا پنجاه پرسش واقعی کاربران را جمع کنید، پاسخ درست هرکدام را دستی مشخص کنید و بعد از هر تغییر در تکهبندی، همان مجموعه را دوباره اجرا کنید. بدون این مجموعهٔ سنجش، هر تغییری فقط حدس است.
از این نمونه تا یک سرویس واقعی
نمونهٔ بالا کامل است و اجرا میشود، اما برای محیط عملیاتی سه چیز کم دارد: یک صف برای پردازش فایلهای جدید، کش کردن امبدینگ پرسشهای تکراری، و لاگ کردن اینکه هر پاسخ از کدام تکهها ساخته شده تا بتوانید خطاها را ردیابی کنید. اگر میخواهید همین را بهعنوان ربات پیامرسان بالا بیاورید، ساختار کد در راهنمای ساخت ربات تلگرام با پایتون آمده است.
برای اجرای هر دو مرحلهٔ امبدینگ و پاسخ، به یک کلید API نیاز دارید. مستندات فنی و نمونهٔ درخواستها در مستندات API نِت اَرز است و اعتبار حساب را میتوانید از صفحهٔ افزایش اعتبار وبسرویس ChatGPT با قیمت لحظهای همان صفحه شارژ کنید. پیشنهاد ما این است که اول با یک پوشهٔ دهفایلی شروع کنید و تا وقتی پاسخها روی همان ده فایل قابل اعتماد نشده، سراغ کل آرشیو نروید.
پرسشهای پرتکرار
چرا کل سند را داخل پرامپت نگذارم و کار را ساده نکنم؟
برای اسناد کوچک این کار شدنی است، اما با چند ده صفحه به سقف پنجرهٔ مدل میخورید و هزینهٔ هر پرسش چند برابر میشود. مهمتر اینکه دقت پاسخ روی متنهای خیلی بلند پایین میآید، چون مدل باید بین حجم زیادی متن نامرتبط دنبال جواب بگردد.
اندازهٔ مناسب هر تکه برای متن فارسی چقدر است؟
در عمل بین ۶۰۰ تا ۱۰۰۰ کاراکتر با یک جمله همپوشانی نتیجهٔ خوبی میدهد. تکهٔ خیلی کوچک جمله را از زمینهاش جدا میکند و تکهٔ خیلی بزرگ چند موضوع را با هم مخلوط میکند؛ بهترین کار این است که دو سه مقدار را روی مجموعهٔ پرسشهای واقعی خودتان امتحان کنید.
با PDF اسکنشدهٔ فارسی چه کنم؟
این فایلها متن ندارند و فقط تصویرند، بنابراین استخراج معمولی روی آنها خروجی خالی میدهد. باید از OCR فارسی استفاده کنید یا هر صفحه را بهعنوان تصویر به یک مدل ویژن بدهید و متن را بگیرید؛ در هر دو حالت خروجی چند صفحهٔ نمونه را حتماً با چشم بازبینی کنید.
چطور جلوی پاسخهای ساختگی چتبات را بگیرم؟
دو کار با هم لازم است: در دستور سیستمی صریح بگویید فقط از متن مرجع استفاده کند و در صورت نبود پاسخ بگوید نمیداند، و در کد یک حد آستانه روی امتیاز شباهت بگذارید. اگر بهترین تکهٔ بازیابیشده امتیاز پایینی داشت، اصلاً درخواست تولید پاسخ نفرستید.
نظر خوانندگان
هنوز نظری ثبت نشده. اگر این مقاله پرسشتان را جواب داد یا جای چیزی در آن خالی ماند، همینجا بنویسید.