انتقل إلى المحتوى الرئيسي

الوحدة 4 — Mapping وtext مقابل keyword والمحلّلات

الكوربوس محمَّل، و_count يُعيد 200 853، ويريد Sami أن يُطلق match الأوّل على « climate change ». توقفه Inès: قبل البحث، يجب فهم لماذا رتّب Elasticsearch كلّ حقل كما رتّبه. هذا هو دور mapping — مخطّط المستندات — ومحلّلاته، التي تُقرّر كيف يصير عنوان سلسلةً من المصطلحات القابلة للبحث. تفتح هذه الوحدة elasticsearch/mappings/news.json وتُعلّق على كلّ سطر فيه.

mapping الدّيناميكيّ: ما يُخمّنه Elasticsearch لوحده

إن أنشأتَ فهرسًا دون تمرير mapping، يصنع Elasticsearch واحدًا في الهواء انطلاقًا من أوّل مستند مُفهرَس. لنُجرّب في Kibana Dev Tools:

POST devine/_doc
{
"titre": "Change Is Here. Climate Change.",
"vues": 42,
"publie": true,
"date": "2018-05-26"
}

ثمّ اقرأ ما خمّنه:

GET devine/_mapping

النّتيجة (مقطع):

{
"devine": {
"mappings": {
"properties": {
"titre": {
"type": "text",
"fields": { "keyword": { "type": "keyword", "ignore_above": 256 } }
},
"vues": { "type": "long" },
"publie": { "type": "boolean" },
"date": { "type": "date" }
}
}
}
}

ثلاث قواعد نحفظها من هذا التّخمين:

  1. كلّ سلسلة تصير حقل text مع حقل فرعيّ keyword تلقائيّ بطول 256 محرفًا (ignore_above: 256). يمنحك هذا مرونة النّصّ الكامل مع دقّة keyword، مقابل مضاعفة تكلفة التّخزين على هذا الحقل.
  2. الأعداد الصّحيحة تصير long (64 بت)، لا integer. على عدّاد مشاهدات لن يتجاوز مليارَين، هذا هدر.
  3. سلسلة بصيغة ISO 8601 تُعرَف كـdate. سلسلة بصيغة 26/05/2018 لا تُعرَف — تحطّ في text.
فخّ التّواريخ المُخمَّنة

يتعرّف mapping الدّيناميكيّ على بعض صيغ التّواريخ (ISO 8601، RFC 1123…) لا كلّها. مجموعة بيانات بتواريخ محلّيّة dd/MM/yyyy ستُصنَّف في text، ما يجعل range وdate_histogram مستحيلة. قاعدة Veille: ما إن يحتوي حقل تاريخًا، نكتب نوعه وصيغته يدويًّا.

لهذا السّبب بالضّبط تُسلّم الحقيبة elasticsearch/mappings/news.json، المُطبَّق قبل أيّ _bulk.

mapping الفعليّ لفهرس news، حقلًا بحقل

لنفتح elasticsearch/mappings/news.json — هذا هو الملفّ الذي يُرسله ./lab.sh import-news عبر PUT /news.

{
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0,
"refresh_interval": "30s",
"analysis": {
"analyzer": {
"titre_en": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "asciifolding", "stop_en", "stem_en"]
}
},
"filter": {
"stop_en": { "type": "stop", "stopwords": "_english_" },
"stem_en": { "type": "stemmer", "language": "english" }
}
}
},
"mappings": {
"properties": {
"headline": {
"type": "text",
"analyzer": "titre_en",
"fields": {
"raw": { "type": "keyword", "ignore_above": 512 },
"suggest": { "type": "completion", "max_input_length": 120 }
}
},
"short_description": {
"type": "text",
"analyzer": "titre_en"
},
"category": { "type": "keyword" },
"authors": {
"type": "text",
"fields": {
"raw": { "type": "keyword", "ignore_above": 256 }
}
},
"link": { "type": "keyword", "index": false },
"date": { "type": "date", "format": "yyyy-MM-dd" }
}
}
}

لنَرَ ما يقوله كلّ من هذه الخيارات.

headline: text مع محلّل titre_en، وحقلَين فرعيَّين

