RAG · Retrieval-augmented generation · урок 3 · 13 мин

Normalization: цифры, письменности, Unicode

Normalization: numbers, scripts, Unicode

ТемыRAGPython

Зачем

После урока ты объяснишь, почему правильный документ бывает невидим для поиска из-за цифр, разделителей, форм Unicode и смешения письменностей, и напишешь функцию normalization на 10 строк. В вакансиях это иногда называют «data regularization», и уточняющий вопрос о смысле этого слова сам по себе показывает зрелость.

Что такое normalization и чем она не является

Normalization (нормализация) — приведение одного и того же к одному виду, чтобы сравнение, поиск и сопоставление работали. Её путают с sanitization (санитарной очисткой): sanitization убирает опасное (персональные данные, секреты, скрытые инструкции), normalization ничего не убирает по смыслу, а выравнивает запись.

Слово «data regularization» в описании вакансии — не стандартный термин. В конвейере данных оно почти наверняка означает normalization. В машинном обучении regularization значит другое: приёмы, которые мешают модели заучить обучающие данные (штрафы L1 и L2 на веса, dropout, early stopping). Сильный кандидат спрашивает: «Which meaning do you have in mind?» — и отвечает на оба.

Цифры и разделители

Одно число пишут по-разному в разных письменностях. Арабско-индийские цифры ٠١٢٣٤٥٦٧٨٩ (U+0660–U+0669), их персидский вариант ۰۱۲۳۴۵۶۷۸۹ (U+06F0–U+06F9), деванагари ०१२३… — это разные символы Unicode, хотя значат то же, что 0–9. Документ с «١٥٠٠» и запрос «1500» для поиска по словам — разные строки. Это тихий промах: ошибки нет, документа в выдаче тоже нет.

Интересная деталь Python: int('١٥٠٠') вернёт 1500 — функция понимает любые десятичные цифры Unicode. Но строковое сравнение, BM25 и регулярное выражение \d в чужом движке этого не делают, поэтому цифры в тексте приводят к 0–9 явно.

Разделители тысяч ещё хитрее. «1,500», «1 500» (с обычным, неразрывным или узким неразрывным пробелом U+202F), «1.500», «١٬٥٠٠» (арабский разделитель U+066C). Хуже всего точка и запятая: «1.500» по-английски — полтора, по-немецки — тысяча пятьсот; «1,5» по-русски — полтора. Угадывать нельзя. Правило: локаль документа (locale — правила записи чисел и дат для языка и страны) — поле metadata (служебных сведений о документе), и разбор чисел с точкой и запятой идёт по ней. Однозначные разделители (U+066C, узкие пробелы между группами из трёх цифр) можно убирать всегда.

То же с датами и суммами. «03/04/2026» — 3 апреля или 4 марта в зависимости от страны; бывают даты по другому календарю (например, хиджры) и разные часовые пояса. «AED 1,500», «1500 dirhams» и «1 500 ₸» для поиска удобнее хранить ещё и в разобранном виде: сумма числом и код валюты отдельными полями metadata. Тогда фильтр «комиссии больше 1 000» делает код, а не текстовое совпадение.

Unicode: NFC, NFD, NFKC

