data-science// hybrid-rag-sparse-dense-retrieval

سیستم بازیابی تقویتشده پیوندی (Hybrid RAG) با Sparse BM25 و Dense Embeddings

رفع نقاط کور امبدینگهای متراکم در جستجوی کدهای فنی و عبارات دقیق با ترکیب بردارهای تنک (BM25/SPLADE) و متراکم در Qdrant، الگوریتم ادغام رتبه RRF و بازرتبهبندی با Cross-Encoder.

احسان شهبازی·۱۴۰۴/۰۸/۰۸·۱۳ دقیقه مطالعه
// SBU Data Science Lab

چرا جستجوی برداری خالص (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)

چالش اصلی پس از دریافت نتایج از این دو مسیر، نحوه ترکیب آنهاست؛ زیرا امتیاز شباهت کسینوسی در بازه [−1,1][-1, 1] قرار دارد اما امتیاز BM25 یک عدد نامحدود (مثلاً 18.4) است و توزیع آماری آنها کاملاً متفاوت است. الگوریتم RRF بدون نیاز به نرمالسازی امتیازها، صرفاً بر اساس رتبه ($r$) هر سند در هر لیست، امتیاز نهایی را محاسبه میکند:

∑ FORMULA
RRF_Score(d∈D)=∑m∈{dense,sparse}1k+rm(d)\text{RRF\_Score}(d \in D) = \sum_{m \in \{\text{dense}, \text{sparse}\}} \frac{1}{k + r_m(d)}

که در آن ثابت هموارساز kk (معمولاً k=60k=60) مانع از تسلط بیش از حد رتبههای اولِ یک موتورِ واحد شده و اسنادی را که در هر دو روشِ معنایی و واژگانی رتبه خوبی کسب کردهاند به صدر جدول میآورد.

مرحله نهایی: بازرتبهبندی عمیق با 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 بازرتبهبندی مینماید:

hybrid_rag_retriever.py
python
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) را تا ۳۵ درصد بهبود میبخشد.