Startseite / Artikel

Datenreinigung für Produktions-RAG: Kodierung, OCR, Doppelungsbeseitigung und PII

Wie man analysierte Unternehmensdokumente in für die Suche bereiten Text umwandelt: Fehlerbehebung bei der Kodierung, Entfernung von Standardtexten und Duplikaten, Kennzeichnung risikoreicher OCR-Ergebnisse, Handhabung verschiedener Sprachen sowie Maskierung personenbezogener Daten.

Verlag: REACTAPP.TOP21 Min. Lesezeit4926 Wörter

Quellen: Data Cleaning and Preprocessing for Production RAG: Building Retrieval-Ready Documents

Stellen Sie sich eine Bank vor, die einen Wissensassistenten über 200.000 interne und regulatorische Dokumente eingesetzt hat. Die Einpflege der Daten verläuft reibungslos. PDFs werden analysiert, gescannte AML-Bekanntmachungen durchlaufen das OCR-Verfahren, SharePoint-Dateien kommen pünktlich an, und Tabellen werden getrennt extrahiert. Es scheint nichts im Datensatz zu fehlen.

Doch die Antworten sind ungleichmäßig. Ein Compliance-Beauftragter fragt, welche Dokumente für eine verschärfte Sorgfaltspflicht bei einem hochriskanten Unternehmenskunden erforderlich sind. Der Suchalgorithmus liefert fünf Ergebnissestücke. Drei enthalten die relevanten KYC-Richtlinien, und zwei weisen fast identischen Text aus einer älteren Version des Dokuments auf. Jedes Ergebnisstück beginnt mit demselben Header:

CONFIDENTIAL - INTERNAL USE ONLY
Group Financial Crime Compliance
Page 47 of 132

Ein Teil des Textes enthält außerdem den Satz „AII high-risk corporate customers must undergo enhanced due diIigence.“ Schauen Sie genau hin: All wurde zu AII und diligence zu diIigence, weil das OCR ein kleines l mit einem großen I verwechselt hat.

An anderen Stellen derselben Datei ist ein nicht-trennbarer Leerzeichen überlebt, ein Apostroph im Windows-1252-Format wurde falsch decodiert, und der Fußtext erscheint 132 Mal, weil er auf jeder Seite vorhanden war. Die archivierte Kopie der Richtlinie befindet sich im Index neben der genehmigten Version, da SharePoint beide aufbewahrt hat.

Technisch gesehen funktionierte der Eingabeprozess. Aus betrieblicher Sicht ist das Korpus jedoch unvollständig, und Entwickler, die ihr erstes RAG-System erstellen, unterschätzen oft, wie wichtig dieser Unterschied ist.

Warum unvollständige Daten verborgen bleiben

Die Schäden sind in der Regel so subtil, dass nichts abstürzt. Eine fehlerhafte Bytefolge veranlasst eine Vektordatenbank nicht zum Abbruch. Ein duplizierter Absatz führt nicht dazu, dass eine Embedding-API versagt. Ein wiederholter Fußteil löst keine Warnung aus, und ein OCR-Fehler von 1 zu l stoppt die Indexierungsarbeit nicht. Der Pipeline verarbeitet alles problemlos, und die Folgen treten später in Form von Abfragerückfällen auf.

Duplizierte Dokumente belegen mehrere Plätze in den Top-k-Ergebnissen. Wiederholte Standardtexte erzeugen künstliche Ähnlichkeiten zwischen unverwandten Abschnitten. OCR-Fehler verändern wichtige Entitäten, Prozentsätze, Kontonummern sowie regulatorische Grenzwerte. Kodierungsfehler fügen Tokens hinzu, die keinerlei geschäftliche Bedeutung haben. Wenn schließlich eine fehlerhafte Antwort erscheint, könnte bereits jede nachgelagerte Komponente ihre Aufgabe erfüllt haben: Der Suchmechanismus hat das Gespeicherte zurückgegeben, der Neurangier hat das Gelieferte bewertet und das große Sprachmodell hat auf dem ihm zur Verfügung stehenden Kontext gearbeitet. Der Fehler trat bereits vor allen drei auf.

Nehmen wir eine AML-Richtlinie, die vorschreibt, dass Transaktionen über 10.000 EUR zusätzliche Überprüfungen der Geldherkunft erfordern. Nachdem fehlerhafte OCR-Verarbeitung und eine fehlerhafte Kodierungsumwandlung stattgefunden haben, wird im Index „EUR l0,000“ gespeichert. Eine Person erkennt den Fehler sofort. Ein Embedding-Modell behandelt den Satz dennoch als nahezu identisch mit Begriffen wie Transaktionsschwellenwerte und Überprüfungen der Geldherkunft, wodurch die Suche perfekt funktioniert – was besonders beunruhigend ist. Der beschädigte Text erreicht das LLM, da die semantische Suche textuelle Mängel ignoriert. Das System findet schließlich den richtigen Abschnitt der Richtlinie, enthält dort jedoch den falschen regulatorischen Wert.

Deshalb kann die Reinigung nicht auf text.strip().lower() reduziert werden.

Wo die Reinigung in einem RAG-Pipeline-Workflow Platz findet

Die Reinigung erfolgt zwischen der Aufnahme der Dokumente und dem Aufteilen in Blöcke, und diese Reihenfolge ist bewusst gewählt. Man möchte die Ähnlichkeit der Dokumente nicht messen, bevor offensichtliche Kodierungsfehler behoben wurden. Man möchte den Text nicht übermäßig normalisieren, bevor die Sprache erkannt wurde. Man möchte wiederholte rechtliche Klauseln nicht einfach löschen, nur weil sie wie Standardformulare aussehen. Zudem sollten OCR-Korrekturen mit geringer Zuverlässigkeit die Compliance-Pflichten nicht heimlich verändern.

