Configurer Elasticsearch de manière optimale

Share Button

Read the English version

Ce billet traite d’elasticsearch qui est un moteur de recherche très puissant.
La plus grande difficulté que nous avons rencontrée est que nous ne savions pas comment bien configurer Elasticsearch pour obtenir des résultats de recherche pertinents. Une autre difficulté est (désolé de le dire) que la documentation officielle n’est pas très bien faite. Ok, c’est mon opinion et je suis obligé d’admettre que j’y ai trouvé des informations très utiles, mais parfois, les informations y sont difficiles à trouver.
En conséquence, nous avons mis les mains dans elasticsearch pour comprendre comment il fonctionne. Et nous pensons que nous avons fini par comprendre plein de choses que nous ne savions pas, et nous avons trouvé une (presque?) parfaite configuration.

Mais commençons par le commencement. Comme je l’ai dis, notre principal problème était de trouver des résultats pertinents (pas pertinents pour le moteur de recherche, mais pertinents pour l’utilisateur qui lance la recherche).
Et parfois, les résultats sont plutôt surprenants.

Par exemple, prenons une recherche “PC bon état” qui retournerait les résultats :

  Title : Mon astuce
  Description: Étalez la pâte et ajoutez un soupçon de fêta

  Title: PC assez vieux
  Description: Mais bonne configuration

  Title: Ordinateur bon état
  Description: Lorem ipsum dolor sit amet consectetur adipiscing elit

Avec une configuration “basique” (pas de configuration spécifique ou même une mauvaise configuration), le premier résultat est pertinent pour Elasticsearch, nous verrons pourquoi plus tard.
Mais, en tant qu’utilisateur, le troisième est probablement plus pertinent, et il peut être assez perturbant de ne pas le voir en première place.

Comprendre comment Elasticsearch fonctionne signifie comprendre comment il indexe les documents, et comment il les retrouve.

Les filtres basiques

La configuration la plus basique que vous devriez (en fait je devrais dire “vous devez“) utiliser est de définir des filtres de base.
Leur rôle sera de “normaliser” le texte recherché (et celui qui est indexé) plutôt que d’améliorer la pertinence des résultats. C’est pourquoi ils sont très importants.

Par exemple, si vous n’utilisez pas ces filtres, le texte “Mon prénom est Grégory” sera indexé tel quel.
En conséquence, si quelqu’un lance une recherche avec le texte “gregory”, vous n’êtes pas sûr que le résultat sera retrouvé (à cause de l’accent et de la majuscule).

C’est le rôle des filtres lowercase, qui supprime toutes les majuscules, et asciifolding qui transforme chaque caractère Unicode qui ne fait pas partie de l’ensemble Basic Latin Unicode Block en son équivalent ascii. Plus simplement, il transforme tous les caractères bizarres comme é, à, ç, ð, æ, å, etc.

Le filtre worddelimiter

Le filtre worddelimiter est utilisé pour séparer un “mot” en plusieurs mots. Un petit exemple pour comprendre : imaginez que vous avez fait une faute de frappe dans la phrase “Être ou ne pas être.Telle est la question”. Vous pouvez remarquer qu’il manque un espace après le point. Sans ce filtre, Elasticsearch va indexer “être.Telle” comme un mot unique : “etretelle”. Avec ce filtre, il comprendra qu’il doit indexer “être” et “telle” séparément.

Le filtre stopword

Le filtre stopword consiste en une liste de mots insignifiants qui sont supprimés du document avant de commencer le processus d’indexation. Ce filtre est utilisé pour éviter d’indexer des mots comme “et”, “de”, “à”, “que”, “qui”, etc. Bien sûr, la liste est spécifique à chaque langue, mais pour certaines langues, il existe plusieurs listes (plus ou moins exhaustives) et vous pouvez choisir la liste que vous préférez.

Le filtre snowball

