Módulo 7: Operar RAG en producción

Manejar 0 resultados

Descripción

search_docs puede devolver una lista vacía — el Módulo 3 (Lección 07) ya lo mostró con una query hecha de puro ruido ("xyzxyzxyz nonsense query zzzqqq") y el Módulo 2 (Lección 06) ya lo estableció como un resultado legítimo del índice BM25, no un error. Esta lección construye la pieza que falta: qué hace la respuesta final cuando eso pasa. La pregunta de fondo es de grounding —¿el agente admite honestamente que no encontró nada, o rellena el silencio con una respuesta que suena plausible pero no viene de ningún documento?— y la respuesta correcta parece obvia hasta que se ejecuta el caso real. Vas a ver, con evidencia de tu propia terminal, que search_docs devolviendo [] es en realidad el caso raro: la inmensa mayoría de las preguntas que Reservo no puede responder no vuelven vacías — vuelven con chunks de score bajo que, sin mirar el número con cuidado, parecen resultados normales.

Conexión con el módulo

Esta lección construye sobre la Lección 02 (citar la fuente): antes de preguntarse qué citar, hace falta decidir si hay algo que citar. La Lección 04 (umbral de relevancia) retoma exactamente el hallazgo incómodo de esta lección —que [] es raro— y da la herramienta que sí cubre el caso común.


Analogía: "no lo tenemos" en vez de una respuesta inventada

En cualquier mostrador de atención real, hay preguntas para las que la respuesta honesta es "no tenemos eso" — un cliente pregunta por el horario del gimnasio del edificio, y Reservo no tiene ningún gimnasio ni ninguna política sobre uno. Una persona en el mostrador que se toma en serio su trabajo dice "no tenemos información sobre eso" sin dudar. Una que no quiere decepcionar a nadie podría, en cambio, improvisar algo plausible a partir de lo que sabe de otros edificios parecidos — y ahí es donde empieza el problema: el cliente se va con una respuesta que suena a política oficial de Reservo, pero que nadie escribió ni aprobó. El desafío de esta lección no es enseñar a decir "no lo tenemos" — eso es fácil cuando la pregunta es obviamente ajena al negocio. El desafío real es notar que, en la práctica, el mostrador automático casi nunca vuelve con las manos completamente vacías: casi siempre trae algo, aunque ese algo no tenga nada que ver con lo que se preguntó — y confundir "traje algo" con "encontré la respuesta" es, en este trabajo, el error más caro de todos.


handle_no_results: la pieza mínima

from search_docs_tool import search_docs

NO_RESULTS_MESSAGE = "No encontré información sobre esto en los documentos de Reservo."


def handle_no_results(hits):
    """Si hits esta vacio, devuelve el mensaje honesto de 'no lo tenemos'.
    Si hay al menos un resultado, devuelve None -- la respuesta sigue
    su curso normal (citando lo que search_docs si trajo)."""
    if not hits:
        return NO_RESULTS_MESSAGE
    return None

Sobre una query hecha de puro ruido, sin ningún término real del corpus:

hits = search_docs("xyzxyzxyz nonsense query zzzqqq", k=3)
print("hits:", hits)
fallback = handle_no_results(hits)
print("respuesta:", fallback if fallback else "(sigue el flujo normal con los hits)")

Qué esperar:

hits: []
respuesta: No encontré información sobre esto en los documentos de Reservo.

Este es el caso limpio: search_docs no matcheó ningún término del corpus con la query, devolvió [], y handle_no_results activa el mensaje honesto. Ningún LLM entra en esta parte —es lógica determinista sobre una lista de Python— pero es la base indispensable de lo que sigue: [modelo·concepto] con claude-sonnet-5, si el tool_result de search_docs llega como [] y el prompt del sistema instruye responder solo con lo que las tools devuelven, la respuesta esperada es una variación de "no encontré información sobre esto en los documentos disponibles" — no una respuesta genérica basada en el conocimiento general del modelo sobre membresías de coworking. Un agente sin esa instrucción explícita, recibiendo [], podría en cambio recurrir a lo que "sabe" de sistemas parecidos y responder con confianza sobre algo que Reservo nunca documentó — exactamente el tipo de respuesta sin grounding que el Módulo 4 (Lección 06) mide con check_grounding para números, y que esta lección previene un paso antes, en la decisión misma de si hay base para responder.


El hallazgo incómodo: [] es el caso raro

Antes de asumir que handle_no_results cubre "las preguntas que Reservo no puede responder", vale la pena probarlo contra preguntas genuinamente ajenas al negocio — no ruido aleatorio, sino preguntas reales, con gramática real, sobre temas que Reservo simplemente no documenta:

off_topic_queries = [
    "What is the weather forecast for tomorrow?",
    "How do I reset my email password?",
    "Can I bring my dog to the building?",
]

for q in off_topic_queries:
    hits = search_docs(q, k=3)
    print(f"{q!r}")
    print(f"  hits={len(hits)}  ", [(h['doc_id'], h['score']) for h in hits])

Qué esperar:

'What is the weather forecast for tomorrow?'
  hits=3   [('cancellation-policy', 6.131), ('booking-faq', 4.843), ('cancellation-policy', 4.732)]
'How do I reset my email password?'
  hits=3   [('wifi-and-equipment-faq', 5.382), ('wifi-and-equipment-faq', 3.334), ('cancellation-policy', 2.548)]
'Can I bring my dog to the building?'
  hits=3   [('studio-room-manual', 3.658), ('booking-faq', 3.512), ('payment-methods-faq', 3.466)]

Ninguna de las tres preguntas —sobre el clima, sobre una contraseña de correo, sobre traer una mascota— tiene absolutamente nada que ver con los documentos de Reservo. Y sin embargo, ninguna devuelve []. Las tres traen tres resultados, con scores en un rango (2.5 a 6.1) que, a simple vista, no se distingue del rango de scores que las queries ancla genuinamente relevantes también producen. handle_no_results, tal como está escrita, no activa el mensaje honesto en ninguno de estos tres casos — para el sistema, "hay resultados" es indistinguible de "hay resultados relevantes".

La razón es la misma que sostiene toda la honestidad léxica de esta guía desde el Módulo 2: BM25 no necesita que una query sea sobre el corpus para encontrar coincidencias — solo necesita que comparta palabras, y sin remoción de stopwords, casi cualquier pregunta en inglés bien formada comparte algo ("is", "the", "does", "can") con algún chunk de un corpus de 57 fragmentos. [] solo aparece cuando la query no comparte ningún término, ni siquiera uno funcional — el caso de "xyzxyzxyz nonsense query zzzqqq", tokens que literalmente no existen en ningún idioma del corpus. Una pregunta real, gramaticalmente normal, casi nunca cae en ese caso extremo.


Por qué esto importa más de lo que parece

Piensa en las consecuencias de confiar solo en handle_no_results para decidir cuándo admitir "no lo tenemos". Si el criterio fuera "responde con el mensaje honesto solo si hits está vacío", las tres preguntas de arriba —clima, contraseña de correo, mascotas— pasarían el chequeo y seguirían el flujo normal: search_docs les entregaría tres chunks reales, con scores que parecen normales, y nada en el camino le avisaría al agente que esos chunks no tienen relación con la pregunta. [modelo·concepto] un agente que recibe esos tres chunks de cancellation-policy/booking-faq como tool_result de una pregunta sobre el clima, sin ninguna señal adicional, corre el riesgo real de citarlos como si fueran relevantes —"según nuestros documentos..."— simplemente porque llegaron como resultado de una tool que sí se ejecutó con éxito.

Esto no es un defecto de handle_no_results — es su límite honesto, exactamente rotulado: cubre el caso de ausencia total de coincidencia léxica, que es real pero poco frecuente. El caso mucho más común —coincidencia léxica débil, sin relación temática real— necesita una herramienta distinta, que mire el score, no solo la longitud de la lista. Esa es, precisamente, la Lección 04 de este módulo.


El flujo combinado, visualizado

search_docs(query, k) -> hits
    │
    ▼
hits == []?
    │
    ├── SI -> handle_no_results(hits) -> mensaje honesto, fin
    │
    └── NO -> hits tiene 1+ resultados
              │
              pero: ¿son relevantes, o solo comparten
              palabras funcionales con la query?
              (esta lección NO responde eso -- Leccion 04 si)

handle_no_results resuelve la rama izquierda del diagrama con una lógica de tres líneas. La rama derecha —qué hacer con resultados no vacíos pero de baja confianza— es deliberadamente el trabajo de la siguiente lección, no de esta.


