كلاود
development

بحث عربي ذكي في تطبيقك: Meilisearch على سيرفرك

كلاود|١٩ أكتوبر ٢٠٢٦|6 دقائق قراءة|١٢٨ مشاهدة
بحث عربي ذكي في تطبيقك: Meilisearch على سيرفرك

بحث LIKE يفشل مع الهمزات والتاء المربوطة والتشكيل، بينما Meilisearch يوحّد الحروف العربية ويتسامح مع الأخطاء ويرجع النتائج في أقل من 50ms. مليون سجل يحتاج من 2 إلى 4GB ذاكرة، والمفتاح الرئيسي لا يغادر السيرفر.

إذا كان تطبيقك يبحث في النصوص العربية بـLIKE '%term%' فمستخدموك يفوّتون نتائج كل يوم. العميل يكتب مدرسه والسجل محفوظ مدرسة، أو يكتب مستشفي والبيانات فيها مستشفى، فيرجع الاستعلام فاضياً. الحل اللي يناسب أغلب الفرق هو Meilisearch على سيرفرك أنت: محلّله يوحّد أشكال الحروف العربية ويحذف التشكيل، ويتسامح مع الأخطاء الإملائية من غير أي ضبط، ويرجع نتائج مرتبة في أقل من 50ms حتى مع ملايين السجلات، ويرتبط بـLaravel عبر Scout بنحو عشرة أسطر إعداد. كتالوج فيه مليون منتج بحجم 1KB تقريباً لكل سجل يحتاج غالباً من 3 إلى 6GB على القرص للفهرس، ويشتغل براحة مع 2 إلى 4GB ذاكرة متاحة للتخزين المؤقت، يعني سيرفر بـ4 vCPU و8GB يشيله مع تطبيقك. الجزء اللي ما فيه تهاون هو الأمان: Meilisearch يستمع على 127.0.0.1 فقط، ويشتغل بمفتاح رئيسي لا يقل عن 16 بايت، والمتصفح ما يشوف إلا مفتاح بحث فقط أو توكن مستأجر. في هذا المقال نشرح ليش يفشل بحث SQL في العربي، وكيف يعالجه Meilisearch، وربط Scout، وحساب الذاكرة، وإدارة المفاتيح، ومقارنته بـFULLTEXT في MySQL وOpenSearch.

ليش يفشل LIKE مع العربي

في العربية حروف يكتبها الناس بالتبادل، وSQL يعاملها كأنها حروف مختلفة تماماً. أشهر نقاط التعثر:

  • أشكال الهمزة: أ وإ وآ وا تُكتب غالباً بنفس الطريقة من لوحة الجوال، وبعض ترتيبات MySQL توحّد شيئاً منها وبعضها لا، ويتغير السلوك بين utf8mb4_general_ci وutf8mb4_0900_ai_ci.
  • التاء المربوطة والهاء: ة وه في آخر الكلمة مثل مدرسة ومدرسه، ولا يوجد ترتيب قياسي يعتبرهما متساويتين.
  • الألف المقصورة والياء: ى وي مثل مستشفى ومستشفي.
  • التشكيل والتطويل: عنوان محفوظ مُحاسَبة أو مـحاسبة لن يطابق بحثاً عادياً ما لم يتجاهلهما الترتيب.
  • أداة التعريف: المستخدم يبحث عن سيارة والنص فيه السيارة، ولا يوجد ترتيب يكافئ المطابقة الأقرب.

فوق مشكلة المطابقة، LIKE '%term%' ما يقدر يستخدم فهرس B-tree، فكل بحث يمسح الجدول كاملاً. على 500,000 صف يعني هذا غالباً من 300 إلى 800ms للاستعلام الواحد، ويسوء كلما كبر الجدول، ثم لا يوجد تسامح مع الأخطاء ولا ترتيب حسب الصلة، فمنتج اسمه بالضبط ما كتبه المستخدم قد يظهر في الصفحة الرابعة.

كيف يتعامل Meilisearch مع العربي

Meilisearch يقطّع النص بمكتبة Charabia اللي تتعرف على نوع الخط وتطبّق مطبِّعات خاصة بالعربية. في الإصدارات الحالية يوحّد أشكال الألف إلى ألف مجردة، ويعامل ى مثل ي وة مثل ه عند المطابقة، ويحذف التشكيل والتطويل، ويفصل أداة التعريف حتى تطابق السيارة كلمة سيارة. ما تحتاج تضبط شيئاً من هذا، لأنه يُطبق على كل حقل عربي تلقائياً.