Один и тот же видимый символ можно записать одной кодовой точкой или двумя. «é» — это либо U+00E9, либо «e» плюс комбинируемое ударение U+0301. На экране одинаково, а == вернёт False, и длины строк будут 4 и 5 для «Café». Unicode определяет формы нормализации (стандарт UAX #15):

Форма Что делает Когда
NFC собирает символ в одну точку, где можно по умолчанию для хранения и сравнения
NFD раскладывает на базовую букву и знаки так приходят имена файлов с некоторых систем
NFKC NFC плюс замена «совместимых» символов для ключа поиска, с осторожностью

NFKC превращает лигатуру «fi» в «fi», полноширинные «1500» в «1500», римскую цифру «Ⅰ» в «I». Но она же делает из «x²» строку «x2», а из «½» — «1⁄2». Сам стандарт предупреждает: формы KC и KD нельзя слепо применять к произвольному тексту, они стирают различия, важные для смысла. Важно и обратное: NFKC не трогает арабско-индийские цифры — их переводят отдельной таблицей.

Ещё два шага того же рода: casefold() (регистр, агрессивнее, чем lower()) и свёртка вариантов букв, своя для каждого языка: «ё» → «е» в русском, варианты алифа и диакритика в арабском. Свёртка помогает поиску, но тоже стирает различия, поэтому её проверяют замером.

Смешанные письменности

Кириллическая «А» и латинская «A» выглядят одинаково, но это разные символы. Код продукта «АF-2026», набранный с русской раскладкой, не совпадёт с «AF-2026» ни в BM25 (поиске по словам), ни в базе. Имена в латинской транслитерации пишут по-разному («Mohammed», «Muhammad»), а запросы бывают на двух языках в одной фразе.

Стандарт UTS #39 описывает mixed-script detection (обнаружение строки из нескольких письменностей) и confusables (похожие символы) с функцией skeleton (скелет строки: каждый символ заменён типовым двойником). Важная оговорка стандарта: skeleton — не normalization, а внутренний инструмент проверки, и хранить его как ключ навсегда нельзя: таблицы меняются с версиями Unicode. Практический вывод: для кодов и идентификаторов проверяй, что строка в одной письменности, и отклоняй или исправляй смесь при вводе.

Правило двух сторон и функция на 10 строк

Главное правило: одна и та же функция normalize() на ingestion (приёме документов в систему) и на запросе. Нормализовал документы, но не запрос — поиск промахивается молча. Версию функции пишут в metadata индекса: изменили normalization — переиндексируй, иначе стороны разойдутся.

Второе правило: нормализованная копия — только для поиска. Оригинальный текст хранится рядом и идёт в ответ и в citation (ссылку на источник), а проверка чисел в ответе сравнивает с оригиналом.

import re, unicodedata

DIGITS = str.maketrans('٠١٢٣٤٥٦٧٨٩۰۱۲۳۴۵۶۷۸۹', '0123456789' * 2)
GROUP = re.compile(r'(?<=\d)[٬    ](?=\d{3}\b)')

def normalize(s: str) -> str:
    s = s.translate(DIGITS)          # арабско-индийские и персидские цифры
    s = GROUP.sub('', s)             # ٬ и пробелы между группами цифр
    s = unicodedata.normalize('NFKC', s)
    return s.casefold()

assert normalize('١٬٥٠٠') == normalize('1 500') == '1500'

Порядок шагов важен. Если сначала вызвать NFKC, узкий неразрывный пробел превратится в обычный, и правило разделителей уже не отличит его от пробела между словами.

Когда normalization вредит

Та же функция склеит «раздел 4 500 AED» в «раздел 4500 AED» — номер раздела слился с суммой. NFKC превратит «м²» в «м2», свёртка диакритики сольёт разные слова. Поэтому normalization проверяют замером: 10–20 вопросов на каждом языке с цифрами в обеих формах, recall (доля найденных верных документов) до и после, отдельно по языку.

Схема

Термины · Key terms

normalization
приведение одного и того же к одному виду для поиска и сравнения
sanitization
удаление опасного: персональных данных, секретов, скрытых инструкций
regularization (ML)
приёмы против заучивания данных моделью: L1, L2, dropout
code point
кодовая точка, номер символа в Unicode
NFC / NFD
собранная и разложенная форма одного символа
NFKC
NFC плюс замена совместимых символов; теряет часть различий
casefold
приведение регистра для сравнения
locale
локаль: правила записи чисел и дат для языка и страны
mixed-script
строка из символов нескольких письменностей
confusables
разные символы, которые выглядят одинаково
citation
ссылка на источник утверждения в ответе

Сделай сам

Скопируй функцию из урока и допиши три проверки через assert: «١٥٠٠» равно «1500»; «Café», записанное через комбинируемое ударение, равно «Café» одной точкой; «АF-2026» с кириллической «А» не равно «AF-2026». Добавь функцию, которая для строки печатает имена письменностей её букв через unicodedata.name(), и проверь её на «АF-2026».

Обсуди с тьютором: покажи код и вывод и спроси, стоит ли исправлять кириллическую «А» в кодах автоматически или лучше отклонять такой ввод — и где в pipeline (конвейере обработки) это делать.

Проверь себя · ответ под вопросом

1Что сломается, если нормализовать документы, но не запрос?
Ключи поиска на двух сторонах разойдутся: в индексе «1500», в запросе «١٥٠٠». Поиск по словам промахнётся молча, без ошибки. Поэтому одна функция на обеих сторонах и её версия в metadata индекса.
2Какие два значения у слова regularization, и что ты спросишь собеседующего?
В конвейере данных — почти наверняка normalization: цифры, Unicode, даты, суммы. В машинном обучении — приёмы против заучивания (L1, L2, dropout, early stopping). Спрошу, какой смысл имеется в виду, и коротко отвечу на оба.
3Приведи случай, когда нормализация вредит: стирает различие, важное для смысла.
NFKC делает из «x²» строку «x2», из «м²» — «м2». Удаление пробелов-разделителей склеивает номер раздела с суммой: «раздел 4 500» → «4500». Свёртка диакритики может слить разные слова; поэтому оригинал хранится рядом, а эффект меряют recall до и после.

Interview questions · US / UK

1The job description says "data regularization". What would you assume it means, and what would you build?
Strong answer: ask which meaning; in a pipeline it means normalization — digits across scripts, thousands separators by locale, Unicode NFC/NFKC, dates and currencies; one function at ingestion and query time, versioned; ML meaning is L1/L2/dropout.
2Users search "1500" and miss documents that contain the same amount. How do you debug it?
Strong answer: search the index directly for both forms; inspect code points of the stored text; check digits in another script, separators, NFD vs NFC; check that query and documents use the same normalize version; fix, re-index, measure recall per language.
3Scenario: someone proposes applying NFKC and stripping all diacritics on every document. Do you agree?
Strong answer: only for the search key, never for stored or displayed text; NFKC and folding erase meaningful distinctions (x², diacritics); keep the original for answers and citations; measure retrieval before and after per language.

Ключевые фразы · Say it aloud

Материалы