PII Anonymization

PII-анонимизация — детекция и автоматическая замена персональных данных (имена, телефоны, карты, паспорта) на заглушки <PERSON> до отправки в LLM. Промышленный open-source стандарт — Microsoft Presidio. «Семь строк в пайплайне — и вы предотвратили передачу имён и номеров карт в модель»: это минимум для любого продакшн-агента в РФ.

Суть

Чтобы использовать внешние API (OpenAI) в РФ-контуре, нужно отправлять не Иван Иванович, а <PERSON>. Presidio детектирует чувствительные сущности и маскирует их, оставляя смысл запроса. Это конкретный инструмент для pre-prompt scanning из DLP for LLM.

Зачем это нужно

  • Комплаенс — ПДн нельзя отправлять в зарубежные API (серверы США/ЕС); анонимизация снимает это ограничение.
  • Минимум усилий — интеграция в несколько строк, заметный эффект на риск.

Как работает

  • Microsoft Presidio — open-source: AnalyzerEngine находит PII, AnonymizerEngine маскирует. Детектирует ФИО, телефоны, email, номера карт, паспортные данные.
  • Под капотом — NER + regex:
    • NER (Named Entity Recognition) — NLP-задача: модель находит в тексте слова/фразы заранее определённых классов (имя, организация, локация) и присваивает метки.
    • regex — для форматных сущностей (номера карт, телефоны).
  • Локализация под РФ — из коробки Presidio заточен под англоязычные данные; российские форматы (паспорт РФ, СНИЛС, ИНН) добавляются через кастомные recognizers (несложно: объявляем переменные/паттерны и подаём в инструмент).
  • Где в пайплайне: входящий трафик — детект/маскирование PII, блокировка API-ключей и кредов, логирование подозрительных паттернов; исходящий — фильтр данных из RAG, проверка, что модель «не вспомнила» обучающие данные.

Выбор метода: цена точности

Эксплуатационный разбор сводит выбор к таблице, где видно, почему гибрид выигрывает не по точности, а по её сочетанию с латентностью:

Метод Латентность (1 Кб) Recall Ограничения
Regex < 1 мс ~60% Ломается на нестандартном написании
NER (SpaCy / Stanza) 15–30 мс ~85% Требует CPU/GPU, ложные срабатывания на брендах
Presidio (гибрид) 10–20 мс ~92% Оптимален для продакшена
Локальный BERT > 150 мс ~98% Критически снижает пропускную способность

Чистый regex дёшев, но пропускает две ошибки из пяти. Локальный BERT почти не ошибается, но полутора сотнями миллисекунд на каждый запрос убивает пропускную способность сервиса. Гибридный пайплайн держит точность около 92% при задержке в пределах двадцати миллисекунд — это и есть рабочая точка.

Ключевой вывод: нельзя отправлять незашифрованные ФИО и балансы ни в облачную модель, ни в трейсы. Про второе забывают чаще: система наблюдаемости хранит промпты целиком, и без маскирования персональные данные оседают там же.

Что известно про качество на русских ПДн

Проверено 2026-08-22: опубликованных метрик качества для русских распознавателей Presidio нет. Готовый пакет существует — presidio-ru-recognizers (первый релиз 0.1.0, май 2026) закрывает ИНН, СНИЛС, ОГРН, ОГРНИП, паспорт РФ, российский телефон и расчётный счёт, — но ни F1, ни precision/recall для него не заявлены. Значит опираться на чужие цифры не получится, надо мерить на своих данных.

Практически важнее другое: русские ПДн распадаются на два класса с принципиально разной надёжностью детекции.

Структурные идентификаторы — ИНН, СНИЛС, ОГРН, номер счёта — имеют жёсткий формат и контрольную сумму. Их не нужно распознавать моделью вообще: регулярное выражение плюс проверка контрольной суммы даёт практически нулевой процент ложных срабатываний, потому что случайная последовательность цифр контрольную сумму не проходит. Здесь ошибок ждать неоткуда, и именно эти сущности закрывает готовый пакет.

Именованные сущности — ФИО, адреса, названия организаций — контрольной суммы не имеют и требуют NER-модели. Вот здесь русский и проседает, и по понятной причине: падежи. «Иванову Ивану Ивановичу» и «Иванов Иван Иванович» — одна сущность в разных формах, и модель, обученная на именительном падеже, вторую пропускает. Сюда же — отчества, которых нет в англоязычных наборах, и совпадение фамилий с нарицательными словами.

Отсюда рабочая схема: структурное — правилами с контрольной суммой, именованное — NER-моделью с русской морфологией, и измерять качество только на второй половине, потому что первая не ошибается. Набор для замера собирается из своих документов с ручной разметкой — несколько сотен примеров, обязательно с косвенными падежами.

Кастомный распознаватель под российские форматы

Практический минимум — паспорт РФ через собственный паттерн, добавленный в реестр анализатора:

from presidio_analyzer import AnalyzerEngine, PatternRecognizer, Pattern
from presidio_analyzer.nlp_engine import NlpEngineProvider
from presidio_anonymizer import AnonymizerEngine

