Головна / Статті

Очищення даних для RAG у виробництві: кодування, OCR, видалення дублікатів та персональних даних

Як перетворити оброблені корпоративні документи на текст, готовий до пошуку: виправлення кодування, видалення шаблонних та дублікатних елементів, позначки ризикованого OCR, обробка мов та маскування персональних даних.

Видавець: REACTAPP.TOP21 хв читання4926 слів

Джерела: Data Cleaning and Preprocessing for Production RAG: Building Retrieval-Ready Documents

Уявіть банк, який створив асистента з інформацією на основі 200 000 внутрішніх та регуляторних документів. Процес отримання даних працює без проблем: PDF-файли парсуються, відскановані циркуляри щодо боротьби з відмиванням грошей проходять обробку OCR, файли з SharePoint надходять у запланований час, а таблиці виділяються окремо. Здається, у цьому корпусі немає нічого нестачаючого.

Проте відповіді є нерівномірними. Спеціаліст з дотримання правил запитує, які документи потрібні для посиленого перевірки високоризикового корпоративного клієнта. Система повертає п’ять фрагментів; у трьох з них міститься відповідна політика KYC, а у двох — майже ідентичний текст зі старішої версії документа. Кожен фрагмент починається з однакового заголовка:

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

Один фрагмент також містить речення „Усі корпоративні клієнти з високим ризиком повинні пройти посилену перевірку.“ Подивіться уважно: All перетворилося на AII, а diligence — на diIigence, оскільки OCR сплутав маленьку літеру l та велику I.

В інших частинах того ж файлу під час обробки залишився пробіл без розриву, апостроф у форматі Windows-1252 був неправильно декодований, і цей футер з’являється 132 рази, оскільки він був на кожній сторінці. Архівна копія політики знаходиться в індексі поруч із схваленою, адже SharePoint зберіг обидві.

З технічної точки зору процес імпорту працював. Однак з оперативної точки зору корпус даних є брудним, і інженери, які створюють свою першу систему RAG, часто недооцінюють значення цієї різниці.

Чому ховаються брудні дані

Зазвичай пошкодження є настільки незначними, що ніщо не збивається з роботи. Неправильна послідовність байтів не спричиняє збою в базі даних типу vector. Дубльований абзац не призводить до невдачі API для генерації ембеддингів. Повторюваний футер не викликає жодних попереджень, а помилка OCR з 1 на l не зупиняє процес індексації. Система весело обробляє все, а рахунок з’являється пізніше у вигляді помилок під час пошуку.

Дубльовані документи займають кілька позицій у результатах типу «топ-к». Повторювані шаблонні фрагменти створюють штучну схожість між непов’язаними частинами тексту. Помилки OCR змінюють важливі елементи, відсотки, номери рахунків та нормативні ліміти. Артефакти кодування додають токени, які не мають бізнес-значення. До того часу, як з’являється неправильна відповідь, кожен наступний компонент може виконувати свою роботу: система пошуку повертає те, що було збережено, система ранжування оцінює те, що отримала, а ШІ працює на основі того контексту, який надійшов до неї. Проблема виникла раніше, ніж у всіх трьох компонентах.

Необхідно мати політику протидії відмиванню грошей, яка передбачає додаткову перевірку походження коштів для транзакцій на суму понад 10 000 євро. Коли відбувається погана обробка тексту через OCR та помилкова конвертація кодування, у індексі з’являється запис „EUR l0,000“. Людина миттєво помічає цю помилку. Проте модель з вбудовуванням все одно сприймає це речення як щось, пов’язане з такими поняттями, як порогові значення транзакцій та перевірка походження коштів, тому пошук функціонує ідеально, що є тривожним. Пошкоджений текст потрапляє до ШІ, оскільки семантичний пошук ігнорує текстові недоліки. Система знаходить відповідний розділ політики, але всередині нього міститься хибне регуляторне значення.

