ども、@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
では kuromoji
、ngram
を利用する。
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
あとは ローカルで起動した Kibana の Sense を起動して、データのクエリを投げて遊んでみよう。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 の save
や update
, destroy
が呼ばれた時のコールバックで Elasitcsearch に登録している。つまり、直接生の SQL を書いていたり、外部から別で SQL を呼んでデータを挿入しているといった場合には、別途 Elasticsearch にもデータを更新することを忘れないようにしよう。不整合が起きる原因となる。
DELETE WHERE
みたいなことがしたくなるのだけども、Elasticsearch 2 系からは DELETE Query が廃止されたようだ。なので定期的に再インポートしたり、Elasticsearch Rails の records
メソッドを使って Elasitcsearch にはあるけど ActiveRecord にはないデータは取ってこないといったような工夫で対応しよう。
追記 (2016/5/9)
上記の状態でサジェストを実現すると、モデルを更新した時にサジェストが反映されない問題が起きる。その対応について以下の記事にまとめた。
Elasticsearch Rails でサジェストを実現(続き)
終わりに
Elasticsearch の公式ドキュメントをゼロから読んでいたのだが、Elasticsearch に関しては実践から始めた方がより理解できる気がした。割と感覚的に実装していけるのがこの技術の良さなのかもな。それからこだわりたい部分をしっかりと学んでいけば大丈夫。
この記事を書くまでにあらゆるサイトを巡って調査し、公式ドキュメントを読み、実現することができた。次の私のような方がこの記事で得られるものがあれば幸いである。
こうして技術というのは発展していくのかな、とも最近感じるようになったので、頑張って書いてみた。