af83

Quelques bases pour préparer une indexation dans Elasticsearch

TL;DR

Si le mapping dynamique d'Elasticsearch est utile pour pouvoir indexer et requêter des documents sans attendre, il est très rapidement utile de spécifier explicitement et plus finement la structure et le mode d'indexation des données en fonction des types de recherches envisagés.

Pour cela, nos premiers outils seront les analyzers, les multi fields et les dynamic templates. Nous en ferons une succincte visite guidée.

Introduction

Elasticsearch étant extrêmement simple à mettre en place, on peut très rapidement profiter de la grande richesse des fonctionnalités qu'il propose. Et ce même pour des projets de volumétrie modeste où les questions de montée en charge et de passage à l'échelle ne se poseraient pour où en tout cas ne concerneraient pas la partie recherche.

Mettons nous en route…

Si Elasticsearch n'est pas encore installé sur la machine, nous pouvons l'installer en un rien de temps. Par exemple en utilisant notre petit outil, desi

$ # Installons ES et vérifions que tout est OK
$ gem install desi && desi install 0.90.0 && desi start && desi status
OK. Elastic Search cluster 'elasticsearch' (v0.90.0) is running on 1 node(s) with status green
$ # Pas de panique si le status est à "yellow", cela n'a rien de dramatique
$ # sur une instance locale avec un seul node ES lancé !

Nous pouvons à présent indexer des documents directement en faisant des requêtes POST sur l'instance d'Elasticsearch que nous venons de lancer, accessible sur le port 9200.

Admettons que nous voulons indexer des billets de blog.

Un billet de blog fascinant

Commençons par un billet fort, marqué par des pensées provocantes et inspirées.

$ curl -X POST http://localhost:9200/myblog/posts/1 -d '{
  "title": "Devinette passionnante",
  "content": "Question : Quelle est la différence entre une autruche ? Réponse : Elle ne sait ni voler !",
  "tags": ["devinette", "Kolossale Finesse"],
  "created_at": "2013-04-26T11:11:17+02:00",
  "updated_at": "2013-04-26T11:11:17+02:00",
  "secret_note": "C'est vraiment du grand n'importe quoi ce billet !"
}'
{"ok":true,"_index":"myblog","_type":"posts","_id":"1","_version":1}

Puis un commentaire subtil qui amène la conversation sur des cimes encore inexplorées…

$ cat <<EOD > /tmp/commentaire.json
{
  "post_id": 1,
  "content": "C'était vraiment très intéressant !",
  "created_at": "2013-04-26T11:11:20+02:00",
  "updated_at": "2013-04-26T11:11:20+02:00"
}
EOD
$ curl -X POST http://localhost:9200/myblog/comments/1 -d @/tmp/commentaire.json
{"ok":true,"_index":"myblog","_type":"comments","_id":"1","_version":1}

Après un petit refresh pour nous assurer que les documents sont bien disponibles pour la recherche, nous constatons que nous pouvons effectivement récupérer le contenu que nous venons d'indexer par une recherche full-text :

$ curl -X POST http://localhost:9200/myblog/_refresh
{"ok":true,"_shards":{"total":10,"successful":4,"failed":0}}

$ curl -X POST 'http://localhost:9200/myblog/posts/_search?q=autruche&pretty=true'
{
  "took" : 6,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 1,
    "max_score" : 0.21875,
    "hits" : [ {
      "_index" : "myblog",
      "_type" : "posts",
      "_id" : "1",
      "_score" : 0.21875, "_source" : {
        "title": "Devinette passionnante",
        "content": "Question : Quelle est la différence entre une autruche ? Réponse : Elle ne sait ni voler !",
        "tags": ["devinette", "kolossale finesse"],
        "created_at": "2013-04-26T11:11:20+02:00",
        "updated_at": "2013-04-26T11:11:20+02:00",
        "secret_note": "C'est vraiment du grand n'importe quoi ce billet !"
}'
      }
    } ]
  }
}

Tout ceci est bel et bon. Nous avons pu indexer des documents et les retrouver.

There's the rub : configurer le bon analyseur

Que se passe-t-il maintenant si nous faisons néanmoins une deuxième recherche sur « était » pour retrouver notre magnifique commentaire ?

$ curl "http://localhost:9200/myblog/posts/_search?q=était&pretty=true"
{
  "took" : 8,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 0,
    "max_score" : null,
    "hits" : [ ]
  }
}

