Aller au contenu principal

Module 5 — Rechercher : Query DSL, bool, filtres et pertinence

L'index news contient les 200 853 articles importés au module 3, et le mapping posé au module 4 leur donne les bons analyseurs. Sami reçoit sa première commande d'Inès : rendre la barre de recherche de Veille aussi précise qu'un opérateur humain, avec pertinence, filtres, mise en évidence et pagination profonde. Ce module est la boîte à outils qui répond à cette commande.

Deux contextes, deux boussoles

Chaque clause d'une requête Query DSL s'évalue soit en contexte requête, soit en contexte filtre. La différence n'est pas cosmétique : elle décide de la pertinence et de la performance.

  • Le contexte requête répond à « à quel point ce document colle à ma question ? » et calcule un _score. C'est ce qu'on veut pour un texte libre tapé par un lecteur (headline, short_description).
  • Le contexte filtre répond à « oui ou non, ce document passe-t-il ? » sans calculer de score. Elasticsearch met le résultat en cache dans un bitset : la deuxième exécution est quasi gratuite. C'est ce qu'on veut pour les critères exacts (catégorie, plage de dates, existence d'un champ).

La règle de Veille : dès qu'un critère est un choix binaire, il va dans filter ; le texte libre reste dans must ou should.

Réflexe filtre

Si vous ne comptez pas trier par pertinence sur un critère (category, date, authors.raw), mettez-le dans filter. Vous gagnez en vitesse et vous rendez le score lisible.

match : la requête analysée

match est la requête plein-texte de base. Le texte passé est analysé avec le même analyseur que le champ, puis chaque terme est cherché dans l'index inversé. Le score combine BM25 et la fréquence des termes.

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

Réponse : hits.total.value = 2 834, le premier document est « Change Is Here. Climate Change. ». L'analyseur titre_en (standard + lowercase + asciifolding + stop anglais + stemmer anglais) transforme climate change en deux termes climat et chang, ce qui rattrape les variantes changed, changing, changes.

Par défaut match accepte tout document qui contient au moins un des termes (opérateur or). Pour exiger tous les termes :

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

Pour chercher l'expression exacte, dans l'ordre :

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

Le compteur descend nettement : on n'a plus que les titres où les deux mots se suivent (votre chiffre peut différer légèrement selon la version de l'analyseur).

multi_match : chercher dans plusieurs champs

Un titre gagné a plus de poids qu'un mot dans le corps du texte. On le dit à Elasticsearch en boostant le champ avec ^3.

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

best_fields (défaut) prend le meilleur champ pour chaque document. most_fields additionne, cross_fields traite les champs comme un seul texte, phrase cherche l'expression. Pour Veille, best_fields avec un boost sur headline donne des résultats naturels.

term, terms, range, exists

term cherche une valeur exacte, non analysée. Sur un champ text c'est presque toujours une erreur ; sur un keyword, c'est la bonne clé.

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

Résultat : 32 739 documents (la catégorie la plus nombreuse). Pour plusieurs valeurs :

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

Total attendu : 32 739 + 17 827 + 9 887 = 60 453.

range filtre sur une plage. Sur le champ date (format yyyy-MM-dd) :

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

exists sélectionne les documents qui possèdent un champ (utile après un import partiel) :

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

prefix et wildcard : à manier avec précaution

prefix cherche un début de terme sur un keyword. Il est acceptable si le préfixe fait plus de deux caractères et si le champ n'est pas colossal.

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

wildcard avec * en début de motif force un scan complet des termes : c'est la clause qui met un cluster à genoux.

wildcard en début

{"wildcard": {"headline.raw": "*trump*"}} scanne tous les termes du champ. Sur news c'est encore tolérable, sur un index client de vingt millions de documents c'est un incident. Préférez match sur le champ text ou l'autocomplétion (module 8).

bool : composer une requête

bool est la Suisse du Query DSL. Elle combine quatre listes de clauses :

  • must : toutes ces clauses doivent matcher, contexte requête (score).
  • should : ces clauses peuvent matcher ; si l'une matche, le score monte.
  • filter : toutes ces clauses doivent matcher, contexte filtre (pas de score, cache).
  • must_not : aucune de ces clauses ne doit matcher, contexte filtre.

Exemple : les articles de POLITICS qui parlent de Trump depuis 2016, sans mentionner « Russia » dans le titre.

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
}

Lire cette requête dans l'ordre du haut vers le bas donne une phrase claire : « je veux trump en pertinence, dans POLITICS, à partir de 2016, sans Russia, avec bonus si le résumé parle d'immigration ». C'est le patron à réutiliser pour presque toutes les recherches de Veille.

Le score, _score et BM25 en intuition

