elasticsearch-rails運用

2年ほど前に [elasticsearch-rails検証](http://qiita.com/chase0213/items/ca6acc5d6bfecbd72be5) という記事を書いたのですが、今でもたまにいいね・ストックしてくださる方がいるので、その後どう運用しているかの続きを書きます。

# 目次

- 結局採用したの？
- どういうコンセプト？
- 設定方法は？
- 困ったところある？

# 結局採用したの？

目次に 4つ項目が並んでいる時点でお察しですが、採用しました。

# どういうコンセプト？

前回、[複数モデルにまたがるクエリ](http://qiita.com/chase0213/items/ca6acc5d6bfecbd72be5#%E8%A4%87%E6%95%B0%E3%83%A2%E3%83%87%E3%83%AB%E3%81%AB%E3%81%BE%E3%81%9F%E3%81%8C%E3%82%8B%E3%82%AF%E3%82%A8%E3%83%AA) のところで、

> そもそも authors に関する情報も article の index として保存してしまうと、authers の index を作成するときに情報が重複することになるので、index の更新時間が単純に考えて重複した index分だけ増えます。
なのでこういう使い方は、仮にできたとしてもアンチパターンまっしぐらかも知れません。

と書いたのですが、若気の至りと言いますか、RDB と KVS では違うよねというところに気づいて、リレーションごとネストして持たせる方法をとっています。
この方法によって解決される問題は、あるカテゴリに属する記事に関して、そのカテゴリ名でも hit するし、記事のタイトルや本文でも hit するという、ある種全文検索エンジンとして真っ当な使い方ができることです（しかも RDB は舐めないので速い）。

# 設定方法は？

前回と重複するところもありますが、バージョンが変わったりしているかもしれないので書いていきます。

## モデルへ全文検索機能の追加

elasticsearch-rails の機能を使用するために、`Elasticsearch::Model` を include します。

```ruby
class Category < ActiveRecord::Base

  # これを追加
  include Elasticsearch::Model

  # コールバックをフックして Elasticsearch のドキュメントを更新する場合はこちらも
  # include Elasticsearch::Model::Callbacks

  ...
```

## アナライザーとかの設定

同モデルにおいて、例えば以下のように設定を書いていきます。
[kuromoji](http://www.atilika.org/) というよく使われる形態素解析器を使っています。
細かい話をしだすとこの記事ではスペースが足りないので、ここでは割愛します。
とりあえず、この設定ファイルがわからない場合は、まずは全文検索エンジンの仕組みについての学習を先にされることをオススメします。

```ruby
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インデックスを使うのを止めて、手動で設定します。

```ruby
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側で送り出すデータを以下のように書いて変形してあげます。

```ruby
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 の用語で、検索対象の単位です）をアップロードすれば、検索を行うことができます。

### インデックス作成

```irb
> Category.__elasticsearch__.create_index! force: true
```

### インポート

```irb
> Category.import
```

## Nested Search

実際に使っているクエリを簡略化したものなので、間違えているかもしれません、雰囲気こんな感じです。
`nested`、`path`、`articles.title`辺りが肝かと思います。

```ruby
keyword = #...

# クエリの組み立て
query = {
  "bool" => {
    "should" => [
      {
        "match" => {
          "name" => keyword
        }
      },
      {
        "nested" => {
          "path" => "articles",
          "query" => {
            "match" => {
              "articles.title" => keyword
            }
          }
        }
      }
    ]
  }
}

# 検索
Category.search(query)
```

ここまで来て、検索対象って普通カテゴリじゃなくて記事だよねって気づいたのですが、今更なのでもうこのままにします。記事を検索したい場合は、上の手順の Category と Article を全て逆にすると大体正しいかと思います。


# 困ったところある？

あまりないのですが、クエリの組み立てが結構大変というのが唯一かつ最大の問題点です。
booleanクエリ（AND/ORなど）は elasticsearch ２系以降、should/must を使用する形式が採用されたのですが、英語ネイティブでないからなのかあまり直感的に書けずに毎回ドキュメントを参照している気がします。
まぁこれは elasticsearch-rails の問題ではないのですが・・・。

しかしこの点において、elasticsearch-rails では query が ruby のオブジェクトとして構築できるので、例えば must の条件を動的に変えたいという場面で、

```ruby
query[...]['must'] << some_condition
```

などと書けるのは大変良いです。

# まとめ

#### 結局採用したの？
しました

#### どういうコンセプト？
前言撤回して、ネストして（非正規化して）保持しています

#### 設定方法は？
上述の通りです

#### 困ったところある？
あまりないです