WITH semantic_search AS (
SELECT id, 1 - (embedding <=> :query_embedding::vector) as similarity
FROM medical_documents
ORDER BY embedding <=> :query_embedding::vector
LIMIT 30
),
text_search AS (
SELECT id, ts_rank_cd(to_tsvector('romanian', content), query) as text_rank
FROM medical_documents, plainto_tsquery('romanian', :query_text) query
WHERE to_tsvector('romanian', content) @@ query
ORDER BY text_rank DESC
LIMIT 30
)
SELECT
COALESCE(s.id, t.id) as document_id,
(COALESCE(s.similarity, 0) * 0.7) + (COALESCE(t.text_rank, 0) * 0.3) as hybrid_score
FROM semantic_search s
FULL OUTER JOIN text_search t ON s.id = t.id
ORDER BY hybrid_score DESC
LIMIT 10;Am tot văzut hype-ul cu baze de date vectoriale dedicate gen Pinecone sau Qdrant pentru RAG. În practică, dacă ai deja datele în Postgres, să adaugi încă o bază de date doar pentru embeddings e de multe ori o bătaie de cap administrativă inutilă. Am trecut recent prin asta la un proiect cu vreo 150.000 de documente medicale și am decis să mergem all-in pe Postgres cu pgvector.
Setup-ul și de ce HNSW te poate pune în cap
Când ai zeci de mii de vectori de dimensiune mare (noi am folosit modelul text-embedding-3-small de la OpenAI, cu 1536 de dimensiuni), o simplă căutare exactă prin cosine similarity (operatorul <=>) devine extrem de lentă. Devine practic un sequential scan pe toată tabela. Ca să rezolvăm asta, am trecut la un index HNSW (Hierarchical Navigable Small World).
Aici vine primul trade-off sincer pe care trebuie să-l înțelegi: indexarea HNSW mănâncă memorie RAM de rupe la build time. La prima încercare de reindexare pe o instanță de test cu 4GB RAM, baza de date ne-a dat crash instant (Out of Memory). Am fost nevoiți să urcăm instanța la 16GB RAM și să ajustăm maintenance_work_mem serios doar ca să putem construi indexul fără downtime. Partea bună? După ce s-a construit, timpul de query a scăzut de la 450ms la sub 12ms.
De ce căutarea semantică simplă dă rateuri
Embeddings-urile sunt geniale pentru a înțelege contextul ("durere de cap" se va potrivi cu "migrenă"), dar sunt incredibil de proaste la potriviri exacte. Dacă utilizatorul caută un cod de diagnostic specific, cum ar fi „ICD-10-M54”, un model vectorial s-ar putea să returneze alte documente despre dureri de spate, ignorând fix fișa medicală care conținea acel cod exact.
Soluția pe care am implementat-o este căutarea hibridă. Combinăm similarity search-ul semantic oferit de pgvector cu full-text search-ul clasic din Postgres (tsvector și tsquery), folosind o schemă simplă de ponderare.
Query-ul de hybrid search care rulează în producție
Mai jos e query-ul pe care l-am optimizat. Folosește două Common Table Expressions (CTE) pentru a rula în paralel căutarea semantică și cea clasică pe text, apoi le combină rezultatele folosind o formulă de scor ponderat.
În query-ul de mai jos, am dat o pondere de 70% căutării vectoriale și 30% căutării clasice. Raportul ăsta l-am reglat empiric, după ce am rulat vreo 200 de query-uri de test introduse manual de medici.
Postgres s-a dovedit a fi incredibil de capabil. Totuși, dacă ai de gestionat zeci de milioane de documente sau ai nevoie de filtrare dinamică extrem de complexă în timp ce parcurgi graful HNSW, s-ar putea să lovești limitările de memorie ale Postgres-ului mult mai repede decât cu o soluție dedicată.
Voi ce folosiți pentru RAG în producție? Ați rămas pe Postgres sau ați făcut pasul spre baze de date vectoriale dedicate?