Ruby on Railsと素のPostgreSQLで日本語全文検索 - 2020-12-22 - ククログ

ククログ

株式会社クリアコード > ククログ > Ruby on Railsと素のPostgreSQLで日本語全文検索

Ruby on Railsと素のPostgreSQLで日本語全文検索

PostgreSQLに超高速な日本語全文検索機能を追加するPGroongaを開発している須藤です。今回はPGroongaやpg_bigmなど拡張モジュールを使わずにPostgreSQLの組み込み機能だけで日本語全文検索を実現する方法を紹介します。PGroongaを使う方法はRuby on RailsでPostgreSQLとPGroongaを使って日本語全文検索を実現する方法を参照してください。

Heroku PostgresなどDBaaSとして提供されているPostgreSQLではPGroongaを使えません。(DBaaSとして提供しているベンダーがPGroongaをインストールしてくれないから。)PostgreSQLの組み込み機能だけでは日本語全文検索を満足に実現することができないので、DBaaSのPostgreSQLを使っていると次のように日本語全文検索で困ってしまいます。

2023-07-03追記:Supabaseが提供するPostgreSQLがPGroongaをサポートしました!

ということで、PostgreSQLの組み込み機能だけを使ってそれなりの日本語全文検索機能(PGroongaほどではない)を実現する方法を紹介します。

どうしてPostgreSQLの組み込み機能だけで日本語全文検索をできないか

まず、日本語全文検索をするために足りないPostgreSQLの組み込み機能について説明します。

一般的に、インデックスを使った全文検索は次のように実現されます。

データ登録:

  1. 検索対象の文書をトークンに分割する

    • トークン:検索対象となる最小単位

    • 2文字ずつトークンに分割する例:"日本語"→"日本"、"本語"、"語"

  2. 次の情報をインデックス(転置インデックス)に登録する

    1. トークン

    2. トークンが含まれれる文書のID

    3. トークンの出現位置

検索:

  1. クエリーをトークンに分割する

  2. 転置インデックスを使って各トークンが連続して含まれる文書集合を検索する

たとえば、次のデータで考えます。

文書ID 文書
1 日本の米
2 日本語

それぞれの文書を2文字ずつのトークンに分割するとこうなります。

文書ID 文書 トークン
1 日本の米 日本, 本の, の米, 米
2 日本語 日本, 本語, 語

転置インデックスに登録するとこうなります。

トークン 文書ID/出現位置
日本 1/1,2/1
本の 1/2
の米 1/3
1/4
本語 2/2
2/3

それではこのデータに対して「日本語」で全文検索してみましょう。

まず「日本語」をトークンに分割します。(普通は最後の「語」は使っても使わなくても検索結果は変わらないので使いません。)

  • 日本

  • 本語

次に各トークンに対して出現する文書IDを探します。

トークン 文書ID/出現位置
日本 1/1,2/1
本語 2/2
2/3

文書1と文書2が候補です。

次にすべてのトークンが含まれている文書だけに絞り込みます。

トークン 文書ID/出現位置
日本 2/1
本語 2/2
2/3

文書2だけになりました。

最後にトークンの出現位置を見て連続してトークンが出現しているかを確認します。1(日本)→2(本語)→3(語)と出現しているので連続して出現しています。

ということで、文書2に「日本語」が含まれています。

この処理の中でPostgreSQLの組み込み機能で提供されていない機能は次のとおりです。

  1. 日本語をいい感じにトークンに分割する機能

  2. 転置インデックスにトークンの出現位置を含める機能

2.はインデックスを使わずにシーケンシャルサーチで対応するRecheck機能があるのでなんとかなりますが、1.は代替機能がありません。

pg_trgmという惜しい機能があり、設定をすれば日本語も使えるのですが、2文字以下のクエリーは使えません。日本語では「米」や「日本」など2文字以下のクエリーは普通に使われるので実用的ではありません。

では、PostgreSQLの組み込み機能だけで日本語全部検索をするにはどうすればよいかというと1.をアプリケーション側で実装すればよいです。

Ruby on Railsで日本語のトークナイズ

PostgreSQLには全文検索のための仕組みとして次のものを用意しています。

  • tsvector:文書に含まれるトークンとそれぞれの出現位置を持つ型

  • tsquerytsvectorをどうやって検索するかを示す型

  • フレーズ検索:トークンが連続して出現しているかという条件

  • GIN:転置インデックス

PostgreSQL 9.5まではフレーズ検索がなくてここで紹介する方法を使えませんでした。しかし、そろそろPostgreSQL 9.5がEOLになることもあり、古いバージョンのPostgreSQLを気にせずにここで紹介する方法を使えます。

まず、新しくRuby on Railsを使ったアプリケーションを作ります。ここでは記事の最初にあるツイートにあるように日本語学習のための辞書を検索するアプリケーションを作ります。

rails new dictionary --database=postgresql
cd dictionary

このアプリケーションでは次のようなデータを検索します。

  • 辞書内の各単語(日本語)

  • 辞書内の各単語の意味(英語)

  • 辞書内の各単語のよみがな(日本語、複数)

rails generate scaffold item \
  name:text \
  name_tsvector:tsvector \
  meaning:text \
  'readings:text{array}' \
  readings_tsvector:tsvector

PostgreSQLのtext[]を指定するためにreadings:text{array}と指定したのですが、まだarrayはカラム修飾子としてサポートされていないみたいです。後でパッチを書いておかないと。。。(だれか書かない?サポートするよ!)

text[]関連だけでなく、インデックス用の設定も追加しないといけないのでマイグレーションファイルを編集します。

変更前:

class CreateItems < ActiveRecord::Migration[6.1]
  def change
    create_table :items do |t|
      t.text :name
      t.tsvector :name_tsvector
      t.text :meaning
      t.text{array} :readings

      t.timestamps
    end
  end
end

変更後:

class CreateItems < ActiveRecord::Migration[6.1]
  def change
    create_table :items do |t|
      t.text :name
      t.tsvector :name_tsvector
      t.text :meaning
      t.text :readings, array: true
      t.tsvector :readings_tsvector

      t.timestamps

      t.index :name_tsvector, using: "GIN"
      t.index "to_tsvector('english', meaning)", using: "GIN"
      t.index :readings_tsvector, using: "GIN"
    end
  end
end

変更点:

  • text{array}text array: true

  • インデックスを追加

    • t.index :name_tsvector, using: "GIN":単語の全文検索用(日本語)

    • t.index "to_tsvector('english', meaning)", using: "GIN":単語の意味の全文検索用(英語)

    • t.index :readings_tsvector, using: "GIN":単語のよみがなの全文検索用(日本語)

単語の意味は英語なのでPostgreSQLの全文検索機能(to_tsvector('english'))を使っています。単語とよみがなはアプリケーションでtsvectorを作るのでカラムを作ってそれにインデックスを作っています。

それでは、日本語をいい感じにトークンに分割する機能を作ります。BigramTokenizer#tokenizeが2文字ごとのトークンに分割しています。build_tsvectorは分割した情報をtsvectorに変換する機能で、build_tsquerytsqueryに変換する機能です。build_tsqueryでフレーズ検索(<->)を使っていることが重要です。

lib/bigram_tokenizer.rb:

class BigramTokenizer
  def initialize(input)
    @input = input
  end

  def build_tsvector
    postings = tokenize(:index)
    postings.collect {|token, positions| "#{token}:#{positions.join('')}"}.join(" ")
  end

  def build_tsquery
    postings = tokenize(:query)
    template = postings.size.times.collect {"tsquery(?)"}.join(" <-> ")
    [template, postings.keys]
  end

  private
  def tokenize(usage)
    postings = {}
    if @input.is_a?(Array)
      texts = @input
    else
      texts = [@input]
    end
    position = 1
    texts.each do |text|
      chars = text.unicode_normalize(:nfkc).gsub(/\p{Space}/, "").chars
      chars.each_cons(2) do |char1, char2|
        token = "#{char1}#{char2}"
        postings[token] ||= []
        postings[token] << position
        position += 1
      end
      if usage == :index or chars.size == 1
        unless chars.empty?
          postings[chars.last] ||= []
          postings[chars.last] << position
          position += 1
        end
      end
      position += 1
    end
    postings
  end
end

