【PostgreSQL】pg_trgmによる文字列の類似度検索を試す
はじめに
データベースに、類似したデータが既に登録されているかどうかを確認したいケースがあります。
LIKE検索を使えば、指定したパターンについて部分一致検索が可能です。また、全角と半角、大文字と小文字の違いはプログラムで正規化することで、ある程度は同一のデータとして扱うことができます。しかし、正規化が難しい表記ゆれや、スペルミスなどで異なる文字列となった場合には、完全一致・正規化だけでは類似しているかどうかを判別できません。
今回は、PostgreSQLのpg_trgmモジュールを使用して、データベースに類似データがあるかどうかを検索してみました。
pg_trgmモジュールとは
pg_trgmは、文字列を連続した3文字ずつに分割(トライグラム)し、共通のトライグラムの個数によって文字列同士の類似度を判定する、PostgreSQLの拡張機能です。
例えば、「醤油を大さじ2杯加えます。」という文章は、 " 醤"、" 醤油"、"醤油を"、"油を大"、"を大さ"、…のように分割されます。
類似度を判定するための関数には以下のものがあり、それぞれ0から1までの小数で類似度が表現されます。1に近づくほど一致しているということになります。
similarity(クエリ文字列, 対象文字列)
クエリ文字列と対象文字列の、全体的なトライグラム集合の類似度を調べます。
word_similarity(クエリ文字列, 対象文字列)
対象文字列に存在する任意の連続した部分文字列の中で、クエリ文字列と最も類似する部分の類似度を調べます。クエリ文字列が対象文字列の一部である場合に高くなる傾向があります。
例えば、クエリ文字列がappleで対象文字列がapplesの場合、類似度が高くなります。
strict_word_similarity(クエリ文字列, 対象文字列)
word_similarityと同様ですが、単語の境界を考慮します。
クエリ文字列がapple、対象文字列がapplesの場合、単語の境界を考慮するとapplesで一つの単語となります。クエリ文字列を対象文字列の一部とみなすためにはsが不要と判定されるため、word_similarityより類似度は低くなります。
また、基本的に単語の境界を半角スペースで判断するため、日本語の文章そのままの場合、期待通りに動かない場合があります。
参考:F.35. pg_trgm — トライグラム一致を使ったテキストの類似度をサポートする
前提
本記事は以下の条件で検証しています。
- PostgreSQL 18.4
試してみる
検証用に以下のテーブルを作成します。
CREATE TABLE recipes (
id SERIAL NOT NULL,
instruction VARCHAR(255) NOT NULL,
created_at TIMESTAMP,
updated_at TIMESTAMP,
PRIMARY KEY (id)
);
データを投入します。
INSERT INTO recipes (instruction) VALUES
('醤油を大さじ2杯加えます。'),
('醤油を大さじ2杯加えます。'),
('しょうゆを大さじ2杯加えます。'),
('醤油を大さじ2杯加え、炒めます。'),
('醤油を大匙2杯加えます。'),
('砂糖を大さじ2杯加えます。'),
('大さじ2杯加え'),
('砂糖を大さじ1、醤油を大さじ2杯加えます。'),
('砂糖を大さじ1、醤油を大さじ2杯加えます。ざっくりと混ぜます。'),
('醤油を大さじ2杯、みりんを大さじ1杯、砂糖を小さじ1杯加えます。'),
('ごま油を大さじ2杯加え、ボウルであえます。'),
('醤油を入れたら、ひと煮立ちするまで待ちます。'),
('大さじ2杯の醤油を加えます。'),
('大さじ加えます2杯醤油を。'),
('レンジで3分間あたためます。'),
('豚薄切り肉100g、ピーマン2個、なす2本'),
('焼き色がついたら裏返します。')
;
まだpg_trgmが有効になっていない場合は、最初の1回のみ以下のクエリを実行して拡張機能を有効にします。
-- 初回のみ
CREATE EXTENSION IF NOT EXISTS pg_trgm;
以下のクエリを実行して、similarity、word_similarity、strict_word_similarityをそれぞれ取得します。
SELECT
id,
instruction,
similarity('醤油を大さじ2杯加えます。', instruction) as sim,
word_similarity('醤油を大さじ2杯加えます。', instruction) AS word_sim,
strict_word_similarity('醤油を大さじ2杯加えます。', instruction) as strict_word_sim
FROM recipes
ORDER BY sim DESC;
結果は以下のようになりました。
| id | instruction | sim | word_sim | strict_word_sim |
|---|---|---|---|---|
| 334 | 醤油を大さじ2杯加えます。 | 1.0 | 1.0 | 1.0 |
| 341 | 砂糖を大さじ1、醤油を大さじ2杯加えます。 | 0.68421054 | 1.0 | 1.0 |
| 335 | 醤油を大さじ2杯加えます。 | 0.625 | 0.625 | 0.625 |
| 337 | 醤油を大さじ2杯加え、炒めます。 | 0.6111111 | 0.7692308 | 0.71428573 |
| 338 | 醤油を大匙2杯加えます。 | 0.5625 | 0.5625 | 0.5625 |
| 339 | 砂糖を大さじ2杯加えます。 | 0.5294118 | 0.6923077 | 0.5294118 |
| 336 | しょうゆを大さじ2杯加えます。 | 0.47368422 | 0.6923077 | 0.47368422 |
| 342 | 砂糖を大さじ1、醤油を大さじ2杯加えます。ざっくりと混ぜます。 | 0.4642857 | 1.0 | 1.0 |
| 343 | 醤油を大さじ2杯、みりんを大さじ1杯、砂糖を小さじ1杯加えます。 | 0.41379312 | 0.61538464 | 0.5714286 |
| 344 | ごま油を大さじ2杯加え、ボウルであえます。 | 0.36 | 0.53846157 | 0.3888889 |
| 346 | 大さじ2杯の醤油を加えます。 | 0.35 | 0.3888889 | 0.35 |
| 340 | 大さじ2杯加え | 0.3125 | 0.3846154 | 0.3125 |
| 347 | 大さじ加えます2杯醤油を。 | 0.18181819 | 0.21052632 | 0.18181819 |
| 345 | 醤油を入れたら、ひと煮立ちするまで待ちます。 | 0.12903225 | 0.23076923 | 0.16666667 |
| 348 | レンジで3分間あたためます。 | 0.03846154 | 0.07692308 | 0.03846154 |
| 350 | 焼き色がついたら裏返します。 | 0.03846154 | 0.07692308 | 0.03846154 |
| 349 | 豚薄切り肉100g、ピーマン2個、なす2本 | 0.0 | 0.0 | 0.0 |
概ね、似たデータはどの関数でも0.4~1の数値を示し、似ていないデータは0~0.1を示していることがわかります。
「砂糖を大さじ1、醤油を大さじ2杯加えます。」のような、部分的に一致しているが余計な文字列が追加されている場合、全体的なトライグラムの類似度を測るsimilarityでは0.68だったものの、2つの文字列の部分文字列の類似度を表すword_similarityやstrict_word_similarityでは1となりました。
逆に、「大さじ2杯加え」のような、対象文字列がクエリ文字列の一部である場合は、word_similarityでは低い類似度となります。
select
word_similarity('醤油を大さじ2杯加えます。', '砂糖を大さじ1、醤油を大さじ2杯加えます。ざっくりと混ぜます。'), -- 1.0
word_similarity('砂糖を大さじ1、醤油を大さじ2杯加えます。ざっくりと混ぜます。', '醤油を大さじ2杯加えます。'); -- 0.4642857
また、1文字だけ全角・半角が異なる「醤油を大さじ2杯加えます。」が全関数で0.625、漢字・ひらがな違いの「醤油を大匙2杯加えます。」や「しょうゆを大さじ2杯加えます。」もsimilarityでは0.5程度となり、表記ゆれがトライグラムベースの類似度の低下につながることが見てとれます。一方で、意味的に異なる「砂糖を大さじ2杯加えます。」も、表記ゆれと同程度の類似度となっています。
word_similarityとstrict_word_similarityの数値の違いがなぜこうなるかについては、文章を見ただけではわかりません。上述のとおり日本語の文章を形態素解析なしでそのまま扱う場合、判定が正しくない場合があります。基本的にはsimilarityかword_similarityを用途に応じて使うのが良さそうです。
pg_trgmの演算子
類似度についていくつかの演算子が用意されています。
%演算子を使うと、pg_trgm.similarity_thresholdで設定された値(デフォルトは0.3)以上の類似度を検索することができます。
SELECT
id,
instruction,
similarity('醤油を大さじ2杯加えます。', instruction) as sim,
word_similarity('醤油を大さじ2杯加えます。', instruction) AS word_sim
FROM recipes
where '醤油を大さじ2杯加えます。' % instruction
ORDER BY sim DESC;
以下の結果の通り、0.3以上のデータが取得できます。
| id | instruction | sim | word_sim |
|---|---|---|---|
| 334 | 醤油を大さじ2杯加えます。 | 1.0 | 1.0 |
| 341 | 砂糖を大さじ1、醤油を大さじ2杯加えます。 | 0.68421054 | 1.0 |
| 335 | 醤油を大さじ2杯加えます。 | 0.625 | 0.625 |
| 337 | 醤油を大さじ2杯加え、炒めます。 | 0.6111111 | 0.7692308 |
| 338 | 醤油を大匙2杯加えます。 | 0.5625 | 0.5625 |
| 339 | 砂糖を大さじ2杯加えます。 | 0.5294118 | 0.6923077 |
| 336 | しょうゆを大さじ2杯加えます。 | 0.47368422 | 0.6923077 |
| 342 | 砂糖を大さじ1、醤油を大さじ2杯加えます。ざっくりと混ぜます。 | 0.4642857 | 1.0 |
| 343 | 醤油を大さじ2杯、みりんを大さじ1杯、砂糖を小さじ1杯加えます。 | 0.41379312 | 0.61538464 |
| 344 | ごま油を大さじ2杯加え、ボウルであえます。 | 0.36 | 0.53846157 |
| 346 | 大さじ2杯の醤油を加えます。 | 0.35 | 0.3888889 |
| 340 | 大さじ2杯加え | 0.3125 | 0.3846154 |
<%演算子は%演算子と似ていますが、similarityの代わりにword_similarityを基準とします。word_similarity_thresholdのデフォルトは0.6です。
SELECT
id,
instruction,
similarity('醤油を大さじ2杯加えます。', instruction) as sim,
word_similarity('醤油を大さじ2杯加えます。', instruction) AS word_sim
FROM recipes
where '醤油を大さじ2杯加えます。' <% instruction
ORDER BY word_sim DESC;
| id | instruction | sim | word_sim |
|---|---|---|---|
| 334 | 醤油を大さじ2杯加えます。 | 1.0 | 1.0 |
| 341 | 砂糖を大さじ1、醤油を大さじ2杯加えます。 | 0.68421054 | 1.0 |
| 342 | 砂糖を大さじ1、醤油を大さじ2杯加えます。ざっくりと混ぜます。 | 0.4642857 | 1.0 |
| 337 | 醤油を大さじ2杯加え、炒めます。 | 0.6111111 | 0.7692308 |
| 339 | 砂糖を大さじ2杯加えます。 | 0.5294118 | 0.6923077 |
| 336 | しょうゆを大さじ2杯加えます。 | 0.47368422 | 0.6923077 |
| 335 | 醤油を大さじ2杯加えます。 | 0.625 | 0.625 |
| 343 | 醤油を大さじ2杯、みりんを大さじ1杯、砂糖を小さじ1杯加えます。 | 0.41379312 | 0.61538464 |
他にも演算子がありますので、気になった方は公式ドキュメントをご参照ください。
参考:F.35. pg_trgm — トライグラム一致を使ったテキストの類似度をサポートする
補足
インデックスについて
pg_trgmモジュールはGiSTインデックスとGINインデックスをサポートしています。
本記事では検証していませんが、大量データを扱う場合はいずれかのインデックスの追加を検討してください。
以下の公式ドキュメントではGINインデックスを推奨していますが、用途に応じて適切な方を選択してください。
その他の文字列比較手法
今回はトライグラムによる表記レベルの類似度検索を扱いましたが、目的に応じて他の手法もあります。
pg_bigm
pg_trgmは3文字ずつの区切りで、英語などのアルファベット圏での使用を想定しています。日本語の文章では、strict_word_similarityの単語境界の判定が期待と異なる場合があります。一方、pg_bigmは2文字ずつの区切りで、日本語処理に対応した設計がされています。
レーベンシュタイン距離
pg_trgmと同じように、文字の並びが似ているかを測る手法ですが、pg_trgmが0~1で類似度で表すのに対し、レーベンシュタイン距離はある文字列を別の文字列に変換するために必要な操作回数を距離として表します。
数文字のスペルミスのような操作には強い一方、単語の位置が大きく入れ替わるようなケースでは距離が大きくなる傾向があります。
ベクトル化(埋め込み)
表記ではなく意味的な近さを測ります。「しょうゆ」と「醤油」のような表記ゆれも類似語と判定できますが、その分実装が複雑になります。「意味的に似た文章を探したい」という要件であればベクトル検索の検討が必要です。
おわりに
類似したデータの検索にトライグラムを利用したPostgreSQLの拡張機能、pg_trgmを試してみました。
今回用意したデータの中では概ね期待した結果が得られましたが、表記ゆれで類似度が低下したり、意味が違っていても違いが数文字であれば類似寄りとして判定されるなどがありました。
similarityやword_similarityのしきい値をどのくらいに設定すれば良いかは、実際のデータや用途によって異なるため、ご自身のデータセットで実際に検証してみることをおすすめします。
この記事がどなたかの参考になれば幸いです。








