用 Go 语言自托管视觉大模型 OCR:光栅化、提示词设计与解析
通过Ollama上的视觉大语言模型,Go语言工作进程可将医疗PDF文件转换为JSON格式:采用pdftoppm进行光栅化处理、使用精确的提示词、设置备用解析方案以及利用Redis Streams进行数据读取。
来源: Inside the OCR Pipeline: Code, Prompts, and Failure Handling (Part 2 of 2)
选择自托管的视觉模型而非云端的OCR API仅是决策的一半;另一半则在于构建一个能够处理模型实际输出结果的流程。本指南将以一个用于读取医学实验室报告并将结果存储在Postgres中的生产级Go工作进程为例进行讲解。您将了解页面是如何被光栅化的、提示词是如何构建的、如何解析那些不符合预期结构的输出,以及该工作进程如何在避免重复处理任务的情况下实现扩展。
步骤1:将PDF转换为页面图像
Ollama的视觉模型只能处理图像,无法识别PDF文件,因此每次上传的内容都会先被转换为每页一张图像。它没有使用体积庞大的渲染库,而是调用工作节点主机上已安装的Poppler套件中的pdftoppm工具。该工具会将字节数据写入临时文件,以300 dpi的分辨率将每页渲染为PNG格式,之后再读取并删除这些生成的文件。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"`
}
该请求是向 Ollama 的 /api/generate 发送的普通 POST 请求。图像被转换为 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), ¶meters); 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), ¶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
首先会进行严格的解析,因此表现正常的响应会走最简化的路径。接着,任何包含非空数组的包装键都会被接受,而单独的对象则会被提升为列表。通过递归遍历可以处理深层嵌套,并将匹配度较低的字段名(如test_name、name、parameter_name)映射到同一个字段。作为最后手段,正则表达式会提取出各个{...}块并分别进行解析,从而解决因键重复而导致的响应错误问题。
这种方式并不优雅,理想情况下模型应每次都能正确响应。这是选择自行开发紧凑型模型的代价,而非使用经过优化的商业 API:用订阅费用换取防御性的解析功能,对于 MVP 来说这是值得的。如果您打算借鉴这一思路,有两点建议:记录哪种策略有效,以便衡量提示词或模型修改是否有所帮助;并对那些较为宽松的策略的结果进行更严格的验证。最新版本的 Ollama 还支持在 format 参数中指定 JSON 模式,具体请查阅对应版本的文档。
步骤 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或结合XCLAIM与XPENDING来将过期的任务交给正常的处理程序。
短暂故障会在报告被标记为FAILED之前进行最多三次尝试,并伴有短暂的延迟时间。那些通过重试无法解决的错误,比如存储中缺少源文件,会被视为致命错误而直接跳过重试流程。
总结
这些选项单独来看都没有理论上的最佳解。使用 pdftoppm、组合备用解析器以及接受部分报告都是为了解决现实中的限制而做出的妥协:有限的MVP预算、需保留在内部的敏感患者数据,以及必须按时交付产品的小型团队。
- 根据最重要的最小文本尺寸来调整光栅化分辨率。
- 将JSON模式视为提示,重点解析模型实际生成的图形结构,优先处理路径信息。
- 将提示以明确规则的形式写出,每条规则针对一种已观察到的故障情况。
- 按页面和任务分别隔离故障,并让部分结果可见。
- 只有在所有副作用都处理成功后才确认队列消息。