До того, как я узнал про 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.
Когда парсер встречает открывающий тег, вызывается обработчик начала элемента. При закрытии тега вызывается второй обработчик. Если между ними находится текст, приходит ещё одно событие.
Никакого дерева при этом не создаётся, поэтому объём используемой памяти практически не зависит от размера 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, где стандартная библиотека уже содержит всё необходимое и не требует никаких внешних зависимостей.
Автор: Avlakan


