AppBrew tech blog

AppBrewのエンジニアチームの日々です

RailsプロジェクトでのElasticsearchとの付き合い方について

こんにちは、AppBrewのエンジニアの吉野です。 前回の記事ではElasticsearchとはどういうものか、ということについて書きました。

tech.appbrew.io

この記事では、

①ローカルでのElasticsearchの環境構築

から、

Railsとどうつなぎ合わせるか

③実運用上での注意点など

について書いていきたいと思います。

動作環境

Rails 4.2.8 Elasticsearch 2.4.5

①ローカルでのElasticsearchの環境設定

開発するにあたり、ローカルで環境がほしいと思います。 ElasticSearchのAnalyzerは標準としていくつかありますが、日本語のTokenizeなどをするならば Analyzer として、kuromoji Analyzer、icu_normalizer あたりを入れていきましょう。 GUIはelasticsearch-headを入れておくことにします。

brew install elasticsearch@2.4
echo 'export PATH="/usr/local/opt/elasticsearch@2.4/libexec/bin:$PATH"' >> ~/.bash_profile
source ~/.bash_profile
plugin install analysis-kuromoji analysis-icu mobz/elasticsearch-head

次に設定を多少いじりましょう /usr/local/opt/elasticsearch@2.4/libexec/config/elasticsearch.ymlに以下の行を追加しておくと何かと便利です。

script.inline: on
script.indexed: on
script.engine.groovy.inline.search: on
script.search: on
script.groovy.sandbox.enabled: true

起動は elasticsearchで可能です。

$ elasticsearch
[2018-02-25 16:53:50,683][INFO ][node                     ] [Mandroid] version[2.4.6], pid[1939], build[5376dca/2017-07-18T12:17:44Z]
[2018-02-25 16:53:50,683][INFO ][node                     ] [Mandroid] initializing ...
[2018-02-25 16:53:51,358][INFO ][plugins                  ] [Mandroid] modules [reindex, lang-expression, lang-groovy], plugins [head, analysis-kuromoji, analysis-icu], sites [head]
[2018-02-25 16:53:51,420][INFO ][env                      ] [Mandroid] using [1] data paths, mounts [[/ (/dev/disk1)]], net usable_space [40.6gb], net total_space [232.5gb], spins? [unknown], types [hfs]
[2018-02-25 16:53:51,420][INFO ][env                      ] [Mandroid] heap size [990.7mb], compressed ordinary object pointers [true]
[2018-02-25 16:53:51,420][WARN ][env                      ] [Mandroid] max file descriptors [10240] for elasticsearch process likely too low, consider increasing to at least [65536]
[2018-02-25 16:53:53,126][INFO ][node                     ] [Mandroid] initialized
[2018-02-25 16:53:53,126][INFO ][node                     ] [Mandroid] starting ...
[2018-02-25 16:53:53,213][INFO ][transport                ] [Mandroid] publish_address {127.0.0.1:9300}, bound_addresses {[fe80::1]:9300}, {[::1]:9300}, {127.0.0.1:9300}
[2018-02-25 16:53:53,219][INFO ][discovery                ] [Mandroid] elasticsearch_yosshi/yI1KRtR_TGCC34vpmzoJEQ
[2018-02-25 16:53:56,268][INFO ][cluster.service          ] [Mandroid] new_master {Mandroid}{yI1KRtR_TGCC34vpmzoJEQ}{127.0.0.1}{127.0.0.1:9300}, reason: zen-disco-join(elected_as_master, [0] joins received)
[2018-02-25 16:53:56,281][INFO ][http                     ] [Mandroid] publish_address {127.0.0.1:9200}, bound_addresses {[fe80::1]:9200}, {[::1]:9200}, {127.0.0.1:9200}
[2018-02-25 16:53:56,281][INFO ][node                     ] [Mandroid] started
[2018-02-25 16:53:56,363][INFO ][gateway                  ] [Mandroid] recovered [6] indices into cluster_state
[2018-02-25 16:53:57,592][INFO ][cluster.routing.allocation] [Mandroid] Cluster health status changed from [RED] to [YELLOW] (reason: [shards started [[post_development_3][1]] ...]).

