16 KiB
پایپلاین بازیابی دو مرحلهای: Vector Search + Reranking
مقدمه
در سیستمهای RAG (Retrieval-Augmented Generation)، کیفیت پاسخ مدل زبانی مستقیماً به کیفیت دادههایی بستگی دارد که به عنوان context به آن داده میشود. اگر دادههای نامرتبط یا کمارتباط به مدل برسند، پاسخ نهایی نیز ضعیف خواهد بود.
برای حل این مشکل، ما از یک پایپلاین بازیابی دو مرحلهای استفاده میکنیم:
- مرحله اول - Vector Search: بازیابی N کاندید اولیه از وکتور دیتابیس بر اساس فاصله معنایی (Semantic Similarity)
- مرحله دوم - Reranking: ارزیابی دقیقتر کاندیدها توسط یک مدل Reranker و انتخاب بهترینها
چرا فقط Vector Search کافی نیست؟
محدودیتهای جستجوی وکتوری
جستجوی وکتوری (Vector Search) بر اساس فاصله بردارها (مثلاً Cosine Similarity) کار میکند. یعنی سوال کاربر و اسناد موجود در دیتابیس هر دو به بردار تبدیل میشوند و نزدیکترین بردارها به عنوان نتیجه برگردانده میشوند.
اما این روش محدودیتهایی دارد:
| محدودیت | توضیح |
|---|---|
| دقت متوسط | Embedding model ها معنای کلی متن را میگیرند، نه ارتباط دقیق سوال-جواب را |
| حساسیت به نحوه نوشتن | دو جمله با معنای یکسان اما ساختار متفاوت ممکن است فاصله بیشتری داشته باشند |
| عدم درک سوال-جواب | مدل embedding فقط شباهت معنایی میسنجد، نه اینکه آیا یک سند واقعاً پاسخ سوال را دارد |
| چالشهای چندزبانه | در متون فارسی/عربی/انگلیسی مخلوط، embedding ممکن است دقت کمتری داشته باشد |
مثال عملی
فرض کنید کاربر میپرسد: "حکم روزه مسافر چیست؟"
Vector Search ممکن است این نتایج را برگرداند:
- سندی درباره احکام روزه مسافر (مرتبط)
- سندی درباره احکام نماز مسافر (شبیه اما نامرتبط)
- سندی درباره فضیلت روزه (کلمه روزه دارد اما پاسخ سوال نیست)
- سندی درباره احکام روزه بیمار (مشابه اما متفاوت)
Reranker میتواند تشخیص دهد که سند ۱ دقیقاً به سوال پاسخ میدهد و بقیه را در اولویت پایینتر قرار دهد.
معماری پایپلاین
نمای کلی
graph TD
A["سوال کاربر"] --> B["rag_injection_hook"]
B --> C["build_rag_prompt"]
subgraph "مرحله ۱: Vector Search"
C --> D["Embedding سوال کاربر"]
D --> E["جستجو در Qdrant"]
E --> F["N=7 کاندید اولیه"]
end
subgraph "مرحله ۲: Reranking"
F --> G["ارسال به Jina Reranker API"]
G --> H["ارزیابی ارتباط هر کاندید با سوال"]
H --> I["مرتبسازی بر اساس relevance_score"]
I --> J["انتخاب top_n=3 نتیجه برتر"]
end
J --> K["ساخت context با منبع و امتیاز"]
K --> L["تزریق به prompt کاربر"]
L --> M["ارسال به LLM"]
M --> N["پاسخ نهایی"]
جریان داده
سوال کاربر
│
▼
┌─────────────────────────────┐
│ Pre-Hook: rag_injection_hook│ ← src/utils/hooks.py
│ ورودی کاربر را دریافت و │
│ به build_rag_prompt پاس │
│ میدهد │
└─────────────┬───────────────┘
│
▼
┌─────────────────────────────┐
│ مرحله ۱: Vector Search │ ← src/utils/search_knowledge.py
│ ───────────────────────── │
│ • Embedding سوال با │
│ EmbeddingFactory │
│ • جستجو در Qdrant │
│ • دریافت 7 کاندید اولیه │
└─────────────┬───────────────┘
│
▼
┌─────────────────────────────┐
│ مرحله ۲: Reranking │ ← src/utils/reranker.py
│ ───────────────────────── │
│ • ارسال سوال + 7 کاندید │
│ به Jina Reranker API │
│ • دریافت relevance_score │
│ برای هر کاندید │
│ • انتخاب 3 کاندید برتر │
└─────────────┬───────────────┘
│
▼
┌─────────────────────────────┐
│ ساخت Context نهایی │
│ ───────────────────────── │
│ • افزودن منبع و امتیاز │
│ به هر سند │
│ • ترکیب با سوال کاربر │
│ • تزریق به RunInput │
└─────────────┬───────────────┘
│
▼
Agent (LLM)
پیادهسازی
مرحله ۱: نقطه ورود - Pre-Hook
فایل: src/utils/hooks.py
@observe(name="rag_injection_hook")
def rag_injection_hook(run_input: RunInput, **kwargs):
"""
Intercepts the user input and injects RAG context.
"""
original_input = run_input.input_content
run_input.input_content = build_rag_prompt(original_input)
این hook قبل از اجرای Agent فراخوانی میشود. ورودی خام کاربر را میگیرد و آن را از پایپلاین RAG عبور میدهد. نتیجه (prompt غنیشده با context) جایگزین ورودی اصلی میشود.
نکته: تغییرات روی run_input به صورت in-place اعمال میشوند. نیازی به return نیست.
مرحله ۲: Vector Search - بازیابی کاندیدهای اولیه
فایل: src/utils/search_knowledge.py
def build_rag_prompt(user_question: str, embedder_model_name: str = None) -> str:
global knowledge_base
# Lazy initialization
if knowledge_base is None:
embed_factory = EmbeddingFactory()
embedder = embed_factory.get_embedder(embedder_model_name)
vector_db = get_qdrant_store(embedder=embedder)
knowledge_base = Knowledge(vector_db=vector_db)
# جستجوی اولیه: دریافت 7 کاندید نزدیکترین
initial_results = knowledge_base.search(query=user_question, max_results=7)
چرا max_results=7؟
عدد 7 یک تعادل بین دو نیاز است:
- پوشش کافی: اگر تعداد کاندیدها خیلی کم باشد (مثلاً 3)، ممکن است بهترین نتیجه در بین آنها نباشد
- سرعت و هزینه: اگر تعداد خیلی زیاد باشد (مثلاً 50)، هزینه و زمان Reranking بالا میرود
با 7 کاندید اولیه، Reranker فضای کافی برای انتخاب 3 نتیجه واقعاً مرتبط دارد.
Lazy Initialization
Knowledge Base فقط یکبار مقداردهی میشود (در اولین درخواست) و سپس در یک متغیر global نگهداری میشود. این کار از ایجاد اتصال مجدد به Qdrant در هر درخواست جلوگیری میکند.
مرحله ۳: Reranking - فیلتر هوشمند نتایج
فایل: src/utils/reranker.py
def rerank_documents(query: str, documents: List[Any], top_n: int = 3) -> List[Any]:
# 1. استخراج محتوای متنی اسناد
doc_contents = [doc.content for doc in documents]
# 2. ارسال به Jina Reranker API
payload = {
"model": "jina-reranker-v3",
"query": query,
"documents": doc_contents,
"top_n": top_n
}
response = requests.post(url, headers=headers, json=payload)
results = response.json()["results"]
# 3. بازسازی لیست اسناد با امتیاز جدید
reranked_docs = []
for result in results:
original_index = result["index"]
relevance_score = result["relevance_score"]
doc = documents[original_index]
doc.meta_data["rerank_score"] = relevance_score
reranked_docs.append(doc)
return reranked_docs
مدل Reranker: jina-reranker-v3
از مدل Jina Reranker v3 استفاده میکنیم. دلایل انتخاب این مدل:
| ویژگی | توضیح |
|---|---|
| پشتیبانی چندزبانه | عملکرد مناسب روی متون فارسی، عربی و انگلیسی |
| درک سوال-جواب | بر خلاف embedding، ارتباط بین سوال و پاسخ را میفهمد (Cross-Encoder) |
| سرعت | برای تعداد کم اسناد (7 کاندید) بسیار سریع است |
| API ساده | فقط یک HTTP POST call نیاز دارد |
تفاوت Reranker با Embedding
| Embedding (Bi-Encoder) | Reranker (Cross-Encoder) | |
|---|---|---|
| ورودی | هر متن جداگانه encode میشود | سوال و سند با هم encode میشوند |
| خروجی | بردار (vector) | امتیاز ارتباط (relevance score) |
| سرعت | سریع (مناسب جستجوی میلیونها سند) | کندتر (مناسب دهها سند) |
| دقت | متوسط | بالا |
| کاربرد | مرحله اول: غربالگری سریع | مرحله دوم: انتخاب دقیق |
به زبان ساده:
- Embedding مثل خواندن سریع عنوان کتابها در کتابخانه و انتخاب چند کتاب مرتبط است
- Reranker مثل ورق زدن آن چند کتاب و انتخاب بهترینها بر اساس محتوای واقعی
نحوه کار Jina API
Jina یک لیست از اسناد و یک سوال دریافت میکند. برای هر سند، یک relevance_score (بین 0 تا 1) و index (شماره سند در لیست اصلی) برمیگرداند:
{
"results": [
{ "index": 2, "relevance_score": 0.92 },
{ "index": 0, "relevance_score": 0.78 },
{ "index": 5, "relevance_score": 0.65 }
]
}
سپس ما با استفاده از index، سند اصلی را از لیست اولیه پیدا کرده و relevance_score را در meta_data آن ذخیره میکنیم.
مرحله ۴: ساخت Context نهایی
پس از reranking، اسناد برتر به همراه اطلاعات منبع و امتیاز ارتباط به یک context تبدیل میشوند:
context_parts = []
for doc in relevant_docs:
meta = getattr(doc, "meta_data", {}) or {}
source = meta.get('source', 'Unknown')
score = meta.get('rerank_score', 0)
content = f"[Source: {source} | Relevance: {score:.2f}]\n{doc.content}"
context_parts.append(content)
context_str = "\n\n".join(context_parts)
هر سند در context نهایی شامل:
- Source: منبع سند (مثلاً نام کتاب یا فایل)
- Relevance: امتیاز ارتباط از Reranker (0 تا 1)
- Content: محتوای اصلی سند
مرحله ۵: Prompt نهایی
final_prompt = (
"Here is the context from the database:\n"
"---------------------\n"
f"{context_str}\n"
"---------------------\n"
f"User Question: {user_question}"
)
این prompt به LLM داده میشود. مدل با دیدن context مرتبط و سوال کاربر، پاسخ مناسب تولید میکند.
مدیریت خطا و Fallback
سطح ۱: خطا در Reranker
اگر Jina API در دسترس نباشد یا خطا دهد، سیستم به ترتیب اصلی Vector Search بازمیگردد:
except Exception as e:
print(f"❌ Reranking failed: {e}. Falling back to vector search order.")
return documents[:top_n]
سطح ۲: عدم وجود API Key
اگر JINA_API_KEY تنظیم نشده باشد، بدون reranking ادامه میدهد:
api_key = os.getenv("JINA_API_KEY")
if not api_key:
print("⚠️ JINA_API_KEY not found. Returning original order.")
return documents[:top_n]
سطح ۳: خطا در Knowledge Base
اگر کل Knowledge Base در دسترس نباشد، به مدل اطلاع داده میشود که پاسخ ندهد:
except Exception as e:
context_str = "Knowledge base temporarily unavailable. dont answer the question ..."
خلاصه زنجیره Fallback
Reranking موفق → 3 سند با بالاترین ارتباط
│ (خطا)
▼
Fallback → 3 سند اول از Vector Search (بدون reranking)
│ (خطا)
▼
Fallback → پیام عدم دسترسی به Knowledge Base
تنظیمات و پارامترها
متغیرهای محیطی
| متغیر | توضیح | الزامی |
|---|---|---|
JINA_API_KEY |
کلید API برای Jina Reranker | بله (بدون آن فقط Vector Search) |
QDRANT_URL |
آدرس سرور Qdrant | بله |
پارامترهای قابل تنظیم
| پارامتر | مقدار فعلی | محل تعریف | توضیح |
|---|---|---|---|
max_results |
7 | search_knowledge.py |
تعداد کاندیدهای اولیه از Vector Search |
top_n |
3 | search_knowledge.py → rerank_documents() |
تعداد نتایج نهایی پس از Reranking |
model |
jina-reranker-v3 |
reranker.py |
مدل Reranker مورد استفاده |
توصیه برای تنظیم پارامترها
- نسبت
max_resultsبهtop_nباید حداقل 2:1 باشد تا Reranker فضای کافی برای انتخاب داشته باشد - افزایش
max_resultsدقت را بالا میبرد اما سرعت و هزینه API را افزایش میدهد top_n=3برای اکثر سناریوها مناسب است؛ تعداد بیشتر ممکن است context را شلوغ کند
فایلهای مرتبط
| فایل | مسئولیت |
|---|---|
src/utils/hooks.py |
Pre-Hook برای تزریق RAG به ورودی Agent |
src/utils/search_knowledge.py |
پایپلاین اصلی RAG: جستجو، reranking و ساخت prompt |
src/utils/reranker.py |
ارتباط با Jina Reranker API و مرتبسازی نتایج |
src/knowledge/embedding_factory.py |
ساخت و مدیریت مدلهای Embedding |
src/knowledge/vector_store.py |
اتصال به Qdrant و مدیریت collection |
نتیجهگیری
پایپلاین دو مرحلهای Vector Search + Reranking بهترین تعادل بین سرعت و دقت را فراهم میکند:
- مرحله اول (Vector Search) سریع است و از بین میلیونها سند، تعداد محدودی کاندید مرتبط انتخاب میکند
- مرحله دوم (Reranking) دقیق است و با درک عمیقتر ارتباط سوال-جواب، بهترین اسناد را فیلتر میکند
این ترکیب باعث میشود context ارسالی به LLM با کیفیت بالا باشد و در نتیجه پاسخهای دقیقتر و مرتبطتری تولید شود.