Czyszczenie danych dla RAG produkcyjnego: kodowanie, OCR, usuwanie duplikatów i PII
Jak przekształcić zanalizowane dokumenty firmowe w tekst gotowy do wyszukiwania: naprawa kodowania, usuwanie szablonów i duplikatów, flagi ryzykownego OCR, obsługa języków oraz maskowanie danych osobowych.
Źródła: Data Cleaning and Preprocessing for Production RAG: Building Retrieval-Ready Documents
Załóżmy bank, który wykorzystuje asystenta wiedzy do przetwarzania 200 000 wewnętrznych i regulacyjnych dokumentów. Proces pobierania przebiega sprawnie – pliki PDF są analizowane, zeskanowane obwieszczenia dotyczące AML przechodzą przez OCR, pliki z SharePoint przychodzą zgodnie z planem, a tabele są wydzielane osobno. Wydaje się, że w tym korpusie nic nie brakuje.
Jednak odpowiedzi są nierówne. Pracownik ds. zgodności pyta, jakie dokumenty są potrzebne do wzmocnionej weryfikacji klienta korporacyjnego o wysokim ryzyku. System zwraca pięć fragmentów tekstu – trzy zawierają odpowiednią politykę KYC, a dwa mają niemal identyczny tekst z starszej wersji dokumentu. Każdy fragment zaczyna się od tego samego nagłówka:
CONFIDENTIAL - INTERNAL USE ONLY
Group Financial Crime Compliance
Page 47 of 132
Jeden fragment zawiera również zdanie „Wszyscy klienci korporacyjni o wysokim ryzyku muszą przejść pogłębioną weryfikację.” Przyjrzyj się uważnie: All zamieniło się w AII, a diligence w diIigence, ponieważ OCR pomylił małe litery l i i.
W innych częściach tego samego pliku przetrwała przerwa nieprzerywająca, apostrof z kodowania Windows-1252 został błędnie zdekodowany, a stopka pojawia się 132 razy, ponieważ znajdowała się na każdej stronie. Zarchiwizowana wersja polityki znajduje się w indeksie obok tej zatwierdzonej, ponieważ SharePoint zachował obie.
Technicznie proces pobierania danych zadziałał poprawnie. Pod względem operacyjnym korpus jest zanieczyszczony, a inżynierowie tworzący swój pierwszy system RAG często nie doceniają, jak ważna jest ta różnica.
Dlaczego dane zanieczyszczone się ukrywają
Szkody są zazwyczaj na tyle niewielkie, że nic się nie zawala. Błędna sekwencja bajtów nie powoduje awarii bazy danych wektorowej. Powtórzony akapit nie powoduje awarii API do generowania embeddingów. Powtórzony stopka nie uruchamia żadnego alarmu, a błąd OCR z 1 na l nie blokuje procesu indeksowania. System radośnie integruje wszystko, a rachunek pojawia się później w postaci błędów przy wyszukiwaniu.
Dokumenty powtórzone zajmują kilka pozycji w wynikach top-k. Powtarzające się szablony tworzą sztuczną podobieństwo między niespowiązanymi fragmentami tekstu. Błędy OCR zmieniają istotne dane, procenty, numery kont i limity regulacyjne. Artefakty kodowania dodają tokeny pozbawione znaczenia biznesowego. Zanim pojawi się błędna odpowiedź, każdy z komponentów w łańcuchu może wykonywać swoją funkcję: narzędzie wyszukiwania zwróciło to, co zostało zapisane, narzędzie ponownego rankowania sklasyfikowało to, co otrzymało, a model językowy pracował na podstawie dostarczonego mu kontekstu. Problem pojawił się wcześniej niż we wszystkich trzech tych elementach.
Należy zastosować politykę AML, która stanowi, że transakcje przekraczające 10 000 EUR wymagają dodatkowych weryfikacji źródła środków. Po błędnym rozpoznawaniu tekstu OCR oraz nieprawidłowej konwersji kodowania, to, co trafia do indeksu, wygląda jako „EUR l0,000”. Osoba natychmiast zauważa błąd. Model embedding nadal traktuje tę frazę jako odnoszącą się do pojęć takich jak progi transakcyjne i weryfikacje źródła środków, dzięki czemu wyszukiwanie funkcjonuje doskonale – co jest niepokojące. Zepsuty tekst dociera do LLM, ponieważ semantyczne wyszukiwanie toleruje niedoskonałości tekstowe. System znajduje odpowiednią sekcję polityki, ale zawiera ona błędną wartość regulacyjną.
Dlatego też czyszczenie tekstu nie może być sprowadzone do operacji text.strip().lower().
Gdzie mieści się czyszczenie w pipeline RAG
Czyszczenie następuje pomiędzy przyjmowaniem dokumentów a ich dzieleniem na fragmenty, a ta kolejność jest celowa. Nie chcesz mierzyć podobieństwa dokumentów, zanim naprawisz oczywiste uszkodzenia kodowania. Nie chcesz agresywnie normalizować tekstu, zanim zidentyfikujesz jego język. Nie chcesz usuwać powtarzających się klauzul prawnych tylko dlatego, że przypominają standardowe wzory. Ponadto korekty OCR o niskiej wiarygodności nie powinny potajemnie zmieniać obowiązków związanych z przestrzeganiem regulacji.
Gdy tekst z szumem zostanie podzielony na fragmenty i włączony do struktury, czyszczenie staje się znacznie trudniejsze. Duplikowane akapity już stworzyły duplikatowe wektory, uszkodzone słowa wpłynęły na granice fragmentów, powtarzające się nagłówki zajęły przestrzeń tokenów, a błędy OCR są już częścią reprezentacji semantycznej. Czyszczenie powinno odbywać się przed dzieleniem na fragmenty, a nie jako naprawa stosowana po tym, jak jakość zaczyna spadać.
Normalizacja kodowania: najpierw napraw tekst
Kolekcje korporacyjne rzadko mają tę samą historię kodowania. Nowoczesna polityka może polegać na użyciu UTF-8, archiwum korespondencji z klientami może używać Windows-1252, a starsze eksporty – Latin-1. Tekst kopiowany pomiędzy SharePointem, e-mailem, Wordem oraz starszymi systemami dokumentów mógł być kilkakrotnie kodowany i dekodowany, zanim trafi do procesu przetwarzania. Wynik wygląda w ten sposób:
Customer’s identity must be verified before account opening.
The customer must provide proof of address.
Enhanced Due Diligence â€" High Risk Customers
Ta zepsucia nazywają się mojibake i występują, gdy bajty zapisane w jednym kodowaniu są odczytywane jako inne. Od razu widać, że customer’s jest uszkodzone. W procesie wydobywania danych zepsuty token trafia do narzędzia tokowania inaczej niż customer's, a na większą skalę takie niezgodności fragmentują słownictwo i dodają niepotrzebną zmienność do przestrzeni embeddingów.
Należy uważać na to, jak daleko sięga naprawa. Nieostrożna funkcja uznaje, że każdy znak poza ASCII to śmieci, jednak europejskie zbiory danych dotyczące płatności zawierają legalnie takie wyrażenia jak Société Générale, Deutsche Bundesbank, €10,000 i Überweisung. Usunięcie akcentów i symboli powoduje utratę rzeczywistych informacji. Celem nie jest tekst składający się wyłącznie z znaków ASCII, lecz spójny Unicode, który zachowuje swoje znaczenie.
Naprawa tekstu Unicode za pomocą ftfy
Nie musisz samodzielnie katalogować każdego wzoru mojibake, ponieważ ftfy już naprawia typowe błędy Unicode i kodowania. W tym miejscu można rozpocząć etap oczyszczania produktu:
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,
)
Zwróć uwagę na to, co funkcja pomija. Nie zamienia wszystkiego na małe litery, nie usuwa akcentów z Société Générale, nie eliminuje znaku euro ani nie spłaszcza dokumentu do jednej linii. Te pominięcia są celowe.
Dlaczego agresywne czyszczenie szkodzi
Wiele starszych pipeline’ów NLP zostało zaprojektowanych dla modeli typu bag-of-words lub TF-IDF i one zamieniały tekst na małe litery, usuwały interpunkcję, słowa stopowe oraz przeprowadzały proces stemowania. Bezsensowne stosowanie tych metod przed użyciem nowoczesnych embeddingów niszczy przydatne informacje. Zastanów się, co by to zrobiło z tym zdaniem:
Customer must NOT be classified as low risk.
Teksty finansowe dostarczają jeszcze bardziej delikatnych przykładów. EUR 10,000 i EUR 100,000 wyglądają podobnie literowo, lecz różnią się znacznie pod względem znaczenia praktycznego, a Artykuł 6(1)(a) i Artykuł 6(1)(b) mają identyczną strukturę, ale różną moc prawną. Zasadą pracy jest agresywne normalizowanie formy przedstawienia informacji oraz konserwatywne zachowywanie ich znaczenia. Zamiana customer’s na customer's to zmiana formy, która przywraca to, co miał na myśli autor. Usunięcie znaku €, usunięcie akcentów z imienia klienta lub automatyczna konwersja l0% na 10% zmienia fakty, od których firma może się opierać.
Usuwanie szablonowych fragmentów: struktura, a nie słowa kluczowe
Tytuły i stopki to nie ten sam rodzaj problemu co szum kodowania. Powtarzanie się Strona 47 z 132 jest wyraźnie elementem szablonowym, natomiast wymóg zgodności występujący w kilku sekcjach polityki może być powtarzany celowo i mieć znaczenie prawnicze. Częstotliwość pojawiania się danej linii nie pozwala określić, z którym z tych przypadków mamy do czynienia.
Lepszy sygnał łączy powtarzanie z pozycją. Tytuły znajdują się u góry stron, a stopki u dołu, przy czym numery stron lekko się zmieniają, zachowując jednak ten sam wzorzec strukturalny:
KYC Policy | Version 7.2 | Page 41 of 132
KYC Policy | Version 7.2 | Page 42 of 132
Logika dopasowania dokładnego traktuje dwa różne łańcuchy, natomiast logika uwzględniająca strukturę widzi dwie instancje jednego szablonu. To jeden z powodów, dla których wykrywanie szablonów działa najlepiej, gdy nadal istnieją granice stron, więc unikaj przekształcania zinterpretowanego dokumentu w jeden ogromny łańcuch zaraz po jego odczytaniu.
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
Progiem decyzyjnym jest min_page_ratio – to on ma największe znaczenie. Zasada nie brzmi „usuń każdą linię, która pojawia się dwa razy”. Chodzi raczej o to, by „traktować linię o podobnej strukturze jako tekst szablonowy, jeśli stale pojawia się w obszarze marginesu strony na większości stron dokumentu”. W przypadku polityki obejmującej 100 stron, gdzie nagłówek CONFIDENTIAL - INTERNAL USE ONLY występuje na 98 stronach, dowody są przekonujące.
Kolejny istotny punkt: usunięcie czegoś z tekstu używanego do wstawiania nie oznacza jego usunięcia z systemu. Czasami informacje powinny przenieść się ze warstwy treści semantycznych do warstwy metadanych. Pola takie jak wersja, właściciel, klasyfikacja i data wejścia w życie lepiej zachować jako metadane, zamiast kopiować je do każdego fragmentu tekstu.
Deduplikacja poza dokładnymi kopiami
Dokument może być doskonale skodowany, bez uszkodzeń spowodowanych OCR oraz pozbawiony treści standardowych, a mimo to utrudniać wyszukiwanie – po prostu dlatego, że korpus zawiera go kilka razy. Repozytoria przedsiębiorstw są pełne takich przypadków:
AML_Policy_v8_Final.docx
AML_Policy_v8_Final_Approved.docx
AML_Policy_v8_Final_Copy.docx
AML_Policy_v8_Approved_2026.docx
Indeksuj wszystkie cztery pliki jako oddzielne dokumenty, a ich fragmenty będą konkurować ze sobą podczas wyszukiwania. Jeśli zapytasz o wzmocniony monitoring klientów o wysokim ryzyku, możliwe jest, że trzy miejsca zostaną zajęte przez jedną sekcję polisy, pozostawiając dwa na naprawdę różne dowody. Duplikaty kosztują więcej niż tylko miejsce na dysku – zakłócają one rzeczywiste znaczenie pojęcia top-k.
Dokładne duplikaty z haszowaniem treści
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
Hash jest również przydatny do indeksowania inkrementalnego. Jeśli ten sam znormalizowany dokument pojawi się podczas kolejnej procedury pobierania danych jutro wieczorem, jego hash jest już znany, więc proces może pominąć ponowne czyszczenie, dzielenie na fragmenty i wstawianie go do struktury.
Prawie duplikaty przy użyciu MinHash LSH
Dokładne tworzenie hashów obejmuje proste przypadki. Trudniejszy jest przypadek dwóch wersji polityki KYC, na przykład wersji 7 i 8, które są w 98% identyczne pod względem tekstu, ale różnią się jednym przepisem regulacyjnym. Z punktu widzenia zgodności nie są one bezwzględnie duplikatami, ponieważ ten ostatni przepis zmienia wymagania kontrolne.
W zregulowanym systemie RAG wykrywanie niemal identycznych dokumentów powinno najpierw odpowiedzieć na inne pytanie, a mianowicie które dokumenty są wystarczająco podobne, by warto je badać, pozostawiając usunięcie jako odrębną decyzję. W przypadku 200 000 dokumentów porównania parami wymagają około 20 miliardów operacji. MinHash z haszowaniem wrażliwym na lokalizację unika tego poprzez tworzenie kompaktowych podpisów i umieszczanie podobnych z nich w odpowiednich grupach kandydatów.
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
Gdy polityki KYC wersji v7 i v8 trafiają do tego samego folderu, system rozpoznaje, że wymagają dokładniejszej analizy, ale to wszystko. Nie nauczył się jeszcze, że jedna z nich powinna zostać usunięta. Decyzja w środowisku produkcyjnym powinna łączyć podobieństwo tekstu z metadanymi, takimi jak identyfikator polityki, wersja, data wejścia w życie, status zatwierdzenia i jurysdykcja, ponieważ samo podobieństwo nie pozwala na dokonanie takiej oceny.
Błędy OCR: poprawnie wyglądający tekst, który jest błędny
Naprawa kodowania służy do poprawy znaków przechowywanych w niewłaściwy sposób. Błędy OCR należą do odrębnej kategorii. W tym przypadku silnik może z pełną pewnością odczytać w pełni poprawny znak Unicode, a mimo to wybrać niewłaściwy. Typowymi przypadkami pomyłek są 0 i O, 1, l i I, 5 i S, oraz rn i m.
Łączne wskaźniki błędów mogą być niskie, ale ogólna dokładność nie jest miarą istotną w przypadku regulowanego RAG. Dokument o 40 stronach, który jest poprawny w 99,8%, może nadal mieć uszkodzone najważniejsze wartości. Kluczowym aspektem architektury jest to, że błędy OCR różnią się pod względem ryzyka. Poprawa błędu custorner na customer stanowi niskie ryzyko, ponieważ kontekst i słownik silnie wskazują na zamierzone słowo. Zmiana EUR l0,000 na EUR 10,000 należy do zupełnie innej kategorii, ponieważ błędna interpretacja może wpłynąć na obowiązki związane z przestrzeganiem regulacji.
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
Funkcja celowo nie modyfikuje dokumentu. Wyrażenie regularne może wskazać, że l0,000 wygląda podejrzanie w kontekście walutowym, ale nie może udowodnić, że źródło faktycznie podało 10,000.
Zwykłe słownictwo lepiej radzi sobie z słownikiem znającym dane dziedziny. Teksty bankowe wymagają ogólnej listy słów wraz z terminami takimi jak beneficial ownership, counterparty, SWIFT, IBAN, SEPA i sanctioned. W przeciwnym razie standardowy korektor ortograficzny chętnie „poprawi” prawidłowe terminy finansowe na gorsze.
W warunkach produkcyjnych algorytm OCR ocenia poziom pewności rozpoznania tekstu w odniesieniu do znaczenia potencjalnego błędu. Pewne odczyty tekstów o niskim znaczeniu zazwyczaj można poprawić bez udziału człowieka. Treści o wysokim ryzyku, takie jak wartości pieniężne, procenty, numery artykułów regulacyjnych oraz daty, powinny zostać przeanalizowane niezależnie od poziomu pewności OCR. System finansowy powinien dążyć do kontrolowanej poprawności, a nie do maksymalnej automatyzacji.
Wstępna obróbka tekstów wielojęzycznych
Wykrywanie języka wydaje się błahostką, dopóki zbiór tekstów nie przekracza granic. Repozytorium dokumentacji operacyjnych może zawierać angielskie procedury, niemieckie listy do klientów, francuskie komunikaty regulacyjne oraz hiszpańskie umowy, a jeden plik z informacjami o dochodzeniach antyprzemytniczych może zawierać teksty w kilku językach w ramach jednego zapisu sprawy.
Język wpływa na wszystko w dalszej kolejności. Korekta pisowni od niego zależy, wzorce błędów OCR różnią się w zależności od pisma, nazwane entity zawierają akcenty, które muszą przetrwać proces normalizacji, a sam model embedding może zachowywać się inaczej w zależności od indeksowanego języka.
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,
)
Gdy dokument łączy różne języki, wykrywanie na poziomie akapitów jest lepsze niż jedno orzeczenie dotyczące całego pliku. Dzięki temu również narzędzie do dzielenia tekstu otrzymuje wskazówkę dotyczącą struktury: jeśli podział nastąpi na granicy językowej, której narzędzie nie widziało, jeden fragment może zawierać dwa niespowiązane konteksty językowe.
Część zespołów najpierw tłumaczy wszystko na język angielski. To upraszcza indeks, ale wprowadza dodatkową transformację pomiędzy tekstem oryginalnym a tym, co jest pobierane. Ponadto klauzule dotyczące zgodności w języku niemieckim mogą używać sformułowań o precyzyjnym znaczeniu prawniczym, które tłumaczenie subtelnie zmienia. Bardziej solidny projekt traktuje tekst w języku oryginalnym jako źródło prawdy i przechowuje każde tłumaczenie jako dodatkową reprezentację, z zapisem pochodzenia w metadanych. Metadane językowe również powinny być precyzyjne:
metadata = {
"primary_language": "en",
"languages": ["en", "de", "fr"],
"language_distribution": {"en": 0.72, "de": 0.21, "fr": 0.07},
"is_multilingual": True,
}
Rozpoznawanie języka może również służyć jako kontrola stanu. Załóżmy, że polityka AML w Wielkiej Brytanii, która powinna być w języku angielskim, nagle zgłasza primary_language = „unknown” przy confidence = 0.31. Plik może nie być wielojęzyczny; OCR mógł tak mocno zniekształcić tekst, że rozpoznawacz go już nie identyfikuje.
Dane osobowe i dane wrażliwe: wykrywanie przed umieszczeniem
Przedprzetwarzanie to jedna z ostatnich szans na kontrolowanie tego, co trafia do infrastruktury wyszukiwania. Plik KYC może zawierać imiona klientów, numery paszportów, IBAN-y, numery telefonów oraz adresy e-mail. Gdy te dane trafią do usługi embedding i do bazy wektorowej, problem staje się znacznie trudniejszy niż zabezpieczenie oryginalnego dokumentu, ponieważ kopie istnieją już w żądaniach embedding, wektorach, metadanych, buforach, indeksach, logach i kopiach zapasowych.
Kwestia nie polega na tym, czy model embeddingowy potrafi zrozumieć dane PII, ponieważ faktycznie potrafi. Chodzi raczej o to, czy te informacje w ogóle muszą być uwzględnione.
<ORGANIZATION>, śledczy zajmujący się AML, szukając informacji oNorthbridge Trading GmbH, nic nie znajdzie. Dlatego standardowym podejściem jest ustalenie polityki obsługi dla każdego wykrytego typu entytety przed podjęciem jakichkolwiek działań. Nazwy publiczne i operacyjne zazwyczaj są zachowywane, dane PII klientów są maskowane, tokenizowane lub ograniczane w dostępie, wewnętrzne odniesienia organizacyjne podlegają zasadom dostępu, a dane uwierzytelniające nigdy nie trafiają do indeksu.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),
)
Zastępowanie wartości wpisanymi zamiennikami zazwyczaj działa lepiej w przypadku RAG niż ich usuwanie:
Customer IBAN: <IBAN>
Customer Email: <EMAIL_ADDRESS>
Zdanie zachowuje swoją strukturę niosącą znaczenie, więc pytanie o to, gdzie weryfikowany jest IBAN klienta, nadal może pasować do tekstu zawierającego <IBAN>. Gdy relacje pomiędzy wzmiankami muszą zostać zachowane, deterministyczne tokeny są lepsze od ogólnych zmiennych miejscowych. CUSTOMER_7F29A1 pojawiający się we 40 rekordach przypadków pomaga zachować ciągłość śledztwa, podczas gdy zastąpienie każdego imienia przez <PERSON> mogłoby to przerwać.
Metryki są łatwo zapominanym źródłem wycieków. Treść główna może zostać starannie zredagowana, a mimo to w jej polach nadal mogą znajdować się dane takie jak customer_name: "Lena Hoffmann" lub ścieżka źródłowa, np. /customers/Lena_Hoffmann/KYC/passport_review.pdf. Dlatego mechanizmy kontroli danych wrażliwych muszą obejmować wszystko, co jest indeksowane, a nie tylko tekst.
Pipeline na poziomie produkcyjnym
Podczas eksperymentowania wystarczają indywidualne funkcje czyszczenia. W produkcji obowiązuje surowszy standard: dokument nie może przejść do kroku dzielenia na fragmenty tylko dlatego, że seria kroków została wykonana bez błędów. System musi odnotować, co zostało zmienione, co nadal wygląda podejrzanie, czy pojawiły się dane wrażliwe, czy plik jest duplikatem innego oraz czy ostateczny wynik zasługuje na indeksację.
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
Różnica pomiędzy raw_text a clean_text jest fundamentalna. Pierwszy odpowiada na pytanie, co faktycznie zostało wydobyte podczas przetwarzania, natomiast drugi wskazuje, jaką reprezentację możemy podzielić na fragmenty i włączyć do systemu. Log transformacji wyjaśnia, jak jedno stało się drugim, a ta ścieżka przekształceń jest nieoceniona, gdy sześć miesięcy później zgłoszony zostanie błąd przy odzyskiwaniu danych.
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
Brama wyprowadza jeden z trzech określonych stanów. PASS umożliwia dalsze dzielenie dokumentu na fragmenty bez nierozwiązanych problemów o wysokim ryzyku. REVIEW oznacza, że uzyskano treść nadającą się do użycia, ale niektóre transformacje wymagają weryfikacji przez osobę przed indeksowaniem. REJECT oznacza, że wynik nie jest na tyle wiarygodny, by go indeksować. Zeskanowany dokument z wątpliwym progiem l0% trafia do stanu REVIEW; PDF zawierający zaledwie 17 uszkodzonych znaków na 80 stronach prawdopodobnie trafia do stanu REJECT; natomiast polityka, której jedyne korekty dotyczyły apostrofów i odstępów, jest klasyfikowana jako PASS.
Metryki z etapu czyszczenia również warto zachować. Jeśli text_reduction_ratio zwykle usuwa 4–8% treści dokumentu, a po publikacji skacze do 63%, oznacza to, że coś poszło nie tak. Jeśli unknown_language_rate gwałtownie rośnie z 0,3% na 18% po aktualizacji biblioteki OCR, oznacza to regresję w wcześniejszych etapach przetwarzania. Takie liczby sprawiają, że proces czyszczenia staje się widoczny i łatwy do zarządzania, zamiast być niezauważalnym skryptem.
Kompromisy w produkcji
Żadna konfiguracja czyszczenia nie jest odpowiednia dla wszystkich, a dokonane wybory mają rzeczywiste konsekwencje.
- Naprawa kodowania.
ftfyto tani i skuteczny narzędzie do pierwszej obróbki typowych problemów z kodowaniem i uszkodzeń Unicode. Należy go traktować jako wsparcie dla deterministycznej normalizacji Unicode, a nie jako uprawnienie do dowolnego przepisywania tekstu. - Usuwanie duplikatów. Hashowanie SHA-256 jest praktycznie bezkosztowe i powinno radzić sobie z dokładnymi duplikatami przed zastosowaniem droższych technik analizy podobieństwa. MinHash LSH jest przydatny do znajdowania niemal identycznych danych na dużą skalę, ale jego próg powinien generować kandydatów, a nie decyzje o usunięciu.
- Korekta OCR. Wyszukiwanie w słowniku jest mało kosztowne i daje przewidywalne wyniki dla codziennych słów. Model językowy wielowymiarowy, który potrafi analizować kontekst, może rozwiązać trudniejsze przypadki, natomiast model generatywny używany w procesie wstępnego przetwarzania wiąże się z dodatkowymi kosztami oraz ryzykiem tworzenia wiarygodnie brzmiących, lecz błędnych wersji tekstu. W przypadku materiałów regulowanych niech proponuje poprawki w ustrukturyzowanym formacie, który może zatwierdzić recenzent, zamiast pozwalać mu edytować dokumenty bez ich wcześniejszego zobaczenia.
- Multijęzyczne obsługiwanie. Wykrywanie całego dokumentu jest tanie i wystarczające do kierowania zapytaniami. Wykrywanie na poziomie akapitów lepiej radzi sobie z plikami mieszanych językowo, ale wiąże się z wyższymi kosztami; to, co będzie skuteczniejsze, zależy od rzeczywistego rozkładu języków oraz od kryteriów oceny efektywności wyszukiwania. Słowo „multijęzyczne” na karcie modelu niewiele mówi o możliwości wydobycia niemieckich terminów związanych z AML z pytania o zgodność w języku angielskim.
- Dane osobowe. Skuteczna cenzura zmniejsza ryzyko ujawnienia informacji, ale uniemożliwia wyszukiwanie według konkretnych entytetów. Często lepszym rozwiązaniem jest oddzielenie semantycznego wyszukiwania wektorowego od bezpiecznego wyszukiwania dokładnych dopasowań identyfikatorów, dzięki czemu jeden indeks nie musi rozwiązywać obu problemów jednocześnie.
Główna zasada przechodząca przez to wszystko brzmi: niech wymagania związane z wyszukiwaniem oraz ryzyko zniekształcenia znaczenia kierują decyzjami dotyczącymi przetwarzania wstępnego, a nie estetyka oczyszczonego tekstu.
Z powrotem do polityki otwarcia
Wróćmy do pierwotnej polityki KYC. Proces pobierania danych odbył się pomyślnie, a pipeline czyszczenia wykonywa teraz znacznie więcej kroków przed umieszczeniem danych w indeksie. Naprawia uszkodzone kodowanie bez usuwania prawidłowych znaków wielojęzycznych, usuwa powtarzające się nagłówki stron, zachowując jednocześnie metadane dokumentu, wykrywa dokładne kopie przechowywane w różnych repozytoriach oraz traktuje starsze wersje polityki jako powiązane, a nie jako elementy do ślepego usuwania. Oznacza EUR l0,000 jako poważną anomalię OCR i odmawia domyślnej interpretacji, rozpoznaje sekcje wielojęzyczne, wykrywa wrażliwe identyfikatory klientów przed dotarciem ich do warstwy embedowania, rejestruje każdą transformację i decyduje, czy wynik powinien zostać przyjęty, przeanalizowany lub odrzucony.
Dopiero wtedy dokument trafia do etapu dzielenia na fragmenty. Kolejność ma znaczenie, ponieważ dzielenie na fragmenty nie może przywrócić informacji, które zostały już uszkodzone podczas przetwarzania wstępnego, a modele embeddingowe nie potrafią odróżnić wiarygodnej wiedzy od zniekształceń, które dostały się do korpusu bez kontroli.
Po oczyszczeniu pojawia się inny problem: gdzie przeciąć dokument 80-stronicowej polityki AML, aby pozostał czysty? Stworzenie prostego okna składającego się z 500 tokenów jest łatwe, ale może ono oddzielić regułę od jej wyjątku, usunąć nagłówek z tabeli lub połączyć ze sobą niespowiązane klauzule tylko dlatego, że dzielą one ten sam budżet tokenów. Metody dzielenia obejmują podejścia o stałej wielkości i rekurencyjne, a także takie oparte na semantyce, strukturze, relacjach rodzic-dziecko oraz poziomie propozycji – każde z nich wymaga odrębnego potraktowania.
List kontrolny przed dzieleniem na fragmenty
Zanim plik przejdzie do krojenia na fragmenty i wgrania, etap przetwarzania wstępnego powinien być w stanie udzielić jasnej odpowiedzi na każde z poniższych pytań:
- Czy kodowanie i normalizacja Unicode zostały przeprowadzone bez utraty znaków mających znaczenie?
- Czy zostały obsłużone znaki kontrolne, znaki o zerowej szerokości, powtarzające się nagłówki i stopki oraz inne elementy zakłócające wyszukiwanie?
- Czy zidentyfikowano dokładne i niemal identyczne kopie, zachowując przy tym oryginalne wersje dokumentów?
- Czy wątpliwe odczyty OCR dotyczące kwot, procentów, dat, identyfikatorów i odniesień prawnych zostały zgłoszone komuś, a nie poprawione potajemnie?
- Czy język został zidentyfikowany z odpowiednią dokładnością, w tym w przypadku treści mieszanych?
- Czy informacje poufne zostały sklasyfikowane zgodnie z polityką bezpieczeństwa aplikacji przed wgraniem?
- Czy każdą istotną zmianę można prześledzić z powrotem do pierwotnie przetworzonego tekstu?
- Czy przed rozpoczęciem dzielenia na fragmenty istnieje wyraźna decyzja PASS, REVIEW lub REJECT?
Jeśli jakakolwiek odpowiedź jest niejasna, dokument nie jest gotowy do wydobycia.
Jeśli uszkodzona polisa została już podzielona na 80 fragmentów, wstawiona, skopiowana do indeksu wektorowego, umieszczona w pamięci cache i udostępniona przez aplikację online, naprawa pliku źródłowego już nie wystarcza. Każdy pochodny fragment musi zostać zlokalizowany, jego wektory usunięte, pamięci cache unieważnione, dokument przeprocesowany, a usunięcie przestarzałych reprezentacji zweryfikowane. Odrzucenie błędnej wiedzy jest najtańszym rozwiązaniem, zanim przekształci się ona w embedding.
Pokazany kod ma na celu przejrzystość i poprawność. W środowiskach produkcyjnych należy dodać zorganizowane logowanie, zarządzanie konfiguracją i sekretami, trwałe ślady audytowe transformacji, metryki przetwarzania wstępnego, mechanizmy przywracania po awariach oraz ludzką weryfikację korekt o wysokim ryzyku. W środowiskach regulowanych każda destruktywna transformacja powinna być możliwa do prześledzenia do oryginalnego źródła.
Literatura pokrewna
- Strategie dzielenia na fragmenty dla RAG w środowiskach produkcyjnych: o rozmiarze stałym, semantycznym oraz typie rodzic-dziecko — praktyczny przegląd technik dzielenia danych w RAG, od podziału o rozmiarze stałym i rekurencyjnym po metody typu rodzic-dziecko, propozycje, późne dzielenie na fragmenty, RAPTOR, tabele, kod oraz testowanie wydajności.
- Szukanie wideo i audio według znaczenia: Budowa systemu multimodalnego RAG z użyciem Gemini Embedding 2 — Jak pojedyncza przestrzeń embeddingów dla tekstu, audio, wideo i plików PDF zastąpiła procesy transkrypcji, dzięki funkcjom dzielenia na fragmenty, ChromaDB, rozszerzania zapytań, ponownego sortowania i ustawiania progów.
- Przyjmowanie danych do systemu RAG w produkcji dla banków: pliki PDF, OCR, tabele i łączniki — Jak przyjmować dokumenty bankowe do systemu RAG: klasyfikować pliki PDF, wykonywać OCR z flagami do sprawdzenia, zachowywać strukturę tekstu, obsługiwać tabele i obrazy oraz tworzyć odpornie działające łączniki.
- Hybrydowy RAG dla tekstów prawniczych: dzielenie na fragmenty, BM25, BGE i ponowna klasyfikacja — Proces RAG w kontekście Konstytucji Indii: dzielenie na fragmenty rodzic-dziecko, wyszukiwanie za pomocą BM25 w połączeniu z BGE, fuzja rankingów, ponowna klasyfikacja za pomocą cross-encodera oraz rozszerzanie zapytań, przetestowane na benchmarkach.
- Projektowanie RAG dla 100 milionów dokumentów: shardowanie, wyszukiwanie hybrydowe i koszty — Jak zmieniają się rzeczy, gdy system RAG rozrasta się do miliardów fragmentów: asynchroniczne pobieranie danych, kierowanie ruchem, shardowanie, hybrydowe wyszukiwanie, ponowna klasyfikacja, autoryzacja, buforowanie i kontrola kosztów.
- Trzy przyczyny niepowodzeń systemów RAG w produkcji: fragmentaryczny kontekst, dewiacje i niewłaściwe użycie — Dlaczego systemy RAG pomijają subtelności, tracą jakość w miarę rozwoju baz wiedzy lub udzielają błędnych odpowiedzi pomimo skutecznego wyszukiwania, wraz z praktycznymi rozwiązaniami na poziomie warstwy wyszukiwania.