العنوان هو ما يبحث فيه Sami بالنّصّ الكامل. فهو إذن text — مُحلَّل، مُقسَّم إلى مصطلحات، قابل للبحث بـmatch. المحلّل المرافق له، titre_en، مُعرَّف فوقه مباشرة في settings.analysis.analyzer. المحلّل سلسلة من ثلاثة طوابق: character filter (لا شيء هنا)، وtokenizer (standard، الذي يقطع على علامات التّرقيم والمسافات)، وقائمة token filters:

  • lowercase: كلّ شيء بالحروف الصّغيرة، حتّى يحطّ « Trump » و« trump » على المصطلح نفسه.
  • asciifolding: يطوي الحركات والحروف غير ASCII (cafécafe، Beyoncébeyonce، naïvenaive). هذا ما يسمح لقارئ يكتب « Beyonce » بلا حركة أن يعثر على « Beyoncé ».
  • stop_en: يزيل الكلمات الفارغة الإنجليزيّة (the، a، of…) المُعرَّفة في القائمة _english_.
  • stem_en: يُعيد الكلمات إلى جذرها. changing وchanged وchanges تصير كلّها chang؛ climate تصير climat. هذا هو stemming، الذي يجعل 2 834 نتيجة تظهر على « climate change » بدل الجزء الضّئيل من التّواجدات الصّارمة.

لـheadline حقلان فرعيّان، مُعلَنان بـfields. هذا هو الحقل المتعدّد، التّقنيّة التي تسمح بتخزين عدّة عروض لنفس المحتوى:

  • headline.raw من نوع keyword (ignore_above: 512): السّلسلة الخامّ، غير المُحلَّلة، تُقتطع إن تجاوزت 512 محرفًا. هذا هو العرض الذي يُستعمل للتّجميع أو الفرز أو term دقيق على العنوان.
  • headline.suggest من نوع completion (max_input_length: 120): بنية خاصّة (FST، ناقل حالات محدود) تسمح بالإكمال التّلقائيّ على البداية. المعامل max_input_length: 120 يُصرّح بعناوين طولها 120 محرفًا؛ القيمة الافتراضيّة لـcompletion هي 50، وكانت ستقطع نصف عناوين HuffPost وتكسر الإكمال التّلقائيّ على المقالات الطّويلة (الوحدة 8).

short_description: text مع المحلّل نفسه

لا حقل فرعيّ: لسنا بحاجة إلى الفرز أو التّجميع على الملخّصات. titre_en يفعل نفس عمله على العناوين، بالفوائد نفسها (stemming، asciifolding).

category: keyword

الفئات الـ41 (POLITICS، WELLNESS، ENTERTAINMENT…) هي بطاقات ثابتة. نريد التّجميع والفرز والتّرشيح عليها، لا البحث بالنّصّ الكامل أبدًا. keyword هو النّوع الوحيد الذي يُلبّي المهامّ الثّلاث: يُخزّن القيمة الخامّ في فهرس مُحسَّن لعمليّات البحث الدّقيق والتّجميع في doc_values.

GET news/_search
{
"size": 0,
"aggs": { "cats": { "terms": { "field": "category", "size": 5 } } }
}

النّتيجة المتوقَّعة:

{ "aggregations": { "cats": { "buckets": [
{ "key": "POLITICS", "doc_count": 32739 },
{ "key": "WELLNESS", "doc_count": 17827 },
{ "key": "ENTERTAINMENT", "doc_count": 16058 },
{ "key": "TRAVEL", "doc_count": 9887 },
{ "key": "STYLE & BEAUTY", "doc_count": 9649 }
] } } }

لو كانت category من نوع text، لكانت هذه القيم قد حُلِّلت: « STYLE & BEAUTY » كانت ستصير مصطلحَين style وbeauty، وكانت agrégation ستحسب كلّ واحد على حدة، وكان ترتيب أبجديّ للفرز مستحيلًا. احفظ القاعدة: keyword لما نُصنّفه، text لما نبحث فيه.

authors: text وauthors.raw من نوع keyword

الحقل authors مزدوج الرّأس، وليس هذا صدفة. سلسلة مثل « Lee Moran and Ron Dicker » يجب أن تصلح لاستعمالَين متناقضَين:

  • البحث عن كلّ مقالات مؤلّف بالنّصّ الكامل: match authors: "Lee Moran". هنا نريد محلّلًا يقطع على المسافات ويضع بالحروف الصّغيرة.
  • إحصاء كم مقالًا وقّعه كلّ مؤلّف دقيق: terms field: authors.raw. هنا نريد السّلسلة الخامّ، بلا تحليل.

قمّة المؤلّفين، بـauthors.raw:

GET news/_search
{
"size": 0,
"aggs": { "top": { "terms": { "field": "authors.raw", "size": 5 } } }
}

النّتيجة المتوقَّعة:

Reuters       4 954
Lee Moran 2 433
Ron Dicker 1 915
Ed Mazza 1 328
Cole Delbyck 1 145

