MAATRIX / Блог / Как поднять ИИ-анализ документов на сервере

Как поднять ИИ-анализ документов на сервере

Как поднять ИИ-анализ документов на сервере

MAATRIX

Папка с полусотней договоров, и по каждому нужно вытащить сумму, срок и стороны — не найти нужный файл, а прочитать его и выдать значения. Обычный чат с базой знаний для этого не годится: он ищет похожие куски текста и на один и тот же вопрос честно отвечает по-разному. Ниже — как поднять ИИ-анализ документов на сервере так, чтобы модель читала файл целиком, отвечала предсказуемо и не путала скан с пустым местом.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →

ИИ-анализ документов и RAG-поиск — разные задачи, разная настройка

У AnythingLLM для документа есть два разных режима, и путаница между ними — источник большинства неверных ответов.

  • Найти ответ в базе из тысяч файлов — вопрос «где у нас написано про...». Это RAG-поиск: файл режется на чанки, в ответ идут несколько похожих кусков — по умолчанию четыре, — остального для модели не существует. Ему посвящена статья про RAG по своим документам.
  • Разобрать конкретный документ или партию однотипных — вопрос «что написано в этом договоре про...», файл известен заранее. Это анализ, и весь материал ниже — про него.

Для второго случая в AnythingLLM есть закрепление (pin): документ целиком вставляется в промпт, минуя поиск по кускам. Документация честна: оно даёт «full-text comprehension… at the expense of speed and cost» и рекомендовано «для документов, которые целиком помещаются в контекстное окно или критически важны» — как крайняя мера.

Для ИИ-анализа документов закрепление — не крайняя мера, а режим по умолчанию: не нужно, чтобы модель угадывала, какие 20% договора ей показать, — нужно видеть договор целиком. Дальше — как это настроить и обойти два его ограничения: контекстное окно и сканы без текста.

Почему обычный чат для анализа не годится: пересказ, разный ответ и текст вместо значения

Первая причина — режим чата. По умолчанию пространство отвечает в Chat: по документации, «uses LLM general knowledge w/custom embeddings to produce output» — не найдя пункта о неустойке, модель подставит типовую формулировку из общих знаний, неотличимую в таблице от настоящей цитаты. Query строже: «will not use LLM unless there are relevant sources... & does not recall chat history» — без найденного текста прямо отвечает, что не нашёл. Третий режим, automatic, — для вызова инструментов; для анализа выбор между query и chat, почти всегда в пользу первого.

Вторая причина — температура. У каждого пространства есть параметр openAiTemp, доступный в интерфейсе и в API при создании. По умолчанию у большинства провайдеров он больше нуля — компромисс в пользу живости ответа, а для анализа это риск: один и тот же вопрос об одном договоре дважды при 0,7 даст разный порядок пунктов, а на пограничных формулировках сумм («порядка полутора миллионов» против «1 500 000 рублей») — разное итоговое число. Для пространства-анализатора ставьте openAiTemp: 0.

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

Развернуть за пару минут

Готовый образ на VPS MAATRIX: NVMe, AMD EPYC, root-доступ. Локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Развернуть AnythingLLM

Пространство для анализа: закрепление документа и системный промпт под конкретные поля

Пространство под анализ настраивается один раз — дальше через него проходят десятки документов одного типа. Три поля стоит задать сразу при создании:

curl -s -X POST http://127.0.0.1:3001/api/v1/workspace/new \
  -H "Authorization: Bearer $ALLM_KEY" -H 'Content-Type: application/json' \
  -d '{
    "name": "Анализ договоров",
    "chatMode": "query",
    "openAiTemp": 0,
    "topN": 12,
    "similarityThreshold": 0,
    "queryRefusalResponse": "NO_DATA"
  }'

chatMode и openAiTemp — из раздела выше. queryRefusalResponse меняет фразу отказа: вместо There is no relevant information in this workspace to answer your query. пространство ответит вашей строкой — для скрипта это разница между хрупким сравнением подстроки и точным if answer == "NO_DATA".

