ちょっと硬派なコンピュータフリークのBlogです。

カスタム検索

2010-09-16

Rroongaで楽しく全文検索!!(RubyでXchatをもっと便利にするシリーズその3)

今日も引き続きXChat-Rubyでプラグインを作る話である。そろそろ読者の皆さんも飽きて来た頃だろうかと不安を覚えつつも、「書きたいから書くのだ!」という強い信念をもって本日もつっ走りたいと思う。さて、前回のエントリでは「自動的に挨拶をするボット」を作成した。実際に利用できるプラグインをどのようにして作成できるかをおおよそご理解頂けたかと思う。(まだ見てない人はすぐにチェックすること!)

今日はもう少し実用的な機能として、XChat上のメッセージを全文検索するためのプラグインを紹介しようと思う。
※いろいろとツッコミを頂いたので追記しました。

Groonga!!

まずは肝心の全文検索エンジンであるGroongaをインストールしよう。GroongaはSennaの後継である。Groongaの正式版は、Groongaのホームページから入手できる。Mecabを利用する場合にはMecabを事前にインストールしておこう。

shell> sudo aptitude install libmecab-dev mecab-ipadic-utf8 # ubuntuの場合
shell> mkdir groonga && cd groonga
shell> tar xvf /path/to/groonga-1.0.0.tar.gz && cd groonga-1.0.0
shell> ./configure && make && sudo make install
追記:主なLinuxディストリビューションでは、yumやaptを使ってパッケージ形式のものをインストールすることが出来るので、そちらのほうがおすすめだそうだ。インストール方法はこちらのページに載っているので参照して欲しい。

続いてRubyのバインディングをインストール。
shell> sudo aptitude install rubygems # Ubuntuの場合
shell> sudo gem install rroonga
余談だが、以前groonga gemをインストールしようとすると古いバージョンがインストールされてしまい、rroongaという名前のgemをインストールする必要があった。現在は、gemでgroongaをインストールすると、依存関係に従ってrroongaがインストールされるようになっている。というか、Twitterで呟いたら直してくれた。ありがたい!←追記:インストールするのはrroonga gemのほうがいいそうだ。

データベースのopen

まずは基本から。groongaライブラリをロードしよう。
begin
  require 'groonga'
rescue LoadError
  require 'rubygems'
  require 'groonga'
end
Groongaを利用するためには、まず「データベース」を作成しなければならない。データベースは複数のファイルから構成されるので、専用のディレクトリに配備するといいだろう。新規作成の場合はGroonga#create、既にファイルが存在する場合はGroonga#openメソッドを呼んでデータベースを利用できる状態にしよう。
def open(base_path, encoding)
      reset_context(encoding)
      path = File.join(base_path, "xchat_index.db")
      if File.exist?(path)
        @database = Groonga::Database.open(path)
      else
        FileUtils.mkdir_p(base_path)
        @database = Groonga::Database.create(:path => path)
        define_schema
      end
    end
本プラグインでは~/.xchat2/indexer_db/chat_index.dbという名前でデータベースを作成するようにしている。次で説明するスキーマを定義したあとには次のようなファイルが作成される。
shell> ls ~/.xchat2/indexer_db
xchat_index.db          xchat_index.db.0000102  xchat_index.db.0000106    xchat_index.db.0000109
xchat_index.db.0000000  xchat_index.db.0000103  xchat_index.db.0000107    xchat_index.db.0000109.c
xchat_index.db.0000100  xchat_index.db.0000104  xchat_index.db.0000108    xchat_index.db.000010A
xchat_index.db.0000101  xchat_index.db.0000105  xchat_index.db.0000108.c

スキーマ定義

