Elasticsearch et Symfony : Statistiques avec les agrégations

Share Button

Read the English version

Elasticsearch est capable d’indexer d’énormes volumes de données, aussi bien des documents que des chiffres.
Dans les versions inférieures à la v1.0.0, les facettes permettaient de récupérer des statistiques pour une liste de documents indexés (répartition par tag, moyenne, écart-type,…)
Au fil du temps, l’utilisation de ces facettes a évolué. Les développeurs ont souhaité s’en servir pour réaliser des statistiques de plus en plus poussées.
En réponse à cela, la géniale équipe Elasticsearch a ajouté l’implémentation des agrégations :
- Métriques : Somme, minimum, maximum, moyenne, ….
- “Buckets” : Répartition par terme, par date, par valeur, … Ces agrégations peuvent contenir des sous-agrégations. On peut donc calculer des statistiques dans une répartition par terme, un truc de malade!

Le but de cet article est de décrire une des utilisations qui peuvent être faites de ces agrégations : des statistiques.


Vous pourrez trouver plus d’informations au sujet des agrégations sur cet article :
https://www.found.no/foundation/elasticsearch-aggregations/
N’hésitez pas non plus à lire la documentation officielle

Pour écrire cet article nous allons utiliser les sources disponibles sur le dépôt Github du blog.
Le but est de fournir :

  • une liste de tags (et le nombre d’articles associé)
  • La répartition des articles en fonction de leur date de publication (triés par catégorie).

L’indexation des articles et catégories n’est pas détaillée ici, tout comme la recherche et les filtres de base. N’hésitez pas à lire nos articles précédents :

Généralités sur les agrégations

Les applications possibles des agrégations sont nombreuses et voici pour nous les plus intéressantes :

Statistiques / Métriques :

  • stats : Retourne min/max/sum/avg/count
  • min/max : Retourne la valeur minimum/maximum sur un champ
  • sum/avg : Retourne la somme/moyenne sur l’un des champs

Répartitions du nombre de documents indexés :

  • terms : En fonction de la valeur d’un champ. Ressemble à un group by + count SQL, mais plus puissant (permet de compter aussi les éléments imbriqués)
  • range : En fonction de plages de valeurs données par l’utilisateur (Voir la documentation)
  • date range : En fonction de plages de date données par l’utilisateur
  • histogram : Selon un intervalle de valeur défini par l’utilisateur (par ex répartition de produits par tranche de 50€)
  • date histogram : Selon un intervalle de dates défini par l’utilisateur

Autres :

  • filter : Permet d’ajouter des filtres spécifiques à une agrégation (ou un groupe d’agrégations)
  • nested : Permet d’ajouter des agrégations sur des nested

Méthode de calcul

Attention : Les agrégations, comme les facettes, sont calculées a partir des résultats de la query associée et non des filtres utilisés.

Par exemple pour cette recherche Elasticsearch :
-> Les agrégations seront calculées sur tout l’index (aucune Query à part match_all).
-> Les résultats seront filtrés sur la date et le champ publication.

{
  "query": {
    "match_all": {}
  },
  "filter": {
    "bool": {
      "must": [
        {
          "range": {
            "publishedAt": {
              "gte": "2014-04-07T00:00:00Z",
              "lte": "2014-04-08T23:59:59Z"
            }
          }
        },
        {
          "terms": {
            "published": [
              "true"
            ]
          }
        }
      ]
    }
  }
}

Alors qu’avec cette seconde recherche, les agrégations ET les résultats sont impactés par la date et le champ publication.

{
  "query": {
    "bool": {
      "must": [
        {
          "range": {
            "publishedAt": {
              "gte": "2013-04-07T00:00:00Z",
              "lte": "2014-04-08T23:59:59Z"
            }
          }
        },
        {
          "terms": {
            "published": [
              "true"
            ]
          }
        }
      ]
    }
  }
}

En vrac quelques cas d’utilisation:

  • Vous souhaitez utiliser des filters : utilisez une query filtered. Le fait de les inclure dans la partie query de votre recherche impactera les agrégations/facets
  • Vous souhaitez avoir des agrégations générales mais filtrer les résultats de recherche sur champ : ajoutez des filters à votre recherche
  • Vous souhaitez avoir une de vos agrégations sur tout votre index : utilisez l’agrégation “global”. Voir la documentation
  • Vous souhaitez faire plusieurs agrégations, chacune filtrée différemment : utilisez les filtres d’agrégation. voir la documentation

Récupérer une liste de tags, et le nombre d’articles associés

