Небольшой стенд для честного замера: как модели читают настоящие документы. 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.cerzakupki.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 пропускает ловушки, которые никто не подтвердил как неотвечаемые.
Черновая ловушка, ответ на которую в документе всё-таки есть, превращает верный
ответ в засчитанную выдумку — то самое число, ради которого всё это писалось.
Все три молчат: модель отвечает уверенно, ошибку видно только по счётчику токенов.
- Контекст по умолчанию. У Ollama это 4096 токенов. Медианный договор
выборки — 13 287. Из 50 договоров целиком помещается один; остальные
модель читает на четверть и не предупреждает. Лечится
num_ctxв Modelfile. - Два текстовых блока в запросе. OpenAI-совместимый эндпоинт Ollama
выбрасывает содержимое целиком: 2050 токенов превращаются в 22.
bench/providers.pyсклеивает соседние текстовые блоки в один. - Режим размышлений.
/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 не написаны.
Один прогон локальной модели без повторов. Документы русские: на английских
договорах числа будут другими.
Это выборка и задания одного человека. Смысл публикации в том, чтобы прогнать свою модель на том же наборе и сравнить.