volver al blog

Parsear PDFs para RAG: el eslabón que casi nadie mide

ragingestapdf

El PDF no guarda texto, guarda dibujos de texto

Un PDF no es un documento. Es una lista de instrucciones de dibujo: pon este glifo en estas coordenadas, con esta fuente, a este tamaño. No hay párrafos, no hay tablas, no hay orden de lectura. Todo eso lo reconstruye el visor cuando lo miras. Un parser lo reconstruye peor cuando lo procesas.

Esa diferencia es invisible mientras el corpus va bien. Aparece cuando un usuario pregunta por una cifra que sabe que está en el documento y el sistema responde que no la encuentra. Miras el chunk recuperado y ahí está la cifra, sí, pero pegada a la columna de al lado, con el pie de página incrustado en medio y la unidad tres filas más arriba.

He visto equipos dedicar semanas a afinar la recuperación con un corpus que ya nacía ilegible. Es de los eslabones más baratos de arreglar y de los que menos se miden.

Cuatro formas de romper un documento

Los fallos de parseo no son aleatorios. Se repiten siempre los mismos cuatro, y conviene saber reconocerlos a simple vista.

Orden de lectura. El texto se extrae en el orden en que se dibujó, que no tiene por qué ser el orden en que se lee. En un documento a dos columnas, un extractor ingenuo te devuelve la primera línea de la izquierda, luego la primera de la derecha y así hasta convertir dos argumentos coherentes en una conversación cruzada.

Tablas aplanadas. Una tabla en PDF suele ser texto suelto más unas líneas dibujadas. Si el parser no reconstruye la rejilla, la tabla sale como una ristra de números sin cabecera. El modelo lee “1.240” sin saber si es un importe, un año o una referencia, y se lo inventa con total aplomo.

Cabeceras, pies y marcas de agua. Se repiten en cada página, se cuelan en cada chunk y contaminan los embeddings con ruido idéntico. 200 páginas con el mismo pie legal hacen que 200 chunks se parezcan entre sí más de lo que se parecen a la pregunta.

Documentos escaneados. Sin capa de texto no hay nada que extraer. El parser devuelve cadenas vacías, la ingesta no falla y el documento entra en el índice como un fantasma: existe, no dice nada y nadie se entera hasta meses después.

Elegir familia de parser

No hay un ganador. Hay tres familias con perfiles de coste y fidelidad muy distintos, y la decisión correcta depende de la pinta que tengan tus documentos.

Extracción directaModelo de disposiciónModelo visión-lenguaje
EjemplosPyMuPDF, pdfplumberDocling, UnstructuredGranite-Docling, modelos multimodales
Velocidad por páginaMilisegundosCientos de ms a segundosSegundos
CosteCero en cómputo; PyMuPDF es AGPLCPU o GPU propiaPor token o GPU
Orden de lecturaFrágil con columnasBuenoBueno
TablasPobres salvo rejilla explícitaBuenasBuenas, con alucinaciones ocasionales
EscaneadosNada sin OCRCon OCR integradoNativo
DeterminismoTotalAltoBajo: dos pasadas pueden diferir

El sesgo razonable es empezar por abajo. Si tus documentos son informes generados por software, a una columna y con tablas sencillas, PyMuPDF resuelve la mayoría de los casos con salida reproducible y sin coste de cómputo apreciable. Conviene revisar su licencia AGPL antes de meterlo en un producto cerrado. Subir un peldaño puede multiplicar por cien el tiempo de ingesta, y eso importa cuando reindexas un corpus entero.

El último peldaño merece un aviso. Un modelo de visión puede completar lo que no ve bien, y esa es exactamente la propiedad que no quieres en la capa de ingesta: una alucinación en el chunk se convierte en una fuente citada, con su número de página, que el usuario no tiene forma de cuestionar.

Comparar antes de decidir

La comparación se hace en una tarde con una muestra de 20 documentos representativos. No con el PDF de ejemplo del tutorial: con los tuyos, incluidos los feos.

import re

import pymupdf  # el import histórico `fitz` sigue funcionando, pero está desaconsejado
from docling.document_converter import DocumentConverter

convertidor = DocumentConverter()
SEPARADOR_TABLA = re.compile(r"^\s*\|[\s\-:|]+\|\s*$")  # la fila `|---|---|` de Markdown

def con_pymupdf(ruta: str) -> str:
    with pymupdf.open(ruta) as documento:
        # sort=True ordena los bloques por posición, no por orden de dibujo.
        # Arregla muchos casos de dos columnas y no cuesta nada probarlo.
        return "\n\n".join(p.get_text("text", sort=True) for p in documento)

def con_docling(ruta: str) -> str:
    # Reconstruye disposición, orden de lectura y estructura de tablas.
    return convertidor.convert(ruta).document.export_to_markdown()

def diagnostico(nombre: str, texto: str) -> None:
    lineas = [linea.strip() for linea in texto.splitlines() if linea.strip()]
    repetidas = len(lineas) - len(set(lineas))          # cabeceras y pies duplicados
    tablas = sum(1 for linea in lineas if SEPARADOR_TABLA.match(linea))
    print(
        f"{nombre:10s} "
        f"caracteres={len(texto):7d} "
        f"tablas={tablas:3d} "
        f"lineas_repetidas={repetidas:4d}"
    )

for ruta in ("informe_dos_columnas.pdf", "contrato_escaneado.pdf"):
    print(f"\n{ruta}")
    diagnostico("pymupdf", con_pymupdf(ruta))
    diagnostico("docling", con_docling(ruta))

Tres señales bastan para decidir. Un recuento de caracteres cercano a cero delata un escaneado sin OCR. Cero tablas reconocidas en un documento que sí las tiene te dice que ese parser no sirve para este corpus. Y un número alto de líneas repetidas es el ruido de cabeceras y pies que vas a tener que limpiar antes de trocear.

Para lo que ese script no ve, abre el Markdown resultante y lee tres páginas al azar. Si tú no entiendes el documento leyendo la salida, el modelo tampoco. Es una prueba tosca y detecta más problemas de los que parece.

Convertir el diagnóstico en una barrera

Una comparación puntual sirve una vez. Lo que evita el problema a largo plazo es negarse a indexar lo que no pasa el filtro.

Tres reglas en la ingesta cubren casi todo:

  • Menos de 100 caracteres por página de media: es un escaneado. Va a la cola de OCR, no al índice.
  • Más de un 20 % de líneas duplicadas: se detectan las repeticiones por página y se eliminan antes de trocear.
  • Cero tablas reconocidas en un documento cuyo parser directo sí detecta rejillas: se escala al parser con modelo de disposición.

Lo importante no es el umbral exacto, que dependerá de tu corpus, sino que el documento roto se pare en la puerta y aparezca en un registro. La alternativa por defecto —ingerir en silencio lo que salga— convierte cada fallo de parseo en un fallo de recuperación seis meses después, cuando ya nadie recuerda cómo entró ese PDF.

Antes de tocar nada más

Mira tu corpus antes de tocar el modelo de embeddings. Coge 10 de los documentos que se consulten de verdad, extrae el texto tal y como lo tienes indexado hoy y léelo.

Si es legible, tu problema está en la recuperación y este artículo no te sirve. Si no lo es, ya sabes por qué falla el RAG, y la corrección cuesta una tarde en lugar de un trimestre.

Es un trabajo poco vistoso, sin una métrica llamativa que enseñar en una reunión. También es el que más veces he visto arreglar un sistema que llevaba semanas dando respuestas raras.

Fuentes