データベースを作成したら、次はテーブルを作成しよう。Groongaはスキーマレスではないので、事前にきっちりとスキーマを定義してからレコードを追加するというスタイルをとる。本プラグインでは次のように、Topics、Messages、Termsという3つのテーブルを定義している。
def define_schema
      Groonga::Schema.define do |schema|
        schema.create_table("Topics",
                            :type => :patricia_trie,
                            :key_type => "ShortText") do |table|
        end

        schema.create_table("Messages",
                            :type => :array) do |table|
          table.short_text("server")
          table.short_text("channel")
          table.short_text("nick")
          table.reference("topic", "Topics")
          table.text("message")
#         table.boolean("highlighted")
          table.time("timestamp")
        end

        schema.create_table("Terms",
                            :type => :patricia_trie,
                            :key_type => "ShortText",
                            :default_tokenizer => "TokenMecab",
                            :key_normalize => true) do |table|
          table.index("Messages.message")
          table.index("Topics._key")
        end
      end
    end
本来ならメッセージに関するすべての情報をMessagesテーブルにぶち込んでもよかったのだが、トピックだけ別のTopicsテーブルに出している。これは、容量を圧縮したかったからである。通常、トピックはいったん設定されたらしばらく同じものが利用されるし、割とひとつひとつのメッセージが長いので、それをいちいち記録すると容量の無駄が多い。Messagesテーブルの定義においてtable.reference("topic", "Topics")という行があるが、これによりTopicsテーブルへの参照が定義される。すると、Messagesテーブルへレコードを追加した際、Topicsテーブルへレコードが追加され、Messagesテーブルにはトピックの実態は残らず個別のトピックへの参照(恐らく_idによる)が作成される。Topicsテーブルではトピックそのものがキーなので、重複したトピックは格納されない。これにより、かなりの容量を圧縮できるのである。(テーブルを参照するカラムという発想は非常に面白い。JOINを内包したテーブルといった感じだろうか。)

テーブルにはいくつかの型があり、Topicsテーブルではpatricia_trieを、Messagesテーブルではarrayを指定している。arrayは主キーがないテーブルであり、今回のようにメッセージをただひたすら追加していくような場合に適している。レコードにはそれぞれ_idという属性が追加され、レコードの識別をする場合に利用できる。また、主キーのあるタイプのテーブルでは、_keyという名前の属性も追加される。こちらもレコードを識別するために利用可能であり、重複したレコードが追加されないようになっている。

また、Termsテーブルにはインデックスだけが定義されている。Groongaでは、このように別のテーブルにおいて全文検索インデックスを作成する仕様になっているらしいので、仕様通りに別のテーブルにインデックスを作成しよう。

テーブルで利用できる型などについては、Groongaのドキュメント(特にデータ型の章およびtable_createコマンドの説明)を、rroongaの使い方についてはrroongaのリファレンスマニュアルを参照するといいだろう。

レコードの追加

データベースをいったんopenすると、"Groonga['テーブル名']"を指定することでテーブルを参照することができる。レコードの追加はaddメソッドを利用する。
def add_message(attributes)
      Groonga['Messages'].add(attributes)
    end
ここではattributesというパラメーターを指定しているが、attributesは実際にはHashのインスタンスになっており、次のように各カラムの値を指定している。
def add_message(nick, msg, highlight=false)
    attributes = {
      :server => get_info('server'),
      :channel => get_info('channel'),
      :nick => nick,
      :topic => get_info('topic'),
      :message => msg,
#     :highlighted => highlight,
      :timestamp => Time.now
    }
    @db.add_message(attributes)  # <==== 上記のメソッドを呼び出している。
  end
至ってシンプルである。

全文検索をかけよう

全文検索は少々変わった(?)仕様になっていて、最初は戸惑うかも知れない。以下は検索条件を指定して、ソートをする箇所のロジックである。引数の「server」に合致するIRCサーバーで、かつ「channel」に合致するチャンネルで発言されたメッセージで、「words」を含むメッセージを最大「n」個取得するというメソッドである。ちなみに、wordsはArrayになっていて、すべての語を含むメッセージを見つけたい。検索条件は、次のようにブロックで定義する。
def find_message(server ,channel, words, n)
      result = Groonga['Messages'].select do |record|
        words.split.collect do |w|
          record.message =~ w
        end.unshift([
                     record.channel == channel,
                     record.server == server,
                    ]).flatten
      end.sort([["_id", :descending]], :limit => n)
