Logo Craft Homelab Docs Нейросети Хостинг Обучение Контакты
Буква «ё» в Python и PostgreSQL: NFC, unaccent и омоглифы Трендовые github проекты в нашем телеграм канале. Подпишись →
11 ноября 2026 г.

Две точки над «е»: где строки ломаются в проде

Каждый раз, когда код сравнивает строки, кто-то решает, какие различия считать существенными. Для поиска удобно, чтобы запрос «береза» находил «берёзу». Для логинов, адресов и других идентификаторов та же логика опасна: система начинает считать одинаковыми разные значения. Буква «ё» хорошо показывает весь набор проблем сразу — от длины строки в Python до функциональных индексов в PostgreSQL и фишинговых логинов.

Одна «ё» — две последовательности кодовых точек

В Unicode буква «ё» записывается двумя способами:

  • одним символом U+0451;
  • парой: кириллическая «е» (U+0435) плюс комбинируемая трема (U+0308).

На экране разницы нет, в UTF-8 это разные байты. Разложенная форма появляется в реальных данных, например при импорте имён файлов: HFS+ исторически приводила имена к канонически разложенной форме. APFS хранит имя в исходной нормализации, а для поиска и сравнения считает хеш нормализованной формы. Если строки попадают в приложение из разных источников без согласования, в базе окажутся оба варианта.

Проверка в Python:

import sys
import unicodedata as ud

print("Python:", sys.version)
print("Unicode database:", ud.unidata_version)

samples = {
    "precomposed": "ё",
    "decomposed": "ё",
    "plain_e": "е",
}

for name, value in samples.items():
    print(name, [f"U+{ord(ch):04X}" for ch in value])
    print("  NFC:", [f"U+{ord(ch):04X}" for ch in ud.normalize("NFC", value)])
    print("  NFD:", [f"U+{ord(ch):04X}" for ch in ud.normalize("NFD", value)])

Обе записи «ё» под NFC превращаются в ['U+0451'], под NFD — в ['U+0435', 'U+0308']. Это канонические формы из UAX #15.

