惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

P
Proofpoint News Feed
AI
AI
T
Threatpost
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
C
Cybersecurity and Infrastructure Security Agency CISA
Scott Helme
Scott Helme
AWS News Blog
AWS News Blog
P
Privacy & Cybersecurity Law Blog
G
GRAHAM CLULEY
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
C
CERT Recently Published Vulnerability Notes
Cyberwarzone
Cyberwarzone
NISL@THU
NISL@THU
P
Privacy International News Feed
Schneier on Security
Schneier on Security
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
SecWiki News
SecWiki News
T
Tor Project blog
W
WeLiveSecurity
Security Archives - TechRepublic
Security Archives - TechRepublic
Spread Privacy
Spread Privacy
H
Hacker News: Front Page
Latest news
Latest news
C
Cyber Attacks, Cyber Crime and Cyber Security
D
Darknet – Hacking Tools, Hacker News & Cyber Security
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
T
Troy Hunt's Blog
Cisco Talos Blog
Cisco Talos Blog
人人都是产品经理
人人都是产品经理
腾讯CDC
博客园 - 【当耐特】
Engineering at Meta
Engineering at Meta
The Hacker News
The Hacker News
Application and Cybersecurity Blog
Application and Cybersecurity Blog
PCI Perspectives
PCI Perspectives
罗磊的独立博客
阮一峰的网络日志
阮一峰的网络日志
N
News and Events Feed by Topic
The Cloudflare Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
TaoSecurity Blog
TaoSecurity Blog
博客园 - 叶小钗
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
T
Threat Research - Cisco Blogs
量子位
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
美团技术团队
D
Docker
C
CXSECURITY Database RSS Feed - CXSecurity.com
T
Tenable Blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Извлечение и обработка требований из документов с помощью NLP-инструментов
Андрей Канивец · 2026-05-12 · via Все публикации подряд на Хабре

Простой

13 мин

7.5K

Приветствую всех читателей Хабр.

Думаю, многим знаком этот сценарий: появляется задача — и первая мысль: «скормлю все LLM, она разберётся». Поначалу получается красиво, всё работает и есть первые результаты. Потом начинаешь проверять детали и замечаешь, что модель местами добавляет текст от себя. Потом смотришь на затрачиваемое время и понимаешь, что при текущей скорости обработка всего объёма документов закончится через год.

Именно в такой ситуации я оказался, когда захотел обработать базу ГОСТов.

Эта статья — про то, как я прошёл путь от «кидаем всё в LLM» до детерминированного пайплайна на классических NLP-инструментах. И про то, как в этом помогли те же самые языковые модели — но уже в роли консультантов, а не рабочей лошадки.

Задача

В архиве лежало порядка 54 тысяч PDF-файлов — ГОСТы и другие нормативные документы. Нужно было найти в них текстовые фрагменты, похожие на требования, извлечь их и зафиксировать с указанием конкретного раздела, откуда они были взяты.

В качестве среды для эксперимента я использовал n8n. Он удобен тем, что содержит множество готовых узлов для работы как с данными, так и с AI-агентами. Кроме того, на каждом шаге запуска можно посмотреть, что происходит с данными, увидеть входящий и исходящий JSON и быстро подправить алгоритм работы цепочки в случае необходимости. Для задач, где много итераций и гипотез, это сильно ускоряет работу.

Конфигурация стенда выглядела так: 2 x GPU серии RTX 5060 с 16 ГБ памяти, модель Qwen3-30B A3B Q6K, запущенная локально через LM Studio.

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

Версия решения 1.0: «скормим всё в LLM»

Первая реализация цепочки выглядела предельно просто:

  1. Забираем PDF из хранилища (у меня это был S3-совместимый storage)

  2. Извлекаем текст с помощью стандартной ноды Extract From File

  3. Готовим и предварительно фильтруем текст

  4. Передаём в LLM с инструкцией извлечь требования в JSON (рабочий промпт получился достаточно большой, он разрабатывался и дорабатывался с помощью LLM)

  5. Собираем результат.

Первая же проблема вскрылась на этапе извлечения текста. При обработке PDF на выходе получался текст с целым зоопарком артефактов:

  • слова с разрывами посередине: рас-\nщепление, при-\nмер

  • слова, сформированные через пробел: т е м п е р а т у р ы

  • «поехавшие» дефисы, превратившиеся в минусы или тире

  • случайные символы от артефактов сканирования

  • нарушенный порядок колонок в многоколоночных документах.