Damned! Rien du tout. Quel est le problème ? Tout simplement que la chaîne que nous avons fournie, « C'était » n'a pas dû être analysée tel que nous le pensions par l'analyseur standard.

Mais comment Elasticsearch a-t-il analysé et indexé les documents que nous lui avons fourni, au juste ? Il suffit de le lui demander…

$ curl http://localhost:9200/myblog/_mapping?pretty=true
{
  "myblog" : {
    "posts" : {
      "properties" : {
        "content" : {
          "type" : "string"
        },
        "created_at" : {
          "type" : "date",
          "format" : "dateOptionalTime"
        },
        "tags" : {
          "type" : "string"
        },
        "title" : {
          "type" : "string"
        },
        "updated_at" : {
          "type" : "date",
          "format" : "dateOptionalTime"
        }
      }
    },
    "comments" : {
      "properties" : {
        "content" : {
          "type" : "string"
        },
        "created_at" : {
          "type" : "date",
          "format" : "dateOptionalTime"
        },
        "post_id" : {
          "type" : "long"
        },
        "updated_at" : {
          "type" : "date",
          "format" : "dateOptionalTime"
        }
      }
    }
  }
}

Comme on peut voir la détection automatique du type de champ — le dynamic mapping — a plutôt bien fonctionné. Cependant, pour ce qui est des chaînes de caractères, il nous manque encore des informations.

Utilisons l'API analyze pour avoir plus de détails :

$ curl 'http://localhost:9200/myblog/_analyze?pretty=true' -d "C'était"
{
  "tokens" : [ {
    "token" : "c'était",
    "start_offset" : 0,
    "end_offset" : 5,
    "type" : "<ALPHANUM>",
    "position" : 1
  } ]
}

Nous pouvons constater 3 choses :

  1. Cette chaîne n'a pas été découpée en deux tokens, « c' » et « était », comme nous aurions pu nous y attendre.
  2. La capitale « C » a été passée en bas de casse.
  3. L'accent aigu sur « était » est demeuré tel quel

Si nous avions utilisé l'analyseur french, le résultat aurait été légèrement différent :

$ curl 'http://localhost:9200/myblog/_analyze?analyzer=french&pretty=true' -d "C'était"
{
  "tokens" : [ {
    "token" : "c'etait",
    "start_offset" : 0,
    "end_offset" : 7,
    "type" : "<ALPHANUM>",
    "position" : 1
  } ]

On constate que cet analyseur a procédé à de l'asciifolding, de sorte que les accents ont disparu du texte d'origine. Voilà qui est fréquemment utile.

Si nous utilisons une autre combinaison de tokenizers et de token filters, nous pouvons obtenir plus intéressant encore :

$ curl 'http://localhost:9200/myblog/_analyze?tokenizer=letter&filters=asciifolding,lowercase&pretty=true' -d "c'était"
{
  "tokens" : [ {
    "token" : "c",
    "start_offset" : 0,
    "end_offset" : 1,
    "type" : "word",
    "position" : 1
  }, {
    "token" : "etait",
    "start_offset" : 2,
    "end_offset" : 7,
    "type" : "word",
    "position" : 2
  } ]
}

Nous obtenons bien 2 tokens. De sorte que nous pourrions effectivement retrouver en cherchant sur « était ». Ce ne serait cependant pas la solution idéale car le premier token « c » est tout à fait inutile. Il serait préférable que les articles — qui ne présentent aucun intérêt particulier pour la recherche — ne soient pas indexés.

Nous utiliserons pour cela le token filter elision, que nous avons déjà eu l'occasion de voir dans un précédent billet.

Le nuage de tags

Avant d'en venir à la mise en place de cet analyseur, commençons par examiner si nous pouvons faire des facettes sur les tags pour générer ce nuage de tags que le monde nous enviera.

curl 'http://localhost:9200/myblog/_search?pretty=true&size=0' -d '
{
  "query" : {
      "match_all" : {  }
  },
  "facets" : {
      "tag" : {
          "terms" : {
              "field" : "tags",
              "size" : 10
          }
      }
  }
}
'

Seules les facettes nous intéressent ici, nous passons donc le paramètre size=0 pour éviter de récupérer les documents.

La réponse que nous obtenons n'est pas tout à fait celle que nous attendions.

{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 2,
    "max_score" : 1.0,
    "hits" : [ ]
  },
  "facets" : {
    "tag" : {
      "_type" : "terms",
      "missing" : 1,
      "total" : 3,
      "other" : 0,
      "terms" : [ {
        "term" : "Kolossale",
        "count" : 1
      }, {
        "term" : "Finesse",
        "count" : 1
      }, {
        "term" : "devinette",
        "count" : 1
      } ]
    }
  }
}

