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.
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.
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 :
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
.
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 :
Hash
qui reprend la sémantique de requêtage d’ElasticsearchDans 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.
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.
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 :
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.
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.
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.
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 :
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.
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.
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.
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.
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.