الدرس 13 من 23

البحث الهجين وإعادة الترتيب

تنفيذ البحث الهجين

4 دقيقة للقراءة

ادمج بحث BM25 بالكلمات المفتاحية مع بحث تشابه المتجهات باستخدام دمج الترتيب التبادلي (RRF) لنتائج مثلى.

نظرة عامة على البنية

                    الاستعلام
          ┌───────────┴───────────┐
          ▼                       ▼
    ┌──────────┐           ┌──────────┐
    │   BM25   │           │  بحث    │
    │  بحث    │           │ المتجهات │
    └────┬─────┘           └────┬─────┘
         │                      │
         │   النتائج + الترتيب  │
         └──────────┬───────────┘
            ┌──────────────┐
            │     RRF      │
            │     دمج      │
            └──────┬───────┘
            النتائج المدمجة

تنفيذ BM25

أولاً، أعد البحث بالكلمات المفتاحية مع BM25:

from rank_bm25 import BM25Okapi
import nltk
from nltk.tokenize import word_tokenize

nltk.download('punkt')

class BM25Retriever:
    def __init__(self, documents: list[str]):
        # تجزئة المستندات
        self.documents = documents
        self.tokenized_docs = [word_tokenize(doc.lower()) for doc in documents]

        # بناء فهرس BM25
        self.bm25 = BM25Okapi(self.tokenized_docs)

    def search(self, query: str, k: int = 10) -> list[tuple[int, float]]:
        """أرجع أزواج (فهرس_المستند، الدرجة)."""
        tokenized_query = word_tokenize(query.lower())
        scores = self.bm25.get_scores(tokenized_query)

        # احصل على أعلى k فهارس ودرجات
        top_indices = scores.argsort()[-k:][::-1]
        return [(idx, scores[idx]) for idx in top_indices]

تنفيذ بحث المتجهات

from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma

class VectorRetriever:
    def __init__(self, documents: list[str]):
        self.documents = documents
        self.embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

        # بناء فهرس المتجهات
        self.vectorstore = Chroma.from_texts(
            texts=documents,
            embedding=self.embeddings
        )

    def search(self, query: str, k: int = 10) -> list[tuple[int, float]]:
        """أرجع أزواج (فهرس_المستند، الدرجة)."""
        results = self.vectorstore.similarity_search_with_score(query, k=k)

        # اربط النتائج بالفهارس
        doc_to_idx = {doc: idx for idx, doc in enumerate(self.documents)}
        return [(doc_to_idx[r[0].page_content], r[1]) for r in results]

دمج الترتيب التبادلي (RRF)

RRF يدمج الترتيبات دون الحاجة لتطبيع الدرجات:

def reciprocal_rank_fusion(
    rankings: list[list[tuple[int, float]]],
    k: int = 60
) -> list[tuple[int, float]]:
    """
    ادمج ترتيبات متعددة باستخدام RRF.

    المعاملات:
        rankings: قائمة ترتيبات، كل منها [(معرف_المستند، الدرجة), ...]
        k: ثابت RRF (افتراضي 60)

    يرجع:
        الترتيب المدمج [(معرف_المستند، درجة_rrf), ...]
    """
    rrf_scores = {}

    for ranking in rankings:
        for rank, (doc_id, _) in enumerate(ranking):
            if doc_id not in rrf_scores:
                rrf_scores[doc_id] = 0
            # صيغة RRF: 1 / (k + rank)
            rrf_scores[doc_id] += 1 / (k + rank + 1)

    # رتب حسب درجة RRF تنازلياً
    sorted_results = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
    return sorted_results

لماذا يعمل RRF:

  • مستقل عن الدرجات: لا حاجة لتطبيع أنظمة تسجيل مختلفة
  • قائم على الموقع: المستندات المرتبة عالياً بأنظمة متعددة تُعزز
  • الثابت k: يتحكم في مدى تفضيل المستندات الأعلى ترتيباً

المسترجع الهجين الكامل