Саме тому очищення тексту не можна звести до простої операції text.strip().lower().

Яке місце займає очищення в процесі RAG

Очищення виконується між отриманням документа та його розділенням на частини, і ця послідовність є навмисною. Ви не хочете вимірювати схожість документів до того, як усунете очевидні пошкодження кодування. Ви не хочете агресивно нормалізувати текст до того, як визначите його мову. Ви не хочете видаляти повторювані юридичні формулювання лише тому, що вони схожі на шаблонні тексти. Крім того, корекції OCR з низькою достовірністю не повинні таємно змінювати обов’язки щодо дотримання правил.

Як тільки шумний текст розділено на частини та вбудовано, очищення стає значно складнішим. Дублікати абзаців вже створили дублікатні вектори, пошкоджені слова вплинули на межі частин, повторювані заголовки зайняли простір для токенів, а помилки OCR вже вбудовані у семантичне представлення. Очищення має виконуватися перед розділенням на частини, а не як корекція, застосована після того, як якість починає погіршуватися.

Нормалізація кодування: спочатку виправте текст

Корпоративні колекції рідко мають спільну історію кодування. Сучасна політика може передбачати використання UTF-8, архів листування з клієнтами — Windows-1252, а старіші експорти — Latin-1. Текст, скопійований між SharePoint, електронною поштою, Word та застарілими системами документів, міг бути кілька разів закодований та декодований перед тим, як потрапити до системи обробки. Результат виглядає так:

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

Цю пошкодженість називають mojibake, і вона виникає тоді, коли байти, записані в одному кодуванні, читаються як у іншому. Людина відразу бачить, що customer’s пошкоджений. У процесі обробки даних пошкоджений токен потрапляє до токенізатора інакше, ніж customer's, і в масштабних системах такі неузгодженості роз’єднують словниковий запас та додають зайву варіативність у простір ембеддингів.

Будьте обережні з мірою виправлень. Недбала функція вважає будь-який символ поза межами ASCII сміттям, проте в європейських корпусах даних про платежі закономірно зустрічаються такі терміни, як Société Générale, Deutsche Bundesbank, €10,000 та Überweisung. Якщо прибрати акценти та символи, справжня інформація зникає. Метою є не текст лише в форматі ASCII, а послідовний Unicode, який зберігає своє значення.

Виправлення Unicode за допомогою ftfy

Вам не потрібно самостійно каталогувати кожен варіант моїбаке, адже ftfy вже виправляє поширені помилки Unicode та кодування. Саме звідти можна розпочати етап очищення для продакшну:

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,
    )

Зверніть увагу, що функція не торкається. Вона не перетворює все на маленькі літери, не видаляє акценти з Société Générale, не прибирає символ євро та не перетворює документ на один рядок. Ці пропуски є навмисними.

Чому радикальне очищення шкодить

Багато старіших пайплайнів NLP були розроблені для моделей типу „мішок слів“ чи TF-IDF, і вони перетворювали текст на маленькі літери, видаляли пунктуацію, слова-зупинки та виконували інші операції обробки. Сліпе застосування таких методів перед сучасними ембеддингами призводить до втрати корисної інформації. Розгляньте, що це може спричинити з цим реченням:

Customer must NOT be classified as low risk.

Фінансовий текст пропонує ще більш делікатні приклади. EUR 10,000 та EUR 100,000 виглядають схоже за написанням, проте суттєво відрізняються за оперативним значенням, а Стаття 6(1)(a) та Стаття 6(1)(b) мають ідентичну структуру, але різну юридичну силу. Основне правило полягає у рішучій нормалізації форматування та обережному збереженні значення. Заміна customer’s на customer's — це зміна форми, яка відновлює первісний зміст тексту. Видалення символа €, прибирання акцентів з імені клієнта чи автоматичне перетворення l0% на 10% змінюють факти, на які компанія може покладатися.

Видалення шаблонного тексту: структура, а не ключові слова

