Embeddings


Primero, el término. Un embedding es una lista de números que representa un fragmento de texto y captura su significado, de modo que textos con significados parecidos se convierten en números parecidos. Este capítulo trata sobre cómo convertir palabras en algo que una computadora puede comparar, y es lo que permite que la búsqueda funcione por significado en lugar de por palabras clave.
El texto como vectores
¿Cómo sabe una computadora que "perro" y "cachorro" están relacionados, mientras que "perro" y "engrapadora" no lo están? Las palabras no comparten ninguna letra relevante.
El truco consiste en convertir el texto en números que capturen su significado, de modo que significados parecidos terminen siendo números parecidos. Esa lista de números es un embedding, también llamado vector. Un modelo lee el texto y devuelve el vector.
Tú no interpretas los números directamente. Lo que importa es la relación entre ellos.
Una imagen mental aproximada: imagina cada fragmento de texto colocado como un punto en el espacio, donde las cosas con significados parecidos quedan cerca unas de otras. "Perro" y "cachorro" caen en el mismo vecindario. "Engrapadora" queda del otro lado de la ciudad. Los embeddings reales usan cientos o miles de dimensiones en lugar de las dos o tres que puedes imaginar, pero la intuición se mantiene: cercanía significa similitud.
¿De dónde salen esos números, y por qué coinciden con el significado? Un modelo de embeddings es un pariente cercano del modelo predictivo que vimos en Cómo funcionan los LLM, entrenado con una enorme cantidad de texto. Para volverse bueno en su tarea, tuvo que aprender qué palabras e ideas aparecen en contextos parecidos, la vieja idea de que conoces una palabra por la compañía que frecuenta.
"Perro" y "cachorro" aparecen en el mismo tipo de oraciones, así que el modelo aprendió a representarlos de forma parecida. "Engrapadora" frecuenta otra compañía, así que queda en otro lugar. Los cientos de números son direcciones de significado aprendidas, cada una capturando algún eje de cómo varía el texto, ninguna etiquetada de una forma que un humano pueda leer. Nunca decodificas los números, solo mides qué tan cerca están dos vectores.
Quieres una búsqueda que funcione por significado en lugar de por ortografía, de modo que "que me devuelvan mi dinero" encuentre una línea sobre reembolsos. La estrategia consiste en convertir cada fragmento de texto en un vector, una lista de números de longitud fija donde los significados parecidos quedan cerca entre sí. Un modelo de embeddings lee el texto y produce ese vector, y todo el punto es que la distancia entre vectores representa qué tan parecidos son en significado.
El vector tiene una longitud fija, su dimensión, a menudo entre varios cientos y un par de miles de números. Todo texto que conviertas en embedding con el mismo modelo sale con la misma longitud, y eso es lo que te permite comparar cualquier par de ellos. Esas dimensiones son direcciones de significado aprendidas, no categorías etiquetadas por humanos, así que no interpretas la dimensión 47 como "formalidad". Tratas el vector como un punto opaco y mides distancias.
Antes de convertir algo real en embedding, hay un paso que decide si todo el sistema funciona: el chunking. Casi nunca conviertes un documento completo en un solo vector, porque un solo vector para un manual de 20 páginas mezcla todos sus temas en un único punto promedio que no coincide bien con nada. En cambio, divides el documento en chunks, pasajes de más o menos un párrafo a una página, y conviertes cada chunk en embedding por separado. Convierte en embedding los chunks, no documentos completos, para que cada vector cargue una sola idea concreta.
El tamaño del chunk es una decisión de compromiso real que debes tomar de forma deliberada. Los chunks demasiado grandes promedian varias ideas juntas y recuperan resultados imprecisos. Los chunks demasiado pequeños pierden el contexto circundante que le daba sentido a una oración. Un punto de partida común es unos pocos cientos de tokens por chunk con un pequeño traslape entre chunks vecinos, de modo que una oración dividida en el límite entre dos chunks todavía aparezca completa en al menos uno de ellos, y luego ajustas según lo que tu recuperación efectivamente devuelve.
La búsqueda por significado descansa en una sola idea: mapear el texto a un espacio vectorial (un espacio de coordenadas de dimensión fija donde cada punto es una lista de números) de tal forma que la similitud semántica se convierta en proximidad geométrica. Un modelo de embeddings es ese mapeo: lee texto y devuelve un vector.
Todo lo que construyas encima, indexación, recuperación, ranking, es geometría sobre esos vectores. Si te equivocas en el mapeo, nada de lo que sigue puede compensarlo.
La primera palanca de diseño es qué conviertes en embedding, y eso es el chunking: dividir los documentos fuente en pasajes antes de generar los embeddings, en lugar de convertir archivos completos. La razón es que un embedding es una compresión con pérdida de su entrada en un solo punto, así que cuantas más ideas distintas contenga un chunk, más se promedian hacia un centroide vago que no recupera nada con precisión. Ajusta el tamaño del chunk a la unidad sobre la que un usuario preguntará, normalmente un pasaje de unos pocos cientos de tokens, con un pequeño traslape para que un dato que quede a caballo entre dos chunks sobreviva en al menos uno de ellos.
La estrategia de chunking es lo que define si la calidad de la recuperación se gana o se pierde, más incluso que la elección del modelo:
- Las ventanas de tamaño fijo son económicas, pero cortan oraciones a mitad de una idea.
- La división consciente de la estructura (por encabezados, párrafos o bloques de código) mantiene cada chunk coherente y suele recuperar mejor, a costa de un pipeline más elaborado.
Sea lo que elijas, guarda la fuente y la posición del chunk junto a su vector, porque en el momento de la recuperación necesitas poder señalar de dónde vino un dato, y un vector desnudo no puede decírtelo.
Una propiedad más que conviene internalizar: las dimensiones del embedding no son ejes interpretables, así que no puedes depurar la recuperación leyendo un vector. Depuras embeddings inspeccionando lo que recuperan, nunca leyendo los números. Cuando una consulta trae el chunk equivocado, la solución está en el chunking, en el modelo o en la consulta, examinados a través de los resultados recuperados, no de las coordenadas en sí.
Generar un embedding
Le pides el vector a un modelo de embeddings, de forma muy parecida a una llamada normal a un modelo, pero con un endpoint y un modelo distintos.
from openai import OpenAI
client = OpenAI()
EMBED_MODEL = "text-embedding-3-small" # punto de cambio para el modelo de embeddings
def embed(text):
response = client.embeddings.create(model=EMBED_MODEL, input=text)
return response.data[0].embedding # una lista de números
vector = embed("un perro pequeño y juguetón")
print(len(vector)) # por ejemplo, 1536 númerosEl resultado es una lista de números. Una regla importa más que las demás: compara siempre embeddings generados por el mismo modelo. Los vectores de modelos distintos viven en espacios distintos y no son comparables, como medidas en unidades diferentes. Mezclar modelos da puntajes sin sentido, siempre.
Y como toda llamada a un modelo, se te cobra por tokens, así que convertir en embedding una colección grande de documentos tiene un costo que vale la pena estimar de antemano, antes de correrlo sobre miles de documentos.
La llamada en sí es un endpoint distinto con la misma forma que una llamada de chat: envías texto, recibes un vector de vuelta.
from openai import OpenAI
client = OpenAI()
EMBED_MODEL = "text-embedding-3-small"
def embed_batch(texts):
# envía muchos textos en una sola solicitud, recibe un vector por texto
response = client.embeddings.create(model=EMBED_MODEL, input=texts)
return [item.embedding for item in response.data]
chunks = ["un perro pequeño y juguetón", "un perro joven", "un artículo de oficina"]
vectors = embed_batch(chunks) # un vector por chunk, en el mismo orden de entradaLa forma varía según el proveedor, pero la idea de solicitud y respuesta es la misma en todas partes. Dos puntos prácticos más allá de la llamada básica. Primero, convierte en embedding por lotes: la mayoría de los endpoints aceptan una lista de textos en una sola solicitud y devuelven un vector por cada uno, lo cual es más rápido y económico que una llamada por chunk, porque pagas menos overhead de solicitud y te topas menos con los límites de tasa. Cuando indexas un corpus grande, procesar por lotes es lo que mantiene el costo y el tiempo real dentro de lo razonable.
Segundo, la regla del mismo modelo tiene una compañera: el modelo y la dimensión son una decisión, no algo que se deja por defecto. Los modelos de embeddings más grandes, con más dimensiones, capturan distinciones más finas, pero cuestan más de calcular, almacenar y comparar. Elige el modelo y la dimensión más pequeños que aun así logren separar tus datos.
Algunos modelos incluso te permiten pedir un vector más corto para intercambiar un poco de precisión por almacenamiento más económico y búsquedas más rápidas. Lo que elijas se vuelve un contrato: si cambias el modelo más adelante, tendrás que regenerar cada vector almacenado.
La superficie de la API es pequeña. Envías texto, recibes un vector de flotantes, y las decisiones interesantes están alrededor de esa llamada, no dentro de ella.
from openai import OpenAI
client = OpenAI()
EMBED_MODEL = "text-embedding-3-small"
def embed_batch(texts):
# una solicitud, muchos vectores de vuelta, en el orden de entrada
response = client.embeddings.create(model=EMBED_MODEL, input=texts)
return [item.embedding for item in response.data]La forma de la solicitud y la respuesta varía según el proveedor, pero todo endpoint de embeddings sigue este mismo contrato: entran lotes, salen vectores. Procesar por lotes no es un lujo a escala, es la diferencia entre un trabajo de indexación que termina y uno que se estanca toda la tarde: cada solicitud carga un overhead fijo y cuenta contra un límite de tasa, así que amortiza ambos enviando lotes grandes, y luego retrocede y reintenta ante las fallas parciales que un trabajo grande inevitablemente tendrá.
El modelo y la dimensión son un compromiso arquitectónico con consecuencias a largo plazo. Los vectores de mayor dimensión almacenan más distinción, pero cuestan más en cada eje que importa en producción: el almacenamiento por vector, la memoria del índice y el tiempo por comparación escalan con la dimensión. Algunos modelos soportan embeddings tipo Matryoshka (vectores que puedes truncar a una longitud más corta y aun así usar), que te permiten conservar vectores completos para precisión y servir un prefijo truncado para una primera búsqueda económica. Vale la pena cuando tu corpus es lo bastante grande como para que el almacenamiento y la latencia de consulta, y no la calidad del modelo, sean la restricción que te limita.
El compromiso que muerde más adelante es el versionado. Un embedding almacenado queda ligado para siempre a una versión exacta del modelo.
Los vectores de dos versiones de modelo no son comparables, así que no puedes convertir en embedding una consulta nueva con un modelo más reciente y compararla contra un índice construido con el anterior. El resultado es silencioso: los puntajes parecen plausibles y los rankings están mal. Trata la versión del modelo como parte del esquema de tu índice, guárdala junto a los vectores, y planea regenerar los embeddings como una migración, algo que se cubre más adelante en este capítulo, en la discusión sobre actualidad de los datos.
Medir la similitud
Para saber qué tan relacionados están dos fragmentos de texto, mides qué tan cerca están sus vectores. La medida estándar es la similitud coseno, que devuelve un número entre -1 y 1: más cerca de 1 significa más similar, cerca de 0 significa que no están relacionados. No necesitas conocer las matemáticas detrás para usarla. Piénsalo como comparar hacia dónde apuntan dos vectores.
La dirección es lo que carga el significado aquí, por eso el coseno compara el ángulo en lugar de la distancia bruta. Dos textos sobre reembolsos apuntan hacia el mismo lado, sin importar si uno es una frase corta y el otro un párrafo largo.
import math
def cosine_similarity(a, b):
dot = sum(x * y for x, y in zip(a, b))
mag_a = math.sqrt(sum(x * x for x in a))
mag_b = math.sqrt(sum(x * x for x in b))
return dot / (mag_a * mag_b)
perro = embed("un perro pequeño y juguetón")
cachorro = embed("un perro joven")
engrapadora = embed("un artículo de oficina para unir papel")
print(cosine_similarity(perro, cachorro)) # más alto, los dos son cercanos en significado
print(cosine_similarity(perro, engrapadora)) # más bajo, los dos no están relacionadosLos números confirman la intuición: perro y cachorro puntúan más alto, perro y engrapadora puntúan más bajo. Comparar el vector de una consulta contra vectores almacenados es el corazón de la búsqueda semántica. Esa única función es la maniobra sobre la que se construye el resto del capítulo.
Ya tienes vectores, ahora necesitas un número que diga qué tan cerca están dos de ellos. El estándar es la similitud coseno: mide el ángulo entre dos vectores, ignorando su longitud, y devuelve un valor entre -1 y 1 donde más alto significa más parecidos. El ángulo, no la distancia, es lo que quieres, porque un pasaje largo y una frase corta sobre el mismo tema deberían puntuar como similares aunque sus vectores tengan magnitudes distintas.
def dot(a, b):
return sum(x * y for x, y in zip(a, b))
# si los vectores están normalizados a longitud 1, la similitud coseno equivale a su producto punto
score = dot(query_vector, doc_vector) # no se necesita divisiónAquí está la jugada que rinde frutos a escala: normaliza tus vectores a longitud 1 cuando los almacenes. Una vez que cada vector tiene la misma longitud, la división en la similitud coseno desaparece y el puntaje se convierte en un simple producto punto, que es una multiplicación y una suma por dimensión, mucho más rápido de ejecutar sobre un conjunto grande. Normaliza una vez al almacenar, y luego compara con un producto punto. Muchos modelos de embeddings devuelven vectores ya normalizados, pero no lo asumas, verifícalo.
El otro cambio respecto al ejemplo de juguete: casi nunca quieres solo la coincidencia más cercana. Quieres recuperación top-k, los k resultados con puntaje más alto, normalmente entre tres y diez. Un vector puede quedarse justo por debajo, mientras que el segundo y el tercero juntos contienen la respuesta, y devolver un conjunto pequeño le da margen de trabajo a la siguiente etapa. Devolver solo el primero descarta contexto que después vas a querer haber conservado.
Necesitas una métrica de similitud, y la similitud coseno (el coseno del ángulo entre dos vectores, en el rango de -1 a 1) es la opción por defecto porque ignora la magnitud, así que la longitud del documento no ahoga el tema. Esa elección tiene una consecuencia de rendimiento que conviene diseñar con cuidado: el coseno sobre vectores sin normalizar cuesta un producto punto más dos cálculos de magnitud por comparación, y pagas ese costo por cada vector del índice en cada consulta.
import numpy as np
# normaliza al momento de escribir, una vez por vector
def normalize(v):
v = np.asarray(v, dtype=np.float32)
return v / np.linalg.norm(v)
# al momento de la consulta, el coseno sobre vectores normalizados es un solo producto matriz-vector
def search(query_vec, matrix):
return matrix @ query_vec # un puntaje por fila, vectorizadoLa optimización consiste en normalizar cada vector a longitud unitaria al momento de escribirlo, lo cual hace que la similitud coseno sea idéntica a un producto punto y te permite puntuar todo el corpus como una sola multiplicación matriz-vector en lugar de un bucle en Python. Normaliza al escribir y la similitud se convierte en un producto punto sobre todo el índice a la vez. Esta es la línea base de fuerza bruta: exacta, directa y lineal en el tamaño del corpus, lo cual está bien hasta unos cientos de miles de vectores aproximadamente y se derrumba por encima de eso, la señal para pasar a un índice aproximado que se cubre más adelante.
Dos realidades de producción que la métrica de juguete oculta. Primero, casi nunca quieres el argmax, quieres top-k con un umbral de puntaje, porque la brecha entre el primer y el segundo resultado te dice qué tan confiable es la coincidencia, y un resultado top que aun así puntúa bajo significa que nada en tu corpus respondió realmente la consulta. Devuelve eso como "sin buena coincidencia" en lugar de entregar la fila menos mala. Segundo, la similitud coseno es relativa, no absoluta: un puntaje de 0.4 solo tiene sentido frente a la distribución de puntajes que produce tu corpus y tu modelo, así que calibra los umbrales de forma empírica para cada índice en lugar de copiar un número mágico de un blog.
Búsqueda semántica
Junta las piezas y puedes buscar por significado en lugar de por palabras clave. Convierte tus documentos en embeddings una vez y guarda los vectores. Cuando llega una consulta, convierte esa consulta en embedding también, y luego devuelve los documentos cuyos vectores estén más cerca.
docs = [
"Restablece tu contraseña desde la página de configuración de la cuenta.",
"Nuestra política de reembolsos permite devoluciones dentro de 30 días.",
"Contacta a soporte por correo electrónico para preguntas de facturación.",
]
# convierte cada documento en embedding una vez, de antemano
index = [{"text": text, "vector": embed(text)} for text in docs]
def search(query):
query_vector = embed(query)
scored = [{"text": item["text"], "score": cosine_similarity(query_vector, item["vector"])} for item in index]
return sorted(scored, key=lambda x: x["score"], reverse=True)[0] # la coincidencia más cercana
print(search("¿cómo hago para que me devuelvan mi dinero?"))
# devuelve la línea de política de reembolsos, aunque no comparte palabras clave con la consultaLa consulta "¿cómo hago para que me devuelvan mi dinero?" encuentra la línea de reembolsos aunque no comparte palabras importantes con ella. La búsqueda semántica coincide por significado, no por ortografía. La búsqueda por palabras clave la habría pasado por alto completamente, porque la consulta y el documento no tienen palabras importantes en común.
El patrón siempre es el mismo: convierte en embedding una vez y conserva los vectores, convierte la consulta en embedding en el momento de la solicitud, puntúa, ordena, devuelve la más cercana. Ese es el ciclo de la búsqueda semántica, y lo reutilizarás durante el resto de este manual.
La versión de juguete guarda los vectores en una lista de Python y puntúa cada uno en cada consulta. Eso funciona para una demo y se rompe en cuanto tu corpus crece, porque un escaneo lineal vuelve a ejecutar la comparación completa sobre cada vector en cada consulta, y además estás reconstruyendo el índice en memoria en cada reinicio.
La solución es una base de datos vectorial: un almacén construido para guardar vectores y responder "encuentra los k más cercanos a este vector de consulta" rápidamente y desde disco. En lugar de tu propia lista y tu bucle, escribes los vectores una vez y la base de datos se encarga de la búsqueda, la persistencia y la actualización de entradas individuales.
# la forma varía según la base de datos vectorial, pero el patrón es el mismo en todas partes
index.upsert([
{"id": "doc-1", "vector": embed(chunk), "metadata": {"source": "faq.md", "section": "reembolsos"}}
for chunk in chunks
])
results = index.query(vector=embed(user_question), top_k=5) # los 5 más cercanos, no solo 1Dos cosas que la base de datos te da y que la lista no:
- Devuelve resultados top-k en una sola llamada, los varios más cercanos en lugar de solo el mejor, que es justo lo que alimenta a la siguiente etapa.
- Almacena metadata junto a cada vector, el archivo fuente, la sección y la marca de tiempo, de modo que un resultado pueda señalar de dónde vino y puedas filtrar por ello.
Guarda los vectores en una base de datos vectorial, no en una lista de Python, en el momento en que tengas datos reales. La lista es para aprender el mecanismo, no para operarlo.
El ciclo de referencia, convertir en embedding una vez, almacenar, convertir la consulta en embedding, puntuar, ordenar, devolver, es correcto y no sobrevive al contacto con la escala. Un escaneo de fuerza bruta es lineal en el tamaño del corpus por consulta, así que con un millón de vectores y cualquier tasa de consultas real estás gastando un segundo de CPU por búsqueda que no tienes disponible. Todo lo relacionado con la búsqueda en producción se trata de evitar comparar la consulta contra cada vector.
Eso es lo que te da la búsqueda de vecino más cercano aproximado (ANN: algoritmos que encuentran los vectores más cercanos rápidamente, aceptando una pequeña probabilidad de pasar por alto el verdadero vecino más cercano). Dos familias dominan:
- HNSW (Hierarchical Navigable Small World) construye un grafo por capas de los vectores y lo recorre de lo grueso a lo fino, dando baja latencia de consulta a costa de un uso alto de memoria.
- IVF (inverted file) agrupa los vectores en clústeres y busca solo en los clústeres más cercanos, usando menos memoria pero necesitando que sus centros de clúster se reentrenen a medida que los datos cambian.
Por encima de unos cientos de miles de vectores, ANN no es opcional, es el índice. El compromiso que estás ajustando es recall (la fracción de verdaderos vecinos más cercanos que el índice efectivamente devuelve) contra latencia y memoria: empuja el recall hacia 1.0 y las consultas se vuelven más lentas, relájalo y pierdes silenciosamente chunks relevantes.
El modo de falla que el ciclo de juguete oculta por completo es la actualidad de los datos. Un índice es una fotografía en el tiempo, así que cuando un documento fuente cambia, su vector almacenado queda desactualizado y sigue siendo recuperado como si estuviera vigente. Necesitas un pipeline de regeneración de embeddings que se dispare con el cambio del documento: detectar la edición, volver a dividir en chunks y volver a generar los embeddings de ese documento, e insertar los vectores nuevos mientras eliminas los viejos, idealmente rastreado con un hash de contenido para regenerar solo lo que realmente cambió en lugar de todo el corpus en cada edición. Los vectores desactualizados son la manera silenciosa en que un sistema de recuperación empieza a mentir.
La última brecha es la coincidencia exacta. La búsqueda vectorial está construida para pasarla por alto: una consulta con el número de orden A-10293 o un código de error se convierte en embedding hacia una región difusa y puede no hacer emerger el único documento que contiene esa cadena exacta. La búsqueda híbrida ejecuta búsqueda por palabras clave (que acierta en tokens exactos) junto con búsqueda vectorial (que acierta en significado) y combina los rankings, de modo que identificadores, nombres y términos raros no se pierdan en la difusión semántica. Recurre a ella siempre que tu corpus contenga cosas que los usuarios vayan a escribir de forma literal.
Para qué no sirven los embeddings
Los embeddings son una herramienta de similitud, no una herramienta de razonamiento. Algunos límites que hay que tener presentes para usar la herramienta correcta en cada caso:
- No hacen coincidencia exacta. Para encontrar un número de orden exacto o un código de error, una búsqueda por palabras clave o en base de datos funciona mejor.
- No razonan. Te dicen que dos cosas están relacionadas, no por qué, y no si una se deriva lógicamente de la otra.
- Se desactualizan. Si tus documentos cambian, tienes que volver a generar sus embeddings, o tu búsqueda apuntará a contenido viejo.
Los embeddings encuentran lo que está relacionado, no juzgan qué es verdadero o actual. Cada uno de estos vacíos tiene una herramienta mejor, y saber cuál usar es la mitad de la habilidad.
La trampa con una herramienta que funciona tan bien es querer usarla para todo. Tres límites marcan dónde detenerse y elegir otra cosa.
La coincidencia exacta es el primero. Los embeddings mapean un ID de orden o un código de error al mismo espacio semántico difuso que todo lo demás, así que A-10293 cae cerca de otras cadenas con forma de número de orden, no exactamente sobre sí mismo. Para identificadores, códigos y cualquier valor que un usuario escriba literalmente, la búsqueda por palabras clave o en base de datos es la herramienta adecuada, y la búsqueda híbrida (búsqueda por palabras clave y vectorial ejecutadas juntas) es cómo obtienes ambas cosas a la vez.
El razonamiento es el segundo. La similitud no es inferencia: que dos chunks puntúen alto significa que tratan de cosas relacionadas, no que uno responde una pregunta o se deriva lógicamente del otro. El razonamiento ocurre en el modelo al que le entregas los chunks recuperados, no en la recuperación. La recuperación encuentra candidatos, el modelo hace el pensamiento.
La actualidad de los datos es el tercero y el que muerde en silencio. Tu índice es una fotografía tomada en el momento en que generaste los embeddings, así que los documentos editados siguen devolviendo su redacción vieja hasta que vuelvas a generar sus embeddings. Trata la regeneración de embeddings ante cambios como parte del sistema, no como algo que haces a mano cuando alguien nota que las respuestas están mal.
Saber dónde se detienen los embeddings es lo que mantiene confiable a un sistema de recuperación, y tres límites importan en producción.
La coincidencia exacta es un punto ciego estructural, no un problema de ajuste. Los embeddings comprimen la entrada hacia vecindarios semánticos, así que identificadores, códigos, SKUs y nombres propios poco comunes se difuminan hacia cadenas de apariencia similar en lugar de caer exactamente sobre sí mismos. La búsqueda vectorial pura puede posicionar una coincidencia casi correcta por encima de la coincidencia exacta. La solución es una recuperación híbrida que fusiona un índice léxico (por palabras clave o BM25, un ranking clásico basado en frecuencia de términos) con el índice vectorial, de modo que los tokens exactos se comparen exactamente mientras el significado se compara semánticamente, y no arriesgas la búsqueda de una orden a la geometría.
El desvío es el límite que se erosiona en silencio. El desvío de embeddings (embedding drift) es el desajuste que ocurre cuando cambia la versión del modelo detrás de tu índice: los vectores de dos versiones ocupan espacios distintos y no son comparables, así que una consulta convertida en embedding con un modelo nuevo, comparada contra un índice construido con el anterior, devuelve resultados sin sentido que parecen plausibles. Fija la versión del modelo en el esquema de tu índice, y trata cualquier actualización de modelo como una migración completa de regeneración de embeddings, no como un simple reemplazo, porque no se lanza ningún error, solo resultados silenciosamente peores.
La evaluación es el límite que la gente omite hasta que la recuperación ya está fallando. No puedes juzgar a simple vista si la recuperación funciona bien, tienes que medirlo: reserva un conjunto de consultas con chunks relevantes conocidos y calcula recall@k (si el chunk correcto quedó entre los primeros k) y precisión (qué proporción de lo que devolviste era relevante). Si no estás midiendo recall@k, no sabes si la recuperación funciona.
Ese conjunto de evaluación también es lo que te dice si una nueva estrategia de chunking, un nuevo modelo o una nueva ponderación híbrida realmente ayudó, en lugar de simplemente cambiar un conjunto de fallas por otro que todavía no has notado. La calidad de la recuperación es la base sobre la que se sostiene RAG, así que merece una prueba real, no una impresión.
En la práctica
El flujo completo vive en la función search de arriba: convierte en embedding un pequeño conjunto de entradas de preguntas frecuentes una vez, y luego responde consultas por significado. El patrón es el mismo a cualquier escala, solo cambia el almacenamiento a medida que creces.
En el siguiente capítulo tomas las coincidencias más cercanas y las entregas a un modelo, para que responda a partir de tus documentos en lugar de a partir de su memoria. Esa técnica es RAG, y está construida directamente sobre lo que escribiste aquí.
El mecanismo ya está resuelto: divide en chunks, genera embeddings por lotes, almacena en una base de datos vectorial, recupera top-k por similitud. Lo que cambia al pasar a producción es la infraestructura alrededor: la regeneración de embeddings ante cambios, la metadata sobre la que filtras, la versión del modelo que fijas.
Los chunks recuperados no son el final. En el siguiente capítulo los entregas a un modelo como contexto para que responda a partir de tus documentos en lugar de a partir de su entrenamiento congelado. Eso es RAG, y la calidad de sus respuestas está limitada por la recuperación que construyes aquí.
Todo lo visto aquí, chunking, normalización, indexación ANN, recuperación híbrida, actualidad de los datos y evaluación con recall@k, es la capa de recuperación, y es la parte de un sistema de IA que determina, en silencio, si las respuestas son buenas o no. El modelo que está por encima solo puede razonar sobre lo que la recuperación le entrega, así que una respuesta equivocada dicha con confianza suele ser una falla de recuperación disfrazada de generación.
Ese es el puente hacia el siguiente capítulo. RAG es esta capa de recuperación alimentando contexto fundamentado a un modelo, y cada modo de falla de aquí, vectores desactualizados, coincidencias exactas perdidas, recall bajo, se manifiesta ahí como una respuesta equivocada. Construye la recuperación para que sea medible y esté actualizada, y RAG tendrá algo sólido sobre lo cual sostenerse.