Дальше загрузка: POST /v1/document/upload возвращает location — путь вида custom-documents/dogovor-142.pdf-3f9b….json, — а привязка к пространству идёт через update-embeddings с этим путём в массиве adds (подробно — в статье про установку AnythingLLM на VPS). Специфика анализа в том, что происходит дальше: документация прямо оговаривает — закрепить можно только уже вшитый документ, эмбеддинг обязателен, даже если сам поиск не нужен. Закрепление включается пуш-пином в интерфейсе либо тем же вызовом из скрипта:

curl -s -X POST http://127.0.0.1:3001/api/v1/workspace/analiz-dogovorov/update-pin \
  -H "Authorization: Bearer $ALLM_KEY" -H 'Content-Type: application/json' \
  -d '{"docPath":"custom-documents/dogovor-142.pdf-3f9b….json","pinStatus":true}'

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

Ты анализируешь один документ, закреплённый в этом пространстве. Отвечай
ТОЛЬКО валидным JSON без markdown-разметки и пояснений до или после:
{
  "storona_1": "полное наименование первой стороны",
  "storona_2": "полное наименование второй стороны",
  "data_dogovora": "ДД.ММ.ГГГГ",
  "summa": "число без пробелов и валюты",
  "valuta": "RUB|USD|EUR",
  "srok_deystviya": "текст пункта о сроке",
  "usloviya_rastorzheniya": "текст пункта или NO_DATA",
  "citata_summy": "дословная фраза из документа, где указана сумма"
}
Если поля в документе нет — ставь значение "NO_DATA", не придумывай.

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

Документ длиннее окна модели: разбор по частям и двухпроходный анализ

Закрепление честно предупреждает о границе: оно годится, когда документ целиком помещается в контекстное окно. Годовой отчёт на 180 страниц туда не влезет: облачный провайдер вернёт отказ, а локальная модель молча обрежет промпт с начала, и в контексте не окажется первых страниц с реквизитами сторон.

Русский текст даёт примерно 3,5–4 символа на токен — грубая прикидка, не бенчмарк. Договор на 40 000 знаков — это 10–11 тысяч токенов, в окно на 128 тысяч (класс GPT-4o) он войдёт с запасом. А свод регламентов на 600 000 знаков, под 150–170 тысяч токенов, не поместится уже и туда, и тем более в типичные 4–8 тысяч токенов локальной модели через Ollama (num_ctx, разбор — в статье про локальный запуск LLM через Ollama). Превышение лимита у облачных провайдеров выглядит так:

{"type":"error","error":{"type":"invalid_request_error",
"message":"prompt is too long: 215043 tokens > 200000 maximum"}}

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

csplit -z -f chast- -b '%02d.md' otchet.md '/^## Раздел/' '{*}'
for f in chast-*.md; do
  # для каждой части: upload → update-embeddings → update-pin → chat (query)
  # результат — JSON, накапливается в results.jsonl
  :
done
jq -s '.' results.jsonl > svod.json

Второй проход — запрос той же модели с промптом «вот JSON-объекты по частям одного документа, собери их в одну запись, убери дубли, суммируй числовые поля» и svod.json в теле. Разница с нарезкой из статьи про RAG принципиальна: там она нужна, чтобы поиск быстрее находил релевантный кусок, здесь — чтобы ни один кусок не остался непрочитанным.

Сканы и фото без OCR: анализ напрямую через vision-модель

В статье про RAG есть подробный OCR-конвейер — ocrmypdf плюс tesseract-ocr-rus — для поиска по архиву сканов это правильный путь. Для анализа одного скана есть путь короче: показать его модели напрямую, если она умеет читать изображения.

Условие одно — модель должна быть мультимодальной (vision): из облачных это GPT-4o и семейство Claude, из локальных — модели с пометкой vision в реестре Ollama, а в приложение по умолчанию добавлена LLaVA-Llama3 как встроенный вариант. В интерфейсе изображение перетаскивается в чат вместе с вопросом; через API это отдельное поле в теле запроса:

IMG=$(base64 -w0 skan-schet.png)
curl -s -X POST http://127.0.0.1:3001/api/v1/workspace/analiz-schetov/chat \
  -H "Authorization: Bearer $ALLM_KEY" -H 'Content-Type: application/json' \
  -d @- <<EOF
{
  "message": "Извлеки номер счёта, дату и итоговую сумму. Ответ — JSON.",
  "mode": "chat",
  "attachments": [{
    "name": "skan-schet.png",
    "mime": "image/png",
    "contentString": "data:image/png;base64,${IMG}"
  }]
}
EOF