Заголовки та футери — це не той самий тип проблем, що й шум кодування. Повторення Сторінка 47 з 132 явно є шаблонним текстом, але вимога щодо дотримання правил, яка зустрічається у кількох розділах політики, може бути навмисно повторена та мати юридичну силу. Кількість разів, коли з’являється певний рядок, не дозволяє визначити, з яким із цих двох випадків маємо справу.

Кращий сигнал поєднує повторення з положенням. Заголовки розташовані у верхній частині сторінок, а футери — у нижній; номери сторінок змінюються незначно, залишаючись у тому ж структурному форматі:

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

Логіка точного збігу розглядає два різні рядки, тоді як логіка, що враховує структуру, бачить два екземпляри одного шаблону. Це одна з причин, чому виявлення шаблонних фрагментів працює найкраще, поки існують межі сторінки, тому уникайте перетворення обробленого документа на величезний рядок одразу після його отримання.

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

Поріг min_page_ratio є найважливішим критерієм прийняття рішення. Правило не полягає у „видаленні будь-якої рядка, який з’являється двічі“. Воно полягає у тому, щоб „вважати структурно схожий рядок шаблонним, якщо він постійно з’являється у межах поля сторінки на більшості сторінок документа“. У випадку 100-сторінкового документа, де заголовок CONFIDENTIAL - INTERNAL USE ONLY зустрічається на 98 сторінках, докази є переконливими.

Ще один важливий момент: видалення чогось із тексту, який використовується для вбудовування, не означає його видалення з системи. Іноді інформацію слід переміщувати з рівня семантичного контенту на рівень метаданих. Такі поля, як версія, власник, класифікація та дата набрання чинності, краще зберігати як метадані, ніж копіювати їх у кожен фрагмент.

Усунення дублікатів поза межами точних копій

Документ може бути ідеально закодований, без пошкоджень від OCR та позбавлений зайвого тексту, проте його все одно буде важко знайти лише через те, що корпус містить його кілька разів. Корпоративні репозиторії сповнені такими випадками:

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

Індексуйте всі чотири файли як окремі документи, і їхні фрагменти починають конкурувати під час пошуку. Якщо запитати про посилений моніторинг клієнтів із високим ризиком, можливо, три місця будуть зайняті одним розділом політики, залишивши два для справді різних доказів. Дублікати коштують більше, ніж простір на диску: вони спотворюють справжнє значення поняття «top-k».

Точні дублікати з хешуванням контенту

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

Хеш також корисний для інкрементального індексування. Якщо той самий нормалізований документ з’явиться під час наступної процедури обробки, його хеш вже відомий, тож процес може пропустити повторну очистку, розділення на частини та вставку даних.

Майже однакові дані за допомогою MinHash LSH

Точне генерування хешів підходить для простих випадків. Складнішим є випадок двох версій політики KYC — наприклад, версії 7 та 8 — які на 98% ідентичні за текстом, але відрізняються одним нормативним положенням. З точки зору дотримання правил вони категорично не є дублікатами, оскільки це останнє положення змінює вимоги до контролю.

У регульованій системі RAG виявлення майже однакових документів спочатку повинно відповісти на інше запитання — а саме, які документи є достатньо схожими, щоб їх досліджувати, — залишаючи процедуру видалення як окреме рішення. Для 200 000 документів потрібно близько 20 мільярдів парних порівнянь. MinHash із гешуванням, чутливим до локальності, уникає цього шляхом створення компактних підписів та розміщення схожих документів у відповідних групах-кандидатах.

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

Коли політики KYC версій v7 та v8 потрапляють у одну групу, система розуміє, що їх потрібно уважніше перевірити, але не більше. Вона не зрозуміла, що одну з них слід видалити. Рішення для продакшну має поєднувати подібність тексту з метаданими, такими як ідентифікатор політики, версія, дата набрання чинності, статус схвалення та юрисдикція, адже сама лише подібність не дозволяє провести чітку межу.