يُكلّف الحقل المتعدّد قليلًا من التّخزين (كلّ عنوان مُفهرَس مرّتَين) لكنّه استثمار مربح تقريبًا في أيّ حقل نتردّد فيه بين البحث والتّجميع.

لكلّ مقال رابط إلى المقال الأصليّ في HuffPost. نعرضه للمستخدم، ولا نبحث فيه أبدًا. الإعداد الصّحيح هو keyword + index: false: يُخزّن Elasticsearch القيمة في _source ويُعيدها في hits، لكنّه لا يحتفظ بـindex inversé عليها. نُوفّر مساحة وذاكرة، مقابل قيد وحيد: يستحيل إجراء term أو match أو تجميع على link. هنا، هذه هي النّيّة بالضّبط.

GET news/_search
{
"query": { "term": { "link": "https://www.huffingtonpost.com/entry/..." } }
}

يُعيد الخطأ search_phase_execution_exception: « Cannot search on field [link] since it is not indexed. ». الرّسالة صريحة: نقرأ الحقل، لا نستعلمه.

date: date بصيغة yyyy-MM-dd

date مع format: yyyy-MM-dd يقبل حصريًّا سلاسل مثل 2018-05-26. يُخزّن Elasticsearch القيمة بـمللي ثانية منذ عصر Unix، ما يجعل range وdate_histogram (الوحدة 6) سريعة جدًّا. يُغطّي كوربوس News من 2012-01-28 إلى 2018-05-26؛ أيّ تاريخ خارج هذه الصّيغة يُرفض عند الفهرسة بـmapper_parsing_exception.

المحلّل titre_en في العمل: واجهة _analyze

_analyze هي العدسة: تُبيّن، كلمةً بكلمة، كيف يُحوّل محلّل نصًّا قبل أن يدخل index inversé. يستطيع Sami اختبار الأمر قبل الفهرسة.

POST news/_analyze
{
"field": "headline",
"text": "Change Is Here. Climate Change."
}

الرّدّ (مقطع، نُبقي المصطلحات فقط):

chang  |  here  |  climat  |  chang

أربعة مصطلحات فقط. Is، . يختفيان (stop_en يزيل is، ويأكل tokenizer علامات التّرقيم). Change وchanging كانتا ستُنتجان المصطلح نفسه chang بفضل stem_en. Here يبقى كما هو: ليست كلمة فارغة في الإنجليزيّة.

لنُقارن مع المحلّل الافتراضيّ standard في Elasticsearch:

POST news/_analyze
{
"analyzer": "standard",
"text": "Change Is Here. Climate Change."
}
change | is | here | climate | change

standard يُبقي is، ولا يُطبّق stemmer. match على « climate changing » بـstandard كان سيُعيد صفر نتيجة لأنّ لا عنوان يحتوي بالضّبط changing. مع titre_en، يُعيد أكثر من 2 800: سحر stemmer.

asciifolding في العمل

POST news/_analyze
{
"field": "headline",
"text": "Beyoncé, Céline Dion et François"
}
beyonc  |  celin  |  dion  |  francoi

تختفي الحركات ثمّ يعمل stemmer (beyonce → beyonc، celine → celin). قارئ يكتب « beyonce » بلا حركة يكتب المفتاح المُفهرَس نفسه: يجد المقال. هدية لا غنى عنها لمحرّك يفهرس الإنجليزيّة لكنّ الفرنكوفونيّين يقرأونه.

اختبر دومًا بـ_analyze

قبل إنشاء mapping في الإنتاج، نفّذ خمس إلى عشر عمليّات _analyze على أمثلة تمثيليّة من بياناتك. ترى فورًا إن كانت عناوينك تُقسَّم وتُطبَّع جيّدًا. خمس دقائق تتفادى بها إعادة تصميم mapping بعد ثلاثة أشهر.

أنواع الحقول التي ينبغي معرفتها

يستعمل mapping الخاصّ بـnews خمسة أنواع. يعرض Elasticsearch نحو ثلاثين نوعًا؛ ما لا يُستغنى عنه يسع في صفحة:

النّوعالاستعمالالمثال في news
textسلاسل قابلة للبحث بالنّصّ الكامل، مُحلَّلةheadline، short_description، authors
keywordسلاسل دقيقة: ترشيح، فرز، تجميعcategory، headline.raw، link
dateلحظات، بصيغة حرّةdate (yyyy-MM-dd)
integer، long، short، byteأعداد صحيحة على 32 / 64 / 16 / 8 بتغير مستعملة هنا
float، double، half_float، scaled_floatأعداد عشريّةغير مستعملة
booleantrue/falseغير مستعمل
completionإكمال تلقائيّ على البداية (الوحدة 8)headline.suggest
objectكائن JSON مُتضمَّن، مُفهرَس مُسطَّحًاافتراضيّ على الكائنات
nestedكائن مُتضمَّن يُفهرَس مستقلًّايُعرَف في لمحة
geo_point، geo_shapeإحداثيّات ومضلّعاتليس في هذه الدّورة

