CreateField Blog

オープンソースを使って個人でWebサービスを開発・運営していたブログ

Railsで高速全文検索エンジンMroongaを使うためのチュートリアル

はじめに

MySQLでオープンソースの日本語対応の高速な全文検索エンジンGroongaが使えるMroongaを使って簡単に全文検索機能付きのRailsアプリケーションを作成する方法を紹介します。Railsのデモアプリケーションと実際に使えるサンプルの検索用のメソッドを使って具体的に説明します。

前準備

まず、RailsとMySQLとMroongaが使えるようにしてください。 これらはすでに情報がたくさんあると思うので、さほど苦なく用意できると思います。最近のMariaDBであればデフォルトでバンドルされていたりします。

簡単に試すことができるようにRubyとMySQLとMroongaが自動で環境構築されるVagrantファイルを用意しておきました。

naoa/start-mroonga-with-rails · GitHub

vagrantとubuntu/trusty64のboxとvagrant-omnibusあたりをあらかじめ用意しておき、以下のようにして仮想環境を構築してゲストOSにログインします。

% git clone https://github.com/naoa/start-mroonga-with-rails
% cd start-mroonga-with-rails
% vagrant up
% vagrant ssh

ちなみにRubyのビルドとかが入っているのでかなり時間がかかります。以下のコマンドを実行するとMySQLにMroongaが認識されていることがわかります。

% mysql -uroot -ppassword -e "SHOW ENGINES;"

Railsのインストール

bundlerを使ってRailsをインストールします。別にbundler管理下じゃなくてもいいです。すでにRails環境がある場合は不要です。

% cd /vagrant/
% bundle init
% Gemfile 
gem "rails" #コメントアウト除去
% bundle install

デモアプリケーションの作成

Railsチュートリアルの第2章にあるデモアプリケーションを作ります。

% bundle exec rails new demo_app -d mysql
% cd demo_app
% vi Gemfile
gem 'therubyracer', platforms: :ruby #コメントアウト除去
% bundle install

上記のVagrantfileではmysqlのrootユーザのパスワードをpasswordに設定しているので、config/database.yamlにパスワードを設定します。

% vi config/database.yml
password: password

チュートリアルの手順に沿って、UserとMicropostの簡単なCRUDアプリケーションを作ります。

% bundle exec rake db:create
% bundle exec rails generate scaffold User name:string email:string
% bundle exec rails generate scaffold Micropost content:string user_id:integer
% bundle exec rake db:migrate
% bundle exec rails s -b 0.0.0.0

これでブラウザでhttp://localhost:30000/microposts (上記のvagrantのやつの場合)にアクセスすることにより簡単なポスト機能のRailsアプリケーションが動作していることが確認できます。

このMicropostにMroongaを使った全文検索機能を追加してみます。

Migration関連の拡張Gemのインストール

MySQLのデフォルトでは、InnoDBというストレージエンジンが利用されます。

MySQLでストレージエンジンを指定するにはテーブルオプションにENGINE=を書きます。また、Mroongaでは、カラムとインデックスのコメントを利用することにより転置索引の見出し語をカスタマイズするためのトークナイザーや文字列を正規化するノーマライザーを変更することができます。

現状のActiveRecordではmigrationスクリプトのcreate_tableにテーブルオプションを書く機能はあるのですが、schema.rbにダンプする機能はありません。また、カラムコメントやインデックスコメントについても対応していません。

schema.rbにダンプできなくてもマイグレーションスクリプトによりSQL自体は実行できるのですが、rake db:resetのようなschema.rbを元にテーブルを復元するようなことはできなくなります。このため、SQLで直接テーブル定義を変更するような場合は、schema_format:sqlにする必要があります。この場合、schema.rbではなくstructure.sqlで管理することになります。

これらに対応するため、activereord-mysql-awesomeactiverecord-mysql-commentのgemをインストールします。両方ともRails4.2.0時点では正常に動作します。

% vi Gemfile
gem 'activerecord-mysql-awesome'
gem 'activerecord-mysql-comment'
% bundle install

なお、activerecord-mysql-commentactivereord-mysql-awesomeよりも後に書いてください(一部の追加アクセサが上書きされちゃうため)。

ストレージエンジン変更

micropostsのテーブルをMroongaストレージエンジンに変更します。Railsはテーブルオプションの変更に対応していないので、一旦、ロールバックで削除します。別にdrop_tableで削除してもかまいません。

