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

الإكمال التّلقائيّ والتّسامح مع الأخطاء وES|QL: بحث Elasticsearch متقدّم

على Karim تسليم شريط بحث منتج Veille: يقترح عناوين أثناء الكتابة، ويسامح على أخطاء الطّباعة، ويُهيّئ الأرضيّة للغة أوضح من Query DSL حين يريد محلّل تجميعًا سريعًا. أمّا Léa فتريد تحويل mapping في الإنتاج دون قطع الخدمة. تجمع هذه الوحدة أربعة أوراش: completion على headline.suggest، وfuzziness: AUTO، وES|QL، والـalias، وإعادة الفهرسة دون انقطاع.

الإكمال التّلقائيّ: الـsuggester من نوع completion

تذكير بالـmapping (الوحدة 4): يملك headline حقلًا فرعيًّا headline.suggest من نوع completion بـmax_input_length: 120. هذا النّوع الخاصّ ليس فهرسًا معكوسًا كلاسيكيًّا — إنّه مُحوِّل الحالات المنتهية (FST) يُطابق فورًا بادئةً مع كلّ المصطلحات المُفهرسة. المقابل: يعيش في الذّاكرة الحيّة، وكتابته مُكلِفة.

لماذا max_input_length: 120

القيمة الافتراضيّة لـcompletion هي 50 حرفًا. على مدوَّنة HuffPost، يقطع ذلك عنوانًا واحدًا من كلّ اثنين: «The 20 Best Vegan Recipes You'll Actually Want To Cook This Weekend» (66 حرفًا) يُقطع إلى «The 20 Best Vegan Recipes You'll Actually Want To». المستخدم الذي يكتب «weekend» لن يجد شيئًا. بالانتقال إلى 120 نُغطّي المدوَّنة كلّها مع بقاء FST معقولًا.

القيمة الافتراضيّة فخّ صامت

لا يُعيد completion خطأً على العناوين المقطوعة: يُفهرسها ببساطة إلى الطّول الأقصى. إن «فوَّت» الإكمال يومًا اقتراحات بديهيّة على عناوين طويلة، فأوّل معامل يجب التّحقّق منه بـGET news/_mapping هو max_input_length.

استعلام _search مع suggest

في Kibana Dev Tools:

GET news/_search
{
"_source": false,
"suggest": {
"titres": {
"prefix": "trum",
"completion": {
"field": "headline.suggest",
"size": 5,
"skip_duplicates": true
}
}
}
}

الاستجابة المتوقَّعة (مقتطف من options):

'Truman Show' Delusion: Believing Your Life Is A Reality TV Show
Trump Abandons Commitment To 2-State Solution In Press Conference With Netanyahu
Trump Signs Order Ordering Federal Agencies To Cut Two Regulations For Every New One
Truman Capote's Ashes Sold For $43,750 At Auction
Truman Show Syndrome, Or When People Think Their Life Is A TV Show

ثلاث نقاط لتتذكّرها من هذا الاستعلام:

  • البادئة trum تطابق Trump وTruman معًا: ينظر completion إلى بداية المصطلح، لا إلى معناه.
  • skip_duplicates: true يمنع تكرار عنوانَين متطابقَين في الاقتراحات (مفيد حين تُنشر الدّفعة مرّتَين).
  • _source: false يحذف الـhits الكلاسيكيّة؛ نريد فقط المفتاح suggest.titres. هذا يُخفِّف الاستجابة.

زمن الاستجابة من رتبة جزء من الميليّة ثانية على هذه المدوَّنة — هذا وعد الـFST.

search_as_you_type كبديل

بديل لـcompletion: النّوع search_as_you_type، الذي يُنشئ تلقائيًّا حقولًا فرعيّة ._2gram و._3gram و._index_prefix. يُسامح على الأخطاء في وسط الكلمة ويُطابق عدّة حقول دفعةً واحدة، بثمن فهرس أثقل. لشريط Veille يكفي completion؛ نحتفظ بـsearch_as_you_type للمدوَّنات المتعدّدة اللّغات أو حين نريد «البحث ونحن نكتب» بدلًا من «اقتراح عنوان مضبوط».

التّسامح مع الأخطاء: fuzziness: AUTO

قارئ يبحث عن «climat chnage» (حرف «n» قبل «a») يجب أن يجد رغم ذلك «Climate Change». هذا دور fuzziness — مسافة تحرير Damerau-Levenshtein — التي يقبلها match مباشرةً.

GET news/_search
{
"query": {
"match": {
"headline": {
"query": "climat chnage",
"fuzziness": "AUTO"
}
}
},
"size": 3,
"_source": ["headline"]
}