Premier cas pratique de notre article : réaliser une recherche et récupérer le nombre d’articles associés à chaque tag.
Nous avons donc besoin d’une agrégation de type “Terms”. Elle récupère toutes les valeurs distinctes de “tag” et compte le nombre de résultats pour chacune.

La recherche en JSON
Pour bien comprendre notre recherche, la voici en JSON telle qu’elle sera envoyée à Elasticsearch (et telle que vous pouvez l’écrire dans le plugin head par exemple)

{
  "query": {
    "match_all" : {}
  },
  "aggs": {
    "tag": {
      "terms": {
        "field": "tags"
      }
    }
  },
  "size": 0
}

  • "aggs" : Sous-partie de la recherche qui contient les agrégations
  • "tag" : Nom de notre agrégation. Fixée par l’utilisateur
  • "terms" : Type de l’agrégation
  • "field" : "tags" : Champ concerné par l’agrégation, ici nous agrégeons sur le champ tags

Il est a noter que nous ne souhaitons récupérer aucun résultat. Simplement des agrégations. Nous précisons donc à Elasticsearch qu’il ne doit retourner aucun résultat de recherche : "size" : 0

Le résultat sur nos articles de blog :

{
  took: 2
  timed_out: false
  _shards: {
    total: 5
    successful: 3
    failed: 0
  }
  hits: {
    total: 10
    max_score: 0
    hits: [ ]
  }
  aggregations: {
    tag: {
      buckets: [
        {
          key: symfony2
          doc_count: 9
        }
        {
          key: wsse
          doc_count: 3
        }
        {
          key: rest
          doc_count: 2
        }
        {
          key: android
          doc_count: 1
        }
        {
          key: csv
          doc_count: 1
        }
        {
          key: currency
          doc_count: 1
        }
        {
          key: jquery
          doc_count: 1
        }
        {
          key: knpmenubundle
          doc_count: 1
        }
        {
          key: twig
          doc_count: 1
        }
      ]
    }
  }
}

La structure est assez simple :

  • aggregations : Votre tableau d’agrégations, dont les clés sont les noms des agrégations données lors de la recherche (Tag dans notre cas)
  • bucket : Notre agrégation étant de type “bucket”, nous recevons un tableau d’objets en résultat
  • key : La clé du résultat courant (chez nous un Tag)
  • doc_count : Le nombre de résultats concernés (chez nous le nombre d’articles associés à chaque tag)

Nous pourrions imaginer une structure de sous-agrégations plus complexes. Ce point sera traité plus bas.

La recherche via Elastica et Symfony2

Transformons cette requête JSON en Elastica, dans le SearchRepository Article :

//Obtao\BlogBundle\Entity\SearchRepository\ArticleRepository.php

    public function getStatsQuery()
    {
        $query = new \Elastica\Query(new \Elastica\Query\MatchAll());

        // Agrégation simple (basée sur les tags, on récupère le doc_count pour chaque tag)
        $tagsAggregation = new \Elastica\Aggregation\Terms('tag');
        $tagsAggregation->setField('tags');

        $query->addAggregation($tagsAggregation);

        // pas besoin des résultats, juste les stats
        $query->setSize(0);

        return $query;
    }

Vous pourrez remarquer que notre méthode retourne une query, et non un résultat de recherche. La raison est que nous n’allons pas utiliser le Finder (qui transforme les résultats Elasticsearch en objets Doctrine) mais directement l’index (la couche brute de recherche)

Créons maintenant notre recherche dans le controller :

//Obtao\BlogBundle\Controller\ArticleController
public function statsAction(Request $request)
    {
        $query = $this->container->get('fos_elastica.manager')->getRepository('ObtaoBlogBundle:Article')->getStatsQuery($articleSearch);
        $results = $this->get('fos_elastica.index.obtao_blog.article')->search($query);

        return $this->render('ObtaoBlogBundle:Article:stats.html.twig',array(
            'aggs' => $results->getAggregations()
        ));
    }

Nous lançons simplement notre Query sur l’index article. Ce qui nous retournera une réponse brute et non des objets Doctrine.

Le template :

