> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# Meilisearch: مربع بحث فوري داخل تطبيقك
- URL: https://arabroot.io/articles/meilisearch-بحث-فوري-داخل-تطبيقك/
- Published: 2026-10-13T03:00:00.000Z
- Updated: 2026-10-12T21:38:36.000Z
- Description: كيف تشغل Meilisearch على سيرفرك بمفاتيح محدودة لكل مستخدم ونسخ احتياطي تسترجعه، وتضبطه حتى يفهم النص العربي.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, هندسة البرمجيات وتصميم الأنظمة, قواعد البيانات, الاستضافة الذاتية

عندما يكتب المستخدم في مربع البحث داخل تطبيقك أو متجرك فهو ينتظر النتائج مع كل حرف، ولا يهمه أنه كتب «Kubernets» بدل «Kubernetes» أو «الاحمال» دون همزة، وهذا ما بني له Meilisearch تحديداً. وفي مقال [البحث في النصوص العربية](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D8%AD%D8%AB-%D9%81%D9%8A-%D8%A7%D9%84%D9%86%D8%B5%D9%88%D8%B5-%D8%A7%D9%84%D8%B9%D8%B1%D8%A8%D9%8A%D8%A9-%D9%85%D8%B9-opensearch-%D9%88-meilisearch/) رأينا كيف يتعامل مع الكلمات العربية من حيث التوحيد والتقطيع، أما هنا فسوف نشغله كما يعمل في الإنتاج: بمفتاح رئيسي Master Key، ومفاتيح محدودة لكل تطبيق، ورموز خاصة لكل مستخدم، ونسخ احتياطي نسترجعه فعلاً، ثم نضيف البحث الهجين Hybrid Search بنموذج يعمل على سيرفرك.

وسوف نناقش في هذا المقال ما يلي:

- متى يناسبك Meilisearch ومتى لا يناسبك، وما الذي تعنيه رخصته بعد إضافة نسخة Enterprise.
- تشغيله في وضع الإنتاج مع Docker Compose، والفهرسة والمهام Tasks.
- الإعدادات التي تغير النتائج: الحقول وترتيبها، وتحمل الأخطاء الإملائية Typo Tolerance، والفلاتر Filters والأوجه Facets، وقواعد الترتيب Ranking Rules.
- النص العربي: «ال» والتاء المربوطة والهمزات والتشكيل، ولماذا يجب أن تحدد اللغة في الإعداد وفي السؤال.
- مفاتيح ال API ورموز المستأجرين Tenant Tokens، وكيف نكسرها.
- البحث الهجين مع Ollama، وقياس كل ذلك على خمسين سؤالاً نعرف إجاباتها.
- اللقطات Snapshots والتصدير Dumps، والاسترجاع على سيرفر جديد.

## ما هو Meilisearch ومتى يناسبك؟

