Разбираем производительность морфологических анализаторов для русскоязычных текстов в Python‑среде. Расскажем, почему библиотеки семейства PyMorphy работают из рук вон хорошо так медленно и как интеграция нативного C++ ядра libmorph/rus решает проблему производительности high‑load систем.


Введение

Фактическим стандартом морфологического анализатора для русскоязычных текстов в Python‑среде стали библиотеки семейства PyMorphy (PyMorphy2, PyMorphy3). Основными причинами стали популярность Python среди инженеров ML и низкий порог входа. Но у них есть критический недостаток: они катастрофически медленные.

Из‑за выполнения кода интерпретатором и множественных аллокаций памяти при анализе каждого слова этап лемматизации текста часто становится бутылочным горлышком всего NLP‑пайплайна, заставляя тратить ресурсы на масштабирование там, где без этого можно и обойтись.

Традиционным решением для высоконагруженных систем в таких случаях становится перенос лингвистического ядра на компилируемые языки. В этой статье речь пойдет о создании специализированного Python‑расширения на базе нативной библиотеки libmorph/rus, реализованной на C++. Она появилась в конце 90-х годов прошлого века, была построена на базе Великого словаря А. А. Зализняка для контроля орфографии и грамматики в составе пакета Пропись, работала с Microsoft Word и кучей других офисных пакетоы, а потом 30 лет развивалась в составе Апорта, Рамблера и украинской Меты как как в технологическом отношении, так и в плане вычитки словаря и наращивания словарной базы современной лексикой.

Мы напомним о внутренней организации библиотеки, покажем примеры её использования в Python и приведём результаты профилирования производительности.

Архитектура и организация данных в памяти

При проектировании критичных к задержкам (low‑latency) систем доминируют два фактора: компактность представления словаря в ОЗУ для эффективного использования кэша процессора и отказ от динамического выделения памяти при выполнении операций. Лингвистического ядра это касается в полной мере, так как оно выполняет по вызову на каждые 6–7 байт плоского текста, или почти 150,000 актов лемматизации на мегабайт.

Сравнительный анализ подходов

PyMorphy3

pyrusmorph
(libmorph binding)

Структура словаря

Направленный ациклический граф слов (DAWG).

Сериализованные вырожденные радиксные деревья (Radix Trees). Словарь представляет собой непрерывный блок размером около 2 мегабайт.

Управление памятью

Множественные аллокации кучи, в том числе для результатов.

Stack only/zero alloc. Поиск и разбор выполняются в фиксированных буферах на стеке.

Словари

OpenCorpora

Пополненный словарь на базе Зализняка.

Идентификация

Строковые или динамические представления

Фиксированные числовые идентификаторы лексем и форм слов.

Главное ограничение библиотек PyMorphy вытекает из самой природы рантайма Python. Порождая список омонимов, интерпретатор инстанцирует множество мелких объектов. Это приводит к деградации кэша процессора (L1/L2 cache misses) и тратит дополнительные ресурсы на работу сборщика мусора.

В libmorph/rus анализ слова сводится к последовательному проходу по таблицам радиксного дерева по статической неизменной памяти «снизу вверх» с байтовыми смещениями, а данные представлены однобайтной кодировкой (в потрохах — Windows-1251 как самая очевидная и некогда наглядная). Все промежуточные структуры минимального размера размещены на стеке.

Слова явным образом пронумерованы прямо в словнике, так что идентификацию можно свести к целочисленным значениям. Это делает моментальным и обратный доступ в словарь — для синтеза форм слов. Эти же ID можно использовать и в сторонних системах, например, в инвертированных индексах поискового движка, избавившись от поддержки вторичных словарей системы, то есть избыточной косвенности.

Тесты производительности

Для оценки был подготовлен изолированный стенд. Выполнялось сравнительное тестирование лемматизации (отождествления со словарём) форм слов плоского текста в кодировке utf-8 с приведением к нормальной (словарной) форме и извлечением набора морфологических признаков исходной формы слова.

  • Исходный текст: «Большая книга занимательных фактов в вопросах и ответах» А. П. Кондрашова.

  • Объём данных: 4+ мегабайта текста (около 2 миллионов символов) с вкраплениями латиницы, чисел и естественной пунктуацией.

  • Всего слов: 405000, в том числе 311 775 кириллических и 76 893 символа пунктуации.

