parte del 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. 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. Este artículo explica lo que el analizador extrae de una cadena de usuario: palabras clave, la forma y el tipo de respuesta esperada, sugerencias de alcance, descomposición para preguntas compuestas y el campo de aclaración para entradas demasiado vagas para actuar. El artículo 6c (envío) cubre lo que el analizador decide luego sobre esos campos, utilizando el perfil del documento.
Un usuario escribe una cadena. "¿Cuál es el monto máximo de cobertura? No lo confunda con el deducible, a menudo aparecen juntos". El analizador lo convierte en una fila de columnas escritas: un tema, una forma de respuesta esperada (una cantidad), una pista de alcance (este contrato), una pista negativa (no el deducible) dirigida al resumen de generación y una pista de diseño (a menudo enumeradas juntas) que la recuperación puede usar. Cada pieza se convierte en su propia columna en question_df. Este artículo recorre las cinco familias de campos una a la vez, con el código que llena cada una y el esquema escrito que lo contiene.
1. Las cinco familias de campos que llena el analizador.
Una pregunta es más que sus palabras. También le indica qué forma debe tomar la respuesta, dónde buscar en el documento, si es compuesta o demasiado vaga para actuar en consecuencia. El analizador captura cada uno de estos y los escribe como una columna en question_df. Lea el resto de este artículo como un menú de lo que está disponible, no como una lista de verificación.
Las columnas se dividen en dos grupos.
Lo que el analizador lee de la pregunta misma.
Palabras clave: Tokens para la recuperación de alimentos. Se combinan varias fuentes: explícitas (el usuario las nombró), directas (extraídas de la pregunta), reescrituras de LLM, un diccionario de conceptos expertos y anclajes de expresiones regulares de alta señal como L131-1. Forma de respuesta y tipo de respuesta: Dos ejes ortogonales: la cardinalidad esperada (único, listado, tabla, árbol, nested_json) y el tipo de valor (texto, importe, fecha, iban, dirección,…). Alcance: Dónde buscar en el documento: una página, un capítulo, una sección, un diseño (tabla/imagen), un rango de fechas, una jurisdicción. Descomposición: Subpreguntas cuando la pregunta es compuesta. Aclaración: Una breve pregunta de seguimiento cuando el aporte es demasiado vago para actuar en consecuencia.
Lo que luego decide el analizador (utilizando el perfil del documento además de lo anterior).
Despacho: cuánto contexto circundante leer y devolver, qué estrategia de fragmento usar, qué modelo llamar. Todo en cascada desde el tipo de respuesta, el concepto coincidente y los valores predeterminados del proyecto. Activaciones: qué bloques ejecutar (navegación TOC, incrustaciones, referencias cruzadas,…), degradados según lo que admite el documento.
Cada categoría se convierte en una o más columnas en question_df. Los proyectos eligen lo que necesitan, omiten el resto y agregan nuevas columnas a medida que aparecen modos de falla: un número de póliza para un corredor de seguros, un ID de paciente para RAG médico, un año de regulación para legal. Las subsecciones recorren cada una.
1.1 Palabras clave
La recuperación necesita palabras para buscar en el documento. El analizador los selecciona y los entrega. La redacción del usuario casi nunca coincide con la redacción del documento en el primer intento, por lo que el analizador recopila datos de varias fuentes a la vez.
Aquí está el esquema mínimo que ampliaremos a medida que avance la sección:
clase ParsedQuestion (BaseModel): pregunta_original: palabras clave str: lista [cadena]
Para "¿Cuál es el monto máximo de cobertura?", el analizador produce:
ParsedQuestion( original_question="¿Cuál es el monto máximo de cobertura?", palabras clave=["máximo", "cobertura", "monto"],)
Las palabras clave heredan los errores tipográficos que contenga la pregunta. Extraiga tokens directamente de "¿Cómo se compara la atención de múltiples cabezas con la autoatención?" y búsquedas de recuperación de atención, una cadena que el documento nunca contiene. Cero resultados, el sistema no devuelve nada, el usuario concluye que el tema no está cubierto. La solución es un paso previo económico que se ejecuta antes de la extracción de palabras clave: una llamada de LLM que corrige errores tipográficos y gramaticales sin cambiar el significado, para que las palabras clave salgan limpias.
def correct_spelling(question: str) -> str: """Corregir errores tipográficos y gramaticales sin cambiar el significado.""" inmediato = f"""Corregir cualquier error tipográfico y gramática en la siguiente pregunta. No cambie el significado. No agregue ni elimine información. Devuelva solo la pregunta corregida, nada más. Pregunta: {question}""" resp = client.responses.create(model="gpt-4.1-mini", entrada=prompt) devolver resp.output_text.strip()
En producción, esto se almacena en caché (la misma pregunta escrita por varios usuarios se corrige una vez) y se omite cuando la entrada está limpia.
Permita que los usuarios nombren las palabras clave ellos mismos. Algunos usuarios (analistas, asistentes legales, cualquiera que domine el vocabulario del documento) ya saben exactamente qué términos quieren que coincidan. Una sugerencia de la interfaz de usuario, "Enumere los términos exactos para buscar, separados por comas", abre la ruta de recuperación de mayor precisión que tiene el sistema. Los tokens del usuario van textualmente: peso 1.0, fuente directa, sin LLM, sin expansión de sinónimos (a menos que opten por participar). Para "Busque 'fuerza mayor', 'rescisión', 'caso de incumplimiento' en este contrato", el analizador extrae las tres frases citadas tal como están. Más rápido, más económico y más preciso que cualquier reescritura de LLM cuando el usuario puede nombrar los términos. El lado del producto también es importante: un campo de “términos de búsqueda” al lado del cuadro de preguntas, o una instrucción del sistema (“incluya los términos exactos que desea que coincidan”), mueve una parte mensurable de consultas a este camino.
Cuando el usuario no nombra los términos explícitamente, se completan tres fuentes del lado del analizador: reescrituras de LLM, un diccionario de conceptos expertos y expresiones regulares de anclaje.
La falta de coincidencia de vocabulario es lo primero que se rompe. El usuario pregunta sobre “el tope de lo que pagará la aseguradora” y el documento dice “límite de indemnización por siniestro”. La brecha aparece en todas partes en la empresa:
Seguro: “el límite de lo que pagará el asegurador” → “límite de indemnización por siniestro”. Legal: “qué pasa si salimos anticipadamente” → “disposiciones de terminación anticipada” o “derechos de rescisión”. Finanzas: “cuánto nos devolverán” → “calendario de pago del principal” o “condiciones de reembolso”. Médico: “efectos secundarios” → “eventos adversos” o “contraindicaciones”.
La columna de palabras clave tiene tokens incorrectos; la búsqueda lo pierde todo. Tres fuentes se combinan para llenarlo con los términos que utiliza el documento.
Fuente A: Reescrituras de LLM: reformule la pregunta en 3 a 5 frases que coincidan con la forma en que el documento probablemente exprese la respuesta. El truco consiste en generar el lenguaje que rodea la respuesta, no la respuesta en sí. (Esta es la idea detrás de HyDE, Hypothetical Document Embeddings: generar un pasaje plausible y luego incrustarlo, en lugar de incrustar la pregunta directamente).
# src/question/rewrite.py def rewrite_query(question: str, domain_hint: str = "") -> list[str]: """Reescribe una pregunta de usuario en 3-5 consultas redactadas de la forma en que el pasaje relevante probablemente aparecerá en el documento.""" Prompt = f"""Estás traduciendo una pregunta de usuario en consultas de búsqueda que coinciden con cómo se redactaría la respuesta en un documento {domain_hint o 'professional'}. Volver De 3 a 5 frases alternativas. Utilice el vocabulario que probablemente utilice el documento, no la frase casual del usuario. Genere una frase por línea, sin numeración: {question}""" resp = client.responses.create(model="gpt-4.1-mini", input=prompt) return [line.strip() for line in resp.output_text.splitlines() if line.strip()]
Para "¿qué pasa si salimos temprano?" con domain_hint="contrato comercial", el LLM reescribe la consulta en las cinco frases que probablemente utilice el documento: disposiciones de terminación anticipada, condiciones para salir del acuerdo antes del final del plazo, terminación por conveniencia, tarifas de salida y sanciones por terminación anticipada, derechos de rescisión y requisitos de notificación.
En nuestras implementaciones, una pequeña reescritura basada en domain_hint avanza consistentemente más en la evaluación por modo de falla que en elegir el siguiente modelo de integración en la clasificación. Barato de agregar, fácil de revertir si duele.
Fuente B: el diccionario de expertos, nuestras primeras tablas satélite. Las reescrituras de LLM manejan vocabulario estándar. No manejan “premium” → “prime” (seguro francés), “cotisation” (sociedades mutuas) o “DDPE” (un código de producto de seguro específico), porque el LLM rara vez ha visto estas asignaciones en sus datos de capacitación. Los expertos en dominios conocen los sinónimos significativos en su campo. Los mantienen en dos tablas satélite que juntas forman el diccionario de palabras clave del proyecto. Aquí es donde el sistema amplifica al experto a escala: año tras año, los sinónimos que recopila se acumulan en un activo relacional que el proyecto posee, que puede crecer y auditar en cualquier momento.
El primero contiene los conceptos propiamente dichos: una fila por concepto, con su definición y la familia de documentos a la que pertenece.
El segundo contiene las variantes de las palabras clave: una fila por (concepto, idioma, palabra clave), unida a conceptos_df en la columna del concepto.
La columna de idioma hace que el diccionario funcione con corpus de idiomas mixtos. Pensemos en un grupo asegurador francés cuyos contratos llegan en francés, inglés y a veces español. Sin la columna de idioma, el analizador extraería las variantes incorrectas. La palabra clave_prioridad separa las coincidencias fuertes (primarias) de las más débiles (secundarias); El peso es el compañero numérico utilizado directamente en la partitura léxica. Dividir conceptos y palabras clave de esta manera mantiene los metadatos de cada concepto (definición, tipo de documento) en un solo lugar en lugar de repetirlos en cada fila de palabras clave.
Cómo utiliza el analizador las dos tablas: cuando una palabra clave en la pregunta coincide con una fila en concept_keywords_df, el analizador busca el concepto y extrae todas las variantes (todos los idiomas, todas las prioridades). Luego, la recuperación los busca todos a la vez. Si una palabra clave puede ajustarse a varios conceptos (un número primo podría ser una prima de seguro, una bonificación o un número primario), el analizador le pide al LLM que elija, pasando la columna de definición de conceptos_df para cada candidato:
def disambiguate_concept( pregunta: str, candidatos: pd.DataFrame, *, system_prompt: str = ( "Elija el concepto que mejor se ajuste a la pregunta del usuario. " "Responder solo con el nombre del concepto, sin explicación." ), ) -> str: """`candidatos`: filas de conceptos_df que comparten una palabra clave coincidente.""" opciones = "n".join( f"- {r['concept']}: {r['definición']}" para _, r en candidatos.iterrows() ) user_msg = f"Pregunta: {pregunta}nnCandidatos:n{opciones}" resp = client.responses.create( model="gpt-4.1-mini", input=[{"role": "system", "content": system_prompt}, {"role": "user", "content": user_msg}], ) devuelve resp.output_text.strip()
El LLM elige el que se ajusta a la pregunta del usuario y el resto de la fila se resuelve a partir de ahí.
Las dos tablas crecen con el proyecto: cada recuperación perdida que se remonta a una discrepancia de vocabulario se convierte en una nueva fila.
Las incrustaciones pueden ayudar a descubrir candidatos: incrustar cada frase nominal distinta en el corpus, grupo (HDBSCAN, tamaño de grupo de ~5 minutos), encontrar grupos que se superpongan a las palabras clave existentes de un concepto conocido y dejar que el experto decida qué nuevas variantes admitir. Lo que lee la recuperación es la tabla validada, no las incrustaciones sin formato. El experto valida cada entrada antes de que llegue al diccionario. Eso es lo que hace que el sistema se ajuste al dominio de forma fiable.
Los tutoriales estándar de RAG asumen que la integración de similitud manejará los sinónimos automáticamente. Para algunos dominios lo hace. Para el vocabulario empresarial especializado no es así.
Aquí es donde se muestra la posición editorial de la serie sobre las incrustaciones. El camino habitual es elegir el "mejor" modelo de incrustación midiendo recordar@k en un conjunto de documentos/consultas etiquetadas y luego apoyarse en ese modelo para la coincidencia de sinónimos. Hacemos lo contrario: resolvemos sinónimos con el diccionario validado, mantenemos las incorporaciones como alternativa para los casos que el diccionario aún no cubre y las utilizamos como herramienta de descubrimiento en lugar de como señal de recuperación principal. Los detalles del ladrillo de recuperación son exactamente dónde se asientan las incrustaciones en el embudo.
Cuando el conjunto de valores esté cerrado, enumérelo. Algunos conceptos tienen una lista finita y conocida de valores: nombres de países, códigos de moneda, nombres de estados de EE. UU. más abreviaturas, códigos de productos de seguros, nombres de medicamentos del formulario de la compañía, marcas y modelos de vehículos que vende el corredor. Para estos, el diccionario deja de crecer orgánicamente y se convierte en un inserto masivo de una sola vez: enumera cada valor, cada ortografía común, cada traducción, cada abreviatura, cada variante que aparece en documentos reales.
Tomemos como ejemplo los países. Si un usuario pregunta "¿Cuál es la cobertura en Alemania?", es casi seguro que el documento contenga Alemania, Alemania, Deutschland, DE o DEU como símbolo literal. No hay ningún patrón que detectar, ninguna expresión regular que capture el "campo" en los 195 de ellos. La solución es cargarlos todos (o el subconjunto que usa el corpus) en concept_keywords_df con concept = "país", una fila por (idioma, ortografía). Cualquier coincidencia en la pregunta le indica al analizador que el usuario está preguntando sobre un país y los alcances de recuperación en consecuencia.
El contraste con answer_types_df es marcado. Cantidades, fechas, IBAN y porcentajes comparten patrones estructurales que las expresiones regulares captan. Los nombres de los países no comparten ninguna estructura. Las dos tablas satélite resuelven dos tipos diferentes de problemas: uno para cosas con patrones, el otro para conjuntos cerrados que puedes enumerar de principio a fin.
Fuente C: palabras clave de anclaje: a veces el usuario le proporciona tokens que son demasiado importantes para arriesgarse a perderlos en el promedio de incorporación: códigos de productos internos, referencias regulatorias, números de cláusulas, identificadores.
“¿Se aplica aquí el artículo L131-1 del código de seguros?”
El token "L131-1" es la consulta completa. Si inserta la oración completa, ese token se diluye con "artículo", "código de seguro", "aplicar aquí". Extraiga los tokens de alta señal y enrútelos a un índice léxico: BM25, el algoritmo clásico de puntuación de palabras clave que pondera más los términos raros: junto con la consulta de incrustación.
# src/question/keywords.py import re ANCHOR_PATTERNS = [ r"b[AZ]+d+(?:[-/]d+)*b", # L131-1, ISO-9001, RC-2024 (cualquier número de mayúsculas iniciales) r"b[AZ]{2,}.[AZ]{2,}(?:-d+)?b", # Códigos de estilo NIST: ID.AM, PR.AC-1 r"b[AZ]{3,}b", # GDPR, RCP, SLA r"bd{4,}b", # año, números de identificador ] def extract_anchor_keywords(question: str) -> list[str]: """Extrae tokens de señal alta para recuperación léxica.""" encontrado: list[str] =[]para el patrón en ANCHOR_PATTERNS: found.extend(re.findall(pattern, question)) return list(dict.fromkeys(found)) # de-dup, preservar orden
Las tres fuentes se combinan en el resultado analizado:
clase Palabra clave (BaseModel): texto: str peso: float = 1.0 fuente: Literal["direct", "llm_expansion", "expert_dictionary", "anchor"] semantic_group: str | Ninguno = Ninguno is_regex: bool = False clase ParsedQuestion(BaseModel): pregunta_original: str palabras clave: lista[Palabra clave] # ahora estructurado
Por qué funciona HyDE y por qué las palabras clave explícitas obtienen el mismo beneficio. Un diagnóstico sobre un ejemplo real, en el Artículo 2 (modos de falla de las incrustaciones), sección 3.2, mostró que la consulta cruda “¿cómo cancelo mi póliza” perdió ante un señuelo léxico? La reescritura de HyDE inyectó rescisión, rescisión, notificación por escrito, renovación y el objetivo ganó por un margen de 0,169. El mecanismo detrás de ese efecto es exactamente lo que ya hacen las tablas de satélites anteriores, sin el viaje de ida y vuelta incorporado.
Tres mecanismos se ejecutan al mismo tiempo cuando HyDE funciona:
Expansión de palabras clave y sinónimos: el LLM, al generar una respuesta hipotética, utiliza naturalmente el vocabulario de dominio del que carece la pregunta. "¿Cómo salgo anticipadamente de un contrato?" produce "rescisión anticipada, período de notificación, tarifas de salida, notificación por escrito". Estas son las palabras que contienen los pasajes reales. La incrustación de HyDE los captura, la recuperación encuentra las coincidencias. Coincidencia de registros: la respuesta hipotética adopta el registro del documento (formal, técnico, específico del dominio). El registro conversacional de la pregunta se reemplaza por el registro de la respuesta. La distancia se reduce en el espacio de incrustación. Real, pero más pequeño que el mecanismo 1 en corpus empresariales con un vocabulario acotado. Asociaciones semánticas latentes: el LLM activa asociaciones entrenadas que no son léxicas. La razón más citada en la literatura para la ganancia de HyDE, pero el contribuyente más pequeño en un dominio acotado.
En contextos empresariales (un dominio delimitado: seguros, legal, médico), el mecanismo 1 explica la mayor parte de la ganancia de HyDE. Las palabras clave que produce el LLM en la respuesta hipotética son exactamente las que utiliza el documento. El texto ficticio y el documento comparten el mismo vocabulario. El paso de incrustación captura ese vocabulario compartido, nada más.
Si domina el mecanismo 1, la extracción de las palabras clave directamente captura el mismo beneficio a un costo menor. La incrustación de ida y vuelta desaparece. La recuperación se vuelve auditable (el experto ve qué palabras clave coinciden). Lo que el proyecto construye es un activo permanente (el diccionario en concept_keywords_df) en lugar de regenerar la hipótesis en cada consulta.
Es por eso que la extracción explícita de palabras clave gana a HyDE en los contextos a los que se dirige la serie: dominio limitado, disponibilidad de expertos, se requiere recuperación auditable. En la búsqueda de consumidores en un dominio abierto sin esas restricciones, HyDE mantiene una ventaja porque los mecanismos 2 y 3 pesan más.
En la práctica la comparación es sencilla. HyDE solicita una reescritura, la incrusta, ejecuta coseno, un viaje de ida y vuelta por consulta, sin seguimiento de auditoría. El enfoque anterior realiza una llamada de LLM para extraer las palabras clave una vez, las busca en el diccionario y el registro de auditoría son las filas coincidentes. El diccionario se puede reutilizar en todas las consultas y crece con el proyecto.
1.2 Etiquetar la forma y el tipo de respuesta
Incluso con palabras clave ricas, la recuperación puede devolver pasajes que coincidan con las palabras pero que no contengan una respuesta real. "¿Cuál es la prima anual?" podría coincidir con una frase sobre calidad superior sin cantidad monetaria a la vista. O coincide con un pasaje sobre un primo no relacionado (un bono, un primario). El sistema no tiene forma de distinguir el resultado correcto del incorrecto porque no sabe qué tipo de respuesta está buscando.
La solución es etiquetar cada pregunta en dos ejes independientes: forma_respuesta (cómo se presenta la respuesta: ¿un valor? ¿una lista? ¿una tabla?) y tipo_respuesta (qué contiene cada valor: ¿texto? ¿cantidad? ¿fecha?). "¿Cuál es la prima anual?" es (único, cantidad). “Enumerar las primas anuales por año” es (listado, monto). “Enumerar las exclusiones del contrato” es (listado, texto). El mismo tipo puede viajar bajo cualquier forma y la misma forma puede transportar cualquier tipo. Mantener los dos separados permite que el analizador etiquete cada uno por sí solo.
Tomar cantidades: una pregunta sobre el tipo de cantidad activa un pase de expresiones regulares junto con la búsqueda de palabras clave, buscando tokens numéricos con símbolos de moneda (d[ds.,]*s*(?:EUR|€|USD|$)). "¿Cuál es la prima anual?" ahora coincide con la línea “Prime annuelle: 125 000 – en dos señales a la vez: la palabra clave prime está ahí Y la cantidad de expresiones regulares coincide con 125 000 € en la misma línea. La concordancia de dos señales es mucho más sólida que la palabra clave sola. Por el contrario, si la recuperación encuentra coincidencias de palabras clave pero no hay ninguna cantidad monetaria en ningún lugar de los pasajes candidatos, la respuesta probablemente no esté en el documento; el sistema puede devolver "no se encontró ninguna cantidad" con confianza en lugar de adivinar.
Misma lógica para las fechas: "¿Cuándo comienza la cobertura?" espera una cita. La recuperación escanea patrones de fechas (ISO, configuración regional, escrito) junto con palabras clave como fecha de efecto, comienzo, inicio. Si la zona de palabras clave contiene una fecha limpia y analizable, es casi seguro que esa sea la respuesta; de lo contrario, el campo falta en el contrato.
El eje de tipos está abierto: cualquier cosa para la que pueda nombrar y escribir una expresión regular (o verificación LLM), puede registrar: texto, cantidad, fecha, booleano, correo electrónico, iban, número_póliza, sirena, porcentaje, duración, dirección,… El registro se encuentra en respuesta_tipos_df, una fila por tipo registrado:
La columna retrieval_patterns (mantenida en el DataFrame activo, omitida en la imagen por motivos de ancho) es lo que utiliza la recuperación para confirmar el tipo. La columna output_schema_ref apunta a la generación de clases de Pydantic en; el ladrillo de generación es dueño de ese lado. La columna default_model es el modelo al que recurre el analizador cuando no se aplica ninguna anulación de nivel de concepto: los tipos pequeños (cantidad, fecha, iban) aterrizan en un modelo nano, texto de forma libre en mini. Agregar un nuevo tipo al proyecto es una sola inserción. Todo lo que el analizador no pueda clasificar vuelve a ser texto y omite la confirmación de la expresión regular.
El eje de forma es cerrado y pequeño: cinco valores cubren lo que hemos visto en corpus reales: único (un valor, el valor predeterminado), lista (una enumeración plana), tabla (filas × columnas), árbol (jerarquía anidada), nested_json (un objeto estructurado con subcampos con nombre, por ejemplo, una dirección como {calle, ciudad, código postal}). El registro es tan pequeño que vive en un satélite hermano con dos columnas de valores predeterminados:
Los hechos individuales casi siempre se encuentran en una línea en el fragmento mejor clasificado, por lo que la secuencial guarda ⅔ de los tokens en k=3; Los listados, tablas y árboles deben sintetizarse en todos los pasajes, por lo que combinarlos es el valor predeterminado más seguro. La división es lo que permite "¿Cuál es la prima anual?" (una pregunta (única, monto)) y “Enumerar las primas anuales por año” (una pregunta (listado, monto)) comparten el mismo tipo (monto, misma expresión regular, mismo análisis de valor) y se enrutan de manera diferente en el momento de la generación.
clase ParsedQuestion(BaseModel): pregunta_original: palabras clave str: lista[Palabra clave] forma_respuesta: Literal["single", "listing", "table", "tree", "nested_json"] = "single" tipo_respuesta: str = "text" # FK into answer_types_df
La etiqueta es una propiedad de la pregunta, no del documento. "¿Cuál es la prima?" Es una pregunta (única, cantidad) si el contrato tiene dos páginas o doscientas. Los dos campos son independientes: “Listar las exclusiones” es (listado, texto), “Listar las primas anuales del contrato” es (listado, importe).
La forma es una enumeración cerrada (cinco valores, fijos para el proyecto); el tipo está abierto (una fila por tipo registrado en respuesta_tipos_df). La clasificación en sí se integra en la llamada parse_question consolidada cubierta en el Artículo 6_c (despacho), y ambos registros se inyectan en el indicador de LLM, por lo que agregar un nuevo tipo o una nueva forma es una inserción de fila, no un cambio de código.
1.3 Alcance: dónde buscar en el documento
Una pregunta a menudo indica en qué parte del documento buscar. El analizador captura estas sugerencias en dos campos escritos, ambos aplicados antes de que se ejecute la recuperación de palabras clave. StructuralHints contiene las sugerencias estructurales (página, sección TOC, diseño). ScopeFilters contiene los filtros a nivel de corpus: la capa de aplicación puede pasarlos (cuando conoce la jurisdicción del usuario, rango de fechas, etc.), o el analizador puede eliminarlos de la pregunta cuando nombra uno explícitamente.
Páginas: “Muéstrame la página 3”, “Resumir las páginas 5 a 7”, “Compara la página 2 y la página 9”. La página única, el rango y la lista explícita se contraen en una lista plana de números enteros. sugerencias_paginas =[3], sugerencias_páginas = [5, 6, 7], sugerencias_páginas = [2, 9]. 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. Capítulo/sección: “¿Cuáles son las exclusiones de este contrato?”. Nombra una entrada TOC. toc_section_hint = "Exclusiones". La recuperación coincide con el TOC real del documento. Diseño: “la tabla de horarios al final”. La respuesta se encuentra en una tabla, una imagen o un encabezado. layout_hint = "tabla". Le indica a la recuperación que observe zonas estructuradas, no texto narrativo. Rango de fechas/partes/jurisdicción: “¿Qué firmamos con Acme entre 2022 y 2024?”. Filtros a nivel de corpus que recortan los documentos candidatos antes de ejecutar la recuperación (manejados por un índice a nivel de corpus antes de la canalización con alcance de documento). ScopeFilters(date_range=…, fiestas=["Acme"]).
La misma convención se extiende a otros formatos. hojas_hint: list[str] lleva los nombres de las hojas de Excel fijados en la pregunta (“en la hoja de Precios”); slides_hint: list[int] contiene los números de las diapositivas de PowerPoint (“en la diapositiva 4”, “diapositivas 7 a 9”). El volumen 2 recoge ambos. La idea unificadora es que la redacción del usuario controla el alcance. Una consecuencia que vale la pena señalar: en documentos breves (CV, factura de una sola página, memorando de 1 a 2 páginas), el operador fija la única página (página 1) dentro de la pregunta y el proceso se ejecuta sin cambios. Sin modo de “documentación corta”, sin cambio de estrategia de fragmentos, sin omisión de recuperación. La misma ruta de código que maneja una consulta de corpus de 1000 páginas tiene como alcance un documento de una página. El artículo 8 (generación) desarrolla esta convención desde el lado del despachador.
clase ScopeFilters(BaseModel): secciones: lista[cadena] = Campo(default_factory=lista) rango_fechas: tupla[cadena, cadena] | Ninguno = Ninguna parte: lista[str] = Campo(default_factory=list) jurisdicciones: lista[str] = Campo(default_factory=list) page_range: tuple[int, int] | Ninguno = Ninguno personalizado: dict = Field(default_factory=dict) class StructuralHints(BaseModel): # DONDE vive la respuesta toc_section_hint: str | Ninguno = Ninguno # La sección o capítulo probable del TOC ("Exclusiones", "Anexo A"). # La recuperación coincide con el TOC real del documento. sugerencias_páginas: lista[int] | Ninguno = Ninguno # Páginas fijadas por la pregunta. Sencillo ("página 3" ->[3]), # rango ("páginas 5 a 7" -> [5, 6, 7]) o lista ("páginas 2 y 9" # -> [2, 9]) todos colapsan en una lista plana en el momento del análisis. hojas_hint: lista[cadena] | Ninguno = Ninguno # XLSX, Volumen 2 slides_hint: lista[int] | Ninguno = Ninguno # PPTX, Volumen 2 layout_hint: Literal["texto", "tabla", "imagen", "encabezado"] | Ninguno = Ninguno versión_documento: str | Ninguno = Ninguno # CUÁNTO contexto leer y devolver (la recuperación los consume) detect_context: Literal["line", "sentence", "paragraph"] = "line" # Granularidad de la zona de confirmación de expresiones regulares (sección 2.2). # "línea" para monto/fecha, "párrafo" para descripción. respuesta_context: Literal["línea", "párrafo", "página", "sección", "capítulo", "documento"] = "párrafo" # Cuánto texto circundante recibe el generador. need_summary: bool = False # Verdadero cuando la respuesta abarca más de lo que cabe en una cita textual.
chunk_strategy y sugerido_model también son decisiones de envío por pregunta, pero no son estructurales (no describen el documento, describen cómo la canalización llama al LLM). Viven en el nivel superior de ParsedQuestion, no en StructuralHints, y el compañero de despacho (Artículo 6_c) recorre la cascada que los llena. Lo mismo ocurre con tres campos mantenidos en StructuralHints (detection_context, respuesta_context, need_summary), que describen cuánto texto pasa la expresión regular y lee el generador, no dónde reside la respuesta. El artículo 6_c también cubre sus incumplimientos.
Algunos ejemplos de lo que produce el analizador:
La detección es una llamada de LLM con salida estructurada de Pydantic. Regex era tentador (capture "página 3" con r"páginas+(d+)") y funciona para un puñado de casos triviales, pero falla en el resto: "en la sección de garantía" (modificador antes del sustantivo), "la tabla de resumen al final" (sin número), "el capítulo sobre responsabilidad" (sinónimo), "el apéndice" (sin palabra clave). El LLM los cubre todos en un solo viaje de ida y vuelta, devuelve Pydantic limpio y se mantiene mantenible.
# src/question/hints.py HINTS_PROMPT = ( "Lea la pregunta del usuario y extraiga sugerencias estructurales sobre DÓNDE " "se encuentra la respuesta en el documento Y CUÁNTO contexto necesita la respuesta.nn" "- toc_section_hint: la sección o capítulo que el usuario señaló, comparada con " "entradas típicas de TOC del documento (por ejemplo, 'Exclusiones', 'Límites', 'Programa A'). null si " "no hay sección está implícito.n" "- páginas_hint: lista plana de números de página que el usuario fijó Único ('página 3' ->[3]), rango ('páginas 5 a 7' -> [5, 6, 7]) y lista ('páginas 2 y 9' -> [2, 9]) se contraen en una lista. null en caso contrario.n" "- layout_hint: 'tabla' / 'imagen' / 'encabezado' si la pregunta implica un diseño.n" "- detect_context: granularidad de la zona de confirmación de expresiones regulares. 'línea' para un " "hecho único, 'oración' para prosa corta, 'párrafo' para narrativa.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.n" "- need_summary: Verdadero si la respuesta abarca más de lo que cabe en una cita textual." ) def extract_hints(question: str, *, system_prompt: str = HINTS_PROMPT) -> StructuralHints: resp = client.responses.parse( model="gpt-4.1-mini", input=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": question}, ], text_format=StructuralHints, ) return StructuralHints.model_validate_json(resp.output_text)
Las sugerencias de diseño importan más de lo que parecen. Si el usuario dice "normalmente está en una imagen", es una gran pista. La mayoría de los analizadores eliminan imágenes o las reemplazan con marcadores de posición. Saber que la respuesta se encuentra en una imagen le indica que debe mirar el resultado de OCR de las figuras o marcar la pregunta para el procesamiento de visión y lenguaje. Sin la pista, la canalización busca solo texto y no encuentra nada.
¿Una llamada de LLM por inquietud o una llamada de LLM en total? El artículo muestra cada inquietud por separado para que pueda comprender (y probar) cada pieza por separado. Así es también como construirías la canalización: un ayudante a la vez, validando cada columna antes de agregar la siguiente. En producción, una vez que esté seguro de que el esquema es correcto, combine todo en una llamada consolidada: un viaje de ida y vuelta, un mensaje, un lugar donde el LLM tiene contexto completo. El artículo 6_c (despacho), sección 3.1, muestra que parse_question se consolida de principio a fin.
A escala de un solo documento, los filtros de alcance restringen en qué parte del documento se busca la recuperación. A escala de corpus (Parte IV), se convierten en cláusulas SQL en el índice del corpus. Misma idea, diferente maquinaria.
1.4 Preguntas compuestas
Algunas preguntas no se pueden responder recuperando un solo pasaje, sin importar qué tan bien formule la consulta. Contienen varias subpreguntas agrupadas en una.
“¿Son consistentes los límites de indemnización y responsabilidad en este contrato?”
Una comparación. Recupere la cláusula de indemnización, recupere la cláusula de límite de responsabilidad y luego compárelas.
“¿Este contrato incluye una cláusula de no competencia y, de ser así, por cuánto tiempo?”
Una pregunta condicional. Paso uno: ¿existe una no competencia? Paso dos (solo en caso afirmativo): ¿cuál es la duración?
“¿Cuál es la prima anual y cuáles son las principales exclusiones?”
Dos hechos no relacionados unidos por “y”. Diferentes pasajes, respuestas independientes.
Cuatro patrones surgen con suficiente frecuencia como para nombrarlos.
Independiente: Dos hechos no relacionados unidos por “y”: “¿Cuál es la prima y cuáles son las exclusiones?” El orquestador ejecuta pdf_qa dos veces en paralelo; la fusión es solo {"sub_questions": [{"q": …, "answer": …}, …]} codificada por la subpregunta analizada.
Secuencial: La segunda parte depende de la primera: “¿Quién es el asegurado y cuál es su domicilio?” La dirección es la del asegurado: primero hay que identificarlo y luego buscar su dirección. El orquestador ejecuta las subpreguntas en orden y sustituye la respuesta anterior por las palabras clave de la siguiente subpregunta.
Unificado: Dos términos que hacen referencia al mismo concepto, no dos preguntas: “¿Cuáles son las exclusiones y limitaciones?” En la mayoría de los documentos de pólizas, las exclusiones y limitaciones aparecen juntas en la misma sección. Descomponer esto en dos subpreguntas duplica el trabajo. Manténgala como una sola pregunta con ambos términos potenciados en la recuperación de palabras clave.
Condicional: Una condición limita el alcance: "Si la póliza es para propiedad comercial, ¿cuál es el límite de cobertura contra incendios?" La condición se convierte en un filtro de alcance; la pregunta real es "¿cuál es el límite de cobertura de incendios?", se ejecuta en el subconjunto del documento que coincide.
Una regla general económica para elegir el patrón es la prueba "y": reemplace "y" por "; también". Si aún se lee con naturalidad, las partes son independientes. Si se lee de forma incómoda, están unificados. Para los casos más difíciles, un LLM clasifica. has_compound_indicators es el prefiltro barato (una expresión regular que busca bandb, borb, ?…?, imperativos múltiples); llm_classify_decomposition es una llamada de salida estructurada que devuelve una descomposición:
Descomposición de clases (Modelo base): patrón: Literal ['único', 'independiente', 'secuencial', 'unificado', 'condicional'] = 'único' sub_preguntas: lista[cadena] = Campo(default_factory=lista) filtro_condicional: dict | Ninguno = Ninguno def decompose(pregunta: str) -> Descomposición: si no has_compound_indicators(pregunta): return Descomposición(pattern='single') return llm_classify_decomposition(pregunta)
La descomposición agrega latencia y costo. Detectar primero la estructura compuesta; sólo se descomponen cuando el patrón lo amerita.
En una implementación de producción, aproximadamente el 30 % de las preguntas de los usuarios en el primer mes de la versión beta eran compuestas y el proceso arrojaba respuestas incompletas en la mayoría de ellas. Agregar descomposición compuesta en la capa de análisis aumentó drásticamente la satisfacción del usuario sin ningún otro cambio en el proceso.
1.5 Aclaración: cuando el sistema vuelve a preguntar
Algunas preguntas son demasiado vagas para actuar en absoluto y ningún análisis las hace viables.
“¿Cuál es el límite?” ¿Gorra sobre qué? ¿Responsabilidad? ¿Indemnización? ¿Daños y perjuicios? ¿De primera calidad? "Muéstrame la última versión". ¿Última versión de qué documento? ¿Último a partir de cuándo? "Compárelo con el del año pasado". ¿El año pasado qué?
Si el sistema se adelanta y adivina, produce respuestas sutilmente incorrectas que los usuarios no captan (peor) o captan y dejan de confiar en el sistema (también peor). La solución es la más barata posible: detectar que no se puede actuar sobre la pregunta y preguntarle al usuario que regrese en lugar de ejecutar el proceso.
La pregunta analizada lleva este caso en dos campos:
clase ParsedQuestion(BaseModel): # … otros campos sugerencia_clarificación: str | Ninguno = Ninguno motivo_ambigüedad: str | Ninguno = Ninguno
Cuando se establece la aclaración sugerida, el orquestador la devuelve antes de ejecutar la canalización:
"Varios aspectos de esta pregunta son ambiguos. ¿Podría especificar qué límite (cobertura, deducible, sublímite) y qué póliza si tiene varias?"
Una regla simple: si la pregunta usa una palabra que apunta a otra cosa (esto, aquello, lo último, el año pasado, el límite) y el contexto no está disponible en el historial o el alcance de la conversación, pregunte. La detección se ejecuta dentro de la llamada consolidada parse_question cubierta en el complemento de envío (Artículo 6_c), como una subtarea del mensaje de análisis compartido: "devolver una breve pregunta de seguimiento si la entrada es demasiado vaga, nula en caso contrario". La interfaz debería hacer que las respuestas sean económicas: un breve seguimiento, dos o tres opciones sugeridas, listo.
Pocos sistemas de producción hacen esto. El valor predeterminado tiende a ser: avanzar y responder algo. El resultado es un goteo constante de respuestas sutilmente incorrectas y usuarios que poco a poco pierden la confianza en el sistema. Detectar la ambigüedad en el momento del análisis es más barato que detectarla después de que la recuperación haya devuelto los pasajes equivocados.
2. Conclusión
El análisis de preguntas convierte una cadena de usuario ruidosa en un resumen relacional escrito. Cinco familias de columnas en question_df contienen lo que el analizador lee directamente de la pregunta del usuario:
Las palabras clave (con expansión del diccionario de expertos) anclan la búsqueda de recuperación. La forma y el tipo de respuesta le dicen a la generación qué esquema devolver. Las sugerencias de alcance filtran en qué parte del documento buscar. La descomposición compuesta divide la pregunta en subpreguntas cuando es necesario. La aclaración le pide al usuario que responda cuando la pregunta es demasiado ambigua.
Cada columna es un lugar donde se encuentran el vocabulario experto del proyecto, la estructura del documento y la intención del usuario. Agregar una capacidad de análisis significa agregar una columna, no una nueva función.
Dos familias más se ubican encima de estas, decididas por el analizador después de ver el perfil del documento: las decisiones de envío (estrategia de fragmentos, modelo, ventana de contexto de respuesta) y las banderas de activación que desactivan los ladrillos cuando no encajan. Ambos pertenecen al compañero de despacho (artículo 6_c), que también cubre el recorrido de cada columna hasta el ladrillo que la consume.
Fuentes y lecturas adicionales
El artículo replantea la técnica HyDE de Gao et al. (HyDE, ACL 2023) como trabajo de palabras clave fuera de línea: la pieza que soporta la carga son las palabras clave que contiene la respuesta hipotética, no el paso de incrustación en sí. La línea de reescritura de consultas (Ma et al., RRR, EMNLP 2023) es el precedente publicado más cercano para la parte de pregunta_corregida + reescritura del plan estructurado. Los cuatro patrones de preguntas compuestas se asignan a Self-Ask (Press et al., Self-Ask, EMNLP Findings 2023) más IRCoT (Trivedi et al., IRCoT, ACL 2023). El linaje más cercano a “la pregunta se convierte en un objeto escrito que consume el código posterior” es el análisis semántico de texto a SQL (Yu et al., Spider, EMNLP 2018). El Volumen 3 (Agentic Bricks) vuelve a la selección de herramientas en tiempo de ejecución además del plan estructurado definido aquí.
Misma dirección que el artículo:
Gao, Ma, Lin, Callan, Recuperación densa precisa de disparo cero sin etiquetas de relevancia (HyDE), ACL 2023 (arXiv:2212.10496). La técnica HyDE que el artículo reformula: trabajo de palabras clave fuera de línea, no incrustación de documentos hipotéticos en línea. Ma, Gong, He, Zhao, Duan, Reescritura de consultas para modelos de lenguaje grande con recuperación aumentada (RRR), EMNLP 2023 (arXiv:2305.14283). Precedente publicado más cercano para la pregunta_corregida + reescribe parte del plan estructurado. Press et al., Medición y reducción de la brecha de composicionalidad en modelos lingüísticos (Self-Ask), Hallazgos de EMNLP 2023 (arXiv:2210.03350). Patrón de descomposición de preguntas compuestas que amplían los cuatro patrones de este artículo. Trivedi et al., Intercalación de recuperación con razonamiento en cadena de pensamiento para preguntas de varios pasos con uso intensivo de conocimiento (IRCoT), ACL 2023 (arXiv:2212.10509). Subpreguntas secuenciales; complementa Self-Ask para consultas compuestas. Yu et al., Spider: un conjunto de datos etiquetados por humanos a gran escala para tareas de texto a SQL y análisis semántico complejo y entre dominios, EMNLP 2018 (arXiv:1809.08887). Prueba comparativa canónica de texto a SQL; el linaje más cercano a "la pregunta se convierte en un objeto escrito que consume el código posterior".
Ángulo diferente, contexto diferente:
Wang et al., Query2doc: Expansión de consultas con modelos de lenguaje grandes, EMNLP 2023 (arXiv:2303.07678). Una única expansión de la consulta del usuario generada por LLM es suficiente para mejorar la recuperación. El contexto es control de calidad abierto en el dominio; Este artículo trata corpus empresariales donde un plan estructurado completo (pregunta corregida + sugerencias sugeridas + forma de respuesta + resumen de generación) se gana la vida. 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. El Volumen 3 (Ladrillos Agentes) desarrolla esta línea sobre el plan estructurado aquí definido.
Al principio de la serie:
Document Intelligence: introducción a la serie. Qué construye la serie, ladrillo a ladrillo y en qué orden. 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. 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: la forma relacional que RAG necesita. La segunda mitad del bloque de análisis: las tablas relacionales que lee cada bloque posterior.