Самастаўленыя OCR-сістэмы на базе LLM Vision у мове Go: растераванне, формування запитоў, парсінг
Рабочы прыстрой Go перетварае медычныя PDF-файлы на JSON за дапамогай моделі LLM для аналізу звуроцінаў на 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 як падказцы і парсуйце тыя формы, якія на самаў дзеяльна стварае модэль, пры чым спачатку акцэнт на строгіх шляхах.
- Запісуйце інструкцыі як чыстае правіла, кожна з яых нацэлена на адпаведны выяўлены брак.
- Ізолюйце бракі па сторанкі і па заведамасцях, а таксама зробіце частковыя рэзультаты виднымі.
- Прыймайце прыемы з очэрэдзі ўжо пасля таго, як усе побачныя эфекты будуць успешнымі.