pgvector a fondo: cuándo PostgreSQL es suficiente

Índices HNSW dentro de Postgres, filtrado combinado con SQL, límites reales de escala y coste operativo.

pgvector a fondo: cuándo PostgreSQL es suficiente
Foto de Leon Aschemann en Pexels

Segunda entrega del bloque de plataformas, y de nivel avanzado. En la anterior vimos que la primera pregunta al elegir base de datos vectorial es si ya tienes PostgreSQL. Aquí examinamos hasta dónde llega esa respuesta: la extensión pgvector por dentro, sus índices, el problema real del filtrado y el punto en que deja de bastar.

pgvector es una extensión de PostgreSQL que añade un tipo de dato para vectores, operadores de distancia y dos tipos de índice aproximado. No es un producto aparte ni un servicio: es una extensión que se instala en la base de datos que ya tienes.

CREATE EXTENSION IF NOT EXISTS vector;

A partir de ahí, un vector es una columna más de una tabla normal, con las consecuencias que eso tiene: participa en transacciones, entra en las copias de seguridad, respeta las claves ajenas y se somete al mismo sistema de permisos que el resto de tus datos. Es la ventaja decisiva, y volveremos a ella al final.

Tipos y operadores

El tipo básico es vector(n), donde n es la dimensionalidad, almacenado en coma flotante de precisión simple: cuatro bytes por dimensión. Hay además tipos pensados para ahorrar espacio: halfvec, en media precisión, que reduce a la mitad el almacenamiento con una pérdida de precisión que en la práctica suele ser irrelevante para recuperación; y tipos para vectores binarios y dispersos, este último útil para representaciones tipo SPLADE.

Los operadores de distancia son los que importan en el día a día:

  • <=> distancia coseno. La habitual con embeddings de texto.
  • <-> distancia euclídea (L2).
  • <#> producto interno negado. El más rápido cuando los vectores están normalizados, porque equivale a la distancia coseno salvo por el signo.

Un detalle que causa incidentes: el índice sólo se usa si el operador de la consulta coincide con el de la clase de operadores del índice. Un índice creado con vector_cosine_ops no acelera una consulta escrita con <->. PostgreSQL no avisa; simplemente hace un recorrido secuencial y la consulta tarda cien veces más. Si una búsqueda va inexplicablemente lenta, lo primero es mirar el EXPLAIN ANALYZE.

El esquema mínimo de un RAG en Postgres cabe en unas líneas:

CREATE TABLE fragmentos (
  id           bigserial PRIMARY KEY,
  documento_id bigint NOT NULL REFERENCES documentos(id) ON DELETE CASCADE,
  texto        text NOT NULL,
  seccion      text,
  publicado_en date,
  grupos       text[] NOT NULL DEFAULT '{}',
  embedding    vector(1024) NOT NULL
);

-- Consulta de recuperación básica
SELECT id, texto, 1 - (embedding <=> $1) AS similitud
FROM fragmentos
ORDER BY embedding <=> $1
LIMIT 10;

Fíjate en que el orden va por distancia y la similitud se calcula aparte para mostrarla. Ordenar por la expresión 1 - distancia descendente da el mismo resultado lógico pero impide que el planificador use el índice.

Los dos índices: IVFFlat y HNSW

Sin índice, PostgreSQL compara la consulta contra todas las filas. Es exacto y sólo viable con decenas de miles de vectores como mucho.

IVFFlat

Reparte los vectores en listas mediante agrupamiento y, al consultar, sólo explora unas pocas listas. Se construye rápido y ocupa poco.

CREATE INDEX ON fragmentos
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 1000);

SET ivfflat.probes = 20;

Tiene dos inconvenientes serios. El primero es que necesita datos para construirse: el agrupamiento se calcula sobre lo que hay en la tabla en ese momento, así que crear el índice sobre una tabla vacía produce un índice inútil. El segundo es que se degrada según insertas: las listas se calcularon con la distribución antigua y, si el corpus crece o cambia de tema, hay que reconstruirlo. Para un corpus vivo es una carga recurrente.

La regla habitual para lists es del orden de la raíz cuadrada del número de filas cuando se está por debajo del millón, y algo mayor por encima. probes es el mando de calidad en consulta: más sondas, más recall y más latencia.

HNSW

Construye un grafo navegable de vecinos en varias capas. Es el índice por defecto para cualquier cosa seria.

CREATE INDEX ON fragmentos
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 128);

SET hnsw.ef_search = 80;

Los tres parámetros son los mismos que vimos en la entrega 05. m es el número de enlaces por nodo: más enlaces, mejor recall y más memoria. ef_construction controla el esfuerzo al construir: sube la calidad del grafo y alarga mucho el tiempo de creación. ef_search es el único ajustable en caliente, por sesión, y es el mando que usarás en producción para mover el compromiso entre recall y latencia sin tocar el índice.

HNSW admite inserciones incrementales sin degradarse, que es justo lo que IVFFlat no hace, y funciona bien creado sobre tabla vacía. A cambio, se construye despacio y ocupa bastante más memoria.

Dos ajustes operativos que marcan la diferencia al construir índices grandes: subir maintenance_work_mem lo suficiente para que el grafo se construya en memoria y no en disco, y usar construcción paralela con varios trabajadores. La diferencia entre hacerlo y no hacerlo se mide en horas.

Filtrado combinado, y el problema de fondo

Aquí está lo que hay que entender bien, porque es donde pgvector se comporta de forma distinta a un motor dedicado.

