Un PDF puede mostrar dos columnas perfectamente legibles y, aun así, entregar un texto que mezcla ambas línea a línea. En un sistema RAG, esa extracción puede juntar un protocolo de laboratorio con una declaración financiera y producir un fragmento cuyo significado no corresponde a ninguno de los dos.
El problema aparece antes de generar embeddings: el PDF está pensado para colocar texto e imágenes en una página, no para describir párrafos ni un orden de lectura universal. PyMuPDF permite extraer bloques con coordenadas; con ellas podemos reconstruir un orden razonable para páginas de dos columnas y revisar sus límites antes de indexar el contenido.
El orden que ve el parser no siempre es el del lector
Un PDF sin estructura etiquetada no contiene conceptos fiables de párrafo o columna. El orden de sus operaciones de dibujo puede reflejar cómo el programa creador colocó los elementos, no cómo una persona recorre la página. Ordenar todo por la coordenada vertical tampoco resuelve el problema: en una página a dos columnas, puede intercalar la primera línea de la izquierda con la primera de la derecha.
El resultado puede conservar palabras plausibles y superar comprobaciones simples, pero unir temas que deberían permanecer separados. Un fragmento contaminado puede representar una mezcla semántica en lugar del pasaje que respondería a una consulta. La extracción precede al embedding: si el texto ya perdió su secuencia, cambiar el modelo de embeddings no repara ese contenido.
El recorrido que implementaremos es: abrir el PDF, obtener bloques con sus cajas, separar elementos que ocupan el ancho de la página y leer cada banda de izquierda a derecha.
flowchart TD
A["Abrir PDF"] --> B["Extraer bloques y cajas"]
B --> C["Ordenar por altura"]
C --> D{"¿Bloque ancho?"}
D -->|"Sí"| E["Cerrar banda de columnas"]
E --> F["Añadir bloque ancho"]
D -->|"No"| G["Guardar en columna"]
F --> C
G --> C
C --> H["Emitir izquierda y derecha"]
H --> I["Revisar texto antes de RAG"]Preparar una extracción que conserve coordenadas
Instala PyMuPDF y guarda el PDF que quieras inspeccionar en una ruta accesible. El ejemplo consulta el texto plano y los bloques mediante el método Page.get_text. La salida "blocks" devuelve elementos con una caja delimitadora y texto; así evitamos reducir la página a una cadena sin posición.
python -m pip install pymupdf
Antes de construir una heurística, compara el texto plano con los bloques. sort=True solicita a PyMuPDF un reordenamiento de arriba a abajo y de izquierda a derecha, pero no garantiza por sí solo la secuencia correcta en toda página compleja.
import sys
import pymupdf
if len(sys.argv) != 2:
raise SystemExit("Uso: python inspeccion.py documento.pdf")
with pymupdf.open(sys.argv[1]) as document:
page = document[0]
print("--- Texto con sort=True ---")
print(page.get_text("text", sort=True))
print("--- Bloques ---")
for block in page.get_text("blocks", sort=False):
x0, y0, x1, y1, text, block_no, block_type = block
if block_type == 0 and text.strip():
print(f"bloque={block_no} caja=({x0:.1f}, {y0:.1f}, {x1:.1f}, {y1:.1f})")
print(text.strip())
Guárdalo como inspeccion.py y ejecútalo con python inspeccion.py documento.pdf. Comprueba si las frases saltan de una columna a otra y si los bloques anchos —por ejemplo, un título o una tabla— aparecen entre secciones. Esa inspección decide si la heurística siguiente encaja con el documento.
Separar columnas por bandas, no por líneas
Una división fija por el punto medio de la página es tentadora, pero falla cuando hay contenido que ocupa las dos columnas. En su lugar, el siguiente código trata un bloque suficientemente ancho como una barrera: termina la banda actual, lo emite y empieza a acumular los bloques siguientes. Dentro de cada banda, ordena primero la columna izquierda y luego la derecha.
La función está diseñada para páginas de dos columnas. El umbral de ancho es un parámetro para ajustar tras inspeccionar el PDF; no es una regla universal para cualquier diseño.
import pymupdf
def extract_two_column_page(page, span_threshold=0.75):
"""Extrae bloques de texto en bandas para una página de dos columnas."""
blocks = [
block for block in page.get_text("blocks", sort=False)
if block[6] == 0 and block[4].strip()
]
if not blocks:
return ""
midpoint = page.rect.width / 2.0
ordered = []
left = []
right = []
def flush_columns():
nonlocal left, right
ordered.extend(sorted(left, key=lambda block: block[1]))
ordered.extend(sorted(right, key=lambda block: block[1]))
left = []
right = []
for block in sorted(blocks, key=lambda item: item[1]):
x0, y0, x1, y1, text, block_no, block_type = block
width = x1 - x0
if width >= page.rect.width * span_threshold:
flush_columns()
ordered.append(block)
elif x0 < midpoint:
left.append(block)
else:
right.append(block)
flush_columns()
return "\n\n".join(block[4].strip() for block in ordered)
if __name__ == "__main__":
import sys
if len(sys.argv) != 2:
raise SystemExit("Uso: python columnas.py documento.pdf")
with pymupdf.open(sys.argv[1]) as document:
print(extract_two_column_page(document[0]))
El código conserva los bloques anchos en su posición aproximada dentro de la secuencia: los bloques de columna que aparecen antes se emiten primero, luego el elemento que cruza la página, y después se procesan los siguientes. El criterio compara la anchura de la caja con la anchura de la página. Si un documento tiene márgenes amplios o tres columnas, esa clasificación estática puede asignar mal los bloques; la documentación de PyMuPDF expone las cajas precisamente para que la aplicación pueda inspeccionarlas.
Procesar todas las páginas y guardar el texto
Una vez validada la función en páginas representativas, aplícala al documento completo. Este script añade un separador de página y guarda el resultado en UTF-8. Así puedes examinar el material que alimentarías a la etapa posterior del pipeline, en vez de enviar directamente una extracción sin revisar.
import sys
import pymupdf
def extract_two_column_page(page, span_threshold=0.75):
blocks = [
block for block in page.get_text("blocks", sort=False)
if block[6] == 0 and block[4].strip()
]
if not blocks:
return ""
midpoint = page.rect.width / 2.0
ordered = []
left = []
right = []
def flush_columns():
nonlocal left, right
ordered.extend(sorted(left, key=lambda block: block[1]))
ordered.extend(sorted(right, key=lambda block: block[1]))
left = []
right = []
for block in sorted(blocks, key=lambda item: item[1]):
if block[2] - block[0] >= page.rect.width * span_threshold:
flush_columns()
ordered.append(block)
elif block[0] < midpoint:
left.append(block)
else:
right.append(block)
flush_columns()
return "\n\n".join(block[4].strip() for block in ordered)
def main(input_path, output_path):
with pymupdf.open(input_path) as document:
pages = []
for page_number, page in enumerate(document, start=1):
text = extract_two_column_page(page)
pages.append(f"[Página {page_number}]\n{text}")
with open(output_path, "w", encoding="utf-8") as output:
output.write("\n\n".join(pages))
if __name__ == "__main__":
if len(sys.argv) != 3:
raise SystemExit("Uso: python extraer.py entrada.pdf salida.txt")
main(sys.argv[1], sys.argv[2])
La heurística no detecta por sí sola si una página es de una o de dos columnas. Úsala en documentos cuyo diseño hayas comprobado; una página de una columna puede quedar dividida en dos grupos artificiales si el texto atraviesa el centro. Tampoco reconstruye la estructura semántica de tablas complejas. En esos casos, una herramienta de segmentación de diseño puede ser más apropiada, aunque implica otro enfoque de procesamiento.
Auditar antes de indexar
La salida ordenada sigue siendo una hipótesis sobre el diseño. Revisa muestras de páginas con varias columnas y busca transiciones abruptas entre temas. Comprueba también tablas: si los números dejan de estar asociados a encabezados de fila y columna, convertirlos en una secuencia plana puede borrar la relación que necesita una consulta.
Este pequeño control permite extraer una página elegida y mostrarla junto a sus bloques originales. Las coordenadas ayudan a diagnosticar una asignación equivocada; no sustituyen la comparación visual con la página.
import sys
import pymupdf
def extract_two_column_page(page, span_threshold=0.75):
blocks = [b for b in page.get_text("blocks", sort=False)
if b[6] == 0 and b[4].strip()]
if not blocks:
return ""
midpoint = page.rect.width / 2.0
ordered, left, right = [], [], []
def flush():
nonlocal left, right
ordered.extend(sorted(left, key=lambda b: b[1]))
ordered.extend(sorted(right, key=lambda b: b[1]))
left, right = [], []
for block in sorted(blocks, key=lambda b: b[1]):
if block[2] - block[0] >= page.rect.width * span_threshold:
flush()
ordered.append(block)
elif block[0] < midpoint:
left.append(block)
else:
right.append(block)
flush()
return "\n\n".join(b[4].strip() for b in ordered)
if __name__ == "__main__":
if len(sys.argv) != 3:
raise SystemExit("Uso: python auditar.py documento.pdf numero_de_pagina")
page_index = int(sys.argv[2]) - 1
with pymupdf.open(sys.argv[1]) as document:
page = document[page_index]
print("--- Orden reconstruido ---")
print(extract_two_column_page(page))
print("--- Bloques con coordenadas ---")
for block in page.get_text("blocks", sort=False):
if block[6] == 0 and block[4].strip():
print(f"y={block[1]:.1f}, x={block[0]:.1f}: {block[4].strip()}")
Ejecuta, por ejemplo, python auditar.py documento.pdf 3 para inspeccionar la tercera página. La documentación de pypdf advierte que, si una página contiene solo una imagen, la extracción puede ser mínima o vacía; pypdf no es software OCR. Para documentos irregulares, con rejillas complejas o tablas con varias filas, considera una partición consciente del diseño; Unstructured ofrece estrategias para PDF como fast y hi_res.
Límites que conviene detectar pronto
Una caja no equivale a un párrafo. PyMuPDF extrae bloques que son, aproximadamente, párrafos, pero el formato PDF no garantiza que esas unidades correspondan a la estructura que esperaba quien escribió el documento. Los encabezados, pies, notas, fórmulas y tablas también exigen decisiones de extracción.
La estrategia por bandas sirve como punto de partida verificable, no como parser universal. Si el orden cambia entre páginas, hay más de dos columnas o aparecen elementos flotantes, revisa las cajas y ajusta la lógica o cambia a segmentación de diseño. Si produces los PDF que entran al corpus, los PDF etiquetados pueden incluir una estructura lógica con secuencia de lectura explícita.
Antes de tocar embeddings o reindexar, mira el texto que realmente sale del PDF. Si una frase cruza columnas en la extracción, el problema ya está en el material que llegará a RAG.
Fuentes: DEV Community, pypdf documentation, Unstructured documentation, PyMuPDF documentation