Самостоятельная облачная модель Vision LLM для OCR на Go: растеризация, формулировка запросов, парсинг
Рабочий процесс на Go преобразует медицинские PDF-файлы в JSON с использованием нейросети для обработки изображений на Ollama: растеризация с помощью pdftoppm, строгие инструкции, резервное парсинг и потребители данных из Redis Streams.
Источники: Inside the OCR Pipeline: Code, Prompts, and Failure Handling (Part 2 of 2)
Выбор самостоятельно развернутой модели распознавания изображений вместо облачного API OCR — это лишь половина решения; другая половина заключается в создании потока обработки, способного справиться с тем, что фактически возвращает модель. В этом руководстве рассматривается производственный рабочий процесс на языке Go, который читает отчеты медицинских лабораторий и сохраняет результаты в базе данных Postgres. Вы увидите, как растеризуются страницы, как формируется запрос к модели, как парсить вывод, игнорирующий заданную структуру, и как рабочий процесс масштабируется без двойной обработки заданий.
Шаг 1: Преобразование PDF в изображения страниц
Модели визуализации Ollama принимают изображения, а не PDF-файлы, поэтому каждый загруженный документ сначала преобразуется в одно изображение на страницу. Вместо использования тяжелой библиотеки для рендеринга используется утилита pdftoppm из набора Poppler, установленного на хосте рабочего процесса. Эта утилита записывает данные во временный файл, преобразует каждую страницу в формат PNG с разрешением 300 dpi, затем считывает и удаляет созданные файлы. exec.CommandContext связывает дочерний процесс с контекстом задачи, поэтому отмена задания также прерывает процесс рендеринга.
func extractImagesFromPDF(ctx context.Context, pdfBytes []byte) ([][]byte, error) {
tmpFile, _ := os.CreateTemp("", "report-*.pdf")
defer os.Remove(tmpFile.Name())
tmpFile.Write(pdfBytes)
tmpFile.Close()
outPrefix := strings.TrimSuffix(tmpFile.Name(), ".pdf")
// Render each PDF page to a 300dpi PNG
cmd := exec.CommandContext(ctx, "pdftoppm", "-r", "300", "-png", tmpFile.Name(), outPrefix)
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("pdftoppm failed: %v", err)
}
matches, _ := filepath.Glob(outPrefix + "*.png")
var images [][]byte
for _, match := range matches {
imgBytes, _ := os.ReadFile(match)
images = append(images, imgBytes)
os.Remove(match)
}
return images, nil
}
300 dpi оказались оптимальным значением: мелкий текст, такой как единицы измерения и диапазоны нормы, остается читаемым для модели без создания слишком крупных изображений страниц. Для краткости в примере проигнорировано несколько ошибок; их необходимо учесть в реальном коде. Также обратите внимание, что filepath.Glob сортирует элементы лексикографически, поэтому при наличии десяти и более страниц возможно понадобится сортировка по номеру страницы, который добавляет утилита pdftoppm.
Шаг 2: Запросить у модели конкретную структуру JSON
Цель извлечения — список результатов тестирования, а структура данных определяет требования к ним: имя, значение, единица измерения, диапазон нормы, флаг наличия аномалий и самооценка уровня точности.
type ExtractedParameter struct {
ParameterName string `json:"parameter_name"`
Value string `json:"value"`
Unit string `json:"unit"`
ReferenceRange string `json:"reference_range"`
AbnormalFlag bool `json:"abnormal_flag"`
OcrConfidence int `json:"ocr_confidence"`
}
Это обычный запрос POST на адрес /api/generate у Ollama. Изображение кодируется в формате base64 и помещается в поле Images, в поле для запроса указывается категория отчета, стриминг отключен, а параметр Format: "json" установлен. Затем поле Response полученного ответа разбирается на отдельные параметры. Некоторые операции в этом фрагменте выполняются на одной строке, поэтому рассматривайте его как краткое описание алгоритма; в реальных условиях необходимо проверять значение resp.StatusCode и учитывать игнорируемые ошибки.
func (c *OllamaClient) ExtractParameters(ctx context.Context, imageBytes []byte, category string) ([]ExtractedParameter, error) {
base64Image := base64.StdEncoding.EncodeToString(imageBytes)
prompt := fmt.Sprintf(promptTemplate, category) reqData := ollamaRequest{
Model: c.Model,
Prompt: prompt,
Images: []string{base64Image},
Stream: false,
Format: "json", // nudges Ollama toward JSON output — doesn't guarantee it
} reqBytes, _ := json.Marshal(reqData)
req, _ := http.NewRequestWithContext(ctx, "POST", c.URL+"/api/generate", bytes.NewReader(reqBytes))
req.Header.Set("Content-Type", "application/json") resp, err := (&http.Client{}).Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request to ollama: %v", err)
}
defer resp.Body.Close() var oResp ollamaResponse
json.NewDecoder(resp.Body).Decode(&oResp) // parsing continues below — see "Handling model output reliability"
var parameters []ExtractedParameter
if err := json.Unmarshal([]byte(oResp.Response), ¶meters); err == nil {
return parameters, nil
}
// ...
return parameters, nil
}
В запросе, сформулированном в более простых словах, чем в финальной версии, перечислены строгие правила, а в конце приведен пример точного массива, который должен быть возвращен:
You are a medical data extraction assistant. You are given an image of a
medical report.
Rules:
1. Only extract tests that are explicitly visible in the image - never
invent, guess, or infer a result that isn't printed.
2. The report may contain several distinct tests. Create one JSON object
per test - do not merge multiple tests into a single object.
3. Capture the test name, result, unit, and reference range exactly as
printed. Ignore narrative "interpretation" paragraphs.
4. If a result is flagged abnormal (marked with *, H, L, or similar),
set abnormal_flag to true.
5. Rate your own confidence in reading each value, 0-100.
6. Respond with ONLY a flat JSON array - no wrapping object, no markdown.
[
{
"parameter_name": "Hemoglobin",
"value": "13.2",
"unit": "g/dL",
"reference_range": "13.0 - 17.0",
"abnormal_flag": false,
"ocr_confidence": 98
}
]
Каждое правило направлено на определенный тип сбоя. Запрет на использование вымышленных значений помогает бороться с галлюцинациями — самой опасной ошибкой в медицинских данных. Один объект на тест предотвращает объединение нескольких элементов в один большой блок. Копирование значений в том виде, в котором они отображаются, позволяет сохранить единицы измерения и диапазоны, а пропуск разделов с интерпретацией не позволяет вносить описательный текст в структурированные поля. Оценка уверенности полезна для передачи сомнительных значений человеку, хотя самооценка модели является лишь эвристикой, а не точно калиброванной вероятностью. Последнее правило — возвращать только простой массив — имеет большее значение, чем кажется, поскольку режим JSON сам по себе не обеспечивает соблюдения структуры.
Обработка вывода, игнорирующего заданную структуру
Format: „json“ способствует генерации валидного JSON, но это не схема. На практике более маленькая локальная модель может разместить список внутри объекта, например {"tests": [...]}, вернуть один объект или выдать что-то, что не является чистым документом, поддающимся парсингу.
Вместо того чтобы сразу завершить обработку при первом несоответствии, парсер пробует всё более слабые стратегии и останавливается при первом успехе:
// 1. Try the shape we actually asked for: a flat array
if err := json.Unmarshal([]byte(raw), ¶meters); err == nil {
return parameters, nil
}
// 2. Try an object wrapping the array under an unknown key, e.g. {"tests": [...]}
var wrapper map[string][]ExtractedParameter
if err := json.Unmarshal([]byte(raw), &wrapper); err == nil {
for _, v := range wrapper {
if len(v) > 0 {
return v, nil
}
}
}
// 3. Try a single object instead of an array
// 4. Recursively walk arbitrarily nested JSON, matching field names loosely
// (e.g. "test_name", "name", "parameter_name" all resolve to the same field)
// 5. As a last resort, regex out individual {...} blocks and parse each one
// independently - catches cases where duplicate keys break a clean unmarshal
Сначала выполняется строгий анализ, поэтому корректные ответы проходят по самому простому пути. Затем принимаются любые ключи-обертки, содержащие ненулевой массив, после чего отдельный объект преобразуется в список. Рекурсивный обход обрабатывает глубокую вложенность и соотносит имени поля с разным уровнем совпадения (test_name, name, parameter_name) с одним полем. В качестве крайней меры регулярные выражения извлекают отдельные блоки {...} и анализируют каждый из них отдельно, что позволяет восстановить ответы, поврежденные дублирующимися ключами.
Это не очень изящно, и в идеале модель должна справляться каждый раз. Это цена использования компактной модели вручную вместо покупки отлаженного коммерческого API: счет за подписку в обмен на более примитивный способ парсинга, что было оправдано для MVP. Два совета, если вы решите воспользоваться этой идеей: ведите записи о том, какая стратегия сработала, чтобы можно было оценить, помогают ли изменения в запросе или самой модели, а также более строго проверяйте результаты менее строгих стратегий. В новых версиях Ollama также можно указать схему JSON в поле format; ознакомьтесь с актуальной документацией для вашей версии.
Шаг 3: Извлекать многостраничные отчеты постранично
Каждая страница многостораничного отчета отправляется в Ollama как отдельный запрос, а результаты для каждой страницы объединяются в один список перед тем, как что-либо записывается в Postgres. Если одна страница обрабатывается с ошибкой, ошибка фиксируется в журнале, а остальные страницы продолжают обрабатываться, вместо того чтобы весь отчет считался неудачным. Недостатком является то, что отчет может быть сохранен с некоторыми отсутствующими результатами, поэтому запись информации о необработанных страницах позволяет сохранять этот частичный состояние.
Шаг 4: Масштабирование рабочего процесса с использованием групп потребителей Redis Streams
Рабочий процесс читает задания из Redis Stream в рамках группы потребителей, так что несколько экземпляров могут работать параллельно, при этом каждое сообщение доставляется только одному из них. Сообщение подтверждается с помощью XAck только после того, как успешно завершатся как извлечение данных, так и запись в базу данных.
streams, _ := rdb.XReadGroup(ctx, &redis.XReadGroupArgs{
Group: groupName,
Consumer: consumerName,
Streams: []string{streamName, ">"},
Count: 1,
Block: 0,
}).Result()
// ... process message, extract parameters, save to DB ...
rdb.XAck(ctx, streamName, groupName, message.ID)
Запросы с идентификатором > никогда не доставляются группе, Count: 1 обрабатывает по одному заданию за раз, а Block: 0 ожидает бесконечно. Поскольку подтверждение приходит в конце, сбой приводит к тому, что сообщение остается в списке задач на обработку, а не теряется. Однако сообщения, оставшиеся в списке ожидания, не доставляются заново автоматически; необходимо использовать параметры XAUTOCLAIM или XPENDING вместе с XCLAIM, чтобы передать устаревшие задания здоровому потребителю.
Временные сбои пытаются устранить до трех раз с короткими интервалами задержки, прежде чем статус будет помечен как FAILED. Ошибки, которые невозможно исправить путем повторных попыток, например отсутствие исходного файла в хранилище, считаются фатальными и полностью игнорируются при повторных попытках.
Заключение
Ни один из этих вариантов сам по себе не является теоретически оптимальным. Использование pdftoppm, комбинация резервных парсеров и принятие частичных отчетов — это компромиссы, соответствующие реальным ограничениям: ограниченный бюджет на разработку MVP, конфиденциальные данные пациентов, хранящиеся внутри организации, и небольшая команда, которой необходимо было выпустить продукт.
- Настройте разрешение растеризации с учетом самого мелкого текста, имеющего значение.
- Рассматривайте режим JSON как подсказку и парсите те формы, которые действительно генерирует модель, с упором на строгие пути.
- Формулируйте инструкции в виде четких правил, каждое из которых направлено на устранение конкретной проблемы.
- Изолируйте сбои по страницам и задачам, а также делайте видимыми частичные результаты.
- Обрабатывайте сообщения очереди только после успешного выполнения всех побочных действий.