> ## 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.

# البحث الدلالي Semantic Search بالـ Embeddings
- URL: https://arabroot.io/articles/البحث-الدلالي-semantic-search-بال-embeddings/
- Published: 2026-10-12T14:15:00.000Z
- Updated: 2026-10-11T21:16:38.000Z
- Description: متى يتفوق البحث بالمعنى على البحث بالكلمات في النص العربي، وكيف تقيس ذلك على أسئلتك قبل أن تختار.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, هندسة البرمجيات وتصميم الأنظمة, الاستضافة الذاتية

في [دليل RAG](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D8%AD%D8%AB-%D9%81%D9%8A-%D9%85%D8%B3%D8%AA%D9%86%D8%AF%D8%A7%D8%AA%D9%83-%D9%85%D8%B9-rag-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/) استخدمنا البحث بالمتجهات لنجد المقاطع التي يجيب منها نموذج اللغة، وشرحنا هناك التقطيع Chunking ونموذج التضمين وفهرس HNSW في pgvector ودمج القائمتين بـ RRF. أما هذا المقال فعن سؤال آخر يسأله كل من يملك مربع بحث في موقعه أو تطبيقه: هل أضيف البحث الدلالي Semantic Search إلى البحث الحالي بالكلمات؟ وأي نموذج أستخدم للعربية؟ وهل البحث الهجين أفضل دائماً كما يقال؟ والطريقة الوحيدة للإجابة دون تخمين أن تقيس، لذلك أخذنا مقالات عرب رووت نفسها، 366 مقالاً بعناوينها وملخصاتها، وكتبنا ثلاثين سؤالاً كما يكتبها القراء، وقسنا أربع طرق بحث مع ثلاثة نماذج مفتوحة تعمل على معالج CPU دون GPU.

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

- الفرق بين البحث بالكلمات Keyword Search والبحث بالمعنى Semantic Search والبحث الهجين Hybrid Search، ومتى يفشل كل منها.
- نماذج تضمين متعددة اللغات تعمل محلياً، ورخصها، وأيها أفضل للعربية في تجربتنا.
- البحث الهجين داخل OpenSearch بطريقتين: توحيد الدرجات Normalization والدمج بالترتيب RRF.
- كيف تقيس جودة البحث بـ recall@5 وMRR وnDCG على مجموعة أسئلة صغيرة تعرف إجاباتها.
- التكلفة: وقت التضمين، والذاكرة، وحجم الفهرس، وما يحدث عندما تخطئ في أبعاد المتجه.

## ثلاث طرق للبحث

البحث بالكلمات هو ما شرحناه في [مقال كيف يعمل البحث](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/): فهرس معكوس وترتيب BM25، فيجد المستند الذي يشترك مع السؤال في كلمات بعد التحليل، ويعطي الكلمة النادرة وزناً أكبر. وقوته في الأسماء والرموز، فمن يكتب «pgBackRest» أو «HSTS» يريد هذه الكلمة بالضبط، وضعفه أن القارئ لا يعرف دائماً الكلمة التي كتبها الكاتب، فيكتب «الرسائل تذهب إلى البريد المزعج» والمقال الصحيح عنوانه «شرح SPF وDKIM وDMARC».

أما البحث بالمعنى فيستخدم نموذج تضمين Embedding Model يحول كل نص إلى متجه Vector من مئات الأرقام، بحيث تقع النصوص المتقاربة في المعنى متجاورة، ثم يبحث عن أقرب المتجهات إلى متجه السؤال، والصورة التالية تبين الفرق:

