Hoy en día, RAG tiene a veces mala fama, ya sea porque ahora se considera algo totalmente trivial —lo es al principio, pero no tanto al escalarlo— o porque se cree que los «sistemas agénticos» lo han superado —aunque, en muchos casos, basta con rascar un poco la superficie para que enseguida se parezcan mucho a RAG…—.
En este artículo presentamos uno o dos ejemplos prácticos para mostrar cómo abordamos algunos retos habituales, en concreto:
Gestionar datos mixtos de texto y números y por qué hacen fallar a un RAG básico: las palabras clave se solapan y los números carecen de significado semántico.
Por qué conviene diseñar embeddings basados primero en resúmenes: generar un breve resumen descriptivo de cada fragmento y usarlo después para crear embeddings y hacer consultas.
Cómo generar resúmenes contextuales: incluir el contexto del documento principal para distinguir estadísticas con una estructura similar.
Cuándo recurrir al código y a los modelos de Pydantic: si importa conservar el contenido literal, combinar código personalizado o un modelo de Pydantic con llamadas a un LLM para garantizar la fiabilidad.
Conceptos básicos
Los sistemas RAG sustentan desde bots de asistencia hasta asistentes internos de conocimiento.
Por lo general, su funcionamiento interno consiste en:
Dividir los documentos de origen en fragmentos
Convertir cada fragmento en un embedding dentro de un espacio vectorial
Recuperar los K fragmentos más relevantes al realizar la consulta
Generar una respuesta basada en esos fragmentos
Herramientas populares como LangChain, LlamaIndex y Filestore de OpenAI hacen que esos pasos resulten casi triviales. Sin embargo, en procesos reales aparecen datos que no son solo texto denso, y un RAG básico puede tener dificultades. En los siguientes apartados mostraremos ejemplos concretos de problemas con los datos y construiremos gradualmente la solución a medida que aumente la complejidad.
Cuando los datos no son solo texto, algo bastante habitual
Veamos el siguiente fragmento de datos en el contexto de un videojuego:
JSON
Los embeddings funcionan gracias a las relaciones aprendidas entre las palabras mediante su significado semántico y la gramática. En los datos anteriores se mezclan texto y números; fuera de este contexto concreto, los números no guardan relación alguna con las palabras. Por tanto, podríamos decir que este fragmento de datos es básicamente una combinación de palabras más o menos descriptivas seguidas de números aleatorios.
Esto no supondría ningún problema si fuera el único tipo de datos disponible, porque aún podríamos recuperarlos mediante los embeddings de las pocas palabras descriptivas presentes —o simplemente usar texto a SQL—. Pero ¿qué ocurre si este fragmento está enterrado entre muchos otros con texto denso en los que también aparecen esas palabras? Por ejemplo:
JSON
Imaginemos ahora que queremos recuperar la respuesta a «¿Cuál es el alcance de ataque con Ascensión dracónica?». Es muy probable que no podamos recuperar el fragmento relevante porque está oculto entre el ruido de otros fragmentos que contienen las mismas palabras clave.
El problema de fondo es que no podemos distinguir bien estos fragmentos de datos, aunque contengan distintos tipos de información sobre el mismo tema. ¿Podríamos enriquecerlos o mejorarlos de algún modo? Claro que sí :smile:
Enriquecer los datos mediante un resumen; sí, has leído bien
En lugar de crear directamente un embedding del propio fragmento, primero podemos generar un resumen que describa el contenido de los datos y después crear el embedding y recuperarlo a partir de ese resumen. En la fase de generación seguiríamos utilizando los datos originales vinculados al resumen.
Así, para los dos ejemplos de fragmentos anteriores generaríamos resúmenes como estos:
Estadísticas de alcance, velocidad y daño de ataque —por defecto y con Ascensión dracónica—.
Descripción y detalles de Ascensión dracónica, incluidas sus condiciones de activación, efectos visuales e historia.
Después, también ampliamos la consulta para «alinearla» con el resumen. Por ejemplo, transformaríamos «¿Cuál es el alcance de ataque con Ascensión dracónica?» en «¿Cuáles son las estadísticas de alcance de ataque con Ascensión dracónica?». Esto resulta especialmente importante cuando la consulta de recuperación procede de usuarios sin formación técnica que preguntan en lenguaje ~~«libre»~~ humano normal, pues no tienen por qué saber ni preocuparse de cómo funciona un RAG para maximizar la precisión y la exhaustividad.


