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 は埋め込みインフラなしでその意図をネイティブに処理する。
外部依存なしのランキング結果。 FTS インデックスは高速で決定論的であり、API 呼び出しを必要としない。関連性シグナルはフィールド位置で重み付けされた用語頻度から得られる。
検索と並行するブールフィルタリング。 FTS は既存のクエリロジックと自然に合成される:
SELECT * FROM postsWHERE 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 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」
- オートコンプリート / 接頭辞検索
- 部分文字列一致(「son」が「Johnson」「Anderson」に一致)
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 scoreFROM usersWHERE name % $1 -- % 演算子 = 類似度閾値(デフォルト 0.3)ORDER BY score DESCLIMIT 10;% 演算子は pg_trgm.similarity_threshold(デフォルト 0.3、範囲 0–1)を使用する。名前検索では、0.3–0.4 がタイポを拾いつつノイズを低く保つ。
オートコンプリート、接頭辞、包含検索
-- オートコンプリートのための前方一致。トライグラム GIN インデックスは役立つが、-- 純粋な左固定の接頭辞には B-tree パターンインデックスの方が適している場合がある。SELECT name FROM usersWHERE name ILIKE $1 || '%'ORDER BY nameLIMIT 10;
-- 長い文字列内の部分一致のための word_similarity-- ("Andrew Johnson III" 内の "Johnson")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);
-- 正確なルックアップ:高速で曖昧さがない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_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 で国際的なコンテンツ:
-- 発音区別符号を除去し、「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_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 全文検索とあいまい一致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 rankFROM postsWHERE posts @@@ paradedb.fuzzy_phrase(field => 'title', value => 'postgres performnce')ORDER BY rank DESCLIMIT 10;pgvector は密ベクトルの保存と類似性検索を追加する。ユーザーが名前を挙げるのではなく欲しいものを説明する場面——セマンティック検索、RAG、関連コンテンツのレコメンデーション、多言語クエリ——に適したツールだ。Semantic Vector Search and Hybrid Strategies で詳しく扱っている。
意思決定表
| 検索対象 | 推奨 |
|---|---|
| 記事、ドキュメント、チケット | 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 テキスト検索は多くの領域をカバーするが、天井がある。ユーザーが名前ではなく欲しいものを説明するとき——「飛行機で寝るのに役立つもの」「新人エンジニアのデバッグ自信に関する記事」——語彙検索もトライグラム検索も失敗する。
それはベクトル埋め込み、セマンティック検索、ハイブリッドアーキテクチャの領域だ。セマンティックベクトル検索とハイブリッド戦略 で扱っている。