Módulo 3: Features Esenciales para RAG

Cápsula 06: Distance Metrics — la decisión geométrica que casi nadie justifica

Descripción de la cápsula

Cuando creaste tu primera collection en ChromaDB, probablemente pasaste por encima del parámetro metadata={"hnsw:space": "cosine"} o aceptaste el default sin pensar. Es la decisión menos visible y una de las más caras de equivocar: define qué considera tu sistema "similar" y no se puede cambiar sin re-embebir todo el dataset.

Una distance metric no es un detalle técnico. Es una asunción geométrica sobre el espacio de embeddings. Cosine asume que la dirección importa y la magnitud no. L2 (euclidean) asume que ambas importan. Dot product asume que tus vectores ya están normalizados y vas a optimizar velocidad. Cada asunción es válida en su contexto y rota fuera de él. Si tu modelo de embeddings recomienda cosine y tú elegiste L2 "porque sonaba más matemático", el sistema funciona — pero recall cae 5-15% y los resultados ranquean en orden incorrecto sin que sea evidente por qué.

Esta cápsula te da el modelo mental geométrico para entender qué hace cada métrica, cómo elegir la correcta para tu modelo de embeddings, y cómo evitar el error caro: cambiar la métrica de una collection en producción sin re-embeber.

Al finalizar esta cápsula serás capaz de:

  • ✅ Explicar la diferencia conceptual entre cosine, euclidean (L2) y dot product en términos de geometría del espacio
  • ✅ Elegir la métrica correcta para un modelo de embeddings dado (OpenAI, Cohere, image embeddings, custom models)
  • ✅ Configurar la métrica en ChromaDB con hnsw:space y verificar que coincide con la recomendación del modelo
  • ✅ Identificar cuándo dot product gana sobre cosine y por qué
  • ✅ Anticipar el error más caro: cambiar la métrica de una collection sin re-embebir
  • ✅ Diagnosticar resultados ranqueados extrañamente como posible mismatch de métrica

Tiempo estimado: 30-40 minutos


El modelo mental: tres geometrías distintas del mismo espacio

Imagina dos vectores en 2D para que se vea fácil. En realidad son de 384 o 1536 dimensiones, pero la lógica escala.

       y
       │     ↗ B (3, 4)
       │   ↗
       │ ↗
       │↗  ↗ A (1.5, 2)
       │↗
       │
       ─────────────── x

Vectores A y B apuntan exactamente en la misma dirección — B es el doble de largo que A, pero los dos van hacia la misma "esquina" del espacio. ¿Son similares?

La respuesta depende de qué métrica uses:

Cosine similarity: solo importa la dirección

Cosine mide el ángulo entre dos vectores, ignorando la magnitud. Si dos vectores apuntan al mismo lado (ángulo cero), su cosine similarity es 1, sin importar qué tan largos sean.

cosine(A, B) = (A · B) / (||A|| × ||B||)

Para A=(1.5, 2) y B=(3, 4):
A · B = 1.5×3 + 2×4 = 4.5 + 8 = 12.5
||A|| = √(1.5² + 2²) = √6.25 = 2.5
||B|| = √(3² + 4²) = √25 = 5

cosine(A, B) = 12.5 / (2.5 × 5) = 12.5 / 12.5 = 1.0

Cosine similarity = 1.0 → idénticos en dirección. La métrica los considera "iguales" para fines de retrieval.

Distancia coseno (que es lo que ChromaDB realmente almacena): 1 - cosine_similarity. Es decir, va de 0 (idénticos) a 2 (opuestos). En el ejemplo: 1 - 1.0 = 0.0.

Cuándo importa la magnitud cero (cosine la ignora): en text embeddings, la magnitud del vector está correlacionada con la longitud del texto, no con su significado. Un párrafo de 200 palabras y un párrafo de 50 palabras sobre el mismo tema producen vectores con direcciones parecidas pero magnitudes muy distintas. Cosine los empareja correctamente. L2 los considera "lejanos" por la diferencia de magnitud.

Euclidean (L2): importan dirección Y magnitud

L2 mide la distancia en línea recta entre los puntos finales de los vectores.

L2(A, B) = √(Σ(A_i - B_i)²)

