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.
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": {"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 :
- 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).
- IDF : plus le terme est rare dans le corpus, plus il vaut cher (
Trumppèse plus quethe). - 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 productionexplain: 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.
matchanalyse le texte,termprend la valeur brute ; utilisez.rawpour l'exact sur untext.boolstructure la requête :mustpour le sens,filterpour la contrainte,must_notpour l'exclusion,shouldpour le bonus._scoresuit BM25 : fréquence saturée, rareté valorisée, longueur pénalisée ;"explain": truerévèle le calcul.highlightrenvoie des fragments HTML prêts à afficher,_sourcelimite ce qui est rapatrié.from/sizejusqu'à 10 000 résultats,search_afteravec un tri stable au-delà.- Évitez
wildcarden début de motif ; préférez l'autocomplétion (module 8).
Si ça ne marche pas
- «
search_context_missing_exception» quand on paginait avecscroll→ l'APIscrollest réservée aux réindexations, utilisezsearch_afterpour les résultats utilisateurs. hits.total.value: 0inattendu surtermd'un texte → le champ esttext: passez àmatch, ou ciblez le sous-champkeyword(par exempleheadline.raw).- «
too_many_clauses» sur untermsgéant → la valeur d'indices.query.bool.max_clause_countest dépassée, découpez la requête ; sur le kit, redémarrez avec./lab.sh down && ./lab.sh upsi le paramètre a été touché. - Résultat déroutant → ajoutez
"explain": truepuis rejouez la requête sur un document précis avecGET news/_explain/<id>pour lire la décomposition du score.