11 мин чтения

Contextum: конвейер документов в Markdown для AI-агента

AnetLab Contextum: конвейер из пяти стадий между агентом и LLM — конвертация в Markdown, сжатие контекста, идемпотентная доставка и честный реестр потерь.

Виктор Бузин · Founder & Principal Engineer, AnetLab
16+ лет в enterprise-разработке: .NET, AI/LLM-системы, RAG, MCP. Автор материала и практик в студии.

Агент открывает файл — модель читает текст

Агент получает от пользователя файл. Дальше этот файл должен попасть в контекст модели, и вот здесь возникает разрыв: модель не читает PDF. Она читает текст. Всё, что между «файл получен» и «текст в контексте», должен сделать кто-то — и обычно это никто.

Классический сценарий выглядит так: агент или приложение берёт библиотеку, которая «умеет читать PDF», получает строку и передаёт её модели. Дальше начинается тихая порча. Из документа выпадают таблицы, объединённые ячейки, цвет, подчёркивание, над- и подстрочность, векторные картинки, диаграммы. Никто об этом не сообщает, потому что сообщать нечего: библиотека вернула строку, строка выглядит осмысленной, и агент ответил.

Через месяц пользователь получает ответ, в котором нет половины договора, и обвиняет модель. Модель ни при чём: она честно отработала то, что ей дали.

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

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

Почему это отдельный сервис, а не функция агента

Конвертация документа — CPU-ёмкая операция. Рендер страниц, разбор таблиц, построение Markdown забирают процессор целиком. Если этим занимается процесс агента, тяжёлая обработка делит ресурсы с оркестрацией: вызовы моделей, диалог и память начинают ждать конца рендера.

В AnetLab Contextum вынесен ровно этот слой — работа с файлами — в отдельный сервис. Оркестрация диалога, память, инструменты и выбор модели остались в агентском слое. Разделение не про архитектурную красоту: у каждой части своё масштабирование и свои отказы.

Единица масштабирования здесь — роль, а не процесс. Роль CONVERT отвечает только за конвертацию, и если её не хватает, добавляется ещё один процесс CONVERT; COMPRESS, EMBED, INDEX и VAULT при этом не трогаются. Внутри одного процесса верхняя граница параллелизма — 4 задачи одновременно, дальше упираемся в CPU. Гейтвей масштабируется горизонтально: состояние заданий лежит в Postgres, поэтому реплики безопасны.

Гейтвей — единственный, кто касается каталога хранения. Воркеры наружу отдают только диагностику. Между ними — gRPC по protobuf-контракту, один источник правды для Go и .NET.

Пять стадий и флаг честности

Конвейер состоит из пяти стадий, и они не равнозначны:

Стадия Что делает Описание
CONVERT Документ → GFM Markdown Передовой движок (Engine) конвертирует любые офисные форматы данных (PDF, DOCX, XLSX, PPTX и др.) в стандартизированную разметку GitHub Flavored Markdown с полным сохранением исходной структуры, таблиц и форматирования.
COMPRESS Сжатие контекста Двухуровневая оптимизация текста. Включает структурную компрессию (удаление избыточного синтаксического шума разметки) и семантическую компрессию (смысловое сжатие текста и удаление «воды»), что радикально сокращает расход токенов.
EMBED Семантическое чанкирование и векторизация Интеллектуальная нарезка сжатого текста на дискретные смысловые фрагменты (Semantic Chunking). Для каждого чанка генерируются плотные векторные эмбеддинги высокой точности (Embedding Generation), кодирующие чистый смысл информации в многомерном пространстве.
INDEX Запись в векторный индекс Регистрация сгенерированных эмбеддингов в высокопроизводительном векторном индексе. Обеспечивает молниеносный семантический поиск и сопоставление смысловой близости (Similarity Search) для ИИ-агентов.
VAULT запись в базу знаний Надежная транзакционная запись исходного очищенного текста чанков и метаданных в структурированную базу знаний. Выступает в роли структурированного источника правды (Grounding Context) для генеративного ответа LLM-моделей.