Para A=(1.5, 2) y B=(3, 4):
L2(A, B) = √((1.5-3)² + (2-4)²)
        = √((-1.5)² + (-2)²)
        = √(2.25 + 4)
        = √6.25
        = 2.5

L2 distance = 2.5 → relativamente cercanos pero no idénticos. La métrica los considera "parecidos" pero distinguibles.

Cuándo importa esta diferencia: en image embeddings, la magnitud del vector puede estar correlacionada con propiedades visuales reales (intensidad, contraste). Para reconocimiento facial, dos caras parecidas pueden tener vectores con la misma dirección pero magnitudes que reflejan iluminación distinta — y L2 captura esa diferencia.

Dot product: la apuesta geométrica para velocidad

Dot product es matemáticamente el numerador de cosine (sin dividir por las magnitudes):

dot(A, B) = A · B = Σ(A_i × B_i)

Para A=(1.5, 2) y B=(3, 4):
dot(A, B) = 1.5×3 + 2×4 = 4.5 + 8 = 12.5

Dot product = 12.5 → un número que crece cuando los vectores son grandes Y apuntan al mismo lado.

El truco: si todos tus vectores están normalizados (longitud 1), entonces ||A|| = ||B|| = 1, y dot(A, B) = cosine(A, B). En ese caso, dot product es matemáticamente equivalente a cosine pero se calcula 30-40% más rápido (no necesita las dos divisiones por magnitud).

El peligro: si tus vectores no están normalizados, dot product devuelve resultados nonsense. Un vector grande que apunta a cualquier lado va a "ganar" siempre por su magnitud, no por su dirección.


Cuál métrica usar según tu modelo de embeddings

Aquí está la tabla que te ahorrará discusiones:

Modelo de embeddingsMétrica recomendada¿Por qué?
OpenAI text-embedding-3-small / -largecosine (o dot product si normalizas)OpenAI recomienda cosine en su documentación. Los embeddings ya vienen casi normalizados, así que dot product también funciona
OpenAI text-embedding-ada-002 (legacy)cosineVectores normalizados a magnitud 1. Cosine es lo natural
Cohere embed-english / embed-multilingualcosineCohere documenta cosine como la métrica esperada
Sentence Transformers (all-MiniLM-L6-v2)cosineDefault ChromaDB, modelo entrenado con cosine como objetivo
Sentence Transformers (multi-qa-MiniLM)dot productEspecíficamente entrenado para dot product con vectores normalizados
Image embeddings (CLIP, ResNet)cosine en CLIP, L2 en otrosCLIP usa cosine; ResNet y otros image classifiers tradicionales usan L2
Face recognition (FaceNet, ArcFace)L2 (euclidean)Las distancias L2 corresponden a "qué tan parecidas son las caras"
Custom modelsLo que recomiende el paper o la documentaciónNunca asumas, siempre verifica

La regla de oro

Usa la métrica con la que el modelo fue entrenado. Cada modelo de embeddings se entrenó optimizando una función de pérdida específica (ej: contrastive loss con cosine). Si usas otra métrica al inferir, estás midiendo algo distinto a lo que el modelo aprendió a optimizar.

¿Cómo lo verificas? Lee la documentación oficial del modelo. Hugging Face muestra "Cosine Similarity" en la tarjeta del modelo si ese es el caso. OpenAI dice explícitamente "cosine similarity" en su guía de embeddings. Si la documentación no lo dice, busca el paper original — la métrica está en la sección de evaluación.


Comparación práctica: las tres métricas sobre el mismo dataset

Vamos a ver el efecto en resultados reales. Mismo dataset, misma query, tres collections con tres métricas distintas.

# experimento_metricas.py
import chromadb
from chromadb.utils import embedding_functions
import os

openai_ef = embedding_functions.OpenAIEmbeddingFunction(
    api_key=os.getenv("OPENAI_API_KEY"),
    model_name="text-embedding-3-small"
)

client = chromadb.PersistentClient(path="./chroma_metrics_test")

# Mismo dataset, tres collections con métricas distintas
docs = [
    "ChromaDB uses HNSW algorithm for approximate nearest neighbor search.",
    "Pinecone is a managed vector database service for production deployments.",
    "Cosine similarity measures the angle between two vectors.",
    "Vector databases are optimized for high-dimensional similarity search.",
    "RAG systems combine retrieval with language model generation.",
]
ids = [f"doc_{i}" for i in range(len(docs))]