الاستجابة المتوقَّعة: عدّة آلاف من النّتائج (قد يختلف رقمك قليلًا حسب الرّموز)، وفي المقدّمة عناوين تحوي «climate change». تُطبِّق القيمة AUTO قاعدة ذكيّة: صفر تحرير مسموح به لمصطلح من 1 إلى 2 حرف، وواحد لـ3 إلى 5 أحرف، واثنان لـ6 أحرف فأكثر. هذا هو الضّبط الافتراضيّ الذي يجب الاحتفاظ به.

phrase suggester: «Did you mean»

حين تقع الأخطاء على عدّة كلمات، يُولِّد suggester مخصَّص أرجح جملة مصحَّحة:

GET news/_search
{
"suggest": {
"correction": {
"text": "climat chnage",
"phrase": {
"field": "headline",
"size": 3,
"gram_size": 3,
"direct_generator": [
{ "field": "headline", "suggest_mode": "always" }
]
}
}
}
}

الاستجابة (مقتطف):

climate change    (score élevé)
climate changes
climat change

يستعمل phrase suggester نموذج لغة على n-grams للحقل لترتيب التّصحيحات حسب المعقوليّة. هذا ما تعرضه المحرّكات وراء الجملة الكلاسيكيّة «هل تقصد: …».

Fuzzy نعم، لكن ليس في كلّ مكان

fuzziness: AUTO على terms ضخم أو على بادئة قصيرة ("a" أو "le") يصبح بطيئًا ويُلوّث النّتائج. احتفظ به للحقل الرّئيسيّ (headline) وعلى استعلامات مؤلَّفة من مصطلحَين فأكثر. على الحقول الأخرى ابقَ على match صارم.

match_phrase مع slop: الجملة المرنة

يبحث match_phrase عن العبارة بالتّرتيب ومتجاورة. يسمح slop لـElasticsearch بقبول بضع كلمات بين المصطلحات (أو انقلاب) مع احترام فكرة الجملة.

GET news/_search
{
"query": {
"match_phrase": {
"headline": {
"query": "climate change",
"slop": 2
}
}
},
"size": 3
}

بـslop: 0 (القيمة الافتراضيّة)، يطابق الاستعلام «climate change» متجاورة فقط. بـslop: 2، يطابق كذلك «climate is changing» و«change in climate» و«climate rapid change». ينخفض score كلّما زاد عدد الإزاحات اللّازمة. هذا ما تريده الشّاشة الرّئيسيّة في Veille: نسمح ببعض المرونة حول العبارة، دون الوقوع في match مُنفلت.

multi_match مع الوزن

عنوان يحمل معنى أكبر من ملخّص: نُعزِّز headline مقارنةً بـshort_description.

GET news/_search
{
"query": {
"multi_match": {
"query": "climate change",
"fields": ["headline^3", "short_description"],
"type": "best_fields",
"fuzziness": "AUTO"
}
},
"size": 5,
"_source": ["headline", "category"]
}

اللّاحقة ^3 تضرب مساهمة الحقل headline في score بثلاثة. مقرونًا بـfuzziness: AUTO، هذا هو الاستعلام «القويّ» لشريط البحث: يسامح على الأخطاء، ويُفضّل العناوين، ويعبر عبر عدّة حقول. هذا ما يُوَصِّله Karim في النّسخة الأولى من الواجهة البرمجيّة.

ES|QL: لغة الأنابيب من Elasticsearch

ES|QL (Elasticsearch Query Language) لغة أنابيب (شبيهة بـSplunk وSQL) ظهرت في Elasticsearch 8 واستقرّت في 9. تُكمل Query DSL: بينما DSL هو JSON تعريفيّ مصمَّم للبحث الموزون، تربط ES|QL FROM وWHERE وSTATS وSORT وLIMIT في سطر واحد قابل للقراءة، مصمَّمة للتّحليل.

ثلاث طرق لتنفيذها:

  • في Kibana Dev Tools عبر نقطة النّهاية POST _query ({"query": "..."}).
  • في Discover (Kibana) بتبديل مُنتقي اللّغة من KQL إلى ES|QL.
  • من Python (الوحدة 13) عبر العميل الرّسميّ elasticsearch.

مثال 1 — عدّ المقالات حسب الصّنف

POST _query
{
"query": "FROM news | STATS n = COUNT(*) BY category | SORT n DESC | LIMIT 10"
}

الاستجابة (أوّل أسطر values):

POLITICS         32739
WELLNESS 17827
ENTERTAINMENT 16058
TRAVEL 9887
STYLE & BEAUTY 9649

سطر واحد، أنبوب واحد، نتيجة جدوليّة مباشرة. في Query DSL الكلاسيكيّ، الشّيء نفسه كان يتطلّب size: 0 وterms بـfield: category وترتيبًا، إضافةً إلى قليل من ضجيج JSON.

مثال 2 — تصفية وتجميع وترتيب

POST _query
{
"query": "FROM news | WHERE category == \"POLITICS\" | STATS n = COUNT(*) BY category | SORT n DESC | LIMIT 10"
}

النّتيجة: POLITICS 32 739. نحافظ هنا على البنية STATS ... BY category لإظهار البنية النّحويّة؛ على مجموعة واحدة، يكفي STATS n = COUNT(*) بسيط.

مثال 3 — أفضل الكتّاب على مدى تواريخ

POST _query
{
"query": "FROM news | WHERE date >= \"2017-01-01\" AND date < \"2018-01-01\" | STATS articles = COUNT(*) BY authors.raw | SORT articles DESC | LIMIT 5"
}

النّتيجة المتوقَّعة (قد يختلف رقمك قليلًا):

Reuters       1 900+
Lee Moran 600+
Ed Mazza 500+
Ron Dicker 450+
Cole Delbyck 350+

يبقى الأنبوب قابلًا للقراءة حتّى حين نُكدِّس عدّة مصفّيات، وهي بالضّبط ورقة البيع لـES|QL عند Léa (التي تكتب ثلاثين استعلام تحليل أسبوعيًّا).

مثال 4 — استخراج سنة وعمل pivot

POST _query
{
"query": "FROM news | EVAL annee = DATE_EXTRACT(\"year\", date) | STATS n = COUNT(*) BY annee, category | SORT annee ASC, n DESC | LIMIT 20"
}

EVAL يُنشئ حقلًا محسوبًا (annee) على كلّ سطر، وSTATS ... BY annee, category يُجري تجميعًا متقاطعًا. النّتيجة: الأصناف السّائدة في كلّ سنة، من 2012 إلى 2018. هذا هو الاستعلام الذي نكتبه في ثلاثين ثانية لتحضير رسم بيانيّ.

Query DSL وKQL وES|QL: متى نستعمل ماذا

المعيارQuery DSLKQLES|QL
الصّيغةJSON تعريفيّسلسلة مضغوطة (category : "POLITICS" and headline : trump)أنبوب FROM ... | ...
أينREST، عميل، Dev ToolsDiscover وLens وAlerting (Kibana)Dev Tools وDiscover وعميل
الملاءمة والـ_scoreنعم (BM25، explain)نعم (طبقة رقيقة على DSL)ليست مصمَّمة لذلك (نتيجة جدوليّة)
التّجميعات المعقّدةنعم (مُطنِبة)لانعم، قابلة جدًّا للقراءة
المصفّيات المركَّبة (bool، must_not)نعمنعمنعم، بـWHERE
الحقول المحسوبةruntime_mappingsلانعم، EVAL
الجمهورمطوّرو الواجهات البرمجيّةمستخدمو Kibanaمحلّلون ومهندسو بيانات
المُخرجhits مع _scorehitsجدول columns / values

قاعدة Veille:

  • Query DSL لكلّ ما تُسلِّمه الواجهة البرمجيّة لأحد الزّبائن: شريط البحث والإكمال التّلقائيّ والنّتائج المرتَّبة بالملاءمة. هذه الوحدة 5.
  • KQL للاستكشاف السّريع في Discover ولتعريف المصفّيات في لوحة Lens. هذه الوحدة 7.
  • ES|QL لكلّ ما يُشبه تحليلًا بأسلوب SQL على البيانات: التّجميعات المُدارة كـpivot ومقارنة الفترات والحقول المحسوبة. هذه هنا.

الثّلاث تتعايش: يمكن للوحة معلومات واحدة أن تعرض Lens مقودًا بـKQL، وتُغذّي لوحة ES|QL، وتُطلق تنبيهًا مُعرَّفًا في Query DSL.

alias الفهرس وإعادة الفهرسة دون انقطاع

يريد Sami إضافة حقل resume إلى news، أو تغيير محلِّل headline. كما رأينا في الوحدة 4، لا نُعدِّل نوعًا في مكانه: يجب إنشاء فهرس جديد. لكنّ الواجهة البرمجيّة للمنتج تستعلم GET news/_search — فإن أعدنا تسمية الفهرس نكسر العميل.

