postgres-hybrid-text-search について
PostgreSQL で BM25 のキーワード検索と pgvector のセマンティック検索を組み合わせるガイドです。Reciprocal Rank Fusion、重み付け、任意の再ランキング、ハイブリッド検索の調整方法を説明します。
主な機能
- 01BM25 とベクトル検索の併用
- 02RRF によるランキング統合
- 03キーワードと意味検索の重み調整
- 04候補結果の任意の再ランキング
- 05インデックスとクエリ性能の確認
活用シーン
- 01語句と意図の両方に合う文書の検索
- 02完全一致と意味検索結果のバランス調整
- 03検索候補の再ランキング
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ timescale/ pg-aiguide --skill postgres-hybrid-text-searchインストール後、Claude Code で/postgres-hybrid-text-searchを入力して読み込まれたか確認します。
詳細
ハイブリッドテキスト検索
ハイブリッド検索では、キーワード検索(BM25)とセマンティック検索(ベクトル埋め込み)を組み合わせ、両方の利点である正確なキーワード一致と意味に基づく検索を実現します。Reciprocal Rank Fusion(RRF)を使って、両方の方法の結果を1つのランキングリストに統合します。
このガイドでは、pg_textsearch(BM25)とpgvectorの組み合わせを説明します。両方の拡張機能が必要です。大量データを扱う構成、フィルタリング、pgvector の高度なチューニング(バイナリ量子化、HNSW パラメーター)については、pgvector-semantic-search スキルを参照してください。
pg_textsearch は PostgreSQL 用の新しい BM25 テキスト検索拡張機能です。完全なオープンソースで、Tiger Cloud のホステッド環境とセルフマネージド環境の両方で利用できます。真の BM25 ランキングを提供し、多くの場合、PostgreSQL 組み込みの ts_rank より関連性が向上し、大規模環境でより優れたパフォーマンスを実現できます。注:pg_textsearch は現在プレリリース段階であり、本番環境での使用はまだ推奨されていません。pg_textsearch は現在、PostgreSQL 17 と 18 をサポートしています。
ハイブリッド検索を使用する場面
- ハイブリッドを使用:クエリに具体的な用語(製品名、コード、固有名詞)と概念的な意図が混在している場合
- セマンティック検索のみを使用:正確な表現よりも意味が重要な場合(例:「遅いクエリを修正する方法」は「クエリ最適化」に一致する必要があります)
- キーワード検索のみを使用:完全一致が重要な場合(例:エラーコード、SKU、法的引用)
ハイブリッド検索では、通常、どちらか一方のみを使う場合より再現率が向上しますが、複雑さはやや増します。
データの準備
ドキュメントを小さな単位(通常は500~1000トークン)に分割し、各チャンクを埋め込みとともに保存します。BM25 とセマンティック検索はどちらも同じチャンクを対象とするため、同じもの同士を比較でき、結果の統合が簡単になります。
基本手順(デフォルト設定)
-- Enable extensions
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_textsearch;
-- Table with both indexes
CREATE TABLE documents (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
content TEXT NOT NULL,
embedding halfvec(1536) NOT NULL
);
-- BM25 index for keyword search
CREATE INDEX ON documents USING bm25 (content) WITH (text_config = 'english');
-- HNSW index for semantic search
CREATE INDEX ON documents USING hnsw (embedding halfvec_cosine_ops);
BM25 に関する注意事項
- 負のスコア:
<@>演算子は負の値を返し、値が小さいほど一致度が高くなります。RRF は順位を使うため、これは結果の統合に影響しません。 - 言語設定:
text_configをコンテンツの言語に合わせて変更します(例:'french'、'german')。PostgreSQL テキスト検索設定を参照してください。 - チューニング:BM25 には、
k1(用語頻度の飽和度、デフォルトは1.2)とb(長さの正規化、デフォルトは0.75)のパラメーターがあります。デフォルト設定で適切に動作するため、関連性が低い場合にのみ調整してください。CREATE INDEX ON documents USING bm25 (content) WITH (text_config = 'english', k1 = 1.5, b = 0.8); - パーティションテーブル:各パーティションはローカル統計情報を保持します。スコアはパーティション間で直接比較できません。スコアの比較可能性が重要な場合は、個々のパーティションに対してクエリを実行してください。
RRF クエリパターン
Reciprocal Rank Fusion は、複数の検索結果のランキングを統合します。各結果のスコアは 1 / (k + rank) です。ここで k は定数(通常は60)です。検索ごとにスコアを合計し、並べ替えます。
レイテンシを抑えるには、クライアントから両方のクエリを並列実行し、その後クライアント側で結果を統合します。
-- Query 1: Keyword search (BM25)
-- $1: search text
SELECT id, content FROM documents ORDER BY content <@> $1 LIMIT 50;
-- Query 2: Semantic search (separate query, run in parallel)
-- $1: embedding of your search text as halfvec(1536)
SELECT id, content FROM documents ORDER BY embedding <=> $1::halfvec(1536) LIMIT 50;
# Client-side RRF fusion (Python)
def rrf_fusion(keyword_results, semantic_results, k=60, limit=10):
scores = {}
content_map = {}
for rank, row in enumerate(keyword_results, start=1):
scores[row['id']] = scores.get(row['id'], 0) + 1 / (k + rank)
content_map[row['id']] = row['content']
for rank, row in enumerate(semantic_results, start=1):
scores[row['id']] = scores.get(row['id'], 0) + 1 / (k + rank)
content_map[row['id']] = row['content']
sorted_ids = sorted(scores, key=scores.get, reverse=True)[:limit]
return [{'id': id, 'content': content_map[id], 'score': scores[id]} for id in sorted_ids]
// Client-side RRF fusion (TypeScript)
type Row = { id: number; content: string };
type Result = Row & { score: number };
function rrfFusion(keywordResults: Row[], semanticResults: Row[], k = 60, limit = 10): Result[] {
const scores = new Map<number, number>();
const contentMap = new Map<number, string>();
keywordResults.forEach((row, i) => {
scores.set(row.id, (scores.get(row.id) ?? 0) + 1 / (k + i + 1));
contentMap.set(row.id, row.content);
});
semanticResults.forEach((row, i) => {
scores.set(row.id, (scores.get(row.id) ?? 0) + 1 / (k + i + 1));
contentMap.set(row.id, row.content);
});
return [...scores.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([id, score]) => ({ id, content: contentMap.get(id)!, score }));
}
RRF パラメーター
| パラメーター | デフォルト | 説明 |
|---|---|---|
k | 60 | 平滑化定数です。値を大きくすると順位の差が小さくなります。60 が標準です |
| 検索ごとの候補数 | 50 | 値を大きくすると再現率が向上しますが、処理量も増えます |
| 最終件数 | 10 | 結果の統合後に返される件数です |
関連する結果が見つからない場合は、候補数を増やしてください。k=60 の定数を調整する必要はほとんどありません。
キーワード検索とセマンティック検索の重み付け
一方の方法を優先するには、その方法の RRF 寄与分に重みを掛けます。
# Weight semantic search 2x higher than keyword
keyword_weight = 1.0
semantic_weight = 2.0
for rank, row in enumerate(keyword_results, start=1):
scores[row['id']] = scores.get(row['id'], 0) + keyword_weight / (k + rank)
for rank, row in enumerate(semantic_results, start=1):
scores[row['id']] = scores.get(row['id'], 0) + semantic_weight / (k + rank)
// Weight semantic search 2x higher than keyword
const keywordWeight = 1.0;
const semanticWeight = 2.0;
keywordResults.forEach((row, i) => {
scores.set(row.id, (scores.get(row.id) ?? 0) + keywordWeight / (k + i + 1));
});
semanticResults.forEach((row, i) => {
scores.set(row.id, (scores.get(row.id) ?? 0) + semanticWeight / (k + i + 1));
});
まずは両方の重みを同じ(1.0)に設定し、測定した関連性に基づいて調整してください。
ML モデルによる再ランク付け
最高品質を得るには、クロスエンコーダーモデルを使った再ランク付けのステップを追加します。クロスエンコーダー(例:cross-encoder/ms-marco-MiniLM-L-6-v2)はバイエンコーダーより精度が高い一方、初期検索には遅すぎるため、候補セットに対してのみ使用してください。
上記と同じ並列クエリを、より大きな LIMIT(例:100)で実行し、次に:
# 1. Fuse results with RRF (more candidates for reranking)
candidates = rrf_fusion(keyword_results, semantic_results, limit=100)
# 2. Rerank with cross-encoder
from sentence_transformers import CrossEncoder
reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
pairs = [(query_text, doc['content']) for doc in candidates]
scores = reranker.predict(pairs)
# 3. Return top 10 by reranker score
reranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)[:10]
import { CohereClientV2 } from 'cohere-ai';
// 1. Fuse results with RRF (more candidates for reranking)
const candidates = rrfFusion(keywordResults, semanticResults, 60, 100);
// 2. Rerank via API (example uses Cohere SDK; Jina, Voyage, and others work similarly)
const cohere = new CohereClientV2({ token: COHERE_API_KEY });
const reranked = await cohere.rerank({
model: 'rerank-v3.5',
query: queryText,
documents: candidates.map(c => c.content),
topN: 10
});
// 3. Map back to original documents
const results = reranked.results.map(r => candidates[r.index]);
再ランク付けは任意です。ハイブリッド RRF だけでも、単一方式の検索より大幅に改善します。
パフォーマンスに関する考慮事項
- 両方の列にインデックスを作成する:テキストには BM25 インデックス、埋め込みには HNSW インデックスを作成します
- 候補プールを制限する:各方式で通常 50~100 件の候補があれば十分です
- クエリを並列実行する:クライアント側で並列化すると、逐次実行に比べてレイテンシーを低減できます
- レイテンシーを監視する:ハイブリッド検索ではオーバーヘッドが増えるため、両方のインデックスがメモリに収まることを確認してください
pgvectorscale によるスケーリング
大規模なデータセット(ベクトルが 1,000 万件以上)や、選択性の高いメタデータフィルターを使用するワークロードでは、セマンティック検索コンポーネントに HNSW の代わりに pgvectorscale の StreamingDiskANN インデックスを検討してください。
StreamingDiskANN を使用する場合:
- HNSW がメモリに収まらない大規模なデータセット
- ラベル(例:tenant_id、category、tags)でフィルターするクエリ
- 高性能なフィルター付きベクトル検索が必要な場合
ラベルベースのフィルタリング: StreamingDiskANN は、smallint[] 型のラベル列に対するフィルター付きインデックスをサポートしています。ラベルはベクトルとともにインデックス化されるため、後処理のフィルタリングによる精度低下なしに、効率的なフィルター付き検索が可能です。
-- Enable pgvectorscale (in addition to pgvector)
CREATE EXTENSION IF NOT EXISTS vectorscale;
-- Table with label column for filtering
CREATE TABLE documents (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
content TEXT NOT NULL,
embedding halfvec(1536) NOT NULL,
labels smallint[] NOT NULL -- e.g., category IDs, tenant IDs
);
-- StreamingDiskANN index with label filtering
CREATE INDEX ON documents USING diskann (embedding vector_cosine_ops, labels);
-- BM25 index for keyword search
CREATE INDEX ON documents USING bm25 (content) WITH (text_config = 'english');
-- Filtered semantic search using && (array overlap)
SELECT id, content FROM documents
WHERE labels && ARRAY[1, 3]::smallint[]
ORDER BY embedding <=> $1::halfvec(1536) LIMIT 50;
フィルター付きインデックスとチューニングパラメーターの詳細については、pgvectorscale のドキュメントを参照してください。
監視とデバッグ
-- Force index usage for verification (planner may prefer seqscan on small tables)
SET enable_seqscan = off;
-- Verify BM25 index is used
EXPLAIN SELECT id, content FROM documents ORDER BY content <@> 'search text' LIMIT 10;
-- Look for: Index Scan using ... (bm25)
-- Verify HNSW index is used
EXPLAIN SELECT id, content FROM documents ORDER BY embedding <=> '[0.1, 0.2, ...]'::halfvec(1536) LIMIT 10;
-- Look for: Index Scan using ... (hnsw)
SET enable_seqscan = on; -- Re-enable for normal operation
-- Check index sizes
SELECT indexname, pg_size_pretty(pg_relation_size(indexname::regclass)) AS size
FROM pg_indexes WHERE tablename = 'documents';
enable_seqscan = off にしても EXPLAIN で逐次スキャンが表示される場合は、インデックスが存在することと、クエリで正しい演算子(BM25 には <@>、コサイン類似度には <=>)が使われていることを確認してください。pgvector のデバッグに関する詳しいガイダンスについては、pgvector-semantic-search スキルを参照してください。
よくある問題
| 症状 | 考えられる原因 | 対処方法 |
|---|---|---|
| 完全一致が見つからない | キーワード検索で該当結果が返されない | BM25 インデックスが存在することを確認し、text_config がコンテンツの言語と一致していることを確認します |
| セマンティック検索の結果が悪い | 埋め込みモデルの不一致 | クエリの埋め込みに、保存済みの埋め込みと同じモデルが使われていることを確認します |
| クエリが遅い | 候補プールが大きい、またはインデックスがない | 内側の LIMIT を小さくし、両方のインデックスが存在して使用されていることを確認します(EXPLAIN) |
| 結果に偏りがある | 一方の方式が優勢になっている | RRF の重みを調整し、両方の検索で妥当な数の候補が返されることを確認します |