Sobald der störende Text in Blöcke aufgeteilt und eingebettet wurde, wird die Reinigung deutlich schwieriger. Duplizierte Absätze haben bereits duplizierte Vektoren erzeugt, beschädigte Wörter haben die Grenzen der Blöcke bestimmt, wiederholte Überschriften haben den Token-Raum belegt, und OCR-Fehler sind in die semantische Darstellung eingebettet. Die Reinigung gehört vor dem Aufteilen in Blöcke – nicht als Nachbesserung, sobald die Qualität nachlässt.

Kodierungsnormalisierung: Erst den Text reparieren

Unternehmenskollektionen teilen selten dieselbe Kodierungshistorie. Eine moderne Richtlinie kann UTF-8 sein, ein Archiv für Kundenkorrespondenz möglicherweise Windows-1252, während ältere Exporte Latin-1 verwenden. Texte, die zwischen SharePoint, E-Mails, Word und veralteten Dokumentensystemen kopiert werden, wurden möglicherweise mehrfach kodiert und decodiert, bevor sie verarbeitet werden. Das Ergebnis sieht wie folgt aus:

Customer’s identity must be verified before account opening.
The customer must provide proof of address.
Enhanced Due Diligence â€" High Risk Customers

Diese Korruption wird als Mojibake bezeichnet und tritt auf, wenn Bytes in einer Kodierung als solche einer anderen Kodierung gelesen werden. Man erkennt sofort, dass customer’s fehlerhaft ist. In einem Abrufprozess erreicht das korrupte Token den Tokenisierer anders als customer's, und in großem Maßstab führen solche Inkonsistenzen dazu, dass das Vokabular zersplittert wird und unnötige Variationen im Embedding-Raum entstehen.

Achten Sie darauf, wie weit die Reparatur geht. Eine unvorsichtige Funktion betrachtet jeden Zeichen außerhalb von ASCII als Unbrauchbares, doch in europäischen Zahlungskorpusen finden sich rechtmäßig Begriffe wie Société Générale, Deutsche Bundesbank, €10,000 und Überweisung. Entfernen Sie Akzente und Symbole, und die echten Informationen verschwinden. Das Ziel ist kein rein ASCII-basierter Text, sondern konsistentes Unicode, das seine Bedeutung beibehält.

Unicode mit ftfy reparieren

Es ist nicht notwendig, jedes Mojibake-Muster selbst zu katalogisieren, denn ftfy behebt bereits die üblichen Unicode- und Kodierungsfehler. Von dort aus kann eine Produktionsreinigungsphase beginnen:

import re
import unicodedata
from dataclasses import dataclass, field
import ftfy

@dataclass
class TextCleaningResult:
    original_text: str
    cleaned_text: str
    transformations: list[str] = field(default_factory=list)
    quality_flags: list[str] = field(default_factory=list)
def normalize_encoding(text: str) -> TextCleaningResult:
    """
    Repair common encoding problems while preserving meaningful Unicode.
    Suitable for policy, AML/KYC, payment, and regulatory documents where
    accented names, currency symbols, and multilingual text must survive.
    """
    transformations: list[str] = []
    quality_flags: list[str] = []
    if not text:
        return TextCleaningResult(original_text=text, cleaned_text=text)
    cleaned = text
    # Repair mojibake and common Unicode encoding mistakes.
    repaired = ftfy.fix_text(cleaned)
    if repaired != cleaned:
        transformations.append("ftfy_encoding_repair")
        cleaned = repaired
    # NFC preserves characters while producing a consistent Unicode
    # representation. Safer than aggressively stripping accents.
    normalized = unicodedata.normalize("NFC", cleaned)
    if normalized != cleaned:
        transformations.append("unicode_nfc_normalization")
        cleaned = normalized
    # Replace non-breaking spaces with normal spaces.
    if "\u00a0" in cleaned:
        cleaned = cleaned.replace("\u00a0", " ")
        transformations.append("non_breaking_space_normalization")
    # Remove zero-width characters that frequently leak from PDFs,
    # web pages, and copied Office content.
    zero_width_chars = {"\u200b", "\u200c", "\u200d", "\ufeff"}
    if any(char in cleaned for char in zero_width_chars):
        cleaned = "".join(char for char in cleaned if char not in zero_width_chars)
        transformations.append("zero_width_character_removal")
    # Remove control characters; preserve newline and tab because
    # they may still carry document structure needed downstream.
    cleaned_without_controls = "".join(
        char for char in cleaned
        if char in "\n\t" or unicodedata.category(char) != "Cc"
    )
    if cleaned_without_controls != cleaned:
        transformations.append("control_character_removal")
        cleaned = cleaned_without_controls
    # Normalize horizontal whitespace without flattening paragraphs.
    whitespace_normalized = re.sub(r"[ \t]+", " ", cleaned)
    whitespace_normalized = re.sub(r"\n{3,}", "\n\n", whitespace_normalized)
    if whitespace_normalized != cleaned:
        transformations.append("whitespace_normalization")
        cleaned = whitespace_normalized
    cleaned = cleaned.strip()
    # Keep suspicious replacement characters observable rather than silently deleting them.
    if "\ufffd" in cleaned:
        quality_flags.append("unicode_replacement_character_detected")
    return TextCleaningResult(
        original_text=text,
        cleaned_text=cleaned,
        transformations=transformations,
        quality_flags=quality_flags,
    )

Achten Sie darauf, was die Funktion unberührt lässt. Sie wandelt nicht alles in Kleinbuchstaben um, entfernt keine Akzente aus Société Générale, löscht kein Eurozeichen und vereinfacht das Dokument auch nicht zu einer einzigen Zeile. Diese Auslassungen sind beabsichtigt.

Warum eine übermäßige Reinigung schadet

Viele ältere NLP-Pipelines wurden für Bag-of-Words-Modelle oder TF-IDF entwickelt und wandelten den Text in Kleinbuchstaben um, entfernten Interpunktion, Stoppwörter und führten Stemming durch. Die blinde Anwendung dieser Methoden vor modernen Embeddings führt zum Verlust nützlicher Informationen. Betrachten Sie, was das mit diesem Satz anrichten würde:

Customer must NOT be classified as low risk.

Finanzielle Texte bieten noch feinfühligere Beispiele. EUR 10.000 und EUR 100.000 sehen in ihren Buchstaben ähnlich aus, unterscheiden sich jedoch erheblich in ihrer praktischen Bedeutung. Ebenso weisen Artikel 6(1)(a) und Artikel 6(1)(b) eine identische Struktur auf, besitzen aber unterschiedliche rechtliche Wirkung. Die geltende Regel lautet, die Darstellung aggressiv zu normalisieren und die Bedeutung konservativ zu bewahren. Die Umwandlung von customer’s in customer's stellt eine Formänderung dar, die den ursprünglichen Sinn des Autors wiederherstellt. Das Weglassen von €, das Entfernen von Akzenten aus dem Namen eines Kunden oder die automatische Umwandlung von l0% in 10% ändern Fakten, auf die das Unternehmen angewiesen sein könnte.

Entfernung von Standardformulierungen: Struktur statt Schlüsselwörter

Überschriften und Fußzeilen sind nicht derselben Art von Problem wie Kodierungsstörungen. Die Wiederholung von Seite 47 von 132 ist eindeutig Standardtext, doch eine Konformitätsanforderung, die in mehreren Richtlinienabschnitten vorkommt, kann absichtlich wiederholt werden und rechtliche Bedeutung haben. Wie oft eine Zeile erscheint, sagt nicht aus, um welche der beiden Fälle es sich handelt.

Ein besseres Signal kombiniert Wiederholung mit Position. Überschriften befinden sich oben auf den Seiten und Fußzeilen unten, wobei die Seitenzahlen leicht variieren, während das strukturelle Muster erhalten bleibt:

KYC Policy | Version 7.2 | Page 41 of 132
KYC Policy | Version 7.2 | Page 42 of 132

Die Logik der exakten Übereinstimmung erkennt zwei unterschiedliche Zeichenketten, während die strukturbezogene Logik zwei Instanzen desselben Templates erkennt. Das ist ein Grund, warum die Erkennung von Standardtexten am besten funktioniert, solange Seitengrenzen noch vorhanden sind – man sollte daher vermeiden, ein bereits analysiertes Dokument unmittelbar nach der Aufnahme in eine einzige riesige Zeichenkette umzuwandeln.

import re
from collections import Counter
from dataclasses import dataclass

@dataclass
class BoilerplatePattern:
    normalized_text: str
    occurrences: int
    page_ratio: float
    position: str
def normalize_boilerplate_candidate(line: str) -> str:
    """
    Normalize variable fields so structurally identical headers and
    footers can be compared across pages.
    """
    normalized = line.strip()
    normalized = re.sub(
        r"\bpage\s+\d+\s+of\s+\d+\b", "page <n> of <n>",
        normalized, flags=re.IGNORECASE,
    )
    normalized = re.sub(r"\bpage\s+\d+\b", "page <n>", normalized, flags=re.IGNORECASE)
    normalized = re.sub(r"\s+", " ", normalized)
    return normalized.casefold()
def detect_repeated_marginal_text(
    pages: list[dict],
    margin_lines: int = 3,
    min_page_ratio: float = 0.6,
) -> list[BoilerplatePattern]:
    """
    Detect repeated text in page-header or page-footer regions.
    A candidate is considered boilerplate only when it appears in the same
    marginal position across a substantial fraction of the document.
    """
    if not pages:
        return []
    header_counts: Counter = Counter()
    footer_counts: Counter = Counter()
    for page in pages:
        lines = [line.strip() for line in page["text"].splitlines() if line.strip()]
        if not lines:
            continue
        header_counts.update(normalize_boilerplate_candidate(l) for l in lines[:margin_lines])
        footer_counts.update(normalize_boilerplate_candidate(l) for l in lines[-margin_lines:])
    total_pages = len(pages)
    patterns: list[BoilerplatePattern] = []
    for position, counts in (("header", header_counts), ("footer", footer_counts)):
        for normalized_text, occurrences in counts.items():
            page_ratio = occurrences / total_pages
            if page_ratio >= min_page_ratio:
                patterns.append(BoilerplatePattern(
                    normalized_text=normalized_text,
                    occurrences=occurrences,
                    page_ratio=page_ratio,
                    position=position,
                ))
    return patterns

Der Schwellenwert min_page_ratio ist die wichtigste Entscheidungsgrundlage. Die Regel lautet nicht „Lösche jede Zeile, die zweimal erscheint“, sondern „Betrachte eine strukturell ähnliche Zeile als Standardtext, wenn sie überwiegend in einer Randposition der Seite im gesamten Dokument auftaucht“. Bei einer 100-seitigen Richtlinie, in der CONFIDENTIAL - INTERNAL USE ONLY 98 Seiten überschreibt, sind die Anhaltspunkte überzeugend.

Ein weiterer Punkt: Das Entfernen von Inhalten aus dem für das Einbetten verwendeten Text bedeutet nicht unbedingt deren Löschung aus dem System. Manchmal sollte Informationen vom semantischen Inhaltsebenen in die Metadaten-Ebene verschoben werden. Felder wie Version, Eigentümer, Klassifizierung und Gültigkeitsdatum eignen sich besser als Metadaten, anstatt in jeden Teilinhalt kopiert zu werden.

Deduplizierung jenseits exakter Kopien

Ein Dokument kann perfekt kodiert sein, frei von OCR-Schäden und ohne überflüssigen Text, und dennoch bei der Suche Schwierigkeiten bereiten – einfach weil es im Korpus mehrfach vorhanden ist. Unternehmensrepositorien sind voll davon:

AML_Policy_v8_Final.docx
AML_Policy_v8_Final_Approved.docx
AML_Policy_v8_Final_Copy.docx
AML_Policy_v8_Approved_2026.docx