これをモデルのクラスに組み込みます。before_saveのフックで自動でtsvectorを作っています。Item.ftstsvectortsqueryを使って全文検索をしています。(orの使い方あってる?)

app/models/item.rb:

require "bigram_tokenizer"

class Item < ApplicationRecord
  class << self
    def fts(query)
      return where if query.blank?
      tokenizer = BigramTokenizer.new(query)
      template, values = tokenizer.build_tsquery
      where("name_tsvector @@ (#{template})", *values)
        .or(where("to_tsvector('english', meaning) @@ " +
                  "to_tsquery('english', ?)",
                  query))
        .or(where("readings_tsvector @@ (#{template})", *values))
    end
  end

  before_save :update_name_tsvector
  before_save :update_readings_tsvector

  private
  def update_name_tsvector
    self.name_tsvector = build_tsvector(name)
  end

  def update_readings_tsvector
    self.readings_tsvector = build_tsvector(readings)
  end

  def build_tsvector(input)
    return nil if input.blank?
    tokenizer = BigramTokenizer.new(input)
    tokenizer.build_tsvector
  end
end

動作確認してみましょう。データを登録します。

Item.new(name: "米", meaning: "rice", readings: ["こめ", "まい"]).save!
Item.new(name: "日本人", meaning: "Japanese", readings: ["にほんじん"]).save!

単語を検索します。

Item.fts("米")
# [#<Item:0x0000556dbfd97f60
#   id: 1,
#   name: "米",
#   name_tsvector: "'米':1",
#   meaning: "rice",
#   readings: ["こめ", "まい"],
#   readings_tsvector: "'い':5 'こめ':1 'まい':4 'め':2",
#   created_at: Tue, 22 Dec 2020 02:16:46.832519000 UTC +00:00,
#   updated_at: Tue, 22 Dec 2020 02:16:46.832519000 UTC +00:00>]

よみがなを検索します。

Item.fts('にほん')
# [#<Item:0x0000556dbfe6fd70
#   id: 2,
#   name: "日本人",
#   name_tsvector: "'人':3 '日本':1 '本人':2",
#   meaning: "Japanese",
#   readings: ["にほんじん"],
#   readings_tsvector: "'じん':4 'にほ':1 'ほん':2 'ん':5 'んじ':3",
#   created_at: Tue, 22 Dec 2020 02:18:05.809113000 UTC +00:00,
#   updated_at: Tue, 22 Dec 2020 02:18:05.809113000 UTC +00:00>]

意味(英語)を検索します。

Item.fts('rice')
# [#<Item:0x0000556dbff25620
#   id: 1,
#   name: "米",
#   name_tsvector: "'米':1",
#   meaning: "rice",
#   readings: ["こめ", "まい"],
#   readings_tsvector: "'い':5 'こめ':1 'まい':4 'め':2",
#   created_at: Tue, 22 Dec 2020 02:16:46.832519000 UTC +00:00,
#   updated_at: Tue, 22 Dec 2020 02:16:46.832519000 UTC +00:00>]`

動いていますね!

1文字のケースの対応

いや、動いていないんですよ!試した範囲では動いていますが、これだけでは足りないのです。

2文字ごとのトークンに分割しているので「本」など1文字で検索したケースの対応が別途必要です。「米」では動いていましたがこれはたまたまです。最後の1文字のケースだけ別途対応しなくても動いていただけです。

Item.fts("本")
# []

どんな対応をしないといけないかというと指定された1文字から始まるトークンをすべて見つけることです。これはトークンをすべて格納したテーブルを用意し、指定された1文字で前方一致検索することで実現できます。PostgreSQLではLIKE 'X%'でインデックスを使った前方一致検索をできます。注意点はCロケールあるいはtext_pattern_opsオペレータークラスを使わないといけないことです。詳細は11.10. 演算子クラスと演算子族を参照してください。

Ruby on Railsアプリケーションでの実現方法を示します。

まず、トークンを格納するテーブルを用意します。タイムスタンプ用のカラムを用意していないのは必要がないことと、バルクインサートをしやすくするためです。

rails generate model --no-timestamps token name:text

マイグレーションファイルを調整します。ポイントはtext_pattern_opsオペレータークラスを指定していることです。

