Tres nombres para lo que parece la misma cosa
Creas una colección en tu base de datos vectorial y lo primero que te pide es
elegir la métrica: cosine, ip o l2. La documentación dice que con vectores
normalizados son equivalentes, así que eliges una casi al azar y sigues con tu
vida.
Semanas después alguien migra el índice, cambia cosine por ip porque es más
rápido y el ranking se mueve. Los mismos documentos, el mismo modelo de
embeddings, la misma consulta, otro top-5. Y arriba del todo aparecen, sin
excepción, los documentos más largos del corpus.
No es un bug del índice. Es que la equivalencia entre las tres métricas tiene una condición previa, y esa condición no se cumplía.
Qué mide cada una
Un embedding es un vector. Comparar dos vectores admite dos preguntas distintas: hacia dónde apuntan y cuánto miden. Las tres métricas mezclan esas dos preguntas en proporciones diferentes.
Similitud coseno mide solo el ángulo. Divide el producto escalar entre el producto de las normas, así que la magnitud desaparece por construcción. Rango de −1 a 1. Es la que asume la mayoría de la literatura de recuperación.
Producto interno (inner product, o producto escalar) es el numerador de esa fórmula sin el denominador. Mide ángulo y magnitud a la vez. No tiene rango acotado: un vector el doble de largo da el doble de puntuación apuntando en la misma dirección.
Distancia euclídea (L2) mide la separación en línea recta entre los dos extremos. Es una distancia, no una similitud: aquí gana el más pequeño.
La equivalencia que promete la documentación se apoya en una identidad de bachillerato. Si ambos vectores tienen norma 1:
‖q − d‖² = ‖q‖² + ‖d‖² − 2·(q·d) = 2 − 2·cos(q, d)
La distancia L2 al cuadrado es una función decreciente del coseno. Ordenar por una o por la otra da exactamente la misma lista. Y con norma 1 el denominador del coseno vale 1, así que el producto interno es el coseno. Tres métricas, un solo orden.
Todo esto se cae si las normas no valen 1.
El sesgo de magnitud, en un ejemplo
import numpy as np
# a y b apuntan en la misma dirección: mismo "significado".
# Lo único que cambia es la magnitud del vector.
a = np.array([0.6, 0.8]) # norma 1.0
b = np.array([3.0, 4.0]) # norma 5.0
q = np.array([0.8, 0.6]) # la consulta, norma 1.0
def coseno(x, y):
return x @ y / (np.linalg.norm(x) * np.linalg.norm(y))
print(coseno(q, a), coseno(q, b)) # ≈0.96 ≈0.96 -> empate, como debe ser
print(q @ a, q @ b) # ≈0.96 ≈4.80 -> b gana solo por ser grande
Con coseno hay empate: los dos documentos son igual de relevantes porque apuntan
al mismo sitio. Con producto interno, b gana por un factor de cinco sin aportar
ni una pizca más de significado.
En un corpus real esa magnitud extra no es aleatoria. Muchos codificadores producen vectores cuya norma se correlaciona con la longitud del texto, con la frecuencia de los términos o simplemente con lo “genérico” que sea el fragmento. El resultado es un índice que premia sistemáticamente a los fragmentos largos y divagantes frente a los cortos y precisos. Justo lo contrario de lo que quieres en un sistema RAG.
La tabla que deberías tener a mano
| Métrica | Qué mide | Exige normalizar | Coste por comparación | Cuándo usarla |
|---|---|---|---|---|
| Coseno | Solo dirección | No, la normalización es implícita | Producto escalar más dos normas | Por defecto, si no controlas la ingestión |
| Producto interno | Dirección y magnitud | Sí, si no quieres sesgo | Un producto escalar | Vectores ya normalizados, o cuando la magnitud significa algo |
| L2 | Distancia absoluta | Sí, para equivaler al coseno | Una resta y una norma | Espacios entrenados con objetivo euclídeo |
La columna interesante es la del coste. El producto interno es de las operaciones más baratas sobre dos vectores: una multiplicación-acumulación por dimensión, sin divisiones ni raíces. El coseno hace eso mismo y además necesita las normas. Un índice bien implementado cachea la del documento y calcula la de la consulta una sola vez, así que el sobrecoste real aparece cuando normalizas en cada búsqueda.
De ahí la receta estándar: normaliza en la ingestión y configura el índice en
producto interno. Pagas la normalización una vez por documento, en el momento de
indexar, y a cambio cada búsqueda usa la métrica más barata con la semántica del
coseno. No es un truco: es lo que hace por dentro un índice configurado en
cosine, solo que tú lo controlas.
def normalizar(v: np.ndarray) -> np.ndarray:
# eps evita dividir entre cero si el codificador devuelve un vector nulo
return v / np.maximum(np.linalg.norm(v, axis=-1, keepdims=True), 1e-12)
docs = normalizar(modelo.encode(chunks)) # una vez, al indexar
q = normalizar(modelo.encode([consulta])) # una vez, por consulta
# a partir de aquí, docs @ q.T ya es el coseno
Tres formas de romperlo en producción
Mezclar vectores normalizados y sin normalizar en el mismo índice. Pasa cuando la normalización vive en el script de ingestión y alguien añade documentos por otra vía: una reindexación parcial, un backfill, un endpoint nuevo. El índice no se queja. Simplemente, una parte del corpus juega con ventaja. Es de los fallos más difíciles de ver porque no produce ningún error, solo un ranking algo peor.
Cambiar la métrica de un índice ya construido. Los índices aproximados tipo HNSW construyen el grafo de vecinos usando la métrica que les diste al crearlos. Cambiarla después no reordena resultados: invalida la estructura sobre la que se navega. Hay que reindexar.
Asumir que tu modelo devuelve norma 1. Muchos la devuelven, y por eso el error tarda en aparecer. Otros no, y algunos dejan de hacerlo al cambiar de versión o al pasar de la API al modelo autoalojado. No lo asumas: compruébalo.
Ese último punto cabe en un test de cuatro líneas y te ahorra una tarde de depuración:
def test_embeddings_normalizados():
v = modelo.encode(["texto de prueba", "otro texto bastante más largo que el anterior"])
normas = np.linalg.norm(v, axis=1)
assert np.allclose(normas, 1.0, atol=1e-3), f"normas fuera de rango: {normas}"
Qué hacer el lunes
Abre tu pipeline de ingestión y responde a tres preguntas. ¿Qué métrica tiene configurada el índice? ¿Cuál es la norma media de los vectores que estás guardando? ¿Existe algún camino por el que entren documentos sin pasar por la normalización?
Si las tres respuestas no encajan, tienes un sesgo de longitud silencioso en tu recuperación. No se manifiesta como una caída de servicio ni como una excepción en los logs: se manifiesta como respuestas un poco peores de lo que deberían ser, todos los días, sin que nadie sepa por qué.
Normaliza en la ingestión, indexa por producto interno y pon el test. Es media hora de trabajo y elimina una clase entera de problemas.