はじめに
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-awesomeとactiverecord-mysql-commentのgemをインストールします。両方ともRails4.2.0時点では正常に動作します。
% vi Gemfile gem 'activerecord-mysql-awesome' gem 'activerecord-mysql-comment' % bundle install
なお、activerecord-mysql-comment
はactivereord-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
は+
、OR
はOR
、NOT
は-
の演算子をつけます。たとえば、今日
と雨
の両方が含まれるものを検索する場合には今日 +雨
と入力します。デフォルトでは演算子なしはOR
になっているので注意してください。ANDに変更するには、先頭に*D+
というDプラグマをつけます。詳細はこちらを参照してください。
ここまでで基本的な全文検索の機能は使えると思います。
この後はいくつかのオプション機能や関数を使う方法を紹介します。
スニペット
Mroongaでは全文検索でヒットしたキーワードの周辺のスニペット(断片)を抽出するmroonga_snippet
関数が提供されています。
http://mroonga.org/ja/docs/reference/udf/mroonga_snippet.html
ちょっとすぐに試す気にはなれない、非常に長いシンタックスですね。そこで検索クエリとカラム指定だけで自動で組んでくれるスコープ例を作っておきました。app/model/concern/mroonga.rb
にmrn_snippet
とmrn_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
スコープは検索クエリから自動でワードを正規表現で抽出するようにしています。なお、だいたいは動くと思いますがあまり厳密にはやっていないので気になるところがあれば、適宜修正してください。
これで、検索結果一覧で検索ワードのみを太字やハイライトさせてその周辺のみを表示させることができます。
他の検索機能
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のデフォルト。