Elasticsearch pour Ruby on Rails : un tutoriel sur le Chewy Gem

Publié: 2022-03-11

Elasticsearch fournit une puissante interface HTTP RESTful pour l'indexation et l'interrogation des données, basée sur la bibliothèque Apache Lucene. Dès sa sortie de l'emballage, il fournit une recherche évolutive, efficace et robuste, avec prise en charge UTF-8. C'est un outil puissant pour indexer et interroger des quantités massives de données structurées et, ici chez Toptal, il alimente notre recherche de plate-forme et sera bientôt également utilisé pour l'auto-complétion. Nous sommes de grands fans.

Chewy étend le client Elasticsearch-Ruby, le rendant plus puissant et offrant une intégration plus étroite avec Rails.

Étant donné que notre plate-forme est construite à l'aide de Ruby on Rails, notre intégration d'Elasticsearch tire parti du projet elasticsearch-ruby (un cadre d'intégration Ruby pour Elasticsearch qui fournit un client pour se connecter à un cluster Elasticsearch, une API Ruby pour l'API REST d'Elasticsearch et diverses extensions et utilitaires). Sur cette base, nous avons développé et publié notre propre amélioration (et simplification) de l'architecture de recherche d'applications Elasticsearch, présentée sous la forme d'un joyau Ruby que nous avons nommé Chewy (avec un exemple d'application disponible ici).

Chewy étend le client Elasticsearch-Ruby, le rendant plus puissant et offrant une intégration plus étroite avec Rails. Dans ce guide Elasticsearch, j'explique (à travers des exemples d'utilisation) comment nous y sommes parvenus, y compris les obstacles techniques qui sont apparus lors de la mise en œuvre.

La relation entre Elasticsearch et Ruby on Rails est décrite dans ce guide visuel.

Juste quelques notes rapides avant de passer au guide :

  • Chewy et une application de démonstration Chewy sont disponibles sur GitHub.
  • Pour ceux qui sont intéressés par plus d'informations "sous le capot" sur Elasticsearch, j'ai inclus une brève description en annexe de cet article.

Pourquoi Chewy ?

Malgré l'évolutivité et l'efficacité d'Elasticsearch, son intégration à Rails ne s'est pas avérée aussi simple que prévu. Chez Toptal, nous nous sommes retrouvés dans le besoin d'augmenter considérablement le client de base Elasticsearch-Ruby pour le rendre plus performant et pour prendre en charge des opérations supplémentaires.

Malgré l'évolutivité et l'efficacité d'Elasticsearch, son intégration à Rails ne s'est pas avérée aussi simple que prévu.

Et ainsi, le joyau Chewy est né.

Quelques caractéristiques particulièrement remarquables de Chewy incluent :

  1. Chaque indice est observable par tous les modèles associés.

    La plupart des modèles indexés sont liés les uns aux autres. Et parfois, il est nécessaire de dénormaliser ces données liées et de les lier au même objet (par exemple, si vous souhaitez indexer un tableau de balises avec leur article associé). Chewy vous permet de spécifier un index pouvant être mis à jour pour chaque modèle, de sorte que les articles correspondants seront réindexés chaque fois qu'une balise pertinente est mise à jour.

  2. Les classes d'index sont indépendantes des modèles ORM/ODM.

    Avec cette amélioration, la mise en œuvre de la saisie semi-automatique inter-modèles, par exemple, est beaucoup plus facile. Vous pouvez simplement définir un index et travailler avec lui de manière orientée objet. Contrairement à d'autres clients, la gemme Chewy supprime le besoin d'implémenter manuellement des classes d'index, des rappels d'importation de données et d'autres composants.

  3. L'importation en vrac est partout .

    Chewy utilise l'API Elasticsearch en bloc pour une réindexation complète et des mises à jour d'index. Il utilise également le concept de mises à jour atomiques, collectant les objets modifiés dans un bloc atomique et les mettant à jour tous en même temps.

  4. Chewy fournit un DSL de requête de style AR.

    En étant chaînée, fusionnable et paresseuse, cette amélioration permet de produire des requêtes de manière plus efficace.

OK, voyons comment tout cela se passe dans la gemme…

Le guide de base d'Elasticsearch

Elasticsearch a plusieurs concepts liés aux documents. Le premier est celui d'un index (l'analogue d'une base de database en SGBDR), qui est constitué d'un ensemble de documents , qui peuvent être de plusieurs types (où un type est une sorte de table SGBDR).

Chaque document a un ensemble de fields . Chaque champ est analysé indépendamment et ses options d'analyse sont stockées dans le mapping de son type. Chewy utilise cette structure « telle quelle » dans son modèle objet :

 class EntertainmentIndex < Chewy::Index settings analysis: { analyzer: { title: { tokenizer: 'standard', filter: ['lowercase', 'asciifolding'] } } } define_type Book.includes(:author, :tags) do field :title, analyzer: 'title' field :year, type: 'integer' field :author, value: ->{ author.name } field :author_id, type: 'integer' field :description field :tags, index: 'not_analyzed', value: ->{ tags.map(&:name) } end {movie: Video.movies, cartoon: Video.cartoons}.each do |type_name, scope| define_type scope.includes(:director, :tags), name: type_name do field :title, analyzer: 'title' field :year, type: 'integer' field :author, value: ->{ director.name } field :author_id, type: 'integer', value: ->{ director_id } field :description field :tags, index: 'not_analyzed', value: ->{ tags.map(&:name) } end end end

Ci-dessus, nous avons défini un index Elasticsearch appelé entertainment avec trois types : book , movie et cartoon . Pour chaque type, nous avons défini des mappages de champs et un hachage de paramètres pour l'ensemble de l'index.

Donc, nous avons défini l' EntertainmentIndex et nous voulons exécuter quelques requêtes. Dans un premier temps, nous devons créer l'index et importer nos données :

 EntertainmentIndex.create! EntertainmentIndex.import # EntertainmentIndex.reset! (which includes deletion, # creation, and import) could be used instead

La méthode .import est consciente des données importées car nous avons transmis des portées lorsque nous avons défini nos types ; ainsi, il importera tous les livres, films et dessins animés stockés dans le stockage persistant.

Cela fait, nous pouvons effectuer quelques requêtes :

 EntertainmentIndex.query(match: {author: 'Tarantino'}).filter{ year > 1990 } EntertainmentIndex.query(match: {title: 'Shawshank'}).types(:movie) EntertainmentIndex.query(match: {author: 'Tarantino'}).only(:id).limit(10).load # the last one loads ActiveRecord objects for documents found

Maintenant, notre index est presque prêt à être utilisé dans notre implémentation de recherche.

Intégration des rails

Pour l'intégration avec Rails, la première chose dont nous avons besoin est d'être capable de réagir aux changements d'objet RDBMS. Chewy prend en charge ce comportement via des rappels définis dans la méthode de classe update_index . update_index prend deux arguments :

  1. Un identifiant de type fourni au "index_name#type_name"
  2. Un nom de méthode ou un bloc à exécuter, qui représente une référence arrière à l'objet ou à la collection d'objets mis à jour

Nous devons définir ces rappels pour chaque modèle dépendant :

 class Book < ActiveRecord::Base acts_as_taggable belongs_to :author, class_name: 'Dude' # We update the book itself on-change update_index 'entertainment#book', :self end class Video < ActiveRecord::Base acts_as_taggable belongs_to :director, class_name: 'Dude' # Update video types when changed, depending on the category update_index('entertainment#movie') { self if movie? } update_index('entertainment#cartoon') { self if cartoon? } end class Dude < ActiveRecord::Base acts_as_taggable has_many :books has_many :videos # If author or director was changed, all the corresponding # books, movies and cartoons are updated update_index 'entertainment#book', :books update_index('entertainment#movie') { videos.movies } update_index('entertainment#cartoon') { videos.cartoons } end

Étant donné que les balises sont également indexées, nous devons ensuite patcher certains modèles externes pour qu'ils réagissent aux changements :

 ActsAsTaggableOn::Tag.class_eval do has_many :books, through: :taggings, source: :taggable, source_type: 'Book' has_many :videos, through: :taggings, source: :taggable, source_type: 'Video' # Updating all tag-related objects update_index 'entertainment#book', :books update_index('entertainment#movie') { videos.movies } update_index('entertainment#cartoon') { videos.cartoons } end ActsAsTaggableOn::Tagging.class_eval do # Same goes for the intermediate model update_index('entertainment#book') { taggable if taggable_type == 'Book' } update_index('entertainment#movie') { taggable if taggable_type == 'Video' && taggable.movie? } update_index('entertainment#cartoon') { taggable if taggable_type == 'Video' && taggable.cartoon? } end

À ce stade, chaque sauvegarde ou destruction d'objet mettra à jour le type d'index Elasticsearch correspondant.

Atomicité

Nous avons encore un problème persistant. Si nous faisons quelque chose comme books.map(&:save) pour enregistrer plusieurs livres, nous demanderons une mise à jour de l'index de entertainment à chaque fois qu'un livre individuel est enregistré . Ainsi, si nous sauvegardons cinq livres, nous mettrons à jour l'index Chewy cinq fois. Ce comportement est acceptable pour REPL, mais certainement pas acceptable pour les actions du contrôleur dans lesquelles les performances sont critiques.

Nous abordons ce problème avec le bloc Chewy.atomic :

 class ApplicationController < ActionController::Base around_action { |&block| Chewy.atomic(&block) } end

En bref, Chewy.atomic ces mises à jour comme suit :

  1. Désactive le rappel after_save .
  2. Collecte les identifiants des livres enregistrés.
  3. Une fois le bloc Chewy.atomic , utilise les ID collectés pour effectuer une seule demande de mise à jour de l'index Elasticsearch.

Recherche

Nous sommes maintenant prêts à implémenter une interface de recherche. Puisque notre interface utilisateur est un formulaire, la meilleure façon de le construire est, bien sûr, avec FormBuilder et ActiveModel. (Chez Toptal, nous utilisons ActiveData pour implémenter les interfaces ActiveModel, mais n'hésitez pas à utiliser votre joyau préféré.)

 class EntertainmentSearch include ActiveData::Model attribute :query, type: String attribute :author_id, type: Integer attribute :min_year, type: Integer attribute :max_year, type: Integer attribute :tags, mode: :arrayed, type: String, normalize: ->(value) { value.reject(&:blank?) } # This accessor is for the form. It will have a single text field # for comma-separated tag inputs. def tag_list= value self.tags = value.split(',').map(&:strip) end def tag_list self.tags.join(', ') end end

Tutoriel sur les requêtes et les filtres

Maintenant que nous avons un objet de type ActiveModel qui peut accepter et transtyper les attributs, implémentons la recherche :

 class EntertainmentSearch ... def index EntertainmentIndex end def search # We can merge multiple scopes [query_string, author_id_filter, year_filter, tags_filter].compact.reduce(:merge) end # Using query_string advanced query for the main query input def query_string index.query(query_string: {fields: [:title, :author, :description], query: query, default_operator: 'and'}) if query? end # Simple term filter for author id. `:author_id` is already # typecasted to integer and ignored if empty. def author_id_filter index.filter(term: {author_id: author_id}) if author_id? end # For filtering on years, we will use range filter. # Returns nil if both min_year and max_year are not passed to the model. def year_filter body = {}.tap do |body| body.merge!(gte: min_year) if min_year? body.merge!(lte: max_year) if max_year? end index.filter(range: {year: body}) if body.present? end # Same goes for `author_id_filter`, but `terms` filter used. # Returns nil if no tags passed in. def tags_filter index.filter(terms: {tags: tags}) if tags? end end

Contrôleurs et vues

À ce stade, notre modèle peut effectuer des requêtes de recherche avec des attributs passés. L'utilisation ressemblera à :

 EntertainmentSearch.new(query: 'Tarantino', min_year: 1990).search

Notez que dans le contrôleur, nous souhaitons charger des objets ActiveRecord exacts au lieu des wrappers de document Chewy :

 class EntertainmentController < ApplicationController def index @search = EntertainmentSearch.new(params[:search]) # In case we want to load real objects, we don't need any other # fields except for `:id` retrieved from Elasticsearch index. # Chewy query DSL supports Kaminari gem and corresponding API. # Also, we pass scopes for every requested type to the `load` method. @entertainments = @search.search.only(:id).page(params[:page]).load( book: {scope: Book.includes(:author)}, movie: {scope: Video.includes(:director)}, cartoon: {scope: Video.includes(:director)} ) end end

Maintenant, il est temps d'écrire du HAML sur entertainment/index.html.haml :

 = form_for @search, as: :search, url: entertainment_index_path, method: :get do |f| = f.text_field :query = f.select :author_id, Dude.all.map { |d| [d.name, d.id] }, include_blank: true = f.text_field :min_year = f.text_field :max_year = f.text_field :tag_list = f.submit - if @entertainments.any? %dl - @entertainments.each do |entertainment| %dt %h1= entertainment.title %strong= entertainment.class %dd %p= entertainment.year %p= entertainment.description %p= entertainment.tag_list = paginate @entertainments - else Nothing to see here

Tri

En prime, nous ajouterons également le tri à notre fonctionnalité de recherche.

Supposons que nous devions trier sur les champs titre et année, ainsi que par pertinence. Malheureusement, le titre One Flew Over the Cuckoo's Nest sera divisé en termes individuels, donc le tri selon ces termes disparates sera trop aléatoire ; à la place, nous aimerions trier par le titre entier.

La solution consiste à utiliser un champ de titre spécial et à appliquer son propre analyseur :

 class EntertainmentIndex < Chewy::Index settings analysis: { analyzer: { ... sorted: { # `keyword` tokenizer will not split our titles and # will produce the whole phrase as the term, which # can be sorted easily tokenizer: 'keyword', filter: ['lowercase', 'asciifolding'] } } } define_type Book.includes(:author, :tags) do # We use the `multi_field` type to add `title.sorted` field # to the type mapping. Also, will still use just the `title` # field for search. field :title, type: 'multi_field' do field :title, index: 'analyzed', analyzer: 'title' field :sorted, index: 'analyzed', analyzer: 'sorted' end ... end {movie: Video.movies, cartoon: Video.cartoons}.each do |type_name, scope| define_type scope.includes(:director, :tags), name: type_name do # For videos as well field :title, type: 'multi_field' do field :title, index: 'analyzed', analyzer: 'title' field :sorted, index: 'analyzed', analyzer: 'sorted' end ... end end end

De plus, nous allons ajouter à la fois ces nouveaux attributs et l'étape de traitement du tri à notre modèle de recherche :

 class EntertainmentSearch # we are going to use `title.sorted` field for sort SORT = {title: {'title.sorted' => :asc}, year: {year: :desc}, relevance: :_score} ... attribute :sort, type: String, enum: %w(title year relevance), default_blank: 'relevance' ... def search # we have added `sorting` scope to merge list [query_string, author_id_filter, year_filter, tags_filter, sorting].compact.reduce(:merge) end def sorting # We have one of the 3 possible values in `sort` attribute # and `SORT` mapping returns actual sorting expression index.order(SORT[sort.to_sym]) end end

Enfin, nous allons modifier notre formulaire en ajoutant une boîte de sélection des options de tri :

 = form_for @search, as: :search, url: entertainment_index_path, method: :get do |f| ... / `EntertainmentSearch.sort_values` will just return / enum option content from the sort attribute definition. = f.select :sort, EntertainmentSearch.sort_values ...

La gestion des erreurs

Si vos utilisateurs effectuent des requêtes incorrectes telles que ( ou AND , le client Elasticsearch générera une erreur. Pour gérer cela, apportons quelques modifications à notre contrôleur :

 class EntertainmentController < ApplicationController def index @search = EntertainmentSearch.new(params[:search]) @entertainments = @search.search.only(:id).page(params[:page]).load( book: {scope: Book.includes(:author)}, movie: {scope: Video.includes(:director)}, cartoon: {scope: Video.includes(:director)} ) rescue Elasticsearch::Transport::Transport::Errors::BadRequest => e @entertainments = [] @error = e.message.match(/QueryParsingException\[([^;]+)\]/).try(:[], 1) end end

De plus, nous devons afficher l'erreur dans la vue :

 ... - if @entertainments.any? ... - else - if @error = @error - else Nothing to see here

Tester les requêtes Elasticsearch

La configuration de test de base est la suivante :

  1. Démarrez le serveur Elasticsearch.
  2. Nettoyer et créer nos index.
  3. Importez nos données.
  4. Effectuez notre requête.
  5. Croiser le résultat avec nos attentes.

Pour l'étape 1, il est pratique d'utiliser le cluster de test défini dans le gem elasticsearch-extensions. Ajoutez simplement la ligne suivante à l'installation post-gem Rakefile de votre projet :

 require 'elasticsearch/extensions/test/cluster/tasks'

Ensuite, vous obtiendrez les tâches Rake suivantes :

 $ rake -T elasticsearch rake elasticsearch:start # Start Elasticsearch cluster for tests rake elasticsearch:stop # Stop Elasticsearch cluster for tests

Elasticsearch et Rspec

Tout d'abord, nous devons nous assurer que notre index est mis à jour pour être synchronisé avec nos modifications de données. Heureusement, la gemme Chewy est livrée avec l'utile matcher update_index rspec :

 describe EntertainmentIndex do # No need to cleanup Elasticsearch as requests are # stubbed in case of `update_index` matcher usage. describe 'Tag' do # We create several books with the same tag let(:books) { create_list :book, 2, tag_list: 'tag1' } specify do # We expect that after modifying the tag name... expect do ActsAsTaggableOn::Tag.where(name: 'tag1').update_attributes(name: 'tag2') # ... the corresponding type will be updated with previously-created books. end.to update_index('entertainment#book').and_reindex(books, with: {tags: ['tag2']}) end end end

Ensuite, nous devons tester que les requêtes de recherche réelles sont effectuées correctement et qu'elles renvoient les résultats attendus :

 describe EntertainmentSearch do # Just defining helpers for simplifying testing def search attributes = {} EntertainmentSearch.new(attributes).search end # Import helper as well def import *args # We are using `import!` here to be sure all the objects are imported # correctly before examples run. EntertainmentIndex.import! *args end # Deletes and recreates index before every example before { EntertainmentIndex.purge! } describe '#min_year, #max_year' do let(:book) { create(:book, year: 1925) } let(:movie) { create(:movie, year: 1970) } let(:cartoon) { create(:cartoon, year: 1995) } before { import book: book, movie: movie, cartoon: cartoon } # NOTE: The sample code below provides a clear usage example but is not # optimized code. Something along the following lines would perform better: # `specify { search(min_year: 1970).map(&:id).map(&:to_i) # .should =~ [movie, cartoon].map(&:id) }` specify { search(min_year: 1970).load.should =~ [movie, cartoon] } specify { search(max_year: 1980).load.should =~ [book, movie] } specify { search(min_year: 1970, max_year: 1980).load.should == [movie] } specify { search(min_year: 1980, max_year: 1970).should == [] } end end

Tester le dépannage du cluster

Enfin, voici un guide pour dépanner votre cluster de test :

  • Pour commencer, utilisez un cluster à un nœud en mémoire. Ce sera beaucoup plus rapide pour les spécifications. Dans notre cas : TEST_CLUSTER_NODES=1 rake elasticsearch:start

  • Il existe certains problèmes avec l'implémentation du cluster de test elasticsearch-extensions lui-même liés à la vérification de l'état du cluster à un nœud (il est jaune dans certains cas et ne sera jamais vert, de sorte que la vérification du démarrage du cluster à statut vert échouera à chaque fois). Le problème a été résolu dans un fork, mais j'espère qu'il sera bientôt résolu dans le référentiel principal.

  • Pour chaque ensemble de données, regroupez votre demande dans des spécifications (c'est-à-dire importez vos données une seule fois, puis effectuez plusieurs demandes). Elasticsearch se réchauffe pendant une longue période et utilise beaucoup de mémoire de tas lors de l'importation de données, alors n'en faites pas trop, surtout si vous avez un tas de spécifications.

  • Assurez-vous que votre machine dispose de suffisamment de mémoire ou Elasticsearch se bloquera (nous avions besoin d'environ 5 Go pour chaque machine virtuelle de test et d'environ 1 Go pour Elasticsearch lui-même).

Emballer

Elasticsearch se décrit comme "un moteur de recherche et d'analyse open source, distribué, en temps réel, flexible et puissant". C'est l'étalon-or des technologies de recherche.

Avec Chewy, nos développeurs de rails ont regroupé ces avantages sous la forme d'un joyau Ruby open source simple, facile à utiliser et de qualité production qui offre une intégration étroite avec Rails. Elasticsearch et Rails – quelle combinaison géniale !

Elasticsearch et Rails -- quelle combinaison géniale !
Tweeter


Annexe : éléments internes d'Elasticsearch

Voici une très brève introduction à Elasticsearch "sous le capot"…

Elasticsearch est construit sur Lucene, qui utilise lui-même des index inversés comme structure de données principale. Par exemple, si nous avons les chaînes « les chiens sautent haut », « sautent par-dessus la clôture » ​​et « la clôture était trop haute », nous obtenons la structure suivante :

 "the" [0, 0], [1, 2], [2, 0] "dogs" [0, 1] "jump" [0, 2], [1, 0] "high" [0, 3], [2, 4] "over" [1, 1] "fence" [1, 3], [2, 1] "was" [2, 2] "too" [2, 3]

Ainsi, chaque terme contient à la fois des références et des positions dans le texte. De plus, nous choisissons de modifier nos termes (par exemple, en supprimant les mots vides comme "le") et appliquons un hachage phonétique à chaque terme (pouvez-vous deviner l'algorithme ?) :

 "DAG" [0, 1] "JANP" [0, 2], [1, 0] "HAG" [0, 3], [2, 4] "OVAR" [1, 1] "FANC" [1, 3], [2, 1] "W" [2, 2] "T" [2, 3]

Si nous recherchons ensuite "le chien saute", il est analysé de la même manière que le texte source, devenant "DAG JANP" après hachage ("chien" a le même hachage que "chiens", comme c'est le cas avec "sauts" et "saut").

Nous ajoutons également une logique entre les mots individuels de la chaîne (basée sur les paramètres de configuration), en choisissant entre ("DAG" ET "JANP") ou ("DAG" OU "JANP"). Le premier renvoie l'intersection de [0] & [0, 1] (c'est-à-dire le document 0) et le second, [0] | [0, 1] [0] | [0, 1] (c'est-à-dire les documents 0 et 1). Les positions dans le texte peuvent être utilisées pour noter les résultats et les requêtes dépendantes de la position.