Utilisation d'Elasticsearch

Publié le 7 janvier 2015 par Nicolas Cavigneaux | outils

Cet article est publié sous licence CC BY-NC-SA

Depuis quelque temps j’utilise intensivement Elasticsearch et les gems dédiés pour mettre en place des moteurs de recherche textuel non seulement puissants mais aussi très réactifs.

Pour ceux qui ne le connaissent pas, Elasticsearch est un moteur de recherche textuel très à la mode qui permet de faire des recherche très complexes sur une grande quantité de données en une fraction de seconde.

De grands noms comme Github ou encore Soundcloud l’utilisent de manière intensive pour proposer à leur utilisateurs des possibilités de recherches évoluées mais surtout ultra-réactives.

Pour ce qui nous concerne, nous allons voir comment l’utiliser dans le cadre d’un projet Ruby on Rails via deux gems qui sont elasticsearch-ruby et elasticsearch-rails.

Le premier permet de créer un « pont » entre le service Elasticsearch (le serveur et son API) et votre code Ruby. Le deuxième sert quant à lui à intégrer facilement les possibilités d’indexation et de recherche à vos modèles Rails.

Installation

Pour utiliser Elasticsearch au sein de votre application Rails il vous faudra bien sûr avoir installé le service Elasticsearch. Sous Mac OS X en utilisant homebrew un simple brew install elasticsearch suffit.

Il faudra ensuite ajouter les deux gems précédemment cités à votre Gemfile :

gem 'elasticsearch-model'
gem 'elasticsearch-rails'

Ensuite un simple bundle install et vous êtes parés à utiliser Elasticsearch.

Passons maintenant à l’utilisation basique à travers ces gems.

Paramètrage basique d’un modèle

Pour pouvoir rechercher des documents dans Elasticsearch il faut préalablement indexer les-dits documents. Dans l’idée il faut donc faire en sorte que les données soient indexées dans le service Elasticsearch à chaque ajout ou modification d’une entrée en base.

Pour ce faire, le gem elasticsearch-rails fournit des callbacks basiques qui se chargeront de faire ça pour nous de manière transparente. On peut donc, dans notre modèle à indexer, ajouter les lignes suivantes :

include Elasticsearch::Model
include Elasticsearch::Model::Callbacks

Le simple fait d’ajouter ces deux lignes va nous permettre d’avoir accès à un certain nombre de méthodes relatives à Elasticsearch, notamment la méthode de classe #import qui permet de demander l’indexation des objets actuellement disponible dans la table. C’est le travail du premier include.

Le deuxième include va lui mettre en place des callbacks qui vont s’assurer que chaque instance de modèle verra son indexation dans Elasticsearch mise à jour à chaque sauvegarde (callback after sur le save).

On peut donc avec ses deux simples lignes avoir une recherche via Elasticsearch tout à fait fonctionnelle. Par défaut, le gem va indexer tous les champs de la table correspondant au modèle. Vous pourrez donc effectuer des recherches sur l’ensemble des attributs persistés de votre modèle.

C’est ici le fonctionnement le plus basique qu’on peut mettre en place via ces gems. Dans de nombreux cas ce sera suffisant à la mise en place d’une recherche basique.

Pour lancer l’import (indexation) des objets de votre modèle, vous pouvez depuis une console lancer la commande suivante :

MonModel.import

Le gem elasticsearch-rails propose aussi des tâches Rake qu’il est possible d’ajouter à votre jeu de tâches en créant un fichier dans lib/tasks avec pour contenu :

require 'elasticsearch/rails/tasks/import'

L’ajout de ce fichier rake vous fera bénéficier de deux tâches :

  • rake elasticsearch:import:all
  • rake elasticsearch:import:model

La première permet d’importer tous les modèles dans Elasticsearch, la seconde quant à elle cible uniquement le modèle que vous préciser via la variable d’environnement CLASS.

Recherche basique

Voyons maintenant ce que nous pouvons faire avec cette configuration minimale. Tout d’abord le gem elasticsearch-model va mettre à disposition une méthode de classe search. Cette méthode peut être appelée de deux façons différente :

  • en lui passant une chaîne qui sera utilisée comme « query string » sur l’ensemble des attributs du modèle
  • en lui passant un Hash qui reprend la sémantique de requêtage d’Elasticsearch

