Validar la respuesta RAG antes de que el usuario la vea: tramos, citas y el ciclo de retroalimentación

el bloque generacional de Enterprise Document Intelligence, una serie que construye un sistema RAG empresarial a partir de cuatro bloques: análisis de documentos, análisis de preguntas, recuperación y generación. El artículo 8A (el contrato de respuesta) declaró el esquema de respuesta mecanografiado; El artículo 8B (montaje rápido) construyó el despachador que llama al modelo en su contra. Esta parte trata sobre lo que sucede después de que el modelo responde: el validador que verifica intervalos, comillas y formatos; no encontrado como resultado de primera clase; la unión que eleva las citas a rectángulos en el PDF; y los circuitos de retroalimentación que convierten la generación de un paso terminal en un paso al que el oleoducto puede reaccionar.

La generación es el cuarto ladrillo. Un lector que llegue aquí puede aprender los primeros tres de sus propios artículos:

Dónde se ubica este artículo en la serie: Artículo 8 (generación), la parte de validación, dentro de la Parte II (los cuatro ladrillos) – Imagen del autor

📓 Los cuadernos complementarios ejecutables están en GitHub: doc-intel/notebooks-vol1.

El repositorio público de códigos complementarios en doc-intel/notebooks-vol1 – Imagen del autor

1. Confía pero verifica

Una respuesta escrita no es una respuesta marcada. La salida estructurada es el comienzo de la validación, no el final. El modelo todavía cita líneas fuera del rango de entrada, parafrasea citas que juró que eran palabra por palabra, establece complete_answer_found=True en una respuesta parcial y devuelve formas que el informe no solicitó.

La solución es la validación posgeneración. El validador toma la pregunta analizada junto con la respuesta para poder señalar discrepancias de forma con lo solicitado. Se combinan tres controles:

Forma: la respuesta devuelta debe ser una instancia del esquema que el registro eligió para el informe, con elementos completados cuando respuesta_encontrada=True. Evidencia: cada Span debe hacer referencia a un rango de líneas real, cada cita debe ser una subcadena de las líneas citadas después de un espacio en blanco tolerante + normalización de referencias bibliográficas. Formato: ISO 8601 para fechas, ISO 4217 para monedas, etc.

1.1 El validador

A continuación se muestra la implementación. Observe el bucle por elemento y por intervalo: cada problema se informa individualmente, por lo que el modo de falla es visible de un vistazo (un intervalo incorrecto en el elemento 2 no oculta una moneda incorrecta en el elemento 3).

def validar_answer(respuesta: AnswerBase, line_df: pd.DataFrame, parsed_q: ParsedQuestion | Ninguno = Ninguno) -> lista[cadena]: errores: lista[cadena] =[]valid_lines = set(line_df["overall_line_num"].values) si parsed_q no es Ninguno: ExpectedSchema = ANSWER_REGISTRY[parsed_q.expected_answer_shape] si no esinstance(respuesta, ExpectedSchema): errores.append(f"no coincide la forma: {ExpectedSchema.__name__} esperado") if bool(answer.items) != respuesta.answer_found: errores.append(f"answer_found no coincide len(items)={len(answer.items)}") para i, elemento en enumerate(answer.items): si no, item.spans: errores.append(f"item[{i}] no tiene intervalos") para j, sp en enumerate(item.spans): si sp.line_start no está en valid_lines: errores.append(f"line_start {sp.line_start} no en la entrada") if sp.line_end < sp.line_start: errores.append(f"line_end antes de line_start") if sp.quote: citado = _join_cited_lines(line_df, sp) if _normalize(sp.quote) no en _normalize(cited): errores.append(f"quote no textualmente en las líneas citadas") if (fecha:= getattr(item, "fecha", Ninguno)) no es Ninguno: si no es re.fullmatch(r"d{4}-d{2}-d{2}", fecha.iso): errores.append(f"date.iso not AAAA-MM-DD") si (amt := getattr(item, "cantidad", Ninguno)) no es Ninguno: si no es re.fullmatch(r"[AZ]{3}", amt.currency): errores.append(f"moneda no ISO 4217") si respuesta.extraction_method == "verbatim" y no cualquiera(sp.quote for sp in item.spans): errores.append(f"verbatim pero sin comillas") devuelve errores

Un eje que el bucle no cubre: campos uno contra el otro. Cada verificación anterior lee un campo a la vez. La siguiente categoría es la de campos cruzados: restricciones en las que dos campos deben coincidir. La fecha de inicio de un contrato debe preceder a su fecha de finalización; las líneas de una factura deben sumar el total indicado. El esquema indica dónde afecta esto: un valor con extract_method="computed" (un total que el modelo sumó en lugar de leerlo en la página) es exactamente lo que debe conciliarse con sus partes. Declare las restricciones en el esquema y ejecútelas en la misma pasada, agregándolas a la misma lista de errores: fecha_final 2024-12-01 precede a fecha_inicio 2025-01-01, las líneas de pedido suman 350, el total indicado es 400. Una falta de coincidencia no es un artefacto de análisis, es un número en el que el usuario no debe confiar. (La coherencia entre diferentes documentos es un problema a escala de corpus, que se deja para un artículo posterior).

