Beliebte Suchanfragen
//

Text aus PDFs extrahieren: Gruselgeschichten für RAG-Entwickler

26.10.2026 | 22 Minuten Lesezeit

Ein Dokument, aus dem die Buchstaben ungeordnet herausfallen

Im Artikel über Retrieval Augmented Generation haben wir uns angeschaut, wie man Dokumente in Chunks zerlegt, embedded, durchsucht und neu sortiert. Eine fundamentale Frage habe ich dort ausdrücklich ausgeklammert: Wie kommt man überhaupt an den Text? Bei Markdown oder HTML ist das kein Thema. Aber der wohl häufigste Fall ist der SharePoint-Ordner voller PDFs, und PDFs haben eigene Herausforderungen, über die man, wie ich damals schrieb, einen eigenen Artikel schreiben könnte. Das hier ist dieser Artikel.

Alles, was danach kommt -- Chunking, Embeddings, hybride Suche, Reranking --, setzt voraus, dass sauberer Text vorliegt. Fehler beim Parsen ziehen sich durch die ganze Pipeline, und kein Reranker repariert einen Chunk, in dem "Ejf [bimvoh" statt "Die Zahlung" steht.

Das Beispiel ist konstruiert, aber nicht weit hergeholt. Es stammt aus einem Testdokument, das Claude und ich für diesen Artikel gebaut haben: ein PDF mit 37 Seiten, das im Viewer völlig harmlos aussieht und auf jeder Seite einen anderen Stolperstein für die Textextraktion enthält. Auf dieses Dokument habe ich eine Reihe von Werkzeugen losgelassen, von pypdf bis zu einem multimodalen LLM. Das PDF gibt es hier.

Weil bald Halloween ist, kann man den Rest des Artikels als Sammlung von Gruselgeschichten lesen, über Dokumente, die im Viewer brav aussehen und im Index Unheil anrichten. Für alle, die gerade ihre ersten PDFs in eine RAG-Pipeline schieben, ist es eine Liste der Stellen, an denen man genauer hinsehen sollte. Und wer das schon länger macht, findet vielleicht die Erklärung für eine seltsame Beobachtung von damals, etwa warum die Suche nach "Müller" ein Dokument nicht findet, in dem "Müller" steht.

Was ein PDF eigentlich ist (und was nicht)

PDF ist ein grandioses Format, um Dokumente so darzustellen, wie sie auf Papier gedruckt aussehen sollen. Aber sobald man versucht, den Text zu extrahieren, bekommt man das Gefühl, dass sich jemand Mühe gegeben hat, ein Format auszudenken, das Textextraktion möglichst nervenaufreibend macht. Ein PDF beschreibt nämlich keinen Fließtext, sondern, wo welche Glyphe auf die Seite gemalt wird. Im besten Fall hängt ein Absatz zusammen. Im schlechtesten Fall ist jeder Buchstabe einzeln positioniert. Im allerschlechtesten Fall nicht mal in der richtigen Reihenfolge.

Am einfachsten sieht man das, wenn man den Inhalt einer Seite entpackt, etwa mit qpdf --qdf --object-streams=disable in.pdf out.pdf oder mutool show. Ein Satz wie "Die Lieferung erfolgt frei Haus." kann dann so aussehen (gekürzt aus dem Testdokument):

BT 1 0 0 1 72      697.89 Tm (D) Tj ET
BT 1 0 0 1 80.664  697.89 Tm (i) Tj ET
BT 1 0 0 1 83.328  697.89 Tm (e) Tj ET
BT 1 0 0 1 93.336  697.89 Tm (L) Tj ET
BT 1 0 0 1 100.008 697.89 Tm (i) Tj ET
...

Jeder Buchstabe bekommt einen eigenen Textblock (BT … ET), eine eigene Position (Tm) und wird einzeln gezeichnet (Tj). Ein Leerzeichen gibt es nicht. Zwischen "e" und "L" ist nur der Abstand der x-Koordinaten etwas größer (10 pt statt 2,7 pt zwischen "i" und "e"), und ob das ein Wortabstand ist, muss der Extraktor raten. Dafür muss er die Breite jeder Glyphe kennen, denn die Koordinate gibt nur an, wo eine Glyphe anfängt. Genauso wenig gibt es Zeilen, Absätze, Überschriften oder Tabellen.

Dazu kommt, dass die Reihenfolge im Content-Stream beliebig ist. Ein Generator darf erst alle Überschriften zeichnen und dann den Fließtext, die Fußzeile zuerst oder die Zeilen eines Absatzes von unten nach oben. Dem Viewer ist das egal, aber für einen Extraktor wird es dadurch komplizierter.

Glyphe ist nicht gleich Zeichen