По идее, текст нужно было нормализовать до передачи в модель: почистить, склеить, восстановить структуру. Но, как это часто бывает, я решил пойти по более короткому пути — «LLM всё поймёт» — и отправлял текст как есть. При этом я исходил из результатов предыдущих экспериментов, и у меня уже был опыт, что современные LLM достаточно устойчивы к noisy input и умеют «восстанавливать» смысл даже при повреждённом тексте.

На практике оказывается — да, восстанавливают. Но слишком хорошо.

Когда я начал проверять результаты, вскрылся важный момент: модель не просто извлекала требования — она их интерпретировала. Где-то аккуратно дополняла, где-то переформулировала, а иногда и прямо «достраивала» фразы, которых в оригинале не было. По сути, это была попытка компенсировать шум, получаемый при извлечении текста, и таким образом итоговый текст во многих записях уже не совпадал с источником.

Дальше всплыло ещё одно ограничение — размер контекста. На практике ГОСТ на 30–40 страниц легко превращается в 100–200 тысяч символов, что банально не помещается в окно локальной модели. Пришлось разбивать текст на куски примерно по 15 000 символов с перекрытием около 500 символов. Такой размер я подобрал с запасом — чтобы и системный промпт, и сам текст гарантированно помещались в контекст.

Третий неприятный момент это время обработки. В среднем на один документ уходило 7–10 минут. Когда я прикинул общее время работы, картина получилась не очень оптимистичной:

7 минут × 54 000 документов / 60 / 24 / 365 ≈ 7 месяцев непрерывной работы.

Даже если идти в оптимизацию: ускорение инференса, распараллеливание обработки, оптимизация размера чанков — в лучшем случае это сокращало срок до 2-3 месяцев.

Однако ключевой проблемой оставалась недетерминированность результата. Даже при идентичных входных данных модель генерировала разные формулировки, что противоречило самой идее воспроизводимого извлечения требований. Частично эту проблему можно было смягчить настройками инференса (снижение температуры, фиксация seed, ужесточение промпта), но я отказался от дальнейших попыток оптимизации. Я счёл такой подход неэффективным: требовалось менять стратегию, а не просто подбирать параметры.

Консультируемся с LLM о том, как избавиться от LLM

Парадоксально, но это оказалось одним из самых рациональных решений.

Я сформулировал задачу примерно следующим образом: «Как извлекать требования из нормативных документов без LLM, если исходный текст получен через парсинг PDF и содержит артефакты?». С этим вопросом пошёл к нескольким большим моделям — GigaChat, YandexGPT, DeepSeek, Qwen и т.д.

Кроме того, я стал использовать модели как «ревьюверы»: ответ одной модели отдавал другой с просьбой покритиковать, найти слабые места и предложить улучшения. Такая перекрёстная работа быстро отсекла поверхностные идеи и вытаскивала более качественные решения. В итоге все рекомендации сошлись на одном и том же направлении: необходимо разложить цепочку обработки на простые проверяемые шаги и отдать каждый шаг специализированному инструменту.

Консенсусное решение выглядело так:

  • Регулярные выражения — для выделения нумерованных разделов, подпунктов и списков. Ключевая идея — перейти от поиска требований в произвольных чанках к анализу логически структурированных блоков в тексте.

  • Коррекция текста — нормализация текста в выделенных фрагментах для устранения шума. На этом этапе принимаются решения вроде «склеивать ли рас-\nщепление обратно» и исправляются ошибки распознавания.

  • Rule-based извлечение требований — финальный проход по очищенному тексту с поиском лингвистических паттернов, характерных для требований (модальные конструкции, долженствование, ограничения, условия и т.п.).

Фактически вместо одной «умной, но дорогой» сущности реализовывалась композиция из трёх «простых, но предсказуемых» инструментов, каждый из которых решает строго свою задачу.

Ниже покажу, как устроен каждый из этих блоков.

Шаг 1: регулярное выражение для выявления блоков текста

Первое важное наблюдение выданное LLM, которое в итоге сильно упростило архитектуру: тексты ГОСТов и похожих нормативных документов почти всегда структурированы предсказуемо. Каждый раздел, подраздел и подпункт начинается с номера — 1.1, 2.3.4, 5.2.1. — и продолжается до следующего аналогичного маркера.

Это означает, что вместо нарезания текста на фиксированные чанки, как было сделано в первой версии, можно опираться на саму структуру документа. И тогда задача превращается не в «угадать границы смысла», а в «восстановить формальную разметку».

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

