ボクココ

個人開発に関するテックブログ

Elasticsearch Rails で 全文検索とサジェスト機能を実現

ども、@kimihom です。

前回の記事で、Elasticsearch Rails の導入周りの調査内容をレポートした。

今回は調査内容をもとに、Elasticsearch と Rails を組み合わせて実装したので実践編としてまとめてみる。

Elasticsearch と Rails 間のデータ同期に関して

まず懸念事項のデータ同期に関する話。それなりにパフォーマンスを気にするのであれば、Resque や Sidekiq を用いてバックグラウンドに回して ActiveRecord のコールバックを活用してElasticsearch と 同期を取るのだけども、バックグラウンドジョブの実装は割とトラップが多く、その程度でバックグラウンドジョブを実現する価値があるのかだいぶ悩んだ。以下の記事が参考になった。

attracie.hatenablog.com

確かに実際に導入するのは簡単なんだけど、プロセスやメモリの管理をしっかりと考えないといけないし、実際に問題が起きた時に検知できて解決できるような仕組みを導入しないといけない。そこまでのことをしてまで導入するかどうかというのは一度冷静になって考えるべき、という意見はとても納得のいくものだった。

現時点でそこまで負荷のかかるサービスでもないし、今回はバックグラウンド処理は見送って、同期処理でやってみることにした。そうなると実装がかなりシンプルになる。

mapping などのデータ定義を考えなければ、modelに1個モジュールをincludeするだけで良くなる。これでデータのCRUD時に勝手に Elasitcsearch のデータもmapping定義に合わせて一緒に更新してくれる。実際にやってみたところそこまで動作が遅くなったという感じはしなかったので、この方法でひとまず正解だったと思っている。具体的にはモデルに以下を include すればおk。

    include Elasticsearch::Model
    include Elasticsearch::Model::Callbacks

これからのサンプルコードは全て Model 内に書くか、 Concern で切り出してそのモジュール内に書くかで対応できる。

Elasticsearch サービス

では Elasticsearch を使おうとなった時に、どのサービスを使えばいいのだろうか?自分でEC2などで建てる方法ももちろんあるだろうが、私は最も手軽な外部サービスを利用することにした。

そこで選んだのが、 Bonsai というサービスだ。テスト環境は無料から始められる。本番の Elasticsearch には $50 かかるが、実際に同様の環境を構築しなければならない時間やサーバコストを考えると、全然気にならない値段だ。

Bonsai に登録したら Elasticsearch の URL がゲットできるので、 Rails の config/initializers/bonsai.rb で初期化しよう。

Elasticsearch::Model.client = Elasticsearch::Client.new url: ENV['BONSAI_URL']

tokenizer, filter, analyzer

まず Elasticsearch Rails でぎょっとするのがスキーマ定義だろう。

私なりの理解で説明すると、tokenizer で文章をどのように区切るかという方法を定義し、filter でその区切った文字をどのように整形して保存するかを定義する。そして analyzer でどのtokenizerを使ってfilterで整形するかを定義する。最終的に各フィールドがどの analyzer で保存するかを決める必要がある。それを踏まえた上で、以下のサンプルコードを紹介する。参考にしたのはこちらのQiitaより。もろもろ調べた感じ、こちらが一番近いものになった。

    index_name "myapp"

    settings index: {
      analysis: {
        tokenizer: {
          kuromoji: {
            type: 'kuromoji_tokenizer'
          },
          ngram_tokenizer: {
            type: "nGram",
            min_gram: 3,
            max_gram: 11,
            token_chars: ['digit']
          }
        },
        filter: {
          pos_filter: {
            type:     'kuromoji_part_of_speech',
            stoptags: ['助詞-格助詞-一般', '助詞-終助詞'],
          },
          greek_lowercase_filter: {
            type:     'lowercase',
            language: 'greek',
          }
        },
        analyzer: {
          kuromoji_analyzer: {
            type:      'custom',
            tokenizer: 'kuromoji_tokenizer',
            filter:    ['kuromoji_baseform', 'pos_filter', 'greek_lowercase_filter', 'cjk_width'],
          },
          number_analyzer: {
            tokenizer: 'ngram_tokenizer'
          }
        }
      }
    } do
      mapping _source: { enabled: true }, _all: { enabled: true, analyzer: "kuromoji_analyzer" } do
        indexes :external_id, type: 'long', index: 'not_analyzed'
        indexes :name, type: 'string', analyzer: 'kuromoji_analyzer'
        indexes :text, type: 'string', analyzer: 'kuromoji_analyzer'
        indexes :number, type: 'string', analyzer: 'number_analyzer'
        self.instance_eval do # hack for including "suggest context"
          context = {external_id: {type: "category"}}
          @mapping[:suggest_field] = {type: "completion", context: context}
        end
      end
    end

tokenizer では kuromojingram を利用する。

kuromoji は日本語をいい感じに単語で区切ってくれて、それをキーワードとして分割してくれる。んでそれを'kuromoji_baseform', 'pos_filter', 'greek_lowercase_filter', 'cjk_width' などでより綺麗な形に整形して保存する。

対して nGram は min_gram から max_gram までを文字を分割して保存する方法だ。今回は番号検索とかでこちらを利用した。1234 って数字があった時、 min_gram 2, max_gram 3 の場合、12, 23, 34, 123, 234 って感じで保存してくれる。そんで 123 って検索した時にこれがヒットするようになるというわけだ。

デフォルトで使える各 tokenizer, filter, analyzer などは公式ドキュメントに全てまとまっている。やはり細かいオプションなどはこうしたところしか載っていないので必ず読むことになるだろう。

mapping メソッドにて、Elasticsearch に保存するキー名を定義する。基本的に Rails モデルで定義したカラム名のうち、検索で使いたいものだけ登録する形になると思う。ここで上部に定義した analyzer を指定してあげることで保存、検索にそのアナライザを使って検索してくれることになる。

今回は サジェスト 機能も実装したので、suggest_field も定義した。Completion Suggesterの公式ドキュメントを見ながら設定した。今回の仕様として、external_id 毎に検索のサジェストも分けるようにしたかったので、Elasticsearch の Context Suggester というのを同時に併用して実現した。これを使えば サジェスト機能でも where のような絞り込みをした上でサジェストしてくれるようになる。

そんで Elasticsearch Rails のソースコードを見ると、Context Suggesterを実現する上で必要な contextをセットできなかったので、instance_eval を使って強引に設定した。もっといい方法があるのかもしれないが、見た感じやり方がなかった。

以上でスキーマ定義が完了する。実際は上記設定を何度も変えて、データをインポートしながらちゃんと検索で出てくるのかをテストすることになる。

as_indexed_json の定義

続いて ActiveRecord と Elasitcsearch の関連付けを行う。 ActiveRecord カラムと Elasticsearch のキーは違うことになることが多いわけで、以下のようなメソッドを定義してあげる。

    INDEX_FIELDS = %w(external_id name text number).freeze
    SUGGEST_FIELDS = %w( name ).freeze
    def as_indexed_json(options={})
      indexed_json = self.as_json.select { |k, _| INDEX_FIELDS.include?(k) }
      indexed_json.merge!({
        suggest_field: {
          input: indexed_json.select { |k, _| SUGGEST_FIELDS.include?(k) }.values,
          context: {
            external_id: self.external_id
          }
        }
      })
    end

モデルに関連づいた key-value 形式のハッシュと、 suggest_field のついたハッシュが組み合わさったような形になる。 suggest_field は配列が指定できるので、サジェスト項目の候補を複数いれておくことも可能だ。

データインポート

さて、これらの定義を終えると次は DB -> ActiveRecord -> Elasticsearch でデータをインポートすることになる。

lib/tasks/elasticsearch.rakeに以下を追記。

require 'elasticsearch/rails/tasks/import'

これで rake タスクが追加される。以下を実行することで一括インポートが実行される。

bundle exec rake environment elasticsearch:import:all FORCE=y

あとは ローカルで起動した KibanaSense を起動して、データのクエリを投げて遊んでみよう。Sense の URL は Bonsai で登録した Elasitcsearch の URL に変更する。クエリの種類に関しては他サイトや公式サイト 見ながら組み立てよう。基本的に SQLでできることは Elasitcsearch でもだいたいできる。 LIKE 句は Elasticsearch だと tokenizer の分類になることに注意。LIKE をできるようにするために Elasitcsearch を入れているので当然のことだ。

Rails 側で検索、サジェスト

公式ドキュメントを読めば書いてあるけど一応載せておく。

    def self.search external_id, keyword
      query = { query: { and: [
        { constant_score: { filter: { term: { external_id: external_id } } } },
        { query_string: { query: keyword, fields: ["number", "name", "text"] } }
      ] } }
      Elasticsearch::Model.search( query ).records.to_a
    end

    def self.suggest external_id, keyword
      query = { suggest: {
        text: keyword,
        completion: {
          field: "suggest_field",
          size: 10,
          context: { external_id: external_id }
      } } }
      __elasticsearch__.client.suggest(body: query, index: INDEX_NAME)["suggest"].to_a.first.to_hash["options"]
    end

上記の クエリは、 where で 外部id をあらかじめ絞った場合 の検索だ。

その他知っておきたいこと

Sense でのクエリの書き方は知っておいたほうがためになる。といっても REST で body が JSON のを投げるだけなので Elasitcsearch の書き方を知るだけだが。例えばマッピング定義を確認したい時は、 GET my_index/_mapping で確認できる。いちいち Rails でコード書いてテストみたいなのは時間がかかるから、 Sense で動いたら Rails コードに落とし込むってやり方がいいかと思われる。

今回は ActiveRecord の saveupdate, destroy が呼ばれた時のコールバックで Elasitcsearch に登録している。つまり、直接生の SQL を書いていたり、外部から別で SQL を呼んでデータを挿入しているといった場合には、別途 Elasticsearch にもデータを更新することを忘れないようにしよう。不整合が起きる原因となる。

DELETE WHERE みたいなことがしたくなるのだけども、Elasticsearch 2 系からは DELETE Query が廃止されたようだ。なので定期的に再インポートしたり、Elasticsearch Rails の records メソッドを使って Elasitcsearch にはあるけど ActiveRecord にはないデータは取ってこないといったような工夫で対応しよう。

追記 (2016/5/9)

上記の状態でサジェストを実現すると、モデルを更新した時にサジェストが反映されない問題が起きる。その対応について以下の記事にまとめた。

Elasticsearch Rails でサジェストを実現(続き)

終わりに

Elasticsearch の公式ドキュメントをゼロから読んでいたのだが、Elasticsearch に関しては実践から始めた方がより理解できる気がした。割と感覚的に実装していけるのがこの技術の良さなのかもな。それからこだわりたい部分をしっかりと学んでいけば大丈夫。

この記事を書くまでにあらゆるサイトを巡って調査し、公式ドキュメントを読み、実現することができた。次の私のような方がこの記事で得られるものがあれば幸いである。

こうして技術というのは発展していくのかな、とも最近感じるようになったので、頑張って書いてみた。