Помилки OCR: текст, який виглядає правильно, але є неправильним

Виправлення кодування виправляє символи, які були збережені неправильно. Помилки OCR належать до окремої категорії. У цьому випадку движок міг з упевненістю прочитати абсолютно коректний символ Unicode та просто обрати неправильний. Типові плутанини між схожими символами — 0 та O, 1, l та I, 5 та S, а також rn та m.

Загальний рівень помилок може бути низьким, але сумарна точність — це не той показник, який має значення для регульованих систем RAG. 40-сторінковий документ, у якому 99,8% тексту є правильним, все одно може мати пошкоджені найважливіші дані. Ключовою архітектурною особливістю є те, що помилки OCR відрізняються за рівнем ризику. Виправлення custorner на customer є менш ризикованим, оскільки контекст та словник сильно підказують потрібне слово. Натомість заміна EUR l0,000 на EUR 10,000 належить до зовсім іншої категорії, адже неправильний варіант може змінити обов’язки щодо дотримання правил.

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

Функція навмисно не змінює документ. Регулярний вираз може показати, що l0,000 виглядає підозріло у фінансовому контексті, але він не може довести, що у вихідному тексті справді було написано 10,000.

Звичайний словниковий запас краще обробляється словником, який знає конкретну галузь. Для банківських текстів потрібен загальний словник та терміни на кшталт beneficial ownership, counterparty, SWIFT, IBAN, SEPA та sanctioned. Інакше звичайний коректор охоче „виправить“ законні фінансові терміни на гірші.

У реальних умовах алгоритм OCR оцінює рівень впевненості двигуна та визначає, наскільки важливою є помилка. Читання тексту з низьким рівнем критичності, яке здійснюється з високою впевненістю, зазвичай можна виправити без участі людини. Контент із високим ризиком — тобто дані про грошові суми, відсотки, номери нормативних документів та дати — має проходити перевірку незалежно від рівня впевненості OCR. Фінансова система повинна прагнути до контрольованої точності, а не до максимальної автоматизації.

Багатомовна попередня обробка

Виявлення мови здається незначним, поки корпус даних не охоплює кілька країн. База даних операцій з платежами може містити документи англійською, листи клієнтів німецькою, нормативні повідомлення французькою та контракти іспанською, а один файл з розслідуванням AML може містити кілька мов у одному записі справи.

Мова впливає на все, що йде далі. Від неї залежить корекція орфографії, шаблони помилок OCR відрізняються залежно від писемності, іменовані ентитети містять акценти, які мають зберегтися під час нормалізації, а сама модель ембеддингу може поводитися по-різному залежно від індексованої мови.

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,
    )

Коли документ поєднує кілька мов, виявлення на рівні абзаців краще, ніж єдиний вердикт для всього файлу. Це також дає інструменту розбиття на частини підказку щодо структури: якщо розбиття відбувається через межу мови, яку інструмент раніше не бачив, одна частина може містити два незв’язані мовні контексти.

Деякі команди спочатку перекладають усе на англійську. Це спрощує індекс, проте додає ще одну стадію перетворення між авторитетним текстом та тим, що отримується під час пошуку. Крім того, положення про дотримання норм у Німеччині можуть містити формулювання з точним юридичним значенням, яке переклад може непомітно змінити. Більш міцна архітектура розглядає текст оригінальною мовою як джерело істини та зберігає будь-який переклад як додаткову версію, причетність до якої фіксується в метаданих. Метадані про мову також повинні бути точними:

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

Виявлення мови також може слугувати перевіркою стану системи. Припустимо, що документ з політикою боротьби з відмиванням грошей у Великобританії, який мав бути англійським, раптово повідомляє primary_language = „unknown“ при confidence = 0.31. Це означає, що файл може бути не багатомовним; OCR міг настільки сильно пошкодити текст, що детектор більше не може його розпізнати.

Персональні дані та конфіденційна інформація: виявляти перед вставкою

