سیستم بازیابی تقویتشده پیوندی (Hybrid RAG) با Sparse BM25 و Dense Embeddings
رفع نقاط کور امبدینگهای متراکم در جستجوی کدهای فنی و عبارات دقیق با ترکیب بردارهای تنک (BM25/SPLADE) و متراکم در Qdrant، الگوریتم ادغام رتبه RRF و بازرتبهبندی با Cross-Encoder.
چرا جستجوی برداری خالص (Dense-Only RAG) در محیطهای سازمانی شکست میخورد؟
اکثر آموزشهای مقدماتی RAG (Retrieval-Augmented Generation) صرفاً متون را با یک مدل Bi-Encoder (مانند text-embedding-3-large یا BAAI/bge-m3) به بردارهای متراکم (Dense Vectors) تبدیل کرده و با شباهت کسینوسی (Cosine Similarity) جستجو میکنند. اما در سیستمهای سازمانی واقعی، جستجوی متراکم به تنهایی در سه سناریو حیاتی با شکست سنگین مواجه میشود:
- کدهای خطا، شماره قطعه (SKU) و شناسههای دقیق: وقتی کاربر دنبال کد خطای
ERR_SSL_PINNING_409یا شماره مدلRTX-4090-TIمیگردد، امبدینگهای متراکم این رشتههای خارج از واژگان (Out-of-Vocabulary) را در فضای معنایی فشرده کرده و اسناد کاملاً نامرتبط برمیگردانند. - اصطلاحات تخصصی دامنه و اسامی خاص: نام توابع برنامهنویسی، نام داروها یا مواد قانونی خاص که در دادههای آموزشی مدل امبدینگ تکرار نشدهاند.
- پدیده Hubness در ابعاد بالا: در فضاهای برداری با ابعاد بالا (۱۰۲۴ یا ۱۵۳۶ بعدی)، برخی بردارهای عمومی به عنوان همسایه نزدیکِ تقریباً هر پرسشی ظاهر میشوند و دقت (Precision@K) را کاهش میدهند.
معماری دوگانه: بردارهای متراکم (Dense) در کنار بردارهای تنک (Sparse BM25 / SPLADE)
برای ساخت یک موتور جستجوی ضدگلوله، باید دو دیدگاه مکمل را به صورت موازی اجرا کنیم:
- مسیر Dense Retrieval (درک معنایی): بردارهای متراکم با ایندکس گراف HNSW که مترادفها، مفهوم جمله و ارتباط معنایی بین کلمات با ظاهر متفاوت را درک میکنند.
- مسیر Sparse Retrieval (تطابق واژگانی دقیق): بردارهای تنک (با ابعاد واژگان مثلاً ۳۰,۰۰۰ که تنها چند درایه غیرصفر دارند) بر پایه الگوریتم BM25 یا مدلهای عصبی SPLADE که وزن دقیق تکرار کلمات کلیدی (
TF-IDFبه همراه اشباع طول سند) را محاسبه میکنند.
الگوریتم ادغام رتبه معکوس (Reciprocal Rank Fusion - RRF)
چالش اصلی پس از دریافت نتایج از این دو مسیر، نحوه ترکیب آنهاست؛ زیرا امتیاز شباهت کسینوسی در بازه قرار دارد اما امتیاز BM25 یک عدد نامحدود (مثلاً 18.4) است و توزیع آماری آنها کاملاً متفاوت است. الگوریتم RRF بدون نیاز به نرمالسازی امتیازها، صرفاً بر اساس رتبه ($r$) هر سند در هر لیست، امتیاز نهایی را محاسبه میکند:
که در آن ثابت هموارساز (معمولاً ) مانع از تسلط بیش از حد رتبههای اولِ یک موتورِ واحد شده و اسنادی را که در هر دو روشِ معنایی و واژگانی رتبه خوبی کسب کردهاند به صدر جدول میآورد.
مرحله نهایی: بازرتبهبندی عمیق با Cross-Encoder Reranker
مدلهای امبدینگ (Bi-Encoders) بردار پرسش و بردار سند را به صورت مستقل محاسبه میکنند و بنابراین مکانیسم Self-Attention بین کلمات پرسش و کلمات سند برقرار نمیشود. پس از آنکه با Hybrid Search و RRF، تعداد ۵۰ سند برتر (Top-50 Candidates) را در کمتر از ۱۵ میلیثانیه استخراج کردیم، زوجِ (Query, Document) را همزمان وارد یک مدل Cross-Encoder (مانند BAAI/bge-reranker-v2-m3) میکنیم تا با دقت تمامتوجهی (Full Cross-Attention)، ۵ سند طلایی نهایی را برای ارسال به LLM انتخاب کند.
پیادهسازی پایپلاین Hybrid RAG با Qdrant، ادغام RRF و Cross-Encoder
در کد زیر، یک موتور بازیابی دو مرحلهای کامل پیادهسازی شده است که به صورت همزمان بردارهای Dense و Sparse را در Qdrant جستجو کرده، نتایج را در سمت سرور با RRF ادغام میکند و در نهایت با مدل Cross-Encoder بازرتبهبندی مینماید:
from typing import List, Dict, Any
from qdrant_client import QdrantClient, models
from sentence_transformers import CrossEncoder
class ProductionHybridRetriever:
"""
Two-Stage Hybrid RAG Pipeline:
Stage 1: Native Qdrant Sparse (BM25/SPLADE) + Dense HNSW Retrieval with Server-Side RRF
Stage 2: Deep Cross-Encoder Reranking for Top-K Precision
"""
def __init__(
self,
qdrant_url: str,
collection_name: str,
reranker_model_name: str = 'BAAI/bge-reranker-v2-m3',
device: str = 'cuda'
):
self.client = QdrantClient(url=qdrant_url, prefer_grpc=True)
self.collection_name = collection_name
self.reranker = CrossEncoder(reranker_model_name, max_length=1024, device=device)
def create_hybrid_collection(self, dense_dim: int = 1024) -> None:
self.client.recreate_collection(
collection_name=self.collection_name,
vectors_config={
'dense_bge': models.VectorParams(
size=dense_dim,
distance=models.Distance.COSINE,
hnsw_config=models.HnswConfigDiff(m=16, ef_construct=200)
)
},
sparse_vectors_config={
'sparse_bm25': models.SparseVectorParams(
index=models.SparseIndexParams(on_disk=False)
)
}
)
def retrieve_and_rerank(
self,
query_text: str,
dense_query_vec: List[float],
sparse_indices: List[int],
sparse_values: List[float],
prefetch_limit: int = 40,
final_top_k: int = 5
) -> List[Dict[str, Any]]:
# Stage 1: Execute parallel Dense + Sparse prefetch and fuse via RRF inside Qdrant
fused_response = self.client.query_points(
collection_name=self.collection_name,
prefetch=[
models.Prefetch(
query=dense_query_vec,
using='dense_bge',
limit=prefetch_limit
),
models.Prefetch(
query=models.SparseVector(indices=sparse_indices, values=sparse_values),
using='sparse_bm25',
limit=prefetch_limit
),
],
query=models.FusionQuery(fusion=models.Fusion.RRF),
limit=prefetch_limit,
with_payload=True
)
candidates = fused_response.points
if not candidates:
return []
# Stage 2: Cross-Encoder full self-attention scoring over (Query, Passage) pairs
sentence_pairs = [(query_text, pt.payload.get('text', '')) for pt in candidates]
cross_scores = self.reranker.predict(sentence_pairs, batch_size=16, show_progress_bar=False)
reranked_results = []
for point, score in zip(candidates, cross_scores):
reranked_results.append({
'doc_id': point.id,
'rrf_score': point.score,
'rerank_score': float(score),
'text': point.payload.get('text', ''),
'metadata': point.payload.get('metadata', {})
})
reranked_results.sort(key=lambda x: x['rerank_score'], reverse=True)
return reranked_results[:final_top_k]نکته کلیدی مهندسی: هنگام قطعه کردن اسناد (Chunking) برای سیستمهای Hybrid RAG، هرگز قطعات را بدون زمینه (Context) ذخیره نکنید. تکنیک Contextual Header Injection (اضافه کردن عنوان سند، مسیر سرفصلها و نام محصول به ابتدای متن هر Chunk پیش از محاسبه بردارهای Dense و Sparse) نرخ بازیابی صحیح (Recall@5) را تا ۳۵ درصد بهبود میبخشد.