Галоўная / Артыкулы

Самастаўленыя OCR-сістэмы на базе LLM Vision у мове Go: растераванне, формування запитоў, парсінг

Рабочы прыстрой Go перетварае медычныя PDF-файлы на JSON за дапамогай моделі LLM для аналізу звуроцінаў на Ollama: растерызацыя за дапамогай pdftoppm, строгі запиты, альтернатыўны парсінг і відпрымальнікі пакетаў у Redis Streams.

Выдавец: REACTAPP.TOP7 хв чытання1684 слоў

Крыніцы: 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), &parameters); 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), &parameters); 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 як падказцы і парсуйце тыя формы, якія на самаў дзеяльна стварае модэль, пры чым спачатку акцэнт на строгіх шляхах.
  • Запісуйце інструкцыі як чыстае правіла, кожна з яых нацэлена на адпаведны выяўлены брак.
  • Ізолюйце бракі па сторанкі і па заведамасцях, а таксама зробіце частковыя рэзультаты виднымі.
  • Прыймайце прыемы з очэрэдзі ўжо пасля таго, як усе побачныя эфекты будуць успешнымі.