% bundle exec rake db:rollback

マイグレーションスクリプトに以下のようにENGINE=Mroongaのテーブルオプションを追記します。

class CreateMicroposts < ActiveRecord::Migration
  def change
    create_table :microposts, options: 'ENGINE=Mroonga' do |t|
      t.string :content
      t.integer :user_id
      t.timestamps
    end
  end
end

これでマイグレートすれば、Mroongaストレージエンジンでmicropostsテーブルが作成されます。以下のコマンドでMySQLのテーブル定義を確認することができます。stringだと255文字と短いのでtextとかにしておいてもいいかもしれません。

% bundle exec rake db:migrate
% mysql -uroot -ppassword demo_app_development -e "SHOW CREATE TABLE microposts;"

+------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Table      | Create Table                                                                                                                                                                                                                                                                                                                                                                                                   |
+------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| microposts | CREATE TABLE `microposts` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `content` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL,
  `user_id` int(11) DEFAULT NULL,
  `created_at` datetime DEFAULT NULL,
  `updated_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=Mroonga AUTO_INCREMENT=6 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci |
+------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+

全文インデックスの追加

Mroongaストレージエンジンで高速な全文検索機能を使うには、以下のようにしてFULLTEXT INDEXを追加します。

% bundle exec rails g migration AddFullTextIndexToMicroposts
class AddFullTextIndexToMicroposts < ActiveRecord::Migration
  def change
    add_index :microposts, :content, type: :fulltext
  end
end

これでマイグレートすれば、全文インデックスが追加されます。

% bundle exec rake db:migrate
% mysql -uroot -ppassword demo_app_development -e "SHOW CREATE TABLE microposts;"
+------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+

| Table      | Create Table                                                                                                                                                                                                                                                                                                                                                                                                   |

+------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| microposts | CREATE TABLE `microposts` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `content` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL,
  `user_id` int(11) DEFAULT NULL,
  `created_at` datetime DEFAULT NULL,
  `updated_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  FULLTEXT KEY `index_microposts_on_content` (`content`)
) ENGINE=Mroonga AUTO_INCREMENT=6 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci |
+------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+

schema.rbでも以下のようにきちんとテーブルオプションとインデックスが出力されています。

  create_table "microposts", force: :cascade, options: "ENGINE=Mroonga DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci" do |t|
    t.string   "content",    limit: 255
    t.integer  "user_id",    limit: 4
    t.datetime "created_at"
    t.datetime "updated_at"
  end

  add_index "microposts", ["content"], name: "index_microposts_on_content", type: :fulltext

これでデータベースの準備はできました。次はRailsアプリケーションに検索機能を追加します。

Viewに検索フォームの追加

microposts#indexに非常に簡単な検索フォームを追加します。app/views/microposts/index.html.erbに以下を追記します。

<h2>Search Microposts</h2>

<%= render 'search_form' %>

以下のapp/views/microposts/_search_form.html.erbを追加します。

<%= form_tag microposts_path, method: :get do |f| %>
  <div class="field">
    <%= text_field_tag :keyword %>
  </div>
  <div class="actions">
    <%= submit_tag "Search", :name => nil %>
  </div>
<% end %>

これでSearchボタンをクリックするとフォームの内容がparams[:keyword]としてmicropostsコントローラのindexアクションに渡されます。

コントローラの修正

デモアプリケーションでは、app/controllers/microposts_controller.rbのindexアクションは以下のようになっており、Micropostの全てを出力するようになっています。

  def index
    @microposts = Micropost.all
  end

これを検索フォームのパラメータにより全文検索するように変更します。

  def index
    query = params[:keyword]
    columns = "content"
    @microposts = Micropost.where("MATCH(#{columns}) AGAINST('#{query}' IN BOOLEAN MODE)")
  end

MroongaではWHERE句にMATCH([col_name1, col_name2…]) AGAINST('検索クエリ' IN BOOLEAN MODE)の構文で全文検索することができます。通常はIN BOOLEAN MODEをつけてください。デフォルトではIN NATURAL LANGUAGE MODEの自然文検索になります*1。だいたいの用途ではIN BOOLEAN MODEでいいはずです。

これで検索フォームの内容で全文検索した結果が得られます。いくつかデータをポストしてみて試してみると検索したレコードのみが取得できることがわかると思います。

