Die perfekte Rechnungsextraktion: Warum eine Hybrid-Pipeline aus Docling und GOT-OCR-2.0 ein gutes Match ist.

Stell dir vor, du musst tausende Rechnungen verarbeiten. Die Dokumente kommen aus ganz Europa: Schweden, Italien, Spanien, Deutschland. Manche sind perfekt digital als PDF erzeugt, andere sind schlechte Scans von 20-jährigen Druckern, bei denen die Tabellenlinien leicht verschoben sind.

Wenn du jetzt ein Standard-OCR-Tool wie Tesseract über alles jagst, wirst du feststellen: Die Tabellenstrukturen brechen auf den Scans komplett zusammen. Die Spalte „Einzelpreis“ vermischt sich mit der „Menge“, und dein LLM beginnt zu halluzinieren, um die Lücken zu füllen.

Auf der anderen Seite: Wenn du jedes einzelne Bild an ein Vision-LLM sendest, explodieren deine Latenzzeiten und die Token-Kosten (oder die GPU-Last bei lokalen Modellen).

Die Lösung ist kein einzelnes „Super-Modell“, sondern eine intelligente Routing-Architektur. Eine Pipeline, die erkennt, wann sie den schnellen Pfad nehmen kann und wann sie die „schwere Artillerie“ auffahren muss.

Die Architektur des hybriden OCR-Routings

Das Ziel ist maximale Präzision bei optimaler Performance. Wir setzen auf einen dreistufigen Kaskaden-Ansatz.

Der Workflow im Überblick

  1. Docling (Tesseract-Pfad): Wir starten mit Docling. Es ist schnell und liefert uns Markdown und Seitenbilder.
  2. Die Qualitäts-Heuristik: Bevor wir den Text an das LLM senden, prüfen wir: Ist die Seite „gut“ oder „schlecht“?
  3. GOT-OCR-2.0 (Der Heavy-Lifter): Seiten, die die Qualitätsprüfung nicht bestehen, werden an GOT-OCR-2.0 gesendet, das spezialisiert auf strukturierte Dokumente ist.
  4. Vision-Fallback & Review: Wenn das Ergebnis immer noch unplausibel ist, springt ein Vision-LLM ein. Am Ende erfolgt ein strikter Review-Pass.

1. Die Kunst des Routings: Wann ist eine Seite „schlecht“?

Ein häufiger Fehler bei OCR-Pipelines ist es, blind zu vertrauen. Wir brauchen messbare Signale für die Seitenqualität. Wir nutzen zwei einfache, aber effektive Heuristiken:

Das Tabellen-Signal:
Wenn eine erkannte Tabelle mehr als 60 % leere Zellen aufweist, ist das ein klares Zeichen dafür, dass die OCR die Struktur verloren hat. Die Daten „rutschen“ in falsche Spalten.

Das Text-Signal:
Wenn eine gesamte Seite weniger als 100 Zeichen liefert, ist die OCR schlichtweg gescheitert (oft bei sehr schlechten Scans oder rein bildbasierten Formularen).

def get_problematic_pages(result) -> set[int]:
    bad_pages = set()
    for table in result.document.tables:
        rows = table.data.grid
        total, empty = 0, 0
        for row in rows:
            for cell in row:
                total += 1
                if not cell.text.strip():
                    empty += 1
        if total > 0 and (empty / total) > 0.6:   # >60% leere Zellen = kaputte Tabelle
            bad_pages.add(page_idx)

    for i, page in enumerate(result.pages):
        page_text = " ".join(...)
        if len(page_text.strip()) < 100:           # <100 Zeichen = OCR gescheitert
            bad_pages.add(i)

    return bad_pages

2. GOT-OCR-2.0: Die Geheimwaffe für Tabellen

Wenn Docling/Tesseract scheitern, setzen wir auf GOT-OCR-2.0. Im Gegensatz zu generischen Vision-Modellen ist GOT darauf trainiert, strukturierte Inhalte (insbesondere Tabellen) nativ zu erkennen.

Das Besondere: GOT gibt die Tabellen nicht als einfachen Text aus, sondern in LaTeX. Das ist auf den ersten Blick ein Problem, da LLMs Markdown bevorzugen, aber technisch eine Chance. LaTeX bewahrt die exakte Zellstruktur.

Um dies nutzbar zu machen, implementieren wir einen Parser, der die \begin{tabular}-Umgebungen in saubere Markdown-Pipe-Tabellen konvertiert.

def _latex_table_to_markdown(text: str) -> str:
    # Pass 1: Verschachtelte Tabulars flach machen
    text = _flatten_inner_tabulars(text)
    # Pass 2: Outer tabulars → Pipe-Tabellen
    outer_re = re.compile(r"\\begin\{tabular\}.*?\\end\{tabular\}", re.DOTALL)
    text = outer_re.sub(_parse_one_tabular, text)
    # Pass 3: Restliche LaTeX-Math-Mode-Artefakte entfernen
    text = re.sub(r"\\\((.+?)\\\)", r"\1", text)
    return text[:8000]

3. Strukturierte Extraktion mit Pydantic und Instructor

Ein LLM einfach zu fragen „Gib mir die Daten als JSON“ führt fast immer zu Fehlern. Mal fehlen Felder, mal ändern sich die Typen. Wir nutzen instructor, um Pydantic-Schemata direkt als Output-Zwang durchzusetzen.

Das Problem der Mehrsprachigkeit: AliasChoices

Maritime Rechnungen sind international. Mal heißt es „Quantity“, mal „Menge“, mal „Lev ant“ (schwedisch). Anstatt mühsame Normalisierungen zu schreiben, nutzen wir AliasChoices.

class RechnungsPosition(BaseModel):
    beschreibung: Optional[str] = Field(
        default=None,
        validation_alias=AliasChoices(
            "beschreibung", "description", "benämning", "artikel"
        )
    )
    menge: Optional[float] = Field(
        default=None,
        validation_alias=AliasChoices("menge", "quantity", "qty", "Lev ant", "enh")
    )

Das LLM kann nun in der Sprache des Originaldokuments antworten, aber Pydantic mappt es automatisch auf unser internes Schema.

Intelligente Reparatur im Validator

Nicht jedes Modell ist perfekt. Manchmal vergisst das LLM den Gesamtwert einer Position, obwohl Einzelpreis und Menge vorhanden sind. Hier setzen wir @field_validator ein, um die Daten im Flug zu reparieren:

@field_validator('gesamtbetrag', mode='before')
@classmethod
def fix_gesamtbetrag(cls, v, info):
    if v is None and info.data.get('einzelpreis') and info.data.get('menge'):
        return info.data['einzelpreis'] * info.data['menge']
    return v

4. Plausibilitätsprüfung und der Vision-Review-Pass

Selbst mit strukturierter Ausgabe können Halluzinationen auftreten. Es werden daher zwei Sicherheitsstufen eingebaut.

Der Inline-Check (Decision Engine)

Bevor wir ein Ergebnis akzeptieren, prüfen wir es auf „Implausibilität“. Wenn folgende Kriterien zutreffen, triggern wir den teuren Vision-Fallback:
– Eine Rechnung mit Positionen hat keine Gesamtsumme.
– Mehr als 50 % der Positionen haben keinen Preis.
Das Tesseract-Artefakt: Der gleiche Preis taucht auf 4 oder mehr Positionen auf (ein typisches Zeichen für falsch gelesene Spalten).

Der Summen-Drift-Check

Am Ende berechnen wir die Summe aller extrahierten Positionen und vergleichen sie mit dem Bruttobetrag der Rechnung.
Drift > 100 % (Faktor 2x): Klarer OCR-Fehler $\rightarrow$ Human Review.
Drift 2 % bis 100 %: Möglicher Rundungsfehler oder fehlende Position $\rightarrow$ Human Review.

Der Vision-Review-Pass

Um „False Positives“ bei der Fehlersuche zu vermeiden, nutzen wir ein Vision-LLM für einen finalen Check. Der Trick hier: Strikte Output-Constraints. Wir verbieten Freitext. Das Modell darf nur zwei Tags setzen: HANDSCHRIFT: oder OCR_DOLLAR:. Wenn nichts zutrifft, muss es zwingend nur OK antworten. Das eliminiert das Problem, dass das Modell „Fehler sucht, wo keine sind“, nur weil es im Prompt dazu aufgefordert wurde.

5. Local First: Der Stack

Für sensible Finanzdaten ist die Cloud oft ein No-Go. Dieses gesamte System läuft lokal:
OCR: Docling / Tesseract & GOT-OCR-2.0 (Local GPU).
LLM: Qwen3-VL-8B (via llama.cpp oder vLLM).
Orchestrierung: Python mit instructor und Pydantic.

Das Ergebnis ist ein System, das datenschutzkonform ist, keine API-Kosten verursacht und durch die Hybrid-Strategie eine Präzision erreicht, die reine Vision-Modelle oft übertrifft.

Key Takeaways für deine eigene Pipeline

  1. Vertraue keiner OCR blind: Baue Heuristiken (leere Zellen, Zeichenanzahl), um die Qualität zu messen.
  2. Nutze spezialisierte Modelle: GOT-OCR-2.0 ist für Tabellen Gold wert, wenn Tesseract scheitert.
  3. Struktur erzwingen: Nutze instructor und AliasChoices für mehrsprachige Dokumente.
  4. Validierung vor Akzeptanz: Implementiere Summen-Checks und Plausibilitäts-Prüfungen, bevor die Daten in die Datenbank fließen.
  5. Vision-Reviews strikt begrenzen: Erlaube nur spezifische Tags, um Halluzinationen bei der Fehlererkennung zu vermeiden.

Zusammenfassung der Implementierung

Die Pipeline wurde so konzipiert, dass sie robust gegenüber den typischen Fehlern von PDF-Extraktionen ist. Durch den path_used String im Output (z.B. "mixed:text=[0,1],got=[2],got_fallback_vision=[3]") wissen wir exakt, welcher Pfad für welche Seite genutzt wurde. Das macht das Debugging zum Kinderspiel und erlaubt es, die Heuristiken kontinuierlich zu verfeinern.