隔週連載groonga

第5回 Rubyでサーバ要らずの高速全文検索! - rroongaの紹介

この記事を読むのに必要な時間:およそ 6 分

rroongaが大事にしていること

rroongaが大事にしていることは次の2点です。

  • groongaの速度をできるだけ阻害しないこと
  • Rubyで使いやすいこと

それぞれ順に説明します。

groongaの速度をできるだけ阻害しないこと

groongaの特長の1つは「高速であること」です。Rubyからgroongaを使う場合,groongaを直接Cから使うときに比べてオーバーヘッドが発生することは確実です。実行している処理が増えているのでこれは当然です。しかし,特長と呼べなくなるぐらい速度が落ちてしまっては悪影響のほうが大きすぎます。そのため,rroongaではgroongaの速度をできるだけ阻害しないことを大事にしています。

具体的には次のような工夫をしています。

  • 一時的なコピー領域の再利用
  • Rubyレベルでループを回さない
一時的なコピー領域の再利用

groongaの世界のデータをRubyの世界に持っていくときはデータをコピーする必要があります。逆に,Rubyの世界のデータをgroongaの世界に持っていくときもデータをコピーする必要があります。理由は2つあり,メモリ管理の仕組みと整合性をとるためとデータを変換する必要があるためです。コピーをするための領域を再利用することでオーバーヘッドを少なくしています。

Rubyのオブジェクト(Stringなど)はRubyの世界の中でメモリ管理をしています。Rubyの外の世界のメモリをRubyの世界ではうまく管理できません。いつまで長生きするメモリかわからないからです。そのため,groongaの世界のデータはRubyのメモリ管理の仕組みを使ってコピーし,Rubyのメモリ管理の仕組みの中で管理します。

Rubyの世界のデータとgroongaの世界のデータは形式が違うため,相互に変換をする必要があります。そのとき,元のデータを破壊的に変更して変換すると変換元の世界で不整合が発生します。そのためデータをコピーして変換します。ただし,groongaの世界とRubyの世界の「間」の変換処理は一時的なものです。つまり,ここで使う領域は再利用できます。領域を再利用できると,コピーすることは変わりませんが,動的にメモリを割り当てる回数が減ります。動的にメモリを割り当てる処理は重い処理なので,この回数が減るとオーバーヘッドがかなり小さくなります。

Rubyの世界とgroongaの世界の間のデータのやりとり

Rubyの世界とgroongaの世界の間のデータのやりとり。rroongaがそれぞれの世界のデータを変換するために一時的な領域が必要になる。一時的な領域を再利用することで変換時のオーバーヘッドを小さくしている。

この一時的なコピー領域はテーブル単位・カラム単位で1つ持っています。つまり,同じテーブル・カラムに対して値の出し入れを何度も繰り返すときほど効果が大きくなるということです。

ところで,テーブル単位・カラム単位で1つだけで大丈夫なのかと心配になった人もいるのではないでしょうか? これは大丈夫です。Rubyは同時に複数のスレッドが動かないため,1つで十分なのです。

Rubyレベルでループを回さない

オーバーヘッドを少なくするためには,Rubyレベルで処理する量を少なくします。特にループを少なくします。ループでは同じ処理を何度も実行するため,1回の処理時間が少なくても回数が多くなればオーバーヘッドは大きくなります。

では,groongaでループが必要になるときはどんなときでしょうか。それは,検索するときと検索結果に対する処理を実行するときです。

rroongaで検索するときは次のようにテーブルオブジェクトのselectメソッドを使います。

bookmarks = Groonga["Bookmarks"] # "Bookmarks"テーブルを取得
bookmarks.select do |bookmark|
  # http://gihyo.jp/内のブックマークのうち
  bookmark.url.prefix_search("http://gihyo.jp/") &
    # コメントに「groonga」を含むブックマークを検索
    (bookmark.comment =~ "groonga")
end

selectが次のように動くことを想像したのではないでしょうか?

class Table
  def select
    matched_records = []
    each do |record|
      matched_records << record if yield(record)
    end
    matched_records
  end
end

しかし,実際は違います。次のように動きます。ポイントはRubyレベルでは検索式を作るだけで,実際の検索の詳細はgroongaに丸投げしているところです。なお,このコードはイメージなので,実際のクラス名やメソッド名は少し違うことに注意してください。ユーザが詳細を意識する必要はないのでわかりやすそうな名前を選びました。

class Table
  def select
    # 検索式を生成するオブジェクトを作る
    expression_builder = ExpressionBuilder.new
    # selectに指定したブロック内で検索式を作る
    # 前の例を再掲:
    #   bookmarks.select do |bookmark|
    #     bookmark.url.prefix_search("http://gihyo.jp/") &
    #       (bookmark.comment =~ "groonga")
    #   end
    # このブロックの場合は「bookmark」は実は
    # ExpressionBuilderでレコードそのものではない
    # ExpressionBuilderのメソッドを呼ぶことで
    # Rubyの構文で検索式を指定している
    expression_builder = yield(expression_builder)
    # ブロックで作ったExpressionBuilderをコンパイルして検索式を作る
    expression = expression_builder.compile
    # 検索式をgroongaに渡してgroongaレベルで検索
    select_by_groonga(expression)
  end
end

このように検索式だけをRubyレベルで作り検索処理をgroongaに任せることにより,Rubyレベルでループを回さずに済みます。

groonga内部でもできるだけループが少なくするようにしています。検索するときは,できるだけループを少なくするために,テーブルを全件スキャンするのではなくインデックスを使って検索しています。インデックスを使えるかどうかはrroongaから渡された検索式を解析して判断します。

groonga内部で効率よく処理できるようにがんばっているため,rroongaはできるだけgroongaに処理を任せることが基本です。

検索結果に対する処理を実行するときも,できるだけgroongaで処理をします。例えば,集合の演算(和や積など)をするときはgroongaの集合演算機能を使います。これは,複数の検索結果をまとめるときに使います。グループ化やソートをするときもgroongaの機能を使います。

ただし,最終的な検索結果を1つずつ処理するところはRubyレベルでループを回す必要があります。ここでは,必要な分だけループを回すようにしてください。

おさらい

一時的なコピー領域を再利用したり,Rubyレベルでループを回さない工夫をすることにより,rroongaはgroongaの速度を阻害しないような作りになっています。

著者プロフィール

須藤功平(すとうこうへい)

フリーソフトウェアプログラマで株式会社クリアコード代表取締役(2代目)。Sennaの後半から開発に参加しはじめて,groongaの開発にも関わるようになる。C言語で書かれたライブラリをRubyから使えるようにすることが趣味なので,Rubyからgroongaを使えるようにするrroongaを開発した。