WITH semantic_search AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> $1) AS rank
FROM documents
ORDER BY embedding <=> $1
LIMIT 30
),
keyword_search AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank_cd(tsv, websearch_to_tsquery('romanian', $2)) DESC) AS rank
FROM documents
WHERE tsv @@ websearch_to_tsquery('romanian', $2)
ORDER BY rank
LIMIT 30
)
SELECT
COALESCE(s.id, k.id) AS id,
d.content,
(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
JOIN documents d ON d.id = COALESCE(s.id, k.id)
ORDER BY rrf_score DESC
LIMIT 5;Am aruncat Pinecone pe geam acum vreo opt luni când am refăcut pipeline-ul de RAG pentru o bază de cunoștințe de vreo 450k chunks. Postgres era deja în stack pentru auth și date relaționale, așa că adăugarea pgvector a fost o decizie de bun-simț. Totuși, vector search-ul pur vine rapid cu limitări dacă îl arunci orbește în producție.
Problema cu vector search-ul pur
Modelele de embeddings (gen text-embedding-3-small de la OpenAI sau bge-base) prind excelent intenția generală și nuanțele semantice. Dacă un utilizator caută "cum modific adresa de livrare", găsește chunk-ul relevant instant.
Probemele apar când ai chestii exacte: erori din loguri (ERR_CODE_5023), ID-uri de produse, nume proprii sau acronime tehnice de nișă. Modelul bagă token-urile respective într-o ciorbă de probabilități, iar distanța cosinus returnează bucăți de text care par vag conexe la nivel teoretic, dar ratează fix codul pe care voiai să-l găsești. Aici intervine hybrid search-ul.
Cum construim hibridul: HNSW plus GIN
Pentru a evita un cluster separat de Meilisearch sau OpenSearch, ne bazăm pe uneltele native din Postgres:
- Index
HNSW(m = 16,ef_construction = 64) pe coloana de tipvector(1536). Am lăsat demultIVFFlatîn urmă fiindcă la batch inserts constante se degradează fărăREINDEXperiodic. - O coloană generată
tsvectorpe conținutul textului, acoperită de un index clasicGIN.
ts_rank_cd din Postgres nu este matematic identic cu Okapi BM25 pur, dar calculează densitatea termenilor și proximitatea lor suficient de bine încât diferența în context de RAG să fie insesizabilă pentru utilizator.
Fuziunea rezultatelor cu Reciprocal Rank Fusion (RRF)
Problema clasică la hybrid search este normalizarea scorurilor: distanța cosinus e între 0 și 2 (sau similitudinea între -1 și 1), în timp ce scorul de full-text search e o valoare arbitrară pozitivă dependentă de lungimea documentului. Să le aduni pur și simplu cu ponderi gen 0.7 * vector + 0.3 * text este o rețetă sigură pentru bug-uri ciudate pe măsură ce corpusul crește.
Soluția curată este Reciprocal Rank Fusion (RRF). Nu combini scorurile numerice brute, ci rangul (poziția) din fiecare listă de top rezultate. Formula standard folosește o constantă de amortizare k = 60.
În codul atașat vedeți cum împachetăm totul într-un singur query cu două CTE-uri. Un CTE ia top 30 cele mai apropiate bucăți după vectori, celălalt ia top 30 după text match, iar FULL OUTER JOIN-ul final calculează scorul combinat prin formula 1.0 / (60 + rank).
Trade-off-uri reale
Arhitectura asta are un compromis clar de memorie. La 450k chunks cu 1536 dimensiuni, indexul HNSW ne ocupă aproximativ 2.8 GB de RAM doar pentru el. Ca să meargă fără swap, a trebuit să setăm shared_buffers generos pe un server cu 16 GB RAM și 4 vCPU.
Funcționează brici până la 2-3 milioane de rânduri și câteva zeci de interogări pe secundă. Dacă aveți nevoie de zeci de milioane de vectori și QPS de ordinul sutelor, Postgres va începe să gâfâie din cauza vacuum-ului și a concurenței pe lock-urile de memorie. Dar până acolo, eliminarea unei baze de date specializate din diagramă vă scutește de ore întregi de debugging pe sync-uri picate.
Voi țineți vectorii în baza principală sau ați simțit nevoia să rupeți stack-ul către un vector store dedicat?