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 :
- Toute chaîne devient un champ
textavec un sous-champkeywordautomatique de 256 caractères (ignore_above: 256). Cela vous donne la souplesse d'un plein-texte plus l'exactitude d'unkeyword, au prix d'un doublement du coût de stockage sur ce champ. - Les nombres entiers deviennent
long(64 bits), pasinteger. Sur un compteur de vues qui ne dépassera jamais deux milliards, c'est du gaspillage. - Une chaîne au format ISO 8601 est reconnue comme
date. Une chaîne au format26/05/2018ne l'est pas — elle atterrit entext.
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ïve→naive). 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,changesdeviennent touschang;climatedevientclimat. 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.rawde typekeyword(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 untermexact sur le titre.headline.suggestde typecompletion(max_input_length: 120) : une structure spéciale (FST, transducteur d'états finis) qui permet l'autocomplétion en préfixe. Le paramètremax_input_length: 120autorise des titres de 120 caractères ; la valeur par défaut decompletionest 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.
link : keyword avec index: false
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.
_analyzeAvant 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 :
| Type | Utilisation | Exemple dans news |
|---|---|---|
text | Chaînes cherchables en plein-texte, analysées | headline, short_description, authors |
keyword | Chaînes exactes : filtrer, trier, agréger | category, headline.raw, link |
date | Instants, format libre | date (yyyy-MM-dd) |
integer, long, short, byte | Entiers sur 32 / 64 / 16 / 8 bits | non utilisé ici |
float, double, half_float, scaled_float | Décimales | non utilisé |
boolean | true/false | non utilisé |
completion | Autocomplétion en préfixe (module 8) | headline.suggest |
object | Objet JSON imbriqué, indexé à plat | par défaut sur les objets |
nested | Objet imbriqué à indexer indépendamment | à connaître en aperçu |
geo_point, geo_shape | Coordonnées et polygones | pas 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 :
- Créer un nouvel index
news_v1avec le mapping correct. - Réindexer les données avec l'API
_reindex. - Basculer un alias de
news_v0versnews_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.
_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.
titre(chaîne libre, cherchée en plein-texte, affichée)isbn(identifiant à 13 chiffres, jamais cherché en plein-texte)couverture_url(adresse d'image, affichée, jamais interrogée)auteur(chaîne libre, à la fois cherchée et agrégée)genre(une valeur parmi 30, filtrée et agrégée)
Solution
titre:textavec l'analyseur adapté à la langue. Ajoutertitre.rawenkeywordsi l'on veut trier alphabétiquement ou agréger.isbn:keyword. Ce n'est pas un nombre à additionner ; c'est une clé exacte à filtrer et à joindre.couverture_url:keywordavecindex: false. On la stocke dans_source, on ne l'interroge jamais.auteur: multi-champs —text(recherche) +auteur.rawkeyword(agrégation), comme dansnews.genre:keyword. Filtrer et agréger, pas plus. Untextcasserait « 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-champkeywordde 256 : pratique, mais on ne le laisse pas décider en production. textpour ce qu'on cherche en plein-texte,keywordpour ce qu'on filtre, trie ou agrège ; le multi-champsfield.rawmarie les deux.- L'analyseur
titre_endu 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. _analyzemontre, avant même d'indexer, comment un texte sera découpé — c'est l'outil de vérification à réflexe.index: falsesur unkeywordéconomise stockage et mémoire quand on ne fait qu'afficher la valeur.headline.suggestest uncompletionavecmax_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/_mappingrenvoie un champ absent → l'importateur a rencontré un document sans ce champ. Vérifiez avecGET news/_search { "query": { "exists": { "field": "authors" } } }combien de documents le portent, puis./lab.sh import-newssi le compte est anormal.illegal_argument_exception : mapper ... cannot be changed→ tentative de changer un type existant. Créez un nouvel index et utilisezPOST _reindex.mapper_parsing_exceptionsur 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
matchrenvoie zéro résultat sur un titre pourtant présent → l'analyseur ne fait pas ce que vous croyez. ExécutezPOST news/_analyze { "field": "headline", "text": "votre texte" }pour voir les tokens réellement indexés.
Pour aller plus loin
- Documentation Elasticsearch 9 — Mapping et types de champs
- Documentation Elasticsearch 9 — Analyseurs, tokenizers et token filters
- Documentation Elasticsearch 9 —
_reindexet migration de données - Documentation Elasticsearch 9 — Multi-fields