このエントリでは Ruby on Rails と MySQL を使って日本語の全文検索を行う方法を記述する。Ruby on Rails のバージョンは 2.0.2、MySQL のバージョンは 5.0.67、Tritonn のバージョンは 1.0.12、Hyper Estraier のバージョンは 1.4.10 を使用した。サンプルの文章データとして、あらゆる日本人にとって極めて身近な著作権切れ文章である『ドグラ・マグラ』と『黒死館殺人事件』を利用した。処理のために整形したデータは本エントリに添付しておく。またデータベースへアクセスするコードではマイグレーションを除きできるだけベンチマークを取るようにし、その結果は本エントリの最後に記載する。
ページネーション
Rails でページネーションを実現する will_paginate という plugin は ActiveRecord に標準でついているような検索メソッドには対応しているものの、全文検索用 plugin による検索のように特殊なメソッドを使う検索結果のページネーションには対応していない。しかし will_paginate/lib/will_paginate/finder.rb を見ると、手軽に独自のやり方を追加できることがわかる。acts_as_tritonn の find_fulltext に対応したページネーションを実装すればよい。
まずは will_paginate をインストールする。公式の Wiki には「The gem is preferred method of installation; if you have the plugin in "vendor/plugins/will_paginate/" directory, it may be best to remove it and simply configure your application to load the gem.
」とあるので次のようにして gem からインストールする:
gem sources -a http://gems.github.com
gem install mislav-will_paginate
今後は will_paginate をインストールしているものとして話を進める。全文検索におけるページネーションへの対応は各 plugin の項で説明する。
全文検索を行うことのできる二つの plugin
MySQL で日本語の全文検索を行うには二つのメジャーな方法がある、Tritonn を使う場合と Hyper Estraier を使う場合だ。どちらの場合も SQL で全文検索専用の構文を使わなけらず、素の Rails からではマイグレーションや検索などで多少見栄えが悪い。それを解決するために、Tritonn の場合は acts_as_tritonn、Hyper Estraier の場合は acts_as_searchable という plugin が存在する。
acts_as_triton
その名の通り Rails から Tritonn を使うための plugin。次のようにしてインストールする:
script/plugin install http://ryu.rubyforge.org/svn/acts_as_tritonn
更に、Tritonn の SourceForge.JP プロジェクトページなどから Tritonn のバイナリパッケージをインストールし、起動しておくこと。
データベースの準備
mysql コマンドを実行し、次のクエリでデータベースを作成する:
CREATE DATABASE tritonn_development DEFAULT CHARACTER SET utf8;
config/database.yml の development を次のように編集する:
development:
adapter: mysql
database: tritonn_development
username: tritonn
password:
host: localhost
encoding: utf8
timeout: 5000
各種設定は各自の環境に合わせること。
次にデータベースを定義するためマイグレーションを書くのだが、ここでひとつ問題がある。activerecord-2.0.2/lib/active_record/connection_adapters/mysql_adapter.rb を見ると、create_table は次のようになっている:
def create_table(name, options = {}) #:nodoc:
super(name, {:options => "ENGINE=InnoDB"}.merge(options))
end
ストレージエンジンが InnoDB になるようベタ書きされている。このため ActiveRecord のマイグレーションでは、MySQL の設定がどうなっていようがデフォルトでは InnoDB になってしまう。
activerecord-2.0.2/lib/active_record/fixtures.rb の RDoc には次のように書かれている:
# When *not* to use transactional fixtures: (略) # 2. Your database does not support transactions. Every Active Record database supports transactions except MySQL MyISAM. # Use InnoDB, MaxDB, or NDB instead.
つまり、transactional fixtures のためにトランザクションの無い MyISAM を避けデフォルトで InnoDB を使うようになっているのだろう。
しかし Tritonn では MyISAM を使う必要がある。デフォルトで InnoDB になってしまう設定を上書きするには、create_table に :options => "ENGINE = MYISAM" とすればよい。
まずモデルを作成する:
script/generate model paragraph
db/migrate/001_create_paragraphs.rb を編集する:
class CreateParagraphs < ActiveRecord::Migration
def self.up
create_table :paragraphs, :options => "ENGINE = MYISAM" do |t|
t.column :body, :text, :null => false
end
end
def self.down
drop_table :paragraphs
end
end
app/models/paragraph.rb を acts_as_tritonn に対応させる:
class Paragraph < ActiveRecord::Base
acts_as_tritonn
end
次にこのモデルに全文検索のインデックスを張る。次のコマンドを実行する:
script/generate migration AddIndex
db/migrate/002_add_index.rb を編集する:
class AddIndex < ActiveRecord::Migration
def self.up
add_index :paragraphs, [:body], :fulltext => "NGRAM"
end
def self.down
remove_index :paragraphs, :body
end
end
最後にマイグレーションを実行する:
rake db:migrate
あとはそこにデータを入れるだけで自動的に全文検索用インデックスが更新される。
データの投入
サンプル文章を改行区切りで記述してあるファイル text.txt を Rails アプリケーションディレクトリ直下に置き、script/runner で動かすことのできる次のようなファイル、script/add_paragraphs.rb を用意する:
# -*- coding: utf-8 -*-
require 'benchmark'
puts Benchmark::CAPTION
bm = Benchmark.measure do
open "text.txt" do |file|
while line = file.gets
line.chomp!
paragraph = Paragraph.new()
paragraph.body = line
paragraph.save!
end
end
end
puts bm
そして次のようにコマンドを実行する:
script/runner script/add_paragraphs.rb
これで text.txt の中身を一行ずつ全文検索データベースに投入することができる。
データの検索
挿入したデータを検索する script/search_paragraphs.rb を準備する:
# -*- coding: utf-8 -*-
require 'benchmark'
Benchmark.bm do |x|
x.report do
paragraphs = Paragraph.find_fulltext({:body => "人間"})
end
x.report do
paragraphs = Paragraph.find_fulltext({:body => "殺し"})
end
x.report do
paragraphs = Paragraph.find_fulltext({:body => "残酷"})
end
x.report do
paragraphs = Paragraph.find_fulltext({:body => "人間 殺し 残酷"})
end
end
検索する文字列にはサンプルデータ中で一般的な単語を使用した。次のコマンドを実行することでこのスクリプトを呼び出すことができる:
script/runner script/search_paragraphs.rb
ページネーション
まず config/environment.rb の最後へ次の一行を追加すること:
require 'will_paginate'
find_fulltext をページネーションに対応させるため、app/controllers/application.rb の最後へ次のコードを追加する:
module WillPaginate
module Finder
module ClassMethods
def paginate_by_find_fulltext(query, options)
page, per_page, total_entries = wp_parse_options(options)
WillPaginate::Collection.create(page, per_page, total_entries) do |pager|
options.delete(:page)
options.delete(:per_page)
count_options = options.dup
count_options.delete(:select)
unless pager.total_entries
pager.total_entries = count_fulltext(query, count_options)
end
options[:limit] = per_page.to_i
options[:offset] = (page.to_i - 1) * per_page.to_i
pager.replace find_fulltext(query, options)
end
end
end
end
end
見た通り、WillPaginate::Collection.create を呼び、渡したブロックの中で pager.replace に検索結果を、pager.total_entries に結果件数を代入すればよい。
これで paginate_by_find_fulltext を使ってページネーションができるようになった。実際にページネーションを行うサンプルを以下に示す。
app/controllers/test_controller.rb
class TestController < ApplicationController
def search
@search_word = params[:search_word].to_s
if @search_word != ''
@paragraphs = Paragraph.paginate_by_find_fulltext(
{:body => @search_word},
:page => params[:page],
:per_page => 10,
:order => "id"
)
end
end
end
app/views/test/search.html.erb
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ja" lang="ja">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta http-equiv="Content-Script-Type" content="text/javascript" />
<title>検索</title>
</head>
<body>
<% form_tag({:action => "search"}, {:onsubmit => "if (this.search_word.value.strip() != '') location.href='/'+encodeURIComponent(this.search_word.value.strip()).replace(/%20/g, '+').replace(/%2F/g, '%252F'); return false;", :method => "get"}) do %>
<p>
<%= text_field_tag "search_word", h(@search_word) %>
<%= submit_tag "検索", :class => "submit" %>
</p>
<% end %>
<% if defined? @paragraphs %>
<ul>
<% @paragraphs.each do |paragraph| %>
<li><%= h(paragraph.body) %></li>
<% end %>
</ul>
<%= will_paginate @paragraphs, :prev_label => 'Prev', :next_label => 'Next' %>
<% end %>
</body>
</html>
config/routes.rb を以下のように書き換える:
ActionController::Routing::Routes.draw do |map|
map.connect '/search', :controller => 'test', :action => 'search'
map.connect '/search/*search_word', :controller => 'test', :action => 'search'
end
あとは script/server で起動し http://localhost:3000/search/ にアクセスし検索を行えばよい。あらかじめ script/add_paragraphs.rb でデータを投入しておけば、多くの検索結果がある場合にきちんとページネーションが行われているのを見ることができる。
acts_as_searchable
Rails から Hyper Estraier を使っての全文検索を提供する plugin。次のようにしてインストールする:
ruby script/plugin source svn://poocs.net/plugins/trunk
ruby script/plugin install acts_as_searchable
また Hyper Estraier のバイナリパッケージも入手しておくこと。
データベースの準備
mysql コマンドを実行し、次のクエリでデータベースを作成する:
CREATE DATABASE hyperestraier_development DEFAULT CHARACTER SET utf8;
config/database.yml の development を次のように編集する:
development:
adapter: mysql
database: hyperestraier_development
username: root
password:
host: localhost
encoding: utf8
timeout: 5000
estraier:
host: localhost
user: admin
password: admin
port: 1978
node: development
各種設定は各自の環境に合わせること。
次に Hyaper Estraier のバイナリのあるディレクトリへ PATH を通した状態で Hyper Estraier のデータを置くディレクトリへ移動し、次のコマンドを実行する:
estmaster init test
Hyper Estraier を起動する:
estmaster start test
ウェブブラウザで http://localhost:1978/ を開き、administration -> Manage Nodes の順で開き、二つのテキスト入力欄を「development」と「node for development」で埋め、create を押す。これで全文検索のためのノードが生成された。
モデルを作成する:
script/generate model paragraph
db/migrate/001_create_paragraphs.rb を編集する:
class CreateParagraphs < ActiveRecord::Migration
def self.up
create_table :paragraphs do |t|
t.column :body, :text, :null => false
end
end
def self.down
drop_table :paragraphs
end
end
Hyper Estraier は MySQL とは別に存在するため、MySQL へのマイグレーションの際には特別なことをする必要が無い。
app/models/paragraph.rb を acts_as_searchable に対応させる:
class Paragraph < ActiveRecord::Base
acts_as_searchable :searchable_fields => [:body]
end
これで body カラムに全文検索インデックスが張られる。
マイグレーションを実行する:
rake db:migrate
データの投入
acts_as_tritonn の時と同様に、サンプル文章を改行区切りで記述してあるファイル text.txt を Rails アプリケーションディレクトリ直下に置き、script/runner で動かすことのできる次のようなファイル、script/add_paragraphs.rb を用意する:
# -*- coding: utf-8 -*-
require 'benchmark'
require 'acts_as_searchable'
puts Benchmark::CAPTION
bm = Benchmark.measure do
open "text.txt" do |file|
while line = file.gets
line.chomp!
paragraph = Paragraph.new()
paragraph.body = line
paragraph.save!
end
end
end
puts bm
save! 時の全文検索インデックスの更新は plugin によって隠蔽されているため、acts_as_tritonn の時と全く同じソースコードを使うことができる。
コマンドの実行も同様に行う:
script/runner script/add_paragraphs.rb
データの検索
挿入したデータを検索する script/search_paragraphs.rb を準備する:
# -*- coding: utf-8 -*-
require 'benchmark'
Benchmark.bm do |x|
x.report do
paragraphs = Paragraph.fulltext_search("人間")
end
x.report do
paragraphs = Paragraph.fulltext_search("殺し")
end
x.report do
paragraphs = Paragraph.fulltext_search("残酷")
end
x.report do
paragraphs = Paragraph.fulltext_search("人間 AND 殺し AND 残酷")
end
end
acts_as_tritonn の場合と構文が大きく違うことに注意。スクリプトの呼び出しは変わらない:
script/runner script/search_paragraphs.rb
ページネーション
まず config/environment.rb の最後へ次の一行を追加すること:
require 'will_paginate'
fulltext_seach をページネーションに対応させる plugin があるので、それを利用する:
script/plugin install http://small-plugins.googlecode.com/svn/trunk/will_paginate_acts_as_searchable/
この plugin をインストールすることによって使えるようになる paginate_by_fulltext_search を使ったページネーションを行うサンプルを以下に示す。
app/controllers/test_controller.rb
class TestController < ApplicationController
def search
@search_word = params[:search_word].to_s
if @search_word != ''
@paragraphs = Paragraph.paginate_by_fulltext_search(
@search_word,
:page => params[:page],
:per_page => 10,
:order => "id"
)
end
end
end
app/views/test/search.html.erb
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ja" lang="ja">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta http-equiv="Content-Script-Type" content="text/javascript" />
<title>検索</title>
</head>
<body>
<% form_tag({:action => "search"}, {:onsubmit => "if (this.search_word.value.strip() != '') location.href='/'+encodeURIComponent(this.search_word.value.strip()).replace(/%20/g, '+').replace(/%2F/g, '%252F'); return false;", :method => "get"}) do %>
<p>
<%= text_field_tag "search_word", h(@search_word) %>
<%= submit_tag "検索", :class => "submit" %>
</p>
<% end %>
<% if defined? @paragraphs %>
<ul>
<% @paragraphs.each do |paragraph| %>
<li><%= h(paragraph.body) %></li>
<% end %>
</ul>
<%= will_paginate @paragraphs, :prev_label => 'Prev', :next_label => 'Next' %>
<% end %>
</body>
</html>
config/routes.rb を以下のように書き換える:
ActionController::Routing::Routes.draw do |map|
map.connect '/search', :controller => 'test', :action => 'search'
map.connect '/search/*search_word', :controller => 'test', :action => 'search'
end
Hyper Estraier を起動してから script/add_paragraphs.rb でデータを投入し、次に script/server で Rails アプリケーションを起動し http://localhost:3000/search/ にアクセスし検索を行えば、ページネーションが動作していることを確認することができる。
ベンチマーク結果
まず念のために言っておくと、これはそれぞれの plugin を通しての結果なので Tritonn 及び Hyper Estraier が持つ本来のパフォーマンスとは限らない。plugin を使わなくてもいいというのなら Tritonn や Hyper Estraier の複雑な構文をそのまま Rails で使うことで更に高速化する余地は残されている。
以下は、ディスク I/O を最小限にするため、ディスク上のファイルへあらかじめアクセスして HDD 内部や OS 側でのキャッシュを効かせた状態で計測している。
search_paragraphs.rb の動作結果:
acts_as_tritonn
user system total real
0.063000 0.031000 0.094000 ( 0.110000)
0.015000 0.000000 0.015000 ( 0.015000)
0.000000 0.000000 0.000000 ( 0.000000)
0.000000 0.000000 0.000000 ( 0.000000)
acts_as_searchable
user system total real
0.000000 0.000000 0.000000 ( 0.016000)
0.000000 0.000000 0.000000 ( 0.016000)
0.000000 0.000000 0.000000 ( 0.015000)
0.000000 0.000000 0.000000 ( 0.000000)
一番最初だけ 4 倍の差で acts_as_seachable の方が速い。しかし残りのうち二つでは acts_as_tritonn の方が速かったりするので、実運用上の様々な検索を実行した場合にはもっと違う、複雑な結果が出ると思う。実際に使う場面に応じて必要な計測を行い、適したものを使うべきだろう。
add_paragraphs.rb の動作結果:
acts_as_tritonn
user system total real
4.328000 1.422000 5.750000 ( 8.921000)
acts_as_searchable
user system total real
10.844000 4.891000 15.735000 (168.829000)
ちょっと見過ごせない程の極端な差がついた。acts_as_seachable で save! の時に毎回インデックスを更新していることが問題になっているようだ。Hyper Estraier の User's Guide にはこうある。
可用性の確保
インデックスを更新している最中にはロックがかかるので、そのインデックスを使った検索はその間はできなくなります。検索システムとしては、その間は停止時間ということになります。それを避けるためには、インデックスのコピーに対して更新を処理を行い、完了したらオリジナルと入れ換えるようにするとよいでしょう。
Hyper Estraier では元々大規模な利用においてリアルタイムでインデックスを更新することを考えていないようだ。従って acts_as_seachable の実装に問題があるということになる。簡単に全文検索インデックスを付けたい場合に acts_as_searchable は使えるが、現状のままではある程度の更新が発生する大規模サイトに使うのは難しい。acts_as_searchable から更新時のリアルタイムインデックス更新機能を削り、任意のタイミングでインデックスを更新するように改造する必要がある。
acts_as_tritonn の場合にはこのような手間はよほど大規模にならない限り必要無いと思われるため、そのまま使うことができる。
text.zip
最近のコメント