راهنمای جامع ساخت Semantic Search برای اسناد فارسی؛ از Chunking تا Retrieval
در این راهنمای عملی، معماری یک موتور جستجوی معنایی فارسی را از نرمالسازی متن و Chunking تا Hybrid Search، Reranking، ACL و Context Building بررسی میکنیم.
- جستجوی معنایی فارسی
- Semantic Search فارسی
- RAG فارسی
- Semantic Search Python
- Hybrid Search
- Vector Search

ساخت یک نمونه نمایشی از RAG معمولاً ساده است: یک فایل را میخوانیم، آن را به چند بخش تقسیم میکنیم، برای هر بخش embedding میسازیم و نزدیکترین بردارها به سؤال کاربر را پیدا میکنیم. اما فاصله میان همین دمو و یک موتور جستجوی قابل اتکا برای استفاده واقعی، بسیار بیشتر از چیزی است که در نگاه اول به نظر میرسد.
در توسعه ماژول Semantic Chunk Search v4 دقیقاً با همین مسئله روبهرو شدیم. هدف اولیه، جدا کردن لایه Chunking و Semantic Search از یک پروژه بزرگتر بود؛ اما خیلی زود مشخص شد که اگر بخواهیم جستجو روی اسناد فارسی، فایلهای PDF و Word، کد، جدول و دادههای سازمانی قابل اعتماد باشد، «Embedding + Cosine Similarity» کافی نیست.
این مقاله معماریای را توضیح میدهد که در نهایت به آن رسیدیم و مهمتر از خود معماری، دلیل وجود هر بخش را بررسی میکند.
Semantic Search دقیقاً چه مشکلی را حل میکند؟
جستجوی سنتی معمولاً بر تطابق کلمه متکی است. اگر کاربر عبارتی را دقیقاً با همان واژگانی که در سند آمده جستجو کند، این روش بسیار خوب عمل میکند. مشکل زمانی شروع میشود که مفهوم یکسان با کلمات متفاوت بیان شود.
برای مثال فرض کنید در سند نوشته شده باشد:
افزایش زمان پاسخ سرویس میتواند ناشی از اشباع Connection Pool باشد.
اما کاربر بپرسد:
چرا API بعضی وقتها کند میشود؟
یک موتور lexical ممکن است ارتباط قوی میان این دو متن پیدا نکند، در حالی که Semantic Search میتواند نزدیکی معنایی آنها را تشخیص دهد.
اما سناریوی معکوس هم وجود دارد. اگر کاربر ERR_DB-7312 را جستجو کند، شباهت معنایی اهمیت زیادی ندارد؛ کاربر همان شناسه دقیق را میخواهد. اینجا Exact Match و BM25 معمولاً از Vector Search بهتر هستند.
همین تضاد یکی از مهمترین درسهای ما بود: هیچ روش retrieval واحدی برای همه نوع سؤال بهترین نیست.
معماریای که در عمل به آن رسیدیم
Pipeline نهایی را میتوان به دو مسیر اصلی تقسیم کرد: ingestion و query.
در ingestion تقریباً چنین مراحلی داریم:
Source
-> Load
-> Normalize
-> Security Scan
-> Structural Parsing
-> Chunking
-> Metadata + Provenance
-> Enrichment
-> Embedding / Lexical / Exact indexes
-> Persistence
و هنگام جستجو:
Query
-> Normalize
-> Route / Decompose
-> Dense candidates
-> BM25F candidates
-> Exact-ID candidates
-> Optional sparse/graph/visual candidates
-> Fusion
-> Reranking
-> ACL / Policy
-> Diversity
-> Context Assembly
این معماری شاید برای یک Demo بزرگ به نظر برسد، اما هر مرحله در پاسخ به یک مشکل واقعی به وجود آمده است.
مرحله اول: نرمالسازی متن فارسی
اگر روی زبان فارسی کار میکنید، normalization یک قابلیت جانبی نیست؛ بخشی از retrieval است.
در دادههای واقعی ممکن است این موارد همزمان وجود داشته باشند:
یفارسی ويعربیکفارسی وكعربی- اعداد
۱۲۳،١٢٣و123 - نیمفاصله یا ZWNJ
- چند نمایش مختلف Unicode از یک متن ظاهراً یکسان
دو عبارت ممکن است برای چشم انسان کاملاً یکسان باشند، اما در سطح کدپوینت متفاوت باشند. این تفاوت میتواند Exact Search، tokenization و حتی بخشی از کیفیت embedding را تحت تأثیر قرار دهد.
در ماژول ما normalization فقط متن را تغییر نمیدهد؛ mapping میان offset متن نرمالشده و متن اصلی را نیز نگه میدارد. این نکته برای citation و نمایش محل اصلی پاسخ مهم است.
مرحله دوم: Chunking فقط بریدن متن نیست
اولین نسخههای بسیاری از سیستمهای RAG متن را هر ۵۰۰ یا ۱۰۰۰ کاراکتر میبرند. این روش برای شروع بد نیست، اما ساختار معنایی سند را نادیده میگیرد.
یک سند ممکن است شامل موارد زیر باشد:
- عنوان و زیرعنوان
- پاراگراف عادی
- جدول
- کد
- Markdown
- شماره خطا
- لیست
- بخشهای وابسته به یکدیگر
به همین دلیل ما چند strategy مختلف ساختیم، از جمله:
- Recursive Chunking
- Semantic Chunking
- Sliding Window
- Token-based Chunking
- Markdown-aware Chunking
- Code Chunking
- Table-aware Chunking
- Composite Chunking
مهمترین تصمیم این بود که strategy را متناسب با ساختار محتوا انتخاب کنیم، نه اینکه همه چیز را با یک الگوریتم ببریم.
اندازه Chunk چقدر باید باشد؟
پاسخ ثابت وجود ندارد.
Chunk خیلی بزرگ دو مشکل ایجاد میکند. اول اینکه embedding چند موضوع مختلف را در یک بردار مخلوط میکند. دوم اینکه هنگام ساخت context، بخش زیادی از token budget صرف متن غیرمرتبط میشود.
Chunk خیلی کوچک نیز مشکل دیگری دارد: معنای محلی از دست میرود و پاسخ ممکن است بدون context کافی بازیابی شود.
در معماری ما اندازه Chunk با token budget مدل embedding هماهنگ میشود. اگر embedding provider حداکثر ورودی مشخصی داشته باشد، chunk size نباید از آن عبور کند.
این مسئله در محیط واقعی مهم است؛ زیرا ممکن است Chunker از نظر خودش خروجی معتبر بسازد ولی مدل embedding ورودی را truncate کند. در این حالت بخشی از سند بدون اینکه متوجه شویم از representation حذف میشود.
چرا Multi-Representation Retrieval اضافه کردیم؟
یک Chunk را میتوان فقط با متن خام index کرد، اما ما به مرور representationهای دیگری نیز اضافه کردیم:
raw_textsummarykeywordstitlesreferences
چرا؟ چون نوع سؤال کاربران متفاوت است.
اگر سؤال درباره مفهوم باشد، متن خام و summary میتوانند مفید باشند. اگر کاربر اسم یک بخش را بداند، title representation اهمیت پیدا میکند. اگر شماره RFC، Ticket ID یا Error Code را جستجو کند، references ارزش بیشتری دارند.
در نتیجه retrieval از یک فضای تکبرداری به یک سیستم چندشاهدی تبدیل شد.
Dense Search کافی نیست
یکی از تصمیمهای کلیدی در v4 استفاده از Hybrid Retrieval بود.
Dense Search برای شباهت معنایی عالی است، اما مشکلاتی دارد:
- شناسههای دقیق
- شماره خطا
- نام متغیرها
- acronymها
- کلمات نادر
- Queryهایی که واژه دقیق بسیار مهم است
برای همین کنار Dense Search، یک BM25F field-aware lexical index و Exact Identifier Index قرار گرفت.
BM25F نسبت به BM25 ساده این مزیت را دارد که فیلدها را جداگانه وزن میدهد. برای مثال وجود واژه در title میتواند مهمتر از وجود آن در متن عادی باشد.
Fusion: وقتی چند موتور جواب متفاوت میدهند
وقتی Dense، BM25F، Exact و Sparse همزمان candidate تولید میکنند، باید نتایج آنها با هم ترکیب شوند.
یک روش خطرناک این است که score خام آنها را مستقیماً با هم جمع کنیم. Score مدل embedding با score BM25 مقیاس یکسانی ندارد.
برای حل این مسئله از Weighted Reciprocal Rank Fusion استفاده کردیم. در RRF رتبه یک نتیجه در هر retrieval channel مهمتر از score خام آن است. این کار fusion را نسبت به اختلاف مقیاس score مقاومتر میکند.
Reranking؛ مرحلهای که کیفیت را جدیتر میکند
Candidate generation باید سریع باشد. Reranking میتواند گرانتر ولی دقیقتر باشد.
در ماژول مسیرهای مختلفی در نظر گرفته شده است:
- Cross Encoder
- Token MaxSim
- Multi-vector Late Interaction
- Linear LTR
- Pairwise Linear LTR
قرار نیست همه deploymentها همه اینها را فعال کنند. برای یک سازمان کوچک ممکن است Dense + BM25F + Cross Encoder کاملاً کافی باشد.
اصل معماری این است که retrieval سریع، مجموعه کوچکی از candidateها را تولید کند و مدل دقیقتر فقط روی همان مجموعه اجرا شود.
ACL باید قبل از نمایش نتیجه اعمال شود؛ ولی حتی این کافی نیست
در سیستم سازمانی، relevance تنها معیار retrieval نیست. کاربر نباید سندی را ببیند که مجوز مشاهده آن را ندارد.
ما metadataهایی مانند اینها را در نظر گرفتیم:
tenant_idvisibilityownerallowed_usersallowed_groups
یکی از باگهای جدیای که در hardening پیدا شد این بود که اگر متن یک source تغییر نکند اما ACL آن تغییر کند، ingestion نباید نتیجه بگیرد «چیزی عوض نشده است» و metadata قدیمی را حفظ کند.
این تجربه یک قانون مهم را نشان داد:
Idempotent ingestion فقط درباره متن نیست؛ policy و metadata هم بخشی از state سند هستند.
Context Building: نزدیکترین Chunk لزوماً context نهایی نیست
Retrieval یک Chunk را پیدا میکند، اما مدل تولیدکننده ممکن است برای پاسخ درست به parent یا همسایههای آن نیاز داشته باشد.
برای همین context assembler اضافه شد که میتواند:
- خود hit را وارد context کند
- parent را اضافه کند
- neighborها را اضافه کند
- همه را زیر token budget نگه دارد
در hardening حتی یک bug ظریف پیدا شد: گاهی parent آنقدر بزرگ بود که token budget را مصرف میکرد و خود hit از context حذف میشد. سیاست نهایی تغییر کرد تا خود hit همیشه اول وارد context شود.
این نوع باگها معمولاً در معماری روی کاغذ دیده نمیشوند؛ در تست adversarial مشخص میشوند.
Persistence و Re-indexing
برای استفاده واقعی، index باید بتواند با تغییر sourceها کنار بیاید.
نیازهای مهم عبارتاند از:
- re-ingest امن
- source replacement
- source deletion
- rollback
- restart recovery
- feedback persistence
در نسخه فعلی SQLite با WAL برای local durable store استفاده میشود و Qdrant adapter نیز برای backend خارجی وجود دارد.
SQLite برای deployment توزیعشده طراحی نشده، اما برای محیط کوچک، تست و local persistence بسیار مفید است.
اندازهگیری کیفیت بدون Evaluation بیمعناست
یک اشتباه رایج این است که retrieval را با چند سؤال دستی ارزیابی کنیم و بگوییم «به نظر خوب میآید».
در ماژول مجموعهای از metricها اضافه شد:
- Precision@K
- Recall@K
- MRR
- NDCG
- Candidate Recall
- Citation Coverage
- Boundary F1
- Latency P95/P99
حتی در خود evaluation یک bug پیدا شد که میتوانست NDCG در سطح source را به دلیل تکرار چند chunk از یک source اشتباه محاسبه کند. این مسئله نیز یادآور همان قانون کلی است: ابزار اندازهگیری هم باید تست شود.
معماری پیشنهادی برای یک تیم کوچک
برای یک شرکت کوچک لازم نیست همه قابلیتها را از روز اول فعال کنید. نقطه شروع منطقی میتواند این باشد:
PDF/DOCX/HTML
-> Persian normalization
-> Composite/Recursive chunking
-> Multilingual embedding
-> Dense Search
-> BM25F
-> Exact Search
-> RRF
-> Cross Encoder
-> ACL
-> Context Assembly
-> Qdrant
سپس براساس داده واقعی میتوان Sparse Retrieval، Late Interaction، Graph Retrieval یا مدلهای LTR را اضافه کرد.
جمعبندی
بزرگترین درسی که از ساخت Semantic Chunk Search گرفتیم این بود که Semantic Search یک مدل نیست؛ یک سیستم است.
کیفیت نهایی از حاصل تعامل چند بخش ایجاد میشود:
- متن چطور نرمال میشود؟
- کجا Chunk میشود؟
- چه representationهایی index میشوند؟
- candidateها از چه مسیرهایی میآیند؟
- چطور fusion و reranking انجام میشود؟
- دسترسی کاربر کجا کنترل میشود؟
- context چگونه ساخته میشود؟
- کیفیت با چه metricهایی اندازهگیری میشود؟
اگر فقط روی embedding model تمرکز کنیم، بخش بزرگی از مسئله را نادیده گرفتهایم. در یک سیستم واقعی، معماری retrieval، کیفیت داده، chunking و policy حداقل به اندازه انتخاب مدل اهمیت دارند.
پیشنهاد برای مطالعه بعدی
اگر میخواهید عمیقتر وارد معماری شوید، مقالههای بعدی این مجموعه را بخوانید:
- چرا Chunking مهمترین بخش RAG است؟
- Hybrid Search و ترکیب BM25 با Vector Search
- چالشهای نرمالسازی متن فارسی
- امنیت RAG و Prompt Injection داخل اسناد
- طراحی Multi-Tenant RAG و ACL
بهروزرسانی:
همهٔ نوشتهها