Logo Craft Homelab Docs Нейросети Хостинг Обучение Контакты
pyrusmorph: лемматизация русского текста в Python в 27 раз быстрее pymorphy3 Трендовые github проекты в нашем телеграм канале. Подпишись →
25 октября 2026 г.

621 тысяча слов в секунду: C++-морфология для русского NLP-пайплайна

В Python-проектах, которые работают с русским текстом, морфологию почти всегда отдают pymorphy2 или pymorphy3. Порог входа низкий, ставится одной командой, словарь OpenCorpora покрывает большинство задач. Проблемы начинаются на объёме: лемматизация вызывается на каждое слово, а слово в плоском тексте встречается примерно раз в 6–7 байт. Это около 150 000 вызовов анализатора на мегабайт текста, и в поисковом индексаторе или ML-препроцессинге этот этап часто оказывается самым медленным во всём пайплайне.

pyrusmorph — Python-расширение поверх libmorph/rus, морфологической библиотеки на C++. Её история начинается в конце 1990-х: она строилась на словаре А. А. Зализняка для проверки орфографии в пакете «Пропись» (интеграция с Microsoft Word и другими офисными пакетами), затем 30 лет развивалась внутри поисковиков Апорт, Рамблер и украинской Меты. За это время словарь вычитывали и пополняли современной лексикой.

Откуда берётся медлительность pymorphy

Словарь pymorphy3 хранится как DAWG — направленный ациклический граф слов. Сам по себе граф компактен, но каждый разбор в Python порождает пачку мелких объектов: список омонимов, теги, промежуточные строки. Каждый объект — аллокация в куче. На сотнях тысяч слов это даёт промахи по кэшам L1/L2 и лишнюю работу сборщику мусора. Ограничение сидит в рантайме Python, поэтому оптимизировать его внутри чистого Python-кода почти некуда.

Как организован libmorph/rus

У low-latency систем две главные заботы: словарь должен быть компактным, чтобы помещаться в кэш процессора, а на горячем пути не должно быть динамического выделения памяти. libmorph построен вокруг обоих требований.

pymorphy3pyrusmorph (libmorph)
Структура словаряDAWGсериализованные вырожденные радиксные деревья, один непрерывный блок ~2 МБ
Память при разборемножество аллокаций в куче, включая результатытолько стек, фиксированные буферы, zero alloc
СловарьOpenCorporaрасширенный словарь на базе Зализняка
Идентификациястроки и динамические представленияфиксированные числовые ID лексем и форм

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

Образ словаря отображается в память при загрузке библиотеки, поэтому пиковое потребление процесса держится на уровне его размера — около 2 МБ — и предсказуемо. Все внутренние данные используются только на чтение, так что в многопоточном окружении расход памяти не растёт с числом потоков.

Числовые ID вместо строк

Каждое слово в словнике пронумеровано. Результат лемматизации — целочисленный ID лексемы и ID формы, и по этой паре можно сразу синтезировать нужную словоформу, без обратного поиска по строке. Для поисковых систем это полезно отдельно: эти же ID можно класть прямо в инвертированный индекс. Вторичный словарь «нормальная форма → ID» внутри движка становится ненужным, и из системы уходит лишний уровень косвенности.

Бенчмарк

Стенд изолированный, тест однопоточный, на одном ядре одного процессора. Корпус — книга «Большая книга занимательных фактов в вопросах и ответах» А. П. Кондрашова: больше 4 МБ текста в UTF-8, около 2 млн символов, с латиницей, числами и обычной пунктуацией. Всего 405 000 токенов, из них 311 775 кириллических слов и 76 893 знака пунктуации.

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

pymorphy3pyrusmorph
Время, с13,70,5
Скорость, слов/с22 800621 000
Опознано слов278 519278 221

Тот же тест нативным C++-приложением на libmorph/rus выдаёт больше 1 300 000 слов в секунду. Получается, что обёртка pybind11 и пересечение границы Python/C++ сами по себе съедают примерно половину производительности. Даже с этой потерей выигрыш над pymorphy3 — 25–30 раз.

