-- Extensie și tabel cu stocare hibridă
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id BIGSERIAL PRIMARY KEY,
content TEXT NOT NULL,
embedding vector(1536),
fts_tokens tsvector GENERATED ALWAYS AS (to_tsvector('romanian', content)) STORED
);
-- Indecși optimizați
CREATE INDEX idx_docs_hnsw ON documents USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
CREATE INDEX idx_docs_fts ON documents USING gin (fts_tokens);
-- Query hibrid cu îmbinare de scoruri
WITH vector_search AS (
SELECT id, 1 - (embedding <=> $1) AS vec_score
FROM documents
ORDER BY embedding <=> $1 LIMIT 20
),
fts_search AS (
SELECT id, ts_rank_cd(fts_tokens, plainto_tsquery('romanian', $2)) AS fts_score
FROM documents
WHERE fts_tokens @@ plainto_tsquery('romanian', $2)
ORDER BY fts_score DESC LIMIT 20
)
SELECT
COALESCE(v.id, f.id) AS id,
(COALESCE(v.vec_score, 0) * 0.7) + (COALESCE(f.fts_score, 0) * 0.3) AS final_score
FROM vector_search v
FULL OUTER JOIN fts_search f ON v.id = f.id
ORDER BY final_score DESC
LIMIT 10;Anul trecut am migrat pipeline-ul de RAG pentru un client din zona legală. Aveau în jur de 180.000 de documente și plăteau vreo 400$ pe lună pe o instanță de Pinecone doar ca să facă semantic search. Am zis că e momentul să aducem totul în Postgres cu pgvector, dar după prima săptămână în producție au început plângerile: căutările pe numere de legi sau coduri de eroare returnau baliverne.
Aici e greșeala pe care o văd la 90% din implementările de RAG pe care le audiez: se bazează exclusiv pe embeddings și distanță cosinus.
De ce căutarea vectorială pură dă chix
Un model de embedding precum text-embedding-3-small de la OpenAI e genial la înțelegerea contextului general. Dacă cauți 'cum reziliez un contract', îți găsește paragrafele despre reziliere chiar dacă apare doar cuvântul 'denunțare unilaterală'.
Dar ce se întâmplă când userul caută 'Articolul 45 alin. 2' sau 'Eroare KB90123'? Embedding-ul aplatizează tokenii specifici într-un spațiu vectorial dens unde numărul '45' își pierde valoarea exactă. Aici vectorii eșuează lamentabil, iar căutarea clasică de tip BM25 (keyword search) este absolut necesară.
Soluția Hibridă: pgvector + tsvector
Nu ai nevoie de Elasticsearch sau Meilisearch pe lângă baza ta de date relatională. Postgres știe deja să facă ambele lucruri excelent dacă le pui cap la cap.
Sistemul funcționează simplu: faci o interogare vectorială pentru semantică, una cu Full-Text Search (FTS) pentru termeni preciși, iar la final le combini scorurile. În exemplul de mai jos am folosit o abordare de scor ponderat direct în SQL, dar în aplicații mai complexe poți aplica Reciprocal Rank Fusion (RRF).
Pe proiectul nostru, o pondere de 0.7 pentru vectori și 0.3 pentru BM25 a crescut acuratețea răspunsurilor de la 62% la 91% pe un set de test de 500 de întrebări reale primite de la utilizatori.
Indexarea HNSW: Setări și latențe
Când depășești 100k de înregistrări, căutarea secvențială vă omoară procesorul. Indexul HNSW (Hierarchical Navigable Small World) introdus în pgvector 0.5.0 este mult peste vechiul IVFFlat (care necesită re-antrenare când se schimbă datele).
Totuși, indexarea HNSW consumă RAM masiv la build. La un tabel cu 150k vectori de 1536 de dimensiuni, am fost obligat să cresc temporar maintenance_work_mem la 2GB, altfel Postgres intra direct în swap și bloca tot serverul. Setările m = 16 și ef_construction = 64 mi-au oferit o latență de căutare sub 30ms la o acuratețe de scanare (recall) de peste 95%.
Concluzia
Postgres e mai mult decât capabil să ducă RAG în producție până la câteva milioane de vectori. Salvezi bani grei pe servicii terțe și scapi de calvarul de a sincroniza două baze de date diferite la fiecare insert sau update.
Voi ce folosiți în prod pentru RAG? Ați rămas pe baze dedicate gen Qdrant/Pinecone sau ați mutat totul în Postgres?