Кроме пяти стадий, отдельно поставляется опциональный воркер ANON — обезличивание персональных данных: он находит и маскирует или удаляет ФИО, контакты, адреса и документные данные до того, как текст попадёт в аналитическую обработку. В базовый контракт воркер не входит.

Пока стадия из таблицы работает на встроенном симуляторе, она обязана вернуть simulated = true, и консоль показывает бейдж SIMULATION. Это не отчётность, а часть контракта: стадия, которая ничего не делает, обязана об этом сказать. Предъявлять выдуманные байты и токены как измеренные — нельзя.

Следствие для интегратора формулируется прямо: из Contextum как из источника истины можно брать Markdown (CONVERT) и сжатый Markdown с чанками (COMPRESS). Эмбеддинги, векторный поиск и запись в базу знаний — нельзя, пока эти стадии не заменены настоящими движками. Три из пяти стадий сегодня — заглушки, и это видно в ответе, а не в документации.

Отдельно: PPTX конвертируется в диалект MARP, где выражается больше, чем в GFM — цвет фона слайда, фоновая картинка, цвет текста, размер колоды. Состав потерь у презентации меньше, и это не недосмотр, а свойство формата.

Реестр потерь: что не выразилось в Markdown

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

Вторая часть правила важнее первой: то, что отключено опцией, потерей не считается. Формулы при includeCellFormulas = false — это выбор пользователя, а не дефект конвертации. Разница между «мы не смогли выразить» и «мы не стали выражать» — принципиальная, и в реестре она сохранена.

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

Доставка, которая не может выполнить стадию дважды

Брокер даёт доставку at-least-once: сообщение может прийти повторно. Это не отказоустойчивость, это нормальный режим NATS JetStream. Значит, приёмник обязан быть идемпотентным, и решается это не флагом «уже обработано», а тремя разными механизмами:

Уровень Механизм Что гарантирует
Outbox таблица stage_dispatches + relay публикация не потеряется, если процесс упал между записью и отправкой
Inbox таблица stage_events с уникальным event_id событие не применится дважды
Claim таблица stage_executions с арендой стадия не выполнится дважды одновременно; повтор либо переиграет результат, либо будет пропущен

Ключ claim-таблицы — dispatch_id, и он детерминирован:

dispatch_id = hex(sha256(job_id + "|" + role)[:16])

Это не оптимизация, а условие работоспособности. Повторная публикация после перезапуска обязана породить тот же идентификатор — иначе воркер выполнит стадию повторно как новую, и результат второй стадии просто перезапишет первый. 32 hex-символа — каноническая запись UUID без дефисов, поэтому колонка объявлена как uuid.

Аренда по умолчанию — 600 секунд, и это значение должно быть заметно больше максимального времени стадии. Владелец в строке меняется только в двух случаях: строка в статусе DONE (воспроизведение) или аренда истекла (владелец умер). Во всех остальных случаях пришедшая сторона получает HeldElsewhere, и доставка пропускается с задержкой, а не выполняется поверх чужой работы. Просроченные аренды освобождает фоновый цикл — иначе истечение аренды было бы видно только в момент следующей попытки claim.

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

Метрики: замер, оценка и заявление

В продукте три разных вещи, и их легко спутать. Разделение сделано в контракте, потому что в отчётности эти три числа стоят рядом.

Замер — байты на входе и на выходе, длительность стадии, число чанков. Это факты.

Оценка — токены. Считаются эвристикой «1 токен ≈ 4 символа», и это не токенизатор GPT, Claude или Gemini. Любая цифра, подписанная «токены», — оценка по длине текста. Для прикидки «сколько контекста съест агент» это полезно, для точного биллинга — нет, и называть её «токенами модели» было бы враньём.

Заявление об экономии — свойство стадии COMPRESS и только её. CONVERT честно меряет байты и токены, но экономию не объявляет: сжатие ещё не происходило. Сводные метрики берутся из результата последней измеренной стадии, а не как максимум по стадиям, и в сводке есть два указателя на источник — outputStage и savingsStage. Если интегратор строит интерфейс «экономии контекста», он должен брать totals последней стадии: максимум по стадиям даст цифру, которую сжатие не заслужило.

