Очистка данных для производственного RAG: кодирование, OCR, удаление дубликатов и обработка персональных данных
Как преобразовать разобранные корпоративные документы в текст, готовый к поиску: устранение проблем с кодировкой, удаление шаблонных элементов и дубликатов, обработка рискованных флагов OCR, работа с языками и маскировка персональных данных.
Источники: 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, часто недооценивают важность этой разницы.
Почему скрываются испорченные данные
Повреждения обычно настолько незначительны, что ничего не ломается. Некорректная последовательность байтов не приводит к сбою векторной базы данных. Дублирование абзаца не вызывает сбоев в 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
Вам не обязательно самостоятельно вести каталог всех вариантов mojibake, поскольку 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, не убирает знак евро и не превращает документ в одну строку. Эти пропуски сделаны намеренно.
Почему агрессивная обработка вредна
Многие старые инструменты обработки естественного языка были разработаны для моделей типа «сумка слов» или 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
Индексируйте все четыре файла как отдельные документы, и их фрагменты будут конкурировать друг с другом при поиске. Если запросить усиленный мониторинг клиентов с высоким риском, один раздел политики может занять три слота, оставив всего два для действительно разных доказательств. Дубликаты стоят дороже, чем просто дисковое пространство: они искажают смысл понятия «топ-к».
Точные дубликаты с хешированием содержимого
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 миллиардов парных сравнений. Alгоритм 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. Финансовая система должна стремиться к контролируемой точности, а не к максимальной автоматизации.
Мультиязычная предобработка
Обнаружение языка кажется незначительным вопросом, пока корпус данных не выходит за границы одной страны. Хранилище документов, связанных с платежными операциями, может содержать английские процедурные документы, немецкие письма клиентов, французские нормативные уведомления и испанские контракты; к тому же один файл, связанный с расследованием по противодействию отмыванию денег, может включать несколько языков в одном записи дела.
Язык влияет на всё, что происходит дальше. От него зависит коррекция орфографии, шаблоны ошибок 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>. Когда необходимо сохранить связи между упоминаниями, детерминистичные токены превосходят обычные местоимения. Повторяющийся в 40 записях CUSTOMER_7F29A1 позволяет сохранить целостность следственной информации, тогда как замена каждого имени на <PERSON> приведёт к её утрате.
Метаданные — это легко упускаемый источник утечек. Основной текст можно тщательно отредактировать, но при этом в его полях всё равно могут оставаться такие данные, как customer_name: "Lena Hoffmann" или путь к источнику вида /customers/Lena_Hoffmann/KYC/passport_review.pdf. Поэтому меры контроля за конфиденциальными данными должны охватывать всё, что индексируется, а не только текст.
Инструментарий производственного уровня
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
Разница между 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, содержащий всего 17 искажённых символов на 80 страницах, скорее всего, попадает в состояние REJECT; а политика, в которой единственными исправлениями были использование апострофов и пробелов, считается соответствующей стандартам и находится в состоянии PASS.
Показатели этапа очистки также стоит сохранять. Если text_reduction_ratio обычно уменьшает объем документа на 4–8%, а после выпуска резко возрастает до 63%, это означает наличие проблемы. Если unknown_language_rate после обновления библиотеки OCR растет с 0,3% до 18%, это свидетельствует о ухудшении качества работы детектора на более ранних этапах. Такие цифры позволяют видеть процесс очистки и контролировать его, вместо того чтобы иметь дело с невидимыми алгоритмами.
Компромиссы в производственной среде
Нет такой конфигурации очистки, которая подошла бы всем, и выбор определенных настроек имеет реальные последствия.
- Исправление кодировки. Инструмент
ftfyпозволяет быстро и эффективно устранить типичные ошибки кодировки и повреждения данных Unicode. Следует рассматривать его как вспомогательный инструмент для детерминированной нормализации Unicode, а не как возможность произвольно переписывать текст. - Устранение дубликатов. Хеширование SHA-256 практически бесплатно и способно обнаруживать точные дубликаты раньше, чем более дорогостоящие методы определения сходства. MinHash LSH полезен для поиска почти идентичных дубликатов в больших объемах, однако его порог должен генерировать кандидатов, а не решения об удалении.
- Коррекция с помощью OCR. Поиск в словаре требует немного ресурсов и работает предсказуемо с обычными словами. Нейросеть большого размера, способная анализировать контекст, может справляться с более сложными случаями, но использование генеративной модели на этапе предобработки приводит к дополнительным затратам и риску создания правдоподобно звучащих переписанных версий текста. Для материалов, подлежащих регулированию, пусть модель предлагает исправления в структурированном формате для утверждения рецензентом, а не позволяет ей редактировать документы без просмотра.
- Мультиязычная обработка. Обнаружение всего документа стоит недорого и подходит для маршрутизации. Обнаружение на уровне абзацев лучше подходит для файлов с смешанными языками, но требует больших затрат; то, что окажется эффективнее, зависит от реального распределения языков и критериев поиска. Термин «мультиязычный» на карточке модели мало что говорит о возможности извлечения немецких терминов, связанных с АМЛ, из вопроса на английском языке.
- Персональные данные. Жесткая цензура снижает риски утечки информации, но мешает поиску по конкретным элементам. Часто лучшим решением является разделение семантического векторного поиска от безопасного поиска с точным совпадением для идентификаторов, чтобы один индекс не должен был решать оба вида задач.
Основная идея, связывающая все эти аспекты: пусть требования к поиску и риск искажения смысла определяют решения по предварительной обработке, а не красота очищенного текста.
Возвращаемся к начальной политике
Вернитесь к первоначальной политике KYC. Механизм обработки успешно извлек данные, а процесс очистки теперь выполняет гораздо больше действий перед тем, как включить их в индекс. Он восстанавливает повреждённую кодировку, не удаляя законные многоязычные символы, удаляет дублирующиеся заголовки страниц, сохраняя при этом метаданные документа, выявляет точные копии, хранящиеся в разных хранилищах, и рассматривает более старые версии политики как связанные, а не как элементы, которые следует бездумно удалять. Он отмечает EUR l0,000 как серьёзную аномалию OCR и отказывается делать предположения про себя, распознаёт многоязычные разделы, обнаруживает конфиденциальные идентификаторы клиентов до того, как они попадут в слой встраивания, записывает каждую трансформацию и решает, следует ли результату пройти проверку, быть отправленным на рассмотрение или отклонённому.
Только тогда документ попадает на этап разбиения на фрагменты. Порядок имеет значение, поскольку при разбиении невозможно восстановить информацию, уже уничтоженную на этапе предобработки, а встроенные представления не могут отличить достоверные знания от ошибок, попавших в корпус без проверки.
После очистки возникает другая проблема: где разделить 80-страничную политику АМЛ на части? Создать простое окно из 500 токенов легко, но оно может отделить правило от его исключения, изъять таблицу без заголовка или соединить несвязанные пункты лишь потому, что у них одинаковый лимит токенов. Методы разделения варьируются от фиксированного размера и рекурсивных до семантических, учитывающих структуру, отношений «родитель-дочерний» и на уровне предложений, причем каждый из них требует отдельного рассмотрения.
Чек-лист перед разбиением на фрагменты
Прежде чем файл будет обработан с целью разбиения на чанки и встраивания, на этапе предварительной обработки должен быть дан четкий ответ на каждый из следующих вопросов:
- Прошла ли кодировка и нормализация Unicode без потери символов, имеющих значение?
- Были ли обработаны управляющие символы, символы нулевой ширины, повторяющиеся заголовки и подзаголовки, а также другой «шум», мешающий поиску?
- Были ли обнаружены точные и почти идентичные копии, при этом оригинальные версии документов остались нетронутыми?
- Были ли сомнительные результаты распознавания символов для сумм, процентов, дат, идентификаторов и юридических ссылок сообщены кому-либо, а не скрытно исправлены?
- Был ли язык определен с достаточной степенью точности, включая контент с смешанными языками?
- Была ли конфиденциальная информация классифицирована в соответствии с политикой безопасности приложения перед встраиванием?
- Возможно ли отследить каждое значимое изменение до первоначально обработанного текста?
- Существует ли явное решение PASS, REVIEW или REJECT перед началом разбиения на части?
Если какой-либо из ответов неясен, документ ещё не готов к извлечению.
Если повреждённый документ уже был разделён на 80 частей, встроен, скопирован в векторный индекс, сохранён в кэше и предоставлен живым приложением, восстановление исходного файла уже недостаточно. Необходимо найти каждую из полученных частей, удалить соответствующие векторы, аннулировать кэш, перепроцессировать документ и проверить удаление устаревших представлений. Отклонение некорректных данных — самый экономичный способ предотвратить их использование в виде векторных представлений.
Представленный код разработан с учётом ясности и корректности. Для использования в производстве необходимо добавить структурированную логгирование, систему управления конфигурациями и секретами, постоянные записи аудита преобразований, показатели предварительной обработки, механизмы восстановления при сбоях и проверку человеком для корректировок с высоким уровнем риска. В регулируемых средах каждое разрушающее преобразование должно быть отслеживаемо до исходного источника.
Связанные материалы
- Стратегии разбиения на части для RAG в производстве: фиксированный размер, семантический подход и структура «родитель-дочерь» — практический обзор методов разбиения данных в RAG, от фиксированного размера и рекурсивного разделения до структуры «родитель-дочерь», использования предложений, позднего разбиения, технологии RAPTOR, таблиц, кода и методов оценки эффективности.
- Поиск видео и аудио по смыслу: создание мультимодальной системы RAG с использованием Gemini Embedding 2 — как один пространство эмбеддингов для текста, аудио, видео и PDF-файлов заменило традиционные методы транскрипции, включая разбиение на фрагменты, использование ChromaDB, расширение запросов, переранжирование и задание пороговых значений.
- Получение данных для производственных систем RAG в банках: PDF-файлы, OCR, таблицы и соединители — как загружать банковские документы в систему RAG: классификация PDF-файлов, выполнение OCR с флагами для проверки, сохранение структуры текста, обработка таблиц и изображений, а также создание надежных соединителей.
- Гибридный RAG для юридических текстов: разбиение на фрагменты, BM25, BGE и переранжирование — Пайплайн RAG для юридических данных на основе Конституции Индии: разбиение на родительские и дочерние фрагменты, поиск с использованием BM25 и BGE, слияние рангов, переранжирование с помощью кросс-кодера и расширение запроса; тестирование на эталонных наборах данных.
- Проектирование RAG для 100 миллионов документов: шардинг, гибридный поиск, затраты — Что меняется, когда система RAG достигает миллиардов фрагментов: асинхронная обработка данных, маршрутизация, шардинг, гибридный метод поиска, переранжирование, авторизация, кэширование и контроль затрат.
- Три причины сбоев в производственных системах RAG: фрагментированный контекст, дрейф данных и неправильное использование — почему системы RAG упускают нюансы, теряют качество по мере роста баз знаний или дают неверные ответы даже при эффективном поиске, а также практические решения на уровне механизма поиска.