![يسار: سؤال الرسائل التي أرسلها من سيرفري تذهب إلى البريد المزعج، وتحته مقال شرح SPF وDKIM وDMARC، وبينهما علامة X حمراء ومكتوب لا توجد كلمة مشتركة بعد التحليل، وتحته صندوق فيه pgBackRest وHSTS وVPN-ERR-729 ولكنه يجد الأسماء والرموز النادرة بدقة. يمين: فضاء متجهات فيه مجموعات نقاط للنسخ الاحتياطي والدخول الموحد وتوثيق البريد، والسؤال نقطة برتقالية قريبة من مجموعة توثيق البريد داخل دائرة متقطعة](https://arabroot.io/content/images/2026/10/semantic-search-01-keyword-vs-meaning.webp)

البحث بالكلمات يحتاج كلمة مشتركة، والبحث بالمعنى يحتاج معنى قريباً

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

## نماذج تضمين مفتوحة تفهم العربية

اخترنا ثلاثة نماذج متعددة اللغات متاحة في [Ollama](https://docs.ollama.com/capabilities/embeddings?ref=arabroot.io) وتعمل على CPU، وتحققنا من رخصة كل نموذج من صفحته في Hugging Face:

| النموذج                                                                                                                       | الحجم في Ollama | الأبعاد | الرخصة     |
| ----------------------------------------------------------------------------------------------------------------------------- | --------------- | ------- | ---------- |
| [BAAI/bge-m3](https://huggingface.co/BAAI/bge-m3?ref=arabroot.io)                                                             | 1.2 GB          | 1024    | MIT        |
| [Qwen3-Embedding-0.6B](https://huggingface.co/Qwen/Qwen3-Embedding-0.6B?ref=arabroot.io)                                      | 639 MB          | 1024    | Apache-2.0 |
| [granite-embedding-278m-multilingual](https://huggingface.co/ibm-granite/granite-embedding-278m-multilingual?ref=arabroot.io) | 562 MB          | 768     | Apache-2.0 |

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

- الرخص الثلاث تسمح بالاستخدام التجاري، أما نماذج مثل [EmbeddingGemma](https://huggingface.co/google/embeddinggemma-300m?ref=arabroot.io) فرخصتها Gemma الخاصة وتحتاج قبول شروط قبل التنزيل، لذلك اقرأ الرخصة قبل أن تبني منتجاً على أي نموذج.
- صفحة granite تذكر العربية صراحة في قائمة لغاتها، وbge-m3 وQwen3 يذكران دعم أكثر من مئة لغة.
- نموذج [Qwen3-Embedding](https://huggingface.co/Qwen/Qwen3-Embedding-0.6B?ref=arabroot.io) مصمم ليأخذ تعليمات Instruction قبل السؤال وليس قبل المستند، وتقول صفحته إن التعليمات تحسن النتيجة بين 1% و5% في أغلب المهام، وتنصح بكتابتها بالإنجليزية، وسوف نقيس ذلك.

## التجربة: 366 مقالاً وثلاثون سؤالاً

### البيانات والأسئلة

البيانات هي عنوان وملخص كل مقال في الموقع وقت التجربة، في ملف `corpus.jsonl`، وكل مقال مستند واحد قصير، فلا نحتاج تقطيعاً هنا، لأن العنوان والملخص معاً أقل من مئة كلمة، أما المستندات الطويلة فتقطعها كما في [قسم التقطيع في دليل RAG](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D8%AD%D8%AB-%D9%81%D9%8A-%D9%85%D8%B3%D8%AA%D9%86%D8%AF%D8%A7%D8%AA%D9%83-%D9%85%D8%B9-rag-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#chunking). والأسئلة ثلاثون سؤالاً كتبناها بأنفسنا وحددنا لكل سؤال المقالات الصحيحة، وهي من أربعة أنواع: كلمات مفتاحية Keyword مثل «pgBackRest»، وأسئلة بصياغة مختلفة Paraphrase مثل «كيف أمنع موظفاً غادر الشركة من الدخول إلى الأنظمة»، وأسئلة بلهجة محكية Dialect مثل «ابغى اسوي نسخه احتياطيه للسيرفر»، وأسئلة بالإنجليزية English عن مقالات عربية مثل «how to choose a VPS». وهذا جزء من الملف:

```json
[
  {"type": "keyword", "q": "pgBackRest", "rel": ["postgres-pitr"]},
  {"type": "paraphrase", "q": "الرسائل التي أرسلها من سيرفري تذهب إلى البريد المزعج", "rel": ["email-authentication", "amazon-ses-relay", "transactional-email"]},
  {"type": "dialect", "q": "ابغى اسوي نسخه احتياطيه للسيرفر", "rel": ["backup-321", "backup-tools-compare"]},
  {"type": "english", "q": "how to choose a VPS", "rel": ["choose-vps", "cloud-cost"]}
]
```

والأرقام من مجموعة صغيرة كتبناها بأنفسنا، فهي توضيحية Illustrative ولا تصلح حكماً عاماً على النماذج، ولكن الطريقة نفسها هي ما تحتاجه: أسئلة حقيقية من سجل البحث في موقعك، ومعها الإجابات الصحيحة، ثم القياس بعد كل تغيير.

### OpenSearch وOllama في Docker

```yaml
# Semantic search lab: OpenSearch (BM25 + k-NN) and Ollama for embeddings, CPU only.
# Security is disabled here to keep the lab short; never do that outside 127.0.0.1
# (the OpenSearch guide on the site shows the security plugin).
name: lab-sem

services:
  os:
    image: opensearchproject/opensearch:3.9.0
    container_name: lab-sem-os
    environment:
      - discovery.type=single-node
      - OPENSEARCH_JAVA_OPTS=-Xms1g -Xmx1g
      - DISABLE_SECURITY_PLUGIN=true
      - DISABLE_INSTALL_DEMO_CONFIG=true
    mem_limit: 3g
    volumes:
      - os-data:/usr/share/opensearch/data
    ports:
      - "127.0.0.1:19202:9200"

  ollama:
    image: ollama/ollama:0.40.3
    container_name: lab-sem-ollama
    environment:
      - OLLAMA_KEEP_ALIVE=30m        # keep the model in memory between requests
      - OLLAMA_LOAD_TIMEOUT=20m      # a slow, busy CPU needs longer than the default 5 minutes
      - OLLAMA_MAX_LOADED_MODELS=1
    volumes:
      - ollama-models:/root/.ollama

  py:
    image: python:3.13.16-slim
    container_name: lab-sem-py
    working_dir: /lab
    volumes:
      - ./:/lab
    command: ["sleep", "infinity"]

volumes:
  os-data:
  ollama-models:
```

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

- عطلنا الإضافة Security في هذه التجربة فقط لنختصر الأوامر، والمنفذ على `127.0.0.1`، أما الإعداد الصحيح مع الصلاحيات ففي [دليل 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/).
- `OLLAMA_LOAD_TIMEOUT=20m` لم يكن في الملف أول مرة، وأضفناه بعد أن فشل تحميل النموذج بالرسالة `timed out waiting for llama-server to start`، لأن الجهاز كان مشغولاً بتجارب أخرى، والمهلة الافتراضية خمس دقائق.
- Ollama هنا ليس فيه نموذج لغة للإجابة، وإنما نماذج تضمين فقط.

والبرنامج `search.py` بلغة Python دون أي مكتبة خارجية، وهو يضمن المستندات ويفهرسها، ثم يشغل الأسئلة بأربع طرق ويحسب المقاييس:

```python
# Semantic search lab: the same 30 labelled questions against BM25, vectors and hybrid search in OpenSearch.
#   python search.py index <model>      embed the corpus with <model> (Ollama) and index it
#   python search.py eval <model>       recall@5, MRR@10, nDCG@10 per method
#   python search.py ask <model> "<q>"  top 5 of every method for one question
#   python search.py ann <model>        HNSW top 10 against exact search top 10
# Standard library only.
import json, math, statistics, sys, time, urllib.error, urllib.request

OS = "http://lab-sem-os:9200"
OLLAMA = "http://lab-sem-ollama:11434"
# Qwen3-Embedding expects an instruction before the query (model card); documents are embedded as they are
QWEN_TASK = "Instruct: Given a web search query, retrieve relevant articles that answer the query\nQuery:"

def call(method, url, body=None):
    data = json.dumps(body, ensure_ascii=False).encode() if body is not None else None
    req = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(req, timeout=3600) as r:
            return json.loads(r.read() or b"{}")
    except urllib.error.HTTPError as e:
        raise SystemExit(f"{method} {url} -> {e.code}: {e.read().decode()[:600]}")

def embed(model, texts):
    return call("POST", f"{OLLAMA}/api/embed", {"model": model, "input": texts})["embeddings"]

def index_name(model):
    return "docs-" + model.replace(":", "-").replace(".", "")

def query_text(model, q):
    import os
    if model.startswith("qwen3-embedding") and os.environ.get("NO_INSTRUCT") != "1":
        return QWEN_TASK + q
    return q

def load():
    docs = [json.loads(l) for l in open("corpus.jsonl", encoding="utf-8") if l.strip()]
    return docs, json.load(open("queries.json", encoding="utf-8"))

def cmd_index(model):
    docs, _ = load()
    texts = [d["title"] + "\n" + d["excerpt"] for d in docs]
    t0 = time.time(); vecs = []
    for i in range(0, len(texts), 16):
        vecs += embed(model, texts[i:i + 16])
    secs = time.time() - t0
    dim = len(vecs[0]); name = index_name(model)
    call("DELETE", f"{OS}/{name}?ignore_unavailable=true")
    call("PUT", f"{OS}/{name}", {
        "settings": {"index": {"knn": True, "number_of_shards": 1, "number_of_replicas": 0}},
        "mappings": {"properties": {
            "title": {"type": "text", "analyzer": "arabic"},
            "text": {"type": "text", "analyzer": "arabic"},
            "vec": {"type": "knn_vector", "dimension": dim,
                    "method": {"name": "hnsw", "engine": "faiss", "space_type": "innerproduct",
                               "parameters": {"m": 16, "ef_construction": 128}}}}}})
    bulk = ""
    for d, t, v in zip(docs, texts, vecs):
        bulk += json.dumps({"index": {"_index": name, "_id": d["id"]}}) + "\n"
        bulk += json.dumps({"title": d["title"], "text": t, "vec": v}, ensure_ascii=False) + "\n"
    req = urllib.request.Request(f"{OS}/_bulk?refresh=true", data=bulk.encode(), method="POST",
                                 headers={"Content-Type": "application/x-ndjson"})
    r = json.loads(urllib.request.urlopen(req, timeout=3600).read())
    norm = math.sqrt(sum(x * x for x in vecs[0]))
    print(f"{model}: {len(docs)} docs, dim {dim}, |v|={norm:.3f}, embedding {secs:.1f}s "
          f"({1000 * secs / len(docs):.0f} ms/doc), bulk errors: {r['errors']}")

def pipelines():
    call("PUT", f"{OS}/_search/pipeline/hybrid-minmax", {"phase_results_processors": [{"normalization-processor": {
        "normalization": {"technique": "min_max"},
        "combination": {"technique": "arithmetic_mean", "parameters": {"weights": [0.5, 0.5]}}}}]})
    call("PUT", f"{OS}/_search/pipeline/hybrid-rrf", {"phase_results_processors": [{"score-ranker-processor": {
        "combination": {"technique": "rrf", "rank_constant": 60}}}]})

def search(model, method, q, qvec=None, k=10):
    name = index_name(model)
    bm25 = {"multi_match": {"query": q, "fields": ["title^2", "text"]}}
    knn = {"knn": {"vec": {"vector": qvec, "k": 50}}} if qvec is not None else None
    if method == "bm25":
        body, path = {"size": k, "query": bm25}, f"{OS}/{name}/_search"
    elif method == "vector":
        body, path = {"size": k, "query": knn}, f"{OS}/{name}/_search"
    else:
        body = {"size": k, "query": {"hybrid": {"queries": [bm25, knn]}}}
        path = f"{OS}/{name}/_search?search_pipeline={method}"
    body["_source"] = False
    r = call("POST", path, body)
    return [h["_id"] for h in r["hits"]["hits"]], r["took"]

def metrics(ranked, rel):
    rel = set(rel)
    recall5 = len(rel & set(ranked[:5])) / 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 recall5, mrr, dcg / idcg

METHODS = ["bm25", "vector", "hybrid-minmax", "hybrid-rrf"]

def cmd_eval(model):
    _, qs = load(); pipelines()
    t0 = time.time()
    qvecs = embed(model, [query_text(model, q["q"]) for q in qs])
    embed_ms = 1000 * (time.time() - t0) / len(qs)
    rows, by_type, took = {}, {}, {}
    for m in METHODS:
        scores = []
        for q, v in zip(qs, qvecs):
            ranked, t = search(model, m, q["q"], v)
            s = metrics(ranked, q["rel"]); scores.append(s)
            by_type.setdefault((m, q["type"]), []).append(s[2]); took.setdefault(m, []).append(t)
        rows[m] = [statistics.mean(x) for x in zip(*scores)]
    print(f"model {model}  ({len(qs)} questions, query embedding {embed_ms:.0f} ms each)")
    print(f"{'method':<15}{'recall@5':>10}{'MRR@10':>9}{'nDCG@10':>9}{'took ms':>9}")
    for m in METHODS:
        print(f"{m:<15}{rows[m][0]:>10.3f}{rows[m][1]:>9.3f}{rows[m][2]:>9.3f}{statistics.median(took[m]):>9.0f}")
    types = sorted({q["type"] for q in qs})
    print("nDCG@10 by question type: " + ", ".join(f"{t} ({sum(q['type'] == t for q in qs)})" for t in types))
    for m in METHODS:
        print(f"  {m:<15}" + "".join(f"{statistics.mean(by_type[(m, t)]):>12.3f}" for t in types))

def cmd_ask(model, q):
    pipelines(); v = embed(model, [query_text(model, q)])[0]
    for m in METHODS:
        ranked, _ = search(model, m, q, v, k=5)
        print(f"{m:<14} " + "  ".join(f"{i + 1}.{d}" for i, d in enumerate(ranked)))

def cmd_ann(model):
    _, qs = load(); name = index_name(model)
    qvecs = embed(model, [query_text(model, q["q"]) for q in qs])
    same = []
    for v in qvecs:
        approx = [h["_id"] for h in call("POST", f"{OS}/{name}/_search", {"size": 10, "_source": False,
                  "query": {"knn": {"vec": {"vector": v, "k": 10}}}})["hits"]["hits"]]
        exact = [h["_id"] for h in call("POST", f"{OS}/{name}/_search", {"size": 10, "_source": False, "query": {
            "script_score": {"query": {"match_all": {}}, "script": {"source": "knn_score", "lang": "knn",
                             "params": {"field": "vec", "query_value": v, "space_type": "innerproduct"}}}}})["hits"]["hits"]]
        same.append(len(set(approx) & set(exact)) / 10)
    print(f"{model}: HNSW top-10 matches exact top-10 in {100 * statistics.mean(same):.1f}% of results")

if __name__ == "__main__":
    {"index": cmd_index, "eval": cmd_eval, "ask": cmd_ask, "ann": cmd_ann}[sys.argv[1]](*sys.argv[2:])
```

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

- الحقل `vec` من نوع `knn_vector` بفهرس HNSW من مكتبة Faiss، و`space_type: innerproduct` لأن Ollama يرجع متجهات طولها 1 كما يظهر في المخرج `|v|=1.000`، والضرب الداخلي للمتجهات بطول 1 يساوي تشابه جيب التمام Cosine Similarity.
- الحقلان `title` و`text` بالمحلل `arabic`، فالبحث بالكلمات هنا هو BM25 الحقيقي في Lucene مع المحلل العربي الذي شرحناه في [مقال البحث في النصوص العربية](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/)، وليس بحثاً بسيطاً.
- البحث الهجين استعلام واحد من نوع `hybrid` فيه الاستعلامان، ثم Search Pipeline تدمج القائمتين، وسوف نشرحها بعد قليل.

### التضمين والفهرسة

```bash
docker compose -p lab-sem up -d
docker exec lab-sem-ollama ollama pull bge-m3:567m
docker exec lab-sem-ollama ollama pull qwen3-embedding:0.6b
docker exec lab-sem-ollama ollama pull granite-embedding:278m
docker exec lab-sem-py python search.py index bge-m3:567m
```

```text
bge-m3:567m: 366 docs, dim 1024, |v|=1.000, embedding 391.2s (1069 ms/doc), bulk errors: False
qwen3-embedding:0.6b: 366 docs, dim 1024, |v|=1.000, embedding 1274.2s (3481 ms/doc), bulk errors: False
granite-embedding:278m: 366 docs, dim 768, |v|=1.000, embedding 1238.6s (3384 ms/doc), bulk errors: False
```

وهنا يجب أن نكون صريحين في الأرقام: الجهاز معالج Intel Core i7-7700K بأربع أنوية دون GPU، وكان يشغل تجارب أخرى في نفس الوقت، فارتفع زمن النموذجين الثاني والثالث إلى أكثر من ثلاث ثوان للمستند، فلا تقارن سرعة النماذج من هذه الأرقام، وإنما خذ منها الدرس: تضمين بضعة آلاف من المستندات على CPU مشغول يأخذ ساعات، لذلك اجعل التضمين عملية في الخلفية تعمل مرة عند إضافة المستند أو تعديله، ولا تجعله جزءاً من طلب المستخدم.

## البحث الهجين في OpenSearch

المشكلة في الدمج أن درجة BM25 رقم لا حد له، ودرجة التشابه بين 0 و1، فلا معنى لجمعهما مباشرة. ويوفر OpenSearch طريقتين على شكل Search Pipeline:

- [normalization-processor](https://docs.opensearch.org/latest/search-plugins/search-pipelines/normalization-processor/?ref=arabroot.io): يحول درجات كل قائمة إلى مقياس من 0 إلى 1 بطريقة `min_max`، ثم يحسب متوسطها بأوزان تحددها، وقد جعلناها 0.5 لكل قائمة.
- [score-ranker-processor](https://docs.opensearch.org/latest/search-plugins/search-pipelines/score-ranker-processor/?ref=arabroot.io): أضيف في الإصدار 2.19، ويهمل الدرجات ويستخدم الترتيب فقط بطريقة RRF، فيعطي كل مستند `1/(60 + ترتيبه)` في كل قائمة، وقد شرحنا فكرة RRF في [دليل RAG](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D8%AD%D8%AB-%D9%81%D9%8A-%D9%85%D8%B3%D8%AA%D9%86%D8%AF%D8%A7%D8%AA%D9%83-%D9%85%D8%B9-rag-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#rrf).

والصورة التالية تبين الطريقتين، والقوائم فيها حقيقية من التجربة لسؤال البريد المزعج مع النموذج bge-m3:

![استعلام hybrid فيه match وknn يخرج قائمتين: قائمة BM25 فيها transactional-email ثم nats ثم compare-platforms، وقائمة المتجهات فيها amazon-ses-relay ثم ntfy ثم transactional-email، ثم إما normalization-processor بطريقة min_max وarithmetic_mean وأوزان 0.5 و0.5، أو score-ranker-processor بطريقة rrf والمعادلة 1 على 60 زائد الترتيب، والنتيجة بطريقة min_max هي transactional-email ثم amazon-ses-relay ثم nats](https://arabroot.io/content/images/2026/10/semantic-search-02-hybrid-pipeline.webp)

استعلام واحد وقائمتان، وال Search Pipeline تقرر طريقة الدمج

وهذا مخرج الأمر `ask` لنفس السؤال، أول خمس نتائج لكل طريقة:

```bash
docker exec lab-sem-py python search.py ask bge-m3:567m "الرسائل التي أرسلها من سيرفري تذهب إلى البريد المزعج"
```

```text
bm25           1.transactional-email  2.nats  3.compare-platforms  4.build-email-security  5.citus
vector         1.amazon-ses-relay  2.ntfy  3.transactional-email  4.listmonk  5.mailcow
hybrid-minmax  1.transactional-email  2.amazon-ses-relay  3.nats  4.ntfy  5.listmonk
hybrid-rrf     1.transactional-email  2.listmonk  3.mailcow  4.amazon-ses-relay  5.nats
```

في المخرج أعلاه لاحظ التالي: BM25 وجد مقالاً صحيحاً واحداً لأنه يحتوي كلمة «البريد»، ثم جاء بمقالات NATS وCitus لأن كلمة «الرسائل» و«سيرفر» فيها، والمتجهات وجدت مقالين صحيحين في المركزين الأول والثالث، أما مقال SPF وDKIM وDMARC فلم يظهر في أي قائمة ضمن الخمسة الأولى، وهذا يذكرنا بأن البحث الدلالي ليس سحراً. ولاحظ أن RRF أنزل `amazon-ses-relay` من الأول في قائمة المتجهات إلى الرابع، لأنه لم يظهر في قائمة BM25 أصلاً.

## النتائج: أي طريقة وأي نموذج؟

المقاييس الثلاثة كما يعرفها [كتاب Introduction to Information Retrieval](https://nlp.stanford.edu/IR-book/html/htmledition/evaluation-of-ranked-retrieval-results-1.html?ref=arabroot.io): recall@5 هي نسبة المقالات الصحيحة التي ظهرت في أول خمس نتائج، وMRR@10 تكافئ ظهور أول نتيجة صحيحة في الأعلى، وnDCG@10 تجمع الاثنين، فتعطي كل نتيجة صحيحة وزناً يقل كلما نزل ترتيبها. والأمر:

```bash
docker exec lab-sem-py python search.py eval bge-m3:567m
```

```text
model bge-m3:567m  (30 questions, query embedding 69271 ms each)
method           recall@5   MRR@10  nDCG@10  took ms
bm25                0.531    0.587    0.534        6
vector              0.764    0.900    0.814        3
hybrid-minmax       0.758    0.806    0.756        4
hybrid-rrf          0.731    0.710    0.679        3
nDCG@10 by question type: dialect (3), english (6), keyword (7), paraphrase (14)
  bm25                  0.503       0.353       0.945       0.414
  vector                0.796       0.810       0.945       0.754
  hybrid-minmax         0.944       0.733       0.945       0.631
  hybrid-rrf            0.646       0.693       0.945       0.546
```

```text
model qwen3-embedding:0.6b  (30 questions, query embedding 10179 ms each)
method           recall@5   MRR@10  nDCG@10  took ms
bm25                0.531    0.587    0.534        3
vector              0.750    0.898    0.816        3
hybrid-minmax       0.700    0.766    0.728        5
hybrid-rrf          0.656    0.715    0.678        5
nDCG@10 by question type: dialect (3), english (6), keyword (7), paraphrase (14)
  bm25                  0.503       0.353       0.945       0.414
  vector                0.726       0.937       0.945       0.719
  hybrid-minmax         0.736       0.834       0.945       0.572
  hybrid-rrf            0.517       0.796       0.945       0.528
```

```text
model granite-embedding:278m  (30 questions, query embedding 47 ms each)
method           recall@5   MRR@10  nDCG@10  took ms
bm25                0.531    0.587    0.534       88
vector              0.739    0.788    0.737        5
hybrid-minmax       0.750    0.723    0.695       18
hybrid-rrf          0.692    0.726    0.680        6
nDCG@10 by question type: dialect (3), english (6), keyword (7), paraphrase (14)
  bm25                  0.503       0.353       0.945       0.414
  vector                0.773       0.878       0.945       0.564
  hybrid-minmax         0.769       0.783       0.945       0.516
  hybrid-rrf            0.632       0.754       0.945       0.526
```

والصورة التالية تجمع nDCG@10 للطرق الأربع والنماذج الثلاثة:

![رسم أعمدة لقيم nDCG@10: BM25 يساوي 0.534 للنماذج الثلاثة، والمتجهات 0.737 لـ granite و0.814 لـ bge-m3 و0.816 لـ qwen3، والهجين بطريقة min_max يساوي 0.695 و0.756 و0.728، والهجين بطريقة rrf يساوي 0.680 و0.679 و0.678](https://arabroot.io/content/images/2026/10/semantic-search-03-results.webp)

البحث بالمتجهات وحده كان الأفضل في هذه المجموعة، والهجين لم يتفوق عليه

في النتائج أعلاه لاحظ التالي:

- **البحث بالمعنى رفع الجودة بوضوح:** من 0.534 مع BM25 إلى 0.814 مع bge-m3 و0.816 مع Qwen3، والفرق كله تقريباً في أسئلة الصياغة المختلفة واللهجة والإنجليزية، فالأسئلة الإنجليزية مثلاً نزلت مع BM25 إلى 0.353 لأن المقالات عربية، وارتفعت مع المتجهات إلى أكثر من 0.8.
- **أسئلة الكلمات المفتاحية متساوية:** 0.945 لكل الطرق والنماذج، لأن أسماء مثل pgBackRest وHSTS موجودة في العناوين، والنموذجان الكبيران عرفاها أيضاً، فلم نر الحالة المعروفة التي يفشل فيها البحث بالمعنى في الرموز، وقد رأيناها في [دليل RAG](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D8%AD%D8%AB-%D9%81%D9%8A-%D9%85%D8%B3%D8%AA%D9%86%D8%AF%D8%A7%D8%AA%D9%83-%D9%85%D8%B9-rag-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#hybrid-lab) مع جملة من داخل نص طويل.
- **البحث الهجين لم يتفوق على المتجهات هنا:** لأن BM25 كان ضعيفاً في أغلب الأسئلة، فالدمج معه أضاف نتائج خاطئة مثل مقال NATS وأنزل نتائج صحيحة، والاستثناء أسئلة اللهجة مع bge-m3 حيث ارتفع الهجين بـ min\_max إلى 0.944، لأن BM25 مع المحلل العربي وجد «احتياطيه» و«نسخه». والدرس أن الهجين ليس أفضل دائماً، وأن عليك أن تقيس على أسئلتك قبل أن تعتمده.
- **RRF كان الأضعف بين طرق الدمج في هذه المجموعة:** لأنه يعطي القائمتين نفس الوزن مهما كانت جودة إحداهما، أما min\_max فيحافظ على فرق الدرجات داخل القائمة.
- **زمن البحث نفسه صغير:** بين 3 و6 أجزاء من الألف من الثانية في OpenSearch، أما الوقت الحقيقي ففي تضمين السؤال، فالرقم 69271 ms لـ bge-m3 يشمل تحميل النموذج في الذاكرة بعد نموذج آخر، والرقم 47 ms لـ granite بعد أن كان محملاً، لذلك اترك نموذج الأسئلة محملاً دائماً، ولا تشغل نموذجين على نفس Ollama في الإنتاج.

### أثر التعليمات مع Qwen3-Embedding

شغلنا نفس التقييم دون التعليمات قبل السؤال، بالمتغير `NO_INSTRUCT=1`:

```text
model qwen3-embedding:0.6b  (30 questions, query embedding 775 ms each)
method           recall@5   MRR@10  nDCG@10  took ms
vector              0.761    0.847    0.765        2
```

نزلت nDCG@10 للمتجهات من 0.816 إلى 0.765، ونزلت أسئلة الكلمات المفتاحية من 0.945 إلى 0.892، أي أن سطراً واحداً بالإنجليزية قبل السؤال غير النتيجة بوضوح في هذه المجموعة، وهو أكثر مما ذكرته صفحة النموذج من 1% إلى 5%، وقد يختلف على بياناتك. والقاعدة: اقرأ صفحة النموذج قبل استخدامه، فبعض النماذج تحتاج بادئة مثل `query:` أو تعليمات، وبعضها لا يحتاج شيئاً كما في bge-m3 وgranite.

## فهرس HNSW والتكلفة

البحث عن أقرب المتجهات بدقة كاملة يعني مقارنة السؤال بكل متجه، وهذا بطيء مع الملايين، لذلك تستخدم الفهارس التقريبية Approximate Nearest Neighbor واختصاراً ANN، وأشهرها [HNSW](https://arxiv.org/abs/1603.09320?ref=arabroot.io) الذي شرحنا فكرته في [دليل RAG](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D8%AD%D8%AB-%D9%81%D9%8A-%D9%85%D8%B3%D8%AA%D9%86%D8%AF%D8%A7%D8%AA%D9%83-%D9%85%D8%B9-rag-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#hnsw). والسؤال الذي يجب أن تسأله: كم نتيجة يفقدها الفهرس التقريبي مقارنة بالبحث الكامل؟ والأمر `ann` يقارن أول عشر نتائج من HNSW بأول عشر من بحث كامل Exact بالدالة `knn_score`:

```text
bge-m3:567m: HNSW top-10 matches exact top-10 in 100.0% of results
```

تطابق كامل، وهذا متوقع مع 366 متجهاً فقط، فالفهرس يكاد يزور كل النقاط، أما مع الملايين فقيس نفس الرقم بعد كل تغيير في `m` أو `ef_search`، لأن الفهرس الأسرع قد يفقد نتائج دون أن ينبهك. والتكلفة الثانية الذاكرة، فهذه أحجام ال Indices وذاكرة الفهارس الشعاعية من التجربة:

```text
index                       docs.count store.size
docs-bge-m3-567m                   366        3mb
docs-granite-embedding-278m        366      2.3mb
docs-qwen3-embedding-06b           366        3mb

"graph_memory_usage" : 4187        (KB, the three indices together)
```

والحساب بسيط: المتجه بـ 1024 بعداً من نوع float يأخذ 4096 بايت، فمليون مستند يحتاج قرابة 4 جيجابايت من الذاكرة للمتجهات وحدها قبل روابط الرسم البياني، و768 بعداً تخفضها بالربع، ولهذا يوفر OpenSearch [ضغط المتجهات Quantization](https://docs.opensearch.org/latest/vector-search/optimizing-storage/knn-vector-quantization/?ref=arabroot.io) إلى 16 بت أو بايت واحد أو بت واحد مقابل نقص في الدقة تقيسه بنفس الطريقة. وبحسب ما رأيناه فالنموذج الأصغر granite بـ 768 بعداً أعطى 0.737 مقابل 0.814 لـ bge-m3، فالقرار بين الذاكرة والجودة قرار تقيسه ولا تخمنه.

## نكسر الإعداد: نموذج بأبعاد مختلفة

أشهر خطأ عند تغيير النموذج أن تغير نموذج الأسئلة وتنسى المستندات، أو العكس، فيصل متجه بـ 768 بعداً إلى Index عرف بـ 1024:

```bash
curl -s -H 'Content-Type: application/json' "http://127.0.0.1:19202/docs-bge-m3-567m/_search" \
  -d '{"size":3,"query":{"knn":{"vec":{"vector":[0.01, ... 768 values],"k":3}}}}'
```

```text
400 failed to create query: Query vector has invalid dimension: 768. Dimension should be: 1024
```

هذا الخطأ جيد لأنه واضح، والخطأ الأخطر لا يظهر برسالة: أن تغير النموذج إلى آخر بنفس عدد الأبعاد، مثل bge-m3 وQwen3 وكلاهما 1024، فيقبل OpenSearch السؤال ويرجع نتائج لا معنى لها، لأن كل نموذج يرتب المعاني في فضاء مختلف. لذلك احفظ اسم النموذج وإصداره مع ال Index، مثلاً في اسمه كما فعلنا `docs-bge-m3-567m`، وأعد تضمين كل المستندات عند تغيير النموذج.

## متى تضيف البحث الدلالي؟

- أضفه عندما تجد في سجل البحث أسئلة كثيرة لا ترجع نتائج أو ترجع نتائج خاطئة لأن القارئ استخدم كلمات غير كلمات المحتوى، أو كتب بلهجة أو بلغة أخرى.
- ابدأ بقائمة أسئلة حقيقية مع إجاباتها، ولو ثلاثين سؤالاً كما فعلنا، وقس BM25 أولاً، فإذا كان جيداً في أغلبها فقد يكفيك تحسين المحلل والمرادفات.
- جرب المتجهات وحدها والهجين، واختر بالأرقام، ولا تفترض أن الهجين أفضل.
- احسب التكلفة: وقت التضمين لكل مستند جديد، والذاكرة لكل مليون متجه، وزمن تضمين السؤال، وهو الذي يضاف إلى كل بحث.
- إذا كان سؤالك «أريد إجابة وليس قائمة نتائج» فأنت تبحث عن RAG، وقد شرحناه في دليله.

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

- **الحكم بالانطباع:** ثلاثة أسئلة تجربها بنفسك ليست قياساً، والفروق بين النماذج في التجربة كانت أصغر من الفرق بين سؤال وآخر.
- **اعتماد الهجين دون قياس:** رأينا أنه أنزل الجودة في هذه المجموعة لأن BM25 كان ضعيفاً.
- **نسيان تعليمات النموذج أو بادئته:** مع Qwen3 نزلت الجودة من 0.816 إلى 0.765 دون التعليمات.
- **تغيير النموذج دون إعادة التضمين:** أسوأ حالاته حين تكون الأبعاد متساوية، فلا يظهر أي خطأ.
- **تضمين السؤال في نموذج يحمل من القرص مع كل طلب:** رأينا 69 ثانية لسؤال واحد عند تحميل النموذج، مقابل 47 جزءاً من الألف والنموذج محمل.
- **تجاهل رخصة النموذج:** النموذج جزء من منتجك مثل أي مكتبة، فتحقق من رخصته قبل أن تبني عليه.

## تدريب عملي

1. غير الأوزان في `hybrid-minmax` إلى `[0.2, 0.8]` لصالح المتجهات، ثم أعد التقييم ولاحظ هل يتفوق الهجين على المتجهات وحدها.
2. أضف إلى ملف الأسئلة عشرة أسئلة من سجل البحث في موقعك أو تطبيقك، وأعد القياس، ثم قارن ترتيب النماذج بما رأيناه هنا.

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

```bash
docker compose -p lab-sem down -v
# optional
docker rmi opensearchproject/opensearch:3.9.0 ollama/ollama:0.40.3
```

## الخلاصة

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

- البحث بالكلمات يجد الأسماء والرموز بدقة، والبحث بالمعنى يجد ما كتب بكلمات مختلفة أو بلهجة أو بلغة أخرى، والهجين يدمجهما بتوحيد الدرجات أو بالترتيب.
- في تجربتنا على 366 مقالاً وثلاثين سؤالاً رفعت المتجهات nDCG@10 من 0.534 إلى 0.816، ولم يتفوق الهجين عليها، وهذا قد يختلف على بياناتك، فقس.
- bge-m3 برخصة MIT وQwen3-Embedding-0.6B برخصة Apache-2.0 كانا الأفضل، وgranite بـ 768 بعداً أقل ذاكرة وأقل جودة قليلاً.
- التعليمات قبل السؤال في Qwen3 غيرت النتيجة بوضوح، فاقرأ صفحة النموذج قبل استخدامه.
- احفظ اسم النموذج مع ال Index، واترك نموذج الأسئلة محملاً، واحسب ذاكرة المتجهات قبل أن تصل إلى الملايين.

[![](https://arabroot.io/content/images/2026/10/rag-cover-cover.webp)دليلRAG للمبتدئين: نموذج لغة يجيب من مستنداتك على سيرفرككيف تجعل نموذج اللغة يجيب من مستندات مؤسستك ويذكر مصدره، دون أن يخرج أي مستند من سيرفرك.](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D8%AD%D8%AB-%D9%81%D9%8A-%D9%85%D8%B3%D8%AA%D9%86%D8%AF%D8%A7%D8%AA%D9%83-%D9%85%D8%B9-rag-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/search-basics-cover-cover.webp)دليلكيف يعمل البحث؟ الفهرس المعكوس Inverted Index وترتيب النتائجلماذا لا يكفي LIKE للبحث في تطبيقك، وكيف يجد محرك البحث الكلمات في جزء من الثانية ويرتب أهم النتائج في الأعلى.](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/)[![](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/opensearch-cover-cover.webp)دليلOpenSearch: البديل المفتوح لـ Elasticsearch وقصة الترخيصلماذا ولد OpenSearch من Elasticsearch، وكيف تشغله على سيرفرك بصلاحيات دقيقة وتنبيهات حقيقية على سجلاتك.](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/)

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

- أكتوبر 2026: كتابة المقال، وتجربة على OpenSearch 3.9.0 وOllama 0.40.3 بثلاثة نماذج تضمين على CPU، بعناوين وملخصات 366 مقالاً من الموقع وثلاثين سؤالاً مع إجاباتها.