[Groonga] MySQLとPostgreSQLと日本語全文検索3:MroongaとPGroongaの導入方法例 mypgft

2016年9月29日(肉の日!)に「MySQLとPostgreSQLと日本語全文検索3}[https://groonga.doorkeeper.jp/events/50541]」というイベントを開催しました。その名の通りMySQLとPostgreSQLでの日本語全文検索についての話題を扱うイベントです。今回も{DMM.comラボさんに会場を提供してもらいました。

2月9日に開催した1回目のイベントでは{Mroonga}[http://mroonga.org/ja/]・{PGroonga}[https://pgroonga.github.io/ja/]については次の2つのことについて紹介しました。

6月9日に開催した2回目のイベントではMroonga・PGroongaについては次の2つのことについて紹介しました。

今回はMroonga・PGroongaについては次のことについて紹介しました。

Mroonga・PGroonga導入方法

関連リンク:

Redmineへの導入方法

RedmineへのMroonga・PGroongaの導入方法を説明します。RedmineはRuby on Railsを利用しているのでRuby on Railsを使っているアプリケーションに導入する例ということになります。

redmine_full_text_searchプラグインを使うとRedmineでMroongaまたはPGroongaを使って全文検索できるようになります。

このプラグインを使うとRedmineの全文検索が高速になります。たとえば、クリアコードで使っているRedmineには3000件くらいのチケットがありますが、その環境では次のように高速になりました。

プラグイン 時間
なし 467ms
あり 93ms

200万件のチケットがある環境でも約380msで検索できているという報告もあります。

200万チケット@MySQLでやってみたよ。検索時間は約380ms。 #Redmine の未来が広がって嬉しいな。ありがたいな。/Redmineで高速に全文検索する方法 - ククログ(2016-04-11) https://t.co/s7FA4gSThu @_clear_code

— Kuniharu AKAHANE (@akahane92) 2016年5月21日

Mroongaを導入する方法

Mroongaはトランザクションに対応していないのでトランザクションが必須のRedmineに組み込む場合はひと工夫必要になります。単純に、ALTER TABLE table ENGINE=Mroonga ADD FULLTEXT INDEX (column)とするわけにはいきません。

ではどうするかというと別途全文検索用のテーブルを作成して元のテーブルとはJOINできるようにします。(他にもレプリケーションしてレプリケーション先をMroongaにするという2回目のイベントで紹介した方法もありますが、プラグインでやるには大掛かりなのでこの方法を使っています。)

マイグレーションファイルでいうと次のようにします。ここではissuesテーブル用の全文検索用のテーブルを作成しています。

def up
  create_table(:fts_issues, # 全文検索用テーブル作成
               id: false, # idは有効・無効どっちでも可
               options: "ENGINE=Mroonga") do |t|
    t.belongs_to :issue, index: true, null: false
    t.string :subject, default: "", null: false
    t.text :description, limit: 65535, null: false
    t.index [:subject, :description], type: "fulltext"
  end
end

全文検索用のテーブルには元のデータをコピーする必要があります。マイグレーション時には既存のデータを一気にコピーします。そのため、本当のマイグレーションの内容は次のようになります。データコピー後にインデックスを追加するようにしているのはそっちの方が速いからです。

def up
  create_table(:fts_issues, # 全文検索用テーブル作成
               id: false, # idは有効・無効どっちでも可
               options: "ENGINE=Mroonga") do |t|
    t.belongs_to :issue, index: true, null: false
    t.string :subject, default: "", null: false
    t.text :description, limit: 65535, null: false
  end
  execute("INSERT INTO " + # データをコピー
            "fts_issues(issue_id, subject, description) " +
            "SELECT id, subject, description FROM issues;")
  add_index(:fts_issues, [:subject, :description],
            type: "fulltext") # 静的インデックス構築(速い)
end

このテーブルのモデルは次のようになります。

class FtsIssue < ActiveRecord::Base
  # 実際はissue_idカラムは主キーではない。
  # 主キーなしのテーブルなので
  # Active Recordをごまかしているだけ。
  self.primary_key = :issue_id
  belongs_to :issue
end

Mroonga導入後に更新されたデータはアプリケーション(Redmine)側でデータをコピーします。Active Recordのafter_saveフックを利用します。Mroongaがトランザクションをサポートしていないため、ロールバックのタイミングによってはデータに不整合が発生することがありますが、再度保存すれば復旧できることとそれほどロールバックは発生しないため、実運用時には問題になることはないでしょう。

class Issue
  # この後にロールバックされることがあるのでカンペキではない
  # 再度同じチケットを更新するかデータを入れ直せば直る
  after_save do |record|
    fts_record =
      FtsIssue.find_or_initialize_by(issue_id: record.id)
    fts_record.subject     = record.subject
    fts_record.description = record.description
    fts_record.save!
  end
end

全文検索時は全文検索用のテーブルをJOINしてMATCH AGAINSTを使います。

issue.
  joins(:fts_issue).
  where(["MATCH(fts_issues.subject, " +
               "fts_issues.description) " +
          "AGAINST (? IN BOOLEAN MODE)",
         # ↓デフォルトANDで全文検索
         "*D+ #{keywords.join(', ')}"])

この説明はわかりやすさのために実際の実装を単純化しています。詳細が知りたい方は実装を確認してください。

PGroongaを導入する方法

PGroongaはトランザクションに対応しているので別途全文検索用のテーブルを作成する必要はありません。既存のテーブルに全文検索用のインデックスを作成します。

マイグレーションファイルでいうと次のようにします。ここではissuesテーブルに全文検索用のインデックスを作成しています。enable_extension("pgroonga")はPGroongaを使えるようにするためのSQLです。

def up
  enable_extension("pgroonga")
  add_index(:issues,
            [:id, :subject, :description],
            using: "pgroonga")
end

あとは検索時に全文検索条件をつけるだけです。

issue.
  # 検索対象のカラムごとに
  # クエリーを指定
  where(["subject @@ ? OR " +
         "description @@ ?",
         keywords.join(", "),
         keywords.join(", ")])

この説明もわかりやすさのために実際の実装を単純化しています。詳細が知りたい方は実装を確認してください。

Zulipへの導入方法

ZulipへのPGroongaの導入方法を説明します。ZulipはPostgreSQLを使っているので、導入するのはPGroongaだけです。ZulipはDjangoを使っているのでDjangoを使っているアプリケーションに導入する例ということになります。

Zulipはチャットツールです。チャットツールなので小さなテキストの書き込みが頻繁に発生する傾向があります。各書き込みは十分速く完了する必要があります。書き込みが遅いとユーザーの不満が溜まりやすいからです。

Zulipは書き込みをできるだけ速くするためにインデックスの更新を遅延させています。インデックスの更新はデータの追加よりも重い処理なので、その処理を後回しにしているということです。(PGroongaは検索だけでなく更新も速いので遅延させずにリアルタイムで更新しても十分速いかもしれません。アプリケーションの要件次第でどのような実装にするか検討する必要があります。)

Zulipは、インデックスの更新を遅延させるため、カラムの値を直接全文検索対象にせずに、別途全文検索用のカラムを用意しています。その全文検索用のカラムの更新を後回しにすることでインデックスの更新を遅延させています。

マイグレーションファイルでいうと次のようにします。最初のALTER ROLEはPGroongaが提供する@@という全文検索用のオペレーターの優先順位を調整するためのものです。本質ではないのでここでは気にしなくて構いません。

migrations.RunSQL("""
ALTER ROLE zulip SET search_path
  TO zulip,public,pgroonga,pg_catalog;
ALTER TABLE zerver_message
  ADD COLUMN search_pgroonga text;
UPDATE zerver_message SET search_pgroonga =
  subject || ' ' || rendered_content;
CREATE INDEX pgrn_index ON zerver_message
  USING pgroonga(search_pg$roonga);
""", "...")