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でランクを統合。クエリの意図から始め、テキスト形状を確認する
クエリの意図(正確 vs. セマンティック)とテキスト形状(構造化 vs. 文章)でマッピングされた4つの Postgres 検索プリミティブ。同じテーブルに4つのインデックスすべてを配置できる——選択はテーブルごとではなくクエリごとに行う。

全文検索が勝つ場面

キーワードで文章を検索する。 ブログ記事、ドキュメント、製品説明、サポートチケット、法務文書。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 のセットアップ

-- Generated column keeps the index current automatically
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);
-- Query
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);
-- Finds "Micheal Jordan" when searching "Michael Jordan"
SELECT id, name, similarity(name, $1) AS score
FROM users
WHERE name % $1 -- % operator = similarity threshold (default 0.3)
ORDER BY score DESC
LIMIT 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 users
WHERE name ILIKE $1 || '%'
ORDER BY name
LIMIT 10;
-- word_similarity for partial matches within longer strings
-- ("Johnson" within "Andrew Johnson III")
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);
-- Exact lookup: fast and unambiguous
SELECT id, name FROM users WHERE email = $1;

より広い教訓:正解がある問題にあいまい検索を使うのはカテゴリのエラーだ。何か を返す——そしてそれは自信を持って間違っているかもしれない。


これらのツールの組み合わせ

これらのツールはきれいに組み合わせられる。ひとつだけを選ぶ必要はない。

キーワードのタイプミスに耐性のある検索ボックスに FTS + pg_trgm:

-- Trigram similarity on title catches typos; ts_rank handles body relevance
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

-- 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_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 full-text search with fuzzy matching
CREATE 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 rank
FROM posts
WHERE posts @@@ paradedb.fuzzy_phrase(field => 'title', value => 'postgres performnce')
ORDER BY rank DESC
LIMIT 10;

pgvector は密ベクトルの保存と類似度検索を追加する。ユーザーが名前ではなく欲しいものを説明するとき——セマンティック検索、RAG、関連コンテンツレコメンデーション、多言語クエリ——に適したツールだ。セマンティックベクトル検索とハイブリッド戦略で詳細を扱っている。


意思決定表

検索対象推奨
記事、ドキュメント、チケット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 テキスト検索は多くの領域をカバーするが、天井がある。ユーザーが名前ではなく欲しいものを説明するとき——「飛行機で寝るのに役立つもの」「新人エンジニアのデバッグ自信に関する記事」——語彙検索もトライグラム検索も失敗する。

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