Попередня обробка — це один із останніх шансів контролювати те, що потрапляє до інфраструктури пошуку. У файлі з процедурою верифікації клієнта можуть бути імена клієнтів, номери паспортів, IBAN, номери телефонів та адреси електронної пошти. Після того, як ці дані проходять через сервіс вставки та потрапляють у базу даних векторів, проблема стає набагато складнішою, ніж захист оригінального документа, адже копії тепер існують у запитах на вставку, векторах, метаданих, кешах, індексах, журналах та резервних копіях.

Питання не в тому, чи може модель ембеддингу розуміти персональні дані, адже вона може. Справа у тому, чи взагалі потрібно ембеддувати цю інформацію.

Видалення кожної ентитету, яку знаходить детектор, дійсно робить корпус безпечнішим, але це псує можливість пошуку за ентитетами. Якщо кожну назву компанії замінити на <ORGANIZATION>, слідчий з боротьби з відмиванням грошей, який шукає інформацію про Northbridge Trading GmbH, нічого не знайде. Тому оптимальний підхід — встановити правила обробки для кожного типу виявленої ентитети перед подальшими діями. Публічні та операційні назви зазвичай залишаються, персональні дані клієнтів маскуються, токенізуються або обмежуються у доступі, внутрішні організаційні посилання підпорядковуються правилам доступу, а облікові дані ніколи не потрапляють до індексу.

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),
        )

Заміна значень на введені місця заміни зазвичай ефективніша для RAG, ніж їх видалення:

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

Речення зберігає свою структуру, яка несе значення, тому запит про те, де перевіряється IBAN клієнта, все ще може відповідати тексту, що містить <IBAN>. Коли необхідно зберегти зв’язки між згадками, детерміністичні токени кращі за універсальні замінники. CUSTOMER_7F29A1, який зустрічається у 40 записах, допомагає зберегти цілісність слідства, тоді як заміна кожного імені на <PERSON> призведе до його втрати.

Метадані — це щось, що легко забувається під час обробки. Основний текст можна ретельно скоригувати, але в його полях все одно можуть залишитися дані на кшталт customer_name: "Lena Hoffmann" або шляху до джерела, наприклад /customers/Lena_Hoffmann/KYC/passport_review.pdf. Тому механізми контролю конфіденційних даних мають охоплювати все, що індексується, а не лише текст.

Інструментарій промислового рівня

Під час експериментування достатньо окремих функцій очищення. У промисловому використанні діє суворіший стандарт: документ не повинен переходити до етапу розділення на частини лише через те, що ланцюжок кроків виконався без виникнення винятків. Система має фіксувати, що було змінено, що все ще виглядає підозріло, чи знайшлися конфіденційні дані, чи файл є дублікатом іншого, та чи заслуговує кінцевий результат на індексацію.

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

Різниця між raw_text та clean_text є фундаментальною. Перший відповідає на питання, що саме було вилучено під час обробки даних, а другий — яку форму представлення ми готові розділити на частини та вбудувати. Журнал трансформацій пояснює, як один варіант перетворився на інший, і ця історія є надзвичайно корисною, коли через шість місяців повідомляється про збій у отриманні даних.

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

Шлюз виводить один із трьох чітко визначених станів. PASS дозволяє документу продовжувати обробку без нерозв’язаних високоризикових знахідок. REVIEW означає, що був отриманий придатний для використання контент, але певні трансформації потребують підтвердження людини перед індексуванням. REJECT означає, що результат не є достатньо надійним для індексування. Осканований документ із сумнівним порогом l0% потрапляє у стан REVIEW; PDF, у якому на 80 сторінках є лише 17 пошкоджених символів, ймовірно, потрапляє у стан REJECT; а політика, у якій єдиними виправленнями були апострофи та пробіли, має статус PASS.

