Як ми навчили комп'ютер відмінювати укра ...

Як ми навчили комп'ютер відмінювати українську бюрократію: історія одного NLP-проекту

Jul 29, 2025

Мільйон користувачів щодня заповнюють електронні документи в умовному державному секторі (прим. автора - тут і далі домен обфускований, тому термінологія була замінена на відповідно обфусковану, але схожу), і постійно виникає одна проблема — як правильно написати "начальника відділу" чи "заступника директора департаменту" в потрібному відмінку? Здається відносно тривіальним завданням, але коли це треба робити тисячі разів на день, помилки неминучі - зі сторони користувача

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

Початкові виклики

image

Коли почали прикидати завдання, здавалося, що все буде просто: візьмемо готову бібліотеку для відмінювання, підключимо до API — і справа зроблена. Але швидко з'ясувалося, що з українською мовою не все так просто, особливо коли йдеться про специфічну адміністративну термінологію.

Основні технічні виклики

Складність фраз
"Тимчасово виконуючий обов'язки заступника начальника управління" — як визначити, які частини відмінювати, а які залишити незмінними?

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

Брак готових рішень
Інструментів для української мови мало, а наявні не завжди працюють із нашою специфікою, обмежена кількість інструментів, мовних корпусів ну і так далі.

Перші спроби та невдачі

image

Спочатку використали форк з pymorphy3 — популярну бібліотеку для морфологічного аналізу. Вона непогано працювала з простими словами, але складні фрази обробляла некоректно.

# Очікуваний результат
morph.parse("заступник директора").inflect({'gent'})
# Результат: "заступника директора" ✓

# Реальність з складними фразами
morph.parse("тимчасово виконуючий обов'язки заступника").inflect({'gent'})
# Результат: непередбачуваний хаос ✗

Стало зрозуміло, що потрібно розробляти власне рішення, яке враховуватиме специфіку адміністративної термінології.

Крім того тестувались речі по типу shevchenko.js, та інші, з перемінним успіхом, там в цілому десь краще, десь гірше, але все потрібне нічого з коробки не підтримувало.

Аналіз паттернів

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

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

Типи виявлених паттернів

Опис кількох із знайдених паттернів:

Тимчасові призначення:

  • "тимчасово виконуючий обов'язки"

  • "т.в.о."

  • "в.о."

Заступництво:

  • "перший заступник"

  • "заступник"

  • "головний заступник"

Складні посади через дефіс:

  • "директор-координатор"

  • "інспектор-ревізор"

Комбіновані конструкції:

  • "тимчасово виконуючий обов'язки першого заступника директора департаменту"

Кожен тип потребував окремої логіки обробки.

Архітектурне рішення

Патерн Chain of Responsibility

Застосували архітектурний патерн, де кожен "інфлектор" відповідає за конкретний тип фраз, від найбільш широкого до більш спеціалізованих із базовим випадком, який ловить загальні юзкейси, які не вписуються в паттерни, або слугує як фолбек:

TvoInflector → TempActingInflector → DeputyDirectorInflector → ActingDeputyInflector → OperatorInflector → SeveralWordsCompoundInflector → SingleWordHyphenedFirstStaticInflector → SingleWordInflector

InflectorsRunner.run() послідовно викликає can_apply() На кожному інфлекторі зупиняється на першому збігу. SingleWordInflector завжди повертає True — це явний catch-all і почасти контрольована деградація. Це загалом було свідоме рішення через певні особливості використання і домену.

Пошук форми слова відбувається в StaticInflectionRepository.get_inflection()

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

Окремо варто зупинитися на OperatorInflector: він обробляє складні посади через дефіс і є єдиним місцем, де ланцюжок рекурсивно входить сам у себе — для обробки першої частини фрази конструюється внутрішній InflectorsRunner з прапором exclude_operator=True.

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

Управління даними та оптимізація

Еволюція збереження винятків

Спочатку винятки зберігалися в Python-словниках безпосередньо в коді, по суті, в хешмапах. Це було зручно мені як стоп-геп, бо можна було швидко ітеруватись і перевіряти гіпотези, але коли їх кількість зросла, перейшли на рішення на PostgreSQL з адміністративною панеллю для редагування.

Структура бази даних:

  • Базова форма (називний відмінок) — батьківський запис

  • Всі інші відмінки — дочірні записи

  • Ефективні запити через один JOIN

Проблема продуктивності та її вирішення

image

Після переходу на PostgreSQL кількість запитів до бази стала одразу доволі істотною. Для фрази "тимчасово виконуючий обов'язки заступника директора" виконувалося 4–5 запитів, бо для частин такої посади потрібно було відмінювати її різні частини по-різному: щось відмінювалося, щось ні, в залежності від таргет-відмінку.

Архітектура бази даних

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

Для слова "директор" в базі зберігається:

  • Батьківський запис: id=1, form='base', word='директор', parent_id=NULL

  • Дочірні записи:

    • id=2, form='datv', word='директору', parent_id=1

    • id=3, form='gent', word='директора', parent_id=1

    • id=4, form='accs', word='директора', parent_id=1