# Cosine (recomendado para OpenAI)
collection_cosine = client.get_or_create_collection(
    name="metrics_cosine",
    embedding_function=openai_ef,
    metadata={"hnsw:space": "cosine"}
)
collection_cosine.add(documents=docs, ids=ids)

# L2 (NO recomendado para text embeddings, pero veámoslo)
collection_l2 = client.get_or_create_collection(
    name="metrics_l2",
    embedding_function=openai_ef,
    metadata={"hnsw:space": "l2"}
)
collection_l2.add(documents=docs, ids=ids)

# Inner product (= dot product)
collection_ip = client.get_or_create_collection(
    name="metrics_ip",
    embedding_function=openai_ef,
    metadata={"hnsw:space": "ip"}
)
collection_ip.add(documents=docs, ids=ids)

query = "What algorithm does ChromaDB use for similarity search?"

print("=== COSINE (recomendado para OpenAI) ===")
results = collection_cosine.query(query_texts=[query], n_results=3)
for doc, dist in zip(results['documents'][0], results['distances'][0]):
    print(f"  {dist:.3f} | {doc[:60]}...")

print("\n=== L2 (no recomendado para text embeddings de OpenAI) ===")
results = collection_l2.query(query_texts=[query], n_results=3)
for doc, dist in zip(results['documents'][0], results['distances'][0]):
    print(f"  {dist:.3f} | {doc[:60]}...")

print("\n=== INNER PRODUCT / DOT (cuando vectores ya están normalizados) ===")
results = collection_ip.query(query_texts=[query], n_results=3)
for doc, dist in zip(results['documents'][0], results['distances'][0]):
    print(f"  {dist:.3f} | {doc[:60]}...")

Output típico:

=== COSINE (recomendado para OpenAI) ===
  0.198 | ChromaDB uses HNSW algorithm for approximate nearest neig...
  0.412 | Vector databases are optimized for high-dimensional simil...
  0.498 | Cosine similarity measures the angle between two vectors...

=== L2 (no recomendado para text embeddings de OpenAI) ===
  0.629 | ChromaDB uses HNSW algorithm for approximate nearest neig...
  0.918 | Vector databases are optimized for high-dimensional simil...
  0.998 | Cosine similarity measures the angle between two vectors...

=== INNER PRODUCT / DOT ===
  -0.802 | ChromaDB uses HNSW algorithm for approximate nearest neig...
  -0.587 | Vector databases are optimized for high-dimensional simil...
  -0.501 | Cosine similarity measures the angle between two vectors...

Observaciones críticas:

  1. El ranking es el mismo en las tres métricas en este ejemplo, porque los embeddings de OpenAI vienen casi normalizados y los documentos son lo suficientemente distintos como para que cualquier métrica los separe.

  2. Las distancias absolutas son completamente distintas:

    • Cosine: rango [0, 2]
    • L2: rango [0, ∞]
    • Dot product: rango [-∞, ∞] (negativo porque ChromaDB devuelve negativo del IP para mantener "menor = más similar")
  3. Si aplicas un threshold (ej: descartar resultados con distance > 0.5), el threshold tiene que coincidir con la métrica. Un threshold de 0.5 que funciona bien con cosine es absurdo con L2 (rechazaría casi todo) o con dot product (rechazaría casi nada por los signos).

Cuándo el ranking sí cambia entre métricas

En datasets más grandes y con queries cerca de la frontera entre dos clusters, las tres métricas pueden devolver órdenes distintos:

# Ejemplo donde las métricas divergen
docs_difficult = [
    "ChromaDB uses HNSW for approximate nearest neighbor search.",       # Texto corto y técnico
    "ChromaDB is an open-source embedding database designed for AI applications, supporting HNSW indexing, metadata filtering, persistent storage, and integration with popular embedding models including OpenAI, Cohere, and Sentence Transformers, making it suitable for both prototyping and production RAG systems.",  # Texto largo, más contexto
]

# Query corta
query_short = "HNSW algorithm"

# Con cosine: ambos rankean alto porque la dirección semántica es similar
# Con L2: el segundo rankea más bajo porque su vector tiene magnitud mayor (texto más largo)
# → diferencia de ranking observable cuando hay variación grande de longitudes de documento