التسامح مع الأخطاء مفعّل افتراضياً: الكلمات من 5 أحرف فأكثر تقبل خطأ واحداً، والكلمات من 9 أحرف فأكثر تقبل خطأين، فلو كتب أحدهم مستشفيت بدل مستشفيات تطلع النتيجة. تقدر تعدّل الحدّين لكل فهرس، وتلغي التسامح في حقول مثل رمز المنتج أو رقم الهوية حيث المطابقة التقريبية خطأ.

الترتيب يمشي على سلسلة قواعد واضحة: عدد الكلمات المطابقة، ثم الأخطاء، ثم تقارب الكلمات، ثم الحقل اللي حصلت فيه المطابقة، ثم الدقة. ضع name قبل description في searchableAttributes فتفوز المطابقة في اسم المنتج دائماً، وهذا غالباً ما يتوقعه المستخدم.

تشغيله على سيرفرك

يكفيك ملف تنفيذي واحد أو حاوية Docker واحدة، وهذا إعداد إنتاج مختصر:

docker run -d --name meili --restart unless-stopped \
  -p 127.0.0.1:7700:7700 \
  -e MEILI_ENV=production \
  -e MEILI_MASTER_KEY="$(openssl rand -base64 32)" \
  -e MEILI_NO_ANALYTICS=true \
  -v /srv/meili/data:/meili_data \
  getmeili/meilisearch:v1.11

احفظ المفتاح الرئيسي المولَّد في ملف الأسرار قبل أن يضيع. الربط على 127.0.0.1 يعني أن العمليات على نفس السيرفر فقط تقدر توصله، ولو احتاجت الواجهة الأمامية وصولاً مباشراً فضع Nginx قدامه بالتشفير مع حد بنحو 20 طلباً في الثانية لكل IP على مسار البحث.

ربط Laravel Scout

ثبّت الحزم ووجّه Scout إلى نسختك:

composer require laravel/scout meilisearch/meilisearch-php http-interop/http-factory-guzzle

SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=http://127.0.0.1:7700
MEILISEARCH_KEY=your-admin-api-key

أضف الـtrait المسمى Searchable للموديل، وخلِّ toSearchableArray() يرجع الحقول اللي تبحث أو تصفّي بها فقط، لأن إرسال الصفوف كاملة بأوصاف HTML طويلة يضخّم الفهرس من مرتين إلى ثلاث بلا فائدة. عرّف الإعدادات في config/scout.php تحت index-settings، مثل ترتيب الحقول القابلة للبحث، وfilterableAttributes مثل city وcategory_id، وsortableAttributes مثل price. بعدها نفّذ php artisan scout:sync-index-settings ثم php artisan scout:import "App\Models\Product"، واستيراد مليون سجل بدفعات من 1,000 يأخذ عادة من 10 إلى 25 دقيقة على 4 vCPU، ومن بعدها يحدّث Scout الفهرس مع كل حفظ عبر الطابور.

أضف مرادفات للكلمات اللي يستخدمها سوقك: جوال وموبايل وهاتف، أو تكييف ومكيف. المرادفات في Meilisearch مجرد خريطة JSON بسيطة، وثلاثون مرادفاً جيداً تصلح غالباً بحوثاً فاشلة أكثر من أي تعديل في الترتيب.

كتلة إعدادات تبدأ منها

'index-settings' => [
    App\Models\Product::class => [
        'searchableAttributes' => ['name', 'brand', 'description'],
        'filterableAttributes' => ['city', 'category_id', 'in_stock'],
        'sortableAttributes'   => ['price', 'created_at'],
        'typoTolerance' => [
            'disableOnAttributes' => ['sku'],
        ],
    ],
],

اقتصر في الحقول القابلة للتصفية على ما تصفّي به الواجهة فعلاً، لأن كل حقل يزيد حجم الفهرس ووقت الفهرسة، غالباً من 5 إلى 10% لكل حقل في الكتالوجات الكبيرة. الشيء نفسه ينطبق على الحقول القابلة للفرز، فإذا كانت الواجهة ما تعرض إلا الفرز بالسعر والأحدث فلا تضف غيرهما، وراجع القائمة كل ما أضفت فلتراً جديداً للصفحة حتى لا يتضخم الفهرس بحقول ما أحد يستخدمها.

