Rails ApplicationからAmazon Elasticsearch Serviceを使って素早く全文検索を可能にする

More than 1 year has passed since last update.

背景

プロダクトに全文検索機能が必要となりElasticSearchを使って開発することに、かといってElasticSearchの実装・運用経験がない私。
そこで最近Amazon Elasticsearch Serviceがリリースされたので導入してみました。

Amazon Elasticsearch Service

Amazon Elasticsearch Service

Amazon Elasticsearch Service は、AWS クラウドで Elasticsearch を簡単にデプロイ、
操作、スケーリングできるようにするマネージドサービスです。

https://aws.amazon.com/jp/elasticsearch-service より引用)
スタートアップで働く私にとってできる限りサービス開発に時間を割きたく、インフラストラクチャへの注意を可能な限り少なくしたいです。
その点においてAmazon Elasticsearch Serviceの導入は悪くないと考えました。

Amazon Elasticsearch Serviceセットアップ

ドメイン

ドメインを指定します。
注意として例えばcoubicというドメインを指定すると下記のようにsearchがprefixされたendpointになります。
search-coubic-xxxxxxxxxxxxxxxxx.aws-region.es.amazonaws.com

クラスター

ElasticSearchは、1台だけで検索サーバーとして動作します。また、大量のデータを処理したり耐障害性を実現するために複数のサーバー間で協調して動作することもできます。
ElasticSearchではこれらのサーバー群のことをクラスタ、群をなす1台1台のサーバーのことをノードと呼びます。
サービス要求に応じてクラスター等の設定をします。
料金イメージについては (http://calculator.s3.amazonaws.com/index.html?lng=ja_JP) よりシミュレーションすることが可能です。
※必ずしも正確な料金であることを私は保証できません。

アクセスポリシー

サービス構成の都合に合わせてアクセスポリシーを設定します。

Application

目的

商品をタイトル、詳細から全文検索できるようにします。

Gem

RailsからAmazon Elasticsearch Serviceを簡単に使えるGemをインストールします。

Gemfile
gem 'elasticsearch-model'
gem 'elasticsearch-dsl'

elasticsearch-modelはActiveRecordからElasticSearchを簡単に使える機能を提供
elasticsearch-dslはrubyからElasticSearchの検索クエリを簡単に書けるDSLを提供

接続設定

Amazon Elasticsearch Serviceへの接続を指定します。

/config/initializers/elasticsearch.rb
Elasticsearch::Model.client =
    Elasticsearch::Client.new hosts: [
                                  {host: 'your-aws-es-endpoint',
                                   port: '80'}]

私はここでport指定をせずに少しハマりました。

インデックスから検索まで流れ

アプリケーションへ検索を実装する前に、ElasticSearchがどのように動作するか初めに大体の流れを理解します。

  • インデキシング
    • ElasticSearchに送られてきたデータを処理し、インデックスに保存する。
  • アナライズ
    • インデキシングの時にデータの内容を解析し分割しインデックスに書き込む。
  • 検索
    • クエリの条件を満たすドキュメントを探す。
  • アナライザ
    • 検索時にクエリの操作を指定する。

インデックス作成から検索まで順に見ていきます。

インデックス名決定

インデックスはElasticSearchがデータを保存する場所です。RDBMSへ例えるとテーブルに相当するものと言えるでしょうか。
今回はモデル単位でインデックスを作成する想定です。

searchable.rb
module Searchable
  extend ActiveSupport::Concern

  included do
    include Elasticsearch::Model
    index_name  [model_name.collection, Rails.env].join('_')
  end
end

スキーママッピング

スキーママッピングはインデックス構造を定義するために使用します。
インデックスは複数のタイプを含めることができますが、ここでは1つのタイプを定義しています。
次の属性を持つタイプであるCourseをスキーママッピングします。
- name :string
- description :text

course_searchable.rb
module CourseSearchable
  extend ActiveSupport::Concern

  included do
    include Searchable

    settings index: {
                 analysis: {
                      tokenizer: {
                          kuromoji_tokenizer: {
                            type: 'kuromoji_tokenizer',
                            mode: 'search'
                          }
                      },
                      filter: {
                         pos_filter: {
                             type:     'kuromoji_part_of_speech',
                             stoptags: %w(助詞-格助詞-一般 助詞-終助詞),
                         },
                         greek_lowercase_filter: {
                             type:     'lowercase',
                             language: 'greek',
                         },
                      },
                      char_filter: {
                          custom_mapping: {
                              type: 'mapping',
                              mappings: ['カ => カ', 'ガ=>ガ']
                          }
                      },
                      analyzer: {
                          kuromoji_analyzer: {
                             type:      'custom',
                             tokenizer: 'kuromoji_tokenizer',
                             filter: %w(kuromoji_baseform pos_filter greek_lowercase_filter cjk_width),
                             char_filter: %w(custom_mapping)
                         }
                      }
                 }
             }

    mappings dynamic: 'false' do
      indexes :name, analyzer: 'kuromoji_analyzer', index: 'analyzed'
      indexes :description, analyzer: 'kuromoji_analyzer', index: 'analyzed'
    end

    def as_indexed_json(options = {})
      attributes
          .symbolize_keys
          .slice(:name, :description)
    end
  end
end

実際にはCourseモデルにCourseSearchableモジュールをincludeしています。

course.rb
class Course < ActiveRecord::Base
  include CourseSearchable
end

インデックスの作成

rails consoleからAmazon Elasticsearch Serviceへインデックスを作成します。

Course.__elasticsearch__.create_index! force: true

Amazon Elasticsearch Serviceのダッシュボードからインデックスが作成されたことを確認できると思います。

データの投入

rails consoleからAmazon Elasticsearch Serviceへ前商品データを投入します。

Course.__elasticsearch__.import

検索

入力されたキーワードから全文検索を行うクエリを組み立て、検索を実行します。

course_searchable.rb
module CourseSearchable
  extend ActiveSupport::Concern
    .....省略  

    def self.search(query, size)
      search_definition = Elasticsearch::DSL::Search.search {
        size size         
        query do
          multi_match do
            query keyword
            fields %w(name description)
          end
        end
      }
      __elasticsearch__.search(search_definition)
    end
  end
end

実際の検索ではElasticSearchの検索結果からRDBMSへ問い合わせを行い、検索結果に肉付けをしています。

courses_controller.rb
courses = Course.search(keyword, SEARCH_LIMIT)
results =  Course.where course_id: courses.records.to_a.map(&:id)

ドキュメント更新

Courseモデルが作成・更新された時にElasticSearchへドキュメントの更新を伝えます。
UXを考慮して、ActiveJobを使って非同期で処理しています。

indexer_job.rb
class IndexerJob < ActiveJob::Base
  include Elasticsearch::Model

  queue_as :default

  def perform(course)
    course.__elasticsearch__.update_document
  end
end

終わり

シンプルですが全文検索ができました。
弊社サービスよりデモが確認できます。
https://popcorn.coubic.com/search?category=13&location=1&q=%E8%82%A9%E3%81%93%E3%82%8A

参考書籍

高速スケーラブル検索エンジン ElasticSearch Server

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.