OCR de LLM Vision autohospedado en Go: rasterización, generación de prompts y análisis
Un trabajador de Go convierte PDF médicos en JSON utilizando un LLM de visión en Ollama: rasterización con pdftoppm, prompts estrictos, análisis de respaldo y consumidores de Redis Streams.
Fuentes: Inside the OCR Pipeline: Code, Prompts, and Failure Handling (Part 2 of 2)
Elegir un modelo de visión autohostado en lugar de una API OCR en la nube es solo la mitad de la decisión; la otra mitad consiste en un proceso que pueda manejar lo que realmente devuelve el modelo. Esta guía sigue a un trabajador en Go de producción que lee informes de laboratorios médicos y almacena los resultados en Postgres. Verá cómo se rasterizan las páginas, cómo se forma la instrucción de procesamiento, cómo analizar resultados que ignoran la estructura solicitada y cómo el trabajador escala sin procesar las tareas dos veces.
Paso 1: Convertir PDF en imágenes de página
Los modelos de visión de Ollama aceptan imágenes, no PDFs, por lo que cada carga se procesa primero convirtiéndola en una imagen por página. En lugar de utilizar una biblioteca de renderizado pesada, el proceso utiliza pdftoppm del conjunto Poppler, instalado en el servidor donde se ejecuta el proceso. La función escribe los datos en un archivo temporal, renderiza cada página como PNG a 300 dpi y luego lee y elimina los archivos generados. exec.CommandContext vincula el proceso hijo al contexto de la tarea, de modo que la cancelación también detiene el proceso de renderizado.
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 resultó ser el equilibrio adecuado: los textos pequeños como las unidades y los rangos de referencia permanecen legibles para el modelo sin generar imágenes de página demasiado grandes. El fragmento omite varios errores por brevedad; házselos frente en el código real. También ten en cuenta que filepath.Glob ordena de forma léxica, por lo que para diez o más páginas podrías querer ordenarlas según el número de página que agrega pdftoppm.
Paso 2: Pedir al modelo una estructura JSON específica
El objetivo de la extracción es una lista de resultados de pruebas, y la estructura define los requisitos: nombre, valor, unidad, rango de referencia, indicador de anomalía y una puntuación de confianza autoinformada.
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 llamada es un POST simple a /api/generate de Ollama. La imagen se codifica en base64 y se incluye en Images, el prompt se rellena con la categoría del informe, la transmisión en flujo continuo está desactivada y se establece Format: "json". Luego, el campo Response del sobre se desestructura en el conjunto de parámetros. Algunas instrucciones en este fragmento se ejecutan juntas en líneas separadas, por lo que debe leerse como un esquema del flujo; además, en entornos de producción hay que verificar resp.StatusCode y los errores ignorados.
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
}
El prompt, redactado de forma rephraseada en lugar del texto utilizado en producción, enumera reglas estrictas y finaliza con un ejemplo del arreglo exacto que debe devolverse:
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
}
]
Cada regla está dirigida a un modo de fallo específico. Prohibir valores inventados aborda el problema de las alucinaciones, el error más peligroso en los datos médicos. Tener un único objeto por prueba impide que el modelo combine varios elementos en uno solo. Copiar los valores tal como se imprimen mantiene las unidades y rangos adecuados, y omitir los párrafos de interpretación evita que la narrativa se incluya en campos estructurados. La puntuación de confianza es útil para dirigir los valores dudosos a un humano, aunque la autoevaluación del modelo es más bien una heurística que una probabilidad calibrada. La última regla, responder únicamente con un array simple, es más importante de lo que parece, ya que el modo JSON por sí solo no garantiza la estructura.
Analizando salidas que ignoran la forma solicitada
Formato: "json" tiende a generar JSON válido, pero no se trata de un esquema. En la práctica, un modelo local más pequeño puede anidar la lista dentro de un objeto como {"tests": [...]}, devolver un único objeto o emitir algo que no sea un documento legible y bien estructurado.
En lugar de fallar en la extracción al encontrar la primera discrepancia, el analizador prueba estrategias cada vez menos estrictas hasta detenerse en el primer éxito:
// 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
Primero se ejecuta el análisis estricto, por lo que las respuestas bien formadas siguen el camino más sencillo. A continuación, se acepta cualquier clave de envoltorio que contenga un array no vacío; luego, un objeto individual se convierte en una lista. Un recorrido recursivo maneja el anidamiento profundo y asigna nombres de campos que coinciden de forma laxa (test_name, name, parameter_name) a un único campo. Como último recurso, una expresión regular extrae los bloques {...} individuales y los analiza por separado, lo que permite recuperar respuestas dañadas por claves duplicadas.
No es elegante, y lo ideal sería que el modelo cumpliera siempre. Este es el precio de utilizar un modelo compacto por cuenta propia en lugar de pagar por una API comercial pulida: una factura de suscripción a cambio de un análisis defensivo, lo cual valió la pena para un MVP. Dos sugerencias si copias esta idea: registra qué estrategia funcionó para poder medir si los cambios en los prompts o en el modelo son útiles, y valida con mayor rigor los resultados de las estrategias menos estrictas. Las versiones recientes de Ollama también pueden aceptar un esquema JSON en el parámetro format; consulta la documentación actual correspondiente a tu versión.
Paso 3: Extraer los informes de varias páginas página por página
Cada página de un informe multi-páginas se envía a Ollama como una solicitud independiente, y los resultados de cada página se concatenan en una sola lista antes de escribir cualquier cosa en Postgres. Si alguna página falla, el error se registra y las páginas restantes siguen procesándose en lugar de hacer que todo el informe falle. La desventaja es que un informe puede guardarse con algunos resultados faltantes, por lo que registrar las páginas fallidas mantiene visible ese estado parcial.
Paso 4: Escalar el trabajador con grupos de consumidores de Redis Streams
El trabajador lee las tareas desde un Redis Stream como parte de un grupo de consumidores, de modo que varias instancias pueden ejecutarse en paralelo mientras cada mensaje se entrega solo a una de ellas. Un mensaje se confirma con XAck únicamente después de que tanto la extracción como la escritura en la base de datos hayan tenido éxito.
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)
Las solicitudes de ID > nunca se entregan al grupo; Count: 1 procesa un trabajo a la vez, mientras que Block: 0 espera indefinidamente. Dado que la confirmación llega al final, una caída deja el mensaje en la lista de entradas pendientes en lugar de perderlo. Sin embargo, los mensajes pendientes no se vuelven a entregar por sí solos; es necesario utilizar XAUTOCLAIM o XPENDING junto con XCLAIM para transferir las tareas obsoletas a un consumidor funcional.
Las fallas transitorias se intentan resolver hasta tres veces con un breve retraso antes de que el informe sea marcado como FAILED. Los errores que no se pueden solucionar con reintentos, como la falta del archivo de origen en el almacenamiento, se consideran fatales y se omiten los reintentos por completo.
Conclusión
Ninguna de estas opciones es teóricamente la mejor por sí sola. Utilizar pdftoppm, combinar parsers alternativos y aceptar informes parciales son compromisos que se adaptan a limitaciones reales: un presupuesto reducido para el MVP, datos de pacientes sensibles que deben permanecer en la empresa y un equipo pequeño al que le corresponde entregar el producto.
- Ajuste la resolución de rasterización según el texto más pequeño que sea importante.
- Trate el modo JSON como una pista y analice las formas que realmente genera el modelo, priorizando siempre los caminos.
- Escriba instrucciones como reglas explícitas, cada una dirigida a un fallo observado.
- Aíslé los fallos por página y por tarea, y haga visibles los resultados parciales.
- Reconozca los mensajes de la cola solo después de que todos los efectos secundarios tengan éxito.
Lecturas relacionadas
- Auto-hospedaje del servidor agente LangGraph con Postgres y Redis — Aprenda cómo Langhost reemplaza la capa de persistencia de LangGraph por Postgres y Redis, permitiendo a los equipos auto-hospedar el servidor agente sin modificaciones bajo una licencia MIT.
- Prompts inversos: convirtiendo una buena sesión de LLM en un prompt reutilizable — Aprenda cómo extraer un prompt de uso único a partir de una conversación exitosa de múltiples turnos con un LLM, y cómo la inversión de prompts permite recuperarlos cuando solo se dispone de las salidas.