Ruby on RailsのElasticsearch:ChewyGemのチュートリアル
公開: 2022-03-11Elasticsearchは、Apache Luceneライブラリの上に構築された、データのインデックス作成とクエリを行うための強力なRESTfulHTTPインターフェイスを提供します。 箱から出してすぐに、UTF-8をサポートする、スケーラブルで効率的で堅牢な検索を提供します。 これは、大量の構造化データのインデックス作成とクエリを行うための強力なツールであり、ここToptalでは、プラットフォーム検索を強化し、間もなくオートコンプリートにも使用されます。 私たちは大ファンです。
プラットフォームはRubyonRailsを使用して構築されているため、Elasticsearchの統合では、elasticsearch-rubyプロジェクト(Elasticsearchクラスターに接続するためのクライアントを提供するElasticsearchのRuby統合フレームワーク、ElasticsearchのRESTAPI用のRubyAPI、およびさまざまな拡張機能とユーティリティ)。 この基盤に基づいて、Elasticsearchアプリケーション検索アーキテクチャの独自の改良(および簡略化)を開発してリリースしました。これは、Chewyという名前のRuby gemとしてパッケージ化されています(サンプルアプリはこちらから入手できます)。
ChewyはElasticsearch-Rubyクライアントを拡張し、より強力にし、Railsとの緊密な統合を提供します。 このElasticsearchガイドでは、実装中に発生した技術的な障害を含め、これをどのように達成したかについて(使用例を通じて)説明します。
ガイドに進む前に、いくつかの簡単なメモ:
- ChewyとChewyデモアプリケーションの両方がGitHubで利用できます。
- Elasticsearchの「内部」情報に興味がある人のために、この投稿の付録として簡単な記事を掲載しました。
なぜ歯ごたえがあるのですか?
Elasticsearchのスケーラビリティと効率性にもかかわらず、Railsとの統合は予想ほど単純ではありませんでした。 Toptalでは、基本的なElasticsearch-Rubyクライアントを大幅に拡張して、パフォーマンスを向上させ、追加の操作をサポートする必要があることに気付きました。
そしてこうして、歯ごたえのある宝石が生まれました。
Chewyの特に注目すべき機能は次のとおりです。
すべてのインデックスは、関連するすべてのモデルで監視できます。
ほとんどのインデックス付きモデルは相互に関連しています。 また、場合によっては、この関連データを非正規化し、同じオブジェクトにバインドする必要があります(たとえば、タグの配列を関連する記事と一緒にインデックス付けする場合)。 Chewyを使用すると、すべてのモデルに更新可能なインデックスを指定できるため、関連するタグが更新されるたびに、対応する記事のインデックスが再作成されます。
インデックスクラスは、ORM/ODMモデルから独立しています。
この機能拡張により、たとえば、クロスモデルのオートコンプリートの実装がはるかに簡単になります。 インデックスを定義して、オブジェクト指向で操作するだけです。 他のクライアントとは異なり、Chewy gemを使用すると、インデックスクラス、データインポートコールバック、およびその他のコンポーネントを手動で実装する必要がなくなります。
一括インポートはどこにでもあります。
Chewyは、一括のElasticsearch APIを利用して、完全なインデックスの再作成とインデックスの更新を行います。 また、アトミック更新の概念を利用して、アトミックブロック内の変更されたオブジェクトを収集し、それらをすべて一度に更新します。
ChewyはARスタイルのクエリDSLを提供します。
この拡張機能は、連鎖可能、マージ可能、および遅延であるため、クエリをより効率的に生成できます。
では、これがすべて宝石でどのように機能するかを見てみましょう…
Elasticsearchの基本ガイド
Elasticsearchには、ドキュメントに関連するいくつかの概念があります。 1つ目は、 index
(RDBMSのdatabase
の類似物)のインデックスです。これは、いくつかのtypes
( type
はRDBMSテーブルの一種)のdocuments
のセットで構成されます。
すべてのドキュメントには一連のfields
があります。 各フィールドは個別に分析され、その分析オプションはそのタイプのmapping
に保存されます。 Chewyは、オブジェクトモデルでこの構造を「そのまま」利用します。
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
上記では、 entertainment
と呼ばれるElasticsearchインデックスを、 book
、 movie
、 cartoon
の3つのタイプで定義しました。 タイプごとに、いくつかのフィールドマッピングと、インデックス全体の設定のハッシュを定義しました。
そこで、 EntertainmentIndex
を定義し、いくつかのクエリを実行したいと思います。 最初のステップとして、インデックスを作成し、データをインポートする必要があります。
EntertainmentIndex.create! EntertainmentIndex.import # EntertainmentIndex.reset! (which includes deletion, # creation, and import) could be used instead
タイプを定義したときにスコープを渡したため、 .import
メソッドはインポートされたデータを認識します。 したがって、永続ストレージに保存されているすべての本、映画、漫画がインポートされます。
これで、いくつかのクエリを実行できます。
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
これで、インデックスを検索実装で使用する準備がほぼ整いました。
Railsの統合
Railsと統合するために最初に必要なのは、RDBMSオブジェクトの変更に対応できるようにすることです。 Chewyは、 update_index
クラスメソッド内で定義されたコールバックを介してこの動作をサポートします。 update_index
は2つの引数を取ります。
-
"index_name#type_name"
形式で提供されるタイプ識別子 - 実行するメソッド名またはブロック。これは、更新されたオブジェクトまたはオブジェクトコレクションへの後方参照を表します。
依存モデルごとに次のコールバックを定義する必要があります。
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
タグにもインデックスが付けられているため、次に、変更に反応するように、いくつかの外部モデルにモンキーパッチを適用する必要があります。
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
この時点で、オブジェクトを保存または破棄するたびに、対応するElasticsearchインデックスタイプが更新されます。
アトミシティ
まだ1つの長引く問題があります。 books.map(&:save)
のように複数の本を保存する場合、個々の本が保存されるたびにentertainment
インデックスの更新を要求します。 したがって、5冊の本を保存すると、Chewyインデックスが5回更新されます。 この動作はREPLには受け入れられますが、パフォーマンスが重要なコントローラーアクションには受け入れられません。
Chewy.atomic
ブロックでこの問題に対処します。
class ApplicationController < ActionController::Base around_action { |&block| Chewy.atomic(&block) } end
つまり、 Chewy.atomic
は、これらの更新を次のようにバッチ処理します。
-
after_save
コールバックを無効にします。 - 保存した本のIDを収集します。
-
Chewy.atomic
ブロックが完了すると、収集されたIDを使用して単一のElasticsearchインデックス更新リクエストを作成します。
検索
これで、検索インターフェースを実装する準備が整いました。 ユーザーインターフェイスはフォームであるため、フォームを作成するための最良の方法は、もちろん、FormBuilderとActiveModelを使用することです。 (Toptalでは、ActiveDataを使用してActiveModelインターフェイスを実装していますが、お気に入りのgemを自由に使用してください。)
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
クエリとフィルターのチュートリアル
属性を受け入れてタイプキャストできるActiveModelのようなオブジェクトができたので、検索を実装しましょう。
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
コントローラーとビュー
この時点で、モデルは渡された属性を使用して検索要求を実行できます。 使用法は次のようになります。

EntertainmentSearch.new(query: 'Tarantino', min_year: 1990).search
コントローラでは、 Chewyドキュメントラッパーではなく、正確なActiveRecordオブジェクトをロードする必要があることに注意してください。
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
それでは、 entertainment/index.html.haml
で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
並べ替え
ボーナスとして、検索機能に並べ替えも追加します。
タイトルフィールドと年フィールド、および関連性で並べ替える必要があると想定します。 残念ながら、 One Flew Over the Cuckoo's Nest
というタイトルは個々の用語に分割されるため、これらの異なる用語で並べ替えるのはランダムすぎます。 代わりに、タイトル全体で並べ替えたいと思います。
解決策は、特別なタイトルフィールドを使用し、独自のアナライザーを適用することです。
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
さらに、これらの新しい属性と並べ替え処理ステップの両方を検索モデルに追加します。
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
最後に、並べ替えオプションの選択ボックスを追加してフォームを変更します。
= 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 ...
エラー処理
ユーザーが(
またはAND
のような誤ったクエリを実行すると、Elasticsearchクライアントはエラーを発生させます。これを処理するために、コントローラーにいくつかの変更を加えましょう。
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
さらに、ビューにエラーをレンダリングする必要があります。
... - if @entertainments.any? ... - else - if @error = @error - else Nothing to see here
Elasticsearchクエリのテスト
基本的なテスト設定は次のとおりです。
- Elasticsearchサーバーを起動します。
- インデックスをクリーンアップして作成します。
- データをインポートします。
- クエリを実行します。
- 結果を私たちの期待と相互参照します。
ステップ1では、elasticsearch-extensionsgemで定義されたテストクラスターを使用すると便利です。 プロジェクトのRakefile
-gemインストールに次の行を追加するだけです。
require 'elasticsearch/extensions/test/cluster/tasks'
次に、次のRakeタスクを取得します。
$ rake -T elasticsearch rake elasticsearch:start # Start Elasticsearch cluster for tests rake elasticsearch:stop # Stop Elasticsearch cluster for tests
ElasticsearchとRspec
まず、データの変更と同期するようにインデックスが更新されていることを確認する必要があります。 幸い、Chewy gemには、便利update_index
マッチャーが付属しています。
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
次に、実際の検索クエリが適切に実行され、期待される結果が返されることをテストする必要があります。
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
テストクラスターのトラブルシューティング
最後に、テストクラスターのトラブルシューティングのガイドを次に示します。
まず、メモリ内の1ノードクラスタを使用します。 スペック的にははるかに高速になります。 この場合:
TEST_CLUSTER_NODES=1 rake elasticsearch:start
1ノードのクラスターステータスチェックに関連する
elasticsearch-extensions
テストクラスターの実装自体にいくつかの既存の問題があります(場合によっては黄色で、緑色になることはないため、緑色のステータスクラスターの開始チェックは毎回失敗します)。 この問題はフォークで修正されましたが、メインリポジトリですぐに修正されることを願っています。データセットごとに、リクエストを仕様にグループ化します(つまり、データを1回インポートしてから、複数のリクエストを実行します)。 Elasticsearchは長時間ウォームアップし、データのインポート中に大量のヒープメモリを使用するため、特に仕様がたくさんある場合は、やりすぎないでください。
マシンに十分なメモリがあることを確認してください。そうしないと、Elasticsearchがフリーズします(テスト仮想マシンごとに約5GB、Elasticsearch自体に約1GBが必要です)。
まとめ
Elasticsearchは、「柔軟で強力なオープンソース、分散型のリアルタイム検索、および分析エンジン」と自称しています。 これは、検索テクノロジーのゴールドスタンダードです。
Chewyを使用して、Rails開発者は、これらの利点を、Railsとの緊密な統合を提供するシンプルで使いやすい本番品質のオープンソースRubygemとしてパッケージ化しました。 ElasticsearchとRails–なんて素晴らしい組み合わせでしょう!
付録:Elasticsearchの内部
これがElasticsearchの「内部」の簡単な紹介です…
ElasticsearchはLuceneに基づいて構築されており、Lucene自体が主要なデータ構造として転置インデックスを使用しています。 たとえば、「犬が高くジャンプする」、「フェンスを飛び越える」、「フェンスが高すぎる」という文字列がある場合、次の構造になります。
"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]
したがって、すべての用語には、テキストへの参照とテキスト内の位置の両方が含まれます。 さらに、用語を変更し(たとえば、「the」などのストップワードを削除して)、すべての用語に音声ハッシュを適用することを選択します(アルゴリズムを推測できますか?)。
"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]
次に「犬のジャンプ」をクエリすると、ソーステキストと同じ方法で分析され、ハッシュ後に「DAG JANP」になります(「犬」は「犬」と同じハッシュを持ち、「ジャンプ」と"ジャンプ")。
また、文字列内の個々の単語の間に(構成設定に基づいて)ロジックを追加し、( "DAG" AND "JANP")または( "DAG" OR "JANP")のいずれかを選択します。 前者は[0] & [0, 1]
(つまり、ドキュメント0)の共通部分を返し、後者は[0] | [0, 1]
[0] | [0, 1]
(つまり、ドキュメント0と1)。 テキスト内の位置は、結果のスコアリングと位置に依存するクエリに使用できます。