Das zweite große Missverständnis betrifft die Zeichen selbst. Das D in (D) Tj ist ein Code, den die Schrift auf eine Glyphe abbildet. Bei einfachen Schriften ist der Code meist der ASCII-Wert. Bei eingebetteten Subset-Fonts (praktisch alles, was Office oder ein Browser erzeugt) ist es oft einfach die Nummer der Glyphe in der Schrift. Für die Darstellung reicht das völlig. Wer extrahieren will, braucht zusätzlich die Information, welches Unicode-Zeichen hinter der Glyphe steckt, und die steht in einer eigenen, optionalen Tabelle, der ToUnicode-CMap.

Fehlt die CMap, ist das Ergebnis Zeichensalat -- oft eine Art Caesar-Chiffre, weil die Glyphnummern gegenüber ASCII verschoben sind. Ist sie falsch, kommt plausibel aussehender Unsinn heraus, wie das "Ejf [bimvoh" aus der Einleitung. Die Seite sieht aber in beiden Fällen perfekt aus, weil der Viewer die Tabelle nicht braucht.

HTML, DOCX oder Markdown tragen im Vergleich dazu Semantik: Ein <h2> ist eine Überschrift und ein <td> eine Tabellenzelle, während PDF nur die Optik beschreibt. Mit Tagged PDF (und PDF/UA für Barrierefreiheit) gibt es zwar einen Strukturbaum mit Lesereihenfolge, Überschriften und Tabellen. Aber in freier Wildbahn sind die meisten PDFs nicht (sinnvoll) getaggt, und die meisten Parser lesen die Tags auch nicht.

Die Kandidaten

Bevor wir uns die Stolpersteine ansehen, kurz die Werkzeuge, die ich verglichen habe. Sie fallen in drei Gruppen, die sich grundverschieden verhalten.

  • Textlayer-Extraktoren: pypdf, pdfminer.six, PyMuPDF, pdftotext aus Poppler und MarkItDown (das intern pdfminer aufruft und für Seiten, die nach Tabelle aussehen, pdfplumber; weil es sich fast überall wie pdfminer verhält, fehlt es in der Übersicht weiter unten). Sie lesen nur, was im Content-Stream steht, und rekonstruieren Wörter und Zeilen mit Heuristiken. Das ist schnell, billig und deterministisch.
  • Layout-Pipelines: Docling und Unstructured (Strategie hi_res). Sie lesen ebenfalls den Textlayer, legen aber ML-Modelle für Layout, Lesereihenfolge und Tabellen darüber und machen OCR, wo kein Text ist. Ausgabe ist Markdown bzw. eine Liste von Elementen mit Typ.
  • Nur das Bild: Tesseract als klassische OCR auf den gerenderten Seiten (300 dpi) und Claude Haiku 5.5 als multimodales LLM auf den Seitenbildern (Prompt sinngemäß "transkribiere die Seite als Markdown"). Beide sehen nur, was gerendert wird, und ignorieren den Textlayer komplett.

Diese Auswahl ist willkürlich, und der Vergleich anhand meines konstruierten Stolperstein-PDFs ist sicherlich nicht vollständig. Ich finde ihn dennoch nützlich, weil er ein Gefühl dafür vermittelt, welcher Typ von Werkzeug sich für welceh Problme eignen. Weiter unten steht eine große Übersicht, in der ich für jeden Stolperstein und jeden Testkandidaten bewertet habe, ob der Text heil ankommt.

Wenn man Kontrolle darüber hat, welcher Art die PDFs sind, die in das RAG eingespielt werden, kann man dafür eine maßgeschneiderte, schnelle und günstige Lösung bauen. Beispielsweise könnte ich mir vorstellen, dass große Sammlungen von Gesetzestexten mit einem schnellen Textlayer-Extraktor und ggf. speziellem Postprocessing für Kopf- und Fußzeilen sehr gut funktionieren. Wenn man hingegen relativ wenige Dokumente, aber dafür mit komplexem Aufbau und vielen Bildern hat, lohnt sich vielleicht die teure Verarbeitung mit einem multimodalen LLM. Und wenn der Datenbestand aus PDFs besteht, die aus einem Firmen-Wiki exportiert wurden, werft sie weg und indexiert direkt einen HTML-Export oder den Quelltext im Markup des Wikis.

Die Stolpersteine

Das Testdokument hat 33 Stolpersteine plus zwei Varianten der ganzen Datei (beschädigt und verschlüsselt). Den kompletten Katalog mit Beobachtungen pro Werkzeug gibt es im Repository. Hier gehe ich die durch, die mir für RAG am wichtigsten erscheinen, grob von klein nach groß: Zeichen, Wörter, Seite, Struktur, Bilder und zuletzt bösartige Dateien.

Zeichen

Ligaturen. Viele Schriften setzen "fi", "ffi" oder "fl" als eine Glyphe. Im besten Fall bildet die ToUnicode-CMap sie auf die einzelnen Buchstaben ab. Sorgfältige Satzprogramme wie LaTeX machen das, und wer nur mit solchen PDFs testet, bemerkt das Problem gar nicht. Häufig zeigt die CMap aber auf den Ligatur-Codepoint (U+FB03 für "ffi", so macht es bspw. Chrome), und dann steht "Effizienz" im extrahierten Text. Für Embeddings ist das ein kleiner Qualitätsverlust, für BM25 und die Volltextsuche ist es ein anderes Wort. Auf der Ligaturenseite im Testdokument hat keiner der Textlayer-Extraktoren das normalisiert. Die Gegenmaßnahme ist ein Einzeiler, NFKC-Normalisierung (unicodedata.normalize("NFKC", text)), und schadet in aller Regel nicht. Fehlt die Ligatur in der CMap ganz, hilft auch NFKC nicht mehr: Dann kommt "EᎴzienz" oder "E zienz" heraus, je nach Extraktor. Die beiden Layout-Pipelines scheitern jeweils an der anderen Variante. Docling normalisiert den Ligatur-Codepoint, gibt bei der fehlenden Ligatur aber "EᎴzienz" aus. Unstructured lässt den Codepoint stehen, erkennt bei der fehlenden Ligatur aber den Zeichensalat und schaltet auf OCR um.

Fehlende oder falsche ToUnicode-CMap. Der Fall aus der Einleitung. Ohne CMap liefern pypdf, PyMuPDF und pdftotext Glyphnummern als Zeichen (".¾QGLJXQJVIULVW" für "Kündigungsfrist"). pdfminer rekonstruiert den Text, solange die eingebettete Schrift noch ihre eigene Zeichentabelle hat, und liefert sonst das kryptische (cid:37)(cid:72)….

Mit falscher CMap liefern alle Textextraktoren und beide Layout-Pipelines den verschobenen Text. Solche CMaps entstehen durch fehlerhafte Generatoren oder werden absichtlich als "Kopierschutz" eingebaut. Tesseract und Haiku lesen beide Seiten richtig, weil sie nur das Bild sehen.

Interessant ist Unstructured. Bei fehlender CMap und bei der Type-3-Schrift ohne Zeichenzuordnung liefert es den korrekten Text, weil es Zeichensalat erkennt und für diese Stellen auf OCR zurückfällt. Bei der falschen CMap greift das nicht, der verschobene Text sieht für die Heuristik wie normaler Text aus. Docling gibt in allen Fällen den Zeichensalat aus.

Null-Bytes. Eine Variante davon ist eine CMap, die Umlaute auf U+0000 abbildet. pypdf und pdfminer liefern dann Gr\x00\x00e aus D\x00sseldorf. Das ist nicht nur hässlich: PostgreSQL (und damit pgvector) kann bspw. \x00 in Textspalten gar nicht speichern.
Steuerzeichen sollte man nach der Extraktion deshalb grundsätzlich entfernen.

Unicode-Doppelgänger. Hier ist die Extraktion korrekt, und es geht trotzdem schief: "Müller" mit kombinierendem Trema (u + ¨) statt vorkomponiertem ü, "Pass​wort" mit einem Null-Breite-Leerzeichen, "Kаffee" mit kyrillischem а. Im Viewer und im extrahierten Text sieht alles gleich aus, aber die Suche nach "Müller" findet das Wort nicht. NFKC behebt das Trema, das unsichtbare Leerzeichen muss man selbst entfernen, und das kyrillische а erkennt man nur, wenn man die Schriftsysteme innerhalb eines Wortes prüft.

Wörter und Zeilen

Leerzeichen. Wie oben im Content-Stream zu sehen, muss jeder Extraktor aus Abständen raten, wo ein Wort endet. Jeder macht das mit eigenen Schwellwerten, und deshalb gibt es kein Werkzeug, das alle Varianten richtig hat. Gesperrte Schrift wird bei pdfminer und PyMuPDF zu "W i c h t i g e r", zu enge Wortabstände (1 pt) werden bei fast allen zu "RechnungsadresseundLieferadressesindidentisch." Bei Letzterem patzt sogar Tesseract ("Rechnungsadresseund Lieferadresse sindidentisch."), weil die Lücke im Bild genauso schmal ist. Ein einfaches Qualitätssignal ist die mittlere Wortlänge: sehr kurz deutet auf gesperrten Text, sehr lang auf fehlende Leerzeichen.

Nicht eingebettete Schriften. Um eine Lücke zwischen zwei Glyphen zu messen, braucht der Extraktor die Breite der ersten Glyphe, und die steht in der Schrift oder im /Widths-Array des Fontdictionarys. Es kommt aber durchaus vor, dass beides fehlt: Die Schrift wird nur beim Namen genannt und ist nicht eingebettet, und /Widths fehlt, obwohl die Spezifikation es außer für die 14 Standardschriften vorschreibt. Der Viewer nimmt eine Ersatzschrift aus dem System, und die Seite sieht einigermaßen normal aus. Die Extraktoren müssen die Breiten dagegen raten. pypdf nimmt für jede Glyphe pauschal die halbe Schriftgröße als Breite an. Für schmale Buchstaben wie i, l oder t ist das zu breit, die errechneten Lücken schrumpfen, und bei einzeln positionierten Wörtern kommt "Die LieferungerfolgtfreiHaus" heraus. Docling und Unstructured kleben die Wörter ebenso zusammen. pdfminer nimmt die Breite 0 an, und dann zerfällt die Seite in ein Zeichen pro Zeile, selbst wenn echte Leerzeichen im Stream stehen. Solche "Buchstabensäulen" sind für RAG natürlich unbrauchbar. PyMuPDF und pdftotext lesen beide Varianten richtig, Tesseract und Haiku sowieso. Bei Arial passiert übrigens nichts, weil pypdf und pdfminer dafür Ersatzmetriken mitbringen. Im Testdokument lösen wir dieses Problem aber mit Calibri aus.

Silbentrennung und Zeilenumbrüche. Getrennte Wörter ("Donaudampfschiff-\nfahrt") zerfallen in zwei Teile, und die Volltextsuche findet das ganze Wort nicht mehr. Alle Textextraktoren behalten Trennstriche und Zeilenumbrüche. Docling fügt getrennte Wörter zusammen und tappt dabei in die naheliegende Falle: Aus "E-Mail" am Zeilenende wird "EMail".

Hoch- und Tiefstellungen. "Der Umsatz stieg 2023¹ um 12 Prozent" wird bei pdfminer, PyMuPDF und pdftotext zu "Der Umsatz stieg 20231 um 12 Prozent", aus "250 m²" wird "250 m2". Kein LLM kann das hinterher auflösen. Tesseract macht es nicht besser. Aus der Fußnote wird ein Apostroph, aus "m²" "m*" und aus "10⁻³" "10°".

Seite und Layout

Mehrspaltiges Layout. Das betrifft vor allem Paper, Broschüren und Zeitschriften. Wer Zeilen über die ganze Breite liest oder dem Stream folgt, mischt beide Spalten.

Der Chunk enthält dann Sätze wie "Jeder Mitarbeitende hat Reisekosten: Dienstreisen werden mit der Anspruch auf 30 Urlaubstage pro Jahr." Überraschend fand ich hier zwei Dinge. pdfminer, das klassische Werkzeug, trennt die Spalten mit seiner Layoutanalyse korrekt. Und Docling, trotz ML-Layoutmodell, verschränkt sie wie pypdf, weil das Modell auf dieser Seite nur einen Textblock erkennt. Unstructured trennt die Spalten korrekt. Ähnlich sieht es aus, wenn die Zeilen eines Absatzes im Stream von unten nach oben gezeichnet sind: Docling gibt den Absatz rückwärts aus, pdfminer und Unstructured richtig.

Kopf- und Fußzeilen. "VERTRAULICH -- nur für den internen Gebrauch" steht auf jeder Seite und landet bei allen Textextraktoren in jedem Chunk. Das macht Chunks einander ähnlicher, als sie sind, und die Frage "Was ist vertraulich?" findet jede Seite. Docling klassifiziert Kopf- und Fußzeilen und lässt sie weg, Unstructured behält sie. Ohne Layoutmodell kann man Zeilen entfernen, die auf vielen Seiten an derselben Position wiederkehren.

Seitengrenzen. Wer seitenweise parst (bspw. damit jeder Chunk eine Seitenzahl für die Quellenangabe hat), zerschneidet Sätze, die über die Seitengrenze laufen. Auch beim durchgehenden Parsen stehen dann Fußnote, Fußzeile und Kopfzeile mitten im Satz.

Tabellen über Seitengrenzen. Läuft eine Tabelle über zwei Seiten, wird auf der zweiten meist die Kopfzeile wiederholt. In Listen mit Beträgen steht außerdem oft am Ende der ersten und am Anfang der zweiten Seite eine Zeile "Übertrag" mit der Zwischensumme. Keines der Werkzeuge hat die beiden Hälften wieder zusammengeführt. Die zweite Hälfte ist ein eigener Chunk, und ohne Beschriftung wie "(Fortsetzung)" ist nicht zu erkennen, zu welcher Tabelle sie gehört. Die Übertragszeilen sehen aus wie Daten, und wer über alle Zeilen summiert, zählt die Beträge der ersten Seite mehrfach. Aber auch eine sauber zusammengesetzte Tabelle macht später Probleme: Wie im RAG-Artikel beschrieben, beantwortet textbasiertes RAG Fragen schlecht, die über eine große Tabelle aggregieren. Ein Datenbestand, der zu großen Teilen aus solchen mehrseitigen Tabellen besteht, braucht deshalb womöglich eine andere Lösung als textbasiertes RAG, etwa die Tabellen als strukturierte Daten zu extrahieren (am besten aus einer anderen Quelle als der PDF) und per SQL abzufragen.

