通过使用亚马逊 Elasticsearch 改变了搜索机制的故事

因为在工作中要使用Amazon Elasticsearch进行搜索引擎的導入,所以打算把当时的工作记录下来。
顺便说一下,这里提供的代码示例是在实际的示例项目中所用的代码。

2016年9月13日追記
在我负责之前,使用的是一个不同的搜索引擎而不是elasticsearch的设计,但是由于权限数据是分层结构的,当数据量变大时,性能显著下降。为了改善这个问题,我们决定重新设计并实现了一个使用最广泛的Elasticsearch的数据结构。

做过的事情 de

    • index作成バッチ(indexとaliasの作成)

 

    • 既存データのAmazon Elasticsearchへの移行バッチ

 

    • 検索処理作成

 

    • 検索結果の微調整

 

    • テスト(各モジュールのテスト、全体通してのテスト)

 

    • Circle CI上にElasticsearchをインストールしテストを回す

 

    バックアップ・リストア

环境

    • elasticsearch 2.3系

 

    • elasticsearch-rails

 

    elasticsearch-model

做过的事情的具体细节

我将详细描述我做过的事情。

创建索引/创建别名/数据导入批处理

我在研究各种学习会的资料时发现,考虑到索引的更新和操作,使用搜索处理时不直接指定索引,而是设置别名指定,可以在零停机时间内进行各种操作。因此,在初始构建时我决定在批处理中同时创建索引和别名。创建别名后,在head插件上会显示如下信息。

スクリーンショット 2016-09-13 0.17.56.png

示例代码如下。

1 require 'optparse'
  2
  3 class SetupElasticsearch
  4   class << self
  5     def execute
  6       logger = ActiveSupport::Logger.new("log/#{class_name}_batch.log", 'daily')
  7       force = args[:force] || false
  8
  9       Photo.create_index!(force: force)
 10       Photo.create_alias!
 11       # importする
 12     end
 13
 14     private
 15
 16     def args
 17       options = {}
 18
 19       OptionParser.new { |o|
 20         o.banner = "Usage: #{$0} [options]"
 21         o.on("--force=OPT", "option1") { |v| options[:force] = v }
 22       }.parse!(ARGV.dup)
 23
 24       options
 25     end
 26   end
 27 end
 1 module Searchable
  2   extend ActiveSupport::Concern
  3
  4   included do
  5     include Elasticsearch::Model
  6     include Elasticsearch::Model::Callbacks
  7
  8     unless Rails.env.test?
  9       after_save :transfer_to_elasticsearch
 10       after_destroy :remove_from_elasticsearch
 11     end
 12
 13     # Set up index configuration and mapping
 14     settings index: {
 15       number_of_shards:   5,
 16       number_of_replicas: 1,
 17       analysis: {
 18         filter: {
 19           pos_filter: {
 20             type:     'kuromoji_part_of_speech',
 21             stoptags: ['助詞-格助詞-一般', '助詞-終助詞']
 22           },
 23           greek_lowercase_filter: {
 24             type:     'lowercase',
 25             language: 'greek'
 26           },
 27           kuromoji_ks: {
 28             type: 'kuromoji_stemmer',
 29             minimum_length: '5'
 30           }
 31         },
 32         tokenizer: {
 33           kuromoji: {
 34             type: 'kuromoji_tokenizer'
 35           },
36           ngram_tokenizer: {
 37             type: 'nGram',
 38             min_gram: '2',
 39             max_gram: '3',
 40             token_chars: %w(letter digit)
 41           }
 42         },
 43         analyzer: {
 44           kuromoji_analyzer: {
 45             type:      'custom',
 46             tokenizer: 'kuromoji_tokenizer',
 47             filter:    %w(kuromoji_baseform pos_filter greek_lowercase_filter cjk_width)
 48           },
 49           ngram_analyzer: {
 50             tokenizer: "ngram_tokenizer"
 51           }
 52         }
 53       }
 54     } do
 55       mapping _source: { enabled: true },
 56               _all: { enabled: true, analyzer: "kuromoji_analyzer" } do
 57         indexes :id, type: 'integer', index: 'not_analyzed'
 58         indexes :description, type: 'string', analyzer: 'kuromoji_analyzer'
 59       end
 60     end
 61
 62     def as_indexed_json(_options = {})
 63       as_json
 64     end
 65
 66     def transfer_to_elasticsearch
 67       __elasticsearch__.client.index  index: index_name, type: 'photo', id: id, body: as_indexed_json
 68     end
 69
 70     def remove_from_elasticsearcha
 71       __elasticsearch__.client.delete index: index_name, type: 'photo', id: id
 72     end
 73   end
 74
 75   module ClassMethods
 76     def create_index!(options = {})
 77       client = __elasticsearch__.client
 78       client.indices.delete index: Consts::Elasticsearch[:index_name][:photo] if options[:force]
 79       client.indices.create index: Consts::Elasticsearch[:index_name][:photo],
 80         body: {
 81           settings: settings.to_hash,
 82           mappings: mappings.to_hash
 83         }
          }
 84     end
 85
 86     def create_alias!
 87       client = __elasticsearch__.client
 88       if client.indices.exists_alias? name: Consts::Elasticsearch[:alias_name][:photo]
 89         client.indices.delete_alias index: Consts::Elasticsearch[:index_name][:photo], alias_name: Consts::Elasticsearch[:alias_name][:photo]
 90       end
 91
 92       client.indices.put_alias index: Consts::Elasticsearch[:index_name][:photo], name: Consts::Elasticsearch[:alias_name][:photo]
 93     end
