Elasticsearch pentru Ruby on Rails: Un tutorial pentru bijuteriile mestecate

Publicat: 2022-03-11

Elasticsearch oferă o interfață HTTP puternică, RESTful pentru indexarea și interogarea datelor, construită pe baza bibliotecii Apache Lucene. Ieșit din cutie, oferă o căutare scalabilă, eficientă și robustă, cu suport UTF-8. Este un instrument puternic pentru indexarea și interogarea unor cantități masive de date structurate și, aici, la Toptal, stimulează căutarea pe platforma noastră și va fi folosit în curând și pentru completarea automată. Suntem mari fani.

Chewy extinde clientul Elasticsearch-Ruby, făcându-l mai puternic și oferind o integrare mai strânsă cu Rails.

Deoarece platforma noastră este construită folosind Ruby on Rails, integrarea noastră a Elasticsearch profită de proiectul elasticsearch-ruby (un cadru de integrare Ruby pentru Elasticsearch care oferă un client pentru conectarea la un cluster Elasticsearch, un API Ruby pentru API-ul REST al Elasticsearch și diverse extensii și utilități). Pe această bază, am dezvoltat și lansat propria noastră îmbunătățire (și simplificare) a arhitecturii de căutare a aplicației Elasticsearch, ambalată ca o bijuterie Ruby pe care am numit-o Chewy (cu un exemplu de aplicație disponibil aici).

Chewy extinde clientul Elasticsearch-Ruby, făcându-l mai puternic și oferind o integrare mai strânsă cu Rails. În acest ghid Elasticsearch, discut (prin exemple de utilizare) cum am realizat acest lucru, inclusiv obstacolele tehnice care au apărut în timpul implementării.

Relația dintre Elasticsearch și Ruby on Rails este descrisă în acest ghid vizual.

Doar câteva note rapide înainte de a trece la ghid:

  • Atât Chewy, cât și o aplicație demonstrativă Chewy sunt disponibile pe GitHub.
  • Pentru cei interesați de mai multe informații „sub capotă” despre Elasticsearch, am inclus un scurt articol ca anexă la această postare.

De ce Chewy?

În ciuda scalabilității și eficienței Elasticsearch, integrarea acestuia cu Rails nu s-a dovedit a fi chiar atât de simplă pe cât se anticipase. La Toptal, ne-am trezit nevoia să creștem semnificativ clientul de bază Elasticsearch-Ruby pentru a-l face mai performant și pentru a sprijini operațiuni suplimentare.

În ciuda scalabilității și eficienței Elasticsearch, integrarea acestuia cu Rails nu s-a dovedit a fi chiar atât de simplă pe cât se anticipase.

Și astfel s-a născut bijuteria Chewy.

Câteva caracteristici deosebit de remarcabile ale Chewy includ:

  1. Fiecare indice este observabil de către toate modelele aferente.

    Cele mai multe modele indexate sunt legate între ele. Și uneori, este necesar să denormalizați aceste date asociate și să le legați de același obiect (de exemplu, dacă doriți să indexați o serie de etichete împreună cu articolul asociat). Chewy vă permite să specificați un index actualizabil pentru fiecare model, astfel încât articolele corespunzătoare vor fi reindexate ori de câte ori o etichetă relevantă este actualizată.

  2. Clasele de index sunt independente de modelele ORM/ODM.

    Cu această îmbunătățire, implementarea completării automate între modele, de exemplu, este mult mai ușoară. Puteți doar să definiți un index și să lucrați cu el în mod orientat pe obiecte. Spre deosebire de alți clienți, bijuteria Chewy elimină nevoia de a implementa manual clase de index, apeluri de import de date și alte componente.

  3. Importul în vrac este peste tot .

    Chewy utilizează API-ul Elasticsearch în bloc pentru reindexare completă și actualizări de index. De asemenea, utilizează conceptul de actualizări atomice, colectând obiecte modificate într-un bloc atomic și actualizându-le pe toate simultan.

  4. Chewy oferă un DSL de interogare în stil AR.

    Fiind înlănțuită, fuzionabilă și leneșă, această îmbunătățire permite ca interogările să fie produse într-un mod mai eficient.

OK, să vedem cum se desfășoară totul în bijuterie...

Ghidul de bază pentru Elasticsearch

Elasticsearch are mai multe concepte legate de documente. Primul este cel al unui index (analogul unei baze de database în RDBMS), care constă dintr-un set de documents , care pot fi de mai multe types (unde un type este un fel de tabel RDBMS).