المخرج الكلاسيكيّ: alias. الـalias اسم منطقيّ يُشير إلى فهرس فعليّ واحد أو أكثر، ويتحوّل بشكل ذرّيّ.

الخطوة 1 — إعادة تسمية الفهرس الحاليّ

إن كان news أصلًا فهرسًا فعليًّا، تبدأ الهجرة بـ: «جعل news alias يُشير إلى news_v1 حقيقيّ».

POST _reindex
{
"source": { "index": "news" },
"dest": { "index": "news_v1" }
}

ثمّ نحذف news (انتبه: لا قرّاء لمدّة ثانيتَين) وننشئ الـalias:

DELETE news
POST _aliases
{
"actions": [
{ "add": { "index": "news_v1", "alias": "news" } }
]
}

في إنتاج Veille، نُفضِّل التّخطيط منذ البداية: يُسمّى الفهرس الأوّل news_v1 ويكون news alias يُشير إليه. كان يمكن للوحدة 3 فعل ذلك؛ سنفعله عندما نُسلِّم خدمة فعليّة.

الخطوة 2 — إنشاء news_v2 بالـmapping الجديد

PUT news_v2
{
"settings": { "number_of_shards": 1, "number_of_replicas": 0 },
"mappings": {
"properties": {
"headline": { "type": "text", "analyzer": "titre_en" },
"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" },
"resume": { "type": "text", "analyzer": "titre_en" }
}
}
}

الخطوة 3 — إعادة الفهرسة

POST _reindex
{
"source": { "index": "news_v1" },
"dest": { "index": "news_v2" }
}

على 200 853 مقالًا، احسب بضع عشرات من الثّواني.

الخطوة 4 — تبديل ذرّيّ للـalias

POST _aliases
{
"actions": [
{ "remove": { "index": "news_v1", "alias": "news" } },
{ "add": { "index": "news_v2", "alias": "news" } }
]
}

يُطبَّق الفعلان معًا: لا ميليّة ثانية دون alias، ولا استعلام عميل مرفوض. هذه هي المناورة التي نُعيدها عند كلّ تطوّر للـmapping.

alias لكلّ استعمال

لا شيء يُلزم بامتلاك alias واحد. يمكن أن يُشير news إلى news_v2 للكتابة والقراءة العامّة، وأن يكون news_recent alias آخر مُصفًّى على آخر 30 يومًا (بـfilter في الفعل add). عرضان منطقيّان لفهرس فعليّ واحد.

ILM: نظرة مفاهيميّة

على مدوَّنة ساكنة من 200 000 مقال، فهرس واحد يكفي. على تدفّق — سجلّات تطبيق، دفعات في الزّمن الحقيقيّ، آثار قياس عن بعد — كلّ يوم يحمل أحجامًا جديدة يعبث بإبقائها «ساخنة» إلى الأبد. يُؤتمِت ILM (Index Lifecycle Management) دورة حياة الفهارس في أربع مراحل:

المرحلةالدّورما نفعله عادةً
hotكتابة وقراءة نشيطتانshard أوّليّ، refresh_interval: 1s، فهرسة مكثّفة
warmلا مزيد من الكتابة، قراءة متكرّرةforcemerge لضغط الفهرس، ويمكن تقليل النّسخ
coldقراءة نادرة، تخزين محسَّنلا فهرسة، غالبًا مُرَكَّب في frozen tier
deleteالحذفDELETE للفهرس حين يتجاوز N أيّامًا

يُطلق الانتقال من مرحلة إلى التّالية على معايير (الحجم والعمر وعدد الوثائق). نُعرِّف policy (PUT _ilm/policy/veille_logs)، نُلحقها بقالب فهرس (news-*)، ويقوم Elasticsearch بالباقي. لا تحتاج مدوَّنة News هذا؛ احفظ المبدأ لليوم الذي تُفهرس فيه Veille سجلّات تطبيق.

جرّب 1 — الاقتراح بالبادئة وعدّ المقترحات المتمايزة

استعلم headline.suggest بالبادئة climat، اطلب 10 اقتراحات بلا تكرار، ثمّ استنتج كم عنوانًا متمايزًا يبدأ بهذه البادئة (على حدود الـ10 المُعادة).

الحلّ
GET news/_search
{
"_source": false,
"suggest": {
"titres": {
"prefix": "climat",
"completion": {
"field": "headline.suggest",
"size": 10,
"skip_duplicates": true
}
}
}
}

