Samodzielnie hostowany OCR modelu Vision LLM w Go: rasteryzacja, generowanie promptów, analiza tekstu
Narzędzie w języku Go przekształca pliki PDF medyczne na format JSON za pomocą modelu językowego do rozpoznawania obrazu na platformie Ollama: rasterizacja za pomocą pdftoppm, precyzyjne instrukcje, fallback do parsowania oraz odbieranie danych z Redis Streams.
Źródła: Inside the OCR Pipeline: Code, Prompts, and Failure Handling (Part 2 of 2)
Wybór modelu wizyjnego self-hosted zamiast chmurowej API OCR to tylko połowa decyzji; drugą połową jest proces, który radzi sobie z tym, co faktycznie zwraca model. Ten przewodnik opisuje produkcyjnego pracownika w języku Go, który czyta raporty z laboratoriów medycznych i przechowuje wyniki w Postgresie. Zobaczysz, jak strony są rasterizowane, jak kształtuje się prompt, jak analizować wyniki ignorujące zapytany format oraz jak pracownik skaluje się bez podwójnej obróbki zadań.
Krok 1: Przekształcenie plików PDF w obrazy stron
Modele wizualne Ollamy przyjmują obrazy, a nie pliki PDF, więc każdy załadunek jest najpierw przetwarzany na jeden obraz na stronę. Zamiast używać ciężkiej biblioteki do renderowania, proces wykorzystuje pdftoppm z zestawu Poppler, zainstalowanego na serwerze procesora. Funkcja zapisuje dane do pliku tymczasowego, renderuje każdą stronę w formacie PNG o rozdzielczości 300 dpi, a następnie odczytuje i usuwa utworzone pliki. exec.CommandContext łączy proces potomny z kontekstem zadania, dzięki czemu anulowanie również zatrzymuje proces renderowania.
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 okazało się odpowiednią wartością – małe elementy tekstu, takie jak jednostki miary i zakresy referencyjne, pozostają czytelne dla modelu, przy czym nie powstają zbyt duże obrazy stron. Ze względu na zwięzłość fragment kodu pomija kilka błędów; należy je naprawić w rzeczywistym kodzie. Należy również zauważyć, że filepath.Glob sortuje elementy leksykalnie, więc przy dziesięciu lub więcej stronach warto posortować je według numeru strony, który dodaje narzędzie pdftoppm.
Krok 2: Poproś model o określony kształt JSON
Celem wydobywania danych jest lista wyników testów, a struktura określa ich specyfikację: nazwa, wartość, jednostka miary, zakres referencyjny, flaga wskazująca na nieprawidłowości oraz ocena pewności samodzielnie podana przez model.
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"`
}
Zapytanie to zwykły POST do /api/generate w Ollamie. Obraz jest kodowany w formacie base64 i umieszczany w polu Images, pole prompt jest wypełniane kategorią raportu, strumieniowanie jest wyłączone, a ustawiony jest format Format: „json”. Następnie pole Response z koperty jest konwertowane na tablicę parametrów. Niektóre instrukcje w tym fragmencie są wykonywane razem na pojedynczych liniach, więc należy to traktować jako zarys przepływu, a w warunkach produkcyjnych sprawdzić wartość resp.StatusCode oraz błędy, które zostały zignorowane.
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
}
Prompt, sformułowany w inny sposób niż w wersji produkcyjnej, wymienia sztywne zasady i kończy się przykładem dokładnej tablicy, którą należy zwrócić:
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
}
]
Każde zasadę dotyczy określonego sposobu awarii. Zakaz używania wymyślonych wartości pomaga w radzeniu sobie z halucynacjami, najniebezpieczniejszym błędem w danych medycznych. Jedno obiekt na test zapobiega łączeniu elementów w jeden wielki zbiór. Kopiowanie wartości w formie wydrukowanej zachowuje jednostki i zakresy, a pomijanie akapitów interpretacyjnych zapobiega umieszczaniu treści narracyjnych w strukturalnych polach. Wskaźnik pewności jest przydatny do kierowania wątpliwych wartości do człowieka, chociaż samo ocenianie przez model jest metodą heurystyczną, a nie skalibrowaną prawdopodobieństwem. Ostatnia zasada – odpowiadanie wyłącznie prostym tablicą – ma większe znaczenie, niż się wydaje, ponieważ sam tryb JSON nie gwarantuje zachowania struktury.
Analiza wyników, które ignorują żądany kształt
Format: „json” skłania do generowania poprawnego JSON, ale nie jest to schemat. W praktyce mniejszy model lokalny może umieścić listę wewnątrz obiektu, np. {"tests": [...]}, zwrócić pojedynczy obiekt lub wygenerować coś, co nie stanowi spójnego, możliwego do zinterpretowania dokumentu.
Zamiast zawieść się przy pierwszej niezgodności podczas wydobywania danych, parser próbuje coraz bardziej luźnych strategii i zatrzymuje się przy pierwszym sukcesie:
// 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
Najpierw odbywa się ścisła analiza, więc odpowiedzi o prawidłowym zachowaniu wybierają najprostszą ścieżkę. Następnie akceptowana jest każda kluczowa wartość typu wrapper zawierająca niepustą tablicę, a pojedynczy obiekt jest przekształcany w listę. Recyrtyczne przeszukiwanie radzi sobie z głębokim nawijaniem i mapuje nazwy pól o luźnym dopasowaniu (test_name, name, parameter_name) na jedno pole. Jako ostateczność regex wyodrębnia pojedyncze bloki {...} i analizuje każdy z nich osobno, co pomaga przy naprawie odpowiedzi uszkodzonych przez duplikowane klucze.
To nie jest eleganckie, a idealnie model powinien zawsze działać poprawnie. To cena korzystania z kompaktowego modelu samodzielnie zamiast płacenia za dopracowaną komercyjną API: rachunek subskrypcji w zamian za bardziej restrykcyjną analizę danych, co było opłacalne przy tworzeniu MVP. Dwie sugestie, jeśli chcesz skopiować tę ideę: zapisuj strategię, która przyniosła sukces, aby móc ocenić, czy pomocne są zmiany w promptach lub modelu, oraz sprawdzaj wyniki z mniej restrykcyjnych strategii bardziej dokładnie. Najnowsze wersje Ollamy mogą również przyjmować schemat JSON w polu format; sprawdź aktualną dokumentację dla swojej wersji.
Krok 3: Wyodrębnianie wielostronicowych raportów strona po stronie
Każda strona wielostronicowego raportu jest wysyłana do Ollamy jako osobisty żądanie, a wyniki z poszczególnych stron są łączone w jedną listę przed zapisaniem czegokolwiek do Postgresa. Jeśli jedna strona zawiedzie, błąd jest zapisywany, a pozostałe strony są nadal przetwarzane, zamiast przez to cały raport zawieść. Wadą jest to, że raport może zostać zapisany z brakującymi niektórymi wynikami, więc rejestrowanie nieudanych stron utrzymuje ten częściowy stan widoczny.
Krok 4: Skalowanie pracownika za pomocą grup konsumentów Redis Streams
Pracownik odczytuje zadania z strumienia Redis w ramach grupy konsumentów, dzięki czemu kilka instancji może działać równolegle, przy czym każda wiadomość jest dostarczana tylko do jednej z nich. Wiadomość jest potwierdzona za pomocą XAck dopiero po tym, jak zarówno ekstrakcja, jak i zapis do bazy danych zakończyły się pomyślnie.
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)
Zapytania ID > nigdy nie są dostarczane do grupy, Count: 1 przetwarza jedno zlecenie na raz, a Block: 0 czeka nieskończenie długo. Ponieważ potwierdzenie przychodzi na końcu, awaria powoduje, że wiadomość pozostaje na liście zleceń w oczekiwaniu, zamiast zostać utracona. Jednak wiadomości w oczekiwaniu nie są automatycznie dostarczane ponownie; należy planować użycie XAUTOCLAIM lub XPENDING w połączeniu z XCLAIM, aby przekazać stare zadania zdrowemu konsumentowi.
Błędy tymczasowe są próbowane ponownie maksymalnie trzy razy z krótkim opóźnieniem, zanim raport zostanie oznaczony jako FAILED. Błędy, których nie da się naprawić poprzez ponowne próby, takie jak brak pliku źródłowego w pamięci przechowywania, są traktowane jako krytyczne i całkowicie pomijane podczas prób.
Podsumowanie
Żadna z tych opcji sama w sobie nie jest teoretycznie najlepsza. Korzystanie z pdftoppm, stosowanie alternatywnych parserów oraz akceptacja częściowych raportów to kompromisy wynikające z rzeczywistych ograniczeń: mały budżet na MVP, wrażliwe dane pacjentów przechowywane lokalnie oraz mały zespół, który musiał szybko dostarczyć produkt.
- Dostosuj rozdzielczość rasteryzacji do najmniejszego tekstu, który ma znaczenie.
- Traktuj tryb JSON jako wskazówkę i analizuj kształty faktycznie generowane przez model, priorytetowo biorąc pod uwagę ścieżki.
- Zapisuj instrukcje jako wyraźne zasady, każda skierowana na konkretny zaobserwowany błąd.
- Izoluj błędy na poziomie poszczególnych stron i zadań, a także umożliwiaj widoczność częściowych wyników.
- Potwierdzaj otrzymanie wiadomości z kolejki dopiero po pomyślnym wykonaniu każdego efektu ubocznego.
Literatura pokrewna
- Self-Hosting LangGraph Agent Server z Postgres i Redis — Dowiedz się, jak Langhost zastępuje warstwę trwałości LangGraph przez Postgres i Redis, umożliwiając zespołom samodzielne hostowanie niezmodyfikowanego Agent Servera pod licencją MIT.
- Reverse Prompting: Przekształcanie udanej sesji LLM w prompt wielokrotnego użycia — Dowiedz się, jak wydobyć prompt jednorazowy z udanej rozmowy wieloetapowej z LLM oraz jak technika odwrócenia promptu pozwala przywrócić pierwotne prompty, gdy masz dostęp tylko do ich wyników.