class HybridRetriever:
    def __init__(self, documents: list[str]):
        self.documents = documents
        self.bm25 = BM25Retriever(documents)
        self.vector = VectorRetriever(documents)

    def search(
        self,
        query: str,
        k: int = 10,
        bm25_weight: float = 0.5,
        vector_weight: float = 0.5
    ) -> list[dict]:
        """
        البحث الهجين يجمع BM25 وبحث المتجهات.

        المعاملات:
            query: استعلام البحث
            k: عدد النتائج للإرجاع
            bm25_weight: وزن نتائج BM25
            vector_weight: وزن نتائج المتجهات

        يرجع:
            قائمة مستندات مع الدرجات
        """
        # احصل على النتائج من كلا النظامين
        bm25_results = self.bm25.search(query, k=k*2)
        vector_results = self.vector.search(query, k=k*2)

        # طبق دمج RRF
        fused = reciprocal_rank_fusion([bm25_results, vector_results])

        # أرجع أعلى k مع محتوى المستند
        results = []
        for doc_id, score in fused[:k]:
            results.append({
                "content": self.documents[doc_id],
                "score": score,
                "doc_id": doc_id
            })

        return results

تنفيذ LangChain

باستخدام مسترجع المجموعة المدمج في LangChain:

from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
from langchain_community.vectorstores import Chroma

# إنشاء المسترجعات
bm25_retriever = BM25Retriever.from_documents(documents)
bm25_retriever.k = 10

vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 10})

# ادمج مع المجموعة
ensemble_retriever = EnsembleRetriever(
    retrievers=[bm25_retriever, vector_retriever],
    weights=[0.5, 0.5]  # وزن متساوٍ
)

# البحث
results = ensemble_retriever.get_relevant_documents("OAuth authentication")

البحث الهجين الأصلي لقاعدة البيانات

العديد من قواعد البيانات المتجهة تدعم البحث الهجين أصلياً:

Qdrant

from qdrant_client import QdrantClient
from qdrant_client.models import models

client = QdrantClient("localhost", port=6333)

# البحث الهجين مع دمج مدمج
results = client.query_points(
    collection_name="documents",
    query=query_embedding,
    using="dense",  # حقل المتجهات
    with_payload=True,
    limit=10,
    query_filter=None,
    # تمكين الهجين مع المتجهات المتفرقة
    with_vectors=False,
).points

# أو استخدم متجهات Qdrant المتفرقة لبحث شبيه بـ BM25

Weaviate

import weaviate

client = weaviate.connect_to_local()
collection = client.collections.get("Document")

# البحث الهجين
results = collection.query.hybrid(
    query="OAuth authentication",
    alpha=0.5,  # 0 = كلمات مفتاحية فقط، 1 = متجهات فقط، 0.5 = متوازن
    limit=10
)

Pinecone

from pinecone import Pinecone

pc = Pinecone(api_key="your-key")
index = pc.Index("hybrid-index")

# Pinecone الهجين مع متجهات كثيفة-متفرقة
results = index.query(
    vector=dense_embedding,
    sparse_vector={
        "indices": sparse_indices,
        "values": sparse_values
    },
    top_k=10,
    include_metadata=True
)

ضبط الأوزان الهجينة

def find_optimal_weights(
    queries: list[str],
    ground_truth: list[list[int]],
    retriever: HybridRetriever
) -> tuple[float, float]:
    """ابحث عن أوزان BM25/المتجهات المثلى."""
    best_weights = (0.5, 0.5)
    best_score = 0

    for bm25_w in [0.3, 0.4, 0.5, 0.6, 0.7]:
        vector_w = 1 - bm25_w

        total_recall = 0
        for query, truth in zip(queries, ground_truth):
            results = retriever.search(
                query,
                k=10,
                bm25_weight=bm25_w,
                vector_weight=vector_w
            )
            retrieved_ids = [r["doc_id"] for r in results]
            recall = len(set(retrieved_ids) & set(truth)) / len(truth)
            total_recall += recall

        avg_recall = total_recall / len(queries)
        if avg_recall > best_score:
            best_score = avg_recall
            best_weights = (bm25_w, vector_w)

    return best_weights

نصيحة التنفيذ: ابدأ بأوزان متساوية (0.5/0.5)، ثم اضبط بناءً على أنماط استعلاماتك. المجالات التقنية غالباً تستفيد من وزن BM25 أعلى؛ المجالات المفاهيمية تفضل وزن المتجهات.

التالي، لنستكشف استراتيجيات إعادة الترتيب لتحسين جودة النتائج أكثر. :::

اختبار

الوحدة 4: البحث الهجين وإعادة الترتيب

خذ الاختبار