В итоге регулярное выражение пришлось доводить вручную — итеративно, с разбором реальных примеров и постепенным закрытием нескольких «дыр» в логике. Для отладки и проверки вариантов я активно использовал regex101.com.

Итоговый вариант выражения выглядел так:

/(^|\n)(\d+\.(\d+\.?)+)\s+([^\n]+)([\s\S]*?)(?=\n\d+\.(\d+\.?)+\s+|\n?$)/g

Основные элементы выражения:

(^|\n)                        — начало строки или перевод строки
(\d+\.(\d+\.?)+)              — номер раздела (1.2, 3.4.5, 2.3. и т.д.)
\s+                           — пробел(ы) после номера
([^\n]+)                      — заголовок раздела до конца строки
([\s\S]*?)                    — содержимое раздела (минимальный захват)
(?=\n\d+\.(\d+\.?)+\s+|$)     — остановка перед следующим разделом или концом текста
g                             — глобальный поиск всех совпадений

Принятые допущения

Регулярное выражение работает хорошо для большинства ГОСТов, но есть случаи, когда оно даёт сбой:

  1. Номера с буквами: Если в нумерации используются буквы (например, 1.2.а или Приложение А), выражение их не захватит, потому что паттерн \d+ ожидает только цифры. Я решил не усложнять выражение и сознательно игнорировать такие пункты.

  2. Вложенные списки внутри раздела: Выражение захватывает весь текст до следующего номера раздела, но если внутри есть маркированные списки с точками (например, или -), они остаются внутри блока. Это не ошибка, но может помешать последующей обработке. Как правило, такие большие блоки будут отфильтрованы позже.

Обработка текста с помощью регулярного выражения была реализована в виде Code-ноды, я специально попросил LLM сразу сгенерировать готовый JSON код n8n ноды, а потом через буфер обмена вставил ноду в цепочку.

Итог реализации шага 1

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

Шаг 2: чистка полученного текста

После сегментации документа на блоки артефакты из текста никуда не делись. И наиболее проблематичные из них — символы перевода строки (\n). Удалить их «в лоб» невозможно: в зависимости от контекста они несут разную смысловую нагрузку. Иногда \n — это лишь артефакт автоматического переноса, и строки следует объединить. Иногда он обозначает границу абзаца, где достаточно заменить символ на пробел. А в некоторых случаях это структурный разделитель (например, пункт списка), который трогать нельзя.

Я поискал готовые варианты для исправления текста, их можно разделить на три категории:

Коммерческие API — ABBYY FineReader, Yandex Vision и подобные им решения. Дают высокое качество, но требуют платных подписок и обращения к внешним сервисам, что не всегда допустимо по архитектуре или политике безопасности.

Seq2Seq-модели и гибридные пайплайны (например, связка DeepPavlov + ruT5 + кастомные правила). Мощные решения, но мне показались сложными в развёртывании.

Орфографические корректоры — PySpellChecker, SymSpell. Быстрые и детерминированные, однако принципиально не решают задачу. Они работают со словами изолированно, не учитывают контекст и морфологию, поэтому не могут отличить рас- от реального слова или корректно обработать разорванный перенос.

Мне требовалось не «исправление опечаток», а проверка гипотезы о склейке. Логика проста: взять левую и правую части вокруг \n, склеить, проверить — есть ли такое слово в словаре? Если да — склеиваем, если нет — ставим пробел. С такой задачей справляется морфологический анализатор, он учитывает морфологическую структуру русского языка, работает быстро, не требует обучения и позволяет валидировать гипотезы без привлечения тяжёлых нейросетей.

В качестве библиотеки я использовал pyMorphy 3, поскольку ранее уже с ней работал.

Алгоритм работы корректора

Основная логика разбита на четыре блока, каждый закрывает свой случай.

Первый блок— дефисный перенос. Это простейший случай: дефис однозначно маркирует разрыв, регулярное выражение закрывает его без какой-либо морфологии:

text = re.sub(r"-\n\s*", "", text)

Второй блок— «рассыпанные» слова. Некоторые слова получались со вставленным пробелом между каждой буквой: П р и м е ч а н и е. Для них работает отдельная функция merge_spaced_letters: она ищет паттерн \b(?:[А-Яа-яЁё]\s+){2,}[А-Яа-яЁё]\b, склеивает кандидата и проверяет его через is_known_word.

Ключевая функция — проверка is_known_word. Pymorphy3 умеет угадывать незнакомые слова по суффиксам и аналогиям, поэтому простого вызова morph.parse(word) недостаточно: он вернул бы True для любой правдоподобной последовательности букв. Функция проверяет, что все методы в стеке разбора слова — это DictionaryAnalyzer:

from pymorphy3 import MorphAnalyzer

morph = MorphAnalyzer()

def is_known_word(word: str) -> bool:
    if not word or len(word) < 3:
        return False
    parses = morph.parse(word)
    for p in parses:
        # Проверяем, что слово найдено именно в словаре, а не эвристически
        if all(type(method).__name__ == "DictionaryAnalyzer"
               for method, *_ in p.methods_stack):
            return True
    return False

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

Третий блок— обычные переносы строк. Здесь вся логика сосредоточена в функции should_merge. Функция принимает левый и правый фрагменты вокруг \n и проверяет три сценария:

def should_merge(left: str, right: str) -> bool:
    left_clean = left.strip()
    right_clean = right.strip()
    merged = left_clean + right_clean
    
    left_known  = is_known_word(left_clean)
    right_known = is_known_word(right_clean)
    merged_known = is_known_word(merged)

    # Если оба фрагмента самостоятельные слова — не склеиваем
    if left_known and right_known and not merged_known:
        return False  
    
    # Склеиваем только если результат валиден по словарю
    return merged_known

Главная функция fix_ocr_text собирает всё воедино: сначала дефисы, затем рассыпанные буквы, затем построчный проход через re.split с захватом групп, где каждая пара (left, right) уходит в should_merge.

Итог реализации шага 2

Проверка реализована как отдельный Python-микросервис в Docker с REST API. n8n дёргает его через HTTP-ноду. Это удобно: сервис обновляется независимо от workflow и легко тестируется и дорабатывается. После нескольких тестовых запусков я еще добавил проверку с помощью SymSpell с небольшим словарём.

Шаг 3: поиск маркеров требований в тексте

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

Стопроцентная точность здесь не нужна, задача скромнее: отсеять явно не релевантные фрагменты, чтобы дальше не тратить ресурсы на их обработку. Даже если будет пропущено 5–10% требований — это лучше, чем хранить всё подряд или снова гонять текст через LLM.

Что отличает требование от не-требования

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

  • Модальные глаголы: «должен», «обязан», «требуется»

  • Ограничения: «запрещено», «не допускается», «не более»

  • Условия: «при», «если», «в случае»

  • Нормативные формулировки: «устанавливается», «определяется»

Именно такие паттерны и нужно ловить в тексте. Но просто искать ключевые слова недостаточно: «должен» может быть частью цитаты, а «запрещено» — упоминанием в справочном приложении. Отсюда вытекает потребность в учёте всех маркеров в предложении.

Использование spaCy для оценки текста

Для оценки текста я использовал библиотеку spaCy, но не в стандартном режиме NER, а с использованием правил для поиска фрагментов текста. Ранее в статье «Использование библиотеки spaCy для поиска сущностей в тексте», я уже приводил приёмы работы с правилами для загрузки паттернов, в этом проекте я так же использовал JSONL-файл с описанием паттернов для определения сущностей.

Примеры таких правил:

  • Модальные глаголы (Modal): Мы ищем слова вроде «должен», «обязан» для маркировки обязательств.

    {"label": "Modal", "pattern": [{"LEMMA": {"IN": ["должный", "должна", "обязан"]}}]}
  • Ограничения (Restriction): Запреты и ограничения формулируются через «запрещено», «не допускается», а также количественные ограничения («не более»).

    {"label": "Restriction", "pattern": [{"LEMMA":{"IN":["запрещать","запретить"]}}]}
  • Объекты требований (TargetObject): В ГОСТах требования часто привязаны к конкретному оборудованию или программному обеспечению. Мы извлекаем эти сущности, чтобы потом связывать требование с его носителем. В реальности паттерн содержат достаточно большой перечень слов и словосочетаний.

    {"label": "TargetObject", "pattern":[{"LEMMA":{"IN":["изделие","система", "программа"]}}]}

Все паттерны загружаются в отдельный span_ruler. После обработки текста логика проверки строится на системе весов: каждый найденный лингвистический маркер прибавляет или вычитает баллы из общего результата.

Например:

  • Маркер «Restriction» (ограничение, запрещение) — самый сильный якорь для требования. Он даёт большой вес и сразу выводит текст в категорию требований.

  • Маркер «MetaText» (мета-текст, ссылки на другие разделы) — работает как антипаттерн; если он есть без других маркеров, текст помечается как «прочий».

