parte del 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ó la familia de esquemas tipificados y el ANSWER_REGISTRY que asigna cada forma de respuesta a su esquema. Esta parte construye la llamada que completa el contrato: entra una ParsedQuestion; el despachador elige el esquema del registro, compone el indicador del sistema a partir de una BASE fija más fragmentos, crea el indicador del usuario, llama al modelo y mantiene el seguimiento completo. Lo que sucede con la respuesta después de la convocatoria es el artículo 8C (validación).
La generación es el cuarto ladrillo. Un lector que llegue aquí puede aprender los primeros tres de sus propios artículos:
📓 Los cuadernos complementarios ejecutables están en GitHub: doc-intel/notebooks-vol1.
1. Del breve al aviso: el despachador
Un mensaje por forma de pregunta, redactado en el momento de la llamada. Ese es el despachador. La alternativa es el mega-mensaje al que se dirige cada código base RAG: un mensaje del sistema que maneja cantidades, fechas, listas, tablas y texto libre a la vez. Crece una nueva cláusula condicional en cada llamada (“si la respuesta es una fecha, use ISO 8601; si es una cantidad, ISO 4217; si es una lista, un elemento por elemento…”), el modelo lo lee todo cada vez, y en dos meses nadie recuerda qué cláusula se agregó para cada caso.
El despachador que construiremos reemplaza ese desastre. Contrato: entra una ParsedQuestion; Surgen tres cosas: el esquema (seleccionado de ANSWER_REGISTRY por expect_answer_shape), el mensaje del sistema (una BASE fija más los fragmentos de las solicitudes breves) y el mensaje del usuario (pregunta + palabras clave + líneas de pasaje etiquetadas). Llama al modelo, conserva la respuesta sin procesar completa en el seguimiento y devuelve un resultado escrito. Agregar una nueva forma agrega un fragmento; agregar una nueva restricción agrega un fragmento; nada combinatorio.
Las alternativas que descartamos: un mega-mensaje con cada cláusula siempre presente (desperdicia tokens, difíciles de depurar cuando una cláusula de formato de tabla se mezcla con una respuesta de cantidad), o N mensajes independientes por forma (más limpio por forma, pero duplica las restricciones transversales (formatear, distinguir, desambiguar) en cada plantilla y fuerza la resincronización en cada cambio).
1.1 El resumen: ParsedQuestion
El despachador lee una ParsedQuestion producida por el bloque de análisis de preguntas. El esquema completo es relacional (objetos Pydantic anidados, no un resumen plano): palabras clave (objetos escritos, no cadenas), forma_respuesta_esperada, descomposición, filtros_alcance, un plan de ejecución, notas de análisis, además de los dos preparativos para los siguientes bloques (recuperación: RetrievalQuery y generación: GenerationBrief).
Este artículo lee tres partes de este objeto: expected_answer_shape (selecciona el esquema y el fragmento de forma), generación (una breve descripción de las restricciones de formato, desambiguación entre candidatos cercanos y valores para distinguir) y palabras clave (que se repiten en el mensaje del usuario para que el modelo pueda marcar cuáles aparecieron en los pasajes recuperados). El límite es claro: la detección de formas vive en el análisis sintáctico; El conjunto de plantillas vive aquí.
Creamos ParsedQuestion en línea a continuación; El paquete actual src/docintel/question/parse_question.py incluye solo la versión mínima. El esquema y el despachador se promocionan al paquete una vez que el bloque de análisis de preguntas logra su implementación completa.
clase Palabra clave (BaseModel): texto: str peso: float = 1.0 fuente: Literal['direct','llm_expansion','expert_dictionary'] = 'direct' is_regex: bool = False clase RetrievalQuery(BaseModel): main_query: str reescribe: lista[str] =[]palabras clave_ancla: lista[cadena] =[]sugerencia_sección: cadena | Ninguno = Ninguno alcance: str = clase 'predeterminada' GenerationBrief(BaseModel): pregunta_original: str format_constraint: dict[str, str] = {} desambiguación: str | Ninguno = Ninguno debe_distinguir: lista[Distinción] =[]class ExecutionPlan(BaseModel): use_toc_navigation: bool = True use_keyword_retrieval: bool = True use_embeddings: bool = False iterate_on_feedback: bool = True max_iterations: int = 3 class ParsedQuestion(BaseModel): pregunta_original: str palabras clave: lista[Palabra clave] forma_de_respuesta esperada: Literal['texto','cantidad','fecha','booleano','lista','tabla'] subpreguntas_descompuestas: lista[cadena] =[]activaciones: Plan de ejecución = Plan de ejecución() notas_análisis: lista[cadena] =[]aclaración_sugerida: str | Ninguno = Ninguno recuperación: RecuperaciónGeneración de consultas: GeneraciónBrief
1.2 Consejos estructurales de la pregunta
La redacción de la pregunta del usuario abarca la recuperación, sin ningún indicador nuevo en el proceso. Cuando la pregunta dice "en la página 1", "páginas 5 a 7" o "en la hoja de precios", ese puntero se desplaza sobre ParsedQuestion.structural_hints. Recuperación lo lee y filtra el espacio de búsqueda. Sin chunk_strategy="passthrough", sin argumento de omisión, sin ruta especial de documento corto. El operador controla el alcance escribiéndolo dentro de la pregunta.
El campo lleva una lista por formato. Este artículo está disponible únicamente en PDF, por lo que páginas_hint es el que importa aquí; los campos hermanos para los otros formatos que la serie alcanza más adelante permanecen fuera del alcance:
clase Sugerencias estructurales (Modelo base): sugerencias_páginas: lista [int] | Ninguno = Ninguno # PDF; A continuación se incluye un equivalente en hoja/diapositiva para formatos posteriores.
Las frases simples, de rango y de lista colapsan en la misma lista plana en el momento del análisis: “página 1” se convierte en[1], “páginas 5 a 7” se convierte en [5, 6, 7], “página 2 y página 7” se convierte en [2, 7]. Luego, la recuperación filtra con una expresión, page_df[page_df.page_num.isin(pages_hint)], y no se ramifica según la forma de la sugerencia. Las páginas con sugerencias se conservan incluso cuando ninguna palabra clave coincide con ellas: el usuario las fijó explícitamente, esa es la superficie de respuesta.
El caso del doctorado corto es donde esto se gana la vida. Cuando la fuente es un CV, una factura de una página o una nota de 1 o 2 páginas, todo el documento cabe en la ventana contextual del modelo. El operador escribe "Extraiga estos campos de la página 1 de este CV", "Lea la página 1 y devuelva las partidas de la factura", "En la página 1, enumere todas las partes nombradas". El análisis de preguntas extrae páginas_hint=[1]; la recuperación filtra line_df a líneas en la página 1 (que en un documento de una página es cada línea); La generación lee el documento completo y ejecuta el esquema solicitado. La forma de la canalización es idéntica a una consulta de corpus de 1000 páginas que llega a una sola página: el mismo código, la misma cadena de auditoría, el mismo contrato.
El mismo mecanismo se extiende a los otros formatos que la serie alcanza más adelante: el nombre de una hoja o el número de una diapositiva abarcan la recuperación de la misma manera que lo hace aquí un número de página. Esos formatos están fuera de alcance; El punto que se traslada es que el puntero de alcance depende de la pregunta, no de una bandera de canalización.
El modo de falla que se debe evitar es el movimiento opuesto: agregar filtrado de palabras clave o incrustar similitudes en un documento de una página que no lo necesita, y observar cómo el filtro elimina los campos que el LLM habría detectado. El esquema realiza el trabajo campo por campo sobre la atención del LLM; el corpus a filtrar es demasiado pequeño para que cualquier señal de recuperación agregue valor. La sugerencia estructural de la pregunta es la única señal que necesita el oleoducto.
1.3 El indicador del sistema: BASE + fragmentos
La BASE es independiente de la forma: codifica el contrato que se cumple para cada llamada: citar, escribir, fallar honestamente. Los fragmentos son específicos de cada preocupación: uno por forma (cantidad, fecha, lista, tabla, booleano, texto), uno por restricción transversal (formato, distinción, desambiguación, descomposición). El despachador redacta sólo lo que pide el escrito; nada más.
El Artículo 8A enmarcó por qué esto es importante: el modelo predice una continuación plausible, no busca nada, por lo que anclamos cada reclamo a un número de línea fuente que el oleoducto puede verificar en lugar de a una prosa que el modelo pueda suavizar. Ese ancla es tan buena como el número. La regla GLOBAL_LINE en BASE no es repetitiva. La primera vez que ejecutamos esto en el documento de Atención, el modelo devolvió line_start=33 para una cotización que se encontraba en la línea global 267 (página 6, line_in_page=33). El modelo había elegido la tercera columna de las filas de pasajes de mensajes de usuario en lugar de la primera porque las columnas no estaban etiquetadas. Deletrearlo en el mensaje del sistema y etiquetar las columnas en el mensaje del usuario (sección 1.3) hizo que el error desapareciera. Cuando una fila de pasaje contiene varias columnas de números enteros, el modelo elegirá la que le parezca a menos que usted diga qué columna contiene el número de línea.
BASE = """Usted responde preguntas estrictamente a partir de los pasajes del documento proporcionado. Reglas: – Use solo información de los pasajes. – Cada elemento en `items` debe llevar al menos un Span citando números de línea de origen. – Un Span es contiguo (line_start, line_end). Use múltiples Spans en un elemento cuando la evidencia de respaldo se divide en regiones no adyacentes. – IMPORTANTE: Span.line_start y Span.line_end DEBEN ser el valor GLOBAL_LINE (la PRIMERA columna de cada fila de pasaje), NO la línea_en_página por página: si los pasajes no contienen la respuesta solicitada, devuelva elementos =.[], answer_found=False y explique en las advertencias qué se encontró y qué no. – Establezca complete_answer_found=False cuando la respuesta exista pero sea parcial. – Si los pasajes entran en conflicto, establezca conflicting_evidence=True y aparezca en las advertencias. – Si un pasaje parece mal formado (tabla rota, OCR confuso), establezca context_structured=False. """
El fragmento de forma no reemplaza la elección del esquema: el esquema ya lo aplica Responses.parse(text_format=…). El fragmento dirige la estrategia de extracción del modelo: "devolver un AnswerItem por elemento de la lista", "la moneda DEBE ser ISO 4217 válida", "no convertir si el documento cita una moneda diferente, establecer respuesta_encontrada=False en su lugar". El esquema impone el tipo en la decodificación; fragmento dirige la extracción en el momento oportuno.
SHAPE_FRAGMENTS = { "text": "Utilice `text=…` por elemento. Manténgase cerca de la redacción original.", "amount": "Rellene `amount = Amount(value, moneda, unidad)`. ISO 4217.", "date": "Rellene `date = DateValue(iso, original)`. iso = AAAA-MM-DD.", "list": "UN elemento por elemento. Cada elemento tiene sus propios Spans.", "table": "TableValue(encabezados, filas). Rectangular. UN elemento por tabla.", "boolean": "Verdadero/Falso. Las respuestas condicionales van en advertencias.", } def format_fragment(restricción: dict[str, str]) -> str | Ninguno: si no hay restricción: devuelve Ninguna regla =[]si "moneda" en la restricción: reglas.append(f"La moneda DEBE ser {moneda!r}.") si "período" en la restricción: reglas.append(f"El período DEBE ser {período!r}.") if "fecha_formato" en la restricción: reglas.append(f"Fechas como {fecha_formato!r}.") return "Formato de restricciones:n- " + "n- ".join(rules) def distinguir_fragmento(distinciones: lista[Distinción]) -> str | Ninguno: si no hay distinciones: devuelve Ninguna líneas = [f"- {d.this!r} NO es {d.not_!r}. Devuelve solo {d.this!r}." para d en distinciones] return "Ten cuidado con estas distinciones:n" + "n".join(lines)
1.4 El despachador y el aviso del usuario
El despachador lee la pregunta analizada, elige el esquema de ANSWER_REGISTRY[parsed_q.expected_answer_shape] (o una anulación de respuesta_schema=… explícita, para formas personalizadas: consulte la sección 1.5), compone BASE + los fragmentos relevantes y devuelve el par (indicador, aplicado). La lista aplicada se incluye en el rastreo, por lo que un formato incorrecto seis meses después se puede rastrear hasta el conjunto exacto de fragmentos que se compusieron. Nunca a “decidió el agente”.
def build_system_prompt(parsed_q: ParsedQuestion) -> tupla[str, lista[str]]: partes: lista[str] = [BASE] aplicada: lista[str] = ["BASE"] parts.append(SHAPE_FRAGMENTS[parsed_q.expected_answer_shape]) apply.append(f"SHAPE:{parsed_q.expected_answer_shape}") breve = parsed_q.generación if frag := format_fragment(brief.format_constraint): parts.append(frag); aplicado.append("FORMATO") si frag := distinguir_fragmento(breve.debe_distinguir): partes.append(frag); apply.append("DISTINGUISH") if breve.desambiguación: parts.append(f"Desambiguación: {breve.desambiguación}") apply.append("DESAMBIGUACIÓN") if parsed_q.decomposed_subquestions: parts.append("Esta pregunta se descompone en …") apply.append("DESCOMPOSICIÓN") return "nn".join(partes), aplicado
El mensaje de usuario es un caparazón delgado: la pregunta, las palabras clave originales (para que el modelo pueda marcar cuáles se encontraron en los pasajes) y las líneas candidatas. El encabezado de la columna GLOBAL_LINEtpagetline_in_pagettext se repite como un recordatorio de una línea justo antes de los datos: la misma forma que usa la regla BASE, en mayúsculas. Dos recordatorios en dos lugares suenan redundantes, pero son los que solucionaron el error de numeración por página versus global de la sección 1.3. Seguro barato para un fallo recurrente.
def build_user_prompt(parsed_q: ParsedQuestion, filtered_line_df: pd.DataFrame) -> str: df = filtered_line_df if "overall_line_num" no está en df.columns: df = df.reset_index(drop=False).rename(columns={"index": "overall_line_num"}) líneas = "n".join( f"{int(r.overall_line_num)}t{int(r.page_num)}t{int(r.line_num)}t{r.text}" para r en df.itertuples() ) keyword_strs = [k.text para k en parsed_q.keywords] return ( f"Pregunta: {parsed_q.original_question}nn" f"Palabras clave de consulta originales: {keyword_strs}nn" "Pasajes (separados por tabulaciones: GLOBAL_LINE\tpage\tline_in_page\ttext).n" "Cite mediante Span.line_start = GLOBAL_LINE (primera columna).nn" f"{lines}" )
Una llamada versus k llamadas: una pequeña elección arquitectónica se esconde dentro de build_user_prompt. Cuando la recuperación devuelve k=3 fragmentos candidatos, podemos entregar los tres al modelo en una llamada combinada, o llamar al despachador secuencialmente, fragmento por fragmento, deteniéndonos en el momento en que tengamos lo que necesitamos.
Los dos modos tienen perfiles de costos muy diferentes:
Combinado (una llamada con todos los k fragmentos). El modelo ve todo a la vez, puede hacer referencias cruzadas entre pasajes, emite citas por pasaje de forma natural a través de elementos: lista[XItem] (un elemento por hallazgo, cada uno con su propio intervalo). Costo: un viaje de ida y vuelta, con el tamaño del contexto completo. Este es el valor predeterminado cuando la respuesta se puede sintetizar en fragmentos (una lista de exclusiones distribuidas en páginas, una definición más su nota al pie de ejemplo). Secuencial con terminación anticipada (una llamada por fragmento, parada en caso de éxito). Procese los fragmentos en orden de clasificación de recuperación. Después de cada llamada, verifique answer_found=True y complete_answer_found=True: si ambas cosas son ambas, envíe y omita el resto. Costo en el mejor de los casos: el valor de una porción de contexto. Este es el movimiento correcto cuando la respuesta es un hecho único que probablemente viva en un lugar (una cantidad, una fecha, el nombre de una persona, un sí o un no), por lo que la parte mejor clasificada casi siempre lo tiene. Guarda 2/3 de las fichas en k=3.
Otros dos casos obligan a la secuencial independientemente de la forma de la pregunta: cada fragmento es lo suficientemente grande como para que la combinación empujaría el contexto más allá del margen del 70-80% (sección 1.5), o los fragmentos son heterogéneos de una manera que rompe un esquema (una sección del contrato versus su enmienda versus su cronograma, cada uno con sus propias reglas de validación).
La decisión entre combinado y secuencial se toma en sentido ascendente, en el análisis en cuestión, no aquí. El análisis de preguntas ya clasifica la pregunta (forma_respuesta, tipo_respuesta, ambigüedad y contexto_respuesta para la cantidad de texto circundante que se debe leer). La sugerencia de enrutamiento se encuentra junto a la misma ParsedQuestion, en un chunk_strategy de nivel superior: "combined" | campo "secuencial". Luego, la recuperación lee esa pista y decide qué tirar (un ancla apretada frente a un tramo más amplio). En este artículo, Generation recibe la lista final de fragmentos más la estrategia y simplemente la ejecuta. Todas las decisiones son ascendentes; este artículo solo ejecuta el ciclo.
Tres beneficios concretos de este diseño de composición:
La modificación de un formato afecta a un archivo: cambie la representación de las cantidades (siempre dos decimales, siempre la moneda final) → edite SHAPE_FRAGMENTS["cantidad"]. Nada más se mueve. Pydantic aplica la estructura de tipo Cantidad por separado. Agregar una restricción toca un archivo. Un nuevo tipo de pregunta comienza a pedir "el valor más reciente cuando el documento proporciona un historial" → agregue una rama prefer_recent_fragment(brief.prefer_recent) en el despachador. Otras formas están intactas. La auditabilidad es un resultado gratuito: result.meta["fragments_applied"] enumera exactamente qué fragmentos se compusieron para esta llamada. Un formato incorrecto dentro de seis meses se puede atribuir a una forma mal detectada (problema de análisis de preguntas) o a un fragmento con errores (el problema de este artículo).
1.5 Llamar al modelo, almacenar el seguimiento, esquemas personalizados
Temperatura 0. La generación de RAG es extracción, no escritura creativa. La reproducibilidad importa más que la variación.
Conserve siempre la respuesta completa sin formato en el seguimiento de la canalización. Tokens, versión del modelo, identificación de la solicitud, motivo de finalización, huella digital del sistema, cualquier otra cosa que exponga el SDK: todo reside en el uso, modelo, identificación, huella digital del sistema. Mantener solo output_text es el error; almacenar toda la carga útil cuesta unos pocos KB por llamada y ahorra horas de trabajo forense posterior. Es común el error opuesto: estimar tokens con un tokenizador local antes de la llamada. Tiktoken varía entre las versiones del modelo y usted graba la CPU local para volver a calcular lo que devuelve la API de forma gratuita.
Mantenga el margen. Utilización del tope al 70-80%. Los modelos se degradan mucho antes del límite estricto: llenan 127k de una ventana de 128k y la calidad de las respuestas, el seguimiento de las instrucciones y el razonamiento caen juntos. Si constantemente completa más que eso, el problema está en sentido ascendente: la recuperación devuelve demasiados pasajes, el esquema es demasiado grande o la pregunta necesitaba un filtrado de alcance más agresivo. La señal a observar es el uso["input_tokens"]/model_max_input en una ventana de llamadas recientes, no una estimación única previa a la llamada.
En producción, las respuestas sin procesar van a una tabla de respuestas o a un almacén de objetos codificados por ID de solicitud. Seis meses después, cuando un usuario informa que "la respuesta a la pregunta X solía ser diferente", puede obtener la solicitud exacta, la respuesta exacta, la versión exacta del modelo y reconstruir lo que sucedió. Sin la carga útil en bruto, esa conversación es imposible.
def generar(parsed_q: ParsedQuestion, filtered_line_df, cliente, respuesta_esquema: tipo[BaseModel] | Ninguno = Ninguno) -> GenerationResult: Respuesta = respuesta_esquema o ANSWER_REGISTRY[parsed_q.expected_answer_shape] sistema, aplicado = build_system_prompt(parsed_q) usuario = build_user_prompt(parsed_q, filtered_line_df) resp = client.responses.parse( model=model_chat, input=[{"role": "system", "content": system}, {"role": "user", "content": user}], text_format=Respuesta, temperatura=0.0, store=False, ) respuesta = Answer.model_validate_json(resp.output_text) return GenerationResult(answer=answer, meta={ "schema_used": Respuesta.__name__, "fragments_applied": aplicado, "template_version": "v1", "raw_response": resp.model_dump(mode="json"), })
Esto es lo que contiene result.answer para la pregunta en ejecución, "¿Cuáles son las opciones mencionadas para la codificación posicional?", ejecutando generate(parsed_q, filtered_line_df, client) en el documento Attention Is All You Need (Vaswani et al. 2017; licencia de distribución no exclusiva de arXiv, declarada en la página de resumen de arXiv). Las rutas ejecutables llaman a los servicios OpenAI (gpt-4.1, gpt-4o-mini), regidos por los Términos de uso de OpenAI:
{ "extraction_method": "textualmente", "confianza": 1.0, "advertencias":[], "answer_found": true, "complete_answer_found": true, "context_completeness_weak": 1.0, "context_structured": true, "llm_discovered_keywords": ["codificación posicional", "aprendida", "fija", "sinusoidal", "seno y coseno", "incrustaciones posicionales"], "keywords_found": ["codificación posicional", "aprendida", "fija", "sinusoidal", "incrustaciones posicionales"], "conflicting_evidence": false, "suggested_clarification": null, "items": [ {"text": "Codificaciones posicionales aprendidas", "spans": [{"line_start": 165, "line_end": 165, "quote": "Hay muchas opciones de codificaciones posicionales, aprendidas y fijas[9]."}, {"line_start": 273, "line_end": 273, "quote": "También experimentamos con el uso de incrustaciones posicionales aprendidas"}]}, {"text": "Codificaciones posicionales fijas (sinusoidales), "spans": [{"line_start": 200, "line_end": 200, "quote": "Hay muchas opciones de codificaciones posicionales, aprendidas y fijas[9]."}, {"line_start": 165, "line_end": 165, "quote": "En este trabajo, utilizamos funciones seno y coseno de diferentes frecuencias."}]} ] }
Y el resultado de seguimiento.meta guardado junto con: esquema_usado confirma la selección del registro, fragmentos_aplicado es el registro de auditoría de la composición del mensaje y raw_response es la carga útil de OpenAI reducida aquí a las claves que buscará más adelante (modelo, identificación, uso) más la lista de claves de nivel superior restantes para una integridad forense:
{ "schema_used": "TextAnswer", "fragments_applied": ["BASE", "SHAPE:list"], "template_version": "v1", "raw_response": { "model": "gpt-4.1", "id": "resp_06c182cee1f7d692016a16beb1074c8196…", "usage": { "input_tokens": 5051, "input_tokens_details": {"cached_tokens": 0}, "output_tokens": 546, "output_tokens_details": {"reasoning_tokens": 0}, "total_tokens": 5597 }, "_other_keys": ["antecedentes", "completado_en", "content_filters", "conversación", "created_at", "error", "frequency_penalty", "incomplete_details", "instructions", "max_output_tokens", "max_tool_calls", "metadatos", "moderación", "objeto", "salida", "parallel_tool_calls", "presence_penalty", "previous_response_id", "prompt", "prompt_cache_key", "prompt_cache_retention", "razonamiento", "identificador_seguridad", "nivel_servicio", "estado", "almacenamiento", "temperatura", "texto", "elección_herramienta", "herramientas", "top_logprobs", "top_p", "truncamiento", "usuario"] } }
La anulación del usuario: A veces el proyecto tiene una forma de dominio específico que el registro no cubre: el ejemplo de Dirección del Artículo 8A (el contrato de respuesta) es exactamente este caso. El generate() del artículo acepta answer_schema=MyCustomSchema como una anulación; el despachador lo usa en lugar del valor predeterminado del registro. El esquema personalizado debe subclasificar AnswerBase para que los campos de comentarios permanezcan en su lugar. En la biblioteca enviada, la superficie equivalente es pdf_fields_qa(fields=[…]) para formas de dominio por campo; Los equipos que hoy necesitan un esquema de respuesta totalmente personalizado ajustan llm_answer_with_evidence directamente (la anulación de kwarg en pdf_qa es una promoción planificada, seguida del traslado del registro a src/docintel/generación/).
La carga útil sin procesar, familia por familia: la API LLM devuelve mucho más que la respuesta estructurada que ve el usuario. El envoltorio JSON que rodea el contenido incluye tres familias de campos, y cada uno gana su lugar alimentando una preocupación posterior:
Contenido: la salida del modelo, salida dentro de la salida[0].contenido[0].text hasta que Pydantic lo analice en una subclase AnswerBase escrita. Esa es la parte que se convierte en la respuesta visible para el usuario. Uso: tokens de entrada, tokens de salida, además de contadores de aciertos/errores de caché. Una capa de latencia y costo de seguimiento lee use.input_tokens y use.output_tokens para calcular el costo por pregunta; Los recuentos de caché nos dicen qué parte del mensaje sirvió el proveedor desde su caché de prefijo. Seguimiento: id, modelo, creado_en, estado. Una auditoría de seguridad de seguimiento lee la identificación y el modelo para reproducir una respuesta anterior seis meses después, cuando el usuario informa que "la respuesta a la pregunta X solía ser diferente".
La regla: nunca persistir solo en el contenido. Una capa de almacenamiento de seguimiento detalla la tabla llm_response que contiene este JSON palabra por palabra. Unos cuantos bytes adicionales por solicitud, categorías completas de análisis que el equipo puede ejecutar más tarde en lugar de adivinar.
{ "id": "resp_063b40a2e3406595016a0c4d62ee3c8195", "object": "response", "created_at": 1742832000, "model": "gpt-4o-mini-2024-07-18", "status": "completado", "output": [ {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "{"items": [{"text": "Codificaciones posicionales aprendidas", "spans": [{"line_start": 266, "line_end": 266, "quote": "Hay muchas opciones de codificaciones posicionales, aprendidas y fijas[9]."}]}, …], "answer_found": verdadero, "complete_answer_found": verdadero, "confianza": 0.9, "advertencias":[], "extraction_method": "verbatim", "keywords_found": ["positional", "encoding"], "conflicting_evidence": false, "suggested_clarification": null}"}]} ], "usage": { "input_tokens": 2347, "input_tokens_details": {"cached_tokens": 1850}, "output_tokens": 412, "output_tokens_details": {"reasoning_tokens": 0}, "total_tokens": 2759 } }
2. Evidencia por campo: el camino de escalada
Cuando la respuesta sea veinte campos escritos en lugar de una oración, empuje el contenedor AnswerWithEvidence hasta el nivel del campo. El esquema se convierte en una clase Pydantic cuyos campos son FieldExtraction[T]: cada uno lleva su propio valor escrito más el intervalo desde el que el modelo lo leyó. El LLM llena todo el árbol en una llamada estructurada; El código descendente lee perfil.email.value para el campo y perfil.email.page / line_start / line_end para la cita. El AnswerWithEvidence de reclamo único sigue siendo el predeterminado; este es el camino de escalada para la extracción de múltiples campos en contextos regulados (clasificación de recursos humanos, decisiones crediticias, admisión de atención médica, ingesta de facturas).
La serie ya hace esto a nivel de elemento. AddressItem del Artículo 8A (el contrato de respuesta) envuelve un valor de Dirección con sus tramos. AmountItem envuelve un Amount con sus tramos. DateItem envuelve un DateValue con sus intervalos. El artículo es el valor más el lugar donde se leyó el valor. El mismo patrón, aplicado a nivel de campo en lugar de a nivel de elemento de lista, es lo que esta sección hace explícito.
2.1 Tres formas ingenuas que se rompen
El primer reflejo es una llamada por campo. Cada llamada es un qa.ask(pdf, "¿cuál es el correo electrónico del candidato?", respuesta_formato=str). La auditoría es limpia (una línea de registro por campo), pero el contexto del documento se envía N veces y la factura aumenta linealmente. En un CV de una sola página con muchos campos, este es el formulario más caro que existe.
El segundo reflejo es una llamada que devuelve un JSON plano sin evidencia. {nombre: "…", correo electrónico: "…", teléfono: "…"}. El costo se reduce en un factor de N porque el documento se envía una vez. La procedencia desaparece por completo; un campo incorrecto no se puede rastrear hasta una línea específica. Un candidato que apela no tiene respuesta ¿qué línea de mi CV leyó el modelo?
El tercer reflejo es una llamada con un bloque de evidencia de alto nivel. {perfil: {…todos los campos…}, abarca: […]}. Mejor, pero la cita es por respuesta, no por campo. El revisor ve una lista de páginas pero no puede asignar "esta página respalda el correo electrónico" a "esa página respalda la función actual". Cuando un solo campo es incorrecto, todo el bloque de evidencia se vuelve sospechoso.
Ninguno de los tres amplía AnswerWithEvidence honestamente. El movimiento correcto es empujar la envoltura hasta el nivel del campo.
2.2 El patrón: un contenedor por campo
La primitiva es una clase Pydantic genérica que envuelve un valor escrito con su propia evidencia:
desde escribir import Generic, TypeVar desde pydantic import BaseModel, Field T = TypeVar("T") class FieldExtraction(BaseModel, Generic[T]): """Análogo por campo de AnswerWithEvidence. Cada campo lleva su propio valor escrito más el intervalo desde el cual el LLM lo leyó.""" valor: T | Ninguno = Campo(…, descripción="Valor escrito o nulo si no se encuentra.") cita: str = Campo(default="", descripción="Líneas textuales de la fuente analizada.") página: int | Ninguno = Campo (predeterminado = Ninguno) line_start: int | Ninguno = Campo (predeterminado = Ninguno) line_end: int | Ninguno = Campo(predeterminado=Ninguno) encontrado: bool = Campo(predeterminado=Verdadero) advertencia: str = Campo(predeterminado="")
El esquema de salida se convierte en una clase Pydantic cuyos campos son una instancia de FieldExtraction[T]. El LLM llena todo el árbol en una llamada estructurada. Para el caso del CV de un artículo extra sobre campos basados en reglas:
clase CandidateProfile(BaseModel): nombre: FieldExtraction[str] correo electrónico: FieldExtraction[str] teléfono: FieldExtraction[str] linkedin_url: FieldExtraction[str] current_role: FieldExtraction[str] años_experiencia: FieldExtraction[int] # … y así sucesivamente, veintitantos campos
El código descendente lee perfil.email.value para el campo y perfil.email.page/line_start/line_end para la cita. El resaltador de bbox del Artículo 8C (validación) se ejecuta por campo en lugar de por solicitud; cada campo tiene su propio rectángulo amarillo. El registro de auditoría escribe una fila por campo si así lo desea y una fila por solicitud si no lo desea. Misma primitiva, dos granularidades.
2.3 Verificar las citas, por campo
El trabajo posterior al LLM es donde un JSON se convierte en un objeto confiable: model_validate_json lo analiza, los validadores a nivel de campo imponen formatos de dominio y un model_validator(mode="after") completa los campos derivados. La capa que se gana su permanencia aquí es la de verificar. Para cada FieldExtraction[T] cuyo valor no sea Ninguno, la cita debe aparecer realmente (módulo de espacio en blanco) en algún lugar de la fuente analizada. Una cita alucinada lee de manera plausible a un revisor pero no existe en la página. El verificador recorre el esquema, verifica las subcadenas de cada cita con las líneas analizadas y marca todas las que no aparecen. Es barato y detecta el modo de fracaso que más temen las empresas: el modelo inventó una justificación. Es la versión por campo del validador integrado en el Artículo 8C (validación).
def verificar_citaciones (perfil: BaseModel, línea_df: pd.DataFrame) -> lista[str]: """Devuelve una lista de (field_path, cita) para las citas que no aparecen en line_df. Lista vacía = cada cita citada está en la fuente.""" flat = " ".join(line_df["text"].astype(str)).lower() misses =[]para field_path, campo en _walk_fields_of_type(profile, FieldExtraction): si field.value es Ninguno o no field.quote: continuar si field.quote.lower().strip() no está en plano: misses.append((field_path, field.quote)) devuelve errores
Un perfil que regresa con misses ==[]es más seguro que el mismo perfil devuelto sin el cheque. Un perfil que regresa con dos o tres citas alucinadas es uno que el revisor debe leer junto con la fuente. El modo de falla es exactamente el que ya maneja el circuito de retroalimentación del Artículo 8C (validación), aplicado por campo.
El resto de la historia de múltiples campos es su propio tema y se encuentra en un artículo adicional dedicado: descomponer un valor en espacios escritos para que un filtro SQL pueda alcanzarlo (código postal como su propio campo, no enterrado en una cadena de dirección), calcular campos derivados después de que el LLM lea el sin formato (un código de país ISO de "italiano"), fusionar preguntas de formas mixtas detrás de un punto de entrada qa.ask(…) y mezclar esta extracción de LLM con campos basados en reglas para casos regulados (clasificación de recursos humanos, crédito decisiones, atención sanitaria). La parte que pertenece a la generación es la de arriba: empuje el contenedor de evidencia hasta el nivel de campo, luego verifique cada cita con la fuente.
3. Pocos disparos dinámicos: recuperación aplicada al mensaje
Un fragmento más, agregado en el momento de la consulta: los ejemplos validados más cercanos a la nueva pregunta, extraídos de un banco y colocados en el mensaje antes de la llamada. Es una pregunta frecuente dirigida al modelo. Las preguntas frecuentes clásicas preparan pares de preguntas y respuestas para las personas; este banco los prepara para el modelo, orientado al formato de respuesta y las extracciones complicadas. El mecanismo reutiliza el ladrillo de recuperación. El despachador de la sección 1 ensambla el mensaje de BASE más los fragmentos de tiempo de construcción; aquí se agrega un fragmento más en el momento de la consulta: los ejemplos más cercanos a la nueva pregunta, extraídos de un banco y colocados en el mensaje antes de la llamada. El mismo retrieve_pages se ejecuta en example_bank_df en lugar del line_df del corpus, y devuelve de 3 a 5 ejemplos (más diluye la nueva pregunta). El banco crece a medida que el equipo selecciona ejemplos validados; el mensaje los detecta en la siguiente consulta, no es necesario implementarlos.
3.1 Un ejemplo, de principio a fin
Tomemos como ejemplo un fracaso concreto. Un usuario pregunta "¿cuál es la prima anual?" en un contrato cuya línea 212 imprime la prima con un signo de dólar ($1.850,50). Sin ningún ejemplo, el modelo copia la fuente demasiado literalmente: devuelve la moneda como "$" en lugar del código ISO-4217 "USD". Todas las reglas están en BASE, pero el modelo aún falla en el formato de símbolo versus código.
El banco guarda preguntas anteriores que el equipo ya respondió y verificó, una fila cada una: el texto de la pregunta, la respuesta validada JSON y algunas etiquetas. La recuperación (contra el texto de la pregunta, con el mismo incrustador que usa el corpus) extrae la fila anterior más cercana, donde exactamente esa normalización ya se resolvió:
{ "question": "¿Cuál fue la prima del año pasado?", "answer_json": { "answer_type": "amount", "items": [ {"amount": {"value": 1850.5, "currency": "USD"}, "spans": [{"line_start": 212, "line_end": 212, "quote": "Prima anual: $1,850.50"}]} ], "answer_found": verdadero }, "etiquetas": ["monto", "prima", "usd"] }
El despachador coloca esa fila en el mensaje del usuario como un ejemplo resuelto, justo antes de la nueva pregunta:
Aquí hay una respuesta anterior en la forma exacta esperada. P: ¿Cuál fue la prima del año pasado? (línea de origen: "Prima anual: $1,850.50") A: {"answer_type": "amount", "items": [{"amount": {"value": 1850.5, "currency": "USD"}, "spans": [{"line_start": 212, "line_end": 212}]}], "answer_found": true} Ahora responda esta pregunta de la misma forma. P: ¿Cuál es la prima anual?
El ejemplo hace la enseñanza. Combina una fuente desordenada ($1850,50) con la respuesta limpia y validada (valor 1850,5, moneda "USD"), por lo que el modelo copia ese mapeo exacto en lugar de volver a derivarlo de un párrafo de reglas que simplemente ignoró. La misma fila también lleva el anidamiento de elementos/intervalos y el indicador respuesta_encontrado por demostración.
3.2 Tres lugares donde vale la pena
Cada ejemplo vale su valor sólo cuando previene un error específico y recurrente:
Los ejemplos de formato corrigen errores de normalización. El caso premium anterior: un monto pasado limpio evita que el modelo devuelva "$" para la moneda en lugar del código ISO "USD". Busque uno siempre que el formato de origen y el formato de destino difieran y el modelo siga copiando el origen. Los ejemplos de extracción corrigen un comportamiento. Un IBAN impreso en dos líneas, con una fila anterior que une las mitades en un valor, impide que el modelo devuelva solo la primera línea. El ejemplo es la especificación de cómo extraer, no solo qué forma devolver. Los ejemplos de análisis de preguntas solucionan la desambiguación. Una pregunta vaga anterior con su análisis resuelto ("el bit de garantía" → campo_objetivo="duración_de_garantía") muestra una nueva pregunta vaga en qué campo aterrizar, en lugar de adivinar términos_de_garantía o exclusiones_de_garantía.
El costo es real, así que manténgalo condicional. Omita el fragmento cuando aún no haya un banco, cuando la nueva pregunta no se parezca a nada en el banco (un tirón fuera de distribución agrega ruido, no señal), o cuando el banco contiene datos confidenciales que el mensaje no debería incluir. El artículo adicional sobre Preguntas frecuentes como RAG desarrolla la versión extrema, donde todo el corpus es el banco de ejemplo.
4. Conclusión
Una pregunta analizada, una llamada escrita: el registro elige el esquema, BASE más fragmentos componen el mensaje del sistema, el mensaje del usuario lleva la pregunta, las palabras clave y las líneas de pasaje etiquetadas, y el seguimiento registra cada decisión, de modo que un formato incorrecto seis meses después se puede rastrear hasta el conjunto exacto de fragmentos. Aquí nada confía en el modelo todavía. La respuesta que recibe se compara con el contrato del Artículo 8A (el contrato de respuesta) mediante el Artículo 8C (validación) antes de que alguien la lea.
5. Fuentes y lecturas adicionales
El despachador redacta el mensaje en el momento de la compilación a partir de piezas escritas; Las principales alternativas de la literatura son las manos que controlan el modelo en tiempo de ejecución. Leer los dos uno al lado del otro es la mejor manera de ver qué compra el interruptor diseñado (reproducibilidad, costo limitado, un seguimiento auditable) y qué renuncia (flexibilidad abierta). La línea agente, selección de herramientas en tiempo de ejecución además de este despachador, es un trabajo de seguimiento más allá de esta serie.
Misma dirección que el artículo:
Mialon et al., Modelos de lenguaje aumentado: una encuesta, 2023 (arXiv:2302.07842). Estudio del espacio de diseño LLM aumentado. Descripción general útil para leer junto con el patrón del despachador.
Ángulo diferente, contexto diferente:
Yao et al., ReAct: Sinergia del razonamiento y la actuación en modelos lingüísticos, ICLR 2023 (arXiv:2210.03629). El agente elige herramientas en tiempo de ejecución: el LLM decide cuándo llamar a la recuperación, qué recuperar y cuándo detenerse. El extremo opuesto del espectro de control de los fragmentos de tiempo de construcción de este artículo. Schick et al., Toolformer: Los modelos de lenguaje pueden aprender a usar herramientas por sí mismos, NeurIPS 2023 (arXiv:2302.04761). El modelo decide en línea qué herramienta llamar, sin ensamblaje previo. La misma compensación que ReAct: flexibilidad frente a reproducibilidad y costo limitado.
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