Este es el momento que el curso lleva preparando desde 01-01. Tenemos embeddings y búsqueda semántica (04-01), una base vectorial persistente con filtros (04-02) y un corpus bien troceado con metadatos (04-03). Y de los módulos anteriores tenemos algo igual de valioso: un SYSTEM_DOCUBOT que desde 02-01 exige basar las respuestas exclusivamente en el bloque <documentacion>, una prompt_respuesta() que lo construye, un contrato JSON con campo fuentes, y un responder() robusto con reintentos y degradación (03-02). Todas esas piezas se diseñaron con un hueco en el centro: alguien tenía que llenar el bloque <documentacion> — y hasta hoy ese alguien éramos nosotros, a mano. En esta lección cerramos el círculo: construimos rag.py, la función responder_con_rag() que, dada una pregunta, recupera automáticamente los chunks relevantes, monta el bloque de documentación con sus fuentes reales y genera la respuesta. También tomaremos las decisiones de diseño que no salen en los diagramas de marketing (¿cuántos chunks?, ¿en qué orden?, ¿y si no hay nada relevante?), integraremos RAG con las conversaciones de 03-03 y saldaremos una cuenta pendiente de 01-04: la pregunta del PDF ante la que el DocuBot ingenuo alucinaba.
Contenido
- La arquitectura completa: cómo encajan las piezas
- Recuperar: de la pregunta a los chunks
- Aumentar: construir el bloque
<documentacion>con fuentes - Generar:
rag.pyyresponder_con_rag() - Decisiones de diseño: k, orden y el umbral anti-ruido
- RAG en conversaciones: integración con
Conversacion - Antes y después: la pregunta del PDF de 01-04
La arquitectura completa: cómo encajan las piezas
RAG (Retrieval-Augmented Generation, generación aumentada por recuperación) tiene tres fases con nombre propio: recuperar los fragmentos relevantes, aumentar el prompt con ellos, generar la respuesta. Nada de la tercera fase es nuevo para nosotros — es exactamente el DocuBot de los módulos 2 y 3. Lo nuevo es que las dos primeras dejan de ser manuales:
flowchart TD
P[Pregunta del usuario] --> E["embed(pregunta, tipo='query')<br/>(embeddings.py, 04-01)"]
E --> R["coleccion.query(top-k, filtros)<br/>(docs_nubelia, 04-02/04-03)"]
R --> U{"¿similitud máxima<br/>≥ umbral?"}
U -- no --> F["Respuesta anti-alucinación<br/>(regla 3 de SYSTEM_DOCUBOT)"]
U -- sí --> D["Construir bloque <documentacion><br/>con chunks + fuentes"]
D --> PR["prompt_respuesta()<br/>(prompts.py, 02-01)"]
PR --> G["responder(pregunta, documentacion)<br/>(docubot.py, 03-02: reintentos + degradación)"]
G --> J["Contrato JSON validado<br/>{categoria, respuesta, fuentes, confianza}<br/>con fuentes REALES de los metadatos"]Merece la pena detenerse en lo que este diagrama significa para el curso: no vamos a tocar ni prompts.py, ni validacion.py, ni docubot.py. El sistema prompt de 02-01 ya hablaba del bloque <documentacion> cuando ese bloque solo podíamos llenarlo copiando y pegando; el responder() de 03-02 ya recibía documentacion como parámetro. El diseño por capas está a punto de pagarse: RAG es una capa nueva por encima, no una reescritura.
Recuperar: de la pregunta a los chunks
La primera fase traduce la pregunta al espacio de significados y consulta la colección:
# rag.py (parte 1) — recuperación
from embeddings import embed
from vectordb import obtener_coleccion
K_DEFECTO = 4 # cuántos chunks recuperar (decisión justificada más abajo)
UMBRAL_RELEVANCIA = 0.45 # similitud mínima del mejor chunk (calibrar con 04-05)
def recuperar(pregunta: str, k: int = K_DEFECTO, filtros: dict | None = None) -> list[dict]:
"""Devuelve los k chunks más relevantes, como lista de dicts uniformes."""
coleccion = obtener_coleccion()
resultado = coleccion.query(
query_embeddings=embed([pregunta], tipo="query"),
n_results=k,
where=filtros, # None = buscar en todo el corpus
)
chunks = []
for doc, meta, dist in zip(resultado["documents"][0],
resultado["metadatas"][0],
resultado["distances"][0]):
chunks.append({
"texto": doc,
"similitud": 1 - dist, # distancia coseno -> similitud (04-02)
"url": meta["url"],
"titulo": f"{meta['titulo_pagina']} > {meta['titulo_seccion']}",
"version": meta.get("version", ""),
})
return chunksTodo es material conocido: embed con input_type="query" (04-01), la colección docs_nubelia (04-02), la conversión distancia→similitud, y metadatos que vienen de la ingesta (04-03). La función normaliza el formato "lista de listas" de Chroma a una lista de dicts con la que el resto del pipeline trabaja cómodo.
Sobre el parámetro filtros: aquí es donde se enchufaría el filtro por categoría usando el clasificador de 02-02, o el filtro por versión del producto. Como vimos en el ejercicio 2 de 04-02, un filtro basado en una clasificación errónea sabotea la recuperación — por eso el valor por defecto es None (buscar en todo) y el filtro es una opción que se activa con evidencia, no una suposición que se cablea.
Aumentar: construir el bloque <documentacion> con fuentes
La segunda fase da formato a los chunks para el prompt. Cada fragmento va numerado y con su fuente visible, para que el LLM pueda citarla:
# rag.py (parte 2) — construcción del bloque de documentación
def construir_documentacion(chunks: list[dict]) -> str:
"""Formatea los chunks recuperados como el bloque <documentacion> espera."""
partes = []
for i, c in enumerate(chunks, start=1):
partes.append(
f"[Fragmento {i} | fuente: {c['url']} | sección: {c['titulo']}]\n{c['texto']}"
)
return "\n\n---\n\n".join(partes)Detalles con intención:
- Numerar los fragmentos permite instruir al modelo (vía
prompt_respuesta(), que ya pide citar fuentes) para referirse a ellos sin ambigüedad, y facilita el diagnóstico: cuando una respuesta sea mala, sabremos exactamente qué vio el modelo. - La fuente viaja dentro del bloque, pero el campo
fuentesdel JSON final no dependerá de que el modelo la copie bien: lo rellenaremos nosotros desde los metadatos (siguiente apartado). El modelo cita para el texto de la respuesta; el sistema garantiza la trazabilidad. - El separador
---marca fronteras claras entre fragmentos: barato y eficaz contra la mezcla accidental de contenidos.
Generar: rag.py y responder_con_rag()
La función que une las tres fases y que los módulos 5 y 6 usarán como "el DocuBot completo":
# rag.py (parte 3) — el pipeline completo
from prompts import prompt_respuesta, PROMPT_VERSION
from docubot import responder # 03-02: reintentos, streaming, degradación
from validacion import RESPUESTA_FALLBACK
FRASE_SIN_INFO = "No encuentro esa información en la documentación disponible."
def responder_con_rag(pregunta: str, k: int = K_DEFECTO,
filtros: dict | None = None) -> dict:
"""Pipeline RAG completo: recuperar -> aumentar -> generar.
Devuelve el contrato JSON de DocuBot:
{"categoria", "respuesta", "fuentes", "confianza"}
"""
# 1) RECUPERAR
chunks = recuperar(pregunta, k=k, filtros=filtros)
# Guardarraíl: si ni el mejor chunk es relevante, no generamos sobre ruido
if not chunks or chunks[0]["similitud"] < UMBRAL_RELEVANCIA:
return {
"categoria": "otros",
"respuesta": FRASE_SIN_INFO,
"fuentes": [],
"confianza": "baja",
}
# 2) AUMENTAR
documentacion = construir_documentacion(chunks)
# 3) GENERAR — el responder() robusto de 03-02, sin tocar
respuesta = responder(pregunta, documentacion)
# 4) Fuentes REALES: del sistema, no de la memoria del modelo
if respuesta != RESPUESTA_FALLBACK:
respuesta["fuentes"] = sorted({c["url"] for c in chunks})
return respuestaLos cuatro pasos, comentados:
- Paso 1 y su guardarraíl. Recuperar siempre devuelve algo — los k vecinos más cercanos existen aunque la pregunta sea "¿puedo pagar con criptomonedas?". La pregunta correcta no es "¿hay resultados?" sino "¿son relevantes?". Si el mejor chunk no llega al umbral, respondemos directamente con la frase anti-alucinación de la regla 3 de
SYSTEM_DOCUBOT— sin gastar una llamada al LLM y sin darle la oportunidad de estirar un chunk irrelevante hasta convertirlo en una respuesta plausible y falsa. Fíjate en el orden de los beneficios: primero honestidad, luego ahorro. - Paso 3 hereda toda la robustez de 03-02 gratis: reintentos con backoff+jitter, degradación a
RESPUESTA_FALLBACKsi todo falla, validación del contrato conlimpiar_json/validar_contrato. No duplicamos ni una línea de esa lógica. - Paso 4 es el salto de fiabilidad anunciado en 04-03:
fuentesdeja de ser lo que el modelo dice haber usado y pasa a ser lo que el sistema sabe que le dio. Unsetdeduplicado y ordenado de URLs de los metadatos. Si mañana un usuario duda de una respuesta, esas URLs son verificables con un clic. - ¿Y el caché?
responder_con_rages el lugar natural para consultarCacheSemantico(04-01) antes del paso 1 y guardar después del paso 4 — lo dejamos fuera del listado por claridad, y como ejercicio.
Decisiones de diseño: k, orden y el umbral anti-ruido
Tres decisiones que todo pipeline RAG toma, las haga explícitas o no. Mejor explícitas:
| Decisión | Nuestra elección | Razonamiento | Riesgo si te pasas / te quedas corto |
|---|---|---|---|
| ¿Cuántos chunks (k)? | k=4 |
Con chunks de ~450 tokens (04-03), son ~1.800 tokens de contexto: suficiente para preguntas que tocan 2 secciones, asumible en coste (03-04) | k alto: coste, dilución, "lost in the middle" (01-04). k bajo: la respuesta puede estar en el chunk nº 5 |
| ¿En qué orden? | Más relevante primero | Los modelos atienden mejor al principio del contexto; el fragmento 1 es el que casi seguro se usa | Orden aleatorio o inverso regala precisión sin ganar nada |
| ¿Y si nada es relevante? | Umbral sobre la similitud del mejor chunk → frase anti-alucinación | Recuperar ruido y generar sobre él es fabricar alucinaciones "con fuentes"; mejor un no honesto | Umbral alto: DocuBot se vuelve inútilmente tímido. Umbral bajo: vuelven las alucinaciones con cita |
Sobre el valor UMBRAL_RELEVANCIA = 0.45: es un punto de partida, no una verdad. Como aprendimos en 04-01, la escala de similitudes depende del modelo de embeddings; el valor definitivo debe salir de medir sobre el conjunto de evaluación de 02-04 — que, recuerda, tiene un ~20% de casos "sin respuesta" precisamente para esto. En 04-05 convertiremos esa calibración en un experimento con números.
Una mención honesta para cerrar el apartado: existe una técnica llamada reranking — recuperar más candidatos (p. ej. 20) y reordenarlos con un modelo específico que compara pregunta y chunk con más finura que el coseno, quedándose con los 4 mejores. Mejora sistemas maduros, añade una pieza y una latencia más, y para el tamaño del corpus de Nubelia no es la primera palanca a tocar. Sabemos que existe; seguimos.
RAG en conversaciones: integración con Conversacion
DocuBot no recibe preguntas sueltas: mantiene conversaciones (clase Conversacion y GestorSesiones, 03-03). ¿Cómo conviven RAG e historial? La respuesta corta es el lema que acuñamos en 03-03: "historial para lo dicho, RAG para lo sabido". En la práctica:
- Se embebe la pregunta del turno, no el historial. Embeber la conversación entera contaminaría la búsqueda con los temas de turnos anteriores.
- Pero la pregunta cruda a veces no basta. Tras "¿cómo invito a un miembro?", el usuario pregunta "¿y cómo lo elimino?" — un embedding de "¿y cómo lo elimino?" no encontrará la doc de gestión de miembros. La solución se llama query rewriting: una llamada previa y barata al LLM que reformula la pregunta con el contexto del historial ("¿cómo elimino a un miembro de un espacio de trabajo?") antes de embeberla. Lo mencionamos para que tenga nombre; una versión sencilla basta:
# rag.py (parte 4) — RAG por turno dentro de una conversación
def responder_en_conversacion(conv, pregunta: str, k: int = K_DEFECTO) -> dict:
"""Un turno de conversación con RAG: recuperar por turno, historial aparte."""
pregunta_busqueda = reformular_si_hace_falta(conv, pregunta) # query rewriting
chunks = recuperar(pregunta_busqueda, k=k)
if not chunks or chunks[0]["similitud"] < UMBRAL_RELEVANCIA:
respuesta = {"categoria": "otros", "respuesta": FRASE_SIN_INFO,
"fuentes": [], "confianza": "baja"}
else:
documentacion = construir_documentacion(chunks)
# La documentación va en el turno ACTUAL; el historial lo aporta conv
respuesta = responder(pregunta, documentacion, historial=conv.mensajes())
if respuesta != RESPUESTA_FALLBACK:
respuesta["fuentes"] = sorted({c["url"] for c in chunks})
conv.agregar_turno(pregunta, respuesta) # el truncado a 10 turnos (03-03) sigue vigente
return respuestaEl punto arquitectónico importante: la documentación recuperada pertenece al turno, no al historial. Cada turno recupera lo suyo; los chunks del turno anterior no se arrastran (inflarían el contexto y el coste, y el truncado de 03-03 los limpiaría igualmente). El historial conserva lo dicho — preguntas y respuestas — y RAG aporta en cada turno lo sabido que ese turno necesita.
Antes y después: la pregunta del PDF de 01-04
Cerremos la cuenta pendiente. En 01-04, el "DocuBot ingenuo" —una llamada pelada al modelo, sin system prompt ni documentación— respondía esto ante una pregunta sobre informes:
Pregunta: "¿Cómo exporto un informe de proyecto a PDF?" DocuBot ingenuo (01-04): "Para exportar a PDF, ve a Informes > Exportar > PDF y selecciona la plantilla deseada. También puedes usar el endpoint
/clonede la API para duplicar el informe antes de exportarlo."
Dos invenciones en tres líneas: la exportación a PDF no existe y el endpoint /clone tampoco. Plausible, fluido, falso. Ahora, el mismo caso con el pipeline completo:
from rag import responder_con_rag
import json
print(json.dumps(responder_con_rag("¿Cómo exporto un informe de proyecto a PDF?"),
ensure_ascii=False, indent=2)){
"categoria": "otros",
"respuesta": "La exportación a PDF no está soportada en Nubelia. Los informes de proyecto pueden exportarse a CSV y Excel desde el menú Informes > Exportar (Fragmento 1). Si necesitas un PDF, una opción es exportar a Excel e imprimir a PDF desde tu hoja de cálculo.",
"fuentes": ["wiki/informes/exportacion"],
"confianza": "alta"
}Vale la pena diseccionar por qué esto funciona, pieza a pieza y lección a lección:
- La recuperación (04-01/04-02) encontró el chunk de "Informes y exportación" — que dice explícitamente que PDF no está soportado, gracias a que la ingesta (04-03) conservó la sección entera.
- La regla 2 de
SYSTEM_DOCUBOT(02-01) obligó al modelo a ceñirse a ese chunk: no hay de dónde sacar un menú "Exportar > PDF" inexistente. - El contrato JSON (02-03) y su validación defensiva devolvieron una estructura procesable, y
fuentescontiene la URL real del chunk — puesta por el sistema, no por el modelo. - Y si la documentación no hubiera dicho nada sobre exportación, el umbral habría activado la frase exacta de la regla 3: "No encuentro esa información en la documentación disponible."
Este antes/después es el argumento del curso en miniatura: la diferencia entre un LLM que suena a experto en Nubelia y un sistema que lo es no está en el modelo — es el mismo — sino en todo lo que hemos construido alrededor.
Errores Comunes y Consejos
- Generar siempre, haya o no haya recuperación decente. Es el error más caro en calidad: el LLM convertirá ruido en prosa convincente. El umbral sobre la similitud del mejor chunk es tu red; calíbralo, pero nunca lo quites.
- Dejar que el modelo invente el campo
fuentes. Sifuentessale de la generación, tendrás citas bonitas y ocasionalmente falsas. Rellénalo desde los metadatos de los chunks recuperados: es información que el sistema posee con certeza. - Embeber el historial completo para recuperar en conversaciones. La búsqueda se contamina con los temas de todos los turnos. Embebe la pregunta del turno (reformulada si es elíptica).
- Arrastrar los chunks de turnos anteriores en el contexto. Coste creciente y contexto inflado por documentación que ya no viene al caso. Documentación por turno; historial para lo dicho.
- Tratar k y el umbral como constantes universales copiadas de un tutorial. Son función de tu corpus, tu chunking y tu modelo de embeddings. La lección 04-05 existe para darles valores con fundamento.
- Consejo: registra en cada llamada (como el CSV de 02-04) la pregunta, los ids y similitudes de los chunks recuperados y la respuesta. Cuando llegue una respuesta mala —llegará— podrás responder la pregunta clave del diagnóstico: ¿falló la recuperación o falló la generación? Es exactamente la distinción sobre la que se construye la próxima lección.
Ejercicios
- Enchufar el caché semántico. Modifica
responder_con_rag()para consultar una instancia deCacheSemantico(04-01) antes de recuperar, y guardar la respuesta después de generar (solo si no esRESPUESTA_FALLBACKy su confianza no es"baja"— razona por qué esas dos condiciones). - El umbral en acción. Con el corpus de demo cargado, ejecuta
responder_con_rag()con estas tres preguntas y anota similitud del mejor chunk y comportamiento: (a) "¿cómo restablezco mi contraseña?", (b) "¿puedo pagar Nubelia con criptomonedas?", (c) "¿qué opináis del último fichaje del Real Madrid?". ¿Distingue el umbral los tres casos? ¿Qué caso de 02-04 representa cada una? - Query rewriting mínimo. Implementa
reformular_si_hace_falta(conv, pregunta): si la conversación está vacía, devuelve la pregunta tal cual; si no, usapreguntar_docubot()(03-01) con un prompt breve que pida reescribir la pregunta como autocontenida usando el historial. Pruébala con el diálogo "¿cómo invito a un miembro?" → "¿y cómo lo elimino?".
Soluciones
- Esqueleto de la integración:
from cache_semantico import CacheSemantico
_cache = CacheSemantico(umbral=0.92, ttl_segundos=3600)
def responder_con_rag(pregunta, k=K_DEFECTO, filtros=None):
cacheada = _cache.obtener(pregunta)
if cacheada is not None:
return cacheada
# ... pipeline igual que antes ...
if respuesta != RESPUESTA_FALLBACK and respuesta.get("confianza") != "baja":
_cache.guardar(pregunta, respuesta)
return respuestaPor qué las dos condiciones: cachear el fallback congelaría un error transitorio (un pico de errores de API de 03-02) convirtiéndolo en la respuesta oficial durante todo el TTL; y cachear respuestas de confianza baja multiplica el alcance de las respuestas más dudosas del sistema — un caché debe amplificar los aciertos, no las dudas. Nota el orden: el caché se consulta antes de recuperar, así que un hit ahorra el embedding de recuperación, la consulta a Chroma y la llamada al LLM.
-
Resultados típicos: (a) similitud ~0.75-0.85 → muy por encima del umbral, respuesta normal con fuente
wiki/administracion/seguridad-y-acceso— un caso fácil de 02-04. (b) similitud ~0.35-0.45 → zona fronteriza: hay chunks de facturación/planes que se parecen algo; según caiga respecto al umbral, DocuBot responde la frase anti-alucinación o intenta responder con la doc de planes (y si el prompt funciona, dirá que no hay información sobre criptomonedas) — un caso sin respuesta, y la mejor demostración de por qué el umbral se calibra con datos y no a ojo. (c) similitud ~0.10-0.20 → claramente bajo el umbral, frase anti-alucinación sin gastar llamada — un caso fuera de dominio (pariente de los adversariales suaves). Si tu umbral no separa (a) de (c) con holgura, revisa antes el chunking que el umbral. -
Una versión mínima y suficiente:
def reformular_si_hace_falta(conv, pregunta: str) -> str:
if not conv.mensajes():
return pregunta
instruccion = (
"Reescribe la última pregunta del usuario como una pregunta autocontenida, "
"resolviendo pronombres y referencias con ayuda del historial. "
"Devuelve SOLO la pregunta reescrita.\n\n"
f"Historial:\n{conv.resumen_texto()}\n\nÚltima pregunta: {pregunta}"
)
return preguntar_docubot(instruccion).strip()Con el diálogo del enunciado, la reformulación esperada es "¿Cómo elimino a un miembro de un espacio de trabajo en Nubelia?" — y esa sí recupera la doc correcta. Coste: una llamada corta extra por turno conversacional; actívala solo cuando hay historial (la condición del if) para no pagarla en preguntas sueltas. Query rewriting da para mucho más (expansión de consultas, multi-pregunta), pero esta versión cubre el caso que de verdad rompe RAG conversacional: las preguntas elípticas.
Conclusión
DocuBot ya es lo que prometimos en 01-01: un asistente que de verdad conoce Nubelia. rag.py cierra el círculo con responder_con_rag() — recuperar (embedding de la pregunta + top-k sobre docs_nubelia), aumentar (bloque <documentacion> numerado y con fuentes) y generar (el responder() robusto de 03-02, intacto) — devolviendo el contrato JSON de siempre pero con una diferencia de categoría: las fuentes son ahora hechos del sistema, no declaraciones del modelo. Por el camino hemos tomado las decisiones que definen un RAG serio (k=4 razonado, orden por relevancia, y el umbral que prefiere un "No encuentro esa información en la documentación disponible." honesto antes que generar sobre ruido), hemos integrado RAG con las conversaciones de 03-03 bajo el lema "historial para lo dicho, RAG para lo sabido" (con query rewriting para las preguntas elípticas), y hemos saldado la deuda de 01-04: la pregunta del PDF que antes producía menús y endpoints inventados ahora produce una respuesta correcta, con fuente verificable. Pero seamos honestos como lo fuimos con el DocuBot ingenuo: que el pipeline funcione en nuestras demos no significa que funcione. ¿Recupera los chunks correctos el 95% de las veces o el 70%? ¿El umbral de 0.45 es sabio o casualidad? ¿Las respuestas son fieles a los chunks o el modelo sigue adornando? Un sistema RAG tiene dos motores que pueden fallar por separado — la recuperación y la generación — y evaluarlos requiere separarlos. Ese es exactamente el trabajo de la última lección del módulo: evaluación de sistemas RAG (04-05).
Curso de IA Generativa y LLMs para Desarrolladores
Módulo 1: Fundamentos de la IA generativa
- Qué es la IA generativa y por qué importa a los desarrolladores
- Cómo funciona un LLM: tokens, embeddings y atención
- Panorama de modelos y proveedores
- Limitaciones y riesgos: alucinaciones, contexto y costes
Módulo 2: Prompt engineering
- Anatomía de un prompt: roles, instrucciones y contexto
- Técnicas de prompting: few-shot, cadena de razonamiento y plantillas
- Salidas estructuradas: JSON y control de formato
- Iteración y evaluación de prompts
Módulo 3: Integración de LLMs en aplicaciones
- Primera integración con la API de un LLM
- Streaming, errores y reintentos
- Conversaciones y gestión del contexto
- Costes, latencia y caching
Módulo 4: RAG - Generación aumentada por recuperación
- Embeddings y búsqueda semántica
- Bases de datos vectoriales
- Ingesta y chunking de documentos
- Construcción de un pipeline RAG completo
- Evaluación de sistemas RAG
Módulo 5: Function calling y agentes
- Function calling: conectar el LLM con tu código
- De LLM a agente: el bucle de razonamiento y acción
- Frameworks de orquestación
