Un índice vectorial se vuelve caro mucho antes de lo que la gente espera. No por el modelo de embeddings, que ya cuesta céntimos, sino por la memoria: si guardas vectores de 3072 dimensiones en float32, cada uno ocupa 12 KB, y un millón de vectores son 12 GB solo de datos, sin contar el grafo del índice. Lo que aprendí construyendo el matching de JXBS es que ese tamaño es negociable en dos ejes independientes —cuántas dimensiones guardas y con qué precisión guardas cada una— y que recortar en ambos degrada el recall mucho menos de lo que la intuición sugiere, siempre que uses el vector pequeño para buscar y el grande para decidir.
Los dos ejes: dimensiones y precisión
Cuando quieres que un embedding ocupe menos, hay exactamente dos palancas. La primera es truncar dimensiones: quedarte con las primeras N componentes del vector en lugar de todas. La segunda es cuantizar: bajar la precisión de cada componente, de float32 a int8 o incluso a un solo bit.
Estas palancas se multiplican. Un vector de 1024 dimensiones en int8 ocupa 1 KB frente a los 4 KB del mismo vector en float32, y frente a los 12 KB del vector completo de 3072 en float32. Doce veces menos memoria. La pregunta no es si eso ahorra —evidentemente sí—, sino cuánto recall pierdes por el camino.
Truncar dimensiones no es tan bárbaro como suena
Truncar un vector arbitrario destruye información de forma impredecible: no hay ninguna garantía de que las primeras componentes sean las importantes. Lo que cambió el panorama es el entrenamiento tipo matryoshka, donde el modelo se entrena explícitamente para que los prefijos del vector sean, por sí solos, representaciones útiles. Las primeras dimensiones cargan la señal gruesa; las últimas afinan matices.
Si tu modelo soporta este esquema —los principales de OpenAI y varios abiertos lo hacen—, truncar es literalmente cortar el array y renormalizar:
function truncar(vector: number[], dims: number): number[] {
const corte = vector.slice(0, dims);
const norma = Math.hypot(...corte);
return corte.map((v) => v / norma); // renormalizar es obligatorio
}
Ese renormalizar no es opcional. Si usas similitud coseno con vectores normalizados y truncas sin volver a normalizar, las magnitudes dejan de ser comparables y las distancias se ensucian. Es el error más común y el más silencioso: no revienta nada, solo empeora los resultados.
Si tu modelo no está entrenado con matryoshka, no trunques. Ahí la reducción honesta pasa por PCA o por un proyector aprendido, y eso ya es un componente más que mantener, versionar y reindexar.
Cuantizar: int8 casi gratis, binario con matices
Bajar de float32 a int8 conserva un recall sorprendentemente alto —en mis pruebas la pérdida ha sido marginal— a cambio de un cuarto de la memoria. El truco es la calibración: necesitas conocer el rango real de tus componentes para mapearlo al rango de int8, y ese rango se calcula sobre una muestra representativa de tu corpus, no sobre el primer lote que tengas a mano.
La cuantización binaria es otra historia. Reduces cada componente a un bit según su signo, ocupas 32 veces menos y comparas con distancia de Hamming, que es un XOR y un popcount: brutalmente rápido. Pero pierdes recall de forma visible. Nadie serio usa binario como respuesta final; se usa como filtro.
💡 La pregunta correcta no es "¿qué precisión uso?" sino "¿qué precisión uso en cada fase?". Buscar y decidir son problemas distintos y merecen representaciones distintas.
El patrón que uso: buscar barato, decidir caro
En lugar de elegir un único formato, guardo dos. Un vector pequeño y cuantizado para recorrer el índice, y el vector completo en float32 —o algo cercano— para reordenar los pocos candidatos que sobreviven.
La primera fase recupera, digamos, los 200 candidatos más cercanos usando el vector barato. Es una fase de recall: solo tiene que garantizar que los buenos estén dentro de esos 200, no que estén bien ordenados. La segunda fase toma esos 200, calcula la similitud con el vector de precisión completa y los ordena de verdad. Esa segunda fase toca 200 vectores, no un millón, así que la memoria del índice no la limita: los vectores completos pueden vivir en disco o en una tabla normal de Postgres.
-- Fase 1: recall barato sobre el vector cuantizado (indexado)
WITH candidatos AS (
SELECT id
FROM documentos
ORDER BY embedding_int8 <=> $1::vector
LIMIT 200
)
-- Fase 2: reordenar los 200 con el vector de precisión completa
SELECT d.id, d.titulo, d.embedding_full <=> $2::vector AS distancia
FROM documentos d
JOIN candidatos c ON c.id = d.id
ORDER BY distancia
LIMIT 10;
Esto es exactamente la misma idea que aplicar un reranker encima de la búsqueda semántica, pero un piso más abajo: en vez de un modelo caro reordenando, es un vector caro reordenando. Y se pueden combinar los dos.
Cómo decido el punto de corte
No hay número mágico. Lo que hago es medir. Congelo un conjunto de consultas con sus resultados relevantes conocidos, defino como referencia el recall@10 con el vector completo en float32, y voy probando configuraciones: 3072/float32, 1024/float32, 1024/int8, 512/int8, binario+rerank. Cada una da un par (recall, memoria). Con esa tabla delante la decisión deja de ser una discusión de opiniones y pasa a ser una elección de trade-off explícita.
Mi sesgo por defecto, cuando el corpus es grande: dimensiones medias con int8 en el índice, vector completo guardado aparte para la segunda fase. Elegí eso porque el coste de memoria del índice cae casi un orden de magnitud, a cambio de una consulta con dos pasos en vez de uno y de mantener dos columnas sincronizadas cuando reindexas. Ese es el precio real, y hay que decirlo: cada esquema de compresión es una migración pendiente. El día que cambies de modelo de embeddings, reindexas ambas columnas.
Y una advertencia final: no optimices esto antes de tener el problema. Con cien mil vectores, float32 completo cabe en memoria sin despeinarse y no vas a notar la diferencia. La compresión es una respuesta a una restricción concreta, no una virtud en sí misma.