Errores comunes

  1. Pensar que handle_no_results cubre "todas las preguntas que Reservo no puede responder". Como demostró la sección anterior con tres preguntas reales, ninguna genuinamente fuera de tema, ninguna devolvió []. handle_no_results cubre exactamente un caso: ausencia total de coincidencia léxica. Es una pieza necesaria, no suficiente.

  2. Tratar [] como un fallo del sistema que hay que "arreglar" forzando algún resultado. Como ya estableció el Módulo 2 y confirmó el Módulo 3, una lista vacía es la respuesta correcta y honesta del índice cuando no hay ninguna coincidencia — forzar un resultado de score bajo en su lugar sería reemplazar un "no sé" honesto por una respuesta potencialmente engañosa, exactamente lo contrario de lo que esta lección busca.

  3. Asumir que el mensaje de NO_RESULTS_MESSAGE reemplaza la necesidad de un prompt de sistema que instruya al modelo a admitir cuando no sabe. handle_no_results es una salvaguarda determinista sobre el caso de [] literal — no reemplaza la instrucción explícita, a nivel de prompt del agente, de que la respuesta debe basarse solo en lo que las tools devolvieron. Sin esa instrucción, un modelo que reciba [] como tool_result podría, de todas formas, intentar responder desde su conocimiento general.

  4. No distinguir, en el diseño del sistema, entre "0 resultados" y "resultados de baja confianza". Son dos fallas distintas con evidencia distinta: la primera se detecta con len(hits) == 0; la segunda necesita mirar el score de cada resultado. Tratarlas como el mismo problema —"si handle_no_results no se activó, todo está bien"— es exactamente el error que esta lección expuso con las tres preguntas fuera de tema.


Ejercicios

Ejercicio 1: Confirma el caso limpio (Fácil)

Ejecuta handle_no_results sobre el resultado de search_docs("qwjkl zxcvb poiuy", k=3) (otra query de puro ruido, distinta a la del ejemplo trabajado) y confirma que el mensaje honesto se activa.

Ver solución
hits = search_docs("qwjkl zxcvb poiuy", k=3)
print("hits:", hits)
print("respuesta:", handle_no_results(hits))

Salida esperada:

hits: []
respuesta: No encontré información sobre esto en los documentos de Reservo.

Explicación: igual que con la query de ruido del ejemplo trabajado, ninguno de estos tres tokens (qwjkl, zxcvb, poiuy) existe en el vocabulario del corpus de Reservo — no hay ni una sola coincidencia léxica posible, así que search_docs devuelve [] y handle_no_results activa el mensaje honesto correctamente. Este es exactamente el caso para el que la función fue diseñada.

Ejercicio 2: Mide la tasa real de "0 resultados" sobre un lote mixto (Medio)

Arma una lista con las seis queries ancla del corpus (relevantes) más las tres preguntas fuera de tema del ejemplo trabajado (clima, contraseña, mascota) más una de puro ruido. Ejecuta search_docs sobre las diez y cuenta cuántas activan handle_no_results. ¿El resultado confirma o contradice el hallazgo de esta lección?

Ver solución
batch = [
    "What is the cancellation policy for Boardroom bookings?",
    "How much discount does the pro tier get?",
    "What equipment is in the Focus room?",
    "Can I get a refund if I didn't show up?",
    "What payment methods does Reservo accept?",
    "Is there wifi in the Lounge?",
    "What is the weather forecast for tomorrow?",
    "How do I reset my email password?",
    "Can I bring my dog to the building?",
    "xyzxyzxyz nonsense query zzzqqq",
]

zero_count = 0
for q in batch:
    hits = search_docs(q, k=3)
    fallback = handle_no_results(hits)
    if fallback:
        zero_count += 1
    print(f"{'[]' if fallback else f'{len(hits)} hits':8s}  {q}")

print(f"\n{zero_count}/{len(batch)} preguntas activaron handle_no_results ({zero_count/len(batch):.0%})")

Salida esperada:

3 hits    What is the cancellation policy for Boardroom bookings?
3 hits    How much discount does the pro tier get?
3 hits    What equipment is in the Focus room?
3 hits    Can I get a refund if I didn't show up?
3 hits    What payment methods does Reservo accept?
3 hits    Is there wifi in the Lounge?
3 hits    What is the weather forecast for tomorrow?
3 hits    How do I reset my email password?
3 hits    Can I bring my dog to the building?
[]        xyzxyzxyz nonsense query zzzqqq

1/10 preguntas activaron handle_no_results (10%)