Indexieren Sie alle vier Dateien als separate Dokumente – ihre Abschnitte konkurrieren dann beim Abrufen. Wenn Sie nach einer verbesserten Überwachung von Hochrisikokunden fragen, könnten bereits drei Slots durch einen einzigen Policisektor belegt werden, wodurch nur noch zwei für wirklich unterschiedliche Beweismittel übrig bleiben. Duplikate kosten nicht nur Speicherplatz: Sie verfälschen außerdem die Bedeutung von „top-k“.

Echte Duplikate mit Inhaltshashing

import hashlib
from dataclasses import dataclass

@dataclass
class DeduplicationRecord:
    document_id: str
    source: str
    content_hash: str
    duplicate_of: str | None = None
def canonicalize_for_hashing(text: str) -> str:
    """
    Deterministic normalization before hashing.
    Does not lowercase or remove punctuation because those transformations
    could collapse documents that are not actually identical.
    """
    lines = [" ".join(line.split()) for line in text.splitlines()]
    return "\n".join(lines).strip()
def calculate_content_hash(text: str) -> str:
    canonical_text = canonicalize_for_hashing(text)
    return hashlib.sha256(canonical_text.encode("utf-8")).hexdigest()
def find_exact_duplicates(
    documents: list[dict],
) -> tuple[list[dict], list[DeduplicationRecord]]:
    """
    Keep one canonical copy of each identical document while preserving
    duplicate lineage for auditability.
    """
    seen_hashes: dict[str, str] = {}
    unique_documents: list[dict] = []
    records: list[DeduplicationRecord] = []
    for document in documents:
        content_hash = calculate_content_hash(document["clean_text"])
        if content_hash in seen_hashes:
            records.append(DeduplicationRecord(
                document_id=document["document_id"],
                source=document["source"],
                content_hash=content_hash,
                duplicate_of=seen_hashes[content_hash],
            ))
            continue
        seen_hashes[content_hash] = document["document_id"]
        unique_documents.append(document)
        records.append(DeduplicationRecord(
            document_id=document["document_id"],
            source=document["source"],
            content_hash=content_hash,
        ))
    return unique_documents, records

Der Hash ist auch nützlich für ein inkrementelles Indexieren. Wenn dasselbe normalisierte Dokument bei der Importierung morgen Abend erneut auftaucht, ist sein Hash bereits bekannt, sodass der Prozess das Erneut Reinigen, Aufteilen und Einbetten überspringen kann.

Nähe-Duplikate mit MinHash LSH

Eine exakte Hashierung erledigt die einfachen Fälle. Schwieriger ist es bei zwei Versionen einer KYC-Vorschrift, zum Beispiel Version 7 und Version 8, die zu 98 % im Text identisch sind, aber in einer regulatorischen Klausel voneinander abweichen. Aus Sicht der Konformität sind sie keineswegs Duplikate, denn diese letzte Klausel ändert die Kontrollanforderungen.

In einem regulierten RAG-System sollte die Erkennung nahezu identischer Dokumente zunächst eine andere Frage beantworten, nämlich welche Dokumente ähnlich genug sind, um untersucht zu werden, wobei die Löschung als eigene Entscheidung zurückbleibt. Für 200.000 Dokumente erfordern Paarvergleiche etwa 20 Milliarden Vergleiche. MinHash mit lokalitätsempfindlichem Hashing vermeidet dies, indem es kompakte Signaturen erstellt und ähnliche Signaturen in Kandidaten-Container platziert.

import re
from datasketch import MinHash, MinHashLSH

def create_word_shingles(text: str, shingle_size: int = 5) -> set[str]:
    """
    Five-word shingles work well for long policy and regulatory documents
    because they capture local textual structure without being overly
    sensitive to isolated formatting changes.
    """
    tokens = re.findall(r"\b\w+\b", text.casefold())
    if len(tokens) < shingle_size:
        return {" ".join(tokens)} if tokens else set()
    return {
        " ".join(tokens[i:i + shingle_size])
        for i in range(len(tokens) - shingle_size + 1)
    }
def create_minhash(text: str, num_perm: int = 128) -> MinHash:
    shingles = create_word_shingles(text)
    minhash = MinHash(num_perm=num_perm)
    for shingle in shingles:
        minhash.update(shingle.encode("utf-8"))
    return minhash
def build_duplicate_index(
    documents: list[dict],
    threshold: float = 0.85,
    num_perm: int = 128,
) -> tuple[MinHashLSH, dict[str, MinHash]]:
    """
    Build an LSH index for candidate near-duplicate discovery.
    The threshold identifies candidates. It does not automatically
    determine whether a document is deleted.
    """
    lsh = MinHashLSH(threshold=threshold, num_perm=num_perm)
    signatures: dict[str, MinHash] = {}
    for document in documents:
        document_id = document["document_id"]
        signature = create_minhash(document["clean_text"], num_perm=num_perm)
        signatures[document_id] = signature
        lsh.insert(document_id, signature)
    return lsh, signatures

Wenn die KYC-Richtlinien v7 und v8 in denselben Ordner gelangen, hat der Pipeline gelernt, dass sie genauer untersucht werden sollten – und nichts weiter. Er hat nicht gelernt, dass eine davon entfernt werden sollte. Eine Entscheidung für die Produktion sollte Textähnlichkeit mit Metadaten wie Richtlinien-ID, Version, Inkrafttrittsdatum, Genehmigungsstatus und Zuständigkeitsbereich kombinieren, denn allein die Ähnlichkeit reicht nicht aus, um eine Entscheidung zu treffen.

OCR-Fehler: scheinbar korrekter Text, der falsch ist

Die Kodierungsreparatur behebt Zeichen, die falsch gespeichert wurden. OCR-Fehler gehören zu einer separaten Kategorie. Hier kann die Verarbeitungseinheit ein völlig gültiges Unicode-Zeichen mit voller Sicherheit gelesen haben und einfach das falsche ausgewählt haben. Typische Verwechslungen sind 0 und O, 1, l und I, 5 und S sowie rn und m.

