Selbst gehosteter Vision LLM OCR in Go: Rasterisierung, Prompting, Parsing
Ein Go-Worker wandelt medizinische PDFs mithilfe eines Vision-LLMs auf Ollama in JSON um: pdftoppm zur Rasterisierung, strenge Prompts, Fallback-Parsing sowie Redis Streams-Consumer.
Quellen: Inside the OCR Pipeline: Code, Prompts, and Failure Handling (Part 2 of 2)
Die Wahl eines selbst gehosteten Vision-Modells anstelle einer Cloud-OCR-API ist nur die halbe Entscheidung; die andere Hälfte betrifft einen Pipeline, der mit dem umgehen kann, was das Modell tatsächlich zurückgibt. Diese Anleitung folgt einem Produktions-Go-Worker, der medizinische Labortests ausliest und die Ergebnisse in Postgres speichert. Sie werden sehen, wie Seiten gerastert werden, wie die Anfrage formuliert wird, wie Ausgaben interpretiert werden können, die die angeforderte Struktur ignorieren, und wie der Worker skaliert, ohne Aufgaben doppelt zu verarbeiten.
Schritt 1: PDFs in Seitenbilder umwandeln
Ollamas Vision-Modelle akzeptieren Bilder, nicht PDFs, weshalb jede Hochladung zunächst in ein Bild pro Seite umgewandelt wird. Anstelle einer schweren Render-Bibliothek greift der Worker auf pdftoppm aus dem Poppler-Set zurück, das auf dem Host des Workers installiert ist. Die Funktion schreibt die Bytes in eine temporäre Datei, rendernt jede Seite als PNG bei 300 dpi und liest anschließend die erzeugten Dateien wieder aus, um sie zu löschen. exec.CommandContext verbindet den Kindprozess mit dem Kontext der Aufgabe, sodass auch ein Stopp der Aufgabe das Rendering beendet.
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 erwies sich als der richtige Ausgleich: Kleine Angaben wie Einheiten und Referenzbereiche bleiben für das Modell lesbar, ohne dass überdimensionierte Seitenbilder entstehen. Aus Kürze werden im Beispiel mehrere Fehler weggelassen; kümmern Sie sich in echtem Code darum. Beachten Sie außerdem, dass filepath.Glob lexikalisch sortiert, weshalb Sie bei zehn oder mehr Seiten möglicherweise nach der von pdftoppm hinzugefügten Seitennummer sortieren möchten.
Schritt 2: Fordern Sie vom Modell eine bestimmte JSON-Struktur an
Das Extraktionsziel ist eine Liste von Testergebnissen, und die Struktur legt den Aufbau fest: Name, Wert, Einheit, Referenzbereich, Abweichungsflagge sowie ein selbst gemessener Zuverlässigkeitswert.
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"`
}
Der Aufruf ist ein einfacher POST-Anfrage an Ollamas /api/generate. Das Bild wird in Base64 in Images kodiert, der Prompt wird mit der Berichtskategorie gefüllt, das Streaming ist deaktiviert und Format: „json“ wird festgelegt. Anschließend wird das Response-Feld des Envelopes in den Parameter-Array umgewandelt. Einige Anweisungen in diesem Auszug werden auf derselben Zeile ausgeführt, daher sollte man dies als Überblick über den Ablauf betrachten und in der Produktion resp.StatusCode sowie die ignorierten Fehler überprüfen.
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
}
Der Prompt, der eher umformuliert als mit den tatsächlichen Produktionsworten verfasst ist, listet strenge Regeln auf und endet mit einem Beispiel für den genauen Array, der zurückgegeben werden soll:
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
}
]
Jede Regel bezieht sich auf einen bestimmten Fehlermodus. Das Verbot erfundener Werte bekämpft Halluzinationen, den gefährlichsten Fehler bei medizinischen Daten. Ein Objekt pro Test verhindert, dass das Modell mehrere Elemente zu einem einzigen Block zusammenführt. Das Kopieren der Werte in ihrer gedruckten Form bewahrt Einheiten und Wertebereiche, während das Überspringen von Interpretationsabschnitten verhindert, dass narrative Inhalte in strukturierte Felder gelangen. Der Vertrauenswert ist nützlich, um zweifelhafte Werte an einen Menschen weiterzuleiten – doch die Selbstbewertung des Modells ist eher eine Heuristik als eine kalibrierte Wahrscheinlichkeit. Die letzte Regel, nur ein einfaches Array zurückzugeben, ist wichtiger, als es auf den ersten Blick scheint, denn allein der JSON-Modus sorgt nicht für eine strenge Strukturierung.
Ausgabe parsen, die die angeforderte Struktur ignoriert
Format: „json“ neigt die Erstellung dazu, gültiges JSON zu erzeugen, ist aber kein Schema. In der Praxis kann ein kleineres lokales Modell die Liste innerhalb eines Objekts wie {"tests": [...]} einbetten, ein einzelnes Objekt zurückgeben oder etwas ausgeben, das kein sauber parsbares Dokument ist.
Anstatt bei der ersten Abweichung bei der Extraktion zu versagen, probiert der Parser zunehmend lockerere Strategien und stoppt bei dem ersten Erfolg:
// 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
Zuerst findet die strenge Parse-Operation statt, sodass sich ordnungsgemäß strukturierende Antworten den günstigsten Weg nehmen. Anschließend werden alle Wrapper-Schlüssel akzeptiert, die ein nicht leeres Array enthalten, wobei ein einzelnes Objekt in eine Liste umgewandelt wird. Ein rekursiver Ablauf kümmert sich um tiefes Verknoten und ordnet locker übereinstimmende Feldnamen (test_name, name, parameter_name) einem einzigen Feld zu. Als letztes Mittel extrahiert eine reguläre Ausdrucksuche die einzelnen {...}-Blöcke und parset diese einzeln, wodurch Antworten gerettet werden können, die durch doppelte Schlüssel beschädigt sind.
Es ist nicht elegant, und idealerweise würde das Modell jedes Mal funktionieren. Das ist der Preis dafür, selbst ein kompaktes Modell zu betreiben anstelle von einem ausgereiften kommerziellen API – eine Abonnementgebühr im Tausch gegen ein defensives Parsen, was für ein MVP lohnenswert war. Zwei Vorschläge, falls Sie die Idee übernehmen: Protokollieren Sie, welche Strategie erfolgreich war, damit Sie messen können, ob Änderungen am Prompt oder am Modell helfen, und überprüfen Sie die Ergebnisse der weniger strengen Strategien genauer. Neuere Ollama-Versionen können außerdem ein JSON-Schema im Feld format entgegennehmen; schauen Sie in die aktuellen Dokumentationen für Ihre Version.
Schritt 3: Mehrseitige Berichte Seite für Seite extrahieren
Jede Seite eines mehrseitigen Berichts wird als eigene Anfrage an Ollama gesendet, und die Ergebnisse pro Seite werden vor dem Schreiben in Postgres zu einer einzigen Liste zusammengefügt. Wenn eine Seite fehlschlägt, wird der Fehler protokolliert, und die verbleibenden Seiten werden dennoch verarbeitet, anstatt dass der gesamte Bericht fehlschlägt. Der Kompromiss besteht darin, dass ein Bericht mit fehlenden Ergebnissen gespeichert werden kann; dadurch bleibt der teilweise Status sichtbar.
Schritt 4: Skalierung des Workers mit Redis Streams Consumer Groups
Der Worker liest Aufgaben aus einem Redis Stream im Rahmen eines Consumer Groups, sodass mehrere Instanzen parallel laufen können, während jede Nachricht nur an eine von ihnen übermittelt wird. Eine Nachricht wird mit XAck erst dann bestätigt, nachdem sowohl die Extraktion als auch der Schreibvorgang in der Datenbank erfolgreich waren.
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)
Die >-ID-Anfragen für Nachrichten werden niemals an die Gruppe übermittelt. Count: 1 bearbeitet jeweils nur eine Aufgabe nach der anderen, während Block: 0 endlos wartet. Da die Bestätigung zuletzt eintrifft, bleibt die Nachricht in der Liste der ausstehenden Einträge, anstatt verloren zu gehen. Ausstehende Nachrichten werden jedoch nicht automatisch erneut übermittelt; planen Sie stattdessen XAUTOCLAIM oder XPENDING in Kombination mit XCLAIM, um veraltete Aufgaben an einen funktionsfähigen Empfänger weiterzuleiten.
Temporäre Fehler werden bis zu dreimal mit kurzen Verzögerungen wiederholt, bevor der Bericht als FAILED markiert wird. Fehler, die durch Wiederholungen nicht behoben werden können – beispielsweise fehlende Quelldateien im Speicher –, gelten als fatal und werden ganz auf Wiederholungsversuche verzichtet.
Zusammenfassung
Ausgehend allein betrachtet ist keine dieser Optionen die theoretisch beste. Die Nutzung von pdftoppm, das Hinzuziehen zusätzlicher Parsers sowie die Akzeptanz von unvollständigen Berichten sind Kompromisse, die an tatsächliche Einschränkungen angepasst sind: ein begrenztes Budget für das MVP, sensible Patientendaten, die intern aufbewahrt werden müssen, sowie ein kleines Team, das einen fertigen Produktivstart erreichen musste.
- Stellen Sie die Auflösung der Rasterisierung entsprechend dem kleinsten relevanten Text ein.
- Betrachten Sie den JSON-Modus als Hinweis und analysieren Sie stattdessen die Formen, die das Modell tatsächlich erzeugt – dabei Priorität auf präzise Pfade geben.
- Schreiben Sie Anweisungen als explizite Regeln, wobei jede Regel auf ein festgestelltes Versagen abzielt.
- Isoleieren Sie Fehler pro Seite und pro Auftrag und machen Sie unvollständige Ergebnisse sichtbar.
- Anerkennen Sie Warteschlangenmeldungen erst, nachdem alle Nebeneffekte erfolgreich abgeschlossen sind.