Por eso elegir la métrica que coincide con tu modelo importa más cuando tu dataset tiene textos de longitudes muy variables.


Configurando la métrica en ChromaDB

ChromaDB soporta tres valores para hnsw:space:

ValorMétricaCuándo usarlo
"cosine"Cosine distanceDefault y recomendado para text embeddings. OpenAI, Cohere, Sentence Transformers
"l2"Squared L2 distanceImage embeddings tradicionales (ResNet), face recognition
"ip"Inner product (dot product)Embeddings ya normalizados donde quieres optimizar velocidad
# Cosine (default + recomendado para text)
collection = client.create_collection(
    name="my_collection",
    metadata={"hnsw:space": "cosine"}
)

# L2 (image embeddings, custom models que lo requieran)
collection = client.create_collection(
    name="my_collection",
    metadata={"hnsw:space": "l2"}
)

# Inner product (vectores normalizados, optimización)
collection = client.create_collection(
    name="my_collection",
    metadata={"hnsw:space": "ip"}
)

Verificar la métrica de una collection existente

collection = client.get_collection("my_collection")
print(collection.metadata)
# {'hnsw:space': 'cosine'}

Si no aparece la clave hnsw:space, ChromaDB está usando el default (l2 antes de v0.4.0, cosine después). Verifícalo siempre antes de asumir — diferencias entre versiones han causado bugs en migraciones.

Casos especiales: distance threshold por métrica

Cuando configuras un score_threshold para descartar resultados de baja calidad, el valor depende de la métrica:

# Cosine distance: rango típico [0, 2], threshold útil 0.4-0.7
def is_relevant_cosine(distance: float) -> bool:
    return distance < 0.5  # 0.5 ≈ "moderadamente relevante"

# L2 distance: rango [0, ∞], depende del dataset
def is_relevant_l2(distance: float, max_distance: float) -> bool:
    # Necesitas calcular max_distance empíricamente sobre tu dataset
    return distance < (max_distance * 0.4)

# Inner product (negativo): valores más negativos = más similares
def is_relevant_ip(distance: float) -> bool:
    return distance < -0.5  # Más negativo = más similar (en ChromaDB)

Práctica recomendada: corre el sistema sobre 100-200 queries de tu eval set, ploteá la distribución de distancias de los chunks correctos vs los incorrectos, y elige el threshold donde se separan. No copies thresholds de tutoriales — el rango óptimo depende de tu dataset.


Trampas y errores comunes

Trampa 1: cambiar la métrica sin re-embebir

El error:

# Día 1: creas collection con L2
collection_v1 = client.create_collection(
    "docs", metadata={"hnsw:space": "l2"}
)
collection_v1.add(documents=mil_docs, ids=mil_ids)

# Día 30: alguien dice "deberíamos usar cosine para text embeddings"
# Borras y recreas, pensando que solo cambia la métrica
client.delete_collection("docs")
collection_v2 = client.create_collection(
    "docs", metadata={"hnsw:space": "cosine"}
)
# ❌ Te falta re-insertar los docs

Síntoma A: olvidaste re-insertar y la collection queda vacía.

Síntoma B: re-insertaste, pero hiciste copy-paste de los IDs viejos asumiendo que los embeddings se recalcularían. Si guardaste los embeddings precomputados en algún lado y los pasaste con embeddings=..., los embeddings son los mismos (calculados con el modelo original) — y la métrica nueva los interpreta distinto pero los datos no cambian.

Cómo prevenir: la regla es simple — cambiar hnsw:space requiere re-embebir todos los documentos desde el texto original. Si solo tienes los embeddings y no los textos, no puedes cambiar la métrica responsablemente.

Trampa 2: dot product con vectores no normalizados

El error:

# Embeddings de un modelo custom que NO normaliza outputs
custom_embeddings = [
    [0.5, 0.1, 0.2, ...],  # magnitud 0.55
    [5.0, 1.0, 2.0, ...],  # magnitud 5.5 — el mismo vector escalado 10x
    [0.3, 0.6, 0.1, ...],  # magnitud 0.7
]

collection = client.create_collection(
    "test", metadata={"hnsw:space": "ip"}  # ← inner product
)
collection.add(embeddings=custom_embeddings, documents=docs, ids=ids)

# Query
results = collection.query(query_embeddings=[[1, 1, 1, ...]], n_results=3)