Le filtre snowball est utilisé pour tronquer des mots selon un “stemmer” spécifique. Un stemmer (stem signifie “racine/radical” en anglais) utilise certaines règles pour déterminer la racine d’un mot. Par exemple, les mots “développeur”, “développer”, “développement”, “développeuses”, etc seront transformés en “developpe”.
C’est particulièrement utile pour retrouver un document dont le titre est “Cherche un bon développeur” quand vous recherchez “Cherche quelqu’un qui est bon en développement”. Attention, différents stemmers peuvent retourner des résultats différents (par exemple, la racine de français et française peut être “françai” ou “français” selon le stemmer choisi).

Le filtre elision

Le filtre elision peut être très important dans certaines langues (comme le français) et un peu moins important dans d’autres (comme l’anglais). Il supprimer des “mots” insignifiants avant l’indexation.
Par exemple “j’attends que tu m’appelles” sera indexé comme “attends que tu appelles” (au final, “que” et “tu” seront probablement supprimés par le filtre stopword). Comme vous le voyez, les mots “j’” et “m’” ont été supprimés car ils font partie de la liste du filtre elision (voir l’exemple de configuration ci-dessous).

Définissez vos propres filtres

Vous pouvez et devriez définir vos propres filtres. Si vous jetez un oeil à la fin de ce billet, vous verrez un exemple de configuration. Vous verrez également que le filtre “stopwords” que nous utilisons dans nos analyseurs est un filtre personnalisé de type stopword, pour le Français. Si vous avez besoin d’un autre filtre pour l’anglais, vous pouvez ajouter un autre filtre personnalisé (“stopwords_en” par exemple).

Le tokenizer nGram

Nous avions cherché des exemples de configuration sur le net, et l’erreur que nous avions faite au début était d’utiliser ces configurations directement sans les comprendre.
Cela inclut le tokenizer nGram, qui a un rôle très important.

Par exemple, le texte que nous cherchons “PC bon état” sera coupé en différentes parties. Voici deux exemples :

1er exemple avec la configuration :

 "min_gram" : "2",
 "max_gram" : "3"

Le résultat sera pc, bo, bon, on, et, eta, ta, tat, at

2eme example avec la (meilleure) configuration :

 "min_gram" : "3",
 "max_gram" : "20"

Le résultat sera pc, bon, eta, etat (bon ok, l’exemple est pas super car les mots sont trop petits…)

Pour expliquer un peu plus, le tokenizer ngram coupe chaque mot en chaque combinaison de min_gram à max_gram caractères.
Avec min_gram = 3 and max_gram = 20, “elasticsearch” sera transformé en “ela, elas, elast, …, elasticsearc, elasticsearch”. Et le même processus est répété à partir de la deuxième lettre : “las, last, lasti, lastic, …, lasticsearch”, et ensuite à partir de la 3ème lettre etc… Dans ce cas, le max_gram n’est pas atteint car “elasticsearch” ne contient que 13 caractères, donc si vous spécifiez un max_gram supérieur à 13, le résultat ne changera pas.

Ce tokenizer est plutôt sympathique puisqu’à partir de “bon”, on peut retrouver des résultats qui contiennent “bonne” par exemple. Mais dans le cas de la première configuration, il y a trop de petits mots qui ne sont pas signifiant et qui vont correspondre à plein de trucs. Par exemple, “soupcon” sera découpé entre autres en “pc”, “con” et “on”, qui vont matcher respectivement “pc” (de PC), le “bon” (de bon) et le “on” (de bon).
De même “étalez” matchera “eta” et “ta” (de état), peut être même que le “ez” matchera le “et” (de état).

Bref, vous comprenez mieux pourquoi Elasticsearch trouve le premier résultat très pertinent, alors que pour un utilisateur, ils ne sont pas très pertinents. Augmenter le min_gram et le max_gram permet d’éviter ces effets de bords. Mais il faut également utiliser les filtres vus auparavant. C’est là que les analyseurs entrent en jeu.

Définissez vos propres analyseurs

