До того, как я узнал про libmorph

Ранее я уже рассказывал о том, как в проекте по извлечению требований из ГОСТов в итоге отказался от подхода «скормим всё в LLM» в пользу детерминированного пайплайна из регулярок, морфологического анализатора и NLP обработке с использованием spaCy («Извлечение и обработка требований из документов с помощью NLP-инструментов»). А позже, когда решил систематизировать для себя, какие вообще существуют морфоанализаторы для русского языка, наткнулся на библиотеку libmorph, и это оказалось как раз тем решением, которое лично мне закрыло вопрос лемматизации в проектах на Free Pascal, без необходимости поднимать Python-окружение («Современные морфоанализаторы русского языка: от словарей к нейросетям»).

Но к libmorph я пришёл не сразу, история, о которой пойдёт речь в этой статье, случилась раньше — и, по сути, именно из-за незнания о существующей библиотеки я пошёл тем путём, который здесь описываю.

Когда DOM перестаёт быть решением

До того как в моих проектах появился libmorph, нужно было как-то обрабатывать слова, и логичным решением было использовать данные словаря OpenCorpora. Поскольку я обычно пишу свои экспериментальные проекты в Lazarus на Free Pascal, самым очевидным решением казалось работать с исходным XML напрямую, просто один раз разобрать словарь и использовать нужные данные в собственном коде.

Именно здесь меня ждал первый неприятный сюрприз.

Собственно словарь - это обычный XML, который можно обработать привычным способом: загрузить в память, пройтись по дереву DOM и извлечь необходимые данные. На практике всё оказалось иначе. Полный словарь OpenCorpora (dict.opcorpora.xml) занимает больше 400 МБ в распакованном виде.

Первая попытка использовать привычный DOM-парсер закончилась печально: программа долго строила дерево документа, потребление памяти росло без остановки, а до обработки данных дело так и не доходило. Причина довольно проста — DOM сначала загружает весь документ целиком, создавая объект для каждого элемента, атрибута и текстового узла, и только потом отдаёт управление программе. Для небольших XML это удобно. Для словаря на сотни мегабайт — это плохой путь.

Именно тогда я сталь искать альтернативы для обработки XML. Все рекомендации направляли меня в сторону SAX-парсинга, который давно присутствует в стандартной библиотеке Free Pascal, но почему-то встречается в примерах значительно реже, чем работа с DOM.

Отличия подходов
Отличия подходов

В статье хочу показать некоторые особенности устройства SAX в fcl-xml и при пример его использования для обработки словаря OpenCorpora.

Что нужно из OpenCorpora для построения лемматизатора

Перед тем как писать парсер, я посмотрел, какие данные вообще содержатся в словаре и как с ними работать. Если открыть dict.opcorpora.xml, можно увидеть, что документ состоит из нескольких крупных разделов: список граммем, словарь лемм, типы связей, связи между словами.

Для морфологического поиска меня интересовал только раздел <lemmata>.

Пример записи:

<lemma id="1" rev="1">    
  <l t="ёж">        
    <g v="NOUN"/>        
    <g v="anim"/>        
    <g v="masc"/>    
  </l>    
  <f t="ёж">        
    <g v="sing"/>        
    <g v="nomn"/>    
  </f>    
  <f t="ежа">        
    <g v="sing"/>        
    <g v="gent"/>    
  </f>
</lemma>

Где:

  • <lemma> — одна словарная статья;

  • <l> — нормальная форма слова;

  • <f> — одна из словоформ.

Дополнительные грамматические признаки находятся в отдельных тегах <g>, вложенных внутрь <l> и <f>, которые содержат данные для определения части речи, число, падеж, род и остальные характеристики слова.

Стоит отметить одну особенность формата. На первый взгляд кажется, что часть речи должна храниться отдельным атрибутом. На самом деле это не так. Она приходит как одна из граммем внутри элемента <l>. Поэтому во время разбора документа приходится анализировать не только саму лемму, но и связанные с ней грамматические признаки.

Ещё один момент, который сначала меня запутал. В интернете довольно много статей про OpenCorpora, однако часть из них описывает не словарь, а аннотированный корпус (annot.opcorpora.xml). Форматы похожи только названием проекта.