デフォルトはlocalhostの9200番でサーバーが立ちます。 また、 http://localhost:9200/_plugin/head/ へとアクセスすると香ばしいGUIでElasticsearchの中にあるデータを確認できます。

f:id:yosshi0774:20180314000102p:plain

ローカルの設定は以上です。

Railsとどうつなぎ合わせるか

ここからRails側でElasticsearchを使うための設定について書いていきます。

自前で必要なAPIを叩くようなものをwrapしていっても良いわけですが、 便利なものが世の中にはあるので使っていきましょう。 今回使うGemは以下の4つです。

gem 'elasticsearch', git: 'git://github.com/elasticsearch/elasticsearch-ruby.git'
gem 'elasticsearch-dsl', git: 'git://github.com/elasticsearch/elasticsearch-ruby.git'
gem 'elasticsearch-model', git: 'git://github.com/elasticsearch/elasticsearch-rails.git'
gem 'elasticsearch-rails', git: 'git://github.com/elasticsearch/elasticsearch-rails.git'

それぞれのgemは以下のような機能を提供してくれます。

  • elasticsearch
    • ElasticsearchのrubyのAPIClientを提供してくれています。
    • 通信部分で気になることがあればこれを見ましょう
  • elasticsearch-dsl
    • Elasticsearchのmappingの定義、検索のクエリ設計などをrubyっぽく書くことができるようになります。
    • このライブラリ中を見てみると検索クエリの書き方のサンプルが多くのっているのでわからなくなったら見てみましょう。
  • elasticsearch-model
    • 名前の通りincludeすることでそのmodelに対してsearchやimport などElasticsearchに関連したメソッドを生やしてくれるものです
  • elasticsearch-rails
    • development環境でelasticsearch関連のlogをキレイに吐いてくれたり、データ投入のためのrake taskなどを提供してくれます。

必要なgemは入れたので実装に移りましょう。

Searchable moduleを実装

mappingは各modelによって違うとして、いくつか共通化したい処理や設定が存在するかと思います。 そのため、searchable.rbのようなmoduleを作り、共通化していきましょう。

searchable moduleでは2つの役割を担っています

①カスタムしたanalyzerの設定 ②レコードに関するコールバックの設定 ③APIクライアントの初期化

具体的には以下のようなものとなっています。

// 'app/models/concerns/searchable.rb'
require 'elasticsearch/model'

Elasticsearch::Model.client = Elasticsearch::Client.new log: true, scheme: 'https', host: , port: 'ポート番号', user: 'user名', password: 'password'

module Searchable
  extend ActiveSupport::Concern

  included do
    include Elasticsearch::Model

    after_commit on: [:create] do
      ElasticSearchIndexerJob.perform_later(self.class.to_s, self.id) unless Rails.env.development?
    end

    after_commit on: [:update] do
      ElasticSearchIndexUpdaterJob.perform_later(self.class.to_s, self.id) unless Rails.env.development?
    end

    after_commit on: [:destroy] do
      begin
        __elasticsearch__.delete_document unless Rails.env.development?
      rescue => ex
      end
    end
     
   settings analysis: {
      analyzer: {
        ....
      }
    }
  end
end

本来createやupdateはbulkで行うべきなのでしょうが、軽い実装なら上記のようなコールバックを用意しておき、以下のようなjobを用意してあげれば、dbとelasticsearchの同期をとることができます。

class ElasticSearchIndexerJob < ActiveJob::Base
  queue_as :default

  def perform(klass, id)
    sleep(1)
    record = klass.constantize.find_by(id: id)
    record.__elasticsearch__.index_document if record.present?
  end
end

各modelの設定

moduleを作ったため、これをincludeして、各モデルの設定をすれば大丈夫です。 やることは以下4点です。

①indexの定義 ②mappingの定義 ③as_indexed_jsonのオーバーライド ④search queryの設計

まずサンプルコードを貼ると以下のような形となります。

