WITH vector_matches AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> $1) AS rank_vec
FROM documents
ORDER BY embedding <=> $1 LIMIT 40
),
text_matches AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank_cd(text_vector, websearch_to_tsquery('romanian', $2)) DESC) AS rank_text
FROM documents
WHERE text_vector @@ websearch_to_tsquery('romanian', $2)
ORDER BY rank_text LIMIT 40
)
SELECT
COALESCE(v.id, t.id) AS document_id,
COALESCE(1.0 / (60 + v.rank_vec), 0.0) + COALESCE(1.0 / (60 + t.rank_text), 0.0) AS rrf_score
FROM vector_matches v
FULL OUTER JOIN text_matches t ON v.id = t.id
ORDER BY rrf_score DESC LIMIT 10;Anul trecut am mutat un pipeline de RAG cu peste 180.000 de documente tehnice de pe o bază de date dedicată (Qdrant) direct în Postgres, folosind extensia pgvector. Am vrut să simplific arhitectura și să reduc costurile de infrastructură. Am scăzut factura de AWS cu aproape 35%, dar ne-am lovit repede de o problemă supărătoare în producție.
Vectorii sunt fantastici pe semantică, dar sunt groaznici la meciuri exacte pe șiruri de caractere. Dacă un utilizator căuta o eroare specifică precum ERR_502_BAD_GATEWAY sau un cod de piesă XF-902-B, cosinusul dintre vectori returna articole generice despre rețea sau piese similare. Rata de recall pe interogări exacte scăzut-a dramatic sub 60%.
Problema cu căutarea vectorială pură
Modelele de embeddings (de la text-embedding-3-small până la modele open-source precum bge-m3) comprimă tot sensul unui text într-o reprezentare densă de 1536 de dimensiuni. În procesul ăsta de compresie, tokenii rari sau identificatorii unici își pierd identitatea exactă.
Textul devine un punct într-un spațiu vectorial. Dacă query-ul conține un UUID, un email sau un serial number, distanța cosinus va aduce la suprafață documente "similare ca stil", dar care nu conțin deloc codul respectiv. În aplicații enterprise sau e-commerce, asta înseamnă halucinații garantate din partea LLM-ului.
Soluția: Căutare Hibridă (HNSW + tsvector)
Nu e nevoie să aduci Elasticsearch sau Typesense în ecuație doar pentru asta. Postgres are un motor excelent de Full-Text Search (FTS) bazat pe BM25/BM15 și un index GIN foarte rapid. Combinând căutarea vectorială (folosind indici HNSW) cu cea lexicală (tsvector) într-o singură interogare, obții ce e mai bun din ambele lumi.
Pentru a combina cele două clasamente complet diferite (distanța cosinus între 0 și 2 vs. scorul de rank FTS care e nelimitat), cea mai stabilă abordare pe care am testat-o este Reciprocal Rank Fusion (RRF). Formula e simplă: calculezi o poziție pentru vectori, o poziție pentru text și le aduni inversul plus o constantă (de obicei k=60).
Ce am învățat și trade-off-uri reale
Performanța e excelentă dacă configurezi indicii corect, dar există niște compromisuri clare:
- Consum de RAM: Un index HNSW (
vector_cosine_ops) pe 180k vectori cum=16șief_construction=64ocupă în jur de 1.4 GB de memorie. Dacă indexul nu încape complet în RAM, latența sare de la 18ms la peste 300ms per interogare fiindcă Postgres începe să citească de pe disk. - Complexitate la scriere: La fiecare
INSERTsauUPDATE, Postgres trebuie să actualizeze atât indexul GIN cât și structura grafului HNSW. Viteza de ingestie a scăzut cu aproximativ 25% față de un tabel simplu. - Tuning pe constanta RRF: Constanta
kdin formula RRF influențează puternic dacă favorizezi căutarea lexicală sau pe cea semantică. După vreo două săptămâni de A/B testing cu un set de date de test, am rămas lak=60pentru query-uri generale șik=20când utilizatorii scriau coduri scurte.
Voi cum gestionați căutarea hibridă în aplicațiile voastre de RAG? Folosiți tot Postgres nativ sau ați preferat o bază de date dedicată doar pentru vectori?