Recherche simple de type « query string »

Dans sa forme la plus simple, avec une simple chaîne donc, voici ce qu’on pourrait faire :

response = MonModel.search "Synbioz"

response.results.total
# => Nombre de réponses qui correspondent à la recherche

response.results
# => Hash contenant les résultats (avec score) directement issues de la réponse d'Elasticsearch #

response.records
# => Tableau contenant l'ensemble des objets ActiveRecord correspondant à la recherche

On peut donc faire de simple recherche textuelle (un peu comme vous le feriez sur Google). Ces recherches peuvent inclure de la logique booléenne.

Ce qui nous est retourné dans .results n’est ni plus, ni moins que la réponse du service Elasticsearch à ceci prêt que le JSON a été transformé en un Hash Ruby. C’est très pratique puisque ça vous donne accès au score de chaque réponse, tous les attributs indexés sont présents, la majorité des informations dont vous pouvez avoir besoin sont là. C’est intéressant car ce n’est ici qu’un Hash Ruby, les réponses ne sont pas des objets ActiveRecord instanciés ce qui permet d’améliorer nettement les temps de réponse et la consommation mémoire sur des requêtes avec un grand nombre de réponses.

Si toutefois vous aviez besoin d’accéder à la correspondance de la réponse en objet ActiveRecord instancié (pour accéder à ses relations, le manipuler, le sauver, …) vous pouvez passer par la forme .records qui là vous retourne un tableau d’objets ActiveRecord.

Recherche via le DSL Elasticsearch

Dans bien des cas vous ne voudrez pas rechercher sur tous les attributs, vous souhaiterez utiliser des filtres, …

Dans ce cas il ne sera plus possible de passer par une simple « query string », vous devrez requêter Elasticsearch via son langage spécifique (DSL JSON). Fort heureusement ça reste très simple, tout ce que vous avez à faire est de créer un Hash qui suit les conventions du langage d’Elasticsearch puis de le passer à la méthode #search. Cette méthode se chargera pour vous de convertir le Hash Ruby en JSON et de le transmettre au service Elasticsearch.

Vous pourrez par exemple faire quelque chose comme :

MonModel.search {
  query: {
    multi_match: {
      query: params[:query],
      fields: ["name", "description"]
    }
  },
  filter: {
    term: { author_id: params[:author_id] }
  }
}

Nous avons donc ici mis en place une recherche plus ciblée utilisant le langage dédié du service Elasticsearch. Si vous ne connaissez pas ce langage, sachez que c’est un pré-requis à une utilisation évoluée d’Elasticsearch. Je vous invite donc à vous référer à la documentation dédiée.

La requête ci-dessus va rechercher la chaîne passée dans le paramètre query mais uniquement sur les attributs name et description du modèle. Elle va également utiliser un filtre pour ne prendre que les résultats pour lesquels le author_id est égal à ce qui a été passé dans le paramètre author_id.

Nous pouvons donc déjà aller assez loin dans les recherches et leur traitement avec cette configuration minimale. Le DSL de requêtage livré par Elasticsearch est très puissant.

Il s’avère qu’en pratique, vous aurez parfois besoin d’une indexation personnalisée, spécifique à vos besoins et qui notamment va respecter les particularités de la langue dans laquelle vous indexez vos documents. Voyons comment faire.

Une indexation plus adaptée au français

Chaque langue a ses propres règles et ses spécificités qui souvent sont à prendre en compte lorsqu’on souhaite découper un texte pour créer des unités logiques.

Elasticsearch prévoit ces particularités et permet de régler de manière très fine la façon dont on veut découper et indexer les documents. Si vous souhaitez faire de la recherche textuelle évoluée pour par exemple pouvoir :

  • retrouver un mot qui comporte un article que vous le passiez ou non dans la recherche
  • retrouver un mot depuis sa racine ou un dérivé
  • ne pas prendre en compte la casse
  • ne pas prendre en compte les accents

il vous faudra alors paramétrer de manière plus fine votre modèle. Il ne suffira plus de se baser sur les filtres livrés de base par la gem, il faudra définir vous même les règles de découpage.

Les possibilités sont très larges et bien qu’on puisse absolument tout personnaliser, Elasticsearch est livré avec un certain nombre de possibilités de configuration par défaut. Si vous voulez explorer toutes les possibilités je vous invite à vous référer à la section idoine de la documentation

