Elasticsearch per Ruby on Rails: un tutorial per la gemma gommosa
Pubblicato: 2022-03-11Elasticsearch fornisce una potente interfaccia HTTP RESTful per l'indicizzazione e l'esecuzione di query sui dati, basata sulla libreria Apache Lucene. Immediato, fornisce una ricerca scalabile, efficiente e robusta, con supporto UTF-8. È un potente strumento per l'indicizzazione e l'interrogazione di enormi quantità di dati strutturati e, qui in Toptal, alimenta la ricerca della nostra piattaforma e sarà presto utilizzato anche per il completamento automatico. Siamo grandi fan.
Poiché la nostra piattaforma è costruita utilizzando Ruby on Rails, la nostra integrazione di Elasticsearch sfrutta il progetto elasticsearch-ruby (un framework di integrazione Ruby per Elasticsearch che fornisce un client per la connessione a un cluster Elasticsearch, un'API Ruby per l'API REST di Elasticsearch e varie estensioni e utenze). Basandosi su queste basi, abbiamo sviluppato e rilasciato il nostro miglioramento (e semplificazione) dell'architettura di ricerca dell'applicazione Elasticsearch, confezionato come una gemma Ruby che abbiamo chiamato Chewy (con un'app di esempio disponibile qui).
Chewy estende il client Elasticsearch-Ruby, rendendolo più potente e fornendo una più stretta integrazione con Rails. In questa guida di Elasticsearch, discuto (attraverso esempi di utilizzo) come abbiamo raggiunto questo obiettivo, inclusi gli ostacoli tecnici emersi durante l'implementazione.
Solo un paio di brevi note prima di procedere alla guida:
- Sia Chewy che un'applicazione demo Chewy sono disponibili su GitHub.
- Per coloro che sono interessati a maggiori informazioni "sotto il cofano" su Elasticsearch, ho incluso un breve articolo come appendice a questo post.
Perché Gommoso?
Nonostante la scalabilità e l'efficienza di Elasticsearch, integrarlo con Rails non si è rivelato così semplice come previsto. In Toptal, ci siamo trovati a dover aumentare in modo significativo il client Elasticsearch-Ruby di base per renderlo più performante e supportare operazioni aggiuntive.
E così è nata la gemma Chewy.
Alcune caratteristiche particolarmente degne di nota di Chewy includono:
Ogni indice è osservabile da tutti i relativi modelli.
La maggior parte dei modelli indicizzati sono correlati tra loro. E a volte, è necessario denormalizzare questi dati correlati e associarli allo stesso oggetto (ad esempio, se vuoi indicizzare un array di tag insieme al loro articolo associato). Chewy ti consente di specificare un indice aggiornabile per ogni modello, quindi gli articoli corrispondenti verranno reindicizzati ogni volta che viene aggiornato un tag pertinente.
Le classi di indici sono indipendenti dai modelli ORM/ODM.
Con questo miglioramento, l'implementazione del completamento automatico tra modelli, ad esempio, è molto più semplice. Puoi semplicemente definire un indice e lavorarci in modo orientato agli oggetti. A differenza di altri client, Chewy gem elimina la necessità di implementare manualmente classi di indici, callback di importazione dati e altri componenti.
L'importazione in blocco è ovunque .
Chewy utilizza l'API di massa Elasticsearch per la reindicizzazione completa e gli aggiornamenti dell'indice. Utilizza anche il concetto di aggiornamenti atomici, raccogliendo oggetti modificati all'interno di un blocco atomico e aggiornandoli tutti in una volta.
Chewy fornisce una query DSL in stile AR.
Essendo concatenabile, unificabile e pigro, questo miglioramento consente di produrre query in modo più efficiente.
OK, quindi vediamo come va a finire tutto questo nella gemma...
La guida di base a Elasticsearch
Elasticsearch ha diversi concetti relativi ai documenti. La prima è quella di un index
(l'analogo di un database
in RDBMS), che consiste in un insieme di documents
, che possono essere di diversi types
(dove un type
è una specie di tabella RDBMS).
Ogni documento ha una serie di fields
. Ogni campo viene analizzato in modo indipendente e le sue opzioni di analisi sono memorizzate nella mapping
per il suo tipo. Chewy utilizza questa struttura "così com'è" nel suo modello a oggetti:
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
Sopra, abbiamo definito un indice Elasticsearch chiamato entertainment
con tre tipi: book
, movie
e cartoon
. Per ogni tipo, abbiamo definito alcune mappature dei campi e un hash di impostazioni per l'intero indice.
Quindi, abbiamo definito EntertainmentIndex
e vogliamo eseguire alcune query. Come primo passo, dobbiamo creare l'indice e importare i nostri dati:
EntertainmentIndex.create! EntertainmentIndex.import # EntertainmentIndex.reset! (which includes deletion, # creation, and import) could be used instead
Il metodo .import
è a conoscenza dei dati importati perché abbiamo passato gli ambiti quando abbiamo definito i nostri tipi; quindi, importerà tutti i libri, i film e i cartoni animati archiviati nella memoria permanente.
Fatto ciò, possiamo eseguire alcune query:
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
Ora il nostro indice è quasi pronto per essere utilizzato nella nostra implementazione di ricerca.
Integrazione delle rotaie
Per l'integrazione con Rails, la prima cosa di cui abbiamo bisogno è essere in grado di reagire alle modifiche agli oggetti RDBMS. Chewy supporta questo comportamento tramite callback definiti all'interno del metodo della classe update_index
. update_index
accetta due argomenti:
- Un identificatore di tipo fornito nel formato
"index_name#type_name"
. - Un nome di metodo o un blocco da eseguire, che rappresenta un riferimento a ritroso all'oggetto o alla raccolta di oggetti aggiornati
Dobbiamo definire questi callback per ogni modello dipendente:
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
Poiché anche i tag sono indicizzati, è necessario applicare una patch di scimmia ad alcuni modelli esterni in modo che reagiscano alle modifiche:
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
A questo punto, ogni oggetto salvato o distrutto aggiornerà il tipo di indice Elasticsearch corrispondente.
Atomicita
Abbiamo ancora un problema persistente. Se eseguiamo qualcosa come books.map(&:save)
per salvare più libri, richiederemo un aggiornamento dell'indice di entertainment
ogni volta che viene salvato un singolo libro . Quindi, se salviamo cinque libri, aggiorneremo l'indice Chewy cinque volte. Questo comportamento è accettabile per REPL, ma certamente non accettabile per le azioni del controller in cui le prestazioni sono critiche.
Affrontiamo questo problema con il blocco Chewy.atomic
:
class ApplicationController < ActionController::Base around_action { |&block| Chewy.atomic(&block) } end
In breve, Chewy.atomic
questi aggiornamenti come segue:
- Disabilita la richiamata
after_save
. - Raccoglie gli ID dei libri salvati.
- Al completamento del blocco
Chewy.atomic
, utilizza gli ID raccolti per effettuare un'unica richiesta di aggiornamento dell'indice Elasticsearch.
Ricerca
Ora siamo pronti per implementare un'interfaccia di ricerca. Poiché la nostra interfaccia utente è un modulo, il modo migliore per crearlo è, ovviamente, con FormBuilder e ActiveModel. (In Toptal, utilizziamo ActiveData per implementare le interfacce ActiveModel, ma sentiti libero di usare la tua gemma preferita.)
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 su query e filtri
Ora che abbiamo un oggetto simile ad ActiveModel che può accettare e typecast attributi, implementiamo la ricerca:
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
Controller e visualizzazioni
A questo punto, il nostro modello può eseguire richieste di ricerca con attributi passati. L'utilizzo sarà simile a:

EntertainmentSearch.new(query: 'Tarantino', min_year: 1990).search
Si noti che nel controller, vogliamo caricare oggetti ActiveRecord esatti invece di wrapper di documenti 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
Ora è il momento di scrivere un po' di HAML su 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
Ordinamento
Come bonus, aggiungeremo anche l'ordinamento alla nostra funzionalità di ricerca.
Si supponga di dover ordinare i campi del titolo e dell'anno, nonché per pertinenza. Sfortunatamente, il titolo One Flew Over the Cuckoo's Nest
sarà suddiviso in termini individuali, quindi l'ordinamento in base a questi termini disparati sarà troppo casuale; invece, vorremmo ordinare per l'intero titolo.
La soluzione è utilizzare un campo del titolo speciale e applicare il proprio analizzatore:
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
Inoltre, aggiungeremo sia questi nuovi attributi che la fase di elaborazione dell'ordinamento al nostro modello di ricerca:
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
Infine, modificheremo il nostro modulo aggiungendo la casella di selezione delle opzioni di ordinamento:
= 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 ...
Gestione degli errori
Se i tuoi utenti eseguono query errate come (
o AND
, il client Elasticsearch genererà un errore. Per gestirlo, apportiamo alcune modifiche al nostro controller:
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
Inoltre, dobbiamo rendere l'errore nella vista:
... - if @entertainments.any? ... - else - if @error = @error - else Nothing to see here
Test delle query Elasticsearch
La configurazione di base del test è la seguente:
- Avvia il server Elasticsearch.
- Pulisci e crea i nostri indici.
- Importa i nostri dati.
- Esegui la nostra richiesta.
- Incrocia il risultato con le nostre aspettative.
Per il passaggio 1, è conveniente utilizzare il cluster di test definito nella gem elasticsearch-extensions. Basta aggiungere la seguente riga all'installazione post-gem di Rakefile
del tuo progetto:
require 'elasticsearch/extensions/test/cluster/tasks'
Quindi, otterrai le seguenti attività di Rake:
$ rake -T elasticsearch rake elasticsearch:start # Start Elasticsearch cluster for tests rake elasticsearch:stop # Stop Elasticsearch cluster for tests
Elasticsearch e Rspec
Innanzitutto, dobbiamo assicurarci che il nostro indice sia aggiornato per essere sincronizzato con le modifiche ai dati. Fortunatamente, la gemma Chewy viene fornita con 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
Successivamente, dobbiamo verificare che le query di ricerca effettive vengano eseguite correttamente e che restituiscano i risultati attesi:
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
Risoluzione dei problemi del cluster di test
Infine, ecco una guida per la risoluzione dei problemi del cluster di test:
Per iniziare, usa un cluster in memoria a un nodo. Sarà molto più veloce per le specifiche. Nel nostro caso:
TEST_CLUSTER_NODES=1 rake elasticsearch:start
Esistono alcuni problemi esistenti con l'implementazione del cluster di test
elasticsearch-extensions
relativa al controllo dello stato del cluster a un nodo (in alcuni casi è giallo e non sarà mai verde, quindi il controllo di avvio del cluster con stato verde fallirà ogni volta). Il problema è stato risolto in un fork, ma si spera che venga risolto presto nel repository principale.Per ogni set di dati, raggruppa la tua richiesta in specifiche (ad esempio, importa i tuoi dati una volta e poi esegui più richieste). Elasticsearch si riscalda per molto tempo e utilizza molta memoria heap durante l'importazione dei dati, quindi non esagerare, soprattutto se hai un sacco di specifiche.
Assicurati che la tua macchina abbia memoria sufficiente o Elasticsearch si bloccherà (abbiamo richiesto circa 5 GB per ogni macchina virtuale di test e circa 1 GB per Elasticsearch stesso).
Avvolgendo
Elasticsearch si autodefinisce "un motore di ricerca e analisi open source, distribuito, in tempo reale flessibile e potente". È il gold standard nelle tecnologie di ricerca.
Con Chewy, i nostri sviluppatori di rails hanno confezionato questi vantaggi come una gemma Ruby open source semplice, facile da usare, di qualità di produzione che fornisce una stretta integrazione con Rails. Elasticsearch e Rails: che combinazione fantastica!
Appendice: interni di Elasticsearch
Ecco una brevissima introduzione a Elasticsearch “under the hood”…
Elasticsearch è basato su Lucene, che a sua volta utilizza gli indici invertiti come struttura dati primaria. Ad esempio, se abbiamo le stringhe "i cani saltano in alto", "salta oltre il recinto" e "il recinto era troppo alto", otteniamo la seguente struttura:
"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]
Pertanto, ogni termine contiene sia riferimenti che posizioni nel testo. Inoltre, scegliamo di modificare i nostri termini (ad esempio, rimuovendo le parole non significative come "il") e di applicare l'hashing fonetico a ogni termine (riuscite a indovinare l'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 quindi interroghiamo "il cane salta", viene analizzato allo stesso modo del testo di partenza, diventando "DAG JANP" dopo l'hashing ("cane" ha lo stesso hash di "cani", come è vero con "salti" e "salto").
Aggiungiamo anche una logica tra le singole parole nella stringa (in base alle impostazioni di configurazione), scegliendo tra ("DAG" AND "JANP") o ("DAG" OR "JANP"). Il primo restituisce l'intersezione di [0] & [0, 1]
(cioè il documento 0) e il secondo, [0] | [0, 1]
[0] | [0, 1]
(cioè documenti 0 e 1). Le posizioni nel testo possono essere utilizzate per il punteggio dei risultati e per le query dipendenti dalla posizione.