Для построения собственного морфологического словаря нужен именно dict.opcorpora.xml. Дальше в статье речь пойдёт только о нём.

Подключение SAX в Free Pascal

Следующая неожиданность ждала уже при поиске примеров. При поиске Free Pascal SAX XML, поисковик быстро находит десятки публикаций с CreateSAXParser, XMLRead или стандартными примерами XML через DOM. Сложилось такое впечатление, что хорошего современного tutorial именно «SAX + SAX_XML для Lazarus/FPC» практически нет. Часть материалов относится к давно устаревшим версиям библиотек, часть — вообще к другим XML-фреймворкам для Pascal.

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

Основные классы находятся в двух модулях:SAX иSAX_XML, а главный рабочий класс — TSAXXMLReader.

В отличие от многих других библиотек здесь не нужно наследоваться от специальных интерфейсов или создавать отдельный объект-парсер. Достаточно назначить обработчики событий, после чего TSAXXMLReader начинает последовательно читать XML и вызывать указанные методы. Дальше всё зависит уже не от парсера, а от того, какое состояние хранит сам обработчик. Именно эта особенность делает SAX настолько экономичным по памяти.

Как работает SAX: события вместо дерева

Если раньше вы работали только с DOM, первое знакомство с SAX может показаться непривычным. Здесь нет объекта документа, нет дерева элементов и нельзя написать что-то вроде «найти все <lemma>». Парсер вообще ничего не знает о структуре документа целиком. Он просто читает XML слева направо и сообщает программе, что происходит в текущий момент. Представьте себе человека, который читает книгу вслух. Он не держит в голове весь текст — только текущую страницу. Примерно так же работает и SAX.

Когда парсер встречает открывающий тег, вызывается обработчик начала элемента. При закрытии тега вызывается второй обработчик. Если между ними находится текст, приходит ещё одно событие.

Схема работы SAX
Схема работы SAX

Никакого дерева при этом не создаётся, поэтому объём используемой памяти практически не зависит от размера XML-документа.

Минимальный SAX-обработчик

Достаточно создать объект TSAXXMLReader, назначить обработчики событий и запустить разбор файла.

Reader.OnStartElement := @Handler.OnStart;
Reader.OnEndElement := @Handler.OnEnd;
Reader.Parse('dict.opcorpora.xml');

Вся логика сосредоточена внутри методов обработчика.

Когда встречается новый тег, обработчик получает два основных параметра:

  • имя элемента (LocalName);

  • список его атрибутов (Attrs).

Например, при чтении

<f t="абажура"/>

парсер сообщит, что встретился элемент f, а атрибут t уже будет доступен обработчику.

Получить его можно сразу по имени.

Text := Attrs.GetValue('', 't');

На этом моменте я допустил ошибку новичка. Сначала написал собственную функцию, которая перебирала все атрибуты по индексам. Лишь позже заметил, что TSAXAttributes уже умеет искать атрибут по имени самостоятельно.

SAX ничего не хранит за вас

Когда открывается <lemma>, парсер не создаёт объект автоматически — все структуры приходится создавать самостоятельно. У меня практически весь обработчик строится вокруг двух временных объектов: текущей леммы и текущей словоформы.

Логика естественная: начали читать <lemma> — создали запись; встретили <l> — заполнили текст; встретили <f> — создали словоформу; закрылся </f> — добавили её в лемму; закрылся </lemma> — сохранили готовую запись в общий список. В памяти одновременно существует только одна обрабатываемая лемма — независимо от того, содержит словарь десять записей или полмиллиона. Именно это позволяет SAX работать с очень большими XML практически без роста потребления памяти.

Для OpenCorpora оказалось достаточно буквально нескольких полей состояния.

Во время разбора документа меня интересовали:

  • текущая лемма;

  • текущая словоформа;

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

Когда встречается тег <l>, сохраняется текст нормальной формы. Когда позже начинают приходить элементы <g>, становится понятно, что эти граммемы относятся именно к текущей лемме. Затем начинается разбор очередной словоформы <f>, и все следующие граммемы уже относятся к ней. После закрытия элемента <lemma> накопленные данные можно окончательно сохранить и очистить временные структуры.

