Elasticsearch für Ruby on Rails: Ein Tutorial zum Chewy Gem

Veröffentlicht: 2022-03-11

Elasticsearch bietet eine leistungsstarke RESTful-HTTP-Schnittstelle zum Indizieren und Abfragen von Daten, die auf der Apache Lucene-Bibliothek aufbaut. Es bietet sofort eine skalierbare, effiziente und robuste Suche mit UTF-8-Unterstützung. Es ist ein leistungsstarkes Tool zum Indizieren und Abfragen großer Mengen strukturierter Daten, und hier bei Toptal unterstützt es unsere Plattformsuche und wird bald auch für die automatische Vervollständigung verwendet. Wir sind große Fans.

Chewy erweitert den Elasticsearch-Ruby-Client, macht ihn leistungsfähiger und bietet eine engere Integration mit Rails.

Da unsere Plattform mit Ruby on Rails erstellt wurde, nutzt unsere Integration von Elasticsearch das Elasticsearch-Ruby-Projekt (ein Ruby-Integrationsframework für Elasticsearch, das einen Client für die Verbindung mit einem Elasticsearch-Cluster, eine Ruby-API für die REST-API von Elasticsearch und verschiedene Erweiterungen und Dienstprogramme). Aufbauend auf dieser Grundlage haben wir unsere eigene Verbesserung (und Vereinfachung) der Elasticsearch-Anwendungssucharchitektur entwickelt und veröffentlicht, verpackt als Ruby-Juwel, das wir Chewy genannt haben (mit einer Beispiel-App, die hier verfügbar ist).

Chewy erweitert den Elasticsearch-Ruby-Client, macht ihn leistungsfähiger und bietet eine engere Integration mit Rails. In diesem Elasticsearch-Leitfaden erörtere ich (anhand von Anwendungsbeispielen), wie wir dies erreicht haben, einschließlich der technischen Hindernisse, die während der Implementierung aufgetreten sind.

Die Beziehung zwischen Elasticsearch und Ruby on Rails wird in diesem visuellen Leitfaden dargestellt.

Nur ein paar kurze Anmerkungen, bevor Sie mit der Anleitung fortfahren:

  • Sowohl Chewy als auch eine Chewy-Demoanwendung sind auf GitHub verfügbar.
  • Für diejenigen, die an mehr „unter der Haube“-Informationen über Elasticsearch interessiert sind, habe ich diesem Beitrag eine kurze Zusammenfassung als Anhang beigefügt.

Warum Zäh?

Trotz der Skalierbarkeit und Effizienz von Elasticsearch gestaltete sich die Integration mit Rails nicht ganz so einfach wie erwartet. Bei Toptal mussten wir den grundlegenden Elasticsearch-Ruby-Client erheblich erweitern, um ihn leistungsfähiger zu machen und zusätzliche Operationen zu unterstützen.

Trotz der Skalierbarkeit und Effizienz von Elasticsearch gestaltete sich die Integration mit Rails nicht ganz so einfach wie erwartet.

Und so wurde das Chewy-Juwel geboren.

Einige besonders bemerkenswerte Eigenschaften von Chewy sind:

  1. Jeder Index ist von allen verwandten Modellen beobachtbar.

    Die meisten indizierten Modelle sind miteinander verwandt. Und manchmal ist es notwendig, diese verwandten Daten zu denormalisieren und an dasselbe Objekt zu binden (z. B. wenn Sie ein Array von Tags zusammen mit dem zugehörigen Artikel indizieren möchten). Mit Chewy können Sie für jedes Modell einen aktualisierbaren Index angeben, sodass entsprechende Artikel immer dann neu indiziert werden, wenn ein relevantes Tag aktualisiert wird.

  2. Indexklassen sind unabhängig von ORM/ODM-Modellen.

    Mit dieser Erweiterung ist beispielsweise die Implementierung von modellübergreifender Autovervollständigung viel einfacher. Sie können einfach einen Index definieren und objektorientiert damit arbeiten. Im Gegensatz zu anderen Clients beseitigt das Chewy-Gem die Notwendigkeit, Indexklassen, Datenimport-Callbacks und andere Komponenten manuell zu implementieren.

  3. Massenimport ist überall .

    Chewy nutzt die Bulk-Elasticsearch-API für vollständige Neuindizierung und Indexaktualisierungen. Es verwendet auch das Konzept der atomaren Aktualisierungen, wobei geänderte Objekte innerhalb eines atomaren Blocks gesammelt und alle auf einmal aktualisiert werden.

  4. Chewy bietet eine Abfrage-DSL im AR-Stil.

    Da diese Erweiterung verkettbar, zusammenführbar und faul ist, können Abfragen effizienter erstellt werden.

OK, also mal sehen, wie sich das alles im Edelstein abspielt …

Der grundlegende Leitfaden zu Elasticsearch

Elasticsearch hat mehrere dokumentbezogene Konzepte. Der erste ist der eines index (das Analogon einer database in RDBMS), der aus einer Reihe von documents besteht, die von mehreren types sein können (wobei ein type eine Art RDBMS-Tabelle ist).

