Aller au contenu principal

Module 6 — Agrégations : compter, grouper, suivre dans le temps

Le module 5 sait retrouver des articles précis ; ici on demande au corpus des chiffres et des tendances. Léa prépare la revue trimestrielle de Veille : combien d'articles par catégorie, comment le sujet climat monte-t-il d'année en année, qui écrit le plus ? Les agrégations d'Elasticsearch répondent à ces questions en une seule requête, sans jamais renvoyer les documents.

Deux réflexes qui changent tout

size: 0 dit à Elasticsearch de ne rapatrier aucun document : on ne veut que le bloc aggregations. Un oubli sur size provoque 10 hits inutiles par-dessus le calcul.

Les agrégations vivent sous la clé aggs (alias aggregations). Chaque agrégation porte un nom que vous choisissez, un type (terms, date_histogram…), des paramètres, et éventuellement des sous-agrégations dans son propre aggs.

terms : compter par valeur

terms regroupe les documents par la valeur exacte d'un champ keyword. C'est l'agrégation la plus utilisée.

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

Résultat attendu, buckets en tête :

  • POLITICS — 32 739
  • WELLNESS — 17 827
  • ENTERTAINMENT — 16 058
  • TRAVEL — 9 887
  • STYLE & BEAUTY — 9 649

Le total du corpus est 200 853 documents ; la somme des cinq premiers buckets vaut donc 86 160, soit 43 % du corpus. Elasticsearch renvoie aussi doc_count_error_upper_bound (marge d'erreur potentielle liée au découpage en shards ; nulle ici car l'index a un seul shard) et sum_other_doc_count (le reste).

Sur les auteurs, on cible le sous-champ keyword authors.raw :

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

Les cinq premiers buckets :

  • Reuters — 4 954
  • Lee Moran — 2 433
  • Ron Dicker — 1 915
  • Ed Mazza — 1 328
  • Cole Delbyck — 1 145
terms sur un text

{"terms": {"field": "headline"}} échoue ou coûte cher : headline est analysé (titre_en) et n'a pas de fielddata par défaut. On agrège toujours sur un keyword (ici headline.raw, category, authors.raw).

date_histogram : suivre le temps

date_histogram découpe l'axe temps en intervalles réguliers. Depuis Elasticsearch 8, on emploie calendar_interval (calé sur le calendrier : année, mois, semaine, jour) ou fixed_interval (durée fixe en heures ou minutes). Le vieux interval est retiré.

GET news/_search
{
"size": 0,
"aggs": {
"par_annee": {
"date_histogram": {
"field": "date",
"calendar_interval": "year",
"format": "yyyy"
}
}
}
}

Le corpus court du 2012-01-28 au 2018-05-26, on voit donc sept buckets (2012 à 2018). Les buckets vides sont omis par défaut ; ajoutez "min_doc_count": 0 avec des bornes extended_bounds pour un axe complet.

Pour un pas mensuel :

GET news/_search
{
"size": 0,
"aggs": {
"par_mois": {
"date_histogram": {
"field": "date",
"calendar_interval": "month",
"format": "yyyy-MM"
}
}
}
}

(votre chiffre peut différer légèrement d'un bucket à l'autre selon les dates).

range : intervalles à la carte

Là où date_histogram découpe régulièrement, range définit des tranches nommées.

GET news/_search
{
"size": 0,
"aggs": {
"avant_apres_2016": {
"range": {
"field": "date",
"ranges": [
{ "to": "2016-01-01", "key": "2012-2015" },
{ "from": "2016-01-01", "key": "2016-2018" }
]
}
}
}
}

cardinality : compter les valeurs distinctes

Combien d'auteurs différents dans le corpus ?

GET news/_search
{
"size": 0,
"aggs": {
"nb_auteurs": {
"cardinality": { "field": "authors.raw" }
}
}
}

cardinality utilise l'algorithme HyperLogLog++ : rapide et à mémoire bornée, mais approximatif. Le paramètre precision_threshold (défaut 3 000, maximum 40 000) règle le compromis précision/mémoire — au-dessous du seuil, le compte est presque toujours exact ; au-dessus, l'erreur relative moyenne reste sous 1 à 2 %.

