Самостійно розгорнутий OCR для Vision LLM на 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)
Запити ID > ніколи не доставляються до групи, Count: 1 обробляє по одній завданню за раз, а Block: 0 чекає необмежено довго. Оскільки підтвердження надходить останнім, збій залишає повідомлення у списку очікуючих записів, замість того щоб втратити його. Однак очікуючі повідомлення не доставляються знову самостійно; потрібно використовувати XAUTOCLAIM або XPENDING разом із XCLAIM, щоб передати застарілі завдання справному споживачеві.
Тимчасові помилки пробуваються ще три рази з коротким інтервалом перед тим, як звіт буде позначений як FAILED. Помилки, які неможливо виправити шляхом повторних спроб, наприклад відсутність вихідного файлу у сховищі, вважаються фатальними та повністю ігноруються під час повторних спроб.
Підсумок
Жоден із цих варіантів сам по собі не є теоретично найкращим. Використання pdftoppm, поєднання резервних парсерів та прийняття часткових звітів — це компроміси, які враховують реальні обмеження: малий бюджет на створення MVP, конфіденційні дані пацієнтів, які зберігаються внутрішньо, та невелика команда, якій потрібно було швидко випустити продукт.
- Налаштовуйте роздільну здатність растеризації відповідно до найменшого значущого тексту.
- Розглядайте режим JSON як підказку та парсуйте форми, які насправді генерується модель, надаючи пріоритет суворим шляхам.
- Формулюйте інструкції як чіткі правила, кожне з яких спрямоване на вирішення конкретної проблеми.
- Ізолюйте проблеми за кожною сторінкою та завданням, а також робіть часткові результати видимими.
- Підтверджуйте наявність повідомлень у черзі лише після того, як усі побічні ефекти виконані успішно.