el bloque de análisis de preguntas de Enterprise Document Intelligence, una serie que construye un sistema RAG empresarial a partir de cuatro bloques: análisis, análisis de preguntas, recuperación y generación. Las partes anteriores: el artículo 6_a (tesis) defendió el análisis de la pregunta y mostró los dos informes del consumidor en los que se divide la fila analizada. El artículo 6_b (extracción) recorrió las cinco familias de columnas que el analizador lee directamente de la cadena del usuario. Este artículo recoge la otra mitad: las columnas que el analizador decide sobre ellas, utilizando el perfil del documento, además de las elecciones de arquitectura y persistencia que debe tomar el bloque.
Las rutas de código ejecutable en este artículo llaman a la familia OpenAI gpt-4.1 para el análisis de preguntas; ese servicio es propietario y se rige por los Términos de uso de OpenAI.
1. Los campos que el analizador decide con el perfil del documento.
Tome un CV de una página y la pregunta "¿cómo se llama?". El análisis de preguntas por sí solo devuelve palabras clave = ["nombre"] y la recuperación busca el nombre literal de la palabra en el archivo. Un CV nunca dice nombre. Nada coincide, la respuesta vuelve vacía. Un humano no respondería esa pregunta sin nada más con qué continuar: primero echaría un vistazo al documento, vería un currículum y leería el nombre como una solicitud del nombre del candidato. El analizador necesita el mismo punto de partida. Tan pronto como ve que el documento es un currículum y que el nombre del candidato se encuentra en la parte superior de la página 1, el nombre de la palabra clave se resuelve en una persona, no en un token literal para buscar.
El despachador completa dos familias de columnas más justo después de que regresa el análisis de preguntas, utilizando la pregunta analizada MÁS el perfil del documento. En el código enviado, ese perfil es la zona semántica de parsing_summary, el dictado a nivel de documento producido por el analizador. Incluye doc_type (currículum vitae, contrato, factura,…), campos_típicos (los campos sobre los que suelen hacer preguntas sobre este tipo de documento) y un breve resumen escrito por un LLM que cabe al principio de un mensaje del sistema. El despachador lee estos tres campos y los usa para establecer la estrategia de fragmentos y el contexto de respuesta. Aterrizan en la misma fila question_df, por lo que la recuperación y la generación ven un registro.
1.1 Envío: cuánto contexto, qué estrategia de fragmento, qué modelo
Una vez que el analizador tiene la información literal, siguen tres decisiones más: cuánto texto circundante leer y devolver, si combinar los k fragmentos principales en una llamada LLM o alimentarlos en secuencia, y qué modelo llamar. Los tres son valores predeterminados que el proyecto puede anular por concepto, por tipo de respuesta o por pregunta. La cascada es la misma siempre: anulación de nivel de concepto > forma/tipo predeterminado > reserva del proyecto.
Cuánto contexto leer y devolver: Tres campos en StructuralHints contienen esto:
contexto_detección: la granularidad de la zona de confirmación de expresiones regulares (Artículo 6_b, forma de respuesta y tipo de respuesta). "línea" para cantidades y fechas, "párrafo" para descripción. respuesta_contexto: cuánto texto circundante recibe el generador. "línea" para un valor único, "párrafo" para una explicación, "página" para un resumen, "sección" para un tema, "capítulo" o "documento" para un resumen amplio. need_summary: Verdadero cuando la respuesta abarca más de lo que cabe en una cita textual.
Los valores predeterminados provienen de las mismas dos tablas satélite que ya conocemos: respuesta_shapes_df para valores predeterminados a nivel de forma (la columna default_answer_context), conceptos_df para anulaciones a nivel de concepto. "¿Cuál es la prima anual?" es (único, monto) sin concepto específico; obtiene respuesta_context = "línea" de respuesta_shapes_df. “¿Cuáles son las exclusiones de este contrato?” coincide con el concepto de exclusiones; obtiene respuesta_context = "capítulo" de conceptos_df, que anula la forma de listado predeterminada de "sección".
Por qué la forma Y la longitud son tan importantes que no se pueden recuperar. Las mismas pistas alimentan el envío combinado versus secuencial del bloque de generación. Cuando la respuesta es un solo hecho en un fragmento (una cantidad, una fecha, un IBAN, un sí/no), la generación llama al LLM secuencialmente, fragmento por fragmento en orden de clasificación de recuperación, y se detiene tan pronto como respuesta_encontrada=Verdadero y respuesta_completa_encontrada=Verdadero. Eso ahorra ~⅔ de los tokens de entrada en k=3 cuando la respuesta está en el primer fragmento. Cuando la respuesta se sintetiza en varios pasajes (una lista de exclusiones repartidas en páginas, una definición más su nota a pie de página, una comparación), la generación combina todos los k fragmentos en una sola llamada. La decisión la toma una vez, aquí, el analizador; la recuperación y la generación simplemente se ejecutan. A escala empresarial (millones de documentos × k fragmentos principales por pregunta), el ahorro por pregunta constituye la mayor parte de la factura de LLM.
La estrategia en sí se encuentra en el nivel superior de ParsedQuestion.chunk_strategy y el valor predeterminado proviene de las tablas satélite, con este orden de resolución:
def resolve_chunk_strategy( respuesta_forma: str, matched_concept: str | Ninguno, respuesta_shapes_df: pd.DataFrame, conceptos_df: pd.DataFrame, ) -> Literal["combinado", "secuencial"]: """Anulación de nivel de concepto > valor predeterminado de forma de respuesta > valor predeterminado estricto.""" si matched_concept no es Ninguno: fila = conceptos_df[concepts_df["concepto"] == matched_concept] si no es fila.empty y pd.notna(fila.iloc[0].get("default_chunk_strategy")): devuelve fila.iloc[0]["default_chunk_strategy"] fila = respuesta_shapes_df[answer_shapes_df["forma"] == respuesta_forma] si no fila.empty: devuelve fila.iloc[0]["default_chunk_strategy"] devuelve "combinado"
El despachador determinista (sección 2.1, enfoque B) llama a esto justo después de que regresa el análisis de la pregunta, escribe el resultado en parsed.chunk_strategy (nivel superior) y se ejecuta la misma cascada para respuesta_contexto (también controlado por la forma). need_summary permanece en estructural_hints porque describe el documento, no el envío. El LLM puede anular cualquiera de los valores predeterminados en PARSE_PROMPT (subtarea 6) cuando la pregunta en sí contradice la convención. "Dame un resumen de una línea de las exclusiones" anula el concepto de exclusiones default_answer_context = "capítulo". Los valores predeterminados son convenciones, no restricciones.
Eligiendo el modelo: dos satélites en cascada. La misma idea se aplica a la elección del modelo. Extraer una cantidad de una línea no necesita el mismo modelo que leer tres páginas de densa jerga legal. Para el primero basta con un modelo pequeño; el segundo quiere uno más fuerte. Codificar gpt-4.1 en todas partes es un desperdicio en los casos fáciles y parece barato en los difíciles. Dividimos esto en dos satélites por una razón: un llm_model_tiers_df conceptual para razonar sobre los depósitos y un llm_models_df preciso con una fila por modelo específico. El valor predeterminado por pregunta apunta a un nombre de modelo preciso, porque en tiempo de ejecución tenemos que llamar a algo concreto. Los modelos a los que se hace referencia en este artículo (la familia OpenAI gpt-4.1 y la familia Claude de Anthropic) son servicios de nube propietarios que se rigen respectivamente por los Términos de uso de OpenAI y la Política de uso de Anthropic.
La agrupación conceptual primero. Cuatro niveles que sobreviven a la rotación del catálogo de proveedores:
Luego el registro preciso. Una fila por modelo específico que el proyecto puede llamar, con las características que un desarrollador debe elegir:
Los precios y las ventanas de contexto se actualizan cada pocos meses; el esquema no. Fijar la tabla a una consulta ("a partir de 2026-05, ¿qué modelos puede llamar el proyecto?") le da a la implementación una única fuente de verdad. Cuando el equipo valide gpt-4.5 dentro de seis meses, será una actualización de una fila más una repetición del conjunto de evaluación, no un cambio de código.
Los valores predeterminados apuntan a un modelo preciso, no a un nivel. Una pregunta de nivel nano no le pide al despachador que “use algún modelo nano”; Solicita el bendito nanomodelo del proyecto, el que el equipo ha evaluado en el corpus. Entonces, answer_types_df.default_model y conceptos_df.default_model tienen un nombre preciso (FK a llm_models_df.model). La cascada resuelve ese nombre directamente:
def resolve_model( tipo_respuesta: str, concepto_coincidente: cadena | Ninguno, tipo_respuesta_df: pd.DataFrame, conceptos_df: pd.DataFrame, respaldo: str = "gpt-4.1-mini", ) -> str: """Anulación de nivel de concepto > valor predeterminado de tipo de respuesta > respaldo del proyecto. Devuelve un nombre de modelo preciso.""" si matched_concept no es Ninguno: fila = conceptos_df[concepts_df["concepto"] == matched_concept] si no es fila.empty y pd.notna(fila.iloc[0].get("default_model")): devuelve fila.iloc[0]["default_model"] fila = respuesta_tipos_df[answer_types_df["tipo"] == tipo_respuesta] si no fila.empty y pd.notna(fila.iloc[0].get("default_model")): devuelve fila.iloc[0]["default_model"] retorno de reserva
El despachador escribe el resultado en parsed.suggested_model (nivel superior). El bloque de generación lee ese nombre, recupera la fila de llm_models_df para verificaciones de ventana de contexto/precios/capacidad y llamadas. Cuando el equipo quiere cambiar gpt-4.1 por gpt-4.5 después de la evaluación, es una ACTUALIZACIÓN llm_models_df SET model='gpt-4.5' WHERE tier='standard' (o dos inserciones de fila más una actualización de columna predeterminada), no un cambio de código.
1.2 Activaciones: adaptación al perfil del documento
Hasta ahora hemos asumido que el documento sigue el juego. No siempre es así.
Tome "¿Qué dice en la página 1?" En un PDF, la “página 1” es real: las páginas son artefactos físicos del formato y el analizador conoce sus límites. En un archivo de Word, la “página 1” depende del renderizador: la fuente del usuario, el ancho de la pantalla y el controlador de impresión cambian los saltos de página. La "página 1" que vio el usuario puede ser diferente de la "página 1" en otro visor. Si el analizador codifica extract_page_numbers=True, el sistema devuelve "ver página 2" en un documento de Word, incorrecto con un alto nivel de confianza.
La misma trampa se aplica siempre que la pregunta hace referencia a un elemento estructural que el documento no contiene: un TOC que no existe, un encabezado de sección que no está declarado, una tabla que el analizador no pudo extraer. La solución es que el analizador observe el perfil del documento (metadatos devueltos por parse_pdf del bloque de análisis del documento) y reduzca las activaciones que no encajan. El perfil es un pequeño objeto escrito:
clase DocumentProfile(BaseModel): formato: Literal["pdf", "docx", "html", "txt", "xlsx"] has_toc: bool = False has_tables: bool = False n_pages: int | Ninguno = Ninguno # Ninguno cuando el formato no tiene idiomas de páginas reales: list[str] = Field(default_factory=list) is_scanned: bool = False # OCR, se espera más ruido ortográfico
Luego, el analizador consulta el perfil para mantener las activaciones honestas:
class ExecutionPlan(BaseModel): use_toc_navigation: bool = True use_keyword_retrieval: bool = True use_embeddings: bool = False follow_cross_references: bool = False decompose_compound: bool = False iterate_on_feedback: bool = True extract_page_numbers: bool = True def parse_question(question, doc_profile) -> ParsedQuestion: parsed = base_parse(pregunta) if doc_profile.format == 'docx': parsed.activations.extract_page_numbers = False si no doc_profile.has_toc: parsed.activations.use_toc_navigation = False retorno analizado
El campo parsing_notes captura lo que el analizador notó pero no pudo aplicar. Fluye hasta el bloque _meta de la respuesta en el lado de generación para que el usuario sepa que el sistema entendió la limitación. No se equivocan con la respuesta de alta confianza en la “página 2”; obtienen una respuesta con una nota de que las referencias de las páginas son aproximadas en este formato.
La misma idea se aplica en otros lugares:
Error común: codificar los indicadores de activación como predeterminados independientemente del tipo de documento. Una canalización que siempre establece extract_page_numbers=True produce citas de páginas incluso cuando el documento no tiene páginas reales. Las activaciones deben provenir de las propiedades reales del documento, no de los valores predeterminados de todo el proyecto.
1.3 El esquema completo
Llegados a este punto, el esquema abarca todo lo construido apartado por apartado, tanto en el artículo 6_b (extracción) como hasta ahora en este artículo. Algunos campos aparecen aquí por primera vez: son los vínculos relacionales entre la fila de preguntas y las tablas satélite, que vale la pena nombrar explícitamente para que los dos informes al consumidor introducidos en el Artículo 6_a tengan sentido una vez reunidos.
class ParsedQuestion(BaseModel): # La entrada sin procesar, guardada para auditoría pregunta_original: str pregunta_corregida: str = "" # con corrección ortográfica (sección 2.1) # Lo que el usuario pide palabras clave: lista[Palabra clave] = Campo(default_factory=lista) # → concept_keywords_df → conceptos_df # Dos ejes ortogonales para la respuesta esperada (sección 2.2) respuesta_forma: Literal["single", "listing", "table", "tree", "nested_json"] = "single" tipo_respuesta: str = "texto" # → FK into answer_types_df # Cómo está estructurada la pregunta (secciones 2.4 + 2.3) descomposición: Descomposición = Campo(default_factory=Descomposición) alcance_filtros: AlcanceFilters = Field(default_factory=ScopeFilters) estructural_hints: StructuralHints = Field(default_factory=StructuralHints) # Cómo la canalización distribuye las llamadas LLM (en cascada desde concepto/tipo, sección 2.6) chunk_strategy: Literal["combined", "sequential"] = "combined" # despacho de generación sugerido_modelo: str = "gpt-4.1-mini" # → FK into llm_models_df # Cuándo el LLM debe distinguir conceptos relacionados (sección 3.2) desambiguación: str | Ninguno = Ninguno distractores: lista[str] = Campo(default_factory=list) # Qué debe hacer el sistema (sección 2.7) activaciones: ExecutionPlan = Field(default_factory=ExecutionPlan) # Lo que el analizador notó sobre sus propias elecciones parsing_notes: lista[str] = Campo(default_factory=list) aclaración_sugerida: str | Ninguno = Ninguno motivo_ambigüedad: str | Ninguno = Ninguno # Recuperación de los dos informes del consumidor (ensamblados derivados, sección 3): RetrievalQuery | Ninguno = Ninguna generación: GenerationBrief | Ninguno = Ninguno
Entran en juego varias capas relacionales, que reflejan el análisis de documentos. La mesa central es fija; Los satélites enumerados aquí son ejemplos que un proyecto normalmente termina necesitando, no un conjunto cerrado:
question_df (siempre). Una fila por pregunta analizada, con las columnas arriba. La fila es lo que el bloque _meta (sección 2.3) registra en el disco para auditoría, y qué paneles de control SQL a nivel de corpus: “¿cuántas preguntas de tipo cantidad hicieron los usuarios el mes pasado?”, “¿qué preguntas provocaron una aclaración?”, “¿qué palabras clave fueron más afectadas?”. conceptos_df (típico). Una fila por concepto (premium, non_compete,…) con su tipo de documento y definición. En todo el proyecto, mantenido por expertos en el dominio. concept_keywords_df (típico). Una fila por (concepto, idioma, palabra clave). Unido a conceptos_df en concepto. El área de gran crecimiento: cada recuperación perdida que resulta ser una discrepancia de vocabulario se convierte aquí en una nueva fila. respuesta_types_df (típico). Una fila por tipo de respuesta registrado (cantidad, fecha, iban, texto, dirección,…) con retrieval_patterns (utilizado por el bloque de recuperación), output_schema_ref (utilizado por el bloque de generación), definición y el default_model por tipo. Agregar un nuevo tipo es una sola inserción. answer_shapes_df (pequeño, fijo). Una fila por forma de respuesta registrada (única, lista, tabla, árbol, nested_json) con valores predeterminados por forma para chunk_strategy y respuesta_contexto. La forma determina cómo se presenta la respuesta; El tipo determina lo que contiene cada valor. La división hace que “Enumerar las primas anuales” (una pregunta (listado, monto)) sea una decisión de envío diferente de “¿Cuál es la prima?” (una pregunta (única, cantidad)). llm_model_tiers_df (pequeño, conceptual). Cuatro filas (nano, mini, estándar, razonamiento) con el costo relativo, la latencia y los casos de uso que se adaptan a cada nivel. Permite al equipo razonar sobre la elección del modelo en términos independientes del proveedor. llm_models_df (una fila por modelo preciso que el proyecto puede llamar). Incluye proveedor, nivel (FK a llm_model_tiers_df), ventana de contexto, soporte de salida estructurada, precio por token de 1 millón y notas. El valor predeterminado por pregunta apunta a un nombre de modelo preciso en esta tabla; cambiar gpt-4.1 por gpt-4.5 después de la evaluación es una actualización de fila.
Se agregan otros satélites cuando el dominio los requiere. Un RAG legal a menudo desarrolla códigos de mapeo regulatorios_df (“L131-1”) a sus textos reales para que el analizador pueda resolver las referencias. Un corpus corporativo crece con un entidad_alias_df por lo que "BNP" y "BNP Paribas" y "el Banco" se resuelven en la misma entidad. A uno científico le crece unit_conversions_df. El mismo patrón que las columnas: comience con lo que necesita y agregue cuando un caso real lo requiera.
Dos de las columnas de question_df (recuperación, generación) se construyen a partir de las demás: el analizador las ensambla a partir de las columnas sin procesar, de modo que la recuperación y la generación reciban cada una solo lo que necesitan. La siguiente sección trata sobre por qué están divididos de esta manera.
Resumen de las columnas de question_df: para cada columna: qué transporta, cuándo está configurado, quién lo consume en sentido descendente.
2. Opciones de arquitectura
La sección 1 analizó lo que el despachador decide en la parte superior de la fila analizada: valores predeterminados de envío, indicadores de activación, el esquema ensamblado. Este da un paso atrás: quién escribe cada una de esas decisiones (el usuario, una regla determinista o un LLM en tiempo de ejecución), cómo llegan las opciones a la llamada de nivel superior y cómo se audita cada decisión.
2.1 Tres enfoques para decidir las activaciones
El campo del plan de ejecución de la pregunta analizada contiene un conjunto de indicadores de activación: use_toc_navigation, use_keyword_retrieval, decompose_compound, etc. La sección 1.2 los presentó; éste cubre quién decide lo que debe hacer en una ejecución determinada.
Esta es una de las principales opciones arquitectónicas de la serie. Tres enfoques.
Método A. Anulaciones explícitas del usuario. El usuario pasa indicadores de activación como argumentos a pdf_qa. Para forzar la recuperación semántica y omitir los ciclos de descomposición y retroalimentación, la llamada dice pdf_qa(contract, question="¿Cuáles son todas las obligaciones?", use_embeddings=True, decompose_compound=False, iterate_on_feedback=False).
Pro: control total, totalmente reproducible, depurable. Desventaja: el usuario debe comprender el sistema para elegir de forma inteligente. En la práctica, nadie hace esto en consultas rutinarias; es una anulación manual para desarrollo y depuración.
Enfoque B. Despachador determinista. El sistema analiza la pregunta analizada y el perfil del documento y aplica reglas basadas en código para decidir las activaciones. La siguiente función es ilustrativa; un despachador de producción lleva entre 15 y 30 de estas reglas, acumuladas durante la vida útil de la implementación:
def decide_activations(parsed: ParsedQuestion, doc_profile: DocumentProfile) -> ExecutionPlan: plan = ExecutionPlan() # valores predeterminados si parsed.decomposition.pattern == "independiente": plan.decompose_compound = Verdadero si doc_profile.format == "docx": plan.extract_page_numbers = False si parsed.answer_shape == "listado": plan.iterate_on_feedback=Plan de retorno verdadero
Ventaja: reproducible, depurable, la sabiduría acumulada del equipo vive en el código. Desventaja: requiere escribir y mantener las reglas. Cada nuevo patrón de preguntas que no encaja es una regla a agregar.
Enfoque C. LLM-decide-todo (autónomo). El sistema describe las subfunciones disponibles a un LLM y le pide que elija. Ventaja: flexible, maneja casos que el equipo no había planeado. Desventaja: no reproducible (el LLM puede decidir de manera diferente en cada ejecución), costoso (cada pregunta cuesta una llamada de LLM adicional para el enrutamiento), difícil de depurar (el razonamiento está en los pesos del LLM).
La posición de la serie: Método B como predeterminado, Método A como anulación manual, Método C rechazado para la empresa.
Este es el mismo argumento que se repite cada vez que surge el término “RAG agente”. Para los contextos empresariales (legal, de seguros, de servicios financieros), la reproducibilidad, la auditabilidad y el costo acotado son más importantes que cualquier flexibilidad adicional que adquiera el Enfoque C. El método B le ofrece los tres. El método A le permite anular cuando necesita probar una configuración específica.
Esta es también la razón por la que el “RAG agente” funciona mejor que el RAG ingenuo, cuando se hace bien. La parte agente no es mágica. Es que el sistema analiza la pregunta antes de buscar, en lugar de tratar la recuperación como un primer paso mecánico. Una vez que se divide el trabajo entre la preparación para la recuperación y la preparación para la generación, se vuelve mucho más fácil razonar sobre el resto del proceso, sin necesidad de que el LLM esté en el circuito de control.
2.2 La llamada de nivel superior: cinco familias de argumentos
Una vez que el despachador decide las activaciones automáticamente, la llamada pdf_qa(pdf_path, question) del usuario es suficiente para la mayoría de los casos. Pero a veces el usuario quiere anular un comportamiento específico: modificar la recuperación top_k, omitir el enrutamiento TOC en un documento que no tiene un esquema utilizable, inyectar un PromptContext precargado. La llamada de nivel superior tiene que manejar esto sin complicarse.
El patrón que funciona: organizar los argumentos de anulación en cinco familias, cada una con el nombre del ladrillo al que afecta. El pdf_qa actual de docintel.pipeline.qa.pdf incluye ocho kwargs agrupados de esa manera:
def pdf_qa( pdf_path: str | Ruta, pregunta: str, *, # Anulaciones de análisis (Artículo 5/10) método: str = "fitz", # Anulaciones de análisis de preguntas (este artículo) expert_dict: dict[str, list[str]] | Ninguno = Ninguno, # Anulaciones de recuperación (Artículos 7/9) top_k: int = 5, use_toc: bool = Verdadero, # Anulaciones de generación (Artículo 8) include_bbox: bool = False, # Anulaciones de comportamiento de canalización tienda: "Tienda | Ninguno" = Ninguno, cliente: "OpenAI | Ninguno" = Ninguno, contexto: PromptContext Ninguno = Ninguno,) -> AnswerWithEvidence: …
El usuario que no quiere anulaciones simplemente llama a pdf_qa(contract_pdf, "¿Cuál es la prima?"). El usuario que desea desactivar el enrutador LLM TOC en un documento con un contorno roto hace pdf_qa(contract_pdf, "¿Cuál es la prima?", use_toc=False). No se requiere ninguna de las anulaciones; todos tienen valores predeterminados controlados por el despachador. El hermano de documentos cruzados corpus_pdf_qa refleja el mismo patrón con project_id al frente y una tapa top_k_docs.
Lo que viene. La arquitectura tiene espacio para algunas anulaciones, pero el paquete no se envía hoy: un answer_schema=MyCustomSchema para anular el registro por pregunta (Artículo 8 (generación), sección 3.5), retrieval_methods=["keyword", "embedding"] para elegir la pila de métodos en tiempo de ejecución (Artículo 7, recuperación), iterate_on_feedback=True + max_iterations=3 para ejecutar el Reintento de la misma ejecución en respuestas incompletas (Artículo 13 (el proceso de flujo de trabajo) y Artículo 14 (el problema del corpus)). Cada uno amplía una de las cinco familias anteriores sin reorganizar el resto. El artículo mantiene el diseño de familias por ladrillo con precisión, por lo que agregar kwargs más tarde es mecánico.
2.3 El bloque _meta en la salida
La pregunta analizada es interna de pdf_qa. Pero aparecen rastros de ello en el resultado, y eso es importante para el usuario.
El JSON de salida tiene la respuesta (el resultado de la generación) y un bloque _meta que registra lo que se hizo:
{ "answer": "La prima es de 125.000 € al año.", "page_number": 4, "line_start": 12, "line_end": 14, "quote": "Prima anual: 125.000 €", "_meta": { "decomposition": "single", "activations": { "use_toc_navigation": true, "use_keyword_retrieval": true, "use_embeddings": falso, "extract_page_numbers": verdadero }, "omitido":[], "análisis_notas":[], "iterations": 1, "retrieval_methods_used": ["toc", "keyword"], "model": "gpt-4.1", "prompt_versions": {"question_parsing": "v2.4", "generación": "v4.2"} } }
El bloque _meta contiene el patrón de descomposición, qué activaciones estaban activadas o desactivadas, qué se omitió (y por qué, en parsing_notes), cuántas iteraciones atravesó la tubería, qué métodos de recuperación se activaron y las versiones del modelo y del indicador para la reproducibilidad (los mismos campos que lee una evaluación por modo de falla más adelante).
Esto no es opcional. Es lo que hace que el sistema sea auditable. Cuando un usuario cuestiona una respuesta, el bloque _meta es la explicación. Cuando el equipo depura una regresión, el bloque _meta es el seguimiento. Cuando el cumplimiento pregunta "¿por qué el sistema dio esta respuesta?", el bloque _meta es la respuesta.
El usuario que no quiera ver _meta en su interfaz de usuario puede ocultarlo. Pero siempre se genera y siempre se registra, porque hacerlo no cuesta nada y el registro de auditoría es lo que necesitan las implementaciones de producción.
La pregunta analizada también se conserva en el disco, siguiendo la convención que instala el bloque de análisis de documentos: save_parsed_question(pdf_path, question, parsed_question) escribe la ParsedQuestion completa en la salida ///questions//parsed_question.json. El slug combina un prefijo legible de la pregunta con un hash corto para que preguntas casi idénticas nunca choquen. El siguiente bloque (recuperación) lee el mismo archivo. No se debe volver a llamar al LLM para analizar preguntas al iterar en la recuperación o generación en sentido descendente.
3. En la práctica
3.1 parse_question de un extremo a otro
El artículo 6_b recorrió cada problema del analizador como su propio ayudante, cada uno con su propia llamada LLM. Así es como la prosa construye el esquema columna por columna. En producción, una llamada LLM consolidada devuelve toda la fila a la vez. Un viaje de ida y vuelta, un mensaje para mantener, un lugar donde el LLM ve la pregunta completa.
El esquema que llena el LLM:
clase FullParse(BaseModel): """Todo lo que el LLM produce en una sola llamada.""" pregunta_corregida: cadena palabras clave_extraídas: lista[cadena] palabras clave_reescritas: lista[cadena] forma_respuesta: cadena # single | listado | mesa | árbol | nested_json tipo_respuesta: str # FK en respuesta_tipos_df descomposición: Descomposición sugerencias_estructurales: Consejos estructurales chunk_strategy: Literal['combinado','sequential'] = 'combinado' modelo_sugerido: str = 'gpt-4.1-mini' aclaración_sugerida: str | Ninguno = Ninguno desambiguación: str | Ninguno = Ninguno distractores: lista[str] = Campo(default_factory=lista)
El mensaje guía al LLM a través de las subtareas. Cada subtarea en el mensaje corresponde a una columna en FullParse:
def build_parse_prompt( respuesta_tipos_df: pd.DataFrame, respuesta_formas_df: pd.DataFrame, ) -> str: """Las listas de tipos de respuesta y formas de respuestas se inyectan desde los satélites, por lo que agregar un nuevo tipo o una nueva forma es una inserción de una sola fila, sin solicitud de edición.""" type_label = ", ".join(answer_types_df["type"]) Shapes_label = ", ".join(answer_shapes_df["shape"]) return ( "Analizas las preguntas de los usuarios en un objeto estructurado que se consumirá en la recuperación posterior y " "generación. Devuelve JSON que coincide con el esquema FullParse.nn" "Subtareas:n" "1. corrected_question: corregir errores tipográficos. Sin cambios de significado.n" "2. palabras clave_extraídas: 1-3 frases nominales de contenido de la pregunta.n" "3. palabras clave_reescritas: 3-5 frases cortas que coincidan con cómo " "la respuesta probablemente aparecerá en el documento. Vocabulario del documento, no la frase casual del usuario.n" f"4. respuesta_forma: una etiqueta de {{{shapes_label}}}. columnas, 'árbol' para jerarquía anidada, 'nested_json' para un " "objeto estructurado con subcampos con nombre.n" f"5. tipo_respuesta: una etiqueta de {{{types_label}}}. El tipo de valor que " "lleva cada elemento. 'Enumera las primas anuales' es (lista, monto); texto).n" "6. descomposición: patrón (único/independiente/secuencial/unificado/condicional), " "subpreguntas si es compuesto y filtro_condicional si el patrón es condicional.n" "7. sugerencias_estructurales: DÓNDE (sugerencia_sección_toc, sugerencia_páginas, sugerencia_diseño) y " "CUÁNTO (contexto_detección, contexto_respuesta, resumen_necesidades). respuesta_context, " "necesita_summary, fragment_strategy, modelo_sugerido en sus valores predeterminados A MENOS que la " "pregunta misma los contradiga (por ejemplo, 'resumen de una línea de las exclusiones' " "anula el valor predeterminado a nivel de capítulo del concepto de exclusiones; 'compare las cláusulas " "de indemnización en este contrato y la versión anterior' eleva el modelo_sugerido a un " "modelo de nivel de razonamiento como o4-mini).n" "8. aclaración_sugerida: pregunta breve de seguimiento si la entrada es demasiado vaga. " "nulo en caso contrario.n" "9. desambiguación + distractores: patrones 'límites, no deducibles'." ) PARSE_PROMPT = build_parse_prompt(answer_types_df, answer_shapes_df)
El oleoducto. La única llamada LLM realiza el trabajo de análisis. Dos pasos que no son del LLM permanecen separados: palabras clave ancla (expresión regular, determinista, rápida) y la búsqueda en el diccionario experto (filtro pandas, sin modelo).
def parse_question( pregunta: str, *, expert_kw_df: pd.DataFrame | Ninguno = Ninguno, system_prompt: str = PARSE_PROMPT, ) -> ParsedQuestion: resp = client.responses.parse( model="gpt-4.1-mini", input=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": question}, ], text_format=FullParse, ) full = FullParse.model_validate_json(resp.output_text) Anchor_kw = extract_anchor_keywords(full.corrected_question) # regex dict_kw_df = ( lookup_expert_keywords(full.corrected_question, expert_kw_df) si expert_kw_df no es Ninguno más pd.DataFrame() ), fuente="diccionario_experto", grupo_semántico=fila["concepto"]) para _, fila en dict_kw_df.iterrows()] ) devuelve ParsedQuestion( pregunta_original=pregunta, pregunta_corregida=pregunta_corregida_completa, palabras clave=palabras clave, forma_respuesta=forma_respuesta_completa, tipo_respuesta=tipo_respuesta_completa, descomposición=descomposición.completa, estructural_hints=full.structural_hints, chunk_strategy=full.chunk_strategy, sugerido_model=full.suggested_model, sugerido_clarificación=full.suggested_clarification, desambiguación=full.disambiguation, distractors=full.distractors, # filtros_alcance, activaciones, recuperación, generación son completados por el # despachador (sección 4.1) una vez que el perfil del documento es disponible. # parsing_notes se agrega más adelante cuando las activaciones bajan de categoría).
La compensación versus el proceso paso a paso del Artículo 6_b:
Paso a paso (un ayudante por inquietud): fácil de depurar por inquietud, fácil de anular un mensaje sin tocar los demás, fácil de realizar pruebas A/B de submenús individuales. Más de 5 llamadas de LLM por pregunta. Consolidado (una llamada, un esquema): un viaje de ida y vuelta, un mensaje, un contexto modelo. Es más difícil atribuir un error a una subtarea específica. ~5 veces más barato y ~5 veces más rápido.
La serie predeterminada está consolidada para la producción. La canalización paso a paso sigue siendo útil para pruebas y depuración. Cuando un campo parece incorrecto, intercambie el asistente independiente de ese campo, vuelva a ejecutarlo y compare.
La mayoría de las preguntas no llenan todas las columnas. Una búsqueda simple necesita pregunta_corregida + palabras clave + forma_respuesta + tipo_respuesta. Una pregunta de listado compuesta completa la descomposición, estructural_hints.answer_context = "chapter", need_summary = True. Los valores predeterminados del esquema manejan los campos no utilizados, por lo que parse_question siempre devuelve una fila completa.
3.2 Ejemplos sobre el corpus del corredor
Algunos casos concretos del contexto del corredor de seguros que se remontan a las Partes IV y V. Cada ejemplo muestra sólo las columnas que utiliza el caso; el resto del esquema ParsedQuestion (pregunta_corregida, sugerencias_estructurales, recuperación, generación,…) mantiene sus valores predeterminados.
Ejemplo 1. Una búsqueda de puntos con palabras clave expertas.
Pregunta del usuario: "¿Quél est le montant de la prime annuelle?"
ParsedQuestion( original_question="¿Qué es el montaje del primer año?", respuesta_forma="single", respuesta_tipo="cantidad", palabras claves=[ Palabra clave(text="prime", peso=1.0, fuente="direct"), Palabra clave(text="montant", peso=0.8, fuente="direct"), Palabra clave(text="annuelle", peso=0.7, fuente="direct"), Palabra clave(text="premium", peso=0.9, fuente="diccionario_experto", semantic_group="prime"), Palabra clave(text="cotización", peso=0.9, fuente="diccionario_experto", semantic_group="prime"), Palabra clave(text=r"d+[s.,]?d*s*(?:EUR|€)", peso=0.8, fuente="diccionario_experto", is_regex=True), ], descomposición=Descomposición(pattern="single"), activaciones=Plan de ejecución( use_toc_navigation=True, use_keyword_retrieval=True, extract_page_numbers=True, ), parsing_notes=["Pregunta en francés; diccionario experto aplicado."], )
La cantidad de expresión regular en la lista de palabras clave es el patrón de confirmación de tipo del Artículo 6_b (extracción), sección 1.2: la recuperación requerirá una cantidad monetaria en la zona coincidente, no solo la superposición de palabras clave.
Ejemplo 2. Una pregunta compuesta, descomposición independiente.
Pregunta del usuario: “¿Cuál es la prima anual y cuáles son las principales exclusiones?”
ParsedQuestion( original_question="¿Cuál es la prima anual y cuáles son las principales exclusiones?", decomposition=Decomposition( Pattern="independent", sub_questions=[ "¿Cuál es la prima anual?", "¿Cuáles son las principales exclusiones?", ], ), activaciones=ExecutionPlan(decompose_compound=True), parsing_notes=["Pregunta compuesta detectada. Descompuesta en 2 subpreguntas independientes."], )
El orquestador ve decompose_compound=True y ejecuta pdf_qa dos veces en paralelo (una vez por subpregunta), luego ensambla una salida combinada.
Ejemplo 3. Una pregunta ambigua que provoca una aclaración.
Pregunta del usuario: "¿Cuál es el límite?"
ParsedQuestion( original_question="¿Cuál es el límite?", sugerido_clarificación=( "Existen varios tipos de límites en este contrato: límite de cobertura, sublímite, " "deducible, límite agregado. ¿Sobre cuál estás preguntando?"), ambiguity_reason="single_term_with_multiple_referents", parsing_notes=["Pregunta ambigua; se sugiere una aclaración antes de ejecutar el proceso."], )
Ejemplo 4. Una degradación de activación con reconocimiento de documentos.
Pregunta del usuario: “¿Qué dice en la página 3 del contrato?”, el documento es formato Word.
ParsedQuestion( original_question="¿Qué dice en la página 3 del contrato?", activaciones=ExecutionPlan( extract_page_numbers=False, ), parsing_notes=[ "El usuario mencionó 'página 3' pero el documento tiene formato Word. " "Los números de página en Word dependen del renderizador; se trata como ubicación aproximada.", ], )
En estado salvaje: Seis meses de producción en el sistema de intermediario:
Latencia de análisis promedio: 280 ms (una llamada de LLM de nivel medio para descomposición + expansión de palabras clave) Distribución por patrón de descomposición: único 71 %, independiente 19 %, condicional 6 %, secuencial 3 %, unificado 1 % Aclaración activada en el 4 % de las preguntas Entradas del diccionario experto: 340, creciendo entre 5 y 10 por mes Degradaciones de activación con reconocimiento de documentos: el 12 % de las preguntas alcanzan al menos una
Ablación: con el análisis desactivado (las preguntas se tratan como cadenas planas), la precisión cayó del 91 % al 76 %. La brecha de 15 puntos es lo que se compra con el análisis.
3.3 Trampas de implementación comunes
Algunas trampas al implementar el análisis de preguntas en la práctica.
Tratar el análisis de preguntas como simplemente "extraer palabras clave". Las palabras clave son un resultado entre muchos. Los procesos que se detienen en la extracción de palabras clave omiten la descomposición, los filtros de alcance, las restricciones de formato y las decisiones de activación, todo lo cual afecta la calidad en etapas posteriores del proceso.
Almacenamiento en caché de la pregunta analizada en todos los documentos. La pregunta analizada depende del perfil del documento. La misma pregunta analizada para un documento PDF y un documento de Word tendrá diferentes indicadores de activación. La clave de caché debe incluir el perfil del documento, no solo el texto de la pregunta.
Saltarse el diccionario experto porque "las incrustaciones manejarán sinónimos". Manejan sinónimos del diccionario. No manejan acrónimos internos, términos específicos de jurisdicciones ni vocabulario codificado comercialmente que el modelo de integración nunca ha visto. El diccionario experto es algo que el proyecto sigue creciendo.
Descomponerse agresivamente: la sobredescomposición produce respuestas que no se unen. “¿Cuáles son las exclusiones y limitaciones?” está unificado, no independiente, en la mayoría de los contextos políticos. La prueba de desambiguación (reemplazar “y” por “; también”) es el cheque barato; la clasificación LLM es la red de seguridad.
Mezclar restricciones de formato en la consulta de recuperación. “Importe de la prima, formateado como número entero, en EUR” debería generar una consulta de recuperación de “importe de la prima” y un resumen de generación que lleve la restricción de formato. Mezclar el formato en la recuperación contamina la búsqueda.
Configuración manual de todos los indicadores de activación en cada llamada. Esto omite todo el objetivo del despachador. El pdf_qa(pdf_path, question) predeterminado debería producir activaciones sensatas a partir de la pregunta analizada y el perfil del documento. Las anulaciones explícitas son para los casos en los que el equipo sabe más que el valor predeterminado.
Olvidando que la pregunta analizada son datos. La pregunta analizada es el artefacto que lee el resto del proceso. Vale la pena hacerlo inspeccionable, registrable y controlado por versiones. Los sistemas de producción deberían poder mostrar "para la pregunta X, la estructura analizada era Y, y es por eso que el sistema hizo Z".
4. Conclusión
Un resumen analizado es tan útil como la capa de enrutamiento que convierte sus columnas en un comportamiento de canalización. El enrutamiento tiene tres partes: una vista RetrievalQuery que recupera las columnas sobre las que puede actuar (palabras clave, reescrituras, anclajes, filtros de alcance) y nada más; una visión de GenerationBrief que le da a la generación lo que necesita (pregunta original, limitaciones de formato, desambiguación, distractores); y un mapa de activaciones que desactiva bloques específicos cuando el perfil del documento los hace inútiles. El bloque _meta registra cada decisión de enrutamiento, por lo que una pregunta mal enrutada aparece como una diferencia en el registro de auditoría, no como una respuesta misteriosa.
Una canalización que ajusta los modelos de incrustación y los tamaños de los fragmentos, pero enruta la cadena de usuario sin procesar a cada ladrillo, está dejando la mayor parte de su calidad sobre la mesa incluso antes de que comience la recuperación. El paso de envío no agrega un modelo. Dirige a los que ya están allí.
5. Fuentes y lecturas adicionales
Este artículo adopta una posición sobre la elección de la arquitectura detrás del envío de preguntas. La serie utiliza por defecto un despachador determinista (enfoque B en la sección 2.1): reproducible, auditable y de costo acotado. El punto de contraste es la línea agente donde un LLM decide el enrutamiento en tiempo de ejecución, en la que la literatura ha convergido bajo varios nombres. El Volumen 3 (Ladrillos Agentes) desarrolla la alternativa agente sobre el plan estructurado que define este artículo; aquí citamos la posición con la que se contrasta el despachador determinista.
Ángulo diferente, contexto diferente:
Schick et al., Toolformer: Los modelos de lenguaje pueden aprender a usar herramientas por sí mismos, NeurIPS 2023 (arXiv:2302.04761). El modelo decide cuándo y qué herramienta llamar en línea, sin analizar preguntas por adelantado. Lo opuesto al despachador determinista que incluye este artículo: enrutamiento ingresado en el LLM en tiempo de ejecución, no extraído por adelantado en un plan escrito. Yao et al., ReAct: Sinergia del razonamiento y la actuación en modelos lingüísticos, ICLR 2023 (arXiv:2210.03629). El patrón de bucle agente que enruta el tiempo de ejecución entre el razonamiento y las llamadas a herramientas. El mismo compromiso que Toolformer: flexibilidad a costa de reproducibilidad y costo limitado. El Volumen 3 cubre el ámbito de la auditoría que hace que esto sea viable en contextos regulados.
Al principio de la serie:
Parte I:
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. Los rerankers tampoco son mágicos: cuando la capa de codificador cruzado vale la pena. Lo que agrega un codificador cruzado sobre las incorporaciones de codificador doble, medido y cuándo vale la pena la latencia. 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. 10 errores comunes de RAG que seguimos viendo en producción. Diez errores de producción, organizados ladrillo por ladrillo, con la solución para cada uno.
Parte II: