読者です 読者をやめる 読者になる 読者になる

リア充爆発日記

You don't even know what ria-ju really is.

RailsとMySQL5.6.xで全文検索をやってみた

時間がないので備忘録的に書いていく。

http://dev.mysql.com/doc/refman/5.6/en/fulltext-search.html

前提

全文検索には、Natural Language Full-Text SearchesとBoolean Full-Text Searchesがある。
日本語で前者を使うには、分かち書きを別途導入する必要があって辞書メンテとかが大変なので、後者を選択。

後者の場合はNgramを使えば言語問わずなんとかなる。Ngramについては別途ググってください。


MySQLの設定

[mysqld]
ft_min_word_len = 2
innodb_ft_min_token_size = 2

上がMyISAM用で下がInnoDB用なのでどっちかでいい人はどっちかだけで。
デフォルトは4なんだけど、日本語の場合2文字あれば充分意味を為す単語が多いので2文字から。1文字から引っ掛けたい人は1を設定。ちなみにデフォルトだと英語でもdogとかは引っかからないので英語のブログとかだと3を設定するケースが多かった。ように思う。
追記)innodb_ft_min_token_sizeのデフォは3だった。http://dev.mysql.com/doc/refman/5.6/en/innodb-parameters.html#sysvar_innodb_ft_min_token_size

gem

ngramはシンプルなので自分で書いてもいいけど、自分で書く必要もないので以下のgemを使うことにした。

https://github.com/tkellen/ruby-ngram

gem "ngram", "~> 1.0.0"

migration

model

model(テーブル)の作成自体は特に気を使うは必要は・・・あるかな。
Ngramを使う場合、たとえば検索対象の文が"あいうえお"だったら"あい いう うえ えお"という感じになる(n=2の場合)ので、引っ掛けたいコンテンツのボリュームによるけど少なくとも3倍以上の容量を確保しておく必要がでてくる。

ので、検索対象とするコンテンツのMAXから計算して足りるくらいのデータ長を確保しておく。

index

MySQL依存になってしまうのはあたりまえだけど仕方ない。
で、たぶんもうこれは、生SQLを実行するしかない。
modelのmigrationにくっつけてもいいんだけど、INDEXの再作成時とかに便利なので別途migration定義をすることにした。

class AddFulltextIndexToYourModels < ActiveRecord::Migration
  def self.up
    execute "create fulltext index your_model_fulltext_index on your_models (content);"
  end

  def self.down
    execute "drop index your_models_fulltext_index on your_models;"
  end
end

こうしておくと、たとえばデータ作成バッチとかで

    require Rails.root.join("db/migrate/2014xxxxxxxxxx_add_fulltext_index_to_your_models.rb")
    AddFulltextIndexToYourModels.down
    
    # loading.....

    AddFulltextIndexToYourModels.up

みたいに使えてDRYに便利。


model

ngramを使う場合は、データをぶちこむのも、検索するのも対象コンテンツをngramにかける必要がある。

class YourModel < ActiveRecord::Base
  MAX_LENGTH_CONTENT = 1000000
  validates :content, presence: true, length: {maximum: MAX_LENGTH_CONTENT}

  before_save :ngram_data

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

  private

  def ngram_data
    self.content = YourModel.ngram(self.content) if self.content.present?
  end

  def self.ngram(data, connector=' ')
    n ||= NGram.new({
                        size: 2,
                        word_separator: "",
                        padchar: ""
                      })

    parsed = n.parse(data)
    parsed.join(connector)
  end

end

各ワードに+をつけているのは、boolean modeで「含む」を表すため。-を付けると「含まない」になる。けど、そういうフクザツなのは今回考慮しない。


spec

ここがけっこうハマった。

  describe '.search' do
    before do
      @your_model.content = 'こんにちは'
      @your_model.save!
    end

    it { expect(YourModel.search('こんにちは').count).to eq 1 }
  end

こんな感じで問題ないはずなんだけど、なぜかテストがとおらなかった。

原因はDatabaseCleanerの設定にあった。
DBがMySQLなら、strategyはたいがいtransactionにしといたほうが、早いからいいんだけど、transactionだとどういうわけかindexがロールバックされちゃうのかな、とにかく引っかからない。truncationにするとうまくいく。
追記)よく調べたらtransactionは、commitすることなくrollbackで高速化を図っているという話だった。そんくらい名前から察せよ、という感じですな。とにかくcommitされなかったらindexもつくられないよね、ということで。

なので、fulltext indexを使うやつだけtruncationにした。

spec_helper.rb
>|ruby|
config.before(:suite) do
DatabaseCleaner.strategy = :transaction
DatabaseCleaner.strategy = :truncation, {only: %w(your_models)}
FactoryGirl.reload
end
|


追記)上記の設定は機能しない。よく考えりゃあたりまえか・・・。以下のように個別に対応した。

describe YourModel do
  before(:all) do
    DatabaseCleaner.strategy = :truncation
  end

  after(:all) do
    DatabaseCleaner.strategy = :transaction
  end

LIKE検索程度でいいんだったら、この程度の手間で使えるのでマジおすすめ。

[改訂新版] Apache Solr入門 ~オープンソース全文検索エンジン (Software Design plus)

[改訂新版] Apache Solr入門 ~オープンソース全文検索エンジン (Software Design plus)