الوحدة 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" }
}
}
}
}
ثلاث قواعد نحفظها من هذا التّخمين:
- كلّ سلسلة تصير حقل
textمع حقل فرعيّkeywordتلقائيّ بطول 256 محرفًا (ignore_above: 256). يمنحك هذا مرونة النّصّ الكامل مع دقّةkeyword، مقابل مضاعفة تكلفة التّخزين على هذا الحقل. - الأعداد الصّحيحة تصير
long(64 بت)، لاinteger. على عدّاد مشاهدات لن يتجاوز م ليارَين، هذا هدر. - سلسلة بصيغة 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ïve→naive). هذا ما يسمح لقارئ يكتب « 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
يُكلّف الحقل المتعدّد قليلًا من التّخزين (كلّ عنوان مُفهرَس مرّتَين) لكنّه استثمار مربح تقريبًا في أيّ حقل نتردّد فيه بين البحث والتّجميع.
link: keyword مع index: false
لكلّ مقال رابط إلى المقال الأصليّ في 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 | أعداد عشريّة | غير مستعملة |
boolean | true/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)، لكن لا نغيّر أبدًا النّوع الأساسيّ. الإجراء الصّحيح يتألّف من ثلاث خطوات:
- إنشاء فهرس جديد
news_v1بـmapping صحيح. - إعادة فهرسة البيانات بواسطة واجهة
_reindex. - تحويل 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. برِّر بجملة واحدة.
titre(سلسلة حرّة، مبحوث عنها بالنّصّ الكامل، معروضة)isbn(معرِّف من 13 رقمًا، لا يُبحث عنه بالنّصّ الكامل أبدًا)couverture_url(عنوان صورة، معروض، لا يُستعلَم عنه أبدًا)auteur(سلسلة حرّة، مبحوث عنها ومُجمَّعة في آن)genre(قيمة من بين 30، مُرشَّحة ومُجمَّعة)
الحلّ
titre:textبالمحلّل الملائم للّغة. أضفtitre.rawمن نوعkeywordإن أردت الفرز الأبجديّ أو التّجميع.isbn:keyword. ليس عددًا للجمع؛ بل مفتاح دقيق للتّرشيح والرّبط.couverture_url:keywordمعindex: false. نُخزّنه في_source، ولا نستعلمه أبدًا.auteur: حقل متعدّد —text(بحث) +auteur.rawkeyword(تجميع)، كما فيnews.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" }لرؤية المصطلحات المُفهرَسة فعلًا.
للاستزادة
- توثيق Elasticsearch 9 — Mapping et types de champs
- توثيق Elasticsearch 9 — Analyseurs, tokenizers et token filters
- توثيق Elasticsearch 9 —
_reindexet migration de données - توثيق Elasticsearch 9 — Multi-fields