Тестирование проводилось в однопоточном режиме на одном ядре одного и того же процессора для оценки эффективности алгоритма. Предварительно текст разбивался на слова, и в расчёт при измерении скорости принималась только последовательная лемматизация готовой кириллицы.

pymorphy3

pyrusmorph

Затраченное время, сек.

13.7

0.5

Скорость, слов в сек.

22 800

621 000

Опознано слов

278 519

278 221

Для сравнения, тот же тест нативным C++‑приложением с использованием libmorph/rus показывает производителность более 1 300 000 слов в секунду. Это позволяет сделать интересный вывод: сама python‑обёртка вокруг C++‑ядра уже даёт двукратное замедление.

В целом же использование сериализованных префиксных деревьев на C++ в связке с отказом от динамических аллокаций памяти обеспечивает ускорение этапа морфологического анализа в 25–30 раз. При этом пиковое потребление памяти процессом остается стабильным и детерминированным, не превышая фактического объема словаря — двух мегабайт, так как его образ просто отображается в память при загрузке библиотеки. В многопоточном окружении расход памяти не растёт — все внутренние данные libmorph при работе используются в режиме read‑only.

Анализ расхождений

Интересно, что pymorphy3 опознал и лемматизировал на три сотни слов больше по сравнению с libmorph. Мы выделили эти слова и присмотрелись получше.

Уникальных нераспознанных слов у pymorphy3 оказалось 114, у pyrusmorph — 306. Из них общих — 41, среди которых осмысленными представились только Ио (один из спутников Юпитера, ближайший к нему), Поликсо (жена родосского царя Тлеполема, греч.), Ра (египетский бог) и Фау (название немецкой буквы V и ракеты Вернера фон Брауна).

Остальное ушло в разницу. Вот примеры явных пропусков (слов, которые не распознались зря):

Пропуски pymorphy3: ИМТ, Антиопы, Гоморры, Денёв, Деций, Полидекта, Полифем, регии, Транссиб, …

Пропуски pyrusmorph: Александро, Арахна, аргоссцы, Бартоломеу, благоуспешно, Гавайи, Галилео, Гвидо, гинкго, ГОЭЛРО, гуппи, Данаей, Дафна, Джорджио, Джулиано, дигидрогенмонооксидом, Захарченко, коринфцы, Марио, Медео, Монгольфье, принародно, Сорренто, табуировано,...

В обоих случаях это аббревиатуры, древнегреческие имена, «новая мифология» и экзотическая география. Примечательно, что pyrusmorph также не знает о легендарном полковнике Захарченко и его квартире, набитой наличными.

Также мы изучили ложные срабатывания — слова, которые не должны распознаваться как склоняемые или спрягаемые формы, явно таковыми не являются и не должны распознаваться как склоняемые‑спрягаемые, однако были ошибочно приняты системами:

pymorphy3: АКТҐ (Ґ из украинского алфавита), апэреа, бактерио, бо, вениаминитя, взрыво, Дыхсу, Кпк, навсего, ЬйпйАее, …

pyrusmorph: Гус, ГэВ, кёй, коу, СсС, умм, Хоу, Ээт

Некоторые из этих слов — например, Гус в pyrusmorph — взялись не из вероятностного анализатора, а явно прописаны в словаре (имеется ввиду тренер по футболу Гус Хиддинг).

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

Использование pyrusmorph в Python

Библиотека pyrusmorph построена на основе библиотеки libmorph/rus и пакета pybind11, что обеспечивает Python‑разработчику прямой доступ ко всему функционалу оригинальной C++‑системы: словарной (mlmaruWc) лемматизации, эвристическому анализу (mlfaruWc), генерации словоформ и итераторам по словарю с поддержкой символов шаблона *?.

Ниже пример сценария использования библиотеки:

  • лемматизация (получение нормальной формы слова);

  • печать частей речи в человекочитаемом виде;

  • синтез форм слова;

  • работа с эвристическими моделями.

import pyrusmorph