Чем отличаются результаты

pymorphy3 опознал примерно на 300 слов больше. Уникальных нераспознанных слов у pymorphy3 — 114, у pyrusmorph — 306, общих среди них 41 (из осмысленных — Ио, Поликсо, Ра, Фау).

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

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

В обоих списках в основном аббревиатуры, античные имена, современные имена собственные и экзотическая география. У pyrusmorph заметнее дыры в иностранных именах и топонимах — если ваш корпус состоит из новостей или туристических текстов, это стоит проверить на своих данных до переезда.

Ложные срабатывания, то есть строки, которые анализатор принял за склоняемые формы:

  • pymorphy3: АКТҐ (с украинской Ґ), апэреа, бактерио, бо, вениаминитя, взрыво, Дыхсу, Кпк, навсего, ЬйпйАее;
  • pyrusmorph: Гус, ГэВ, кёй, коу, СсС, умм, Хоу, Ээт.

Часть ложных срабатываний pyrusmorph прописана в словаре явно: «Гус» там есть как имя футбольного тренера Гуса Хиддинка. pymorphy3 охотнее строит гипотезы по обобщённым моделям словоизменения и выдаёт больше вариантов разбора; libmorph сильнее опирается на словарь. Какое поведение лучше, зависит от задачи: для поиска с высокой полнотой гипотезы полезны, для точного разбора лишние варианты добавляют шум.

API

Пакет ставится из PyPI, лицензия MIT:

pip install pyrusmorph

Доступ к функциональности C++-ядра идёт через два класса: mlmaruWc — словарная лемматизация и синтез форм, mlfaruWc — эвристический (вероятностный) анализ слов, которых нет в словаре.

import pyrusmorph

morph = pyrusmorph.mlmaruWc()
fuzzy = pyrusmorph.mlfaruWc()

for lemma in morph.lemmatize('простой', 0):  # омонимы с ID лексемы и граммемами
    print(lemma.lexid, lemma.lemma)
    for gram in lemma.grams:
        # младшие 6 бит wdInfo — индекс части речи по Зализняку
        print('  pos idx:', gram.wdInfo & 0x3f, 'form id:', gram.idForm)

print(morph.build_form(182067, 5))  # синтез формы по ID лексемы и ID формы

def on_match(lexeme_id, forms):
    for f in forms:
        print(lexeme_id, f.form, f.text)

morph.find_match('ко*ро*ва', on_match)  # ? — один символ, * — любая последовательность

for stem in fuzzy.lemmatize('вундервафля', 0):  # гипотезы для неизвестного слова
    print(stem.fProb, stem.lemma[:stem.lStem], stem.lemma[stem.lStem:], stem.clsId)

print(fuzzy.build_form('командировоч', 14, 5))  # форма по основе и классу словоизменения

Часть речи возвращается числом, поэтому для вывода в человекочитаемом виде придётся завести таблицу отображения: индексы соответствуют пометам Зализняка (м, ж, св, нсв, п, числ и так далее).

Когда переходить

pyrusmorph имеет смысл пробовать, если лемматизация видна в профиле: индексация больших корпусов, препроцессинг для ML, потоковая обработка текстов, где сейчас приходится масштабировать воркеры из-за pymorphy. Дополнительные плюсы — память под словарь фиксирована на ~2 МБ и не растёт с числом потоков, а числовые ID лексем можно отдать прямо в поисковый индекс.

Перед заменой прогоните оба анализатора на выборке своих текстов и сравните списки нераспознанных слов: словари различаются, и на корпусе с большим количеством иностранных имён разница может оказаться заметнее, чем 300 слов на 311 тысяч из этого теста. Если и этого мало, остаётся вызывать libmorph напрямую из C++: там скорость примерно вдвое выше, чем через Python-обёртку.