Chiffres exacts sur petit ensemble

Sur news, 41 catégories exactes tiennent facilement dans la précision par défaut. Pour un compte parfaitement exact sur un très grand ensemble, il faut passer par composite (voir plus bas) et compter les buckets côté client.

avg, stats, min, max

Les métriques classiques s'appliquent aux champs numériques et aux dates. news n'a pas de champ numérique métier, mais on peut regarder la date la plus ancienne et la plus récente :

GET news/_search
{
"size": 0,
"aggs": {
"bornes_dates": { "stats": { "field": "date" } }
}
}

La réponse contient min = 2012-01-28, max = 2018-05-26 (avec des variantes min_as_string / max_as_string bien lisibles).

top_hits : un échantillon dans chaque bucket

top_hits est une sous-agrégation qui renvoie N documents représentatifs par bucket parent. Idéal pour « le titre le plus récent de chaque catégorie ».

GET news/_search
{
"size": 0,
"aggs": {
"par_categorie": {
"terms": { "field": "category", "size": 5 },
"aggs": {
"dernier_titre": {
"top_hits": {
"size": 1,
"sort": [ { "date": "desc" } ],
"_source": ["headline", "date"]
}
}
}
}
}
}

Sous-agrégations : la puissance qui compte

L'imbrication est ce qui distingue les agrégations d'un simple GROUP BY. La question de Léa : combien d'articles par catégorie, année par année, sur POLITICS et WELLNESS ?

GET news/_search
{
"size": 0,
"query": {
"terms": { "category": ["POLITICS", "WELLNESS"] }
},
"aggs": {
"par_categorie": {
"terms": { "field": "category", "size": 2 },
"aggs": {
"par_annee": {
"date_histogram": {
"field": "date",
"calendar_interval": "year",
"format": "yyyy"
}
}
}
}
}
}

Chaque bucket parent (POLITICS, WELLNESS) porte son propre par_annee. Le total de par_categorie.POLITICS vaut 32 739 ; celui de par_categorie.WELLNESS vaut 17 827 ; le tout tient dans une seule requête.

filter et filters en agrégation

filter (au singulier) restreint le calcul à un sous-ensemble sans changer la requête globale.

GET news/_search
{
"size": 0,
"aggs": {
"recents": {
"filter": { "range": { "date": { "gte": "2017-01-01" } } },
"aggs": {
"par_categorie": { "terms": { "field": "category", "size": 5 } }
}
}
}
}

filters (au pluriel) fait N buckets nommés en une passe, un peu comme un range mais pour des critères libres.

GET news/_search
{
"size": 0,
"aggs": {
"sujets": {
"filters": {
"filters": {
"climat": { "match": { "headline": "climate change" } },
"election": { "match": { "headline": "election" } },
"sante": { "match": { "headline": "health" } }
}
}
}
}
}

Le bucket climat contient 2 834 documents (voir module 5).

composite : paginer une agrégation

terms avec size: 10000 est un anti-patron : la mémoire monte et rien ne garantit qu'on récupère toutes les clés. composite paginera proprement, avec un after_key équivalent à search_after.

GET news/_search
{
"size": 0,
"aggs": {
"toutes_categories": {
"composite": {
"size": 100,
"sources": [
{ "cat": { "terms": { "field": "category" } } }
]
}
}
}
}

La réponse contient un after_key ; on relance en le passant sous "after": { "cat": "..." } jusqu'à épuisement. Sur news, deux appels suffisent à couvrir les 41 catégories exactes.

Ordre et size : trois pièges

  • terms renvoie par défaut les buckets triés par doc_count décroissant. Pour trier par la clé, "order": {"_key": "asc"} ; par une sous-métrique, "order": {"nom_metrique": "desc"}.
  • Le size par défaut est 10. Un top 20 exige "size": 20.
  • Sur plusieurs shards, terms demande à chaque shard shard_size clés (par défaut size * 1.5 + 10) puis fusionne. doc_count_error_upper_bound mesure l'incertitude. Sur news (un seul shard), la marge est nulle.

