2年ほど前に elasticsearch-rails検証 という記事を書いたのですが、今でもたまにいいね・ストックしてくださる方がいるので、その後どう運用しているかの続きを書きます。
目次
- 結局採用したの?
- どういうコンセプト?
- 設定方法は?
- 困ったところある?
結局採用したの?
目次に 4つ項目が並んでいる時点でお察しですが、採用しました。
どういうコンセプト?
前回、複数モデルにまたがるクエリ のところで、
そもそも authors に関する情報も article の index として保存してしまうと、authers の index を作成するときに情報が重複することになるので、index の更新時間が単純に考えて重複した index分だけ増えます。
なのでこういう使い方は、仮にできたとしてもアンチパターンまっしぐらかも知れません。
と書いたのですが、若気の至りと言いますか、RDB と KVS では違うよねというところに気づいて、リレーションごとネストして持たせる方法をとっています。
この方法によって解決される問題は、あるカテゴリに属する記事に関して、そのカテゴリ名でも hit するし、記事のタイトルや本文でも hit するという、ある種全文検索エンジンとして真っ当な使い方ができることです(しかも RDB は舐めないので速い)。
設定方法は?
前回と重複するところもありますが、バージョンが変わったりしているかもしれないので書いていきます。
モデルへ全文検索機能の追加
elasticsearch-rails の機能を使用するために、Elasticsearch::Model
を include します。
class Category < ActiveRecord::Base
# これを追加
include Elasticsearch::Model
# コールバックをフックして Elasticsearch のドキュメントを更新する場合はこちらも
# include Elasticsearch::Model::Callbacks
...
アナライザーとかの設定
同モデルにおいて、例えば以下のように設定を書いていきます。
kuromoji というよく使われる形態素解析器を使っています。
細かい話をしだすとこの記事ではスペースが足りないので、ここでは割愛します。
とりあえず、この設定ファイルがわからない場合は、まずは全文検索エンジンの仕組みについての学習を先にされることをオススメします。
settings analysis: {
filter: {
pos_filter: {
type: 'kuromoji_part_of_speech',
stoptags: ['助詞-格助詞-一般', '助詞-終助詞'],
},
greek_lowercase_filter: {
type: 'lowercase',
language: 'greek',
},
},
tokenizer: {
kuromoji: {
type: 'kuromoji_tokenizer',
},
ngram_tokenizer: {
type: 'nGram',
min_gram: '2',
max_gram: '3',
token_chars: [
'letter',
'digit'
]
}
},
analyzer: {
kuromoji_analyzer: {
type: 'custom',
tokenizer: 'kuromoji_tokenizer',
filter: ['kuromoji_baseform', 'pos_filter', 'greek_lowercase_filter', 'cjk_width'],
},
ngram_analyzer: {
tokenizer: 'ngram_tokenizer'
}
}
}
Elasticsearch側のインデックスの設定
今回、index をネストさせることを前提としているので、dynamicインデックスを使うのを止めて、手動で設定します。
settings index: { number_of_shards: 3 } do
mappings dynamic: 'false' do
indexes :id, type: :long, index: :not_analyzed
indexes :name, type: :string, index: :analyzed, analyzer: :kuromoji_analyzer
indexes :articles, type: :nested do
indexes :title, type: :string, index: :analyzed, analyzer: :ngram_analyzer
indexes :body, type: :string, index: :analyzed, analyzer: :kuromoji_analyzer
end
end
end
実際に私が運用しているものよりかなり簡略化していますが、雰囲気はこんな感じです。
ポイントは、中腹辺りで宣言している、indexes :articles, type: :nested
のところです。
この設定を行うことで、Elasticsearch に「あ、Category は Article をネストして持ってるのね、ふむふむ」ということを理解させます。
ただし、あくまでこれは Elasticsearch が理解する話なので、Railsから送り出すデータがその形式になっているか否かは別の問題です。なので、Rails側で送り出すデータを以下のように書いて変形してあげます。
def as_indexed_json(options={})
# カテゴリについて
category_attrs = {
id: self.id,
name: self.name,
}
# カテゴリに紐づく記事について
category_attrs[:articles] = self.articles.map do |article|
{
title: article.title,
body: article.body,
}
end
category_attrs.as_json
end
as_indexed_json
メソッドは、elasticsearch-model の中で import が実行される際に呼ばれます。
https://github.com/elastic/elasticsearch-rails/blob/b6d485748c71a07d064ea2a46a6da82d64a04cd7/elasticsearch-model/lib/elasticsearch/model/adapters/active_record.rb#L111
従って、このメソッドをオーバーライドすることで、(Elasticsearch への)import時に実行されるデータの変換をコントロールすることができます。
インデックスの作成&インポート
ここまでできたら、あとは Elasticsearch にインデックスを登録して、ドキュメント(Elasticsearch の用語で、検索対象の単位です)をアップロードすれば、検索を行うことができます。
インデックス作成
> Category.__elasticsearch__.create_index! force: true
インポート
> Category.import
Nested Search
実際に使っているクエリを簡略化したものなので、間違えているかもしれません、雰囲気こんな感じです。
nested
、path
、articles.title
辺りが肝かと思います。
keyword = #...
# クエリの組み立て
query = {
"bool" => {
"should" => [
{
"match" => {
"name" => keyword
}
},
{
"nested" => {
"path" => "articles",
"query" => {
"match" => {
"articles.title" => keyword
}
}
}
}
]
}
}
# 検索
Category.search(query)
ここまで来て、検索対象って普通カテゴリじゃなくて記事だよねって気づいたのですが、今更なのでもうこのままにします。記事を検索したい場合は、上の手順の Category と Article を全て逆にすると大体正しいかと思います。
困ったところある?
あまりないのですが、クエリの組み立てが結構大変というのが唯一かつ最大の問題点です。
booleanクエリ(AND/ORなど)は elasticsearch 2系以降、should/must を使用する形式が採用されたのですが、英語ネイティブでないからなのかあまり直感的に書けずに毎回ドキュメントを参照している気がします。
まぁこれは elasticsearch-rails の問題ではないのですが・・・。
しかしこの点において、elasticsearch-rails では query が ruby のオブジェクトとして構築できるので、例えば must の条件を動的に変えたいという場面で、
query[...]['must'] << some_condition
などと書けるのは大変良いです。
まとめ
結局採用したの?
しました
どういうコンセプト?
前言撤回して、ネストして(非正規化して)保持しています
設定方法は?
上述の通りです
困ったところある?
あまりないです