Ludia 用の Rails プラグイン acts_as_ludia を作りました

第四十六回

使い方や機能など,詳しくは後で書きます書きました.
acts_as_ludiaの更新情報もご覧下さい.

acts_as_ludiaの概要と機能

LudiaによるPostgreSQLの高速全文検索機能をRuby on Railsから利用するためのプラグインです.名前はacts_as_ludiaといいます(そのまんまですいません).
今までRailsからLudiaを使う場合,findのconditionsオプションにクエリをごりごり書く必要がありました.

Model.find(:all, :conditions => "body @@ '*D+ ruby rails'")

これが,acts_as_ludiaを利用すると以下のように簡潔にコードを書くことができます.もちろん,AND/OR検索や日本語での検索も可能です.

Model.find_fulltext(:body => "ruby rails")

また,Ludia 1.0.0から追加された検索単語周辺の文章(いわゆるスニペット(snippet))を簡単な書式で検索結果と同時に取得することができます.

Model.find_fulltext({:body => "ruby rails"}, :snippet => true)

このエントリの続きには,acts_as_ludiaの導入方法,使い方について書いてあります.

acts_as_ludiaの導入方法

acts_as_ludiaの導入は以下の手順で行います.なお,Ludiaの導入はすでに済んでいて,正常に稼動しているとします.

  • Railsアプリケーションの作成
  • ダウンロードと配置
  • Modelへの追記
適当なRailsアプリケーションの作成

acts_as_ludiaプラグインを利用するためのRailsアプリケーションをPostgreSQLをデータベースに利用して作成します.すでにPostgreSQLを利用しているアプリケーションがあり,そこに導入する場合,この項は読み飛ばしていただいてかまいません.
ここでは,アプリケーション名をludiaとし,以下の手順で作成します.

  • railsコマンドによる雛形作成
  • config/database.yml修正
  • config/environment.rbへの追記
  • データベースとテーブルの作成

railsコマンドによる雛形作成
PostgreSQLを利用するよう明示的に指定しています.

$ rails ludia --database=postgresql

config/database.yml修正
以下のようにdatabase.ymlを修正します(コメント部分は削ってあります).なお,PostgreSQLのユーザとしてrailsをパスワードrailsで作ってあるものとします.

login: &login
  adapter: postgresql
  username: rails
  password: rails
  host: localhost

development:
  database: ludia_development
  <<: *login

test:
  database: ludia_test
  <<: *login

production:
  database: ludia_production
  <<: *login

config/environment.rbへの追記
なんのことはありません,日本語を扱うための記述を先頭に追加するだけです.

# 以下の2行をenvironment.rbの先頭に追加
$KCODE = 'u'
require 'jcode'

データベースとテーブルの作成
PostgreSQLにデータベースとテーブルを作成します.今回はめんどくさい簡略化のためdevelopment環境だけ作ります.
まず,データベースの作成とLudia用のSQLを流し込みます.

$ createdb ludia_development -U rails
Password for user rails:
CREATE DATABASE
$ psql -f vendor/plugin/acts_as_ludia/share/pgsenna2.sql ludia_development -U rails
Password for user rails:
CREATE FUNCTION
CREATE FUNCTION
(中略)
CREATE OPERATOR CLASS
CREATE OPERATOR CLASS

次に,Migrationを利用してテーブルを作成します.ついでにModelも作っちゃいましょう.

$ ruby script/generate model Test