class CreateTokens < ActiveRecord::Migration[6.1]
  def change
    create_table :tokens do |t|
      t.text :name, null: false, unique: true

      t.index "name text_pattern_ops", unique: true
    end
  end
end

全文検索用のインデックスを作るとき(build_tsvectorのとき)はトークンをtokensテーブルに格納します。全文検索するとき(build_tsqueryのとき)は1文字での検索かをチェックし、1文字での検索の場合は指定された文字から始まるトークンをすべて取得してORで検索します。

diff --git a/lib/bigram_tokenizer.rb b/lib/bigram_tokenizer.rb
index 605a04b..8ae8ffc 100644
--- a/lib/bigram_tokenizer.rb
+++ b/lib/bigram_tokenizer.rb
@@ -5,13 +5,20 @@ class BigramTokenizer
 
   def build_tsvector
     postings = tokenize(:index)
+    Token.insert_all(postings.keys.collect {|token| {name: token}})
     postings.collect {|token, positions| "#{token}:#{positions.join('')}"}.join(" ")
   end
 
   def build_tsquery
     postings = tokenize(:query)
-    template = postings.size.times.collect {"tsquery(?)"}.join(" <-> ")
-    [template, postings.keys]
+    if postings.size == 1 and (first_token = postings.keys[0][0]).size == 1
+      tokens = Token.where("name LIKE ?", "#{first_token}%").pluck(:name)
+      template = tokens.size.times.collect {"tsquery(?)"}.join(" || ")
+      [template, tokens]
+    else
+      template = postings.size.times.collect {"tsquery(?)"}.join(" <-> ")
+      [template, postings.keys]
+    end
   end
 
   private

それではもう一度1文字で全文検索してみましょう。

Item.fts('本')
# [#<Item:0x0000556dbfe6fd70
#   id: 2,
#   name: "日本人",
#   name_tsvector: "'人':3 '日本':1 '本人':2",
#   meaning: "Japanese",
#   readings: ["にほんじん"],
#   readings_tsvector: "'じん':4 'にほ':1 'ほん':2 'ん':5 'んじ':3",
#   created_at: Tue, 22 Dec 2020 02:18:05.809113000 UTC +00:00,
#   updated_at: Tue, 22 Dec 2020 02:18:05.809113000 UTC +00:00>]

こんどこそ動いていますね!

なお、このアプリケーションのソースコードは https://gitlab.com/ktou/rails-postgresql-japanese-fts にあります。

おまけ:どうして形態素解析器を使ってトークンに分割しないの?

なぜか「全文検索のことはよくわからない初心者なのですがトークンの分割には形態素解析器を使いたい」という人が多いように感じます。初心者の場合はとりあえずここで紹介したような2文字ごとにトークンに分割する方法から使い始めたほうがよいです。形態素解析器を使う場合は形態素解析器が使う辞書や言語モデルのことを考えたり、どうしてそのようなトークンに分割されたのか・より適切な分割方法にするにはどうすればよいかを考えたり調べたりする必要があるなど、初心者には荷が重いでしょう。まずはLIKEのように動く2文字ごとにトークンに分割する方法からはじめて、だんだん全文検索に関する知見が溜まってからどうやって速度・精度・スコアリングなどをチューニングしていくかを検討していく方が現実的です。

まとめ

Ruby on RailsとPostgreSQLの組み込み機能だけを使ってそれなりの日本語全文検索機能を実現する方法を紹介しました。GINには「転置インデックスにトークンの出現位置を含める機能」がないのでヒット数が多くなると遅くなりがちですが、それなりのデータ量なら気にならないでしょう。

PostgreSQLで本格的に日本語全文検索をしたくなったらPGroongaを使ってみてね。ただ、DBaaSでPGroongaを使える日はなかなか来なそうなので導入の敷居は高そうです。たとえば、Azure Database for PostgreSQLにはPGroongaを含めてくれリクエストがありますが、対応される気配はなさそうです。

2023-07-03追記:Supabaseが提供するPostgreSQLがPGroongaをサポートしました!

全文検索関係の技術支援が必要な方やPGroongaが組み込まれたDBaaSを立ち上げたい!という方はお問い合わせください。力になれるはずです。