Коли надходить запит на відмінювання "директор" у давальному відмінку, система виконує один JOIN-запит: знаходить базовий запис з word='директор' та form='base', потім через parent_id отримує дочірній запис з form='datv'.

Гнучкість структури дозволяє теоретично зробити будь-який запис батьківським, але на практиці це ніколи не використовується, бо логічно ми завжди прив’язуємося до базової форми слова. Всі операції CRUD жорстко орієнтовані на схему "база → відмінки". Навіть якщо в базі з'явиться аномальний запис, де form='datv' стане батьком, система його проігнорує, оскільки всі запити шукають тільки form='base'.

Обмеження поточного дизайну

Така архітектура забезпечує швидкість (один JOIN) та простоту (зрозуміла ієрархія), але створює N+1 проблему — кожне слово у фразі вимагає окремого запиту до бази. Для фрази "тимчасово виконуючий обов'язки заступника директора" генерується 4–5 окремих звернень до PostgreSQL.

Рішення — кешування в Redis:

def generate_cache_key(self, request) -> str:
    key_data = {
        'words': request.words,
        'case': request.case.value,
    }
    key_string = json.dumps(key_data, sort_keys=True, default=str)
    return f'inflect:{hashlib.md5(key_string.encode()).hexdigest()}'

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

Кешування виявилося надзвичайно ефективним через стандартизованість запитів — ті самі посади повторюються плюс-мінус регулярно.

Боротьба з некоректним вводом

Проблема змішування алфавітів

Валідація відбувається всередині StaticInflectionRepository.get_inflection() — тобто на рівні шару інфлекції, після роутингу через ланцюжок, а не на межі API. Це означає, що некоректний символ виявляється саме в момент спроби знайти форму слова.

Користувачі часто вводили тексти із змішанням кирилиці та латиниці (наприклад, "дирeктор", де 'e' — латинська літера), що порушувало роботу системи.

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

def normalize_word(word: str) -> str:
    if UKRAINIAN_UNICODE_RANGE_REGEX.match(word):
        return word
    
    # Виявляємо проблемні символи та їх позиції
    non_ukrainian_chars = []
    for match in re.finditer(ПАТТЕРН, word):
        char = match.group()
        pos = match.start()
        non_ukrainian_chars.append(f'{char} (pos: {pos})')
    
    log_warning(f'Проблемні символи: {", ".join(non_ukrainian_chars)}')
    return word  # Повертаємо без змін

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

Використання LLM для тестування

Для забезпечення стійкості до різноманітних варіантів введення використали LLM для генерації синтетичних тестових даних. Модель отримувала реальні документи та генерувала правдоподібні, але неіснуючі назви посад.

Це дозволило адаптувати систему до "креативності" користувачів — коли хтось створює власні назви посад, система принаймні намагається їх коректно відмінювати замість відмови.

Моніторинг та діагностика

image

Кожен запит логується у структурованому JSON з X-Correlation-ID наскрізь через усі пов'язані мікросервіси. На рівні DEBUG фіксуються, який інфлектор обробив кожне слово, попередження про неукраїнські символи та про фолбек на pymorphy3. OpenSearch дозволяє шукати по всьому ланцюжку за одним correlation ID. Ефективність кешу відстежується через стандартне Redis-відношення keyspace_hits / (keyspace_hits + keyspace_misses).

Поточні показники

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

Найпопулярніші типи запитів:

  • Прості посади ("директор", "начальник") — майже завжди з кешу

  • Складні фрази з тимчасовими призначеннями — частіше потребують запитів до бази

  • Некоректний ввід — відфільтровується на рівні валідації

Технічна інфраструктура

Сервіс працює як чотири Docker-контейнери: FastAPI, PostgreSQL, Redis та адміністративна панель.

Висновки

Зупинитися — важливіше, ніж безконтрольно додавати

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

Поріг міграції зі стоп-гепу

Початковий Python-словник для винятків був свідомим тимчасовим рішенням — він дозволяв швидко ітеруватися на новій предметній області. Міграція на PostgreSQL стала необхідною не тоді, коли словник виріс, а тоді, коли для додавання нового запису потрібно було розуміти граматичні правила, а не просто знати правильну форму слова.

Непередбачуваність користувачів

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

Якість без бенчмарку

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

Подальший розвиток та обмеження

Із прикладних речей, які прототипувалися і відносно працюють — спелчекер, певно, на першому місці.

Також відмінювання вимагають назви різноманітних додатків, супровідних документів та інших суміжних сутностей — зараз система в цілому з цим може впоратися, але дизайнилася вона під інше.

Головний принцип, який варто зберегти: система має дизайнитися під спеціалізовані задачі, а не намагатися охопити всю мову через один сервіс. Зробити універсальний "комбайн" технічно можливо — але це інша архітектура, інше покриття тестами і інші точки відмови. Для NLP на відносно малочастотній мові вузька спеціалізація сервісу — це радше необхідність, ніж примха.

Gefällt dir dieser Beitrag?

Kaufe Strange Rin einen token

2 Kommentare

Mehr von Strange Rin