Приветствую всех читателей Хабр.
Думаю, многим знаком этот сценарий: появляется задача — и первая мысль: «скормлю все LLM, она разберётся». Поначалу получается красиво, всё работает и есть первые результаты. Потом начинаешь проверять детали и замечаешь, что модель местами добавляет текст от себя. Потом смотришь на затрачиваемое время и понимаешь, что при текущей скорости обработка всего объёма документов закончится через год.
Именно в такой ситуации я оказался, когда захотел обработать базу ГОСТов.
Эта статья — про то, как я прошёл путь от «кидаем всё в LLM» до детерминированного пайплайна на классических NLP-инструментах. И про то, как в этом помогли те же самые языковые модели — но уже в роли консультантов, а не рабочей лошадки.
Задача
В архиве лежало порядка 54 тысяч PDF-файлов — ГОСТы и другие нормативные документы. Нужно было найти в них текстовые фрагменты, похожие на требования, извлечь их и зафиксировать с указанием конкретного раздела, откуда они были взяты.
В качестве среды для эксперимента я использовал n8n. Он удобен тем, что содержит множество готовых узлов для работы как с данными, так и с AI-агентами. Кроме того, на каждом шаге запуска можно посмотреть, что происходит с данными, увидеть входящий и исходящий JSON и быстро подправить алгоритм работы цепочки в случае необходимости. Для задач, где много итераций и гипотез, это сильно ускоряет работу.
Конфигурация стенда выглядела так: 2 x GPU серии RTX 5060 с 16 ГБ памяти, модель Qwen3-30B A3B Q6K, запущенная локально через LM Studio.
Я планировал быстро собрать рабочую цепочку и запустить обработку всего массива документов, и в целом задача выглядела несложно, но дьявол, как обычно, кроется в деталях.
Версия решения 1.0: «скормим всё в LLM»
Первая реализация цепочки выглядела предельно просто:
Забираем PDF из хранилища (у меня это был S3-совместимый storage)
Извлекаем текст с помощью стандартной ноды
Extract From FileГотовим и предварительно фильтруем текст
Передаём в LLM с инструкцией извлечь требования в JSON (рабочий промпт получился достаточно большой, он разрабатывался и дорабатывался с помощью LLM)
Собираем результат.
Первая же проблема вскрылась на этапе извлечения текста. При обработке 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.2.аилиПриложение А), выражение их не захватит, потому что паттерн\d+ожидает только цифры. Я решил не усложнять выражение и сознательно игнорировать такие пункты.Вложенные списки внутри раздела: Выражение захватывает весь текст до следующего номера раздела, но если внутри есть маркированные списки с точками (например,
•или-), они остаются внутри блока. Это не ошибка, но может помешать последующей обработке. Как правило, такие большие блоки будут отфильтрованы позже.
Обработка текста с помощью регулярного выражения была реализована в виде 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 там, где она не нужна.
