PDF e RAG: conserva l’ordine di lettura tra le colonne

PDF e RAG: conserva l’ordine di lettura tra le colonne

Un PDF può mostrare due colonne perfettamente leggibili e, nonostante ciò, restituire un testo che le mescola riga per riga. In un sistema RAG, questa estrazione può unire un protocollo di laboratorio a una dichiarazione finanziaria e produrre un frammento il cui significato non corrisponde a nessuno dei due.

Il problema si presenta prima di generare gli embedding: il PDF è progettato per posizionare testo e immagini su una pagina, non per descrivere paragrafi o un ordine di lettura universale. PyMuPDF consente di estrarre blocchi con coordinate; grazie a esse possiamo ricostruire un ordine ragionevole per le pagine a due colonne e verificarne i limiti prima di indicizzare il contenuto.

L’ordine visto dal parser non è sempre quello del lettore

Un PDF privo di struttura con tag non contiene concetti affidabili di paragrafo o colonna. L’ordine delle operazioni di disegno può riflettere il modo in cui il programma che ha creato il file ha posizionato gli elementi, non il percorso seguito da una persona per leggere la pagina. Ordinare tutto in base alla coordinata verticale non risolve il problema: in una pagina a due colonne, può alternare la prima riga a sinistra con la prima a destra.

Il risultato può conservare parole plausibili e superare controlli semplici, ma unire argomenti che dovrebbero restare separati. Un frammento contaminato può rappresentare una miscela semantica invece del passaggio che risponderebbe a una query. L’estrazione precede l’embedding: se il testo ha già perso la sequenza, cambiare il modello di embedding non corregge quel contenuto.

Il flusso di lavoro che implementeremo è: aprire il PDF, ottenere i blocchi con le relative caselle, separare gli elementi che occupano la larghezza della pagina e leggere ogni fascia da sinistra a destra.

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"]

Preparare un’estrazione che conservi le coordinate

Installa PyMuPDF e salva il PDF che vuoi esaminare in un percorso accessibile. L’esempio interroga il testo semplice e i blocchi tramite il metodo Page.get_text. L’output "blocks" restituisce elementi con una casella delimitatrice e il testo; in questo modo evitiamo di ridurre la pagina a una stringa priva di posizione.

python -m pip install pymupdf

Prima di costruire un’euristica, confronta il testo semplice con i blocchi. sort=True chiede a PyMuPDF di riordinare dall’alto verso il basso e da sinistra a destra, ma da solo non garantisce la sequenza corretta in ogni pagina complessa.

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())

Salvalo come inspeccion.py ed eseguilo con python inspeccion.py documento.pdf. Verifica se le frasi passano da una colonna all’altra e se i blocchi larghi —per esempio un titolo o una tabella— compaiono tra le sezioni. Questa ispezione determina se l’euristica successiva è adatta al documento.

Separare le colonne per fasce, non per righe

Una divisione fissa a metà pagina è allettante, ma non funziona quando ci sono contenuti che occupano entrambe le colonne. Il codice seguente tratta invece un blocco sufficientemente largo come una barriera: termina la fascia corrente, lo emette e inizia ad accumulare i blocchi successivi. All’interno di ogni fascia, ordina prima la colonna sinistra e poi quella destra.

La funzione è progettata per pagine a due colonne. La soglia di larghezza è un parametro da regolare dopo aver esaminato il PDF; non è una regola universale valida per qualsiasi layout.

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]))

Il codice conserva i blocchi larghi nella loro posizione approssimativa nella sequenza: prima emette i blocchi di colonna che vengono prima, poi l’elemento che attraversa la pagina e infine elabora i blocchi successivi. Il criterio confronta la larghezza della casella con quella della pagina. Se un documento ha margini ampi o tre colonne, questa classificazione statica può assegnare i blocchi in modo errato; la documentazione di PyMuPDF espone le caselle proprio affinché l’applicazione possa esaminarle.

Elaborare tutte le pagine e salvare il testo

Dopo aver convalidato la funzione su pagine rappresentative, applicala all’intero documento. Questo script aggiunge un separatore di pagina e salva il risultato in UTF-8. In questo modo puoi esaminare il materiale che forniresti alla fase successiva della pipeline, invece di inviare direttamente un’estrazione non verificata.

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])

L’euristica non rileva da sola se una pagina ha una o due colonne. Usala con documenti il cui layout hai verificato; una pagina a colonna singola può essere divisa artificialmente in due gruppi se il testo attraversa il centro. Inoltre, non ricostruisce la struttura semantica di tabelle complesse. In questi casi, può essere più adatto uno strumento di segmentazione del layout, anche se richiede un approccio di elaborazione diverso.

Verificare prima di indicizzare

L’output riordinato resta un’ipotesi sul layout. Esamina campioni di pagine con più colonne e cerca transizioni improvvise tra argomenti. Controlla anche le tabelle: se i numeri non sono più associati alle intestazioni di riga e colonna, convertirli in una sequenza lineare può cancellare la relazione necessaria a una query.

Questo semplice controllo consente di estrarre una pagina a scelta e mostrarla insieme ai blocchi originali. Le coordinate aiutano a diagnosticare un’assegnazione errata; non sostituiscono il confronto visivo con la pagina.

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()}")

Esegui, per esempio, python auditar.py documento.pdf 3 per esaminare la terza pagina. La documentazione di pypdf avverte che, se una pagina contiene solo un’immagine, l’estrazione può essere minima o vuota; pypdf non è un software OCR. Per documenti irregolari, con griglie complesse o tabelle con più righe, valuta una partizione consapevole del layout; Unstructured offre strategie per i PDF come fast e hi_res.

Limiti da individuare tempestivamente

Una casella non equivale a un paragrafo. PyMuPDF estrae blocchi che corrispondono approssimativamente a paragrafi, ma il formato PDF non garantisce che queste unità corrispondano alla struttura prevista da chi ha scritto il documento. Anche intestazioni, piè di pagina, note, formule e tabelle richiedono decisioni di estrazione.

La strategia per fasce è un punto di partenza verificabile, non un parser universale. Se l’ordine cambia da una pagina all’altra, ci sono più di due colonne o compaiono elementi flottanti, esamina le caselle e modifica la logica oppure passa alla segmentazione del layout. Se produci i PDF che entrano nel corpus, i PDF con tag possono includere una struttura logica con una sequenza di lettura esplicita.

Prima di intervenire sugli embedding o reindicizzare, controlla il testo effettivamente estratto dal PDF. Se una frase attraversa le colonne durante l’estrazione, il problema è già presente nel materiale che arriverà a RAG.

Fonti: DEV Community, documentazione di pypdf, documentazione di Unstructured, documentazione di PyMuPDF