Síntoma: el vector con magnitud 5.5 va a "ganar" siempre, sin importar si está cerca de la query semánticamente. La magnitud domina sobre la dirección.

Por qué pasa: dot product crece linealmente con la magnitud. Vectores grandes obtienen scores grandes mecánicamente.

Cómo prevenir: antes de usar ip, normaliza tus embeddings:

import numpy as np

def normalize(vec):
    norm = np.linalg.norm(vec)
    return (np.array(vec) / norm).tolist() if norm > 0 else vec

normalized_embeddings = [normalize(e) for e in custom_embeddings]
collection.add(embeddings=normalized_embeddings, documents=docs, ids=ids)

O simplemente usa cosine que normaliza implícitamente — pierdes la pequeña ventaja de velocidad pero ganas robustez.

Trampa 3: confundir similaridad con distancia

El error: lees un tutorial que dice "cosine similarity de 0.85 es muy bueno" e intentas filtrar con distance > 0.85 en ChromaDB.

Síntoma: filtras al revés. Los resultados que deberías guardar son los que descartas.

Por qué pasa: ChromaDB devuelve distancia, no similaridad.

  • Cosine similarity: rango [-1, 1], 1 = idéntico, -1 = opuesto
  • Cosine distance (lo que ChromaDB devuelve): 1 - cosine_similarity, rango [0, 2], 0 = idéntico

Una similaridad de 0.85 corresponde a una distancia de 0.15.

Cómo prevenir: lee siempre la documentación de la herramienta. ChromaDB documenta explícitamente: "distances: smaller is more similar". Cuando dudes, hace una sanity check con dos textos idénticos:

collection.add(documents=["test"], ids=["a"])
result = collection.query(query_texts=["test"], n_results=1)
print(result['distances'])  # Debería ser ~0.0, no ~1.0

Trampa 4: usar L2 con OpenAI embeddings "porque l2 es más estándar en ML"

El error: alguien con background fuerte de ML clásico llega al equipo y argumenta que L2 es "la métrica estándar". Cambian la métrica de cosine a L2 sin cambiar el modelo de embeddings.

Síntoma: recall medido sobre el eval set baja 5-15%. Los resultados ranquean diferente, a veces mejor en queries cortas y peor en queries largas (por la sensibilidad a magnitud).

Por qué pasa: los embeddings de OpenAI fueron optimizados con una loss function basada en cosine. Usar L2 al inferir compara geometrías que el modelo nunca aprendió a producir.

Cómo prevenir: pegar la documentación oficial. OpenAI dice cosine. ChromaDB default cosine. Cohere dice cosine. Si el modelo no dice explícitamente "L2", no uses L2.

Trampa 5: asumir que ChromaDB usa la métrica que tú esperas

El error: creaste la collection sin pasar metadata={"hnsw:space": ...}, asumiendo que el default era cosine. Pero estás usando una versión vieja de ChromaDB donde el default era L2.

Síntoma: queries que en otro sistema rankeaban A→B→C aquí rankean B→A→C. Inexplicable hasta que verificas el metadata de la collection.

Cómo prevenir: siempre especifica explícitamente la métrica al crear collections. No confíes en defaults entre versiones.

# ❌ Confiar en default
collection = client.create_collection("docs")

# ✅ Explícito
collection = client.create_collection(
    "docs", metadata={"hnsw:space": "cosine"}
)

Trampa 6: comparar distancias entre collections con métricas distintas

El error: tienes dos collections, una con cosine y otra con L2 (digamos, una para texto y otra para imágenes en una app multimodal). Quieres "rankear conjuntamente" los resultados de ambas.

Síntoma: comparas distancia 0.4 (cosine, decentemente similar) con 4.5 (L2, depende del dataset) y el ranking no tiene sentido.

Por qué pasa: las métricas tienen rangos y semánticas distintas. No son comparables directamente.

Cómo prevenir: si necesitas combinar resultados de múltiples retrieval, normaliza los scores a un rango común [0, 1]:

def normalize_scores(distances, metric):
    """Normaliza distancias a similaridad [0, 1] donde 1 es más similar."""
    if metric == "cosine":
        # Cosine distance [0, 2] → similarity [1, 0]
        return [1 - (d / 2) for d in distances]
    elif metric == "l2":
        # L2 [0, ∞] requiere normalización empírica
        max_d = max(distances)
        return [1 - (d / max_d) for d in distances]
    elif metric == "ip":
        # Inner product (negativo en Chroma) → similarity
        # Asume embeddings normalizados, rango [-1, 0]
        return [1 + d for d in distances]  # de [-1, 0] a [0, 1]

Esta es una solución parcial. Para combinación robusta de retrievals, considerá técnicas como Reciprocal Rank Fusion (RRF), que ignora los scores absolutos y combina rankings — cubierto en la guía #8 (Advanced RAG).


Ejercicio aplicado

Escenario: estás revisando el código de un compañero que está construyendo un sistema multimodal de búsqueda para una empresa de moda. El sistema permite buscar productos por (a) descripción textual y (b) imagen de referencia. Tu compañero te pasa este snippet:

# search_service.py
from chromadb import PersistentClient
from chromadb.utils import embedding_functions

client = PersistentClient(path="./chroma_fashion")

# Para embeddings de texto: usa text-embedding-3-small de OpenAI
text_ef = embedding_functions.OpenAIEmbeddingFunction(
    api_key=API_KEY, model_name="text-embedding-3-small"
)
products_text = client.create_collection(
    "products_text",
    embedding_function=text_ef,
    metadata={"hnsw:space": "ip"}  # ← decidió esto
)

# Para embeddings de imagen: usa CLIP via custom embedding function
products_image = client.create_collection(
    "products_image",
    embedding_function=clip_ef,  # función custom que llama a CLIP
    metadata={"hnsw:space": "l2"}  # ← decidió esto
)

# Función para combinar resultados
def hybrid_search(text_query: str, image_path: str, top_k: int = 10):
    text_results = products_text.query(query_texts=[text_query], n_results=top_k)
    image_results = products_image.query(query_images=[image_path], n_results=top_k)

    # Combina los rankings sumando las distancias (?)
    combined = {}
    for doc_id, dist in zip(text_results['ids'][0], text_results['distances'][0]):
        combined[doc_id] = combined.get(doc_id, 0) + dist
    for doc_id, dist in zip(image_results['ids'][0], image_results['distances'][0]):
        combined[doc_id] = combined.get(doc_id, 0) + dist

    return sorted(combined.items(), key=lambda x: x[1])[:top_k]

Pregunta: identifica los tres errores conceptuales sobre distance metrics en este código y explica cómo corregir cada uno.

Solución

Error 1: hnsw:space="ip" para text embeddings de OpenAI

text-embedding-3-small de OpenAI viene casi normalizado (magnitud ~1) pero no exactamente. La diferencia entre los vectores no normalizados puede ser del 5-10%, lo suficiente para que dot product de scores que dependen parcialmente de la magnitud y no solo de la dirección.

OpenAI documenta explícitamente cosine como la métrica recomendada. Hay dos opciones correctas:

# Opción A (más simple): usar cosine
products_text = client.create_collection(
    "products_text",
    embedding_function=text_ef,
    metadata={"hnsw:space": "cosine"}
)

# Opción B (si quieres velocidad de IP): normalizar explícitamente los embeddings
# antes de insertarlos. Pero ChromaDB no expone esto fácilmente cuando usas
# embedding_function — para eso tendrías que pre-computar embeddings,
# normalizarlos, e insertar con embeddings=... en lugar de documents=...
# Más trabajo, marginal en velocidad. Usá cosine.

Error 2: hnsw:space="l2" con CLIP

CLIP fue entrenado con cosine similarity como objetivo (la loss function alinea representaciones de imagen y texto en un espacio donde cosine similarity refleja semejanza semántica). Usar L2 con CLIP rompe la correspondencia entre el espacio aprendido y la métrica de evaluación.

products_image = client.create_collection(
    "products_image",
    embedding_function=clip_ef,
    metadata={"hnsw:space": "cosine"}  # ← cambiar a cosine
)

Si tu compañero está pensando en image embeddings de otros modelos (ResNet, EfficientNet, FaceNet), L2 sí puede ser correcto. Pero el comentario dice CLIP — y CLIP es cosine.

Error 3: sumar distancias de cosine + L2 directamente