{# ObtaoBlogBundle:Article:stats.html.twig #}
{% extends 'ObtaoBlogBundle::layout.html.twig' %}

{% block body %}
    {% for tagAgg in aggs.tag.buckets %}
        {{ tagAgg.key }} ({{ tagAgg.doc_count }}){% if not loop.last %}, {% endif %}
    {% endfor %}
{% endblock %}

Et voila! Le résultat : symfony2 (9), wsse (3), rest (2), android (1), csv (1), currency (1), jquery (1), knpmenubundle (1), twig (1)

Récupérer une répartition des articles par date. Et pour chaque date, les catégories pour lesquelles un article a été publié

Vous maîtrisez maintenant les bases de l’agrégation sous Elasticsearch. Vous auriez pu faire la même chose avec des facettes.
Voyons maintenant un cas plus complexe.

Un client (quelconque) souhaitant rester anonyme voudrait récupérer la répartition par mois des articles. Et pour chaque mois, les catégories d’article qui ont été impactées.
Avec les sous-agrégations, c’est possible ! Ouf…

Voici rapidement le code du Repository

    public function getStatsQuery(ArticleSearch $articleSearch)
    {
        $query = new \Elastica\Query(new \Elastica\Query\MatchAll());

        // Agrégation plus complexe, on récupère les catégories pour chaque mois
        $dateAggregation = new \Elastica\Aggregation\DateHistogram('dateHistogram','publishedAt','month');
        $dateAggregation->setFormat("dd-MM-YYYY");
        $categoryAggregation = new \Elastica\Aggregation\Terms('category');
        $categoryAggregation->setField("category");

        $dateAggregation->addAggregation($categoryAggregation);

        $query->addAggregation($dateAggregation);

        // pas besoin des résultats, juste les stats
        $query->setSize(0);

        return $query;
    }

Nous créons une première agrégation de type DateHistogram appelée “dateHistogram”. Cette agrégatation va se baser sur le champ publishedAt avec des intervalles d’un mois (month).
Le format de la clé dans le résultat sera sous forme "dd-MM-YYY".

Nous créons ensuite une deuxième agrégation de type Terms, appelée “category”. Cependant, au lieu d’ajouter cette agrégation à la $query, nous l’ajoutons à notre agrégation précédente.
Cela aura pour effet de créer une sous-agrégation, nos résultats seront agregés par mois puis par catégorie.

Une fois la Query exécutée, voici le résultat :

aggregations: {
  dateHistogram: {
    buckets: [
      {
        key_as_string: 01-11-2012
        key: 1351728000000
        doc_count: 1
        category: {
          buckets: [
              {
                  key: Symfony2
                  doc_count: 1
              }
          ]
        }
      }
      {
        key_as_string: 01-04-2013
        key: 1364774400000
        doc_count: 1
        category: {
          buckets: [
            {
              key: Symfony2
              doc_count: 1
            }
          ]
        }
      }
      {
        key_as_string: 01-05-2013
        key: 1367366400000
        doc_count: 2
        category: {
          buckets: [
            {
              key: Android and API
              doc_count: 1
            }
            {
              key: Symfony2
              doc_count: 1
            }
          ]
        }
      }
      {
        key_as_string: 01-06-2013
        key: 1370044800000
        doc_count: 1
        category: {
          buckets: [
            {
              key: Symfony2
              doc_count: 1
            }
          ]
        }
      }
      {
        key_as_string: 01-09-2013
        key: 1377993600000
        doc_count: 2
        category: {
          buckets: [
            {
              key: Android and API
              doc_count: 1
            }
            {
              key: Symfony2
              doc_count: 1
            }
          ]
        }
      }
    ]
  }
}

Plusieurs choses sont à remarquer:

  • Une entrée “key_as_string” est apparue pour l’agrégation par date : c’est la date formatée selon le format donné lors de la recherche (dd-MM-YYY)
  • Une sous-agrégation “category” est présente sous le dateHistogram. Elle contient toutes les catégories impactées durant cette période (La sous-agrégation est “filtrée” par l’agrégation parente)
  • Le template

    {% extends 'ObtaoBlogBundle::layout.html.twig' %}
    
    {% block body %}
        {% for dateAgg in aggs.dateHistogram.buckets %}
            
    • {{ dateAgg.key_as_string }} ({{ dateAgg.doc_count}})
        {% for category in dateAgg.category.buckets %}
      • {{ category.key }}
      • {% endfor %}
    {% endfor %} {% endblock %}

    Vous pourrez faire beaucoup de choses avec les agrégations : la vitesse de calcul et de réponse d’ElasticSearch est impressionnante. En quelques lignes de code, il est possible d’associer les agrégations à un Google Chart, de rendre l’agrégation paramétrable grâce à formulaire, … Utilisez votre imagination.

    Bon courage!

    Share Button

    Leave a Reply

    Your email address will not be published. Required fields are marked *

    You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>

    Protected by WP Anti Spam