Phase 2: Regulated RAG & ComplianceKD 4

Docling vs PyMuPDF4LLM for Financial Tables: Multi-Column Statements and Tax Audit PDFs

Docling vs PyMuPDF4LLM on financial statements and tax audit PDFs: speed, merged-cell table handling, licensing, Python code and local RAG tips.

Published

Every private RAG system for finance, tax or audit work starts with the same unglamorous problem: getting tables out of PDFs without scrambling the numbers. A balance sheet with two comparative-year columns, a trial balance with merged account-group headers, or a scanned tax schedule will expose the weaknesses of any parser fast, and a single misaligned cell can turn a correct answer into a confidently wrong one.

Two open-source libraries dominate local PDF-to-Markdown pipelines: IBM's Docling and PyMuPDF4LLM. They take fundamentally different approaches, and the right choice depends on your documents, your hardware and your licensing constraints. This guide compares them head to head on the things that matter for financial documents, gives working code for both, and shows how to wire the output into a local RAG pipeline.

How each library works

PyMuPDF4LLM is a thin layer over PyMuPDF (MuPDF's Python bindings). It reads the PDF's own text objects and drawing commands, groups text into blocks by position, and detects tables geometrically: either from ruling lines (lines_strict, the default) or from text alignment (text). There is no machine-learning model involved, which makes it extremely fast and fully deterministic, and it runs anywhere Python does.

Docling treats each page as an image and a set of text cells. A layout model segments the page into regions (text, tables, headers, figures), and a dedicated table-structure model, TableFormer, predicts the row and column grid of each table, including cells that span several rows or columns. The predicted structure is then matched back to the PDF's text. This costs far more compute, but it can recover structure that isn't explicit in the PDF's drawing commands.

Head-to-head comparison

CriterionDoclingPyMuPDF4LLM
Table detectionML layout modelRuling lines or text alignment
Table structureTableFormer predicts the gridGeometric heuristics
Merged / spanning cellsPredicts row and column spansNo span model: merged headers are repeated or left empty
Borderless tablesHandled by the layout and structure modelsNeeds the text strategy; less reliable
Multi-column page layoutsLayout model sets reading orderPositional sorting; can interleave columns
Scanned PDFsBuilt-in OCR (EasyOCR, Tesseract, RapidOCR)Requires Tesseract via PyMuPDF's OCR support
SpeedSeconds per page on CPU; much faster with a GPUTypically well under a second per page on CPU
HardwareCPU works; GPU recommended at volumeCPU only
OutputStructured document model, Markdown, JSON, DataFramesMarkdown, plus per-page table data
LicenseMITAGPL-3.0 (commercial license available from Artifex)

Two rows deserve special attention for B2B teams.

Merged cells are where financial tables break most parsers. Statements routinely group line items under spanning headers ("Current assets", "Non-current liabilities") and put two fiscal years under a merged "Group" or "Company" header. If the parser doesn't model spans, the header relationship is lost, and a downstream LLM has to guess which column a number belongs to.

Licensing can decide the question before any benchmark does. PyMuPDF is dual-licensed under the AGPL-3.0 and a commercial license. If you distribute software that includes it, or offer it to users over a network, AGPL obligations may apply unless you buy a commercial license. Docling's MIT license has no such conditions. Have your legal team confirm how either license applies to your deployment.

Benchmark methodology

Speed and accuracy claims for document parsers depend heavily on the test documents, so here is the method we use, including the harness itself, so you can reproduce the comparison on your own PDFs. Results on vendor demo documents rarely predict results on your own statements.

Test set. Four document classes that stress different failure modes:

  1. Digitally generated annual-report statements with comparative-year columns
  2. Trial balances with merged account-group headers
  3. Two-column audit-report layouts with tables embedded in the text flow
  4. Scanned tax schedules (300 DPI) requiring OCR

Ground truth. Each table is hand-labeled as a CSV of cell values, including the value under every merged header.

Metrics.

  • Seconds per page, measured after a warm-up run so model loading isn't counted.
  • Cell accuracy: the share of ground-truth cells whose normalized value appears in the same row and column of the extracted table.
  • Numeric fidelity: the share of numeric cells reproduced exactly, after stripping thousands separators and normalizing parentheses to negative signs.

Measured results on our reference set will be published here once the lab run is complete. Until then, the harness below lets you generate the same numbers for your own documents.

Extracting tables with Docling

Install Docling (it downloads its layout and TableFormer models on first run, so run it once on a connected machine before moving to an air-gapped host):

pip install docling pandas

Configure the pipeline for maximum table fidelity. TableFormerMode.ACCURATE uses the larger structure model, and turning off cell matching lets TableFormer's predicted cells override the PDF's text cells. That helps when adjacent figures in a statement were typeset as a single text run.

from docling.datamodel.base_models import InputFormat
from docling.datamodel.pipeline_options import PdfPipelineOptions, TableFormerMode
from docling.document_converter import DocumentConverter, PdfFormatOption

pipeline_options = PdfPipelineOptions(do_table_structure=True, do_ocr=False)
pipeline_options.table_structure_options.mode = TableFormerMode.ACCURATE
pipeline_options.table_structure_options.do_cell_matching = False

converter = DocumentConverter(
    format_options={InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options)}
)

