-- Exemplu de căutare hibridă folosind RRF (Reciprocal Rank Fusion) în Postgres
WITH vector_search AS (
SELECT id, title,
row_number() OVER (ORDER BY embedding <=> $1) AS rank
FROM documents
ORDER BY embedding <=> $1
LIMIT 50
),
text_search AS (
SELECT id, title,
row_number() OVER (ORDER BY ts_rank_cd(fts_vector, plainto_tsquery('romanian', $2)) DESC) AS rank
FROM documents
WHERE fts_vector @@ plainto_tsquery('romanian', $2)
LIMIT 50
)
SELECT
COALESCE(v.id, t.id) AS doc_id,
COALESCE(v.title, t.title) AS title,
-- k=60 este standardul recomandat pentru RRF
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;Dacă faci RAG, probabil te-ai lovit deja de faza în care căutarea semantică pură dă chix pe termeni preciși, cum ar fi coduri de produse, ID-uri sau denumiri exacte de branduri. Am pățit asta pe un proiect cu peste 120.000 de documente tehnice, unde userii căutau chestii ultra-specifice gen „eroare 404 pe modulul X”. Embeddings-urile de la OpenAI pur și simplu ignorau specificitatea codurilor și returnau rezultate generale despre erori HTTP.
Soluția nu a fost să mai adaug o bază de date de vectori dedicată (încă o chestie de sincronizat și mentenanță în plus), ci să rămân în Postgres și să configurez un hybrid search solid folosind pgvector și Full-Text Search-ul nativ.
De ce eșuează embeddings-urile singure
Embeddings-urile (cum sunt cele generate de text-embedding-3-small de la OpenAI, cu 1536 de dimensiuni) sunt excelente pentru a înțelege contextul conceptual. Dacă cauți „cum repar scurgerea de apă”, vor returna documente despre instalații sanitare chiar dacă cuvântul „repar” nu apare explicit.
Dar au o problemă majoră de granularitate. Când userul caută exact piesa „supapă-34B”, distanța cosinus (cosine similarity) s-ar putea să prioritizeze „supapă-34A” doar pentru că sunt extrem de apropiate în spațiul vectorial. Pentru un sistem de producție, asta e rețeta perfectă ca userul să primească informații greșite.
Setup-ul de pgvector cu HNSW
Pentru a asigura performanța la cele peste 120k de documente, indexul implicit ivfflat nu a fost de ajuns deoarece acuratețea scădea destul de mult la modificări frecvente de date. Am mers pe un index HNSW (Hierarchical Navigable Small World).
Un trade-off sincer pe care trebuie să-l știi: indexul HNSW mănâncă foarte mult RAM. La 120k de vectori de dimensiune 1536, indexul a ocupat instant în jur de 1.4 GB RAM doar ca să poată face căutarea rapid. Dacă nu ai destul RAM alocat pentru shared_buffers în Postgres, o să înceapă să scrie pe disc și performanța se prăbușește.
Magia hibridă: Reciprocal Rank Fusion (RRF)
Ca să combinăm cele mai bune lumi (căutarea semantică din pgvector și căutarea exactă din keyword search - BM25 style), folosim un algoritm numit Reciprocal Rank Fusion (RRF).
Ideea din spatele RRF este simplă: rulăm ambele căutări separat, le clasificăm rezultatele de la 1 la N, iar apoi le combinăm scorurile folosind o formulă matematică simplă: 1 / (k + rank). Valoarea standard pentru constanta k este 60. Această abordare normalizează scorurile, indiferent de scalele complet diferite folosite de distanța cosinus și de algoritmul de text-search din Postgres (ts_rank_cd).
În codul de mai jos, am implementat o interogare SQL curată care face exact acest lucru printr-un query hibrid de tip CTE. Pe proiectul nostru, asta a crescut relevanța primelor 3 rezultate returnate cu peste 40%.
Compromisul de care nu scrie în tutoriale
Postgres este genial pentru că ai ACID, ai datele relaționale și vectorii în același loc, iar consistența e garantată. Dar nu e glonțul de argint.
Dacă ai de gând să scalezi la peste 5-10 milioane de documente cu update-uri în timp real la vectori, Postgres va începe să gâfâie la rebuild-ul indecșilor HNSW. Generarea indexului blochează resurse masive. În plus, parserul de text search nativ din Postgres (tsvector) nu este la fel de flexibil pe limba română sau pe corectarea automată a greșelilor de tipar cum este Elasticsearch.
Pentru proiecte medii și mari (până în câteva milioane de rânduri), însă, simplitatea de a avea totul într-o singură bază de date bate orice alt stack complex.
Voi cum gestionați căutarea hibridă în producție? Mergeți pe all-in-one în Postgres sau preferați să mențineți o infrastructură separată cu Qdrant/Pinecone și Elasticsearch?