En ce qui nous concerne nous ne verrons que la configuration que j’ai mise en place sur mes projets pour gérer la langue française d’une manière qui me semble optimale.

Settings

Tout d’abord la section settings qui définit comment devrons être découpés les documents, ce qui devra être transformé, ignoré, les délimiteurs bref tout ce qui va régir la façon dont Elasticsearch va lire et analyser vos documents pour en faire une liste de tokens.

Voici la configuration que j’utilise actuellement :

settings analysis: {
  analyzer: {
    default: {
      tokenizer: "standard",
      filter: ["snowball", "lowercase", "asciifolding", "stopwords", "elision", "worddelimiter"],
      char_filter: ["html_strip"]
    }
  },
  filter: {
    snowball: {
      type: 'snowball',
      language: 'French'
    },
    elision: {
      type: 'elision',
      articles: %w{l m t qu n s j d}
    },
    stopwords: {
      type: 'stop',
      stopwords: '_french_',
      ignore_case: true
    },
    worddelimiter: {
      type: 'word_delimiter'
    }
  }
}

La section analyser permet de définir différents types d’analyses, j’ai ici écrasé la configuration utilisée par défaut (default) mais il est possible d’en ajouter par exemple pour en avoir une par langue supportées.

Ne prenant en compte que le français j’ai fait le choix de paramétrer la section default qui est celle utilisée par défaut si on ne le précise pas lors du mapping que nous verrons par la suite.

Typiquement lorsque vous supportez plusieurs langues, vous avez besoin d’un analyser par langue chacun utilisant ses propres filtres et règles.

tokenizer

L’analyser que nous avons mis en place contient plusieurs sections. Tout d’abord le tokenizer qui définie la façon dont une chaîne doit être découpée en « mots-clés ». Le tokenizer standard a été conçu pour être particulièrement adapté aux langues européennes.

Il en existe d’autres décrits dans cette page qui servent à gérer les cas les plus courants mais il est tout à fait possible d’en créer un de toute pièce si le besoin s’en fait sentir.

filter

La section filter sert quant à elle à préciser les traitements qu’on souhaite appliquer aux tokens, les règles qui régiront leur reconnaissance lors de la recherche. Ce peut être des filtres standards (livrés par Elasticsearch) ou montés de toutes pièces. On en utilise ici plusieurs :

  • “snowball” : permet de découper les mots pour reconnaître les sous-parties (continuant -> continu)
  • “lowercase” : ne prend pas en compte la casse lors du matching
  • “asciifolding” : convertie les caractères non-ascii en leur équivalent ascii
  • “stopwords” : liste des mots qui forcent le découpage
  • “elision” : liste des articles à ne pas prendre en compte
  • “worddelimiter” : liste des délimiteurs de mots (ex: “Wi-Fi” -> “Wi” et “Fi”)

char_filter

Finalement j’utilise un char_filter “html_strip” qui permet de supprimer l’HTML d’un texte indexé pour éviter de matcher du texte non-pertinent.

filter personnalisé

Pour certains des filtres utilisés, j’ai également défini un comportement personnalisé.

Pour le snowball je précise qu’on est en mode de découpage snowball en langue française.

Concernant l’élision, je définis les chaînes de caractères qui devront être reconnues et traitées comme des articles et donc ignorées si non présentes dans la recherche.

Pour les stopwords, je demande à ce que la liste _french_ prédéfinie par Elasticsearch soit utilisée et que la casse ne soit pas prise en compte.

Finalement je crée un filtre de type word_delimiter.

Ces différentes règles misent bout à bout donnent un résultat satisfaisant en ce qui concerne l’analyse de texte en français et notamment pour des recherches partielles ou à racine commune.

Mapping

Le mapping est une autre partie importante de la configuration d’un modèle pour son indexation par Elasticsearch. C’est dans cette partie que vous allez lister les champs que vous voulez indexer mais également quel type d’analyser devra leur être appliqué au moment de l’indexation.

Voici un exemple de définitions :

mapping do
  indexes :id, index: :not_analyzed
  indexes :author_id, index: :not_analyzed
  indexes :title
  indexes :content
  indexes :english_content, analyzer: 'english'
  indexes :state, analyzer: 'keyword'

  indexes :tags do
    indexes :name, analyzer: 'keyword'
  end