1.2 Verbatim es más difícil de lo que parece

Ejecute el validador en el resultado del modelo real para el artículo La atención es todo lo que necesita (Vaswani et al. 2017; licencia de distribución no exclusiva de arXiv) y la verificación de “cita no textual” activa errores de cita reales y sutiles (no solo artefactos de espacios en blanco). Tres causas recurrentes:

Espacios en blanco y referencias bibliográficas: el analizador de PDF divide un párrafo lógico en 5 a 10 líneas físicas; el modelo lo reconstruye como una cadena con espacios simples. Las líneas fuente a menudo llevan[9]-El estilo refs el modelo de tiras. Ambos parecen "incorrectos" pero son semánticamente fieles. El _normalize_for_quote_check anterior (contraer espacios en blanco, soltar [N]) cubre estos. Combinación de líneas adyacentes: el modelo elige el número de línea incorrecto para una cotización y, a menudo, señala la línea después de que finaliza la cotización. En el ejemplo en ejecución, el modelo afirmó: "Hay muchas opciones de codificaciones posicionales, aprendidas y fijas".[9].” se sentó en la línea global 267, cuando abarcaba 265-266; La línea 267 lleva la siguiente oración (“En este trabajo, usamos funciones seno y coseno…”). De uno en uno o de dos en texto de varias líneas. El validador lo atrapa; el modelo no se da cuenta. Tramos de varias líneas truncados: el modelo proporciona line_start=line_end=275 para una cotización que abarca 275-276. La primera mitad de la cita está en la línea citada; la segunda mitad no lo es. La subcadena falla correctamente.

La primera causa es inofensiva (la fidelidad semántica está intacta); los dos siguientes son fallos reales que el validador detecta porque el mensaje no lo hizo. Esta es la verificación de subcadenas que hace el trabajo real: el modelo indicó la cita con la misma confianza independientemente de que las palabras estén o no en la página, y la verificación detecta la que no es compatible antes de que llegue al usuario.

Líneas citadas frente a la afirmación del modelo: donde subcadena + normalización permite el paso de errores – Imagen del autor

La versión estricta de la verificación de subcadenas todavía tiene su lugar cuando su corpus está prenormalizado (cláusulas extraídas de una base de datos de contratos, por ejemplo) y cualquier desviación de espacios en blanco sería en sí misma un error que vale la pena detectar. Para corpus derivados de PDF, la normalización en espacios en blanco y referencias es el piso correcto; La precisión del número de línea permanece bajo el ojo del validador.

1.3 Cuando falla la validación: tres opciones

Cuando la validación falla, la canalización tiene opciones:

Vuelva a intentarlo con un mensaje más estricto o un modelo diferente. Marcar para revisión: devolver la respuesta con una advertencia. Rechazar: negarse a devolver la respuesta al usuario.

La opción que elijas depende del contexto. Para un uso interactivo de bajo riesgo, vuelva a intentarlo. Para rutas críticas de auditoría (legal, de cumplimiento, financiera), rechace y solicite revisión humana. La ruta de reintento importa más allá de la llamada individual: las salidas rechazadas nunca llegan al usuario, por lo que la tasa de paráfrasis entregada cae a lo que sobreviva a la verificación de la subcadena, incluso si la tasa bruta del modelo no cambia.

Para ver las tres opciones en funcionamiento, la siguiente demostración crea una AmountAnswer deliberadamente mala que pone en práctica cuatro defectos a la vez: falta de coincidencia de forma (el resumen en ejecución solicitó lista = TextAnswer/ListAnswer), intervalo fuera del rango de entrada, moneda que no es ISO 4217, afirmación textual sin comillas en ningún intervalo. El validador informa cada uno. En un proceso de producción, esta respuesta sería rechazada; en un contexto de menor riesgo, se volvería a intentar con un aviso más estricto.

1.4 “No encontrado” como resultado de primera clase

En RAG empresarial, devolver una respuesta incorrecta es peor que no devolver ninguna respuesta. Una respuesta incorrecta en un contrato de seguro puede inducir a error al gestor de reclamaciones. Una respuesta incorrecta en una presentación regulatoria puede resultar en una violación del cumplimiento. Una respuesta incorrecta sobre una cláusula de no competencia puede costar un trato. Un “no encontrado” obliga al usuario a buscar más, que es el comportamiento correcto cuando el sistema no tiene la respuesta.

