Снова приветствую всех читателей Хабр.

В предыдущей статье был приведен пример создания кода проекта для задачи автоматизации обработки данных, в результате чего получилось подготовить нужную информацию по модели данных ЛОЦМАН:PLM. Эти данные планируется использовать для построения механизмов обработки поисковых запросов пользователей к базе ЛОЦМАН:PLM — в частности, для распознавания сущностей в тексте запроса. Это позволит понимать, на какие объекты модели данных ссылается пользователь: изделия, их параметры, типы документов и так далее.

Для решения новой задачи я решил опробовать возможности библиотеки spaCy, в которой сущности можно распознавать на основе заранее заданных паттернов. В ходе экспериментов с библиотекой и её модулями EntityRuler и SpanRuler я столкнулся с рядом особенностей, и в данной статье делюсь накопленным опытом и наработками — надеюсь, они окажутся полезными и для вас.

Я не буду подробно останавливаться на вопросах подключения и инициализации библиотеки, так как у неё имеется отличная документация с примерами, а кроме того, на Хабре уже опубликовано несколько статей, посвященных этой библиотеке:

Создание и загрузка паттернов

Далее по тексту для термина Span буду использовать термин фрагмент текста или фрагмент.

Самый первый шаг подготовки — это определение паттернов для правил поиска сущностей или фрагментов текста. Если вы уже работали с rule-based NER в spaCy, то знаете, что паттерны представляют собой список условий, по которым spaCy будет пытаться выделить сущность или фрагмент из текста.

У каждого правила есть три поля:

  • id - уникальный идентификатор (строка), по которому можно отличать найденный фрагмент от других результатов;

  • label - метка, обычно имя категории, в которую будет помещён найденный фрагмент текста;

  • pattern - само правило поиска: может быть строкой (дляPhraseMatcher) или списком объектов (для Matcher).

Пример минимального паттерна:

{
  "id": "product_123",
  "label": "PRODUCT",
  "pattern": "Компрессорная установка"
}

Или более сложный вариант:

{
  "id": "param_voltage",
  "label": "PARAMETER",
  "pattern": [
    {"LOWER": "напряжение"},
    {"IS_DIGIT": true}
  ]
}

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

Хорошее пояснение формата паттернов приведено в статье Можно всё: решение NLP-задач при помощи spaCy - см. раздел 4.

В моём случае правила готовились автоматически с помощью цепочки в n8n на основе очищенного XML-файла с информацией о модели данных ЛОЦМАН:PLM.

Проблема одинаковых паттернов

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

{"label": "ТипыСвязей","pattern": [{"lemma": "требование"}],"id": "Требования"}
{"label": "Типы","pattern": [{"lemma": "требование"	}],	"id": "Требование"}

Здесь «Требования» обозначает тип связи между объектами, а «Требование»тип самого объекта. Несмотря на разное семантическое значение, оба правила срабатывают на одну и ту же лемму — «требование».

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

Почему EntityRuler оказался неудобным

Стандартный EntityRuler добавляет найденные сущности напрямую в doc.ents, что вроде бы логично — он дополняет предсказания встроенной NER-модели. Однако на практике это решение оказалось слишком жёстким:

  • EntityRuler перезаписывает содержимое doc.ents без возможности гибко управлять тем, чьи предсказания важнее — модели или правил.

  • Отсутствует механизм разрешения конфликтов между разными наборами правил или между правилами и моделью.

Именно по обозначенным выше причинам я решил, что вместо EntityRuler лучше использоватьSpanRuler. А чтобы избежать конфликтов, разумным решением будет разделение паттернов по разным компонентам пайплайна, где каждый компонент отвечает только за свой набор правил и не вмешивается в обработку других.

Коллекции SpanRuler

При разделении правил по разным компонентам пайплайна для каждого из них создаются отдельные коллекции внутри doc.spans. Я перестроил цепочку в n8n и разнёс формирование правил для каждого типа сущностей по отдельным файлам:

  • attr.json — для атрибутов

  • docs.json — для документов

  • links.json — для типов связей

  • и т.д.