Lire une réponse d'agrégation

La structure est régulière :

  • hits.total.value : nombre de documents concernés par la query.
  • aggregations.<nom>.buckets : liste des buckets.
  • Chaque bucket : key (la valeur), doc_count (le compte), et les sous-agrégations sous leur nom.
  • Pour un date_histogram : key (timestamp epoch ms), key_as_string (chaîne formatée).

Écrire un petit paragraphe qui prend chaque champ le premier jour permet de lire sans effort n'importe quelle réponse ensuite.

À vous 1 — Top 3 des catégories en 2017

Écrivez une requête qui renvoie les trois catégories les plus fréquentes uniquement pour les articles publiés en 2017. Aucune donnée de document n'est nécessaire.

Solution
GET news/_search
{
"size": 0,
"query": {
"bool": {
"filter": [ { "range": { "date": { "gte": "2017-01-01", "lt": "2018-01-01" } } } ]
}
},
"aggs": {
"top3_2017": {
"terms": { "field": "category", "size": 3 }
}
}
}

La query restreint le champ d'analyse à 2017 (en filter, sans score). L'agrégation terms travaille ensuite sur ce sous-corpus. Comparez avec le classement global pour voir si POLITICS reste devant WELLNESS cette année-là (votre chiffre peut différer légèrement).

À vous 2 — Nombre d'articles de Lee Moran par catégorie

Le brief indique que Lee Moran a écrit 2 433 articles au total. Combien dans COMEDY, WEIRD NEWS, ENTERTAINMENT, POLITICS ?

Solution
GET news/_search
{
"size": 0,
"query": {
"term": { "authors.raw": "Lee Moran" }
},
"aggs": {
"par_categorie": {
"terms": { "field": "category", "size": 5 }
}
}
}

Buckets attendus : COMEDY 779, WEIRD NEWS 391, ENTERTAINMENT 387, POLITICS 264, SPORTS 101. La somme (1 922) est inférieure à 2 433 : le reste se répartit dans les 36 autres catégories. Passez "size": 41 pour tout voir.

À vous 3 — Nombre d'auteurs distincts par année

Le corpus est-il de plus en plus contributif ? Renvoyez une année → nombre d'auteurs distincts (approx.).

Solution
GET news/_search
{
"size": 0,
"aggs": {
"par_annee": {
"date_histogram": {
"field": "date",
"calendar_interval": "year",
"format": "yyyy"
},
"aggs": {
"auteurs_distincts": {
"cardinality": { "field": "authors.raw", "precision_threshold": 5000 }
}
}
}
}
}

Chaque bucket par_annee porte une sous-agrégation cardinality. Le precision_threshold monte pour garder une bonne précision sur les 23 082 auteurs distincts recensés dans le corpus.

Points à retenir

  • size: 0 isole les agrégations ; les hits inutiles disparaissent.
  • terms groupe par keyword, date_histogram par intervalle calendar_interval ou fixed_interval.
  • cardinality est un HyperLogLog++ approximatif : réglez precision_threshold selon la précision voulue.
  • Les sous-agrégations reproduisent le calcul par bucket parent : catégories par année, auteurs par catégorie.
  • filter/filters restreignent une agrégation sans toucher à la query ; composite pagine avec after_key.
  • Lisez la réponse dans l'ordre : buckets[].key, buckets[].doc_count, buckets[].<sous_agg>.

Si ça ne marche pas

  • « Fielddata is disabled on text fields by default » sur un terms de headline → agrégez sur le sous-champ keyword (headline.raw, authors.raw) ; ne réactivez pas fielddata.
  • doc_count_error_upper_bound non nul et top qui bouge → augmentez shard_size ou size ; sur news (un seul shard) l'erreur est nulle.
  • date_histogram retourne 0 bucket → vérifiez que le champ est bien date avec ./lab.sh es news/_mapping?filter_path=**.date ; sinon, mapping à corriger (module 4).
  • « Trying to create too many buckets » → limitez size ou passez à composite avec size: 100 et pagination.

Pour aller plus loin