Die Gesamtfehlerquoten mögen niedrig sein, doch die aggregierte Genauigkeit ist nicht der maßgebliche Indikator für regulierte RAG-Systeme. Ein 40-seitiger Informationsbogen, der zu 99,8 % korrekt ist, kann dennoch kritische Werte enthalten, die fehlerhaft sind. Der entscheidende architektonische Aspekt besteht darin, dass OCR-Fehler unterschiedliche Risiken aufweisen. Die Korrektur von custorner in customer ist risikofrei, da Kontext und ein Wörterbuch stark auf das beabsichtigte Wort hindeuten. Die Umwandlung von EUR l0,000 in EUR 10,000 gehört hingegen einer völlig anderen Kategorie an, da eine falsche Schätzung Verpflichtungen im Hinblick auf die Konformität verändern kann.

import re
from dataclasses import dataclass

@dataclass
class OCRIssue:
    token: str
    issue_type: str
    risk_level: str
    suggested_value: str | None
    requires_review: bool
CURRENCY_PATTERN = re.compile(r"\b(EUR|USD|GBP|INR)\s+([A-Za-z0-9.,]+)\b")
PERCENTAGE_PATTERN = re.compile(r"\b([A-Za-z0-9.,]+)\s*%")
def detect_numeric_ocr_issues(text: str) -> list[OCRIssue]:
    """
    Detect suspicious OCR substitutions inside financial values.
    This function identifies candidates. It does not silently rewrite
    high-risk values in the source text.
    """
    issues: list[OCRIssue] = []
    substitutions = {"O": "0", "o": "0", "I": "1", "l": "1"}
    def inspect_numeric_token(token: str, issue_type: str) -> None:
        if token.replace(",", "").replace(".", "").isdigit():
            return
        corrected = "".join(substitutions.get(char, char) for char in token)
        numeric_candidate = corrected.replace(",", "").replace(".", "")
        if numeric_candidate.isdigit() and corrected != token:
            issues.append(OCRIssue(
                token=token,
                issue_type=issue_type,
                risk_level="high",
                suggested_value=corrected,
                requires_review=True,
            ))
    for match in CURRENCY_PATTERN.finditer(text):
        inspect_numeric_token(match.group(2), issue_type="currency_value")
    for match in PERCENTAGE_PATTERN.finditer(text):
        inspect_numeric_token(match.group(1), issue_type="percentage")
    return issues

Funktionen ändern das Dokument absichtlich nicht. Eine reguläre Ausdrucksweise kann zeigen, dass l0,000 im Kontext von Währungen verdächtig erscheint, kann aber nicht beweisen, dass ursprünglich tatsächlich 10,000 stand.

Normales Vokabular wird besser von einem Wörterbuch bewältigt, das sich mit dem jeweiligen Bereich auskennt. Banktexte benötigen eine allgemeine Wortsammlung sowie Begriffe wie beneficial ownership, counterparty, SWIFT, IBAN, SEPA und sanctioned. Andernfalls wird ein einfacher Rechtschreibprüfer gerne legitime Finanzbegriffe in schlechtere umwandeln.

In der Produktion wiegt die Nutzung von OCR-Verbesserungen das Vertrauen des Systems in die Erkennung gegen den Stellenwert eines möglichen Fehlers ab. Zuverlässige Erkennungen von Texten mit geringem Risiko können in der Regel ohne menschliche Einmischung korrigiert werden. Hochriskante Inhalte – wie Geldbeträge, Prozentsätze, Nummern von Vorschriftenartikeln und Daten – sollten unabhängig vom OCR-Vertrauensgrad überprüft werden. Ein Finanzsystem sollte auf kontrollierte Genauigkeit abzielen und nicht auf maximale Automatisierung.

Mehrsprachige Vorausverarbeitung

Spracherkennung erscheint unbedeutend, solange ein Korpus innerhalb nationaler Grenzen bleibt. Ein Repository für Zahlungsvorgänge kann beispielsweise englische Prozessdokumente, deutsche Kundenbriefe, französische Vorschriftenmitteilungen und spanische Verträge enthalten, und eine AML-Ermittlungsakte kann in einem einzigen Fallbericht mehrere Sprachen beinhalten.

Sprache beeinflusst alles, was danach kommt. Die Rechtschreibkorrektur hängt davon ab, die Fehlermuster der OCR-Technologie variieren je nach Schriftsystem, benannte Entitäten enthalten Akzente, die bei der Normalisierung beibehalten werden müssen, und das Embedding-Modell selbst kann je nach indizierter Sprache unterschiedlich funktionieren.

from dataclasses import dataclass
from langdetect import DetectorFactory, LangDetectException, detect_langs

DetectorFactory.seed = 0  # Makes pipeline output reproducible.
@dataclass
class LanguagePrediction:
    language: str
    confidence: float
    alternatives: list[tuple[str, float]]
    needs_review: bool
def detect_document_language(
    text: str,
    confidence_threshold: float = 0.85,
) -> LanguagePrediction:
    sample = text.strip()
    if not sample:
        return LanguagePrediction(language="unknown", confidence=0.0, alternatives=[], needs_review=True)
    try:
        predictions = detect_langs(sample)
    except LangDetectException:
        return LanguagePrediction(language="unknown", confidence=0.0, alternatives=[], needs_review=True)
    best = predictions[0]
    alternatives = [(p.lang, round(p.prob, 4)) for p in predictions]
    return LanguagePrediction(
        language=best.lang,
        confidence=best.prob,
        alternatives=alternatives,
        needs_review=best.prob < confidence_threshold,
    )

Wenn ein Dokument mehrere Sprachen mischt, ist die Erkennung auf Absatzebene besser als ein einheitliches Urteil für das gesamte Dokument. Zudem liefert sie dem Aufteilungsprogramm Hinweise zur Struktur: Wenn eine Aufteilung über eine Sprachgrenze erfolgt, die das Programm nicht erkannt hat, kann ein einzelner Abschnitt zwei unverwandte sprachliche Kontexte enthalten.