class Post < ActiveRecord::Base
  include Searchable

 index_name "post" #①

  mappings dynamic: 'false' do  #②
    indexes :content, analyzer: 'kuromoji', type: 'string'
    indexes :n_content, analyzer: 'ngram_analyzer', type: 'string'

    indexes :like_count, type: 'integer'
    indexes :media_status, type: 'integer'
    indexes :published_at, type: 'date'

    indexes :info, analyzer: 'ngram_analyzer', type: 'string'

    indexes :products, type: 'nested' do
      indexes :name, analyzer: 'ngram_analyzer', type: 'string'
      indexes :id, type: 'integer'
    end
    ...
  end

  def as_indexed_json(option = {}) #③
     info = "hogehoge"
     attributes
      .symbolize_keys
      .slice(:content, :like_count, :published_at)
      .merge(n_content: self.content)
      .merge(media_status: media_status)
      .merge(info: info)
      .merge(products: products.map{|p| {id: p.id, name: p.name}})
      ...
  end

  has_many :products, through: :post_products

  def self.search(params) #④
    search_definition = Elasticsearch::DSL::Search.search {
      from (page.to_i - 1) * 20
      size 20
      query {
        function_score {
          query {
            filtered {
              query {
                multi_match {
                  query text
                  operator 'and'
                  fields %w{ content n_content info } 
                 }
              }
           }
         ...
    }   
    __elasticsearch__.search(search_definition)
  end
end

それぞれについて見ていきましょう

indexの定義

これはわざわざ説明するまでのこともなくindexの名前を設定しています。 バージョニングなどを考えたindex名については後述しますが、基本的にはわかりやすい名前をつけておけば大丈夫です。

mappingの定義

どのfiledがどういう型か、どういったanalyzerを使用するかということを定義しています。 RDBでいうところの1対多の関係を表すときは type: 'nested'をつけておくと配列をindexingすることができます。

as_indexed_jsonのオーバーライド

mappingに則り、indexingするデータをhashの形へと変換してあげます。 ここで不足があると mappingは定義したのにデータが投入されない ということが起こってしまうので気をつけましょう

search queryの設計

最後にmappingをもとに検索クエリを組み立てて行きます。 本当に多くのクエリを用いて検索ができるため、詳細は省きますが、elasticsearch-dslのgithubを見てみると多くのサンプルを見ることができます。

これでsearchクエリさえ組み立てることができれば大丈夫というところまできました。 mappingの定義が大丈夫となったら (Model名).__elasticsearch__.import をしてデータを投入してみましょう。

これでいつでも検索できるようになりました!

③実運用上の問題点と解決法

最後に運用上でやっていることや問題となったことについてまとめたいと思います。

いまいちどの辺がクエリにヒットしてレコードが引っ張られているのかわからない問題

我々がやったことといえば、mappingを決め、要件をJSONに落としこんだだけですが、 実際与えらえれたクエリに対してどの辺がヒットしているのか? ということを確認したいニーズは多少あるかと思います。 そんなときは highlight という機能を使うと幸せになれるかと思います。

上記self.search(params)のクエリ定義の中に

  def self.search(params) 
    search_definition = Elasticsearch::DSL::Search.search {
      from (page.to_i - 1) * 20
      size 20
      highlight {                                       
        fileds: [:n_content, :content]          # ここを追加
      }                                                        
      query {
        function_score {
          query {
       ...

highlightというブロックを追加します。 そして検索して結果をほっていくと

 pry(main)> Post.search({text: "キャンメイク"}).response.hits.hits.map(&:highlight)
 => {"n_content"=>
   ["<em>キャンメイク春のコスメ</em>♡\n" + "可愛<em>い</em>過ぎ♪\n" + "限定色とか新色とか新作が出るそうです。\n" + "ちなみにTwitter情報です!\n" + "新作は色<em>ん</em>なカラーが出ますね。\n" + "すご<em>く</em>楽しみです!\n" + "セザ<em>ンヌの新色も楽しみで欲しいのい</em>っぱ<em>い</em>w"],
  "content"=>["<em>キャンメイク</em>春のコスメ♡\n" + "可愛い過ぎ♪\n" + "限定色とか新色とか新作が出るそうです。\n" + "ちなみにTwitter情報です!\n" + "新作は色んなカラーが出ますね。\n" + "すごく楽しみです!\n" + "セザンヌの新色も楽しみで欲しいのいっぱいw"]

となり、ひっかかった部分が強調タグで囲まれていることがわかりますね。 "キャンメイク"と打ったのに”ク”だけに反応していたりして、なかなか難しそうですね。

引っ張ってきたレコード、スコア順じゃない問題

elasticsearch-modelの恩恵は偉大で (Model名).search(params).records.to_aとするとSQLを発行し、レコードを取得できるわけですが、クエリを見てみると

  Post Load (0.5ms)  SELECT `posts`.* FROM `posts` WHERE `posts`.`id` IN (301306, 300359, 297179, 304908, 302542, 299076, 312292, 303429, 308305, 304312, 311940, 297988, 301020, 307750, 305654, 298394, 311388, 308077, 297068, 296636)

のようにwhereで引いてきているため、elasticsearchでのスコア順が保証してくれません。

そのため自前でソートするのがよいかと思います。

(例)

Post.search(params).map(&:id).tap { |ids| Post.where(id: ids).order("field(posts.id, #{ids.join(',')})") }

で順番が保証されます。

activerecord-importとelasticsearch-modelのimportがconflictする問題

(※導入しようとした当時はconflictしてたのですが、現在ではactiverecord-importのimportメソッドはbulk_importにrenameしたようです。 参考: https://github.com/zdennis/activerecord-import/blob/master/lib/activerecord-import/import.rb)

一応困ってる人がいたりするかもしれないので軽く残しておくと elasticsearch-modelが各モデルに対して生やしてくれるメソッドとactiverecord-importが ActiveRecord::Associations::CollectionAssociation に生やしてくれるimportメソッドが被ってしまうという問題があり、メソッドの探索順序的に elasticsearch-modelが生やしてくれるimportが常に使われてしまうという問題がありました。

これはconfig/application.rb

require 'activerecord-import/base'

class ActiveRecord::Base
  class << self
    alias :bulk_insert :import
    remove_method :import
  end
end

としてaliasを貼ってあげることで 解決していました

fieldを追加したりmappingを変えたくなったんだけどサービスはとめたくない問題

ベストプラクティスかどうかはわかりませんが、 弊社ではindex_nameにsuffixをつけることでblue-greenの切り替えをを行っています。

具体的にはconfig/initializersに以下のような定数を記述したファイルをつくり、

  CURRENT_ES_POST_INDEX = 2
  CURRENT_ES_PRODUCT_INDEX = 1
  CURRENT_ES_USER_INDEX = 2
  CURRENT_ES_ARTICLE_INDEX = 0

各modelのindex_nameを

class Post < ActiveRecord::Base
  include Searchable
  index_name "post_#{Rails.env.production? ? 'production' : 'staging'}_#{CURRENT_ES_POST_INDEX}"
  ...
end

のようにしておくと

①mappingを定義しなおしたものをデプロイ ②rake taskなどでCURRENT_HOGEHOGE_INDEX をインクリメントしたインデックスをつくりデータを投入

Post.__elasticsearch__.index_name = "post_#{Rails.env.production? ? 'production' : 'staging'}_#{CURRENT_ES_POST_INDEX + 1}"
Post.__elasticsearch__.import

③作り終えたら実際にconfig/initializersの下にある定数をインクリメントしデプロイ

というフローでblue-greenを実現することができます。

検索候補とか出したい

検索システムを作ってると候補を出したいってなることがあるかと思います。

書き始めると長くなるので、とても参考になるURLを貼っておきます。

https://medium.com/hello-elasticsearch/elasticsearch-%E3%82%AD%E3%83%BC%E3%83%AF%E3%83%BC%E3%83%89%E3%82%B5%E3%82%B8%E3%82%A7%E3%82%B9%E3%83%88%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%81%AE%E3%81%9F%E3%82%81%E3%81%AE%E8%A8%AD%E8%A8%88-352a230030dd

基本的には,弊社では検索のログや、ブランド名、商品名、ユーザー名などを、(とても参考になるURLで言われているような)Analyzerを用いてtokenizeするだけでそこそこのものができたりします。