الساعة الثانية بعد منتصف الليل. تصلك تذكرة دعم فيها جملة واحدة:
دفعت بس حسابي لسا مجانيوفي قاعدة معرفتك — التي كتبتها بعناية وتضم آلاف المقالات — يوجد مقال اسمه بالضبط:
Subscription Activation Failureالمقال موجود. الجواب موجود. لكن محرك البحث في تطبيقك أعاد صفر نتائج، وأنت الآن تكتب الرد يدويًا للمرة الأربعين هذا الأسبوع.
لم تفشل قاعدة البيانات، ولم يخطئ المستخدم في السؤال. ما حدث ببساطة أن الاثنين يتحدثان عن الشيء نفسه بكلمات لا يشترك فيها حرف واحد. هذه هي الفجوة الدلالية (Semantic Gap): المسافة بين ما يعنيه المستخدم وبين الكلمات التي كتبها.
وهذه الفجوة ليست مشكلة ذكاء اصطناعي — إنها مشكلة تصميم بحث. والحل ليس استبدال كل شيء بـ AI، بل معرفة أي أداة تناسب أي نوع من الأسئلة.
في هذا المقال سنبني الحالة نفسها ثلاث مرات في Laravel 13: مرة بـ LIKE، ومرة بـ Full-Text Search، ومرة بـ Vector Search — ثم ندمجها في Hybrid Search. وسنرى بالتفصيل لماذا لا يوجد "أفضل دائمًا"، وأين تنهار كل طريقة بالضبط.
ما الذي يوفره Laravel 13 فعليًا (والقيود قبل الحماس)
Laravel 13 يقدم الطرق الثلاث داخل Query Builder نفسه:
whereLikeوorWhereLike— مطابقة أنماط نصية بطريقة موحّدة عبر قواعد البيانات المختلفة، مع إمكانية التحكم في حساسية حالة الأحرف.whereFullTextوorWhereFullText— بحث نصي يعتمد على فهارس Full-Text الأصلية، ومدعوم على MariaDB و MySQL و PostgreSQL.whereVectorSimilarTo— بحث دلالي بالتشابه المتجهي (Cosine Similarity).
وهنا القيد الذي يجب أن تعرفه قبل أن تخطط لأي شيء:
Vector Search في Laravel يتطلب PostgreSQL مع إضافة
pgvector(أو MongoDB عبر حزمة Laravel MongoDB)، بالإضافة إلى Laravel AI SDK. إذا كان تطبيقك يعمل على MySQL أو SQLite، فهذه الميزة غير متاحة لك أصلًا.
هذا القيد وحده يغيّر قرارات معمارية كثيرة. إن كنت على MySQL، فخياراتك الواقعية هي: Full-Text Search، أو Full-Text متبوعًا بـ Reranking عبر AI SDK، أو ترحيل جزء من بياناتك إلى PostgreSQL، أو استخدام محرك بحث خارجي عبر Scout.
الـ Dataset الذي سنعمل عليه
لنفترض جدول knowledge_articles بالأعمدة التالية:
id
title
content
embeddingوبداخله هذه المقالات:
- Failed Subscription Payments — Steps for resolving payments that were completed successfully but did not activate the customer's subscription.
- Password Reset Guide — How customers can recover access when they forget their account password.
- Refund Policy — Customers may receive reimbursement within seven days of purchase.
- Card Transaction Declined — Troubleshooting credit card transactions that are rejected during checkout.
- Subscription Activation — How subscriptions are activated after successful payment.
واستعلام المستخدم الذي سنختبر به كل شيء:
مشكلة دفع الاشتراكلاحظ التحدي: المستندات بالإنجليزية، والاستعلام بالعربية. هذا ليس سيناريو نادرًا — إنه الحالة الافتراضية في معظم منتجات المنطقة.
الطريقة الأولى: LIKE — البحث عن الأحرف
أبسط شكل ممكن:
use App\Models\KnowledgeArticle;
$articles = KnowledgeArticle::query()
->whereLike('title', '%payment%')
->get();التوقيع الكامل للطريقة هو:
whereLike(
string $column,
string $value,
bool $caseSensitive = false,
string $boolean = 'and',
bool $not = false
)وميزتها على كتابة where('title', 'LIKE', '%payment%') يدويًا أنها توفر طريقة موحّدة عبر قواعد البيانات المختلفة مع إمكانية التحكم في حساسية حالة الأحرف، والمطابقة غير حسّاسة لحالة الأحرف افتراضيًا. (خيار حساسية الأحرف غير مدعوم حاليًا على SQL Server.)
السؤال الذي تطرحه LIKE بسيط جدًا:
هل هذه الأحرف موجودة داخل النص؟نقطة أداء يتجاهلها الجميع
هذه ليست تفصيلة ثانوية، فهي تحدد إن كان بحثك سيصمد عند مليون صف:
LIKE 'INV-928%' → يستطيع استخدام فهرس B-tree ✔
LIKE '%928184%' → Full Table Scan ✘أي wildcard في بداية النمط يُلغي قدرة قاعدة البيانات على استخدام الفهرس، لأن فهرس B-tree مرتّب من اليسار. لهذا فإن:
Customer::query()
->whereLike('email', '%gmail.com')
->get();سيقرأ الجدول كاملًا. إن كنت تحتاج هذا النوع من البحث بكثرة، فالحل الصحيح ليس فهرسًا أفضل، بل عمود مشتق (مثل email_domain) وفهرس عليه.
أين تنهار LIKE
المقال:
Failed Subscription Paymentsوالمستخدم يبحث:
billing problemلا يوجد تطابق. لا لأن الجواب غير موجود، بل لأن السلسلتين لا تشتركان في تتابع أحرف واحد.
والمشكلة تتضخم بالعربية بسبب طبيعة اللغة نفسها:
Text : فشل عملية الدفع الخاصة بالاشتراك
Query : دفعت بس حسابي ما اشتغلهنا تجتمع ثلاث مشاكل دفعة واحدة:
- الاشتقاق: "دفعت" و"الدفع" جذرهما واحد، لكن أحرفهما مختلفة.
- السوابق واللواحق الملتصقة: "بالاشتراك" تحتوي "اشتراك" لكن "الاشتراك" لا تطابق "اشتراك" إلا بـ wildcard من الطرفين.
- اختلاف صور الحروف: أ / إ / آ / ا، و ة / ه، و ي / ى، إضافة إلى التشكيل. مستخدم يكتب "اشتراكى" لن يطابق "اشتراكي" حرفيًا.
إن كنت مضطرًا لاستخدام LIKE مع العربية، فالحد الأدنى المقبول هو تخزين عمود مُطبَّع (normalized) تُوحّد فيه هذه الصور وتحذف التشكيل، ثم تبحث فيه بعد تطبيق التطبيع نفسه على استعلام المستخدم.
متى تكون LIKE هي الخيار الصحيح؟
الخطأ المعاكس — وهو شائع منذ ظهور الـ Embeddings — هو اعتبار LIKE أداة بائدة. هي ليست كذلك. عندما يكتب المستخدم:
INV-928184
FXG-1029
john@example.com
0599123456فهو لا يريد "معنى" إطلاقًا. يريد مطابقة حرفية دقيقة:
Customer::query()
->whereLike('phone', '0599123%')
->limit(20)
->get();لا يوجد أي مبرر لتحويل رقم هاتف إلى Embedding. بل إن فعل ذلك يجعل النتائج أسوأ: نموذج الـ Embedding سيرى الأرقام على أنها "نص يشبه أرقام هواتف أخرى" ويعيد لك عملاء لا علاقة لهم بالبحث.
الطريقة الثانية: Full-Text Search — البحث عن الكلمات
قواعد البيانات الحديثة تحتوي محركات بحث نصي مدمجة. الفرق الجوهري عن LIKE هو أن Full-Text Search يفهم حدود الكلمات والاشتقاق (stemming) ودرجة الصلة (relevance) — فبحث عن "running" يمكن أن يطابق سجلًا يحتوي "run".
أولًا: الفهرس
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
Schema::create('knowledge_articles', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('content');
$table->timestamps();
$table->fullText(['title', 'content']);
});وعلى PostgreSQL تحديدًا يمكنك ضبط اللغة التي يعتمد عليها الاشتقاق:
$table->fullText('content')->language('english');ثانيًا: الاستعلام
$articles = KnowledgeArticle::query()
->whereFullText(['title', 'content'], 'subscription payment')
->get();يقوم Laravel بتوليد الـ SQL المناسب لكل driver: MATCH(...) AGAINST(...) على MariaDB و MySQL، و to_tsvector(...) @@ plainto_tsquery(...) على PostgreSQL.
فرق حاسم بين MySQL و PostgreSQL
هذه النقطة تُنسى كثيرًا وتسبب ارتباكًا حقيقيًا في الإنتاج:
| قاعدة البيانات | سلوك whereFullText |
|---|---|
| MariaDB / MySQL | تُصفّي النتائج وتُرتّبها تلقائيًا حسب درجة الصلة |
| PostgreSQL | تُصفّي النتائج المطابقة فقط، دون ترتيب حسب الصلة |
إذا كنت على PostgreSQL وتحتاج ترتيبًا تلقائيًا حسب الصلة، فالحل الموصى به هو استخدام Database Engine الخاص بـ Laravel Scout، الذي يتولى هذا الترتيب نيابةً عنك.
لماذا Full-Text أفضل من سلسلة LIKE؟
لأنك مع LIKE ستنتهي حتمًا إلى كتابة شيء كهذا:
$query->whereLike('content', '%subscription%')
->orWhereLike('content', '%payment%')
->orWhereLike('content', '%failed%');ثم تكتشف أنك تبني محرك بحث بيدك وأنت تجيب على أسئلة لا تنتهي: AND أم OR؟ أي كلمة أهم؟ كيف نرتّب النتائج؟ ماذا عن الكلمات الشائعة؟ ماذا عن صيغ الكلمة المختلفة؟ Full-Text Search مُصمَّم أصلًا للإجابة على هذه الأسئلة عبر فهرس مقلوب (inverted index) ودرجات صلة محسوبة.
لكن Full-Text ليس Semantic Search — وبالعربية المشكلة أكبر
Full-Text أفضل من LIKE، لكنه ما يزال يعمل على مستوى الكلمات، لا المعنى. ومع العربية تحديدًا تظهر عقبات إضافية عملية:
- الاشتقاق العربي معقد: العربية لغة اشتقاقية غير خطية، ومحركات الاشتقاق الجاهزة تعطي نتائج متفاوتة. تحقّق من الإعدادات اللغوية المتاحة فعليًا في نسختك من PostgreSQL عبر
\dFقبل أن تفترض دعمًا معيّنًا. - الحد الأدنى لطول الكلمة: في InnoDB يتجاهل الفهرس افتراضيًا الكلمات الأقصر من ثلاثة أحرف (
innodb_ft_min_token_size). كلمات عربية كثيرة قصيرة وذات معنى. - قوائم الكلمات الشائعة (stopwords) الافتراضية مبنية على الإنجليزية.
- عبر اللغات؟ مستحيل: مهما ضبطت الإعدادات، لن يربط Full-Text بين
reimbursementو"إرجاع المصاري". لا توجد كلمة مشتركة يمكن مطابقتها.
وهنا بالضبط تبدأ الحاجة إلى نوع مختلف تمامًا من البحث.
الطريقة الثالثة: Embeddings و Vector Search — البحث عن المعنى
ما هو الـ Embedding فعلًا؟
الـ Embedding هو مصفوفة رقمية عالية الأبعاد (مئات أو آلاف الأرقام عادةً) تمثّل المعنى الدلالي لقطعة نص. نموذج مُدرَّب على كميات ضخمة من النصوص يتعلّم أن يضع النصوص المتقاربة في المعنى قريبة من بعضها في فضاء متجهي.
use Illuminate\Support\Str;
$embedding = Str::of('Payment completed but subscription was not activated.')
->toEmbeddings();
// [0.018, -0.293, 0.817, 0.104, ...]هذه الأرقام ليست كلمات ولا يمكن قراءتها. لا يوجد بُعد اسمه "الدفع" وآخر اسمه "الاشتراك". ما يهم هو الموقع النسبي: نصّان متقاربان في المعنى ينتج عنهما متجهان متقاربان في الاتجاه.
كيف تُقاس القرابة؟ Cosine Similarity
المقياس المستخدم هو جيب تمام الزاوية بين المتجهين:
similarity(A, B) = (A · B) / (‖A‖ × ‖B‖)النتيجة بين -1 و 1، حيث 1 يعني اتجاهًا متطابقًا. والملاحظة المهمة أن المقياس يعتمد على الاتجاه لا الطول — ولهذا لا يؤثر طول النص بحد ذاته على القياس بقدر ما يؤثر مضمونه. وفي pgvector يقابل ذلك المعامل <=> الذي يعطي مسافة جيب التمام، أي:
cosine_similarity = 1 - cosine_distanceتوليد Embeddings بكفاءة
توليد Embedding واحد لكل مقال في حلقة يعني استدعاء API لكل عنصر. الأفضل هو التوليد بالجملة، لأنه يتطلب استدعاءً واحدًا فقط لمزوّد الخدمة:
use Laravel\Ai\Embeddings;
$response = Embeddings::for([
'Napa Valley has great wine.',
'Laravel is a PHP framework.',
])->generate();
$response->embeddings; // [[0.123, 0.456, ...], [0.789, 0.012, ...]]هذه هي الطريقة الصحيحة لعمل Backfill لعشرة آلاف مقال: قسّمها إلى دفعات (chunks) وأرسل كل دفعة مرة واحدة.
تخزين المتجهات وفهرستها
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
Schema::ensureVectorExtensionExists();
Schema::create('knowledge_articles', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('content');
$table->vector('embedding', dimensions: 1536)->index();
$table->timestamps();
});ثلاث نقاط جوهرية في هذه الـ Migration:
Schema::ensureVectorExtensionExists()يضمن تفعيل إضافةpgvectorقبل إنشاء الجدول.- عدد الأبعاد ليس رقمًا عشوائيًا. القيمة
1536هنا تطابق مخرجات نموذجtext-embedding-3-smallمن OpenAI مثالًا. إن غيّرت النموذج وتغيّر عدد الأبعاد، فأنت أمام Migration جديدة وإعادة توليد كل المتجهات — لا يمكن مقارنة متجهات بأبعاد مختلفة. - استدعاء
index()ينشئ فهرس HNSW (Hierarchical Navigable Small World)، وهو ما يسرّع بحث التشابه بشكل هائل على البيانات الكبيرة. بدونه ستقارن قاعدة البيانات المتجه بكل صف في الجدول.
ثم على الـ Model، حوّل العمود إلى مصفوفة ليتولى Laravel التحويل بين مصفوفات PHP وصيغة المتجه في قاعدة البيانات:
class KnowledgeArticle extends Model
{
protected function casts(): array
{
return [
'embedding' => 'array',
];
}
}الاستعلام بالتشابه
$articles = KnowledgeArticle::query()
->whereVectorSimilarTo('embedding', 'مشكلة دفع الاشتراك')
->limit(5)
->get();سطر واحد. وما يحدث خلفه: عندما تُمرَّر سلسلة نصية عادية بدل مصفوفة متجه، يولّد Laravel الـ Embedding تلقائيًا عبر المزوّد المُعدّ لديك، ثم يقارنه بالمتجهات المخزّنة باستخدام Cosine Similarity، ويستبعد ما هو دون العتبة، ويرتّب النتائج تلقائيًا من الأكثر تشابهًا.
التوقيع الكامل:
whereVectorSimilarTo(
string $column,
array|string $vector,
float $minSimilarity = 0.6,
bool $order = true
)ولمن يحتاج تحكمًا أدق، تتوفر أيضًا whereVectorDistanceLessThan و selectVectorDistance و orderByVectorDistance للعمل مباشرةً مع قيم المسافة بدل درجات التشابه، أو لإظهار المسافة المحسوبة كعمود في النتائج، أو للتحكم اليدوي في الترتيب.
تقسيم النصوص الطويلة (Chunking)
خطأ شائع: توليد Embedding واحد لمقال من ثلاثة آلاف كلمة. النتيجة متجه "متوسط" باهت لا يمثّل أي فكرة بدقة، فتضعف نتائج البحث كلما طال المستند.
الحل هو تقسيم المحتوى إلى مقاطع (عادةً بين ٢٠٠ و٥٠٠ كلمة، مع تداخل بسيط بينها للحفاظ على السياق)، وتخزين كل مقطع كصف مستقل بمتجهه الخاص مرتبطًا بالمقال الأصلي. عندها يعيد البحث الفقرة المناسبة لا المقال كاملًا — وهذا أهم بكثير في سيناريوهات RAG.
إبقاء المتجهات متزامنة مع النص
إذا تغيّر المحتوى ولم يتغيّر المتجه، يصبح لديك:
content ≠ embeddingوهذا أسوأ من عدم وجود بحث دلالي أصلًا، لأنه يعيد نتائج تبدو صحيحة وهي مبنية على نص قديم. تخيّل مقالًا تغيّرت سياسته من "الاسترجاع خلال ٧ أيام" إلى "١٤ يومًا" بينما المتجه ما زال يمثّل النسخة القديمة.
الحل هو ربط التوليد بدورة حياة الـ Model:
namespace App\Observers;
use App\Jobs\GenerateArticleEmbedding;
use App\Models\KnowledgeArticle;
class KnowledgeArticleObserver
{
public function saved(KnowledgeArticle $article): void
{
if (! $article->wasRecentlyCreated
&& ! $article->wasChanged(['title', 'content'])) {
return;
}
GenerateArticleEmbedding::dispatch($article->id);
}
}وداخل الـ Job:
use Illuminate\Support\Str;
$article->updateQuietly([
'embedding' => Str::of($article->title."\n".$article->content)->toEmbeddings(),
]);ثلاثة قرارات مقصودة هنا:
dispatchإلى طابور: توليد الـ Embedding استدعاء شبكي؛ لا مكان له داخل دورة الطلب.updateQuietly: يمنع إطلاق أحداث الـ Model مجددًا، وبالتالي يمنع حلقة لا نهائية بين الـ Observer والـ Job.- دمج العنوان مع المحتوى: العنوان غالبًا أكثف مصدر للمعنى في المقال، وإهماله يضيّع إشارة مجانية قوية.
عتبة التشابه: المشكلة التي لا يراها أحد حتى الإنتاج
البحث المتجهي لا يعيد "لا نتائج" من تلقاء نفسه. مهما كان السؤال غريبًا، سيوجد دائمًا مستند أقرب من غيره:
Knowledge Base : Laravel, Payments, Subscriptions
Query : كيف أغيّر زيت سيارتي؟ستحصل على نتيجة. ستكون بلا معنى، لكنها ستُعرض للمستخدم بثقة تامة. ولهذا فإن قيمة minSimilarity ليست تفصيلة تجميلية:
$articles = KnowledgeArticle::query()
->whereVectorSimilarTo(
column: 'embedding',
vector: $query,
minSimilarity: 0.72,
)
->limit(5)
->get();القيمة الافتراضية للطريقة هي 0.6، والعتبة قيمة بين 0.0 و 1.0 حيث 1.0 يعني تطابق المتجهين. ولا تنسَ الجانب الآخر: عتبة مرتفعة جدًا تعني نتائج صفرية لأسئلة مشروعة. رسالة "لم نجد مقالًا مناسبًا، هل تود التواصل مع الدعم؟" أفضل بكثير من مقال عشوائي، لكن إظهارها لنصف المستخدمين كارثة أيضًا.
لا تختر 0.72 لأنها تبدو رقمًا جيدًا. العتبة المناسبة تعتمد على نموذج الـ Embedding وعلى بياناتك وعلى طبيعة أسئلة مستخدميك، ولا سبيل لمعرفتها إلا بالقياس — وهو ما سنصل إليه بعد قليل.
دمج البحث المتجهي مع الفلاتر التقليدية
البحث الدلالي لا يلغي منطق العمل. في تطبيق متعدد المستأجرين، لا يجوز أن يرى مستخدم من فريق مقالات فريق آخر مهما كان التشابه الدلالي عاليًا:
$documents = Document::query()
->where('team_id', $user->team_id)
->whereVectorSimilarTo('embedding', $request->input('query'))
->limit(10)
->get();هذا النمط — تشابه متجهي داخل نطاق محدود بـ where عادية — هو الشكل الافتراضي الصحيح لأي بحث دلالي في تطبيق حقيقي، لا الاستثناء.
Hybrid Search: لماذا الاختيار بين الطرق سؤال خاطئ
تأمل هذا الاستعلام الواقعي جدًا:
INV-839102 دفعت بس Premium ما اشتغلبداخله شيئان مختلفان تمامًا:
INV-839102— معرّف دقيق لا يقبل التقريب.- "دفعت بس Premium ما اشتغل" — معنى بلغة طبيعية عامية مختلطة.
مطابقة حرفية ممتازة للأول وعاجزة تمامًا عن الثاني. وبحث متجهي ممتاز للثاني وضعيف — بل مضلّل — مع الأول. فلماذا نختار واحدًا؟
النمط الأول: Full-Text ثم Reranking
هذا أبسط شكل من الدمج، ولا يتطلب تخزين أي متجهات. Reranking تقنية يعيد فيها نموذج AI ترتيب مجموعة من النتائج حسب صلتها الدلالية بالاستعلام، وهي تعمل على أي نص خام دون الحاجة إلى حساب Embeddings مسبقًا:
$articles = Article::query()
->whereFullText('body', $request->input('query'))
->limit(50)
->get()
->rerank('body', $request->input('query'), limit: 10);الفكرة: استخدم Full-Text السريع لتقليص آلاف السجلات إلى خمسين مرشحًا، ثم دع نموذجًا دلاليًا يرتّب هؤلاء الخمسين. تحصل على سرعة البحث النصي داخل قاعدة البيانات مع دقة الترتيب الدلالي.
ويمكن استخدام الـ Reranking مباشرة على أي مصفوفة نصوص:
use Laravel\Ai\Reranking;
$response = Reranking::of([
'Django is a Python web framework.',
'Laravel is a PHP web application framework.',
'React is a JavaScript library for building user interfaces.',
])->rerank('PHP frameworks');
$response->first()->document;ملاحظة مهمة لمن هم على MySQL: هذا النمط لا يتطلب pgvector ولا عمود متجه إطلاقًا. إنه أسرع طريق لإضافة فهم دلالي إلى بحثك دون تغيير قاعدة البيانات.
النمط الثاني: دمج نتيجتين بـ Reciprocal Rank Fusion
عندما تشغّل بحثين متوازيين، تواجه سؤالًا حقيقيًا: كيف تدمج قائمتين درجاتهما غير قابلة للمقارنة أصلًا؟ درجة صلة MySQL ليست على مقياس Cosine Similarity، وجمعهما مباشرة بلا معنى.
الحل القياسي في محركات البحث هو تجاهل الدرجات نهائيًا واستخدام الترتيب فقط:
$textResults = KnowledgeArticle::query()
->whereFullText(['title', 'content'], $query)
->limit(20)
->get();
$vectorResults = KnowledgeArticle::query()
->whereVectorSimilarTo('embedding', $query, minSimilarity: 0.70)
->limit(20)
->get();ثم:
/**
* Reciprocal Rank Fusion
*
* score(doc) = Σ weight / (k + rank)
*/
function fuse(array $lists, int $k = 60, int $limit = 10): array
{
$scores = [];
$models = [];
foreach ($lists as $list) {
$weight = $list['weight'];
foreach (array_values($list['results']->all()) as $rank => $model) {
$scores[$model->id] ??= 0.0;
$scores[$model->id] += $weight / ($k + $rank + 1);
$models[$model->id] = $model;
}
}
arsort($scores);
return array_slice(
array_map(fn ($id) => $models[$id], array_keys($scores)),
0,
$limit
);
}
$final = fuse([
['results' => $textResults, 'weight' => 1.0],
['results' => $vectorResults, 'weight' => 1.4],
]);لماذا هذه المعادلة تحديدًا؟ لأن k (وقيمته المعتادة ٦٠) يخفف الفارق بين المراكز الأولى فلا تسيطر قائمة واحدة على النتيجة، ولأن مستندًا يظهر في المركز الخامس في كلتا القائمتين يتفوّق غالبًا على مستند يتصدّر واحدة ويغيب عن الأخرى — وهو سلوك مرغوب تمامًا. أما الأوزان فهي مكانك للتعبير عن منطق العمل: ارفع وزن البحث المتجهي إن كان معظم مستخدميك يكتبون بلغة طبيعية، واخفضه في قاعدة معرفة تقنية مليئة بأكواد الأخطاء.
معمارية كاملة
User Query
|
Query Classification
(identifier? keywords? natural language?)
|
+--------------+--------------+
| | |
Exact Full-Text Vector
(LIKE / =) Search Search
| | |
IDs Keywords Semantics
| | |
+--------------+--------------+
|
Fusion (RRF + weights)
|
Reranking
|
Final Top Results
|
+--------------+--------------+
| |
Search UI AI Agent
|
RAG
|
Answerولا يشترط بناء هذا كاملًا من اليوم الأول. ابدأ بـ Full-Text، أضف Reranking عند الحاجة، ثم أدخل المتجهات إن أثبت القياس أنك تحتاجها.
تصنيف الاستعلام: أرخص تحسين ممكن
لا تحتاج نموذجًا لتحديد نوع السؤال. تعبير نمطي بسيط يكفي لالتقاط أغلب الحالات:
$looksLikeIdentifier = (bool) preg_match(
'/\b([A-Z]{2,6}-\d{3,}|\d{6,})\b/u',
$query
);
if ($looksLikeIdentifier) {
// ابدأ بمطابقة حرفية دقيقة، ولا تنفق استدعاء AI أصلًا
}هذا الشرط وحده يوفّر آلاف استدعاءات الـ Embedding شهريًا في أي نظام دعم فني حقيقي.
التكلفة والزمن: البحث الدلالي ليس مجانيًا
LIKE و Full-Text ينفّذان بالكامل داخل قاعدة البيانات. أما البحث المتجهي فيضيف:
- تكلفة استيعاب: توليد Embedding لكل مستند عند الإنشاء والتحديث.
- زمن استجابة إضافي: كل استعلام جديد يحتاج استدعاء شبكي لتوليد متجه السؤال قبل أن تبدأ قاعدة البيانات عملها.
- تخزين: متجه بـ 1536 بُعدًا من نوع
float4يقارب ٦ كيلوبايت لكل صف — أي نحو ٦٠ ميغابايت لعشرة آلاف صف، قبل حساب فهرس HNSW نفسه.
وأهم تحسين عملي هنا هو التخزين المؤقت لمتجهات الأسئلة. في أنظمة الدعم، نسبة كبيرة من الأسئلة متكررة حرفيًا: خزّن المتجه بمفتاح مشتق من النص المُطبَّع، وستحذف استدعاء API كاملًا من مسار الاستجابة لجزء معتبر من الطلبات. (وتذكّر أن تُبطل الذاكرة المؤقتة إذا غيّرت نموذج الـ Embedding.)
الخلاصة: لا تدفع هذه التكلفة في بحث لا يحتاج فهمًا دلاليًا أصلًا.
القياس: لا تُقيّم بحثك بالانطباع
هذه هي الخطوة التي يتخطاها معظم المطورين، وهي الوحيدة التي تفصل بين بحث يعمل وبحث يبدو أنه يعمل. ابنِ مجموعة اختبار من أسئلة حقيقية من سجلات الدعم لديك مع المقال الصحيح لكل سؤال:
Query: دفعت بس الاشتراك ما تفعّل → Expected: Subscription Activation Problem
Query: نسيت كلمة السر → Expected: Password Reset Guide
Query: بدي مصاري الاشتراك ترجع → Expected: Refund Policy
Query: INV-928184 → Expected: Invoice #928184ثم قِس بمقاييس معروفة بدل "بدت النتائج جيدة":
- Recall@k — نسبة الأسئلة التي ظهر فيها المستند الصحيح ضمن أول
kنتائج. هذا هو المقياس الحاسم إذا كان البحث يغذّي وكيل RAG، لأن ما لا يُسترجَع لا يمكن للنموذج أن يجيب عنه. - MRR (Mean Reciprocal Rank) — متوسط مقلوب رتبة أول نتيجة صحيحة. يكافئ وضع الجواب في الأعلى لا مجرد وجوده.
- nDCG@k — عندما تكون الصلة متدرّجة لا ثنائية (مقال ممتاز / مقبول / غير ذي صلة).
- معدل الصفر (Zero-result rate) — لضبط
minSimilarityتحديدًا: كم نسبة الأسئلة المشروعة التي لم تُعِد شيئًا؟
ثم شغّل الطرق الأربع على المجموعة نفسها وقارن. جدول كهذا هو ما يجب أن تنتهي إليه:
| الطريقة | Recall@1 | Recall@3 | Recall@5 |
|---|---|---|---|
| LIKE | 42% | 55% | 60% |
| Full-Text | 61% | 73% | 79% |
| Vector | 78% | 89% | 93% |
| Hybrid | 84% | 94% | 97% |
تنبيه: الأرقام أعلاه توضيحية بحتة ولا تمثّل قياسًا حقيقيًا لأي نظام. الغرض منها هو شكل الجدول لا قيمه — بياناتك ستعطي أرقامًا مختلفة تمامًا، وقد تجد أن Full-Text وحده يكفيك تمامًا.
وماذا عن Laravel Scout؟
كل ما سبق طرق تستدعيها يدويًا في الكود. Scout يقدّم مقاربة مختلفة عبر السمة Searchable التي تُبقي فهارس البحث متزامنة تلقائيًا مع الـ Models عند الإنشاء والتحديث والحذف. ويمكنك التحكم في استراتيجية البحث لكل عمود عبر PHP Attributes:
use Laravel\Scout\Attributes\SearchUsingFullText;
use Laravel\Scout\Attributes\SearchUsingPrefix;
use Laravel\Scout\Searchable;
class Article extends Model
{
use Searchable;
#[SearchUsingPrefix(['id'])]
#[SearchUsingFullText(['title', 'body'])]
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'title' => $this->title,
'body' => $this->body,
];
}
}لاحظ أن هذا مثال حيّ على فكرة المقال كلها: معرّف يُبحث فيه بالبادئة، ونص يُبحث فيه بـ Full-Text — استراتيجيتان مختلفتان داخل النموذج الواحد. والميزة الإضافية أن Database Engine في Scout يرتّب النتائج حسب الصلة تلقائيًا حتى على PostgreSQL.
أما المحركات الخارجية مثل Algolia و Meilisearch و Typesense فتصبح منطقية عندما تحتاج تحمّل الأخطاء الإملائية أو الفلترة المتعددة الأوجه أو البحث الجغرافي على نطاق ضخم.
Vector Search ليست سحرًا: ثلاث حالات لا تستخدمها فيها
المعرّفات. بحث عن
Order #928174لا يحتاج فهمًا دلاليًا:Order::where('order_number', '928174')->first();رموز المنتجات. من يبحث عن
FX-PRO-9281لا يريد منتجًا "يشبه في المعنى" هذا الرمز. يريد المنتج نفسه.الأسماء. البحث عن "Ahmad Mahmoud" يحتاج مطابقة بادئة أو جزئية أو تحمّلًا للأخطاء الإملائية — لا تشابهًا دلاليًا. البحث الدلالي هنا قد يعيد لك "Mohammed Ahmed" لأنه "اسم عربي مشابه"، وهي نتيجة عديمة الفائدة.
والقاعدة العامة: إن كان النص المطلوب رمزًا لا لغة، فالبحث الدلالي يضرّ ولا ينفع.
حالة خاصة: البحث في الأخطاء التقنية
هذه الحالة توضّح التكامل بشكل جميل. لديك:
SQLSTATE[23000]مستخدم يبحث بـ SQLSTATE 23000 → مطابقة حرفية أو Full-Text ممتازة.
ومستخدم آخر يكتب: "ليش Laravel بعطيني duplicate database value؟" → هنا قد يعثر البحث الدلالي على مقال يشرح Integrity constraint violation رغم غياب أي كلمة مشتركة.
السؤالان يقصدان الشيء نفسه تمامًا. ونظام بحث جيد يجيب عليهما معًا.
علاقة البحث بـ RAG
عندما تبني وكيل دعم فني، فإن البحث ليس ميزة جانبية — إنه نصف النظام:
Question
↓
Hybrid Search ← Retrieval
↓
Top Relevant Chunks
↓
AI Agent ← Generation
↓
Answerومن هنا تأتي قاعدة مهمة: لا تجعل الـ LLM يبحث بنفسه في قاعدة البيانات.
تصميم سيئ: تحميل عشرة آلاف مقال، إرسالها كلها إلى النموذج، ثم مطالبته بإيجاد المقال المناسب. هذا مكلف وبطيء، وتتدهور دقته كلما امتلأ السياق.
التصميم الصحيح: قاعدة البيانات تُرشّح، والنموذج يفهم ويصوغ. استخدم كلًا منهما فيما يجيده — الفلترة والفهرسة والاسترجاع من جهة، والفهم والاستدلال وتوليد اللغة من جهة أخرى.
ولاحظ أن جودة إجابة الوكيل محكومة سقفها بجودة الاسترجاع: مستند لم يُسترجَع لا يمكن لأي نموذج أن يجيب منه، مهما كان ذكيًا.
مقارنة نهائية
| الطريقة | تفهم | ممتازة لـ | المتطلبات |
|---|---|---|---|
whereLike | الأحرف والسلاسل الجزئية | المعرّفات، البريد، الهواتف، رموز المنتجات | لا شيء |
whereFullText | الكلمات وحدودها واشتقاقها ودرجة صلتها | المقالات، التوثيق، أوصاف المنتجات، تذاكر الدعم | فهرس Full-Text — MariaDB / MySQL / PostgreSQL |
| Reranking | الصلة الدلالية لنص خام | تحسين ترتيب نتائج موجودة دون تخزين متجهات | Laravel AI SDK فقط |
whereVectorSimilarTo | المعنى والتشابه الدلالي | اللغة الطبيعية، البحث عبر اللغات، RAG | PostgreSQL + pgvector (أو MongoDB) + AI SDK |
| Hybrid | الكلمة والمعنى معًا | أنظمة بحث إنتاجية بأسئلة مختلطة | ما سبق + منطق دمج وترتيب |
الخلاصة
قبل الذكاء الاصطناعي كان سؤالنا:
هل يحتوي هذا النص على هذه الأحرف؟ثم صار بإمكاننا أن نسأل:
أي المستندات أكثر صلة بهذه الكلمات؟واليوم أصبح بإمكاننا طرح سؤال مختلف نوعيًا:
أي المستندات أقرب في المعنى إلى ما يقصده المستخدم؟ولهذا يمكن لـ "مشكلة دفع الاشتراك" أن تقودك إلى Failed Subscription Activation رغم أنهما لا يشتركان في كلمة واحدة، ولا حتى في لغة واحدة.
لكن هذا لا يعني أن البحث الدلالي بديل عن كل شيء. الأداة الصحيحة هي:
LIKE— حين يهمّك النص نفسه.- Full-Text — حين تهمّك الكلمات وصلتها بالمستند.
- Reranking — حين تريد ترتيبًا دلاليًا دون بناء بنية متجهية.
- Vector Search — حين يهمّك المعنى.
- Hybrid — حين يحتوي سؤال المستخدم على كل ذلك دفعة واحدة، وهو الغالب.
Laravel اليوم يوفّر هذه المستويات كلها داخل النظام البيئي نفسه — من whereLike و whereFullText إلى whereVectorSimilarTo والـ Reranking عبر AI SDK وصولًا إلى Scout — دون الحاجة إلى بنية تحتية منفصلة في معظم الحالات.
لذلك لم يعد السؤال:
"هل أستبدل بحثي العادي ببحث AI؟"
بل صار:
"أي نوع من البحث يناسب هذا النوع من الأسئلة — وكيف أقيس ذلك على بياناتي أنا؟"
وحين تدمج الاثنين بشكل صحيح، تحصل على محرك بحث يفهم الكلمة حين تكون الكلمة مهمة، ويفهم المعنى حين تختلف الكلمات.

مقال جميل جدا .. شكرا جزيلا لك
الف شكر لك، استاذ واثق، منكم نتعلم