Automate Document Processing with Local AI: Parse Invoices with Docling and Ollama

Photograph of an Austrian invoice with revenue stamps Photo: Asurnipal via Wikimedia Commons (CC BY-SA 4.0)

Every business sits on a pile of PDFs — invoices, contracts, reports — and someone is paid to retype their contents into spreadsheets. This guide automates that: a small local pipeline that watches a folder, parses each PDF with Docling (IBM’s open-source document understanding toolkit), extracts structured fields with a local LLM, and writes clean rows to a CSV. No cloud APIs, no per-page fees, and the documents never leave your machine.

Our worked example processes invoices: invoice number, date, vendor, line items, and totals. But the pattern — parse, extract, tabulate — fits contracts, CVs, and lab reports just as well. It’s aimed at developers comfortable with Python.

Prerequisites

  • OS: Linux or macOS (Windows via WSL2).
  • Python: 3.10–3.12.
  • RAM: 8 GB minimum. Docling downloads layout-analysis models on first run (~1–2 GB).
  • Ollama: installed with a capable small model pulled (ollama pull qwen2.5:7b). The extractor calls it over HTTP, as in our Ollama guide.

Step 1: Install Docling and parse your first PDF

pip install docling
python3 - <<'EOF'
from docling.document_converter import DocumentConverter

converter = DocumentConverter()
result = converter.convert("invoice.pdf")
md = result.document.export_to_markdown()
print(md[:1500])
EOF

The first run downloads Docling's models, so expect a wait. What comes back is the document as structured Markdown — headings, tables converted to Markdown tables, reading order preserved. Compare that with what pypdf gives you on the same file (often a jumble of text fragments) and you'll see why this step matters: the LLM in step 3 can only extract what the parser understood. Docling also ships a CLI — docling invoice.pdf --to md — handy for quick checks.

One design decision to make early: how much structure to preserve. For extraction, flat Markdown is ideal — the model reads it like a person would. But if you later feed these documents into a RAG pipeline, keep Docling's heading hierarchy intact and chunk on headings rather than fixed character counts; section-aware chunks retrieve far better than arbitrary ones. Either way, save the Markdown alongside the PDF — re-parsing is the slowest part of the pipeline, and you'll re-run extraction many times while iterating on prompts.

Step 2: Extract structured fields with a local LLM

Markdown is readable; a CSV needs fields. We ask the local model to return JSON with exactly the schema we want, then validate it in Python:

import json, requests

SCHEMA_PROMPT = """Extract the following fields from the invoice text below.
Return ONLY valid JSON with these keys:
{"invoice_number": string, "invoice_date": string (YYYY-MM-DD),
 "vendor": string, "currency": string, "grand_total": number,
 "line_items": [{"description": string, "qty": number, "amount": number}]}

Invoice text:
---
{markdown}
---"""

def extract_fields(markdown: str) -> dict:
    r = requests.post(
        "http://localhost:11434/api/generate",
        json={
            "model": "qwen2.5:7b",
            "prompt": SCHEMA_PROMPT.format(markdown=markdown[:12000]),
            "stream": False,
            "format": "json",          # constrains output to valid JSON
            "options": {"temperature": 0.0},
        },
        timeout=300,
    )
    r.raise_for_status()
    data = json.loads(r.json()["response"])
    # Minimal validation: fail loudly on missing fields
    for key in ("invoice_number", "invoice_date", "vendor", "grand_total"):
        if key not in data:
            raise ValueError(f"Missing field: {key}")
    return data

