WITH vector_search AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> $1) AS rank
FROM documents
ORDER BY embedding <=> $1
LIMIT 40
),
text_search AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank_cd(fts_vector, websearch_to_tsquery('romanian', $2)) DESC) AS rank
FROM documents
WHERE fts_vector @@ websearch_to_tsquery('romanian', $2)
ORDER BY rank DESC
LIMIT 40
)
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 migrat anul trecut sistemul de căutare pentru o aplicație B2B cu aproximativ 140.000 de documente tehnice și manuale de mentenanță. Am scos Elasticsearch din ecuație ca să reducem din complexitate și am mers 100% pe Postgres cu extensia pgvector. A fost o decizie excelentă pentru factură — am tăiat vreo 35% din costurile de infrastructură —, dar am dat repede cu capul de pragul de sus al căutării pur semantice.
De ce pică search-ul pur vectorial în producție
Embeddings-urile (fie că folosești text-embedding-3-small de la OpenAI sau bge-m3 self-hosted) sunt fantastice pentru intenție și context general. Dacă userul caută "cum resetez parola de admin", vectorial găsești imediat secțiunea potrivită, chiar dacă în textul original scrie "procedură schimbare credentiale privilegiate".
Problema apare când userul caută coduri de eroare, SKU-uri de piese, nume de funcții din cod sau termeni specifici ca ERR_SOCKET_TIMEOUT_502. Modelele de embedding tind să netezească tokens-ii rari în spațiul vectorial. Rezultatul? Vector search-ul îți întoarce chestii conexe despre rețelistică și socket-uri, dar ratează fix documentul care conține codul exact de eroare. Aici intervine BM25 (sau varianta nativă din Postgres, tsvector).
Hybrid Search nativ: Vectorial + BM25
Ca să rezolvi problema fără să aduci Solr sau Elastic înapoi în stack, cel mai curat mod este un Hybrid Search scris direct în SQL. Combinezi o căutare vectorială bazată pe distanță Cosine (<=>) cu un full-text search clasic (websearch_to_tsquery).
Tot secretul stă în modul în care îmbini scorurile. Nu poți aduna pur și simplu scorul oferit de ts_rank cu distanța vectorială, pentru că au scări complet diferite și algoritmi diferiți de scalare. Răspunsul tehnic elegant se numește Reciprocal Rank Fusion (RRF).
Principiul RRF e simplu și robust: iei primele N rezultate din căutarea vectorială și primele N din full-text search, le dai fiecăruia un scor bazat pe poziția/rangul lor în listă (1 / (k + rank)), iar apoi faci suma scorurilor pentru documentele comune.
Cum configurezi indecșii în Postgres
Pentru partea de vectori, recomand indexul HNSW în loc de IVFFlat, mai ales dacă ai modificări frecvente în baza de date. Un setup standard arată așa:
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
Pentru full-text search, creezi o coloană generată tsvector pe care o indexezi cu GIN. Astfel, ambele căutări rulează în câțiva milisecunde.
Unde te lovește în producție (Trade-offs realiste)
Să nu crezi că totul e roz. HNSW în pgvector e incredibil de rapid (am scos latențe de sub 12ms pe vectori de 1536 dimensiuni), dar papă RAM cu lingura. Pentru un milion de documente chunk-uite, pregătește-te să aloci câțiva gigabaiți buni doar pentru indecși în shared_buffers ca să nu atingi disk-ul.
În plus, interogările hibride cu RRF folosesc CTE-uri (WITH), ceea ce înseamnă că planner-ul din Postgres nu știe întotdeauna să optimizeze perfect resursele dacă nu pui LIMIT-uri stricte în sub-selecturi înainte de JOIN.
Dacă ai sub 500k de documente și un server cu 16GB RAM, ecuația e simplă: Postgres cu pgvector și BM25 nativ e mai mult decât suficient. Scapi de coșmarul sincronizării bazei de date cu un cluster extern.
Voi ce folosiți pentru RRF în stack-ul vostru? Faceți fuziunea rangurilor direct în SQL prin CTE-uri sau aduceți top 50 din fiecare în Node/Python și le combinați în memorie?