Tres cosas hacen que "no encontrado" funcione correctamente:

El esquema lo permite claramente: items=[], respuesta_encontrada=Falso, sin citas falsas ni valores falsos. Una lista de elementos vacía es la forma estructurada de decir “No sé”: no hay texto que malinterpretar, ni intervalos que perseguir, ni valores que representar. El mensaje del sistema lo requiere: Instrucción explícita en BASE: “Si los pasajes no contienen la respuesta solicitada, devuelva elementos =[], respuesta_encontrada=Falso, y explique en las advertencias lo que se encontró y lo que no”. Esto no es opcional. Sin él, los modelos producen algo por defecto incluso cuando nada lo respalda. El código descendente distingue a NA de una respuesta real. Verificar len(answer.items) == 0 es suficiente. La aplicación no muestra una respuesta vacía como si fuera real. Dice “el documento no parece contener esta información” claramente, sin disfrazarse.

La parte más difícil no es la implementación técnica. Es la voluntad cultural de aceptar que “no encontrado” es un resultado correcto. Los equipos bajo presión para producir respuestas "inteligentes" a menudo optimizan para obtener tasas de NA bajas, lo cual es exactamente el incentivo equivocado. Una tasa alta de NA en preguntas cuyas respuestas no están en el corpus es una señal de que el sistema es honesto. Una tasa baja de NA en las mismas preguntas es una señal de que está alucinando.

# Ruta de NA: pregunta fuera de tema en el documento de Atención. La regla BASE se activa limpiamente: # elementos=[], answer_found=False, las advertencias explican lo que no se encontró. Ningún número inventado. Missing_q = ParsedQuestion( original_question="¿Cuáles son los ingresos anuales de la empresa en 2024?", palabras clave=[Keyword(text="ingresos"), Keyword(text="2024")], expected_answer_shape="cantidad", retrieval=RetrievalQuery(main_query="ingresos 2024"), generate=GenerationBrief( original_question="¿Cuál es el ingreso anual de la empresa? ¿ingresos en 2024?", format_constraint={"currency": "USD"}, ), ) missing_result = generate(missing_q, filtered_line_df, client) a = missing_result.answer print("esquema utilizado:", miss_result.meta["schema_used"]) print("items:", a.items) print("answer_found:", a.answer_found) print("advertencias:", a.advertencias) print("validación:", validar_respuesta(a, línea_df_overall, falta_q))

1.5 Falta de coincidencia de formas: cuando no se puede proporcionar la forma solicitada

Un caso específico se sitúa entre “respuesta encontrada” y “no encontrada”: la pregunta espera una forma mecanografiada, pero el documento sólo tiene una forma más suave. “¿Cuál es la prima?” espera una Cantidad (valor, moneda). El documento dice que "los precios se negocian caso por caso". Hay información, pero no en la forma que se solicitó. Tres opciones para manejar esto, cada una con un costo real:

Opción 1, degradación silenciosa a texto: establezca el esquema en TextAnswer, coloque la respuesta en prosa en elementos[0].text, suelte la forma_de_respuesta_esperada original. El usuario obtiene algo. El código descendente que espera una cantidad se rompe (la aplicación estaba generando una etiqueta de precio y ahora recibe una oración). La discrepancia es invisible: nada en la salida indica que la forma fue degradada. El error aparece en producción cuando un gráfico muestra una cadena donde debería estar un número.

Opción 2, NA explícito con advertencias: mantener el esquema solicitado (por ejemplo, AmountAnswer), establecer elementos =[], respuesta_encontrada=Falso, respuesta_completa_encontrada=Falso. Coloque la explicación en prosa con advertencias: "El documento menciona que el precio se negocia caso por caso, pero no proporciona una cantidad específica". El código descendente ve answer_found=False y muestra la ruta "no encontrada", mostrando la advertencia al usuario. El usuario sabe qué sistema intentó, qué encontró y qué falta. Sin roturas silenciosas, sin valor falso.

Opción 3, forzar la forma con un valor predeterminado. Establecer elementos[0].importe = Importe(valor=0, moneda="EUR"). Conveniente para el código posterior (siempre escrito), catastrófico para todo lo demás. El validador no puede distinguir 0 EUR reales (un servicio gratuito) de 0 EUR "no pudimos extraer un valor". Los registros de auditoría se contaminan con ceros fantasmas. No.

La serie elige la Opción 2: El costo es que el código posterior debe manejar respuesta_encontrada=False explícitamente. El beneficio es que no se pierde información silenciosamente: el validador puede señalar la discrepancia (sección 1.1), el registro de auditoría incluye la advertencia y el usuario nunca ve un número inventado. Los campos answer_found y complete_answer_found del esquema existen precisamente para que la canalización pueda bifurcarse limpiamente en estos casos sin inferir la intención de los datos faltantes.