Explicación: confirma el hallazgo con un número concreto: de diez preguntas, solo la de puro ruido activó el mensaje honesto — el 10% del lote. Las tres preguntas genuinamente fuera de tema (clima, contraseña, mascota) quedaron indistinguibles, según este único chequeo, de las seis preguntas realmente relevantes: las diez tienen len(hits) == 3. Si handle_no_results fuera la única salvaguarda del sistema, un 90% de las preguntas —relevantes o no— pasaría sin ninguna señal de alerta. Esto es exactamente el argumento que abre la Lección 04: hace falta un criterio que mire el score, no solo la longitud de la lista.

Ejercicio 3: Diseña un mensaje honesto más específico (Difícil)

NO_RESULTS_MESSAGE es un mensaje genérico, igual para cualquier query. Escribe una función handle_no_results_verbose(query, hits) que, cuando hits esté vacío, devuelva un mensaje que incluya la query original entre comillas —por ejemplo, para que un usuario pueda confirmar que el sistema entendió bien lo que preguntó—. Ejecútala sobre la query de ruido del ejemplo trabajado y discute, en una frase, un riesgo de incluir la query textual del usuario dentro de un mensaje de respuesta.

Ver solución
def handle_no_results_verbose(query, hits):
    """Version de handle_no_results que ecoa la query original, para que
    el usuario confirme que el sistema entendio bien la pregunta."""
    if not hits:
        return f'No encontré información sobre "{query}" en los documentos de Reservo.'
    return None


msg = handle_no_results_verbose("xyzxyzxyz nonsense query zzzqqq", [])
print(msg)

Salida esperada:

No encontré información sobre "xyzxyzxyz nonsense query zzzqqq" en los documentos de Reservo.

Explicación: el mensaje ahora es más útil para el usuario —confirma explícitamente qué se entendió que se preguntó, en vez de un genérico que podría corresponder a cualquier pregunta sin respuesta—. El riesgo real: si query viene directamente del texto que un usuario escribió, sin ningún tipo de sanitización, incluirlo tal cual dentro de un mensaje que después se muestra en una interfaz (web, chat) abre la puerta a inyección de contenido no confiable en esa interfaz —el mismo tipo de problema que motiva sanitizar cualquier input de usuario antes de reflejarlo de vuelta, sin importar si el flujo pasa por un LLM en el medio o no—. Esta lección no resuelve ese problema de sanitización —está fuera de su alcance— pero vale la pena nombrarlo antes de usar un patrón como este en un sistema real.


Resumen y siguiente paso

  • handle_no_results(hits) activa un mensaje honesto de "no lo tenemos" exactamente cuando search_docs devuelve [] — ausencia total de coincidencia léxica con el corpus.
  • El hallazgo central, ejecutado: [] es el caso raro. Tres preguntas genuinamente fuera de tema (clima, contraseña de correo, mascotas) devolvieron, las tres, resultados no vacíos con scores que no se distinguen a simple vista de una query relevante — de un lote de diez preguntas, solo el 10% activó el mensaje honesto.
  • Este resultado no invalida handle_no_results — lo ubica correctamente como una pieza necesaria, pero insuficiente, para un manejo honesto de "no sé": cubre el extremo de ausencia total de señal, no el caso mucho más común de señal débil y sin relación temática real.
  • [modelo·concepto] un agente instruido a responder solo con lo que las tools devuelven, recibiendo [], admite que no encontró información; sin esa instrucción explícita, corre el riesgo de recurrir a su conocimiento general — la razón de fondo por la que esta pieza importa antes de llegar a la generación de la respuesta final.

Siguiente lección: 04 — Filtrar chunks irrelevantes con un umbral. La pieza que sí cubre el caso común: resultados no vacíos, pero de relevancia demasiado baja para citarlos con seriedad.


Recursos adicionales

  1. production-rag-and-document-ingestion-guide, Módulo 3, Lección 07 (search_docs ejecutada de punta a punta) — el primer caso de [] ejecutado en esta guía, con la query de ruido que esta lección retoma.
  2. production-rag-and-document-ingestion-guide, Módulo 2, Lección 06 (El límite léxico: un sinónimo que no matchea) — por qué [] es un resultado legítimo del índice, no un error a corregir.
  3. production-rag-and-document-ingestion-guide, Módulo 4, Lección 06 (Grounding: anclar la respuesta en los chunks) — check_grounding, el chequeador que confirma si un número citado en la respuesta final tiene respaldo real en el historial, la pieza que actúa un paso después de la decisión de esta lección.
  4. Python — Truthiness de secuencias vacías — la base de if not hits:, usado en handle_no_results para detectar la lista vacía.