94
 95     def bulk_import
 96       client = __elasticsearch__.client
 97
 98       find_in_batches do |entries|
 99         result = client.bulk(
100           index: index_name,
101           type: document_type,
102           body: entries.map { |entry| { index: { _id: entry.id, data: entry.as_indexed_json } } },
103           refresh: (i > 0 && i % 3 == 0), # NOTE: 定期的にrefreshしないとEsが重くなる
104         )
105      end
106     end
107   end
108 end

使用create_index命令创建索引,并使用alias命令为索引创建别名。然后使用bulk_import将数据通过bulk API导入到elasticsearch中。

创建搜索处理

我正在使用elasticsearch-rails,匹配器使用的是simple_query_string。

simple_query_string:
  { query: @condition_params[:keyword],
    fields: ['name', 'description'],
    default_operator: 'and'
  }

查询的格式如下所示。(参考)

{"query":{"bool":{
  "must":[
    {"term":{"owner_id":1}},
    {"term":{"type":"T"}},
    {"simple_query_string":{
       "query":"天気","fields":[
         "name","description"
       ],
       "default_operator":"and"
      }
    },
    {"term":{"creator_id":24383}}
  ],
  "must_not":[
    {"term":{"public_flag":0}}
  ],
  "should":[
    {"term":{"permission":"hogehoge"}},
    {"term":{"permission2":"fugafuga"}}
  ]
}},
"size":10,
"sort":[{"id":"desc"}]
}

我认为还有其他更多的准备方法,但是。。。

搜索结果的微调整

将score的最小值设定为某个值。

由于有时搜索结果的匹配率较低,所以我在查询中添加了一个min_score参数,将score值较低的结果排除掉。

分页

在Elasticsearch查询中表达分页时,使用from和size。

{"query":{"bool":{
  "must":[
    ・・・・・・・・・
  ],
  "must_not":[
    ・・・・・・・・・
  ],
  "should":[
        ・・・・・・・・・
  ]
}},
"from": 0, 
"size":10,
"sort":[{"id":"desc"}]
}

如果使用elasticsearch-model来实现这个任务的话,

 @client.search(query).offset(params[:page]).limit(params[:offset]).records

使用offset方法和limit方法就像这样。

考试(对每个模块的测试,以及整体的测试)

我使用rspec为每个类编写了代码。我将query中的must/shoud/not_must/sort分别分为不同的类来创建查询语句。然后,我为每个类编写了测试(以确保生成了正确的查询)。我还修改了spec/requests文件夹下的测试,使用elasticsearch进行测试,但正如下面所述,在本地执行测试时需要指定参数才能运行测试。

在Circle CI上安装Elasticsearch并运行测试。

在circle.yml中安装elasticsearch,以便在Circle CI上仅启动使用elasticsearch的测试。

 13 dependencies:
 14   cache_directories:
 15     - "~/docker"
 16   override:
 17     - bundle check --path=vendor/bundle || bundle install --path=vendor/bundle --j    obs=4 --retry=3
 18     - if [[ ! -e elasticsearch-2.3.5 ]]; then wget https://download.elasticsearch.    org/elasticsearch/elasticsearch/elasticsearch-2.3.5.tar.gz && tar -xvf elasticsear    ch-2.3.5.tar.gz; fi
 19     - elasticsearch-2.3.5/bin/plugin install analysis-kuromoji
 20     - elasticsearch-2.3.5/bin/elasticsearch: {background: true}
 21     - sleep 10 && curl --retry 10 --retry-delay 5 -v http://127.0.0.1:9200/
・・・・・・・・・・・・・
・・・・・・
 43
 44 test:
 45   override:
 46     - CI=true RAILS_ENV=test bundle exec rspec spec

我在RSpec的一侧稍作补充。

 53   # 一部のテストを環境によっては実行させないようにするため追加
 54   config.filter_run_excluding broken: true unless ENV['CI']

describe PhotoSearch, broken: true do

end

备份和恢复

备份不使用Amazon Elasticsearch Service的自动备份,而是手动备份。需要注意的是角色等设置。

        {
            "Effect": "Allow",
            "Action": [
                "s3:ListBucket",
                "iam:PassRole"
            ],
            "Resource": [
                "arn:aws:s3:::staging-backup",
                "arn:aws:iam::319807237558:role/EC2StagingApplication"
            ]
        }
"Principal": {
        "Service": [
                 "es.amazonaws.com",
      ・・・・・・
         ]
  }

我使用elasticsearch-ruby/elasticsearch-api/lib/elasticsearch/api/actions/snapshot/中的代码作为参考来编写。

结果

我认为最大的改变是修复了数据结构的问题,原本需要大约10秒的响应时间现在已经减少到了4秒以内。

广告
将在 10 秒后关闭
bannerAds