تُدرِج الاستجابة حتّى 10 عناوين متمايزة تبدأ بـ«Climat…» (skip_duplicates: true يحذف التّكرار الحرفيّ). إن أردتَ أكثر فارفع size — انتبه، هذا يحذف كذلك المقترحات ذات score المنخفض فوق الحدّ.

جرّب 2 — خطأ متعمَّد

قارن عدد نتائج match صارم وmatch بـfuzziness: AUTO على الاستعلام «climat chnage». اشرح الفارق.

الحلّ
GET news/_count
{ "query": { "match": { "headline": "climat chnage" } } }

GET news/_count
{
"query": {
"match": {
"headline": { "query": "climat chnage", "fuzziness": "AUTO" }
}
}
}

الاستعلام الأوّل يُعيد نتائج قليلة جدًّا: chnage لا يكاد يكون مصطلحًا حقيقيًّا. الثّاني يُعيد آلافًا: fuzziness: AUTO يسمح بتحرير على chnage (تبديل حرفَين) فيصبح change، وتحرير على climat فيطابق climate (بعد stemming يقعان على المصطلح climat نفسه). قارن مع «Change Is Here. Climate Change.» في مقدّمة الـhits.

جرّب 3 — ES|QL: تطوّر شهريّ لصنف

اكتب استعلام ES|QL يعدّ مقالات صنف POLITICS بالشّهر بين 2016-01-01 و2018-05-26، مرتَّبًا من الأقدم إلى الأحدث.

الحلّ
POST _query
{
"query": "FROM news | WHERE category == \"POLITICS\" AND date >= \"2016-01-01\" AND date <= \"2018-05-26\" | EVAL mois = DATE_TRUNC(1 month, date) | STATS n = COUNT(*) BY mois | SORT mois ASC"
}

يُقصّر DATE_TRUNC(1 month, date) كلّ تاريخ إلى اليوم الأوّل من شهره؛ ويُجمِّع STATS ... BY mois. المخرج جدول (mois, n) جاهز للرّسم. في Query DSL يُنجَز الشّيء نفسه بـdate_histogram في calendar_interval: month — انظر الوحدة 6.

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

  • يُجيب completion على headline.suggest بميليّات معدودة على بادئة؛ ويتفادى max_input_length: 120 القطع الصّامت للقيمة الافتراضيّة (50).
  • fuzziness: AUTO يُصلح خطأً إلى خطأين حسب طول المصطلح؛ يُحتفظ به للحقول النّصّيّة الرّئيسيّة.
  • يُولِّد phrase suggester أرجح تصحيح لاستعلام متعدّد الكلمات، لعرضه في «هل تقصد: …».
  • يعثر match_phrase مع slop على عبارة حتّى إن تسلّلت بينها كلمات أو انقلاب.
  • ES|QL (FROM ... | WHERE ... | STATS ... BY ... | SORT | LIMIT) هو الأنبوب المفضَّل للتّحليل؛ ويبقى Query DSL ملك البحث الموزون.
  • alias الفهرس ينتقل ذرّيًّا من news_v1 إلى news_v2: هذا مفتاح إعادة الفهرسة دون انقطاع.
  • ILM يُؤتمِت الدّورة hot → warm → cold → delete للمدوَّنات التي تنمو مع الزّمن؛ لا حاجة له على news، ولا غنى عنه حين نُفهرس السّجلّات.

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

  • suggest يُعيد قائمة فارغة على بادئة موجودة رغم ذلك في العناوين → الحقل ليس من نوع completion، أو أنّ العنوان قُطع إلى 50 حرفًا (القيمة الافتراضيّة). تحقّق من GET news/_mapping ثمّ نفِّذ ./lab.sh import-news لإعادة الفهرسة بـmapping الحقيبة.
  • unknown query [fuzziness] على استعلام termfuzziness ينطبق على الجمل النّصّيّة (match وmulti_match)، لا على term. مرِّر إلى match.
  • POST _query يُعيد unknown function [DATE_TRUNC] أو ما يشبه → دالّة ES|QL غير موجودة بهذه الصّيغة في 9.5.3. راجع elastic.co/docs/reference/query-languages/esql/esql-functions-operators وكيِّف (DATE_EXTRACT وBUCKET).
  • POST _aliases يُعيد index_not_found_exception → الفهرس المذكور في remove غير موجود (محذوف مسبقًا) أو ذاك في add غير موجود أيضًا (لم يُنشأ بعد). نفِّذ GET _cat/indices?v ثمّ أعِد التّبديل بالتّرتيب الصّحيح: إنشاء news_v2، ثمّ إعادة الفهرسة، ثمّ POST _aliases بالفعلَين معًا.

للاستزادة