Aller au contenu principal

Module 4 — Mapping, text contre keyword, analyseurs

Le corpus est chargé, _count renvoie 200 853, et Sami veut déjà lancer sa première match sur « climate change ». Inès l'arrête : avant de chercher, il faut comprendre pourquoi Elasticsearch a rangé chaque champ comme il l'a fait. C'est le rôle du mapping — le schéma des documents — et de ses analyseurs, qui décident comment un titre devient une suite de termes cherchables. Ce module ouvre elasticsearch/mappings/news.json et en commente chaque ligne.

Le mapping dynamique : ce qu'Elasticsearch devine tout seul

Si vous créez un index sans passer par un mapping, Elasticsearch en fabrique un à la volée à partir du premier document indexé. Essayons dans Kibana Dev Tools :

POST devine/_doc
{
"titre": "Change Is Here. Climate Change.",
"vues": 42,
"publie": true,
"date": "2018-05-26"
}

Puis lisez ce qu'il a deviné :

GET devine/_mapping

Résultat (extrait) :

{
"devine": {
"mappings": {
"properties": {
"titre": {
"type": "text",
"fields": { "keyword": { "type": "keyword", "ignore_above": 256 } }
},
"vues": { "type": "long" },
"publie": { "type": "boolean" },
"date": { "type": "date" }
}
}
}
}

Trois règles à retenir de cette devinette :

  1. Toute chaîne devient un champ text avec un sous-champ keyword automatique de 256 caractères (ignore_above: 256). Cela vous donne la souplesse d'un plein-texte plus l'exactitude d'un keyword, au prix d'un doublement du coût de stockage sur ce champ.
  2. Les nombres entiers deviennent long (64 bits), pas integer. Sur un compteur de vues qui ne dépassera jamais deux milliards, c'est du gaspillage.
  3. Une chaîne au format ISO 8601 est reconnue comme date. Une chaîne au format 26/05/2018 ne l'est pas — elle atterrit en text.
Le piège des dates devinées

Le mapping dynamique reconnaît quelques formats de date (ISO 8601, RFC 1123…) mais pas tous. Un jeu de données avec des dates locales dd/MM/yyyy sera classé en text, ce qui rend impossibles les range et les date_histogram. La règle Veille : dès qu'un champ contient une date, on écrit son type et son format à la main.

C'est précisément pour éviter ces surprises que le kit livre elasticsearch/mappings/news.json, appliqué avant tout _bulk.

Le mapping réel de l'index news, champ par champ

Ouvrons elasticsearch/mappings/news.json — c'est ce fichier que ./lab.sh import-news envoie via PUT /news.

{
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0,
"refresh_interval": "30s",
"analysis": {
"analyzer": {
"titre_en": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "asciifolding", "stop_en", "stem_en"]
}
},
"filter": {
"stop_en": { "type": "stop", "stopwords": "_english_" },
"stem_en": { "type": "stemmer", "language": "english" }
}
}
},
"mappings": {
"properties": {
"headline": {
"type": "text",
"analyzer": "titre_en",
"fields": {
"raw": { "type": "keyword", "ignore_above": 512 },
"suggest": { "type": "completion", "max_input_length": 120 }
}
},
"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" }
}
}
}

Voyons ce que raconte chacun de ces choix.

headline : text avec analyseur titre_en, plus deux sous-champs

Le titre est ce que Sami cherche en plein-texte. Il est donc text — analysé, découpé en termes, cherchable avec match. L'analyseur qui lui est associé, titre_en, est défini juste au-dessus dans settings.analysis.analyzer. Un analyseur est une chaîne à trois étages : un character filter (rien ici), un tokenizer (standard, qui coupe sur la ponctuation et les espaces) et une liste de token filters :

  • lowercase : tout en minuscules, pour que « Trump » et « trump » atterrissent sur le même terme.
  • asciifolding : replie les accents et caractères non ASCII (cafécafe, Beyoncébeyonce, naïvenaive). C'est ce qui permet à un lecteur qui tape « Beyonce » sans accent de retrouver « Beyoncé ».
  • stop_en : retire les mots vides anglais (the, a, of…) définis par la liste _english_.
  • stem_en : ramène les mots à leur racine. changing, changed, changes deviennent tous chang ; climate devient climat. C'est le stemming, qui fait passer 2 834 résultats pour « climate change » au lieu de la fraction stricte des occurrences.

headline a deux sous-champs, déclarés avec fields. C'est le multi-champs, la technique qui permet de stocker plusieurs vues d'un même contenu :

  • headline.raw de type keyword (ignore_above: 512) : la chaîne brute, non analysée, tronquée si elle dépasse 512 caractères. C'est cette vue qui sert à agréger, trier ou faire un term exact sur le titre.
  • headline.suggest de type completion (max_input_length: 120) : une structure spéciale (FST, transducteur d'états finis) qui permet l'autocomplétion en préfixe. Le paramètre max_input_length: 120 autorise des titres de 120 caractères ; la valeur par défaut de completion est 50, ce qui aurait tronqué la moitié des titres HuffPost et cassé l'autocomplétion sur les articles longs (module 8).

short_description : text avec le même analyseur

Pas de sous-champ : on n'a pas besoin de trier ou d'agréger sur les résumés. titre_en fait le même travail que sur les titres, avec les mêmes bénéfices (stemming, asciifolding).

category : keyword

Les 41 catégories (POLITICS, WELLNESS, ENTERTAINMENT…) sont des étiquettes fixes. On veut agréger, filtrer et trier dessus, jamais chercher en plein-texte. keyword est le seul type qui remplit les trois missions : il stocke la valeur brute dans un index optimisé pour les recherches exactes et les agrégations en doc_values.

GET news/_search
{
"size": 0,
"aggs": { "cats": { "terms": { "field": "category", "size": 5 } } }
}

Résultat attendu :

{ "aggregations": { "cats": { "buckets": [
{ "key": "POLITICS", "doc_count": 32739 },
{ "key": "WELLNESS", "doc_count": 17827 },
{ "key": "ENTERTAINMENT", "doc_count": 16058 },
{ "key": "TRAVEL", "doc_count": 9887 },
{ "key": "STYLE & BEAUTY", "doc_count": 9649 }
] } } }

Si category avait été text, ces valeurs auraient été analysées : « STYLE & BEAUTY » serait devenu deux termes style et beauty, l'agrégation aurait compté chacun à part, et l'ordre alphabétique de tri aurait été impossible. Retenez la règle : keyword pour ce qu'on classe, text pour ce qu'on cherche.

authors : text et authors.raw en keyword

Le champ authors est bicéphale, et ce n'est pas un hasard. Une chaîne comme « Lee Moran and Ron Dicker » doit se prêter à deux usages contradictoires :

  • Chercher tous les articles d'un auteur en plein-texte : match authors: "Lee Moran". Là on veut l'analyseur qui coupe sur les espaces et met en minuscules.
  • Compter combien d'articles chaque auteur exact a signés : terms field: authors.raw. Là on veut la chaîne brute, sans analyse.

Le top des auteurs, avec authors.raw :

GET news/_search
{
"size": 0,
"aggs": { "top": { "terms": { "field": "authors.raw", "size": 5 } } }
}

Résultat attendu :

Reuters       4 954
Lee Moran 2 433
Ron Dicker 1 915
Ed Mazza 1 328
Cole Delbyck 1 145

Le multi-champs coûte un peu de stockage (chaque titre est indexé deux fois) mais c'est un investissement rentable pour à peu près n'importe quel champ où l'on hésite entre chercher et grouper.

Un article a un lien vers l'article original du HuffPost. On l'affiche à l'utilisateur, on ne cherche jamais dedans. La bonne configuration est keyword + index: false : Elasticsearch stocke la valeur dans _source et la renvoie dans les hits, mais n'entretient pas d'index inversé dessus. On économise de l'espace et de la mémoire, au prix d'une seule contrainte : impossible de faire un term, un match ou une agrégation sur link. Ici, c'est exactement l'intention.

GET news/_search
{
"query": { "term": { "link": "https://www.huffingtonpost.com/entry/..." } }
}

Renvoie une erreur search_phase_execution_exception : « Cannot search on field [link] since it is not indexed. ». Le message est explicite : on lit le champ, on ne l'interroge pas.

date : date au format yyyy-MM-dd

date avec format: yyyy-MM-dd accepte exclusivement des chaînes comme 2018-05-26. Elasticsearch stocke la valeur en millisecondes depuis l'époque Unix, ce qui rend les range et les date_histogram (module 6) très rapides. Le corpus News couvre du 2012-01-28 au 2018-05-26 ; toute date hors ce format serait rejetée à l'indexation avec un mapper_parsing_exception.

L'analyseur titre_en à l'œuvre : l'API _analyze

_analyze est la loupe : elle montre, mot par mot, comment un analyseur transforme un texte avant qu'il n'entre dans l'index inversé. Sami peut tester avant même d'indexer.

POST news/_analyze
{
"field": "headline",
"text": "Change Is Here. Climate Change."
}

Réponse (extrait, on ne garde que les tokens) :

chang  |  here  |  climat  |  chang

Quatre termes seulement. Is, . disparaissent (stop_en retire is, la ponctuation est mangée par le tokenizer). Change et changing produiraient le même terme chang grâce à stem_en. Here reste tel quel : ce n'est pas un mot vide en anglais.

Comparons avec l'analyseur standard par défaut d'Elasticsearch :

POST news/_analyze
{
"analyzer": "standard",
"text": "Change Is Here. Climate Change."
}
change | is | here | climate | change

Le standard garde is, ne stemme pas. Une match sur « climate changing » avec standard renverrait zéro résultat parce qu'aucun titre ne contient exactement changing. Avec titre_en, elle en renvoie plus de 2 800 : la magie du stemmer.

asciifolding en action

POST news/_analyze
{
"field": "headline",
"text": "Beyoncé, Céline Dion et François"
}
beyonc  |  celin  |  dion  |  francoi

Les accents disparaissent puis le stemmer opère (beyonce → beyonc, celine → celin). Un lecteur qui tape « beyonce » sans accent tape la même clé indexée : il retrouve l'article. C'est un cadeau indispensable pour un moteur qui indexe de l'anglais mais que consultent des lecteurs francophones.

Toujours tester avec _analyze

Avant de créer un mapping en production, exécutez cinq à dix _analyze sur des exemples représentatifs de vos données. Vous verrez immédiatement si vos titres se retrouvent bien découpés et normalisés. C'est cinq minutes qui évitent une refonte de mapping trois mois plus tard.

Les types de champs à connaître

Le mapping de news utilise cinq types. Elasticsearch en propose une trentaine ; les incontournables tiennent en une page :

TypeUtilisationExemple dans news
textChaînes cherchables en plein-texte, analyséesheadline, short_description, authors
keywordChaînes exactes : filtrer, trier, agrégercategory, headline.raw, link
dateInstants, format libredate (yyyy-MM-dd)
integer, long, short, byteEntiers sur 32 / 64 / 16 / 8 bitsnon utilisé ici
float, double, half_float, scaled_floatDécimalesnon utilisé
booleantrue/falsenon utilisé
completionAutocomplétion en préfixe (module 8)headline.suggest
objectObjet JSON imbriqué, indexé à platpar défaut sur les objets
nestedObjet imbriqué à indexer indépendammentà connaître en aperçu
geo_point, geo_shapeCoordonnées et polygonespas dans ce cours

nested mérite une note : quand vous indexez un tableau d'objets ([{ "auteur": "A", "role": "principal" }, { "auteur": "B", "role": "invité" }]), Elasticsearch aplatit par défaut, ce qui casse l'association A/principal et B/invité (une requête « auteur A et rôle invité » renverrait le document à tort). nested conserve la structure au prix d'une requête un peu plus lourde (nested query). Sur news on n'en a pas besoin — les auteurs sont une simple chaîne.

Changer un mapping = _reindex

Voici l'erreur classique. Sami crée par mégarde un index où headline est déclaré keyword au lieu de text, réalise qu'il ne peut plus faire de match propre, et veut « corriger » le mapping :

PUT news_v0
{
"mappings": {
"properties": {
"headline": { "type": "keyword" }
}
}
}

PUT news_v0/_mapping
{
"properties": {
"headline": { "type": "text" }
}
}

Le second appel échoue :

illegal_argument_exception : mapper [headline] cannot be changed from type [keyword] to [text]

On ne modifie pas le type d'un champ existant. On peut ajouter de nouvelles propriétés, ajouter des multi-champs (fields) sur un text existant, changer certains paramètres accessoires (ignore_above), mais jamais changer le type de base. La bonne procédure tient en trois étapes :

  1. Créer un nouvel index news_v1 avec le mapping correct.
  2. Réindexer les données avec l'API _reindex.
  3. Basculer un alias de news_v0 vers news_v1 (module 8) pour que les requêtes clientes ne changent pas.
POST _reindex
{
"source": { "index": "news_v0" },
"dest": { "index": "news_v1" }
}

_reindex fonctionne en interne comme un search + _bulk. Sur 200 853 articles, comptez quelques dizaines de secondes avec le kit. Vous pouvez lancer la commande en asynchrone avec ?wait_for_completion=false et suivre l'avancement via GET _tasks.

Réindexer, c'est copier, pas transformer

_reindex ne modifie pas les documents en place. Il les relit du source, les envoie au dest. Si le nouveau mapping est plus strict (par exemple un date avec un format précis), un document qui ne colle pas au format sera rejeté au moment de l'écriture. _reindex renvoie un rapport avec updated, created, failures : lisez-le avant de croire que tout est passé.

Index templates : en aperçu

Un index template applique automatiquement un mapping et des settings aux nouveaux index dont le nom correspond à un motif. Si Veille indexe demain news-2026-09-09, news-2026-09-10… un template news-* évite de répéter le mapping. On les définit avec PUT _index_template/veille_news ; c'est le pilier des architectures à index roulants. On les rencontre à nouveau dans le module 8 quand on parle d'ILM et de bascule d'alias.

À vous

Exercice 1 — Prouver l'effet du stemmer

Comparez le nombre de résultats renvoyés par une match sur « climate change » puis sur « climates changed ». Sans stemmer, ces deux requêtes donneraient des comptes différents. Expliquez pourquoi elles donnent le même.

Solution
GET news/_count
{ "query": { "match": { "headline": "climate change" } } }

GET news/_count
{ "query": { "match": { "headline": "climates changed" } } }

Les deux requêtes renvoient exactement le même compte, autour de 2 834. L'analyseur titre_en applique stem_en : climate et climates se réduisent tous deux à climat, change et changed à chang. Les termes cherchés dans l'index inversé sont donc identiques dans les deux cas. Vérifiez avec POST news/_analyze { "field": "headline", "text": "climates changed" } : vous verrez les tokens climat et chang.

Exercice 2 — Choisir entre text et keyword

Pour chacun de ces champs d'un futur index livres, dites si vous choisiriez text, keyword, les deux (multi-champs) ou keyword avec index: false. Justifiez en une phrase.

  1. titre (chaîne libre, cherchée en plein-texte, affichée)
  2. isbn (identifiant à 13 chiffres, jamais cherché en plein-texte)
  3. couverture_url (adresse d'image, affichée, jamais interrogée)
  4. auteur (chaîne libre, à la fois cherchée et agrégée)
  5. genre (une valeur parmi 30, filtrée et agrégée)
Solution
  1. titre : text avec l'analyseur adapté à la langue. Ajouter titre.raw en keyword si l'on veut trier alphabétiquement ou agréger.
  2. isbn : keyword. Ce n'est pas un nombre à additionner ; c'est une clé exacte à filtrer et à joindre.
  3. couverture_url : keyword avec index: false. On la stocke dans _source, on ne l'interroge jamais.
  4. auteur : multi-champs — text (recherche) + auteur.raw keyword (agrégation), comme dans news.
  5. genre : keyword. Filtrer et agréger, pas plus. Un text casserait « Science-fiction » en deux termes.

Exercice 3 — Reproduire l'erreur « mapper cannot be changed from type [text] to [keyword] »

Créez un index piege, indexez un document avec un champ titre que le mapping dynamique classe en text, puis essayez de forcer titre en keyword. Lisez le message d'erreur exact.

Solution
POST piege/_doc
{ "titre": "Un premier document" }

PUT piege/_mapping
{ "properties": { "titre": { "type": "keyword" } } }

Réponse :

illegal_argument_exception : mapper [titre] cannot be changed from type [text] to [keyword]

La correction propre : créer piege_v1 avec le bon mapping et POST _reindex { "source": {"index": "piege"}, "dest": {"index": "piege_v1"} }. C'est exactement ce qu'on ferait sur news en production, avec un alias en plus pour ne pas couper les lecteurs (module 8).

Points à retenir

  • Le mapping dynamique met tout string en text + sous-champ keyword de 256 : pratique, mais on ne le laisse pas décider en production.
  • text pour ce qu'on cherche en plein-texte, keyword pour ce qu'on filtre, trie ou agrège ; le multi-champs field.raw marie les deux.
  • L'analyseur titre_en du kit (standard + lowercase + asciifolding + stop_en + stem_en) est la raison pour laquelle une recherche « climate change » retourne 2 834 résultats et pas quelques dizaines.
  • _analyze montre, avant même d'indexer, comment un texte sera découpé — c'est l'outil de vérification à réflexe.
  • index: false sur un keyword économise stockage et mémoire quand on ne fait qu'afficher la valeur.
  • headline.suggest est un completion avec max_input_length: 120 (la valeur par défaut 50 tronquerait la moitié des titres HuffPost).
  • On ne change pas le type d'un champ : on crée un nouvel index avec le bon mapping et on utilise _reindex (avec un alias en production).

Si ça ne marche pas

  • GET news/_mapping renvoie un champ absent → l'importateur a rencontré un document sans ce champ. Vérifiez avec GET news/_search { "query": { "exists": { "field": "authors" } } } combien de documents le portent, puis ./lab.sh import-news si le compte est anormal.
  • illegal_argument_exception : mapper ... cannot be changed → tentative de changer un type existant. Créez un nouvel index et utilisez POST _reindex.
  • mapper_parsing_exception sur une date → la valeur ne colle pas au format déclaré (yyyy-MM-dd). Regardez le document fautif dans les logs, puis corrigez-le à la source ou élargissez le format ("format": "yyyy-MM-dd||yyyy/MM/dd").
  • Une match renvoie zéro résultat sur un titre pourtant présent → l'analyseur ne fait pas ce que vous croyez. Exécutez POST news/_analyze { "field": "headline", "text": "votre texte" } pour voir les tokens réellement indexés.

Pour aller plus loin