Показники на етапі очищення також варто зберігати. Якщо text_reduction_ratio зазвичай скорочує документ на 4–8%, а після випуску зростає до 63%, це ознака проблеми. Якщо unknown_language_rate після оновлення бібліотеки OCR зростає з 0,3% до 18%, це свідчить про проблеми на ранніх етапах обробки. Такі цифри роблять процес очищення прозорим та керованим, замість того щоб залишати його невидимою скриптою.

Компроміси у продакшені

Жодна конфігурація очищення не підходить усім, і вибір має реальні наслідки.

  • Виправлення кодування. ftfy є дешевим та ефективним інструментом для первинної обробки типових пошкоджень у форматі mojibake та Unicode. Використовуйте його як допоміжний засіб для детерміністичної нормалізації Unicode, а не як дозвіл на довільну переробку тексту.
  • Усунення дублікатів. Хешування SHA-256 майже не коштує та здатне виявляти точні дублікати ще до застосування більш дорогих методів оцінки схожості. MinHash LSH корисний для пошуку майже однакових даних у великих обсягах, проте його поріг має генерувати кандидатів, а не рішення про видалення.
  • Корекція за допомогою OCR. Пошук у словнику коштує небагато та працює стабільно зі звичайними словами. Нейромережа, яка розуміє контекст, може вирішувати складніші випадки, але генеративна модель під час попередньої обробки створює додаткові витрати та ризик створення правдоподібних, але неправильних версій тексту. Для регульованих матеріалів нехай вона пропонує корекції у структурованому форматі, який може схвалити рецензент, замість того щоб дозволяти їй редагувати документи без перегляду.
  • Багатомовне оброблення. Виявлення всього документа є недорогим та підходить для маршрутизації. Виявлення на рівні абзаців краще підходить для файлів зі змішаними мовами, але коштує дорожче; те, що буде ефективніше, залежить від реального розподілу мов та критеріїв пошуку. Термін „багатомовний“ на картці моделі мало що говорить про можливість пошуку німецьких термінів AML у запитанні англійською мовою.
  • Персональні дані. Жорстке стирання інформації знижує ризик витоку, але ускладнює пошук за елементами. Часто кращим підходом є розділення семантичного векторного пошуку від безпечного пошуку за точними ідентифікаторами, щоб один індекс не мусив вирішувати обидві проблеми.

Основна ідея, яка проходить крізь усе це: нехай вимоги до пошуку та ризик спотворення значень керують рішеннями щодо попередньої обробки, а естетика очищеного тексту залишається останнім критерієм.

Назад до початкової політики

Поверніться до початкової політики KYC. Процес обробки успішно її витягнув, а потім система очищення виконує значно більше кроків перед тим, як дозволити її потрапити до індексу. Вона виправляє пошкоджену кодування, не видаляючи законних багатомовних символів, видаляє повторювані заголовки сторінок, зберігаючи метадані документа, виявляє точні копії, збережені в різних сховищах, та розглядає старі версії політик як пов’язані, а не як елементи для сліпого видалення. Вона позначає EUR l0,000 як серйозну аномалію OCR та відмовляється припускати щось без підтвердження, розпізнає багатомовні розділи, виявляє конфіденційні ідентифікатори клієнтів ще до того, як вони потраплять у шар імбеддингу, фіксує кожну трансформацію та вирішує, чи слід дозволити результат пройти, передати на перевірку чи відхилити.

Лише тоді документ потрапляє на етап розділення на частини. Порядок має значення, оскільки розділення на частини не може відновити інформацію, яку вже знищив попередній обробник, а ембеддинги не можуть відрізнити авторитетні знання від пошкоджень, які потрапили до корпусу без перевірки.

Після очищення постає інша проблема: де розрізати 80-сторінкову політику AML на частини? Створити просте вікно з 500 токенів — це легко, проте це може призвести до того, що правило буде відокремлене від його винятку, таблиця — від її заголовка, або несуміжні пункти будуть об’єднані лише через спільний бюджет токенів. Підходи до розділення варіюються від фіксованого розміру та рекурсивних до семантичних, з урахуванням структури, на рівні батьківських та дочірніх елементів, а також на рівні пропозицій, і кожен з них потребує окремого розгляду.

