Elasticsearch dla Ruby on Rails: samouczek do klejnotu do żucia

Opublikowany: 2022-03-11

Elasticsearch zapewnia potężny, RESTful interfejs HTTP do indeksowania i odpytywania danych, zbudowany na bazie biblioteki Apache Lucene. Natychmiast po wyjęciu z pudełka zapewnia skalowalne, wydajne i niezawodne wyszukiwanie z obsługą UTF-8. Jest to potężne narzędzie do indeksowania i odpytywania ogromnych ilości uporządkowanych danych, które w Toptal wspomaga wyszukiwanie na naszej platformie, a wkrótce zostanie również wykorzystane do autouzupełniania. Jesteśmy wielkimi fanami.

Chewy rozszerza klienta Elasticsearch-Ruby, czyniąc go bardziej wydajnym i zapewniając ściślejszą integrację z Rails.

Ponieważ nasza platforma jest zbudowana przy użyciu Ruby on Rails, nasza integracja Elasticsearch wykorzystuje projekt elasticsearch-ruby (struktura integracji Ruby dla Elasticsearch, która zapewnia klienta do łączenia się z klastrem Elasticsearch, API Ruby dla API REST Elasticsearch i różne rozszerzenia i narzędzia). Opierając się na tym fundamencie, opracowaliśmy i wydaliśmy własne ulepszenie (i uproszczenie) architektury wyszukiwania aplikacji Elasticsearch, spakowane jako klejnot Ruby, który nazwaliśmy Chewy (z przykładową aplikacją dostępną tutaj).

Chewy rozszerza klienta Elasticsearch-Ruby, czyniąc go bardziej wydajnym i zapewniając ściślejszą integrację z Rails. W tym przewodniku Elasticsearch omawiam (poprzez przykłady użycia), jak to osiągnęliśmy, w tym przeszkody techniczne, które pojawiły się podczas implementacji.

Związek między Elasticsearch a Ruby on Rails jest przedstawiony w tym wizualnym przewodniku.

Kilka krótkich notatek przed przejściem do przewodnika:

  • Zarówno Chewy, jak i aplikacja demonstracyjna Chewy są dostępne na GitHub.
  • Dla zainteresowanych bardziej „pod maską” informacji o Elasticsearch, zamieściłem krótki opis jako dodatek do tego postu.

Dlaczego żucia?

Pomimo skalowalności i wydajności Elasticsearch, integracja z Rails nie okazała się tak prosta, jak przewidywano. W Toptal stwierdziliśmy, że musimy znacznie rozszerzyć podstawowego klienta Elasticsearch-Ruby, aby był bardziej wydajny i obsługiwał dodatkowe operacje.

Pomimo skalowalności i wydajności Elasticsearch, integracja z Rails nie okazała się tak prosta, jak przewidywano.

I tak narodził się klejnot do żucia.

Kilka szczególnie godnych uwagi cech Chewy to:

  1. Każdy indeks jest obserwowalny przez wszystkie powiązane modele.

    Większość indeksowanych modeli jest ze sobą powiązanych. Czasami konieczne jest zdenormalizowanie powiązanych danych i powiązanie ich z tym samym obiektem (np. jeśli chcesz zindeksować tablicę tagów wraz z powiązanym artykułem). Chewy umożliwia określenie aktualizowalnego indeksu dla każdego modelu, więc odpowiednie artykuły będą ponownie indeksowane za każdym razem, gdy odpowiedni tag zostanie zaktualizowany.

  2. Klasy indeksu są niezależne od modeli ORM/ODM.

    Dzięki temu ulepszeniu implementacja na przykład autouzupełniania między modelami jest znacznie łatwiejsza. Możesz po prostu zdefiniować indeks i pracować z nim w sposób obiektowy. W przeciwieństwie do innych klientów, klejnot Chewy eliminuje potrzebę ręcznego wdrażania klas indeksów, wywołań zwrotnych importu danych i innych komponentów.

  3. Import zbiorczy jest wszędzie .

    Chewy wykorzystuje zbiorczy interfejs API Elasticsearch do pełnego ponownego indeksowania i aktualizacji indeksów. Wykorzystuje również koncepcję aktualizacji atomowych, zbierając zmienione obiekty w bloku atomowym i aktualizując je wszystkie jednocześnie.

  4. Chewy zapewnia DSL zapytania w stylu AR.

    Dzięki możliwości łączenia w łańcuch, łączenia i lenistwa, to ulepszenie umożliwia tworzenie zapytań w bardziej wydajny sposób.

OK, więc zobaczmy, jak to wszystko rozegra się w klejnocie…

Podstawowy przewodnik po Elasticsearch

Elasticsearch ma kilka koncepcji związanych z dokumentami. Pierwszym z nich jest index (odpowiednik database w RDBMS), który składa się z zestawu documents , które mogą być kilku types (gdzie type jest rodzajem tabeli RDBMS).

