WITH semantic_search AS (
SELECT id, row_number() OVER (ORDER BY embedding <=> $1) as rank
FROM documents
ORDER BY embedding <=> $1
LIMIT 50
),
keyword_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 50
)
SELECT
coalesce(s.id, k.id) as document_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;Nu vă mai aruncați la baze de date vectoriale dedicate cum sunt Pinecone sau Qdrant de cum auziți de RAG. Postgres cu extensia pgvector este adesea mai mult decât suficient și îți salvează o grămadă de timp cu infrastructura. Am implementat recent soluția asta pentru un sistem cu 120.000 de documente de suport tehnic și am reușit să ținem totul într-o singură bază de date, simplificând masiv backend-ul.
Totuși, dacă mergi doar pe căutare semantică (embeddings), o să te lovești rapid de o problemă frustrantă: utilizatorii caută chestii extrem de specifice, cum ar fi coduri de eroare ("ERR-404") sau denumiri exacte de componente. Embeddings-urile sunt groaznice la potriviri exacte de genul ăsta. Aici intră în scenă căutarea hibridă: combinăm pgvector cu clasicul Full-Text Search (FTS) din Postgres folosind o aproximare prin Reciprocal Rank Fusion (RRF).
Cum legăm vectorii de text search
Ideea e simplă. Pentru fiecare document, salvăm textul curat, un tsvector pentru căutarea clasică și un vector (de exemplu, de 1536 dimensiuni dacă folosești text-embedding-3-small de la OpenAI).
Când vine query-ul de la user, generăm embedding-ul pentru query și rulăm două căutări în paralel în aceeași bază de date: căutarea semantică folosind distanța cosinus (<=>) și căutarea clasică prin indexul de text. Pentru a combina rezultatele, folosim RRF. Este un algoritm matematic simplu care acordă un scor fiecărui document în funcție de poziția sa în ambele liste de rezultate. Scorul final pune în top documentele care apar sus în ambele căutări, eliminând nevoia de a normaliza manual scoruri complet diferite (cum sunt distanțele cosine vs scorurile de text search).
Compromisul de care nu-ți spune nimeni la pgvector
Sună perfect, dar există un trade-off major pe care l-am simțit pe pielea mea: memoria RAM și timpul de indexare.
Dacă folosești indexul HNSW (Hierarchical Navigable Small World) pentru căutări rapide sub 15ms, trebuie să știi că acest index se construiește și se rulează direct în memorie. La cele 120k de documente ale noastre, indexul HNSW ocupa în jur de 1.8 GB de RAM doar pentru vectori. Dacă serverul tău de Postgres este deja la limită cu resursele, riști să lovești swap-ul, iar latența query-urilor va crește instantaneu de 10-15 ori.
Un alt detaliu care m-a blocat o zi întreagă: setarea maintenance_work_mem în Postgres. Implicit, valoarea e foarte mică (deseori 64MB). Când vrei să construiești un index HNSW pe vectori mari de 1536 de dimensiuni, trebuie să urci valoarea asta la cel puțin 1GB sau chiar 2GB, altfel indexarea va dura ore întregi sau va crăpa direct cu o eroare de Out of Memory.
Concluzia mea
Postgres cu pgvector și FTS este un monstru de productivitate pentru 90% din cazurile de RAG din producție. Îți recomand să eviți complexitatea unei baze de date vectoriale dedicate până când nu treci de pragul de câteva milioane de documente sau ai bugete mari de infrastructură.
Voi cum gestionați căutarea hibridă? Ați încercat RRF direct în SQL sau preferați să faceți fuziunea scorurilor în codul de backend (Node/Python)?