Elasticsearch classe les résultats par _score décroissant. La formule par défaut est BM25 (Best Matching 25), une évolution de TF-IDF. Trois idées suffisent pour la lire :

  1. TF : plus le terme apparaît dans le document, plus le score monte, avec saturation (le dixième « trump » ne pèse presque rien de plus que le troisième).
  2. IDF : plus le terme est rare dans le corpus, plus il vaut cher (Trump pèse plus que the).
  3. Longueur : un titre court qui contient le terme est mieux noté qu'un long article où il est noyé.

Pour voir le calcul en détail sur un document précis, ajoutez "explain": true :

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

Chaque hits[i]._explanation donne la décomposition arithmétique du score. C'est indispensable quand un résultat vous surprend.

explain en production

explain: true est coûteux — à réserver au débogage. Sur une intégration, préférez enregistrer la requête et la rejouer en Dev Tools lorsqu'un client conteste un classement.

highlight : surligner ce qui a matché

Pour l'interface de Veille, Léa veut voir dans les résultats ce qui a matché.

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

Chaque hit reçoit un objet highlight.headline avec des fragments HTML prêts à afficher. Karim les injecte tels quels dans le composant React de la barre de résultats.

Pagination : from/size et search_after

Pour les premières pages, from et size suffisent :

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

Elasticsearch refuse from + size > 10 000 par défaut (paramètre index.max_result_window). Au-delà, la pagination profonde utilise search_after : on renvoie les valeurs de tri du dernier hit reçu pour repartir juste après, sans coût mémoire côté cluster.

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

Deux règles : le champ de tri doit être stable (une date suffit rarement, on ajoute _id), et la requête doit être identique d'un appel à l'autre.

_source : réduire la charge réseau

L'API renvoie tout le document par défaut. Sur Veille, Karim n'a besoin que de headline, date, category pour la liste :

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

_source: false supprime carrément le contenu (utile pour compter ou pour un top_hits interne).

À vous 1 — Combien d'articles POLITICS mentionnent « election » ?

Écrivez la requête en une seule commande Dev Tools et lisez le compteur dans hits.total.value.

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

must porte le texte libre (donc le score), filter retient la catégorie sans polluer la pertinence. size: 0 évite de rapatrier les documents quand on ne veut que le compte (votre chiffre peut différer légèrement).

À vous 2 — Les articles TRAVEL de 2017, triés du plus récent au plus ancien, avec headline surligné

Renvoyez seulement headline, date et 10 documents.

Solution
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>"] } }
}
}

Les deux critères vont dans filter (pas besoin de score). Le tri stable ajoute _id en second critère, ce qui permet d'enchaîner sur search_after si l'on veut la page suivante.

À vous 3 — Corriger une requête qui ne renvoie rien

Sami écrit ceci et n'obtient aucun résultat. Pourquoi, et comment corriger ?

GET news/_search
{
"query": {
"term": { "headline": "Trump" }
}
}
Solution

term cherche la valeur exacte, non analysée. Or headline est un champ text avec l'analyseur titre_en qui met tout en minuscules et applique un stemmer : le terme dans l'index n'est ni Trump ni trump mais trump (racine). term avec Trump échoue silencieusement. Deux corrections possibles :

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

ou, si l'on veut vraiment une comparaison exacte sur la valeur brute :

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

Règle : match sur un text, term sur un keyword.

Points à retenir

  • Contexte requête pour le score, contexte filtre pour les critères binaires et le cache.
  • match analyse le texte, term prend la valeur brute ; utilisez .raw pour l'exact sur un text.
  • bool structure la requête : must pour le sens, filter pour la contrainte, must_not pour l'exclusion, should pour le bonus.
  • _score suit BM25 : fréquence saturée, rareté valorisée, longueur pénalisée ; "explain": true révèle le calcul.
  • highlight renvoie des fragments HTML prêts à afficher, _source limite ce qui est rapatrié.
  • from/size jusqu'à 10 000 résultats, search_after avec un tri stable au-delà.
  • Évitez wildcard en début de motif ; préférez l'autocomplétion (module 8).

Si ça ne marche pas

  • « search_context_missing_exception » quand on paginait avec scroll → l'API scroll est réservée aux réindexations, utilisez search_after pour les résultats utilisateurs.
  • hits.total.value: 0 inattendu sur term d'un texte → le champ est text : passez à match, ou ciblez le sous-champ keyword (par exemple headline.raw).
  • « too_many_clauses » sur un terms géant → la valeur d'indices.query.bool.max_clause_count est dépassée, découpez la requête ; sur le kit, redémarrez avec ./lab.sh down && ./lab.sh up si le paramètre a été touché.
  • Résultat déroutant → ajoutez "explain": true puis rejouez la requête sur un document précis avec GET news/_explain/<id> pour lire la décomposition du score.

Pour aller plus loin