Three things make this reliable: temperature: 0.0 (extraction isn't creative writing), format: "json" (Ollama constrains decoding to valid JSON), and the validation step that refuses to silently accept garbage. Truncating the Markdown to ~12k characters keeps slow invoices fast; Docling puts the important content early anyway.

Step 3: Wire it into a folder-watching pipeline

Create process_invoices.py tying it together — parse, extract, append to CSV, move processed files aside:

import csv, json
from pathlib import Path
from docling.document_converter import DocumentConverter

INBOX, DONE = Path("inbox"), Path("done")
INBOX.mkdir(exist_ok=True); DONE.mkdir(exist_ok=True)
converter = DocumentConverter()

rows = []
for pdf in sorted(INBOX.glob("*.pdf")):
    print(f"Processing {pdf.name} ...")
    md = converter.convert(str(pdf)).document.export_to_markdown()
    try:
        data = extract_fields(md)   # from Step 2
    except Exception as e:
        print(f"  FAILED: {e} — leaving file for manual review")
        continue
    for item in data.get("line_items", []):
        rows.append({
            "file": pdf.name,
            "invoice_number": data["invoice_number"],
            "invoice_date": data["invoice_date"],
            "vendor": data["vendor"],
            "currency": data.get("currency", ""),
            "grand_total": data["grand_total"],
            "item": item.get("description", ""),
            "qty": item.get("qty", ""),
            "amount": item.get("amount", ""),
        })
    pdf.rename(DONE / pdf.name)

with open("invoices.csv", "w", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=rows[0].keys())
    writer.writeheader(); writer.writerows(rows)
print(f"Wrote {len(rows)} line items to invoices.csv")

Drop PDFs into inbox/, run the script, open invoices.csv. For a hands-off version, run it on a schedule (cron) or add a file watcher — the logic doesn't change.

Step 4: Check quality before trusting it

Run ten representative invoices through and compare the CSV against manual entry. Track two numbers: field accuracy (are the values right?) and failure rate (how often does it refuse or produce invalid JSON?). Common fixes: invoices with unusual layouts parse worse — Docling's table structure helps, but truly exotic templates may need a per-vendor prompt tweak; and grand_total errors usually mean the model read a subtotal — adding "the final total at the bottom of the invoice" to the prompt fixes most of them.

Running it on a schedule and watching it

A script you run manually is a prototype; a pipeline runs itself. The simplest production shape: a cron job that runs process_invoices.py every hour against a shared inbox folder, appending to a cumulative CSV (switch "w" to "a" and write the header only if the file is new). Add a dead-letter pattern — files that fail extraction move to a review/ folder instead of blocking the batch, and a daily email or chat message summarises what was processed and what needs eyes.

Log three things per file: processing time, the model used, and the extracted grand total. The totals log is your canary — a sudden spike in failures or a drift in average invoice value tells you a vendor changed their template before anyone complains. And version your prompts: keep SCHEMA_PROMPT in a separate file with a version number, because you will iterate on it, and "which prompt produced this CSV" is a question you'll eventually need to answer.

Where things go wrong

Docling's first run is slow / downloads fail. It fetches model weights from Hugging Face on first use. If you're offline or behind a proxy, pre-download once with network access — subsequent runs are fully local.

Scanned PDFs produce empty Markdown. Pure image scans need OCR. Enable Docling's OCR pipeline for those files (slower, but it works), or pre-process with an OCR tool.

JSON parse errors from the LLM. Even with format: "json", small models occasionally emit trailing commentary. Our validation catches it; the practical fix is a retry loop (2–3 attempts) before flagging for manual review.

Hallucinated line items. If the invoice has no itemised table, the model may invent one. Guard with: "line_items": [] is acceptable — add "use an empty list if there are no line items" to the prompt.

Wrap-up

You've built the canonical local document pipeline: Docling for layout-aware parsing, a local LLM for schema-constrained extraction, CSV out. The same architecture powers RAG ingestion — in fact, Docling's Markdown output is an excellent chunking source for a knowledge base. Start with one document type, measure accuracy, then expand; automation you can't measure is just hope with extra steps.

Featured image: Asurnipal via Wikimedia Commons (CC BY-SA 4.0)

Leave a Reply

Your email address will not be published. Required fields are marked *