La consulta natural es esta:

SELECT id, texto
FROM fragmentos
WHERE publicado_en >= '2024-01-01'
  AND grupos && $2          -- solapa con los grupos del usuario
ORDER BY embedding <=> $1
LIMIT 10;

El planificador tiene dos caminos. Puede filtrar primero por los índices B-tree y GIN de las columnas de metadatos y después ordenar por distancia sobre el subconjunto —exacto, y lento si el subconjunto es grande—, o puede recorrer el índice HNSW y descartar sobre la marcha lo que no cumple el WHERE.

El segundo camino tiene una trampa conocida: el recorrido del índice se detiene cuando ha reunido candidatos suficientes según ef_search, y si el filtro es muy selectivo puede que casi ninguno de esos candidatos lo cumpla. El resultado es que devuelve menos de diez filas, o diez filas peores que las verdaderas diez mejores, sin dar ningún error. Es el fallo silencioso del que hablábamos en la entrega anterior.

Hay tres mitigaciones reales:

  • Subir ef_search para las consultas filtradas. Amplía la exploración y aumenta la probabilidad de reunir suficientes candidatos válidos. Es la mitigación más barata y la más usada.
  • Índices parciales por el valor de filtrado más frecuente, cuando ese valor tiene cardinalidad baja y estable. Un índice HNSW por cada inquilino, por ejemplo, convierte el filtro en elección de índice y elimina el problema.
  • Forzar el filtrado previo con una subconsulta materializada cuando sabes que el subconjunto es pequeño: acota primero, ordena después. Es exacto, y sólo funciona si el subconjunto es realmente pequeño.

Para búsqueda híbrida no hace falta salir de Postgres: la búsqueda léxica se hace con tsvector y ts_rank, o con la extensión de trigramas para tolerancia a erratas, y la fusión por rango recíproco de la entrega 15 se escribe en SQL con dos CTE numeradas por ROW_NUMBER(). No es elegante, pero es una consulta y no dos sistemas.

pgvectorscale y alternativas

pgvectorscale es una extensión complementaria que añade un índice de tipo DiskANN y cuantización, con el objetivo explícito de que el índice no tenga que caber entero en memoria. Permite corpus mayores en máquinas más modestas, a costa de más lecturas de disco. Si te acercas al límite de memoria, evalúalo antes de plantear una migración.

En la misma familia hay bases de datos que envuelven PostgreSQL con capacidades vectoriales añadidas y servicios gestionados que ofrecen pgvector preinstalado y afinado. La ventaja de todas ellas es la misma: mantienes el dialecto SQL y las herramientas.

Los límites reales

pgvector deja de ser la respuesta correcta cuando se cumple alguna de estas condiciones:

  • El índice no cabe en memoria y pgvectorscale no basta. A partir de decenas de millones de vectores de alta dimensionalidad, mantener el rendimiento en Postgres exige máquinas caras.
  • El filtrado es la operación dominante y muy selectivo. Es el punto donde los motores con filtrado integrado en el recorrido del grafo ganan de forma clara.
  • Necesitas reparto horizontal del índice. Postgres escala vertical y en réplicas de lectura con naturalidad; repartir un índice vectorial entre nodos ya no es su terreno.
  • El corpus se reindexa entero con frecuencia. Construir HNSW sobre decenas de millones de filas es caro y bloquea recursos que la base de datos necesita para lo demás.

Y una advertencia menos obvia: meter la carga vectorial en la misma instancia que sirve tu aplicación transaccional significa que una reindexación pesada compite por la caché y por la entrada/salida con las consultas de negocio. Si vas a hacerlo en serio, plantéate una réplica dedicada o una instancia separada. Sigue siendo Postgres, con las mismas herramientas, pero sin ese acoplamiento.

La ventaja que casi siempre decide

Todo lo anterior son matices sobre una realidad más simple: una sola base de datos.

El fragmento, su texto, sus metadatos, sus permisos y su vector viven en la misma fila. Insertar un documento y sus vectores es una transacción: o entra todo o no entra nada, sin estados intermedios en los que el índice vectorial dice una cosa y la tabla de documentos otra. Borrar un documento con ON DELETE CASCADE borra sus vectores, y el derecho al olvido de la entrega 13 deja de ser un procedimiento de dos sistemas. La copia de seguridad que ya haces incluye el índice. Los permisos que ya tienes se aplican. Las herramientas de monitorización que ya usas siguen sirviendo. Y cuando algo falla de madrugada, la persona de guardia está depurando PostgreSQL, no un sistema que vio por primera vez hace tres meses.

Ese conjunto de propiedades no aparece en ninguna comparativa de rendimiento y decide la mayoría de los proyectos de tamaño medio.

Qué viene ahora

La siguiente entrega cruza la frontera y examina los tres motores dedicados de código abierto más usados: Qdrant, Weaviate y Milvus. Arquitectura, modelo de despliegue, filtrado, cuantización, reparto y replicación, y una lectura honesta de qué perfil de equipo encaja con cada uno.

Los tres libros gratuitos de los que nace esta serie están disponibles en PDF y EPUB: RAG para tu negocio (nivel básico), RAG por dentro (intermedio) y RAG en producción (avanzado).


Aviso de contenido generado con inteligencia artificial.
Este artículo ha sido redactado con la asistencia de sistemas de inteligencia artificial y revisado antes de su publicación. Se informa de ello de forma expresa en cumplimiento de la normativa vigente sobre transparencia en el uso de IA.