Actualizado: 2026-06-20

Puntos clave

  • pgvector + HNSW es el default razonable en 2026 para hasta ~10 M de vectores por nodo: el mismo Postgres que ya operas, sin una pila nueva ni un segundo plan de backup.
  • pgvectorscale (StreamingDiskANN más cuantización binaria estadística) sube el techo a 50 M y deja el p95 por debajo de 50 ms con reranking, según el benchmark propio de Timescale de abril de 2026 (471 QPS @ 99 % de recall).
  • El rerank de dos etapas es donde se gana la calidad: un candidato amplio por coseno sobre HNSW y un reordenado fino con cross-encoder. En cuanto sales de la demo, quitarlo se nota.
  • Las evals con DSPy + MLflow convierten el “se siente mejor” en un número que puedes vigilar. Cambiar el prompt sin medir es tirar a ciegas.
  • Los SLOs son lo que mantiene vivo un RAG en producción: p95 < 700 ms end-to-end, recall@10 ≥ 0,85, y un presupuesto cerrado de coste por mil queries.

Por qué Postgres + pgvector es default en 2026

Tres razones operativas pesan más que cualquier gráfica de benchmark.

  • Una base de datos, un plan de backup. No tienes que diseñar un segundo régimen de operaciones para una “vector DB” aparte. PITR, replicación lógica y pg_dump ya cubren los embeddings igual que cubren el resto.
  • Joins relacionales contra los embeddings. Filtrar por tenant, fecha o categoría es un WHERE normal. En las vector DBs especializadas eso suele convertirse en payload filtering con sus propias limitaciones.
  • Madurez. ANN-Benchmarks 2025 dejó a pgvector arriba con HNSW; pgvectorscale llevó la cifra a 471 QPS @ 50 M con 99 % de recall en su propio informe, y su StreamingDiskANN recorta la distancia con Milvus en datasets grandes.

¿Cuándo no? Cuando pasas de los 100 M de vectores, cuando necesitas p99 por debajo de 10 ms sobre miles de QPS, o cuando ya operas otra base de datos para el producto y prefieres aislar el subsistema de IA. Ahí una Qdrant o una Milvus dedicada gana.

Esquema de tablas y migraciones