Отсюда два практических следствия:

  1. len() в Python считает кодовые точки. Графемные кластеры (UAX #29), то есть то, что пользователь видит как один символ, он не учитывает. Для U+0451 получится 1, для разложенной формы — 2. Срез [:1] по разложенной строке оставит голую «е» и выбросит точки. Обрезка ника или превью до N символов по такой строке даёт искажённый текст.
  2. Нормализация Unicode «ё» в «е» не превращает. Простая U+0435 остаётся собой и в NFC, и в NFD. NFC/NFD согласуют две записи одной и той же буквы, а «е» и «ё» для них разные буквы. Слияние «е» и «ё» происходит только тогда, когда вы сами вводите такое правило.

Регулярка, которая не пропускает «ёж»

Отдельная ловушка для валидаторов:

import re
print(re.fullmatch(r"[а-яА-Я]+", "ёж"))  # None

ё (U+0451) и Ё (U+0401) лежат вне диапазонов U+0430–U+044F и U+0410–U+042F. Форма регистрации с таким паттерном отклонит Алёну и Фёдора. Варианты исправления: явно дописать [а-яА-ЯёЁ] или описать допустимые символы через Unicode-свойства. Поведение других regex-движков стоит проверять отдельно, переносить этот вывод на них без теста нельзя.

unaccent в PostgreSQL 18: полезная потеря информации

Для поиска склейка «е/ё» часто полезна: пользователь вводит «все», в документе «всё». Цена — компромисс между полнотой и точностью. Полнота растёт, а пары вроде «мел/мёл» или «совершенный/совершённый» начинают совпадать.

В PostgreSQL 18 (ветка REL_18_STABLE) файл contrib/unaccent/unaccent.rules содержит правила Ё → Е и ё → е. Там же есть односимвольная запись для U+0308 без замены — такие записи удаляют знак. В итоге стандартный unaccent сводит к «е» обе формы «ё», даже без предварительного NFC.

CREATE EXTENSION IF NOT EXISTS unaccent;

SELECT unaccent('ё') AS from_yo,
       unaccent('е') AS from_ye,
       unaccent('ё') = unaccent('е') AS equal_after_unaccent;

SELECT unaccent('мёл') = unaccent('мел') AS semantic_collision;

Оба сравнения дают true. unaccent(U&'\0435\0308') тоже возвращает 'е'.

Ограничения, о которых стоит знать заранее:

  • Само расширение ничего не меняет в существующих таблицах и запросах. Это фильтрующий словарь и функция, которые подключают явно.
  • Функции unaccent объявлены как STABLE: результат зависит от словаря и внешнего файла правил. В выражении функционального индекса нужна IMMUTABLE-функция, поэтому напрямую unaccent туда не встанет. Обёртка, помеченная IMMUTABLE без оговорок, рискованна: если файл правил поменяется, индекс тихо разойдётся с фактическим результатом функции.
  • Одноаргументная unaccent(text) ищет словарь unaccent в своей схеме. Если расширение установлено в нестандартную схему, имена нужно квалифицировать.

Пример поисковой конфигурации:

CREATE TEXT SEARCH CONFIGURATION article_unaccent (COPY = pg_catalog.simple);

ALTER TEXT SEARCH CONFIGURATION article_unaccent
    ALTER MAPPING FOR hword, hword_part, word
    WITH unaccent, pg_catalog.simple;

SELECT to_tsvector('article_unaccent', 'берёза')
       @@ plainto_tsquery('article_unaccent', 'береза') AS found;

SELECT to_tsvector('article_unaccent', 'мёл')
       @@ plainto_tsquery('article_unaccent', 'мел') AS semantic_collision;

Основа здесь simple, потому что у pg_catalog.russian своя особенность: в стоп-листе russian.stop есть «все», но нет «всё», а Snowball проверяет стоп-лист до стемминга. Пара «все/всё» в стандартной русской конфигурации обрабатывается несимметрично. С simple видно поведение unaccent без влияния стеммера и стоп-слов; в боевой конфигурации эту асимметрию придётся учитывать.

Омоглифы: обратная проблема

С «ё» система намеренно склеивает разные символы. С омоглифами наоборот: компьютер видит разные строки, человек — одинаковые. paypal и pаypаl с двумя кириллическими а (U+0430) в большинстве шрифтов неотличимы. UTS #39 приводит эту пару как классический пример mixed-script confusables.

Расчёт на то, что от подмены защитит NFKC, не оправдывается:

import unicodedata as ud

latin = "paypal"
mixed = "pаypаl"
for form in ("NFC", "NFD", "NFKC", "NFKD"):
    print(form, ud.normalize(form, latin) == ud.normalize(form, mixed))

Все четыре формы дают False. NFKC/NFKD сводят лигатуры и римские цифры к общей форме, но латинскую a в кириллическую а не превращают. unaccent здесь тоже бесполезен: он применяет заданные замены диакритики и визуальное сходство не ищет.

Смешение кириллицы и латиницы бывает легитимным, поэтому тотальный запрет не подходит. UTS #39 предлагает два механизма:

  • Restriction Levels — для каждого типа идентификатора задаются допустимые скрипты и их сочетания: Single Script, Highly Restrictive, Moderately Restrictive.
  • Confusable skeleton — внутренняя форма строки для сравнения идентификаторов на визуальное сходство.

Skeleton — только сигнал риска. Его нельзя показывать пользователю и нельзя хранить вместо исходного логина. Таблицы confusables меняются между версиями Unicode: если skeleton кешируется в базе, фиксируйте версию алгоритма и пересчитывайте значения при обновлении.

Четыре разных представления одной строки

Одной normalize() на всё не хватит. Удобнее явно разделить назначения:

  • Оригинал — то, что ввёл пользователь, без поисковых преобразований. Источник для отображения и аудита.
  • Каноническая форма — NFC там, где нужно согласовать U+0451 и U+0435 U+0308. «е» и «ё» остаются разными.
  • Поисковый ключ — отдельное поле или индекс по правилам поиска: unaccent, NFC + unaccent, регистр, стемминг. Распространённый приём — индексировать нормализованное и точное представления вместе: первое расширяет выдачу, второе даёт точные совпадения и повышает их в ранжировании.
  • Проверка идентификатора — Restriction Level и confusables при регистрации и смене имени. Правила допустимых символов, нормализации и уникальности должны совпадать на регистрации, входе и проверке дублей.

Как ведут себя пары строк по разным критериям:

Пара==После NFCunaccent / NFC + unaccentUTS #39
U+0451 и U+0435 U+0308falsetruetrue—
«ё» и «е»falsefalsetrue—
«мёл» и «мел»falsefalsetrue—
paypal и pаypаlfalsefalsefalseskeleton совпадает

Главное правило из таблицы: поисковый ключ никогда не используется как ключ идентичности, а skeleton — как нормализованное имя. Если unaccent-колонка случайно попадёт в UNIQUE-ограничение, «Алёна» и «Алена» станут одним пользователем. Если сравнивать логины только через NFKC, pаypаl зарегистрируется рядом с paypal.