بازیابی و تولید
RAG روی دادههای فارسی — از بازیابی تا پاسخ
خط لولهی RAG از chunking تا تولید پاسخ، جایی که در عمل میشکند، و اینکه چه چیزی در دادهی فارسی سختتر است: مرز تکهها، نرمالسازی و متن دوجهته.
تیمی که میخواهد روی مستندات داخلیاش یک دستیار پرسشوپاسخ بسازد، معمولاً نسخهی اول را در یک بعدازظهر بالا میآورد: متنها را تکهتکه میکند، برای هر تکه embedding میگیرد، در یک vector store میریزد و نزدیکترین تکهها را کنار پرسش به مدل زبانی میدهد. روی ده سؤال نمونه جواب میدهد. بعد همان سیستم را روی آرشیو واقعی فارسی — بخشنامه، صورتجلسه، مستند فنی — امتحان میکند و کیفیت میریزد. RAG خراب نشده است؛ فرضهایی که پشت تنظیمات پیشفرض بود برای دادهی فارسی درست نیست.
این مقاله خط لولهی RAG را مرحلهبهمرحله باز میکند، نشان میدهد هر مرحله در عمل کجا میشکند، و بعد روی چیزهایی تمرکز میکند که مخصوص زبان فارسی است.
RAG چه چیزی را حل میکند
مدل زبانی چیزی را که در دادهی آموزشش نبوده نمیداند، و پنجرهی زمینه (context window) هم آنقدر بزرگ نیست که کل آرشیو سازمان را در هر درخواست بفرستید — و اگر هم باشد، هزینهاش منطقی نیست. RAG یعنی بهجای آموزش دوبارهی مدل، در لحظهی پرسش چند تکهی مرتبط از منبع خودتان را پیدا کنید و همراه پرسش در prompt بگذارید. مدل پاسخ را از روی همان متن میسازد.
یک نتیجهی مستقیم از این تعریف بیرون میآید که اغلب نادیده گرفته میشود: کیفیت RAG سقفی دارد که مرحلهی بازیابی تعیین میکند. اگر تکهی درست اصلاً به prompt نرسد، هیچ مدلی — گران یا ارزان — نمیتواند جواب درست بدهد. بیشتر وقتی که تیمها صرف عوض کردن مدل میکنند، باید صرف بازیابی شود.
خط لوله پنج مرحله دارد:
- chunking — بریدن سند به تکههای قابل بازیابی
- embedding — تبدیل هر تکه به یک بردار عددی
- retrieval — پیدا کردن نزدیکترین بردارها به بردار پرسش
- reranking — مرتبسازی دوبارهی نتایج با یک مدل دقیقتر و کندتر
- generation — ساختن پاسخ از روی تکههای انتخابشده
chunking؛ جایی که بیشترین خرابی اتفاق میافتد
تکهبندی سادهترین مرحله به نظر میرسد و در عمل بیشترین سهم را در خطا دارد. تکه باید دو شرط متضاد را همزمان برآورده کند: بهقدر کافی کوچک باشد که بردارش یک معنای مشخص را نمایندگی کند، و بهقدر کافی بزرگ باشد که پاسخ کامل داخلش جا شود. اگر تکهها ریز باشند، جملهی حاوی جواب از شرطش جدا میافتد. اگر درشت باشند، بردار میانگینِ چند موضوع میشود و در جستوجو گم میشود.
مشکل معمول این است که اندازهی پیشفرض کتابخانهها بر حسب کاراکتر یا token تنظیم شده و روی متن انگلیسی کالیبره شده است. متن فارسی در همان تعداد token اطلاعات کمتری حمل میکند، چون tokenizerها روی خط فارسی کارآمد نیستند و هر کلمه به چند token شکسته میشود. یعنی یک تکهی «۵۱۲ توکنی» فارسی از نظر محتوایی کوتاهتر از معادل انگلیسیاش است. اگر عددها را دستنخورده از یک آموزش انگلیسی بردارید، تکههایتان از آنچه فکر میکنید کممحتواتر خواهند بود.
مرز تکه هم در فارسی سرراست نیست. جداکنندهی جمله در متن فارسی همیشه نقطه نیست؛ متن اداری و حقوقی پر از جملههای طولانی با «و»، «که» و ویرگول فارسی (،) است و گاهی یک بند کامل بدون نقطه تمام میشود. جداکنندهی مبتنی بر \n\n روی PDFهای تبدیلشده به متن هم بیاعتماد است، چون شکستن خط اغلب از چیدمان صفحه میآید نه از ساختار پاراگراف.
چند قاعدهی عملی:
- اول بر اساس ساختار سند ببرید — عنوان، ماده، بند، جدول — و فقط وقتی ساختار نبود به شمارش token برگردید.
- همپوشانی (overlap) بگذارید تا جملهای که روی مرز افتاده در یکی از دو تکه کامل بماند.
- عنوان بخش و عنوان سند را به ابتدای هر تکه بچسبانید. این ارزانترین کاری است که میتوانید بکنید و اثرش روی بازیابی محسوس است، چون تکه دیگر بیزمینه نیست.
- اندازه را روی دادهی خودتان تنظیم کنید، نه روی مقدار پیشفرض.
نرمالسازی متن فارسی
فارسی چند شکل نوشتاری برای یک چیز دارد و این مستقیماً به بازیابی آسیب میزند. حرف «ی» گاهی با یای فارسی (U+06CC) و گاهی با یای عربی (U+064A) تایپ میشود؛ «ک» گاهی کاف فارسی (U+06A9) و گاهی کاف عربی (U+0643) است. نیمفاصله (zero-width non-joiner، U+200C) در «میرود» گاهی فاصلهی معمولی است، گاهی اصلاً نیست. ارقام هم سه شکل دارند: لاتین، عربی (U+0660–U+0669) و فارسی (U+06F0–U+06F9).
برای مدل embedding اینها رشتههای متفاوتی هستند. برای جستوجوی کلیدواژهای فاجعهاند: کاربر «کارگزینی» با کاف فارسی مینویسد و سند با کاف عربی ذخیره شده و تطبیق دقیق شکست میخورد.
راهحل یک مرحلهی نرمالسازی است که دقیقاً یکسان روی اسناد هنگام ایندکس و روی پرسش هنگام جستوجو اجرا شود. اگر این دو با هم فرق کنند، همان مشکل را از در دیگر برگرداندهاید.
import re
def normalize_fa(text: str) -> str:
# یکسانسازی حروف عربی به فارسی
text = text.replace("\u064A", "\u06CC") # ي -> ی
text = text.replace("\u0643", "\u06A9") # ك -> ک
# حذف اعراب و کشیدگی
text = re.sub(r"[\u064B-\u0652\u0640]", "", text)
# یکسانسازی ارقام عربی و فارسی به لاتین
for i, d in enumerate("\u0660\u0661\u0662\u0663\u0664\u0665\u0666\u0667\u0668\u0669"):
text = text.replace(d, str(i))
for i, d in enumerate("\u06F0\u06F1\u06F2\u06F3\u06F4\u06F5\u06F6\u06F7\u06F8\u06F9"):
text = text.replace(d, str(i))
# فاصلههای تکراری و نیمفاصلههای اضافه
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\u200c{2,}", "\u200c", text)
return text.strip()
نکتهی مهم دربارهی نیمفاصله: آن را کورکورانه حذف نکنید. حذف نیمفاصله «میرود» را به «میرود» تبدیل میکند و تبدیلش به فاصله آن را به «می رود» میشکند؛ هر دو ممکن است با شکل رایج در بقیهی دادهتان ناسازگار باشند. یک تصمیم بگیرید و همهجا — ایندکس، پرسش، و متن نمایشدادهشده به کاربر — همان را اعمال کنید. کتابخانههایی مثل hazm و parsivar این کار را آماده دارند؛ اگر از آنها استفاده میکنید، نسخه را قفل کنید تا رفتار نرمالسازی بین ایندکس و اجرا عوض نشود.
محتوای درهم RTL و LTR
مستندات فنی فارسی تقریباً همیشه دوزبانهاند: پاراگراف فارسی راستبهچپ با نام متغیر، مسیر فایل، پیام خطا و قطعهکد انگلیسی چپبهراست داخلش. این برای خط لوله دو دردسر میسازد.
اول، استخراج متن. وقتی PDF یا HTML را به متن خام تبدیل میکنید، ترتیب کاراکترها در بخشهای دوجهته ممکن است جابهجا شود و پرانتز و علامتها سر جای اشتباه بیفتند. متن حاصل برای چشم انسان تقریباً درست به نظر میرسد ولی برای تطبیق رشتهای خراب است. قبل از ساختن ایندکس، چند سند نمونه را خام نگاه کنید، نه در مرورگر.
دوم، تکهبندی. اگر جداکنندهتان فقط علائم فارسی را میشناسد، یک بلوک کد طولانی وسط سند به تکهی قبلی یا بعدی میچسبد و آن را از موضوع منحرف میکند. بلوکهای کد را جدا نگه دارید و بهعنوان تکهی مستقل — با عنوان بخشِ فارسی چسبیده به بالایشان — ایندکس کنید.
retrieval و reranking
بازیابی برداری تنها کافی نیست، مخصوصاً در فارسی که نامها و اصطلاحهای دقیق زیاد است. اگر کاربر شمارهی یک بخشنامه یا نام دقیق یک سامانه را بپرسد، شباهت معنایی ممکن است ده سند «شبیه» برگرداند که هیچکدام همان نیست. ترکیب جستوجوی کلیدواژهای با جستوجوی برداری — یعنی hybrid search — این حالت را پوشش میدهد.
reranking مرحلهای است که اغلب حذف میشود و نباید بشود. الگو ساده است: در مرحلهی اول تعداد نسبتاً زیادی نامزد را ارزان و سریع بیاورید، بعد با یک مدل دقیقتر که پرسش و متن را با هم میبیند، همانها را دوباره مرتب کنید و فقط چند تای بالا را به مدل بدهید. این کار هم دقت را بالا میبرد و هم prompt را کوتاه نگه میدارد.
مرحلهی تولید پاسخ
وقتی تکهها آماده شد، سه چیز را در prompt صریح کنید:
- مدل فقط از روی متن دادهشده جواب بدهد و اگر جواب در متن نیست، همین را بگوید. بدون این دستور، مدل خلأ را از دانش عمومیاش پر میکند و شما تفاوتش را نمیبینید.
- هر ادعا به تکهی منبعش ارجاع بدهد. ارجاع هم برای کاربر مفید است و هم به شما اجازه میدهد خطای بازیابی را از خطای تولید تفکیک کنید.
- ترتیب تکهها معنادار باشد؛ مرتبطترین را نزدیک انتهای prompt بگذارید.
چطور بفهمیم کار میکند
بدون سنجه، هر تغییری در chunk size یا مدل embedding فقط حدس است. حداقل کاری که باید بکنید این است که دو چیز را جدا اندازه بگیرید:
- بازیابی: برای مجموعهای از پرسشهای واقعی، تکهی درست جزو نتایج بازگرداندهشده هست یا نه. این را میشود بدون دخالت مدل زبانی سنجید.
- پاسخ: پاسخ نهایی درست و مستند به متن هست یا نه.
اگر بازیابی خراب باشد، بهبود مرحلهی تولید بیفایده است. تفکیک این دو، بیشتر بحثهای بینتیجهی تیمی را تمام میکند. روش ساختن یک مجموعهی ارزیابی کوچک و صادقانه را در مقالهی ارزیابی توضیح دادهایم.
RAG یک تکنیک نیست، یک خط لوله است. هر مرحله میتواند مستقل خراب شود و هر مرحله باید مستقل سنجیده شود.
جمعبندی عملی برای فارسی: نرمالسازی را جدی بگیرید و در ایندکس و پرسش یکسان اجرا کنید؛ اندازهی تکه را روی دادهی خودتان تنظیم کنید نه روی پیشفرض انگلیسی؛ عنوان را به تکه بچسبانید؛ جستوجوی کلیدواژهای را کنار برداری نگه دارید؛ و قبل از عوض کردن مدل زبانی، مطمئن شوید تکهی درست اصلاً به دست مدل میرسد.