Recherche dynamique avec hstore et ElasticSearch

Publié le 5 mars 2014 par Nicolas Zermati | outils

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

Dans cet article, je me propose de faire une introduction à ElasticSearch et à son intégration avec Ruby On Rails. Puisque l’intégration est plutôt facile à réaliser, je vais également introduire l’utilisation de l’extension postgresql hstore.

Pour cela, je vais prendre en exemple une petite application de recherche de produits. L’idée est d’avoir un nombre arbitraire de propriétés sur ces produits et de pouvoir effectuer des recherches efficacement sur celles-ci.

Prérequis

Avant de commencer, assurez vous d’avoir une machine pourvue d’ElasticSearch, d’une base de données postgresql, d’une version de Ruby récente ainsi que de la gem bundler.

Vous pouvez ensuite rapatrier le code de cet article depuis Github. Si vous voulez tester l’application, vous pouvez également le faire directement via la démo en ligne.

Si vous installez l’application chez vous, pensez à activer l’extension hstore sur la base que vous utiliserez. ActiveRecord 4 inclut le support pour ce type de champs de manière native.

Présentation du modèle utilisé

Pour illustrer l’utilisation d’un hstore, on va créer un modèle Product. Ce modèle n’a qu’un title et un champ properties. Notre objectif est d’utiliser properties pour étendre au besoin les caractéristiques de nos produits à la manière d’une base sans schéma.

bundle exec rails g scaffold Product title:string properties:hstore

Ensuite, il n’y a plus qu’à réaliser la migration.

Intégrer ElasticSearch

Gems

La gem tire était la référence en terme d’intégration avec Rails. Toutefois en septembre 2013, elle cesse d’être maintenue au profit d’une autre implémentation : elasticsearch-rails via elasticsearch-ruby et elasticsearch-model. Ce sont donc ces gems que nous utilisons pour cet article.

La principale différence avec tire est l’absence du DSL de requêtage. De mon point de vue, ce dernier n’était pas nécessaire dans la mesure ou ElasticSearch utilise déjà un DSL, même si ce dernier est en JSON.

Modèles

Pour indiquer à Rails qu’il faut indexer les produits et que l’on souhaite disposer des fonctions de recherche depuis la classe Product on aura :

class Product < ActiveRecord::Model
  include Elasticsearch::Model
  include Elasticsearch::Model::Callbacks
end

Ces quelques lignes vont permettre d’écrire ce type de recherches :

Product.search({
  filter: {
    terms: { 'properties.color' => ['green', 'blue'] }
  }
})

Cette recherche retourne une instance de Elasticsearch::Response::Response depuis laquelle on peut accéder au résultat de la requête à ElasticSearch, aux Products associés à ces résultats, etc.

Création et indexation de données factices

L’application de démo dispose de seeds pour Product. Vous pouvez donc créer 1000 produits à l’aide de la commande suivante :

bundle exec rake db:seed

Pour chaque Product créé, une requête d’ajout est envoyée à ElasticSearch. Ces requêtes vont placer dans l’index products un document de type product avec comme id l’id du produit créé. Les termes index, document, type et id sont relatif à ElasticSearch, voir le glossaire pour plus d’informations.

Le contenu du document envoyé par Rails à ElasticSearch est le résultat de la méthode Product#as_indexed_json. Vous pouvez bien entendu redéfinir cette méthode qui par défaut est :

def as_indexed_json(options={})
  self.as_json(options.merge root: false)
end

Dans notre cas, properties est retourné sous forme de Hash et est donc indexé par ElasticSearch.

Note : Si vous avez créé des Products avant l’intégration avec ElasticSearch alors il vous faudra réindexer votre modèle à l’aide de la commande suivante :

bundle exec rake environment elasticsearch:import:model CLASS='Product' FORCE=y

Récupération des clés

Chaque produit dispose dans properties de clés et de valeurs associées. Pour lister ces clés, j’utilise la requête suivante :

