DanLevy.net

Postgres テキスト検索ガイド 2026

データベースにすでにある検索ツールと、それぞれが本領発揮する場面。

ほとんどのチームは Postgres の検索ツールをひとつしか使っていない。3つとも把握しているチームは、複雑さを抑えたまま優れた検索を提供し、まだ必要のない専用検索サービスへの迂回コストを回避できる。

このガイドでは Postgres ネイティブの選択肢を網羅する。それぞれ何をするか、いつ適しているか、どう組み合わせるか。


3 つのツール

全文検索tsvector / GIN インデックス)は語彙ベースだ。テキストを形態素(lexeme)に分割し、ステミング(語幹抽出)を行い、インデックスに対してクエリを照合する。「running」と「runs」は同じ形態素に収束する。「dog」と「dogs」も同じだ。ランキング関数(ts_rank)は、クエリ用語が頻出する、あるいは目立つ位置にあるドキュメントを高く評価する。

トライグラムpg_trgm)は文字列を3文字のオーバーラップするスライスに分割し、2つの文字列がどれだけのスライスを共有するかを測定する。「Dan」→ " da""dan""an "。「Micheal」と「Michael」はトライグラムの大部分を共有するため、類似度は高くなる。これにより pg_trgm はあいまいな名前のマッチング、タイプミス耐性、オートコンプリート——FTS が苦手とする領域——で優れた力を発揮する。

完全一致インデックス(B-tree、hash)は主キー、メールアドレス、ID、SKU、そして答えがバイナリ(一致するかしないか)のあらゆるものを扱う。これらは「検索」には感じられないかもしれないが、この話題に含める価値がある。正解がある問題にあいまい検索やセマンティック検索を使うのが最悪のパターンだからだ。

選択肢は sophistication の問題ではない。クエリの形状にツールを合わせられるかどうかだ。

Postgres 検索ツールマップpg_trgm、全文検索、pgvector、ハイブリッド検索を入力形状とクエリの意図で比較。入力形状で検索プリミティブを選ぶ同じ Postgres テーブルで4つすべてをサポートできる。コツはクエリをテキストに合わせること。単語の正確さが重要意味が重要短い/構造化テキスト長い文章/チャンクあいまいpg_trgm名前、住所、タイトル、タイプミス、オートコンプリート、部分文字列。表記の類似度:スペリング距離。類似pgvector関連アイテム、重複チケット、短い説明からのレコメンデーション。埋め込み類似度:意味の距離。語彙的全文検索記事、ドキュメント、ログ、サポートコンテンツ。クエリの単語が出現すべき場所。形態素、ステミング、ランキング、ブールフィルタ。ハイブリッドFTS + pgvector技術ドキュメントとRAG。概念的な質問と正確な記号の両方。両方を実行し、RRFでランクを統合。クエリ意図から始め、テキスト形状を確認する
四つの Postgres 検索プリミティブをクエリ意図(正確 vs 意味的)とテキスト形状(構造化 vs 散文)でマッピング。同じテーブルが四つのインデックスをすべて持てる——選択はテーブル単位ではなくクエリ単位だ。

全文検索(FTS)が勝る場面

キーワードで散文を検索する。 ブログ記事、ドキュメント、商品説明、サポートチケット、法律文書。FTS はこの形状のコンテンツのために設計された:自然言語テキストに対するインデックス付きランキング検索。

キーワードベースのユーザークエリ。 ユーザーが検索語を入力し、タグで絞り込んだり、キーワードで閲覧したりする。FTS は埋め込みインフラなしでその意図をネイティブに処理する。

外部依存なしのランキング結果。 FTS インデックスは高速で決定論的であり、API 呼び出しを必要としない。関連性シグナルはフィールド位置で重み付けされた用語頻度から得られる。

検索と並行するブールフィルタリング。 FTS は既存のクエリロジックと自然に合成される:

SELECT * FROM posts
WHERE search_vector @@ to_tsquery('english', 'postgres & performance')
AND category = 'tutorial'
AND published_at > NOW() - INTERVAL '6 months';

FTS のセットアップ

-- 生成列によりインデックスが自動的に最新状態を保つ
ALTER TABLE posts ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(body, '')), 'B')
) STORED;
CREATE INDEX posts_search_idx ON posts USING GIN (search_vector);
-- クエリ
SELECT title, ts_rank(search_vector, query) AS rank
FROM posts, to_tsquery('english', 'postgres & performance') query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 10;

setweight は重要度を割り当てる:A(タイトル)は B(本文)より上位。これがほとんどのコンテンツ検索ユースケースにおける関連性モデルのすべてだ。

FTS が苦手なもの


トライグラム(pg_trgm)が勝る場面

pg_trgm は、FTS が一貫して躓く中間領域をカバーする。

FTS はテキストを形態素にトークン化しステミングする。散文に対してはこれが正しい。名前や短い識別子に対しては、しばしば正しくない:

pg_trgm は言語非依存でもある。これは多様な言語的背景を持つ名前にとって重要だ。FTS は言語ごとの辞書設定を必要とする。

あいまいな名前検索

CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX users_name_trgm_idx ON users USING GIN (name gin_trgm_ops);
-- "Michael Jordan" を検索して "Micheal Jordan" を発見する
SELECT id, name, similarity(name, $1) AS score
FROM users
WHERE name % $1 -- % 演算子 = 類似度閾値(デフォルト 0.3)
ORDER BY score DESC
LIMIT 10;

% 演算子は pg_trgm.similarity_threshold(デフォルト 0.3、範囲 0–1)を使用する。名前検索では、0.3–0.4 がタイポを拾いつつノイズを低く保つ。

オートコンプリート、接頭辞、包含検索

-- オートコンプリートのための前方一致。トライグラム GIN インデックスは役立つが、
-- 純粋な左固定の接頭辞には B-tree パターンインデックスの方が適している場合がある。
SELECT name FROM users
WHERE name ILIKE $1 || '%'
ORDER BY name
LIMIT 10;
-- 長い文字列内の部分一致のための word_similarity
-- ("Andrew Johnson III" 内の "Johnson")
SELECT id, name, word_similarity($1, name) AS score
FROM users
WHERE $1 <% name
ORDER BY score DESC
LIMIT 10;

トライグラム GIN インデックスは、特に ILIKE '%pattern%' の包含クエリやタイポ許容マッチングで有用——トライグラムインデックスがなければ通常フルテーブルスキャンになるパターンだ。

FTS より pg_trgm を選ぶべきとき

シナリオ推奨
タイポを含む人名・会社名検索pg_trgm
オートコンプリート/接頭辞検索pg_trgm(または接頭辞クエリ付き FTS)
短い文字列、識別子、コードpg_trgm
記事、ドキュメント、チケットFTS
ログメッセージのキーワードFTS
多言語の名前検索pg_trgm(言語非依存)

完全一致 SQL が勝る場面

一部の「検索」問題は、実は検索ではない。

「メール dan@example.com のユーザーを見つける」は等価性チェックだ。「注文 ORD-12345 を見つける」は主キールックアップだ。「tutorial カテゴリの投稿を日付順にリストする」は絞り込みクエリだ。これらは B-tree や hash インデックスに属する。

ここで FTS やトライグラムを使うと、正確性を向上させずに複雑さだけが増す——そして正確な識別子に対して、近似一致は一致しないより悪い。

CREATE INDEX users_email_idx ON users (email);
-- 正確なルックアップ:高速で曖昧さがない
SELECT id, name FROM users WHERE email = $1;

より広い教訓:正解がある問題に近似検索を使うのは、カテゴリーの誤りだ。それは何かを返す——それは自信を持って間違っている可能性がある。


ツールの組み合わせ

これらのツールはきれいに合成される。厳密に一つを選ぶ必要はない。

FTS + pg_trgm で、キーワードのタイポを許容する検索ボックス:

-- タイトルのトライグラム類似度がタイポを捉え、ts_rank が本文の関連性を処理する
SELECT id, title,
ts_rank(search_vector, to_tsquery('simple', $1)) AS fts_rank,
similarity(title, $1) AS trgm_score
FROM posts
WHERE search_vector @@ to_tsquery('simple', $1)
OR title % $1
ORDER BY (ts_rank(search_vector, to_tsquery('simple', $1)) + similarity(title, $1)) DESC
LIMIT 10;

