Edited at
LivesenseDay 20

検索画面をRails + elasticsearchで作るために考えたことや、やったこと

More than 1 year has passed since last update.


はじめに

こんにちは。転職会議チームでエンジニアをやっている @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のドキュメント設計をするといろいろとツライことになりそうなのでご注意ください。

たとえば、


  1. 単一typeのみを削除するAPIは提供されていない

  2. type間の関連性は親:子で1:多は実現できるが、多:多は実現できない

といった特徴があります。

※ 1については、Twitterに疑問を投げたところ、elastic社の@johtaniさんには以下のようにリプライをいただきました。(ありがとうございます!)

というわけで、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 } }
]

https://github.com/elastic/elasticsearch-ruby/blob/master/elasticsearch-api/lib/elasticsearch/api/actions/bulk.rb#L13

ヘッダ行と、ドキュメントの実値の行で分ける必要があるんですね。

ドキュメントの実値は 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

ポイントは parentrouting で、bulk importするときに、親を持つドキュメントをindexingする際は、親のidを明示的に指定する必要があります。

そこで、子側の searchable_* objectにparentとroutingというattributeを持たせました。


というわけで

indexingは以下のようなフローで行われます。


  1. ActiveRecordによって、DBから求人の元データを取得


  2. include Elasticsearch::Persistence::Model しているObjectに変換

  3. bulk API用のパラメータに変換

  4. 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 だったら、 用意された termrange などといった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日目の記事でした。