Elasticsearch for Ruby on Rails:Chewy Gem 教程
已發表: 2022-03-11Elasticsearch 提供了一個強大的 RESTful HTTP 接口,用於索引和查詢數據,構建在 Apache Lucene 庫之上。 開箱即用,它提供可擴展、高效且健壯的搜索,並支持 UTF-8。 它是索引和查詢大量結構化數據的強大工具,在 Toptal,它為我們的平台搜索提供動力,並且很快也將用於自動完成。 我們是超級粉絲。
由於我們的平台是使用 Ruby on Rails 構建的,因此我們對 Elasticsearch 的集成利用了 elasticsearch-ruby 項目(Elasticsearch 的 Ruby 集成框架,它提供了用於連接到 Elasticsearch 集群的客戶端、用於 Elasticsearch 的 REST API 的 Ruby API,以及各種擴展和實用程序)。 在此基礎上,我們開發並發布了我們自己對 Elasticsearch 應用程序搜索架構的改進(和簡化),打包為我們命名為 Chewy 的 Ruby gem(此處提供示例應用程序)。
Chewy 擴展了 Elasticsearch-Ruby 客戶端,使其更強大並提供與 Rails 更緊密的集成。 在本 Elasticsearch 指南中,我將討論(通過使用示例)我們是如何實現這一點的,包括在實施過程中出現的技術障礙。
在繼續閱讀指南之前,只需幾個快速說明:
- GitHub 上提供了 Chewy 和 Chewy 演示應用程序。
- 對於那些對有關 Elasticsearch 的“幕後”信息感興趣的人,我在這篇文章的附錄中包含了一個簡短的文章。
為什麼耐嚼?
儘管 Elasticsearch 具有可擴展性和效率,但將其與 Rails 集成並沒有預期的那麼簡單。 在 Toptal,我們發現自己需要顯著增強基本的 Elasticsearch-Ruby 客戶端,以提高其性能並支持額外的操作。
因此,耐嚼的寶石誕生了。
Chewy 的一些特別值得注意的功能包括:
所有相關模型都可以觀察到每個索引。
大多數索引模型彼此相關。 有時,有必要對這些相關數據進行非規範化並將其綁定到同一個對象(例如,如果您想索引一組標籤及其相關文章)。 Chewy 允許您為每個模型指定一個可更新的索引,因此每當相關標籤被更新時,相應的文章就會被重新索引。
索引類獨立於 ORM/ODM 模型。
例如,有了這個增強功能,實現跨模型自動完成就容易多了。 您可以只定義一個索引並以面向對象的方式使用它。 與其他客戶端不同,Chewy gem 消除了手動實現索引類、數據導入回調和其他組件的需要。
批量進口無處不在。
Chewy 利用批量 Elasticsearch API 進行完整的重新索引和索引更新。 它還利用原子更新的概念,在一個原子塊中收集更改的對象並一次更新它們。
Chewy 提供了一種 AR 風格的查詢 DSL。
通過可鏈接、可合併和惰性,此增強允許以更有效的方式生成查詢。
好的,讓我們看看這一切如何在 gem 中發揮作用……
Elasticsearch 基本指南
Elasticsearch 有幾個與文檔相關的概念。 第一個是index
(類似於 RDBMS 中的database
),它由一組documents
組成,可以是多種types
(其中type
是一種 RDBMS 表)。
每個文檔都有一組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
。 對於每種類型,我們為整個索引定義了一些字段映射和設置哈希。
所以,我們已經定義了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
有兩個參數:
- 以
"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 索引類型。
原子性
我們還有一個揮之不去的問題。 如果我們執行類似books.map(&:save)
類的操作來保存多本書,那麼每次保存一本書時,我們都會請求更新entertainment
索引。 因此,如果我們保存五本書,我們將更新 Chewy 索引五次。 這種行為對於 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
請注意,在控制器中,我們要加載精確的 ActiveRecord 對象而不是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
現在,是時候在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-extensions gem 中定義的測試集群很方便。 只需將以下行添加到項目的Rakefile
post-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
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
測試集群故障排除
最後,這是對測試集群進行故障排除的指南:
首先,使用內存中的單節點集群。 規格會快得多。 在我們的例子中:
TEST_CLUSTER_NODES=1 rake elasticsearch:start
elasticsearch-extensions
測試集群實現本身存在一些與單節點集群狀態檢查相關的問題(在某些情況下它是黃色的,並且永遠不會是綠色,因此每次綠色狀態集群啟動檢查都會失敗)。 該問題已在 fork 中修復,但希望很快會在主 repo 中修復。對於每個數據集,將您的請求按規範分組(即,導入一次數據,然後執行多個請求)。 Elasticsearch 預熱很長時間,在導入數據時會使用大量堆內存,所以不要過度使用,尤其是在你有一堆規範的情況下。
確保您的機器有足夠的內存,否則 Elasticsearch 將凍結(我們需要大約 5GB 用於每個測試虛擬機,大約 1GB 用於 Elasticsearch 本身)。
包起來
Elasticsearch 自稱是“一個靈活而強大的開源、分佈式、實時搜索和分析引擎”。 這是搜索技術的黃金標準。
借助 Chewy,我們的 Rails 開發人員將這些優勢打包為一個簡單、易於使用、生產質量的開源 Ruby gem,它提供與 Rails 的緊密集成。 Elasticsearch 和 Rails——多麼棒的組合!
附錄:Elasticsearch 內部結構
下面是對 Elasticsearch “幕後”的簡要介紹……
Elasticsearch 建立在 Lucene 之上,Lucene 本身使用倒排索引作為其主要數據結構。 例如,如果我們有字符串“the dogs jump high”、“jump over the fence”和“the fence was too high”,我們得到以下結構:
"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]
如果我們然後查詢“the dog jumps”,它會以與源文本相同的方式進行分析,在散列後變為“DAG JANP”(“dog”與“dogs”具有相同的散列,就像“jumps”和“跳”)。
我們還在字符串中的各個單詞之間添加一些邏輯(基於配置設置),在 (“DAG” AND “JANP”) 或 (“DAG” OR “JANP”) 之間進行選擇。 前者返回[0] & [0, 1]
(即文檔 0)的交集,後者返回[0] | [0, 1]
[0] | [0, 1]
(即文檔 0 和 1)。 文本內位置可用於評分結果和位置相關查詢。