File: tutorial — rroonga - ラングバ

チュートリアル

このページでは簡単なアプリケーションの作成を通してRroongaの操作方法を紹介します。

インストール

RroongaはRubyGemsでインストールできます。

% sudo gem install rroonga

データベースの作成

簡単なブックマークアプリケーション用のデータベースを作ってみます。以下のようにRroongaを読み込んでirbを起動します。

% irb --simple-prompt -r groonga
>>

それでは、ファイルを指定してデータベースを作成します。

>> Groonga::Database.create(:path => "/tmp/bookmark.db")
=> #<Groonga::Database ...>

ここで作成したデータベースは、これ以降、暗黙のうちに利用されます。最初にデータベースを作成したら特に意識する必要はありません。

テーブルを定義

Groongaには以下の4種類のテーブルがあります。

Groonga::Hash
Hash table. It manages records via each primary key. It supports very quickly exact match search.
Groonga::PatriciaTrie
Patricia Trie. It supports some search such as predictive search and common prefix search, but it provides a little slowly exact match search than Groonga::Hash. It provides cursor to take records in ascending or descending order.
Groonga::DoubleArrayTrie
Double Array Trie. It requires large spaces rather than other tables, but it can update key without ID change. It provides exract match search, predictive search and common prefix search and cursor like Groonga::PatriciaTrie.
Groonga::Array
配列。主キーの存在しないテーブルです。レコードはIDによって識別します。

ここではハッシュテーブルを利用して、 Items という名前のテーブルを作成します。キーは文字列とします。

>> Groonga::Schema.create_table("Items", :type => :hash)
=> [...]

これで Items という名前のテーブルが作成できました。定義したテーブルはGroonga.[]で参照できます。

>> items = Groonga["Items"]
=> #<Groonga::Hash ...>

テーブルはRubyのHashのように扱えます。例えば、以下のように items.size でテーブルに登録されているレコードの件数を取得できます。

>> items.size
=> 0

レコードを追加

Items テーブルにレコードを追加します。

>> items.add("http://en.wikipedia.org/wiki/Ruby")
=> #<Groonga::Record ...>
>> items.add("http://www.ruby-lang.org/")
=> #<Groonga::Record ...>

件数を確認すると確かに0件から2件増えています。<

>> items.size
=> 2

主キーを指定してレコードを取り出す時には以下のようにします。

>> items["http://en.wikipedia.org/wiki/Ruby"]
=> #<Groonga::Record ...>

全文検索を行う

各itemのタイトル文字列を登録して、全文検索できるようにしてみましょう。

まず Items テーブルに title という名前のカラムを追加します。ここでは、 Text 型のデータを持つカラムとして定義します。

>> Groonga::Schema.change_table("Items") do |table|
?>     table.text("title")
>>   end
=> [...]

定義したカラムは #{テーブル名}.#{カラム名} という名前になります。 テーブルと同じように Groonga.[] で参照できます。

>> title_column = Groonga["Items.title"]
=> #<Groonga::VariableSizeColumn ...>

次に、全文検索するために、文字列を分解して得られる各単語を格納するためのテーブルを別途用意します。ここでは Terms という名前でテーブルを定義します。

>> Groonga::Schema.create_table("Terms",
?>                              :type => :patricia_trie,
?>                              :normalizer => :NormalizerAuto,
?>                              :default_tokenizer => "TokenBigram")

ここでは、トークナイザとして :default_tokenzier => "TokenBigram" を指定しています。トークナイザとは文字列を単語に分解するオブジェクトのことです。デフォルトではトークナイザは指定されていません。全文検索を利用するためにはトークナイザを指定する必要があるので、ここではN-gramの一種であるバイグラムを指定しています。

N-gramを利用した全文検索では、分解したN文字とその出現位置を利用して全文検索を行います。N-gramのNは文字列を何文字毎に分解するかの文字数になります。Groongaは1文字で分解するユニグラム、2文字のバイグラム、3文字のトリグラムをサポートしています。

また、大文字小文字の区別なく検索するために :normalizer => :NormalizerAuto も指定しています。

単語格納用テーブルの準備ができたので、 Items テーブルの title カラムに対するインデックスを定義します。

>> Groonga::Schema.change_table("Terms") do |table|
?>     table.index("Items.title")
>>   end
=> [...]

少し違和感を感じるかもしれませんが、 Items テーブルのカラムに対するインデックスは、 Terms テーブルのカラムとして定義します。

Items にレコードが登録されると、その中に含まれる単語に該当するレコードが Terms に自動的に追加されるようになります。

Terms は、文書に含まれる語彙に相当する、やや特殊なテーブルだと言えます。しかし、他のテーブルと同様に語彙テーブルには自由にカラムを追加し、単語毎の様々な属性を管理することができます。これはある種の検索処理を行う際には非常に便利に機能します。

これでテーブルの定義は完了です。先ほど登録した各レコードの title カラムに値をセットします。

