組み込み型お手軽全文検索エンジンのRubyライブラリを作って2ちゃんねるQ&A検索の全文検索エンジンを置き換えた

2ちゃんねるQ&A検索(非公式)全文検索は pg_trgm を使っていたのですが、だんだんとデータが増えてくるとPostgreSQLの共有メモリを増やさないと検索がタイムアウトするようになってきて、最近ではVPSのメモリのほとんどをPostgreSQLに割り当ててもクソ重くてやばかったので、昨日、意を決して組み込み型お手軽全文検索エンジンをテキトウに作って置き換えたら爆軽爆速になった。

qarc.info は意外に月100万訪問者以上あるんですけど、月800円のVPS1台にウェブサーバーからデータベースサーバーから全部突っ込んだ運用をしていて、これでどこまでいけるかというのが個人的な趣味みたいな感じなので、今回もできるだけ軽い実装かつ導入がめんどくさくないものを作ってみました。半日くらいで適当に作って、組み込みながらちょこちょこ直したもので、あまりいいものではないんですけど、その紹介をします。

FtsLite

FtsLiteは組み込み型お手軽全文検索エンジンRubyライブラリです。
基本的にはSQLite3 FTS4のラッパーで、日本語のbigramやtrigram、wakachi_bigramなどのトークナイザーをRubyのレイヤで実装したものです。

qarc.info で使われています。

Ruby 1.9.2 以降と、FTS4 に対応してる SQLite3 が必要で、SQLite3 はできれば 3.7.7 以降がよいです(FTS4の仮想テーブルに対するINSERT OR REPLACEが実装されているのでsetのパフォーマンスがよい)。

インストール
gem install fts_lite
Usage
# -*- coding: utf-8 -*-
require 'fts_lite'

FtsLite::Index.open("./index.sqlite3") do |db|
  # set(docid, text, sort_value = nil)
  db.set(1, "なぜナポリタンは赤いのだろうか?")
  db.set(2, "昼飯のスパゲティナポリタンを眺めながら、積年の疑問を考えていた。")
  # docid_array = search(query, options = {})
  docids = db.search("ナポリタン")
  puts docids.join(",")
  docids = db.search("赤い ナポリタン")
  puts docids.join(",")

  # update_sort_value(docid, sort_value)
  db.update_sort_value(1, 2)
  db.update_sort_value(2, 1)

  docids = db.search("ナポリタン", :order => :asc, :limit => 1)
  puts docids.join(",")
end
1,2
1
2

set(docid, text, sort_value)でデータを登録します。
docidはレコードを表すIDで search ではこのIDの配列を検索結果として返します。
textは全文検索インデックスのためのテキストデータです。取り出すことはできないので、元のデータは別のデータベースにあることを想定しています。
sort_valueはソート用の値で、searchの時にこの値でソートしたり、ソートしたうえで上位N件を取り出したりできます。

search(query, options) で検索できて、queryは空白区切りでAND検索です。このへんの仕様は用途によっていろいろだと思うけど、僕はテキトウにANDだけできればいいやと思っているので、いじりたい人は lib/tokenizer.rb で定義してある query というメソッドをいじってください。
optionsは :order に :asc か :desc を指定すると sort_value で昇順ソートまたは降順ソートします。 :order が指定されない場合は docid の昇順になります。:limit => N を指定すると検索結果の上位N件だけを返します。

Railsで使う

まず config/application.rb あたりで

QUESTION_FTS = FtsLite::Index.open(File.join(Rails.root.to_s, "fts", "index.sqlite3"), :table_name => "questions")
THREAD_FTS = FtsLite::Index.open(File.join(Rails.root.to_s, "fts", "index.sqlite3"), :table_name => "threads")
# ...

とコネクションを作ってグローバルにアクセスできるようにしておきます。
モデルでしか使わない場合は、モデルの中で定義したほうがいいかもしれません。
:table_name を指定するとひとつのファイルに複数の全文検索インデックスが持てます。

あとは、たとえば、Question という ActiveRecord のモデルがあって、全文検索用のテキストデータ(内容やタイトルなんかを適当に結合した文字列)を作成する make_ft というメソッドとソート用の表示数 view_count というカラムがあるとすると

class Question < ActiveRecord::Base
   LIMIT = 1000
   after_save :set_ft

   def set_ft
     QUESTION_FTS.set(id, make_ft, view_count)
   end
   def self.search(query)
     find(:all,
          :conditions => ["id in (?)", QUESTION_FTS.search(query, :order => :desc, :limit => LIMIT)],
          :order => "view_count DESC")
   end
   # def make_ft ...
end

まず after_save でレコードの更新時に全文検索インデックス側も更新するようにしておきます。
Question.search というメソットでは、全文検索を行って、その結果(IDの配列)を含むレコードをさらにDBに問い合わせることで検索結果となるレコードを返しています。

この実装だとレプリケーションしている場合に、別のサーバーでレコードが更新されるとトリガーが効かなくてローカルの全文検索インデックスが更新されないことに注意してください。

トークナイザー

デフォルトでは:bigramになっています。openのオプションで、:tokenizer => :wakachi_bigram などと与えると変えれるのですが、日本語の場合、:bigram以外は微妙なので、変えなくていいと思います。英語の場合は :trigram を指定するとよいと思います。
変えれるようにしてみたものの変えなくていい感じで微妙機能です。

感想

SQLite3 の FTS は昔は別モジュールになっていろいろめんどくさかったのですが、最近は組み込まれてるようで、Ubuntuにデフォルトで入っているようなlibsqlite3でも有効になっていたので、テキトウに使える軽量全文検索エンジンとして便利だと思います。