Separo siempre documents de chunks. El documento es la unidad de negocio (permisos, borrado, metadatos); el chunk es la unidad de retrieval. El tenant_id viaja en las dos tablas a propósito: lo vas a necesitar para filtrar, para particionar y para Row-Level Security.

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
    id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id   uuid NOT NULL,
    source      text NOT NULL,
    title       text,
    body        text NOT NULL,
    created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE chunks (
    id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    document_id   uuid NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
    tenant_id     uuid NOT NULL,
    chunk_index   int NOT NULL,
    text          text NOT NULL,
    embedding     vector(1024) NOT NULL,
    token_count   int,
    created_at    timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX chunks_tenant_idx ON chunks (tenant_id);

Repetir tenant_id en chunks no es redundancia inútil: te ahorra un join en la query caliente y te permite un índice parcial o una partición por tenant más adelante.

Sobre la dimensión: vector(1024) encaja con BGE-M3 y con Cohere v3. Si usas text-embedding-3-large puedes pedir 3072 o recortar a 1024/256 con el parámetro dimensions (los embeddings Matryoshka aguantan el recorte sin desplomarse). Menos dimensiones es menos índice y menos latencia, así que recorta hasta donde tu recall lo tolere.

Generación e ingesta de embeddings

  • Modelo. text-embedding-3-large sigue siendo el techo de calidad gestionado; BGE-M3 a 1024 dim es lo más razonable en local; Cohere Embed v3 si el caso es multilingüe de verdad. Fija el modelo y la dimensión antes de indexar nada: cambiarlos obliga a reindexar todo el corpus.
  • Normaliza. Para coseno conviene normalizar los vectores a norma 1 en ingesta; así puedes usar inner product (<#>), que es más barato y da el mismo orden.
  • Chunking. 500-800 tokens con 50-100 de solapamiento va bien para texto técnico. Para conversaciones, partir por turno respeta mejor la semántica. Si el dominio lo permite, el contextual retrieval (anteponer a cada chunk un par de frases que lo sitúan en su documento) sube el recall de forma medible.
  • Ingesta idempotente. Lote de 64-128 chunks por llamada, y un único job con ON CONFLICT (document_id, chunk_index) DO NOTHING para que reejecutar sea seguro. Guarda en cada fila el modelo y los parámetros de chunking con los que se generó; sin ese versionado, cualquier comparación posterior es inválida.

Indexado HNSW: parámetros que importan

CREATE INDEX chunks_embedding_hnsw
ON chunks USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

El opclass tiene que casar con el operador de la query: vector_cosine_ops con <=>, vector_l2_ops con <->, vector_ip_ops con <#>. Si no casan, Postgres ignora el índice y hace un seq scan silencioso.

  • m = 16: conexiones por nodo en el grafo. 16 es un buen default; 32 sube el recall a cambio de tamaño y de tiempo de construcción.
  • ef_construction = 64: profundidad de búsqueda al construir. 128-256 mejora el grafo; lo pagas en build.
  • ef_search: parámetro de runtime. Empieza en 40 y súbelo por sesión (SET LOCAL hnsw.ef_search = 80) hasta alcanzar tu recall@10 objetivo, ni un punto más. Cada subida cuesta latencia.

El índice HNSW vive en memoria. Si no cabe en shared_buffers o en la caché del SO, la latencia se dispara: calcula su tamaño antes de prometer un p95. Y construirlo es lento por defecto, así que dale memoria y paralelismo:

SET maintenance_work_mem = '8GB';
SET max_parallel_maintenance_workers = 7;

-- en producción, sin bloquear escrituras:
CREATE INDEX CONCURRENTLY chunks_embedding_hnsw
ON chunks USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);

-- y vigila el progreso:
SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS pct
FROM pg_stat_progress_create_index;

Almacenamiento y cuantización

Cuando el corpus crece, el índice en memoria es el primer cuello de botella. Tres palancas, de menor a mayor agresividad:

  • halfvec (media precisión). Indexa con halfvec_cosine_ops y el índice ocupa la mitad, con una pérdida de recall casi imperceptible. Es lo primero que activo: gratis en la práctica.
  • Cuantización binaria. Guarda una versión bit del embedding e indexa con bit_hamming_ops para generar candidatos baratísimos, y luego reordena por distancia exacta sobre el vector completo. pgvectorscale lo automatiza con su cuantización binaria estadística (SBQ).
  • Menos dimensiones. Con embeddings Matryoshka recortas la dimensión en origen (3072 → 1024 → 512) y el índice encoge en proporción. Mide el recall en cada escalón; hay un punto en el que cae en picado.

La regla práctica que sigo: halfvec casi siempre, binaria cuando el dataset no cabe en RAM, recorte de dimensión cuando controlas el modelo de embeddings.

Filtrado a escala: el precipicio de recall

Este es el error más caro y el menos obvio. Una query así parece inocente:

SELECT id, text FROM chunks
WHERE tenant_id = $1
ORDER BY embedding <=> $2
LIMIT 10;

Si tenant_id = $1 selecciona pocas filas, HNSW puede devolver sus top-k por vector y que casi ninguno pase el filtro, así que te quedas con tres resultados en vez de diez, o con ninguno. El índice no sabe del filtro. Las opciones dependen de cuánto filtres:

  • Índice parcial o partición por tenant. Si el filtro más común es el tenant, un CREATE INDEX ... WHERE tenant_id = ... o una partición declarativa le da a cada tenant su propio grafo.
  • Subir ef_search. Con filtros poco selectivos suele bastar.
  • Iterative scan (pgvector 0.8). SET hnsw.iterative_scan = strict_order (orden exacto) o relaxed_order (mejor recall), acotado con hnsw.max_scan_tuples. El índice sigue escaneando hasta reunir suficientes candidatos que pasen el filtro.

Sin medir recall con el filtro puesto, este fallo no aparece en una demo y sí en la primera reunión con un cliente que no encuentra su propio documento.

Reranking de dos etapas

Un embedding es una compresión con pérdida: un bi-encoder mete consulta y documento en vectores por separado y los compara por coseno, así que pierde matices. El cross-encoder lee consulta y documento juntos y puntúa la pareja, que es mucho más preciso y mucho más caro. De ahí las dos etapas: HNSW trae 50 candidatos baratos y el cross-encoder reordena y deja los 5 mejores.

  • Cross-encoder local: BAAI/bge-reranker-v2-m3 reordena 50 candidatos en ~80 ms en CPU, ~20 ms en GPU.
  • Cross-encoder gestionado: Cohere Rerank v3, ~10 ms p95 más coste por llamada.
  • Punto medio: modelos de late interaction tipo ColBERT, entre el bi-encoder y el cross-encoder en coste y en calidad.

El rerank duplica el coste por query y casi siempre lo vale: en mis datasets, quitarlo baja la relevancia un 10-15 %.

Búsqueda híbrida: BM25 + vectores

El vector solo falla en lo que el vector no ve: códigos de producto, nombres propios, números de versión, términos exactos. Ahí BM25 (búsqueda léxica) recupera lo que el embedding pasa por alto, y la combinación de los dos es lo que mejor aguanta en producción.

En Postgres tienes dos caminos: el full-text search nativo (tsvector/tsquery), o ParadeDB (pg_search) si quieres BM25 de verdad. Luego fusionas las dos listas con Reciprocal Rank Fusion (RRF), que suma 1/(k + rango) de cada lista y se ahorra calibrar escalas entre puntuaciones incompatibles. Lo desarrollo en búsqueda híbrida: BM25 + vectores.

Evals con DSPy + MLflow

Cambiar el prompt sin medir es tirar a ciegas. Pipeline mínimo:

  • DSPy (dspy.ai): defines la firma (query, context) → answer y mides con métricas de retrieval (context precision, context recall) y de generación (faithfulness, relevancia de la respuesta).
  • MLflow: cada run queda registrado con el hash del corpus, el modelo de embeddings, los parámetros de HNSW, las métricas y la latencia. Así comparas dos configuraciones sin discutir de memoria.
  • Frecuencia: reejecuta el eval-set en cada cambio de prompt, modelo o pipeline. Es la condición para aprobar un cambio.

Ancla el eval-set en 100-200 queries representativas y revísalo cada trimestre. Si usas un LLM como juez, calíbralo contra un subconjunto etiquetado por humanos: si el juez da 4,5 donde la persona da 3, está inflando. Lo cuento en LLM como juez: evaluación madura.

Operarlo: pooling, monitorización y reindex

Lo que se rompe en producción no es el álgebra vectorial, es la operación.

  • Pooling. Pon PgBouncer en modo transacción delante. Cada query de retrieval es corta y muy concurrente; sin pool te comes los límites de conexiones de Postgres.
  • Deriva de recall. El recall no es estable mientras el corpus crece y cambia. Mide recall@10 contra el eval-set en un cron y alerta cuando baje del umbral, en lugar de esperar a que se queje un usuario.
  • Reindex. Tras muchos borrados o updates de embeddings, el grafo HNSW se degrada. REINDEX INDEX CONCURRENTLY lo reconstruye sin bloquear. Vigila el bloat con pg_stat_user_indexes y ajusta el autovacuum en chunks si tiene mucha rotación.

SLOs y prueba de carga

Tres SLOs mantienen vivo el sistema:

  • Latencia end-to-end: p95 < 700 ms para un single-turn corto, p95 < 1500 ms cuando entran rerank y un LLM grande.
  • Recall@10 sobre el eval-set: ≥ 0,85 estable; por debajo de 0,80 salta alerta.
  • Coste por mil queries: presupuesto cerrado y alertable. Un RAG con un LLM caro escala el OPEX hasta hacerse insostenible si nadie mira la factura.

La prueba de carga la hago con k6 en una máquina aparte del Postgres: 50 usuarios concurrentes, 10 minutos sostenidos, midiendo p50/p95/p99. La repito tras cada release de producto y tras cada subida de versión de pgvector, y meto un test de regresión de recall en CI para que un cambio de parámetros no degrade la calidad sin que nadie se entere.

Para patrones más amplios, ver RAG en producción: patrones y frameworks de evaluación de retrieval.

Repos de referencia: pgvector/pgvector[1], pgvectorscale[2], DSPy[3], MLflow[4]. Cada empresa debería mantener su propio eval-set: el problema de los benchmarks de vendor es que no ven tu dominio.

  1. pgvector/pgvector
  2. pgvectorscale
  3. DSPy
  4. MLflow