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

  • 3
    いいね
  • 0
    コメント

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

この記事は検索処理をAmazon Elasticsearch Serviceに置き換えた話パート1の続きです。

indexの作成

indexの作成にはelasticsearch用のgemで提供しているメソッドを使います。settingやmappingの定義は以下のように定義しておきます。
settingsでは、日本語検索や全角半角でも同じように検索させるためにkuromojiプラグインやicu_normalizerを設定しています。またsharedの数やreplicasの数なども設定しています。
mappingsはここにも書いたようにsortの設定を入れています。この定義を入れることで日本語のソートも思ったような動作をします。

  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の向き先を新しい方に向けるだけで変更ができるので、運用を考えた時に入れておいても良いと判断したからです。

elasticsearch_aliasの例.png

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

検索処理実装

matcherはどれを使うか

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

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

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

この値のどれかとマッチするを条件に入れたい場合はtermsを使う

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にバックアップするバッチは以下のようにelasticsearch-ruby/elasticsearch-api/lib/elasticsearch/api/actions/snapshot/を使って書きました。

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ではエンジニアを募集しています。
興味のある方は是非お越しください。