Busca “cachorro” en un sistema que solo compara palabras clave y un documento sobre el entrenamiento de perros no aparecerá, aunque cualquiera que leyera ambos los consideraría estrechamente relacionados. La búsqueda por palabras clave compara cadenas de texto. No sabe que “cachorro” y “perro” significan casi lo mismo. Los embeddings resuelven justo eso. Convierten un texto en una lista de números, un vector, ubicado de forma que los textos con significado parecido queden cerca de otros textos con significado parecido, sin importar qué palabras usen.

Cómo un modelo de embeddings convierte el texto en números

Un modelo de embeddings es una red neuronal entrenada para mapear texto a un punto en un espacio de alta dimensionalidad: 384, 768 o 1536 dimensiones son tamaños habituales, según el modelo. No busca palabras en un diccionario. A partir de enormes cantidades de texto ha aprendido qué palabras y frases aparecen en contextos parecidos, y lo codifica en el punto hacia el que apunta el vector.

La distancia en ese espacio refleja la similitud de significado. “Rey” y “reina” quedan cerca. “Rey” y “golden retriever” quedan lejos. Nadie le dijo al modelo que comparara esas palabras; la geometría surge del entrenamiento, y la comparación es consecuencia de dónde termina cada palabra.

Puedes ver la misma forma con números elegidos a mano, antes de que intervenga un modelo real. Asigna a cada palabra un vector de 3 números (un modelo real usa cientos):

1
2
3
king  = [0.90, 0.10, 0.85]
queen = [0.88, 0.12, 0.10]
dog   = [0.05, 0.95, 0.40]

king y queen coinciden mucho en los dos primeros números. dog no. Para convertir “coinciden mucho” en una puntuación única, hace falta una métrica de similitud.

Cosine similarity, dot product y Euclidean distance comparados

Tres métricas aparecen constantemente en el trabajo con embeddings, y cada una responde a una pregunta ligeramente distinta:

MétricaQué mideCuándo usarla
Cosine similarityEl ángulo entre dos vectores, ignorando su longitudOpción por defecto para embeddings de texto, porque la mayoría de los modelos se entrena para esto
Dot productÁngulo y longitud juntosEl modelo fue entrenado explícitamente para ranking por dot product (algunos modelos de retrieval lo están)
Euclidean distanceDistancia en línea recta entre los dos puntosDatos espaciales o basados en conteos; menos común para texto

La cosine similarity es la que usarás casi siempre. Muchos modelos de text embeddings normalizan su salida a longitud 1, así que la dirección es lo único que varía, y en vectores normalizados cosine y dot product dan el mismo ranking. La puntuación va de -1 (opuesto) a 1 (idéntico); 0 significa que no hay relación. Así se ve con los vectores elegidos a mano:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import numpy as np

def cosine_similarity(a, b):
    a, b = np.array(a, dtype=float), np.array(b, dtype=float)
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))

king  = [0.90, 0.10, 0.85]
queen = [0.88, 0.12, 0.10]
dog   = [0.05, 0.95, 0.40]

print(cosine_similarity(king, queen))  # 0.801
print(cosine_similarity(king, dog))    # 0.375

0.801 contra 0.375. king queda mucho más cerca de queen que de dog. Un modelo real produce el mismo tipo de diferencia, repartida en cientos de dimensiones que aprendió de los datos en lugar de tres elegidas a mano.

Cómo generar embeddings en Python

sentence-transformers es la librería open-source estándar para esto. Funciona en local sin necesitar una API key:

1
pip install sentence-transformers
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
sentences = [
    "The weather is lovely today.",
    "It's so sunny outside!",
    "He drove to the stadium.",
]
embeddings = model.encode(sentences)          # shape: (3, 384)
print(model.similarity(embeddings, embeddings))

all-MiniLM-L6-v2 convierte cada frase en un vector de 384 números, y model.similarity calcula la cosine similarity para cada par. Las dos frases sobre el buen tiempo casi no comparten palabras, y aun así puntúan alto entre sí y bajo frente a la del estadio. El mismo patrón que el ejemplo de juguete, salvo que ahora los números vienen de un modelo que aprendió lenguaje.

Las API alojadas devuelven lo mismo. Los endpoints text-embedding-3-small de OpenAI y embed de Cohere entregan ambos una lista de números decimales por cada texto. Si ya ejecutas modelos en local, Ollama también sirve embeddings: ollama pull nomic-embed-text, y luego llama a su endpoint /api/embed (la configuración se explica en Cómo Ejecutar un LLM en Local con Ollama). La librería cambia. El vector, y lo que haces con él, no.

Cómo la búsqueda por similitud vectorial encuentra la coincidencia más cercana

