WITH vector_search AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> $1) as rank
FROM documents
ORDER BY embedding <=> $1
LIMIT 20
),
text_search AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank_cd(fts, plainto_tsquery('romanian', $2)) DESC) as rank
FROM documents
WHERE fts @@ plainto_tsquery('romanian', $2)
ORDER BY rank DESC
LIMIT 20
)
SELECT
COALESCE(v.id, t.id) AS document_id,
COALESCE(1.0 / (60 + v.rank), 0.0) + COALESCE(1.0 / (60 + t.rank), 0.0) AS rrf_score
FROM vector_search v
FULL OUTER JOIN text_search t ON v.id = t.id
ORDER BY rrf_score DESC
LIMIT 10;Am trecut recent un sistem RAG cu 350k de documente de la Pinecone înapoi în Postgres folosind pgvector și Full Text Search. Dacă datasetul tău e sub 5-10 milioane de vectori, adăugarea unui vector DB dedicat e de multe ori o complicație inutilă de arhitectură. În postarea asta îți arăt cum să faci căutare hibridă direct în Postgres și la ce capcane să fii atent în producție.
De ce e greșit să te bazezi doar pe Vector Search
RAG-ul pur pe bază de embeddings are o problemă masivă pe care am lovit-o violent în producție: ignoră potrivirile exacte.
Dacă userul caută "eroare ERR_SYS_4092", modelul de embeddings (de exemplu text-embedding-3-small) va returna paragrafe despre erori de sistem în general, concepte similare, dar poate rata fix documentul care conține codul exact "ERR_SYS_4092". Căutarea semantică înțelege intenția, dar e complet oarbă la ID-uri, coduri de eroare, nume de variabile sau SKU-uri de produse.
Aici intră în scenă FTS (Full Text Search) clasic, bazat pe algoritmul BM25. Când le combini, obții acuratețe maximă.
Cum am structurat Hybrid Search în Postgres
Postgres are deja suport excelent pentru FTS prin tsvector și tsquery. Cu extenșia pgvector, adăugăm o coloană de tip vector(1536) și un index HNSW.
Trucul pentru căutarea hibridă curată este algoritmul RRF (Reciprocal Rank Fusion). În loc să încerci să aduni scorul de cosinus (care e între 0 și 1) cu scorul ts_rank (care depinde de lungimea documentului și nu are o limită superioară), le clasifici separat și combini pozițiile lor în rezultate:
RRF_Score = 1 / (60 + Rank_vector) + 1 / (60 + Rank_text)
Metoda asta e extrem de stabilă și nu necesită "magic numbers" de ponderare ajustate manual în fiecare săptămână.
Rezultate concrete și latență
Pe instanța noastră de RDS (PostgreSQL 16, 4 vCPU, 16GB RAM):
- Pinecone + FTS separat în Elasticsearch: 180ms - 220ms per query (două decolări în rețea + fuziune în Node.js).
- Hybrid search direct în Postgres: 38ms - 45ms.
Consumul de memorie a crescut cu aproximativ 2.1 GB pentru indexul HNSW, dar am scăpat de un serviciu extern și de problemele de sincronizare asincronă a datelor.
Trade-off-uri reale (unde e nasol)
Nu totul e lapte și miere. Iată de ce trebuie să ții cont înainte să faci mutarea:
- Rebuild-ul indexului HNSW: Crearea indexului pe Postgres consumă enorm CPU și RAM. La 350k vectori, un
CREATE INDEXcum=16, ef_construction=64a blocat scrierile grele pe tabelă câteva minute bune până am crescutmax_parallel_workers. - RAM-ul e rege: Spre deosebire de IVFFlat care e mai iertător, HNSW trebuie să stea aproape integral în RAM/
shared_bufferspentru latențe sub 50ms. Dacă RAM-ul se umple, queries cad pe disk și latența sare la 800ms+. - Limita de dimensiuni:
pgvectorare o limită pe dimensiunile vectorilor în funcție de index (deși 1536/3072 pentru OpenAI funcționează perfect, verificați dacă folosiți modele open-source exotice).
Voi cum faceți retrieval-ul în producție acum? Ați rămas pe vector DB-uri dedicate sau ați migrat totul în Postgres?