Wenn ein RAG-System über deutsche Unternehmensdokumente schlechte Antworten gibt, wird zuerst das Modell verdächtigt. Meistens liegt es woanders: in der Normalisierung, in der Erkennung sensibler Daten, an den Schnittgrenzen des Chunkings.
Ich habe die letzten Wochen an einem Toolkit für genau diese Ebene gearbeitet. Dabei sind mir Fälle untergekommen, die ich hier aufschreibe. Alles, was ich unten behaupte, ist im Repository als Test hinterlegt oder über einen Benchmark nachvollziehbar. Wo ich etwas nur einmal gemessen und nicht als Befehl hinterlegt habe, steht das an der Stelle dabei.
Wer das lieber anklickt als liest: Unter mehrabix-deutsches-ki-toolkit.hf.space läuft eine Demo mit fünf Schritten, ohne Installation. Jeder Schritt stellt die deutsche Behandlung einer naiven gegenüber. Am Ende steht ein kleines Sprachmodell, das die Antwort formuliert, und darunter die Prüfung, ob seine Quellenangaben stimmen.
Der Code steht auf GitHub, das Paket auf PyPI.
pip install deutsches-ki-toolkit
1. Presidio kennt deutsches Steuerrecht nicht
Microsoft Presidio ist ein guter Ausgangspunkt für PII-Erkennung. Die Standardinstallation bringt rund 30 Erkennungsmuster mit, die vor allem auf US-Formate ausgelegt sind. Deutsche Steuernummern sind nicht dabei.
Man findet dazu Behauptungen im Netz. Ich wollte es selbst sehen und habe es zu einem Test gemacht:
from deutsches_ki.core.enums import EntityType
from deutsches_ki.pii import detect
from deutsches_ki.pii.detectors.presidio import PresidioDetector
from deutsches_ki.pii.detectors.regex import RegexDetector
text = "Steuernummer: 12/345/67890, eingetragen unter HRB 12345."
nur_presidio = detect(text, detectors=[PresidioDetector("de_core_news_sm")])
mit_mustern = detect(text, detectors=[PresidioDetector("de_core_news_sm"), RegexDetector()])
Ergebnis: nur_presidio enthält weder die Steuernummer noch die Handelsregisternummer. Mit den eigenen Mustern kommt DE_HR_NUMBER dazu. Das ist kein Vorwurf an Presidio, sondern eine Lücke, die man kennen sollte, bevor man sich darauf verlässt.
Dazu kommt ein zweites Problem, das in den Presidio-Issues nachzulesen ist: schaltet man auf Deutsch um, liefert get_supported_entities(language="de") nur noch einen Bruchteil der Entitätstypen, und Kontextwörter wirken für Deutsch gar nicht. Kontextwörter sind aber genau das, was Erkennung brauchbar macht: Ein Hinweiswort in der Nähe hebt die Konfidenz und entscheidet oft darüber, ob ein Treffer überhaupt zugelassen wird.
Die Steuernummer allein hat übrigens 16 Formate, eines pro Bundesland. Ohne Prüfsumme bleibt nur, sie über ein deutsches Kontextwort abzusichern, sonst produziert man Fehlalarme am laufenden Band.
2. Umlaute und die Volltextsuche in PostgreSQL
Wenn man Vektor- und Volltextsuche kombinieren will, landet man bei PostgreSQL mit pgvector. Der Volltextteil hat eine Falle, die man erst merkt, wenn jemand „Schnoesel“ tippt und „Schnösel“ erwartet.
Probiert man es aus, stellt man fest: weder die Standardkonfiguration noch default_text_search_config = 'german' bringt die beiden zusammen. Der übliche Rat ist, die unaccent-Regeln zu ändern, damit ä zu ae wird statt zu a. Das funktioniert, verlangt aber Schreibrechte im Serververzeichnis und ist beim nächsten Update wieder weg.
Ich habe einen anderen Weg gewählt: Neben dem Originaltext steht eine Suchspalte, die bereits in Python gefaltet wurde.
def chunk_index_text(chunk) -> str:
section = chunk.section
return f"{section}\n{chunk.content}" if section else chunk.content
# gespeichert wird:
" ".join(search_tokens(chunk_index_text(chunk)))
search_tokens löst Umlaute und ß auf und zerlegt Komposita in ihre Bestandteile. In der Datenbank steht dann:
content_search TEXT NOT NULL,
search_vector TSVECTOR GENERATED ALWAYS AS (
to_tsvector('simple'::regconfig, content_search)
) STORED
Damit ist die Volltextsuche in der Datenbank exakt dieselbe wie die lexikalische Suche im Arbeitsspeicher: gleiche Faltung, gleiche Komposita-Zerlegung, kein Sonderfall. Und der Originaltext bleibt unangetastet in content stehen.
3. Komposita verstecken Bedeutung
„Versicherungsbeitrag“ und „Beitrag zur Versicherung“ meinen dasselbe. Für einen Tokenizer sind es zwei verschiedene Wörter. Wer vor dem Indizieren nicht zerlegt, verliert Treffer, und die semantische Suche rettet das nur selten.
Meine Zerlegung arbeitet heuristisch über eine Wortliste und behandelt die Fugenelemente (s, n, en, er, e). Der Vergleich läuft über die gefaltete Form, und genau daran bin ich zuerst gescheitert.
Der Fehler: Die Suchform faltet ü zu ue. Die Wortliste enthielt aber „Kündigung“ mit Umlaut. Ergebnis: „Kündigungsfrist“ wurde zu „kuendigungsfrist“, ließ sich nicht mehr zerlegen, und eine Suche nach „Frist“ fand den Abschnitt nicht. Ein Bug, der genau die Funktion kaputtmacht, für die man das Toolkit einbaut.
Jetzt wird auf beiden Seiten gefaltet, und der Fall steht als Regressionstest im Repository:
def test_umlaut_compound_is_split_after_folding() -> None:
tokens = search_tokens("Kündigungsfrist")
assert "kuendigungsfrist" in tokens
assert "kuendigung" in tokens
assert "frist" in tokens
Die Lehre ist nicht „Umlaute sind schwierig“, sondern: bei deutscher Suche gibt es zwei Darstellungen desselben Wortes, und jede Stelle im Code muss wissen, mit welcher sie arbeitet.
4. Abschnittstitel tragen das Thema, wurden aber nicht indiziert
Ein Abschnitt heißt „§ 4 Zahlungsbedingungen“, im Text steht „Die Zahlung ist innerhalb von 30 Tagen fällig“. Die Frage lautet „Wie lange ist die Zahlungsfrist?“
Der Chunk enthielt nur den Fließtext. Das Wort „Zahlungsfrist“ kommt darin nicht vor, „Zahlung“ schon. Der Titel, der das Thema trägt, war nicht im Index. Nachdem er mit hineingehört, findet die Frage den richtigen Abschnitt.
Bei deutschen Dokumenten ist das wichtiger als in englischen, weil die Überschriften stark verdichtet sind: „§ 4 Zahlungsbedingungen“, „Anlage 2 Vergütung“, „Punkt 3.1 Haftungsausschluss“.
Dieselbe Überschriftenerkennung greift inzwischen auch bei eingefügtem Text. Vorher hatte nur der Dateiweg einen Abschnittsbaum, ein per from_text übergebener Vertrag fiel auf einen einzigen Block zurück. In der Demo sieht man den Unterschied zwischen den Schritten eins und fünf.
5. Ein Chunker, der nach Token zählt, zerreißt Paragraphen
Deutsche Verträge und Gesetzestexte leben von § 4 Abs. 2. Ein Chunker, der stur nach Tokenzahl schneidet, trennt Absatz eins von Absatz zwei, und genau dort steht die Ausnahme.
Geschnitten wird deshalb in dieser Rangfolge: Dokument, Abschnitt, Absatz, Satz, Token. Die Token-Grenze kommt zuletzt:
Chunk: § 4 Zahlungsbedingungen
(1) Die Zahlung ist innerhalb von 30 Tagen nach Rechnungsstellung fällig.
(2) Bei verspäteter Zahlung fallen Verzugszinsen an.
Jeder Chunk trägt dabei Datei, Abschnitt, Abschnittspfad und Seite mit, damit eine Antwort später belegen kann, woher sie stammt.
6. Ergebnisse, die von Lauf zu Lauf schwanken
Diese hier hat mich am meisten geärgert, weil sie still ist. Meine Tests liefen mal grün, mal rot, bei gleicher Eingabe.
Ursache: Bei gleicher Punktzahl entschied die Chunk-Kennung, welcher Treffer zuerst kam. Die Kennungen kommen aus uuid4(), sind also bei jedem Prozessstart andere. Zwei gleich bewertete Treffer tauschten dadurch die Plätze, und ein Test, der auf den ersten Treffer prüft, kippt zufällig.
Für ein Projekt, das Benchmarks als Glaubwürdigkeitsargument benutzt, ist das tödlich. Jetzt entscheidet bei Gleichstand die Reihenfolge im Index:
position = {chunk.id: index for index, chunk in enumerate(self._chunks)}
ordered = sorted(fused.items(), key=lambda pair: (-pair[1], position[pair[0]]))
Dazu gibt es einen Test, der achtmal neu indiziert und prüft, dass die Reihenfolge stabil bleibt. Über PYTHONHASHSEED hätte man den Fehler nie gefunden, über die zufälligen Kennungen schon.
7. Dokumentinhalt ist fremder Inhalt
Ein PDF ist Datenmaterial. Wenn darin „Ignoriere alle vorherigen Anweisungen“ steht, ist das Dokumentinhalt und keine Anweisung an das Modell. Klingt selbstverständlich, ist es aber nicht, sobald Dokumente in einen Prompt wandern.
Das Toolkit kennzeichnet Quellen im Prompt ausdrücklich als fremden Inhalt und erkennt verdächtige Formulierungen vorher, auf Deutsch und Englisch:
from deutsches_ki.security import scan_text
report = scan_text(open("vertrag.txt", encoding="utf-8").read())
if not report.is_clean:
for finding in report.injections:
print(finding.rule, finding.severity, finding.text)
Dazu kommt die Suche nach Zugangsdaten in Dokumenten. Die Fundsätze werden dabei maskiert ausgegeben, damit ein Prüfbericht nicht selbst zum Leck wird.
8. Eine Anonymisierung, die nur an der Oberfläche wirkte
Diese hier ist mir erst beim Bauen der Demo aufgefallen, und sie ist die unangenehmste im ganzen Text.
Die Kette war so gebaut: Text einlesen, sensible Stellen finden, ersetzen, zerlegen, suchen. Nach dem Ersetzen stand im Dokument der bereinigte Text. Von außen sah die Anonymisierung also korrekt aus.
Der Chunker liest aber nicht den Text des Dokuments, sondern dessen Abschnittsbaum, und der entsteht beim Einlesen der Datei – also vor dem Ersetzen. Ich hatte den Text ersetzt und den Baum stehen lassen:
doc = GermanDocument.from_file("vertrag.txt")
doc.detect_pii()
doc.anonymize("redact")
doc.text # "Kunde: ..., IBAN ..." — bereinigt
doc.chunk()[0].content # "Kunde: Max Mustermann, IBAN DE89 3704 ..." — unverändert
Über die Suche und damit über jede RAG-Antwort kam der Originaltext zurück, obwohl die Anonymisierung gemeldet hatte, dass sie gelaufen ist. Ein Fehler, den man nicht bemerkt, weil das Ergebnis an der Stelle, an der man hinschaut, richtig aussieht.
Behoben ist er, indem der Baum nach dem Ersetzen neu gebaut wird:
self.document.content = result.text
self.document.sections = build_sections(result.text)
Dazu gehört ein Test, der nach dem Anonymisieren die Chunks prüft und nicht den Text. Die Lehre daraus ist allgemeiner, als sie klingt: Bei einer Kette aus mehreren Stufen muss man an der letzten prüfen, nicht an der, die man gerade gebaut hat. Und es ist ein Argument dafür, sensible Daten so spät wie möglich zu ersetzen – je weniger Stufen danach kommen, desto weniger Stellen können sie weitertragen.
Antworten mit geprüften Quellenangaben
Eine Antwort ohne Quelle ist eine Behauptung. Deshalb werden die Quellen im Prompt nummeriert, und die vom Modell genannten Nummern werden hinterher geprüft:
from deutsches_ki.rag import DeutschRAG
rag = DeutschRAG(retriever, llm=provider)
answer = rag.ask("Welche Zahlungsfrist gilt?")
print(answer.answer)
print(answer.metadata["citations"])
# {"cited": [1], "valid": [1], "unknown": [], "source_count": 3}
Erfindet das Modell einen Verweis auf [9], landet das in unknown und fällt auf. Statt die Ausgabe eines Sprachmodells ungeprüft weiterzureichen, bekommt man ein Signal, wann man ihr nicht trauen sollte.
In der Demo steht dieser Schritt zum Anklicken. Dort läuft Qwen2.5-1.5B-Instruct auf einer Grafikkarte und beantwortet die Frage aus denselben Abschnitten, die der Extraktiv-Schritt davor benutzt. Die Antwort ist richtig – und das Modell nennt keine einzige Quelle, obwohl der Prompt es verlangt. Die Prüfung schreibt dann „Keine Quelle genannt“ statt eine fehlende Angabe zu übersehen. Das ist der ehrlichere Ausgang für einen Blogartikel über Zitatprüfung: Man sieht sie arbeiten, weil sie etwas zu beanstanden hat.
Messen statt behaupten
„Die Antwort sieht gut aus“ ist keine Messung. Das Toolkit bringt Metriken, ein Datensatzformat und einen Bewertungslauf mit:
deutsches-ki evaluate datasets/benchmark/deutsch_rag.yaml --corpus datasets/fixtures
Über 12 deutsche Fragen zu Vertrag, Rechnung und Handbuch, mit dem deterministischen Hashing-Modell als Embedder (also ohne neuronales Modell):
| Metrik | Wert |
|---|---|
| Recall@1 | 0,92 |
| Recall@5 | 1,00 |
| MRR | 0,94 |
| nDCG@5 | 0,96 |
| Trefferquote@5 | 1,00 |
Der ehrliche Teil: Recall@1 von 0,92 heißt, dass eine von zwölf Fragen den richtigen Abschnitt nicht auf Platz eins findet. Recall@5 von 1,00 heißt, dass er immer unter den ersten fünf ist. Für ein RAG-System ist das der relevante Wert, weil das Modell ohnehin mehrere Quellen bekommt.
Die Zahl, die mich misstrauisch gemacht hat
Direkt nachdem ich die Tabelle oben geschrieben hatte, ist mir etwas passiert, das gut zu diesem Text passt.
Installiert man zusätzlich Docling, liest der Bewertungslauf auch die PDF im Bestand mit. Der Bestand enthält denselben Vertrag zweimal: einmal als Markdown, einmal als PDF. Dasselbe Kommando, derselbe Code, nur eine Bibliothek mehr:
| Metrik | ohne Docling | mit Docling |
|---|---|---|
| Recall@1 | 0,92 | 0,58 |
| MRR | 0,94 | 0,78 |
| nDCG@5 | 0,96 | 0,84 |
Von 0,92 auf 0,58, ohne eine Zeile zu ändern. Schaut man nach, welcher Treffer auf Platz eins lag, sieht man, was wirklich passiert ist:
Frage: Wie lange ist die Zahlungsfrist?
1. vertrag.pdf § 4 Zahlungsbedingungen 0.033
2. vertrag.md § 4 Zahlungsbedingungen 0.032
Der richtige Abschnitt steht ganz oben. Nur stammt er aus der anderen Datei, und der Bewertungssatz erwartet „vertrag.md#§ 4 Zahlungsbedingungen“. Zwei fast identische Dokumente, ein Unterschied von einer Tausendstel Punktzahl, und die Kennzahl halbiert sich. Die Suche hat nichts falsch gemacht, die Bewertung schon.
Ich schreibe das nicht, weil es die Zahlen schönt, sondern weil es die andere Hälfte der Regel ist: Eine Kennzahl ohne den Bestand, auf dem sie erhoben wurde, ist keine Kennzahl. Und wenn zwei Dokumente denselben Inhalt in zwei Formaten enthalten, muss man sich entscheiden, ob man das im Bestand haben will, bevor man Recall@1 als Fortschritt liest.
Vorher waren es hier übrigens Recall@1 0,75 und MRR 0,84. Der Sprung auf 0,92 kommt von groben Wortstämmen in den Such-Token: „Ersatzteilen“ findet jetzt „Ersatzteile“. Ich lasse die alten Zahlen stehen, statt sie zu überschreiben, weil der Vergleich die eigentliche Information ist.
Derselbe Befehl vergleicht auch Embedding-Modelle:
deutsches-ki evaluate datasets/benchmark/deutsch_rag.yaml --corpus datasets/fixtures --compare hashing,bge-m3
| Modell | Dimension | Recall@5 | MRR | nDCG@5 | ms je Frage |
|---|---|---|---|---|---|
| hashing | 256 | 1,00 | 0,78 | 0,84 | 1,5 |
| BGE-M3 | 1024 | 1,00 | 0,88 | 0,91 | 182,9 |
Beide Modelle finden in diesem Lauf jede Antwort unter den ersten fünf. Der Unterschied liegt darin, wie oft sie auf Platz eins landet: 0,78 gegen 0,88. Dafür braucht BGE-M3 rund hundertzwanzigmal so lange je Frage. Für zwölf Fragen ist das nebensächlich, bei Tausenden Anfragen ist es eine Entscheidung.
Nebenbei sieht man hier dasselbe Problem wie oben: In diesem Lauf steht hashing bei MRR 0,78, im Einzellauf davor bei 0,94. Dasselbe Modell, derselbe Datensatz, nur ein anderer Bestand, weil der Vergleichspfad die PDF mitliest. Solche Zahlen gehören immer mit dem Befehl zusammen, der sie erzeugt hat.
Dasselbe gilt für die deutsche Behandlung selbst. In einem eigenen Durchlauf über dieselben zwölf Fragen erreicht die deutsche Suche 10 von 12, eine sorgfältig gebaute naive Suche mit Satzzeichenbereinigung und Stoppwortliste ebenfalls 10 von 12. Erst die nachlässige Variante – an Leerzeichen trennen, Satzzeichen kleben lassen – fällt auf 8 von 12 zurück. Das war eine einmalige Messung und kein Befehl aus dem Toolkit, deshalb steht sie hier als Beobachtung und nicht als Tabelle.
Der Vorsprung zeigt sich ohnehin nicht überall, sondern genau dort, wo Deutsch schwer ist: „Ersatzteilen“ findet den Abschnitt „Ersatzteile“, und das gelingt keiner der naiven Varianten. Einzelne Fälle verliert die deutsche Suche auch, etwa bei „Wartung“ gegen „Wartungsintervalle“. Wer etwas anderes behauptet, hat nicht nachgemessen. In der Demo lässt sich das je Frage anschauen, statt es zu glauben.
Was ich noch nicht weiß
Ich schreibe das lieber hin, als es später erklären zu müssen. Der Abschnitt ist seit der ersten Fassung dieses Textes deutlich kürzer geworden, weil ich einiges nachgeholt habe.
Gegen die echten Bibliotheken geprüft und größtenteils in der CI: Docling für PDF, BGE-M3, der Cross-Encoder, GLiNER, PostgreSQL mit pgvector (eigener Server, eigener CI-Job), die spaCy- und Presidio-Detektoren gegen ein echtes deutsches Modell und der MCP-Server. Das Docker-Image habe ich gebaut und ausgeführt.
Was die Anbindung an Ollama und vLLM angeht, ist die Kette aus Suche, Prompt und Antwort gegen ein echtes Modell gelaufen. Ein Betrieb unter Last ist damit nicht geprüft, und das behaupte ich auch nicht.
Zwei Dinge, die ich beim Sprachmodell gesehen habe und die hierher gehören:
Ein kleines Modell hält sich nicht an die Zitatvorgabe. Das Modell in der Demo, Qwen2.5-1.5B-Instruct, beantwortet die Frage richtig, lässt aber die geforderten Nummern [1] weg. Die Prüfung meldet das, statt es zu übersehen – in der Demo ist genau das zu sehen.
Das Urteil durch ein Sprachmodell ist schwankend. Dieselbe Frage, dieselben Quellen, dieselbe Antwort, dreimal bewertet: Treue 0,0, dann 0,5, dann 1,0. Ein Modell als Richter ist bequem, aber nicht belastbar. Deshalb sind die deterministischen Maße die Grundlage, und das Urteil durch ein Modell ist eine freiwillige Zugabe.
Noch nicht gebaut: Bewertung der Antwortqualität über ein Sprachmodell (die deterministischen Näherungen gibt es), eine öffentliche Seite mit den Messwerten aus mehreren Läufen, eine Enterprise-Datenbankanbindung und Feintuning.
Ausprobieren
pip install deutsches-ki-toolkit
deutsches-ki pii rechnung.txt
deutsches-ki scan vertrag.docx.md
deutsches-ki search ./dokumente "Wie lange ist die Kündigungsfrist?"
deutsches-ki evaluate datasets/benchmark/deutsch_rag.yaml --corpus ./dokumente
Wer nur schauen will, nimmt die Demo im Browser. Alles läuft dort auf dem Server, es wird nichts gespeichert.
Die Grundinstallation bleibt leicht. Schwere Bausteine kommen über Extras dazu: [nlp], [presidio], [docling], [embeddings], [postgres], [mcp].
Was mich interessiert
Zwei Fragen, bei denen ich wirklich etwas mitnehmen würde:
Welche deutschen Sprachfälle fallen in euren Pipelines durch? Komposita, Abkürzungen, Adressformate, Fachterminologie, gemischt deutsch-englische Texte? Ich sammle solche Fälle und mache Regressionstests daraus, weil genau die den Unterschied machen.
Und: hat jemand den unaccent-Weg sauber unter Produktionsbedingungen laufen, also mit eigenen Regeln und einem Deployment, das sie überlebt? Mich würde interessieren, ob sich der Aufwand gegenüber der Suchspalte in Python lohnt.
Der Code steht unter Apache-2.0: github.com/mehrabix/deutsches-ki-toolkit










