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 の問題ではない。クエリの形状にツールを合わせられるかどうかだ。
全文検索が勝つ場面
キーワードで文章を検索する。 ブログ記事、ドキュメント、製品説明、サポートチケット、法務文書。FTS はこの形状のコンテンツ——自然言語テキストに対するインデックス付きのランク付け検索——のために設計されている。
キーワードベースのユーザークエリ。 ユーザーが検索語を入力し、タグでフィルタリングし、キーワードで閲覧する。FTS は埋め込みインフラなしでその意図をネイティブに処理する。
外部依存なしのランク付き結果。 FTS インデックスは高速で決定論的、API コールを必要としない。関連性のシグナルは、フィールドの位置で重み付けされた用語頻度から得られる。
検索と並列するブールフィルタリング。 FTS は既存のクエリロジックと自然に組み合わせられる:
SELECT * FROM postsWHERE search_vector @@ to_tsquery('english', 'postgres & performance') AND category = 'tutorial' AND published_at > NOW() - INTERVAL '6 months';FTS のセットアップ
-- Generated column keeps the index current automaticallyALTER 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);
-- QuerySELECT title, ts_rank(search_vector, query) AS rankFROM posts, to_tsquery('english', 'postgres & performance') queryWHERE search_vector @@ queryORDER BY rank DESCLIMIT 10;setweight は重要度を割り当てる:A(タイトル)は B(本文)より上位になる。これがほとんどのコンテンツ検索ユースケースにおける関連性モデルのすべてだ。
FTS がうまく扱えないもの
- クエリのタイプミス — 「javascipt」は「javascript」にマッチしない
- 人名、住所、予測可能なステミングができない固有名詞
- 特別な設定なしのプレフィックス/オートコンプリート
- ユーザーが名前ではなく概念を説明するクエリ
トライグラムが勝つ場面(pg_trgm)
pg_trgm は FTS が一貫して躓く中途半端な領域をカバーする。
FTS はテキストを形態素に分割しステミングする。文章にとっては正しい。名前や短い識別子にとってはそうでないことが多い:
- 人名(「Dan Levy」→ 辞書と言語設定によって異なるステミング)
- 会社名、住所、正確な綴りが重要な製品タイトル
- タイプミスを含むクエリ — 「Micheal Jordan」「Amaon」「javascipt」
- オートコンプリート/プレフィックス検索
- 部分文字列マッチ(「Johnson」「Anderson」にマッチする「son」)
pg_trgm は言語非依存でもある。多様な言語的背景を持つ名前にとってこれは重要だ。FTS は言語ごとに辞書設定が必要になる。
あいまい名前検索
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX users_name_trgm_idx ON users USING GIN (name gin_trgm_ops);
-- Finds "Micheal Jordan" when searching "Michael Jordan"SELECT id, name, similarity(name, $1) AS scoreFROM usersWHERE name % $1 -- % operator = similarity threshold (default 0.3)ORDER BY score DESCLIMIT 10;% 演算子は pg_trgm.similarity_threshold(デフォルト 0.3、範囲 0〜1)を使用する。名前検索では 0.3〜0.4 がタイプミスを捉えつつノイズを低く抑える。
オートコンプリート、プレフィックス、部分一致検索
-- Prefix matching for autocomplete. A trigram GIN index can help,-- but a B-tree pattern index may be better for pure left-anchored prefixes.SELECT name FROM usersWHERE name ILIKE $1 || '%'ORDER BY nameLIMIT 10;
-- word_similarity for partial matches within longer strings-- ("Johnson" within "Andrew Johnson III")SELECT id, name, word_similarity($1, name) AS scoreFROM usersWHERE $1 <% nameORDER BY score DESCLIMIT 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);
-- Exact lookup: fast and unambiguousSELECT id, name FROM users WHERE email = $1;より広い教訓:正解がある問題にあいまい検索を使うのはカテゴリのエラーだ。何か を返す——そしてそれは自信を持って間違っているかもしれない。
これらのツールの組み合わせ
これらのツールはきれいに組み合わせられる。ひとつだけを選ぶ必要はない。
キーワードのタイプミスに耐性のある検索ボックスに FTS + pg_trgm:
-- Trigram similarity on title catches typos; ts_rank handles body relevanceSELECT id, title, ts_rank(search_vector, to_tsquery('simple', $1)) AS fts_rank, similarity(title, $1) AS trgm_scoreFROM postsWHERE search_vector @@ to_tsquery('simple', $1) OR title % $1ORDER BY (ts_rank(search_vector, to_tsquery('simple', $1)) + similarity(title, $1)) DESCLIMIT 10;多言語コンテンツに FTS + unaccent:
-- Strip diacritical marks so "José" matches "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_refreshBEFORE INSERT OR UPDATE OF title, body ON postsFOR 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_refreshBEFORE INSERT OR UPDATE OF name ON usersFOR 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, nameFROM usersWHERE name_search % unaccent($1)ORDER BY similarity(name_search, unaccent($1)) DESCLIMIT 10;トリガーの例は生成カラムやインデックス式の中で unaccent() を使わないようにしている。PostgreSQL の不変性ルールが重要になるからだ。unaccent() を独自の不変関数でラップする場合は、アップグレード/設定リスクを受け入れていることをドキュメント化すること。
注目に値する拡張
pg_trgm はほとんどの Postgres ディストリビューションに同梱されているが、明示的な有効化が必要。Postgres におけるあいまい文字列マッチングの基盤。
unaccent はインデックス作成とクエリの前に発音記号を除去する。ヨーロッパ言語のコンテンツにおいて pg_trgm と FTS の両方と相性がよい。Postgres に同梱。
pg_bigm はトライグラムアプローチをバイグラム(2文字スライス)に拡張し、pg_trgm が性能を発揮しにくい CJK(中国語、日本語、韓国語)言語で結果を大幅に改善する。別途インストールが必要。同梱されていない。
pg_search(ParadeDB 製)は標準の GIN / tsvector スタックを Tantivy ベースの BM25 インデックスに置き換える。BM25 スコアリング(多くの場合 ts_rank より優れる)、FTS クエリ内でのあいまいマッチング、ファセット検索、大規模テーブルでの劇的に高速なインデックス作成が得られる。標準 FTS がランキングや性能の限界を示し始めたときのドロップインアップグレードパスだ。
-- pg_search: BM25 full-text search with fuzzy matchingCREATE INDEX posts_bm25_idx ON posts USING bm25 (id, title, body) WITH (key_field = 'id', text_fields = '{"title": {}, "body": {}}');
-- Query with BM25 scoring + fuzzy matching (catches "javascipt")SELECT id, title, paradedb.score(id) AS rankFROM postsWHERE posts @@@ paradedb.fuzzy_phrase(field => 'title', value => 'postgres performnce')ORDER BY rank DESCLIMIT 10;pgvector は密ベクトルの保存と類似度検索を追加する。ユーザーが名前ではなく欲しいものを説明するとき——セマンティック検索、RAG、関連コンテンツレコメンデーション、多言語クエリ——に適したツールだ。セマンティックベクトル検索とハイブリッド戦略で詳細を扱っている。
意思決定表
| 検索対象 | 推奨 |
|---|---|
| 記事、ドキュメント、チケット | FTS |
| タイプミスを含む人名・会社名 | pg_trgm |
| オートコンプリート、プレフィックス検索 | pg_trgm |
| 短いコード、識別子 | pg_trgm |
| ログメッセージのキーワード | FTS |
| 国際的な名前 | pg_trgm + unaccent |
| 大規模コンテンツ、より良いランキング | pg_search(ParadeDB BM25) |
| 主キー、正確なメール、ID | B-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 ランクとコサイン距離は異なる通貨であり、算術的に組み合わせることはできない。
-- Hybrid search: FTS + pgvector merged with RRFWITH 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_scoreFROM fts FULL JOIN vec ON fts.id = vec.idORDER BY rrf_score DESCLIMIT 10;ブランチあたりの 60 ドキュメントの候補プール(LIMIT 60)は一般的な出発点だ。再現率が低い場合は広げ、速度を求める場合は狭める。
次に
Postgres テキスト検索は多くの領域をカバーするが、天井がある。ユーザーが名前ではなく欲しいものを説明するとき——「飛行機で寝るのに役立つもの」「新人エンジニアのデバッグ自信に関する記事」——語彙検索もトライグラム検索も失敗する。
それはベクトル埋め込み、セマンティック検索、ハイブリッドアーキテクチャの領域だ。セマンティックベクトル検索とハイブリッド戦略で扱っている。