result = converter.convert("financial-statements.pdf")
doc = result.document

for i, table in enumerate(doc.tables):
    df = table.export_to_dataframe(doc=doc)
    df.to_csv(f"docling_table_{i}.csv", index=False)
    print(f"Table {i}: {df.shape[0]} rows x {df.shape[1]} columns")

# Full document as Markdown for the RAG pipeline
markdown = doc.export_to_markdown()

For scanned tax schedules, set do_ocr=True. If you have an NVIDIA GPU, Docling uses it automatically through PyTorch, which is the single biggest speed lever.

Extracting tables with PyMuPDF4LLM

pip install pymupdf4llm pandas

to_markdown handles the whole document; page_chunks=True returns one dictionary per page with metadata attached, which is convenient for citations:

import pymupdf4llm

pages = pymupdf4llm.to_markdown(
    "financial-statements.pdf",
    page_chunks=True,
    table_strategy="lines_strict",  # try "text" for borderless tables
)

for page in pages:
    meta = page["metadata"]
    page_number = meta.get("page_number", meta.get("page"))  # key name varies by version
    print(page_number, len(page.get("tables", [])), "tables")

markdown = "\n\n".join(page["text"] for page in pages)

The keys in each page dictionary have changed between PyMuPDF4LLM releases (newer layout mode, for example, returns page_boxes instead of tables), so print page.keys() once against your installed version before building on them.

When you need the cell grid itself rather than Markdown, drop down to PyMuPDF's table finder:

import pymupdf

doc = pymupdf.open("financial-statements.pdf")
for page in doc:
    for i, table in enumerate(page.find_tables(strategy="lines_strict").tables):
        df = table.to_pandas()
        df.to_csv(f"pymupdf_p{page.number + 1}_t{i}.csv", index=False)

If a statement has no ruling lines and lines_strict finds nothing, switch to strategy="text" and check the result carefully: text-alignment detection is sensitive to how the PDF was typeset.

The benchmark harness

This script times both parsers on a folder of PDFs and scores numeric fidelity against hand-labeled CSVs. It keeps the scoring deliberately simple: cells are compared by position after normalization.

import re
import sys
import time
from pathlib import Path

import pandas as pd
import pymupdf
from docling.document_converter import DocumentConverter


def normalize(value) -> str:
    text = str(value).strip().replace(",", "").replace("−", "-")
    if re.fullmatch(r"\(\d+(\.\d+)?\)", text):  # (1,234) -> -1234
        text = "-" + text[1:-1]
    return text


def numeric_fidelity(extracted: pd.DataFrame, truth: pd.DataFrame) -> float:
    hits = total = 0
    for r in range(len(truth)):
        for c in range(truth.shape[1]):
            expected = normalize(truth.iat[r, c])
            if not re.fullmatch(r"-?\d+(\.\d+)?", expected):
                continue  # score numeric cells only
            total += 1
            try:
                hits += normalize(extracted.iat[r, c]) == expected
            except IndexError:
                pass
    return hits / total if total else float("nan")


