pgvector en 2024: índices HNSW y escalado real

Nodos interconectados en red azul representando grafos de búsqueda vectorial

pgvector pasó de curiosidad a pieza seria durante 2023. La 0.5.0 en agosto añadió HNSW (Hierarchical Navigable Small World) como tipo de índice, aproximándose al rendimiento de bases vectoriales dedicadas para muchos casos. Para equipos que ya tienen PostgreSQL operando, la pregunta “¿necesito Pinecone/Qdrant/Weaviate?” tiene cada vez más respuestas negativas.

Este artículo cubre cómo usar pgvector bien en 2024: diferencias reales entre IVFFlat y HNSW, tuning que importa, y el límite donde escala deja de ser suficiente.

Por qué pgvector gana tracción

Tres razones prácticas:

  • Operación familiar. Backups, replicación, monitoring, permisos — todo ya existe para PostgreSQL. No hay una nueva base de datos que cuidar.
  • JOIN con datos relacionales. Los embeddings suelen acompañarse de metadata (usuarios, fechas, categorías). JOIN nativo evita two-phase pipelines.
  • Licencia permisiva. PostgreSQL + pgvector son BSD. Sin sorpresas de licencia.

En cambio, bases dedicadas tienen su justificación para escala extrema (billones de vectores) o latencia sub-10ms como requisito duro.

IVFFlat vs HNSW: qué elegir

pgvector ofrece dos tipos de índices para búsqueda aproximada:

IVFFlat (desde 0.4):

  • Más memoria-eficiente en disco.
  • Build rápido.
  • Recall peor a parámetros por defecto — hay que subir lists y probes.
  • Reentrenar tras muchos inserts (pierde calidad).

HNSW (desde 0.5):

  • Recall muy bueno por defecto.
  • Insert incremental sin degradación.
  • Más memoria RAM durante consulta.
  • Build más lento.

Para la mayoría de casos hoy: HNSW es la opción correcta. IVFFlat tiene sentido en datasets muy grandes donde la memoria es crítica.

Crear un índice HNSW

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE docs (
    id bigserial PRIMARY KEY,
    content text,
    embedding vector(1536)  -- OpenAI text-embedding-3-small
);

-- Construir índice HNSW tras insertar (o con datos vacíos)
CREATE INDEX ON docs USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

Parámetros clave:

  • m (default 16): conexiones por nodo. Más = mejor recall, más memoria.
  • ef_construction (default 64): elementos evaluados al construir. Más = mejor calidad, build más lento.
  • ef_search (session-level, default 40): elementos evaluados al consultar. Más = mejor recall, latencia mayor.

Para consultas:

SET hnsw.ef_search = 100;

SELECT content FROM docs
ORDER BY embedding <=> '[0.1, 0.2, ...]'::vector
LIMIT 10;

El operador <=> es distancia coseno, <-> es euclidiana, <#> es producto interno negativo.

Filtros combinados

El caso “embedding + WHERE” es donde pgvector brilla vs bases puras:

SELECT content FROM docs
WHERE tenant_id = $1
  AND created_at > NOW() - interval '30 days'
ORDER BY embedding <=> $2
LIMIT 10;

Cuidado con el orden: PostgreSQL puede escanear el índice vectorial primero y luego filtrar, o al revés. Si el filtro es muy selectivo (por ejemplo, solo 100 docs de 1M), el índice vectorial es ineficiente — mejor un index scan normal luego distance sort.

Para esto, pgvector permite post-filtering y partial indexes:

-- Índice parcial para tenant_id comunes
CREATE INDEX ON docs USING hnsw (embedding vector_cosine_ops)
  WHERE tenant_id IN (1, 2, 3);

Diseñar índices parciales por tenant evita escaneo global cuando los tenants son muy distintos en volumen.

Rendimiento orientativo

En hardware mediano (16 cores, 64GB RAM, SSD NVMe), órdenes de magnitud:

  • 10M vectores, dim=1536, HNSW, m=16: ~30GB de índice, ~5ms p50 con ef_search=40.
  • 100M vectores: ~300GB, ~20ms p50, posiblemente necesites scale-up.
  • 1B+ vectores: pgvector empieza a quedar corto. Base dedicada (Qdrant, Milvus) escala mejor.

El factor dimensión importa: embeddings de 384 dim (MiniLM) son 4x más baratos que 1536. Para RAG con alto volumen, considerar modelos más pequeños si la calidad aguanta.

Dimensiones reducidas: el truco de OpenAI 3

OpenAI text-embedding-3-small soporta truncar dimensiones. Truncar de 1536 a 512 reduce el índice 3x con mínima pérdida de calidad en muchos benchmarks.

# Guardar solo los primeros 512 dims
emb = client.embeddings.create(input=text, model="text-embedding-3-small")
emb_truncated = emb.data[0].embedding[:512]

Reindexar con dim=512 puede hacer que un dataset de 10M vectores quepa en memoria donde antes no.

Replicación y HA

pgvector hereda todo de PostgreSQL:

  • Streaming replication: réplicas para lectura.
  • Logical replication: para arquitecturas más sofisticadas.
  • Pgpool / Patroni: para failover.
  • Backups con pg_dump o pgbackrest: igual.

Una réplica read-only dedicada a búsqueda vectorial es patrón común — evita que cargas de inserción afecten latencia de queries RAG.

Monitorización

Lo que importa mirar:

  • pg_stat_user_indexes.idx_scan por índice HNSW.
  • buffer cache hit ratio — HNSW quiere estar en memoria.
  • latencia p95/p99 de las queries vectoriales.
  • recall efectivo: comparar top-10 aproximado con top-10 exacto (brute force) en un subset de queries, periódicamente.

El recall silenciosamente degrada si HNSW se fragmenta; medirlo es la única forma de saberlo.

Donde pgvector empieza a sufrir

Síntomas de que necesitas base vectorial dedicada:

  • >100M vectores y latencia p99 importa.
  • Alta QPS (>1000 queries vectoriales/s) saturando el CPU PostgreSQL.
  • Índices que no caben en RAM: rendimiento cae catastróficamente.
  • Actualizaciones frecuentes causando fragmentación de HNSW.
  • Múltiples tipos de búsqueda (vector + BM25 + filtros complejos) — bases como Weaviate ya integran esto.

Antes de migrar, considerar:

  • Scale-up PostgreSQL (32 cores, 256GB, NVMe).
  • Shardar por tenant en PostgreSQLs separados.
  • Separar la carga vectorial en réplicas dedicadas.

En muchos casos, optimizar PostgreSQL es más barato que migrar.

Ejemplo de pipeline RAG completo

Stack mínimo moderno:

# Ingestión
embedding = openai.embeddings.create(input=chunk, model="text-embedding-3-small")
cursor.execute("INSERT INTO docs (content, embedding) VALUES (%s, %s)",
               (chunk, embedding.data[0].embedding))

# Query
q_emb = openai.embeddings.create(input=user_question, model="text-embedding-3-small")
cursor.execute("""
  SELECT content FROM docs
  ORDER BY embedding <=> %s::vector
  LIMIT 5
""", (q_emb.data[0].embedding,))
context = [row[0] for row in cursor.fetchall()]

# Generate
completion = openai.chat.completions.create(
    model="gpt-4-turbo",
    messages=[{"role": "user", "content": f"Context: {context}\n\nQ: {user_question}"}]
)

50 líneas de código, PostgreSQL como backbone. Suficiente para el 80% de casos RAG.

Conclusión

pgvector en 2024 ha madurado hasta ser la opción por defecto razonable para equipos que ya operan PostgreSQL. HNSW cierra la brecha de rendimiento con bases vectoriales dedicadas para casos medianos. Las bases dedicadas siguen teniendo razón de ser — extrema escala, cargas muy especializadas — pero la barrera para ir por ellas se ha movido bastante arriba. Antes de introducir una nueva pieza de infraestructura, medir si pgvector basta: la mayoría de veces, basta.

Síguenos en jacar.es para más sobre PostgreSQL, RAG y bases vectoriales.

Entradas relacionadas