Aller au contenu principal

Module 8 — Autocomplétion, tolérance aux fautes, ES|QL et recherche avancée

Karim doit livrer la barre de recherche du produit Veille : elle propose des titres pendant qu'on tape, elle pardonne les fautes de frappe, elle prépare le terrain pour un langage plus lisible que Query DSL quand un analyste veut agréger vite. Léa, elle, veut pouvoir basculer un mapping en production sans couper le service. Ce module rassemble les quatre chantiers : completion sur headline.suggest, fuzziness: AUTO, ES|QL, alias et réindexation sans interruption.

Autocomplétion : le suggester completion

Rappel du mapping (module 4) : headline a un sous-champ headline.suggest de type completion avec max_input_length: 120. Ce type spécial n'est pas un index inversé classique — c'est un transducteur d'états finis (FST) qui matche instantanément un préfixe contre tous les termes indexés. Sa contrepartie : il tient en RAM, et il coûte cher à écrire.

Pourquoi max_input_length: 120

La valeur par défaut du completion est 50 caractères. Sur le corpus HuffPost, cela tronque un titre sur deux : « The 20 Best Vegan Recipes You'll Actually Want To Cook This Weekend » (66 caractères) serait coupé à « The 20 Best Vegan Recipes You'll Actually Want To ». L'utilisateur qui tape « weekend » ne retrouverait rien. En passant à 120, on couvre la totalité du corpus News tout en gardant un FST raisonnable.

La valeur par défaut est un piège silencieux

Le completion ne renvoie pas d'erreur sur les titres tronqués : il les indexe simplement à la longueur maximale. Si un jour l'autocomplétion « manque » des suggestions évidentes sur des titres longs, max_input_length est le premier paramètre à vérifier avec GET news/_mapping.

Interroger _search avec suggest

Dans Kibana Dev Tools :

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

Réponse attendue (extrait des 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

Trois choses à retenir de cette requête :

  • Le préfixe trum matche à la fois Trump et Truman : completion regarde le début du terme, pas sa sémantique.
  • skip_duplicates: true évite deux titres identiques dans les suggestions (utile quand une même dépêche a été publiée deux fois).
  • _source: false supprime les hits classiques ; on ne veut que la clé suggest.titres. Cela allège la réponse.

Le temps de réponse est de l'ordre du millimètre de seconde sur ce corpus — c'est la promesse du FST.

search_as_you_type en aperçu

Alternative à completion : le type search_as_you_type, qui crée automatiquement des sous-champs ._2gram, ._3gram, ._index_prefix. Il pardonne les fautes en milieu de mot et matche sur plusieurs champs à la fois, au prix d'un index plus lourd. Pour la barre de Veille, completion suffit ; on retient search_as_you_type pour les corpus multilingues ou quand on veut « chercher tout en tapant » plutôt que « proposer un titre exact ».

Tolérance aux fautes : fuzziness: AUTO

Un lecteur qui cherche « climat chnage » (le « n » avant le « a ») doit malgré tout trouver « Climate Change ». C'est le rôle du fuzziness — la distance d'édition de Damerau-Levenshtein — que match accepte directement.

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

Réponse attendue : plusieurs milliers de résultats (votre chiffre peut différer légèrement selon les tokens), avec en tête des titres qui contiennent « climate change ». La valeur AUTO applique une règle intelligente : 0 édition tolérée pour un terme de 1 à 2 caractères, 1 pour 3 à 5 caractères, 2 pour 6 caractères et plus. C'est le réglage à garder par défaut.

phrase suggester : « Did you mean »

Quand la faute porte sur plusieurs mots, un suggester dédié génère la phrase corrigée la plus probable :

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

Réponse (extrait) :

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

Le phrase suggester utilise un modèle de langage sur des n-grammes du champ pour ranger les corrections par plausibilité. C'est ce qu'affichent les moteurs derrière le classique « Essayez plutôt : … ».

Fuzzy oui, mais pas partout

fuzziness: AUTO sur un terms massif ou sur un préfixe court ("a", "le") devient lent et pollue les résultats. Réservez-le au champ principal (headline) et sur des recherches de deux termes au moins. Pour les autres champs, restez sur match strict.

match_phrase avec slop : la phrase souple

match_phrase cherche l'expression dans l'ordre et collée. slop permet à Elasticsearch d'accepter quelques mots entre les termes (ou une inversion) tout en respectant l'idée de la phrase.

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

Avec slop: 0 (la valeur par défaut), la requête matche seulement « climate change » collé. Avec slop: 2, elle matche aussi « climate is changing », « change in climate » ou « climate rapid change ». Le score baisse à mesure que le nombre de déplacements nécessaires augmente. C'est ce que veut la barre principale de Veille : on tolère un peu de flottement autour d'une expression, sans partir dans un match complètement lâche.

multi_match avec pondération

Un titre porte plus de sens qu'un résumé : on booste headline par rapport à 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"]
}

Le suffixe ^3 multiplie par trois la contribution du champ headline au score. Combiné à fuzziness: AUTO, c'est la requête « robuste » de la barre de recherche : elle pardonne les fautes, elle privilégie les titres, elle passe à travers plusieurs champs. C'est ce que Karim câble dans la première version de l'API.

ES|QL : le langage pipe d'Elasticsearch

ES|QL (Elasticsearch Query Language) est un langage tuyauté (à la Splunk / SQL) apparu en Elasticsearch 8 et stabilisé en 9. Il complète Query DSL : là où DSL est du JSON déclaratif taillé pour la recherche pondérée, ES|QL enchaîne FROM, WHERE, STATS, SORT, LIMIT en une seule ligne lisible, taillée pour l'analyse.

Trois façons de l'exécuter :

  • Dans Kibana Dev Tools avec l'endpoint POST _query ({"query": "..."}).
  • Dans Discover (Kibana) en basculant le sélecteur de langage de KQL à ES|QL.
  • Depuis Python (module 13) via le client officiel elasticsearch.

Exemple 1 — Compter les articles par catégorie

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

Réponse (première lignes de values) :

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

Une ligne, un pipeline, un résultat directement tabulaire. En Query DSL classique, la même chose demandait size: 0, un terms avec field: category et un tri, plus un peu de bruit JSON.

Exemple 2 — Filtrer, agréger, trier

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

Résultat : POLITICS 32 739. On garde ici la structure STATS ... BY category pour montrer la syntaxe ; sur un seul groupe, un simple STATS n = COUNT(*) suffit.

Exemple 3 — Top auteurs sur une plage de dates

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

Résultat attendu (votre chiffre peut différer légèrement) :

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

Le tuyau reste lisible même quand on empile plusieurs filtres, ce qui est précisément l'argument de vente d'ES|QL pour Léa (elle qui écrit une trentaine de requêtes d'analyse par semaine).

Exemple 4 — Extraire une année et pivoter

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 crée un champ calculé (annee) sur chaque ligne, STATS ... BY annee, category fait une agrégation croisée. Résultat : les catégories dominantes de chaque année, du 2012 au 2018. C'est la requête qu'on tape en trente secondes pour préparer un graphique.

Query DSL, KQL et ES|QL : quand utiliser quoi

| Critère | Query DSL | KQL | ES|QL | |---|---|---|---| | Format | JSON déclaratif | Chaîne compacte (category : "POLITICS" and headline : trump) | Pipeline FROM ... | ... | | Où | REST, code client, Dev Tools | Discover, Lens, Alerting (Kibana) | Dev Tools, Discover, code client | | Pertinence, score, _score | Oui (BM25, explain) | Oui (couche fine sur DSL) | Non conçu pour ça (résultat tabulaire) | | Agrégations complexes | Oui (verbeuses) | Non | Oui, très lisibles | | Filtres composites (bool, must_not) | Oui | Oui | Oui, avec WHERE | | Champs calculés | runtime_mappings | Non | Oui, EVAL | | Public visé | Développeurs API | Utilisateurs Kibana | Analystes, ingénieurs data | | Sortie | hits avec _score | hits | Table columns / values |

La règle Veille :

  • Query DSL pour tout ce que l'API livre à un client : barre de recherche, autocomplétion, résultats classés par pertinence. C'est le module 5.
  • KQL pour l'exploration rapide dans Discover et pour définir les filtres dans un tableau de bord Lens. C'est le module 7.
  • ES|QL pour tout ce qui ressemble à une analyse SQL sur les données : agrégations pivotées, comparaisons entre périodes, champs calculés. C'est ici.

Les trois cohabitent : un même dashboard peut afficher un Lens piloté par KQL, alimenter un panneau ES|QL et déclencher une alerte définie en Query DSL.

Alias d'index et réindexation sans interruption

Sami veut ajouter un champ resume à news, ou changer l'analyseur de headline. Comme on l'a vu au module 4, on ne modifie pas un type en place : il faut créer un nouvel index. Mais l'API du produit interroge GET news/_search — si on renomme l'index, on casse le client.

La parade classique : un alias. Un alias est un nom logique qui pointe vers un ou plusieurs index physiques, et qui bascule atomiquement.

Étape 1 — Renommer l'index actuel

Si news est déjà un index physique, la migration commence par : « faire de news un alias qui pointe vers un news_v1 réel ».

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

Puis on supprime news (attention : plus de lecteurs pendant deux secondes) et on crée l'alias :

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

En production Veille, on préfère prévoir dès le départ : le premier index s'appelle news_v1 et news est un alias vers lui. Le module 3 aurait pu le faire ; on le fera dès qu'on livre un vrai service.

Étape 2 — Créer news_v2 avec le nouveau 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" }
}
}
}

Étape 3 — Réindexer

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

Sur 200 853 articles, comptez quelques dizaines de secondes.

Étape 4 — Bascule atomique de l'alias

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

Les deux actions sont appliquées ensemble : pas une milliseconde sans alias, aucune requête cliente refusée. C'est la manœuvre qu'on répète à chaque évolution de mapping.

Un alias par usage

Rien n'oblige à n'avoir qu'un alias. news peut pointer vers news_v2 pour l'écriture et la lecture générale, et news_recent peut être un autre alias filtré sur les 30 derniers jours (avec filter dans l'action add). Deux vues logiques, un seul index physique.

ILM : un aperçu conceptuel

Sur un corpus statique de 200 000 articles, un seul index suffit. Sur un flux — logs applicatifs, dépêches en temps réel, traces de télémétrie — chaque jour amène de nouveaux volumes qu'il est absurde de garder « chauds » indéfiniment. ILM (Index Lifecycle Management) automatise le cycle de vie des index en quatre phases :

PhaseRôleCe qu'on y fait typiquement
hotÉcriture et lecture activesUn shard primaire, refresh_interval: 1s, indexation intense
warmPlus d'écriture, lecture fréquenteforcemerge pour compacter, on peut réduire les répliques
coldLecture rare, stockage optimiséPas d'indexation, souvent monté en frozen tier
deleteSuppressionDELETE de l'index quand il a dépassé N jours

Le passage d'une phase à la suivante se déclenche sur des critères (taille, âge, nombre de documents). On définit une policy (PUT _ilm/policy/veille_logs), on l'attache à un template d'index (news-*), et Elasticsearch fait le reste. Le corpus News n'a pas ce besoin ; retenez le principe pour le jour où Veille indexera des logs applicatifs.

À vous

Exercice 1 — Suggérer sur préfixe et compter les propositions uniques

Interrogez headline.suggest sur le préfixe climat, demandez 10 suggestions sans doublon, et déduisez combien de titres distincts commencent par ce préfixe (à la limite de 10 renvoyés).

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

La réponse liste jusqu'à 10 titres distincts commençant par « Climat… » (skip_duplicates: true retire les doublons stricts). Si vous en voulez plus, montez size — attention, cette valeur retire aussi les propositions à faible score au-delà.

Exercice 2 — Faute volontaire

Comparez le nombre de résultats d'une match stricte et d'une match avec fuzziness: AUTO sur la requête « climat chnage ». Expliquez la différence.

Solution
GET news/_count
{ "query": { "match": { "headline": "climat chnage" } } }

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

La première requête renvoie très peu de résultats : chnage n'est presque jamais un terme réel. La seconde en renvoie des milliers : fuzziness: AUTO autorise une édition sur chnage (transposition de deux lettres), qui devient change, et une édition sur climat qui matche climate (après stemming, ils tombent déjà sur le même terme climat). Comparez avec « Change Is Here. Climate Change. » en tête des hits.

Exercice 3 — ES|QL : évolution mensuelle d'une catégorie

Écrivez une requête ES|QL qui compte les articles de la catégorie POLITICS par mois entre le 2016-01-01 et le 2018-05-26, triés du plus ancien au plus récent.

Solution
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) tronque chaque date au premier jour de son mois ; STATS ... BY mois agrège. La sortie est une table (mois, n) prête à tracer. En Query DSL, la même chose se ferait avec un date_histogram en calendar_interval: month — voir le module 6.

Points à retenir

  • completion sur headline.suggest répond en millisecondes à un préfixe ; max_input_length: 120 évite la troncature silencieuse de la valeur par défaut (50).
  • fuzziness: AUTO rattrape une à deux fautes selon la longueur du terme ; à réserver aux champs plein-texte principaux.
  • Le phrase suggester génère la correction la plus probable d'une requête multi-mots, à afficher en « Essayez plutôt : … ».
  • match_phrase avec slop retrouve une expression même quand quelques mots ou une inversion s'y sont glissés.
  • ES|QL (FROM ... | WHERE ... | STATS ... BY ... | SORT | LIMIT) est le tuyau à privilégier pour l'analyse ; Query DSL reste roi pour la recherche pondérée.
  • Un alias d'index bascule atomiquement d'un news_v1 vers news_v2 : c'est la clé de la réindexation sans coupure.
  • ILM automatise le cycle hot → warm → cold → delete pour les corpus qui croissent dans le temps ; inutile sur news, indispensable dès qu'on indexe des logs.

Si ça ne marche pas

  • suggest renvoie une liste vide sur un préfixe pourtant présent dans les titres → le champ n'est pas de type completion, ou le titre a été tronqué à 50 caractères (valeur par défaut). Vérifiez GET news/_mapping puis ./lab.sh import-news pour réindexer avec le mapping du kit.
  • unknown query [fuzziness] sur une requête termfuzziness s'applique aux clauses plein-texte (match, multi_match), pas à term. Passez à match.
  • POST _query renvoie unknown function [DATE_TRUNC] ou similaire → la fonction ES|QL n'existe pas en 9.5.3 sous cette forme. Consultez elastic.co/docs/reference/query-languages/esql/esql-functions-operators et adaptez (DATE_EXTRACT, BUCKET).
  • POST _aliases renvoie index_not_found_exception → l'index cité dans remove n'existe pas (déjà supprimé) ou celui de add non plus (pas encore créé). Faites GET _cat/indices?v puis relancez la bascule dans le bon ordre : créer news_v2, réindexer, puis POST _aliases avec les deux actions ensemble.

Pour aller plus loin