ブロックの引数を評価して、真になるレコードをフェッチすることになる。ブロックが返す値は真偽値か、もしくは真偽値の配列でなければならない。(配列の場合はすべての条件がANDで評価される。) ソートは、上記のように検索結果に対してsortメソッドを使って条件を指定する。この例では、_id属性を使って降順でソートしている。フェッチする最大の行数は:limitで指定している。 レコードの属性は、次のように[]で参照することが可能である。
result.collect do |r|
        {
            :id => r["._id"],
            :server => r[".server"],
            :channel => r[".channel"],
            :nick => r[".nick"],
            :message => r[".message"],
            :timestamp => r[".timestamp"],
          }
      end.reverse
    end
追記:検索条件の評価は、クエリを文字列として与えてGroongaのパーサーに任せてしまったほうがいいとのこと。また、レコードから値を参照するときは、r[".server"]という形式だけでなく、r["server"]やr.serverという形式でも書けるとのこと。そうすると、上記のメソッドは次のように書ける。
    def find_message(server ,channel, words, n)
      query = "server:#{server} + channel:#{channel} "\
          << words.collect {|w| "message:@#{w}"}.join(' + ')
      Groonga['Messages'].select do |record|
        record.match(query)
      end.sort([["_id", :descending]], :limit => n).collect do |r|
        get_message_as_hash(r)
      end.reverse
    end

    def get_message_as_hash(r)
      {
        :id => r.id,
        :server => r.server,
        :channel => r.channel,
        :nick => r.nick,
        :message => r.message,
        :timestamp => r.timestamp,
        }
    end
文字列でクエリを組み立てるということは、SQLインジェクションならぬ「Groongaインジェクション」が発生することになるので、Webサービスなどで利用する場合にはその点を考慮しなければならない。ちなみに、上記の例ではインジェクションについて一切考えていないので注意すること!!(個人用途だからインジェクション対策など不要なので。)

その他の検索

arrayタイプのテーブルでは、レコードを識別する_id属性でレコードをフェッチしたい場合があるだろう。その場合は次のように検索条件を指定する。ここでは、検索条件がひとつしかないので、単一の真偽値を用いている。
result = Groonga['Messages'].select do |record|
        record.id == msg_id
      end
次の例は_idおよびserver属性、channel属性をそれぞれ検索条件に指定している。こちらは検索条件が複数あるのでArrayを用いている。
messages = Groonga['Messages'].select do |record|
        [
          record.id < msg_id,
          record.server == sv,
          record.channel == ch,
        ]
      end.sort([["_id", :descending]], :limit => n/2).collect do |r|
        {
          :id => r["._id"],
          :nick => r[".nick"],
          :message => r[".message"],
          :timestamp => r[".timestamp"],
          }
      end.reverse

自分の発言を捕捉する

ここでいったんXChatの話に戻ろう。全文検索をするからには、自分が過去に行った発言についても検索の対象にしたい。自分の発言は"SAY"もしくは無名("")コマンドを捕捉することで追跡できるはずであるが、現在XChat-Rubyは無名のコマンドを追跡することが出来ないようである。そこで、XChat-Rubyに手を入れてこの問題を回避した。パッチを作成したので必要な方は利用して頂きたい。XChat-Rubyをビルドする前にこのパッチを適用しよう。(ちなみに、このパッチでは無名のコマンドを強制的にSAYコマンドに変換している。)

不具合等

