고급 유사성 검색: PostgreSQL의 알고리즘 및 최적화
수백만 개의 벡터가 포함된 데이터베이스가 있고 다음에서 쿼리와 가장 유사한 10개를 찾아야 하는 경우 50밀리초 미만, 유사성 검색 엔지니어링 과제가 되다 심각하다. 정확한 검색(무차별 대입)은 정확하지만 확장되지는 않습니다. 각 벡터를 각각의 벡터와 비교합니다. 쿼리의 복잡성은 O(n*d)입니다. 여기서 n은 벡터 수이고 d는 차원입니다. 천만으로 1536차원 벡터 중 각 쿼리에는 150억 개의 부동 소수점 연산이 필요합니다.
해결책은 알고리즘이다 ANN(근사적인 가장 가까운 이웃): 그들은 포기한다 O(log n) 또는 심지어 O(1)의 답을 얻기 위해 정확한 결과를 찾는 것을 보장합니다. 95-99%의 실제 정확도로 쿠션 처리되어 있습니다. pgVector를 사용하는 PostgreSQL은 두 가지를 구현합니다. 가장 널리 사용되는 ANN 알고리즘: HNSW e IVFF플랫.
이 글에서는 이러한 알고리즘이 어떻게 작동하는지, 그리고 언제 사용해야 하는지 분석합니다. 각각, PostgreSQL에서 유사성 검색 쿼리를 최적화하는 방법, 검색을 결합하는 방법 메타데이터 필터가 포함된 벡터와 MMR과 같은 고급 기술을 통해 다양한 결과를 얻을 수 있습니다.
시리즈 개요
| # | Articolo | 집중하다 |
|---|---|---|
| 1 | pg벡터 | 설치, 운영자, 인덱싱 |
| 2 | 심층적인 임베딩 | 모델, 거리, 세대 |
| 3 | PostgreSQL을 사용한 RAG | 엔드투엔드 RAG 파이프라인 |
| 4 | 현재 위치 - 유사성 검색 | 알고리즘 및 최적화 |
| 5 | HNSW 및 IVFFlat | 고급 인덱싱 전략 |
| 6 | 생산 중인 RAG | 확장성 및 성능 |
무엇을 배울 것인가
- 정확한 검색과 대략적인 최근접 이웃(ANN)의 차이점
- HNSW가 내부적으로 작동하는 방식: 탐색 가능한 작은 세계 그래프
- IVFFlat 작동 방식: 클러스터링 및 프로브 검색
- HNSW, IVFFlat, 무차별 대입을 사용하는 경우
- 쿼리 최적화: ef_search 및 프로브 매개변수
- 하이브리드 필터: 메타데이터 필터링과 벡터 검색 결합
- 거리 연산자: 코사인, L2 및 내적 비교
- Python 코드를 사용한 벤치마킹 및 재현율 측정
- 다양한 결과를 위한 MMR(Maximal Marginal Relevance)
- 의미 검색과 키워드 검색: 언제 무엇을 사용할 것인가
정확한 검색과 대략적인 검색
무차별 대입(정확한 검색)
정확한 검색은 쿼리를 데이터베이스의 모든 단일 벡터와 비교합니다. 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는 세 가지 거리 연산자를 지원하며 각각은 다양한 유형의 임베딩에 적합합니다. 잘못된 연산자를 선택하면 검색 품질이 크게 저하될 수 있습니다.
| 연산자 | 유형 | 범위 | 사용 시기 | 인덱스 지원 |
|---|---|---|---|---|
<=> |
코사인 거리 | [0, 2] | 텍스트 임베딩(OpenAI, 문장 변환기) - 기본 | HNSW, IVFFlat(벡터_코사인_ops) |
<-> |
유클리드(L2) 거리 | [0, inf) | 상당한 규모의 벡터(이미지, 오디오) | HNSW, IVFFlat(벡터_l2_ops) |
<#> |
부정적인 내부 제품 | (-inf, 0] | 사전 정규화된 임베딩, 최대 내부 제품 검색 | HNSW, IVFFlat(벡터_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 작동 방식
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_건설 | 더 나은 기억력, 더 느린 쿼리 |
-- 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-평균을 수행하려면 기존 데이터가 필요하며 삽입을 통해 더 빨리 저하됩니다.
IVFFFlat 작동 방식
- 훈련 단계: K-평균 클러스터링은 벡터를 다음과 같이 나눕니다.
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: 선택 가이드
| 특성 | HNSW | IVFF플랫 |
|---|---|---|
| 쿼리 대기 시간 | 더 빠릅니다(종종 동일한 재현율의 2~5배) | 높은 재현율에서는 느려짐 |
| 빌드 시간 | 느림(K-평균은 필요하지 않지만 빌드가 더 복잡함) | 훨씬 더 빠름(~4x) |
| 인덱스 메모리 | 메이저(~2-4x) | 미성년자 |
| 동일한 대기 시간으로 회상 | 더 좋음 (일반적으로) | 약간 낮음 |
| 증분 삽입 | 우수함(재교육 없음) | 성능 저하: 클러스터가 재조정되지 않습니다. |
| 빌드에 필요한 데이터 | 없음(빈 채로 시작할 수 있음) | 기존 데이터 필요(K-평균) |
| 일반적인 데이터세트 | 데이터 세트 증가, 고정밀, RAG | 정적 데이터세트, 우선순위 빠른 빌드 |
선택을 위한 실제 규칙
- HNSW 사용 대부분의 경우 더 나은 기억력, 더 빠른 쿼리, 삽입을 잘 처리합니다. 이는 RAG 시스템의 90%에 적합한 선택입니다.
- IVFFFlat 사용 시기: 수십억 개의 벡터로 구성된 데이터 세트가 있고 HNSW용 메모리가 부족한 경우 또는 완전히 변경되는 데이터에 대해 인덱스를 자주 다시 작성해야 하는 경우.
- 무차별 대입 사용(색인 없음) 시기: 이동통신사가 50,000개 미만이거나 100% 보장된 리콜이 필요한 경우(예: 법률 조사, 규정 준수)
하이브리드 필터를 사용한 쿼리 최적화
제조 과정에서 가장 흔히 발생하는 문제 중 하나는 메타데이터 필터 와 함께 벡터 검색. 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)
의미 검색과 유사성 검색
용어는 종종 같은 의미로 사용되지만 중요한 차이점이 있습니다. which influences the system architecture:
| 유형 | 목적 | 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]}...")
하이브리드 검색: 벡터와 전체 텍스트 결합
RAG용 PostgreSQL의 가장 큰 장점 중 하나는 이를 하나로 결합할 수 있다는 것입니다. 전통적인 전체 텍스트 검색(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)
유사성 검색의 일반적인 문제는 다음과 같습니다. 결과의 중복: 나 top-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개의 결과를 제공합니다. Always use pre-filtering.
- 잘못된 거리 운영자: 텍스트 임베딩에 <=>(코사인) 대신 <->(L2)를 사용하면 검색 품질이 크게 저하됩니다. 인덱스의 작업 유형이 쿼리의 연산자와 일치하는지 확인하세요.
- 유사성 임계값 없음: 거리가 매우 높은 경우에도 모든 top-k를 반환하면 RAG에서 관련 없는 답변이 생성됩니다.
- 중복성 무시: 상위 5개 청크는 모두 문서의 동일한 문장을 포함할 수 있습니다. RAG 응답의 다양성을 위해 MMR을 사용하십시오.
결론 및 다음 단계
PostgreSQL의 유사성 검색은 구성할 매개변수가 많은 필드입니다. 열쇠
근본적인 절충점을 이해합니다. 더 많은 후보자를 조사할수록 회상률이 높아집니다.
하지만 대기 시간은 더 높습니다. 생산 중인 대부분의 RAG 시스템의 경우 HNSW
와 ef_search=60-100 10ms와 50ms 대기 시간 사이의 훌륭한 균형을 제공합니다.
Recall@10은 92~97%입니다.
다음 기사에서 더 자세히 설명하겠습니다. 고급 인덱싱 전략: 특정 사용 사례에 가장 적합한 HNSW 및 IVFFlat 매개변수를 선택하는 방법, 방법 시간 경과에 따른 인덱스 성능 저하 및 증분 업데이트 기술 모니터링 성능 저하 없이. 포함됨: 전체 PostgreSQL 설정, 병렬 인덱스 빌드, 다운타임 없이 REINDEX를 예약하는 방법.







