Elasticsearch : Recherche avancée et objets imbriqués (nested)

Share Button

Read the English version

Dans un système de données non relationnel, ce qui peut manquer ce sont les jointures.
Heureusement, Elasticsearch propose des solutions pour répondre à différents besoins :

Array Type

Lire l’article sur elasticsearch.org

Comme son nom l’indique : ce peut être un tableau de types natifs (string, int, …) mais aussi d’objets (c’est la base utilisée pour les “objects” et les “nested”).

Voici des exemples d’indexations valides :

{
    "Article" : [
      {
        "id" : 12
        "title" : "Un titre d'article",
        "categories" : [1,3,5,7],
        "tag" : ["elasticsearch", "symfony",'Obtao'],
        "author" : [
            {
                "firstname" : "Francois",
                "surname": "francoisg",
                "id" : 18
            },
            {
                "firstname" : "Gregory",
                "surname" : "gregquat"
                "id" : "2"
            }
        ]
      }
    },
    {
        "id" : 13
        "title" : "Un titre de deuxième article",
        "categories" : [1,7],
        "tag" : ["elasticsearch", "symfony",'Obtao'],
        "author" : [
            {
                "firstname" : "Gregory",
                "surname" : "gregquat",
                "id" : "2"
            }
        ]
      }
}

On y trouve différents Array :

  • Categories : tableau d’integer
  • Tags : tableau de string
  • author : tableau d’objets (inner objects ou nested)

Nous reprécisons ce type “simple”, car il est peut être plus facile/maintenable de stocker une valeur aplatie que de vouloir stocker l’objet complet.
Utiliser une structure de données non relationnelle doit vous pousser à vous poser la question d’un modèle spécifique à votre moteur de recherche :

  • Pour filtrer : Si vous souhaitez uniquement filtrer/rechercher/aggréger sur la valeur textuelle d’un objet. Aplatissez la valeur dans l’objet parent.
  • Pour récupérer une liste d’objets associés à un parent (et n’avoir ni besoin de filtrer, ni besoin d’indexer ces objets) : Stockez simplement la liste des id, et hydratez-les avec Doctrine coté Symfony.

Inner objects

Les inner objects ne sont que l’association d’un objet en JSON dans un parent.
Par exemple les “author” dans l’exemple précédent. Le mapping pour cet exemple pourrait être :

fos_elastica:
    clients:
        default: { host: %elastic_host%, port: %elastic_port% }
    indexes:
        blog :
            types:
                article :
                    mappings:
                        title : ~
                        categories : ~
                        tag : ~
                        author : 
                            type : object
                            properties : 
                                firstname : ~
                                surname : ~
                                id : 
                                    type : integer

Vous pourrez effectuer des Filter ou des Query sur ces “inner objects”.
Par exemple :
query: author.firstname=Francois vous retournera l’article avec l’id 12 (et pas l’article avec l’id 13)

Vous pouvez en lire plus sur le site d’Elasticsearch

Les inner objects sont faciles à mettre en oeuvre. Les documents d’Elasticsearch étant sans schéma par défaut, vous pourriez même ne rien déclarer comme mapping et réussir à les indexer.

La limitation de cette méthode réside dans la manière qu’a Elasticsearch de stocker vos données. En réutilisant toujours l’exemple de départ, voici la représentation interne de nos objets :

[
      {
        "id" : 12
        "title" : "Un titre d'article",
        "categories" : [1,3,5,7],
        "tag" : ["elasticsearch", "symfony",'Obtao'],
        "author.firstname" : ["Francois","Gregory"],
        "author.surname" : ["Francoisg","gregquat"],
        "author.id" : [18,2]
      }
      {
        "id" : 13
        "title" : "Un titre de deuxième article",
        "categories" : [1,7],
        "tag" : ["elasticsearch", "symfony",'Obtao'],
        "author.firstname" : ["Gregory"],
        "author.surname" : ["gregquat"],
        "author.id" : [2]
      }
]

La conséquence est que la requête :

{
  "query": {
    "filtered": {
      "query": {
        "match_all": {}
      },
      "filter": {
        "term": {
          "firstname": "francois",
          "surname": "gregquat"
        }
      }
    }
  }
}

author.firstname=Francois AND surname=gregquat vous retournera le document “12″. Dans le cas d’un inner object, cette query peut être traduite par “Qui a au moins un author.surname = gregquat et au moins un author.firstname=francois”.

Pour remédier à ce problème, vous devrez utiliser les nested.

Les nested

Première différence notable : les nested doivent être déclarés lors du mapping.

La déclaration ressemble à celle d’un object, seul le type change :

fos_elastica:
    clients:
        default: { host: %elastic_host%, port: %elastic_port% }
    indexes:
        blog :
            types:
                article :
                    mappings:
                        title : ~
                        categories : ~
                        tag : ~
                        author : 
                            type : nested
                            properties : 
                                firstname : ~
                                surname : ~
                                id : 
                                    type : integer

