このエントリでは 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
intern2008.jpg
こんにちは、研究開発部の溝口です。


弊社でも毎年恒例となりつつある技術系のインターンシップが始まりました。

今年のインターンシップに参加される学生さんは4名で、それぞれ2名づつ2チームに分かれ、ホストとなる社員とともに、研究開発本部のテーマに沿ったプロダクト開発を約1ヶ月にわたってフルタイムで行っていただきます。

今年のテーマは、やはりニコニコ動画関連です。

インターンの最後には社内で成果発表を行っていただきますし、その成果によっては世にでる可能性も十分にあります。

参加者のみなさんにはこの機会にネット・エンターテインメントの開発サイド、仕事としてのソフトウェア開発、多くのエンドユーザ様へのプロダクト・アウトについて経験していただけることを期待しています。

プログラミングキャンプ企業見学


独立行政法人 情報処理推進機構(IPA)/財団法人 日本情報処理開発協会(JIPDEC)が主催となって8月13日〜8月17日にかけて、「セキュリティ&プログラミングキャンプ2008」が開催されています。


そのプログラムの一部として、本日、プログラミングキャンプ参加者のみなさんが企業見学としてドワンゴまで来ていただきました。


そこでは、ドワンゴの成り立ちやビジネスの経緯、開発現場の実際や、サービスができるまでのステップなどをお話させていただきました。


またその後、社内見学ツアーと、ニコニコ動画を作った戀塚を含むドワンゴのエンジニアとの交流会も実施しました。


プログラミングキャンプ参加者の方は14歳〜22歳と若い方ばかりで、交流会に参加したエンジニアも参加者の方々のプログラミングを学ぼうという強い気持ちに刺激を受けたようです。


参加者のみなさんにも、弊社のサービス開発の実際や、エンジニアの文化などにふれていただけたかと思います。


この若い人たちのなかから、将来の「達人プログラマー」が沢山輩出されることでしょう。期待しています。

弊社エンジニア、戀塚昭彦が Adobe Max Japan 2007 における講演で使用した資料を公開します。

講演をお聞きくださった皆様に感謝いたします。

また先日開催されたイベント ITpro Challenge! での動画を ITpro 様に公開していただきました。

講演資料はこちらで公開しております。

ITpro様、ありがとうございました。

第2回モバイル勉強会

| コメント(0) | トラックバック(0)
「第2回モバイル勉強会」 (2007/9/17) で用いた発表用資料です。

Files

ITpro Challenge!

| コメント(0) | トラックバック(0)

ニコニコ動画勉強会

| コメント(0) | トラックバック(0)
ニコニコ動画勉強会 2007-04-25(Wed)