Seitenzahlen. Die Seitenzahl, die der Parser liefert, ist der physische Index (und je nach Bibliothek 0-basiert). Die gedruckte Seitenzahl kann über Page Labels (ISO 32000-1, Abschnitt 12.4.2) etwas ganz anderes sein, "iv" im Vorwort oder "2" auf der dritten physischen Seite. Wer in Quellenangaben auf "Seite 2" verweist und im Viewer zur Fundstelle springen will, muss wissen, welche der beiden gemeint ist. Ein Sprachmodell ist beim Testdokument übrigens von selbst darüber gestolpert und hielt das Inhaltsverzeichnis zunächst für falsch, weil es Bildnummern mit gedruckten Seitenzahlen verglich.

Wasserzeichen und Stempel. Ein diagonales "ENTWURF" landet mitten zwischen zwei Absätzen, ein gedrehter Stempel "GEPRÜFT" kann vom LLM als inhaltliche Aussage verstanden werden. Alle Textextraktoren geben beides aus.

Struktur

Überschriften und Listen. Eine Überschrift ist im PDF nur ein Stück Text in größerer, fetter Schrift. Ohne erkannte Überschriften gibt es kein strukturbewusstes Chunking, wie ich es im RAG-Artikel mit dem Markdown-Splitter beschrieben habe, und keine Überschrift als Kontext im Chunk. Alle Textextraktoren geben Überschriften als normale Zeilen aus. Docling erkennt sie, setzt aber alle auf dieselbe Ebene (##), Unstructured markiert Überschriften als Title, ohne Ebene. Aufzählungszeichen aus Symbolschriften werden zu beliebigen Buchstaben.

Tabellen. Im PDF gibt es keine Tabelle, nur Text an Positionen und ein paar Linien (oder nicht einmal die). Wie viel davon übrig bleibt, hängt stark vom Werkzeug ab.

pdfminer gruppiert spaltenweise, und zwar über beide Tabellen der Seite hinweg: erst alle Regionen und Positionen, dann alle Zahlen. Die Frage "Wie hoch war der Umsatz im Süden 2024?" ist damit nicht mehr sicher zu beantworten, zumal die leere Zelle bei "West 2024" die Zuordnung verschiebt. pypdf und PyMuPDF geben jede Zelle in einer eigenen Zeile aus, was immerhin die Zeilenreihenfolge erhält, und pdftotext -layout erhält die Zeilen optisch am besten. Docling erkennt beide Tabellen (auch die rahmenlose) perfekt und löst die verbundene Kopfzelle zu "Umsatz (Mio. €) - 2023" auf. Unstructured hat mich hier enttäuscht: Von Tabelle A bleibt in der HTML-Ausgabe nur eine Zeile übrig, und in der rahmenlosen Tabelle schneidet die Zellerkennung die ersten Buchstaben ab ("Zeratung", "zeisekosten", "_izenz"). Die Umsatzzahlen aus Tabelle A fehlen in der Ausgabe komplett.

Formulare. Ausgefüllte Werte in AcroForm-Feldern stehen nicht im Content-Stream, sondern in den Feldern und deren Annotationen. pypdf und pdfminer liefern nur die Beschriftungen ("Name:", "Eintrittsdatum:"), PyMuPDF und Docling hängen die Werte am Ende der Seite an, ohne Zuordnung. Haiku ordnet sie korrekt zu. Wer Formulare indexiert, sollte die Felder separat auslesen (reader.get_fields() in pypdf).

Bilder und Scans

Gescannte Seiten. Ein Scan ohne Textlayer ergibt bei allen Textextraktoren einen leeren String, ohne dass ein Fehler auftritt. Ohne Prüfung landet das Dokument still als leerer Chunk im Index und ist nie auffindbar. Docling, Unstructured, Tesseract und Haiku lesen den Scan gut. Dasselbe kann übrigens passieren, wenn eine Seite über einen PostScript-Druckertreiber "gedruckt" wurde. PostScript kennt keine Transparenz, und Seiten mit Transparenz (bspw. Textschatten) werden dabei komplett gerastert. Auch dann liefern die Textextraktoren eine leere Seite.

Vorhandener OCR-Layer. Fieser ist der Scan, auf dem der Scanner schon einen unsichtbaren OCR-Text abgelegt hat, mit typischen Fehlern ("l.Z34,S6" statt "1.234,56"). Weil Text vorhanden ist, schlägt keine "leere Seite"-Prüfung an, und alle Textextraktoren übernehmen die falschen Beträge. Docling ignoriert den vorhandenen Layer und macht selbst OCR, Unstructured übernimmt den Textlayer wie die Textextraktoren. Dass OCR-Rauschen bis in die Antwortqualität durchschlägt, ist übrigens gut untersucht, siehe OHRBench.

Text als Kurven und Diagramme. Aus Grafikprogrammen kommt Text oft "in Kurven umgewandelt", also als Vektorpfade. Für Textextraktoren ist er unlesbar. Bei Diagrammen steckt die Information in den Balkenhöhen, übrig bleiben Achsenbeschriftungen ("0 5 10 15 20 25 Q1 Q2 Q3 Q4"), die zu jeder Frage nach Quartalen passen, aber nichts beantworten. Haiku liest die Balken ab und kennzeichnet die Werte als "ca.", was meiner Meinung nach die beste Lösung ist, aber eben auch den Preis eines LLMs hat.

Bösartige Dateien

Versteckter Text. Hier wird aus einem Qualitätsproblem ein Sicherheitsproblem. Parser extrahieren alles, was im Content-Stream steht, auch das, was kein Mensch sieht: weißer Text auf weißem Grund, Schriftgröße 0,5 pt, Text außerhalb der Seite, Text hinter einem weißen Rechteck oder im unsichtbaren Render-Modus 3.

Dass das real passiert, zeigen Untersuchungen zu Prompt Injection beim automatisierten Bewerbungs-Screening. Für RAG heißt das, Dokumente sind ein Eingabekanal, der oft an allen Guardrails für Nutzereingaben vorbeiläuft. Alle Textextraktoren und Layout-Pipelines geben fünf oder sechs der sechs versteckten Botschaften aus. Tesseract und Haiku sind immun, sie sehen nur den sichtbaren Text.

Schwärzung, die keine ist. Der Klassiker unter den Datenlecks: Ein schwarzer Balken über Name, IBAN und Gehalt, der Text darunter ist unverändert vorhanden.

Der Viewer zeigt nur die schwarzen Balken. Im Index steht der Text aber vollständig, und das LLM zitiert ihn auf Nachfrage bereitwillig. Das gilt für alle Werkzeuge, die den Textlayer lesen, also auch für beide Layout-Pipelines. Tesseract liest an den Stellen nur ein paar Zeichen Rauschen aus den Balken ("B", "]]"), Haiku schreibt "[geschwärzt]".

Kaputte und verschlüsselte Dateien. Defekte PDFs zeigt jeder gute Viewer tolerant an, Parser sind unterschiedlich nachsichtig. Anekdotisch ist mir bspw. LangChains PDFMiner-Loader an einem fehlenden creationdate-Metadatum abgestürzt. Im Testdokument haben alle Werkzeuge außer Haiku, das ich auf diese Varianten nicht angesetzt habe, die beschädigte xref-Tabelle repariert und die verschlüsselte Variante (leeres Benutzerpasswort, Extraktion verboten) trotzdem gelesen. Ob man das Verbot respektieren muss, ist eine organisatorische Frage, die man bewusst entscheiden sollte. Generell sind hochgeladene PDFs nicht vertrauenswürdige Eingabe. Timeouts, Speichergrenzen und eigene Prozesse für große Dateien sind also Pflicht.

Alle Werkzeuge auf einen Blick

Hier ist ein Vergleich der kleinen Werkzeugauswahl, die ich getestet habe. Die Kategoriezuordnung ist möglicherweise etwas subjektiv, aber generell wird der Unterschied der drei Ansätze recht deutlich.

Die Textlayer-Extraktoren scheitern gemeinsam und an denselben Stellen, nämlich überall, wo der Textlayer nicht zum Bild passt (CMaps, Type-3-Schriften, Scans, versteckter Text, Schwärzung). Die Layout-Pipelines reparieren Struktur, Docling vor allem Tabellen und Kopfzeilen, aber kaum kaputte Zeichen. Unstructured kommt dank OCR-Fallback auf mehr Häkchen, der springt allerdings nur bei offensichtlichem Zeichensalat an. Tesseract ist gegen alles immun, was den Textlayer betrifft, und hat dafür die typischen OCR-Schwächen: keine Struktur, falsche Hochstellungen, unbrauchbare Formeln, gedrehter Text, und die Formularwerte sieht es gar nicht. Haiku bekommt kein einziges rotes Kreuz.

Von den vielen Häkchen bei Haiku sollte man sich allerdings nicht einlullen lassen. Ein multimodales LLM interpretiert, was es sieht. Es schreibt Darstellungshinweise in eckige Klammern ("[geschwärzt]"), und die stimmen nicht immer: Den gedruckten Scan beschreibt Haiku als "handschriftlich/gedruckt". Außerdem ist das Ergebnis nicht deterministisch, zwei Läufe liefern nicht denselben Text. Klassische Parser scheitern laut, mit Zeichensalat und (cid:…), ein VLM scheitert dagegen still und plausibel. Es kann Text erfinden, der nicht im Dokument steht. Dazu kommen die hohen Kosten, für jede PDF-Seite ein Bild an ein Sprachmodell zu schicken, und die Frage, ob die Dokumente das Haus überhaupt verlassen dürfen.

Benchmarks wie olmOCR-Bench, OmniDocBench oder OHRBench helfen bei der Orientierung, aber sie spiegeln nicht eure Dokumente wider, und die Rangfolgen ändern sich alle paar Monate. Ein Blick auf die Lizenzen lohnt sich auch: PyMuPDF steht bspw. unter AGPL.

Eine vierte Option habe ich nicht ausprobiert, möchte sie aber erwähnen: das Parsen ganz zu überspringen. Visuelles Retrieval wie ColPali bettet die Seitenbilder direkt ein und sucht dann im Bildraum. Das im RAG-Artikel erwähnte CLIP schneidet dabei auf Textseiten schlecht ab, weil es nicht darauf trainiert ist, Text im Bild zu lesen. ColPali erzeugt stattdessen einen Vektor pro Bildausschnitt und vergleicht jedes Wort/Token der Frage mit diesen Ausschnitten. Die Modelle dazu liegen offen auf GitHub und Hugging Face. Das Retrieval liefert dann keinen Text-Chunk mehr, sondern ein Seitenbild, und das Sprachmodell bekommt für die Antwort direkt dieses Bild (VisRAG hat das systematisch untersucht). Wer ganz ohne Textextraktion arbeitet, braucht also spätestens dort ein multimodales LLM.

PDF als Zwischenformat? Lieber nicht

Wer neben PDFs auch Markdown, HTML oder Quellcode indexiert, kommt leicht auf die Idee, alles zuerst nach PDF zu konvertieren und dann einheitlich zu parsen. Ein PDF hat nämlich Seiten, Markdown nicht. Jeder Chunk bekommt so eine Seitenzahl als Metadatum, und ein PDF-basierter Viewer kann bei einer Quellenangabe direkt auf die richtige Seite springen.

Der Preis dafür ist hoch. Die Konvertierung wirft die Struktur weg, die das Original mitbringt. Aus Überschriften, Code-Blöcken und Tabellen werden positionierte Glyphen, die der Parser anschließend wieder raten muss. Man handelt sich also alle Stolpersteine aus diesem Artikel ein, um ein einziges Metadatum zu erzeugen, und wartet obendrein auf die Konvertierung.

Besser parst man das Original und erzeugt das PDF nur für die Vorschau. Für Formate ohne Seiten gibt es auch andere Anker, auf die ein Viewer springen kann: die Überschrift des Abschnitts, ein Zeilenbereich, ein HTML-Anker. Wenn es die Quelle in einem strukturierten Format gibt (DOCX, HTML, Markdown, ein Confluence-Export), nehmt die Quelle. Was bei der Konvertierung verloren geht, zeigen die "realistischen" Seiten im Testdokument, die ich nicht selbst gebaut, sondern mit echten Programmen erzeugt habe. Ein LibreOffice-Export macht aus durchgestrichenem "zwei" und eingefügtem "drei" in der Änderungsverfolgung "bis zu zweidrei Tage", und Chrome zeichnet Textschatten als zweiten Text, sodass jede Überschrift doppelt im Index steht ("Ablauf der AbrechnungAblauf der Abrechnung").

Was ich empfehle

Aus dem Katalog oben lässt sich eine Pipeline ableiten, die bekannte Stolpersteine abfängt: Textlayer-Extraktor, Qualitätsprüfung pro Seite, Layout-Pipeline, OCR als Gegencheck und für den Rest ein VLM. So etwas ist aufwendig zu bauen und noch aufwendiger zu betreiben, weil jede Stufe ihre eigenen Abhängigkeiten, Modelle und Fehler mitbringt. Für die meisten Anwendungen ist das zu viel. Sinnvoller ist es abzuschätzen, welche Fehler bei den eigenen Dokumenten überhaupt vorkommen können und welche davon wirklich schaden.

Schaut euch den extrahierten Text an. Fast alle Stolpersteine oben fallen beim ersten Lesen auf. Nehmt Stichproben über verschiedene Dokumenttypen, und zwar aus dem Text, der wirklich im Index landet, nicht aus der Vorschau im Viewer.

Klärt, woher die PDFs kommen. Wer die PDFs selbst erzeugt, etwa aus einem eigenen Reporting oder mit einem festen Office-Export, kann ganze Fehlerklassen ausschließen. Ob die Schriften eingebettet sind und eine korrekte CMap haben, ob Scans oder versteckter Text vorkommen, prüft man dort einmal und kann es danach voraussetzen. Dann reicht ein einfacher Textlayer-Extraktor. Noch besser ist es, wenn das Original vorliegt, dann parst man das Original. Bei PDFs von außen (Rechnungen, Bewerbungen, eingescannte Post) kann dagegen jeder Stolperstein aus diesem Artikel auftauchen.

Gewichtet die Fehler. Eine doppelte Kopfzeile oder ein "EMail" verschlechtern das Retrieval ein wenig, und damit lässt sich meist leben. Teuer wird es bei einer Schwärzung, die keine ist, weil sie zum Datenleck wird, und bei verstecktem Text in einer Bewerbung, der als Anweisung im Kontext des LLMs landet. Diese Fehler sollte man abfangen, auch wenn es Aufwand kostet. Andererseits sind manche Prüfungen auf dem Textlayer sehr günstig: Zeichen pro Seite (fast null heißt Scan), der Anteil von (cid:, U+FFFD und Steuerzeichen, die mittlere Wortlänge. Damit fällt ein großer Teil der Probleme auf, und oft ist es am besten, die Seite zu melden, statt sie stillschweigend als leeren oder kaputten Chunk zu indexieren. Gegen eine falsche CMap und gegen versteckten Text hilft kein Signal aus dem Textlayer, dafür muss man ihn mit dem Bild vergleichen, und Tesseract ist dafür ein vergleichsweise billiger Gegencheck.

Rechnet die Kosten durch. Die Textlayer-Extraktoren brauchen pro Seite Millisekunden. Docling und Unstructured laufen auf der CPU, brauchen für das Testdokument aber einige Minuten und bringen große Modelle mit. Tesseract kostet etwa zweieinhalb Sekunden pro Seite (300 dpi, 8 CPU-Kerne). Am teuersten ist das VLM. Jede Seite wird als Bild an ein Modell geschickt und kostet Geld, und das bei jeder Neuindexierung wieder, etwa wenn sich das Modell oder der Prompt ändert. Bei ein paar hundert Seiten spielt das keine Rolle, bei einem SharePoint mit hunderttausenden Seiten schon. Ein kleines Modell wie Haiku senkt die Kosten, hat im Test aber eigene Fehler gezeigt. Dazu kommt die Frage, ob die Dokumente das Haus überhaupt verlassen dürfen. Ein VLM lohnt sich deshalb vor allem für Seiten, an denen alles andere scheitert, also Scans, Diagramme und Formeln, und nur dann, wenn ihr Inhalt den Preis rechtfertigt. Und die Möglichkeit selbst multimodale LLMs zu hosten (bspw. Gemma oder Qwen) kann diese Abwägung noch einmal verschieben.

Normalisieren und Seite mitschreiben. Ein paar Dinge kosten fast nichts und gehören in jede Pipeline: NFKC, Steuerzeichen und Null-Bytes entfernen, weiche Trennzeichen raus, und zu jedem Chunk die Seite mitschreiben, sowohl den Index im Dokument als auch das gedruckte Label.

Das Parsen evaluieren. Die Retrieval-Evaluation aus dem RAG-Artikel zeigt, dass etwas nicht gefunden wird, aber nicht, ob der Fehler beim Parsen, beim Chunking oder beim Retrieval liegt. Ein kleiner Golddatensatz "PDF → erwarteter Text" aus den eigenen Dokumenten ist dafür viel aussagekräftiger und kann mit jedem Problemdokument nach und nach erweitert werden.

Das Parsen ist der unscheinbarste Teil einer RAG-Pipeline, aber jeder spätere Schritt kann nur mit dem arbeiten, was dabei herauskommt. Es verdient deshalb deutlich mehr Aufmerksamkeit, als es meistens bekommt. Beim Bau von RAG-Systemen und zuletzt beim Bau des Testdokuments habe ich jedenfalls mehr über PDF gelernt, als ich je wissen wollte. Seitdem schaudert es mich jedes Mal, wenn jemand das Einlesen von PDFs als gelöstes Problem abtut.

//

Weitere Artikel in diesem Themenbereich

Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.

//
Jetzt für unseren Newsletter anmelden

Alles Wissenswerte auf einen Klick:
Unser Newsletter bietet dir die Möglichkeit, dich ohne großen Aufwand über die aktuellen Themen bei codecentric zu informieren.