Calcular el embedding de un solo texto es la parte fácil. El trabajo real suele ser: dado un vector de consulta, encontrar las coincidencias más cercanas entre miles o millones de vectores almacenados. Eso es la búsqueda por similitud vectorial, y la versión directa es fuerza bruta: puntuar la consulta contra cada vector almacenado y quedarse con los mejores resultados.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import numpy as np, time

rng = np.random.default_rng(0)

for n in [10_000, 100_000, 1_000_000]:
    vectors = rng.normal(size=(n, 384)).astype(np.float32)
    query = rng.normal(size=384).astype(np.float32)

    start = time.perf_counter()
    scores = vectors @ query / (np.linalg.norm(vectors, axis=1) * np.linalg.norm(query))
    top5 = np.argsort(scores)[-5:]
    elapsed = time.perf_counter() - start
    print(f"n={n:>9,}  {elapsed*1000:.2f} ms")

En una máquina normal, esto imprime algo cercano a:

1
2
3
n=   10,000  0.70 ms
n=  100,000  7.82 ms
n=1,000,000  63.64 ms

El tiempo crece linealmente con el número de vectores, O(n) en los términos de la notación Big O, pero NumPy hace que cada paso sea tan barato que un millón de vectores tarda muy por debajo de 100 ms. Eso deja de ser suficiente con tráfico concurrente alto, decenas de millones de vectores, o ambos. En ese punto necesitas un índice approximate nearest neighbor (ANN), que hace por los vectores lo que un B-tree hace por una cláusula WHERE en una base de datos (ver Índices de Base de Datos: Cómo Funcionan y Cuándo Usarlos). Dos tipos aparecen en todas partes:

  • HNSW (Hierarchical Navigable Small World) construye un grafo multicapa que conecta cada vector con sus vecinos, así una búsqueda recorre el grafo hacia la respuesta en lugar de tocar cada vector. Da el mejor recall por milisegundo entre las opciones habituales, y lo paga con más memoria y construcciones de índice más lentas.
  • IVFFlat agrupa los vectores en clústeres alrededor de centroides, y luego busca solo en los clústeres más cercanos a la consulta. Se construye más rápido y usa menos memoria que HNSW, pero necesita datos representativos cargados antes de crearlo. Construido sobre una tabla vacía, produce clústeres malos y mal recall.

En Postgres con la extensión pgvector, cada uno es una sola instrucción:

1
2
3
4
5
-- HNSW: se puede construir en cualquier momento, incluso sobre una tabla vacía
CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops);

-- IVFFlat: carga primero datos representativos, luego construye
CREATE INDEX ON items USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);

Ambos cambian exactitud por velocidad. Pueden no encontrar el vector realmente más cercano, lo cual está bien para búsqueda y recomendaciones y es un problema para cualquier caso que siempre necesite la respuesta exacta.

Errores comunes en la búsqueda con embeddings

  • Consultar con un modelo y almacenar con otro. Los vectores de dos modelos distintos viven en espacios incompatibles. Si cambias de modelo, vuelve a calcular el embedding de cada documento almacenado con el nuevo. Una mezcla de vectores viejos y nuevos devuelve coincidencias inútiles sin lanzar ningún error.
  • Ranking por dot product sobre vectores nunca normalizados. El dot product incluye la longitud, así que un vector largo puede superar en ranking a uno más corto y más relevante. Normaliza al escribir, o usa el operador cosine, que ignora la longitud.
  • Tratar la cercanía como corrección. Una puntuación cosine alta significa “aparece en contextos parecidos”, no “es la respuesta correcta”. Una consulta sobre los efectos secundarios de un medicamento puede devolver un fragmento sobre la dosis de ese mismo medicamento: relacionado, y equivocado. Este es el principal punto débil en los pipelines de RAG. La similitud reduce los candidatos. No los verifica.
  • Añadir un índice ANN antes de que la fuerza bruta sea realmente demasiado lenta. El benchmark anterior maneja un millón de vectores en menos de 100 ms. HNSW o IVFFlat añaden tiempo de construcción, parámetros que ajustar y resultados aproximados. Merece la pena pasadas las decenas de millones de vectores o bajo carga concurrente alta, no por defecto.
  • Olvidar recalcular el embedding cuando cambia la fuente. Un embedding es una foto del texto en el momento en que lo calculaste. Edita el documento sin recalcularlo y la búsqueda seguirá devolviendo la versión anterior con total confianza.

¿Necesitas una base de datos vectorial?

No para empezar. Calcula el embedding de unos cientos de tus documentos reales con sentence-transformers, guarda los vectores en un array de NumPy y búscalos con el bucle de fuerza bruta de arriba. Sin base de datos, sin índice. Pasa a pgvector o a una base de datos vectorial gestionada cuando la fuerza bruta resulte medible y claramente demasiado lenta para tu volumen de datos y consultas. Añade un índice ANN encima solo cuando el escaneo de la tabla sea el cuello de botella que has medido de verdad.

Artículos relacionados