nested يستحقّ ملاحظة: حين تفهرس مصفوفة كائنات ([{ "auteur": "A", "role": "principal" }, { "auteur": "B", "role": "invité" }])، يُسطّح Elasticsearch افتراضيًّا، ما يكسر الارتباط A/principal وB/invité (استعلام « auteur A et rôle invité » سيُعيد المستند خطأً). nested يحفظ البنية مقابل استعلام أثقل قليلًا (nested query). على news لسنا بحاجة إليه — المؤلّفون سلسلة بسيطة.

تغيير mapping = _reindex

هنا الخطأ الكلاسيكيّ. يُنشئ Sami عن سهو فهرسًا حيث headline مُعلَن keyword بدل text، ويُدرك أنّه لم يعد يستطيع تنفيذ match نظيفة، ويريد « تصحيح » mapping:

PUT news_v0
{
"mappings": {
"properties": {
"headline": { "type": "keyword" }
}
}
}

PUT news_v0/_mapping
{
"properties": {
"headline": { "type": "text" }
}
}

الاستدعاء الثّاني يفشل:

illegal_argument_exception : mapper [headline] cannot be changed from type [keyword] to [text]

لا نعدّل نوع حقل موجود. يمكن إضافة خصائص جديدة، أو إضافة حقول متعدّدة (fields) على text موجود، أو تعديل بعض المعاملات الجانبيّة (ignore_above)، لكن لا نغيّر أبدًا النّوع الأساسيّ. الإجراء الصّحيح يتألّف من ثلاث خطوات:

  1. إنشاء فهرس جديد news_v1 بـmapping صحيح.
  2. إعادة فهرسة البيانات بواسطة واجهة _reindex.
  3. تحويل alias من news_v0 إلى news_v1 (الوحدة 8) حتّى لا تتغيّر استعلامات العميل.
POST _reindex
{
"source": { "index": "news_v0" },
"dest": { "index": "news_v1" }
}

_reindex يعمل داخليًّا كـsearch + _bulk. على 200 853 مقالًا، احسب عشرات الثّواني مع الحقيبة. تستطيع تشغيل الأمر بشكل غير متزامن بـ?wait_for_completion=false ومتابعة التّقدّم عبر GET _tasks.

إعادة الفهرسة نسخ لا تحويل

_reindex لا يعدّل المستندات في مكانها. يُعيد قراءتها من source ويُرسلها إلى dest. إن كان mapping الجديد أصرم (مثلًا date بصيغة دقيقة)، فمستند لا يلتصق بالصّيغة سيُرفض لحظة الكتابة. يُعيد _reindex تقريرًا بـupdated وcreated وfailures: اقرأه قبل الاعتقاد بأنّ كلّ شيء مرّ.

Index templates: لمحة

يُطبّق index template تلقائيًّا mapping وsettings على الفهارس الجديدة التي يتطابق اسمها مع نمط. إن فهرست Veille غدًا news-2026-09-09، news-2026-09-10… يتفادى template news-* تكرار mapping. نُعرّفها بـPUT _index_template/veille_news؛ هي ركيزة هندسة الفهارس المتدحرجة. سنُصادفها مجدّدًا في الوحدة 8 عند الحديث عن ILM وتحويل alias.

جرّب 1 — إثبات أثر stemmer

قارن عدد النّتائج التي يُعيدها match على « climate change » ثمّ على « climates changed ». دون stemmer، كان الاستعلامان سيُعيدان عددَين مختلفَين. اشرح لماذا يُعيدان العدد نفسه.

الحلّ
GET news/_count
{ "query": { "match": { "headline": "climate change" } } }

GET news/_count
{ "query": { "match": { "headline": "climates changed" } } }

يُعيد الاستعلامان بالضّبط العدد نفسه، حوالي 2 834. المحلّل titre_en يُطبّق stem_en: climate وclimates تُختصر كلاهما إلى climat، وchange وchanged إلى chang. المصطلحات المبحوث عنها في index inversé متطابقة في الحالتَين. تحقّق بـPOST news/_analyze { "field": "headline", "text": "climates changed" }: سترى المصطلحات climat وchang.

جرّب 2 — الاختيار بين text وkeyword

