البحث الهجين: لما البحث الدلالي لوحده مش كفاية
مستوى المقال: متوسط. الافتراض إن عندك بحث دلالي (embeddings) شغّال بالفعل، وعايز ترفع دقته بدل ما تفضل تدوّر في مشكلة النتايج الناقصة.
لو بحثك الدلالي بيفوّت المقالة اللي فيها كود الخطأ ERR_2043 أو رقم الموديل بالظبط، المشكلة مش في السيرفر ولا في الموديل. دي طبيعة الـ embeddings نفسها. المقال ده هيوريك تدمج بحث الكلمات المفتاحية مع بحث المتجهات في أقل من 30 سطر، وترفع الـ recall من غير ما تغيّر قاعدة بياناتك.
المشكلة باختصار
البحث الدلالي بيدوّر بالمعنى. لو كتبت "عربية سريعة" هيرجّعلك نتايج فيها "سيارة رياضية" حتى لو الكلمة مختلفة. ده قوته. لكن نفس القوة دي بتبقى ضعف لما المستخدم يكتب حاجة حرفية: كود خطأ، اسم دالة، رقم منتج، أو مصطلح نادر. الـ embedding بيحوّل النص لأرقام قريبة في المعنى، فبيضيّع الدقة الحرفية للرمز.
ليه البحث الدلالي بيفوّت الحرفي؟
تخيّل مكتبة فيها أمين ذكي بيفهم المعنى. تقوله "عايز كتاب عن الطبخ الصحي" يجيبلك رفّ كامل مظبوط. لكن لو قلتله "عايز الكتاب رقم ISBN 978-3-16-148410-0"، هو مش حافظ الأرقام، فهيجيبلك كتب "قريبة الشكل" من الرقم. هنا محتاج الفهرس التقليدي اللي بيطابق الرمز حرف بحرف.
علميًا: الـ embedding بيمثّل النص كمتجه كثيف (dense vector) في فضاء المعنى. الرموز النادرة زي الأكواد بيبقى تمثيلها ضعيف لأنها ظهرت قليل وقت التدريب، فبتضيع في زحمة المتجهات المتشابهة. في المقابل، خوارزمية BM25 بتشتغل على تطابق الكلمات الفعلي وترجيح ندرتها، فبتلاقي الرمز النادر فورًا. الاتنين بيكمّلوا بعض: الدلالي للمعنى، وBM25 للدقة الحرفية.
الحل: دمج القائمتين بـ Reciprocal Rank Fusion
المشكلة إن درجات BM25 ودرجات تشابه المتجهات على مقاييس مختلفة تمامًا، فمينفعش تجمعهم مباشرة. الحل اسمه Reciprocal Rank Fusion (RRF): بننسى الدرجات ونشتغل على الترتيب بس. كل مستند بياخد نقاط حسب مركزه في كل قائمة، والمستند اللي بيظهر متقدّم في القايمتين بيكسب.
- شغّل بحث الكلمات المفتاحية وهات أول 50 نتيجة مرتّبة.
- شغّل بحث المتجهات وهات أول 50 نتيجة مرتّبة.
- ادمج الترتيبين بمعادلة RRF وخُد أعلى 10.
from rank_bm25 import BM25Okapi
def reciprocal_rank_fusion(ranked_lists, k=60):
scores = {}
for ranked in ranked_lists: # كل قائمة = ترتيب معرّفات المستندات
for rank, doc_id in enumerate(ranked, start=1):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)
# 1) بحث الكلمات المفتاحية (BM25)
bm25 = BM25Okapi([doc.split() for doc in corpus])
kw_ranking = [doc_id for doc_id, _ in
sorted(enumerate(bm25.get_scores(query.split())),
key=lambda x: x[1], reverse=True)][:50]
# 2) بحث المتجهات (أي مخزن: Qdrant / pgvector / FAISS)
vec_ranking = vector_store.search(embed(query), top_k=50) # يرجّع معرّفات مرتّبة
# 3) الدمج
final = reciprocal_rank_fusion([kw_ranking, vec_ranking])[:10]