Skip to content

Repository files navigation

llm-docs-bench

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

A small benchmark for reading real Russian procurement documents: 50 contracts, half of them scans. Measures how often a model invents an answer that is not in the document. Русскоязычный README ниже.

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


Что здесь замерено, а что нет

Репозиторий выкладывается вместе с роликами, и граница между сделанным и задуманным проведена явно.

Состояние
50 документов, манифест и загрузчик ✅ готово
60 заданий «извлеки поле» ✅ написаны и прогнаны
25 ловушек + 4 пресуппозиционные ✅ написаны и прогнаны
45 заданий qa, 20 retrieval ⬜ болванки без вопросов
Ручные эталоны (gold.jsonl) не размечены, файл пустой
Человеческий прогон с таймером ⬜ не делался
Облачные модели ✅ 21 задание, из них 6 в трёх повторах
Локальная модель ✅ 60 заданий, один прогон

Как считается точность без ручных эталонов. verify_text.py проверяет, что значение из ответа дословно встречается в тексте документа. Это слабее ручной разметки — метод не ловит случай «взято не то число, но оно в документе есть» — зато не требует размеченного эталона и полностью воспроизводим. Все опубликованные проценты получены так, и это сказано рядом с каждым числом.

Результаты и способ проверки каждого числа: results/RESULTS.md


Что измеряется

Тип Что спрашивают Считается верным, когда
field номер, стороны, сумма, срок значение находится в тексте документа
trap то, чего в документе нет модель отказалась отвечать
qa вопрос по тексту вопросы не написаны
retrieval найти ответ по всем 50 документам вопросы не написаны

trap — то, ради чего всё затевалось. Неверный ответ стоит одной правки. Уверенно выдуманный стоит доверия ко всей системе. Отказ засчитывается как правильный ответ, любой другой — как выдумка, и считается отдельной колонкой.

Форма вопроса решает больше, чем модель. «Есть ли в договоре штраф за X?» даёт отказ. «Каков размер штрафа за X?» — заставляет назвать число. Второе формулирование в замере дало 3 выдумки из 4 там, где первое дало 1 из 60.


Равные условия

  • Документ уходит картинками страниц, а не через разбор PDF на стороне провайдера, чтобы никому не достался парсер получше.

  • Берутся первые 8 и последние 4 страницы. Стороны и даты живут в преамбуле и на подписи; середина сорокастраничной спецификации покупает только токены. Настраивается через head_pages / tail_pages.

  • Со всех спрашивается один и тот же строгий JSON, чтобы отказ определялся структурно, а не угадывался по формулировке:

    {"answer": "" | null, "evidence": "…" | null, "doc_id": "…" | null}

Оговорка про локальные модели. Текстовая модель картинок не принимает, поэтому получает текст тех же самых страниц: слой из PDF для цифровых, распознавание для сканов. Это не одинаковые условия, а разные, и разница названа: у облачной на входе страница, у локальной — то, что распознал OCR. select_text.py следит, чтобы страницы были ровно те же.


Результаты коротко

Раунд 1, извлечение полей, доля ответов, подтверждённых текстом документа:

Модель Заданий Подтверждено
Claude Sonnet 4.6 18 100%
GPT-5.4 mini 18 100%
Gemini 3.1 Pro 18 94,4%
Claude Haiku 4.5 18 66,7%
Qwen3-14B локально 60 66,7%

Ловушки, 4 вопроса, ответов на которые в договорах нет:

Модель Назвала число
GPT-5.4 mini 3 из 4
Claude Haiku 4.5 2 из 4
Claude Sonnet 4.6 1 из 4
Gemini 3.1 Pro 0 из 4
Qwen3-14B локально 0 из 4

Локальная модель ошибается иначе: из 20 незачтённых ответов 17 — отказы и только 3 — неверные значения. Когда она отвечает, она права в 93% случаев.

Числа по разным наборам заданий и потому напрямую не сравниваются — подробности и все оговорки в results/RESULTS.md.


Выборка

Сами PDF в репозитории не лежат: это чужие документы с реальными названиями организаций и фамилиями. Вместо них — docs/MANIFEST.csv со ссылками на первоисточник и загрузчик, который соберёт ту же выборку:

python fetch_eis.py --count 50 --cafile russian_trusted_root_ca.cer

zakupki.gov.ru подписан корневым сертификатом Минцифры, которого нет в системном хранилище. Скрипт не работает молча: он требует либо --cafile с этим корнем, либо явного --insecure, чтобы отключение проверки было осознанным решением.


Как пользоваться

pip install -r requirements.txt
cp config.example.yaml config.yaml     # подставьте свои модели, эндпоинты и цены

python fetch_eis.py --count 50 --cafile <корневой сертификат>
python prepare.py                      # отрисовать страницы, вынуть текстовый слой
python ocr.py --lang rus               # распознать сканы (нужен rus.traineddata)
python select_text.py                  # текст ровно тех же страниц — для локальных моделей

python run.py --dry-run                # план, оценка токенов и денег
python run.py --models local14 --workers 1
python verify_text.py --type field     # точность по подтверждаемости текстом
python payback.py --help               # аренда против облака

Ключи берутся только из переменных окружения. config.yaml в .gitignore.

Прерванный прогон продолжается с места остановки: всё, что уже записано в results/raw/, пропускается, если не передать --fresh.

run.py пропускает ловушки, которые никто не подтвердил как неотвечаемые. Черновая ловушка, ответ на которую в документе всё-таки есть, превращает верный ответ в засчитанную выдумку — то самое число, ради которого всё это писалось.


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

Все три молчат: модель отвечает уверенно, ошибку видно только по счётчику токенов.

  1. Контекст по умолчанию. У Ollama это 4096 токенов. Медианный договор выборки — 13 287. Из 50 договоров целиком помещается один; остальные модель читает на четверть и не предупреждает. Лечится num_ctx в Modelfile.
  2. Два текстовых блока в запросе. OpenAI-совместимый эндпоинт Ollama выбрасывает содержимое целиком: 2050 токенов превращаются в 22. bench/providers.py склеивает соседние текстовые блоки в один.
  3. Режим размышлений. /no_think в тексте запроса на Qwen3 не работает — модель всё равно думает и тратит лимит вывода на рассуждения, а на ответ ничего не остаётся и приходит пустая строка. Пустой ответ засчитался бы как отказ, то есть как противоположный вывод. Работает reasoning_effort: none.

Структура

docs/MANIFEST.csv      50 документов: id, файл, скан|цифровой, ссылка, страницы
cases.jsonl            150 заданий без ответов — можно показывать в кадре
gold.jsonl             болванка под ручные эталоны, не заполнена
human/                 пример формата для прогона человека с таймером
bench/                 нормализация, определение отказа, оценка, провайдеры
results/raw/           по одному JSONL на модель и прогон
results/RESULTS.md     числа и способ проверки каждого
build/                 отрисованные страницы и текст (генерируется, не в репозитории)

Границы

Малый объём: 60 заданий на локальной модели и 18 на облачных — разница в один-два процентных пункта здесь шум, в пятнадцать — нет. Облачные и локальная гонялись по разным наборам заданий, поэтому их проценты рядом не ставятся; сопоставимы только ловушки, где вопросы и документы одни и те же.

Ручной разметки нет, человеческого прогона нет, qa и retrieval не написаны. Один прогон локальной модели без повторов. Документы русские: на английских договорах числа будут другими.

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

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages