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

الوحدة 5 — البحث: Query DSL وbool والمرشّحات والملاءمة

يحتوي الفهرس news على الـ200 853 مقالًا المستوردة في الوحدة 3، وmapping المُثبَّت في الوحدة 4 يمنحها المحلّلات الصّحيحة. يستقبل Sami طلبه الأوّل من Inès: أن يجعل شريط بحث Veille دقيقًا كمشغّل بشريّ، بملاءمة، ومرشّحات، وإبراز، وترقيم عميق. هذه الوحدة هي صندوق الأدوات الذي يستجيب لهذا الطّلب.

سياقان، بوصلتان

كلّ جملة في استعلام Query DSL تُقيَّم إمّا في سياق الاستعلام وإمّا في سياق المرشّح. الفرق ليس تجميليًّا: يُقرّر الملاءمة والأداء.

  • سياق الاستعلام يجيب على « إلى أيّ حدّ يلتصق هذا المستند بسؤالي؟ » ويحسب _score. هذا ما نريده لنصّ حرّ يكتبه قارئ (headline، short_description).
  • سياق المرشّح يجيب على « نعم أو لا، هل يمرّ هذا المستند؟ » دون حساب سكور. يضع Elasticsearch النّتيجة في cache على شكل bitset: التّشغيل الثّاني يكاد يكون مجّانًا. هذا ما نريده للمعايير الدّقيقة (فئة، مدى تواريخ، وجود حقل).

قاعدة Veille: ما إن يكون معيار خيارًا ثنائيًّا، يذهب إلى filter؛ النّصّ الحرّ يبقى في must أو should.

الانعكاس نحو filter

إن كنت لن تفرز بالملاءمة على معيار (category، date، authors.raw)، ضعه في filter. تكسب سرعة وتجعل السّكور مقروءًا.

match: الاستعلام المُحلَّل

match هو استعلام النّصّ الكامل الأساسيّ. النّصّ المُمرَّر يُحلَّل بالمحلّل نفسه المستعمل للحقل، ثمّ يُبحث عن كلّ مصطلح في index inversé. السّكور يجمع BM25 وتردّد المصطلحات.

GET news/_search
{
"query": {
"match": { "headline": "climate change" }
},
"size": 3,
"_source": ["headline", "date", "category"]
}

الرّدّ: hits.total.value = 2 834، والمستند الأوّل هو « Change Is Here. Climate Change. ». المحلّل titre_en (standard + lowercase + asciifolding + stop إنجليزيّة + stemmer إنجليزيّ) يُحوّل climate change إلى مصطلحَين climat وchang، ما يلتقط المشتقّات changed وchanging وchanges.

افتراضيًّا يقبل match أيّ مستند يحتوي على الأقلّ أحد المصطلحات (المشغّل or). لطلب كلّ المصطلحات:

GET news/_search
{
"query": {
"match": {
"headline": { "query": "climate change", "operator": "and" }
}
}
}

للبحث عن التّعبير الدّقيق بالتّرتيب:

GET news/_search
{
"query": {
"match_phrase": { "headline": "climate change" }
}
}

يهبط العدد بشكل واضح: لا تعود لنا سوى العناوين التي تتلاحق فيها الكلمتان (رقمك قد يختلف قليلًا حسب إصدار المحلّل).

multi_match: البحث في عدّة حقول

عنوان مكسوب له وزن أكبر من كلمة في متن النّصّ. نقول ذلك لـElasticsearch بتعزيز الحقل بـ^3.

GET news/_search
{
"query": {
"multi_match": {
"query": "climate change",
"fields": ["headline^3", "short_description"],
"type": "best_fields"
}
}
}

best_fields (افتراضيّ) يأخذ أفضل حقل لكلّ مستند. most_fields يجمع، cross_fields يعامل الحقول كنصّ واحد، phrase يبحث عن التّعبير. بالنّسبة إلى Veille، best_fields مع تعزيز على headline يُعطي نتائج طبيعيّة.

term وterms وrange وexists

term يبحث عن قيمة دقيقة، غير مُحلَّلة. على حقل text هو خطأ في الغالب؛ على keyword، هو المفتاح الصّحيح.

GET news/_search
{
"query": {
"term": { "category": "POLITICS" }
}
}

النّتيجة: 32 739 مستندًا (الفئة الأكثر عددًا). لعدّة قيم:

GET news/_search
{
"query": {
"terms": { "category": ["POLITICS", "WELLNESS", "TRAVEL"] }
}
}

الإجماليّ المتوقَّع: 32 739 + 17 827 + 9 887 = 60 453.

range يُرشّح على مدى. على الحقل date (بصيغة yyyy-MM-dd):

GET news/_search
{
"query": {
"range": {
"date": { "gte": "2017-01-01", "lt": "2018-01-01" }
}
},
"size": 0
}

exists يختار المستندات التي تملك حقلًا (مفيد بعد استيراد جزئيّ):

GET news/_search
{
"query": { "exists": { "field": "authors" } },
"size": 0
}

prefix وwildcard: بحذر

prefix يبحث عن بداية مصطلح على keyword. مقبول إن كانت البداية بأكثر من محرفَين والحقل ليس ضخمًا.

GET news/_search
{
"query": { "prefix": { "authors.raw": "Lee " } }
}

wildcard مع * في بداية النّمط يفرض مسحًا كاملًا للمصطلحات: هذه الجملة تُركّع cluster.

wildcard في البداية

{"wildcard": {"headline.raw": "*trump*"}} يمسح كلّ مصطلحات الحقل. على news هذا لا يزال محتملًا، على فهرس عميل بعشرين مليون مستند هو حادث. فضّل match على حقل text أو الإكمال التّلقائيّ (الوحدة 8).

bool: تركيب استعلام

bool هو السّكّين السّويسريّة لـQuery DSL. تجمع أربع قوائم من الجمل:

  • must: كلّ هذه الجمل يجب أن تُطابق، سياق استعلام (سكور).
  • should: هذه الجمل يمكنها أن تُطابق؛ إن طابقت واحدة، يرتفع السّكور.
  • filter: كلّ هذه الجمل يجب أن تُطابق، سياق مرشّح (بلا سكور، مع cache).
  • must_not: لا شيء من هذه الجمل يجب أن يُطابق، سياق مرشّح.

مثال: مقالات POLITICS التي تتحدّث عن Trump منذ 2016، دون ذكر « Russia » في العنوان.

GET news/_search
{
"query": {
"bool": {
"must": [ { "match": { "headline": "trump" } } ],
"filter": [
{ "term": { "category": "POLITICS" } },
{ "range": { "date": { "gte": "2016-01-01" } } }
],
"must_not": [ { "match_phrase": { "headline": "Russia" } } ],
"should": [ { "match": { "short_description": "immigration" } } ]
}
},
"size": 5
}

قراءة هذا الاستعلام من الأعلى إلى الأسفل تُعطي جملة واضحة: « أريد trump بالملاءمة، في POLITICS، منذ 2016، بلا Russia، مع مكافأة إن كان الملخّص يتحدّث عن immigration ». هذا هو النّمط لإعادة استعماله لكلّ عمليّات البحث تقريبًا في Veille.

السّكور و_score وBM25 بالحدس

يُصنّف Elasticsearch النّتائج بـ_score تنازليًّا. الصّيغة الافتراضيّة هي BM25 (Best Matching 25)، تطوير لـTF-IDF. ثلاث أفكار تكفي لقراءتها:

  1. TF: كلّما ظهر المصطلح أكثر في المستند، ارتفع السّكور، مع تشبّع (المصطلح العاشر « trump » لا يزن تقريبًا شيئًا فوق الثّالث).
  2. IDF: كلّما كان المصطلح أندر في الكوربوس، ارتفعت قيمته (Trump يزن أكثر من the).
  3. الطّول: عنوان قصير يحتوي المصطلح أفضل تقييمًا من مقال طويل يغرق فيه.

لرؤية الحساب بالتّفصيل على مستند دقيق، أضف "explain": true:

GET news/_search
{
"explain": true,
"query": { "match": { "headline": "climate change" } },
"size": 1
}

كلّ hits[i]._explanation يُعطي التّفكيك الحسابيّ للسّكور. لا غنى عنه حين تُفاجئك نتيجة.

explain في الإنتاج

explain: true مُكلّف — يجب حجزه للتّصحيح. في تكامل، فضّل تسجيل الاستعلام وإعادة تشغيله في Dev Tools حين يعترض عميل على ترتيب.

highlight: إبراز ما طابق

لواجهة Veille، تريد Léa أن ترى في النّتائج ما طابق.

GET news/_search
{
"query": { "match": { "headline": "climate change" } },
"highlight": {
"fields": {
"headline": { "pre_tags": ["<mark>"], "post_tags": ["</mark>"] }
}
},
"size": 3
}

كلّ hit يستقبل كائن highlight.headline مع أجزاء HTML جاهزة للعرض. يحقنها Karim كما هي في مكوّن React الخاصّ بشريط النّتائج.

التّرقيم: from/size وsearch_after

للصّفحات الأولى، from وsize يكفيان:

GET news/_search
{
"from": 0,
"size": 20,
"query": { "match_all": {} },
"sort": [ { "date": "desc" }, { "_id": "asc" } ]
}

يرفض Elasticsearch from + size > 10 000 افتراضيًّا (المعامل index.max_result_window). بعد ذلك، يستعمل التّرقيم العميق search_after: نُعيد قيم الفرز لآخر hit مستلَم لنبدأ بعده مباشرة، دون تكلفة ذاكرة على cluster.

GET news/_search
{
"size": 20,
"query": { "match_all": {} },
"sort": [ { "date": "desc" }, { "_id": "asc" } ],
"search_after": ["2018-05-26", "199999"]
}

قاعدتان: حقل الفرز يجب أن يكون ثابتًا (التّاريخ نادرًا ما يكفي، نُضيف _id)، ويجب أن يكون الاستعلام مطابقًا من استدعاء لآخر.

_source: تقليل الحمل على الشّبكة

تُعيد الـAPI المستند كاملًا افتراضيًّا. على Veille، لا يحتاج Karim إلّا إلى headline وdate وcategory للقائمة:

GET news/_search
{
"_source": ["headline", "date", "category"],
"query": { "term": { "category": "TRAVEL" } },
"size": 20
}

_source: false يحذف المحتوى كلّيًّا (مفيد للعدّ أو لـtop_hits داخليّ).

جرّب 1 — كم مقالًا في POLITICS يذكر « election »؟

اكتب الاستعلام بأمر واحد في Dev Tools واقرأ العدّاد في hits.total.value.

الحلّ
GET news/_search
{
"size": 0,
"query": {
"bool": {
"must": [ { "match": { "headline": "election" } } ],
"filter": [ { "term": { "category": "POLITICS" } } ]
}
}
}

must يحمل النّصّ الحرّ (ومنه السّكور)، وfilter يحفظ الفئة دون تلويث الملاءمة. size: 0 يتفادى استرجاع المستندات حين نريد العدد فقط (رقمك قد يختلف قليلًا).

جرّب 2 — مقالات TRAVEL لعام 2017، مرتَّبة من الأحدث إلى الأقدم، مع إبراز headline

أعد فقط headline وdate و10 مستندات.

الحلّ
GET news/_search
{
"size": 10,
"_source": ["headline", "date"],
"query": {
"bool": {
"filter": [
{ "term": { "category": "TRAVEL" } },
{ "range": { "date": { "gte": "2017-01-01", "lt": "2018-01-01" } } }
]
}
},
"sort": [ { "date": "desc" }, { "_id": "asc" } ],
"highlight": {
"fields": { "headline": { "pre_tags": ["<mark>"], "post_tags": ["</mark>"] } }
}
}

كلا المعيارَين يذهبان إلى filter (لا حاجة إلى سكور). الفرز الثّابت يُضيف _id كمعيار ثانٍ، ما يسمح بالانتقال إلى search_after إن أردنا الصّفحة التّالية.

جرّب 3 — تصحيح استعلام لا يُعيد شيئًا

يكتب Sami هذا ولا يحصل على أيّ نتيجة. لماذا، وكيف نُصحّح؟

GET news/_search
{
"query": {
"term": { "headline": "Trump" }
}
}
الحلّ

term يبحث عن القيمة الدّقيقة، غير المُحلَّلة. أمّا headline فهو حقل text مع المحلّل titre_en الذي يُحوّل كلّ شيء إلى حروف صغيرة ويطبّق stemmer: المصطلح في الفهرس ليس Trump ولا trump بل trump (الجذر). term بـTrump يفشل بصمت. تصحيحان ممكنان:

GET news/_search
{ "query": { "match": { "headline": "Trump" } } }

أو، إن أردنا فعلًا مقارنة دقيقة على القيمة الخامّ:

GET news/_search
{ "query": { "term": { "headline.raw": "Trump Wins" } } }

القاعدة: match على text، term على keyword.

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

  • سياق الاستعلام للسّكور، سياق المرشّح للمعايير الثّنائيّة وللcache.
  • match يُحلّل النّصّ، term يأخذ القيمة الخامّ؛ استعمل .raw للدّقيق على text.
  • bool يُنظّم الاستعلام: must للمعنى، filter للقيد، must_not للاستبعاد، should للمكافأة.
  • _score يتبع BM25: تردّد مُشبَّع، ندرة مُقدَّرة، طول مُعاقَب؛ "explain": true يكشف الحساب.
  • highlight يُعيد أجزاء HTML جاهزة للعرض، و_source يحدّ ممّا يُسترجَع.
  • from/size حتّى 10 000 نتيجة، search_after مع فرز ثابت بعد ذلك.
  • تجنّب wildcard في بداية النّمط؛ فضّل الإكمال التّلقائيّ (الوحدة 8).

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

  • « search_context_missing_exception » حين كنّا نُرقّم بـscroll → واجهة scroll محفوظة لعمليّات إعادة الفهرسة، استعمل search_after لنتائج المستخدمين.
  • hits.total.value: 0 غير متوقَّع على term لنصّ → الحقل text: انتقل إلى match، أو استهدف الحقل الفرعيّ keyword (مثلًا headline.raw).
  • « too_many_clauses » على terms ضخم → تجاوزت قيمة indices.query.bool.max_clause_count، قسّم الاستعلام؛ على الحقيبة، أعد التّشغيل بـ./lab.sh down && ./lab.sh up إن كان المعامل قد لُمس.
  • نتيجة مربكة → أضف "explain": true ثمّ أعد الاستعلام على مستند دقيق بـGET news/_explain/<id> لقراءة تفكيك السّكور.

للاستزادة