検索処理をAmazon Elasticsearch Serviceに置き換えた話パート2

このエントリは Classi Advent Calendar 2016 の15日目です

この記事は[検索処理をAmazon Elasticsearch Serviceに置き換えた話パート1](http://qiita.com/s_nakamura/items/a41680456d3362be666e)の続きです。

##indexの作成
indexの作成にはelasticsearch用のgemで提供しているメソッドを使います。settingやmappingの定義は以下のように定義しておきます。
settingsでは、日本語検索や全角半角でも同じように検索させるためにkuromojiプラグインやicu_normalizerを設定しています。またsharedの数やreplicasの数なども設定しています。
mappingsは[ここ](http://qiita.com/s_nakamura/items/a41680456d3362be666e#%E3%82%BD%E3%83%BC%E3%83%88)にも書いたようにsortの設定を入れています。この定義を入れることで日本語のソートも思ったような動作をします。

```settingやmappingの例
  settings index: {
    number_of_shards: 5,
    number_of_replicas: 1,
    max_result_window: 1000000,
    analysis: {
      filter: {
        pos_filter: {
          type:     'kuromoji_part_of_speech',
          stoptags: ['助詞-格助詞-一般', '助詞-終助詞']
        },
        greek_lowercase_filter: {
          type:     'lowercase',
          language: 'greek'
        },
        kuromoji_ks: {
          type: 'kuromoji_stemmer',
          minimum_length: '5'
        }
      },
      tokenizer: {
        kuromoji: {
          type: 'kuromoji_tokenizer'
        },
        ngram_tokenizer: {
          type: 'nGram',
          min_gram: '2',
          max_gram: '3',
          token_chars: %w(letter digit)
        }
      },
      analyzer: {
        kuromoji_analyzer: {
          type:      'custom',
          tokenizer: 'kuromoji_tokenizer',
          filter:    %w(kuromoji_baseform pos_filter greek_lowercase_filter cjk_width)
        },
        ngram_analyzer: {
          tokenizer: "ngram_tokenizer"
        },
        nfkc_cf_normalized: {
          tokenizer: 'icu_tokenizer',
          char_filter: [ 'icu_normalizer' ]
        },
        nfd_normalized: {
          tokenizer: 'icu_tokenizer',
          char_filter: [ 'nfd_normalizer' ]
        }
      },
      char_filter: {
        nfd_normalizer: {
          type: 'icu_normalizer',
          name: 'nfc',
          mode: 'decompose'
        }
      }
    }
  } do
    mapping _source: { enabled: true },
      _all: { enabled: true, analyzer: "kuromoji_analyzer" } do
      indexes :id,                           type: 'long', index: 'not_analyzed'

     ・・・・・・・・・・・・・・・・・・・・・・・・
　　　　　　　　　　・・・・・・・・・・・・・・・・・・・・・・・・

      indexes :name,          type: 'string', analyzer:'kuromoji_analyzer', fields: { raw: { type: 'string', index: :not_analyzed } }

     ・・・・・・・・・・・・・・・・・・・・・・・・
　　　　　　　　　　・・・・・・・・・・・・・・・・・・・・・・・・

      indexes :description, type: 'string', analyzer:'kuromoji_analyzer'
      indexes :created_at,  type: 'date'
      indexes :updated_at,  type: 'date'

     ・・・・・・・・・・・・・・・・・・・・・・・・
　　　　　　　　　　・・・・・・・・・・・・・・・・・・・・・・・・

      indexes :user_name, type: 'string', analyzer: 'kuromoji_analyzer', fields: { raw: { type: 'string', index: :not_analyzed } }

     ・・・・・・・・・・・・・・・・・・・・・・・・
　　　　　　　　　　・・・・・・・・・・・・・・・・・・・・・・・・

      # attributeの設定
      indexes :school_age_id, type: 'long'
      indexes :category_id,   type: 'long'
      indexes :difficulty_id, type: 'long'

     ・・・・・・・・・・・・・・・・・・・・・・・・
　　　　　　　　　　・・・・・・・・・・・・・・・・・・・・・・・・

　　　　　　　　　　　　# sharedの設定
      indexes :shared_read, type: 'string', index: 'not_analyzed'
    end
  end
```

そして定義したsettingやmappingをelasticsearch用のgemで用意されているindices.createメソッドにhash形式で渡します。

```
 Elasticsearch::Client.new(~~~~).indices.create index: index_name,
   body: {
     settings: settings.to_hash,
     mappings: mappings.to_hash
   }
```

ちなみに上の例で書いているsettingsやmappingはelasticsearch用のgemに定義されたmethodです。

## データimportバッチの作成
文書の登録・更新にはまずは文書のデータ、そして文書の属性データ、共有データの値をjson形式にします。JSON形式にしたら、それをElasticsearch::Clientのindexメソッドに渡すとドキュメントが登録されます。

```登録処理の例
Elasticsearch::Client.index index: 'search_index', type: 'es_entry', id: id, body: entry_json, refresh: true
```

また初回に既存データを大量にElasticsearchに入れる必要があります。その時上のメソッドだと1件1件になり、効率が良くありません。そういう時はbulkメソッドを使って、ある程度まとまった量を一気に登録させるようにします。

```
__elasticsearch__.client.bulk(
  index: 'search_index',
  type:  'test_type',
  body: entries.map { |entry| { index: { _id: entry.id, data: entry.as_indexed_json } } },
  refresh: true
)
```

## aliasの検討
mappingやsettingの設定を変更したい場合、aliasを設定しておくと必要な時に簡単に変更ができるようになるので、あらかじめ定義しておきました。何かindexの変更がある時にaliasの向き先を新しい方に向けるだけで変更ができるので、運用を考えた時に入れておいても良いと判断したからです。

<img width="259" alt="elasticsearch_aliasの例.png" src="https://qiita-image-store.s3.amazonaws.com/0/1686/764ab238-f995-0c44-5463-a47692d25d79.png">

今後aliasを実際に使って定義を更新した時など、実際に運用で使った際にはナレッジを追記いたします。

## 検索処理実装
### matcherはどれを使うか
matcherは<code>simple_query_string</code>を使いました。検索条件に「_」が入ることもあり、それをうまく処理させるのにsimple_query_stringが適していたからです。

```
{
  simple_query_string:
    { query: "ここに検索ワードが入る",
      fields: ['name', 'fullsearch_text'],
      default_operator: 'and',
    }
}
```

1点注意が必要なのは検索ワードを「""」で囲ってあげることです。そうしないと検索時にヒットする範囲が広くなってしまいます。
例えば「Classiチャレンジ_進研模試１年11月」というワードで検索するとき「""」で囲わずに検索すると「Classiチャレンジ_進研模試ホゲホゲ 問１」のようなものも検索にヒットしてしまいます。

### この値のどれかとマッチするを条件に入れたい場合は<code>terms</code>を使う
entry_typeというカラムに対しTかTdのデータを取得したい場合、検索queryを次のようにtermsを使って書くことができます。

```
{ { temrs: { entry_type: ['T', 'Td'] } } }
```

## バックアップ
バックアップについてはS3にデータの中身を吐き出すようにしています。Amazon Elasticsearch Serviceの方でもバックアップをしているのですが、このバックアップを使って復旧させるのにAmazonの人に頼まないといけないようで、それだと時間がかかってしまうので自前でやった方が良いとのことです。
注意すべき点はポリシーの設定とrole等の設定です。

```ポリシー例 
        {
            "Effect": "Allow",
            "Action": [
                "s3:ListBucket",
                "iam:PassRole"
            ],
            "Resource": [
                "arn:aws:s3:::hogehoge-backup",
                "arn:aws:iam::******:XXXXXXXXXX"
            ]
        }
```

```ロールの追加例
"Principal": {
        "Service": [
                 "es.amazonaws.com",
   　　　・・・・・・
         ]
  }
```

上記のような設定をして、実際S3にバックアップするバッチは以下のように<code>elasticsearch-ruby/elasticsearch-api/lib/elasticsearch/api/actions/snapshot/</code>を使って書きました。

```バックアップ処理の例
def create_backup
  Elasticsearch::Client.new(~~~~).snapshot.create_repository(
    repository: 'snapshot',
    body: repository_setting
  )
end

def repository_setting
  config = YAML.load_file("#{Rails.root}/config/elasticsearch.yml")[ENV['RAILS_ENV'] || 'development'].with_indifferent_access

  if Rails.env.development?
    {
      type: 'fs',
      settings: { location: '/var/elasticsearch/snapshot', compress: true   }
     }
   elsif Rails.env.staging? || Rails.env.production?
     {
       type: "s3",
       settings: {
         bucket: "hogehoge-#{ENV['RAILS_ENV']}-backup",
         region: "XXXXX",
         base_path: "hogehoge_path",
         compress: true,
         access_key: ENV['AWS_ACCESS_KEY_ID'],
         secret_key: ENV['AWS_SECRET_ACCESS_KEY'],
         role_arn: config[:aws_role]
       },
       verify: false
     }
   end
 end
```

# 置き換えた結果
これついては、本日全体公開となりますので、具体的な数値などお伝えできる情報が出来次第追記させていただきます。が、体感的には検索スピードが10~7秒だったものが1~2秒程度に短縮されたと思います。

※追記： 2016/12/15 1000ms以上だったものが、80msに改善されました。

# 最後に
[Classiではエンジニアを募集しています。](https://www.wantedly.com/companies/classi/projects)
興味のある方は是非お越しください。