WITH semantic_search AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> $1) as rank
FROM document_chunks
ORDER BY embedding <=> $1
LIMIT 40
),
keyword_search AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank_cd(tsv_content, plainto_tsquery('romanian', $2)) DESC) as rank
FROM document_chunks
WHERE tsv_content @@ plainto_tsquery('romanian', $2)
ORDER BY rank ASC
LIMIT 40
)
SELECT
COALESCE(s.id, k.id) AS chunk_id,
(COALESCE(1.0 / (60 + s.rank), 0.0) + COALESCE(1.0 / (60 + k.rank), 0.0)) AS rrf_score
FROM semantic_search s
FULL OUTER JOIN keyword_search k ON s.id = k.id
ORDER BY rrf_score DESC
LIMIT 10;Când ai nevoie de RAG, prima tentație e să arunci banii pe Pinecone sau Qdrant. Am făcut greșeala asta anul trecut la un client cu vreo 350.000 de fragmente de contracte, până când costurile lunare au explodat și sincronizarea datelor a devenit un calvar. Soluția a fost să aducem totul înapoi în bătrânul Postgres, folosind pgvector și full-text search hibrid.
De ce pică semantic search-ul pur
Căutarea semantică doar pe bază de embeddings (cosine distance) arată spectaculos în demo-uri când pui întrebări lejere. În producție, când utilizatorul caută un număr exact de dosar, un SKU sau o eroare tehnică gen AUTH_TIMEOUT_902, vectorii încep să fabuleze și returnează documente care „sună la fel”, dar nu conțin termenul exact.
Pentru asta ai nevoie de hybrid search. În loc să complici infrastructura cu Elasticsearch plus o bază vectorială, folosești tsvector din Postgres pentru potrivire exactă de cuvinte cheie (algoritmul BM25-like din engine-ul clasic) combinat cu cosine distance (<=>) din pgvector.
Combinația o facem prin Reciprocal Rank Fusion (RRF). RRF ia primele N rezultate din căutarea text, primele N rezultate din căutarea vectorială și recalculează un scor comun pe baza poziției în clasament. Calitatea răspunsurilor pentru LLM a crescut vizibil din prima zi după ce am implementat asta.
Compromisul dur: HNSW vs IVFFlat
Mulți încep cu IVFFlat pentru că se creează rapid, dar la un volum de peste 100k vectori am măsurat latențe de peste 350ms pe query la pornirea la rece. HNSW e alegerea corectă pentru căutare rapidă: am coborât la 12-18ms latență per interogare pe vectori de 1536 dimensiuni (text-embedding-3-small de la OpenAI).
Trade-off-ul sincer? HNSW mănâncă memorie RAM fără milă și blochează resursele la creare. La cele 350k rânduri, construirea indexului HNSW a durat aproape 40 de minute pe un server cu 16GB RAM și am fost forțat să setez maintenance_work_mem = '2GB', altfel procesul pica direct cu OOM.
Dacă ai un sistem write-heavy, cu mii de update-uri pe minut, HNSW în Postgres te va durea groaznic, iar aici soluțiile dedicate gen Milvus sau Qdrant își merită banii. Dacă ai în schimb scenarii de tip read-heavy sau actualizări periodice pe batch-uri, Postgres e imbatabil.
Un singur query pentru ambele lumi
În loc să tragi rezultatele în aplicație și să faci fuziunea în Python sau Node, poți lăsa Postgres să rezolve totul într-un singur Common Table Expression (CTE). Baza scanează indexul GIN pentru text și indexul HNSW pentru vectori, apoi aplică formula clasică RRF 1 / (k + rank).
Câștigul cel mai mare nu e doar viteza, ci simplitatea operațională: ai tranzacții ACID, backup prin pg_dump și zero date orfane între baza de producție și indexul semantic.
Voi cum abordați căutarea semantică în producție? Ați simțit nevoia unei baze dedicate sau Postgres acoperă tot volumul vostru?