Также учитывается контекст. Если встречается модальный глагол (Modal), но нет условий или ограничений, он может относиться к рекомендациям («можно», «следует»), а не жёстким требованиям.

Пример весов:

REQUIREMENT_WEIGHTS = {
    "Restriction": 1.0,     # абсолютный якорь требования
    "Normative": 0.7,       # нормативная формулировка
    "Modal": 0.4,           # должен / следует (контекстно)
    "Condition": 0.25,      # при, в случае (усиливает)
    "TargetObject": 0.15,   # объект требования
    "MetaText": -0.9        # сильный антипаттерн
}

Для финальной оценки используются пороги для принятия окончательного решения:

  • req_score >= 0.7Требование.

  • Остальное — Описание/Прочее (отбрасывается ).

Работа с весами позволяет гибко настраивать чувствительность алгоритма для варианта, если мы хотим найти всё возможное, можно снизить порог до 0.5; если нужна только жёсткая норма — поднять до 0.8.

Вот упрощённый, но рабочий кусок классификатора:

def classify_text(doc):
    labels = [span.label_ for span in doc.spans.get("req_scoring", [])]
    label_set = set(labels)

    # Сразу найден метатекст
    if "MetaText" in label_set and "Restriction" not in label_set:
        return {"class_": "other", "requirementScore": 0.0, "needScore": 0.0}

    # Базовый скоринг
    req_score = sum(REQUIREMENT_WEIGHTS.get(l, 0) for l in label_set)

    # Дополнительные якоря 
    if "Normative" in label_set and "TargetObject" in label_set:
        req_score += 0.3

    # Ограничение отрицательных значений
    req_score = max(req_score, 0.0)

    # Финальное решение (порог 0.7 выбран эмпирически)
    if req_score >= 0.7:
        return {"class_": "requirement", "requirementScore": req_score}
    else:
        return {"class_": "other"}

Итог реализации шага 3

Так же как и проверка, скоринг реализован как отдельный Python-микросервис со своим REST API. n8n дёргает его через HTTP-ноду. После возврата информации найденные блоки, которые похожи на требование, сохранялись в MongoDB.

Итоговая цепочка в n8n

Вот как выглядит финальная цепочка:

Схема

Схема

Каждый шаг — отдельная нода. На каждой ноде видно входящие и исходящие данные — это позволяет быстро найти, где что-то пошло не так. Например, обнаружил, что первичная фильтрация пропускала оглавление (там тоже есть номера разделов), и дописал отдельный фильтр по длине контента раздела.

Отдельно о двух микросервисах. Я специально вынес pymorphy3 и spaCy в отдельные Docker-контейнеры с REST API, а не встроил прямо в n8n через Code-ноду для обеспечения:

  • Изоляции зависимостей. Python-окружение с ML-библиотеками — своя экосистема, которую не хочется тащить в n8n.

  • Переиспользования. Тот же сервис можно дёргать из других workflow или из любого другого места.

  • Тестирования. REST API тестируется независимо, через обычный HTTP-клиент.

Результаты

Метрика

LLM-подход

NLP-подход

Время на один документ

7–10 минут

~20-30 секунд

Ускорение

от 15 до 20 раз

Галлюцинации / дополнения

Систематически

Отсутствуют (но возможны ошибки извлечения)

Дословность извлечённого текста

Нет

Да

Предсказуемость результата

Низкая

Высокая

Расчётное время на 54 тыс. документов (минимально)

~ 2-3 мес.

~ 15 дней

Пятнадцать дней — это без параллелизации, один поток. С несколькими параллельными потоками реальное время сократилось до 10 дней.

Из 54 тысяч обработанных документов, всего получилось 337 тысяч записей, это хороший объём для последующей обработки и построения датасета.

Но цифры — не главное. Главное другое: результат стал детерминированным. Обработка одного и того же документа всегда даёт один и тот же результат.

Заключение

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

Несколько выводов, которые я для себя сделал:

LLM полезна как консультант. Именно языковые модели помогли сформулировать альтернативное решение — расписали варианты инструментов, объяснили плюсы и минусы, помогли доработать регулярку.

Классические инструменты недооценены. Регулярки, морфологические анализаторы, правила — это предсказуемо, а в продакшне предсказуемость ценнее магии LLM.

Гибрид — лучший вариант. Самый рабочий сценарий на практике:

  • классический NLP → для массовой обработки,

  • LLM → точечно, там где действительно нужна интерпретация (например, для сложных или спорных случаев).

Иногда самый быстрый способ ускорить систему в 20 раз — это просто перестать использовать LLM там, где она не нужна.