なお、このままでは検索フォームに値なしの場合0件になっているので注意です。フォームに値なしの場合をハンドリングすべきです。また、詳細なロジックを書く場合、モデルに書いた方が良いでしょう。

モデル共通のスコープ化

MATCH … AGAINST … IN BOOLEAN MODEとか長いの毎回書きたくないと思うので、ActiveSupport::Concernの機能で全モデル共通のスコープ化させておきます。

  • app/models/concerns/mroonga.rb
module Mroonga
  extend ActiveSupport::Concern
  included do
    scope :mrn_search, ->(query, columns) do
      return if query.nil?
      where("MATCH(#{columns}) AGAINST('#{query}' IN BOOLEAN MODE)")
    end
  end
end

全文検索したいモデルでincludeします。

  • app/models/micropost.rb
class Micropost < ActiveRecord::Base
  include Mroonga
end

これでコントローラー側はシンプルになります。ちなみにこの例のスコープの第二引数のcolumnsは、Arrayじゃなくて”,”区切りのStringです。

  • app/controlers/microposts.rb
  def index
    query = params[:keyword]
    columns = "content"
    @microposts = Micropost.mrn_search(query, columns)
  end

scopeでチェインしてActiveRecord::Relationが返るので、通常のActiveRecordと同様にソートなどのメソッドチェインやkaminariなどのページングライブラリなどがそのまま利用可能です。

なお検索フォームには、単語だけでなく、Googleなどと同様にAND OR NOTのブール演算式で複数の単語の組み合わせで全文検索したりフレーズ検索したりすることができます。MroongaではAND+ORORNOT-の演算子をつけます。たとえば、今日の両方が含まれるものを検索する場合には今日 +雨と入力します。デフォルトでは演算子なしはORになっているので注意してください。ANDに変更するには、先頭に*D+というDプラグマをつけます。詳細はこちらを参照してください。

ここまでで基本的な全文検索の機能は使えると思います。

この後はいくつかのオプション機能や関数を使う方法を紹介します。

スニペット

Mroongaでは全文検索でヒットしたキーワードの周辺のスニペット(断片)を抽出するmroonga_snippet関数が提供されています。

http://mroonga.org/ja/docs/reference/udf/mroonga_snippet.html

ちょっとすぐに試す気にはなれない、非常に長いシンタックスですね。そこで検索クエリとカラム指定だけで自動で組んでくれるスコープ例を作っておきました。app/model/concern/mroonga.rbmrn_snippetmrn_extract_keywordsをコピペして使ってみてください。

https://github.com/naoa/start-mroonga-with-rails/blob/master/mroonga.rb#L34-L89

これでMicropostモデルにmrn_snippet(query, columns)をチェインさせるだけでそのカラム全文の代わりに検索ワードがタグで囲まれた150バイト分のスニペットが得られるようになります。

  • app/controllers/microposts_controller.rb
  def index
    query = params[:keyword]
    columns = "content"
    @microposts = Micropost.mrn_search(query, columns).mrn_snippet(query, columns)
  end
...<span class="keyword">今日</span>は雨だな...

上記のMySQLのmroonga_snippet関数は検索クエリではなく検索ワードごとにタグを指定する必要があるのですが、mrn_snippetスコープは検索クエリから自動でワードを正規表現で抽出するようにしています。なお、だいたいは動くと思いますがあまり厳密にはやっていないので気になるところがあれば、適宜修正してください。

これで、検索結果一覧で検索ワードのみを太字やハイライトさせてその周辺のみを表示させることができます。

f:id:naoa_y:20150225115014p:plain

他の検索機能

Mroongaではデフォルトの演算子を変更するDプラグマやカラムごとの重みを変更するWプラグマや複数のワードが近い距離で出現している文書のみを抽出する近傍検索演算子*Nなどがあります。GitHubにざっくりとこれらを使えるオプションを全部盛りしたスコープ例mrn_searchを作っておいたので、よければコピペして試してみてください。これもだいたい動くと思いますが、あまりテストしていないので不具合があれば、適宜修正してください。

たとえば、近傍検索だと以下のようにオプションを指定します。

  • app/controllers/microposts_controller.rb
@microposts = Micropost
  .mrn_search(query, columns, near: {distance: 5, words: ['今日', '']})

これで今日5の距離以内に出現する文書のみが検索されます。距離はデフォルトではほぼ文字数に相当します。MeCabなどの形態素解析のトークナイザーを使う場合は単語の数だけ離れた距離になります。

トークナイザー、ノーマライザーの変更

