RAG: el bot conoce tus documentos
"El modelo sabe de todo en general y de tu negocio en particular: nada. RAG es el puente entre los dos."
Qué vas a aprender en este capítulo
El bot ya maneja menú, pedidos y horarios con herramientas. Pero doña Carmen tiene un manual de políticas: entregas a domicilio, formas de pago, encargos para eventos, manejo de reclamos, programa de cliente frecuente... Son ~3 páginas de texto que cambian cada tanto, y el bot debería responder sobre ellas con precisión y citando de dónde sacó cada cosa (requisito R5).
¿Cómo le metemos ese conocimiento al bot? En este capítulo vas a aprender la respuesta estándar de la industria: RAG (Retrieval-Augmented Generation — generación aumentada por recuperación):
- Primero, la decisión honesta: cuándo conviene RAG y cuándo es más simple meter todo el documento al prompt (con caching). Spoiler: para 3 páginas, las dos opciones son defendibles — y entender por qué te hace mejor ingeniero.
- Embeddings: convertir texto en vectores donde "¿hacen delivery?" y "entregas a domicilio" quedan cerca aunque no compartan palabras.
- El pipeline completo: trocear (chunking con solapamiento) → indexar (sentence-transformers, local y gratis) → buscar (similitud coseno en numpy, top-k) → inyectar (etiquetas
<contexto>) → citar.
4.1 El problema: ¿dónde metemos el manual?
💡 Intuición
Opción A — fuerza bruta: pegás el manual completo dentro del system prompt. Funciona — el modelo lo lee todo en cada mensaje. Pero estás pagando por releerlo entero en cada turno de cada conversación, diga lo que diga el cliente. Es como obligar a Marta a releer el manual completo de la empresa antes de contestar "¿tienen horchata?".
Opción B — biblioteca con bibliotecaria: guardás el manual partido en fichas, y cuando llega una pregunta, una bibliotecaria ultrarrápida te trae solo las 3 fichas relevantes. El modelo lee 3 fichas en vez de 3 páginas. Eso es RAG.
La gracia está en la bibliotecaria: no busca por palabras exactas, busca por significado. "¿Llevan comida a la casa?" trae la ficha de "Entregas a domicilio" aunque no compartan ni una palabra. Esa magia son los embeddings.
📐 Fundamento
RAG vs contexto directo + caching: el trade-off honesto.
La opción A es menos tonta de lo que parece, gracias al prompt caching: si el system prompt (manual incluido) es idéntico en cada request, la API lo cachea — las lecturas de la parte cacheada cuestan ~0.1× del precio de entrada (las escrituras de caché ~1.25×, se pagan al crear la entrada). Un manual de 4,000 tokens cacheado cuesta como uno de 400. Lo implementamos en el capítulo 5.
| Criterio | Contexto directo + caching | RAG |
|---|---|---|
| Tamaño del corpus | Hasta unas decenas de miles de tokens | Ilimitado en la práctica (solo se envía el top-k) |
| Complejidad | Trivial: concatenar | Pipeline: chunking + índice + búsqueda |
| Precisión | El modelo ve TODO (no hay riesgo de recuperar mal) | Depende de que la búsqueda encuentre el chunk correcto |
| Costo por mensaje | Proporcional al corpus (÷10 con caching) | Proporcional al top-k (chico y constante) |
| Relevancia | El modelo debe ignorar lo irrelevante (con corpus grande, se distrae) | Solo llega lo relevante |
| Citas | Difusas ("según el manual...") | Naturales: sabés exactamente qué chunks se usaron |
| Actualizar docs | Editar el prompt (invalida el caché una vez) | Re-indexar el documento |
Regla práctica: corpus < ~10K tokens y estable → contexto directo + caching gana por simpleza. Corpus grande, creciente o heterogéneo (manuales + FAQ + actas + correos) → RAG. El manual de La Esquina hoy mide ~1,500 tokens — contexto directo bastaría. Lo hacemos con RAG porque (a) doña Carmen ya anunció que va a crecer ("quiero meter el recetario y el manual de empleados"), y (b) este capítulo existe para que aprendás el patrón con un caso manejable.
4.2 Embeddings: el significado hecho vector
📐 Fundamento
Un embedding es una función que convierte un texto en un vector de números reales (en nuestro modelo, 384 dimensiones) con una propiedad entrenada a propósito: textos de significado parecido producen vectores cercanos.
¿Y cómo se mide "cercano" entre vectores? Con la similitud coseno: el coseno del ángulo entre los dos vectores.
donde es el producto punto y la norma (longitud) del vector. Interpretación:
- : misma dirección → significados muy similares.
- : ortogonales → sin relación.
- : opuestos (raro en embeddings de texto).
Se usa el coseno y no la distancia euclidiana porque nos importa la dirección del vector (el "tema"), no su longitud (que varía con la cantidad de texto). Dos textos sobre delivery apuntan hacia el mismo lado del espacio aunque uno sea una línea y el otro un párrafo.
Dato clave para este libro: Anthropic no ofrece una API de embeddings propia. La solución estándar (y mejor para aprender): un modelo open-source de la familia sentence-transformers, que corre local, gratis y sin red. Usaremos paraphrase-multilingual-MiniLM-L12-v2, multilingüe (maneja bien el español) y liviano (~470 MB, corre en CPU).
Instalá lo necesario y comprobá la magia en 20 líneas. Creá 01_probar_embeddings.py:
pip install sentence-transformers numpy
"""Ver la similitud semantica con tus propios ojos."""
import numpy as np
from sentence_transformers import SentenceTransformer
modelo = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
frases = [
"¿Hacen entregas a domicilio?",
"¿Llevan la comida hasta mi casa?",
"¿Puedo pagar con tarjeta de crédito?",
"El cielo de San Miguel está despejado hoy",
]
vectores = modelo.encode(frases) # matriz (4, 384)
print(f"Forma de la matriz: {vectores.shape}")
def coseno(a: np.ndarray, b: np.ndarray) -> float:
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
print("\nSimilitud con la frase 0 ('¿Hacen entregas a domicilio?'):")
for i, frase in enumerate(frases[1:], start=1):
print(f" {coseno(vectores[0], vectores[i]):.3f} {frase}")
Salida típica:
Forma de la matriz: (4, 384)
Similitud con la frase 0 ('¿Hacen entregas a domicilio?'):
0.812 ¿Llevan la comida hasta mi casa?
0.378 ¿Puedo pagar con tarjeta de crédito?
0.135 El cielo de San Miguel está despejado hoy
"Entregas a domicilio" y "llevan la comida hasta mi casa" no comparten ninguna palabra clave y aun así el modelo las pone juntas (0.81). Eso es búsqueda semántica, y ningún LIKE '%delivery%' de SQL puede hacerlo.
4.3 El corpus: políticas y preguntas frecuentes de La Esquina
Guardá este documento como politicas_la_esquina.md — es el conocimiento que el bot va a "estudiar". Fijate que está organizado en secciones con títulos ##, porque vamos a trocear por sección:
# Políticas y preguntas frecuentes — La Esquina
## Entregas a domicilio
Hacemos entregas a domicilio desde la sucursal Centro y la sucursal Roosevelt,
en un radio de 3 kilómetros, todos los días de 11:00 a 19:00. El costo de
envío es 1.50 dólares dentro del radio. El pedido mínimo para entrega es
5.00 dólares.
La sucursal Metrocentro no hace entregas: solo para llevar o comer ahí.
## Formas de pago
Aceptamos efectivo en todas las sucursales. Las sucursales Centro y
Metrocentro también aceptan tarjeta de débito y crédito (Visa y Mastercard).
Aceptamos pagos por transferencia con confirmación previa para encargos
grandes. No aceptamos cheques ni dólares dañados o rotos.
## Encargos para eventos
Para encargos de más de 50 pupusas pedimos al menos 24 horas de anticipación
y un adelanto del 50%. Los encargos de eventos incluyen curtido y salsa en
envases familiares sin costo extra. Para encargos de más de 200 pupusas,
contactar directamente a la encargada de la sucursal Centro al
7777-0000 con 3 días de anticipación.
## Reclamos y devoluciones
Si un pedido llegó incompleto o con errores, lo reponemos sin costo
presentando el comprobante, dentro de las 2 horas siguientes a la compra.
No hacemos devoluciones de dinero, solo reposición del producto. Para
reclamos, el cliente puede hablar con el encargado de turno en cualquier
sucursal o escribir al 7777-0000.
## Cliente frecuente
El programa "Esquina de Oro" da una pupusa gratis por cada 10 compradas.
La tarjeta se pide en caja, es gratuita y se sella con cada compra mínima
de 3.00 dólares. Los sellos no vencen. La tarjeta llena se canjea en cualquier
sucursal. No aplica para encargos de eventos ni entregas a domicilio.
## Higiene y alérgenos
Todas nuestras pupusas se preparan en comales donde también se cocina queso
y chicharrón, por lo que no podemos garantizar ausencia de trazas de lácteos
o cerdo en ninguna pupusa. El aceite que usamos es 100% vegetal. Quien tenga
alergias severas debe avisar en caja antes de ordenar.
4.4 El pipeline RAG completo
Mermaid
flowchart LR
subgraph IDX["INDEXAR (una vez, offline)"]
D["politicas.md"] --> CH["Trocear en chunks
(por sección, con solapamiento)"]
CH --> E1["Embeddings
sentence-transformers"]
E1 --> M["Matriz de vectores
+ lista de chunks"]
end
subgraph QRY["CONSULTAR (cada pregunta)"]
Q["'¿llevan a domicilio?'"] --> E2["Embedding de la pregunta"]
E2 --> S["Similitud coseno
contra TODA la matriz (numpy)"]
M --> S
S --> K["top-k chunks
(k=3)"]
K --> P["Inyectar en el prompt
con etiquetas <contexto>"]
P --> LLM["Claude redacta
+ cita la fuente"]
end
Paso 1: chunking con solapamiento
¿Por qué trocear? Porque la unidad de recuperación importa: si indexás el documento entero como un solo vector, toda búsqueda devuelve "el documento" (inútil); si indexás oración por oración, cada chunk pierde su contexto. El punto dulce: fragmentos de 1-3 párrafos con algo de solapamiento entre consecutivos.
Nuestro corpus ya viene seccionado con ##, así que troceamos por sección — y si una sección fuera muy larga, la partimos con solapamiento:
def trocear(texto: str, max_caracteres: int = 800, solape: int = 150) -> list[dict]:
"""Parte el documento en chunks por seccion '## ', con solapamiento
si una seccion excede max_caracteres.
Devuelve [{"fuente": titulo_seccion, "texto": chunk}, ...]
"""
chunks = []
secciones = texto.split("\n## ")
for seccion in secciones[1:]: # [0] es el titulo del documento
lineas = seccion.split("\n", 1)
titulo = lineas[0].strip()
cuerpo = lineas[1].strip() if len(lineas) > 1 else ""
contenido = f"{titulo}\n{cuerpo}"
if len(contenido) <= max_caracteres:
chunks.append({"fuente": titulo, "texto": contenido})
continue
# Seccion larga: ventanas deslizantes CON solapamiento.
inicio = 0
while inicio < len(contenido):
fin = inicio + max_caracteres
chunks.append({"fuente": titulo, "texto": contenido[inicio:fin]})
if fin >= len(contenido):
break
inicio = fin - solape # ← el solape: retrocedemos 150 chars
return chunks
⚠️ Trampa común
Chunks sin solapamiento que cortan la respuesta en dos. Imaginá que el corte cae justo acá:
...pedimos al menos 24 horas de anticipación y un adelanto │CORTE│ del 50%. Los encargos incluyen curtido...
La pregunta "¿cuánto es el adelanto para encargos?" recupera el primer chunk (que menciona "adelanto"... pero no el monto) o el segundo (que dice "50%"... sin decir de qué). El bot responde a medias o inventa. El solapamiento (repetir los últimos ~150 caracteres de un chunk al inicio del siguiente) garantiza que toda oración viva completa en al menos un chunk. Cuesta un poco más de índice y es el seguro más barato del pipeline. Valores típicos: chunks de 300-800 tokens con solape de 50-100.
Paso 2 y 3: indexar y buscar
Creá rag.py — el módulo completo de RAG, listo para importar desde el bot:
"""RAG para La Esquina: chunking, indexado y busqueda semantica top-k."""
from pathlib import Path
import numpy as np
from sentence_transformers import SentenceTransformer
MODELO_EMBEDDINGS = "paraphrase-multilingual-MiniLM-L12-v2"
def trocear(texto: str, max_caracteres: int = 800, solape: int = 150) -> list[dict]:
"""Parte el documento en chunks por seccion '## ', con solapamiento."""
chunks = []
secciones = texto.split("\n## ")
for seccion in secciones[1:]:
lineas = seccion.split("\n", 1)
titulo = lineas[0].strip()
cuerpo = lineas[1].strip() if len(lineas) > 1 else ""
contenido = f"{titulo}\n{cuerpo}"
if len(contenido) <= max_caracteres:
chunks.append({"fuente": titulo, "texto": contenido})
continue
inicio = 0
while inicio < len(contenido):
fin = inicio + max_caracteres
chunks.append({"fuente": titulo, "texto": contenido[inicio:fin]})
if fin >= len(contenido):
break
inicio = fin - solape
return chunks
class IndiceRAG:
"""Indice semantico en memoria: chunks + matriz de embeddings."""
def __init__(self):
self.modelo = SentenceTransformer(MODELO_EMBEDDINGS)
self.chunks: list[dict] = []
self.matriz: np.ndarray | None = None # (n_chunks, 384), normalizada
def indexar(self, ruta_documento: str) -> None:
"""Trocea el documento y calcula el embedding de cada chunk."""
texto = Path(ruta_documento).read_text(encoding="utf-8")
self.chunks = trocear(texto)
textos = [c["texto"] for c in self.chunks]
vectores = self.modelo.encode(textos)
# Normalizamos una sola vez: el coseno se vuelve un producto punto.
normas = np.linalg.norm(vectores, axis=1, keepdims=True)
self.matriz = vectores / normas
print(f"📚 Indexados {len(self.chunks)} chunks de {ruta_documento}")
def buscar(self, pregunta: str, k: int = 3, umbral: float = 0.30) -> list[dict]:
"""Devuelve los k chunks mas similares a la pregunta.
Filtra los que queden bajo el umbral: si nada es relevante,
mejor devolver poco (o nada) que basura.
"""
if self.matriz is None:
raise RuntimeError("El índice está vacío: llamá a indexar() primero.")
q = self.modelo.encode([pregunta])[0]
q = q / np.linalg.norm(q)
similitudes = self.matriz @ q # (n_chunks,) — cosenos
orden = np.argsort(similitudes)[::-1][:k]
return [
{**self.chunks[i], "similitud": float(similitudes[i])}
for i in orden
if similitudes[i] >= umbral
]
if __name__ == "__main__":
indice = IndiceRAG()
indice.indexar("politicas_la_esquina.md")
for pregunta in ["¿llevan comida a la casa?",
"quiero encargar 100 pupusas para un cumpleaños",
"¿aceptan Bitcoin?"]:
print(f"\n❓ {pregunta}")
for r in indice.buscar(pregunta):
print(f" {r['similitud']:.3f} [{r['fuente']}]")
Corrélo (python rag.py) y mirá los resultados: "¿llevan comida a la casa?" recupera Entregas a domicilio con la mayor similitud; "encargar 100 pupusas" recupera Encargos para eventos; y "¿aceptan Bitcoin?" recupera Formas de pago (relevante: ahí está la respuesta de qué SÍ se acepta).
📐 Fundamento
Tres decisiones de ingeniería del código que merecen explicación:
-
Normalizar los vectores al indexar. El coseno es . Si guardás todos los vectores ya divididos por su norma (), el coseno se reduce a un producto punto, y la búsqueda contra todo el índice es una sola multiplicación matriz-vector:
self.matriz @ q. Para miles de chunks, numpy resuelve esto en microsegundos — no necesitás una base vectorial hasta tener cientos de miles de chunks. -
top-k con
argsort.np.argsort(similitudes)[::-1][:k]ordena los índices de mayor a menor similitud y toma los primeros k. Con k=3 le damos al modelo contexto suficiente sin ahogarlo; k típicos en producción: 3-8. -
El umbral de relevancia. Si el cliente pregunta algo que NO está en las políticas ("¿venden pizza?"), el top-3 igual devuelve los 3 chunks "menos lejanos" — pero todos malos. El umbral (acá 0.30, ajustalo con tus datos) corta esa basura: mejor decirle al modelo "no se encontró nada" que pasarle contexto irrelevante que lo tiente a improvisar conexiones.
Paso 4 y 5: inyectar el contexto y citar
¿Cómo le pasamos los chunks al modelo? Envueltos en etiquetas que separen claramente datos recuperados de instrucciones y de pregunta del usuario. Esta es la integración con el bot — el método nuevo para bot_v4.py (extiende el Chatbot del capítulo 3):
"""bot_v4.py — Esquinita con herramientas + RAG sobre las politicas."""
from bot_v3 import Chatbot, SYSTEM_PROMPT as SYSTEM_V3
from rag import IndiceRAG
SYSTEM_PROMPT = SYSTEM_V3 + """
# Conocimiento de políticas
Cuando el mensaje venga acompañado de un bloque <contexto>, ahí van fragmentos
oficiales de las políticas de La Esquina, cada uno con su fuente entre
corchetes. Reglas:
- Respondé usando SOLO la información del contexto para temas de políticas.
- Mencioná la fuente de forma natural: "según nuestra política de entregas...".
- Si el contexto no contiene la respuesta, decí que vas a consultarlo con un
encargado. NO completés con suposiciones.
"""
class ChatbotConRAG(Chatbot):
"""Esquinita v4: el loop agentico del cap. 3 + recuperacion de politicas."""
def __init__(self, system_prompt: str, ruta_politicas: str,
model: str = "claude-opus-4-8"):
super().__init__(system_prompt, model)
self.indice = IndiceRAG()
self.indice.indexar(ruta_politicas)
def _armar_mensaje_con_contexto(self, mensaje_usuario: str) -> str:
"""Busca chunks relevantes y los antepone al mensaje del usuario."""
recuperados = self.indice.buscar(mensaje_usuario, k=3)
if not recuperados:
return mensaje_usuario # nada relevante: mensaje tal cual
contexto = "\n\n".join(
f"[Fuente: {r['fuente']}]\n{r['texto']}" for r in recuperados
)
return (
f"<contexto>\n{contexto}\n</contexto>\n\n"
f"<pregunta_del_cliente>\n{mensaje_usuario}\n</pregunta_del_cliente>"
)
def enviar(self, mensaje_usuario: str) -> str:
# Recuperamos ANTES de agregar al historial: el turno user que viaja
# ya lleva el contexto adentro.
mensaje_aumentado = self._armar_mensaje_con_contexto(mensaje_usuario)
return super().enviar(mensaje_aumentado)
def main():
bot = ChatbotConRAG(SYSTEM_PROMPT, "politicas_la_esquina.md")
print("🫓 Esquinita v4 (herramientas + RAG) — 'salir' para terminar.\n")
while True:
entrada = input("Vos: ").strip()
if entrada.lower() in ("salir", "exit", "quit"):
print("¡Que le vaya bien!")
break
if not entrada:
continue
print(f"\nBot: {bot.enviar(entrada)}\n")
if __name__ == "__main__":
main()
Probálo:
Vos: ¿Hacen delivery? Vivo por la Roosevelt
Bot: ¡Sí! Según nuestra política de entregas, desde la sucursal Roosevelt
llevamos a domicilio en un radio de 3 km, de 11:00 a 19:00. El envío
cuesta 1.50 y el pedido mínimo es de 5.00 dólares 😊
Vos: ¿Y si me llega incompleto el pedido?
Bot: Según nuestra política de reclamos, se lo reponemos sin costo si
presenta el comprobante dentro de las 2 horas. No hacemos devolución
de dinero, solo reposición del producto.
El bot está citando la fuente y respondiendo solo con lo que el documento dice. Y como ChatbotConRAG hereda de Chatbot, las herramientas del capítulo 3 siguen funcionando: podés preguntar las políticas de entrega y en el mismo chat encargar tus pupusas.
🛠️ En la práctica
Por qué etiquetas tipo <contexto>. Tres razones de producción:
- Separación de poderes. El modelo distingue con claridad qué es dato recuperado (puede estar mal, puede estar incompleto) de qué es instrucción (el system prompt) y qué es pregunta del usuario. Sin delimitadores, todo se mezcla y el modelo puede tratar texto de un documento como si fuera una orden — el vector de la inyección indirecta de prompts que veremos en el capítulo 5.
- Citabilidad. Al meter
[Fuente: ...]en cada fragmento, pedirle al modelo que cite cuesta una línea de system prompt. En sistemas grandes la fuente es la URL o el ID del documento, y el frontend la convierte en link. - Debuggeabilidad. Cuando el bot responda mal, lo primero es loguear qué chunks se recuperaron. El 80% de los fallos de un sistema RAG son fallos de recuperación (el chunk correcto no llegó), no de generación. Si el contexto era bueno y la respuesta mala, el problema es el prompt; si el contexto era malo, el problema es el chunking, el modelo de embeddings o el umbral.
Nota de arquitectura: acá inyectamos contexto en cada mensaje (recuperación incondicional). La alternativa elegante es exponer la búsqueda como una herramienta más (buscar_politicas(pregunta)) y dejar que el modelo decida cuándo buscar — RAG agéntico. Con lo que sabés del capítulo 3, podés implementarlo en 20 líneas (es el ejercicio 4).
⚠️ Trampa común
Contaminar el historial con contexto gigante. Fijate que el mensaje aumentado (con sus chunks) queda guardado en el historial y se reenvía en cada turno siguiente. Con k=3 chunks de ~600 caracteres está bien — son ~450 tokens extra por turno que tocó RAG. Pero si subís k o tus chunks son enormes, cada pregunta de políticas engorda el historial para siempre. Mitigaciones usadas en producción: guardar en el historial el mensaje original (sin contexto) y aumentar solo el último turno antes de enviar; o recortar los bloques <contexto> de turnos viejos al recortar el historial. Para Esquinita, la versión simple alcanza — pero medí (/costo del cap. 2) y decidí con datos.
Resumen visual
| Etapa | Herramienta | Decisión clave | Valores de Esquinita |
|---|---|---|---|
| Trocear | trocear() |
Tamaño del chunk + solapamiento | Por sección ##, máx. 800 chars, solape 150 |
| Indexar | sentence-transformers | Modelo de embeddings (multilingüe para español) | paraphrase-multilingual-MiniLM-L12-v2, 384 dim |
| Buscar | numpy | k + umbral de relevancia | k=3, umbral 0.30, coseno vía producto punto |
| Inyectar | prompt | Delimitadores claros | <contexto> + [Fuente: ...] por chunk |
| Citar | system prompt | Obligar la cita y la honestidad | "solo el contexto", "mencioná la fuente", "si no está, decilo" |
Estado del proyecto — R5 (políticas) ✅. Con R1-R7 cumplidos, Esquinita ya hace todo lo que doña Carmen pidió... en tu terminal. Falta lo que falta siempre: ponerlo en la web, que no se caiga, que no te fundas en costos y que nadie lo hackee. Capítulo 5: a producción.
Ejercicios
✏️ Ejercicio 1 — El experimento del umbral
Con rag.py, probá buscar() con estas preguntas y umbral=0.0 (sin filtro), imprimiendo las similitudes: (a) "¿puedo pagar con tarjeta?", (b) "¿tienen wifi?", (c) "¿cuántos sellos necesito para la pupusa gratis?". ¿Qué similitudes obtiene cada una? ¿Dónde pondrías el umbral a la luz de los números, y qué riesgo tiene ponerlo muy alto?
✅ Solución
Valores típicos (varían un poco por versión del modelo): (a) ~0.75 con Formas de pago — recuperación clarísima; (c) ~0.55 con Cliente frecuente — correcta aunque menos directa (la palabra "sellos" aparece en el chunk); (b) ~0.25-0.35 con su mejor chunk — ruido: el wifi no está en las políticas y ninguna sección habla de eso.
El umbral debe separar (b) de (c): algo entre 0.35 y 0.45 funciona para este corpus. El riesgo de subirlo demasiado: preguntas legítimas formuladas raro ("¿me regalan una si junto compras?") pueden quedar bajo el umbral → el bot dice "no sé" teniendo la respuesta indexada (falsos negativos). El umbral se calibra con preguntas reales de usuarios, no con intuición — guardá las preguntas que fallen y ajustá.
✏️ Ejercicio 2 — Rompé el chunking
Modificá trocear() para usar max_caracteres=120 y solape=0, re-indexá y preguntá "¿cuánto adelanto piden para encargos grandes?". Compará la respuesta con la versión original. Después repetí con solape=60. Explicá lo observado en términos de la trampa de la sección 4.4.
✅ Solución
Con chunks de 120 caracteres sin solape, la sección Encargos para eventos queda picada en ~4 fragmentos, y la oración "pedimos al menos 24 horas de anticipación y un adelanto del 50%" probablemente quede partida: un chunk con "adelanto" y otro con "del 50%". La búsqueda recupera el fragmento con la palabra "adelanto" pero sin el porcentaje → el bot responde vago ("piden un adelanto, le confirmo el monto") o, peor, alucina un número si el prompt no lo frena.
Con solape=60, la oración completa sobrevive en al menos un chunk y la respuesta vuelve a ser "el 50%". Moraleja medible: el solapamiento no es optimización, es corrección — sin él, hay respuestas que tu sistema simplemente no puede dar bien, sin importar qué tan bueno sea el modelo.
✏️ Ejercicio 3 — Citas con formato de auditoría
Doña Carmen quiere auditar las respuestas: pide que cada respuesta basada en políticas termine con la línea 📋 Fuente: <nombre de la sección>. Lograrlo SIN tocar el código Python — solo editando el system prompt. ¿Qué agregaste? ¿Por qué conviene además loguear los chunks recuperados en el código, si el modelo ya cita?
✅ Solución
Agregar a la sección de conocimiento del system prompt algo como:
- Toda respuesta que use el contexto debe terminar con una línea final:
📋 Fuente: <nombre de la fuente usada>
Si usaste varias fuentes, listalas separadas por coma. Si no usaste el
contexto, no agregués esa línea.
Conviene loguear igualmente en código porque la cita del modelo es generada, no garantizada: puede citar la fuente equivocada de las tres recuperadas, u omitirla. El log de recuperados (en _armar_mensaje_con_contexto) es la verdad de qué información tuvo disponible — esencial para depurar la diferencia entre "recuperamos mal" y "el modelo citó mal". Confianza: prompt para la UX, logs para la auditoría.
✏️ Ejercicio 4 — RAG agéntico: la búsqueda como herramienta
Refactorizá la integración: en vez de inyectar contexto en cada mensaje, definí una herramienta buscar_politicas (schema al estilo del cap. 3, con description que diga cuándo usarla) cuya implementación llame a indice.buscar(pregunta, k=3) y devuelva los chunks como JSON. ¿Qué ventajas y desventajas observás frente a la inyección incondicional?
✅ Solución
Schema e implementación (resumen):
TOOL_BUSCAR = {
"name": "buscar_politicas",
"description": ("Busca en las políticas oficiales de La Esquina (entregas, "
"pagos, encargos, reclamos, cliente frecuente, alérgenos). "
"Usala SIEMPRE que pregunten por reglas o políticas del "
"negocio. No sirve para precios del menú: para eso está "
"consultar_menu."),
"input_schema": {"type": "object",
"properties": {"pregunta": {"type": "string",
"description": "La duda del cliente, reformulada como pregunta clara."}},
"required": ["pregunta"]},
}
def buscar_politicas(pregunta: str) -> str:
import json
resultados = INDICE.buscar(pregunta, k=3)
if not resultados:
return json.dumps({"resultados": [], "nota": "Nada relevante en las políticas."},
ensure_ascii=False)
return json.dumps({"resultados": resultados}, ensure_ascii=False)
Ventajas: solo se paga el contexto cuando hace falta (los "hola, quiero 3 de queso" no arrastran chunks); el modelo puede reformular la pregunta para buscar mejor, o buscar dos veces con términos distintos; el historial queda más limpio. Desventajas: una llamada extra a la API por cada búsqueda (más latencia y costo por mensaje de políticas); y si la description es floja, el modelo a veces no busca y responde de memoria — el riesgo que la inyección incondicional no tiene. En producción, el patrón herramienta domina cuando el corpus es grande y las preguntas de conocimiento son minoría del tráfico. Exactamente el caso de Esquinita cuando crezca.
Para profundizar
- sentence-transformers — sbert.net: documentación oficial; mirá la tabla de modelos preentrenados para elegir según idioma, tamaño y calidad.
- Embeddings y RAG en el libro de IA — LLMs y transformers, secciones 6.2 y 6.5: la teoría detrás de lo que acá implementaste.
- numpy — numpy.org/doc: si
argsort, broadcasting o normas te resultaron oscuros, el quickstart oficial lo aclara en una tarde. - Documentación de Anthropic sobre RAG — platform.claude.com/docs: guías de RAG con citas y de cuándo preferir contexto largo + caching (la misma decisión de la sección 4.1, con benchmarks).
- Bases vectoriales (Chroma, pgvector, Qdrant): el siguiente paso cuando numpy en memoria se quede corto — misma matemática, con persistencia e índices aproximados.