Fiecare document are un set de fields . Fiecare câmp este analizat independent și opțiunile sale de analiză sunt stocate în mapping pentru tipul său. Chewy utilizează această structură „ca atare” în modelul său obiect:

 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

Mai sus, am definit un index Elasticsearch numit entertainment cu trei tipuri: book , movie și cartoon . Pentru fiecare tip, am definit niște mapări de câmp și un hash de setări pentru întregul index.

Deci, am definit EntertainmentIndex și vrem să executăm câteva interogări. Ca prim pas, trebuie să creăm indexul și să ne importăm datele:

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

Metoda .import este conștientă de datele importate, deoarece am trecut în domenii când ne-am definit tipurile; astfel, va importa toate cărțile, filmele și desenele animate stocate în stocarea persistentă.

După aceasta, putem efectua câteva interogări:

 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

Acum indexul nostru este aproape gata pentru a fi folosit în implementarea căutării noastre.

Integrarea șinelor

Pentru integrarea cu Rails, primul lucru de care avem nevoie este să putem reacționa la modificările obiectelor RDBMS. Chewy acceptă acest comportament prin apeluri inverse definite în metoda clasei update_index . update_index are două argumente:

  1. Un identificator de tip furnizat în formatul "index_name#type_name" .
  2. Un nume de metodă sau un bloc de executat, care reprezintă o referință înapoi la obiectul sau colecția de obiecte actualizate

Trebuie să definim aceste apeluri inverse pentru fiecare model dependent:

 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

Deoarece etichetele sunt, de asemenea, indexate, în continuare trebuie să corectăm câteva modele externe, astfel încât acestea să reacționeze la modificări:

 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

În acest moment, fiecare obiect salvat sau distrus va actualiza tipul de index Elasticsearch corespunzător.

Atomicitatea

Mai avem o problemă persistentă. Dacă facem ceva de genul books.map(&:save) pentru a salva mai multe cărți, vom solicita o actualizare a indexului de entertainment de fiecare dată când o carte individuală este salvată . Astfel, dacă salvăm cinci cărți, vom actualiza indexul Chewy de cinci ori. Acest comportament este acceptabil pentru REPL, dar cu siguranță nu este acceptabil pentru acțiunile controlerului în care performanța este critică.

Abordăm această problemă cu blocul Chewy.atomic :

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

Pe scurt, Chewy.atomic aceste actualizări după cum urmează:

  1. Dezactivează apelul after_save .
  2. Colectează ID-urile cărților salvate.
  3. La finalizarea blocului Chewy.atomic , folosește ID-urile colectate pentru a face o singură solicitare de actualizare a indexului Elasticsearch.

In cautarea

Acum suntem gata să implementăm o interfață de căutare. Întrucât interfața noastră cu utilizatorul este un formular, cel mai bun mod de al construi este, desigur, cu FormBuilder și ActiveModel. (La Toptal, folosim ActiveData pentru a implementa interfețele ActiveModel, dar nu ezitați să folosiți bijuteria preferată.)

 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 interogări și filtre

Acum că avem un obiect asemănător ActiveModel care poate accepta și tipifica atribute, să implementăm căutarea:

 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

Controlere și vederi

În acest moment, modelul nostru poate efectua cereri de căutare cu atribute transmise. Utilizarea va arăta cam așa:

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

Rețineți că în controler, dorim să încărcăm obiecte ActiveRecord exacte în loc de ambalaje de documente 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

Acum, este timpul să scrieți niște HAML la 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

Triere

Ca bonus, vom adăuga și sortarea la funcționalitatea noastră de căutare.

Să presupunem că trebuie să sortăm câmpurile titlu și an, precum și după relevanță. Din păcate, titlul One Flew Over the Cuckoo's Nest va fi împărțit în termeni individuali, așa că sortarea după acești termeni disparați va fi prea aleatorie; în schimb, am dori să sortăm după întregul titlu.

Soluția este să folosiți un câmp de titlu special și să aplicați propriul analizor:

 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

În plus, vom adăuga atât aceste noi atribute, cât și pasul de procesare a sortării modelului nostru de căutare:

 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

În cele din urmă, vom modifica formularul adăugând caseta de selecție a opțiunilor de sortare:

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

Eroare de manipulare

