今日はもう少し実用的な機能として、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
データベースのopen
まずは基本から。groongaライブラリをロードしよう。begin require 'groonga' rescue LoadError require 'rubygems' require 'groonga' endGroongaを利用するためには、まず「データベース」を作成しなければならない。データベースは複数のファイルから構成されるので、専用のディレクトリに配備するといいだろう。新規作成の場合は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
また、本プラグインを作成するにあたっては、るりまサーチのソースコードがとても参考になった。こちらは本プラグインと違って本格的なアプリケーションなので、ガッツリ参考にするならこちらのほうがいいだろう。
タイムリーなネタなのだが、GroongaをMySQLのストレージエンジンとして利用するプロジェクトにおける進捗が報告されている。Groongaストレージエンジン用のINFORMATION_SCHEMAの雛形が追加されたようだ。今後の開発に期待大!!である。
1 コメント:
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
コメントを投稿