FTS + unaccent で国際的なコンテンツ:

-- 発音区別符号を除去し、「José」が「Jose」に一致するようにする
CREATE EXTENSION IF NOT EXISTS unaccent;
CREATE TEXT SEARCH CONFIGURATION public.simple_unaccent (COPY = pg_catalog.simple);
ALTER TEXT SEARCH CONFIGURATION public.simple_unaccent
ALTER MAPPING FOR hword, hword_part, word
WITH unaccent, simple;
ALTER TABLE posts ADD COLUMN search_vector tsvector;
CREATE TRIGGER posts_search_vector_refresh
BEFORE INSERT OR UPDATE OF title, body ON posts
FOR EACH ROW EXECUTE FUNCTION
tsvector_update_trigger(search_vector, 'public.simple_unaccent', title, body);

unaccent + pg_trgm で国際的な名前検索:

ALTER TABLE users ADD COLUMN name_search text;
CREATE FUNCTION users_name_search_refresh()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
NEW.name_search := unaccent(coalesce(NEW.name, ''));
RETURN NEW;
END;
$$;
CREATE TRIGGER users_name_search_refresh
BEFORE INSERT OR UPDATE OF name ON users
FOR EACH ROW EXECUTE FUNCTION users_name_search_refresh();
CREATE INDEX users_name_search_trgm_idx
ON users USING GIN (name_search gin_trgm_ops);
SELECT id, name
FROM users
WHERE name_search % unaccent($1)
ORDER BY similarity(name_search, unaccent($1)) DESC
LIMIT 10;

トリガーの例は、生成列やインデックス式の中で unaccent() を使うのを避けている。そこでは PostgreSQL の不変性ルールが重要になるからだ。unaccent() を独自の不変関数でラップする場合、アップグレード/設定リスクを受け入れていることを文書化する。


注目すべき拡張

pg_trgm はほとんどの Postgres ディストリビューションに同梱されているが、明示的な有効化が必要。Postgres におけるあいまい文字列マッチングの基盤。

unaccent はインデックス作成とクエリの前に発音区別符号を除去する。欧州言語のコンテンツに対して pg_trgm と FTS の両方とうまく連携する。Postgres に同梱。

pg_bigm はトライグラムアプローチをバイグラム(2文字スライス)に拡張し、pg_trgm が性能を発揮しにくい CJK(中国語、日本語、韓国語)言語で結果を大幅に改善する。別途インストールが必要;同梱されていない。

pg_searchParadeDB 製)は標準の GIN / tsvector スタックを Tantivy ベースの BM25 インデックスに置き換える。BM25 スコアリング(ts_rank より優れることが多い)、FTS クエリ内のあいまい一致、ファセット検索、大規模テーブルでの劇的に高速なインデックス構築を提供する。標準 FTS がランキングやパフォーマンスの限界に達したときのドロップインアップグレードパス。

-- pg_search: BM25 全文検索とあいまい一致
CREATE INDEX posts_bm25_idx ON posts
USING bm25 (id, title, body)
WITH (key_field = 'id', text_fields = '{"title": {}, "body": {}}');
-- BM25 スコアリング + あいまい一致でのクエリ("javascipt" を捕捉)
SELECT id, title, paradedb.score(id) AS rank
FROM posts
WHERE posts @@@ paradedb.fuzzy_phrase(field => 'title', value => 'postgres performnce')
ORDER BY rank DESC
LIMIT 10;

pgvector は密ベクトルの保存と類似性検索を追加する。ユーザーが名前を挙げるのではなく欲しいものを説明する場面——セマンティック検索、RAG、関連コンテンツのレコメンデーション、多言語クエリ——に適したツールだ。Semantic Vector Search and Hybrid Strategies で詳しく扱っている。


意思決定表

検索対象推奨
記事、ドキュメント、チケットFTS
タイポを含む人名・会社名pg_trgm
オートコンプリート、接頭辞検索pg_trgm
短いコード、識別子pg_trgm
ログメッセージのキーワードFTS
国際的な名前pg_trgm + unaccent
大規模コンテンツ、より良いランキングpg_search(ParadeDB BM25)
主キー、正確なメール、IDB-tree インデックス
日付、範囲、ソート済みリストB-tree インデックス
権限、カテゴリ、フィルタ通常の WHERE 句
質問、言い換え、概念pgvector(次の記事を参照)

迷ったとき:スペルのばらつきがある短い文字列 → トライグラム。キーワードクエリの長い文章 → FTS。構造化識別子 → 通常のインデックス。概念的または自然言語クエリ → pgvector。


ハイブリッド検索:2 つのシグナル、1 つのランク

"withRetry timeout errors" のようなクエリが検索ボックスに入力されたとき、そこには2種類の意図が含まれる:ユーザーが知っている正確なシンボル名(withRetry)と概念的な説明(timeout errors)。単一のプリミティブでは両方をカバーできない。FTS とベクトル検索を並列に実行し——Reciprocal Rank Fusion でランク付きリストをマージする——ことで解決する。

RRF は各結果を各リストで 1 / (60 + rank) としてスコアリングし、リスト間で合計する。定数 60 は上位ランクの優位性を緩和するため、両方のリストで2位に入った結果が、一方のリストで1位でもう一方で圏外の結果に勝つことができる。重要なのは、RRF はメソッド間で生スコアを平均しないこと——FTS ランクとコサイン距離は異なる通貨であり、算術的に組み合わせることはできない。

Reciprocal Rank Fusion によるハイブリッド検索クエリが全文検索とベクトル検索に分岐し、それぞれがランクを生成し、Reciprocal Rank Fusion がそれらを1つの結果リストに統合する。ハイブリッド検索は2つの正直なシグナル、そして1つの統合ランク生スコアを平均しないこと。FTS ランクとコサイン距離は異なる通貨だ。ユーザークエリ”withRetrytimeout errors”FTS / BM25正確なシンボルと単語1. API リファレンス2. リトライガイドpgvector概念的な近傍1. ネットワーク障害2. リトライガイドRRF マージ各リストでのランク位置に応じて各結果にスコアを与える。1 / (60 + rank)最終結果トップヒットは正確な用語と意味が一致する場所。
クエリが FTS と pgvector に並列に分岐する。それぞれが独自のランク付きリストを生成する。RRF は各ドキュメントを各リストでの位置でスコアリングし合計する——結果として両方のシグナルが一致するドキュメントが浮上する。
-- Hybrid search: FTS + pgvector merged with RRF
WITH fts AS (
SELECT id, ts_rank(search_vector, query) AS score,
ROW_NUMBER() OVER (ORDER BY ts_rank(search_vector, query) DESC) AS rank
FROM docs, to_tsquery('english', 'withRetry & timeout') query
WHERE search_vector @@ query
LIMIT 60
),
vec AS (
SELECT id,
ROW_NUMBER() OVER (ORDER BY embedding <=> $embedding) AS rank
FROM docs
ORDER BY embedding <=> $embedding
LIMIT 60
)
SELECT COALESCE(fts.id, vec.id) AS id,
(COALESCE(1.0 / (60 + fts.rank), 0) +
COALESCE(1.0 / (60 + vec.rank), 0)) AS rrf_score
FROM fts FULL JOIN vec ON fts.id = vec.id
ORDER BY rrf_score DESC
LIMIT 10;

ブランチあたりの 60 ドキュメント候補プール(LIMIT 60)は一般的な出発点だ。再現率が低い場合は広げ、速度を求める場合は狭める。


次に

Postgres テキスト検索は多くの領域をカバーするが、天井がある。ユーザーが名前ではなく欲しいものを説明するとき——「飛行機で寝るのに役立つもの」「新人エンジニアのデバッグ自信に関する記事」——語彙検索もトライグラム検索も失敗する。

それはベクトル埋め込み、セマンティック検索、ハイブリッドアーキテクチャの領域だ。セマンティックベクトル検索とハイブリッド戦略 で扱っている。