No sacar las cosas de contexto, un consejo aplicable también a la vida
Veamos ahora otro escenario: trabajar con multitud de fragmentos de datos que parecen iguales, como los siguientes:
Plain Text
Si mantenemos el mismo enfoque, imaginemos que preguntamos: «¿Cuál es el alcance de ataque del personaje X?». Con los resúmenes que acabamos de generar, estaríamos jugando a adivinar y dependiendo de la suerte, porque también serían muy similares. Entonces, ¿cómo podríamos diferenciarlos?
La respuesta es sencilla: aportar contexto. Podríamos incluir en el fragmento de datos una referencia a su documento principal; por ejemplo, en este caso: {«personaje»: «X»}. Así podríamos recuperar con precisión los datos correctos del personaje X aunque también tuviéramos los mismos datos para los personajes Y y Z.
Sin embargo, un enfoque mejor y más generalizable sería generar un resumen contextual del fragmento. Es decir, en vez de generar un resumen únicamente del fragmento de datos, podríamos proporcionar tanto el documento principal como el fragmento para generar un resumen contextual general que explicara cómo encaja el fragmento en su documento principal. Por ejemplo:
Este fragmento ofrece estadísticas detalladas de… para el personaje X. El fragmento encaja en el documento completo porque muestra la potencia de X en cuanto a velocidad de ataque…
Este fragmento ofrece estadísticas detalladas de… para el personaje Y. El fragmento encaja en el documento completo porque muestra las estadísticas mejoradas de Y al usar su habilidad especial…
Este fragmento ofrece estadísticas detalladas de… para el personaje Z. El fragmento encaja en el documento completo porque muestra las estadísticas de Z, muy adecuadas para ejercer de tanque en partidas por equipos…
Este método —inspirado en parte por Anthropic— puede parecer excesivo para el ejemplo anterior, pero resulta muy eficaz con fragmentos que podrían malinterpretarse «fuera de contexto». Además, ofrece un enfoque unificado válido para todos los fragmentos y mantiene un proceso de ingeniería ordenado.


Cuando hay que ser ~~maniático del control~~ riguroso
Normalmente recibimos los datos en bloques completos y los dividimos en fragmentos para un sistema RAG. En este ejemplo mostramos algo distinto: datos divididos en fragmentos deficientes. Son secciones aleatorias de un fragmento lógico que, en realidad, deben volver a agruparse. Un fragmento lógico es un bloque de contenido que, por su propia naturaleza, debe mantenerse unido, como un subapartado de un documento o un párrafo coherente.


Nuestro primer intento con estos datos consiste en introducirlo todo en una llamada a un LLM, pedirle que agrupe los fragmentos como considere oportuno y solicitarle después el contenido agrupado. Un LLM debería hacerlo bastante bien, ¿verdad? Pues sí y no.
En varias ocasiones hemos comprobado que los LLM tienden a actuar con cierta pereza y no son fiables cuando se necesita el contenido íntegro y exacto, sobre todo si el contexto es largo. Y tiene todo el sentido. Pero esto era un obstáculo insalvable para este caso de uso concreto, porque necesitábamos el contenido exacto palabra por palabra: sin resúmenes ni omisiones de ninguna parte del original. No podemos perder ningún detalle.
La parte positiva, por supuesto, era que comprendía muy bien la semántica y la estructura de los fragmentos rotos. Siempre que no se negara a reproducir el contenido exacto. Maldita sea :/
Entonces, ¿cómo podíamos aprovechar aquello para lo que un LLM es bueno y evitar aquello en lo que no resulta fiable? Recurrimos a nuestro viejo amigo, el código —es decir, una función personalizada de Python—. Y a un modelo de Pydantic que no podía ser más sencillo. Esta es la solución:
Recorrer las secciones mientras se mantiene un fragmento lógico actual
En cada sección, preguntar al LLM: ¿pertenece esta sección al fragmento lógico actual? Debe responder sí o no siguiendo el modelo de Pydantic.
Si la respuesta es sí, añadir la sección al fragmento; si es no, guardar el fragmento lógico actual, ya completo, e iniciar uno nuevo con esa sección.


Por supuesto, aquí usamos algo más de tokens que al procesar todo el contenido de una sola vez, pero en este caso de uso concreto, donde conservar el contenido exacto era la máxima prioridad, el pequeño coste adicional merecía mucho la pena.
Es una solución muy sencilla, pero sigue un principio importante: cuando se necesita rigor, no conviene depender exclusivamente de los LLM, ya que, al fin y al cabo, son probabilísticos.
El código y las funciones personalizadas, junto con los modelos de Pydantic, permiten obtener resultados predecibles y fiables sin renunciar a las capacidades de los LLM.
Crear una solución de IA generativa es tanto un reto de ingeniería como de inteligencia artificial. Esperamos que estos ejemplos te hayan inspirado para abordar tus propios retos. Para obtener más información sobre soluciones de IA generativa centradas en la ingeniería, consulta nuestro artículo sobre el diseño de sistemas agénticos basados en enrutadores.