Чек-лист перед розділенням на частини

Перш ніж файл перейде до розділення на частини та вбудовування, етап попередньої обробки має бути здатний дати чітку відповідь на кожне з наступних питань:

  • Чи пройшла кодування та нормалізація Unicode без втрати символів, які мають значення?
  • Чи були оброблені керуючі символи, символи нульової ширини, повторювані заголовки та підзаголовки, а також інший „шум“, який ускладнює пошук?
  • Чи були виявлені точні та майже ідентичні копії, залишивши недоторканими справжні версії документа?
  • Чи були сумнівні результати OCR щодо сум, відсотків, дат, ідентифікаторів та юридичних посилань повідомлені комусь, а не мовчки виправлені?
  • Чи була мова визначена з належною деталізацією, включаючи контент змішаної мови?
  • Чи була конфіденційна інформація класифікована відповідно до політики безпеки додатку перед вбудовуванням?
  • Чи можна простежити кожну значущу зміну назад до початкового тексту, який був розпарсований?
  • Чи існує чітке рішення PASS, REVIEW або REJECT перед початком розділення на частини?

Якщо будь-яка відповідь є нечіткою, документ не готовий до отримання.

Якщо пошкоджений документ вже було розділено на 80 частин, вбудовано, скопійовано у векторний індекс, збережено у кеші та надано через діючий додаток, виправлення вихідного файлу більше недостатнє. Необхідно знайти кожну похідну частину, видалити її вектори, скасувати дію кешу, перепроцесувати документ та перевірити видалення застарілих версій. Відхилення некоректних даних є найдешевшим рішенням, перш ніж вони стануть частиною векторних представлень.

Показаний код розроблений з метою забезпечення ясності та коректності. Для продакшну необхідно додати структуроване логування, керування конфігурацією та секретами, постійні записи аудиту змін, показники передпроцесування, механізми відновлення при збоях та людський огляд для корекцій із високим ризиком. У регульованих середовищах кожна руйнівна трансформація має бути простежуваною до початкового джерела.

Пов’язані матеріали

  • Пошук відео та аудіо за змістом: створення багатомодальної системи RAG із використанням Gemini Embedding 2 — як один простір ембеддингів для тексту, аудіо, відео та PDF-файлів замінив традиційні процеси транскрипції, включаючи розділення на частини, використання ChromaDB, розширення запитів, переурочування результатів та встановлення порогів.
  • Збір даних для систем RAG у промислових умовах для банків: PDF-файли, OCR, таблиці та з’єднувачі — як імпортувати банківські документи для систем RAG: класифікувати PDF-файли, виконувати OCR із позначками для перевірки, зберігати структуру тексту, обробляти таблиці та зображення, а також створювати надійні з’єднувачі.
  • Гібридний RAG для юридичних текстів: чанкінг, BM25, BGE та реранкінг — Пайплайн RAG для юридичних документів на основі Конституції Індії: розділення на чанки типу „батько-дитина“, пошук за допомогою BM25 та BGE, об’єднання рейтингів, реранкінг за допомогою крос-енкодера та розширення запиту; протестовано на тестових даних.
  • Проектування RAG для 100 мільйонів документів: шардування, гібридний пошук, витрати — Що змінюється, коли система RAG досягає мільярдів чанків: асинхронне завантаження даних, маршрутизація, шардування, гібридний пошук, реранкінг, авторизація, кешування та контроль витрат.
  • Три причини невдач систем RAG у продакшені: фрагментований контекст, зміни та неправильне використання — чому системи RAG пропускають нюанси, їхня продуктивність падає з ростом баз знань або вони дають неправильні відповіді попри ефективне пошук, та практичні рішення на рівні механізму пошуку.