Vous êtes invités à définir et à utiliser vos propres analyseurs. Un analyseurs est utilisé pour “nettoyer” le document
avant l’indexation, mais aussi pour nettoyer une requête avant une recherche dans l’index. En fait, les filtres ne sont pas utilisés directement mais sont utilisés par les analyseurs. Dans notre exemple ci-dessous, nous avons défini 2 analyseurs : un pour rechercher du texte (custom_search_analyzer), et un autre pour indexer le document avec le tokenizer ngram (custom_analyzer).
Le tokenizer ngram n’est pas utilisé pour transformer la requête car nous ne voulons pas changer le texte recherché. Si l’utilisateur veut retrouver “ordinateur”, avec le tokenizer ngram, nous allons peut être chercher “ordi” ou “ateur”. Ce qui peut conduire à des résultats étranges (comme “ordinal”, “narrateur”, “utilisateur”, …).
Nous pensons que cette configuration est bonne et fonctionne bien. Vous êtes évidemment libre de définir d’autres analyseurs, mais des tests ont prouvé que cette configuration est suffisante (mais perfectible).

Exemple fonctionnel complet (et optimal?)

Cette configuration est celle que nous utilisons la plupart du temps dans nos projets. C’est une base fonctionnelle pour indexer des documents et retrouver des résultats pertinents. Mais seule la moitié du travail est faite… Une autre partie conséquente et importante est de définir correctement votre mapping et de construire correctement la requête qui retrouvera les résultats que vous voulez.
Nous écrirons un autre billet sur ce sujet.

En attendant, amusez-vous et prenez du plaisir à indexer avec cette configuration :

fos_elastica:
  indexes:
    obtao_example_index:
      client: default
      settings:
        index:
          analysis:
            analyzer:
              custom_analyzer :
                type     :    custom
                tokenizer:    nGram
                filter   :    [stopwords, asciifolding ,lowercase, snowball, elision, worddelimiter]
              custom_search_analyzer :
                type     :    custom
                tokenizer:    standard
                filter   :    [stopwords, asciifolding ,lowercase, snowball, elision, worddelimiter]
            tokenizer:
              nGram:
                type:     nGram
                min_gram: 2
                max_gram: 20
            filter:
              snowball:
                type:     snowball
                language: French
              elision:
                type:     elision
                articles: [l, m, t, qu, n, s, j, d]
              stopwords:
                type:      stop
                stopwords: [_french_]
                ignore_case : true
              worddelimiter :
                type:      word_delimiter
      # vous pouvez maintenant utiliser vos analyseurs pour indexer et chercher des documents
      types:
          article :
              mappings:
                  title:
                    boost: 6
                    index_analyzer : custom_analyzer
                    search_analyzer : custom_search_analyzer
                  description: 
                    index_analyzer: custom_analyzer
                    search_analyzer : custom_search_analyzer
                  createdAt   :
                      type: "date"
                  categories    :
                      type: "object"
                      properties:
                          id : ~
                  author :
                      type : "object"
                      properties :
                        id : ~
                        name :
                          index_analyzer : custom_analyzer
                          search_analyzer : custom_search_analyzer
              persistence:
                  driver: orm
                  model: Obtao\BlogBundle\Entity\Article
                  finder: ~
                  provider: ~
                  listener: ~

Dans un prochain billet, nous expliquerons comment construire une requête avec Elastica, et comment configurer votre mapping correctement.

Pour en savoir plus sur Elasticsearch dans un projet Symfony (mais pas que), lisez nos autres articles sur le sujet

Share Button