Cette fois, la représentation interne sera :

[
      {
        "id" : 12
        "title" : "Un titre d'article",
        "categories" : [1,3,5,7],
        "tag" : ["elasticsearch", "symfony",'Obtao'],
        "author" : [{
            "firstname" : "Francois",
            "surname" : "Francoisg",
            "id" : 18
        },
        {
            "firstname" : "Gregory",
            "surname" : "gregquat",
            "id" : 2
        }]
      },
      {
        "id" : 13
        "title" : "Un titre de deuxième article",
        "categories" : [1,7],
        "tags" : ["elasticsearch", "symfony",'Obtao'],
        "author" : [{
            "firstname" : "Gregory",
            "surname" : "gregquat",
            "id" : 2
        }]
      }
]

Cette fois, la structure de l’objet est conservée.

Les nested possèdent leurs propres filtres, ce qui permet de filtrer par objet nested.
Si nous continuons l’exemple de tout a l’heure (dans la limitation des inner objects) nous pourrons écrire une requête :

{
  "query": {
    "filtered": {
      "query": {
        "match_all": {}
      },
      "filter": {
        "nested" : {
          "path" : "author",
          "filter": {
            "bool": {
              "must": [
                {
                  "term" : {
                    "author.firsname": "francois"
                  }
                },
                {
                  "term" : {
                    "author.surname": "gregquat"
                  }
                }
              ]
            }
          }
        }
      }
    }
  }
}

On peut la traduire cette fois par “Qui a un objet author dont surname vaut ‘gregquat’ et dont firstname vaut ‘francois’”. Cette requête ne nous retournera donc aucun resultat.

Un problème subsiste encore et est pénalisant en cas de gros objets :
lorsque l’on souhaite modifier une valeur du nested, on réindexe tout le document parent (Nested compris). Si les objets sont de grande taille, et mis à jour souvent, l’impact sur les performances peut être important.

Pour résoudre ce problème, vous pouvez utiliser les associations parent/child.

Parent/Child

Les associations parent/child sont ce qui se rapproche le plus d’une OneToMany (un parent, plusieurs enfants).
La relation restera malgré tout hiérarchique, un type d’objet n’est associé qu’à un seul parent et il est impossible de réaliser une ManyToMany.

Nous allons donc attacher notre article à une catégorie d’article :

fos_elastica:
    clients:
        default: { host: %elastic_host%, port: %elastic_port% }
    indexes:
        blog :
            types:
                category : 
                    mappings : 
                        id : ~
                        name : ~
                        description : ~
                article :
                    mappings:
                        title : ~
                        tag : ~
                        author : ~
                    _routing:
                        required: true
                        path: category
                    _parent:
                        type : "category"
                        identifier: "id" # optionnel car la valeur par défaut est id
                        property : "category" # optionnel car la valeur par défaut est celle du type

Lors de l’indexation d’un article, une réference vers la Category sera également indexée (category.id).
Nous pourrons ainsi réindexer séparément Categories et articles tout en conservant les références.

Comme pour les nested, il existe des Filter et Query permettant de rechercher en fonction des parents ou enfants :

  • Has Parent Filter / Has Parent Query : Filter/query sur les champs du parent, retourne les objets fils
    Dans notre cas, nous pourrions filtrer les articles dont la Category parente contient “symfony” dans sa description.
  • Has Child Filter / Has Child Query : Filter/query sur les champs de l’objet fils, retourne des objets parents.
    Dans notre cas, nous pourrions filtrer les Categories pour lesquelles “francoisg” a écrit un article

{
  "query": {
    "has_child": {
      "type": "article",
      "query" : {
        "filtered": {
          "query": { "match_all": {}},
          "filter" : {
              "term": {"tag": "symfony"}
          }
        }
      }
    }
  }
}

Cette requête retournera les Categories qui ont au moins un article taggué “symfony”.

Les requêtes sont données ici en json, mais sont facilement transposables en PHP (avec la lib Elastica).

D’autres lectures peuvent être interessantes à ce sujet :

Share Button

