Elasticsearch para Ruby on Rails: un tutorial de Chewy Gem

Publicado: 2022-03-11

Elasticsearch proporciona una potente interfaz RESTful HTTP para indexar y consultar datos, construida sobre la biblioteca Apache Lucene. Desde el primer momento, proporciona una búsqueda escalable, eficiente y robusta, con soporte UTF-8. Es una herramienta poderosa para indexar y consultar cantidades masivas de datos estructurados y, aquí en Toptal, impulsa nuestra búsqueda en la plataforma y pronto se usará también para el autocompletado. Somos grandes fans.

Chewy amplía el cliente de Elasticsearch-Ruby, haciéndolo más potente y proporcionando una integración más estrecha con Rails.

Dado que nuestra plataforma está construida con Ruby on Rails, nuestra integración de Elasticsearch aprovecha el proyecto elasticsearch-ruby (un marco de integración de Ruby para Elasticsearch que proporciona un cliente para conectarse a un clúster de Elasticsearch, una API de Ruby para la API REST de Elasticsearch y diversas extensiones y utilidades). Sobre esta base, desarrollamos y lanzamos nuestra propia mejora (y simplificación) de la arquitectura de búsqueda de aplicaciones de Elasticsearch, empaquetada como una gema de Ruby que llamamos Chewy (con una aplicación de ejemplo disponible aquí).

Chewy amplía el cliente de Elasticsearch-Ruby, haciéndolo más potente y proporcionando una integración más estrecha con Rails. En esta guía de Elasticsearch, discuto (a través de ejemplos de uso) cómo logramos esto, incluidos los obstáculos técnicos que surgieron durante la implementación.

La relación entre Elasticsearch y Ruby on Rails se describe en esta guía visual.

Solo un par de notas rápidas antes de continuar con la guía:

  • Tanto Chewy como una aplicación de demostración de Chewy están disponibles en GitHub.
  • Para aquellos interesados ​​en obtener más información "bajo el capó" sobre Elasticsearch, he incluido un breve artículo como Apéndice de esta publicación.

¿Por qué masticable?

A pesar de la escalabilidad y eficiencia de Elasticsearch, integrarlo con Rails no resultó ser tan simple como se esperaba. En Toptal, nos vimos en la necesidad de aumentar significativamente el cliente básico de Elasticsearch-Ruby para hacerlo más eficaz y admitir operaciones adicionales.

A pesar de la escalabilidad y eficiencia de Elasticsearch, integrarlo con Rails no resultó ser tan simple como se esperaba.

Y así nació la gema Chewy.

Algunas características particularmente notables de Chewy incluyen:

  1. Cada índice es observable por todos los modelos relacionados.

    La mayoría de los modelos indexados están relacionados entre sí. Y, a veces, es necesario desnormalizar estos datos relacionados y vincularlos al mismo objeto (por ejemplo, si desea indexar una matriz de etiquetas junto con su artículo asociado). Chewy le permite especificar un índice actualizable para cada modelo, por lo que los artículos correspondientes se volverán a indexar cada vez que se actualice una etiqueta relevante.

  2. Las clases de índice son independientes de los modelos ORM/ODM.

    Con esta mejora, implementar el autocompletado entre modelos, por ejemplo, es mucho más fácil. Puede simplemente definir un índice y trabajar con él de forma orientada a objetos. A diferencia de otros clientes, la gema Chewy elimina la necesidad de implementar manualmente clases de índice, devoluciones de llamada de importación de datos y otros componentes.

  3. La importación a granel está en todas partes .

    Chewy utiliza la API masiva de Elasticsearch para una reindexación completa y actualizaciones de índices. También utiliza el concepto de actualizaciones atómicas, recopilando objetos cambiados dentro de un bloque atómico y actualizándolos todos a la vez.

  4. Chewy proporciona un DSL de consulta de estilo AR.

    Al ser encadenable, fusionable y perezoso, esta mejora permite generar consultas de una manera más eficiente.

Bien, veamos cómo se desarrolla todo esto en la gema...

La guía básica de Elasticsearch

Elasticsearch tiene varios conceptos relacionados con documentos. El primero es el de un index (el análogo de una base de database en RDBMS), que consta de un conjunto de documents , que pueden ser de varios types (donde un type es una especie de tabla RDBMS).