Проценты экономии мы не публикуем, пока нет замера на реальном корпусе. В документации лежит пример JSON, а пример — не измерение. Число, которое нельзя воспроизвести, на странице продукта выглядит как уверенность, которой нет.

Кому это нужно

Contextum — B2B-продукт: его не покупают поштучно и не для личных целей. Его берёт организация, у которой уже есть LLM-контур или который в проекте.

  • Компании с работающим LLM-контуром. Модель подключена, а документы до неё доходят строкой, потерявшей таблицы и структуру.
  • Компании, которые LLM-контур строят. Контур в проекте, слоя работы с документами в нём нет — это как раз тот момент, когда слой и встраивают.
  • ИИ-провайдеры, которые бустят свои платформы. Файловый слой становится частью того, что провайдер отдаёт своим пользователям, и качество этого слоя становится его ответственностью.
  • ИИ-портальные приложения и внутренние ИИ-проекты, где идёт работа с документами. Договоры, ТЗ, счета, регламенты, отчётность — их приводят к тому, что модель читает, до вызова модели, а не после.

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

Границы, которые мы не прячем

Contextum в статусе POC, и список ограничений — часть продукта, а не сноска. Он отвечает на вопрос «можно ли встраивать уже сейчас», и по части пунктов ответ «нет».

  • Аутентификации нет. Ни по HTTP, ни по gRPC, ни по NATS. Любой, кто знает адрес гейтвея, читает и загружает документы. Допустимо только внутри доверенной сети.
  • TLS и mTLS не включены — сеть считается доверенной.
  • Предел загрузки — 64 МиБ, при превышении приходит 413 TOO_LARGE.
  • Распознавания нет. В объём входит извлечение текстового слоя. OCR и страницы-сканы не заявлены: в контракте нет ни движка распознавания, ни вида потери ocr, и что произойдёт со сканом, документация не описывает.
  • Якоря страниц PDF — вид потери, а не фича. pdf-page-anchor стоит в реестре потерь рядом с color, font, chart и vector-image: в исходном PDF якоря есть, но в Markdown они могут не выразиться. Сколько в документе страниц, листов и слайдов, отвечают unitCount и unitKind (page / sheet / slide).
  • Прогресс — только polling. Вебхуков и SSE нет: состояние забирается через GET /api/v1/jobs/{id}.
  • Автоматической очистки артефактов нет — TTL-сборщик нужен на стороне интегратора.
  • Один процесс — одна роль. Вертикального масштабирования и динамического добавления ролей нет.
  • Размерность вектора и модель эмбеддингов не выбраны — от этого зависит стадия INDEX и выбор индекса.
  • Состояние заданий переживает перезапуск гейтвея только при настроенном ODOC_POSTGRES_DSN. Без него состояние живёт в памяти и теряется — а для интеграции с агентским слоем это недопустимо.

Одна тонкость в контракте стоит отдельного абзаца. Отмена задания — это запрос: CANCEL_REQUESTED означает, что запрос принят, а не что задание уже отменено. Финальный статус станет CANCELLED, но увидеть его можно только следующим опросом. Тот, кто не знает этой детали, напишет код, который отменит задание и сразу прочитает status — и получит не то, что ожидал.

Где мы передумали

Три решения, которые в процессе менялись.

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

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

Честность зашита в флаг, а не в текст. Пока стадия работает на встроенном симуляторе, она возвращает simulated = true. Можно было бы написать в README, какие стадии настоящие, — но текст расходится с кодом при первом же изменении, и никто этого не заметит. Флаг в ответе проверяется машинно.

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

CTA

Карточка продукта с версией, статусом, лицензией и списком стадий — Contextum.

Разбираете, как встроить слой документов в агентскую систему, — напишите нам. Посмотрим, какие стадии вам действительно нужны, какие ограничения критичны для вашего контура и как это ложится рядом с оркестрацией. Пока продукт в статусе POC, договариваться будем о том, что он делает и чего не делает.

Читайте также

Заявка

Обсудить задачу

Оставьте имя и email — отвечу в течение рабочего дня. Если задача срочная, проще написать напрямую наproject@anetlab.ru.