Elasticsearch for Ruby on Rails:Chewy Gem 教程

已发表: 2022-03-11

Elasticsearch 提供了一个强大的 RESTful HTTP 接口,用于索引和查询数据,构建在 Apache Lucene 库之上。 开箱即用,它提供可扩展、高效且健壮的搜索,并支持 UTF-8。 它是索引和查询大量结构化数据的强大工具,在 Toptal,它为我们的平台搜索提供动力,并且很快也将用于自动完成。 我们是超级粉丝。

Chewy 扩展了 Elasticsearch-Ruby 客户端,使其更强大并提供与 Rails 更紧密的集成。

由于我们的平台是使用 Ruby on Rails 构建的,因此我们对 Elasticsearch 的集成利用了 elasticsearch-ruby 项目(Elasticsearch 的 Ruby 集成框架,提供用于连接到 Elasticsearch 集群的客户端、用于 Elasticsearch 的 REST API 的 Ruby API,以及各种扩展和实用程序)。 在此基础上,我们开发并发布了我们自己对 Elasticsearch 应用程序搜索架构的改进(和简化),打包为我们命名为 Chewy 的 Ruby gem(此处提供示例应用程序)。

Chewy 扩展了 Elasticsearch-Ruby 客户端,使其更强大并提供与 Rails 更紧密的集成。 在本 Elasticsearch 指南中,我将讨论(通过使用示例)我们是如何实现这一点的,包括在实施过程中出现的技术障碍。

本可视化指南描述了 Elasticsearch 和 Ruby on Rails 之间的关系。

在继续阅读指南之前,只需几个快速说明:

  • GitHub 上提供了 Chewy 和 Chewy 演示应用程序。
  • 对于那些对有关 Elasticsearch 的“幕后”信息感兴趣的人,我在这篇文章的附录中包含了一个简短的文章。

为什么耐嚼?

尽管 Elasticsearch 具有可扩展性和效率,但将其与 Rails 集成并没有预期的那么简单。 在 Toptal,我们发现自己需要显着增强基本的 Elasticsearch-Ruby 客户端,以提高其性能并支持额外的操作。

尽管 Elasticsearch 具有可扩展性和效率,但将其与 Rails 集成并没有预期的那么简单。

因此,耐嚼的宝石诞生了。

Chewy 的一些特别值得注意的功能包括:

  1. 所有相关模型都可以观察到每个索引。

    大多数索引模型彼此相关。 有时,有必要对这些相关数据进行非规范化并将其绑定到同一个对象(例如,如果您想索引一组标签及其相关文章)。 Chewy 允许您为每个模型指定一个可更新的索引,因此每当相关标签被更新时,相应的文章就会被重新索引。

  2. 索引类独立于 ORM/ODM 模型。

    例如,有了这个增强功能,实现跨模型自动完成就容易多了。 您可以只定义一个索引并以面向对象的方式使用它。 与其他客户端不同,Chewy gem 消除了手动实现索引类、数据导入回调和其他组件的需要。

  3. 批量进口无处不在

    Chewy 利用批量 Elasticsearch API 进行完整的重新索引和索引更新。 它还利用原子更新的概念,在一个原子块中收集更改的对象并一次更新它们。

  4. 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 索引,它具有三种类型: bookmoviecartoon 。 对于每种类型,我们为整个索引定义了一些字段映射和设置哈希。

所以,我们已经定义了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有两个参数:

  1. "index_name#type_name"格式提供的类型标识符
  2. 要执行的方法名称或块,表示对更新的对象或对象集合的反向引用

我们需要为每个依赖模型定义这些回调:

 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按如下方式对这些更新进行批处理:

  1. 禁用after_save回调。
  2. 收集已保存书籍的 ID。
  3. 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 查询

基本测试设置如下:

  1. 启动 Elasticsearch 服务器。
  2. 清理并创建我们的索引。
  3. 导入我们的数据。
  4. 执行我们的查询。
  5. 将结果与我们的期望交叉引用。

对于第 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 和 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)。 文本内位置可用于评分结果和位置相关查询。