ladrillo de Enterprise Document Intelligence, una serie que construye un sistema RAG empresarial a partir de cuatro ladrillos: análisis, análisis de preguntas, recuperación y generación. El análisis es lo primero y ésta es la segunda de sus dos partes. La parte anterior convirtió un PDF en line_df, una fila por línea de texto en la página. Éste cubre el resto del modelo: el conjunto completo de tablas que debe emitir un analizador, qué contiene cada una y cómo se vinculan entre sí, de modo que la tabla de la página 14 mantenga sus columnas y la tarifa de renovación permanezca adjunta a su etiqueta. Los otros tres ladrillos y la respuesta resaltada al final leen estas tablas, nunca el PDF sin formato.
Los tutoriales de RAG comienzan de la misma manera: text = extract_text(pdf). Esa única línea es donde comienzan los problemas del PDF.
Construyes un oleoducto RAG. Funciona en algunos documentos limpios. Luego, un cliente le envía un contrato real: 30 páginas, con una tabla de Lista de cargos en la página 14. El usuario pregunta "¿cuál es la tarifa de renovación?" y el modelo devuelve el número incorrecto.
El equipo dice: "el modelo no puede leer tablas".
El modelo lee bien las tablas. El problema está aguas arriba. Su analizador recorrió la tabla celda por celda y las unió en una larga cadena. La estructura de columnas ha desaparecido. El vínculo entre una etiqueta y su importe ha desaparecido. Se le pide a su modelo que adivine qué número es la tarifa de renovación. A veces acierta. A menudo no es así.
El analizador no falló. Te dio lo que pediste. Pediste algo equivocado.
Un buen analizador de PDF no extrae texto. Modela el documento como un conjunto relacional de tablas. Un PDF de entrada, una tabla por tipo de elemento (siete u ocho hoy, y más a medida que surjan nuevas necesidades).
toc_df: las secciones, tal como las escribió el autor. page_df y line_df: el cuerpo. Cada página. Cada línea. image_df: cada figura en cada página. span_df: negrita, cursiva, color, tamaño de fuente. Cada tramo de cada línea. object_registry: cada título de figura, cada título de tabla, cada anexo. cross_ref_df: cada “ver Figura 2”, cada “ver Tabla 4”, cada “ver Anexo B”. parsing_summary: le indica si el PDF nace digital, escaneado o mixto. Le indica si el OCR es bueno o malo.
La recuperación lee estas tablas. Generación lee estas tablas. Destacando lee estas tablas. Abres el PDF una vez. Después de eso, solo trabajarás con tablas.
Este artículo cubre cada tabla en detalle y luego ejecuta parse_pdf uno al lado del otro en dos archivos PDF muy diferentes para mostrar que las mismas columnas cubren ambos. El artículo anterior (“Más allá de extract_text: las dos capas de un PDF que impulsan la calidad RAG”) cubre el lado ascendente: las señales declaradas que el analizador lee primero y la clasificación a nivel de página que ejecuta antes de que cualquier línea obtenga un número.
1. Una mesa por entidad
Todo lo que hemos extraído se devuelve como un diccionario de tablas más un resumen de análisis, una tabla por entidad del modelo de documento.
La convención de nomenclatura _df hace que la granularidad sea legible desde el nombre mismo. El diagrama en la parte superior de este artículo muestra cómo se produce cada tabla. Cuatro provienen directamente del análisis: line_df (las líneas de texto), parsing_summary (la síntesis a nivel de documento), toc_df (el esquema nativo, a través de doc.get_toc) e image_df (a través de page.get_image_info). Los otros cuatro se derivan de line_df: page_df lo agrega por página, mientras que span_df, object_registry y cross_ref_df se extraen de sus líneas. Cómo se unen las tablas entre sí es una cuestión aparte, que se aborda en la sección 2.
1.1. toc_df: tabla de contenidos
Los TOC están en todas partes en los documentos empresariales. Contratos, informes, políticas, manuales de empleados, documentos reglamentarios: casi todos se envían con una estructura de secciones declarada, y esa estructura es la señal semántica más barata que se le puede dar a un perro perdiguero.
El problema: no siempre es nativo. A veces es solo tipográfico (títulos en negrita, secciones numeradas, subtítulos con sangría) y debe reconstruirse a partir de line_df + span_df.
Nos centramos aquí en el caso nativo (el común para las exportaciones digitales de LaTeX, Word e InDesign); reconstruir un TOC a partir de tipografía cuando no hay marcadores es un tema en sí, esbozado por un analizador adaptativo y tratado en su totalidad en un seguimiento dedicado.
Cómo compilarlo: build_toc_df(doc) llama a doc.get_toc(simple=False) (una entrada por marcador, con el dictado de destino adjunto) y recorre el resultado para calcular parent_idx, Breadcrumb, end_page y start_y. Si ejecuta el documento Atención, obtendrá las 22 entradas que ya se muestran en la sección 1.2 anterior: tres niveles de títulos, marcadores nativos, no se necesita reconstrucción.
La convención implícita end_page: las TOC marcan dónde comienzan las secciones, casi nunca dónde terminan. build_toc_df materializa el final como una columna de todos modos: para cada fila, end_page es la página de inicio de la siguiente entrada en el mismo nivel o menos profundo (el siguiente par o ancestro), con total_pages como respaldo para la última sección. Mire la conclusión del documento de atención: página_inicial=10, página_final=15. El documento sólo tiene 15 páginas, por lo que la última sección absorbe todo hasta el final del documento. La convención mantiene una superposición de una página por diseño (la página_final de una sección es la página_inicio de su sucesora, no la página_inicio_sucesora – 1), lo que hace que el vistazo a la página siguiente del bloque de generación (una fuerte señal de integridad que detecta listas truncadas en los límites de la sección) sea una búsqueda única en lugar de un escaneo en tiempo de ejecución.
La columna start_y, para obtener información: cada marcador en un esquema PDF lleva un punto de destino (x, y) en su página de destino, no solo un número de página. build_toc_df expone y como start_y (valor bruto devuelto por fitz). Fija cada encabezado de sección en una posición precisa dentro de start_page, que es lo que permite la resolución a nivel de línea: la misma (target_page, target_y) → unión de línea utilizada para los enlaces nativos en la sección 1.6. Misma advertencia sobre la orientación de las coordenadas: 720 en el documento Atención (LaTeX, de abajo hacia arriba) y 72 en NIST CSF (Acrobat, de arriba hacia abajo), ambos apuntan a la parte superior de la página, justo desde orígenes opuestos. Almacenamos el valor bruto; las personas que llaman se normalizan cuando necesitan aterrizar en una línea específica.
start_page y end_page son anclajes a nivel de página. Los anclajes a nivel de línea (start_line, end_line) son el refinamiento natural: permiten que las etapas posteriores identifiquen una sección con la línea exacta en line_df, y habilitan la detección de desplazamiento del TOC cuando el documento tiene una portada insertada después de que se generó el TOC (el TOC completo se desplaza en 1 o 2 páginas, un modo de falla del mundo real). El tratamiento completo se encuentra en un artículo adicional dedicado al anclaje y validación de TOC; Por ahora, toc_df se detiene en la granularidad a nivel de página (con start_y como columna adicional para las personas que llaman listas para resolver en una línea).
El rol: toc_df es la señal semántica más barata de todo el proceso. Cada entrada nombra una sección: saber que las líneas 100 a 150 pertenecen a "3.5 Codificación posicional" le dice al recuperador y al LLM de qué tratan esas líneas, antes de que se calcule cualquier incrustación. Las incrustaciones le brindan proximidad actual; el TOC le brinda el significado estructural propio del documento de cada región, declarado por el autor, no inferido. La ruta de navegación extiende esto con un contexto jerárquico: un fragmento se estampa con “Métodos > 3.5 Codificación posicional”, lo que le da al modelo de lenguaje una base a nivel de sección sin inflar el texto del fragmento. end_page es lo que permite que el bloque de generación mire una página más allá de una sección recuperada y detecte respuestas truncadas sin un pase de visión. Cuando el documento tiene un TOC nativo, todo esto es gratis.
Cuidado: las entradas de TOC pueden apuntar a páginas que no existen (una exportación corrupta o truncada). Valide 0 <= núm_página < n_páginas antes de grabar una fila, o un ancla de sección no llega a ninguna parte y la unión del rango de páginas de la sección 2 regresa silenciosamente vacía.
1.2. line_df: granularidad de línea
La fuente de la verdad para el contenido del texto. Cada línea del PDF, con su posición y estilo tipográfico dominante.
Cómo construirlo: fitz_pdf_to_line_df(pdf_path) recorre cada bloque de texto de cada página y emite una fila por línea. asignar_column_positions(line_df) luego anota cada fila con single/left/right/multi. Ejecute en data/paper/1706.03762v7.pdf, 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). Aquí está la página 4 del artículo (la región de la Figura 2 de dos columnas):
El rol: line_df es el manifiesto unificado por elemento del documento. Primero las líneas de texto, pero la misma estructura de filas también incluye marcadores de posición de imágenes y marcadores de posición de tablas: cada elemento de contenido visible en una página es una fila, con su propio bbox, columna_posición y un indicador de tipo de contenido (texto, imagen, tabla). Los campos específicos de texto (fuente, render_mode) son NaN para filas que no son de texto; los metadatos enriquecidos de la imagen y la tabla residen en image_df y la salida del extractor de tablas, unidos nuevamente a través de (page_num, line_num). El resultado es que una única consulta ordenada en line_df.page_num devuelve todos los elementos de una página en orden de lectura, independientemente de su tipo. Las etapas posteriores no tienen que unirse a tres tablas para saber qué hay en esta página.
Cuidado: en archivos PDF de varios GB o de mil páginas, mantener cada línea (e imagen) en la memoria a la vez es un problema. Un modo liviano que omite line_df e image_df para puntos finales que solo necesitan parsing_summary (clasificación, el resumen a nivel de documento) los mantiene económicos; controle el análisis completo en el momento de la ingestión para el resto.
La siguiente captura de pantalla es de Enterprise Document Intelligence, la aplicación de escritorio que estoy creando. El panel Texto de la derecha es line_df visible: el texto nativo de la página, línea por línea, analizado una vez y leído directamente desde la tabla, junto a la página original de donde proviene.
1.3. page_df: granularidad de la página
Síntesis por página. Clasificación, banderas, métricas agregadas.
Cómo construirlo: build_page_df(line_df) agrupa line_df por page_num. detect_columns_per_page(line_df) calcula n_columns y el resultado se fusiona.
Qué más cabe aquí: build_page_df es el hogar adecuado para cualquier señal por página que pueda agregar desde line_df en la misma pasada. Más allá del triplete principal, agregaciones simples llegan aquí de forma gratuita: n_lines (densidad de página), nativos_chars versus ocr_chars (un veredicto rápido escaneado o nativo, no se necesita clasificador), n_fonts y tamaño de fuente (un indicador de estructura aproximada que separa las páginas con muchos encabezados de la prosa simple), image_coverage_ratio (una unión con image_df). Las columnas que necesitan una espera de paso descendente: page_type es producida por classify_page (tratada en el artículo anterior) y parsing_method / context_structured son producidas por una cascada adaptativa que escala a un analizador más pesado cuando fitz no es suficiente.
Ejecute en el papel de Atención:
El rol: page_df es donde se ancla la extracción. Cada analizador, cada ejecución de OCR, cada clasificador opera página por página; page_df es la tabla que registra qué es cada página y cómo se debe manejar. La página también es una buena unidad semántica por sí sola: aproximadamente una o dos ideas por página en artículos académicos, una cláusula por página en contratos, un subtema por página en informes técnicos. Lo suficientemente pequeño como para centrarse, lo suficientemente grande como para contener el contexto. Es por eso que la recuperación generalmente se realiza de manera predeterminada en fragmentos a nivel de página en una canalización RAG mínima y por qué la mayoría de las claves de coordinación posteriores están fuera de page_num. Cuando consulta “de qué trata la página 5”, page_df es la fila que responde; cuando consulta "todas las páginas escaneadas con OCR incorrecto", page_df es lo que filtra.
Cuidado: almacene page_width y page_height por fila, nunca una vez por documento. En las publicaciones técnicas se mezclan carta y A4, y a menudo se inserta una página apaisada para una mesa ancha; un tamaño de página único a nivel de documento hace que todas las métricas derivadas de bbox (detección de columnas, cobertura de imágenes de página completa) se desvíen en las páginas de tamaño impar.
1.4. image_df: granularidad de la imagen
Una fila por imagen incrustada.
Cómo construirlo: el analizador recorre cada página y llama a page.get_image_info(), que devuelve cada imagen incrustada con su cuadro delimitador mostrado y sus dimensiones intrínsecas. El documento de Atención tiene tres:
Describir el contenido de la imagen: hasta ahora, image_df solo ubica cada imagen: un cuadro delimitador, un tamaño, un hash de contenido. No dice nada sobre lo que muestra la imagen y no se puede recuperar un cuadro delimitador. Un gráfico o diagrama no contiene texto extraíble, por lo que el OCR y los analizadores basados en diseño dejan esa parte vacía: para ellos la región es invisible. Para que se pueda buscar la figura, ejecutamos un LLM de visión sobre cada imagen extraída y almacenamos una breve descripción junto a la fila, por ejemplo, “un gráfico de líneas de precios de productos básicos desde 2022” o “la arquitectura Transformer, un codificador de N capas apiladas”. Esa descripción es texto, por lo que la recuperación puede coincidir con ella. Un artículo complementario sobre el enriquecimiento de la visión-LLM recorre este paso en su totalidad.
1.5. object_registry: OBJETIVOS de referencia cruzada
Una referencia cruzada tiene dos lados. El objetivo es donde reside un objeto con nombre en el documento: la línea “Figura 2: La arquitectura del modelo Transformer” en la página 3, la línea “Tabla 1: puntuaciones BLEU” en la página 8. La fuente es una mención en el cuerpo del texto que apunta al objetivo: “como se muestra en la Figura 2”, “consulte la Tabla 1”. object_registry captura el lado de destino, una fila por título. La siguiente subsección (sección 1.6) captura el lado fuente. Resolver las fuentes en las páginas de destino, de modo que un fragmento recuperado que mencione "ver Tabla 1" también extraiga la página donde se encuentra la Tabla 1, es un paso de referencia cruzada de seguimiento que consume ambas tablas.
Cómo construirlo: la detección utiliza patrones de expresiones regulares ANCLADOS al comienzo de una línea (un título real comienza allí, una mención en el cuerpo del texto no); build_object_registry recorre line_df, compara cada línea con los patrones y mantiene el primer resultado para cada par (tipo_objeto, id_objeto). En el documento de atención:
OBJECT_PATTERNS = [ (re.compile(r"^s*(?:Figura|Fig.?)s+(d+)b", re.IGNORECASE), "figura"), (re.compile(r"^s*Tables+(d+)b", re.IGNORECASE), "tabla"), (re.compile(r"^s*(?:Annex|Appendix)s+([A-Z0-9]+)b", re.IGNORECASE), "annex"), ] def build_object_registry(line_df: pd.DataFrame) -> pd.DataFrame: """Devuelve una fila por (tipo_objeto, id_objeto), la primera coincidencia gana."""
Al ejecutar el documento de Atención, el constructor coloca una fila por cada objeto nombrado, con la línea de título como ancla:
1.6. cross_ref_df: FUENTES de referencia cruzada
La mitad simétrica de object_registry. Cada fila es una mención en el texto del cuerpo de un objeto con nombre: “como se muestra en la Figura 2” en la página 4, “consulte la Tabla 1” en la página 7, “consulte el Anexo B para obtener más detalles” en la página 12. Cada mención de este tipo es una fuente que, cuando se resuelve, salta a una página registrada en object_registry.
El mismo patrón que el TOC, dos métodos pueden producir estas filas: enlaces PDF nativos (la fuente determinista, cuando el documento los contiene) y coincidencia de patrones de texto en line_df (el respaldo general, lo que incluye build_cross_ref_df). El método 1 es exacto pero parcial. El método 2 es aproximado pero completo.
Método 1, enlaces PDF nativos: un PDF puede contener sus propias referencias cruzadas en las que se puede hacer clic. fitz.Page.get_links() devuelve una entrada por rectángulo de enlace, con el destino codificado como un triple (target_page, to.x, to.y) para un salto interno o un URI para uno externo:
importar fitz doc = fitz.open("data/nist/NIST.CSWP.29.pdf") para la página en doc: para ln en page.get_links(): tgt_page = ln.get("page") tgt_pt = ln.get("to") # Punto(x, y) en la página de destino print(page.number + 1, ln.get("kind"), tgt_page, tgt_pt, ln.get("uri"))
Lo interesante es to.y. Conocer sólo la página de destino le indica en qué parte del documento llega el enlace, pero no a qué apunta; la coordenada y fija la línea dentro de esa página. Dividimos el destino en dos columnas escalares, tgt_page y tgt_y, y resolvemos la línea de destino encontrando la fila en line_df cuyo y0 es el más cercano a tgt_y en tgt_page.
Dos advertencias prácticas aquí:
Los generadores de PDF difieren según la orientación y. LaTeX regresa de abajo hacia arriba, Acrobat regresa de arriba hacia abajo. El normalizador intenta ambos y mantiene la coincidencia más cercana. tgt_y puede ubicarse entre dos líneas. Redondeamos al más cercano.
La recompensa: una vez que conocemos la línea de destino, podemos unirnos (target_page, landing_text) contra toc_df y recuperar el índice de la sección directamente. Sin expresiones regulares, sin coincidencia de texto con rutas de navegación. El enlace nativo nos dice exactamente en qué toc_idx aterrizamos.
El mismo canal en el documento de Atención presenta una forma diferente de enlace: citas que se resuelven en entradas de bibliografía en lugar de inicios de la sección TOC.
La cobertura es el truco. Los dos PDF de demostración muestran el mismo patrón:
Documento de atención: 95 enlaces internos, todas las citas saltan a entradas de bibliografía, más 18 URI externos (github, arxiv). Cero enlaces nativos para menciones en el cuerpo del texto como "como se muestra en la Figura 2". NIST Cybersecurity Framework 2.0 (CSWP-29; trabajo del gobierno de EE. UU., dominio público en EE. UU., consulte la declaración de derechos de autor del NIST): 47 enlaces internos, todas las entradas del TOC y la lista de figuras que apuntan a los inicios de las secciones, además de 56 URI externos. La misma historia: no hay figuras del cuerpo del texto ni menciones de tablas vinculadas.
Los documentos empresariales suelen ser peores, sin ningún vínculo nativo (escaneos, capturas de pantalla, exportaciones desde herramientas que eliminan metadatos de vínculos). Por lo tanto, los enlaces nativos son una señal excelente cuando están presentes (deterministas, resolubles en un toc_idx cuando el objetivo es un encabezado de sección), pero nunca cubren el conjunto completo de referencias cruzadas que contiene un artículo.
Método 2, coincidencia de patrones de texto: la detección utiliza el mismo vocabulario que OBJECT_PATTERNS, pero SIN ANCLAJE, por lo que la expresión regular coincide en cualquier lugar dentro de una línea; Las líneas de título se excluyen, por lo que la línea que DEFINE la Figura 2 tampoco se cuenta como una mención de la misma.
En el documento de atención:
REFERENCE_PATTERNS = [ (re.compile(r"b(?:Figura|Fig.?)s+(d+)b", re.IGNORECASE), "figura"), (re.compile(r"bTables+(d+)b", re.IGNORECASE), "tabla"), (re.compile(r"b(?:Annex|Appendix)s+([A-Z0-9]+)b", re.IGNORECASE), "annex"), ] def build_cross_ref_df(line_df: pd.DataFrame) -> pd.DataFrame: """Una fila por mención del cuerpo del texto, con ~30 caracteres de contexto."""
Al ejecutar el documento de Atención, cada mención del texto del cuerpo de una figura o tabla aparece como una fila, que se puede volver a unir a object_registry:
Ejecutado en los archivos PDF de demostración, el documento de Atención tiene 13 menciones en el cuerpo del texto que cubren 6 objetos únicos (Figura 1, Figura 2, Tabla 1-4): se hace referencia a algunas figuras varias veces, que es exactamente lo que la tabla del lado fuente debe capturar.
NIST CSF 2.0 tiene 13 menciones (7 referencias de figuras, 5 referencias de anexos, 1 referencia de tabla) que cubren 10 objetos únicos (5 figuras, 4 anexos, 1 tabla). La discrepancia con object_registry del NIST (6 figuras + 3 anexos + 2 tablas) es informativa:
un anexo se menciona en el cuerpo sin un título anclado en el documento (la expresión regular captura una referencia cuyo objetivo se encuentra fuera del texto analizado) nunca se hace referencia a una figura registrada y una tabla registrada
Ambas son señales del mundo real que vale la pena presentar a un solucionador de referencias cruzadas posterior.
1.7. span_df: granularidad de sublínea (opcional)
A veces la línea es demasiado tosca. Una línea puede mezclar texto en negrita y sin negrita (un término definido en un contrato). Una línea en un trabajo de investigación puede incluir una ecuación en línea en cursiva junto con prosa. Una línea en una enmienda puede tener el texto original en negro y la modificación en rojo.
class Span(BaseModel): # Identidad y ordenamiento pdf_hash: str page_num: int line_num: int span_id: int # Qué dice, dónde se ubica text: str bbox: tuple[float, float, float, float] # Señales tipográficas font_name: str font_size: float is_bold: bool is_italic: bool color_rgb: tuple[int, int, int]
Un span_df es más granular que line_df. En el documento Atención, la proporción es de 3480 tramos para 1048 líneas, aproximadamente 3,3 veces más pesada. El coste sólo se amortiza en las etapas que inspeccionan la tipografía:
Detección de encabezado: una línea con una fuente más grande, posiblemente en negrita, probablemente sea un encabezado. Un pase de reconstrucción TOC utiliza esto cuando no hay marcadores nativos. Detección de listado: un espacio en negrita que comienza un párrafo suele ser el marcador de un elemento de enumeración. Términos definidos en los contratos: los términos en negrita o cursiva en los documentos legales a menudo se definen en otros lugares; capturarlos en el momento del análisis permite vincular el glosario más adelante.
Cómo construirlo: Comportamiento predeterminado: parse_pdf(…) devuelve span_df vacío. Las etapas posteriores que lo necesitan llaman a un constructor dedicado en la misma línea:
paper = parse_pdf(paper_pdf) paper["span_df"] = build_span_df(paper_pdf) # 3480 filas en el documento de Atención
Mantener los intervalos detrás de una llamada explícita evita pagar su costo en cada análisis de las etapas que solo necesitan line_df. Ejecute en el papel de Atención:
1.8. parsing_summary: síntesis técnica
Un único diccionario serializable JSON por documento. Responde de un vistazo: “¿este PDF está escaneado?”, “¿necesita OCR?”, “¿qué estrategia de extracción debería utilizar la siguiente etapa?” Y uno más, los ladrillos semánticos posteriores dicen: "¿Qué tipo de documento es este y de qué se trata?"
El dictado está organizado en cinco zonas. Los primeros cuatro son deterministas, creados por el analizador sin una llamada LLM. El quinto, semántico, incluye el tipo de documento más un breve resumen escrito por el LLM que el analizador de preguntas inyecta en el indicador del sistema.
{ "pdf_hash": "abc123…", "n_pages": 87, "pdf_version": "1.7", "source_software": "word_export", "creator_raw": "Microsoft Word 2019", "producer_raw": "Microsoft Word para Microsoft 365", "content_type": "scanned_with_ocr", "is_scanned": verdadero, "has_text_layer": verdadero, "ocr_quality": "buena", "page_type_counts": {"scanned_ocr_good": 80, "native": 5, "empty": 2}, "scanned_page_ratio": 0.92, "has_toc": true, "n_toc_entries": 24, "n_named_objects": 11, "is_encrypted": false, "has_form_fields": falso, "estrategia_recomendada": "use_existing_ocr", "needs_reocr": falso, "páginas_necesarias_ocr":[], "doc_type": "annual_report", "típico_fields": ["fiscal_year", "revenue", "net_ Income", "auditor"], "summary": "Informe anual de 87 páginas para el año fiscal 2023. Cubre ingresos, ingresos netos y notas del auditor en todos los segmentos operativos. Secciones estándar: Carta a los accionistas, MD&A, Estados financieros, Notas". }
La distinción entre software_fuente (a partir de metadatos) y tipo_contenido (inferido del contenido) es importante. Los dos pueden divergir: un PDF cuyo productor es “Microsoft Word” pero cuyo contenido está 100% escaneado significa que alguien pegó imágenes en un documento de Word y las exportó. Esa es información útil; no sobrescribas uno con el otro.
La zona semántica sigue la misma regla en un eje diferente. doc_type es una familia aproximada (currículum vitae, contrato, trabajo_académico, factura, memorando, informe_anual,…) derivada del nombre del archivo + texto de la primera página. Determinista, sin LLM. campos_típicos es la tabla por tipo de documento de nombres de campos a los que es más probable dirigir una pregunta sobre este tipo de documento; un currículum obtiene [nombre, correo electrónico, teléfono, experiencia,…], un contrato obtiene [titular de la póliza, prima, deducible,…]. resumen es el único valor derivado de LLM en el dictado: tres o cuatro oraciones factuales que nombran el tipo de documento, el tema principal y los campos que contiene. Una llamada de LLM en el momento del análisis, almacenada en caché para siempre, inyectada en el mensaje del sistema del analizador de preguntas, entonces "¿cuál es el nombre?" en un CV ya no aparece no encontrado. El artículo complementario sobre qué leer antes de que cualquier línea obtenga un número (“Más allá del texto_extracto”) recorre el diseño completo de ese resumen.
2. El modelo relacional: cómo se vinculan las tablas
Producir las tablas es una cosa; vincularlos es otra. Una vez que existen las tablas, las claves que comparten convierten ocho DataFrames separados en un modelo consultable, y casi todos los enlaces se resuelven en line_df, la fuente de verdad por línea.
Unos pocos enlaces llevan la mayor parte del peso:
toc_df → línea_df. Una entrada TOC conoce su página de inicio (y su inicio), por lo que desde cualquier sección saltas directamente a las líneas que le pertenecen. "Resumir la sección 3.5" se convierte en un filtro de rango de páginas en line_df, no se requiere búsqueda. imagen_df ↔︎ línea_df. Una imagen ocupa una posición en la página, por lo que tiene un espacio de línea en line_df. El texto de esa línea está vacío al principio, ya que una imagen no contiene texto extraíble. Opcionalmente, un pase de visión lee la imagen y escribe una breve descripción en esa celda de texto, para que la recuperación pueda coincidir con "el diagrama de arquitectura" más adelante. El vínculo es lo que hace que ese enriquecimiento sea incremental: llénelo cuando lo necesite y déjelo vacío cuando no lo necesite. cross_ref_df → su objetivo. Una mención en el cuerpo del texto se resuelve en el lugar donde vive el objetivo. "ver Figura 2" se resuelve en object_registry en (ref_type, ref_id); “ver sección 2.3” se resuelve en una entrada toc_df. La tabla se completa a medida que coinciden las referencias, por lo que la resolución se ejecuta de manera lenta, mención por mención. page_df, span_df, object_registry ancla a line_df en page_num o (page_num, line_num), la misma unión en la que se basa cada bloque posterior.
En concreto, las preguntas habituales se dividen en uno o dos filtros:
"Resumir la sección 3.5". Busque su página_inicial y página_final en toc_df, luego line_df[line_df.page_num.between(inicio, fin)]. Sin incrustaciones, sin búsqueda de palabras clave, solo las líneas de la sección. “¿Cuáles son los totales?” en la factura de la sección 3.2 → line_df[line_df.column_position == "right"]. La columna que detectó el analizador ahora es una consulta. “¿Qué muestra la Figura 2?” object_registry resuelve el título en su página y línea; line_df devuelve el texto del título; y si un pase de visión ha llenado el espacio de la imagen, también obtendrás la descripción. "¿Dónde se hace referencia a la Tabla 1?" cross_ref_df[(cross_ref_df.ref_type == "table") & (cross_ref_df.ref_id == 1)] enumera cada mención con su (page_num, line_num), unida nuevamente a toc_df para nombrar la sección en la que se encuentra cada una.
Cada uno es un filtro o una combinación de tablas que ya están en la memoria, nunca un nuevo análisis.
Esto es lo que las uniones te compran aguas abajo. La recuperación extrae una sección de toc_df, la expande a sus líneas en line_df y a las figuras que menciona a través de object_registry; La generación lee esas líneas; el resaltado muestra las citas nuevamente en la página por (núm_página, núm_línea). Todo el proceso se convierte en una cadena de uniones baratas en un solo análisis, en lugar de volver a leer el PDF en cada paso. Cómo estas uniones se convierten en claves primarias, claves externas e índices de SQL concretos es el trabajo de la capa de almacenamiento, más allá del alcance de este artículo.
3. parse_pdf en dos archivos PDF reales, uno al lado del otro
parse_pdf es el único punto de entrada que llama a todos los ayudantes anteriores y devuelve el conjunto completo de tablas vinculadas de una sola vez. Ejecútelo en dos archivos PDF muy diferentes y la estructura de salida será idéntica: mismas claves, formas comparables.
3.1. parse_pdf uno al lado del otro en dos archivos PDF reales
Ejecutar ambas llamadas y colocar los dos dictados devueltos uno al lado del otro muestra que las claves se mantienen, con recuentos por celda que reflejan la forma de cada documento:
Un artículo de investigación de LaTeX y el NIST Cybersecurity Framework 2.0 (CSWP-29, trabajo del gobierno de EE. UU., dominio público). Dos documentos muy diferentes: uno tiene 15 páginas de notación matemática en un diseño de dos columnas estilo NeurIPS, las otras 32 páginas de texto de políticas que combinan secciones de una y dos columnas. Misma llamada a parse_pdf, mismas claves, todas las columnas comparables. El documento Atención deja una sorpresa útil en el camino: esta versión de arXiv incluye 22 entradas TOC nativas, contrariamente a la suposición común de que arXiv elimina los marcadores.
El PDF se abre una vez con fitz, cada ayudante consume el mismo estado del documento y el archivo se cierra antes de regresar. Sin reapertura, sin volver a descargar desde S3, sin inconsistencia entre dos ayudantes que ven diferentes versiones de página. A partir de aquí, la recuperación, generación y anotación nunca volverán a tocar el PDF. Consultan el dict.
3.2. column_position en acción (una factura)
Las facturas son el caso canónico de column_position: las líneas de pedido se encuentran en la columna izquierda (descripciones), los precios y los totales se acumulan en la columna derecha. Elegimos una factura ficticia de una página (data/invoices/invoice_01.pdf, con licencia abierta, generada para la serie) para que el diseño sea una facturación honesta de dos columnas en lugar del título de figura de un artículo de investigación.
Mire primero la página fuente. Cada línea está encuadrada por la columna que le dio el analizador: azul para la izquierda (descripciones), verde para la derecha (cantidades y totales). asigna_column_positions selecciones que se dividen limpiamente:
La línea del encabezado se encuentra en la columna de la izquierda en x0 = 54. Debajo de la tabla de artículos, los totales se apilan a la derecha: “TOTAL VENCIDO:” en x0 ≈ 391, la cantidad $2027,56 en x0 ≈ 497. La línea de pedido en y0 = 397,13 muestra la división claramente: la descripción “Capacitación del personal” se encuentra en x0 = 54 (izquierda), la cantidad 0,5 y El precio unitario $197,58 se sitúa en x0 ≈ 343 y x0 ≈ 395 (derecha). Posteriormente, solicitar "los totales" se convierte en una consulta de una línea contra line_df: line_df[line_df["column_position"] == "right"].
Sin pase de visión, sin aritmética de bbox. Sólo un filtro de columna en una tabla estructurada.
3.3. Dos archivos PDF, mismo analizador, misma forma
Dos documentos muy diferentes, el mismo analizador, resultados estructurados directamente comparables:
Cómo se habría visto esto con un ingenuo analizador get_text(): una cadena por documento, no hay forma de saber qué líneas fueron sometidas a OCR y cuáles eran nativas, no tengo idea de dónde se encuentra cada título de figura, no hay separación entre las mitades izquierda y derecha de una página de dos columnas. Las etapas de recuperación y generación se habrían construido sobre arena.
4. Guarda una vez, recarga para siempre
El análisis es el ladrillo más caro del proceso. El análisis, la recuperación y la generación de preguntas cuestan cada uno una llamada de LLM; el análisis lee bytes y resuelve el diseño. Con PyMuPDF sigue siendo barato (menos de un segundo en un papel pequeño). Con motores más pesados (Azure Layout, Tesseract, vision-LLM fallback), el mismo PDF puede tardar entre 30 segundos y varios minutos por ejecución. Tres iteraciones en un mensaje descendente equivalen a tres ejecuciones de OCR. No hay razón para eso.
La solución se basa en la ruta. Cada PDF escribe sus tablas analizadas en una carpeta espejo en el directorio de salida, coincidiendo exactamente con la ruta de origen. Solo desde la ruta del PDF, cada paso posterior (recuperación, generación, anotación) sabe dónde reside el caché.
Las tablas relacionales van a .xlsx (un archivo por tabla, se abre con un doble clic), parsing_summary a JSON. Excel es suficiente en esta etapa: los pandas viajan de ida y vuelta limpiamente y cada tabla permanece inspeccionable en cualquier herramienta de hoja de cálculo. Una capa de almacenamiento de producción se intercambia en SQLite (claves externas, uniones entre documentos, agregar al actualizar), pero los bloques posteriores consumen DataFrames de cualquier manera.
save_parsed escribe la carpeta; load_parsed devuelve el mismo dictado, o Ninguno si falta el caché. El patrón de llamada es una línea:
analizado = load_parsed(pdf_path) si analizado es Ninguno: parsed = parse_pdf(pdf_path) save_parsed(pdf_path, analizado)
Los ladrillos posteriores hacen lo mismo. El análisis de preguntas escribe su ParsedQuestion en questions//parsed_question.json, la recuperación guarda retrieved_pages.xlsx, la generación guarda respuesta.json. Cada paso es totalmente recuperable desde el disco, cada paso se puede reproducir sin tocar el LLM nuevamente. Cuando modifica un mensaje de generación, no paga por el análisis o la recuperación para volver a ejecutarlo.
5. Conclusión
Un buen analizador RAG no extrae texto. Convierte un PDF no estructurado en un modelo relacional del documento: un conjunto de tablas vinculadas, unidas por identificadores compartidos (núm_página, núm_línea, (tipo_ref, id_ref)), cada uno con una entidad. La recuperación, generación y anotación nunca vuelven a leer el PDF después; consultan DataFrames. Guardar el análisis una vez y recargarlo para siempre convierte una latencia de 30 segundos por pregunta en un costo único por corpus.
Un conjunto relacional de tablas, un PDF de entrada, sin cadenas planas. Cada herramienta posterior que el equipo conecta al analizador (búsqueda de palabras clave, incrustación de similitud, recuperación de secciones, representación de citas, registro de auditoría, seguimiento de cambios) lee estas tablas en lugar de los bytes originales. El PDF se abre una vez, durante la ingesta. Después de eso, todo es SQL o pandas. Esa propiedad es lo que hace que el bloque de análisis valga la pena la inversión en ingeniería: el costo se paga una vez por documento, y cada iteración en el resto del proceso se ejecuta contra un artefacto estable y consultable.
Este artículo forma parte de la serie Enterprise Document Intelligence. La canalización RAG mínima muestra las tablas relacionales en uso de un extremo a otro en un PDF real.
6. Fuentes y lecturas adicionales
Al principio de la serie:
El analizador que describe este artículo sigue la misma arquitectura que Docling (Auer et al., Docling Technical Report, IBM Research 2024): detección de diseño, TableFormer, orden de lectura. La extracción de tablas sin bordes utiliza el modelo de Smock et al. (PubTables-1M / Transformador de mesa, CVPR 2022). La taxonomía de clases de páginas se basa en la misma base que Pfitzmann et al. (DocLayNet, KDD 2022). El artículo agrega un pase de detección del modo de renderizado (nativo/escaneado/mixto) con puntuación de calidad OCR en la parte superior. El analizador produce un conjunto relacional de tablas (line_df, page_df, image_df, toc_df, object_registry, cross_ref_df, span_df, más un dict parsing_summary); la recuperación, generación y anotación posteriores no vuelven a leer el PDF, consultan DataFrames.
Misma dirección que el artículo:
Auer et al., Informe técnico Docling, IBM Research 2024 (arXiv:2408.09869). Arquitectura de referencia para la canalización que describe este artículo: detección de diseño, TableFormer, orden de lectura, representación de documentos unificada. Smock, Pesala, Abraham, PubTables-1M / Table Transformer (TATR), CVPR 2022 (arXiv:2110.00061). Detección de tablas y reconocimiento de estructuras basado en visión; el modelo detrás de la mayoría de los analizadores de tablas modernos. Pfitzmann et al., DocLayNet, KDD 2022 (arXiv:2206.01062). Línea de base empírica para la taxonomía de clases de páginas y los puntos de referencia de detección de diseño. Lo et al., PaperMage, demostraciones de EMNLP 2023. Se asigna a la división de indexación versus lectura (el análisis para la recuperación no es un análisis para la generación de respuestas).
Ángulo diferente, contexto diferente:
Faysse et al., ColPali: Recuperación eficiente de documentos con modelos de lenguaje visual, 2024 (arXiv:2407.01449). Recuperación visión-lenguaje en la imagen de la página. El contexto es de recuperación donde la imagen de la página es el artefacto, sin paso de análisis en tablas. En su lugar, este artículo utiliza DataFrames anclados en cuadros delimitadores como base. Wang et al., DocLLM: un modelo de lenguaje generativo compatible con el diseño para la comprensión de documentos multimodales, JPMorgan 2024 (arXiv:2401.00908). LLM compatible con el diseño que lee el PDF directamente sin un bloque de análisis relacional explícito. Misma familia de enfoque que ColPali; diferente del artefacto relacional consultable de este artículo. Kim et al., Transformador de comprensión de documentos sin OCR (Donut), ECCV 2022 (arXiv:2111.15664). Comprensión de documentos sin OCR de extremo a extremo; contraste útil con el pase de puntuación de calidad de OCR que este artículo agrega además de la detección del modo de renderizado.