def run_docling(pdf: Path, converter: DocumentConverter) -> list[pd.DataFrame]:
    doc = converter.convert(pdf).document
    return [t.export_to_dataframe(doc=doc) for t in doc.tables]


def run_pymupdf(pdf: Path) -> list[pd.DataFrame]:
    with pymupdf.open(pdf) as doc:
        return [t.to_pandas() for page in doc for t in page.find_tables().tables]


def main(folder: str) -> None:
    converter = DocumentConverter()
    for pdf in sorted(Path(folder).glob("*.pdf")):
        truth_files = sorted(pdf.parent.glob(f"{pdf.stem}.truth*.csv"))
        truths = [pd.read_csv(f, header=None, dtype=str) for f in truth_files]
        pages = pymupdf.open(pdf).page_count

        for name, parse in (("docling", lambda p: run_docling(p, converter)), ("pymupdf", run_pymupdf)):
            parse(pdf)  # warm-up, excluded from timing
            start = time.perf_counter()
            tables = parse(pdf)
            seconds = (time.perf_counter() - start) / pages
            scores = [
                numeric_fidelity(t.astype(str), truth)
                for t, truth in zip(tables, truths)
            ]
            mean = sum(scores) / len(scores) if scores else float("nan")
            print(f"{pdf.name:40} {name:8} {seconds:6.2f} s/page  fidelity={mean:.1%}")


if __name__ == "__main__":
    main(sys.argv[1] if len(sys.argv) > 1 else "pdfs")

Name ground-truth files after their PDF (balance-sheet.truth1.csv, balance-sheet.truth2.csv, in table order) and run:

python benchmark_parsers.py ./pdfs

Position-based scoring is strict: if a parser inserts an extra header row, every cell below it shifts and scores as wrong. That is intentional, because a shifted row is exactly the error that corrupts downstream answers. For a gentler view, compare tables after aligning on the first column of line-item labels.

Local RAG integration tips

Parsing is only half the job. How you chunk and index tables determines whether retrieval finds the right numbers.

Keep tables whole. Never let a fixed-size text splitter cut a table in half. Chunk tables as single units, and if a table is too large, split by row groups while repeating the header row in every chunk.

Index tables twice. Store a Markdown rendering for embedding and retrieval, and the structured version (CSV or JSON) alongside it. When the LLM needs to compute something, such as a year-over-year change, pass the structured version rather than asking it to re-read Markdown.

Use structure-aware chunking. Docling ships a HybridChunker that respects document structure and token limits:

from docling.chunking import HybridChunker

chunker = HybridChunker()
for chunk in chunker.chunk(dl_doc=doc):
    text = chunker.contextualize(chunk=chunk)  # prepends section headings
    # embed `text` with your local embedding model and write it to the vector store

Carry provenance metadata. Store the source file hash, page number and table index with every chunk. Auditors and reviewers will ask where a figure came from, and the answer should be one click away.

Don't let the LLM do arithmetic unchecked. For totals and reconciliations, have the model emit structured output and verify it with code. A deterministic check that line items sum to the reported total catches most extraction errors before anyone relies on them.

Stay offline end to end. Both libraries run fully locally once their models are cached. Pair them with a local embedding model and a self-hosted vector store, and client financial data never leaves your network. That matters for ISO 27001 scope and for confidentiality obligations to audit clients.

Which should you choose?

Choose Docling when tables are complex: merged headers, borderless layouts, multi-column pages or scanned documents. It is the safer default for audit and tax workflows where a misplaced figure is costly, and its MIT license is easy to approve. Budget for a GPU if you process documents at volume.

Choose PyMuPDF4LLM when documents are digitally generated with clear ruling lines, throughput matters more than edge-case accuracy, and the AGPL (or a commercial license) works for your deployment. It is also an excellent first pass: route the pages where its table detection looks doubtful to Docling, and you get most of the speed with most of the accuracy.

Whichever you pick, run the harness on a sample of your own documents before committing. For a complete reference architecture that combines Docling, a local LLM and deterministic reconciliation, see our tax audit PDF extraction blueprint.