既存のRailsアプリの検索にElasticSearchを導入してみる



Greenという転職サイトで、ElasticSearchを導入して遊んでみようと思います。
具体的には今の求人が持っているデータをElasticSearchに入れてみて実際に開発環境で動かす事をGoalとします。

## 全文検索とは？

全文検索(Full text search)とは、コンピュータにおいて、複数の文書(ファイ ル)から特定の文字列を検索すること。「ファイル名検索」や「単一ファイル 内の文字列検索」と異なり、「複数文書にまたがって、文書に含まれる全文を 対象とした検索」という意味で使用される。 - 引用：Wikipedia

## インデックスの違い

### Bツリーインデックスとは？
![images.jpg](https://qiita-image-store.s3.amazonaws.com/0/5365/9c993ed8-385b-a510-99a4-b4ac3d883d78.jpeg "images.jpg")
- 引用：[What Is A B Tree Index](http://www.credit-healer.com/picspab/index.php?p=What-Is-a-B-tree-Index)

[RDBのインデックスについて](http://promamo.com/?p=2207)

### 転置インデックスとは？
![1.gif](https://qiita-image-store.s3.amazonaws.com/0/5365/cd3f1ce6-7872-bc4c-1ca8-d5d921d1e31d.gif "1.gif")
- 引用：[thinkit](JBossとLuceneを使った検索システムを作る)

[elasticsearchを全文検索サーバとして活用するなら読んでおきたい、6つのブログ記事をピックアップ（追記あり）](http://y-ken.hatenablog.com/entry/essential-japanese-blogs-for-elasticsearch-study)

## 見出し語の切り出す方法

上で転置インデックスについて説明しましたが、では索引を作るためにはどういった手段があるでしょうか。
代表的なものだけ簡単に説明します。

### 形態素解析

これはrubyの人だとmecabを使った事があると思うので、同じみのものですねw
辞書を登録することで、単語を意味のある単位に切ってくれるというものです。
辞書ありきなので、辞書を常にアップデートすることが重要になってきます。

参考)
[形態素解析とは？](http://gengoro.zoo.co.jp/)

### N-gram

形態素解析は辞書ありきだったのに対して、N-gramは特定の文字数で文字を切っていくタイプです。
下記がイメージ

![31.png](https://qiita-image-store.s3.amazonaws.com/0/5365/1428f9b8-8910-bc28-4b0f-d88af332e060.png "31.png")

参考)
[N-gramってなんだ](http://handin.sakura.ne.jp/archives/179)

実際に見るのが早いのでanalyzerを実行できるプラグインを入れておきます。

```
$ /usr/local/bin/plugin -install polyfractal/elasticsearch-inquisitor
```

実際に下記のURLにアクセス → analyzersを選択

[http://localhost:9200/_plugin/inquisitor/#/analyzersz](http://localhost:9200/_plugin/inquisitor/#/analyzersz)

## 全文検索エンジンの代表例

最近だと、Apache Solr、elasticsearch、Amazon cloudsearchあたりですね。
下記の記事に比較が書いてますので参考までに。
[全文検索システムの比較 - Elasticsearch vs Solr vs Amazon CloudSearch](http://mojavy.com/blog/2014/02/10/search-engine-comparison/)
[SolrとElasticsearchを比べてみよう](http://www.slideshare.net/shinsuke/solr-vses2014)

wantedly、niconicoなど有名サービスがelasticsearchを導入しているところを見ると、
あまり疑わずelasticsearchを導入すればいいかなーと個人的には思ってます。

cross2015でもこの辺の議論はありました。

参考)
[CROSS 2015で話をしてきました #cross2015](http://blog.johtani.info/blog/2015/01/29/talk-at-cross2015/)
[Elasticsearchの紹介と特徴 CROSS 2015](https://speakerdeck.com/johtani/elasticsearchfalseshao-jie-tote-zheng-cross-2015)


## インストールから設定まで

まずはelasticsearchをインストールします。
詳細は[wantedlyのブログ](http://engineer.wantedly.com/2014/02/25/elasticsearch-at-wantedly-1.html)に書いているので、そちらを参考にしてください。

elasticsearch-railsというgemが用意されてます。([document](https://github.com/elasticsearch/elasticsearch-rails))
まずはGemに追加してbundle install

```rb:Gemfile
# for elastic search
gem 'elasticsearch-rails'
gem 'elasticsearch-model'
```

include Elasticsearch::Model を記入するといくつかのメソッドを用意してくれます。
(search、import等)

[__elasticsearch_ delegate](https://github.com/elasticsearch/elasticsearch-rails/blob/master/elasticsearch-model/lib/elasticsearch/model.rb#L112)

今回は登録ユーザを検索するので、user modelに読み込ませるmoduleを書いていきます。

```rb
require 'active_support/concern'
module JobOffer::Searchable
  extend ActiveSupport::Concern

  included do
    include Elasticsearch::Model

    # Customize the index name
    index_name "green_application"

    # Set up index configuration and mapping
    settings index: {
      number_of_shards:   1,
      number_of_replicas: 0,
      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"
          }
        }
      }
    } do
      mapping _source: { enabled: true }, 
_all: { enabled: true, analyzer: "kuromoji_analyzer" } do
        indexes :id, type: 'integer', index: 'not_analyzed'
        indexes :title, type: 'string', analyzer: 'kuromoji_analyzer'
		 ・・・
      end
    end

    def as_indexed_json(options={})
      hash = self.as_json(
        include: {
          job_types: { only: [:job_type_id] },
		   ・・・
        }
      )
      hash['client_name'] = client.name
	  
      hash
    end
  end

  module ClassMethods
    def create_index!(options={})
      client = __elasticsearch__.client
      client.indices.delete index: "green_application" rescue nil if options[:force]
      client.indices.create index: "green_application",
      body: {
        settings: settings.to_hash,
        mappings: mappings.to_hash
      }
    end
  end
end
```

```
analyzerとは？
インデックスを作成したり、クエリを解析するときにデータをどのように処理するかを指定するための機能。分割方法を定義するtokenizerと、分割後の文字列の整形処理を定義するfilterによって構成されます。
```

mappingの部分で指定しているプロパティの説明は下記になります。

```
_source

_sourceプロパティのenabledをtrueにすると、データを全てストアしておきます。
今回はelastic searchの方でidを抽出して、RDBを叩くので一旦falseで問題ないです。
```
また今回はuserデータにいくつかhas_manyのrelationの子データも検索対象としたいので、
```def as_indexed_json(options={})```メソッドで追加します。

データの型や、その他の設定に関しては[本家ドキュメント](http://www.elasticsearch.org/) or [elasticsearchの本](http://www.amazon.co.jp/%E9%AB%98%E9%80%9F%E3%82%B9%E3%82%B1%E3%83%BC%E3%83%A9%E3%83%96%E3%83%AB%E6%A4%9C%E7%B4%A2%E3%82%A8%E3%83%B3%E3%82%B8%E3%83%B3-ElasticSearch-Server-Rafal-Kuc/dp/4048662023)を見ながら理解してもらえれば。

job_offer modelにて上記のmoduleをincludeします

```rb
class JobOffer < ActiveRecord::Base
  include Joboffer::Searchable
```

includeをしたら下記を実行してデータをimportさせます。

```rb
# 既にindexを作っている場合は下記のコマンドで削除
JobOffer.__elasticsearch__.client.indices.delete index: JobOffer.index_name rescue nil
JobOffer.create_index! force: true
JobOffer.import
```

ちなみにこの場合でいくと、子要素データに検索をかける事は上手くいきません。。。
例えば、userというmodelにuser_skillsという子要素を配列で突っ込んで検索をかけると、

```json

{
  "query": {
    "filtered": { 
      "query": {
        "match": { "user_skills.id": 100 }
      },
      "filter": {
        "range": { "user_skills.year": { "gte": 6 }}
      }
    }
  }
}

```

以下のようなデータも対象となってしまいます。。。

```
・・・
user_skills: [
  { id: 100, year: 4 },
  { id: 200, year: 8 }
]
```

求人検索の方であれば、子要素でそこまで細かいfilterは必要ないので、こちらの方がデータ構造としても扱いやすい & 分かりやすいですね。
しかし、今回は子要素で検索をかける必要があるので(それelasticsearch使うのか！？説は一旦おいておいて)、別の手段で構成を創っていきます。

childデータを、elasticsearch-modelのgemを使って入れるやり方はgithubの[example](https://github.com/elasticsearch/elasticsearch-rails/blob/master/elasticsearch-model/test/integration/active_record_associations_parent_child.rb)を参考に。

僕はなぜか上手く行かず。。。
もし上手くimportでparent/childを実現出来ている人がいれば教えてほしい。。。

## kuromojiとは？

rubyの代表格mecabと並びjavaベースの検索システムの形態素解析器 kuromoji
mecab同様辞書が命ですねw

参考)

[Java製形態素解析器「Kuromoji」を試してみる](http://www.mwsoft.jp/programming/lucene/kuromoji.html)
[Kuromojiは何で研究にあまり使われないのか？](http://d.hatena.ne.jp/akkikiki/20140325/1395751195)

## 実際に動かしてみる(Sense)

クエリをelasticsearch上で実行するためのツールとしてsenseというものが用意されています。
下記でインストールして下さい。

```
$ /usr/local/bin/plugin -install elasticsearch/marvel/latest
```

参考)
[ElasticSearch 101 – a getting started tutorial](http://joelabrahamsson.com/elasticsearch-101/)
[elasticsearchのディレクトリ構造とSenseの使い方](http://blog.livedoor.jp/ashibuya0128/archives/52049935.html)

基本的には sense上でqueryを実施 → railsのコンソール上で実施 → 組み込みという流れが良さそうです。

![スクリーンショット 2015-03-11 11.40.05.png](https://qiita-image-store.s3.amazonaws.com/0/5365/d21953bd-46a6-4e4b-df6e-32d90a3f9052.png "スクリーンショット 2015-03-11 11.40.05.png")



## 実際に動かしてみる(rails)

まずは、コンソール上で検索をかけてみる


```rb
JobOffer.__elasticsearch__.search(
    query: {
      term: { "title" : "ruby" }
    },
	filter: {
    	term: {
        	status_id: 1 # open求人
        }
    },
    sort: [
    	{ point: "desc"}
    ],
    size: 100,
    from: 200
).records.to_a
```

ちなみに.to_aをすると、elasticsearch-railsがdocumentのidを引っこ抜いて、rdb側にidでwhereを実施→配列にして返してくれる！
elasticsearch-rails使って運用する際はsourceはfalseで問題ないですね。
その辺の実装は下記にリンク貼っておきます。

参考)
[ids](https://github.com/elasticsearch/elasticsearch-rails/blob/master/elasticsearch-model/lib/elasticsearch/model/response/records.rb#L33)
[records](https://github.com/elasticsearch/elasticsearch-rails/blob/b40479d371836f303dd8aaabed33a75d5de2898f/elasticsearch-model/lib/elasticsearch/model/adapters/active_record.rb#L15)

デフォルトのサイズが10件になっているので、sizeを指定することで.to_aで返ってくる要素数を変更できます。fromはoffsetと同じようなイメージです。

### query と filterの違い

ほとんど同じ事ができるので最初は戸惑うqueryとfilter
実質はかなり違うので簡単な説明を下記に書いておきます。

- query

全文検索を実施したい際に用いる(キーワード検索等)
relevance scoreに影響有り

- filter

status_idや完全一致で検索をかけられるものに関しては用いる(ステータス検索等)
キャッシュが効くのでパフォーマンス的にはかなり良い
relevance scoreに影響は出ない

### どうクエリを生成していくべきか？

大きく2つ考えられる

- 入力情報から動的にクエリを生成
- query-stringを作って一発解決！

実際にやろうと思うと動的のクエリ生成は結構難しい。
検索項目やソートが多くなればなるほど、多様なクエリを作れるようにしないといけない。

一方でquery-stringはある種sqlインジェクション的な危険をはらんでいるw
ググるとQiitaの人が同じような事を書いていた。

[Qiitaがquery-stringっぽい構文を自前実装した理由](http://qiita.com/yuku_t/items/d749111f589c88c8315b)

Qiitaは前者で実装しているらしい。
rubyクライアント向けのOSSも作ってて素敵w
Greenでも本格的に導入するなら、クエリ周りのOSSは作る必要あるな。

一応試しに開発環境にいれるためにクエリ生成を書いてみた。

```rb
  def build_elasticsearch_query
    hash = {}
    if keyword
      hash[:query] = {
        simple_query_string: {
          fields: ["abstract", "title", "access"],
          query: "#{keyword}",
          default_operator: "and"
        }
      }
    end
    hash[:filter]       = {}
    hash[:filter][:and] = []
    hash[:filter][:and] << { term: { status_id: 1 } } unless find_closed
    hash[:filter][:and] << { range: { salary: { gte: salary_bottom.value } } }
    hash
  end
```

簡単に作っただけなので、問題はないが、本格的なクエリになればなるほど条件分岐も増えて大変。。。
さすがに汎用性のある状態で使いたいので、上手くOSSにしたいところ。

## elasticsearch関連で使えそうな便利ライブラリ
- [elasticsearch-ruby](https://github.com/elastic/elasticsearch-ruby)
- [Yamabiko (Fluentd based MySQL/MariaDB Replicator for Elasticsearch/Solr)](https://github.com/y-ken/yamabiko)
- [searchkick](https://github.com/ankane/searchkick)

searchkickは使えてないけど、かなり便利そう！
[速攻で検索機能を実装できるsearchkickを調べた（Ruby）](http://qiita.com/yutori_enginner/items/f0af67f62f4692d68370)

## 総論

- RDB=遅い→全文検索だ！！ 的な雰囲気を出している人がいるが、当たり前ながら得意不得意があるので、全てのアプリケーションで有効というわけではないw
- rails/rubyとのgemも出ているので、取っ付き易い
- ハイライトや予測変換など、使ってないけどキーワード検索をさせる上で面白い機能がいっぱいあった
- [ElasticSearch Serverの本](http://www.amazon.co.jp/%E9%AB%98%E9%80%9F%E3%82%B9%E3%82%B1%E3%83%BC%E3%83%A9%E3%83%96%E3%83%AB%E6%A4%9C%E7%B4%A2%E3%82%A8%E3%83%B3%E3%82%B8%E3%83%B3-ElasticSearch-Server-Rafal-Kuc/dp/4048662023)が分かりやすいので、とりあえず実運用させる際は買うことをおすすめしますw


## 参考
[
「niconicoの検索を支えるElasticsearch」と題して、第7回Elasticsearch勉強会で発表しました](http://ch.nicovideo.jp/dwango-engineer/blomaga/ar674946)
[Qiitaがquery-stringっぽい構文を自前実装した理由](http://qiita.com/yuku_t/items/d749111f589c88c8315b)
[Rails ConsoleでActiveRecordをElasticsearchに放り込んでみる](http://blog.enogineer.com/2014/12/03/rails-console-elasticsearch/)
[Elasticsearch at CrowdWorks](http://www.slideshare.net/mumoshu/20141118-es)
[elasticsearch-rails を使っているときの custom analyzer 設定の書き方](http://blog.inouetakuya.info/entry/2014/11/16/190649)
[Ruby: Rails から Elastic Search で全文検索を実行](http://snippets.feb19.jp/?p=1522)
[active_record_associations_parent_child.rb](https://github.com/elasticsearch/elasticsearch-rails/blob/master/elasticsearch-model/test/integration/active_record_associations_parent_child.rb)
[Getting Started with Elasticsearch on Rails](http://www.codinginthecrease.com/news_article/show/409843?referrer_id=)
[How To Setup Elasticsearch In Your Rails Application In Development](http://www.binarywebpark.com/how-to-setup-elasticsearch-in-your-rails-application-in-development/)
[fields not in mapping are included in the search results returned by ElasticSearch](http://stackoverflow.com/questions/12002069/fields-not-in-mapping-are-included-in-the-search-results-returned-by-elasticsear)
[elasticsearch/elasticsearch-rails](https://github.com/elasticsearch/elasticsearch-rails/tree/master/elasticsearch-model)
[elasticsearch-railsのupdateコールバック処理がモヤモヤ](http://qiita.com/t-sato/items/eb13962bebae2ebbbff1)
[Rails ConsoleでActiveRecordをElasticsearchに放り込んでみる](http://blog.enogineer.com/2014/12/03/rails-console-elasticsearch/)