高度な類似性検索: PostgreSQL のアルゴリズムと最適化
何百万ものベクトルを含むデータベースがあり、クエリに最も類似した 10 個を見つける必要がある場合 50 ミリ秒未満、 類似性検索 エンジニアリング上の課題となる 深刻な。正確な検索 (ブルートフォース) は正確ですが、スケーリングされません。各ベクトルをそれぞれのベクトルと比較します。 クエリの複雑さは O(n*d) で、n はベクトルの数、d は次元です。 1000万で 1536 次元ベクトルの場合、各クエリには 150 億の浮動小数点演算が必要になります。
解決策はアルゴリズムです ANN (近似最近傍):彼らはあきらめます O(log n) または O(1) で正確な結果を見つけて答えを得ることが保証されています。 クッション性があり、実用精度は 95 ~ 99% です。 pgvector を使用した PostgreSQL は 2 つを実装します 最も一般的な ANN アルゴリズム: ニューサウスウェールズ州 e IVFフラット.
この記事では、これらのアルゴリズムがどのように機能するのか、またいつ使用するのかを詳しく分析します。 それぞれ、PostgreSQL で類似検索クエリを最適化する方法、検索を組み合わせる方法 メタデータ フィルターを備えたベクトル、および多様な結果を実現する MMR などの高度な技術。
シリーズ概要
| # | アイテム | 集中 |
|---|---|---|
| 1 | ベクター | インストール、オペレーター、インデックス作成 |
| 2 | 埋め込みの詳細 | モデル、距離、世代 |
| 3 | PostgreSQL を使用した RAG | エンドツーエンドの RAG パイプライン |
| 4 | あなたはここにいます - 類似性検索 | アルゴリズムと最適化 |
| 5 | HNSW および IVFFlat | 高度なインデックス作成戦略 |
| 6 | 本番環境の RAG | スケーラビリティとパフォーマンス |
何を学ぶか
- 完全検索と近似最近傍 (ANN) の違い
- HNSW の内部動作: スモールワールドのナビゲート可能なグラフ
- IVFFlat の仕組み: クラスタリングとプローブ検索
- HNSW と IVFFlat とブルート フォースを使用する場合
- クエリの最適化: ef_search およびプローブのパラメーター
- ハイブリッド フィルター: メタデータ フィルターとベクトル検索を組み合わせます。
- 距離演算子: コサイン、L2、および内積の比較
- Python コードを使用したベンチマークとリコールの測定
- 多様な結果に対する最大周辺関連性 (MMR)
- セマンティック検索とキーワード検索: いつ何を使用するか
完全検索と近似検索
ブルートフォース (完全一致検索)
完全一致検索では、クエリをデータベース内のすべての単一ベクトルと比較します。 100% の再現率が保証されますが、直線的な複雑さがあります。そして正しい選択だけが 小規模なデータセットの場合、または絶対精度が交渉の余地のない要件の場合:
-- Ricerca esatta: nessun indice utilizzato, scan completo
-- Utile per dataset piccoli (< 100K vettori) o quando la precisione e critica
SELECT id, content, embedding <=> query_vec AS distance
FROM documents
ORDER BY embedding <=> query_vec -- scansione sequenziale su tutto il dataset
LIMIT 10;
-- Per forzare il brute force anche quando esiste un indice ANN:
SET enable_indexscan = off;
SELECT id, content, embedding <=> query_vec AS distance
FROM documents
ORDER BY embedding <=> query_vec
LIMIT 10;
SET enable_indexscan = on; -- ripristina
-- Benchmark comparativo (dataset reale su SSD NVMe):
-- Dataset: 1M vettori, 1536 dim, PostgreSQL 16, 32GB RAM, 16 CPU
-- Brute force (seq scan): ~2000ms per query -- 2 secondi!
-- HNSW (ef_search=40): ~10ms per query -- 200x più veloce
-- HNSW (ef_search=100): ~25ms per query -- 80x più veloce
-- IVFFlat (probes=50): ~28ms per query -- 71x più veloce
-- Dataset 100K vettori (brute force accettabile):
-- Brute force: ~50ms per query -- accettabile
-- HNSW: ~3ms per query -- ancora meglio se disponibile
ANN: リコールとパフォーマンスのトレードオフ
-- Verifica se la query sta usando l'indice ANN
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT id, content, embedding <=> '[0.1, 0.2, ...]'::vector AS distance
FROM documents
ORDER BY embedding <=> '[0.1, 0.2, ...]'::vector
LIMIT 10;
-- Output desiderato (usa l'indice HNSW):
-- Index Scan using documents_embedding_hnsw on documents
-- Index Cond: (embedding <=> '...'::vector)
-- Limit: 10
-- Buffers: shared hit=347 (tutto da cache = ottimo!)
-- -> Execution Time: 8.3 ms
-- Output indesiderato (brute force):
-- Seq Scan on documents
-- Sort Key: (embedding <=> '...'::vector) -- ordina dopo scan completo
-- Buffers: shared hit=234 read=8921 (molti disk read = lento!)
-- -> Execution Time: 2341 ms
-- MOTIVO per cui PostgreSQL non usa l'indice:
-- 1. LIMIT troppo grande (>10% delle righe per tabelle piccole)
-- 2. Le statistiche sono vecchie: ANALYZE documents;
-- 3. L'indice non esiste: verifica con \d documents
-- 4. enable_indexscan = off accidentalmente
pgvector の距離演算子
pgvector は 3 つの距離演算子をサポートしており、それぞれが異なるタイプの埋め込みに適しています。 間違った演算子を選択すると、検索の品質が大幅に低下する可能性があります。
| オペレーター | タイプ | 範囲 | いつ使用するか | インデックスのサポート |
|---|---|---|---|---|
<=> |
コサイン距離 | [0、2] | テキスト埋め込み (OpenAI、Sentence Transformers) - デフォルト | HNSW、IVFFlat (vector_cosine_ops) |
<-> |
ユークリッド(L2)距離 | [0, inf) | 有意な大きさのベクトル (画像、音声) | HNSW、IVFFlat (vector_l2_ops) |
<#> |
負の内積 | (-inf, 0] | 事前正規化埋め込み、最大内積検索 | HNSW、IVFFlat (vector_ip_ops) |
-- Cosine distance (più comune per text embeddings):
SELECT id, content,
embedding <=> query_vec AS cosine_distance,
1 - (embedding <=> query_vec) AS cosine_similarity
FROM documents
ORDER BY embedding <=> query_vec
LIMIT 5;
-- L2 distance (euclidea, per immagini/audio):
SELECT id, content,
embedding <-> query_vec AS l2_distance
FROM documents
ORDER BY embedding <-> query_vec
LIMIT 5;
-- Inner product (per embedding normalizzati):
SELECT id, content,
(embedding <#> query_vec) * -1 AS inner_product -- negato per avere più alto = più simile
FROM documents
ORDER BY embedding <#> query_vec -- ORDER BY funziona perchè <#> e negativo
LIMIT 5;
-- Crea indice con l'operatore corretto (DEVE corrispondere alla query!):
-- Per cosine distance (default per text):
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);
-- Per L2 distance:
CREATE INDEX ON documents USING hnsw (embedding vector_l2_ops);
-- Per inner product:
CREATE INDEX ON documents USING hnsw (embedding vector_ip_ops);
-- ERRORE COMUNE: indice con vector_cosine_ops ma query con <->
-- Risultato: PostgreSQL ignora l'indice e fa brute force!
HNSW: 階層型ナビゲート可能なスモールワールド
HNSW は、2025 年から 2026 年にかけて最も人気のある ANN アルゴリズムです。という理論に基づいています。 スモールワールドグラフ: すべてのノードがどこからでも到達できる構造 いくつかのステップでさらに大きくなります (「6 次の分離」現象)。階層構造 を使用すると、ベクトル空間の最も関連性の高い領域にすばやく移動できます。 上位レベルは「高速道路」、基本レベルは精密研究用です。
ニューサウスウェールズ州の仕組み
HNSW は階層レベル構造を構築します。
- レベル 0 (基本): すべてのベクトルは、最も近いものに接続されます。緻密なグラフ。
- より高いレベル (1、2、...): ベクトルのサブセットが徐々に小さくなります。高速ナビゲーションのためのまばらなグラフ。
- エントリーポイント: 検索は常に最高レベル (より少ないベクトル) から開始され、ターゲットに向かって貪欲に下降します。
-- Struttura HNSW concettuale:
--
-- Livello 2: o-----------o-----------o (pochi nodi, salti lunghi - navigazione veloce)
-- \ | /
-- Livello 1: o---o---o---o---o---o---o (navigazione intermedia)
-- \ | | /
-- Livello 0: o-o-o-o-o-o-o-o-o-o-o-o (tutti i vettori, ricerca precisa)
--
-- Algoritmo di ricerca per query Q:
-- 1. Inizia dal livello più alto con un entry point fisso
-- 2. Greedy descent: scendi verso il nodo più vicino a Q a ogni livello
-- 3. Usa quel nodo come entry point per il livello inferiore
-- 4. Ripeti fino al livello 0
-- 5. Al livello 0: beam search con ef_search candidati (candidati esplorati)
-- 6. Restituisci i top-k tra i candidati esplorati
-- Complessità teorica:
-- Build: O(n * log(n)) - sub-lineare nel numero di vettori
-- Query: O(log(n)) - logaritmica! vs O(n) del brute force
-- Creazione indice HNSW (valori ottimali per la maggior parte dei casi):
CREATE INDEX documents_hnsw_idx
ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (
m = 16, -- connessioni per nodo (default 16, range 4-64)
ef_construction = 64 -- candidati durante costruzione (default 64, range 16-200)
);
-- Impostazione ef_search (runtime, senza rebuild):
SET hnsw.ef_search = 60; -- valore raccomandato per produzione RAG
HNSW パラメータ: インタラクティブな概要
| パラメータ | 説明 | デフォルト | 範囲 | 効果増加 |
|---|---|---|---|---|
m |
レベルごとのノードあたりの最大接続数 | 16 | 4-64 | リコールが向上し、メモリが増加し、ビルドが遅くなります |
ef_construction |
構築中に検討される候補 | 64 | 16-200 | リコールの可能性が向上し、ビルドが大幅に遅くなる |
ef_search |
クエリ中 (実行時) に考慮される候補 | 40 | 1-ef_construction | 再現性が向上し、クエリが遅くなります |
-- Configurazione ef_search per query (parametro runtime)
SET hnsw.ef_search = 40; -- default, buon equilibrio
-- Alta precisione (RAG enterprise, medico, legale):
SET hnsw.ef_search = 100;
-- Alta velocità (autocomplete, recommendation):
SET hnsw.ef_search = 20;
-- Benchmark indicativo (1M vettori, 1536 dim, m=16, ef_construction=64):
-- ef_search=20: ~5ms/query, recall@10 ~85%
-- ef_search=40: ~10ms/query, recall@10 ~92%
-- ef_search=100: ~25ms/query, recall@10 ~97%
-- ef_search=200: ~50ms/query, recall@10 ~99%
-- Imposta a livello di sessione (non persiste tra sessioni):
SELECT set_config('hnsw.ef_search', '100', false);
-- Imposta per la sessione corrente (equivalente):
SET hnsw.ef_search = 100;
IVFFlat: フラット圧縮による反転ファイル
IVFFlat は別のアプローチを使用します。ベクトル空間をクラスター (セル) に分割します。 K 平均法を使用し、クエリ中に最も有望なクラスターのみを検索します。 HNSWよりもシンプル ただし、K 平均法を実行するには既存のデータが必要であり、挿入により劣化が早くなります。
IVFFlat の仕組み
- トレーニング段階: K-means クラスタリングはベクトルを次のように分割します。
lists重心 - 構築フェーズ: 各ベクトルは最も近いクラスター (重心) に割り当てられます。
- クエリ: 私を見つけてください
probesクエリに最も近いクラスターを検索し、それらのクラスター内を正確に検索します。
-- IVFFlat richiede dati prima di creare l'indice
-- (ha bisogno di fare K-means clustering)
-- PREREQUISITO: la tabella deve avere almeno qualche centinaio di righe
-- Regola pratica: lists = sqrt(n_rows), con minimo 100
-- Creazione indice IVFFlat
CREATE INDEX documents_ivfflat_idx
ON documents
USING ivfflat (embedding vector_cosine_ops)
WITH (
lists = 100 -- numero di cluster (default 100, raccomandato: sqrt(n_rows))
);
-- Per 1M righe: lists = sqrt(1000000) = 1000
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops) WITH (lists = 1000);
-- Configurazione probes (quanti cluster cercare a query time)
SET ivfflat.probes = 10; -- default, cerca 10 cluster su 1000
-- Alta precisione:
SET ivfflat.probes = 50; -- cerca 50/1000 cluster = 5% dello spazio
-- Benchmark indicativo (1M vettori, lists=1000):
-- probes=5: ~3ms/query, recall@10 ~72%
-- probes=10: ~6ms/query, recall@10 ~82%
-- probes=30: ~18ms/query, recall@10 ~94%
-- probes=100: ~60ms/query, recall@10 ~99%
-- IVFFlat vs HNSW a parita di recall ~92%:
-- IVFFlat (probes=50): ~28ms - più lento!
-- HNSW (ef_search=40): ~10ms - più veloce
-- Conclusione: per recall alta, HNSW e solitamente più efficiente
HNSW 対 IVFFlat: 選択ガイド
| 特性 | ニューサウスウェールズ州 | IVFフラット |
|---|---|---|
| クエリのレイテンシ | 高速化 (多くの場合、同じリコールの 2 ~ 5 倍) | リコールが高いと遅くなる |
| ビルド時間 | 遅い (K 平均法は必要ありませんが、構築がより複雑になります) | はるかに高速 (約 4 倍) |
| インデックスメモリ | メジャー (~2~4x) | マイナー |
| 同等のレイテンシでリコール | より良い(一般的に) | やや低め |
| インクリメンタルインサート | 優れています (再トレーニングなし) | 劣化: クラスターは再調整されません |
| ビルドに必要なデータ | なし (空白から開始できます) | 既存のデータが必要です (K 平均法) |
| 典型的なデータセット | 増大するデータセット、高精度、RAG | 静的データセット、高速ビルドを優先 |
選択のための実践的なルール
- HNSW を使用する ほとんどの場合、リコールが向上し、クエリが高速になり、挿入が適切に処理されます。これは、RAG システムの 90% にとって正しい選択です。
- IVFFlat を使用する 場合: 数十億のベクトルのデータセットがあり、HNSW 用のメモリが不十分な場合、または完全に変更されたデータに対してインデックスを頻繁に再構築する必要がある場合。
- ブルート フォースを使用する (インデックスなし) 場合: キャリア数が 50,000 未満であるか、100% 保証されたリコールが必要な場合 (法的調査、コンプライアンスなど)。
ハイブリッド フィルターを使用したクエリの最適化
製造における最も一般的な問題の 1 つは、以下の組み合わせです。 メタデータフィルター con la ベクトル検索。 pgvector 0.7+ は反復スキャンでこれを処理します。 ただし、次の問題を回避するには、クエリを正しく構成する方法を理解することが重要です。 ポストフィルタリング。
ポストフィルタリングの問題と解決策
-- SBAGLIATO: post-filtering - l'ANN trova i top-k globali, poi filtra
-- Se i top-k risultati ANN non soddisfano il filtro, ottieni meno di k risultati!
SELECT id, content, embedding <=> query_vec AS dist
FROM documents
ORDER BY embedding <=> query_vec
LIMIT 10
-- Il problema: se questi 10 risultati non soddisfano source_type='pdf',
-- non ottieni 10 risultati PDF ma potenzialmente 0!
-- CORRETTO: pre-filtering in pgvector con iterative scan
-- pgvector 0.7+ supporta indexed scan con filtri WHERE
SELECT id, content, embedding <=> query_vec AS dist
FROM documents
WHERE source_type = 'pdf' -- pre-filter: applicato PRIMA dell'ANN
AND language = 'en'
ORDER BY embedding <=> query_vec -- ANN su subset filtrato
LIMIT 10;
-- EXPLAIN verifica che usi l'indice con il filtro:
EXPLAIN (ANALYZE, FORMAT TEXT)
SELECT id, content, embedding <=> '[...]'::vector AS dist
FROM documents
WHERE source_type = 'pdf'
ORDER BY embedding <=> '[...]'::vector
LIMIT 10;
-- Dovrebbe mostrare: "Index Scan using ... with Filter: (source_type = 'pdf')"
-- Se vedi Seq Scan, il filtro potrebbe ridurre troppo il dataset
-- In quel caso, un indice parziale e la soluzione migliore
頻度の高いフィルターの部分インデックス
-- Se filtri sempre per source_type, crea un indice parziale dedicato
-- Vantaggi: molto più piccolo e veloce dell'indice globale
CREATE INDEX documents_hnsw_pdf
ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64)
WHERE source_type = 'pdf'; -- solo i documenti PDF
CREATE INDEX documents_hnsw_recent
ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64)
WHERE created_at > NOW() - INTERVAL '30 days'; -- solo documenti recenti
-- Query che sfruttano automaticamente gli indici parziali
SELECT id, content, embedding <=> query_vec AS dist
FROM documents
WHERE source_type = 'pdf' -- attiva documents_hnsw_pdf automaticamente
ORDER BY embedding <=> query_vec
LIMIT 10;
-- Confronto dimensioni: indice parziale vs totale
SELECT
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS size,
ROUND(pg_relation_size(indexrelid)::numeric /
(SELECT pg_relation_size(indexrelid) FROM pg_stat_user_indexes
WHERE indexname = 'documents_hnsw_total') * 100, 1) AS pct_of_full
FROM pg_stat_user_indexes
JOIN pg_class ON pg_class.relname = pg_stat_user_indexes.indexname
WHERE tablename = 'documents';
-- documents_hnsw_total: 2.1 GB (100%)
-- documents_hnsw_pdf: 487 MB (23% - solo i PDF)
類似性しきい値を使用したクエリ
-- Filtra per distanza minima: evita risultati irrilevanti
-- cosine distance < 0.4 significa cosine similarity > 0.6
SELECT
id,
source_path,
content,
1 - (embedding <=> query_vec::vector) AS similarity,
embedding <=> query_vec::vector AS distance
FROM documents
WHERE
embedding <=> query_vec::vector < 0.4 -- max distance (= min 60% similarity)
ORDER BY embedding <=> query_vec::vector
LIMIT 10;
-- Conversione distanza/similarità per cosine:
-- cosine_distance = 1 - cosine_similarity
-- distance 0.0 = similarity 1.0 (vettori identici)
-- distance 0.2 = similarity 0.8 (molto simili)
-- distance 0.4 = similarity 0.6 (sufficientemente simili per RAG)
-- distance 0.7 = similarity 0.3 (probabilmente non rilevante)
-- distance 1.0 = similarity 0.0 (non correlati)
-- distance 2.0 = similarity -1.0 (opposti)
-- Threshold raccomandati per use case:
-- RAG documenti tecnici: < 0.35 (similarity > 0.65)
-- FAQ answering: < 0.30 (similarity > 0.70)
-- Product recommendation: < 0.50 (similarity > 0.50)
-- Duplicate detection: < 0.10 (similarity > 0.90)
セマンティック検索と類似性検索
これらの用語はしばしば同じ意味で使用されますが、重要な違いがあります。 これはシステム アーキテクチャに影響します。
| タイプ | 客観的 | Esempio | メトリック |
|---|---|---|---|
| 類似性検索 | 指定されたベクトルに近いベクトルを見つける | この画像に類似した画像 | 距離 L2、コサイン |
| セマンティック検索 | テキストクエリと同様の意味を持つドキュメントを検索します | 「PostgreSQL をインストールするにはどうすればよいですか?」その正確なフレーズが含まれていないガイドを見つける | テキスト埋め込みのコサイン類似度 |
| キーワード検索 | キーワードの完全一致 | 「PostgreSQL 16」はその文字列を含むドキュメントのみを検索します | TF-IDF、BM25 |
| ハイブリッド検索 | セマンティクスとキーワードを組み合わせる | 意味上の関連性と専門用語の完全一致のバランスをとる | RRF、加重和 |
セマンティック検索の完全な実装
import psycopg2
from openai import OpenAI
client = OpenAI()
def semantic_search(
conn,
query: str,
top_k: int = 10,
source_type: str = None,
min_similarity: float = 0.6,
include_metadata: bool = True
) -> list[dict]:
"""
Ricerca semantica con filtri opzionali e threshold di qualità.
Args:
conn: Connessione psycopg2 a PostgreSQL
query: La domanda o testo da cercare
top_k: Numero di risultati da restituire
source_type: Filtra per tipo documento ('pdf', 'md', 'html')
min_similarity: Soglia minima di similarità coseno (0.0-1.0)
include_metadata: Se includere il campo metadata JSONB
Returns:
Lista di dict con id, source_path, content, similarity, metadata
"""
# 1. Genera embedding della query
resp = client.embeddings.create(
input=[query.replace("\n", " ")],
model="text-embedding-3-small"
)
query_vec = resp.data[0].embedding
max_distance = 1 - min_similarity # converti similarity -> distance
# 2. Costruisci query SQL dinamica con filtri opzionali
params = [query_vec, max_distance]
filter_clauses = ["embedding <=> %s::vector < %s"]
if source_type:
filter_clauses.append("source_type = %s")
params.append(source_type)
where = " AND ".join(filter_clauses)
metadata_col = "metadata" if include_metadata else "NULL::jsonb"
sql = f"""
SELECT
id,
source_path,
source_type,
chunk_index,
title,
content,
1 - (embedding <=> %s::vector) AS similarity,
{metadata_col} AS metadata
FROM documents
WHERE {where}
ORDER BY embedding <=> %s::vector
LIMIT %s
"""
params_final = [query_vec] + params + [query_vec, top_k]
with conn.cursor() as cur:
cur.execute(sql, params_final)
rows = cur.fetchall()
return [
{
"id": r[0],
"source_path": r[1],
"source_type": r[2],
"chunk_index": r[3],
"title": r[4],
"content": r[5],
"similarity": round(r[6], 4),
"metadata": r[7]
}
for r in rows
]
# Uso
conn = psycopg2.connect("postgresql://postgres:pass@localhost/vectordb")
results = semantic_search(
conn,
query="Come ottimizzare query PostgreSQL per dataset grandi",
top_k=5,
source_type="pdf",
min_similarity=0.65
)
for r in results:
print(f"[{r['similarity']:.3f}] {r['title']} - {r['content'][:100]}...")
ハイブリッド検索: ベクトルとフルテキストの組み合わせ
PostgreSQL for RAG の大きな強みの 1 つは、RAG を 1 つに結合できることです。 従来の全文検索 (BM25 のような) を使用したクエリ セマンティック (ベクトル) 検索。これ 固有名詞などの正確な専門用語を含むクエリに特に役立ちます。 コード、ソフトウェアのバージョン。
-- Hybrid search puro SQL: vector + full-text con Reciprocal Rank Fusion (RRF)
-- RRF Score = sum(1/(k + rank)) per ogni lista risultati
WITH vector_results AS (
-- Ricerca semantica: top 20 per similarità vettoriale
SELECT
id, content, source_path,
ROW_NUMBER() OVER (ORDER BY embedding <=> $1::vector) AS v_rank,
1 - (embedding <=> $1::vector) AS vector_score
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 20
),
fts_results AS (
-- Ricerca full-text: top 20 per rilevanza ts_rank
SELECT
id, content, source_path,
ROW_NUMBER() OVER (
ORDER BY ts_rank(to_tsvector('italian', content),
plainto_tsquery('italian', $2)) DESC
) AS f_rank,
ts_rank(to_tsvector('italian', content),
plainto_tsquery('italian', $2)) AS fts_score
FROM documents
WHERE to_tsvector('italian', content) @@ plainto_tsquery('italian', $2)
LIMIT 20
),
rrf_combined AS (
-- RRF fusion: combina i due rank
SELECT
COALESCE(v.id, f.id) AS id,
COALESCE(v.content, f.content) AS content,
COALESCE(v.source_path, f.source_path) AS source_path,
-- Peso: 70% vector + 30% full-text
COALESCE(0.7 / (60.0 + v.v_rank), 0) +
COALESCE(0.3 / (60.0 + f.f_rank), 0) AS rrf_score,
v.vector_score,
f.fts_score
FROM vector_results v
FULL OUTER JOIN fts_results f ON v.id = f.id
)
SELECT id, content, source_path, ROUND(rrf_score::numeric, 6) AS score
FROM rrf_combined
ORDER BY rrf_score DESC
LIMIT 5;
ベンチマーク: 研究の質を測定する
import time
import psycopg2
from statistics import mean, quantiles
def benchmark_vector_search(conn, query_vectors: list, config: dict) -> dict:
"""
Esegui benchmark di latenza su un set di query vettoriali.
Args:
conn: Connessione PostgreSQL
query_vectors: Lista di vettori query (1536 dimensioni)
config: Dict con ef_search o probes da impostare
Returns:
Dict con metriche di latenza (p50, p95, p99, mean)
"""
# Imposta parametri ANN per questo benchmark
with conn.cursor() as cur:
if "ef_search" in config:
cur.execute(f"SET hnsw.ef_search = {config['ef_search']}")
if "probes" in config:
cur.execute(f"SET ivfflat.probes = {config['probes']}")
latencies = []
for query_vec in query_vectors:
start = time.perf_counter()
with conn.cursor() as cur:
cur.execute("""
SELECT id, embedding <=> %s::vector AS dist
FROM documents
ORDER BY embedding <=> %s::vector
LIMIT 10
""", (query_vec, query_vec))
cur.fetchall()
elapsed_ms = (time.perf_counter() - start) * 1000
latencies.append(elapsed_ms)
p_values = quantiles(latencies, n=100)
return {
"config": config,
"n_queries": len(query_vectors),
"p50_ms": round(p_values[49], 2),
"p95_ms": round(p_values[94], 2),
"p99_ms": round(p_values[98], 2),
"mean_ms": round(mean(latencies), 2),
"min_ms": round(min(latencies), 2),
"max_ms": round(max(latencies), 2)
}
# Confronta diverse configurazioni HNSW
configs = [
{"name": "HNSW ef=20 (fast)", "ef_search": 20},
{"name": "HNSW ef=40 (default)", "ef_search": 40},
{"name": "HNSW ef=100 (precise)", "ef_search": 100},
]
# Usa query di test reali (embedding generati con lo stesso modello dei documenti)
test_queries = [...] # lista di vettori 1536-dim
print(f"Benchmark con {len(test_queries)} query di test:")
for cfg in configs:
result = benchmark_vector_search(conn, test_queries[:100], cfg)
print(f"{cfg['name']}:")
print(f" p50={result['p50_ms']}ms, p95={result['p95_ms']}ms, p99={result['p99_ms']}ms")
リコール測定: ANN品質をチェックする
def measure_recall_at_k(conn, query_vectors: list, k: int = 10) -> float:
"""
Misura Recall@K confrontando ANN con brute force.
Recall@K = (risultati ANN corretti) / k
Un Recall@10 di 0.95 significa che l'ANN trova
9.5 dei 10 risultati esatti su media.
Nota: usa un campione di 50-100 query per stabilità statistica.
"""
total_recall = 0.0
for query_vec in query_vectors:
# Risultati esatti (brute force) - ground truth
with conn.cursor() as cur:
cur.execute("SET enable_indexscan = off")
cur.execute("""
SELECT id FROM documents
ORDER BY embedding <=> %s::vector
LIMIT %s
""", (query_vec, k))
exact_ids = set(r[0] for r in cur.fetchall())
cur.execute("SET enable_indexscan = on")
# Risultati ANN (con indice HNSW/IVFFlat)
with conn.cursor() as cur:
cur.execute("""
SELECT id FROM documents
ORDER BY embedding <=> %s::vector
LIMIT %s
""", (query_vec, k))
ann_ids = set(r[0] for r in cur.fetchall())
# Recall = overlap / k
overlap = len(exact_ids & ann_ids)
total_recall += overlap / k
avg_recall = total_recall / len(query_vectors)
print(f"Recall@{k}: {avg_recall:.4f} ({avg_recall*100:.1f}%)")
return avg_recall
# Target di recall per diversi use case:
# Recall@10 > 0.90 per RAG generale
# Recall@10 > 0.95 per RAG enterprise (medico, legale)
# Recall@10 > 0.85 accettabile per autocomplete/recommendation
# Con HNSW m=16, ef_construction=64, ef_search=40: tipicamente 0.92-0.95
# Con HNSW m=16, ef_construction=64, ef_search=100: tipicamente 0.97-0.99
高度な最適化: クエリプランニング
-- 1. Statistiche aggiornate: fondamentale per buone query plans
ANALYZE documents;
-- 2. Verifica dimensione e statistiche dell'indice HNSW
SELECT
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
idx_scan AS times_used,
idx_tup_read AS tuples_read,
idx_tup_fetch AS tuples_fetched,
ROUND(idx_tup_fetch::numeric / NULLIF(idx_scan, 0), 1) AS avg_tuples_per_scan
FROM pg_stat_user_indexes
WHERE tablename = 'documents'
ORDER BY idx_scan DESC;
-- 3. Parametri di memoria per performance ottimale
SET maintenance_work_mem = '512MB'; -- per BUILD dell'indice HNSW (temporaneo)
SET work_mem = '64MB'; -- per query sort operations
-- 4. Parallel query per dataset grandi
SET max_parallel_workers_per_gather = 4; -- usa 4 worker per query
-- 5. Verifica che shared_buffers sia adeguato
-- L'indice HNSW deve stare in memoria per performance ottimale
SHOW shared_buffers;
-- Regola pratica: shared_buffers = 25% della RAM
-- pg_relation_size(indice HNSW) deve essere << shared_buffers
-- 6. Monitora cache hit ratio per la tabella
SELECT
relname,
heap_blks_read AS disk_reads,
heap_blks_hit AS cache_hits,
ROUND(heap_blks_hit::numeric / NULLIF(heap_blks_read + heap_blks_hit, 0) * 100, 2)
AS cache_hit_pct
FROM pg_statio_user_tables
WHERE relname = 'documents';
-- Target: cache_hit_pct > 95% per performance ottimale
-- 7. Verifica indici non utilizzati (candidati per DROP):
SELECT indexname, idx_scan
FROM pg_stat_user_indexes
WHERE tablename = 'documents'
AND idx_scan = 0
AND indexname NOT LIKE '%pkey%'; -- escludi primary keys
最大周辺関連性 (MMR)
類似性検索でよくある問題は、 結果の冗長性: 私 上位 k 個のチャンクはすべて互いに非常に類似しており、同じ情報をカバーします。これ モデルが同じ情報を繰り返し受信するため、RAG コンテキストの品質が低下します。 MMR は、次のチャンクを段階的に選択することで、関連性と多様性のバランスをとります。 クエリに関連していますが、すでに選択されているものとは異なります。
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
def maximal_marginal_relevance(
query_vec: list[float],
candidate_vecs: list[list[float]],
candidate_data: list[dict],
k: int = 5,
lambda_param: float = 0.5
) -> list[dict]:
"""
MMR: seleziona k risultati bilanciando rilevanza e diversità.
Algoritmo:
1. Seleziona sempre il candidato più rilevante per la query
2. Per ogni successivo candidato, usa MMR score che penalizza
la ridondanza rispetto ai già selezionati
lambda_param:
1.0 = solo rilevanza (equivale a top-k standard)
0.0 = solo diversità (massima varieta)
0.5 = bilanciamento ottimale per RAG (default)
0.6 = leggermente più rilevanza rispetto diversità
Returns:
Lista di k candidati diversificati e rilevanti
"""
query_array = np.array(query_vec).reshape(1, -1)
candidate_array = np.array(candidate_vecs)
# Similarità query-candidato (rilevanza)
query_sims = cosine_similarity(query_array, candidate_array)[0]
selected = []
selected_indices = []
remaining_indices = list(range(len(candidate_vecs)))
while len(selected) < k and remaining_indices:
if not selected:
# Prima iterazione: scegli il più rilevante
best_idx = int(np.argmax(query_sims))
else:
# Iterazioni successive: MMR score
selected_array = candidate_array[selected_indices]
mmr_scores = []
for i in remaining_indices:
relevance = query_sims[i]
# Massima similarità con i già selezionati (ridondanza)
max_redundancy = float(np.max(
cosine_similarity(candidate_array[i:i+1], selected_array)[0]
))
# MMR bilancia rilevanza e ridondanza
mmr = lambda_param * relevance - (1 - lambda_param) * max_redundancy
mmr_scores.append((i, mmr))
best_idx = max(mmr_scores, key=lambda x: x[1])[0]
selected.append(candidate_data[best_idx])
selected_indices.append(best_idx)
remaining_indices.remove(best_idx)
return selected
# Uso con risultati PostgreSQL
# Prima recupera più candidati (top-20), poi applica MMR per top-5 diversificati
chunks_with_vecs = searcher.vector_search_with_embeddings(query, top_k=20)
embeddings = [c['embedding'] for c in chunks_with_vecs]
data = [{'id': c['id'], 'content': c['content']} for c in chunks_with_vecs]
diverse_chunks = maximal_marginal_relevance(
query_vec=query_embedding,
candidate_vecs=embeddings,
candidate_data=data,
k=5,
lambda_param=0.6 # leggermente orientato alla rilevanza
)
# Risultato: 5 chunk rilevanti MA con contenuti diversi tra loro
類似性検索でよくある間違い
- ANN がアクティブ化されていない: EXPLAIN で Seq Scan が表示される場合は、LIMIT がテーブルのサイズに対して妥当であること、インデックスが存在すること、統計が最新であること (ANALYZE) を確認してください。
- ef_search が低すぎる: ef_search=10 では、Recall@10 は 70 ~ 75% しか得られません。運用環境では、RAG には少なくとも 40、できれば 60 ~ 100 を使用します。
- 結果をクリアするポストフィルタリング: ORDER BY の後に (事前フィルタリングなしで) WHERE フィルタを適用すると、ANN はグローバル ks を返してからフィルタリングし、結果が 0 になる可能性があります。常にプレフィルタを使用してください。
- 間違った距離演算子: テキストの埋め込みに <=> (コサイン) の代わりに <-> (L2) を使用すると、検索品質が大幅に低下します。インデックス内の操作のタイプがクエリ内の演算子と一致することを確認します。
- 類似性のしきい値なし: 距離が非常に長い場合でも、すべての上位 K を返すと、RAG で無関係な応答が返されます。
- 冗長性を無視します。 上位 5 つのチャンクはすべて、文書内の同じ文をカバーしている可能性があります。 RAG 応答の多様性のために MMR を使用します。
結論と次のステップ
PostgreSQL の類似性検索は、設定するパラメータが多数あるフィールドです。鍵
そして、基本的なトレードオフを理解します。 検討する候補が多ければ多いほど、再現率は高くなります
しかしレイテンシは高くなります。実稼働環境のほとんどの RAG システムでは、HNSW
と ef_search=60-100 10 ミリ秒と 50 ミリ秒の遅延の間で優れたバランスを提供します
Recall@10 は 92 ~ 97% です。
次の記事でさらに詳しく説明します 高度なインデックス作成戦略: 特定の使用例に最適な HNSW および IVFFlat パラメータを選択する方法、方法 時間の経過に伴うインデックスの劣化と増分更新の手法を監視する パフォーマンスを損なうことなく。含まれるもの: 完全な PostgreSQL セットアップ、並列インデックス構築、 ダウンタイムなしで REINDEX をスケジュールする方法について説明します。







