البحث الهجين
درس 6 من 10
6البحث الهجين
الصورة الكبيرةالبحث بالكلمات يجد «E-1043» بالضبط ويفشل مع إعادة الصياغة. البحث المتّجهي يفهم إعادة الصياغة ويخلط بين رمزَي خطأ متشابهين. البحث الهجين يشغّل الاثنين ويدمج نتائجهما، ثم يعيد ترتيب الأفضل بنموذج أدق. وهو الاختيار الافتراضي الصحيح في أغلب الأنظمة.
ماذا تشاهد في الرسم: القائمتان تدخلان معًا، وRRF في المنتصف يكافئ ما يتّفقان عليه بـ1 / (60 + المرتبة) دون أن يقارن درجاتهما. ثم يقرأ نموذج إعادة الترتيب السؤال مع أفضل 20 فقط، لأنه أدق وأبطأ بكثير.
خارطة هذا الجزء
المفاهيم بالترتيب، اضغط أيًّا منها للقفز إليه:
طريقان إلى نفس الفكرة
عندك أمينا مكتبة: الأول يبحث بالكلمات حرفيًا، فيجد رقم المادة أو الاسم الدقيق. والثاني يبحث بالمعنى، فيجد ما يتحدث عن الفكرة نفسها بكلمات أخرى. البحث الهجين يسأل الاثنين، ثم يأتي ثالث يقرأ ما أحضراه ويرتّبه حسب الأنسب.
BM25 بحث معجمي جيد في الأسماء والأرقام والمصطلحات النادرة، والمتجهي جيد في إعادة الصياغة. تدمج القائمتين بـReciprocal Rank Fusion، ثم مُعيد ترتيب cross-encoder يقرأ السؤال والمقطع معًا. هذا هو الافتراضي الصحيح في أغلب الأنظمة.
قبل أن تبدأ
🧲البحث المتجهي
رأيت أين يخطئ التشابه: الرموز والأرقام والأشياء المتشابهة شكلًا.
📊ترتيب النتائج
تعرف أن المرتبة الأولى والثالثة شيء مختلف عن الدرجة 0.9 والدرجة 4.02، وهذا ما يقوم عليه RRF.
الفكرة الجوهرية
عندك طريقتان للبحث، وكل واحدة تخطئ حيث تصيب الأخرى. في هذا الدرس تبني كل طريقة بيدك، ثم تدمجهما، ثم تضيف مرحلة أخيرة أدق.
1البحث بالكلمات: BM25
BM25 يعطي كل مستند نقاطًا على الكلمات المشتركة بينه وبين السؤال. الكلمة النادرة تساوي نقاطًا أكثر من الشائعة، والتكرار يفيد لكن بتناقص.
import math
from collections import Counter
docs = [
"يحق للموظف إجازة سنوية مدتها واحد وعشرون يوم عمل في السنة.",
"تقدم طلبات الإجازة السنوية قبل أسبوعين على الأقل من موعدها.",
"الإجازة المرضية تحتاج تقريرا طبيا إذا زادت عن يومين متتاليين.",
"يوافق المدير المباشر على الإجازة المرضية والسنوية.",
"يمكن ترحيل عشرة أيام من الإجازة السنوية إلى السنة التالية فقط.",
"تصرف بدلات السفر خلال عشرة أيام من تقديم الفواتير.",
"يجب حجز تذاكر السفر عبر بوابة الحجز الداخلية.",
"ساعات العمل من الثامنة صباحا حتى الرابعة عصرا.",
"العمل عن بعد مسموح يومين في الأسبوع بموافقة المدير.",
"رمز الخطأ E-1043 في بوابة الحجز يعني انتهاء الجلسة.",
]
def words(text):
return [w.strip(".،؟") for w in text.split()]
tokenized = [words(d) for d in docs]
avg_len = sum(map(len, tokenized)) / len(tokenized)
df = Counter(w for doc in tokenized for w in set(doc))
def bm25(query, k1=1.5, b=0.75):
scores = []
for doc in tokenized:
tf = Counter(doc)
s = 0.0
for w in words(query):
if w not in tf:
continue
idf = math.log(1 + (len(docs) - df[w] + 0.5) / (df[w] + 0.5))
s += idf * tf[w] * (k1 + 1) / (tf[w] + k1 * (1 - b + b * len(doc) / avg_len))
scores.append(s)
return scores
for query in ["ماذا يعني E-1043؟", "متى تصرف بدلات السفر؟"]:
s = bm25(query)
best = max(range(len(docs)), key=s.__getitem__)
print(f"{query}\n {s[best]:.2f} [{best}] {docs[best]}")
E-1043 كلمة تظهر في مستند واحد فقط، فوزنها (idf) عالٍ، ووجدها BM25 فورًا. هذا ما يجيده البحث بالكلمات: الرموز والأرقام والأسماء والمصطلحات الدقيقة. لكن اسأل «كم يومًا أرتاح كل عام؟» ولن يجد شيئًا، لأن لا كلمة مشتركة مع «إجازة سنوية».
2الدمج بالمراتب: RRF
الآن عندك قائمتان مرتّبتان لنفس السؤال، وتريد قائمة واحدة. انظر إلى الرسم: القائمتان تدخلان، وتخرج قائمة يصعد فيها ما اتّفقتا عليه.
درجات BM25 والتشابه المتّجهي بمقياسين مختلفين لا يُجمعان مباشرة. RRF (Reciprocal Rank Fusion) يتجاهل الدرجات ويستخدم المراتب فقط: كل مستند يحصل على 1 / (60 + مرتبته) من كل قائمة.
def rrf(rankings, k=60):
scores = {}
for ranking in rankings:
for rank, doc in enumerate(ranking, start=1):
scores[doc] = scores.get(doc, 0) + 1 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)
keyword = [9, 6, 5, 2] # BM25 found the exact error code first
vector = [6, 5, 9, 7] # the vector search put it third
print("keyword:", keyword)
print("vector: ", vector)
print("fused: ", rrf([keyword, vector]))
المستند 6 صار أولًا لأنه عالٍ في القائمتين (الثاني والأول)، و9 ثانيًا لأنه أول في واحدة وثالث في الأخرى. RRF يكافئ الاتفاق: ما يتّفق عليه النظامان يصعد. وثابت 60 يمنع المرتبة الأولى في قائمة واحدة من السيطرة على كل شيء.
3لماذا الاثنان معًا
شغّلنا الطرق الثلاث على نفس الأسئلة العشرة. انظر إلى الرسم: كل طريقة وحدها أصابت 5 من 10 في المرتبة الأولى، والهجين أصاب 6.
عشرة أسئلة ذهبية، ثلاثة منها عن رموز دقيقة والباقي بإعادة صياغة. البديل المتّجهي هنا كلمات مضغوطة إلى ستة أبعاد «معنى».
import math
from collections import Counter
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.decomposition import TruncatedSVD
docs = [
"يحق للموظف إجازة سنوية مدتها واحد وعشرون يوم عمل في السنة.",
"تقدم طلبات الإجازة السنوية قبل أسبوعين على الأقل من موعدها.",
"الإجازة المرضية تحتاج تقريرا طبيا إذا زادت عن يومين متتاليين.",
"يوافق المدير المباشر على الإجازة المرضية والسنوية.",
"يمكن ترحيل عشرة أيام من الإجازة السنوية إلى السنة التالية فقط.",
"تصرف بدلات السفر خلال عشرة أيام من تقديم الفواتير.",
"يجب حجز تذاكر السفر عبر بوابة الحجز الداخلية.",
"ساعات العمل من الثامنة صباحا حتى الرابعة عصرا.",
"العمل عن بعد مسموح يومين في الأسبوع بموافقة المدير.",
"رمز الخطأ E-1043 في بوابة الحجز يعني انتهاء الجلسة.",
"رمز الخطأ E-2210 في بوابة الحجز يعني أن البطاقة مرفوضة.",
"يستخدم النموذج HR-22 لطلب شهادة راتب من الموارد البشرية.",
]
def words(text):
return [w.strip(".،؟") for w in text.split()]
tokenized = [words(d) for d in docs]
avg_len = sum(map(len, tokenized)) / len(tokenized)
df = Counter(w for d in tokenized for w in set(d))
def bm25_rank(query, k1=1.5, b=0.75):
scores = []
for d in tokenized:
tf = Counter(d)
scores.append(sum(math.log(1 + (len(docs) - df[w] + 0.5) / (df[w] + 0.5))
* tf[w] * (k1 + 1) / (tf[w] + k1 * (1 - b + b * len(d) / avg_len))
for w in words(query) if w in tf))
return [i for i in np.argsort(scores)[::-1] if scores[i] > 0]
# a stand-in for an embedding model: word TF-IDF compressed to 6 "meaning" dimensions
tfidf = TfidfVectorizer(analyzer="word", token_pattern=r"[^\s.،؟]+")
svd = TruncatedSVD(n_components=6, random_state=0)
vecs = svd.fit_transform(tfidf.fit_transform(docs))
vecs /= np.linalg.norm(vecs, axis=1, keepdims=True)
def vector_rank(query):
q = svd.transform(tfidf.transform([query]))[0]
return list(np.argsort(-(vecs @ (q / (np.linalg.norm(q) + 1e-9)))))
def rrf(rankings, k=60):
s = {}
for r in rankings:
for rank, d in enumerate(r, 1):
s[d] = s.get(d, 0) + 1 / (k + rank)
return sorted(s, key=s.get, reverse=True)
golden = [("ماذا يعني E-1043؟", 9), ("ما معنى E-2210؟", 10), ("أين أجد HR-22؟", 11), ("كم يوما أرتاح كل عام؟", 0),
("متى أطلب الإجازة؟", 1), ("من يعتمد إجازتي المرضية؟", 3),
("هل أستطيع العمل من البيت؟", 8), ("متى يبدأ الدوام؟", 7),
("كيف أحجز تذكرة طيران؟", 6), ("متى أستلم مال الفواتير؟", 5)]
for name, fn in [("keyword (BM25)", bm25_rank), ("vector", vector_rank),
("hybrid (RRF)", lambda q: rrf([bm25_rank(q), vector_rank(q)]))]:
top1 = sum(int(target in fn(q)[:1]) for q, target in golden)
top3 = sum(int(target in fn(q)[:3]) for q, target in golden)
print(f"{name:15} top-1: {top1}/{len(golden)} top-3: {top3}/{len(golden)}")
print("vector search for 'E-1043' ranks first:", docs[vector_rank("ماذا يعني E-1043؟")[0]])
كل طريقة وحدها أصابت 5 من 10 في المرتبة الأولى، لكن في أسئلة مختلفة، فالهجين أصاب 6. وانظر السطر الأخير: البحث المتّجهي لسؤال E-1043 أعاد مستند E-2210، لأن الرمزين في جملتين متشابهتين جدًّا. هذا بالضبط الخطأ الذي يرتكبه نموذج تضمين حقيقي مع أرقام الطلبات ورموز المنتجات، وBM25 لا يرتكبه أبدًا.
الأرقام هنا صغيرة لأن المجموعة عشرة أسئلة. في مشروعك، شغّل نفس المقارنة على خمسين سؤالًا ذهبيًّا، واعتمد الهجين فقط إن تفوّق، وهو ما يحدث في أغلب الحالات المنشورة.
الطريقتان أصابتا أسئلة مختلفة: الكلمات تجد الرموز الدقيقة مثل E-1043، والمتجهات تفهم إعادة الصياغة. الدمج يأخذ أحسن ما في الاثنين.
4إعادة الترتيب في الآخر
نموذج إعادة الترتيب يقرأ السؤال والمستند معًا، فيحكم على الصلة بدقة أعلى بكثير من مقارنة متّجهين. لكنه بطيء، فلا تعطه إلا القليل.
كل طريقة تجلب 50 مرشّحًا بسرعة، وRRF يدمجها، ثم يقرأ نموذج إعادة الترتيب أفضل 20 فقط، ويذهب إلى السياق 3 إلى 5.
كيف يعمل، خطوة بخطوة: كيف يُحسب ترتيب RRF
تشغّل BM25 والبحث المتجهي على نفس السؤال، فتحصل على قائمتين مرتّبتين.
تعطي كل مستند من كل قائمة 1 / (60 + مرتبته فيها)، وتهمل الدرجات الأصلية تمامًا.
تجمع نقاط كل مستند من القائمتين، فيرتفع ما يظهر عاليًا في الاثنتين، مثل المستند 6.
ترتّب بالمجموع، وترسل أفضل 20 إلى نموذج إعادة الترتيب، ثم تأخذ أفضل 3 إلى 5 للسياق.
مثال محلول
BM25 هو الخوارزمية التي تشغّل محرّكات البحث بالكلمات منذ عقود. كل كلمة مشتركة بين السؤال والمستند تضيف نقاطًا، والكلمات النادرة تضيف أكثر من الشائعة، والتكرار يفيد لكن بتناقص، والمستندات الطويلة تُعاقَب قليلًا.
مثال: الهجين داخل Postgres
WITH kw AS (
SELECT id, row_number() OVER (ORDER BY ts_rank(tsv, query) DESC) AS r
FROM chunks, plainto_tsquery('simple', $1) query
WHERE tsv @@ query
LIMIT 50
), vec AS (
SELECT id, row_number() OVER (ORDER BY embedding <=> $2) AS r
FROM chunks
ORDER BY embedding <=> $2
LIMIT 50
)
SELECT id, sum(1.0 / (60 + r)) AS score
FROM (SELECT * FROM kw UNION ALL SELECT * FROM vec) both_lists
GROUP BY id
ORDER BY score DESC
LIMIT 20;
$1 نص السؤال للبحث النصّي و$2 متّجهه. الإعداد simple لأن إعدادات Postgres اللغوية الجاهزة لا تشمل العربية، فتطبّع النص بنفسك قبل تخزين tsv (الدرس القادم). وأفضل 20 من هنا تذهب لنموذج إعادة الترتيب.
جرّب بلا كود
خرافة مقابل حقيقة
| الخرافة | الحقيقة |
|---|---|
| البحث المتجهي أحدث فهو أفضل من BM25 في كل شيء. | في المثال أعادت المتّجهات مستند E-2210 لسؤال عن E-1043. BM25 لا يخطئ هذا الخطأ مع الرموز والأرقام. |
| يكفي جمع درجة BM25 مع التشابه. | BM25 بلا حدّ أعلى والتشابه بين 0 و1، فيطغى الأول دائمًا. RRF يستخدم المراتب فلا يحتاج معايرة. |
| إعادة الترتيب تستحق دائمًا تكلفتها. | هي أغلى مرحلة، وفائدتها أحيانًا صغيرة على مستندات قصيرة ونظيفة. قِس الهجين معها وبدونها. |
جرّب بنفسك
مستخدم يبحث برقم الطلب ORD-88213. أي طريقة أقرب للنجاح؟
الرقم كلمة نادرة تظهر في مستند واحد، وهذا أقوى ما يجيده BM25. نموذج التضمين يرى «رقم طلب» ويخلط بين الأرقام المتقاربة.
لماذا يُطبَّق نموذج إعادة الترتيب على أفضل 20 فقط لا على كل المستندات؟
المتّجهات تُحسب مسبقًا مرة واحدة، أما إعادة الترتيب فتحتاج تشغيل النموذج على كل زوج (سؤال، مستند) وقت السؤال. على مليون مستند هذا مستحيل عمليًّا.
بطاقات مراجعة سريعة
تحقق من فهمك
لماذا لا نجمع درجة BM25 مع التشابه المتّجهي مباشرة؟
لأن BM25 بلا حدّ أعلى (4 أو 15 حسب السؤال) والتشابه بين 0 و1، فيطغى الأول دائمًا. يمكن التطبيع ثم الجمع بأوزان، لكن الأوزان تحتاج ضبطًا وتتغيّر مع كل سؤال. RRF يتجنّب هذا كله باستخدام المراتب.
لماذا يستخدم استعلام البحث النصي الكامل في Postgres الإعداد simple مع العربية؟
لأن إعدادات اللغات المدمجة في Postgres لا تغطّي العربية، فنستخدم simple ونطبّع النص بأنفسنا قبل تخزين tsv، كما في الدرس التالي.
مصادر هذا الدرس
مرتّبة بالنوع. ابدأ بالتوثيق الرسمي إذا تبي المرجع الدقيق، وبالمقال أو الفيديو إذا تبي الشرح أولًا.
خلّصت الدرس؟
سجّل الدخول ليُحفظ تقدّمك.