Einige Teams übersetzen zunächst alles ins Englische. Dadurch wird der Index einfacher, doch es entsteht eine weitere Umwandlung zwischen dem autoritativen Text und dem abgerufenen Inhalt. Zudem können deutsche Compliance-Klauseln Formulierungen mit präzisem rechtlichem Sinn enthalten, die durch eine Übersetzung subtil verändert werden. Ein robusteres Design betrachtet den Text in der Originalsprache als Quelle der Wahrheit und speichert jede Übersetzung als zusätzliche Darstellung, wobei die Herkunft in Metadaten festgehalten wird. Auch die Sprachmetadaten sollten präzise sein:

metadata = {
    "primary_language": "en",
    "languages": ["en", "de", "fr"],
    "language_distribution": {"en": 0.72, "de": 0.21, "fr": 0.07},
    "is_multilingual": True,
}

Die Spracherkennung kann auch als Überprüfung der Integrität dienen. Angenommen, ein in Englisch erwartetes AML-Dokument aus dem Vereinigten Königreich meldet plötzlich primary_language = „unknown“ bei confidence = 0.31. Das Dokument ist möglicherweise nicht mehrsprachig; die OCR-Technologie könnte den Text so stark verfälscht haben, dass der Erkennungsalgorithmus ihn nicht mehr identifizieren kann.

PII und sensible Daten: Erkennen vor Einbetten

Die Vorkonvertierung ist eine der letzten Möglichkeiten, zu kontrollieren, was in die Suchinfrastruktur gelangt. Ein KYC-Dokument kann Kundennamen, Passnummern, IBANs, Telefonnummern und E-Mail-Adressen enthalten. Sobald diese Daten durch einen Einbettungsdienst in eine Vektordatenbank gelangen, ist das Problem weitaus schwieriger zu bewältigen als bei der Sicherung des ursprünglichen Dokuments, da Kopien nun in Einbettungsanfragen, Vektoren, Metadaten, Caches, Indizes, Protokollen und Backups vorhanden sind.

Die Frage ist nicht, ob das Embedding-Modell PII verstehen kann – schließlich kann es das. Es geht darum, ob die Informationen überhaupt eingebettet werden müssen.

Das Löschen aller von dem Detektor gefundenen Entitäten macht das Korpus zwar sicherer, zerstört aber die Suche nach Entitäten. Wenn jeder Firmenname durch <ORGANIZATION> ersetzt wird, findet ein AML-Ermittler, der nach Northbridge Trading GmbH sucht, nichts. Daher besteht der praktische Ansatz darin, vor jeder Aktion für jeden erkannten Entitätentyp eine Handlungsrichtlinie festzulegen. Öffentliche und operative Namen werden in der Regel beibehalten, Kundendaten mit PII werden redigiert, tokenisiert oder eingeschränkt, interne organisatorische Bezeichnungen unterliegen Zugriffsregeln, und Anmeldeinformationen gelangen niemals in den Index.

from dataclasses import dataclass, field
from presidio_analyzer import AnalyzerEngine

@dataclass
class SensitiveEntity:
    entity_type: str
    start: int
    end: int
    score: float
    original_value: str
@dataclass
class PIIScanResult:
    entities: list[SensitiveEntity] = field(default_factory=list)
    needs_review: bool = False
class BankingPIIDetector:
    def __init__(self) -> None:
        self.analyzer = AnalyzerEngine()
    def detect(
        self,
        text: str,
        language: str = "en",
        score_threshold: float = 0.70,
    ) -> PIIScanResult:
        """
        Detect sensitive entities without modifying the source text.
        Detection is deliberately separated from anonymization because
        different entity types require different handling policies.
        """
        results = self.analyzer.analyze(
            text=text, language=language, score_threshold=score_threshold
        )
        entities = [
            SensitiveEntity(
                entity_type=r.entity_type,
                start=r.start,
                end=r.end,
                score=r.score,
                original_value=text[r.start:r.end],
            )
            for r in results
        ]
        high_risk_types = {"CREDIT_CARD", "IBAN_CODE", "US_SSN", "IP_ADDRESS"}
        return PIIScanResult(
            entities=entities,
            needs_review=any(e.entity_type in high_risk_types for e in entities),
        )

Die Ersetzung von Werten durch vorgefertigte Platzhalter funktioniert in der Regel besser für RAG als ihr Löschen:

Customer IBAN: <IBAN>
Customer Email: <EMAIL_ADDRESS>

Der Satz behält seine bedeutungstragende Struktur bei, sodass eine Frage nach dem Ort, an dem die IBAN eines Kunden überprüft wird, weiterhin mit Texten übereinstimmen kann, die <IBAN> enthalten. Wenn Beziehungen zwischen Erwähnungen beibehalten werden müssen, sind deterministische Token besser als allgemeine Platzhalter. CUSTOMER_7F29A1, das in 40 Fallaufzeichnungen vorkommt, bewahrt eine nachvollziehbare Spur, während das Ersetzen jedes Namens durch <PERSON> diese zerstören würde.

Metadaten sind eine leicht übersehene Datenleckage. Der Haupttext kann sorgfältig redigiert werden, und dennoch können Felder wie customer_name: „Lena Hoffmann“ oder eine Quellroute wie /customers/Lena_Hoffmann/KYC/passport_review.pdf erhalten bleiben. Daher müssen Kontrollmechanismen für sensible Daten alles abdecken, was indiziert wird – nicht nur den Text.

Ein produktionsreifer Pipeline

Individual cleaning functions are enough while you're experimenting. In production a stricter standard applies: a document must not move on to chunking just because a chain of steps ran without an exception. The pipeline has to record what it altered, what still looks suspicious, whether sensitive data turned up, whether the file duplicates another, and whether the end result deserves to be indexed.

from dataclasses import dataclass, field
from enum import Enum
from typing import Any