Обратите внимание на mode — для картинки это chat, не query: поиск изображение не индексирует, и Query честно ответит отказом, ведь «relevant sources from vectorDB» у скана попросту нет. Проверка формата минимальна — MIME должен начинаться с image/, — а если модель картинки не понимает, откажет уже сам провайдер своей ошибкой.

Многостраничный скан одним файлом не отправить — вложение ждёт изображение, не PDF на полсотни страниц. Разбейте на страницы заранее:

pdftoppm -png -r 150 skan-dogovora.pdf stranica

Флаг -r 150 — разрешение: ниже мелкий шрифт и цифры в таблицах модель читает с ошибками, для рукописных пометок разумно поднять до 200–300, но и вес файла растёт кратно. Честная граница: точность зависит от качества скана сильнее, чем от выбора модели — кривой угол съёмки телефоном роняет результат заметнее, чем разница между GPT-4o и локальной vision-моделью.

Пакетный анализ через API: обходим папку и получаем таблицу

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

#!/usr/bin/env bash
API=http://127.0.0.1:3001/api/v1
WS=analiz-dogovorov
KEY="$ALLM_KEY"
OUT="rezultat-$(date +%F).csv"
echo "file,storona_1,storona_2,summa,valuta,citata_summy" > "$OUT"

for f in /root/dogovory/*.pdf; do
  loc=$(curl -s -X POST "$API/document/upload" -H "Authorization: Bearer $KEY" \
        -F "file=@$f" | jq -r '.documents[0].location')
  [ "$loc" = "null" ] && { echo "$f: не разобран, пропуск" >&2; continue; }

  curl -s -X POST "$API/workspace/$WS/update-embeddings" -H "Authorization: Bearer $KEY" \
       -H 'Content-Type: application/json' -d "{\"adds\":[\"$loc\"],\"deletes\":[]}" > /dev/null
  curl -s -X POST "$API/workspace/$WS/update-pin" -H "Authorization: Bearer $KEY" \
       -H 'Content-Type: application/json' -d "{\"docPath\":\"$loc\",\"pinStatus\":true}" > /dev/null

  raw=$(curl -s -X POST "$API/workspace/$WS/chat" -H "Authorization: Bearer $KEY" \
       -H 'Content-Type: application/json' \
       -d '{"message":"Разбери закреплённый документ по схеме.","mode":"query","reset":true}' \
       | jq -r '.textResponse')
  json=$(echo "$raw" | sed -n '/{/,/}/p')
  echo "$json" | jq -e . >/dev/null 2>&1 || { echo "$f: не JSON: $raw" >&2; continue; }

  jq -r --arg f "$(basename "$f")" \
     '[$f, .storona_1, .storona_2, .summa, .valuta, .citata_summy] | @csv' <<< "$json" >> "$OUT"

  curl -s -X POST "$API/workspace/$WS/update-pin" -H "Authorization: Bearer $KEY" \
       -H 'Content-Type: application/json' -d "{\"docPath\":\"$loc\",\"pinStatus\":false}" > /dev/null
  curl -s -X POST "$API/workspace/$WS/update-embeddings" -H "Authorization: Bearer $KEY" \
       -H 'Content-Type: application/json' -d "{\"adds\":[],\"deletes\":[\"$loc\"]}" > /dev/null
done

Флаг "reset":true обнуляет историю треда — без него к десятому договору модель путает реквизиты текущего с упомянутыми раньше: сброс дешевле, чем потом разбираться в путанице. Строка sed -n '/{/,/}/p' защищает от привычки моделей оборачивать JSON в пояснение несмотря на прямой запрет в промпте, а jq -e проверяет, что после вырезки получился валидный объект. И снятие закрепления с удалением из индекса не для порядка: закрепление — флаг pinned в таблице workspace_documents внутри anythingllm.db, и без очистки следующий запрос платит токенами за чужие договоры в контексте.

Честно о скорости: цикл последовательный осознанно — гонка за пин между двумя договорами кончится тем, что один анализ прочитает чужой файл. На сотне документов закладывайте от нескольких минут до получаса в зависимости от провайдера — тот самый «cost of speed» из документации закрепления.

Какой сервер под ИИ-анализ документов брать в MAATRIX

Нагрузка при анализе иная, чем при RAG-поиске: там в модель едет несколько найденных абзацев, здесь — закреплённый документ целиком при каждом обращении. Для внешнего API это счёт за токены, а не нагрузка на сервер: честный минимум — 2 vCPU, 4 ГБ RAM, 40 ГБ NVMe, диск с запасом — скан в 150 DPI весит несколько мегабайт, а закреплённые документы и эмбеддинги хранятся не в одном экземпляре. Для локальной модели разница ощутимее: закрепление гонит в контекст весь промпт, а не пару абзацев, и нагрузка ложится на обработку промпта, а не на генерацию — ответ компактный JSON на десяток строк, вход же иногда десятки тысяч токенов.

На нашем стенде (AMD EPYC 9554, 16 vCPU = 8 физических ядер + HT, qwen2.5:7b Q4_K_M, Ollama 0.33.1) это видно: генерация упирается в память уже на 4 потоках (5,7 / 7,6 / 7,6 / 7,4 ток/с на 2/4/8/16), обработка промпта растёт до 16 (12,6 / 31,0 / 61,7 / 68,0) — а на 32 при тех же 16 vCPU оба показателя обваливаются в двадцать раз (0,35 и 10,9). Вывод: num_thread стоит выставлять по числу физических ядер — здесь 8, — а не по максимуму, который выигрывает у коротких ответов, не у длинного документа.

МодельПамять в покоеТок/с генерации при 16 потоках (наш стенд)
qwen2.5:3b2,2 ГБ34,1
mistral:7b5,0 ГБ12,1
qwen2.5:7b5,1 ГБ7,4
llama3.1:8b5,6 ГБ12,8

Это текстовые модели; vision-модель того же класса весит больше за счёт энкодера изображений — цифру для своей сборки покажет ollama show имя-модели --modelfile. Комфортный вариант под локальную модель — 8 vCPU, 16 ГБ RAM, 100+ ГБ NVMe: ядра под расчёт, память под веса плюс контекст под целый документ. Формула по весам — в статье сколько RAM нужно для AnythingLLM.

Локация — Лондон (UK), причины две: доступность (GPT-4o, Claude и эмбеддинги OpenAI с британского адреса отвечают штатно, тогда как с российского на вложения прилетает тот же региональный отказ, что и на текст) и юрисдикция (реквизиты контрагентов из Евросоюза ближе к британской площадке, чем к серверу в России). Для документов строго под 152-ФЗ берите российскую локацию, но тогда и модель, и vision держите локальными.

AnythingLLM разворачивать вручную не нужно: приложение есть в каталоге apps.maatrix.io и ставится автоматически при заказе сервера, работает на Ubuntu и Debian, а адрес панели и ключи появляются в личном кабинете, в разделе «Доступ». Остаётся создать пространство, задать системный промпт под свою схему и загрузить первую партию документов. Оплата — картой российского банка, по СБП, криптовалютой или токеном MAAT.

Развернуть за пару минут

Готовый образ на VPS MAATRIX: NVMe, AMD EPYC, root-доступ. Локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Развернуть AnythingLLM

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →

Частые вопросы

Цифрам и датам, которые модель достала из документа, можно верить без проверки?

Нет, относитесь к ним как к черновику. Добавьте в схему поле-цитату (citata_summy из раздела про пространство) — дословный фрагмент рядом со значением превращает слепое доверие в факт, который сверяется взглядом за секунду, особенно на пограничных суммах.

Обязательно ли закреплять документ, или для одного файла хватит вопроса без пина?

Для документа в одну-две страницы обычно хватает вопроса без закрепления, если ответ укладывается в несколько абзацев, что найдёт поиск. Закрепление нужно, когда важна каждая часть текста — договор с приложениями, регламент с перекрёстными ссылками.

Скан документа с персональными данными уходит в облачный vision-провайдер — это утечка?

Да: изображение целиком уезжает к провайдеру при каждом запросе — иной периметр приватности, чем у RAG, где наружу уходят только найденные текстовые фрагменты. Для паспортов и медицинских документов используйте локальную vision-модель через Ollama либо закрашивайте чувствительные поля до загрузки.

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.