El coste que nadie mira hasta que llega la factura
Montas un RAG, funciona, lo llevas a producción. Meses después el índice vectorial se ha comido la RAM del servidor y la factura de la base de datos se ha triplicado. Revisas y no es por los documentos: es por los números.
Cada embedding es un vector de floats. Un modelo típico devuelve 1024 dimensiones en float32, es decir 4 bytes por dimensión. Eso son 4 KB por vector. Con 10 millones de fragmentos, son 40 GB solo de embeddings, antes de contar el propio índice de búsqueda.
La cuantización de embeddings ataca justo ese número. Y la parte contraintuitiva: puedes recortar la memoria hasta 32 veces perdiendo muy poco recall. No es magia, es que los float32 guardan una precisión que la búsqueda por similitud no necesita.
Cuantizar no es lo mismo que cuantizar un modelo
Un matiz importante antes de seguir, porque genera confusión. Cuantizar los pesos de un LLM (pasar el modelo a int8 o int4) reduce el tamaño del modelo y acelera la inferencia. Eso es otra cosa. Aquí no tocamos el modelo: dejamos que genere sus embeddings en float32 como siempre, y lo que comprimimos es cómo guardamos esos vectores en el índice.
Dicho de otro modo: el modelo sigue siendo preciso; el almacén se vuelve tacaño. Y la búsqueda vive del almacén.
Los tres niveles de precisión
Hay tres escalones prácticos, de más caro a más barato.
float32 es el punto de partida: 4 bytes por dimensión, precisión completa, recall de referencia. Es lo que te da el modelo si no haces nada.
int8 (cuantización escalar) mapea cada dimensión a un entero de 8 bits. Se toma el rango de valores de esa dimensión en tu corpus y se reparte en 256 niveles. Divides la memoria entre 4 y conservas casi todo el recall. Es el recorte “gratis”: rara vez hay motivo para no hacerlo.
Binaria (1 bit) es el escalón agresivo: cada dimensión se convierte en un solo bit según su signo. Positivo → 1, negativo → 0. La memoria se divide entre 32 y la distancia se calcula con XOR y conteo de bits (distancia de Hamming), que es órdenes de magnitud más rápida que el producto escalar sobre floats.
| Precisión | Bytes/dim (1024d) | Tamaño por vector | Recall típico | Distancia |
|---|---|---|---|---|
float32 | 4 | 4096 B | 100 % (base) | coseno / producto |
int8 | 1 | 1024 B | ~99 % | producto escalar |
| binaria | 0,125 | 128 B | ~92-96 % | Hamming (XOR) |
Los porcentajes de recall varían según el modelo y el dominio, pero el patrón se repite: int8 casi no duele, y la binaria sorprende por lo poco que baja para lo mucho que ahorra.
Por qué la binaria funciona mejor de lo que parece
La intuición dice que tirar un vector de 32 bits a 1 bit debería destrozar la búsqueda. En la práctica no, y hay dos razones.
La primera: en espacios de alta dimensión, el signo de cada componente ya carga con la mayor parte de la información de dirección. Dos vectores que apuntan al mismo sitio comparten el patrón de signos aunque difieran en las magnitudes. Y la búsqueda semántica va de dirección, no de magnitud.
La segunda, y la que hace que todo cuadre en producción: el rescoring. Buscas rápido y barato con los vectores binarios para sacar, digamos, los 100 mejores candidatos. Luego reordenas solo esos 100 con los embeddings de más precisión. Recuperas casi todo el recall que habías perdido, pero el trabajo pesado lo hiciste sobre bits.
El truco está en guardar los float32 (o int8) en un lado barato —disco, o el propio documento— y meter en RAM solo lo binario. Buscas en memoria, rescoras contra disco.
El recorte, en código
Cuantización binaria, búsqueda por Hamming y rescoring con float. Con NumPy se ve el mecanismo completo sin dependencias raras.
import numpy as np
# corpus: 100k embeddings de 1024 dims en float32 (lo que da el modelo)
corpus = np.random.randn(100_000, 1024).astype(np.float32)
def cuantizar_binario(x: np.ndarray) -> np.ndarray:
"""Cada dimensión -> 1 bit por su signo. Empaqueta 8 dims por byte."""
bits = (x > 0).astype(np.uint8) # 1 si positivo, 0 si no
return np.packbits(bits, axis=-1) # 1024 bits -> 128 bytes
# el índice en RAM: 100k x 128 bytes = ~12 MB (era ~390 MB en float32)
indice_bin = cuantizar_binario(corpus)
def buscar(consulta: np.ndarray, k: int = 10, candidatos: int = 100):
q_bin = cuantizar_binario(consulta[None, :])[0]
# 1) filtro barato: distancia de Hamming (XOR + conteo de bits)
xor = np.bitwise_xor(indice_bin, q_bin)
hamming = np.unpackbits(xor, axis=-1).sum(axis=-1)
top = np.argpartition(hamming, candidatos)[:candidatos]
# 2) rescoring: reordena SOLO los candidatos con el float real
sims = corpus[top] @ consulta # producto escalar preciso
mejores = top[np.argsort(-sims)[:k]]
return mejores
resultado = buscar(np.random.randn(1024).astype(np.float32))
Lo relevante no es el código en sí, sino la forma: un filtro binario que descarta el 99,9 % del corpus a velocidad de bits, y un rescoring de precisión sobre un puñado de candidatos. Esa es la combinación que usan por dentro las bases vectoriales que ofrecen “binary quantization” como opción.
Cuándo aplicarlo y cuándo no
No es una decisión de todo o nada. Depende del tamaño y de lo que te juegues en cada consulta.
Por debajo del millón de vectores, sinceramente, ni te molestes con la binaria: cabe en RAM en float32 y la complejidad extra no compensa. Salta a int8 como mucho, que es un cambio de una línea y no tiene contras reales.
A partir de varios millones de fragmentos, o cuando la RAM del índice empieza a marcar la factura, la binaria con rescoring es de los mejores repartos coste-beneficio que hay en un RAG. Reduces 32 veces la memoria caliente y la búsqueda va más rápida.
Dónde tener cuidado: en dominios donde los matices finos importan mucho —búsqueda legal, médica, o cualquier caso donde confundir dos fragmentos parecidos tiene coste real— mide el recall antes y después con tu propio conjunto de evaluación. La binaria sin rescoring sí puede dejar fuera resultados relevantes. La binaria con rescoring casi nunca, pero “casi” no es “nunca”, y eso se comprueba, no se supone.
Qué hacer el lunes
Tres pasos, en orden de esfuerzo.
Primero, activa int8 en tu base vectorial si la soporta (pgvector, Qdrant, Milvus y compañía lo traen). Es memoria entre cuatro con recall intacto, y no hay razón para no tenerlo puesto.
Segundo, si tu índice ya pesa, monta un conjunto de evaluación de recall —cincuenta consultas reales con sus respuestas correctas basta para empezar— y mide qué recall real tienes hoy. Sin esa referencia, cualquier optimización es a ciegas.
Tercero, prueba la binaria con rescoring contra ese conjunto. Si el recall aguanta —y lo normal es que aguante— acabas de recortar tu índice 32 veces. Si no aguanta, ya sabes que tu dominio pide precisión y te quedas en int8, que tampoco está mal.
La regla de fondo es la misma de siempre: mide primero, recorta después. Los float32 son cómodos, pero rara vez te los estás ganando.