>> items["http://en.wikipedia.org/wiki/Ruby"].title = "Ruby"
=> "Ruby"
>> items["http://www.ruby-lang.org/"].title = "Ruby Programming Language"
"Ruby Programming Language"

ここまでのコードより、以下のようにして検索することができます。

>> ruby_items = items.select {|record| record.title =~ "Ruby"}
=> #<Groonga::Hash ..., normalizer: (nil)>

検索結果はGroonga::Hashで返されます。ハッシュのキーに見つかった Items のレコードが入っています。

>> ruby_items.collect {|record| record.key.key}
=> ["http://en.wikipedia.org/wiki/Ruby", "http://www.ruby-lang.org/"]

上の例では record.keyItems のレコードを取得して、さらにそのキーを指定して( record.key.key )で Items のキーを返しています。

record["_key"] でアクセスすると自動的に参照しているレコードを辿っていき、参照先のキーにアクセスできます。

>> ruby_items.collect {|record| record["_key"]}
=> ["http://en.wikipedia.org/wiki/Ruby", "http://www.ruby-lang.org/"]

シンプルなブックマークアプリの拡張

ここまでで作った単機能のアプリケーションをもう少し拡張して、複数のユーザが、それぞれにコメントを記入できるブックマークアプリケーションにしてみましょう。

まず、ユーザ情報とコメント情報を格納するテーブルを追加して、下図のようなテーブル構成にします。

!http://qwik.jp/senna/senna2.files/rect4605.png!

まず、 ユーザ情報を格納する Users テーブルを追加します。

>> Groonga::Schema.create_table("Users", :type => :hash) do |table|
?>     table.text("name")
>>   end
=> [...]

次に、コメントの情報を格納する Comments テーブルを追加します。

>> Groonga::Schema.create_table("Comments") do |table|
?>     table.reference("item")
>>   table.reference("author", "Users")
>>   table.text("content")
>>   table.time("issued")
>>   end
=> [...]

Comments テーブルの content カラムを全文検索できるようにインデックスを定義します。

>> Groonga::Schema.change_table("Terms") do |table|
?>     table.index("Comments.content")
>>   end
=> [...]

これでテーブルが定義できました。

続いてユーザを何人か Users に追加します。

>> users = Groonga["Users"]
=> #<Groonga::Hash ...>
>> users.add("alice", :name => "Alice")
=> #<Groonga::Record ...>
>> users.add("bob", :name => "Bob")
=> #<Groonga::Record ...>

ここで、実際にユーザがブックマークを貼る時の処理を実行してみましょう。ユーザ moritan が、Ruby関連のとあるページをブックマークしたと想定します。

まず対象のページが Items テーブルに登録済かどうか調べます。

>> items.has_key?("http://www.ruby-doc.org/")
=> false

未登録でした。なので、まず当該ページを Items に登録します。

>> items.add("http://www.ruby-doc.org/",
?>           :title => "Ruby-Doc.org: Documenting the Ruby Language") => #<Groonga::Record ...>

次に、登録したitemを item カラムの値に指定して Comments にレコードを登録します。

>> require "time"
=> true
>> comments = Groonga["Comments"]
=> #<Groonga::Array ...>
>> comments.add(:item => "http://www.ruby-doc.org/",
?>              :author => "alice",
?>              :content => "Ruby documents",
?>              :issued => Time.parse("2010-11-20T18:01:22+09:00"))
=> #<Groonga::Record ...>

メソッド化

このあとの利便性のため、上記の一連の手続きをメソッドにまとめてみます。

>> @items = items
=> #<Groonga::Hash ...>
>> @comments = comments
=> #<Groonga::Array ...>
>> def add_bookmark(url, title, author, content, issued)
>>   item = @items[url] || @items.add(url, :title => title)
>>   @comments.add(:item => item,
?>                 :author => author,
?>                 :content => content,
?>                 :issued => issued)
>>   end
=> nil

itemscomments をインスタンス変数に代入しているのはメソッド内からでも見えるようにするためです。

add_bookmark は以下のような手順を実行しています。

  • Items テーブルに該当ページのレコードがあるかどうか調べる。
  • レコードがなければ追加する。
  • Comments テーブルにレコードを登録する。

作成したメソッドを呼び出していくつかブックマークを登録してみましょう。

>> add_bookmark("https://rubygems.org/",
?>              "RubyGems.org | your community gem host", "alice", "Ruby gems",
?>              Time.parse("2010-10-07T14:18:28+09:00"))
=> #<Groonga::Record ...>
>> add_bookmark("http://ranguba.org/",
?>              "Fulltext search by Ruby with groonga - Ranguba", "bob",
?>              "Ruby groonga fulltextsearch",
?>              Time.parse("2010-11-11T12:39:59+09:00"))
=> #<Groonga::Record ...>
>> add_bookmark("http://www.ruby-doc.org/",
?>              "ruby-doc", "bob", "ruby documents",
?>              Time.parse("2010-07-28T20:46:23+09:00"))
=> #<Groonga::Record ...>

全文検索その2

