Amazon Elasticsearchを使って検索の仕組みを変えた話

仕事でAmazon Elasticsearchを使った検索を導入するので、その時やったことを残しておこうと思います。
ちなみにここに載せているコード例は実際にやったことをサンプルプロジェクトでやった時のコードです。

2016/9/13 追記
私が担当する前はelasticsearchでない別の検索エンジンで設計されていたのですが、権限データが階層構造になっていたため、大量のデータになった時に著しく性能が落ちるということが発生しました。それを改善するために検索エンジンで一番使っていたElasticsearchを使ってデータ構造を見直して実装しなおしました。

# やったこと
- index作成バッチ(indexとaliasの作成)
- 既存データのAmazon Elasticsearchへの移行バッチ
- 検索処理作成
- 検索結果の微調整
- テスト(各モジュールのテスト、全体通してのテスト)
- Circle CI上にElasticsearchをインストールしテストを回す
- バックアップ・リストア

# 環境
- elasticsearch 2.3系
- elasticsearch-rails
- elasticsearch-model

# やったことの詳細
以下にやったことの詳細を書いていきます

## index作成/alias作成/データimportバッチ
色々勉強会の資料とか見ていると、indexの更新や運用のことを考えると検索処理で直接indexを指定するのではなく、alias指定にしておいた方がダウンタイム0で色々と出来そうなので、初期構築時に使用するバッチの中でindexとaliasを作成するようにしておきました。aliasを作るとheadプラグイン上では以下のように表示されます。

<img width="284" alt="スクリーンショット 2016-09-13 0.17.56.png" src="https://qiita-image-store.s3.amazonaws.com/0/1686/713dc0b8-aac3-9b37-278c-872f027e55d0.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
```

```app/models/concerns/searchable.rb
 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!でindexを作成し、aliasでindexに対するaliasを作成する。そしてbuil_importでデータをelasticsearchに、bulk APIを使って入れています。

## 検索処理の作成
elasticsearch-railsを使ってやっています。matcherはsimple_query_stringを使っています。

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

queryは以下のような形式になっています。(参考）

```
{"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の最小値を設定
検索結果のマッチ率が低いものが出ることがあったので、scoreの値が低いものは切るようにqueryに<code>min_score</code>を追加しました

### pagination
elasticsearchのQueryでpaginationを表現する場合は<code>from</code>と<code>size</code>を使います。

```
{"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それぞれをクラスに分けてquery文を作成するようにした。で、それぞれに対してテストを作成するようにした（正しいqueryを作成しているか）。
spec/requests以下でelasticsearchを使ってテストをするようにしたが、下にも書いてある通り、ローカルでは実行時に引数を指定して実行しないとテストが実行されないようにした。

## Circle CI上にElasticsearchをインストールしテストを回す
circle ciでだけelasticsearchを使ったテストが起動するようにcircle.yml内で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側にちょいと追記してます。

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

```spec/requests/photo_search_spec.rb

describe PhotoSearch, broken: true do

end
```


## バックアップ・リストア
バックアップはAmazon Elasticsearch Serviceの自動バックアップでなく手動でバックアップを取ります。
注意すべき点はrole等の設定です。

```staging-appのポリシー 
        {
            "Effect": "Allow",
            "Action": [
                "s3:ListBucket",
                "iam:PassRole"
            ],
            "Resource": [
                "arn:aws:s3:::staging-backup",
                "arn:aws:iam::319807237558:role/EC2StagingApplication"
            ]
        }
```

```EC2StagingApplicationのロールに以下を追加
"Principal": {
        "Service": [
                 "es.amazonaws.com",
   　　　・・・・・・
         ]
  }
```

コードはelasticsearch-ruby/elasticsearch-api/lib/elasticsearch/api/actions/snapshot/以下のコードを参考にして書いてます。

## 結果
データ構造を直したというのが一番大きかったと思いますが、レスポンスが１０秒前後かかっていたものが、4秒以内に収まるようになってきました。