Jedes Dokument hat eine Reihe von fields . Jedes Feld wird unabhängig analysiert und seine Analyseoptionen werden in der mapping für seinen Typ gespeichert. Chewy nutzt diese Struktur „wie sie ist“ in ihrem Objektmodell:

 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

Oben haben wir einen Elasticsearch-Index namens entertainment mit drei Typen definiert: book , movie und cartoon . Für jeden Typ haben wir einige Feldzuordnungen und einen Hash von Einstellungen für den gesamten Index definiert.

Wir haben also den EntertainmentIndex definiert und möchten einige Abfragen ausführen. Als ersten Schritt müssen wir den Index erstellen und unsere Daten importieren:

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

Die .import Methode erkennt importierte Daten, da wir bei der Definition unserer Typen Bereiche übergeben haben; Daher werden alle im persistenten Speicher gespeicherten Bücher, Filme und Cartoons importiert.

Damit können wir einige Abfragen durchführen:

 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

Jetzt ist unser Index fast fertig, um in unserer Suchimplementierung verwendet zu werden.

Rails-Integration

Für die Integration mit Rails müssen wir zunächst auf Änderungen von RDBMS-Objekten reagieren können. Chewy unterstützt dieses Verhalten über Callbacks, die in der update_index definiert sind. update_index zwei Argumente:

  1. Eine Typkennung, die im Format "index_name#type_name"
  2. Ein auszuführender Methodenname oder Block, der einen Rückverweis auf das aktualisierte Objekt oder die aktualisierte Objektsammlung darstellt

Wir müssen diese Rückrufe für jedes abhängige Modell definieren:

 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

Da auch Tags indiziert werden, müssen wir als nächstes einige externe Modelle mit einem Monkey-Patch versehen, damit sie auf Änderungen reagieren:

 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

An diesem Punkt aktualisiert jedes Speichern oder Zerstören von Objekten den entsprechenden Elasticsearch-Indextyp.

Atomizität

Wir haben immer noch ein Problem. Wenn wir so etwas wie books.map(&:save) machen, um mehrere Bücher zu speichern, fordern wir jedes Mal, wenn ein einzelnes Buch gespeichert wird, eine Aktualisierung des entertainment an . Wenn wir also fünf Bücher speichern, aktualisieren wir den Chewy-Index fünfmal. Dieses Verhalten ist für REPL akzeptabel, aber sicherlich nicht akzeptabel für Controller-Aktionen, bei denen die Leistung kritisch ist.

Wir gehen dieses Problem mit dem Chewy.atomic Block an:

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

Kurz gesagt, Chewy.atomic diese Updates wie folgt:

  1. Deaktiviert den after_save Callback.
  2. Sammelt die IDs gespeicherter Bücher.
  3. Verwendet nach Abschluss des Chewy.atomic Blocks die gesammelten IDs, um eine einzelne Anfrage zur Aktualisierung des Elasticsearch-Index zu stellen.

Suchen

Jetzt sind wir bereit, eine Suchschnittstelle zu implementieren. Da unsere Benutzeroberfläche ein Formular ist, bauen wir sie natürlich am besten mit FormBuilder und ActiveModel. (Bei Toptal verwenden wir ActiveData, um ActiveModel-Schnittstellen zu implementieren, aber Sie können gerne Ihr Lieblingsjuwel verwenden.)

 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 zu Abfragen und Filtern

Nachdem wir nun ein ActiveModel-ähnliches Objekt haben, das Attribute akzeptieren und typisieren kann, implementieren wir die Suche:

 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 und Ansichten

An diesem Punkt kann unser Modell Suchanfragen mit übergebenen Attributen durchführen. Die Verwendung sieht in etwa so aus:

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

Beachten Sie, dass wir im Controller anstelle von Chewy -Dokumentumhüllungen exakte ActiveRecord-Objekte laden möchten:

 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

Jetzt ist es an der Zeit, etwas HAML unter entertainment/index.html.haml zu schreiben:

 = 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

Sortierung

Als Bonus fügen wir unserer Suchfunktion auch eine Sortierung hinzu.

Angenommen, wir müssen nach den Titel- und Jahresfeldern sowie nach Relevanz sortieren. Leider wird der Titel „ One Flew Over the Cuckoo's Nest in einzelne Begriffe aufgeteilt, sodass die Sortierung nach diesen unterschiedlichen Begriffen zu willkürlich wäre; Stattdessen möchten wir nach dem gesamten Titel sortieren.

Die Lösung besteht darin, ein spezielles Titelfeld zu verwenden und einen eigenen Analysator anzuwenden:

 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

Darüber hinaus werden wir diese beiden neuen Attribute und den Sortierverarbeitungsschritt zu unserem Suchmodell hinzufügen:

 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

Schließlich ändern wir unser Formular und fügen ein Auswahlfeld für Sortieroptionen hinzu:

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

Fehlerbehandlung