Aún si arregla los dos errores anteriores y queda con cosine en ambas collections, sumar distancias absolutas no produce un ranking coherente. Las distribuciones de distancia pueden tener escalas y formas distintas según el dominio (texto vs imagen) aún con la misma métrica. Y si quedaran en métricas distintas, sería completamente nonsense.

La corrección correcta usa Reciprocal Rank Fusion (RRF):

def hybrid_search(text_query: str, image_path: str, top_k: int = 10):
    text_results = products_text.query(query_texts=[text_query], n_results=top_k * 2)
    image_results = products_image.query(query_images=[image_path], n_results=top_k * 2)

    # RRF: combina rankings, no scores
    K = 60  # constante típica de RRF
    rrf_scores = {}

    for rank, doc_id in enumerate(text_results['ids'][0]):
        rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + 1 / (K + rank)

    for rank, doc_id in enumerate(image_results['ids'][0]):
        rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + 1 / (K + rank)

    return sorted(rrf_scores.items(), key=lambda x: -x[1])[:top_k]

RRF combina rankings ignorando los scores absolutos. Funciona aún si las dos collections usan métricas distintas, porque solo importa la posición en cada lista.

Resumen de la corrección:

ErrorCorrección
ip para text OpenAICambiar a cosine
l2 para CLIPCambiar a cosine
Sumar distancias directamenteUsar RRF (rankings, no scores)

Bonus: tu compañero debería documentar la elección de métrica en el código con un comentario que cite la fuente ("OpenAI docs: cosine recommended", "CLIP paper: cosine objective").


Resumen y siguiente paso

Lo que aprendiste:

  • Las distance metrics no son intercambiables — cada una asume una geometría distinta del espacio de embeddings.
  • Cosine: ignora magnitud, mide ángulo. Default para text embeddings (OpenAI, Cohere, Sentence Transformers).
  • L2 (euclidean): considera dirección y magnitud. Apropiado para image embeddings tradicionales y face recognition.
  • Dot product (ip en ChromaDB): matemáticamente equivalente a cosine cuando los vectores están normalizados, ~30% más rápido. Falla en silencio con vectores no normalizados.
  • ChromaDB devuelve distancia, no similaridad. Cosine distance va de 0 (idéntico) a 2 (opuesto). Cuidado al copiar thresholds de tutoriales que mezclan los conceptos.
  • La métrica se elige según la documentación oficial del modelo de embeddings, no según preferencia personal. OpenAI dice cosine, CLIP dice cosine, FaceNet dice L2.
  • Cambiar la métrica de una collection en producción requiere re-embebir desde el texto original — los embeddings precomputados están atados a la métrica con la que se entrenó el modelo.

Checkpoint: antes de avanzar, deberías poder:

  • Explicar a un compañero la diferencia conceptual entre cosine y L2 en términos de "qué ignora cada una".
  • Justificar la elección de métrica para un modelo dado citando documentación, no intuición.
  • Identificar los tres errores más caros de distance metrics (cambio sin re-embebir, dot product sin normalizar, sumar distancias entre métricas distintas).

Siguiente cápsula: 07 — Observability y monitoring para vector databases.

Acabas de aprender a configurar la métrica que define qué considera tu sistema "similar". Pero en producción, ¿cómo sabes si el sistema está funcionando bien según esa métrica? ¿Qué métricas de calidad debes monitorear cuando el dataset crece, los queries cambian, o el modelo de embeddings se actualiza?

La cápsula 07 te enseña qué medir, cómo medirlo, y qué thresholds disparan acción. Es el complemento operativo de las decisiones técnicas que tomaste hasta aquí.


Recursos

  1. OpenAI Embeddings — Distance metrics — Documentación oficial recomendando cosine
  2. ChromaDB — Configuring HNSW Distance Metric — Configuración oficial
  3. CLIP paper — Learning Transferable Visual Models From Natural Language Supervision — Sección 2.3 documenta el uso de cosine similarity
  4. Sentence Transformers — Choosing the Right Distance Metric — Recomendaciones por modelo
  5. Reciprocal Rank Fusion (paper original) — Para combinar resultados entre métricas
  6. Pinecone — Cosine vs Dot Product — Comparación técnica con benchmarks
  7. Vector Norms and Distances — 3Blue1Brown — Visualización geométrica de las métricas

Tiempo estimado: 30-40 minutos Siguiente: 07-observability-monitoring.md