end

Ici j’indexe plusieurs attributs de mon modèle et également l’une de ses relations qui représente ses tags associés. À noter que dès lors que vous définissez vos mappings vous devez être exhaustifs sur les champs à indexer.

On voit que pour l’id et l’author_id je précise index: :not_analyzed qui veut dire que ce champ n’a pas vocation a être analysé et qu’il sera donc indexé tel quel sans traitement.

En ce qui concerne le title et le content, n’ayant rien précisé, l’analyser utilisé sera celui par défaut à savoir default que nous avons personnalisé plus haut. Ces champs seront donc analysés et découpés selon les règles que nous avons établies et qui semble bien adaptées aux textes en français.

Pour le champ english_content nous précisons l’analyser english. Nous ne l’avons pas défini ici mais l’idée serait de faire la même chose que pour default en adaptant les règles à la langue anglaise. Ce champ est ici uniquement à but d’illustration pour que vous compreniez que vous pouvez mixer les analysers en fonction des champs.

Le champ state est lui indexé avec l’analyser keyword qui prend une chaîne comme une entité unique et s’en sert directement comme token. Cela veut dire que lors d’une recherche sur ce champ il faudra entrer le terme exact, en entier pour qu’il soit reconnu.

Finalement nous indexons une relation, pour chaque tag associé nous allons créer une structure qui comprendra ici uniquement le nom du tag indexé sous forme de keyword. Nous aurons donc dans notre document principal, un tableau “tags” qui contiendra pour chacun de nos tags son nom ce qui permet de faire une recherche dessus, notamment d’utiliser un filtre pour ne faire ressortir que les documents qui possèdent tel ou tel tag.

Déporter la logique dans le modèle

Au fur et à mesure de mon utilisation d’Elasticsearch et des gems associés je me suis rendu compte qu’il était fastidieux de gérer la génération du Hash à passer à #search directement dans le contrôleur. C’est surtout vrai pour les recherches compliquées et on obtient rapidement de la redondance d’une action à l’autre.

J’ai donc pris la décision de déporter la génération de la structure de recherche directement dans le modèle associé en surchargeant la méthode #search ce qui me permet dans mes contrôleurs de faire un :

MonModele.search(params)

sans avoir à me soucier dans mon contrôleur de la logique derrière la recherche ou des paramètres à passer. Je passe directement tous les paramètres que je reçois et c’est ma méthode #search qui s’occupe de faire le tri :

  def self.search(params)
    # Criterias skeleton
    criterias = {
      query: {
        bool: {
          must: [],
          should: [],
          minimum_should_match: 1
        }
      },
      filter: {
        and: []
      }
    }

    # Query string
    criterias[:query][:bool][:must] << {
      query_string: {
        query: params[:query].blank? ? "*" : params[:query],
        fields: ["name", "description"]
      }
    }

    # Filter by tags
    if params[:tags]
      params[:tags].each do |tag|
        criterias[:query][:bool][:must] << {
          match: {
            "tags.name" => {
              query: tag
            }
          }
        }
      end
    end

    # more code

    __elasticsearch__.search(criterias)
  end

Le code de recherche est donc centralisé et plus facile à faire évoluer. Ce qu’il y a noter ici c’est que si vous écrasez la méthode #search fournis par le gem vous pouvez quand même toujours y accéder grâce à un alias MonModel.__elasticsearch__.search ce qui permet à la fois de pouvoir l’appeler depuis notre méthode de recherche maison mais aussi de continuer à pouvoir utiliser la méthode #search de base depuis l’extérieur, en console notamment pour effectuer des tests.

Conclusion

Nous n’avons fait ici que survoler les possibilités d’Elasticsearch mais nous avons par contre bien vu comment se servir des gems associés pour l’intégrer dans Rails et notamment comment mettre en place une indexation particulièrement adaptée à notre langue.

Pour aller plus loin vous devrez nécessairement passer par la case documentation du site d’Elasticsearch puisque toutes les possibilités supplémentaires que vous avez sont directement liées au service Elasticsearch et notamment à son DSL de requêtage.

Pour l’aspect purement Ruby / Rails, je pense que les bases sont posées. Il ne vous reste plus qu’à expérimenter.


L’équipe Synbioz.

Libres d’être ensemble.