البحث الهجين: لما البحث الدلالي لوحده مش كفاية
مستوى المقال: متوسط. الافتراض إن عندك بحث دلالي (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]
الثابت k=60 هو القيمة الافتراضية من الورقة الأصلية، وبيقلّل تأثير الفروق الصغيرة في المراكز المتأخرة. ركز إنك مش محتاج تعيير أوزان معقّدة عشان تبدأ.
الأرقام: قبل وبعد
لو عندك بحث دعم فني على 50 ألف مقالة، والمستخدم كتب ERR_2043: البحث الدلالي ممكن يحط المقالة الصح في المركز 14 لأنه شايف كل مقالات الأخطاء متشابهة. BM25 بيحطها في المركز 1 لأنها تطابق حرفي. بعد الدمج بـ RRF بتطلع في المراكز الأولى.
على مستوى مرجعي: في تجارب benchmark المنشورة على مجموعة BEIR، الدمج بين الدلالي وBM25 بيرفع متوسط nDCG@10 بحوالي 8 إلى 12 نقطة مئوية مقابل البحث الدلالي لوحده على مجموعات فيها مصطلحات متخصصة. الأرقام دي تقديرية وبتختلف حسب بياناتك، لكن الاتجاه ثابت: الدمج بيكسب على الاستعلامات المختلطة.
الـ trade-offs وما يجب الانتباه له
بتكسب دقة على الاستعلامات الحرفية، بتخسر بساطة البنية. دلوقتي محتاج تحافظ على فهرسين: فهرس BM25 (زي Elasticsearch أو حتى SQLite FTS) وفهرس متجهات. وكل استعلام بيبقى استعلامين متوازيين، فزمن الاستجابة بيزيد تقريبًا من 15 لـ 40 مللي ثانية حسب حجم بياناتك.
نقطة تانية: RRF بيتجاهل قوة التطابق ويشتغل على الترتيب بس. يعني مستند طلع أول بفارق ضخم، ومستند طلع أول بفارق شعرة، الاتنين بياخدوا نفس نقاط المركز الأول. ده بيبسّط الدمج لكن بيضيّع معلومة القوة النسبية. لو ده مهم لحالتك، جرّب weighted fusion بدل RRF واقبل تعقيد التعيير.
متى لا تستخدم البحث الهجين
لو استعلامات مستخدمينك كلها أسئلة بلغة طبيعية من غير أكواد ولا مصطلحات نادرة، البحث الدلالي لوحده كفاية والدمج هيبقى تعقيد بلا عائد. وكمان لو بياناتك أقل من ألف مستند، الفرق مش هيبان لأن أي بحث هيلاقي النتيجة. وأخيرًا لو الـ latency budget بتاعك ضيق جدًا (تحت 20 مللي ثانية) وكل مللي ثانية بتفرق، فكّر مرتين قبل ما تضيف استعلام تاني.
الخطوة التالية
خُد أصعب 20 استعلام فشل فيها بحثك الحالي، اللي المستخدم اشتكى إنها رجّعت نتايج غلط. شغّل عليها الدمج بالكود اللي فوق، وقارن: كام استعلام منهم النتيجة الصح طلعت في أول 5 نتايج بعد الدمج؟ لو الرقم اتحسّن، فعّل الهجين على production. لو معتحسّنش، يبقى مشكلتك في مكان تاني (الـ chunking أو جودة الـ embeddings) مش في نوع البحث.
المصادر
- Cormack, Clarke, Büttcher (2009) — Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods (SIGIR) — أصل معادلة RRF وقيمة k=60.
- Robertson & Zaragoza (2009) — The Probabilistic Relevance Framework: BM25 and Beyond — الأساس النظري لـ BM25.
- Thakur et al. (2021) — BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models — أرقام مقارنة الدلالي مقابل BM25.
- OpenSearch — Hybrid search documentation — تطبيق عملي للدمج على مستوى محرك بحث.