class MorphFormatter:
    """Отображатор технической части речи в человекочитаемую, по Зализнаку"""
    
    PART_OF_SPEECH_MAP = [
        '?', 'нсв', 'нсв_нп', 'св', 'св_нп', 'св-нсв', 'св-нсв_нп', 'м', 'мо', 'м//мо', 
        'м//с', 'мо', 'мо', 'ж', 'жо', 'ж//жо', 'с', 'со', 'с//со', 'м//ж', 'мо-жо', 
        'м//с', 'мо//со', 'ж//с', 'мн. м//ж', 'п', 'п', 'п', 'мс-п', 'мс', 'мсм', 
        'мсж', 'мсс', 'числ', 'числ._2', 'числ._с', 'числ.-п', 'и', 'им', 'иж', 'ом', 
        'ож', 'ф', 'г', 'гм', 'гж', 'гс', 'мн._от_г', 'вводн.', 'межд.', 'предик.', 
        'предл.', 'союз', 'союз_соч.', 'част.', 'н', 'сокр._сущ.', 'сокр._прил.', 
        'сокр._вводн.', 'сравн.', 'АБ', 'аб', '#1', '#2', '??'
    ]

    @classmethod
    def get_pos_name(cls, wd_info: int) -> str:
        """Извлекает строку человеческой части речи по значению технической."""
        idx = wd_info & 0x3f
        if idx < len(cls.PART_OF_SPEECH_MAP):
            return cls.PART_OF_SPEECH_MAP[idx]
        return '??'


def demo_lemmatize(morph_analyzer, word: str):
    """Лемматизация и печать омонимов"""
    print(f"\nСлово: '{word}'")
    lemmas = morph_analyzer.lemmatize(word, 0)
    
    if not lemmas:
        print("  -> Не найдено в словаре")
        return

    for lemma in lemmas:
        print(f"  ID лексемы: {lemma.lexid:<6d} | Нормальная форма: {lemma.lemma}")
        for gram in lemma.grams:
            pos = MorphFormatter.get_pos_name(gram.wdInfo)
            print(f"    [Граммема] Часть речи: {pos:<10} | ID формы: {gram.idForm}")


def demo_build_form(morph_analyzer, lexeme_id: int, form_id: int):
    """Синтез формы слова"""
    print(f"\nЛексема {lexeme_id}, форма {form_id}:")
    forms = morph_analyzer.build_form(lexeme_id, form_id)
    for form in forms:
        print(f"  -> {form}")


def display_matched_forms(lexeme_id: int, form_list):
    """Callback для find_match"""
    print(f"  ID лексемы: {lexeme_id:<6d}")
    for matched_form in form_list:
        print(f"    Форма {matched_form.form:<2d}: {matched_form.text}")


def demo_fuzzy_analysis(fuzzy_analyzer, word: str):
    """Вероятностная лемматизация"""
    print(f"\nСлово: '{word}'")
    stem_list = fuzzy_analyzer.lemmatize(word, 0)
    
    if not stem_list:
        print("  -> Не удалось построить гипотезы")
        return

    for stem in stem_list:
        # Визуально разделяем основу и флексию
        stem_form = f"{stem.lemma[:stem.lStem]}|{stem.lemma[stem.lStem:]}"
        print(f"  prob: {stem.fProb:<6.4f} | stem: {stem_form:<15} | class: {stem.clsId}")
        for gram in stem.grams:
            pos = MorphFormatter.get_pos_name(gram.wdInfo)
            print(f"    Часть речи: {pos:<10} | id формы: {gram.idForm}")


def demo_fuzzy_build_form(fuzzy_analyzer, stem: str, class_id: int, form_id: int):
    """Синтез формы слова для основы"""
    print(f"\nСинтез формы ('{stem}', class: {class_id}, form id: {form_id}):")
    forms = fuzzy_analyzer.build_form(stem, class_id, form_id)
    if not forms:
        print("  -> Не удалось построить форму")
        return
    for form in forms:
        print(f"  -> {form}")


