الوحدة 5 — البحث الكثيف والمعجمي والهجين
الوحدة السابقة بنت لنا فهرسًا متّجهيًّا. هذه الوحدة تُظهر أنّ البحث بالمتّجهات وحده لا يكفي، وأنّ أحد أفضل استثمارات المشروع هو الجمع بينه وبين بحث معجمي كلاسيكي. المفارقة: تقنية من التسعينات (BM25) تنقذ أحدث تقنيات 2024 من إخفاقات محدَّدة.
أين ينجح البحث الكثيف وأين يُخفق
البحث الكثيف (بمتّجهات التضمين) قوي في الالتقاط الدلالي. سؤال «كيف أنهي عقدي؟» يجد مقطعًا يقول «إشعار الاستقالة يكون بمدّة شهر»، مع أنّ الكلمات لا تتطابق. هذا نجاحه.
لكنّه يُخفق في حالات محدَّدة:
- الأسماء المختصرة: «CDI» أو «RTT» أو «UT2» لا تحمل معنى إحصائيًّا يعرفه النموذج، وقد تُذَوَّب في التضمين.
- المصطلحات النادرة: رمز إجراء داخلي كـ«PROC-042» يظهر بضع مرّات في الوثيقة الأصلية ولا يُترجَم إلى شعاع مميّز.
- الأسئلة القصيرة: «العطلة السنوية» بلا سياق تُنتج شعاعًا مبهمًا.
- الأرقام والتواريخ: التضمين لا يميّز جيّدًا بين «2025» و«2020».
في هذه الحالات، البحث المعجمي بالكلمات — أي «هل تحوي هذه المقاطع كلمة PROC-042؟» — يعمل بامتياز، لأنّه يبحث عن مطابقة سلسلة أحرف لا عن قرب دلالي.
BM25 في السطور الضرورية
BM25 خوارزمية ترتيب معجمية تحسب لكلّ مقطع درجة حسب:
- تواتر كلمات السؤال في المقطع (TF:
term frequency): الأكثر ظهورًا أكثر صلة. - ندرة الكلمة في المجموعة (IDF:
inverse document frequency): كلمة نادرة إخبارية أكثر من كلمة شائعة كـ«اللائحة». - طول المقطع: يُطبَّع لتفادي إفراط في مصلحة المقاطع الطويلة.
الصيغة المبسَّطة لدرجة مقطع عند سؤال :
مع و كإعدادات قياسية. لا يُطلَب حفظ الصيغة، بل فهم أنّ الكلمات النادرة والمطابقة الحرفية هي جوهر النتيجة.
from rank_bm25 import BM25Okapi
import re
def tokenize_ar(text):
# تطبيع بسيط للعربية: إزالة التشكيل وتوحيد الألف
text = re.sub(r"[\u064B-\u0652]", "", text)
text = text.replace("أ", "ا").replace("إ", "ا").replace("آ", "ا")
return re.findall(r"\w+", text.lower())
corpus_tokens = [tokenize_ar(c.text) for c in chunks]
bm25 = BM25Okapi(corpus_tokens)
question = "مدّة إشعار الاستقالة PROC-042"
scores = bm25.get_scores(tokenize_ar(question))
top_indices = scores.argsort()[-5:][::-1]
الجمع: البحث الهجين
الفكرة بسيطة ومربحة: نُشغّل الاثنين، ثم ندمج ترتيبيهما. الطريقة الأكثر استعمالًا: Reciprocal Rank Fusion (RRF)، لصيغتها ثباتٌ لمقاييس الدرجة المختلفة:
مع عادةً. مقطع يظهر في المرتبة الأولى بالبحثين يحصل على الدرجة القصوى؛ ومقطع في المرتبة الأولى بواحد وغير موجود في الآخر يحصل على درجة معقولة.
def rrf(dense_ranks, bm25_ranks, k=60):
scores = {}
for rank, doc_id in enumerate(dense_ranks, start=1):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
for rank, doc_id in enumerate(bm25_ranks, start=1):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
return sorted(scores.items(), key=lambda x: x[1], reverse=True)
# افتراضًا: نأخذ 20 من كلّ محرِّك، وندمج
dense_top = [d.id for d in collection.query(query_embeddings=[q_vec], n_results=20)["ids"][0]]
bm25_top = [chunks[i].id for i in scores.argsort()[-20:][::-1]]
fused = rrf(dense_top, bm25_top)[:10]
على الفيل الأحمر، ينتقل Recall@5 من نحو 0.83 (كثيف وحده) إلى 0.90 (هجين)، وهذا مكسب حقيقي بلا نموذج جديد.
إعادة صياغة السؤال
السؤال الذي يصل من المستخدم غير مثالي عادة. «العطلة السنوية؟» ينقصه سياق. إعادة الصياغة بإجراء صغير من النموذج تُحسّن الاسترجاع كثيرًا:
rewrite_prompt = """أعد صياغة السؤال التالي بشكل مفصَّل يذكر
مصطلحات مرادفة يُحتَمل أن تظهر في مستندات إدارية،
دون إضافة معلومات غير موجودة في السؤال. أعد فقط الصياغة.
السؤال: {q}"""
rewritten = llm.invoke(rewrite_prompt.format(q="العطلة السنوية؟")).content
# مثال المخرج: "ما مدّة العطلة السنوية أو الإجازة السنوية أو الرصيد
# السنوي للإجازات المدفوعة الأجر حسب اللائحة الداخلية؟"
استراتيجية أخرى: HyDE (Hypothetical Document Embeddings)، حيث يُطلب من النموذج توليد جواب افتراضي، ثم يُضمَّن الجواب لا السؤال. الفكرة أنّ الجواب المفترض قريب لغويًّا من المقاطع الحقيقية أكثر من السؤال المجرَّد.
تعدّد الاستفسارات
للأسئلة المركّبة، يفيد توليد استفسارات فرعية متعدّدة والدمج بينها بـRRF:
subqueries_prompt = """اعطِ ثلاث صياغات مختلفة للسؤال التالي،
كلٌّ منها يركّز على زاوية مختلفة (تعريف، إجراء، شروط).
أعدها كقائمة نقطية.
السؤال: {q}"""
subqueries = parse_bullets(llm.invoke(subqueries_prompt.format(q=question)).content)
all_ranks = [dense_search(sq) for sq in subqueries] + [bm25_search(question)]
fused = rrf_multi(all_ranks)
هذه الاستراتيجية مكلفة أكثر (عدّة استدعاءات للنموذج، وعدّة استفسارات على الفهرس)، لكنّها تُنقذ الأسئلة المعقّدة التي لا يجيبها استفسار وحيد.
مثال حيّ من الفيل الأحمر
سؤال المستخدم: «ما إجراء PROC-042؟».
- البحث الكثيف وحده يعطي مقاطع عن الإجراءات العامّة، دون التقاط الرمز المحدَّد. الرتبة 1 مقطع بعنوان «الإجراءات الداخلية للمؤسّسة»، والرتبة 2 مقطع عن مسار طلب الإجازة. المقطع الصحيح في الرتبة 17.
- BM25 وحده يعطي في الرتبة 1 المقطع الذي يحوي حرفيًّا «PROC-042». دقّة قاطعة.
- الهجين بـRRF يعطي المقطع الصحيح في الرتبة 1، ومقاطع سياقية عن الإجراءات المشابهة في الرتب التالية. الأفضل من الاثنين.
سبب فشل الكثيف في هذه الحالة: نموذج التضمين لم يرَ «PROC-042» أثناء تدريبه، ولا يعرف ما يُميّزه عن «PROC-041» أو «PROC-999». يعطيه شعاعًا شبه عشوائي. أمّا BM25 فيرى مطابقة حرفية نادرة جدًّا في المجموعة، فيصعّدها فورًا إلى القمّة.
كقاعدة عامّة: ابدأ بالبحث الكثيف لأنّه أعمّ فائدة، وأضف BM25 حين تلاحظ فشلًا منهجيًّا على الأسماء المختصرة والمصطلحات الحرفية. لا تضع هجينًا معقّدًا من اليوم الأوّل: قِس، حدّد الأعطال، ثم أضِف المكوّن الذي يعالجها. البساطة قبل التعقيد.
في الخلاصة
- البحث الكثيف قوي دلاليًّا، ضعيف على الأسماء المختصرة والمصطلحات النادرة والأرقام.
- BM25 كلاسيكي فعّال: يعتمد التواتر والندرة وطول المقطع؛ يُنقذ ما يفوت الدلاليّ.
- البحث الهجين بـRRF يدمج الترتيبين بلا الحاجة إلى معايرة درجاتهما، ويرفع Recall@5 برفع محسوس.
- إعادة الصياغة وHyDE والاستفسارات الفرعية تحسِّن الاسترجاع على الأسئلة القصيرة أو المركّبة، مقابل استدعاءات إضافية للنموذج.
ملاحظة على العربية: التطبيع اللغويّ
في تطبيقات BM25 على العربية بالتحديد، خط وة التطبيع النصّي التي رأيناها في الوحدة 2 تُظهر أثرها الكامل. فرمز مثل «الاستقالة» قد يُكتَب في مستند «الإستقالة» بهمزة على الألف، فإن لم يوحَّد شكل الألف قبل الفهرسة، لا يقع تطابق معجميّ. القاعدة تعود: تطبيع مطبَّق في الفهرسة يُطبَّق نفسه على السؤال قبل التقسيم إلى رموز، وإلّا فوائد BM25 تُهدَر على العربية دون سبب.
نقطة ثانية: قوائم إيقاف (stop words) قصيرة أو غائبة. كلمات مثل «من»، «إلى»، «على»، «في» شائعة جدًّا في العربية. IDF المُدمَجة في BM25 تخفّض وزنها تلقائيًّا دون حاجة إلى قائمة صريحة، لكن إبقاؤها في الفهرسة يوفّر مساحة ويسرّع البحث بلا خسارة دقّة تُذكَر.
الوحدة التالية: إعادة ترتيب المقاطع بمشفِّر متقاطع بعد البحث الأوّلي، لرفع الدقّة قبل تمرير أفضلها إلى النموذج المولّد.