本プラグインを作成する際、何度かXChatがクラッシュしてしまった。その後、XChatを再起動してMessagesテーブルへレコードの追加を試みるとハングが発生した。ハング時のスタックトレースを取得しておいたので、デバッグの役に立てていただきたい。 ちなみに、ハングは次のようにデータベースをリロードすると解消するので、もしプラグイン利用中にハングに遭遇してしまった人は、次の手順で修復を試みて頂きたい。
shell> groonga ~/.xchat/indexer_db dump > xchat_index.dump
shell> rm ~/.xchat/indexer_db/*
shell> groonga -n ~/.xchat/indexer_db/xchat_index.db < xchat_index.dump
ちなみに、筆者はなぜこの方法で直るのかを理解していない。今のところこのレトロな手順で修復が出来ているに過ぎないので、GroongaをWebサービスなどで運用する場合には、こまめにバックアップをとっておいたほうがいいだろう。 追記:恐らくロックが残ってるからclearlockで直るだろうとのことで、試してみたら直った!!@ktouさん、thxです!!
shell> groonga chat_index.db
> clearlock Messages
[[0,1284646821.98248,0.00041],true]
> clearlock Terms
[[0,1284646842.74371,0.000332],true]
> clearlock Topics
[[0,1284646846.75129,0.000177],true]

まとめ

Rubykaigi2010へ行ってきた流れから(というかるりまサーチのセッションを聞いて)勢いで全文検索プラグインを作成してしまったわけだが、プラグインの作成は決して険しい道のりではなかった。それはRubyがあったからだ!XChat-RubyとrroongaがRubyという土台を通じて直ぐに繋がる。今回のような単純なプログラムなら、それだけで8割は完成したようなものである。もしRubyがなければ、XChatにGroongaを組み込むのにもっと大変な苦労を強いられたことだろう。

今回紹介したプラグインは、GitHubのリポジトリで公開しているので、興味のある人は覗いてみてもらいたい。ライセンスはGPLv2である。なお、アドバイスやバグ報告は随時受け付けているので、Twitterやメール、コメント等で突っ込んで頂ければ幸いである。

http://github.com/mikiya/xchatruby-plugins

また、本プラグインを作成するにあたっては、るりまサーチのソースコードがとても参考になった。こちらは本プラグインと違って本格的なアプリケーションなので、ガッツリ参考にするならこちらのほうがいいだろう。

http://rurema.clear-code.com/

タイムリーなネタなのだが、GroongaをMySQLのストレージエンジンとして利用するプロジェクトにおける進捗が報告されている。Groongaストレージエンジン用のINFORMATION_SCHEMAの雛形が追加されたようだ。今後の開発に期待大!!である。

http://d.hatena.ne.jp/mir/20100916/p1

1 コメント:

Mikiya Okuno さんのコメント...

kou (@ktou) さんから以下の投稿を頂きましたが、うまくBloggerのメッセージに反映されないようなので引用させて頂きます。

> groongaはパッケージで入れるのがお手軽です!
> http://groonga.org/docs/install.html
>
> gemはgroongaじゃなくてrroongaを入れるのがおすすめです!
>
> :type => :arrayにキーは必要ないので、:key_type => "ShortText"を削除するのがおすすめです!
> (後でエラーがでるようにしておこう。。。)
>
> 特にこだわりがないならクエリを自分でパース(空白区切りでトークナイズ)しないでgroongaにお任せするのがおすすめです!ORとかselectコマンドと同じクエリ言語が使えます。(ただしシンタックスエラーに注意。)http://groonga.org/docs/commands/select.html
>
> Groonga['Messages'].select do |record|
> record.match(query) do |match_record|
> match_record.message
> end
> end
>
> カラム値へのアクセスは先頭の.を抜くのがおすすめです!
> r[".server"] == r["server"] == r.server
>
> ハングするやつはロックが残っているからだと思います!(更新時に異常終了するとロックが残ることがある)
> 十分に注意した上でclearlockすると直ると思います!
> http://groonga.org/docs/commands/clearlock.html

コメントを投稿