SELECT DISTINCT k FROM (SELECT skeys(properties) AS k FROM products) AS dt

Cette dernière retourne actuellement les éléments suivants :

+----------+
|    k     |
+----------+
| color    |
| size     |
| gender   |
| category |
+----------+

Une fois ces clés obtenues, on peut s’en servir pour filtrer les produits.

Filtrage des produit

Pour mettre en place la recherche, je construis une requête en JavaScript qui se définit comme un tableau associatif où les clés sont les clés des properties des produits et les valeurs des tableaux des valeurs acceptées ; voici un exemple :

{
  "size":     ["12", "12.5", "13"],
  "gender":   ["male"],
  "category": ["sport"]
}

Cette requête est traitée pour retourner une liste de produits via le code suivant :

es_params = {
  filter: {
    and: query.map{ |key, values| { terms: { "properties.#{key}" => values } } }
  }
}
Product.search(es_params).records.all

On remarque que les paramètres de Product.search sont exactement le JSON qu’attend ElasticSearch. L’intégration avec Rails est donc légère et ne risque pas de se mettre en travers de requêtes avancées.

Autocomplétion des filtres

Si vous avez utilisé la démo de l’application, vous avez pu remarquer que de l’autocomplétion était réalisée sur les clés des properties. On utilise la recherche facetée d’ElasticSearch (documentation) pour récupérer la liste des dix valeurs les plus communes pour une propriété donnée.

field = "properties.color"
es_params = {
  "size" => 0,
  "query" => {
    "prefix" => {
      field => "bl"
    }
  },
  "facets" => {
    "color" => {
      "terms" => {
        "field" => field
      }
    }
  }
}
response = Product.search(es_params)
response.response["facets"]["color"]["terms"].map{ |f| f['term']}

Le code ci-dessus va faire une requête, limitant le nombre de résultats retournés à zéro. La recherche prendra en compte tous les documents contenant un champ properties.color commençant par bl. La partie facets est exploitée directement depuis la réponse d’ElasticSearch.

Note : C’est Visualsearch qui est utilisé pour réaliser le composant de recherche.

Autres éléments

Suite à l’utilisation d’un champ de type hstore, on doit traiter de manière différente les formulaires d’ajout et d’édition d’un produit. En effet, il faut permettre l’ajout, la suppression et la modification de clés et de valeurs. Pour celà, j’utilise un peu de code JavaScript qui ne vaut pas la peine d’être détaillé. En plus de cela, les vues ont été légèrement modifiées pour prendre en compte la nature dynamique de notre hstore.

Les actions search et autocomplete retournent respectivement une vue partielle et une liste de chaînes de caractères au format JSON. La vue partielle est celle des éléments de la liste. La liste des chaînes de caractères correspond aux valeurs autocomplétées des propriétés.

Le reste du code est principalement autogénéré par le scaffold initial.

Cas limites

En l’état, l’application à certaines limites. La gestion des majuscules par exemple ; si vous recherchez un produit dont la propriété color vaut Red vous n’aurez aucun resultat. C’est lié à mes requêtes à ElasticSearch.

Un autre point problématique est la mise à jour du hstore properties via l’action update du controlleur. En effet, je n’ai pas réussi, avec strong_parameters, à autoriser n’importe quelle clé pour le hash properties. Je m’en sors avec le code suivant :

def product_params
  properties = params.require(:product).fetch(:properties, nil).try(:permit!)
  params.require(:product).permit(:title).merge(properties: properties)
end

Conclusion

C’est terminé pour cet article. Je vous invite, si ce n’est pas encore fait, à jouer avec notre démo et à consulter le code associé.

Nous n’avons fait que mettre en place les fondations qui permettrons d’exploiter les nombreuses fonctionnalités d’ElasticSearch dans un projet Ruby On Rails. Toutefois, j’espère vous avoir montré à quel point il est facile d’intégrer cet outil à vos projet.

L’équipe Synbioz.

Libres d’être ensemble.