Elasticsearch para Ruby on Rails: um tutorial para o Chewy Gem
Publicados: 2022-03-11O Elasticsearch fornece uma interface HTTP RESTful poderosa para indexação e consulta de dados, criada com base na biblioteca Apache Lucene. Pronto para uso, ele fornece pesquisa escalável, eficiente e robusta, com suporte a UTF-8. É uma ferramenta poderosa para indexar e consultar grandes quantidades de dados estruturados e, aqui na Toptal, potencializa nossa pesquisa na plataforma e em breve também será usado para preenchimento automático. Somos grandes fãs.
Como nossa plataforma é construída usando Ruby on Rails, nossa integração do Elasticsearch aproveita o projeto elasticsearch-ruby (uma estrutura de integração Ruby para Elasticsearch que fornece um cliente para conexão a um cluster Elasticsearch, uma API Ruby para a API REST do Elasticsearch e várias extensões e utilitários). Com base nessa base, desenvolvemos e lançamos nossa própria melhoria (e simplificação) da arquitetura de pesquisa do aplicativo Elasticsearch, empacotada como uma gem Ruby que chamamos de Chewy (com um aplicativo de exemplo disponível aqui).
Chewy estende o cliente Elasticsearch-Ruby, tornando-o mais poderoso e proporcionando maior integração com Rails. Neste guia do Elasticsearch, discuto (por meio de exemplos de uso) como conseguimos isso, incluindo os obstáculos técnicos que surgiram durante a implementação.
Apenas algumas notas rápidas antes de prosseguir para o guia:
- Ambos Chewy e um aplicativo de demonstração Chewy estão disponíveis no GitHub.
- Para aqueles interessados em mais informações “por baixo do capô” sobre o Elasticsearch, incluí um breve artigo como um apêndice a este post.
Por que Chewy?
Apesar da escalabilidade e eficiência do Elasticsearch, integrá-lo ao Rails não foi tão simples quanto o esperado. Na Toptal, percebemos a necessidade de aumentar significativamente o cliente básico Elasticsearch-Ruby para torná-lo mais eficiente e oferecer suporte a operações adicionais.
E assim nasceu a gema Chewy.
Algumas características particularmente notáveis do Chewy incluem:
Cada índice é observável por todos os modelos relacionados.
A maioria dos modelos indexados estão relacionados entre si. E, às vezes, é necessário desnormalizar esses dados relacionados e vinculá-los ao mesmo objeto (por exemplo, se você deseja indexar um array de tags junto com seu artigo associado). Chewy permite que você especifique um índice atualizável para cada modelo, de modo que os artigos correspondentes serão reindexados sempre que uma tag relevante for atualizada.
As classes de índice são independentes dos modelos ORM/ODM.
Com esse aprimoramento, a implementação do preenchimento automático entre modelos, por exemplo, é muito mais fácil. Você pode simplesmente definir um índice e trabalhar com ele de forma orientada a objetos. Ao contrário de outros clientes, o Chewy gem elimina a necessidade de implementar manualmente classes de índice, retornos de chamada de importação de dados e outros componentes.
A importação em massa está em toda parte .
Chewy utiliza a API Elasticsearch em massa para reindexação completa e atualizações de índice. Ele também utiliza o conceito de atualizações atômicas, coletando objetos alterados dentro de um bloco atômico e atualizando-os todos de uma vez.
Chewy fornece um DSL de consulta no estilo AR.
Por ser encadeável, mesclável e preguiçoso, esse aprimoramento permite que as consultas sejam produzidas de maneira mais eficiente.
OK, então vamos ver como tudo isso se desenrola na gema…
O guia básico do Elasticsearch
O Elasticsearch tem vários conceitos relacionados a documentos. A primeira é a de um index
(o análogo de um database
de dados em RDBMS), que consiste em um conjunto de documents
, que podem ser de vários types
(onde um type
é uma espécie de tabela RDBMS).
Cada documento tem um conjunto de fields
. Cada campo é analisado de forma independente e suas opções de análise são armazenadas no mapping
para seu tipo. Chewy utiliza essa estrutura “como está” em seu modelo de objeto:
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
Acima, definimos um índice Elasticsearch chamado entertainment
com três tipos: book
, movie
e cartoon
. Para cada tipo, definimos alguns mapeamentos de campo e um hash de configurações para todo o índice.
Então, definimos o EntertainmentIndex
e queremos executar algumas consultas. Como primeiro passo, precisamos criar o índice e importar nossos dados:
EntertainmentIndex.create! EntertainmentIndex.import # EntertainmentIndex.reset! (which includes deletion, # creation, and import) could be used instead
O método .import
está ciente dos dados importados porque passamos escopos quando definimos nossos tipos; assim, ele importará todos os livros, filmes e desenhos animados armazenados no armazenamento persistente.
Feito isso, podemos realizar algumas consultas:
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
Agora nosso índice está quase pronto para ser usado em nossa implementação de busca.
Integração Rails
Para integração com Rails, a primeira coisa que precisamos é ser capaz de reagir às mudanças de objetos RDBMS. Chewy oferece suporte a esse comportamento por meio de retornos de chamada definidos no método de classe update_index
. update_index
recebe dois argumentos:
- Um identificador de tipo fornecido no formato
"index_name#type_name"
- Um nome de método ou bloco a ser executado, que representa uma referência inversa ao objeto atualizado ou coleção de objetos
Precisamos definir esses retornos de chamada para cada modelo dependente:
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
Como as tags também são indexadas, a seguir precisamos corrigir alguns modelos externos para que eles reajam às alterações:
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
Nesse ponto, cada objeto salvo ou destruído atualizará o tipo de índice do Elasticsearch correspondente.
Atomicidade
Ainda temos um problema persistente. Se fizermos algo como books.map(&:save)
para salvar vários livros, solicitaremos uma atualização do índice de entertainment
sempre que um livro individual for salvo . Assim, se salvarmos cinco livros, atualizaremos o índice Chewy cinco vezes. Esse comportamento é aceitável para REPL, mas certamente não é aceitável para ações do controlador nas quais o desempenho é crítico.
Resolvemos esse problema com o bloco Chewy.atomic
:
class ApplicationController < ActionController::Base around_action { |&block| Chewy.atomic(&block) } end
Resumindo, Chewy.atomic
essas atualizações da seguinte forma:
- Desabilita o retorno de chamada
after_save
. - Coleta os IDs dos livros salvos.
- Após a conclusão do bloco
Chewy.atomic
, usa os IDs coletados para fazer uma única solicitação de atualização do índice Elasticsearch.
Procurando
Agora estamos prontos para implementar uma interface de pesquisa. Como nossa interface de usuário é um formulário, a melhor maneira de construí-la é, obviamente, com FormBuilder e ActiveModel. (Na Toptal, usamos ActiveData para implementar interfaces ActiveModel, mas sinta-se à vontade para usar sua gem favorita.)
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
Tutorial de consulta e filtros
Agora que temos um objeto do tipo ActiveModel que pode aceitar e typecast atributos, vamos implementar a pesquisa:
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
Controladores e visualizações
Neste ponto, nosso modelo pode realizar solicitações de pesquisa com atributos passados. O uso será algo como:

EntertainmentSearch.new(query: 'Tarantino', min_year: 1990).search
Observe que no controlador, queremos carregar objetos ActiveRecord exatos em vez de wrappers de documentos 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
Agora, é hora de escrever um pouco de HAML em 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
Ordenação
Como bônus, também adicionaremos classificação à nossa funcionalidade de pesquisa.
Suponha que precisamos classificar os campos de título e ano, bem como por relevância. Infelizmente, o título One Flew Over the Cuckoo's Nest
será dividido em termos individuais, portanto, classificar esses termos díspares será muito aleatório; em vez disso, gostaríamos de classificar pelo título inteiro.
A solução é usar um campo de título especial e aplicar seu próprio analisador:
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
Além disso, adicionaremos esses novos atributos e a etapa de processamento de classificação ao nosso modelo de pesquisa:
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
Por fim, modificaremos nosso formulário adicionando a caixa de seleção de opções de classificação:
= 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 ...
Manipulação de erros
Se seus usuários realizarem consultas incorretas como (
ou AND
, o cliente Elasticsearch gerará um erro. Para lidar com isso, vamos fazer algumas alterações em nosso controlador:
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
Além disso, precisamos renderizar o erro na visão:
... - if @entertainments.any? ... - else - if @error = @error - else Nothing to see here
Testando consultas do Elasticsearch
A configuração básica de teste é a seguinte:
- Inicie o servidor Elasticsearch.
- Limpe e crie nossos índices.
- Importe nossos dados.
- Realize nossa consulta.
- Cruze o resultado com nossas expectativas.
Para a etapa 1, é conveniente usar o cluster de teste definido na gem elasticsearch-extensions. Basta adicionar a seguinte linha à instalação pós-gem do Rakefile
do seu projeto:
require 'elasticsearch/extensions/test/cluster/tasks'
Então, você receberá as seguintes tarefas Rake:
$ rake -T elasticsearch rake elasticsearch:start # Start Elasticsearch cluster for tests rake elasticsearch:stop # Stop Elasticsearch cluster for tests
Elasticsearch e Rspec
Primeiro, precisamos garantir que nosso índice seja atualizado para estar em sincronia com nossas alterações de dados. Felizmente, a gema Chewy vem com o útil matcher rspec update_index
:
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
Em seguida, precisamos testar se as consultas de pesquisa reais são executadas corretamente e se retornam os resultados esperados:
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
Solução de problemas do cluster de teste
Por fim, aqui está um guia para solucionar problemas do cluster de teste:
Para começar, use um cluster de um nó na memória. Será muito mais rápido para especificações. No nosso caso:
TEST_CLUSTER_NODES=1 rake elasticsearch:start
Existem alguns problemas existentes com a própria implementação do cluster de teste
elasticsearch-extensions
relacionados à verificação de status do cluster de um nó (é amarelo em alguns casos e nunca será verde, portanto, a verificação inicial do cluster de status verde falhará todas as vezes). O problema foi corrigido em um fork, mas esperamos que seja corrigido no repositório principal em breve.Para cada conjunto de dados, agrupe sua solicitação em especificações (ou seja, importe seus dados uma vez e execute várias solicitações). O Elasticsearch aquece por um longo tempo e usa muita memória heap ao importar dados, portanto, não exagere, especialmente se você tiver várias especificações.
Certifique-se de que sua máquina tenha memória suficiente ou o Elasticsearch irá congelar (exigimos cerca de 5 GB para cada máquina virtual de teste e cerca de 1 GB para o próprio Elasticsearch).
Empacotando
O Elasticsearch é autodescrito como “um mecanismo de análise e pesquisa de código aberto flexível e poderoso, distribuído e em tempo real”. É o padrão ouro em tecnologias de busca.
Com Chewy, nossos desenvolvedores de Rails empacotaram esses benefícios como uma gem Ruby de código aberto simples, fácil de usar, de qualidade de produção e que fornece integração total com Rails. Elasticsearch e Rails – que combinação incrível!
Apêndice: componentes internos do Elasticsearch
Aqui está uma breve introdução ao Elasticsearch “sob o capô”…
O Elasticsearch é construído no Lucene, que usa índices invertidos como sua estrutura de dados primária. Por exemplo, se tivermos as strings “os cachorros pulam alto”, “pulam a cerca” e “a cerca estava muito alta”, obtemos a seguinte estrutura:
"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]
Assim, cada termo contém tanto referências quanto posições no texto. Além disso, optamos por modificar nossos termos (por exemplo, removendo palavras de parada como “o”) e aplicar hashing fonético a cada termo (você consegue adivinhar o algoritmo?):
"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]
Se então consultarmos “the dog jumps”, é analisado da mesma forma que o texto fonte, tornando-se “DAG JANP” após o hash (“dog” tem o mesmo hash que “dogs”, como acontece com “jumps” e "pular").
Também adicionamos alguma lógica entre as palavras individuais na string (com base nas configurações), escolhendo entre (“DAG” E “JANP”) ou (“DAG” OU “JANP”). O primeiro retorna a interseção de [0] & [0, 1]
(ou seja, documento 0) e o último, [0] | [0, 1]
[0] | [0, 1]
(ou seja, documentos 0 e 1). As posições no texto podem ser usadas para pontuar resultados e consultas dependentes de posição.