MroongaのデフォルトのトークナイザーTokenBigramでは、アルファベット記号数字は同一字種ひとまとまりにしてトークナイズされます。たとえばDatabaseという単語に対して、tabaという検索クエリではヒットしません。アルファベットや記号を文字単位でヒットさせたい場合はTokenBigramSplitSymbolAlpha等を使います。トークナイザーの種類はこちらを参照してください。

まず、現在のインデックスを削除します。

% bundle exec rails g migration RemoveFullTextIndexFromMicroposts
class RemoveFullTextIndexFromMicroposts < ActiveRecord::Migration
  def up
    remove_index "microposts", name: "index_microposts_on_content"
  end
  def down
    add_index "microposts", ["content"], name: "index_microposts_on_content", type: :fulltext
  end
end
% bundle exec rake db:migrate

トークナイザーを変更するにはインデックスコメントで指定します。

% bundle exec rails g migration AddSFullTextIndexWithParserToMicroposts
class AddSFullTextIndexWithParserToMicroposts < ActiveRecord::Migration
  def change
    add_index "microposts", ["content"], name:"index_microposts_on_content", type: :fulltext, comment: 'parser "TokenBigramSplitSymbolAlpha"'
  end
end
% bundle exec rake db:migrate

activerecord-mysql-commentによってschema.rbにも反映されています。

  create_table "microposts", force: :cascade, options: "ENGINE=Mroonga DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci" do |t|
    t.string   "content",    limit: 255
    t.integer  "user_id",    limit: 4
    t.datetime "created_at"
    t.datetime "updated_at"
  end

  add_index "microposts", ["content"], name: "index_microposts_on_content", type: :fulltext, comment: "parser \"TokenBigramSplitSymbolAlpha\""

これで、Databaseという単語に対してtabaという検索クエリでもヒットさせることができるようになります。

念のため、以下のコマンドでGroonga側で本当にトークナイザーが認識できているか確認できます。これはmroonga_commandというMroongaの拡張関数を使ってGroonga側のコマンドを発行しています。

 mysql -uroot -ppassword demo_app_development -e "select mroonga_command('table_list');"
+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| mroonga_command('table_list')                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| [[["id","UInt32"],["name","ShortText"],["path","ShortText"],["flags","ShortText"],["domain","ShortText"],["range","ShortText"],["default_tokenizer","ShortText"],["normalizer","ShortText"]],[259,"microposts","demo_app_development.mrn.0000103","TABLE_PAT_KEY|PERSISTENT","Int32",null,null,null],[265,"microposts-index_microposts_on_content","demo_app_development.mrn.0000109","TABLE_PAT_KEY|PERSISTENT","ShortText",null,"TokenBigramSplitSymbolAlpha","NormalizerMySQLUnicodeCI"]] |
+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+

この他のMroongaのオプションの指定方法の詳細は以下を参照ください。Mroongaでコメント句で指定するものをそのままマイグレーションのコメントプションにかけば良いだけです。

http://blog.createfield.com/entry/2014/10/29/084941

https://github.com/naoa/activerecord-mysql-comment

RailsでGroongaを使う他の方法

RailsでGroongaを使う他の方法として、GroongaのRubyバインディングであるRroongaやRroongaをActiveRecordライクに利用できるActiveGroongaを利用する方法があります。

これらは、SQLiteと同様にライブラリとして全文検索の機能を提供しますが、サーバの機能は提供しないため、Railsと同じサーバに全文検索機能をのせる必要があります。

サーバを立てなくて良いというメリットでもあるので、大量アクセスをさばく必要がないようなRailsアプリケーションの場合はこちらの利用を検討してみはいかがでしょうか。

おわりに

上記のように、Mroongaを使えば、Railsアプリケーションに簡単に全文検索機能を追加することができます。MySQLなのでActiveRecord用の資産はほぼ使えますし、既存のコードを書き換える必要はほとんどありません。

MySQLでRailsを使っていて高速な日本語対応の全文検索機能が欲しいと思ったらMroongaを使ってみてはいかがでしょうか。

また、最近、PostgreSQLの拡張機能としてPGroongaが開発されています。こちらは少し構文が変わりますが、上記と同様にSQLで管理できます。そのうち、ActiveRecordで足りない部分を補足したりチュートリアルを書くかも?書かないかも?

HerokuでPGroongaが使えるようになるといいな!

*1:MySQLのデフォルト。