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

## はじめに

こんにちは。転職会議チームでエンジニアをやっている @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さんには以下のようにリプライをいただきました。(ありがとうございます！)

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr"><a href="https://twitter.com/highwide">@highwide</a> 整合性が取りにくいからなくなっています。できれば、ライフサイクルが異なるtypeはインデックスを分けていただくのが良いかと。</p>&mdash; Jun Ohtani (@johtani) <a href="https://twitter.com/johtani/status/681358663303311360">2015年12月28日</a></blockquote>
<script async src="//platform.twitter.com/widgets.js" charset="utf-8"></script>

というわけで、RDBMSの感覚で、「ひとつのアプリケーションが利用するドキュメントは全部同一indexに入れてしまえ」とかやるとつらいことになります。
過度にtype間のリレーションを持たせると、クエリを作るところでもつらくなるので、 **できるだけフラットなドキュメント構造をtypeとして、同時に削除していいもの同士を同一indexとする** というのが良さそうです。

## elasticsearch-persistenceを使う
Railsからelasticsearchへリクエストを送るクライアントライブラリとして採用したのは [elasticsearch-persistence](https://github.com/elastic/elasticsearch-rails/tree/master/elasticsearch-persistence) です。
ネットで、Railsでのelasticsearch利用事例を見ていると、 [elasticsearch-model](https://github.com/elastic/elasticsearch-rails/tree/master/elasticsearch-model) を利用して、RDBMSのTABLE構造をそのままelasticsearchのdocumentとしている事例をときどき見かけたのですが、今回は、既存の複数TABLEをまとめて1ドキュメントとしたいという要件から、ActiveModel(with [Virtus](https://github.com/solnic/virtus))でドキュメントを表現できる `elasticsearch-persistence` を利用しました。

なお、このgemは [elastic/elasticsearch-ruby](https://github.com/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_` オブジェクト化するようなメソッドを用意しました。

例えばこんな感じでしょうか。

```ruby
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](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-bulk.html)を利用することにしました。
親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にこんなメソッドを書いて対応しました。

```ruby
  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は以下のようなフローで行われます。

1. ActiveRecordによって、DBから求人の元データを取得
2. `include Elasticsearch::Persistence::Model` しているObjectに変換
3. bulk API用のパラメータに変換
4. elasticsearchにリクエスト

初回の全件更新時は↑の処理を呼ぶRakeタスクなど書いておくといいと思います。
本当はCookpadさんの [Elasticsearch のインデックスを無停止で再構築する](http://techlife.cookpad.com/entry/2015/09/25/170000) を参考に、エイリアスを切り替えながら無停止で更新できるようにしています。

## 検索の実装
検索は、検索を行う側のアプリケーションに `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` といった階層ごとにメソッドを細かくしていかないとテストがしづらくてツライかとお思います。

### 空の検索項目への対応

検索項目の中に空のものがあって、 

```ruby
{ 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と併せて、以下のような対応をしています。

```ruby
@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](http://qiita.com/advent-calendar/2016/livesense_1) の20日目の記事でした。