Los fragmentos de forma del despachador (Artículo 8B, ensamblaje de mensajes) refuerzan esta regla en el mensaje del sistema: "Si el documento proporciona el valor sin moneda, establezca respuesta_encontrada=Falso y agregue una advertencia. NO adivine". Se le dice al modelo qué hacer con el desajuste; el esquema le da una manera de decirlo; el validador detecta los casos en los que no lo hizo.

# Falta de coincidencia de formas: pide fecha que el papel NO lleva. Esperar artículos =[], # answer_found=Falso, advertencias explicativas. Opción 2 de la sección de falta de coincidencia de formas: # sin degradación silenciosa, sin fecha fantasma. El esquema DateAnswer todavía se devuelve. date_q = ParsedQuestion( original_question="¿En qué fecha del calendario se implementó por primera vez en producción la atención de múltiples cabezales con 8 cabezales en Google?", palabras clave=[Keyword(text="atención de múltiples cabezales"), Keyword(text="implementación")], expected_answer_shape="fecha", retrieval=RetrievalQuery(main_query="fecha de implementación de la atención de múltiples cabezales"), generación=GenerationBrief( original_question="En qué fecha del calendario ¿Se implementó por primera vez en producción en Google la atención de múltiples cabezales con 8 cabezales?", format_constraint={"date_format": "AAAA-MM-DD"}, ), ) date_result = generate(date_q, filtered_line_df, client) a = date_result.answer print("schema used :", date_result.meta["schema_used"]) print("items :", a.items) print("answer_found:", a.answer_found) print("complete_answer_found:", a.complete_answer_found) print("advertencias:", a.advertencias) print("validación:", validar_respuesta(a, line_df_overall, date_q))

1.6 Levantar citas a bboxes

Los números de línea desplazan el documento. Un rectángulo cae sobre la respuesta. El bbox-join es lo que convierte una cita en algo que el espectador puede pintar en el PDF. Es la división en la que se ejecuta todo el bloque: la salida del modelo son datos estructurados puros (números de línea aquí), y cada elemento mostrado, el texto citado y el cuadro, se recupera luego de las tablas de origen. El trabajo de esquema del Artículo 8A (el contrato de respuesta) se detiene en los números de línea, y estos vienen en dos tipos a lo largo de este bloque: el Span del contrato enriquecido usa el line_start/line_end global, mientras que el AnswerWithEvidence mínimo lleva el start_page_num/start_line_num/end_page_num/end_line_num con alcance de página. Cualquiera de las formas le indica a la tubería en qué líneas se apoyó el modelo. Ninguno de los dos es suficiente para que la interfaz de usuario los muestre. La combinación a continuación asigna lo que regrese a las filas line_df. Un visor al lado de un PDF quiere un cuadro de (página, x0, y0, x1, y1) que pueda pintar como una superposición amarilla.

El puente es una unión de posgeneración con line_df. El analizador ya emitió (x0, y0, x1, y1) por línea en el momento del análisis; el LLM elige los números de línea aquí; la unión es un filtro DataFrame:

En: line_df (almacenado en caché desde el análisis) + una cita (página, line_start, line_end). Salida: una lista de cuadros (página, x0, y0, x1, y1) listos para la superposición del visor.

def bboxes_for_citation(line_df, *, página, line_start, line_end, mode="union", image_df=None): coincidente = line_df[ (line_df["page_num"] == página) & (line_df["line_num"] >= line_start) & (line_df["line_num"] <= line_end) ] si coincide.vacío: regresar[]if modo == "unión": return [{ "page": página, "x0": float(matched["x0"].min()), "y0": float(matched["y0"].min()), "x1": float(matched["x1"].max()), "y1": float(matched["y1"].max()), }] return [ {"page": página, "x0": float(r["x0"]), "y0": float(r["y0"]), "x1": float(r["x1"]), "y1": float(r["y1"])} para _, r en matched.sort_values("line_num").iterrows() ]

Dos modos que vale la pena enviar:

union (predeterminado) proporciona el sobre de todas las líneas citadas en la página: un cuadro, se ajusta al 95% de las citas donde el intervalo de respuestas es contiguo y de una sola columna. Barato en el cable, fácil para el espectador. per_line proporciona un cuadro por línea, lo que resulta útil cuando la cita cruza un salto de columna o rodea una imagen incrustada. El sobre cubriría el espacio, la lista por líneas no.

El argumento opcional image_df es lo que hace que las citas de figuras funcionen. El analizador produce un image_df junto con line_df, una fila por imagen incrustada con la misma forma (página, x0, y0, x1, y1). Cuando el rango de líneas de la cita se superpone verticalmente a una imagen en la misma página, el bbox de esa imagen se fusiona en el resultado: en el modo de unión, el sobre se extiende para cubrir la figura más su título; en el modo per_line, la imagen se agrega como un cuadro adicional. El lector que hace clic en una cita que apunta a "ver Figura 3" llega a la figura, no solo a la línea de título.

La función se encuentra en src/docintel/generación/citation_bbox.py. La variante entre documentos (corpus_pdf_qa, una canalización de seguimiento) realiza la misma unión, pero en el line_df del documento original, no en el contexto agregado que vio el LLM. Los números de página sintéticos de la agregación se reasignan a (document_id, original_page) antes de la búsqueda, por lo que bbox apunta al PDF de origen, no al orden de lectura del mensaje.

Por qué esto gana una sección y no una nota al pie: toda la cadena que construyeron los artículos de la generación (esquema, despachador, validación, comentarios) es invisible para el usuario a menos que el espectador pueda ubicar un rectángulo en la página. La unión es de veinte líneas, y esas veinte líneas son las que convierten una respuesta estructurada en algo que el lector puede verificar con la vista.

2. Cerrando el círculo

La mayoría de los diagramas RAG terminan en generar → responder. El esquema más rico hace que la generación responda: la respuesta contiene campos que le indican al canal que se amplíe, vuelva a analizar, pregunte o envíe. La línea se convierte en un bucle.

Cada campo de comentarios conduce a la siguiente acción: enviar, ampliar, volver a analizar, preguntar o enriquecer – Imagen del autor

2.1 Rutas de retroalimentación

Cada campo de autoevaluación en AnswerBase activa una ruta de canalización específica. Las rutas del mismo recorrido reaccionan inmediatamente para corregir la respuesta a esta pregunta; Los caminos a largo plazo acumulan conocimiento para futuras preguntas sobre el mismo concepto.

Señales de la misma ejecución (actúe en ESTA pregunta antes de regresar):

complete_answer_found = False → expanda el alcance de recuperación y vuelva a intentarlo. El disparador canónico de la misma ejecución. La respuesta que obtuvimos es parcial (1 de 5 exclusiones esperadas; falta la mitad de una comparación). Amplíe el conjunto de palabras clave (usando llm_discovered_keywords si está disponible, deduplicadas de las palabras clave originales: consulte el código a continuación) y vuelva a llamar al generador. Costo: un viaje extra de ida y vuelta. Beneficio: cobertura completa de preguntas de varias secciones. context_structured = False → vuelva a analizar las páginas de origen con un método diferente (Camelot, Docling, modelo de lenguaje de visión), luego vuelva a recuperarlas. El modelo ha detectado un error de análisis ascendente que el analizador no detectó. conflicting_evidence = Verdadero → no devuelve la respuesta; mostrar el conflicto al usuario "dos pasajes no están de acuerdo en esta fecha". conjunto de aclaración_sugerida → no responder; Haga al usuario una pregunta específica. Más barato que responder mal.

Señal a largo plazo (acumule, no reaccione ahora):

llm_discovered_keywords → enriquece la tabla de palabras clave del concepto (el diccionario experto creado en el momento del análisis de preguntas, Fuente B). El modelo detectó términos como “página de declaración” o “programa de beneficios” que el informe original no incluía. Para ESTA ejecución, volver a recuperar con el mismo modelo rara vez ayuda. Para la siguiente pregunta sobre el mismo concepto, esos términos ya deberían estar en el diccionario para que la recuperación encuentre los pasajes correctos desde el principio. La canalización persiste las palabras clave descubiertas en una tabla con clave de concepto; la siguiente llamada a parse_question los lee nuevamente en el informe. La desduplicación de la entrada existente es obligatoria (el modelo a menudo vuelve a sugerir términos conocidos). La tabla crece con el proyecto, no con el número de preguntas.

El reintento en la misma ejecución y el enriquecimiento a largo plazo pueden interactuar: si respuestas_completa_encontradas=False y palabras clave_descubiertas están presentes y al menos una de ellas es nueva, el reintento puede usar la unión deduplicada de palabras clave originales + descubiertas para una recuperación más amplia en esta ejecución. Pero el desencadenante del reintento es la señal de integridad, no el descubrimiento de palabras clave: las palabras clave descubiertas en una respuesta completa van a la tabla a largo plazo y a ningún otro lugar.

El modelo no sólo produce una respuesta; es diagnosticar la tubería y proponer soluciones. El costo es un viaje extra de ida y vuelta. El beneficio es un sistema que se recupera de los límites ascendentes: recuperación que falló debido a una discrepancia de vocabulario, un analizador que falló en una tabla, un contexto demasiado limitado. La gente lo llama vagamente “agentico”; es solo control de retroalimentación.

El circuito de retroalimentación que se cierra de nuevo al bloque de análisis es el que omiten la mayoría de las canalizaciones. La generación se convierte en un detector de calidad para el análisis. El resultado: una pérdida silenciosa de calidad cuando los documentos tienen casos extremos.

CONCEPT_KEYWORD_TABLE: dict[cadena, set[cadena]] = {} def persist_discovered_keywords(parsed_q, resultado, concept_key=None) -> set[cadena]: clave = concept_key o (parsed_q.keywords[0].text if parsed_q.keywords else "_default") conocido = CONCEPT_KEYWORD_TABLE.setdefault(key, set()) descubierto = {t.lower() for t in result.answer.llm_discovered_keywords} new_terms = descubierto – conocido conocido.update(new_terms) devuelve new_terms def may_retry_when_incomplete(result, parsed_q, page_df, line_df, generate_fn): si result.answer.complete_answer_found y result.answer.confidence > 0.8: devuelve Ninguno existente = {k.text.lower() para k en parsed_q.keywords} new_terms = [t para t en result.answer.llm_discovered_keywords si t.lower() no existe] si no, new_terms: devuelve Ninguno enriquecido = [k.text para k en parsed_q.keywords] + new_terms new_parsed = parsed_q.model_copy(update={"keywords": [Palabra clave(text=t) for t in enriched]}) _, new_candidates = retrieve_pages(page_df, line_df, enriched, top_k=3) return generate_fn(new_parsed, new_candidates, client)

2.2 Unificación de proveedores

Un sistema RAG real rara vez se comunica con un solo modelo. Puede utilizar OpenAI en producción, Anthropic para evaluación, Mistral o un modelo autohospedado para datos confidenciales internos y Ollama localmente para desarrollo. Cada uno tiene su propio SDK, sus propias peculiaridades, sus propios modos de falla. Difundir llamadas específicas de proveedores a través del código base es la forma de meterse en problemas.

El patrón consiste en envolver a todos los proveedores detrás de una única función:

desde escribir import TypeVar desde pydantic import BaseModel T = TypeVar("T", enlazado=BaseModel) def get_completion( system: str, usuario: str, esquema: tipo[T], proveedor: str = "openai", modelo: str | Ninguno = Ninguno, temperatura: float = 0.0, ) -> T: """Punto de entrada único para cualquier llamada LLM. Devuelve una instancia de Pydantic analizada.""" if proveedor == "openai": return _openai_call(sistema, usuario, esquema, modelo, temperatura) if proveedor == "antrópico": regresa _anthropic_call(sistema, usuario, esquema, modelo, temperatura) si proveedor == "ollama": regresa _ollama_call(sistema, usuario, esquema, modelo, temperatura) si proveedor == "mistral": regresa _mistral_call(sistema, usuario, esquema, modelo, temperatura) rise ValueError(f"Proveedor desconocido: {proveedor}")

Todos los demás módulos del código base llaman a get_completion. Nadie importa openai directamente. Esto se paga de tres maneras:

El intercambio de proveedores es una variable: cambiar de OpenAI a Mistral es cambiar de proveedor="openai" a proveedor="mistral". Nada más se mueve. Las pruebas A/B entre proveedores son triviales. El desarrollo local coincide con la producción. El desarrollo utiliza Ollama localmente con Phi-4 o Mistral-Nemo; La producción utiliza Azure OpenAI. Misma ruta de código, configuración diferente. Los errores que encuentre localmente son los errores que habría encontrado en prod. La lógica alternativa reside en un solo lugar: cuando OpenAI lo limita, el contenedor vuelve a intentarlo de forma transparente con Anthropic. Cuando un modelo autohospedado no funciona, recurra a uno alojado. El código de la aplicación no lo sabe.

El envoltorio también normaliza las peculiaridades. Algunos proveedores manejan resultados estructurados de forma nativa (respuestas.parse de OpenAI), algunos necesitan un esquema JSON en el mensaje (la mayoría), algunos necesitan archivos gramaticales (llama.cpp). El contenedor oculta estas diferencias detrás de un esquema uniforme: parámetro tipo[T]. Dentro de la envasadora, se recurre a la maquinaria adecuada para el proveedor adecuado. En este cuaderno, generate() habla directamente con OpenAI para mantener enfocada la demostración del despachador; en producción, intercambie client.responses.parse por get_completion(…).

2.3 Antipatrones

Una breve lista de antipatrones generacionales que veo repetidamente. Ninguno de ellos es fatal por sí solo. Juntos arruinan la confiabilidad:

El esquema es demasiado vago: una respuesta simple: str sin convención para el valor NA. El modelo inventa los suyos propios (“no disponible”, “n/a”, “desconocido”) y el código posterior no puede detectarlos de manera confiable. Fije la representación de NA: lista de elementos vacíos, respuesta_encontrada=False. La validación se omite en producción: la validación se trata como una preocupación exclusiva del desarrollo. En producción, la salida del modelo sin procesar fluye directamente al usuario. El día que un modelo parafrasea una cita, usted se entera por el usuario, no por sus registros. Puntuaciones de confianza tomadas al pie de la letra. El modelo dice 0,95, la aplicación lo muestra como “confianza alta”. Pero el modelo está mal calibrado. Trate la confianza como una señal de clasificación, no como una garantía. El mensaje del sistema se trata como un texto repetitivo. “Respuesta basada en el contexto” es lo que escriben la mayoría de los equipos, y apenas es una indicación. El indicador del sistema es donde codifica cada restricción: comportamiento de NA, requisito de citación, prohibición de paráfrasis, requisitos de formato. Merece el mismo cuidado que el esquema. Temperatura superior a 0: pérdida de reproducibilidad. La misma pregunta sobre los mismos documentos da respuestas diferentes en cada llamada. Sin presupuesto de tokens: las indicaciones crecen a medida que crece el conjunto de candidatos, y aproximaciones como “aproximadamente 4 caracteres por token” lo enmascaran. Un día, una solicitud llega al límite y se trunca silenciosamente. La calidad baja sin que nadie se dé cuenta. Sin campos de comentarios: la canalización nunca sabe cuándo la recuperación se desvió, cuándo el análisis fue incorrecto o cuándo el contexto estuvo incompleto. Bucle abierto. La calidad se estanca y se mantiene allí. Modelos de código abierto probados con modos de pensamiento habilitados. Un modelo de razonamiento es excelente para problemas matemáticos y malo para JSON. Si su resultado está estructurado, desactive el razonamiento o utilice un modelo sin pensamiento.

2.4 En la práctica: la historia del 12% de paráfrasis

Un equipo ejecuta RAG sobre contratos de seguros con un esquema básico: respuesta, inicio_línea, final_línea. Pasa las preguntas del examen. En producción, las respuestas son “casi correctas, pero no del todo”: deriva de parafraseo, condiciones faltantes, algún valor ocasional, seguro pero incorrecto. El equipo cambia de modelo, realiza ajustes e intercambia proveedores de integración. Nada de eso ayuda.

La solución resulta estar en el esquema. Añaden citas (fragmentos textuales), advertencias (limitaciones) y respuesta_completa (si los pasajes contenían la respuesta completa). La primera vez que validan las citas con las líneas fuente, descubren que el 12% de las respuestas de "alta confianza" contienen citas que no aparecen en los pasajes citados. La modelo parafraseaba mientras pretendía citar. El problema reside en la arquitectura, no en el modelo: el esquema no solicitó palabra por palabra, por lo que el modelo devolvió una paráfrasis.

Añaden una regla: cada cita debe ser una subcadena de las líneas citadas, o la respuesta será rechazada. La tasa de paráfrasis cae del 12% al 0,3%. Nada sobre el modelo cambió. Simplemente sabía que sería revisado.

Unas semanas más tarde, aparece un problema diferente. Aproximadamente el 8% de las respuestas arrojan complete_answer_found=False, pero el sistema las devuelve de todos modos. Añaden un ciclo de retroalimentación: en complete_answer_found=False, el sistema vuelve a recuperar con un alcance más amplio y vuelve a intentarlo. La recuperación de respuestas de varias secciones (exclusiones, condiciones, listas) aumenta notablemente.

La observación interesante, seis meses después: la mayoría de las ganancias provinieron del esquema, no del modelo. Nunca afinaron. Nunca cambiaron de proveedor. Agregaron estructura, la validaron e incorporaron comentarios al proceso. El modelo había sido capaz todo el tiempo; simplemente no lo habían preguntado correctamente.

3. Conclusión

En este bloque, la generación es una ejecución controlada: una función escrita que consume una ParsedQuestion y produce un objeto estructurado en el que cada campo tiene una tarea. El usuario ve la respuesta, las citas, las advertencias; la canalización ve los campos de comentarios (palabras clave descubiertas, integridad del contexto, calidad del análisis, evidencia contradictoria) y decide si reintentar, expandir, volver a analizar o devolver "no encontrado". Cuatro opciones lo mantienen auditable: el esquema es el contrato, el mensaje se compone de fragmentos en lugar de seleccionarse como una plantilla completa, la respuesta se valida antes de que alguien la lea y el proveedor del modelo es un recurso intercambiable.

Esto cierra la Parte II. El siguiente artículo abre la Parte III con los cuatro ladrillos mejorados conectados entre sí en una sola tubería.