Każdy dokument posiada zestaw fields . Każde pole jest analizowane niezależnie, a jego opcje analizy są przechowywane w mapping dla jego typu. Chewy wykorzystuje tę strukturę „tak jak jest” w swoim modelu obiektowym:

 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

Powyżej zdefiniowaliśmy indeks Elasticsearch o nazwie entertainment z trzema typami: book , movie i cartoon . Dla każdego typu zdefiniowaliśmy kilka mapowań pól i hash ustawień dla całego indeksu.

Tak więc zdefiniowaliśmy EntertainmentIndex i chcemy wykonać kilka zapytań. W pierwszym kroku musimy utworzyć indeks i zaimportować nasze dane:

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

Metoda .import jest świadoma importowanych danych, ponieważ przekazaliśmy zakresy podczas definiowania naszych typów; w ten sposób zaimportuje wszystkie książki, filmy i kreskówki przechowywane w pamięci trwałej.

Po wykonaniu tych czynności możemy wykonać kilka zapytań:

 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

Teraz nasz indeks jest prawie gotowy do użycia w naszej implementacji wyszukiwania.

Integracja z szynami

Do integracji z Rails pierwszą rzeczą, jakiej potrzebujemy, jest możliwość reagowania na zmiany obiektów RDBMS. Chewy obsługuje to zachowanie za pośrednictwem wywołań zwrotnych zdefiniowanych w metodzie klasy update_index . update_index przyjmuje dwa argumenty:

  1. Identyfikator typu podany w formacie "index_name#type_name"
  2. Nazwa metody lub blok do wykonania, który reprezentuje odniesienie wsteczne do zaktualizowanego obiektu lub kolekcji obiektów

Musimy zdefiniować te wywołania zwrotne dla każdego modelu zależnego:

 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

Ponieważ tagi są również indeksowane, musimy następnie załatać niektóre modele zewnętrzne, aby reagowały na zmiany:

 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

W tym momencie każdy zapis lub zniszczenie obiektu zaktualizuje odpowiedni typ indeksu Elasticsearch.

Atomowość

Wciąż mamy jeden uporczywy problem. Jeśli zrobimy coś takiego jak books.map(&:save) , aby zapisać wiele książek, po każdym zapisaniu pojedynczej książki poprosimy o aktualizację indeksu entertainment . Tak więc, jeśli zapiszemy pięć książek, zaktualizujemy indeks Chewy pięć razy. To zachowanie jest dopuszczalne w przypadku REPL, ale z pewnością nie jest dopuszczalne w przypadku akcji kontrolera, w których wydajność jest krytyczna.

Rozwiązujemy ten problem za pomocą bloku Chewy.atomic :

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

W skrócie, Chewy.atomic te aktualizacje w następujący sposób:

  1. Wyłącza wywołanie zwrotne after_save .
  2. Zbiera identyfikatory zapisanych ksiąg.
  3. Po zakończeniu bloku Chewy.atomic wykorzystuje zebrane identyfikatory do wykonania pojedynczego żądania aktualizacji indeksu Elasticsearch.

Badawczy

Teraz jesteśmy gotowi do wdrożenia interfejsu wyszukiwania. Ponieważ nasz interfejs użytkownika jest formularzem, najlepszym sposobem na jego zbudowanie jest oczywiście za pomocą FormBuilder i ActiveModel. (W Toptal używamy ActiveData do implementacji interfejsów ActiveModel, ale możesz swobodnie używać swojego ulubionego klejnotu).

 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

Samouczek dotyczący zapytań i filtrów

Teraz, gdy mamy obiekt podobny do ActiveModel, który może akceptować i rzutować atrybuty, zaimplementujmy wyszukiwanie:

 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

Kontrolery i widoki

W tym momencie nasz model może wykonywać żądania wyszukiwania z przekazanymi atrybutami. Sposób użycia będzie wyglądał mniej więcej tak:

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

Zauważ, że w kontrolerze chcemy załadować dokładne obiekty ActiveRecord zamiast opakowań dokumentów 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

Teraz czas napisać trochę HAML pod adresem 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

Sortowanie

Jako bonus dodamy również sortowanie do naszej funkcji wyszukiwania.

Załóżmy, że musimy sortować według pól tytułu i roku, a także według trafności. Niestety, tytuł One Flew Over the Cuckoo's Nest zostanie podzielony na poszczególne terminy, więc sortowanie według tych odmiennych terminów będzie zbyt przypadkowe; zamiast tego chcielibyśmy posortować według całego tytułu.

Rozwiązaniem jest użycie specjalnego pola tytułu i zastosowanie własnego analizatora:

 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

Ponadto dodamy do naszego modelu wyszukiwania zarówno te nowe atrybuty, jak i etap przetwarzania sortowania:

 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

Na koniec zmodyfikujemy nasz formularz, dodając pole wyboru opcji sortowania:

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

Obsługa błędów