if __name__ == "__main__":
    # Init
    morph = pyrusmorph.mlmaruWc()
    fuzzy = pyrusmorph.mlfaruWc()

    print("=== 1. lemmatize ===")
    demo_lemmatize(morph, 'программирование')
    demo_lemmatize(morph, 'Киев')
    demo_lemmatize(morph, 'простой')

    print("\n=== 2. build_form ===")
    demo_build_form(morph, 182067, 5)

    print("\n=== 3. find_match ===")
    print("Поиск по шаблону 'до?ал?д':")
    morph.find_match("до?ал?д", display_matched_forms)
    
    print("\nПоиск по шаблону 'ко*ро*ва':")
    morph.find_match("ко*ро*ва", display_matched_forms)

    print("\n=== 4. stematize ===")
    demo_fuzzy_analysis(fuzzy, "командировочка")
    demo_fuzzy_analysis(fuzzy, "нейрослоп")
    demo_fuzzy_analysis(fuzzy, "вундервафля")

    print("\n=== 5. build form by stem and class ===")
    demo_fuzzy_build_form(fuzzy, "командировоч", 14, 5)

(пример кода отформатирован и причёсан ИИ в соответствии с традициями сообщества, без свойственных мне и C++ стилистических выкрутасов)

Заключение

Разработка поисковых и NLP‑систем часто буксует на неэффективных алгоритмах нижнего уровня, требуя масштабирования железа там, где без этого можно вполне обойтись. Использование интерпретируемого кода для базовых операций на больших объёмах данных представляется неразумной тратой ресурсов.

Библиотека pyrusmorph доказывает, что в экосистеме Python можно работать с промышленной производительностью уровня C++ без усложнения процессов развёртывания.

Установка для Python:

pip install pyrusmorph

Репозиторий проекта

Лицензия: MIT

Подробнее об истории развития лингвистической библиотеки можно почитать в статье Тридцать лет libmorph.

Комментарии (6)


  1. artemmb
    26.10.2026 06:07

    Я попросил нейронку перевести проект PyMorphy на с++. Скорость получилась 900 тыс слов в сек. При этом скорость на python совпала с вашей(21 тыс слов /сек ). У вас быстрее 1,2 млн слов в сек на моей машине. Все мерилось в однопоточке. У меня возник вопрос: зачем эти либы нужны? Хорошо работающей либы разбора предложений русского языка на части я не видел.


    1. Keva Автор
      26.10.2026 06:07

      Можете поконкретнее сформулировать свой вопрос: что вы имеете ввиду под "либой разбора предложений русского языка на части"?

      Вы про синтаксический анализ? Или про что?


      1. artemmb
        26.10.2026 06:07

        Да. Про синтаксический анализ.


        1. Keva Автор
          26.10.2026 06:07

          У меня есть решение, но оно, увы, частичное: не покрывает ни эллипсиса, ни перестановок.

          Граф для "Я кукушку вчера живую видел" или "Вы написали мне два сообщения, а я вам - одно" не сойдётся.

          Зато быстро :)

          Хотя в задачах снятия омонимии в поисковых движках эта штука уверенно даёт 10-15% сокращения объёма индекса при нулевых потерях полноты.


          1. artemmb
            26.10.2026 06:07

            Обидно иметь возможность анализровать слова со скоростью около 1 млн слов в секунду. А анализ синтаксиса в лучшем случае через нейронки 1000 слов в секунду.


            1. Keva Автор
              26.10.2026 06:07

              Ну, поверхностный анализ по дереву составляющих, о котором я говорю, конечно же, тормозит индексацию, но не настолько. Примерно процентов на 20-30 при разумной сегментации входного текста - на предложения, но с ограничением в сотню слов.

              То есть, если в словах в секунду, то где-то до 600-700 тысяч.

              Другое дело, что даже при таких скоростях это становится лимитирующей фазой при полнотекстовом индексировании.

              И если чистую лемматизацию ещё можно обойти, сгруппировав операции и раздав результаты по позициям в тексте, и выжать из многоядерной системы где-то 60 мег индексирования плоского текста в секунду, особенно на больших докумнетах, то применение синтаксиса исключает такие рантайм-группировки, и мы получим около 10 мегабайт в секунду, то есть почти так же фиговенько, как в случае Elastic.

              Upd: правда, Elastic и синтаксиса не гоняет, да.