В самом приложении реализован следующий алгоритм работы, который включает инициализацию языковой модели spaCy, после чего выполняется сканирование каталога с JSON-файлами правил. Для каждого найденного файла создаётся отдельный SpanRuler, добавляемый в конвейер обработки текста. Сами правила из JSON загружаются в соответствующий SpanRuler.

Ключевая функция создания SpanRuler:

ruler = nlp.add_pipe(
    "span_ruler",
    name=ruler_name,
    config={
        "validate": True,
        "spans_key": ruler_name,
        "annotate_ents": False,
        "overwrite": False,
        "ents_filter": {"@misc": "spacy.prioritize_new_ents_filter.v1"},
    },
)

Добавление компонента через nlp.add_pipe("span_ruler") — это не просто регистрация правила. Это создание полностью автономного, изолированного обработчика текста со своими областями хранения данных, собственной схемой валидации и системой фильтрации. Если по основным параметрам все более-менее понятно, то стоит отдельно упомянуть про настройку фильтрации, ents_filter: {"@misc": "spacy.prioritize_new_ents_filter.v1"} - определяет стратегию разрешения конфликтов между сущностями. Если новое правило создаёт сущность, которая пересекается с уже существующей, приоритет получают новые данные. Однако, поскольку annotate_ents=False, этот фильтр работает только внутри пространства doc.spans и не затрагивает глобальные сущности.

Пример кода инициации модели и загрузки правил
import logging
import os
import json
import spacy

# Настройка логирования
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler(os.path.join("/spacy/logs", "app.log")),
        logging.StreamHandler()
    ]
)
logger = logging.getLogger(__name__)
# Загрузка модели spaCy
try:
    logger.info("Загрузка модели spaCy: ru_core_news_lg...")
    nlp = spacy.load("ru_core_news_lg")
    logger.info("Модель spaCy успешно загружена.")

    # Загрузка правил SpanRuler из JSON файлов
    rulers = {} # Словарь для хранения загруженных правил
    try:
        rulers_path = os.path.join(os.path.dirname(__file__), "rulers")
        logger.info(f"Поиск правил в папке: {rulers_path}")
        
        if not os.path.exists(rulers_path):
            logger.warning(f"Папка с правилами не найдена: {rulers_path}")
        else:
            json_files = [f for f in os.listdir(rulers_path) if f.endswith('.json')]
            
            if not json_files:
                logger.warning(f"JSON файлы с правилами не найдены в папке: {rulers_path}")
            else:
                logger.info(f"Найдено {len(json_files)} JSON файл(ов) с правилами.")
                    
                for json_file in json_files:
                    ruler_name = os.path.splitext(json_file)[0]
                    file_path = os.path.join(rulers_path, json_file)
                    try:
                        logger.info(f"Добавление span_ruler: {ruler_name}")
                        ruler = nlp.add_pipe(
                            "span_ruler",
                            name=ruler_name,
                            config={
                                "validate": True,
                                "spans_key": ruler_name,
                                "annotate_ents": False,
                                "overwrite": False,
                                "ents_filter": {"@misc": "spacy.prioritize_new_ents_filter.v1"},
                            },
                        )
                        logger.info(f"Span_ruler успешно добавлен: {ruler_name}")

                        with open(file_path, 'r', encoding='utf-8') as f:
                            rules = json.load(f)
                            ruler.add_patterns(rules)
                            logger.info(f"Успешно загружено {len(rules)} правил в span_ruler: {ruler_name} из файла: {json_file}")
                        
                        rulers[ruler_name] = ruler # Сохраняем ruler для возможного использования
                    except Exception as e:
                        logger.error(f"Не удалось обработать файл {json_file} для span_ruler {ruler_name}. Ошибка: {e}", exc_info=True)
                        # Не прерывать выполнение, если один файл загрузился с ошибкой
                    
                logger.info("Все найденные правила обработаны для span_rulers.")
    except Exception as e:
        logger.error(f"Произошла общая ошибка при настройке span_rulers: {e}", exc_info=True)

except OSError:
    logger.error("Ошибка: Модель 'ru_core_news_lg' не найдена. Убедитесь, что она установлена.")
    nlp = None