Wenn Ihre Benutzer falsche Abfragen wie ( oder AND ausführen, gibt der Elasticsearch-Client einen Fehler aus. Um damit umzugehen, nehmen wir einige Änderungen an unserem Controller vor:

 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

Außerdem müssen wir den Fehler in der Ansicht rendern:

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

Testen von Elasticsearch-Abfragen

Der grundlegende Testaufbau ist wie folgt:

  1. Starten Sie den Elasticsearch-Server.
  2. Bereinigen und erstellen Sie unsere Indizes.
  3. Importieren Sie unsere Daten.
  4. Führen Sie unsere Abfrage durch.
  5. Vergleichen Sie das Ergebnis mit unseren Erwartungen.

Für Schritt 1 ist es praktisch, den Testcluster zu verwenden, der im Gem Elasticsearch-extensions definiert ist. Fügen Sie einfach die folgende Zeile zur Rakefile -Post-Gem-Installation Ihres Projekts hinzu:

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

Dann erhalten Sie die folgenden Rake-Aufgaben:

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

Elasticsearch und Rspec

Zuerst müssen wir sicherstellen, dass unser Index aktualisiert wird, um mit unseren Datenänderungen synchron zu sein. Glücklicherweise enthält das Chewy-Juwel den hilfreichen 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

Als Nächstes müssen wir testen, ob die tatsächlichen Suchanfragen ordnungsgemäß ausgeführt werden und die erwarteten Ergebnisse zurückgeben:

 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

Problembehandlung für Testcluster

Abschließend finden Sie hier eine Anleitung zur Fehlerbehebung bei Ihrem Testcluster:

  • Verwenden Sie zunächst einen In-Memory-Cluster mit einem Knoten. Es wird viel schneller für Spezifikationen sein. In unserem Fall: TEST_CLUSTER_NODES=1 rake elasticsearch:start

  • Es gibt einige bestehende Probleme mit der elasticsearch-extensions selbst im Zusammenhang mit der Statusprüfung des Clusters mit einem Knoten (in einigen Fällen ist sie gelb und wird nie grün sein, sodass die Startprüfung des Clusters mit grünem Status jedes Mal fehlschlägt). Das Problem wurde in einem Fork behoben, aber hoffentlich wird es bald im Hauptrepo behoben.

  • Gruppieren Sie Ihre Anfrage für jeden Datensatz in Spezifikationen (dh importieren Sie Ihre Daten einmal und führen Sie dann mehrere Anfragen durch). Elasticsearch wärmt sich lange auf und verbraucht beim Importieren von Daten viel Heap-Speicher. Übertreiben Sie es also nicht, insbesondere wenn Sie viele Spezifikationen haben.

  • Stellen Sie sicher, dass Ihr Computer über ausreichend Arbeitsspeicher verfügt, sonst friert Elasticsearch ein (wir benötigten etwa 5 GB für jede virtuelle Testmaschine und etwa 1 GB für Elasticsearch selbst).

Einpacken

Elasticsearch beschreibt sich selbst als „eine flexible und leistungsstarke Open-Source-, verteilte Echtzeit-Such- und Analyse-Engine“. Es ist der Goldstandard bei Suchtechnologien.

Mit Chewy haben unsere Rails-Entwickler diese Vorteile als einfaches, benutzerfreundliches Open-Source-Ruby-Juwel in Produktionsqualität verpackt, das eine enge Integration mit Rails bietet. Elasticsearch und Rails – was für eine tolle Kombination!

Elasticsearch und Rails – was für eine tolle Kombination!
Twittern


Anhang: Internals von Elasticsearch

Hier ist eine sehr kurze Einführung in Elasticsearch „unter der Haube“…

Elasticsearch basiert auf Lucene, das selbst invertierte Indizes als primäre Datenstruktur verwendet. Wenn wir zum Beispiel die Zeichenfolgen „die Hunde springen hoch“, „springen über den Zaun“ und „der Zaun war zu hoch“ haben, erhalten wir die folgende 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]

Somit enthält jeder Begriff sowohl Verweise auf als auch Positionen im Text. Darüber hinaus entscheiden wir uns dafür, unsere Begriffe zu modifizieren (z. B. indem wir Stoppwörter wie „the“) entfernen und phonetisches Hashing auf jeden Begriff anwenden (können Sie den Algorithmus erraten?):

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

Wenn wir dann nach „the dog jumps“ fragen, wird es auf die gleiche Weise wie der Quelltext analysiert und wird nach dem Hashing zu „DAG JANP“ („dog“ has the same hash as „dogs“, wie bei „jumps“ and "springen").

Wir fügen auch etwas Logik zwischen den einzelnen Wörtern in der Zeichenfolge hinzu (basierend auf den Konfigurationseinstellungen), indem wir zwischen („DAG“ UND „JANP“) oder („DAG“ ODER „JANP“) wählen. Ersteres gibt den Schnittpunkt von [0] & [0, 1] (dh Dokument 0) zurück und letzteres [0] | [0, 1] [0] | [0, 1] (dh Dokumente 0 und 1). Die In-Text-Positionen können für Scoring-Ergebnisse und positionsabhängige Abfragen verwendet werden.