Inicio / Artículos / Prescindimos de nuestra base de datos vectorial: pgvector sirve 40 millones de embeddings sin problemas

Prescindimos de nuestra base de datos vectorial: pgvector sirve 40 millones de embeddings sin problemas

Una historia de migración: sustituir un vector store de pago por pgvector en 40 millones de filas, con el método de backfill, el error de recall que se escondía tras los dashboards y cuándo no conviene cambiar.

1132 palabras

Lo que costaba de verdad la segunda base de datos

La cuenta eran once facturas de unos cuatro mil dólares al mes, más un worker de sincronización que no paraba de despertar a alguien por la noche. Un segundo proveedor guardaba una lista de números cuyo sitio era junto a filas que la empresa ya tenía. Aprobar esa factura once veces fue fácil. Decir en voz alta, en una reunión, lo que significaba produjo ese silencio que se hace cuando todos lo saben ya y nadie quiere ser el primero.

Aun así, el dinero no era el coste principal. Lo era la costura entre los dos sistemas. Dos bases de datos significan dos fuentes de verdad, lo que significa un worker de sincronización, lo que significa una cola de mensajes fallidos, lo que significa un ingeniero despierto a las 3 de la madrugada leyendo JSON en el móvil. Esta es la arquitectura tal como funcionaba en la práctica, no como aparecía dibujada en el documento de diseño:

app ──> postgres        (rows, permissions, deletes)
   │
   └────> vector store    (embeddings)
              ▲
              │
        sync worker ──> DLQ ──> me, awake, coffee going cold

Cuando un usuario borraba un documento, Postgres eliminaba la fila dentro de la misma transacción. El vector store eliminaba su copia cuando el worker se ponía al día, así que durante unos noventa segundos el contenido borrado seguía apareciendo en las búsquedas. Eso no es un problema de latencia. Es un ticket de soporte con una revisión legal adjunta.

Una tabla, un índice, ningún worker

El reemplazo fue una sola tabla con un solo índice y ningún proceso en segundo plano:

create extension vector;

create table docs (
  id bigserial primary key,
  team_id int not null,
  body text,
  emb vector(768)
);

Lo que convenció al equipo fue la forma de consultarla, porque es SQL normal:

select id, body
from docs
where team_id = 42
order by emb <=> $1
limit 10;

Un embedding no puede desincronizarse de su fila si vive dentro de esa fila. Desapareció toda una categoría de bugs, y con ella el runbook escrito para ellos.

Migrar 40 millones de filas con el producto en marcha

Los relatos de migraciones suelen saltarse esta fase, aunque fue la que más tiempo llevó. El backfill no usó OFFSET. La paginación profunda sobre cuarenta millones de filas degenera en un escaneo secuencial, y el trabajo se vuelve más lento a medida que avanza, justo lo contrario de lo que necesita un proceso largo. Los workers recorrían la clave primaria por rangos y guardaban como checkpoint el último id terminado:

select id, body from docs
where emb is null and id > $1
order by id
limit 2000;

Cada lote tenía dos mil filas, funcionaban ocho workers a la vez y una pausa deliberada entre lotes mantenía a raya el retraso de replicación. Lo que se vigilaba era el retraso de las réplicas, no el rendimiento, porque las réplicas atendían tráfico real todo el tiempo.

El índice se construyó al final. Crear HNSW antes de la carga obliga a reescribir el grafo continuamente a medida que llegan los datos, y entonces la construcción nunca termina en un plazo tolerable.

set maintenance_work_mem = '16GB';
set max_parallel_maintenance_workers = 7;

create index concurrently on docs
using hnsw (emb_h halfvec_cosine_ops)
with (m = 16, ef_construction = 64);

El fallo que no mostraba ningún dashboard

La búsqueda se puso en marcha sobre Postgres y los dashboards se veían estupendos: latencia plana, cero errores. Nadie lo celebró, porque parecía que no había pasado nada. Cuatro días después, un cliente avisó de que un documento que sin duda había subido no aparecía en los resultados.

La causa era que nunca se había configurado hnsw.ef_search. Seguía en su valor por defecto, una lista de candidatos demasiado estrecha para un grafo de ese tamaño, y el recall había bajado a poco más del 90 %. Todos los dashboards medían si la consulta devolvía algo. Ninguno medía si devolvía lo correcto.

La solución fue ponerlo en 100 y luego probar una serie de valores para ver dónde se estabilizaba el recall. Después llegó la comprobación que debería haber existido desde el primer día: un lote fijo de consultas de prueba, cada una con su respuesta correcta conocida, y una alerta si baja la tasa de coincidencia.

set hnsw.ef_search = 100;

El recall es un ajuste, no una propiedad de la base de datos. Si se queda con una sola frase de todo esto, que sea esa.

Resultados medidos

La prueba se hizo sobre cuarenta millones de filas de 768 dimensiones con dieciséis CPU virtuales, y las réplicas atendían las lecturas.

                        vector store     pgvector
  p50 latency              18 ms           24 ms
  p95 latency              41 ms           57 ms
  recall at 10             0.985           0.981
  filtered p95            190 ms           61 ms
  index size                 n/a           45 GB
  monthly cost            $4,100            $280

Las consultas sin filtros tardan seis milisegundos más. Dentro de una llamada al modelo de novecientos milisegundos, esa diferencia pasa desapercibida. El caso con filtros importa más, porque ahí está el verdadero argumento. Las búsquedas en un producto real se filtran por equipo, permiso o fecha. En la configuración anterior había que sacar de un sistema un conjunto de candidatos mayor del necesario y verificarlo en el otro. Con una sola base de datos, el planificador puede sopesar a la vez el filtro y el orden y elegir cómo ejecutarlos.

Las objeciones que probablemente está formulando

HNSW no lleva bien un volumen alto de actualizaciones. Es cierto. Estos documentos se escriben una vez y se leen constantemente, así que el grafo se mantiene estable, pero si reescribe embeddings cada hora, piénselo bien.

Construir índices es caro. También es cierto. Este tardó algo más de una hora, y construirlo de forma concurrente permitió seguir escribiendo todo el tiempo.

Los filtros muy selectivos pueden perjudicar mucho el recorrido del grafo. Cierto otra vez, y el consejo honesto es probar sus propios tipos de filtro en lugar de fiarse de cualquier tabla, incluida esta.

Cuándo quedarse como está

Mantenga su configuración actual si supera los cien millones de vectores y sigue creciendo, si necesita latencias de pocos milisegundos en búsquedas sin filtros o si nadie en el equipo quiere ocuparse de los ajustes de memoria de Postgres. Este camino cambia la factura de un proveedor por su propia atención.

Para todos los demás, la base de datos ya incluye una función por cuyo alquiler le están pagando a alguien.

Lo que se eliminó

Han desaparecido dos mil cuatrocientas líneas de código de sincronización, junto con un servicio, un canal de alertas y un runbook. La mejor decisión de infraestructura del año anterior resultó ser un borrado. Vaya a leer su propia factura. Probablemente ya sabe lo que dice.