適当なエディタでマイグレーションファイル(#{RAILS_ROOT}/db/migrate/001_create_tests.rb)を開き,以下のように記述します.

class CreateTests < ActiveRecord::Migration
  def self.up
    create_table :tests do |t|
      t.column :col1, :text
      t.column :col2, :string
    end
    execute(%|CREATE INDEX index1 ON tests USING fulltext(col1);|)
    execute(%|CREATE INDEX index2 ON tests USING fulltextb((col2::text));|)
  end

  def self.down
    execute(%|DROP INDEX index1;|)
    execute(%|DROP INDEX index2;|)
    drop_table :tests
    execute(%|SELECT pgs2destroy();|)
  end
end

rakeを実行してテーブルを作成します.

$ rake migrate
ダウンロードと配置

script/pluginを利用したインストールが可能です.下記追記部分をご覧ください.
wgetcurlなどを利用して以下のURLからacts_as_ludiaをダウンロードして,解凍後アプリケーションのプラグインディレクトリに配置してください.
acts_as_ludia-0.2.0.tar.gz
※これはrobyforgeでプロジェクトが開始され,script/pluginでインストールできるようになるまでの限定措置です.リンク先がなくなったり,最新版ではなくなる可能性があります.

追記(2007/03/19 21:30)
rubyforgeへのプロジェクト登録が完了したので,script/pluginを利用したインストールが可能になりました.#{RAILS_ROOT}で以下のコマンドを実行することでダウンロードとインストールが可能です.

$ ruby script/plugin install svn://rubyforge.org/var/svn/actsasludia
Modelへの追記

Modelへacts_as_ludiaを利用するための記述を追加します.

#{RAILS_ROOT}/app/models/your_model.rb

class YourModel < ActiveRecord::Base
  acts_as_ludia
end

以上で,acts_as_ludiaの導入は完了です.やたらと手間がかかるように思いますが,Railsアプリケーションとデータベースの初期設定を書いたので長くなってしまいました.プラグインだけならインストール(ダウンロードと配置)してモデルに一行書き足すだけです.

acts_as_ludiaの使い方

acts_as_ludiaの使い方はいたって簡単です.ActiveRecord::Baseにfind_fulltextメソッドが追加されていますので,通常のfindのように呼び出すことでLudiaの全文検索を利用することができます.
ここでは,実際の利用例とともに使い方を説明します.

テストデータ作成

まず,テストデータを流し込んでおきます.前述のacts_as_ludiaの導入方法にしたがってRailsアプリケーション(ludia)を作成された方はTestというモデルがあり,Ludiaのインデックスが張ってあると思いますので,そこにデータを作ってみます.script/consoleから以下のコード*1を実行してください.

Test.create(:col1=>"すもももももももものうち",:col2=>"あの壺はよいものだ")
Test.create(:col1=>"ももから生まれた桃太郎",:col2=>"あの壷はよいものだ")
Test.create(:col1=>"ももんが飛んだら木が揺れた",:col2=>"あの壺は悪いものだ")
Test.create(:col1=>"あなたももうけ話が聞きたいの",:col2=>"あの壷は悪いものだ")
Test.create(:col1=>"昨夜もももの缶詰を開けた",:col2=>"この壷はよいものだ")
Test.create(:col1=>"にわにはにわにわとりがいる",:col2=>"この壺はよいものだ")
find_fulltextを利用した全文検索

acts_as_ludiaを利用するとActiveRecord::Baseにfind_fulltextというメソッドが追加されます.

find_fulltext(query, options={})

上記のようにfind_fulltextメソッドは引数を一つ(とfindオプション)をとります.queryにはハッシュで検索対象列と検索語を渡します.
例えば,col1列に「もも」という語を含む行を検索する場合,以下のように実行します(見やすいようにppを利用しています).

>> pp Test.find_fulltext(:col1 => "もも")
[#<Test:0xb768c880
  @attributes=
   {"id"=>"1",
    "col1"=>"すもももももももものうち",
    "col2"=>"あの壺はよいものだ"}>,
 #<Test:0xb768c650
  @attributes=
   {"id"=>"5",
    "col1"=>"昨夜もももの缶詰を開けた",
    "col2"=>"この壷はよいものだ"}>,
 #<Test:0xb768c614
  @attributes=
   {"id"=>"2",
    "col1"=>"ももから生まれた桃太郎",
    "col2"=>"あの壷はよいものだ"}>]
=> nil

また,クエリハッシュの値に半角の空白で区切って語を入力するとAND検索になります.

>> pp Test.find_fulltext(:col1 => "もも 桃太郎")
[#<Test:0xb6c9d680
  @attributes=
   {"id"=>"2",
    "col1"=>"ももから生まれた桃太郎",
    "col2"=>"あの壷はよいものだ"}>]
=> nil

さらに,クエリハッシュの値に配列を渡すと配列の各要素のOR検索になります.以下ではAND検索と組み合わせた例を示します.

>> pp Test.find_fulltext(:col2 => ["あの 壺", "悪い"])
[#<Test:0xb6c652e4
  @attributes=
   {"id"=>"3",
    "col1"=>"ももんが飛んだら木が揺れた",
    "col2"=>"あの壺は悪いものだ"}>,
 #<Test:0xb6c652a8
  @attributes=
   {"id"=>"4",
    "col1"=>"あなたももうけ話が聞きたいの",
    "col2"=>"あの壷は悪いものだ"}>]
=> nil

さらに,複数列を対象とした全文検索も可能です.これは,クエリハッシュにキーと値を追加します.以下にcol1に「もも」を含み,かつcol2に「壺」を含む行を検索する例を示します.

>> pp Test.find_fulltext(:col1 => "もも", :col2 => "")
[#<Test:0xb6c5c554
  @attributes=
   {"id"=>"1",
    "col1"=>"すもももももももものうち",
    "col2"=>"あの壺はよいものだ"}>,
 #<Test:0xb6c5c518
  @attributes=
   {"id"=>"3",
    "col1"=>"ももんが飛んだら木が揺れた",
    "col2"=>"あの壺は悪いものだ"}>]
=> nil

col1に「もも」を含むか,またはcol2に「壺」を含む行を検索することもできます.この場合,オプションに:all => trueを指定します.
※クエリ部分を単一のハッシュとするため{と}で囲んでいることに注意してください.

>> pp Test.find_fulltext({:col1 => "もも", :col2 => ""}, :all => true)
[#<Test:0xb6c4bd58
  @attributes=
   {"id"=>"1",
    "col1"=>"すもももももももものうち",
    "col2"=>"あの壺はよいものだ"}>,
 #<Test:0xb6c4bd1c
  @attributes=
   {"id"=>"2",
    "col1"=>"ももから生まれた桃太郎",
    "col2"=>"あの壷はよいものだ"}>,
 #<Test:0xb6c4bce0
  @attributes=
   {"id"=>"3",
    "col1"=>"ももんが飛んだら木が揺れた",
    "col2"=>"あの壺は悪いものだ"}>,
 #<Test:0xb6c4bca4
  @attributes=
   {"id"=>"5",
    "col1"=>"昨夜もももの缶詰を開けた",
    "col2"=>"この壷はよいものだ"}>,
 #<Test:0xb6c4bc68
  @attributes=
   {"id"=>"6",
    "col1"=>"にわにはにわにわとりがいる",
    "col2"=>"この壺はよいものだ"}>]
=> nil
スニペットも一緒に取得する

find_fulltextメソッドには検索語周辺の文章(スニペット)を取得するオプションがあります.
利用方法は引数に:snippet => trueを追加します.

(結構重大な)注意点
Ludiaのスニペット取得関数の仕様(?)により,スニペット取得を利用する場合は検索できる列が一つに限定されます.
:snippet => true のデフォルト動作(規約)
単純にfind_fulltextメソッドに:snippet => trueオプションを追加した場合,以下のスニペットオプションのdefault値が適用されます.

:length => 60
スニペットの長さ(byte)を指定します.defaultは 60byte です.
:decorations => ['<strong>', '</strong>']
スニペット内で検索語の前後に追加する文字をそれぞれ指定します.defaultは<strong>タグで囲みます.
:label => 'l_snippet'
得られるオブジェクトに対してスニペット用のラベルを指定します.defaultは"l_snippet"です.
>> pp Test.find_fulltext({:col1 => "桃太郎"}, :snippet => true)
[#<Test:0xb7643540
  @attributes=
   {"l_snippet"=>"ももから生まれた<strong>桃太郎</strong>",
    "id"=>"2",
    "col1"=>"ももから生まれた桃太郎",
    "col2"=>"あの壷はよいものだ"}>]
=> nil

:snippetへオプションを追加する
find_fulltextメソッドの:snippetオプションに明示的にスニペットオプション*2を指定することもできます.これは:snippetにハッシュで渡します.以下に全部指定した例を示します.

>> pp Test.find_fulltext({:col1 => "桃太郎"}, :snippet => {:length => 20, :decorations => ["<em>", "</em>"], :label => "other_ludia_snippet_label"})
[#<Test:0xb75b51f0
  @attributes=
   {"other_ludia_snippet_label"=>"まれた<em>桃太郎</em>",
    "id"=>"2",
    "col1"=>"ももから生まれた桃太郎",
    "col2"=>"あの壷はよいものだ"}>]
=> nil

とりあえず,今のところこんな感じです.これからも機能追加などしていきます.ご意見・ご要望などあれば気軽にコメントしてください.

デモページ

とりあえず,こんなものが簡単に作れるよということではてブ全文検索を作ってみました.
acts_as_ludia を使ってLudiaのデモWebアプリケーションを作りました - のほほん徒然

ソースコード

汚いソース(acts_as_ludia-0.2.0時点)ですがとりあえず置いておきますので,使ってみよう(または参考にしよう)という方はどうぞ.

#{RAILS_ROOT}/vendor/plugins/acts_as_ludia/init.rb
require File.dirname(__FILE__) + '/lib/ludia/acts_as_ludia'
ActiveRecord::Base.send :include, Ludia::ActsAsLudia
#{RAILS_ROOT}/vendor/plugins/acts_as_ludia/lib/ludia/acts_as_ludia.rb
# ActsAsLudia
module Ludia
  module ActsAsLudia
    def self.included(base)
      base.extend ClassMethods
    end

    module ClassMethods
      def acts_as_ludia(options = {})
        return if self.included_modules.include?(Ludia::ActsAsLudia::InstanceMethods)
        send :include, Ludia::ActsAsLudia::InstanceMethods
      end
    end

    module InstanceMethods
      def self.included(base)
        base.extend SingletonMethods
      end
      module SingletonMethods

        # Model.find_fulltext({:col1 => ["hoge moge", "hoho"]})
        # => SELECT * FROM models WHERE col1 @@ '*D+ hoge moge OR hoho';
        # Model.find_fulltext({:col1 => "hoge", :col2 => ["moge", "hoho"]}, :all => true)
        # => SELECT * FROM models WHERE col1 @@ '*D+ hoge' OR col2 @@ '*D+ moge OR hoho';
        # Model.find_fulltext({:col1 => "hoge moge"}, :snippet => true)
        # Model.find_fulltext({:col1 => "hoge moge"}, :snippet => {:length => 45, :decorations => ['<em>', '</em>'])
        def find_fulltext(query, options={})
          raise ArgumentError, "wrong argument class" unless query.is_a? Hash
          raise ArgumentError, "one query is available, if use SNIPPET." if options[:snippet] && query.length > 1
          conditions = []
          query.each_pair do |column, string|
            string = string.join(' OR ') if string.is_a? Array
            conditions << %|#{column.to_s} @@ '*D+ #{string}'|
          end
          conjunction = options.delete(:all) ? ' OR ' : ' AND '
          options[:conditions] = conditions.join(conjunction)

          option_select = snippet(query, options.delete(:snippet)) if options[:snippet]
          if option_select
            if options[:select]
              options[:select] += ", #{option_select}"
            else
              options[:select] = "*, #{option_select}"
            end
          end

          results = find(:all, options)

        end

        private

        def snippet(query, snippet_options)
          options = {
            :query => query.values.first.is_a?(Array) ? query.values.first.join(' ') : query.values.first,
            :length => 60,
            :column => query.keys.first.to_s,
            :escape => true,
            :decorations => ["<strong>", "</strong>"],
            :label => "l_snipett"
          }
          snippet_options = snippet_options.is_a?(Hash) ? snippet_options.reverse_merge(options) : options
          select_string = %|pgs2snippet1(1, #{snippet_options[:length]}, 1, | +
                    %|'#{snippet_options[:decorations].first}', | +
                    %|'#{snippet_options[:decorations][1]}', | +
                    %|#{snippet_options[:escape] ? -1 : 0}, | +
                    %|'#{snippet_options[:query]}', | +
                    %|#{snippet_options[:column]}) |
                    %|as #{snippet_options[:label]|
        end
      end
    end
  end
end

*1:内容はREADMEを参考に適当に作りました

*2:オプションにスニペットオプションを・・ややこしい