RailsとMySQLでN-gram化したデータを使って全文検索をFULL TEXT INDEXで実装する


※この記事に書かれている内容は全て先人たちが解決してくれている内容ですが、自分のメモ的に残しておきます。

まさかLIKE検索とかしてないよね、、？

（最近の）MySQLでは標準で全文検索に対応しているので、簡単に全文検索システムを導入することができます。しかもRailsなら便利なgemもあって、、ふふふ。

サイト内検索を導入したい！って要望は、比較的よくあることだと思います（部分一致のユーザー名検索とかもそうですね）。データ数にほとんど変動がなくかつ数万件程度であれば、LIKE検索をしても対した検索コストにはなりませんが、増えていくようなデータに対しての全文検索でLIKE検索は完全にNGです。

**面倒だからって間違ってもLIKE検索で済まそうなんて思っちゃダメですよ。**

## なぜLIKE検索がいけないのか
理由は単純、
**パフォーマンスが出せないから**
これだけです。

LIKE検索は「インデックスが使えない」ので、テーブルのフルスキャンが発生します。データ量が少なければ問題は出ませんが、データ量が増えれば増えるほど指数関数的に検索コストがかかってきます。

データ量が増えて検索コストが異常に跳ね上がるのは、仕方ないことではありません。
**エンジニアのスキルが足りないだけです。**

※ただし、例外的なケースとして前方一致の検索であればインデックスを使えるので、こういう要件の場合にはわざわざ全文検索を行う必要はありません。

## FULL TEXT INDEXを採用していいケース
何でもかんでもFULL TEXT INDEXを導入すればいいってもんでもありません。あくまでもFULL TEXT INDEXは「簡易的に全文検索を導入できる仕組み」であって、専用のミドルウェアの性能の足元にも及びません。僕の考えでは、以下のようなケースではFULL TEXT INDEXを採用していいと考えています。

- 小規模〜中規模のアプリケーションである(DBサーバー数台程度のもの)
- データ参照/更新がそこまで頻繁ではないテーブルが対象である
- 多少の検索コストは許容できる

上記に当てはまらない場合は、ElasticSearchなどのミドルウェアの導入を検討した方がいいでしょう。

## 実装方法
### 大まかな流れ
前提条件として、FULL TEXT INDEXを使うには、対象のデータをN-gram化するか、形態素解析する必要があります。

- MySQLの設定を変更する
- マイグレーションファイルの作成
- マイグレーションの実行
- 保存時（更新時）に対象のカラムをN-gram化する
- 検索時にN-gram化したキーワードを検索に使うようにする

### MySQL
デフォルト設定だと日本語的にはあまりよろしくないので、設定を変更します。ちなみにデフォルトは3です。

```
[mysqld]
innodb_ft_min_token_size = 2
```

なぜ2にするかというと、日本語は二文字で意味を成すことが多いからです。設定を変更したら、mysqlの再起動を忘れずに。

### Rails
まずはマイグレーションファイルを作りましょう。ここでは、N-gram化したデータを保存するカラムと、INDEXを設定します。

INDEXの方はRailsで記法が用意されていないので、生SQLで書く必要があります。

```ruby
class AddFulltextToPosts < ActiveRecord::Migration
  def change
    add_column :posts, :ngram, :text
    execute "create fulltext index index_posts_on_ngram on posts (ngram);"
  end
end
```

N-gram化するのはgemがあるので、そちらを利用させていただきます。

```Gemfile
gem "ngram", "~> 1.0.0"
```

あとはmodelにロジックを書きます。今回はデータを突っ込むときにN-gram化したいので、`before_create`に処理をかましています。更新の時も処理対象なのであれば、`before_save`に書けばOKです。

```ruby
  before_create do
    self.ngram   = Post.to_ngram(self.content)
  end

  def self.search(key)
    ngramed_key = self.to_ngram(key, ' +')
    where("match(ngram) against (? in boolean mode)", "+#{ngramed_key}")
  end

  def self.to_ngram(data, connector=' ')
    n ||= NGram.new({
                      size: 2,
                      word_separator: "",
                      padchar: ""
                    })
    parsed = n.parse(data)
    parsed.join(connector)
  end
```

これでControllerとかから読み出せば検索できるはず！

### Rspec
と、ここで安心してはいけません。Railsエンジニアはテストが大好きなので、テストのことも考えてあげなければいけません。

今回のテストは、インデックスが作成されていないと検索できないので、それ用に設定を追加する必要があります。

通常テストを走らせる時って、`commit`してくれないんです。インデックスは`commit`のタイミングで生成されるので、いくらデータを用意してあげても、インデックスにヒットしないから、検索できない。そこで今回作成するテストの時は`commit`するように設定してあげます。

helperに以下の設定を追記、

```ruby
  config.use_transactional_fixtures = false

  config.before(:each) do |example|
    DatabaseCleaner.strategy = example.metadata[:cleaner] || :transaction
    DatabaseCleaner.start
  end

  config.after(:each) do
    DatabaseCleaner.clean
  end
```

テスト側で以下のように`cleaner`を設定してあげます。

```ruby
  describe 'search', cleaner: :truncation do
    before do
```

どうですか？これで万事OKですね。

## 参考
以下のサイトを参考にさせていただきました！（ほとんどパクってます！

http://ria10.hatenablog.com/entry/20140107/1389071672
http://qiita.com/yoshitsugu/items/3470dbcadfdd677be543
http://qiita.com/kei-p/items/8aa444d598e2e24f6a9b