La facette Kolosalle Finesse a été scindée en deux. En fait, idéalement, nous voudrions pouvoir l'indexer :

  • en utilisant les règles vues ci-dessus pour pouvoir, le cas échéant, faire une recherche full-text classique dessus en tant que chaîne de caractères
  • quasiment non traitée (avec conversion de casse et suppression des accents mais pas de scission sur les espaces)

Pour définir cet analyseur (que nous nommerons, de façon originale, tag_analyzer) la combinaison de tokenizer et de token filters suivante pourrait être envisagée :

curl 'http://localhost:9200/myblog/_analyze?tokenizer=keyword&filters=asciifolding,lowercase&pretty=true' -d "Kolösalle Finesse"
{
  "tokens" : [ {
    "token" : "kolosalle finesse",
    "start_offset" : 0,
    "end_offset" : 17,
    "type" : "word",
    "position" : 1
  } ]
}

Mais comment donc indexer le contenu du même champ de deux façons différentes ? Dieu merci, nous n'aurons pas besoin de tripatouiller notre document source pour ce faire. Il suffira d'utiliser les multi-fields.

Mettre à profit les multi_fields pour préparer ses données lors de l'indexation

Les multi-fields conviennent parfaitement à notre besoin. Ils permettent de définir pour un champ donné une ou plusieurs indexations alternatives de son contenu, appliquées automatiquement et requêtables séparément. Nous n'aurons pas à modifier la source du document que nous enverrons à Elasticsearch et, réciproquement, elle nous sera renvoyée inchangée.

Ainsi, dans le cas qui nous occupe, cela donnerait pour la portion du mapping correspondant au champ tags quelque qui ressemblerait dans les grandes lignes à :

{
    "post" : {
        "properties" : {
          
            "tags" : {
                "type" : "multi_field",
                "fields" : {
                    "tags" : {"type" : "string", "index" : "analyzed", "analyzer": "custom_french_analyzer"},
                    "tel_quel" : {"type" : "string", "index" : "analyzed", "analyzer": "tag_analyzer"}
                }
            }
          
        }
    }
}

Nous avons donc défini deux multi-fields pour le champ tags. Si nous nommons un multi-field avec le nom du champ d'origine, comme nous l'avons fait ici pour le premier, les paramètres correspondants seront appliqués au champ pris en compte lorsqu'on requête tags tout court (si je fais q=tags:devi* par exemple). Tout autre multi-field — ici tel_quel — va se comporter comme une sorte de sous-champ, que je vais pouvoir cibler lors du requêtage en utilisant une notation pointée (tags.tel_quel).

(À noter que les "sous-champs" définis dans les multi-fields ne seront utilisés lors d'une requête que si celle-ci se fait explicitement sur eux.¹ Il n'y a donc pas de risque d'effets secondaires intempestifs autres qu'une augmentation de la quantité de données indexées.)

Création de l'index : dynamic_templates + multi_fields = <3

Récapitulons. Nous voulons :

  • utiliser un analyseur optimisé pour une recherche full-text sur le français pour les champs title et content
  • indexer les différentes valeurs contenues dans le tableau tags à la fois en tant que texte français comme pour title et content mais également avec l'analyseur tag_analyzer évoqué ci-dessus.
  • faire en sorte que le champ secret_notes, et de manière générale tout champ commençant par secret ne fasse pas l'objet de recherche à moins d'être appelé explicitement (uniquement sur le back-office par exemple)

Pour gérer le cas des champs title et content, nous ferons appel aux dynamic templates.² Ceux-ci permettent de définir pour un type donné un tableau de règles à utiliser pour déterminer comment indexer un champ donné.

Utiliser les dynamic_templates pour un cas aussi simple, est un peu prendre un bazooka pour écraser un moucheron, il faut bien le reconnaître. Mais pour cet exercice, nous nous permettons toutes les folies.

Voyons voir ce que cela donnerait concrètement :

Commençons par supprimer l'index créé précédemment avant de le recréer à nouveau.

