はじめに
こんにちは。転職会議チームでエンジニアをやっている @highwide です。
転職会議では、2016年に一部の検索画面をRails + elasticsearchで実装しました。その際、自分自身、アレコレ新しく学んだことが多かったので、今年のAdvent Calendarのネタにしたいと思います。
注意
Rails 4.2系/elasticsearch 2.4系の情報で書かれているところが多いため、ご参考にしていただく際は最新の情報と照らし合わせながら進めていただけると助かります。
ドキュメント設計
elasticsearchのドキュメント構造は以下のよう階層があります。
- index
└ type(s)
└ document(s)
ちなみに学習を始めた際、
indexはRDBMSにおけるDATABASE、typeはRDBMSにおけるTABLE
といった情報を目にしました。
確かに、階層構造を理解する上で誤った情報ではありませんが、RDBMSのDATABASE/TABLEの感覚でelasticsearchのドキュメント設計をするといろいろとツライことになりそうなのでご注意ください。
たとえば、
- 単一typeのみを削除するAPIは提供されていない
- type間の関連性は親:子で1:多は実現できるが、多:多は実現できない
といった特徴があります。
※ 1については、Twitterに疑問を投げたところ、elastic社の@johtaniさんには以下のようにリプライをいただきました。(ありがとうございます!)
@highwide 整合性が取りにくいからなくなっています。できれば、ライフサイクルが異なるtypeはインデックスを分けていただくのが良いかと。
— Jun Ohtani (@johtani) 2015年12月28日
というわけで、RDBMSの感覚で、「ひとつのアプリケーションが利用するドキュメントは全部同一indexに入れてしまえ」とかやるとつらいことになります。
過度にtype間のリレーションを持たせると、クエリを作るところでもつらくなるので、 できるだけフラットなドキュメント構造をtypeとして、同時に削除していいもの同士を同一indexとする というのが良さそうです。
elasticsearch-persistenceを使う
Railsからelasticsearchへリクエストを送るクライアントライブラリとして採用したのは elasticsearch-persistence です。
ネットで、Railsでのelasticsearch利用事例を見ていると、 elasticsearch-model を利用して、RDBMSのTABLE構造をそのままelasticsearchのdocumentとしている事例をときどき見かけたのですが、今回は、既存の複数TABLEをまとめて1ドキュメントとしたいという要件から、ActiveModel(with Virtus)でドキュメントを表現できる elasticsearch-persistence
を利用しました。
なお、このgemは elastic/elasticsearch-ruby のラッパーとなっているので、
elasticsearchのindexやドキュメント等を操作する大抵のAPIが利用できるようになっています。
indexerの実装
elasticsearchにデータを登録する(indexingをする)アプリケーション「indexer」をにRailsで1システムとして実装することにしました。
ここで活躍するのが、先程の elasticsearch-persistence
です。
このgemを利用し、elasticsearchのドキュメントと1対1で対応するつオブジェクトをSearchable_*
というprefixをつけて定義しました。
Searchable化するための generate
メソッド
このオブジェクトクラスメソッドとして、 generate
という、元のARオブジェクトを受け取って、 Searchable_
オブジェクト化するようなメソッドを用意しました。
例えばこんな感じでしょうか。
class SearchableHoge
include Elasticsearch::Persistence::Model
# attribute定義は省略
class << self
def generate(hoge)
SerchableHoge.new(
title: hoge.title,
body: hoge.body,
fuga_bodies: hoge.fugas.pluck(:body)
)
end
end
end
※ 今思うと hoge
に .searchablize
みたいなメソッドを生やしたほうが、良い設計だった気がします。
bulk import用のAPI用の変換するメソッドを用意
今回、求人を追加する際に、elasticsearchの Bulk APIを利用することにしました。
親docと子docを同時にindexingしたかったからです。
elasticsearchのBulk APIは以下のようなパラメータを送ることで実現できます。
client.bulk body: [
{ index: { _index: 'myindex', _type: 'mytype', _id: 1 } },
{ title: 'foo' },
{ index: { _index: 'myindex', _type: 'mytype', _id: 2 } },
{ title: 'foo' },
{ delete: { _index: 'myindex', _type: 'mytype', _id: 3 } }
]
ヘッダ行と、ドキュメントの実値の行で分ける必要があるんですね。
ドキュメントの実値は to_h
でいけそうです。
ヘッダ行は、複数の Searchable_*
Objectを受けるModelにこんなメソッドを書いて対応しました。
def bulk_params(docs)
{ body: docs.map { |doc| [{ index: header(doc) }, doc.to_h] }.flatten }
end
def header(doc)
header = {
_index: doc.class::INDEX,
_type: doc.class::TYPE,
_id: doc._id
}
header[:_parent] = doc.parent if doc.respond_to?(:parent)
header[:_routing] = doc.routing if doc.respond_to?(:routing)
header
end
ポイントは parent
と routing
で、bulk importするときに、親を持つドキュメントをindexingする際は、親のidを明示的に指定する必要があります。
そこで、子側の searchable_*
objectにparentとroutingというattributeを持たせました。
というわけで
indexingは以下のようなフローで行われます。
- ActiveRecordによって、DBから求人の元データを取得
-
include Elasticsearch::Persistence::Model
しているObjectに変換 - bulk API用のパラメータに変換
- elasticsearchにリクエスト
初回の全件更新時は↑の処理を呼ぶRakeタスクなど書いておくといいと思います。
本当はCookpadさんの Elasticsearch のインデックスを無停止で再構築する を参考に、エイリアスを切り替えながら無停止で更新できるようにしています。
検索の実装
検索は、検索を行う側のアプリケーションに hoge_search_condition
といった検索条件を扱うActiveModelを定義し、 form_for
でパラメータを受け取れるようにしました。
正直ここからは力技で、複数の検索項目をelasticsearchのクエリ化する concern
として hoge_query_builder
みたいなものを実装し、 hoge_search_condition
にincludeしました。
ここで作ったクエリを、indexerと同様の定義がされている Searchable_*
Objectの search
メソッドに渡してあげることで複雑な検索が可能となります。
ちなみに、elasticsearchのクエリは長大なJSON(Rubyの世界ではHashでOK)になりやすく、 should
must
filter
といったコンテクストごとや、 has_parent
has_child
といった階層ごとにメソッドを細かくしていかないとテストがしづらくてツライかとお思います。
空の検索項目への対応
検索項目の中に空のものがあって、
{ term: { some_attribute: nil } }
といったクエリを投げると、elasticsearchはエラーを返します。
そこで、検索valueが nil
だったら、 用意された term
や range
などといったelasticsearchのクエリではなく、nil
を返すような簡易なHelperメソッド的なものを用意し、bool
の等の上階層で Array#compact
を使って捨てるような実装を入れています。
クエリビルダーの検討
ちなみに、elasticsearchのクエリビルダ−に関してはいくつかgemがあるようでしたが、決定版というほどのものもないのかなという判断で、自分でconcern化してメンテした方が安全かなという判断をしてしまいました。
ここは、オススメなどあればお聞きしてみたいところです...。
MAX_RESULT_WINDOW の考慮
これは当初考慮が漏れていて対応が遅れたのですが、elasticsearch 2系では MAX_RESULT_WINDOW
という検索結果の最大表示可能件数が10000件に設定されています。
これを越えて結果にアクセスしようとするとelasticsearchのエラーが起こるため、 Kaminari
によるpaginateと併せて、以下のような対応をしています。
@results = Kaminari
.paginate_array(
@condition.search(params[:page].to_i),
total_count: [
@condition.total_count,
SearchableHoge::MAX_RESULT_WINDOW
].min
).page(params[:page].to_i)
.per(50)
画面では「検索結果◯◯件のうち、#{MAX_RESULT_WINDOW}件表示しています」みたいな対応で乗り切っています。
最後に
本当はもっとアレコレ苦労した気がしますが、とりあえずこんなものでしょうか。
自然言語による検索の精度についてはもっとアレコレ検討したいところです。
オフィシャルのドキュメントや、Qiitaやブログ記事等を参考にしたり、elasticsearch関連の勉強会に参加させていただいたりしながら、実装をすすめる上で考えたことなどまとめてみました。
とはいえ、かなり自分自身、手探りで勧めた感があるので、「もっといいやり方があるよ」ということであれば是非教えていただきたいです!
以上、 Livesense Advent Calendar その1 の20日目の記事でした。