Elasticsearchは、データのインデックス作成とクエリを行うための強力なRESTfulHTTPインターフェースを提供します。 Apache Lucene 図書館。箱から出してすぐに、UTF-8をサポートし、スケーラブルで効率的かつ堅牢な検索を提供します。これは、大量の構造化データのインデックス作成とクエリを行うための強力なツールです。 サルゲッチュ 、プラットフォーム検索を強化し、間もなくオートコンプリートにも使用されます。私たちは大ファンです。
ChewyはElasticsearch-Rubyクライアントを拡張し、より強力にし、Railsとの緊密な統合を提供します。私たちのプラットフォームはを使用して構築されているので Ruby on Rails 、Elasticsearchの統合は、 Elasticsearch-ルビー プロジェクト(Elasticsearchクラスターに接続するためのクライアントを提供するElasticsearch用のRuby統合フレームワーク、ElasticsearchのRESTAPI用のRubyAPI、およびさまざまな拡張機能とユーティリティ)。この基盤に基づいて、Elasticsearchアプリケーション検索アーキテクチャの独自の改善(および簡略化)を開発およびリリースしました。これは、名前を付けたRubygemとしてパッケージ化されています。 歯ごたえ (利用可能なサンプルアプリ付き ここに )。
ChewyはElasticsearch-Rubyクライアントを拡張し、より強力にし、Railsとの緊密な統合を提供します。 このElasticsearchガイドでは、実装中に発生した技術的な障害を含め、これをどのように達成したかについて(使用例を通じて)説明します。
ガイドに進む前の簡単なメモ:
Elasticsearchのスケーラビリティと効率性にもかかわらず、Railsとの統合は予想ほど単純ではありませんでした。 ApeeScapeでは、基本的なElasticsearch-Rubyクライアントを大幅に拡張して、パフォーマンスを向上させ、追加の操作をサポートする必要があることに気付きました。
Elasticsearchのスケーラビリティと効率性にもかかわらず、Railsとの統合は予想されたほど単純ではありませんでした。そしてこうして、歯ごたえのある宝石が生まれました。
Chewyの特に注目すべき機能は次のとおりです。
クレジットカード番号ビザマスターカードをハックする
すべてのインデックスは、関連するすべてのモデルで監視できます。
ほとんどのインデックス付きモデルは相互に関連しています。また、場合によっては、この関連データを非正規化し、同じオブジェクトにバインドする必要があります(たとえば、タグの配列を関連する記事と一緒にインデックス付けする場合)。 Chewyを使用すると、すべてのモデルに更新可能なインデックスを指定できるため、関連するタグが更新されるたびに、対応する記事のインデックスが再作成されます。
インデックスクラスは、ORM / ODMモデルから独立しています。
この機能拡張により、たとえば、クロスモデルオートコンプリートの実装がはるかに簡単になります。インデックスを定義して、オブジェクト指向で操作するだけです。他のクライアントとは異なり、Chewy gemを使用すると、インデックスクラス、データインポートコールバック、およびその他のコンポーネントを手動で実装する必要がなくなります。
一括インポートは どこにでも 。
Chewyは、一括のElasticsearch APIを利用して、完全なインデックスの再作成とインデックスの更新を行います。また、アトミック更新の概念を利用して、アトミックブロック内で変更されたオブジェクトを収集し、それらをすべて一度に更新します。
Chewyは、ARスタイルのクエリDSLを提供します。
この拡張機能は、連鎖可能、マージ可能、および遅延であるため、クエリをより効率的に生成できます。
では、これがすべて宝石でどのように機能するかを見てみましょう…
Elasticsearchには、ドキュメントに関連するいくつかの概念があります。 1つ目はindex
のそれです(database
の類似物 RDBMS )、これはdocuments
のセットで構成され、複数のtypes
で構成されます。 (ここで、type
は一種のRDBMSテーブルです)。
すべてのドキュメントにはfields
のセットがあります。各フィールドは個別に分析され、その分析オプションはmapping
に保存されます。そのタイプのために。 Chewyは、オブジェクトモデルでこの構造を「現状のまま」利用します。
class EntertainmentIndex { 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と統合するために最初に必要なことは、RDBMSオブジェクトの変更に対応できるようにすることです。 Chewyは、update_index
内で定義されたコールバックを介してこの動作をサポートしますクラスメソッド。 update_index
2つの引数を取ります:
'index_name#type_name'
で提供されるタイプ識別子フォーマット依存モデルごとに次のコールバックを定義する必要があります。
class Book タグにもインデックスが付けられているため、次に、変更に反応するように、いくつかの外部モデルにモンキーパッチを適用する必要があります。
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 要するに、Chewy.atomic
これらの更新を次のようにバッチ処理します。
after_save
を無効にします折り返し電話。 - 保存した本のIDを収集します。
Chewy.atomic
の完了時ブロックは、収集されたIDを使用して、単一のElasticsearchインデックス更新リクエストを作成します。
検索中
これで、検索インターフェースを実装する準備が整いました。私たちのユーザーインターフェースはフォームなので、それを構築するための最良の方法は、もちろん、 FormBuilder そして ActiveModel 。 (ApeeScapeでは、 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
コントローラでは、代わりに正確なActiveRecordオブジェクトをロードしたいことに注意してください 歯ごたえ ドキュメントラッパー:
class EntertainmentController さて、いくつかを書く時が来ました HAML 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, 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 さらに、これらの新しい属性と並べ替え処理ステップの両方を検索モデルに追加します。
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 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-拡張機能 宝石。プロジェクトのRakefile
に次の行を追加するだけです。ジェム後のインストール:
require 'elasticsearch/extensions/test/cluster/tasks'
次に、次のようになります レーキ タスク:
$ rake -T elasticsearch rake elasticsearch:start # Start Elasticsearch cluster for tests rake elasticsearch:stop # Stop Elasticsearch cluster for tests
ElasticsearchとRspec
まず、データの変更と同期するようにインデックスが更新されていることを確認する必要があります。幸いなことに、歯ごたえのある宝石には便利な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
次に、実際の検索クエリが適切に実行され、期待される結果が返されることをテストする必要があります。
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
-
elasticsearch-extensions
にはいくつかの既存の問題があります1ノードのクラスターステータスチェックに関連するクラスター実装自体をテストします(場合によっては黄色で、緑色になることはないため、緑色のステータスのクラスター開始チェックは毎回失敗します)。この問題はフォークで修正されましたが、メインリポジトリですぐに修正されることを願っています。
-
データセットごとに、リクエストを仕様にグループ化します(つまり、データを1回インポートしてから、複数のリクエストを実行します)。 Elasticsearchは長時間ウォームアップし、データのインポート中に大量のヒープメモリを使用するため、特に多数の仕様がある場合は、やりすぎないでください。
-
マシンに十分なメモリがあることを確認してください。そうしないと、Elasticsearchがフリーズします(テスト仮想マシンごとに約5GB、Elasticsearch自体に約1GBが必要です)。
まとめ
Elasticsearchは、「柔軟で強力なオープンソース、分散型のリアルタイム検索、および分析エンジン」と自称しています。これは検索テクノロジーのゴールドスタンダードです。
Chewyで、私たちの レール開発者 これらの利点を、Railsとの緊密な統合を提供するシンプルで使いやすい本番品質のオープンソースRubygemとしてパッケージ化しました。 ElasticsearchとRails–なんて素晴らしい組み合わせでしょう!
ElasticsearchとRails-なんて素晴らしい組み合わせでしょう! つぶやき
付録:Elasticsearchの内部
これが 非常に Elasticsearchの「内部」の簡単な紹介…
Elasticsearchは上に構築されています 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と1)。テキスト内の位置は、結果のスコアリングと位置に依存するクエリに使用できます。