Dacă utilizatorii dvs. efectuează interogări incorecte, cum ar fi ( sau AND , clientul Elasticsearch va genera o eroare. Pentru a gestiona asta, să facem câteva modificări controlerului nostru:

 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

Mai mult, trebuie să redăm eroarea în vizualizare:

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

Testarea interogărilor Elasticsearch

Configurația de bază a testării este următoarea:

  1. Porniți serverul Elasticsearch.
  2. Curățați și creați indicii noștri.
  3. Importați datele noastre.
  4. Efectuați interogarea noastră.
  5. Comparați rezultatul cu așteptările noastre.

Pentru pasul 1, este convenabil să utilizați clusterul de testare definit în bijuteria elasticsearch-extensions. Doar adăugați următoarea linie la instalarea post-gem Rakefile a proiectului dvs.:

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

Apoi, veți obține următoarele sarcini Rake:

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

Elasticsearch și Rspec

În primul rând, trebuie să ne asigurăm că indexul nostru este actualizat pentru a fi sincronizat cu modificările noastre de date. Din fericire, bijuteria Chewy vine cu update_index rspec matcher:

 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

În continuare, trebuie să testăm dacă interogările de căutare efective sunt efectuate corect și că returnează rezultatele așteptate:

 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

Testați depanarea clusterului

În cele din urmă, iată un ghid pentru depanarea cluster-ului dvs. de testare:

  • Pentru a începe, utilizați un cluster cu un singur nod în memorie. Va fi mult mai rapid pentru specificații. În cazul nostru: TEST_CLUSTER_NODES=1 rake elasticsearch:start

  • Există unele probleme existente cu implementarea clusterului de testare elasticsearch-extensions în sine legate de verificarea stării clusterului cu un singur nod (este galben în unele cazuri și nu va fi niciodată verde, așa că verificarea de pornire a clusterului cu starea verde va eșua de fiecare dată). Problema a fost rezolvată într-o furcă, dar sperăm că va fi rezolvată în curând în repo principal.

  • Pentru fiecare set de date, grupați solicitarea în specificații (adică, importați datele o dată și apoi efectuați mai multe solicitări). Elasticsearch se încălzește mult timp și folosește multă memorie heap în timpul importului de date, așa că nu exagerați, mai ales dacă aveți o grămadă de specificații.

  • Asigurați-vă că mașina dvs. are suficientă memorie sau Elasticsearch se va îngheța (am avut nevoie de aproximativ 5 GB pentru fiecare mașină virtuală de testare și aproximativ 1 GB pentru Elasticsearch în sine).

Încheierea

Elasticsearch este autodescris ca „un motor de căutare și analiză flexibil și puternic deschis, distribuit, în timp real”. Este standardul de aur în tehnologiile de căutare.

Cu Chewy, dezvoltatorii noștri de șine au împachetat aceste beneficii ca o bijuterie Ruby simplă, ușor de utilizat, de calitate de producție, open source, care oferă o integrare strânsă cu Rails. Elasticsearch și Rails – ce combinație minunată!

Elasticsearch și Rails -- ce combinație minunată!
Tweet


Anexă: elemente interne Elasticsearch

Iată o foarte scurtă introducere în Elasticsearch „sub capotă”...

Elasticsearch este construit pe Lucene, care el însuși folosește indici inversați ca structură de date primară. De exemplu, dacă avem șirurile „câinii sar sus”, „sari peste gard” și „gardul era prea sus”, obținem următoarea structură:

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

Astfel, fiecare termen conține atât referințe la, cât și poziții în text. În plus, alegem să ne modificăm termenii (de exemplu, prin eliminarea cuvintelor oprite precum „the”) și să aplicăm hashing fonetic fiecărui termen (puteți ghici algoritmul?):

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

Dacă interogăm apoi „câinele sare”, acesta este analizat în același mod ca și textul sursă, devenind „DAG JANP” după hashing („câinele” are același hash ca „câinii”, așa cum este adevărat cu „sărituri” și "a sari").

Adăugăm, de asemenea, ceva logică între cuvintele individuale din șir (pe baza setărilor de configurare), alegând între (“DAG” ȘI “JANP”) sau (“DAG” SAU “JANP”). Primul returnează intersecția dintre [0] & [0, 1] (adică, documentul 0), iar cel din urmă, [0] | [0, 1] [0] | [0, 1] (adică documentele 0 și 1). Pozițiile din text pot fi utilizate pentru notarea rezultatelor și interogări dependente de poziție.