Jeśli Twoi użytkownicy wykonują niepoprawne zapytania, takie jak ( lub AND , klient Elasticsearch zgłosi błąd. Aby sobie z tym poradzić, zmieńmy nasz kontroler:

 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

Dalej musimy wyrenderować błąd w widoku:

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

Testowanie zapytań Elasticsearch

Podstawowa konfiguracja testowania jest następująca:

  1. Uruchom serwer Elasticsearch.
  2. Oczyść i stwórz nasze indeksy.
  3. Importuj nasze dane.
  4. Wykonaj nasze zapytanie.
  5. Porównaj wynik z naszymi oczekiwaniami.

W kroku 1 wygodnie jest użyć klastra testowego zdefiniowanego w gemie elasticsearch-extensions. Po prostu dodaj następujący wiersz do instalacji post-gem projektu Rakefile :

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

Następnie otrzymasz następujące zadania Rake:

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

Elasticsearch i Rspec

Po pierwsze, musimy upewnić się, że nasz indeks jest aktualizowany, aby był zsynchronizowany ze zmianami danych. Na szczęście klejnot do żucia zawiera pomocny program dopasowujący 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

Następnie musimy sprawdzić, czy rzeczywiste zapytania wyszukiwania są wykonywane poprawnie i czy zwracają oczekiwane wyniki:

 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

Rozwiązywanie problemów z klastrem

Na koniec oto przewodnik dotyczący rozwiązywania problemów z klastrem testowym:

  • Aby rozpocząć, użyj jednowęzłowego klastra w pamięci. W przypadku specyfikacji będzie znacznie szybciej. W naszym przypadku: TEST_CLUSTER_NODES=1 rake elasticsearch:start

  • Istnieją pewne problemy z samą implementacją klastra testowego elasticsearch-extensions , które są związane ze sprawdzaniem stanu klastra z jednym węzłem (w niektórych przypadkach jest on żółty i nigdy nie będzie zielony, więc sprawdzenie uruchomienia klastra z zielonym stanem za każdym razem zakończy się niepowodzeniem). Problem został rozwiązany w rozwidleniu, ale miejmy nadzieję, że wkrótce zostanie naprawiony w głównym repozytorium.

  • Dla każdego zestawu danych pogrupuj swoje żądanie w specyfikacje (tj. zaimportuj dane raz, a następnie wykonaj kilka żądań). Elasticsearch długo się nagrzewa i zużywa dużo pamięci sterty podczas importowania danych, więc nie przesadzaj, zwłaszcza jeśli masz sporo specyfikacji.

  • Upewnij się, że twoja maszyna ma wystarczającą ilość pamięci, w przeciwnym razie Elasticsearch się zawiesi (wymagaliśmy około 5 GB dla każdej testowej maszyny wirtualnej i około 1 GB dla samego Elasticsearch).

Zawijanie

Elasticsearch sam siebie opisuje jako „elastyczny i potężny silnik open source, rozproszony, wyszukiwania i analizy w czasie rzeczywistym”. To złoty standard w technologiach wyszukiwania.

Dzięki Chewy, nasi programiści Rails połączyli te korzyści jako prosty, łatwy w użyciu, wysokiej jakości produkt Ruby o wysokiej jakości, który zapewnia ścisłą integrację z Rails. Elasticsearch i Rails – co za niesamowita kombinacja!

Elasticsearch i Rails — co za niesamowita kombinacja!
Ćwierkać


Dodatek: Elementy wewnętrzne Elasticsearch

Oto bardzo krótkie wprowadzenie do Elasticsearch „pod maską”…

Elasticsearch opiera się na Lucene, który sam wykorzystuje odwrócone indeksy jako podstawową strukturę danych. Na przykład, jeśli mamy napisy „psy skaczą wysoko”, „przeskakiwać przez płot” i „płot był za wysoko”, otrzymujemy następującą strukturę:

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

Zatem każdy termin zawiera zarówno odniesienia do tekstu, jak i pozycje w nim. Co więcej, decydujemy się na modyfikację naszych terminów (np. poprzez usunięcie słów przerywających, takich jak „the”) i zastosowanie haszowania fonetycznego do każdego terminu (możesz odgadnąć algorytm?):

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

Jeśli następnie zapytamy o „pies skacze”, zostanie on przeanalizowany w ten sam sposób, co tekst źródłowy, stając się po haszowaniu „DAG JANP” („pies” ma ten sam hasz co „psy”, tak jak w przypadku „skoków” i "skok").

Dodajemy również logikę między poszczególnymi słowami w ciągu (na podstawie ustawień konfiguracyjnych), wybierając między („DAG” I „JANP”) lub („DAG” LUB „JANP”). Pierwsza zwraca przecięcie [0] & [0, 1] (tj. dokument 0), a druga, [0] | [0, 1] [0] | [0, 1] (tj. dokumenty 0 i 1). Pozycje w tekście mogą być używane do oceniania wyników i zapytań zależnych od pozycji.