dockling
Русский | English
Локальная обработка PDF для агентов и RAG: конвертация в структурированный Markdown (на Docling) и граф документа, который агент читает через MCP — по узлам, а не «прочитай весь Markdown целиком».
Всё считается локально: ни LLM, ни API-ключей, ни облака. Граф собирается из уже
готового DoclingDocument как обычный JSON, поэтому его можно строить и там, где
Docling не установлен.
| Компонент | Что делает |
|---|---|
pdf2md.py |
PDF → Markdown: текст, рисунки, таблицы, подписи; артефакты рядом (artifacts/, tables/, captions.md, suspicious.md) |
check_run.py |
проверка готового прогона: файлы, ссылки, покрытие страниц, таблицы |
docgraph.py |
граф документа из кэша DoclingDocument: узлы, рёбра, проекции, GraphML, режим чтения для агента |
mcp_docgraph.py |
MCP-сервер над графом: corpus, overview, search, node, tree
|
Требования
- Python 3.10+ (проверено на 3.10–3.13), Windows / Linux / macOS;
- для конвертации — Docling 2.130.0 (см.
requirements.txt), ~2 ГБ с моделями; при первом запуске скачиваются HF-модели (~500 МБ), каталог задаётHF_HOME; -
для графа, MCP-сервера и тестов сторонние зависимости не нужны — только
стандартная библиотека (
networkx— лишь для экспорта в GraphML).
Установка
python -m venv .venv
.venv\Scripts\activate # Linux/macOS: source .venv/bin/activate
pip install -r requirements.txt # только для конвертации
Без установки, через uv:
uv run --with docling python pdf2md.py <input.pdf> <out_dir>
Быстрый старт
# 1. PDF → Markdown (+ кэш DoclingDocument, картинки, таблицы)
python pdf2md.py <input.pdf> docling_output
# 2. граф документа из кэша — Docling уже не нужен
python docgraph.py "docling_output/<имя>/<имя>.json" --view skeleton
# 3. спросить граф как агент: структура, поиск, узел
python docgraph.py "docling_output/<имя>/<имя>.json" --tree
python docgraph.py "docling_output/<имя>/<имя>.json" --search "verification"
python docgraph.py "docling_output/<имя>/<имя>.json" --show texts/12
В примерах ниже встречается
req_dev.pdf— локальный эталонный документ; в репозиторий он не входит (статья под авторским правом). Подставьте свой PDF.
Ключи:
-
--no-cache— игнорировать JSON-кэш и переконвертировать PDF заново; - подписи рисунков по умолчанию привязываются к изображению прямо в Markdown —
<figure><img …><figcaption>…</figcaption></figure>— с относительными ссылкамиartifacts/…, а не только попадают вcaptions.md. Плоский абзац подписи при этом убирается, чтобы она не дублировалась; -
--no-captions-inline— вернуть прежний плоский вывод (и подпись отдельным абзацем) — для сравнения с прежним поведением; -
--captions-inline— устаревший флаг, оставлен для совместимости: привязка подписей теперь поведение по умолчанию; -
--fast— быстрый режим (в 3–5 раз): отключает разбор таблиц (do_table_structure) и генерацию изображений, поэтому рисунков вartifacts/и CSV/HTML-таблиц не будет — подходит для быстрой проверки текста; -
--no-ocr— отключить распознавание текста (OCR). Быстрее на PDF с текстовым слоем; цена — текст внутри растровых страниц и рисунков остаётся нераспознанным. Замер на INCOSE Guide, 10 страниц: 165 с против 205 с с OCR; структура таблиц и число замечаний те же, потому что таблицы этих страниц брались из текстового слоя — растровые таблицы без OCR всё равно теряются; -
--pages N— конвертировать только первые N страниц (для пробных прогонов на больших PDF). Требует необязательный пакетPyPDF2: без него печатается предупреждение в stderr и конвертируется весь документ; -
--raw-tables— выгружать таблицы ровно так, как их разметил Docling, без нормализации по геометрии ячеек (см. «Таблицы» ниже): нужно только для сравнения с прежним поведением; -
--no-toc-repair— не восстанавливать оглавление из текстового слоя PDF (см. «Оглавление» в разделе «Таблицы»). Флаг удобен, чтобы увидеть, что делает Docling сам, и не зависеть от текстового слоя на сканах без него.
Кэш имеет приоритет над --fast/--pages/--no-ocr: если <name>.json уже есть,
документ берётся из него, поэтому для пробного прогона добавляйте --no-cache.
Диагностика
Ход работы идёт в stdout, а шум — в stderr, поэтому они не перемешиваются:
- прогресс по таблицам —
tqdmв stderr; - во время конвертации каждые 10 секунд в stderr пишется RAM процесса
(
[RAM ××MB],[RAM +×MB], при превышении 4 ГБ —[RAM ××MB ⚠ >4GB]), по завершении — пик RAM и совет использовать--fast.
Структура результата
Каждый документ получает свою папку <out_dir>/<name>/, поэтому рисунки и
таблицы разных PDF не перемешиваются:
Файл/каталог (в <out_dir>/<name>/) |
Назначение |
|---|---|
<name>.md |
основной Markdown: текст, рисунки (<figure> с относительными ссылками artifacts/…), markdown-таблицы |
<name>.json |
кэш DoclingDocument — повторный запуск без переконвертации |
artifacts/ |
изображения рисунков (PNG) |
tables/ |
таблицы: table_NN.csv + table_NN.html
|
captions.md |
подписи к рисункам и таблицам |
suspicious.md |
отчёт о подозрительных местах для ручной сверки |
<name>.graph.json |
граф документа (слой A, docgraph.py) — все элементы |
<name>.graph.skeleton.json |
проекция графа: структура и медиа (--view skeleton) |
<name>.graph.blocks.json |
проекция графа: разделы со склеенным текстом (--view blocks) |
Пример: pdf2md.py req_dev.pdf docling_output → docling_output/req_dev/req_dev.md.
Таблицы
Таблицы выгружаются не «как получилось у Docling», а нормализованными по
геометрии ячеек (table_cells в кэше):
- объединённые ячейки (
col_span/row_span> 1) больше не размножаются по всем колонкам — текст попадает в первую из них, остальные остаются пустыми. Без этого шапка оглавления превращалась в три одинаковые колонки; - точечные выноски оглавления (
1.1 PURPOSE AND SCOPE ....... 7) вырезаются, номера страниц остаются отдельной колонкой; - если геометрии в кэше нет или нормализация упала, таблица выгружается прежним
путём (
export_to_dataframe) — прогон не ломается, причина попадает в stderr; - одна и та же нормализованная таблица идёт во все три вида:
tables/table_NN.csv,tables/table_NN.htmlи markdown-блок## Таблица Nв основном файле; именно её видят эвристики (suspicious.md,check_run.py), поэтому ложных «подозрительных годов» из оглавления больше нет.
Флаг --raw-tables возвращает выгрузку без нормализации — для сравнения.
Оглавление
Оглавление Docling разбирает как обычную таблицу, и на INCOSE Guide его строки расползаются: TableFormer объединяет по 2–3 визуальные строки в одну ячейку, номера страниц уезжают в отдельные строки. Такие ячейки починить нельзя — поэтому оглавление собирается заново из текстового слоя PDF (pdfium, он и так идёт с Docling), а Docling-таблица выбрасывается:
- таблица считается оглавлением, если минимум в 15 % ячеек есть точечная
выноска (
is_toc_cells); - страницы берутся из
provтаблицы, символы страницы собираются в строки по базовой линии, а текст строки запрашивается у pdfium по bbox всей строки — так выравненные пробелы и слова с разрядкой (PURPOSE, а неP URPOSE) остаются целыми; - из строки достаются номер пункта (
1.2,B.2), название и номер страницы; строки без выноски (колонтитулы, «Table of Contents») отбрасываются; - результат — таблица
| Номер | Название | Страница |вtables/, в разделе «Таблицы» и на месте оглавления в теле Markdown (заменяются только блоки-таблицы с выносками, ровно столько, сколько восстановлено).
На INCOSE Guide это 140 записей: 50 + 55 + 35 на страницах 3–5. Если текстового
слоя нет (скан) или pypdfium2 не установлен, остаётся прежняя выгрузка Docling,
а причина попадает в stderr.
Кэш
Повторный запуск берёт документ из <name>.json и выполняется быстро.
Кэш привязан к имени входного файла: после смены PDF или настроек конвертации
используйте --no-cache, иначе результат возьмётся из старого кэша. Перед
записью результатов очищаются только управляемые каталоги artifacts/ и
tables/; прочие файлы в папке документа не трогаются.
Коды возврата
-
0— успех; -
2— неверные аргументы или вход (файл не найден / не PDF); -
1— ошибка конвертации или экспорта части артефактов (детали в stderr и в итоговом выводе).
Тесты
python -m unittest discover -s tests -v
Тесты покрывают чистые функции (без загрузки моделей) и не требуют Docling.
В tests/test_check_run.py — тесты инструмента проверки ниже, в
tests/test_docgraph.py — тесты сборки графа документа.
Проверка готового прогона
check_run.py читает уже сконвертированную папку и говорит, всё ли на месте.
Docling не нужен: только json, csv и re, поэтому инструментом можно
проверить и чужой прогон. Что смотрит:
- покрытие страниц провенансом (страницы, на которые не ссылается ни один элемент), элементы по типам;
- таблицы: размеры, пустые ячейки, наличие подписей; рисунки — то же плюс наличие изображения в кэше;
- ссылки в Markdown (относительные,
file:///, процентное кодирование; и markdown-ссылки, и теги<img src="…">) — рабочие, внешние и битые; - файлы в
artifacts/иtables/, разборsuspicious.mdпо категориям; - повторный прогон эвристик (
find_suspicious) по CSV-таблицам — видно, как изменение правил влияет на уже готовый прогон.
python check_run.py "docling_output/<имя>"
python check_run.py "docling_output/<имя>" --json
Коды возврата: 0 — расхождений нет; 1 — есть (битые ссылки, страницы без
элементов, таблицы без CSV и т. п.); 2 — папка не найдена.
Граф документа (слой A)
docgraph.py читает готовый кэш DoclingDocument (<name>.json) и собирает
из него детерминированный граф документа. Ни LLM, ни Docling, ни новых
зависимостей: кэш разбирается как обычный JSON (для GraphML нужен networkx,
но он необязателен).
python docgraph.py "docling_output/<имя>/<имя>.json"
python docgraph.py "docling_output/<имя>/<имя>.json" --view skeleton
python docgraph.py "docling_output/<имя>/<имя>.json" --view blocks --graphml
Узлы: document, page, group, section (из section_header), text,
table, picture, caption. Рёбра: contains (вложенность по parent),
next (порядок чтения), on_page (страница из prov), captions (таблица или
рисунок → подпись).
Что важно в узлах:
-
content_layer—bodyилиfurniture(колонтитулы и подвалы); -
in_reading_order—false, если элемент не попал вbody.children. Такие элементы остаются в графе: у INCOSE Guide это 611 элементов (602 текстовых, 6 подписей, 2 заголовка, 1 таблица) плюс отдельно 108 служебных узлов страниц — ~11 % символов текста документа; уreq_dev— 53 (52 текста и 1 колонтитул) плюс 14 страниц. Терять их нельзя (они и есть самая частая причина «пропавшего» текста); -
walk— позиция в полном порядке обхода,nextидёт строго по ней; - у таблиц и рисунков — относительные пути к готовым артефактам
(
tables/table_NN.csv,artifacts/image_NNNNNN_*.png), если они есть: кэш хранит рисунки какdata:-URI, и в граф этот блоб не попадает; -
level— уровень раздела,text— полный текст элемента.
Виды (--view):
| Вид | Что получается | Файл |
|---|---|---|
full (по умолчанию) |
канонический граф: все элементы | <name>.graph.json |
skeleton |
структура и медиа: документ, страницы, группы, разделы, таблицы, рисунки, подписи; next строится заново по порядку чтения |
<name>.graph.skeleton.json |
blocks |
разделы со склеенным текстом абзацев (block_text, block_chars); текст до первого заголовка — блок #/preamble
|
<name>.graph.blocks.json |
Проекции считаются из полного графа в памяти, кэш повторно не читается.
Формат — node-link JSON (nodes + links, тип ребра в links[].type), годный
для networkx.node_link_graph. Флаг --graphml дополнительно пишет GraphML.
Прогоны: INCOSE Guide — 2351 узел и 6787 рёбер в full, 554/1396 в skeleton,
395/792 в blocks (340 разделов, из них 308 с непустым текстом; максимум
21 961 симв.); req_dev — 221/624, 48/105, 27/52 (17 разделов).
Коды возврата: 0 — успех; 2 — неверные аргументы или вход (файл не найден,
не JSON, не кэш DoclingDocument); 1 — ошибка записи файла.
Просмотр графа
Любой из флагов --tree, --search, --show включает режим просмотра: файлы
не пишутся, срез графа печатается в stdout (с --json — машиночитаемо, для
скриптов и агентов).
# иерархия документа по рёбрам contains, в порядке чтения
python docgraph.py "docling_output/<имя>/<имя>.json" --tree
python docgraph.py "docling_output/<имя>/<имя>.json" --tree --depth 1
# поиск по тексту: подстрока (регистр не важен) либо --regex
python docgraph.py "docling_output/<имя>/<имя>.json" --search "verification"
python docgraph.py "docling_output/<имя>/<имя>.json" --search "R[0-9]{3}" --regex --limit 0
# один узел: по id, по self_ref или по уникальному хвосту
python docgraph.py "docling_output/<имя>/<имя>.json" --show "texts/123"
python docgraph.py "docling_output/<имя>/<имя>.json" --search "requirement" --json --limit 50
-
--treeидёт по иерархииcontainsи печатает строки вида- [text] Заголовок — стр. 5, 120 симв.; элементы не изbody.children— с пометкой «вне порядка чтения», колонтитулы — «колонтитул».--depth Nограничивает глубину (0, по умолчанию, — без ограничения); -
--searchищет поtitle,textиblock_text(в выдачу идёт первое совпавшее поле), в порядке чтения, и показывает фрагмент вокруг совпадения (--snippet N, по умолчанию 160 символов).--limit Nограничивает число показанных совпадений (0— все), общее число печатается всегда. По INCOSE Guide--search "requirement"находит 877 совпадений примерно за 0,4 с; -
--showпечатает краткую строку узла и все его поля в JSON (с--json— только JSON узла); -
--jsonдля--treeдаёт вложенный JSON (children), для--search—matchesплюсcount/returned.
Коды возврата в режиме просмотра: 0 — есть что показать; 1 — поиск ничего
не нашёл; 2 — неверные аргументы (конфликт режимов, пустой шаблон, плохое
регулярное выражение, отрицательные --depth/--limit/--snippet) или узел
не найден.
MCP-сервер (граф для агента)
mcp_docgraph.py отдаёт граф документа по MCP (stdio, JSON-RPC, только
стандартная библиотека): агент сам решает, когда посмотреть структуру, найти
фрагмент или поднять узел по id, вместо чтения всего Markdown целиком.
# запуск вручную (сервер читает построчный JSON-RPC со stdin)
python mcp_docgraph.py --root docling_output
Папки с документами задаются позиционно или флагом --root (можно несколько
раз); без аргументов и без переменной DOCGRAPH_ROOTS берётся текущая папка
проекта, рекурсивно до глубины --scan-depth (по умолчанию 3). Находятся оба
вида файлов: кэш <имя>.json (граф собирается на лету) и готовый
<имя>.graph*.json. По умолчанию предпочитается кэш Docling, с флагом
--prefer-graph — готовый граф (он весит в разы меньше: на этом корпусе 49,9 МБ
против 153,8 МБ). --max-text N ограничивает длину
текста в ответах (0 — без обрезки).
Разобранные документы сервер держит в ограниченном кэше: --max-cached-docs N
(сколько документов, по умолчанию 3) и --max-cached-mb N (потолок суммарного
веса, по умолчанию 64); вес каждого документа — размер его исходного json,
вытесняется самая давняя запись. На корпусе из семи документов без потолка
сервер забирал ~125 МБ и рос дальше, с потолком — 70–85 МБ; в паре с
--prefer-graph corpus отвечает за 0,8 с вместо 4,5 с. Инструмент corpus
показывает число узлов и страниц, поэтому разбирает весь корпус целиком —
именно на нём разница между кэшем и готовым графом заметна сильнее всего.
Сервер ничего не пишет на диск, диагностика идёт в stderr, ответы — в stdout.
Инструментов пять, намеренно мало:
| Инструмент | Что делает |
|---|---|
corpus |
какие документы видны: имя, путь, число узлов/страниц, размер |
overview |
паспорт документа: версия, счётчики, типы узлов, структура (depth) |
search |
поиск по заголовкам и текстам: id, тип, страница, фрагмент; без doc — по всему корпусу |
node |
узел целиком: текст, родитель, соседи, bbox, путь к tables/*.csv и artifacts/*.png
|
tree |
иерархия от документа или от узла (id, depth); служебные узлы страниц не показываются |
Каждый ответ несёт готовую ссылку на источник с реквизитами места, а не только
id и страницу:
-
corpusиoverview— строкассылка: [документ](file:///…/имя.md)(overviewещё ифайл:— исходный PDF/DOCX); -
search— у каждого совпаденияссылка: [документ, разд. «глава», абз. N, стр. P — id](file:///…/имя.md)(и при поиске по одному документу, и по всему корпусу); -
node— строкаисточник: …сразу после метаданных и строкараздел: «…» — id, стр. P; -
tree— строкаисточник: …первой строкой.
Подпись документа в ссылке — короткая: скобочные уточнения ((2019, INCOSE Publications Office)), дублирующийся префикс (INCOSE - INCOSE Guide …) и хвост
источника (… - libgen.li) убираются, длинное имя обрезается до 60 знаков, а id
идёт без имени документа (— /texts/966) — полное имя всё равно видно в самой
ссылке file:///….
Реквизиты — это «глава и параграф»: раздел берётся по порядку чтения (последний
заголовок перед узлом; колонтитулы и повторяющиеся служебные заголовки
отбрасываются), номер — абз. N для текста и подписи, табл. N / рис. N для
таблиц и рисунков (родной номер из Docling, иначе порядковый номер в разделе),
стр. P — страница узла.
Ссылка ведёт на <имя>.md рядом с кэшем (для .graph*.json — на <имя>.md
без суффикса графа) и открывается из ответа в редакторе. Инструкция сервера
(initialize.instructions) требует от агента переносить эту строку в ответ
человеку и заканчивать такой ответ списком источников; если search ничего не
нашёл — значит этого нет в корпусе, и подставлять источник нельзя.
Подключение к MCP-клиенту (пример — секция mcpServers в конфиге клиента):
"docgraph": {
"command": "/абсолютный/путь/к/dockling/.venv/bin/python",
"args": [
"/абсолютный/путь/к/dockling/mcp_docgraph.py",
"--root",
"/абсолютный/путь/к/docling_output"
],
"exposure": "direct",
"description": "dockling: structure, text search and provenance over converted documents."
}
Путь к интерпретатору указывайте абсолютный (в Windows —
...\python): MCP-клиент запускает сервер вне активированного
окружения. Папки документов можно не перечислять — без --root сервер обходит
текущую папку проекта.
Сервер входит в поставку пакета: если он установлен (pip install . или
pip install -e .), доступна команда docgraph-mcp — тогда вместо пары
абсолютных путей в конфиге клиента достаточно "command": "docgraph-mcp"
с теми же аргументами. В PyPI пакет пока не опубликован (после публикации
запуск станет таким: uvx --from dockling docgraph-mcp).
Ограничения OCR
Распознавание таблиц основано на моделях Docling (TableFormer + OCR) и не
является достоверным источником: годы, имена и значения в таблицах могут быть
искажены. Перед использованием сверяйте таблицы с исходным PDF; подозрительные
места автоматически перечисляются в suspicious.md, но значения, искажённые
без нарушения формата (например, «1988» вместо «1985»), детектор не ловит —
их нужно проверять визуально.
Поиск искажённых годов идёт только в тех столбцах, где есть хотя бы одно значение с годом 19xx/20xx: иначе номера страниц и пунктов дают ложные срабатывания на каждом документе с оглавлением. Обратная сторона — столбец, в котором искажены все годы до единого, распознан не будет.
Структура репозитория
pdf2md.py конвертер PDF → Markdown (CLI)
check_run.py проверка готового прогона
docgraph.py граф документа из кэша DoclingDocument (CLI + режим чтения)
mcp_docgraph.py MCP-сервер над графом (stdio)
.gitlab-ci.yml CI на GitLab (hub.mos.ru): тесты на Linux
tests/ юнит-тесты (unittest, без сторонних зависимостей)
plans/ планы развития
docling_output/<имя>/ результат прогона: .md, .json (кэш), artifacts/, tables/, captions.md
Разработка
python -m unittest discover -s tests -v # 373 проверки, ~0.9 с, без зависимостей
Тесты проверяют чистые функции: Docling, networkx и pypdfium2 импортируются
лениво, поэтому окружение для тестов не нужно. CI гоняет их на Linux
(.gitlab-ci.yml, job unittest на python:3.12-slim); workflow GitHub
Actions (.github/workflows/ci.yml) оставлен для Windows/Linux и Python
3.10 / 3.12 / 3.13 — на случай зеркала на GitHub.
Правила участия — CONTRIBUTING.md, история изменений — CHANGELOG.md,
устройство проекта и правила для агентов — AGENTS.md.
Лицензия
MIT — см. LICENSE (то же выражение указано в метаданных pyproject.toml).
Лицензия распространяется на код репозитория; материалы документов, попавших
в docling_output/, подчиняются лицензиям самих документов.