12 thoughts on “Elasticsearch : Recherche avancée et objets imbriqués (nested)

  1. Bonjour,

    Selon le code que vous postez dans Github pour la fonction statsAction

    $results = $this->get('fos_elastica.index.obtao_blog.article')->search($query);

    Je trouve pas la fonction search dans le repository aussi comment vous avez créez l’index
    fos_elastica.index.obtao_blog.article

    • Bonjour,

      L’index (comme les types) est créé automatiquement par FOS lorsque vous le déclarez dans votre configuration

      indexes:
              obtao_blog:
                  client: default
                  types:
                      category: [...]
                      article: [...]
      

      Nous créons l’index “obtao_blog” et les types “category” et “article”
      La fonction search n’est pas dans le repository, elle est présente directement sur les objets index et type fournis par Elastica (Vous pouvez allez voir le code source). Elle permet de faire une recherche brute.

      $results = $this->get(‘fos_elastica.index.obtao_blog.article’)->search($query);
      

      Nous récupérons donc l’index “obtao_blog”, le type “article” et nous réalisons une recherche via Elastica. Sans passer par le finder (et donc le searchRepository).

  2. Bonjour,
    merci pour votre réponse vous avez une idée pour cette erreur

    Key “dateHistogram” for array with keys “” does not exist in ……

    • Oui, on dirait que vous n’avez pas d’aggregation en résultat…
      Dans ces cas la : copiez la requête elastic (que vous trouverez dans le profiler) et lancer la via le plugin head d’ES (dans l’onglet “autre requete”)

      N’hésitez pas à la poster ici en cas de soucis

      • bonjour,
        Je m’excuse de vous déranger
        voici la requète:

        {“query”:{“match_all”:{}},”filter”:{“bool”:{“must”:[{"range":{"publishedAt":{"gte":"2013-05-10T00:00:00Z","lte":"2014-05-10T23:59:59Z"}}}]}},”aggs”:{“dateHistogram”:{“date_histogram”:{“field”:”publishedAt”,”interval”:”month”,”format”:”dd-MM-YYYY”},”aggs”:{“image”:{“terms”:{“field”:”image”}}}}},”size”:0}

        pour le Json retourner honnêtement je n’ai rien compris puisque des anciens indexs qui sont apparus et que je n’utilise plus maintenant

        • Bonsoir,
          je m’exuse mais je trouve pas ou poster mon problème sauf ici j’ai fait un var_dump pour le $query
          j’ai eu ceci
          object(Elastica\Query)#40 (3) { ["_params":protected]=> array(4) { ["query"]=> array(1) { ["match_all"]=> object(stdClass)#43 (0) { } } ["filter"]=> array(1) { ["bool"]=> array(1) { ["must"]=> array(1) { [0]=> array(1) { ["range"]=> array(1) { ["publishedAt"]=> array(2) { ["gte"]=> string(20) “2013-05-11T00:00:00Z” ["lte"]=> string(20) “2014-05-11T23:59:59Z” } } } } } } ["aggs"]=> array(1) { ["dateHistogram"]=> array(2) { ["date_histogram"]=> array(3) { ["field"]=> string(11) “publishedAt” ["interval"]=> string(5) “month” ["format"]=> string(10) “dd-MM-YYYY” } ["aggs"]=> array(1) { ["url"]=> array(1) { ["terms"]=> array(1) { ["field"]=> string(3) “url” } } } } } ["size"]=> int(0) } ["_suggest":protected]=> int(0) ["_rawParams":protected]=> array(0) { } } array(0) { }

          svp j’en ai besoin

          • Bonjour,
            La requête “semble” correcte syntaxiquement (pas d’erreur à l’exécution dans plugin/head). Je pense qu’il y a quand même un soucis au niveau de l’aggréation “image” qui est un niveau trop bas à mon avis.
            Pour votre erreur, il s’agit peut être tout simplement d’une erreur dans l’exploitation du retour d’elasticsearch?
            Pour les anciens index retournés, réindexez vos contenus pour être sûr de bien être à jour :

            app/console fos:elastica:populate
  3. Bonjour,

    J’ai essayé d’implémenter ce que vous m’avez conseillé, et la je me retrouve avec une erreur, Invalid parameter. Has to be array or instance of Elastica\Filter

    mes codes:
    fos_elastica:
    clients:
    default: { host: %elastic_host%, port: %elastic_port% }
    indexes:
    hobiiz:
    client: default
    types:
    activity:
    mappings:
    id:
    type: integer
    activitydate:
    type: date

    listeActivity:
    type : nested
    properties :
    activity : ~
    id :
    type : integer
    persistence:
    driver: orm
    model: Hobiiz\ActivityBundle\Entity\Activity
    finder: ~
    provider: ~
    listener: ~

    mon repositorysearch:
    addMust(new \Elastica\Filter\Range(‘activitydate’,
    array(
    ‘gte’ => \Elastica\Util::convertDate($activitySearch->getDateFrom()->getTimestamp()),
    ‘lte’ => \Elastica\Util::convertDate($activitySearch->getDateTo()->getTimestamp())
    )
    ))

    ->addMust(
    new \Elastica\Query\Terms(‘activity.listeactivity.activity’, array($activitySearch->getListeActivity()))
    );

    $filtered = new \Elastica\Query\Filtered($baseQuery, $boolFilter);

    $query = \Elastica\Query::create($filtered);

    return $this->find($query);
    }

    • J’ai changé mon indexation, voici l’indexation d’une activité:
      {

      _index: hobiiz
      _type: activity
      _id: 6
      _score: 1
      _source: {
      id: 6
      activitydate: 2014-09-25T00:00:00+02:00
      intitule: null
      listeActivity: {
      activity: football
      categories: [
      collectif
      ]
      }
      departement: {
      departementNom: Aisne
      departementCode: 02
      }
      villesFrance: {
      villeDepartement: 02
      villeNomReel: Abbécourt
      location: {
      lat: 49.6
      lon: 3.18333
      }
      }
      }

      }
      voici mon repositorysearch:
      public function search(ActivitySearch $activitySearch)
      {

      // we create a query to return all the articles
      // but if the criteria title is specified, we use it
      if ($activitySearch->getIntitule() != null) {
      $query = new \Elastica\Query\Match();
      $query->setFieldQuery(‘activity.intitule’, $activitySearch->getIntitule());
      $query->setFieldFuzziness(‘activity.intitule’, 0.7);
      $query->setFieldMinimumShouldMatch(‘activity.intitule’, ’80%’);
      //
      } elseif ($activitySearch->getListeActivity() != null) {
      $query = new \Elastica\Query\Match();
      $query->setFieldQuery(‘activity.listeActivity.activity’, $activitySearch->getListeActivity());
      $query->setFieldFuzziness(‘activity.listeActivity.activity’, 0.7);
      $query->setFieldMinimumShouldMatch(‘activity.listeActivity.activity’, ’80%’);
      //
      } else {
      $query = new \Elastica\Query\MatchAll();
      }

      $baseQuery = $query;

      // then we create filters depending on the chosen criterias
      $boolFilter = new \Elastica\Filter\Bool();

      $boolFilter->addMust(new \Elastica\Filter\Range(‘activitydate’,
      array(
      ‘gte’ => \Elastica\Util::convertDate($activitySearch->getDateFrom()->getTimestamp()),
      ‘lte’ => \Elastica\Util::convertDate($activitySearch->getDateTo()->getTimestamp())
      )
      ));

      $filtered = new \Elastica\Query\Filtered($baseQuery, $boolFilter);

      $query = \Elastica\Query::create($filtered);

      return $this->find($query);
      }

      j’ai simplifié mon formulaire pour mes tests, j’ai les champ dateto et date from, un champ text intitulé et un autre champ text listeactivity. la recherche en tapant un intitulé fonctionne bien, en revanche si je tape football dans le champ listeActivity aucune activité ne m’est retourné. alors que l’activity indexé donné en exemple plus haut devrait m’être retourné. je ne vois pas ou est l’erreur.

      merci

  4. Bonjour

    Merci pour l’article. j’essai de faire quelque chose de similaire mais mes données proviennent d’une base de données. Si on suppose que dans votre example on ai une table article et une table auteur avec une relation 1-N. est’il possible d’indexer ces données depuis la base de données directement tout en conservant votre résultat ( un tableau d’objet “author” dans le document ) ?

    j’ai fait un post sur stackoverflow pour exposer plus en détail mon probléme :
    Bonjour

    Merci pour l’article. j’essai de faire quelque chose de similaire mais mes données proviennent d’une base de données. Si on suppose que dans votre example on ai une table article et une table auteur avec une relation 1-N. est’il possible d’indexer ces données depuis la base de données directement tout en conservant votre résultat ( un tableau d’objet “author” dans le document ) ?

    j’ai fait un post sur stackoverflow pour exposer plus en détail mon probléme :
    h t t p ://stackoverflow.com/questions/27063028/elasticsearch-mapping-sub-object-from-database-view

    Merci d’avance

    Merci d’avance

      • Merci d’avoir pris le temps de répondre.

        J’utilise oracle et elasticsearch sans rien entre les deux.
        J’ai crée une vue dans oracle qui contient toutes les données dont j’ai besoin.

        Dans elasticsearch j’ai crée un mapping avec des sous objects et j’utilise la vue pour récupérer les données.
        Mais quand je requete elasticsearch les données sont à plat. par exemple si un auteur à plusieurs articles ( un article a comme propriété par exemple une date, titre, sujet) j’aurai un tableau contenant toutes dates, un tableau contenant tout les titres, un tableau contenant tout les sujet.
        J’aurais plutôt besoin que elasticsearch me retourne un tableau d’objet article afin de conserver la concordance des données.

        j’ai (essayé) d’exposer plus en détail mon problème ici :
        h t t p://stackoverflow.com/questions/27063028/elasticsearch-mapping-sub-object-from-database-view

        je ne vois pas ou renseigné la propriété persistence. j’utilise elasticsearch avec des requetes curl uniquement.

        Merci d’avance.

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