18 thoughts on “Configurer Elasticsearch de manière optimale

  1. Y aurait-il une raison pour laquelle les filtres ne marcheraient pas ? En recherchant vanilles, je n’ai rien, en recherchant vanille, des résultats, malgré avoir mis cette exacte configuration.

    Ma configuration :
    #Elastic Search
    fos_elastica:
    default_manager: orm
    clients:
    default: { host: localhost, port: 9200 }
    indexes:
    website:
    client: default
    settings:
    index:
    analysis:
    analyzer:
    custom_index_analyzer :
    type : custom
    tokenizer: nGram
    filter : [stopwords, asciifolding ,lowercase, snowball, elision, worddelimiter, french_stem]
    custom_search_analyzer :
    type : custom
    tokenizer: standard
    filter : [stopwords, asciifolding ,lowercase, snowball, elision, worddelimiter, french_stem]
    tokenizer:
    nGram:
    type: nGram
    min_gram: 1
    max_gram: 2
    filter:
    snowball:
    type: snowball
    language: French
    elision:
    type: elision
    articles: [l, m, t, qu, n, s, j, d]
    stopwords:
    type: stop
    stopwords: [_french_]
    ignore_case : true
    worddelimiter :
    type: word_delimiter
    index_name: foodmeup
    types:
    recipe:
    mappings:
    name:
    boost: 5
    index_analyzer : custom_index_analyzer
    search_analyzer : custom_search_analyzer
    nickName:
    index_analyzer : custom_index_analyzer
    search_analyzer : custom_search_analyzer
    content:
    index_analyzer : custom_index_analyzer
    search_analyzer : custom_search_analyzer
    userRecipes:
    type: “nested”
    properties:
    name:
    index_analyzer : custom_index_analyzer
    search_analyzer : custom_search_analyzer
    content:
    index_analyzer : custom_index_analyzer
    search_analyzer : custom_search_analyzer
    tags:
    type: “nested”
    boost: 5
    properties:
    name:
    index_analyzer : custom_index_analyzer
    search_analyzer : custom_search_analyzer
    persistence:
    driver: orm
    model: AppBundle\Entity\FoodAnalytics\Recipe
    repository: AppBundle\Repository\FoodAnalytics\RecipeRepository
    provider: ~
    finder: ~
    listener: ~ # by default, listens to “insert”, “update” and “delete”
    product:
    mappings:
    name: { type: string, boost: 10}
    nickName: { type: string }
    content: { type: string }
    userIngredients:
    type: “nested”
    properties:
    name: { type: string }
    nickName: { type: string }
    content: { type: string }
    tags:
    type: “nested”
    boost: 5
    properties:
    name: { type: string }
    persistence:
    driver: orm
    model: AppBundle\Entity\MarketPlace\Product
    repository: AppBundle\Repository\MarketPlace\ProductRepository
    provider: ~
    finder: ~
    listener: ~ # by default, listens to “insert”, “update” and “delete”

  2. J’utilise votre config pour démarrer ElasticSearch sous SF2/Elastica, et j’ai un problème au niveau des “highlights” qui renvoient des résultats “incohérents”, par exemple j’ai un retour “Une jeune personne bpersonne blonde seersonne blonde se teblonde se tenait en partie double, elle avait choisi.” sur la recherche “blonde”. En trafiquant la config d’indexation, les résultats évoluent, mais ne renvoit jamais le bon “highlight” sur le terme recherché. Auriez-vous une piste par rapport à ce problème? Je précise que ce problème se reproduit depuis une requête ES “pure”, et qu’il n’est donc à priori pas lié à ElasticaBundle.

  3. Bonjour, merci pour cet excellent article. J’ai essayé de tester votre exemple mais je reçois cette erreur:

    [Elastica\Exception\ResponseException]
    IndexCreationException[[myIndex] failed to create index]; nested: IllegalArgumentException[Custom Analyzer [custom_analyzer] failed to find
    filter under name [stopwords]];

    Est-ce que vous avez une idée d’où ça peut venir ?

    Cordialement.

  4. J’ai remarqué un détail, si on utilise le tokenizer “ngram”, par défaut elastica utilise son ngram par défaut et ignore le tokenizer que l’on a défini nous même. Il faut donc utilise un nom personnalisé pour le tokenizer.

    Je ne sais pas si c’est lié à une config chez moi mais en tout cas sans cela, mon tokenizer n’est pas utilisé.

    custom_analyzer :
    type : custom
    tokenizer: my_nGram
    filter : [stopwords, asciifolding ,lowercase, snowball, elision, worddelimiter]
    tokenizer:
    my_nGram:
    type: nGram
    min_gram: 2
    max_gram: 20

Leave a Reply to Antoine Berthelin Cancel 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