4. Fuentes y lecturas adicionales

La decodificación restringida garantiza la forma del resultado, nunca su verdad: la “adherencia 100% al esquema” de la publicación de lanzamiento es exactamente donde comienza este artículo, no donde termina. Las ideas publicadas más cercanas a la mitad de retroalimentación son las fichas de reflexión de Self-RAG; el contraste con la cadena de pensamiento muestra qué cambia cuando el modelo emite evidencia en lugar de prosa razonadora.

Misma dirección que el artículo:

OpenAI, resultados estructurados. El “100% de cumplimiento del esquema” garantiza la forma, no el contenido; este artículo es el control que comienza donde termina la garantía. Asai et al., Self-RAG: Aprender a recuperar, generar y criticar a través de la autorreflexión, ICLR 2024 (arXiv:2310.11511). Fichas de reflexión, la idea publicada detrás de reaccionar a las propias señales del modelo: los bucles de retroalimentación de la sección 2 son la versión diseñada.

Ángulo diferente, contexto diferente:

Wei et al., La cadena de pensamiento provoca el razonamiento en modelos de lenguaje grandes, NeurIPS 2022 (arXiv:2201.11903). CoT genera razonamientos en los que el usuario debe confiar; el enfoque de salida estructurada genera evidencia que el usuario puede auditar. Contexto diferente, forma de salida diferente.

Al principio de la serie:

Qué funciona, qué se rompe

Baseline Enterprise RAG, desde PDF hasta respuesta resaltada. El canal de cuatro ladrillos de un extremo a otro: PDF de entrada, respuesta resaltada de salida. Las incrustaciones no son mágicas: los modos de falla predecibles de la recuperación de RAG. Dónde gana la incorporación de similitud (sinónimos, errores tipográficos, paráfrasis), dónde predeciblemente se rompe (términos desconocidos, negación, relevancia de término versus respuesta) y cómo usarlo de todos modos. RAG no es aprendizaje automático y el conjunto de herramientas de ML resuelve el problema equivocado. Por qué los barridos y ajustes de tamaño de fragmentos optimizan lo incorrecto; en su lugar, ruta por tipo de pregunta. De expresiones regulares a modelos de visión: qué técnica RAG se adapta a qué problema. Dos ejes, complejidad documental y control de preguntas, que seleccionan la técnica para cada caso.

Análisis de documentos

Más allá de extract_text: las dos capas de un PDF que impulsan la calidad RAG. La primera mitad del bloque de análisis: la naturaleza del documento, las señales y el resumen. Deje de devolver texto plano desde un PDF: las tablas relacionales que RAG necesita. La segunda mitad del bloque de análisis: las tablas relacionales que lee cada bloque posterior. Cuando PyMuPDF no puede ver la tabla: analice archivos PDF para RAG con Azure Layout. Las mismas tablas de Azure Layout: celdas de tabla nativas, OCR, roles de párrafo. Analice archivos PDF para RAG localmente con Docling: tablas enriquecidas, sin carga en la nube. Las mismas tablas calculadas localmente con Docling: celdas de TableFormer, nada sale de la máquina. Los Vision LLM también son analizadores de PDF: leen cuadros y diagramas para RAG. Visión como analizador: las imágenes se convierten en texto buscable. Analice archivos PDF escaneados para RAG con EasyOCR: el OCR gratuito le proporciona palabras, no un documento. Donde termina el OCR tradicional: texto recuperado, estructura perdida. Hacer que las imágenes de un PDF puedan buscarse en RAG, sin pagar para leerlas todas. La cascada de imágenes: filtrar barato, clasificar, describir sólo lo que vale la pena leer. Reconstruir la tabla de contenido que un PDF olvidó enviar, para que RAG pueda analizarlo por sección. Reconstruir toc_df cuando el PDF imprime una página de contenido pero no incluye ningún esquema.

análisis de preguntas

Las preguntas de RAG también deben analizarse: convierta la cadena del usuario en resúmenes para su recuperación y generación. La tesis del análisis de preguntas: por qué una cadena de usuario necesita el mismo análisis que un documento y cómo se divide en un resumen de recuperación y un resumen de generación. Lo que el analizador de preguntas extrae de una cadena de usuario: palabras clave, alcance, forma, descomposición, aclaración. Las cinco familias de columnas que el analizador lee directamente de la pregunta del usuario, con el código que completa cada una. Envío de la pregunta RAG analizada: estrategia de fragmentos, nivel de modelo, activaciones, auditoría. Las decisiones que toma el analizador sobre la cadena de usuario, utilizando el perfil del documento: envío, activaciones, esquema completo, seguimiento de auditoría (pipeline_trace.json) y un recorrido por el corpus del corredor.

Recuperación