Cada documento tiene un conjunto de fields . Cada campo se analiza de forma independiente y sus opciones de análisis se almacenan en el mapping para su tipo. Chewy utiliza esta estructura "tal cual" en su 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

Arriba, definimos un índice de Elasticsearch llamado entertainment con tres tipos: book , movie y cartoon . Para cada tipo, definimos algunas asignaciones de campo y un hash de configuraciones para todo el índice.

Entonces, hemos definido EntertainmentIndex y queremos ejecutar algunas consultas. Como primer paso, necesitamos crear el índice e importar nuestros datos:

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

El método .import es consciente de los datos importados porque pasamos los ámbitos cuando definimos nuestros tipos; por lo tanto, importará todos los libros, películas y dibujos animados almacenados en el almacenamiento persistente.

Una vez hecho esto, podemos realizar algunas 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

Ahora nuestro índice está casi listo para ser utilizado en nuestra implementación de búsqueda.

Integración de rieles

Para la integración con Rails, lo primero que necesitamos es poder reaccionar a los cambios de objetos RDBMS. Chewy admite este comportamiento a través de devoluciones de llamada definidas dentro del método de clase update_index . update_index toma dos argumentos:

  1. Un identificador de tipo proporcionado en el "index_name#type_name"
  2. Un nombre de método o bloque para ejecutar, que representa una referencia inversa al objeto o colección de objetos actualizados.

Necesitamos definir estas devoluciones de llamada para cada modelo dependiente:

 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

Dado que las etiquetas también están indexadas, lo siguiente que debemos hacer es parchear algunos modelos externos para que reaccionen a los cambios:

 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

En este punto, cada objeto que se guarde o destruya actualizará el tipo de índice de Elasticsearch correspondiente.

Atomicidad

Todavía tenemos un problema persistente. Si hacemos algo como books.map(&:save) para guardar varios libros, solicitaremos una actualización del índice de entertainment cada vez que se guarde un libro individual . Así, si guardamos cinco libros, actualizaremos el índice de Chewy cinco veces. Este comportamiento es aceptable para REPL, pero ciertamente no es aceptable para las acciones del controlador en las que el rendimiento es fundamental.

Abordamos este problema con el bloque Chewy.atomic :

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

En resumen, Chewy.atomic estas actualizaciones de la siguiente manera:

  1. Deshabilita la devolución de llamada after_save .
  2. Recopila los ID de los libros guardados.
  3. Al completar el bloque Chewy.atomic , utiliza los ID recopilados para realizar una única solicitud de actualización del índice de Elasticsearch.

buscando