except Exception as e:
    logger.error(f"Произошла непредвиденная ошибка при загрузке модели spaCy: {e}")
    nlp = None

В итоге получается, что каждый SpanRuler работает изолированно, имеет собственную логику разрешения внутренних конфликтов и пишет результаты в отдельный ключ в doc.spans (например, doc.spans["links"]).

Обработка найденных фрагментов (Span)

После того как все SpanRuler загружены в конвейер и произошла обработка текста, возникает следующий логичный этап — сбор и обработка всех найденных фрагментов.

Каждый SpanRuler записывает свои результаты в doc.spans[ruler_name]. Эти списки изолированы друг от друга: они не пересекаются и не объединяются автоматически. Если оставить всё как есть, то дальше придётся вручную обращаться к каждому из списков, что неудобно и нарушает первоначальную идею простого доступа к информации. Поэтому логичным шагом становится сбор всех фрагментов в общий список.

Самый простой способ объединить разрозненные результаты в один массив:

all_spans = []
for ruler_name, spans in doc.spans.items():
    for span in spans:
        all_spans.append(span)

Теперь all_spans содержит полную разметку, полученную из всех правил, независимо от того, какой компонент их сгенерировал.

Однако возникает важный нюанс: сам объект Span не хранит информацию о том, какому SpanRuler он принадлежит. Без этой информации теряется контекст — например, нельзя понять, является ли фрагмент «требованием» (тип объекта) или «требованием» как типом связи.

Здесь на помощь приходят кастомные атрибуты (Span extensions).

Использование расширения Span extensions

В spaCy каждый объект Span, Token или Doc может быть расширен с помощью пользовательских полей. Это позволяет хранить дополнительную информацию без изменения внутренней структуры объекта. В нашем случае мы хотим добавить поле, которое будет указывать, какой ruler создал данный фрагмент.

Для этого нужно объявить новое поле:

from spacy.tokens import Span
# ...
Span.set_extension("ruler", default=None)

Теперь при сборе фрагментов можно сразу сохранить информацию об источнике:

for ruler_name, spans in doc.spans.items():
    for span in spans:
        span._.ruler = ruler_name
        all_spans.append(span)

Таким образом, каждый фрагмент будет содержать информацию о том с помощью какого компонента он был создан.

Проблема пересекающихся сущностей

Вторая проблема с которой я столкнулся — это ситуация, когда различные компоненты конвейера (правила, Matcher, PhraseMatcher) генерируют перекрывающиеся фрагменты текста. В результате итоговый список Spans может содержать несколько сущностей, занимающих одни и те же токены или их часть.

Это особенно заметно при анализе сложных терминов.

Например, в запросе: Найди мне спецификации требований которые не входят ни в один проект, при использовании словарных шаблонов появились такие пересекающиеся фрагменты: «Спецификация», «Требования», «Спецификация требований», «Требование».

Без фильтрации все они попадут в итоговый список, что приведёт к дублированию понятий и снижению точности дальнейшей логики.

Применение фильтрации пересечений

Функция фильтрации - spacy.util.filter_spans решает две ключевые проблемы:

  1. Удаление перекрывающихся фрагментов.

  2. Выбор наиболее приоритетных из них на основе длины и порядка.

Алгоритм выбирает более длинные фрагменты, предполагая, что они содержат более полную семантику. Например, Спецификация требований "побеждает" более короткие Спецификация и Требования.

Для использования функции нужно импортировать модуль sapn.util

import spacy.util
# ...
all_spans = spacy.util.filter_spans(all_spans)

Нужно больше кастомных полей

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

Логика заполнения этих атрибутов должна опираться на внешние справочники, либо требует анализа дочерних токенов внутри фрагмента. Чтобы не размазывать эту логику по всему коду, захотелось реализовать её в одном месте. И для этого в spaCy есть специальный инструмент — кастомный компонент конвейера, который запускается после всех SpanRuler и дополняет найденные фрагменты или сущности нужными данными.

Регистрация кастомных атрибутов

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

#...
from spacy.tokens import Span

# Регистрируем кастомные атрибуты для Span
for attr in ["ruler", "category", "external_id"]:
    if not Span.has_extension(attr):
        Span.set_extension(attr, default=None)

#...

Создание и регистрация компонента

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

...
from spacy.language import Language

@Language.component("span_custom_attr")
def custom_span_wrapper(doc):
    
    for span in doc.spans["types"]:
        if span.id_ in ["Требование","Спецификация требований"]:
           span._.category = "Системная инженерия"
    
    return doc

Добавление компонента в конвейер

Теперь добавим наш новый компонент в конвейер обработки после всех SpanRuler. Это важно, чтобы он работал уже с окончательным набором фрагментов.

# После загрузки модели и добавления всех SpanRuler
nlp.add_pipe("span_custom_attr", last=True)

# Или, если нужно вставить перед другим компонентом:
# nlp.add_pipe("span_custom_attr", before="ner")

Недостатки работы с компонентом

При исследовании работы компонента обнаружилась особенность, которая состоит в том, что функция компонента будет вызываться каждый раз при вызове doc = nlp(text) . Загружать внутри этой функции данные из файла справочника будет неправильно, и лучше, заранее подгружать справочники в память, и использовать вместо компонента - Language.factory .

Пример кода для Language.factory
def load_ruler_data():
    # Загрузка всех JSON из rulers_path = os.path.join(os.path.dirname(__file__), "rulers")
    data = {}
    rulers_path = os.path.join(os.path.dirname(__file__), "rulers")
    if os.path.exists(rulers_path):
        json_files = [f for f in os.listdir(rulers_path) if f.endswith('.json')]
        for json_file in json_files:
            ruler_name = os.path.splitext(json_file)[0]
            file_path = os.path.join(rulers_path, json_file)
            try:
                with open(file_path, 'r', encoding='utf-8') as f:
                    data[ruler_name] = json.load(f)
                logger.info(f"Успешно загружен файл: {json_file} для правила: {ruler_name}")
            except Exception as e:
                logger.error(f"Не удалось загрузить файл {json_file}: {e}", exc_info=True)
    else:
        logger.warning(f"Папка с правилами не найдена: {rulers_path}")
    return data

# Собственный обработчик для добавления значений custom аттрибутов в span
@Language.factory("span_custom_attr")
def span_custom_attr(nlp, name):
    raw_data = load_ruler_data()

    # Индекс: id -> custom attrs
    indexed_data = {}
    for rules_data in raw_data.values():
        for rule in rules_data:
            rule_id = rule.get("id")
            custom_attrs = rule.get("_")
            if rule_id and isinstance(custom_attrs, dict):
                indexed_data[rule_id] = custom_attrs

    def span_custom_attr_component(doc):
        for ruler_name, spans in doc.spans.items():
            for span in spans:
                # сохраняем ruler
                span._.ruler = ruler_name
                # на всякий случай, если вдруг в правилах остаят пустое поле
                span_id = span.id_
                if not span_id:
                    continue

                custom_attrs = indexed_data.get(span_id)
                if not custom_attrs:
                    continue

                for attr_name, attr_value in custom_attrs.items():
                    if Span.has_extension(attr_name):
                        setattr(span._, attr_name, attr_value)

        return doc

    return span_custom_attr_component

Добавление компонента происходит так-же как и в примере выше после загрузки модели и добавления всех SpanRuler.

В итоге мы получили гибкий инструмент, который:

  • Имеет код который вызывается один раз

  • возвращает компонент для обработки документа

  • поддерживает конфигурацию

  • может хранить состояния

Детальную информацию можно получить в разделе примеров spaCy GUIDES.

Что дальше?

После исследований, с помощью Cline и VS Code был сформирован проект, который представляет собой веб-сервис на основе FastAPI, для обработки текстовых запросов.

Сервис предоставляет REST API-эндпоинты для анализа текста:

  • GET эндпоинт для получения детальной информации о токенах переданного текста.

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

Сервис будет использоваться в цепочках n8n для тестирования различных гипотез по анализу текста поискового запроса. А результаты работы сервиса будут далее обрабатываться с помощью LLM.

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