كم ذاكرة لكل مليون سجل

Meilisearch يحفظ فهرسه على القرص بتقنية LMDB، ويعتمد على ذاكرة التخزين المؤقت في نظام التشغيل للسرعة، فهو ما يحتاج الفهرس كله في الذاكرة، لكن البحث يبطئ لما الجزء النشط ما يتسع.

السجلات (نحو 1KB لكل سجل)حجم الفهرس على القرصالذاكرة لبحث سلسزمن البحث المعتاد
100,000من 0.4 إلى 0.8GBمن 0.5 إلى 1GBمن 5 إلى 15ms
1,000,000من 3 إلى 6GBمن 2 إلى 4GBمن 15 إلى 40ms
5,000,000من 15 إلى 30GB8GB فأكثرمن 30 إلى 80ms

الفهرسة هي اللحظة الثقيلة، فإعادة الفهرسة الكاملة قد تشغل أغلب الأنوية عدة دقائق، لذلك جدول الاستيرادات الكبيرة خارج ساعات العمل. لكتالوج حتى مليون سجل بجانب تطبيق Laravel، الخادم السحابي من باقة أعمال بـ4 vCPU و8GB وسعر 11,500 ريال سنوياً مناسب براحة، ولو كبر الفهرس فإضافة +2GB RAM بـ600 ريال سنوياً و+100GB SSD بـ500 ريال، فتوسّع بالضبط المورد الناقص.

حماية المفتاح الرئيسي

المفتاح الرئيسي يقدر ينشئ الفهارس ويحذفها ويولّد مفاتيح جديدة، فهو لا يخرج من السيرفر أبداً. استخدمه مرة واحدة لعرض المفاتيح الافتراضية، ثم استخدم Default Admin API Key في الخلفية وDefault Search API Key في أي مكان عام. في التطبيقات متعددة المستأجرين، حيث كل شركة تشوف سجلاتها فقط، ولّد توكنات المستأجر في الخلفية بفلتر مثل company_id = 42 وصلاحية ساعة واحدة، فيبحث المتصفح مباشرة وبسرعة، ومع ذلك لا يقدر طلب معدّل يقرأ بيانات مستأجر آخر. دوّر مفتاح الإدارة كل 90 يوماً ومع أي تغيير في الفريق.

Meilisearch أم FULLTEXT في MySQL أم OpenSearch

المعيارMySQL FULLTEXTMeilisearchOpenSearch
توحيد الحروف العربيةجزئي حسب الترتيبمدمجمحلّل عربي مدمج
التسامح مع الأخطاءلا يوجدافتراضياستعلامات fuzzy تحتاج ضبطاً
أقل ذاكرةيستخدم ذاكرة MySQLنحو 512MBمن 2 إلى 4GB لذاكرة JVM
جهد الإعدادفهرس واحدحاوية واحدةعنقود وتعريفات حقول
الأنسب لهأدوات داخلية صغيرةبحث المنتجات والمحتوىالسجلات والتحليلات والاستعلامات المعقدة

FULLTEXT في MySQL مقبول لأداة داخلية فيها 20,000 صف، وOpenSearch يستحق ثقله لما تحتاج تجميعات على مئات الملايين من المستندات، أما البحث اللي يواجه المستخدم في تطبيق عربي فـMeilisearch يعطيك أغلب الجودة بجزء بسيط من عبء التشغيل.

خطواتك التالية

  1. اجمع 50 بحثاً حقيقياً من سجلاتك رجعت بلا نتائج، وهذي تصير مجموعة الاختبار.
  2. شغّل Meilisearch في Docker مربوطاً على 127.0.0.1 بمفتاح رئيسي قوي.
  3. اربط Scout، وخفّف toSearchableArray()، واستورد بياناتك.
  4. أضف ترتيب الحقول والفلاتر وثلاثين مرادفاً محلياً.
  5. أعد تشغيل الخمسين بحثاً وقارن، ثم اكشف للمتصفح مفتاح بحث فقط أو توكن مستأجر.

راجع الباقات في صفحة الأسعار لتحدد حجم السيرفر، وخلِّ التطبيق والفهرس كليهما في الرياض على بعد نحو 8ms من مستخدميك.

مشاركة