class QualityStatus(str, Enum):
    PASS = "pass"
    REVIEW = "review"
    REJECT = "reject"
@dataclass
class Transformation:
    stage: str
    operation: str
    details: dict[str, Any] = field(default_factory=dict)
@dataclass
class QualityFlag:
    code: str
    severity: str
    details: dict[str, Any] = field(default_factory=dict)
@dataclass
class RetrievalReadyDocument:
    document_id: str
    source: str
    raw_text: str
    clean_text: str
    language: str | None = None
    language_confidence: float | None = None
    content_hash: str | None = None
    duplicate_of: str | None = None
    transformations: list[Transformation] = field(default_factory=list)
    quality_flags: list[QualityFlag] = field(default_factory=list)
    metadata: dict[str, Any] = field(default_factory=dict)
    status: QualityStatus = QualityStatus.PASS

Der Unterschied zwischen raw_text und clean_text ist grundlegend. Der erste gibt an, was tatsächlich extrahiert wurde, während der zweite angibt, welche Darstellung wir zur Aufteilung und Einbettung verwenden möchten. Ein Transformationstagebuch erläutert, wie aus dem einen das andere entstanden ist, und diese Abfolge ist von unschätzbarem Wert, wenn sechs Monate später ein Abruffehler gemeldet wird.

import logging
logger = logging.getLogger(__name__)

class ProductionDocumentCleaner:
    def __init__(
        self,
        pii_detector: BankingPIIDetector,
        pii_anonymizer,
        redact_pii_for_embeddings: bool = True,
    ) -> None:
        self.pii_detector = pii_detector
        self.pii_anonymizer = pii_anonymizer
        self.redact_pii_for_embeddings = redact_pii_for_embeddings
    def clean(self, document: dict) -> RetrievalReadyDocument:
        result = RetrievalReadyDocument(
            document_id=document["document_id"],
            source=document["source"],
            raw_text=document["text"],
            clean_text=document["text"],
            metadata=document.get("metadata", {}).copy(),
        )
        try:
            self._normalize_text(result)
            self._detect_language(result)
            self._validate_ocr(result)
            self._handle_sensitive_data(result)
            self._calculate_hash(result)
            self._apply_quality_gate(result)
        except Exception as exc:
            logger.exception("Preprocessing failed for document %s", result.document_id)
            result.quality_flags.append(QualityFlag(
                code="PREPROCESSING_EXCEPTION",
                severity="critical",
                details={"error": str(exc)},
            ))
            result.status = QualityStatus.REJECT
        return result
    def _apply_quality_gate(self, document: RetrievalReadyDocument) -> None:
        severities = {flag.severity for flag in document.quality_flags}
        if "critical" in severities:
            document.status = QualityStatus.REJECT
        elif "high" in severities:
            document.status = QualityStatus.REVIEW
        else:
            document.status = QualityStatus.PASS

Das Gate gibt einen von drei expliziten Zuständen aus. PASS erlaubt es dem Dokument, mit der Aufteilung in Blöcke fortzufahren, da es keine ungelösten, hochriskanten Mängel gibt. REVIEW bedeutet, dass nutzbarer Inhalt erzeugt wurde, aber bestimmte Umwandlungen eine manuelle Überprüfung vor der Indizierung benötigen. REJECT bedeutet, dass das Ergebnis nicht zuverlässig genug ist, um indiziert zu werden. Ein gescanntes Dokument mit einem fragwürdigen l0%-Schwellenwert wird in den Zustand REVIEW versetzt; ein PDF, das auf 80 Seiten nur 17 unleserliche Zeichen liefert, landet vermutlich in REJECT; und eine Richtlinie, bei der die einzigen Korrekturen Apostrophe und Leerzeichen waren, gilt als PASS.

Auch die Metriken für die Reinigungsphase sind es wert, aufbewahrt zu werden. Wenn text_reduction_ratio normalerweise 4–8 % eines Dokuments entfernt und nach einem Release auf 63 % springt, ist etwas schiefgelaufen. Wenn unknown_language_rate nach einem Upgrade der OCR-Bibliothek von 0,3 % auf 18 % ansteigt, deutet das darauf hin, dass weiter oben im Prozess eine Rückschritt aufgetreten ist. Solche Zahlen machen die Reinigung sichtbar und handhabbar, anstatt dass es sich um einen unsichtbaren Script-Prozess handelt.

Kompromisse in der Produktion

Keine Reinigungskonfiguration eignet sich für alle, und die gewählten Optionen haben tatsächliche Folgen.

  • Kodierungsreparatur. ftfy bietet eine kostengünstige und effektive Erstbehandlung typischer Probleme mit Mojibake sowie Schäden durch Unicode. Betrachten Sie es als Ergänzung zur deterministischen Unicode-Normalisierung – nicht als Freibrief, den Text nach Belieben umzuschreiben.
  • Deduplizierung. Das SHA-256-Hashing ist nahezu kostenlos und sollte präzise Duplikate bereits vor teureren Ähnlichkeitsverfahren bearbeiten. MinHash LSH eignet sich gut zur Erkennung von nahezu identischen Inhalten in großem Umfang, doch sein Schwellenwert sollte lediglich Kandidaten liefern, nicht Entscheidungen zur Löschung.
  • OCR-Korrektur. Das Abfragen eines Wörterbuchs kostet wenig und funktioniert zuverlässig bei alltäglichen Wörtern. Ein LLM, das den Kontext berücksichtigen kann, erkennt schwierigere Fälle, doch ein generatives Modell im Vorausverarbeitungsschritt verursacht zusätzliche Kosten sowie das Risiko plausibel klingender Überarbeitungen. Bei regulierten Materialien sollte es Korrekturen in einem strukturierten Format vorschlagen, das von einem Prüfer genehmigt wird, anstatt Dokumente unbesehen zu bearbeiten.
  • Mehrsprachige Verarbeitung. Die Erkennung des gesamten Dokuments ist kostengünstig und eignet sich gut für die Weiterleitung. Die Erkennung auf Paragraphenebene passt besser zu gemischtsprachigen Dateien, ist jedoch teurer; welches Verfahren vorteilhaft ist, hängt von der tatsächlichen Sprachverteilung sowie von den Suchbenchmarks ab. Der Begriff „mehrsprachig“ auf einer Modellbeschreibung sagt wenig darüber aus, ob deutsche AML-Begriffe aus einer englischen Compliance-Frage abgerufen werden können.
  • PII. Eine strenge Auslassung verringert das Risiko, erhöht aber die Komplexität der Suche nach Entitäten. Oft ist es besser, die semantische Vektorsuche von einer sicheren exakten Abfrage nach Identifikatoren zu trennen, sodass kein einziger Index beides lösen muss.