لكلّ من هذه الحقول في فهرس مستقبليّ livres، قل هل تختار text أو keyword أو الاثنَين (حقل متعدّد) أو keyword مع index: false. برِّر بجملة واحدة.

  1. titre (سلسلة حرّة، مبحوث عنها بالنّصّ الكامل، معروضة)
  2. isbn (معرِّف من 13 رقمًا، لا يُبحث عنه بالنّصّ الكامل أبدًا)
  3. couverture_url (عنوان صورة، معروض، لا يُستعلَم عنه أبدًا)
  4. auteur (سلسلة حرّة، مبحوث عنها ومُجمَّعة في آن)
  5. genre (قيمة من بين 30، مُرشَّحة ومُجمَّعة)
الحلّ
  1. titre: text بالمحلّل الملائم للّغة. أضف titre.raw من نوع keyword إن أردت الفرز الأبجديّ أو التّجميع.
  2. isbn: keyword. ليس عددًا للجمع؛ بل مفتاح دقيق للتّرشيح والرّبط.
  3. couverture_url: keyword مع index: false. نُخزّنه في _source، ولا نستعلمه أبدًا.
  4. auteur: حقل متعدّد — text (بحث) + auteur.raw keyword (تجميع)، كما في news.
  5. genre: keyword. ترشيح وتجميع، لا أكثر. text كانت ستكسر « Science-fiction » إلى مصطلحَين.

جرّب 3 — إعادة إنتاج خطأ « mapper cannot be changed from type [text] to [keyword] »

أنشئ فهرسًا piege، فهرِس مستندًا بحقل titre يُصنّفه mapping الدّيناميكيّ في text، ثمّ حاول فرض titre في keyword. اقرأ رسالة الخطأ بالضّبط.

الحلّ
POST piege/_doc
{ "titre": "Un premier document" }

PUT piege/_mapping
{ "properties": { "titre": { "type": "keyword" } } }

الرّدّ:

illegal_argument_exception : mapper [titre] cannot be changed from type [text] to [keyword]

التّصحيح النّظيف: إنشاء piege_v1 بـmapping صحيح وPOST _reindex { "source": {"index": "piege"}, "dest": {"index": "piege_v1"} }. هذا هو بالضّبط ما كنّا سنفعله على news في الإنتاج، مع alias إضافيّ حتّى لا نقطع القرّاء (الوحدة 8).

النقاط الأساسيّة

  • mapping الدّيناميكيّ يضع كلّ سلسلة في text + حقل فرعيّ keyword بطول 256: مريح، لكنّنا لا نتركه يُقرّر في الإنتاج.
  • text لما نبحث عنه بالنّصّ الكامل، keyword لما نُرشّح أو نفرز أو نُجمّع؛ الحقل المتعدّد field.raw يجمع الاثنَين.
  • المحلّل titre_en في الحقيبة (standard + lowercase + asciifolding + stop_en + stem_en) هو السّبب في أنّ بحثًا « climate change » يُعيد 2 834 نتيجة لا بضع عشرات.
  • _analyze يُبيّن، قبل الفهرسة، كيف سيُقسَّم النّصّ — هي أداة التّحقّق الانعكاسيّة.
  • index: false على keyword يوفّر تخزينًا وذاكرة حين نكتفي بعرض القيمة.
  • headline.suggest هو completion بـmax_input_length: 120 (القيمة الافتراضيّة 50 كانت ستقتطع نصف عناوين HuffPost).
  • لا نُغيّر نوع حقل: ننشئ فهرسًا جديدًا بـmapping صحيح ونستعمل _reindex (مع alias في الإنتاج).

استكشاف الأخطاء

  • GET news/_mapping يُعيد حقلًا غائبًا → صادفت المُستوردة مستندًا بلا هذا الحقل. تحقّق بـGET news/_search { "query": { "exists": { "field": "authors" } } } كم مستندًا يحمله، ثمّ ./lab.sh import-news إن كان العدد شاذًّا.
  • illegal_argument_exception : mapper ... cannot be changed → محاولة تغيير نوع موجود. أنشئ فهرسًا جديدًا واستعمل POST _reindex.
  • mapper_parsing_exception على تاريخ → القيمة لا تلتصق بالصّيغة المُعلَنة (yyyy-MM-dd). انظر إلى المستند الخاطئ في السّجلّات، ثمّ صحّحه في المصدر أو وسّع الصّيغة ("format": "yyyy-MM-dd||yyyy/MM/dd").
  • match تُعيد صفر نتيجة على عنوان موجود مع ذلك → المحلّل لا يفعل ما تعتقد. نفّذ POST news/_analyze { "field": "headline", "text": "votre texte" } لرؤية المصطلحات المُفهرَسة فعلًا.

للاستزادة