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_tokens, websearch_to_tsquery('romanian', $2)) DESC) AS rank
FROM documents
WHERE fts_tokens @@ websearch_to_tsquery('romanian', $2)
LIMIT 20
)
SELECT
COALESCE(v.id, t.id) AS 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 5;Am trecut recent printr-o refactorizare de RAG pentru un client din zona de e-commerce tehnic, pe o bază cu vreo 140.000 de documente și specificații. Dacă te bazezi doar pe pgvector și cosine similarity, o să-ți prinzi urechile când userii caută coduri de piese, erori specifice sau termeni tehnici exacți. În postarea asta îți arăt cum am combinat pgvector cu Full-Text Search direct în Postgres, fără să adăugăm ElasticSearch sau Qdrant în stack.
De ce eșuează căutarea vectorială pură?
Embeddings-urile de tip text-embedding-3-small sunt grozave la semantică. Înțeleg că "baterie mașină" e similar cu "acumulator auto". Dar când un inginer a căutat ERR-4049-B, căutarea semantică i-a întors documente despre erori generice de rețea. Modelul pur și simplu a ignorat tokenul exact pentru că în spațiul vectorial acel ID nu are o reprezentare bogată.
Am avut o rată de acuratețe la RAG de doar 62% pe interogări cu ID-uri sau numere de serie. Userii erau frustrați, iar LLM-ul halucina răspunsuri pentru că contextul primit din baza de date era complet aiurea.
Soluția hibridă: pgvector + tsvector
Postgres are deja o funcționalitate de Full-Text Search matură, bazată pe BM25/BM15 concepts via tsvector. În loc să complicăm arhitectura și să adăugăm un vector database dedicat, am extins tabela existentă.
Arhitectura finală folosește trei piloni:
- O coloană de tip
vector(1536)indexată cuHNSW(am renunțat la IVFFlat pentru că HNSW oferă recall superior fără să necesite re-indexare constantă la scrieri). - O coloană
tsvectorgenerată automat pentru textul brut, indexată cuGIN. - O interogare SQL care combină scorurile folosind tehnica Reciprocal Rank Fusion (RRF).
La volumul nostru de 140k de rânduri, indexul HNSW ocupă aproximativ 1.2 GB de RAM (cu ef_construction=128), dar latența medie pe query a scăzut sub 25ms.
Cum le combini eficient (Trade-offs)
Technica RRF execută două sub-queries în paralel: unul pentru top N vectori și unul pentru top N meciuri din text search. Apoi le combină după formula 1 / (k + rank).
Câștigul e uriaș: nu mai trebuie să normalizezi scorul de cosine (care e între -1 și 1) cu scorul ts_rank (care e necalibrat). RRF folosește doar poziția în clasament (rank-ul), deci e foarte stabil.
Trade-off-ul sincer? Faci două scanări de index în aceeași interogare. Asta înseamnă un consum de CPU mai ridicat pe instanța de Postgres. La un volum de peste 150-200 QPS constante, s-ar putea să simți o presiune pe procesor și să ai nevoie de read replicas.
Totuși, am economisit câteva sute de dolari pe lună neplătind un cluster separat de Pinecone, iar Recall@5 a sărit de la 62% la 91%.
Ce index alegi pentru pgvector?
Dacă ai sub 10.000 de rânduri, nici nu-ți trebuie index, un sequential scan durează sub 5ms. La 100k+, ai de ales:
IVFFlat: timp de build rapid, amprentă mică de RAM, dar dacă inserezi date noi constant, calitatea căutării scade dramatic fărăREINDEXperiodic.HNSW: build mai lent, consumă de 2-3 ori mai mult RAM, dar oferă query-uri rapide și nu își pierde performanța când adaugi rânduri noi. Mergi pe HNSW din start.
Voi cum rezolvați problema termenilor exacți în RAG? Ați mers pe soluția hibridă din Postgres sau ați spart infrastructura în servicii dedicate?