$ curl -X DELETE http://localhost:9200/myblog
$ curl -X POST http://localhost:9200/myblog -d '{
  "settings": {
    "analysis": {
      "filter": {
        "elision": {
          "type": "elision",
          "articles": ["l", "m", "t", "qu", "n", "s", "j", "d"]
        }
      },
      "analyzer": {
        "custom_french_analyzer": {
          "tokenizer": "letter",
          "filter": ["asciifolding", "lowercase", "french_stem", "elision", "stop"]
        },
        "tag_analyzer": {
          "tokenizer": "keyword",
          "filter": ["asciifolding", "lowercase"]
        },
      }
    }
  },
  "mappings": {
    "posts": {
      "dynamic_templates": [
        {
          "secrets": {
            "match": "secret_*",
            "match_mapping_type": "string",
            "mapping": {
              "analyzer": "custom_french_analyzer",
              "include_in_all": false
            }
          }
        },
        {
          "strings": {
            "match": "*",
            "match_mapping_type": "string",
            "mapping": {
                "type": "string",
                "index": "analyzed",
                "analyzer": "custom_french_analyzer"
            }
          }
        }
      ],
      "properties": {
          "tags": {
              "type" : "multi_field",
              "fields" : {
                  "tags" : {"type" : "string", "index" : "analyzed", "analyzer": "custom_french_analyzer"},
                  "tel_quel" : {"type" : "string", "index" : "analyzed", "analyzer": "tag_analyzer"}
              }
          }
      }
    },
    "comments": {
      "dynamic_templates": [
        {
          "strings": {
            "match": "*",
            "match_mapping_type": "string",
            "mapping": {
                "type": "string",
                "index": "analyzed",
                "analyzer": "custom_french_analyzer"
            }
          }
        }
      ]
    }
  }
}

Qu'avons-nous défini exactement ici ?

  • Deux analyseurs, custom_french_analyzer et tag_analyzer au niveau de l'index.²

  • Un tableau de dynamic_templates pour les posts. La première entrée du tableau ne s'applique qu'aux champs dont le nom commence par secret_* et qu'Elasticsearch a détecté comme étant des strings. Le mapping appliqué consiste juste à définir l'anayseur utilisé et surtout à spécifier, via include_in_all, que ce champ ne peut être examiné que s'il est appelé spécifiquement.³ Il convient de noter que si deux règles peuvent s'appliquer à un champ donné, c'est la première règle pertinente par ordre de définition qui va s'appliquer. Il convient donc de mettre les règles les plus spécifiques en premier.

  • Des propriétés explicites pour le champ tags de posts. En l'occurrence, nous avons défini ce champ comme un multi-field comme évoqué précédemment. On peut à cette occasion constater que bien que le contenu JSON qui sera injecté contienne un tableau de chaînes de caractères, nous le définissons juste comme un champ de type string. Une caractéristique très appréciable d'Elasticsearch est qu'il s'embête pas / ne nous embêtes pas avec la cardinalité des champs. Seul lui importe le type effectif du contenu. Le type array n'a donc même pas besoin d'exister.

  • Des dynamic_templates pour les comments comme nous l'avons fait pour les posts.

Conclusion

Nous n'avons fait qu'effleurer les différentes possibilités qu'offre Elasticsearch pour préparer l'indexation de nos données. La solution retenue ici est d'ailleurs déjà un tantinet plus sophistiquée que strictement nécessaire pour répondre aux besoins simplissimes que nous nous sommes fixés jusqu'à présent. Nous aurons cependant l'occasion de présenter des cas de figure où toutes ces fonctionnalités prendront tout leur sens. D'ici là, nous vous invitons à vous plonger dans la documentation d'Elasticsearch pour approfondir ce que nous n'avons que survolé ici.

Notes

¹ Plus précisément, le paramètre include_in_all de ces champs est toujours assigné à false. Voir la documentation pour plus de précisions.

² À noter que si nous avions nommé notre analyseur pour le français default au lieu de custom_french_analyzer, nous n'aurions pas eu besoin de plus pour qu'il soit appliqué automatiquement à toutes les chaînes de caractères indexées. Ce choix aurait été tout à fait indiqué dans ce cas précis, mais d'évidence plus contestable dans le cas où un même billet contiendrait des contenus de langues différentes.

³ Nous n'avons pas ici pris la peine de préciser "index": "analyzed" car c'est la valeur par défaut. Nous aurions également pu l'enlever des autres entrées où elle apparaît.

blog comments powered by Disqus