Получается своеобразный конвейер. Парсер ничего не ищет и ничего не анализирует заранее. Он лишь постепенно собирает объект по мере чтения XML. Собственно именно поэтому SAX хорошо подходит для файлов любого размера — одновременно в памяти находятся только данные одной текущей записи, а не весь документ.

У SAX тоже есть ограничения

После всего сказанного может сложиться впечатление, что SAX — универсальное решение для любых XML-документов. На практике это не так.

Самое очевидное ограничение — движение только вперёд: если после обработки очередной леммы понадобилось посмотреть предыдущую запись, сделать это уже нельзя, единственный способ — открыть файл заново. Второе — атрибуты доступны только в момент открытия элемента, их нужно сразу копировать в собственные структуры. Третье — в SAX нет дерева для отладки: если обработчик неожиданно перестал заполнять данные, разбираться приходится по журналу событий или пошагово в отладчике.

Эти ограничения подробно разобраны, например, в «Методах работы с тяжёлыми XML». На практике все они решаются аккуратным проектированием конечного автомата и не перевешивают главного плюса — предсказуемого потребления памяти независимо от размера файла.

Вместо заключения

Для отработки решений был собран проект PasLemmar — экспериментальная библиотека лемматизации русского языка на базе словаря OpenCorpora, которая реализованна на Free Pascal.

Посмотреть код приложений проекта можно на GitHub: PasLemmar.

А в итоге можно сказать, что если задача состоит в измениии несколько элементов и сохранить документ обратно, то DOM по-прежнему остаётся самым удобным вариантом. Но если нужно один раз обработать большой файл, построить индекс, импортировать данные в базу или подготовить собственный бинарный словарь, событийная модель оказывается наиболее эффективной.

Во всяком случае, после этого проекта я перестал воспринимать SAX как устаревший API, доставшийся в наследство от начала двухтысячных. Для потоковой обработки больших XML он по-прежнему остаётся одним из самых практичных инструментов — особенно в Free Pascal, где стандартная библиотека уже содержит всё необходимое и не требует никаких внешних зависимостей.

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


  1. alan008
    19.08.2026 09:14

    Паскаль жив. Но не на Хабре :)

    Приглашаю отписаться в комментариях, кто ещё до сих пор программирует на Delphi или Free Pascal. Буду первым в этом списке :)


    1. sepetov
      19.08.2026 09:14

      Только редкие поделки. По роду деятельности полностью ушёл в веб-разработку на PHP


    1. hurtavy
      19.08.2026 09:14

      До сих пор для себя пишу на нём


    1. KazantsevAlexey
      19.08.2026 09:14

      1. alan008
        19.08.2026 09:14

        Смешной сайт! За авторством Embarcadero небось ))


  1. SpiderEkb
    19.08.2026 09:14

    SAX хорош для больших XML и в тех случаях когда вам нужно только прочитать документ и извлечь из него какие-то данные (причем, вам может интересовать далеко не все что есть в документе).

    DOM хорош для небольших документов, особенно в ситуациях когда вам надо прочитать, внести изменения и сохранить обратно.

    И тут как всегда - сначала вникаем в задачу, а потом подбираем под нее оптимальный инструмент. А не всегда используем только то, что когда-то уже использовали просто потому что оно знакомое.

    При работе с SAX еще надо "в голове" держать контекст. Вот пример

          <Субъект>
            <ИдСубъекта>3569309339</ИдСубъекта>
            <УНС>IRi.003</УНС>
            <ТипСубъекта>
              <Идентификатор>2</Идентификатор>
              <Наименование>Физическое лицо</Наименование>
            </ТипСубъекта>
    ...

    и в том же XML

                <Документ>
                  <ТипДокумента>
                    <Идентификатор>746140005</Идентификатор>
                    <Наименование>PASSPORT</Наименование>
                  </ТипДокумента>
                  <Номер>6620505</Номер>
                  <ОрганВыдачи>Iran (Islamic Republic of)</ОрганВыдачи>
                </Документ>
    ...

    Тут мы видим что теги <Идентификатор> и <Наименование> могут быть как внутри тега <ТипСубъекта>, так и внутри тега <ТипДокумента>

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