OCR de vision LLM auto-hébergé en Go : rasterisation, génération de prompts, analyse
Un outil Go transforme des PDF médicaux en JSON à l’aide d’un LLM visuel sur Ollama : rasterisation par pdftoppm, prompts stricts, analyse de secours et consommateurs de Redis Streams.
Sources: Inside the OCR Pipeline: Code, Prompts, and Failure Handling (Part 2 of 2)
Choisir un modèle de vision auto-hébergé plutôt qu’une API OCR en cloud n’est que la moitié de la décision ; l’autre moitié concerne un pipeline capable de gérer ce que le modèle renvoie réellement. Cette présentation suit un travailleur en Go destiné à l’environnement de production, qui lit des rapports de laboratoire médical et stocke les résultats dans Postgres. Vous verrez comment les pages sont rasterisées, comment la requête est formulée, comment interpréter des résultats qui ignorent la structure demandée, et comment ce travailleur s’adapte sans traiter deux fois les tâches.
Étape 1 : Convertir les PDF en images de pages
Les modèles de vision d’Ollama acceptent des images, et non des PDF, ce qui signifie que chaque téléchargement est d’abord converti en une image par page. Au lieu d’utiliser une bibliothèque de rendu lourde, le processus fait appel à pdftoppm de la suite Poppler, installée sur l’hôte du processus. Cette fonction écrit les données binaires dans un fichier temporaire, rend chaque page en PNG à 300 dpi, puis lit et supprime les fichiers générés. exec.CommandContext relie le processus enfant au contexte de la tâche, de sorte que l’annulation interrompt également le processus de rendu.
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 s’est avéré être l’équilibre idéal : les textes petits tels que les unités et les plages de référence restent lisibles pour le modèle sans générer des images de page trop grandes. Ce fragment omet plusieurs erreurs par souci de concision ; gérez-les dans le code réel. Notez également que filepath.Glob effectue un tri lexical, donc pour dix pages ou plus, vous voudrez peut-être classer par le numéro de page ajouté par pdftoppm.
Étape 2 : Demander au modèle une structure JSON spécifique
L’objectif de l’extraction est une liste de résultats de test, et la structure définit les exigences : nom, valeur, unité, plage de référence, indicateur d’anomalie et un score de confiance auto-déclaré.
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"`
}
La requête est une simple POST vers /api/generate d’Ollama. L’image est encodée en base64 dans Images, le prompt est rempli par la catégorie du rapport, le streaming est désactivé, et Format: "json" est défini. Le champ Response de l’enveloppe est ensuite déstructuré pour obtenir la liste des paramètres. Certaines instructions dans ce fragment sont exécutées ensemble sur une seule ligne ; il faut donc le considérer comme un schéma du flux, et vérifier resp.StatusCode ainsi que les erreurs ignorées en environnement de production.
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
}
Le prompt, reformulé plutôt que reproduit tel quel en production, énumère des règles strictes et se termine par un exemple de l’array exact à retourner :
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
}
]
Chaque règle vise un mode de défaillance. L’interdiction des valeurs inventées permet de lutter contre les hallucinations, l’erreur la plus dangereuse dans les données médicales. Un seul objet par test empêche le modèle de fusionner plusieurs éléments en un seul bloc. La copie des valeurs telles qu’elles sont affichées conserve les unités et les plages de valeurs, tandis que l’omission des paragraphes d’interprétation évite que du texte narratif ne se retrouve dans des champs structurés. Le score de confiance est utile pour diriger les valeurs douteuses vers un humain, bien que l’évaluation automatique du modèle ne soit qu’une heuristique et non une probabilité calibrée. La dernière règle, qui exige de ne répondre qu’avec un tableau plat, est plus importante qu’il n’y paraît, car le mode JSON seul ne garantit pas la structure des données.
Analyse de résultats ignorant la forme requise
Format : « json » pousse la génération vers un JSON valide, mais ce n’est pas un schéma. En pratique, un modèle local plus petit peut placer la liste à l’intérieur d’un objet comme {"tests": [...]}, retourner un seul objet, ou générer quelque chose qui n’est pas un document lisible et structuré.
Au lieu d’échouer à l’extraction dès la première incohérence, le parseur essaie des stratégies de plus en plus souples et s’arrête à la première réussite :
// 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
La analyse stricte s’exécute en premier, de sorte que les réponses bien structurées empruntent le chemin le plus simple. Ensuite, toute clé de conteneur contenant un tableau non vide est acceptée, puis un objet isolé est transformé en liste. Une exploration récursive gère les emboîtements profonds et associe des noms de champs correspondant de manière approximative (test_name, name, parameter_name) à un seul champ. En dernier recours, une expression régulière extrait les blocs {...} individuels et les analyse séparément, ce qui permet de récupérer des réponses endommagées par des clés dupliquées.
Cela n’est pas élégant, et idéalement le modèle devrait respecter les consignes à chaque fois. C’est le prix à payer pour utiliser soi-même un modèle compact plutôt que d’acheter une API commerciale parfaitement optimisée : une facture de abonnement en échange d’un traitement plus prudent des données, ce qui était justifié pour un MVP. Deux suggestions si vous copiez cette idée : enregistrez quelle stratégie a fonctionné afin de pouvoir évaluer si les modifications des prompts ou du modèle sont utiles, et validez plus strictement les résultats obtenus avec les stratégies moins rigoureuses. Les dernières versions d’Ollama permettent également d’utiliser un schéma JSON dans l’attribut format ; consultez la documentation actuelle correspondant à votre version.
Étape 3 : Extraire les rapports multi-pages page par page
Chaque page d’un rapport multi-pages est envoyée à Ollama en tant que demande distincte, et les résultats de chaque page sont concaténés en une seule liste avant qu quoi que ce soit ne soit écrit dans Postgres. Si une page échoue, l’erreur est enregistrée et les pages restantes continuent d’être traitées, évitant ainsi l’échec de tout le rapport. L’inconvénient est qu’un rapport peut être enregistré avec certains résultats manquants ; par conséquent, le suivi des pages échouées permet de conserver cette état partiel visible.
Étape 4 : Échelle du travailleur avec des groupes de consommateurs Redis Streams
Le travailleur lit les tâches depuis un flux Redis en tant que membre d’un groupe de consommateurs, ce qui permet à plusieurs instances de fonctionner en parallèle, chaque message étant livré uniquement à l’une d’entre elles. Un message n’est confirmé avec XAck que lorsque l’extraction et l’écriture dans la base de données ont toutes deux réussi.
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)
Les demandes d’ID > ne transmettent jamais de messages au groupe : Count: 1 traite une tâche à la fois, tandis que Block: 0 attend indéfiniment. Comme la confirmation arrive en dernier, une panne fait que le message reste dans la liste des entrées en attente au lieu d’être perdu. Cependant, les messages en attente ne sont pas relancés automatiquement ; il faut prévoir l’utilisation de XAUTOCLAIM ou XPENDING avec XCLAIM pour transférer les tâches obsolètes à un consommateur fonctionnel.
Les pannes temporaires sont réessayées jusqu’à trois fois avec un court délai d’attente avant que le rapport ne soit marqué FAILED. Les erreurs que les tentatives de réessai ne peuvent pas résoudre, comme l’absence du fichier source dans le stockage, sont considérées comme fatales et les tentatives de réessai sont complètement ignorées.
En résumé
Aucune de ces options n’est théoriquement la meilleure en isolation. Utiliser pdftoppm, combiner des analyseurs de secours et accepter des rapports partiels constituent des compromis adaptés à des contraintes réelles : un budget limité pour le MVP, des données de patients sensibles conservées en interne, ainsi qu’une équipe réduite chargée de livrer le produit.
- Ajustez la résolution de rasterisation en fonction du plus petit texte pertinent.
- Considérez le mode JSON comme un indice et analysez les formes réellement générées par le modèle, en privilégiant d’abord les chemins.
- Rédigez des instructions sous forme de règles explicites, chacune ciblant un échec observé.
- Isolez les échecs par page et par tâche, et affichez les résultats partiels.
- Ne prenez en compte les messages de file d’attente qu’après que chaque effet secondaire ait réussi.