登録したレコードに対して全文検索を実行してみます。

>> records = comments.select do |record|
?>     record["content"] =~ "Ruby"
>>   end
=> #<Groonga::Hash ...>
>> records.each do |record|
?>     comment = record
>>   p [comment.id,
?>       comment.issued,
?>       comment.item.title,
?>       comment.author.name,
?>       comment.content]
>>   end
[1, 2010-11-20 18:01:22 +0900, "Ruby-Doc.org: Documenting the Ruby Language", "Alice", "Ruby documents"]
[2, 2010-10-07 14:18:28 +0900, "RubyGems.org | your community gem host", "Alice", "Ruby gems"]
[3, 2010-11-11 12:39:59 +0900, "Fulltext search by Ruby with groonga - Ranguba", "Bob", "Ruby groonga fulltextsearch"]
[4, 2010-07-28 20:46:23 +0900, "Ruby-Doc.org: Documenting the Ruby Language", "Bob", "ruby documents"]

カラム名と同じメソッドでカラムへのアクセスできます。複合データ型の要素も再帰的に辿ることができます。(同様の出力を普通のRDBで実現するためには、 Items テーブル、 Comments テーブル、 Users テーブルのJOIN操作が必要になります。)

上の式の中で、肝心の検索処理は、第一引数の式を評価する時点で完了していて、レコードセットオブジェクトとしてメモリに蓄積されています。

>> records
#<Groonga::Hash ..., size: <4>>

レコードセットは、出力する前に様々に加工することができます。 以下は、日付で降順にソートしてから出力した例です。

>> records.sort([{:key => "issued", :order => "descending"}]).each do |record|
?>     comment = record
>>   p [comment.id,
?>       comment.issued,
?>       comment.item.title,
?>       comment.author.name,
?>       comment.content]
>>   end
[1, 2010-11-20 18:01:22 +0900, "Ruby-Doc.org: Documenting the Ruby Language", "Alice", "Ruby documents"]
[2, 2010-11-11 12:39:59 +0900, "Fulltext search by Ruby with groonga - Ranguba", "Bob", "Ruby groonga fulltextsearch"]
[3, 2010-10-07 14:18:28 +0900, "RubyGems.org | your community gem host", "Alice", "Ruby gems"]
[4, 2010-07-28 20:46:23 +0900, "Ruby-Doc.org: Documenting the Ruby Language", "Bob", "ruby documents"]
=> [...]

同じitemが何度も出てくると検索結果が見にくいので、item毎にグループ化してみます。

>> records.group("item").each do |record|
?>     item = record.key
>>   p [record.n_sub_records,
?>       item.key,
?>       item.title]
>>   end
[2, "http://www.ruby-doc.org/", "Ruby-Doc.org: Documenting the Ruby Language"]
[1, "https://rubygems.org/", "RubyGems.org | your community gem host"]
[1, "http://ranguba.org/", "Fulltext search by Ruby with groonga - Ranguba"]
=> nil

n_sub_records というのはグループ化した単位に含まれるレコードの件数を示します。SQLで言えば、GROUP BY句を含むクエリのcount関数のような働きです。

少し複雑な検索

さらに実用的な検索を考えてみましょう。

ブックマークが大量に蓄積されるに従って、より的確に適合度を算出する必要性に迫られます。

今のところ検索対象として利用できるのは Items.titleComments.content ですが、 Items.title は元ページから得られるやや信頼できる情報なのに対して、 Comments.content はブックマークユーザが任意に設定できる情報で、やや信憑性に乏しいと言えます。しかし、再現率を確保するためにはユーザのコメントも是非対象に含めたいところです。

そこで、以下のようなポリシーで検索を行うことにします。

  • Search item matched Items.title or Comments.content.
  • Add 10 times heavier weight to socres of each record matched Items.title than ones of Comments.comment.
  • If multi comment of one item are matched keyword, specify the sum of scores of each coments as score of the item.

以下のようにして、commentとitemとそれぞれに対する検索結果を求めます。

>> ruby_comments = @comments.select {|record| record.content =~ "Ruby"}
=> #<Groonga::Hash ..., size: <4>
>> ruby_items = @items.select do |record|
?>     target = record.match_target do |match_record|
?>       match_record.title * 10
>>     end
>>   target =~ "Ruby"
>>   end
#<Groonga::Hash ..., size: <4>>

ruby_comments の結果をitem毎にグループ化し、ruby_items と対応させて出力します。

>> ruby_items = ruby_comments.group("item").union!(ruby_items)
#<Groonga::Hash ..., size: <5>>
>> ruby_items.sort([{:key => "_score", :order => "descending"}]).each do |record|
>>   p [record.score, record.title]
>> end
[22, "Ruby-Doc.org: Documenting the Ruby Language"]
[11, "Fulltext search by Ruby with groonga - Ranguba"]
[10, "Ruby Programming Language"]
[10, "Ruby"]
[1, "RubyGems.org | your community gem host"]

これで目的の結果が得られました。