PDFs in RAG: Preserve Reading Order Across Columns

PDFs in RAG: Preserve Reading Order Across Columns

A PDF can display two perfectly legible columns and still return text that mixes them line by line. In a RAG system, this extraction can combine a laboratory protocol with a financial statement and produce a chunk whose meaning corresponds to neither.

The problem arises before generating embeddings: PDFs are designed to place text and images on a page, not to describe paragraphs or a universal reading order. PyMuPDF can extract blocks with coordinates; using them, we can reconstruct a reasonable order for two-column pages and review its limitations before indexing the content.

The parser's order is not always the reader's order

A PDF without tagged structure does not contain reliable concepts of paragraphs or columns. The order of its drawing operations may reflect how the creating program placed elements, not how a person reads the page. Sorting everything by vertical coordinate does not solve the problem either: on a two-column page, it can interleave the first line on the left with the first line on the right.

The result may preserve plausible words and pass simple checks, yet combine topics that should remain separate. A contaminated chunk may represent a semantic mixture instead of the passage that would answer a query. Extraction comes before embedding: if the text has already lost its sequence, changing the embedding model will not repair that content.

The process we will implement is: open the PDF, get blocks with their bounding boxes, separate elements that span the page width, and read each band from left to right.

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

Prepare an extraction that preserves coordinates

Install PyMuPDF and save the PDF you want to inspect at an accessible path. The example queries plain text and blocks using the Page.get_text method. The "blocks" output returns elements with a bounding box and text; this avoids reducing the page to a positionless string.

python -m pip install pymupdf

Before building a heuristic, compare the plain text with the blocks. sort=True asks PyMuPDF to reorder from top to bottom and left to right, but does not by itself guarantee the correct sequence on every complex page.

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

Save it as inspeccion.py and run it with python inspeccion.py documento.pdf. Check whether sentences jump from one column to another and whether wide blocks—for example, a title or a table—appear between sections. This inspection determines whether the following heuristic suits the document.

Separate columns by bands, not by lines

A fixed split at the page midpoint is tempting, but fails when content spans both columns. Instead, the following code treats a sufficiently wide block as a barrier: it ends the current band, emits the block, and starts accumulating the following blocks. Within each band, it sorts the left column first and then the right.

The function is designed for two-column pages. The width threshold is a parameter to adjust after inspecting the PDF; it is not a universal rule for every 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]))

The code preserves wide blocks in their approximate position in the sequence: column blocks that appear earlier are emitted first, then the element that spans the page, and then the following blocks are processed. The criterion compares the bounding box width with the page width. If a document has wide margins or three columns, this static classification may assign blocks incorrectly; PyMuPDF's documentation exposes the bounding boxes precisely so the application can inspect them.

Process all pages and save the text

Once you have validated the function on representative pages, apply it to the entire document. This script adds a page separator and saves the result as UTF-8. This lets you examine the material you would feed into the next stage of the pipeline, rather than sending an unchecked extraction directly.

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

The heuristic does not automatically detect whether a page has one or two columns. Use it with documents whose layout you have checked; a single-column page may be artificially split into two groups if the text crosses the center. It also does not reconstruct the semantic structure of complex tables. In such cases, a layout-segmentation tool may be more appropriate, although it involves a different processing approach.

Audit before indexing

The ordered output is still a hypothesis about the layout. Review samples of pages with multiple columns and look for abrupt transitions between topics. Check tables too: if numbers are no longer associated with row and column headers, converting them into a flat sequence can erase the relationship a query needs.

This small check lets you extract a selected page and display it alongside its original blocks. The coordinates help diagnose an incorrect assignment; they do not replace visual comparison with the page.

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

For example, run python auditar.py documento.pdf 3 to inspect the third page. The pypdf documentation warns that if a page contains only an image, extraction may be minimal or empty; pypdf is not OCR software. For irregular documents with complex grids or tables with multiple rows, consider layout-aware partitioning; Unstructured offers PDF strategies such as fast and hi_res.

Limitations worth detecting early

A bounding box is not the same as a paragraph. PyMuPDF extracts blocks that are approximately paragraphs, but the PDF format does not guarantee that these units correspond to the structure the document's author intended. Headers, footers, notes, formulas, and tables also require extraction decisions.

The band-based strategy is a verifiable starting point, not a universal parser. If the order changes between pages, there are more than two columns, or floating elements appear, inspect the bounding boxes and adjust the logic or switch to layout segmentation. If you produce the PDFs that enter the corpus, tagged PDFs can include a logical structure with an explicit reading sequence.

Before changing embeddings or reindexing, look at the text that actually comes out of the PDF. If a sentence crosses columns during extraction, the problem is already in the material that will reach RAG.

Sources: DEV Community, pypdf documentation, Unstructured documentation, PyMuPDF documentation