Meilisearch محرك بحث مكتوب بلغة Rust، يعمل كملف تنفيذي واحد يستقبل المستندات بصيغة JSON عبر واجهة HTTP، ويبني منها الفهرس المعكوس Inverted Index الذي شرحناه في مقال [كيف يعمل البحث؟](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D9%8A%D8%B9%D9%85%D9%84-%D8%A7%D9%84%D8%A8%D8%AD%D8%AB-%D8%A7%D9%84%D9%81%D9%87%D8%B1%D8%B3-%D8%A7%D9%84%D9%85%D8%B9%D9%83%D9%88%D8%B3-%D9%88%D8%A7%D9%84%D8%AA%D8%B1%D8%AA%D9%8A%D8%A8/)، ثم يجيب عن الأسئلة في أجزاء من الثانية. والفرق بينه وبين [OpenSearch](https://arabroot.io/articles/opensearch-%D8%A7%D9%84%D8%A8%D8%AF%D9%8A%D9%84-%D8%A7%D9%84%D9%85%D9%81%D8%AA%D9%88%D8%AD-%D9%84-elasticsearch/) أن الإعدادات الافتراضية فيه مصممة لمربع بحث يكتب فيه إنسان: البحث بأول الكلمة Prefix Search، وتحمل الأخطاء، وترتيب يفضل المستند الذي فيه كل كلمات السؤال، دون أن تكتب محللاً Analyzer أو استعلاماً معقداً.

**يناسبك** عندما يكون عندك بحث داخل تطبيق أو متجر أو موقع توثيق، والبيانات تتسع في سيرفر واحد (عشرات الملايين من المستندات الصغيرة في أقصى الحالات المعتادة)، وتريد نتائج أثناء الكتابة مع فلاتر وأوجه للتصفية، وفريقك صغير لا يريد إدارة عنقود Cluster.

**لا يناسبك** للسجلات Logs والتحليلات والتجميعات الكبيرة، فهذا عمل [ELK](https://arabroot.io/articles/%D9%85%D8%AC%D9%85%D9%88%D8%B9%D8%A9-elk-elasticsearch-%D9%88-logstash-%D9%88-kibana/) وOpenSearch، ولا يناسبك إذا كنت تحتاج التوفر العالي High Availability بعدة سيرفرات دون رخصة تجارية، والسبب في القسم التالي، ولا عندما تحتاج تحكماً كاملاً في التقطيع والجذع Stemming للعربية، فهو لا يرجع الكلمة إلى جذعها كما سوف نرى.

## الرخصة: MIT ونسخة Enterprise

كان Meilisearch كله تحت رخصة MIT، ثم قسم المشروع إلى نسختين، وبحسب [ملف الرخصة في المستودع](https://github.com/meilisearch/meilisearch/blob/main/LICENSE?ref=arabroot.io) عند الإصدار 1.55.0 (أكتوبر 2026) فإن المعرف هو `MIT AND BUSL-1.1`:

- **نسخة المجتمع Community Edition:** تحت MIT، وفيها البحث النصي والبحث الدلالي والهجين، ومجانية حتى للاستخدام التجاري، وهي ال Docker Image `getmeili/meilisearch` التي نستخدمها هنا.
- **نسخة Enterprise:** الملفات الموجودة في مجلدات `enterprise_edition` تحت رخصة [BUSL 1.1 معدلة](https://github.com/meilisearch/meilisearch/blob/main/LICENSE-EE?ref=arabroot.io)، تسمح بالاستخدام والتعديل لأغراض غير الإنتاج فقط، مثل التطوير والتجربة، والاستخدام في الإنتاج يحتاج اتفاقاً تجارياً، وتتحول كل نسخة منها إلى MIT بعد أربع سنوات من نشرها. وفي ملف `Cargo.toml` هذه الملفات خلف خيار البناء `enterprise` وليس ضمن الخيارات الافتراضية، ولها Docker Image منفصل باسم `getmeili/meilisearch-enterprise`.

والنقطة التي تهمك عملياً أن [صفحة النسختين](https://www.meilisearch.com/docs/resources/self%5Fhosting/enterprise%5Fedition?ref=arabroot.io) تقول إن الميزة الوحيدة الخاصة بنسخة Enterprise هي التقسيم Sharding، وبحسب [توثيق التقسيم والنسخ](https://www.meilisearch.com/docs/resources/self%5Fhosting/sharding/overview?ref=arabroot.io) فإن النسخ المتماثل Replication بين عدة سيرفرات يحتاج نسخة Enterprise أيضاً. وهذا يعني أن نسخة المجتمع سيرفر واحد، وإذا سقط توقف البحث حتى تعيده، لذلك خطط للاسترجاع السريع من اللقطات كما سوف نرى، أو اختر محركاً فيه عنقود مجاني مثل [Typesense](https://arabroot.io/articles/typesense-%D8%A8%D8%AD%D8%AB-%D8%B3%D8%B1%D9%8A%D8%B9-%D9%8A%D8%AA%D8%AD%D9%85%D9%84-%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1-%D8%A7%D9%84%D8%A7%D9%85%D9%84%D8%A7%D8%A6%D9%8A%D8%A9/) إذا كان التوقف غير مقبول.

## التجربة: Meilisearch في وضع الإنتاج

سوف نستخدم نفس البيانات التي استخدمناها في مقال [البحث الدلالي](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D8%AD%D8%AB-%D8%A7%D9%84%D8%AF%D9%84%D8%A7%D9%84%D9%8A-semantic-search-%D8%A8%D8%A7%D9%84-embeddings/): عناوين 366 مقالاً من الموقع ومقتطفاتها، وأضفنا لكل مقال المسار Track والمرحلة Stage وتاريخ النشر حتى نجرب الفلاتر والترتيب. ولدينا ملف أسئلة فيه خمسون سؤالاً: الثلاثون الأولى من مقال البحث الدلالي كما هي (أسئلة بكلمات المستخدم وليس بكلمات العنوان)، وعشرون جديدة تختبر الكتابة العربية تحديداً: «ال»، والتاء المربوطة والهاء، والهمزات، والتشكيل، والأخطاء الإملائية، والجمع والمفرد. وكل سؤال معه قائمة المقالات الصحيحة التي حددناها بأنفسنا، فالأرقام في هذا المقال توضيحية على مجموعة صغيرة، وليست حكماً عاماً على المحرك.

المثال التالي يبين ملف `compose.yaml`:

```yaml
# Meilisearch lab: one Meilisearch server, Ollama for the embeddings of hybrid search, and a Python client.
# Ports are bound to 127.0.0.1 only. The master key comes from .env (see .env.example).
name: lab-srch2-meili

services:
  meili:
    image: getmeili/meilisearch:v1.55.0
    container_name: lab-srch2-meili
    environment:
      MEILI_ENV: production                 # refuses to start without a master key of 16 bytes or more
      MEILI_MASTER_KEY: ${MEILI_MASTER_KEY}
      MEILI_NO_ANALYTICS: "true"
      # Meilisearch refuses to call internal IPs (embedders, webhooks) unless you allow the network
      MEILI_EXPERIMENTAL_ALLOWED_IP_NETWORKS: 10.213.88.0/24
    volumes:
      - meili-data:/meili_data
    ports:
      - "127.0.0.1:17700:7700"
    mem_limit: 2g

  ollama:
    image: ollama/ollama:0.40.3
    container_name: lab-srch2-ollama
    environment:
      OLLAMA_KEEP_ALIVE: 30m
      OLLAMA_MAX_LOADED_MODELS: "1"
    volumes:
      - ollama-models:/root/.ollama
    mem_limit: 4g

  py:
    image: python:3.13.16-slim
    container_name: lab-srch2-py
    working_dir: /lab
    environment:
      MEILI_MASTER_KEY: ${MEILI_MASTER_KEY}
    volumes:
      - ./:/lab
    command: ["sleep", "infinity"]

networks:
  default:
    ipam:
      config:
        - subnet: 10.213.88.0/24

volumes:
  meili-data:
    name: lab-srch2-meili-data
  ollama-models:
    name: lab-srch2-ollama-models
```

في الإعداد أعلاه لاحظ التالي:

- `MEILI_ENV: production` يغير سلوك السيرفر كما يقول [مرجع الإعدادات](https://www.meilisearch.com/docs/resources/self%5Fhosting/configuration/reference?ref=arabroot.io): يرفض أن يعمل دون مفتاح رئيسي طوله 16 بايت على الأقل، ويغلق واجهة البحث التجريبية Search Preview في المتصفح.
- المفتاح الرئيسي يأتي من ملف `.env` وليس مكتوباً في الملف، وال Volume باسم ثابت حتى لا تفقد الفهرس عند إعادة إنشاء ال Container.
- المنفذ مربوط على `127.0.0.1` فقط، وفي الإنتاج يكون أمامه [Reverse Proxy](https://arabroot.io/articles/%D9%85%D9%82%D8%A7%D8%B1%D9%86%D8%A9-nginx-proxy-manager-%D9%88-traefik-%D9%88-haproxy/) بشهادة TLS.
- المتغير `MEILI_EXPERIMENTAL_ALLOWED_IP_NETWORKS` والشبكة الثابتة سببهما قصة سوف نراها في قسم البحث الهجين.

الآن ننشئ المفتاح ونشغل الخدمات:

```bash
printf 'MEILI_MASTER_KEY=%s\n' "$(openssl rand -base64 32 | tr -d '/+=')" > .env
docker compose up -d
docker logs lab-srch2-meili 2>&1 | grep -E "Environment|Package version|master key"
```

والمخرج سوف يكون كما يلي:

```text
Environment:		"production"
Package version:	"1.55.0"
A master key has been set. Requests to Meilisearch won't be authorized unless you provide an authentication key.
```

ولنكسر الإعداد قبل أن نكمل: ماذا يحدث إذا وضعت مفتاحاً قصيراً مثل `secret123`؟

```bash
docker run --rm -e MEILI_ENV=production -e MEILI_MASTER_KEY=secret123 getmeili/meilisearch:v1.55.0
```

```text
ERROR meilisearch: error=The master key must be at least 16 bytes in a production environment. The provided key is only 9 bytes.

We generated a new secure master key for you (you can safely use this token):

>> --master-key GsTG6BSQDsgYCBNCH-q5BRWswrNcoghmj_2VE894Wsw <<
```

السيرفر رفض أن يعمل واقترح مفتاحاً عشوائياً، وهذا أفضل من أن يعمل بمفتاح يخمن في دقائق. ولا تستخدم المفتاح المقترح كما هو من السجل، والسبب أن السجلات تنسخ وتحفظ في أماكن كثيرة، فأنشئ مفتاحك بنفسك كما فعلنا أعلاه.

## الفهرسة والمهام Tasks

كل عملية كتابة في Meilisearch، سواء إضافة مستندات أو تغيير إعداد، لا تنفذ فوراً، وإنما تصبح مهمة Task في طابور داخلي، ويرجع لك السيرفر رقمها، ثم تسأل عن حالتها. لذلك أي كود يضيف مستندات ثم يبحث فيها مباشرة سوف يجد أحياناً نتائج قديمة، والحل أن تنتظر المهمة. والملف التالي `lab.py` فيه الدوال المشتركة لكل الأمثلة، ومنها `wait` التي تنتظر المهمة:

```python
# Small helpers for the Meilisearch lab, standard library only.
import json, os, time, urllib.error, urllib.request

MEILI = "http://lab-srch2-meili:7700"
MASTER = os.environ["MEILI_MASTER_KEY"]

def call(method, path, body=None, key=MASTER, ndjson=False):
    if ndjson:
        data, ctype = body.encode(), "application/x-ndjson"
    else:
        data, ctype = (json.dumps(body, ensure_ascii=False).encode() if body is not None else None), "application/json"
    req = urllib.request.Request(MEILI + path, data=data, method=method,
                                 headers={"Content-Type": ctype, "Authorization": f"Bearer {key}"})
    try:
        with urllib.request.urlopen(req, timeout=600) as r:
            return r.status, json.loads(r.read() or b"{}")
    except urllib.error.HTTPError as e:
        return e.code, json.loads(e.read() or b"{}")

def wait(task):
    """Every write in Meilisearch is an asynchronous task: wait until it finishes and return it."""
    uid = task["taskUid"]
    while True:
        _, t = call("GET", f"/tasks/{uid}")
        if t["status"] in ("succeeded", "failed", "canceled"):
            return t
        time.sleep(0.2)

def search(index, q, key=MASTER, **opts):
    return call("POST", f"/indexes/{index}/search", {"q": q, **opts}, key=key)

def load_docs():
    return [json.loads(l) for l in open("data/articles.jsonl", encoding="utf-8") if l.strip()]

def load_queries():
    return json.load(open("data/queries.json", encoding="utf-8"))
```

والملف `setup.py` ينشئ فهرسين من نفس المستندات: `plain` دون أي إعداد، كما يحصل عليه من يرفع بياناته ويبدأ، و`articles` بالإعدادات التي نشرحها في القسم التالي:

```python
# Creates two indexes from the same 366 articles:
#   plain     : no settings at all, what you get after the first upload
#   articles  : searchable/filterable/sortable attributes, Arabic locale, ranking rules
import json, sys
from lab import call, wait, load_docs

docs = load_docs()
body = "".join(json.dumps(d, ensure_ascii=False) + "\n" for d in docs)

for index in ("plain", "articles"):
    call("DELETE", f"/indexes/{index}")
    _, t = call("POST", "/indexes", {"uid": index, "primaryKey": "id"}); wait(t)

_, t = call("PATCH", "/indexes/articles/settings", {
    "searchableAttributes": ["title", "excerpt"],          # order = weight: a match in the title ranks higher
    "displayedAttributes": ["id", "title", "track", "stage", "published"],
    "filterableAttributes": ["track", "stage", "published"],
    "sortableAttributes": ["published"],
    "localizedAttributes": [{"attributePatterns": ["title", "excerpt"], "locales": ["ara"]}],
    "rankingRules": ["words", "typo", "proximity", "attribute", "sort", "exactness"],
})
print("settings:", wait(t)["status"])

for index in ("plain", "articles"):
    _, t = call("POST", f"/indexes/{index}/documents", body, ndjson=True)
    t = wait(t)
    print(f"{index:<9} {t['status']}  {t['details']['indexedDocuments']} docs  duration {t['duration']}")

_, s = call("GET", "/indexes/articles/stats")
print("stats:", json.dumps(s, ensure_ascii=False))
```

```bash
docker exec -w /lab lab-srch2-py python setup.py
```

والمخرج سوف يكون كما يلي:

```text
settings: succeeded
plain     succeeded  366 docs  duration PT0.339291173S
articles  succeeded  366 docs  duration PT0.257933292S
stats: {"numberOfDocuments": 366, "indexSize": 2076672, "usedIndexSize": 2035712, "rawDocumentDbSize": 196608, "avgDocumentSize": 529, "isIndexing": false, "numberOfEmbeddings": 0, "numberOfEmbeddedDocuments": 0, "fieldDistribution": {"excerpt": 366, "id": 366, "published": 366, "stage": 366, "title": 366, "track": 366}}
```

لاحظ أن الإعدادات أرسلناها قبل المستندات، والسبب أن تغيير بعض الإعدادات بعد الفهرسة، مثل الحقول القابلة للفلترة أو قواعد اللغة، يعيد فهرسة كل المستندات، وهذا لا يلاحظ في 366 مستنداً، لكنه يأخذ وقتاً طويلاً في ملايين المستندات، والبحث يعمل خلاله بالإعدادات القديمة.

## الإعدادات التي تغير النتائج

في `setup.py` أعلاه لاحظ التالي:

- **الحقول القابلة للبحث searchableAttributes:** ترتيبها هو وزنها، فالكلمة في العنوان ترفع المستند أكثر من الكلمة نفسها في المقتطف. والقيمة الافتراضية هي كل الحقول، وهذا يعني أن البحث عن «software» يجد كل مقالات المسار software لأن الحقل `track` أصبح قابلاً للبحث، لذلك حدد الحقول دائماً.
- **الحقول المعروضة displayedAttributes:** ما يرجع في النتائج، فلا ترجع حقلاً داخلياً للمتصفح دون حاجة.
- **الفلترة والترتيب filterableAttributes وsortableAttributes:** لا يمكن الفلترة أو الترتيب بحقل لم تذكره هنا، كما سوف نرى في الخطأ بعد قليل.
- **اللغة localizedAttributes:** نقول إن العنوان والمقتطف عربيان `ara`، وسوف نرى أثرها الكبير في قسم النص العربي.
- **قواعد الترتيب rankingRules:** يرتب Meilisearch النتائج بقاعدة بعد قاعدة، فالقاعدة الأولى تقسم النتائج إلى مجموعات، والتالية ترتب داخل كل مجموعة، كما يشرح [توثيق قواعد الترتيب](https://www.meilisearch.com/docs/capabilities/full%5Ftext%5Fsearch/relevancy/ranking%5Frules?ref=arabroot.io): عدد كلمات السؤال الموجودة words، ثم عدد الأخطاء typo، ثم قرب الكلمات من بعضها proximity، ثم الحقل الذي وجدت فيه، ثم الترتيب الذي يطلبه السؤال sort، ثم التطابق التام exactness. والقاعدة `attribute` التي استخدمناها هي الاسم القديم الذي يجمع `attributeRank` و`wordPosition`، والتوثيق اليوم ينصح بالقاعدتين منفصلتين حتى تضع `sort` بينهما إذا أردت.

### تحمل الأخطاء الإملائية Typo Tolerance

احفظ الملف التالي باسم `typos.py`، وهو يقرأ إعداد تحمل الأخطاء الافتراضي، ثم يبحث بكلمات فيها أخطاء، ويطبع تفاصيل قاعدة typo لأول نتيجة بالخيار `showRankingScoreDetails`:

```python
# Default typo tolerance, then a few misspelled queries with the ranking details of the first hit.
import json
from lab import call, search

_, t = call("GET", "/indexes/articles/settings/typo-tolerance")
print(json.dumps(t))
for q in ["Kubernets", "Wiregaurd", "Redis", "Redsi", "Rdsi"]:
    _, r = search("articles", q, limit=3, attributesToRetrieve=["id"], showRankingScoreDetails=True)
    hits = r["hits"]
    top = ", ".join(h["id"] for h in hits) or "(none)"
    typo = hits[0]["_rankingScoreDetails"]["typo"] if hits else None
    print(f"{q:<16}{r['estimatedTotalHits']:>4}  {top}   typo rule of first hit: {json.dumps(typo)}")
```

والمخرج سوف يكون كما يلي:

```text
{"enabled": true, "minWordSizeForTypos": {"oneTypo": 5, "twoTypos": 9}, "disableOnWords": [], "disableOnAttributes": [], "disableOnNumbers": false}
Kubernets          4  k8s-terms, cloudnativepg, k8s-need   typo rule of first hit: {"order": 1, "typoCount": 1, "maxTypoCount": 2, "score": 0.6666666666666666}
Wiregaurd          2  wireguard, mikrotik-scenarios   typo rule of first hit: {"order": 1, "typoCount": 1, "maxTypoCount": 2, "score": 0.6666666666666666}
Redis              3  redis-valkey, postgres-for-everything, build-recommender   typo rule of first hit: {"order": 1, "typoCount": 0, "maxTypoCount": 1, "score": 1.0}
Redsi              3  redis-valkey, postgres-for-everything, build-recommender   typo rule of first hit: {"order": 1, "typoCount": 1, "maxTypoCount": 1, "score": 0.5}
Rdsi               0  (none)   typo rule of first hit: null
```

في المخرج أعلاه لاحظ التالي:

- الكلمة من خمسة أحرف فأكثر تقبل خطأ واحداً، ومن تسعة أحرف فأكثر تقبل خطأين، كما يقول [توثيق تحمل الأخطاء](https://www.meilisearch.com/docs/capabilities/full%5Ftext%5Fsearch/relevancy/typo%5Ftolerance%5Fsettings?ref=arabroot.io). لذلك وجد «Redsi» (خمسة أحرف) ولم يجد «Rdsi» (أربعة أحرف).
- تبديل حرفين متجاورين كما في «Wiregaurd» حسب خطأ واحداً وليس اثنين.
- المستند الذي وجد بخطأ يأخذ درجة أقل في قاعدة typo، فإذا كان في البيانات مستند بالكلمة الصحيحة يتقدم عليه.

وقد يتساءل البعض: هل أرفع الحد أو أخفضه؟ والإجابة أن الحد الافتراضي مناسب للكلمات العادية، أما أرقام المنتجات والرموز مثل أرقام الفواتير فأوقف تحمل الأخطاء فيها بالإعداد `disableOnAttributes` أو `disableOnNumbers`، والسبب أن من يبحث عن الفاتورة 10234 لا يريد الفاتورة 10284.

### الفلاتر والأوجه Facets والترتيب

الأوجه Facets هي الأرقام التي تراها بجانب كل خيار في قائمة التصفية، مثل «software (13)»، وتأتي مع النتائج نفسها في طلب واحد. احفظ الملف التالي باسم `facets.py`:

```python
# A search box with filters: the same query, then narrowed by track, sorted by date, with facet counts.
import json
from lab import search

def show(title, **opts):
    status, r = search("articles", "PostgreSQL", limit=4, attributesToRetrieve=["id", "track"], **opts)
    print(f"== {title} (HTTP {status})")
    if status != 200:
        print(json.dumps(r, ensure_ascii=False)); return
    print("hits:", r["estimatedTotalHits"], "|", ", ".join(f"{h['id']}({h['track']})" for h in r["hits"]))
    if "facetDistribution" in r:
        print("facets:", json.dumps(r["facetDistribution"]["track"], ensure_ascii=False))

show("query only", facets=["track"])
show("filter track = apps", filter="track = apps", facets=["track"])
show("filter + newest first", filter="track IN [devops, software] AND published > 1759276800", sort=["published:desc"])
show("sort on a field that is not sortable", sort=["title:asc"])
```

والمخرج سوف يكون كما يلي:

```text
== query only (HTTP 200)
hits: 15 | cloudnativepg(software), postgres-for-everything(software), postgres-large-tables(software), postgres-mysql-mongodb(software)
facets: {"apps": 2, "software": 13}
== filter track = apps (HTTP 200)
hits: 2 | mattermost(apps), supabase(apps)
facets: {"apps": 2}
== filter + newest first (HTTP 200)
hits: 13 | postgres-mysql-mongodb(software), cloudnativepg(software), postgres-setup(software), postgres-for-everything(software)
== sort on a field that is not sortable (HTTP 400)
{"message": "Index `articles`: Attribute `title` is not sortable. Available sortable attributes are: `published`.", "code": "invalid_search_sort", "type": "invalid_request", "link": "https://docs.meilisearch.com/errors#invalid_search_sort"}
```

صيغة الفلتر قريبة من SQL: `=` و`IN` و`AND` و`>` على الأرقام، والتاريخ نحفظه رقماً Unix Timestamp حتى نقارن به. والطلب الأخير فشل لأن `title` ليس في `sortableAttributes`، وهذا الخطأ أفضل من أن يتجاهل السيرفر الترتيب بصمت.

## النص العربي: ماذا يوحد Meilisearch وماذا لا يوحد

شرحنا في [قسم Meilisearch من مقال البحث العربي](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D8%AD%D8%AB-%D9%81%D9%8A-%D8%A7%D9%84%D9%86%D8%B5%D9%88%D8%B5-%D8%A7%D9%84%D8%B9%D8%B1%D8%A8%D9%8A%D8%A9-%D9%85%D8%B9-opensearch-%D9%88-meilisearch/#meilisearch) أن مكتبة التقطيع charabia تفصل «ال» عن الكلمة، وتحذف التشكيل، وتوحد الألف والياء والتاء المربوطة، ولا ترجع الكلمة إلى جذعها، ورأينا مشكلة «ال» مع الإعداد `matchingStrategy`. ولن نعيد ذلك هنا، وإنما نتأكد منه على بياناتنا ونضيف ما لم نذكره هناك. احفظ الملف التالي باسم `arabic.py`:

```python
# The same word written in different ways: how many articles match with each matchingStrategy,
# and which three come first with "frequency".
from lab import search

QUERIES = [
    "التشفير", "التَّشْفِير",              # diacritics
    "الاحتياطية", "الاحتياطيه",            # ta marbuta / ha
    "الأحمال", "الاحمال",                  # hamza on alef
    "الخرائط", "خرائط",                    # with and without «ال»
    "طابور", "طوابير",                     # singular / broken plural
    "شهادة", "الشهادات",                   # singular / sound plural
]
print(f"{'query':<14}{'last':>6}{'frequency':>11}{'all':>6}   top 3 (frequency)")
for q in QUERIES:
    total = {}
    for s in ("last", "frequency", "all"):
        _, r = search("articles", q, limit=3, matchingStrategy=s, attributesToRetrieve=["id"])
        total[s] = r["estimatedTotalHits"]
        if s == "frequency":
            top = ", ".join(h["id"] for h in r["hits"])
    print(f"{q:<14}{total['last']:>6}{total['frequency']:>11}{total['all']:>6}   {top}")
```

والمخرج سوف يكون كما يلي:

```text
query           last  frequency   all   top 3 (frequency)
التشفير          355          5     5   crypto-for-devs, gpg-basics, device-management
التَّشْفِير      355          5     5   crypto-for-devs, gpg-basics, device-management
الاحتياطية       355         17    16   ovh-fire, s3-object-lock, storage-types
الاحتياطيه       355         17    16   ovh-fire, s3-object-lock, storage-types
الأحمال          355          1     1   load-testing
الاحمال          355          1     1   load-testing
الخرائط          355         10     9   mapping-vehicles, maps-basics, traffic-data
خرائط             10         10    10   self-hosted-maps, build-map-app, mapping-vehicles
طابور              2          2     2   queue-concepts, queue-vs-streaming
طوابير             1          1     1   rabbitmq
شهادة              4          4     4   caddy, nginx-proxy-manager, certificates
الشهادات         355          2     2   certificates, haproxy
```

في المخرج أعلاه لاحظ التالي:

- **التشكيل والتاء المربوطة والهمزة:** كل زوج أعطى النتائج نفسها، فالتوحيد يعمل في الكتابة وفي السؤال.
- **«ال» مع القيمة الافتراضية last:** كل كلمة تبدأ بـ «ال» أرجعت 355 مقالاً من 366، والسبب أن «ال» أصبحت كلمة مستقلة، فإذا لم تكف النتائج حذف Meilisearch الكلمة الأخيرة وبقيت «ال» وحدها، وهي في كل مستند تقريباً. والترتيب يبقى معقولاً لأن المستندات التي فيها الكلمتان تأتي أولاً، لكن عدد النتائج والأوجه يصبح بلا معنى، فالمستخدم يرى «355 نتيجة» لسؤال إجابته خمسة مقالات.
- **القيمة frequency:** تحذف أولاً الكلمة الأكثر تكراراً، وهي «ال»، فرجعت الأعداد إلى الحجم الصحيح.
- **لا يوجد جذع:** «طابور» لم تجد «طوابير»، و«شهادة» لم تجد «الشهادات» إلا عبر كلمات أخرى، فجموع التكسير والجموع السالمة تحتاج مرادفات Synonyms إذا كانت مهمة في بياناتك.

وهناك ملاحظة أهم لم تظهر في الجدول أعلاه لأن الفهرس `articles` فيه الإعداد `localizedAttributes`. ففي الفهرس `plain` دون إعداد اللغة بحثنا عن «شركة ناشئة» مكتوبة صحيحة، مرة دون تحديد اللغة ومرة مع الخيار `locales` في السؤال:

```python
from lab import search
for q in ("شركه ناشئه", "كاميرا مراقبة", "شركة ناشئة"):
    for loc in (None, ["ara"]):
        opts = {"locales": loc} if loc else {}
        s, r = search("plain", q, limit=3, attributesToRetrieve=["id"], **opts)
        print(q, loc, r["estimatedTotalHits"], [h["id"] for h in r["hits"]])
```

```text
شركه ناشئه None 0 []
شركه ناشئه ['ara'] 12 ['medical-ai-startup', 'acqui-hiring', 'deep-tech']
كاميرا مراقبة None 0 []
كاميرا مراقبة ['ara'] 3 ['camera-types', 'frigate', 'network-segmentation']
شركة ناشئة None 0 []
شركة ناشئة ['ara'] 12 ['medical-ai-startup', 'acqui-hiring', 'deep-tech']
```

سؤال عربي صحيح من كلمتين رجع صفحة فارغة، والسبب أن Meilisearch يكتشف لغة السؤال وحده، والسؤال القصير قد يكتشف بلغة أخرى فيقطع ويوحد بطريقة مختلفة عن المستندات، وهذا ما يقوله [توثيق البيانات متعددة اللغات](https://www.meilisearch.com/docs/capabilities/indexing/how%5Fto/handle%5Fmultilingual%5Fdata?ref=arabroot.io): الاكتشاف أضعف في النصوص القصيرة، وتحديد اللغة في المستندات عبر `localizedAttributes` وفي السؤال عبر `locales` يحصر الاختيار فيما حددته. لذلك إذا كانت بياناتك عربية فحدد اللغة دائماً، ولا تعتمد على الاكتشاف التلقائي.

## مفاتيح ال API ورموز المستأجرين Tenant Tokens

المفتاح الرئيسي Master Key لا يستخدم إلا لإدارة المفاتيح، وكل تطبيق يأخذ مفتاحاً بأقل صلاحية يحتاجها. والمشكلة التي تقابلها في تطبيقات SaaS أن البحث يعمل من متصفح المستخدم مباشرة لأنه أسرع، فكيف تمنع مستخدم الشركة «أ» من رؤية مستندات الشركة «ب»؟ والحل البسيط أن تضيف الفلتر في الواجهة، وهذا غير مناسب إطلاقاً لأن المستخدم يستطيع أن يغير الطلب من المتصفح ويحذف الفلتر. والحل الصحيح هو رمز المستأجر Tenant Token: رمز JWT يوقعه السيرفر الخلفي Backend بمفتاح بحث، وفيه قواعد بحث searchRules يطبقها Meilisearch على كل طلب بهذا الرمز، ولا يستطيع المستخدم تعديلها دون أن تفسد التوقيع. وقد شرحنا نماذج عزل المستأجرين في مقال [تعدد المستأجرين Multi-Tenancy](https://arabroot.io/articles/%D8%AA%D8%B9%D8%AF%D8%AF-%D8%A7%D9%84%D9%85%D8%B3%D8%AA%D8%A7%D8%AC%D8%B1%D9%8A%D9%86-multi-tenancy-%D9%81%D9%8A-saas-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)، والصورة التالية تبين مسار الرمز:

![السيرفر الخلفي يملك مفتاح بحث، ويوقع به رمز JWT فيه قاعدة البحث track = security ومدة صلاحية، ثم يرسله إلى المتصفح، والمتصفح يبحث مباشرة في Meilisearch بالرمز، وMeilisearch يتحقق من التوقيع ومن المفتاح الأب ويضيف الفلتر إلى كل طلب](https://arabroot.io/content/images/2026/10/meilisearch-01-tenant-token.webp)

المفتاح يبقى في السيرفر الخلفي، والمتصفح يحمل رمزاً موقعاً لا يستطيع تعديل الفلتر الذي فيه

احفظ الملف التالي باسم `keys.py`، وهو يعرض المفاتيح التي ينشئها Meilisearch عند أول تشغيل، ثم ينشئ مفتاح بحث لفهرس واحد، ثم يوقع رمزاً بمكتبة Python القياسية دون أي مكتبة JWT، ثم يكسره بثلاث طرق:

```python
# API keys and tenant tokens: the master key creates a search-only key, the backend signs a tenant token
# with that key, and the browser searches with the token. Standard library only (HS256 JWT).
import base64, hashlib, hmac, json, time
from lab import call, search

def b64(d):
    return base64.urlsafe_b64encode(json.dumps(d, separators=(",", ":")).encode()).rstrip(b"=").decode()

def tenant_token(key, uid, rules, exp):
    head = b64({"alg": "HS256", "typ": "JWT"})
    body = b64({"searchRules": rules, "apiKeyUid": uid, "exp": exp})
    sig = hmac.new(key.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()
    return f"{head}.{body}." + base64.urlsafe_b64encode(sig).rstrip(b"=").decode()

def facets(key, **opts):
    status, r = search("articles", "", key=key, limit=0, facets=["track"], **opts)
    return status, (r.get("facetDistribution", {}).get("track") if status == 200 else r.get("code"))

# 1. the keys Meilisearch creates on first start
_, keys = call("GET", "/keys")
for k in keys["results"]:
    print(f"{k['name']:<34} actions={k['actions']} indexes={k['indexes']}")

# 2. a key that can only search one index
_, k = call("POST", "/keys", {"description": "site search, articles only", "actions": ["search"],
                              "indexes": ["articles"], "expiresAt": None})
print("\nnew key uid:", k["uid"])
print("search with it      :", facets(k["key"])[0])
print("add a document      :", call("POST", "/indexes/articles/documents", [{"id": "x"}], key=k["key"])[0])
print("search index 'plain':", search("plain", "x", key=k["key"])[0])

# 3. tenant token: this user may only see the security track, for 20 minutes
tok = tenant_token(k["key"], k["uid"], {"articles": {"filter": "track = security"}}, int(time.time()) + 1200)
print("\ntoken, no filter            :", facets(tok))
print("token, asks for software    :", facets(tok, filter="track = software"))

# 4. break it: expired token, token signed with another key, token after its key is deleted
old = tenant_token(k["key"], k["uid"], {"articles": {}}, int(time.time()) - 60)
fake = tenant_token("not-the-real-key-not-the-real-key", k["uid"], {"articles": {}}, int(time.time()) + 1200)
print("\nexpired token               :", facets(old))
print("signed with another secret  :", facets(fake))
call("DELETE", f"/keys/{k['uid']}")
print("parent key deleted          :", facets(tok))
```

والمخرج سوف يكون كما يلي:

```text
Default Search API Key             actions=['search'] indexes=['*']
Default Admin API Key              actions=['*'] indexes=['*']
Default Read-Only Admin API Key    actions=['*.get', 'keys.get'] indexes=['*']
Default Chat API Key               actions=['chatCompletions', 'search'] indexes=['*']

new key uid: 05ea39f2-5353-4742-88db-acc5184325b2
search with it      : 200
add a document      : 403
search index 'plain': 403

token, no filter            : (200, {'security': 39})
token, asks for software    : (200, {})

expired token               : (403, 'invalid_api_key')
signed with another secret  : (403, 'invalid_api_key')
parent key deleted          : (403, 'invalid_api_key')
```

في المخرج أعلاه لاحظ التالي:

- Meilisearch أنشأ أربعة مفاتيح افتراضية، ومفتاح Admin الافتراضي يملك كل الصلاحيات، فلا تضعه في أي تطبيق.
- المفتاح الجديد بحث فقط في الفهرس `articles`، فرفض الإضافة والبحث في فهرس آخر برمز 403.
- الرمز رأى مقالات المسار security فقط (39 مقالاً)، وعندما طلب المستخدم software أضيف فلتره إلى فلتر الرمز بعلاقة AND فرجعت النتيجة فارغة، أي أن المستخدم يضيق النتائج ولا يوسعها.
- الرمز المنتهي، والموقع بسر آخر، والرمز الذي حذف مفتاحه الأب، كلها رفضت. والأخير مهم: إذا تسرب مفتاح البحث فاحذفه، فتبطل كل الرموز الموقعة به مرة واحدة، وهذا ما تقوله صفحة [محتوى رمز المستأجر](https://www.meilisearch.com/docs/capabilities/security/advanced/tenant%5Ftoken%5Fpayload?ref=arabroot.io) عن الحقل `apiKeyUid`.

⚠️

اجعل مدة الرمز قصيرة (دقائق وليس أياماً) وجدده من السيرفر الخلفي عند الحاجة، والسبب أن الرمز الذي سرق من المتصفح يبقى صالحاً حتى ينتهي أو يحذف مفتاحه الأب، وحذف المفتاح الأب يوقف كل المستخدمين معاً.

## البحث الهجين Hybrid Search مع Ollama

شرحنا في مقال [البحث الدلالي](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D8%AD%D8%AB-%D8%A7%D9%84%D8%AF%D9%84%D8%A7%D9%84%D9%8A-semantic-search-%D8%A8%D8%A7%D9%84-embeddings/) فكرة التضمين Embeddings والبحث الهجين، واستخدمنا OpenSearch. أما Meilisearch فيستطيع أن يستدعي نموذج التضمين بنفسه: تضيف «مضمناً» Embedder في الإعدادات، فيرسل كل مستند إلى النموذج عند الفهرسة، ويرسل السؤال إليه عند البحث. احفظ الملف التالي باسم `embedder.py`، وهو يستخدم النموذج bge-m3 عبر Ollama، وهو أفضل نموذج في قياس مقال البحث الدلالي:

```python
# Adds an embedder: Meilisearch itself sends title + excerpt of every document to Ollama (bge-m3)
# and keeps the vectors next to the keyword index.
import json, time
from lab import call, wait

t0 = time.time()
_, t = call("PATCH", "/indexes/articles/settings", {"embedders": {"bge": {
    "source": "ollama",
    "url": "http://lab-srch2-ollama:11434/api/embed",
    "model": "bge-m3:567m",
    "documentTemplate": "{{doc.title}}\n{{doc.excerpt}}",
}}})
t = wait(t)
print(t["status"], t.get("error"), f"{time.time() - t0:.1f}s")
_, s = call("GET", "/indexes/articles/stats")
print({k: s[k] for k in ("numberOfDocuments", "numberOfEmbeddings", "numberOfEmbeddedDocuments", "indexSize")})
```

شغلنا الملف أول مرة قبل أن نضيف المتغير `MEILI_EXPERIMENTAL_ALLOWED_IP_NETWORKS` إلى `compose.yaml`، فانتظر قرابة أربع دقائق ثم فشل كما يلي:

```text
failed {'message': 'Index `articles`: Error while generating embeddings: runtime error: could not determine model dimensions:\n  - test embedding failed with runtime error: could not reach embedding server:\n  - bad uri: Rejected URI', 'code': 'vector_embedding_error', 'type': 'invalid_request', 'link': 'https://docs.meilisearch.com/errors#vector_embedding_error'} 226.2s
```

العبارة `Rejected URI` لا تعني أن Ollama متوقف، وإنما أن Meilisearch يرفض الاتصال بأي عنوان داخلي، وهذه حماية من هجمات SSRF، أي أن يجعل أحدهم السيرفر يطلب عناوين داخل شبكتك. والخيار الذي يسمح بشبكة محددة نجده في `meilisearch --help`:

```text
      --experimental-allowed-ip-networks <EXPERIMENTAL_ALLOWED_IP_NETWORKS>
          Experimental control over IP policy.

          Sets this to override the default IP policy of blocking all internal IPs and allow some internal IPs.

          [env: MEILI_EXPERIMENTAL_ALLOWED_IP_NETWORKS=]
```

لذلك ثبتنا شبكة ال Compose على `10.213.88.0/24` وسمحنا بها وحدها، وليس بكل العناوين الداخلية. وبعد إعادة التشغيل نجح الإعداد:

```text
succeeded None 560.9s
{'numberOfDocuments': 366, 'numberOfEmbeddings': 366, 'numberOfEmbeddedDocuments': 366, 'indexSize': 5292032}
```

تسع دقائق لتضمين 366 مستنداً قصيراً على معالج مشغول بتجارب أخرى، ودون GPU، وحجم الفهرس تضاعف تقريباً من 2 إلى 5.3 ميجابايت. وقبل أن تضيف مضمناً إلى فهرس فيه ملايين المستندات احسب هذا الوقت على أجهزتك، فكل تعديل في قالب المستند `documentTemplate` يعيد التضمين كله.

### عندما يتأخر النموذج: نتائج دلالية صفر دون أي خطأ

احفظ الملف التالي باسم `hybrid.py`، وهو يبحث بسؤال من مقال البحث الدلالي مرة والسيرفر يضمن السؤال بنفسه، ومرة بمتجه Vector حسبناه نحن في السيرفر الخلفي وأرسلناه مع الطلب:

```python
# Hybrid search with the embedder called by Meilisearch at search time, then with a vector we computed ourselves.
import json, time, urllib.request
from lab import search

Q = "مكان واحد يدخل منه الموظفون إلى كل الأنظمة بحساب واحد"

def run(label, **opts):
    t0 = time.time()
    status, r = search("articles", Q, limit=3, attributesToRetrieve=["id"], **opts)
    took = time.time() - t0
    if status == 200:
        print(f"{label:<26} HTTP {status} {took:5.2f}s semanticHitCount={r['semanticHitCount']} {[h['id'] for h in r['hits']]}")
    else:
        print(f"{label:<26} HTTP {status} {took:5.2f}s {r['message'].splitlines()[0]}")

run("ratio 0.5, server embeds", hybrid={"embedder": "bge", "semanticRatio": 0.5})
run("ratio 1.0, server embeds", hybrid={"embedder": "bge", "semanticRatio": 1.0})

t0 = time.time()
req = urllib.request.Request("http://lab-srch2-ollama:11434/api/embed",
                             data=json.dumps({"model": "bge-m3:567m", "input": [Q]}).encode())
vec = json.loads(urllib.request.urlopen(req, timeout=600).read())["embeddings"][0]
print(f"{'embedding in the backend':<26} {time.time() - t0:5.2f}s, {len(vec)} dimensions")
run("ratio 0.5, our vector", vector=vec, hybrid={"embedder": "bge", "semanticRatio": 0.5})
```

والجهاز هادئ كان المخرج كما يلي:

```text
ratio 0.5, server embeds   HTTP 200  0.40s semanticHitCount=3 ['gitea-authentik', 'scim', 'authentik']
ratio 1.0, server embeds   HTTP 200  0.12s semanticHitCount=3 ['gitea-authentik', 'scim', 'authentik']
embedding in the backend    0.10s, 1024 dimensions
ratio 0.5, our vector      HTTP 200  0.01s semanticHitCount=3 ['gitea-authentik', 'scim', 'authentik']
```

أما عندما شغلنا الطلبات نفسها والجهاز مشغول بتجربة أخرى تستهلك المعالج، وكان Ollama يحتاج أكثر من ست ثوان للسؤال الواحد، فكانت النتيجة كما يلي، والقيم من تشغيل سابق للطلبات نفسها رتبناها بصيغة مخرجات السكربت ليسهل مقارنتها:

```text
ratio 0.5, server embeds   HTTP 200  1.66s semanticHitCount=0 ['logging-basics', 'authorization', 'nominatim']
ratio 1.0, server embeds   HTTP 400  1.53s Error while generating embeddings: runtime error: could not reach embedding server:
  - timeout: global
embedding in the backend    6.21s
```

وهذا من أخطر ما رأيناه في التجربة: مع `semanticRatio: 0.5` رجع الطلب برمز 200 ونتائج تبدو طبيعية، لكن `semanticHitCount` صفر، أي أن Meilisearch لم ينتظر النموذج أكثر من ثانية ونصف تقريباً ورجع إلى البحث بالكلمات وحده دون أن يخبرك، والنتائج الثلاث لا علاقة لها بالسؤال. ومع `1.0` لم يجد ما يرجع إليه فأعطى خطأ 400\. لذلك:

- راقب الحقل `semanticHitCount` في كل رد، وسجل الحالات التي يكون فيها صفراً مع أن السؤال طويل.
- إذا كان النموذج على المعالج CPU أو على سيرفر مشغول، فاحسب متجه السؤال في السيرفر الخلفي وأرسله بالحقل `vector` كما في الطلب الأخير، فأنت تتحكم في مهلة الانتظار وفي التخزين المؤقت للأسئلة المتكررة، كما يشرح [توثيق المتجهات التي يرسلها المستخدم](https://www.meilisearch.com/docs/capabilities/hybrid%5Fsearch/how%5Fto/search%5Fwith%5Fuser%5Fprovided%5Fembeddings?ref=arabroot.io).

## القياس: خمسون سؤالاً على خمسة إعدادات

الآن نجمع كل ما سبق. الملف `metrics.py` يحسب ثلاثة مقاييس شرحناها في مقال [كيف يعمل البحث](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D9%8A%D8%B9%D9%85%D9%84-%D8%A7%D9%84%D8%A8%D8%AD%D8%AB-%D8%A7%D9%84%D9%81%D9%87%D8%B1%D8%B3-%D8%A7%D9%84%D9%85%D8%B9%D9%83%D9%88%D8%B3-%D9%88%D8%A7%D9%84%D8%AA%D8%B1%D8%AA%D9%8A%D8%A8/#precision-recall) ومقال البحث الدلالي: الاستدعاء في أول خمس نتائج recall@5، وترتيب أول إجابة صحيحة MRR@10، وجودة الترتيب nDCG@10، وعدد الأسئلة التي رجعت صفحة فارغة:

```python
# recall@5, MRR@10 and nDCG@10 (binary relevance) for a list of ranked ids against the labelled ids.
import math

def score(ranked, rel):
    rel = set(rel)
    r5 = len(set(ranked[:5]) & rel) / len(rel)
    mrr = next((1 / (i + 1) for i, d in enumerate(ranked[:10]) if d in rel), 0.0)
    dcg = sum(1 / math.log2(i + 2) for i, d in enumerate(ranked[:10]) if d in rel)
    idcg = sum(1 / math.log2(i + 2) for i in range(min(len(rel), 10)))
    return r5, mrr, dcg / idcg

def table(results, queries):
    """results: {config: {query: [ids]}} -> one line per config and query set."""
    print(f"{'config':<28}{'set':<10}{'recall@5':>9}{'MRR@10':>8}{'nDCG@10':>9}{'zero':>6}")
    for c, per_q in results.items():
        for s in ("semantic", "arabic"):
            qs = [q for q in queries if q["set"] == s]
            rows = [score(per_q[q["q"]], q["rel"]) for q in qs]
            zero = sum(1 for q in qs if not per_q[q["q"]])
            avg = [sum(x) / len(rows) for x in zip(*rows)]
            print(f"{c:<28}{s:<10}{avg[0]:>9.3f}{avg[1]:>8.3f}{avg[2]:>9.3f}{zero:>6}")
```

والملف `evaluate.py` يشغل الأسئلة على خمسة إعدادات، ويحسب متجهات الأسئلة مرة واحدة في البداية للسبب الذي رأيناه في القسم السابق:

```python
# The 50 labelled questions (30 from the semantic search article + 20 Arabic spelling variants) on five setups.
# Query vectors are computed here once with Ollama and sent with the request ("vector"), because on a busy CPU
# Meilisearch's own call to the embedder at search time times out (see the article).
import json, urllib.request
from lab import search, load_queries
from metrics import table

def embed(texts):
    req = urllib.request.Request("http://lab-srch2-ollama:11434/api/embed",
                                 data=json.dumps({"model": "bge-m3:567m", "input": texts}).encode())
    return json.loads(urllib.request.urlopen(req, timeout=1800).read())["embeddings"]

def ids(index, q, **opts):
    status, r = search(index, q, limit=10, attributesToRetrieve=["id"], **opts)
    assert status == 200, r
    return [h["id"] for h in r["hits"]]

queries = load_queries()
VEC = dict(zip([q["q"] for q in queries], embed([q["q"] for q in queries])))

def hybrid(q, ratio, **opts):
    return ids("articles", q, vector=VEC[q], hybrid={"embedder": "bge", "semanticRatio": ratio}, **opts)

CONFIGS = {
    "A plain, defaults":          lambda q: ids("plain", q),
    "B settings + ara":           lambda q: ids("articles", q),
    "C B + frequency":            lambda q: ids("articles", q, matchingStrategy="frequency"),
    "D C + hybrid 0.5":           lambda q: hybrid(q, 0.5, matchingStrategy="frequency"),
    "E vectors only (ratio 1.0)": lambda q: hybrid(q, 1.0),
}
table({c: {q["q"]: f(q["q"]) for q in queries} for c, f in CONFIGS.items()}, queries)
```

والمخرج سوف يكون كما يلي، والمجموعة semantic هي الأسئلة الثلاثون بكلمات المستخدم، وarabic هي الأسئلة العشرون التي تختبر الكتابة العربية:

```text
config                      set        recall@5  MRR@10  nDCG@10  zero
A plain, defaults           semantic      0.353   0.432    0.383     5
A plain, defaults           arabic        0.738   0.856    0.765     2
B settings + ara            semantic      0.353   0.426    0.376     6
B settings + ara            arabic        0.838   0.956    0.860     0
C B + frequency             semantic      0.336   0.417    0.347     5
C B + frequency             arabic        0.838   0.950    0.860     0
D C + hybrid 0.5            semantic      0.764   0.900    0.814     0
D C + hybrid 0.5            arabic        0.941   0.963    0.951     0
E vectors only (ratio 1.0)  semantic      0.764   0.900    0.814     0
E vectors only (ratio 1.0)  arabic        0.836   0.912    0.859     0
```

في المخرج أعلاه لاحظ التالي:

- **إعداد اللغة هو أكبر فرق في الأسئلة العربية:** من 0.765 إلى 0.860، واختفت الأسئلة التي لا ترجع شيئاً، وهما «كاميرا مراقبة» و«شركه ناشئه» اللتان رأيناهما في قسم النص العربي.
- **frequency لم تغير جودة الترتيب هنا:** فائدتها في عدد النتائج والأوجه كما رأينا، وفي الأسئلة الطويلة خفضت المقياس قليلاً لأنها تحذف كلمات مهمة شائعة.
- **البحث بالكلمات وحده ضعيف في أسئلة المستخدمين:** 0.38 فقط، وهذا متوقع لأن الأسئلة مكتوبة بكلمات لا توجد في العناوين.
- **البحث الهجين هو الأفضل في المجموعتين:** 0.814 في أسئلة المستخدمين، وهو الرقم نفسه الذي أعطاه bge-m3 في OpenSearch في مقال البحث الدلالي، و0.951 في الأسئلة العربية لأن الكلمات تصلح ما يخطئ فيه المتجه، مثل «Wiregaurd» التي لا يفهمها النموذج.
- **نقطة الضعف الباقية هي الجذع:** «النسخه الاحتياطيه» أخذت 0.10 في كل إعدادات الكلمات، لأن «النسخة» لا تطابق «النسخ» و«الاحتياطية» لا تطابق «الاحتياطي» دون جذع، والبحث الهجين أصلحها.

## النسخ الاحتياطي: اللقطة Snapshot والتصدير Dump

في Meilisearch طريقتان، وبحسب [توثيق النسخ الاحتياطي](https://www.meilisearch.com/docs/resources/self%5Fhosting/data%5Fbackup/overview?ref=arabroot.io):

- **اللقطة Snapshot:** نسخة من قاعدة البيانات `data.ms` كما هي، استرجاعها سريع لأن الفهرس جاهز، لكنها تعمل مع نفس الإصدار فقط.
- **التصدير Dump:** المستندات والإعدادات والمفاتيح بصيغة مستقلة عن الإصدار، واسترجاعه يعيد الفهرسة كلها، وهو الطريقة الصحيحة للترقية بين الإصدارات.

احفظ الملف التالي باسم `backup.py`:

```python
# A snapshot (copy of the database, same version only) and a dump (portable export, for upgrades).
from lab import call, wait
for kind in ("snapshots", "dumps"):
    _, t = call("POST", f"/{kind}")
    t = wait(t)
    print(f"{kind:<10}{t['status']:<10}{t['duration']:<16}{t.get('details', '')}")
```

```bash
docker exec -w /lab lab-srch2-py python backup.py
docker exec lab-srch2-meili sh -c 'ls -la /meili_data/snapshots /meili_data/dumps'
```

والمخرج سوف يكون كما يلي:

```text
snapshots succeeded PT0.470624422S
dumps     succeeded PT0.996484071S  {'dumpUid': '20261012-200815966'}
/meili_data/dumps:
total 3528
drwxr-xr-x    2 root     root          4096 Oct 12 20:08 .
drwxr-xr-x    5 root     root          4096 Oct 12 20:07 ..
-rw-r--r--    1 root     root       3604322 Oct 12 20:08 20261012-200815966.dump

/meili_data/snapshots:
total 2472
drwxr-xr-x    2 root     root          4096 Oct 12 20:08 .
drwxr-xr-x    5 root     root          4096 Oct 12 20:07 ..
-r--r--r--    1 root     root       2519489 Oct 12 20:08 data.ms.snapshot
```

والنسخة التي لم تسترجعها ليست نسخة، كما ذكرنا في [قاعدة 3-2-1](https://arabroot.io/articles/%D8%A7%D9%84%D9%86%D8%B3%D8%AE-%D8%A7%D9%84%D8%A7%D8%AD%D8%AA%D9%8A%D8%A7%D8%B7%D9%8A-%D8%A8%D9%82%D8%A7%D8%B9%D8%AF%D8%A9-3-2-1-%D9%88%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%A7%D9%84%D8%A7%D8%B3%D8%AA%D8%B9%D8%A7%D8%AF%D8%A9/)، لذلك نسخنا الملفين خارج ال Container وشغلنا سيرفراً جديداً فارغاً يسترجع ال Dump:

```bash
mkdir -p backup
docker cp lab-srch2-meili:/meili_data/dumps/20261012-200815966.dump backup/articles.dump
docker cp lab-srch2-meili:/meili_data/snapshots/data.ms.snapshot backup/data.ms.snapshot
docker run -d --name lab-srch2-meili-restore --network lab-srch2-meili_default \
  -e MEILI_ENV=production -e MEILI_MASTER_KEY="$MEILI_MASTER_KEY" -e MEILI_NO_ANALYTICS=true \
  -v "$PWD/backup:/backup:ro" getmeili/meilisearch:v1.55.0 meilisearch --import-dump /backup/articles.dump
```

فتوقف السيرفر الجديد بعد قرابة أربع دقائق بالخطأ التالي:

```text
ERROR meilisearch: error=Error while generating embeddings: runtime error: could not determine model dimensions:
  - test embedding failed with runtime error: could not reach embedding server:
  - bad uri: Rejected URI
```

ال Dump يحمل إعداد المضمن، والسيرفر الجديد يتحقق منه عند الاستيراد، ولأننا نسينا المتغير `MEILI_EXPERIMENTAL_ALLOWED_IP_NETWORKS` رفض الوصول إلى Ollama ولم يكمل الاسترجاع. وهذا بالضبط ما يحدث يوم الكارثة إذا لم تجرب الاسترجاع قبلها: سيرفر جديد بإعدادات ناقصة. لذلك كتبنا الخطوات في ملف `restore.sh` مع المتغير، وهو يسترجع ال Dump ثم اللقطة، كل واحدة في سيرفر جديد فارغ، ويطبع عدد المستندات والمتجهات والمفاتيح:

```bash
#!/bin/sh
# Restores the dump, then the snapshot, each into a new empty Meilisearch on the lab network, and checks
# the documents, the vectors and the keys. Run from the lab folder after backup.py and the docker cp commands.
set -e
. ./.env
for kind in dump snapshot; do
  if [ "$kind" = dump ]; then ARG="--import-dump /backup/articles.dump"; else ARG="--import-snapshot /backup/data.ms.snapshot"; fi
  docker run -d --name lab-srch2-meili-restore --network lab-srch2-meili_default \
    -e MEILI_ENV=production -e MEILI_MASTER_KEY="$MEILI_MASTER_KEY" -e MEILI_NO_ANALYTICS=true \
    -e MEILI_EXPERIMENTAL_ALLOWED_IP_NETWORKS=10.213.88.0/24 \
    -v "$PWD/backup:/backup:ro" getmeili/meilisearch:v1.55.0 meilisearch $ARG >/dev/null
  START=$(date +%s)
  until docker logs lab-srch2-meili-restore 2>&1 | grep -q -E "listening on|ERROR"; do sleep 2; done
  echo "$kind ready after $(( $(date +%s) - START ))s"
  docker exec lab-srch2-py python -c "
import json, os, urllib.request
key = os.environ['MEILI_MASTER_KEY']
def get(p):
    req = urllib.request.Request('http://lab-srch2-meili-restore:7700' + p, headers={'Authorization': 'Bearer ' + key})
    return json.loads(urllib.request.urlopen(req).read())
s = get('/indexes/articles/stats')
print('$kind', s['numberOfDocuments'], s['numberOfEmbeddedDocuments'], [k['name'] for k in get('/keys')['results']])
"
  docker rm -f -v lab-srch2-meili-restore >/dev/null
done
```

```bash
docker rm -f lab-srch2-meili-restore
sh restore.sh
```

والمخرج سوف يكون كما يلي:

```text
dump ready after 13s
dump 366 366 ['Default Search API Key', 'Default Admin API Key', 'Default Read-Only Admin API Key', 'Default Chat API Key']
snapshot ready after 2s
snapshot 366 366 ['Default Search API Key', 'Default Admin API Key', 'Default Read-Only Admin API Key', 'Default Chat API Key']
```

الرقمان هما عدد المستندات وعدد المستندات التي لها متجهات، والمفاتيح رجعت مع البيانات (المفتاح الذي أنشأناه في قسم المفاتيح حذفناه قبل النسخ). ولاحظ الفرق في الوقت: اللقطة ثانيتان لأن الفهرس جاهز، وال Dump ثلاث عشرة ثانية لأنه أعاد الفهرسة، وهو فرق يكبر مع حجم البيانات. لذلك استخدم اللقطات المجدولة بالخيار `--schedule-snapshot` للاسترجاع السريع، وال Dump قبل كل ترقية.

## التشغيل اليومي

- **الذاكرة:** بعد الفهرسة والتضمين كان ال Container يستخدم 237 ميجابايت مع 366 مستنداً، وMeilisearch يعتمد على ملفات مربوطة بالذاكرة Memory-Mapped، فالذاكرة التي تراها تكبر مع الفهرس، ولكن حجم الفهرس على القرص أهم في التخطيط.
- **الصحة:** الطريق `/health` لا يحتاج مفتاحاً ويناسب فحص ال Reverse Proxy، والطريق `/stats` يعطي عدد المستندات وحجم كل فهرس.
- **الترقية:** أنشئ Dump، ثم شغل الإصدار الجديد على مجلد بيانات فارغ مع `--import-dump`، ولا تشغل إصداراً جديداً على مجلد بيانات قديم إلا إذا قالت ملاحظات الإصدار إن ذلك مدعوم.
- **المهام:** الطريق `/tasks` يحفظ تاريخ كل المهام، وابحث فيه عن المهام الفاشلة `/tasks?statuses=failed` بعد كل رفع كبير.

## أخطاء شائعة

- **وضع مفتاح Admin أو المفتاح الرئيسي في الواجهة:** أي شخص يفتح أدوات المطور في المتصفح يستطيع حذف فهارسك، واستخدم مفتاح بحث أو رمز مستأجر.
- **الفلترة في الواجهة بدل رمز المستأجر:** المستخدم يحذف الفلتر من الطلب فيرى بيانات غيره.
- **ترك اكتشاف اللغة تلقائياً مع نص عربي:** رأينا سؤالاً صحيحاً من كلمتين يرجع صفحة فارغة.
- **ترك searchableAttributes على كل الحقول:** الحقول الداخلية مثل المسار والحالة تدخل في البحث وتخلط النتائج.
- **الوثوق بالبحث الهجين دون مراقبة:** عند تأخر النموذج يرجع إلى الكلمات وحدها برمز 200.
- **انتظار أن يكون Meilisearch المجاني عنقوداً:** النسخ بين السيرفرات في نسخة Enterprise، فخطط للاسترجاع من اللقطات.

## تدريب عملي

- أضف مرادفات Synonyms لـ «طابور» و«طوابير» و«الشهادة» و«الشهادات»، وقس الأسئلة العربية قبلها وبعدها.
- أنشئ رمز مستأجر بفلترين، مسار ومرحلة، ومدة خمس دقائق، وتأكد أنه يرفض بعد انتهائها.

## حذف المختبر

```bash
docker rm -f lab-srch2-meili-restore 2>/dev/null
docker compose down -v
rm -rf backup .env
docker image rm getmeili/meilisearch:v1.55.0
```

## الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

- Meilisearch مناسب لمربع البحث داخل التطبيق، ونسخة المجتمع تحت MIT، لكن التقسيم والنسخ بين السيرفرات في نسخة Enterprise بترخيص BUSL، فالنسخة المجانية سيرفر واحد.
- حدد الحقول القابلة للبحث والفلترة والترتيب واللغة قبل رفع المستندات، فتغييرها بعد ذلك يعيد الفهرسة.
- مع العربية: حدد اللغة `ara` في الإعداد وفي السؤال، واستخدم `matchingStrategy: frequency` حتى لا تتحول «ال» إلى مئات النتائج، وأضف مرادفات للجموع لأنه لا يجذع الكلمات.
- أعط المتصفح رمز مستأجر قصير المدة وليس مفتاحاً، واحذف المفتاح الأب عند التسرب.
- البحث الهجين رفع جودة الترتيب في أسئلة المستخدمين من 0.38 إلى 0.81 على مجموعتنا، لكن راقب `semanticHitCount` واحسب متجه السؤال بنفسك إذا كان النموذج بطيئاً.
- اللقطة للاسترجاع السريع بنفس الإصدار، وال Dump للترقية، وجرب الاسترجاع على سيرفر جديد بنفس متغيرات البيئة.

[![](https://arabroot.io/content/images/2026/10/arabic-search-cover-cover.webp)دليلالبحث في النصوص العربية مع OpenSearch وMeilisearchلماذا يرجع البحث صفحة فارغة لمن يكتب «مستشفي» أو «اجازه»، وكيف تضبط محركك حتى يجد الكلمة بكل أشكالها.](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D8%AD%D8%AB-%D9%81%D9%8A-%D8%A7%D9%84%D9%86%D8%B5%D9%88%D8%B5-%D8%A7%D9%84%D8%B9%D8%B1%D8%A8%D9%8A%D8%A9-%D9%85%D8%B9-opensearch-%D9%88-meilisearch/)[![](https://arabroot.io/content/images/2026/10/semantic-search-cover-cover.webp)دليلالبحث الدلالي Semantic Search بالـ Embeddingsمتى يتفوق البحث بالمعنى على البحث بالكلمات في النص العربي، وكيف تقيس ذلك على أسئلتك قبل أن تختار.](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D8%AD%D8%AB-%D8%A7%D9%84%D8%AF%D9%84%D8%A7%D9%84%D9%8A-semantic-search-%D8%A8%D8%A7%D9%84-embeddings/)[![](https://arabroot.io/content/images/2026/10/typesense-cover-cover.webp)دليلTypesense: بحث سريع يتحمل الأخطاء الإملائيةكيف تبني على Typesense بحثاً يتحمل الأخطاء ويفهم العربية، وتشغله في عنقود من ثلاث عقد يستمر إذا سقطت إحداها.](https://arabroot.io/articles/typesense-%D8%A8%D8%AD%D8%AB-%D8%B3%D8%B1%D9%8A%D8%B9-%D9%8A%D8%AA%D8%AD%D9%85%D9%84-%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1-%D8%A7%D9%84%D8%A7%D9%85%D9%84%D8%A7%D8%A6%D9%8A%D8%A9/)[![](https://arabroot.io/content/images/2026/10/search-compare-cover-cover.webp)دليلأي محرك بحث يناسب تطبيقك ونصوصك العربية؟قارنا أربعة محركات بحث على الأسئلة العربية نفسها في جودة النتائج والذاكرة والسرعة والرخصة، لتعرف أيها يناسب حالتك.](https://arabroot.io/articles/%D8%A7%D9%8A-%D9%85%D8%AD%D8%B1%D9%83-%D8%A8%D8%AD%D8%AB-%D9%8A%D9%86%D8%A7%D8%B3%D8%A8-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D9%83-%D9%88%D9%86%D8%B5%D9%88%D8%B5%D9%83-%D8%A7%D9%84%D8%B9%D8%B1%D8%A8%D9%8A%D8%A9/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة على Meilisearch 1.55.0 وOllama 0.40.3 مع bge-m3 وPython 3.13.16 في Docker.