passport_pattern = Pattern(name="ru_passport", regex=r"\b\d{4}\s+\d{6}\b", score=0.95)
passport_recognizer = PatternRecognizer(
    supported_entity="RU_PASSPORT", patterns=[passport_pattern], supported_language="ru"
)

# движок по умолчанию знает только английский — русский включается явно
provider = NlpEngineProvider(nlp_configuration={
    "nlp_engine_name": "spacy",
    "models": [{"lang_code": "ru", "model_name": "ru_core_news_sm"}],
})
analyzer = AnalyzerEngine(nlp_engine=provider.create_engine(),
                          supported_languages=["ru"])
analyzer.registry.add_recognizer(passport_recognizer)

def sanitize_prompt_for_tracing(prompt: str) -> str:
    results = analyzer.analyze(text=prompt, language="ru")

Строчка с конфигурацией движка обязательна, и её легко пропустить. AnalyzerEngine() без аргументов ставит supported_languages = ["en"], поэтому вызов с language="ru" не найдёт ни одного распознавателя для этого языка. Ошибка неприятна тем, что выглядит не как отказ конфигурации, а как чистый текст: PII в русском тексте просто не находится, и пайплайн отдаёт «персональных данных нет». Модель ru_core_news_sm при этом надо ещё и скачать отдельно (python -m spacy download ru_core_news_sm) — в зависимостях Presidio её нет.

score=0.95 — уверенность паттерна: она участвует в итоговом решении, когда на один фрагмент претендуют несколько распознавателей.

Отдельная деталь для обратимого сценария: если маскирование должно быть обратимым (агент отвечает клиенту по имени, но в модель имя не уходит), соответствие «заглушка → оригинал» хранят в отдельном хранилище с привязкой к сессии, а не в самом промпте.

Как устроено обратимое маскирование

Хранилище соответствий — это словарь на сессию, и заполняется он инвертированным: ключ заглушка, значение оригинал. Инверсия нужна потому, что восстановление идёт от маскированного текста, а не наоборот.

self.de_identification_vault[session_id] = {v: k for k, v in combined_map.items()}

def restore_text(self, session_id: str, masked_text: str) -> str:
    for placeholder, raw_value in self.de_identification_vault.get(session_id, {}).items():
        masked_text = masked_text.replace(placeholder, raw_value)
    return masked_text

Заглушки нумеруются, а не обезличиваются. [MASKED_CARD_1] и [MASKED_CARD_2] вместо одинакового [MASKED_CARD] — иначе две разные карты в одном запросе схлопнутся в одну заглушку, и восстановить, какая где, будет невозможно. Заодно модель сохраняет способность рассуждать о них как о разных сущностях: «перевести с первой на вторую» остаётся осмысленной фразой.

Кому нужна обратимость. Прикладной сценарий — оператор второй линии поддержки: он разбирает обращение и должен видеть реальные данные, тогда как в модель они не уходили. То есть обратимость нужна не агенту, а человеку, который приходит следом.

Дешёвая параллельность. Regex по картам и вызов NER-модели по именам независимы: тяжёлый сетевой вызов запускается первым, регулярные выражения отрабатывают, пока он идёт, результат забирается в конце. Порядок операций здесь не стилистика — при пакетной обработке это разница между суммой задержек и максимумом из них.

ner_task = asyncio.create_task(self._mock_ner_analyze(text))   # сеть пошла
found_cards = re.findall(card_pattern, text)                   # считаем, пока ждём
...
found_names = await ner_task

Риск, который приходит вместе с обратимостью: хранилище соответствий — это концентрат тех самых персональных данных, ради сокрытия которых всё затевалось. Его срок жизни, шифрование и права доступа приходится решать отдельно, иначе маскирование просто переносит утечку из трейсов в новое место (см. DLP for LLM).

Обратимая или необратимая

Развилка проходит по одному вопросу: должен ли пользователь увидеть исходные данные в ответе.

Обратимая нужна там, где маскирование стоит между пользователем и его же данными: агент обрабатывает обращение клиента, значит имя и номер договора должны вернуться в ответ в исходном виде. Тогда подстановка делается на выходе по карте замен, которая живёт в пределах запроса и никуда не сохраняется — карта, пережившая запрос, сама становится утечкой.

Необратимая нужна везде, где данные уходят дальше пользователя: логи, трейсы, аналитика, обучающие наборы, отправка во внешние сервисы. Там восстановление не требуется по определению, а возможность восстановления — риск, ради устранения которого маскирование и делалось (DLP for LLM).

Практическое следствие: в одном пайплайне обычно нужны обе. Ответ пользователю проходит обратимую, а копия того же взаимодействия в наблюдаемость — необратимую, и это два разных прохода, а не один с флагом.

Связано с

  • Agent Observability — трейсы хранят промпты целиком, поэтому маскировать нужно и перед записью в них
  • DLP for LLM — Presidio как инструмент pre-prompt scanning
  • Guardrails — PII-скраб как часть Input-слоя
  • Agent Security — защита данных в общей модели угроз