Ahora estamos listos para implementar una interfaz de búsqueda. Dado que nuestra interfaz de usuario es un formulario, la mejor manera de construirlo es, por supuesto, con FormBuilder y ActiveModel. (En Toptal, usamos ActiveData para implementar interfaces ActiveModel, pero no dude en usar su gema 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 consultas y filtros

Ahora que tenemos un objeto similar a ActiveModel que puede aceptar y encasillar atributos, implementemos la búsqueda:

 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 y vistas

En este punto, nuestro modelo puede realizar solicitudes de búsqueda con atributos pasados. El uso se verá algo como:

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

Tenga en cuenta que en el controlador, queremos cargar objetos ActiveRecord exactos en lugar de envoltorios 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

Ahora es el momento de escribir algo de HAML en 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

Clasificación

Como beneficio adicional, también agregaremos clasificación a nuestra función de búsqueda.

Supongamos que necesitamos ordenar los campos de título y año, así como también por relevancia. Desafortunadamente, el título One Flew Over the Cuckoo's Nest se dividirá en términos individuales, por lo que la clasificación por estos términos dispares será demasiado aleatoria; en su lugar, nos gustaría ordenar por el título completo.

La solución es usar un campo de título especial y aplicar su propio analizador:

 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

Además, agregaremos estos nuevos atributos y el paso de procesamiento de clasificación a nuestro modelo de búsqueda:

 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

Finalmente, modificaremos nuestro formulario agregando un cuadro de selección de opciones de clasificación:

 = 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 ...

Manejo de errores

Si sus usuarios realizan consultas incorrectas como ( o AND , el cliente de Elasticsearch generará un error. Para manejar eso, hagamos algunos cambios en nuestro 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

Además, necesitamos representar el error en la vista:

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

Prueba de consultas de Elasticsearch

La configuración básica de prueba es la siguiente:

  1. Inicie el servidor de Elasticsearch.
  2. Limpiar y crear nuestros índices.
  3. Importar nuestros datos.
  4. Realiza nuestra consulta.
  5. Cruzar el resultado con nuestras expectativas.

Para el paso 1, es conveniente usar el clúster de prueba definido en la gema elasticsearch-extensions. Simplemente agregue la siguiente línea a la instalación posterior a la gema Rakefile de su proyecto:

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

Luego, obtendrás las siguientes tareas Rake:

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

Elasticsearch y Rspec

Primero, debemos asegurarnos de que nuestro índice esté actualizado para estar sincronizado con nuestros cambios de datos. Afortunadamente, la gema Chewy viene con el útil update_index 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

A continuación, debemos probar que las consultas de búsqueda reales se realizan correctamente y que arrojan los 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

Solución de problemas del clúster de prueba

Finalmente, aquí hay una guía para solucionar problemas de su clúster de prueba:

  • Para comenzar, use un clúster de un nodo en memoria. Será mucho más rápido para las especificaciones. En nuestro caso: TEST_CLUSTER_NODES=1 rake elasticsearch:start

  • Hay algunos problemas existentes con la implementación del clúster de prueba elasticsearch-extensions relacionados con la verificación del estado del clúster de un nodo (es amarillo en algunos casos y nunca será verde, por lo que la verificación de inicio del clúster de estado verde fallará siempre). El problema se solucionó en una bifurcación, pero es de esperar que se solucione pronto en el repositorio principal.

  • Para cada conjunto de datos, agrupe su solicitud en especificaciones (es decir, importe sus datos una vez y luego realice varias solicitudes). Elasticsearch se calienta durante mucho tiempo y usa una gran cantidad de memoria de almacenamiento dinámico al importar datos, así que no se exceda, especialmente si tiene muchas especificaciones.

  • Asegúrese de que su máquina tenga suficiente memoria o Elasticsearch se congelará (necesitamos alrededor de 5 GB para cada máquina virtual de prueba y alrededor de 1 GB para Elasticsearch).

Terminando

Elasticsearch se describe a sí mismo como "un motor de análisis y búsqueda distribuido en tiempo real flexible y potente de código abierto". Es el estándar de oro en tecnologías de búsqueda.

Con Chewy, nuestros desarrolladores de Rails han empaquetado estos beneficios como una joya de Ruby de código abierto, de calidad de producción, simple y fácil de usar que proporciona una estrecha integración con Rails. Elasticsearch y Rails: ¡qué combinación increíble!

Elasticsearch y Rails: ¡qué combinación increíble!
Pío


Apéndice: elementos internos de Elasticsearch

Aquí hay una breve introducción a Elasticsearch "bajo el capó"...

Elasticsearch se basa en Lucene, que a su vez utiliza índices invertidos como su estructura de datos principal. Por ejemplo, si tenemos las cadenas "los perros saltan alto", "saltan sobre la cerca" y "la cerca era demasiado alta", obtenemos la siguiente estructura:

 "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]

Por lo tanto, cada término contiene referencias y posiciones en el texto. Además, elegimos modificar nuestros términos (p. ej., eliminando palabras vacías como "el") y aplicar hash fonético a cada término (¿puedes adivinar el 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]

Si luego consultamos por "el perro salta", se analiza de la misma manera que el texto fuente, convirtiéndose en "DAG JANP" después del hash ("perro" tiene el mismo hash que "perros", como ocurre con "saltos" y "salto").

También agregamos algo de lógica entre las palabras individuales en la cadena (según los ajustes de configuración), eligiendo entre ("DAG" Y "JANP") o ("DAG" O "JANP"). El primero devuelve la intersección de [0] & [0, 1] (es decir, el documento 0) y el segundo, [0] | [0, 1] [0] | [0, 1] (es decir, documentos 0 y 1). Las posiciones en el texto se pueden usar para calificar resultados y consultas dependientes de la posición.