Dokumenten-Extraktion lokal: Rechnungen und Belege mit Vision-LLM und OCR datenschutzgerecht auslesen
Rechnungen und Belege lokal verarbeiten: Mit PaddleOCR, Docling und dem Vision-LLM Qwen2.5-VL erzeugen Sie strukturierte JSON-Daten aus PDFs und Scans, validiert mit Pydantic und angebunden an Paperless-ngx, ohne Cloud.
Geprüft am 30.09.2026 · für PaddleOCR 3.7.0
WerbelinksMit * markierte Links sind Werbelinks: Bei einem Kauf erhalten wir eine Provision, der Preis bleibt gleich. Als Amazon-Partner verdiene ich an qualifizierten Verkäufen. Mehr dazu

Rechnungen von Hand abzutippen kostet Zeit und erzeugt Tippfehler, Cloud-Dienste übertragen personenbezogene Daten an Dritte. Diese Anleitung zeigt einen lokalen Workflow, der aus PDFs und Scans strukturierte JSON-Daten erzeugt: mit OCR (Texterkennung) per PaddleOCR 3, Docling, einem Vision-LLM (Sprachmodell, das Bilder liest) wie Qwen2.5-VL und zweistufiger Validierung mit Pydantic. Kein Dokument verlässt dabei Ihren Server.
Voraussetzungen
- Linux-Server oder -PC (x86-64), mindestens 4 CPU-Kerne, 16 GB RAM und 30 GB freier Speicher für Modelle; Python 3.10 oder neuer
- Für das Vision-LLM: NVIDIA-GPU mit mindestens 8 GB VRAM für Qwen2.5-VL-7B über Ollama (4-Bit-quantisiert, 6 GB Download)
- Ollama (ollama.com)
- Paperless-ngx ab Version 2.0 (für Schritt 6)
- Nur für Mistral OCR: API-Key und abgeschlossener Auftragsverarbeitungsvertrag (Data Processing Addendum, DPA) unter console.mistral.ai
Schritt 1: Wählen Sie die Werkzeuge: lokal oder Cloud
Wählen Sie die Kombination nach Hardware und Dokumenten:
| Tool | Typ | CPU-tauglich | VRAM | Lizenz | DSGVO lokal |
|---|---|---|---|---|---|
| PaddleOCR 3.7 (PP-StructureV3) | OCR + Layout | Ja (1–1,5 s/Seite) | Keiner | Apache 2.0 | Ja |
| Docling 2.131 (IBM) | PDF/Bild zu JSON | Ja | Keiner | Apache 2.0 | Ja |
| GraniteDocling-258M | Spezialisiertes Dok.-Modell | Ja (bis 30× schneller) | Gering | Apache 2.0 | Ja |
| Qwen2.5-VL-7B (Ollama) | Vision-LLM | Langsam (10–30×) | Ollama 4-Bit: 8 GB; BF16: ca. 17 GB | Apache 2.0 | Ja |
| Qwen2.5-VL-72B | Vision-LLM | Nein | Ollama 4-Bit: ca. 49 GB; BF16: ca. 145 GB | Qwen License | Ja |
| GLM-4.5V / GLM-OCR | Vision-LLM + Thinking | Langsam | Modellabhängig | MIT | Ja |
| Mistral OCR API | Cloud-SaaS | N/A | N/A | Proprietär | Nein (Cloud, nur mit AV-Vertrag) |
Ohne GPU: PaddleOCR für OCR und Layout, danach ein kleines LLM (z. B. qwen2.5:3b über Ollama) für die JSON-Strukturierung. Mit GPU (ab 8 GB VRAM): Qwen2.5-VL-7B erledigt beides in einem Schritt. Cloud als Ergänzung (z. B. für Handschrift): Mistral OCR mit abgeschlossenem AV-Vertrag.
Verifizieren: nvidia-smi zeigt bei GPU-Einsatz den freien VRAM; er muss über dem Wert in der Tabelle liegen. Ohne GPU wählen Sie die CPU-Variante.
Schritt 2: Installieren Sie die Abhängigkeiten
Installieren Sie die Pakete in einer virtuellen Umgebung, damit sie nicht mit Systempaketen kollidieren. pdf2image braucht zusätzlich Poppler (apt install poppler-utils).
python3 -m venv ~/belege-venv && source ~/belege-venv/bin/activate
# Basis-Pakete (alle Varianten)
pip install docling paddlepaddle "paddleocr[doc-parser]" pydantic opencv-python pdf2image pdfplumber httpx
# Nur für Qwen2.5-VL direkt über Transformers (GPU)
pip install transformers accelerate qwen-vl-utils
# Nur für Mistral OCR (Cloud)
pip install mistralai
# Ollama separat installieren (https://ollama.com), dann das Modell laden:
ollama pull qwen2.5vl:7b
Verifizieren: python -c "import docling, paddleocr, pydantic; print(paddleocr.__version__)" gibt ohne Fehler eine Version ab 3.0 aus; ollama list zeigt qwen2.5vl:7b.
Schritt 3: PDF vorprüfen und Bild vorverarbeiten
Eingescannte PDFs haben keinen Textlayer und liefern leeren Text. Prüfen Sie das zuerst und bereiten Sie schlechte Scans mit OpenCV auf (Binarisierung, Korrektur der Schieflage):
# text_layer_check.py
import pdfplumber
from pdf2image import convert_from_path
import cv2
import numpy as np
def check_text_layer(pdf_path: str) -> bool:
with pdfplumber.open(pdf_path) as pdf:
text = pdf.pages[0].extract_text() or ''
return len(text.strip()) > 50 # Heuristik: weniger = Bild-PDF
def preprocess_scan(image_path: str, output_path: str, target_dpi: int = 300):
img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)
# Otsu-Binarisierung
_, binary = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
# Deskewing (Schieflagen-Korrektur)
coords = np.column_stack(np.where(binary < 255))
angle = cv2.minAreaRect(coords)[-1]
if angle < -45:
angle = -(90 + angle)
else:
angle = -angle
(h, w) = binary.shape[:2]
M = cv2.getRotationMatrix2D((w // 2, h // 2), angle, 1.0)
rotated = cv2.warpAffine(binary, M, (w, h), flags=cv2.INTER_CUBIC,
borderMode=cv2.BORDER_REPLICATE)
cv2.imwrite(output_path, rotated)
print(f'Vorverarbeitung abgeschlossen: {output_path}')
# Beispielaufruf
if not check_text_layer('rechnung.pdf'):
images = convert_from_path('rechnung.pdf', dpi=300)
images[0].save('rechnung_seite1.png')
preprocess_scan('rechnung_seite1.png', 'rechnung_clean.png')
Verifizieren: python text_layer_check.py meldet bei Scans „Vorverarbeitung abgeschlossen“; rechnung_clean.png zeigt schwarzen Text auf weißem Grund, gerade ausgerichtet.
Schritt 4a: OCR und Layout-Analyse mit PaddleOCR PP-StructureV3
PP-StructureV3 erkennt Textblöcke und Tabellen in einem Durchgang und läuft ohne GPU. Ab PaddleOCR 3 ersetzt die Klasse PPStructureV3 mit predict() die frühere PPStructure-Schnittstelle.
# paddleocr_extract.py
from paddleocr import PPStructureV3
pipeline = PPStructureV3(lang='de') # deutsches Erkennungsmodell
for res in pipeline.predict('rechnung_clean.png'):
res.save_to_json(save_path='output') # Layout, Text, Tabellen als JSON
res.save_to_markdown(save_path='output') # Markdown als LLM-Eingabe
Beim ersten Aufruf lädt PaddleOCR die Modelle. Das Markdown geben Sie anschließend einem LLM mit dem Prompt aus Schritt 4c.
Verifizieren: Im Ordner output/ liegen eine JSON- und eine Markdown-Datei; die Markdown-Datei enthält Rechnungsnummer und Positionstabelle lesbar mit Umlauten.
Schritt 4b: PDF-Extraktion mit Docling (IBM)
Für PDFs mit Textlayer ist Docling die direktere Lösung, ohne Umweg über Bilder:
# docling_extract.py
from docling.document_converter import DocumentConverter
import json
converter = DocumentConverter()
result = converter.convert('rechnung.pdf')
# Als JSON exportieren
doc_json = result.document.export_to_json()
print(json.dumps(json.loads(doc_json), ensure_ascii=False, indent=2))
# Alternativ als Markdown (für LLM-Weiterverarbeitung)
print(result.document.export_to_markdown())
Verifizieren: Die Markdown-Ausgabe enthält die Positionstabelle als Markdown-Tabelle mit korrekten Beträgen.
Schritt 4c: Vision-LLM mit Qwen2.5-VL-7B via Ollama
Über Ollama sprechen Sie das Modell per REST-API an, ohne Modellverwaltung in Python. Es liest das Rechnungsbild und liefert direkt JSON:
# Modell laden (einmalig, ca. 6 GB Download)
ollama pull qwen2.5vl:7b
# ollama_invoice.py
import base64, json, httpx
def image_to_base64(path: str) -> str:
with open(path, 'rb') as f:
return base64.b64encode(f.read()).decode()
prompt = """Extrahiere alle Felder aus dieser Rechnung als valides JSON-Objekt.
Pflichtfelder: rechnungsnummer, rechnungsdatum (ISO 8601: YYYY-MM-DD),
lieferant, waehrung (ISO-4217), positionen (Liste mit beschreibung/menge/einzelpreis/gesamtpreis),
nettobetrag, mwst_betrag, bruttobetrag.
Optionale Felder: iban, bestellnummer.
Nur das JSON-Objekt ausgeben, kein erklärender Text."""
payload = {
'model': 'qwen2.5vl:7b',
'prompt': prompt,
'images': [image_to_base64('rechnung_clean.png')],
'stream': False,
'format': 'json'
}
resp = httpx.post('http://localhost:11434/api/generate', json=payload, timeout=120)
result = resp.json()
invoice_data = json.loads(result['response'])
print(json.dumps(invoice_data, ensure_ascii=False, indent=2))
Alternativ laden Sie Qwen2.5-VL direkt über Transformers. Das gibt mehr Kontrolle über Quantisierung und Batch-Verarbeitung, braucht aber für das 7B-Modell in BF16 rund 17 GB VRAM, mit 4-Bit-Quantisierung (bitsandbytes) etwa 6 GB.
Verifizieren: Das Skript gibt ein JSON-Objekt mit allen Pflichtfeldern aus. Vergleichen Sie Rechnungsnummer und Bruttobetrag mit dem Original.
Schritt 4d: Mistral OCR API als Cloud-Ergänzung
Für Belege, an denen lokale Modelle scheitern (vor allem Handschrift), ist Mistral OCR eine Cloud-Ergänzung. Die Dokumente gehen an Mistral AI; schließen Sie vorher den Auftragsverarbeitungsvertrag (Data Processing Addendum) ab. Seit Version 2 des Python-SDK importieren Sie Mistral aus mistralai.client und übergeben die Parameter als Schlüsselwörter.
# mistral_ocr.py
from mistralai.client import Mistral
import base64, os, pathlib
client = Mistral(api_key=os.environ['MISTRAL_API_KEY'])
# Dokument als Base64
doc_bytes = pathlib.Path('rechnung.pdf').read_bytes()
doc_b64 = base64.b64encode(doc_bytes).decode()
response = client.ocr.process(
model='mistral-ocr-latest',
document={
'type': 'document_url',
'document_url': f'data:application/pdf;base64,{doc_b64}',
},
pages=[0, 1], # nur die ersten beiden Seiten
include_image_base64=False,
)
for page in response.pages:
print(page.markdown)
Verifizieren: Nach export MISTRAL_API_KEY=… gibt das Skript je Seite Markdown aus; ein Fehler 401 bedeutet einen ungültigen API-Key.
Schritt 5: Zweistufige Validierung mit Pydantic v2
LLMs erfinden gelegentlich Werte. Pydantic v2 prüft deshalb erst Struktur und Pflichtfelder, dann die Summen auf den Cent. Führen Sie Beträge immer als decimal.Decimal, nie als float: 0.1 + 0.2 ergibt in Python 0.30000000000000004, und Summenvergleiche schlagen dann fehl.
# pydantic_invoice.py
from pydantic import BaseModel, field_validator, model_validator
from decimal import Decimal, ROUND_HALF_UP
from datetime import date
from typing import Optional, List
import json
class LineItem(BaseModel):
beschreibung: str
menge: Decimal
einzelpreis: Decimal
gesamtpreis: Decimal
@model_validator(mode='after')
def check_line_total(self):
expected = (self.menge * self.einzelpreis).quantize(
Decimal('0.01'), rounding=ROUND_HALF_UP
)
if expected != self.gesamtpreis:
raise ValueError(
f'Zeilenpreis falsch: {self.menge} x {self.einzelpreis} != {self.gesamtpreis}'
)
return self
class Invoice(BaseModel):
rechnungsnummer: str # Pflichtfeld
rechnungsdatum: date # Pflichtfeld
lieferant: str # Pflichtfeld
waehrung: str = 'EUR' # ISO-4217
positionen: List[LineItem]
nettobetrag: Decimal
mwst_betrag: Decimal
bruttobetrag: Decimal
iban: Optional[str] = None
@field_validator('waehrung')
@classmethod
def validate_currency(cls, v):
if len(v) != 3 or not v.isalpha():
raise ValueError(f'Ungültiger ISO-4217-Code: {v}')
return v.upper()
@model_validator(mode='after')
def check_total(self):
summe = sum(p.gesamtpreis for p in self.positionen)
expected_brutto = (summe + self.mwst_betrag).quantize(
Decimal('0.01'), rounding=ROUND_HALF_UP
)
if expected_brutto != self.bruttobetrag:
raise ValueError(
f'Bruttosumme stimmt nicht: {expected_brutto} != {self.bruttobetrag}'
)
return self
# Verwendung
raw_data = {
'rechnungsnummer': 'RE-2025-0042',
'rechnungsdatum': '2025-03-15',
'lieferant': 'Muster GmbH',
'waehrung': 'EUR',
'positionen': [
{'beschreibung': 'Servermiete', 'menge': '1', 'einzelpreis': '99.00', 'gesamtpreis': '99.00'}
],
'nettobetrag': '99.00',
'mwst_betrag': '18.81',
'bruttobetrag': '117.81'
}
try:
invoice = Invoice.model_validate(raw_data)
print('Validierung erfolgreich:', invoice.model_dump_json(indent=2))
except Exception as e:
print('Validierungsfehler:', e)
Verifizieren: Das Beispiel meldet „Validierung erfolgreich“. Ändern Sie bruttobetrag testweise auf '117.80': Pydantic meldet dann „Bruttosumme stimmt nicht“.
Schritt 6: Paperless-ngx automatisch befüllen
Paperless-ngx führt nach jedem Import ein Post-Consumption-Skript aus. Den OCR-Text übergibt es nicht als Umgebungsvariable; das Skript liest ihn über die REST-API und schreibt die Rechnungsnummer zurück. Grund-Setup: Paperless-ngx Dokumentenverwaltung mit OCR und Docker einrichten.
# In paperless.conf bzw. bei Docker in docker-compose.env:
PAPERLESS_POST_CONSUME_SCRIPT=/opt/scripts/post-consume.sh
# API-Token des Paperless-Benutzers (Profil > API-Auth-Token)
PAPERLESS_TOKEN=IHR_API_TOKEN
#!/bin/bash
# /opt/scripts/post-consume.sh
# Paperless setzt u. a. DOCUMENT_ID; den OCR-Text holt das Skript per API (benötigt jq)
API="http://localhost:8000/api"
CONTENT=$(curl -s -H "Authorization: Token $PAPERLESS_TOKEN" "$API/documents/$DOCUMENT_ID/" | jq -r .content)
# Rechnungsnummer per Regex aus dem OCR-Text lesen
INV_NR=$(echo "$CONTENT" | grep -oP 'Rechnungsnr[.:\s]+\K[A-Z0-9-]+' | head -n1)
if [ -n "$INV_NR" ]; then
curl -s -X PATCH \
-H "Authorization: Token $PAPERLESS_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"custom_fields\": [{\"field\": 1, \"value\": \"$INV_NR\"}]}" \
"$API/documents/$DOCUMENT_ID/"
echo "Rechnungsnummer gesetzt: $INV_NR"
fi
Die ID des Custom Fields (hier 1) ermitteln Sie per GET /api/custom_fields/. Machen Sie das Skript mit chmod +x ausführbar; bei Docker muss es im Container liegen und jq dort verfügbar sein. Workflows in Paperless-ngx ergänzen das Skript um regelbasierte Zuordnungen.
Verifizieren: Importieren Sie eine Testrechnung. Das Webserver-Log zeigt „Rechnungsnummer gesetzt: …“, und das Dokument trägt den Wert im Custom Field.
Troubleshooting / Typische Fehler
- Leerer Text bei Bild-PDFs: Kein Textlayer vorhanden. Lösung: Prüfung aus Schritt 3, dann OCR-Weg.
- Schlechte Erkennung bei Scans unter 150 DPI: Lösung: Mit 300 DPI rendern und vorverarbeiten (Schritt 3).
- Verbundene Tabellenzellen: PP-StructureV3 und Tesseract verlieren bei mehrspaltigen Layouts Zellgrenzen. Lösung: Docling, GraniteDocling-258M oder Qwen2.5-VL mit Tabellen-Prompt.
- Float-Rundungsfehler bei der Summenprüfung: Siehe Schritt 5. Lösung:
decimal.DecimalmitROUND_HALF_UP. - Mehrdeutiges Datum: „01.03.2025“ ist in den USA der 3. Januar. Lösung: Im Prompt ISO 8601 verlangen und uneindeutige Fälle manuell prüfen.
- VRAM-Engpass beim 72B-Modell: Qwen2.5-VL-72B braucht selbst 4-Bit-quantisiert rund 49 GB. Lösung: 7B-Modell verwenden, bei Bedarf mit 4-Bit-Quantisierung.
- Handschrift und Unterschriften: Lokale Modelle erkennen Handschrift unzuverlässig. Lösung: Manuell prüfen oder Mistral OCR nutzen (Schritt 4d).
- Mehrseitige PDFs mit AGB-Seiten: Zusätzliche Seiten verlängern die Laufzeit und verfälschen die Extraktion. Lösung: Nur Seite 1 bis 2 verarbeiten (bei Mistral OCR per
pages). - Unterschiedliche Layouts je Lieferant: „Rechnungsnr.“, „Invoice No.“, „Re.-Nr.“. Lösung: Few-Shot-Prompting (3 bis 5 Beispiele im Prompt) oder Fine-Tuning auf 50 bis 200 eigenen Rechnungen.
Häufige Fragen
Welches Tool ist für KMU ohne GPU am besten geeignet?
PaddleOCR (PP-StructureV3) mit einem kleinen LLM wie qwen2.5:3b oder llama3.2:3b über Ollama, das den OCR-Text in das JSON-Schema überführt.
Wie verarbeite ich Belege datenschutzgerecht ohne Cloud?
Mit der lokalen Pipeline dieser Anleitung verlässt kein Dokument Ihren Server. Die übrigen DSGVO-Pflichten (Zugriffsschutz, Löschfristen, Verzeichnis der Verarbeitungstätigkeiten) bleiben bestehen. Mistral OCR setzt einen Auftragsverarbeitungsvertrag voraus; für besonders sensible Daten (Personal- oder Gesundheitsdaten) empfiehlt sich ausschließlich die lokale Pipeline. Mehr dazu: DSGVO TOMs und Auftragsverarbeitung in der Praxis.
Wie integriere ich die extrahierten Daten in Paperless-ngx?
Per Post-Consumption-Skript und REST-API, siehe Schritt 6.
Wie gut funktioniert das bei deutschen Rechnungen mit Umlauten?
PaddleOCR unterstützt Deutsch; geben Sie beim Laden lang='de' an. Qwen2.5-VL versteht Deutsch ohne Einstellung. Normalisieren Sie deutsche Datumsangaben (TT.MM.JJJJ) und Beträge (1.234,56) vor der Validierung.
Lohnt sich Fine-Tuning auf eigene Rechnungen?
Ab ca. 50 bis 200 annotierten Rechnungen deutlich. Roboflow stellt ein öffentliches Fine-Tuning-Notebook für Qwen2.5-VL bereit (how-to-finetune-qwen2-5-vl-for-json-data-extraction.ipynb). Ohne GPU ist Fine-Tuning nicht praktikabel; nutzen Sie dann Few-Shot-Prompting mit 3 bis 5 Beispielen.
Wie rechnet Mistral OCR ab?
Nach verarbeiteten Seiten; Batch-Aufträge günstiger. Begrenzen Sie die Seiten per pages. Konditionen: mistral.ai/pricing.
Fazit
Rechnungen lassen sich vollständig lokal auslesen: ohne GPU mit PaddleOCR oder Docling plus kleinem LLM, mit GPU direkt mit Qwen2.5-VL-7B. Entscheidend ist die Validierung mit Pydantic und decimal.Decimal, bevor Werte in Paperless-ngx oder die Buchhaltung gelangen.
Weiterführende Anleitungen und Quellen
- Paperless-ngx: Dokumentenverwaltung mit OCR und Docker einrichten
- Ollama + Open WebUI: Lokale LLMs mit Docker betreiben
- Qdrant: Lokales RAG-System mit Embeddings aufbauen
- DSGVO TOMs und Auftragsverarbeitung in der Praxis
Quellen: Docling-Dokumentation · PaddleOCR-Dokumentation (PP-StructureV3) · Qwen2.5-VL-7B-Instruct (Hugging Face) · Mistral-Dokumentation · Paperless-ngx Advanced Usage