Das durchgehende Prinzip bei all dem ist: Lassen Sie die Suchanforderungen sowie das Risiko einer Bedeutungsverfälschung die Entscheidungen zur Vorausverarbeitung bestimmen – achten Sie nicht darauf, wie ordentlich der bereinigte Text aussieht.

Zur ursprünglichen Richtlinie zurück

Zurück zu der ursprünglichen KYC-Richtlinie. Die Extraktionsphase hat sie erfolgreich importiert, und der Reinigungsprozess führt nun weitaus mehr Schritte durch, bevor die Daten in den Index aufgenommen werden. Er behebt fehlerhafte Kodierungen, ohne legitime mehrsprachige Zeichen zu entfernen, löscht wiederholte Seitenüberschriften bei Beibehaltung der Dokumentmetadaten, erkennt exakte Kopien, die in verschiedenen Repositorien gespeichert sind, und betrachtet ältere Richtlinienversionen als verwandt anstelle von Elementen, die blind gelöscht werden sollten. EUR l0,000 wird als ernsthafte OCR-Anomalie markiert und es wird nicht stillschweigend geraten; mehrsprachige Abschnitte werden erkannt, sensible Kundendaten identifiziert, bevor sie die Einbettungsschicht erreichen, jede Transformation wird aufgezeichnet und entschieden, ob das Ergebnis durchgehen, zur Überprüfung geschickt oder abgelehnt werden soll.

Nur dann gelangt das Dokument zur Aufteilung in Blöcke. Die Reihenfolge ist wichtig, denn die Blockaufteilung kann keine Informationen wiederherstellen, die bereits im Vorausverarbeitungsprozess zerstört wurden, und Embeddings können nicht zwischen autoritativem Wissen und Schäden unterscheiden, die unkontrolliert in das Korpus gelangt sind.

Was nach der Reinigung kommt, ist ein anderes Problem: Wo schneidet man eine saubere 80-seitige AML-Richtlinie ab? Ein einfaches Fenster mit 500 Tokenen ist leicht zu erstellen, doch es kann eine Regel von ihrer Ausnahme trennen, einen Tabellenkopf entfernen oder unzusammenhängende Klauseln zusammenfügen – allein weil sie denselben Token-Budget haben. Die Aufteilungsmethoden reichen von festen Größen und rekursiven Ansätzen über semantische, strukturbezogene sowie elternteil-kind- und propositionenbasierte Verfahren – jedes davon erfordert eine eigene Behandlung.

Checkliste vor der Blockaufteilung

Vor dem Aufteilen in Blöcke und Einbetten eines Dateis sollte die Vorausverarbeitungsphase in der Lage sein, auf jede der folgenden Fragen eine klare Antwort zu geben:

  • Ist die Kodierung sowie die Unicode-Normalisierung ohne Verlust von bedeutungstragenden Zeichen abgelaufen?
  • Sind Steuerzeichen, zeichengleiche Zeichen, wiederholte Überschriften und Fußzeilen sowie anderer Störfaktor bei der Suche berücksichtigt worden?
  • Sind genaue und nahezu identische Kopien erkannt worden, wobei die echten Dokumentversionen unverändert blieben?
  • Sind zweifelhafte OCR-Ergebnisse bezüglich Beträgen, Prozentsätzen, Daten, Identifikatoren und rechtlicher Verweise gemeldet worden, anstatt stillschweigend korrigiert?
  • Welche Sprache wurde mit angemessener Genauigkeit erkannt, einschließlich gemischter Sprachinhalte?
  • Welche sensiblen Informationen wurden vor dem Einbetten gemäß der Sicherheitsrichtlinie der Anwendung klassifiziert?
  • Kann jeder bedeutende Änderung schrittweise auf den ursprünglich analysierten Text zurückverfolgt werden?
  • Gibt es ein eindeutiges Urteil von PASS, REVIEW oder REJECT, bevor das Aufteilen beginnt?

Falls eine Antwort unklar ist, ist das Dokument noch nicht für die Abrufbereitstellung geeignet.

Wenn eine beschädigte Policy bereits in 80 Blöcke aufgeteilt, eingebettet, in einem Vektorindex kopiert, im Cache gespeichert und von einer laufenden Anwendung bereitgestellt wurde, reicht es nicht mehr aus, nur die Quelldatei zu reparieren. Jeder abgeleitete Block muss gefunden werden, seine Vektoren gelöscht, die Caches ungültig gemacht, das Dokument neu verarbeitet und die Entfernung veralteter Repräsentationen überprüft werden. Falsche Informationen abzulehnen, ist am kostengünstigsten, bevor sie zu Embeddings werden.

Der gezeigte Code zielt auf Klarheit und Korrektheit ab. Für die Produktion sollten strukturiertes Logging, Konfigurations- und Geheimnismanagement, dauerhafte Prüfprotokolle für Transformationen, Metriken zur Vorausverarbeitung, Fehlerbehebung sowie menschliche Überprüfung bei korrekturen mit hohem Risiko hinzugefügt werden. In regulierten Umgebungen sollte jede zerstörerische Transformation bis zum ursprünglichen Quelltext nachvollziehbar sein.

Verwandte Artikel