资讯详情

本地OCR+大模型:批量发票合同自动识别整理工作流

📅 2026/10/20 13:44:26 | 华诺云谱 👁 阅读
本地OCR+大模型:批量发票合同自动识别整理工作流
1. 先聊聊这个工作流到底在解决什么问题1.1 那些细碎内容的痛点做项目的人都有这种经历手里一堆发票、合同扫描件、会议纪要照片、甚至手写便签要录入系统或者汇总成表格的时候整个人是崩溃的。逐张打开、肉眼识别、手动敲字一天下来眼睛酸脖子疼还容易漏项。尤其是那种一叠几十页的合同每一页都要校对金额、日期、甲方乙方名称错一个字都是事故。我最初是被一个模拟项目X逼着搞这个的。当时需要把三个月内的报销单据全部电子化大概两百多张发票纸质件堆了大半个纸箱。一开始想用现成的在线工具但涉及财务数据文件传出去心里不踏实而且单张导出还要开会员量大根本扛不住。后面干脆在本地搭了一套OCR识别加自动整理的流水线跑完这批发票只花了不到半小时后续同事看了效果好又把合同扫描件、会议纪要照片全丢给我处理。这个工作流本质上做的事情很简单把图片里的文字抠出来再交给大模型重新排版成我们想要的格式。听起来容易真正做起来有几个坑是绕不开的——识别精度、表格结构、大模型输出稳定性每一环都可能翻车。这篇文章把我踩过的坑、调通的代码、改了好几次的Prompt都整理出来你照着搭一套本地跑通不是问题。1.2 为什么非要本地OCR而不是直接用在线识别先说说选型的思路。市面上现成的OCR服务不少手机自带的扫一扫、各种翻译App、在线文档工具的图片转文字功能日常用着都还行。但放到批量处理、敏感数据、自定义格式这几个场景下问题就来了第一是隐私。发票有公司抬头、税号、金额合同有条款细节这些数据走在线接口等于把内容交给了第三方。很多团队对数据出境管得很严文件根本不允许上传到外部服务。本地跑OCR识别过程全在你自己机器上数据不出内网这道红线就守住了。第二是可定制性。在线工具的输出格式基本是死的要么纯文本要么简单表格。我们的需求是识别完还要按照特定格式输出比如发票要把价税分离、合同要提取关键条款和金额、会议纪要要按议题归纳。这需要对接大模型做二次整理在线工具的接口要么不开放要么格式不支持远不如自己搭一套灵活。第三是成本。批量识别几百张图在线接口是按次数计费的一个月下来是笔不小的开销。本地跑一次投入后面基本都是免费的。我自己这台机器CPU推理一张普通清晰度的发票大约1到2秒两百张也就几分钟的事完全够用。如果后续量更大还可以换GPU加速那就是另外一个量级了。1.3 整体流程怎么串整套工作流就四个环节图片预处理、OCR识别、文本整合、大模型格式化输出。图片预处理解决的是拍歪了、光线暗、分辨率低这类问题。OCR引擎对输入图像的清晰度和方向敏感先把图摆正、调亮、降噪能显著提升识别准确率。OCR识别环节把图片中的文字按位置提取出来附带每个文本块的位置信息和置信度。文本整合环节把分散的文本块按阅读顺序拼成连贯的段落这一步很多人忽略直接导致的后果是识别结果东一句西一句大模型根本没法理解。最后的大模型格式化环节把整理好的原始文本丢给大模型配合精心设计的Prompt让它输出JSON或者表格。这四个环节里前两个是技术活中间那个是细节活最后一个是经验活。我下面逐个拆开讲。2. 技术选型OCR引擎和模型怎么挑2.1 为什么首选PaddleOCROCR这块我前后试过好几个方案最后留下的是PaddleOCR。选它的理由很直接中文识别能力强、安装方便、社区活跃、出错有地方查。Tesseract是老牌开源方案很多年前的通用做法但中文识别效果一般对复杂背景和表格的支持更是吃力。RapidOCR是PaddleOCR模型的推理加速版部署更轻量如果你只要识别不要训练它也是个不错的选择。相比之下PaddleOCR在国内各种票据、合同、身份证这类场景被反复验证过开箱即用的中文模型效果非常能打。而且PaddleOCR自带的文本方向分类器很实用。扫描的文档经常有旋转90度、180度的情况打开方向分类器后引擎会自动把图摆正再识别省掉了不少预处理工作。对于倾斜角度不大的图它也能通过检测框计算倾斜角度不需要额外做图像矫正。我用下来的感受是普通打印体中文的识别准确率基本在95%以上清晰度正常的情况下除了个别特殊字体和极小字号日常需求完全够用。金额、日期、数字串这种关键信息识别率更高因为检测到的字符越少置信度越好。2.2 备用方案Tesseract和RapidOCRTesseract也不是完全没用。它胜在轻量和跨平台如果你的环境装不了PaddlePaddle这个庞然大物或者只需要处理英文和纯数字Tesseract仍然可以作为备选。安装方式也简单pip装pytesseract再装个系统级的tesseract-ocr就行。但要注意它的中文识别需要下载对应的语言包效果相比PaddleOCR还是差一截尤其是对低分辨率图片。RapidOCR则是另一个思路。它把PaddleOCR的模型导出后用ONNX Runtime推理好处是不装PaddlePaddle环境干净很多启动快、内存占用低。如果你只是做简单的文字提取不想引入整个深度学习框架选RapidOCR挺合适。但它的可定制性差一些想微调模型就不太方便了。我自己这边最终还是用PaddleOCR因为后面想扩展表格识别和文档方向分类它自带的能力更完整。所谓选型不是追求最好而是选最适合自己场景的你只需要文字提取就上轻量的需要稳定建工作流就上功能全的。2.3 大模型接口的选择思路大模型这步我建议走OpenAI兼容协议。现在市面上主流的模型服务基本都支持这种格式你只需要改一下base_url、api_key、model名称代码完全不用动。如果你不想把数据送出去也可以本地跑一个开源模型服务同样兼容这套协议只是对机器配置有要求内存至少16G起步推理速度也慢一些。选模型的时候注意两点一是上下文长度有些模型的上下文窗口只有几千token一次性塞进太多OCR文本会被截断。建议选上下文至少8K以上的模型合同这种长文本一张A4纸识别出来大概几百字多页合在一起也不至于爆。二是输出的稳定性这个更多靠Prompt来控制我后面会讲具体怎么设计。3. 环境准备与基础配置3.1 Python环境搭建这个工作流我用的Python 3.10PaddleOCR官方对3.12以下支持比较稳定太新的版本容易踩依赖坑。我建议用虚拟环境装隔离好依赖免得和机器上其他项目的包互相打架。python -m venv ocr_env source ocr_env/bin/activate # Windows下用 ocr_env\Scripts\activate pip install --upgrade pip虚拟环境这种基础操作我之前也嫌麻烦直到被依赖冲突搞崩了两次环境才开始老实用。做工具类项目尤其要注意PaddlePaddle的依赖链很长稍微一乱就报奇奇怪怪的错。3.2 安装依赖核心依赖就这几个paddlepaddle2.6.1 paddleocr2.8.1 opencv-python4.10.0.84 Pillow10.4.0 requests2.32.3装的时候有个顺序问题先把paddlepaddle装上再装paddleocr。中间如果报错缺少什么底层库一般就是OpenCV的依赖问题在Linux上需要装一下libgl1和libglib2.0。Windows上一般没这个问题。如果你有NVIDIA显卡想用GPU加速建议装paddlepaddle-gpu但要注意和CUDA版本的对应关系。没有显卡也不用慌CPU模式下普通文档处理完全能接受真正耗时的是大并发批量处理一般场景根本到不了那个量级。3.3 首次运行验证装完之后先跑一段最简单的代码验证环境是否正常from paddleocr import PaddleOCR ocr PaddleOCR(use_angle_clsTrue, langch, show_logFalse) result ocr.ocr(test.jpg, clsTrue) print(result)第一次运行会下载模型文件大概几十MB下载完成后会自动缓存到本地后面再跑就不会重复下载了。这时候拿一张清晰的打印文档试一下如果输出正常说明环境没问题。如果报了libgomp或者GLIBC相关的错误多半是系统库的版本问题百度一下对应的报错很快能解决。一个我踩过的坑是模型下载源的问题。有些网络环境下默认的模型下载地址可能超时这时候需要手动把模型文件放到位或者在环境变量里配置镜像源。具体做法是在初始化的时候传入det_model_dir、rec_model_dir参数指向本地路径提前下载好模型文件放进去。这个办法在离线内网环境下特别实用。4. 核心代码实现从图片到结构化文本4.1 图片预处理预处理这步直接决定识别的上限。OCR引擎再强输入一张严重倾斜、过曝、分辨率的图效果也白搭。我常用的预处理包括三个动作调整对比度、灰度化、提高分辨率。先看一个基础的预处理函数import cv2 import numpy as np def preprocess_image(image_path, output_pathNone): # 读取图片 img cv2.imread(image_path) if img is None: raise ValueError(f无法读取图片: {image_path}) # 转灰度 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 自适应直方图均衡化增强对比度解决光线不均的问题 clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) enhanced clahe.apply(gray) # 如果图片太小放大两倍提升小字号的识别率 h, w enhanced.shape if min(h, w) 1200: scale 2.0 enhanced cv2.resize(enhanced, None, fxscale, fyscale, interpolationcv2.INTER_CUBIC) # 降噪去掉扫描件的颗粒感 denoised cv2.bilateralFilter(enhanced, d5, sigmaColor50, sigmaSpace50) if output_path: cv2.imwrite(output_path, denoised) return denoised这个函数有三个关键点。第一自适应直方图均衡化CLAHE对那种一部分亮一部分暗的扫描件效果特别好比简单的全局均衡化自然得多。第二放大低分辨率图片是个简单有效的手段图像插值不会增加信息量但能提高OCR引擎的检测稳定性。第三双边滤波在降噪的同时能保住边缘不像高斯模糊那样把文字边缘也糊掉。预处理还有个动作是纠偏。如果图片倾斜角度比较大可以用cv2.minAreaRect配合轮廓检测计算倾斜角然后做仿射变换摆正。不过PaddleOCR自带的方向分类和检测框已经能处理很大一部分倾斜情况预处理这步我一般只做亮度和分辨率调整纠偏交给引擎内部解决。4.2 OCR识别封装核心识别部分我把PaddleOCR封装成一个可复用的类方便多次调用from paddleocr import PaddleOCR class OCREngine: def __init__(self, langch, use_angle_clsTrue, use_gpuFalse): self.ocr PaddleOCR( use_angle_clsuse_angle_cls, langlang, show_logFalse, use_gpuuse_gpu ) def recognize(self, image_path): 返回结构化的文本行列表每项包含文本、置信度和位置信息 result self.ocr.ocr(image_path, clsTrue) lines [] if result is None or len(result) 0: return lines for page in result: if page is None: continue for item in page: box, text_info item text text_info[0] confidence text_info[1] # 过滤低置信度结果 if confidence 0.5: continue lines.append({ text: text, confidence: confidence, box: box }) return lines置信度过滤这里值得说一下。PaddleOCR返回的置信度范围是0到1低置信度的结果通常是检测框定位不准、字迹模糊或者背景干扰导致的。我一开始没做过滤结果识别出来的文本里混了不少乱码和错字。后来加了一个0.5的阈值低置信度行全部丢弃整体文本质量明显提升。当然阈值不能设太高否则会漏掉真实文本0.5对于中文识别场景是比较平衡的值。如果你要处理的是多页PDF还需要把PDF逐页转成图片。这一步用PyMuPDFfitz来做很方便import fitz def pdf_to_images(pdf_path, dpi200): doc fitz.open(pdf_path) images [] for page_num in range(len(doc)): page doc[page_num] pix page.get_pixmap(dpidpi) img_path ftemp_page_{page_num}.png pix.save(img_path) images.append(img_path) return imagesdpi200是个平衡点。太低了文字边缘发虚太高了生成的图片文件巨大识别时间也跟着涨。扫描件的分辨率一般150到300之间200dpi基本能覆盖大多数合同和发票的清晰度需求。4.3 结果清洗与合并OCR识别出来的是一行一行的散装文本带着位置信息。要想让大模型读懂必须按阅读顺序拼成段落。这里有个关键逻辑PaddleOCR检测框是按检测顺序返回的不保证是阅读顺序尤其是多栏排版或者表格内容顺序经常是乱的。最简单有效的排序方法是按检测框的纵坐标分组再把同一行内的文本按横坐标排序def sort_and_merge(lines): # 按检测框的中心点Y坐标分行 rows {} for item in lines: box item[box] # 取顶部两个点的平均Y值作为所在行位置 y_center (box[0][1] box[1][1]) / 2 x_center (box[0][0] box[2][0]) / 2 assigned False for key in rows: if abs(key - y_center) 15: # 高度差在15像素内视为同一行 rows[key].append((x_center, item)) assigned True break if not assigned: rows[y_center] [(x_center, item)] # 按Y坐标排序行按X坐标排序行内文本 sorted_rows sorted(rows.items(), keylambda kv: kv[0]) merged_lines [] for _, row_items in sorted_rows: row_items.sort(keylambda xi: xi[0]) line_text .join([item[1][text] for _, item in row_items]) merged_lines.append(line_text) return \n.join(merged_lines)这个按高度差聚行的思路本质上模拟了人眼阅读的逻辑。实际处理合同扫描件的时候多栏文本、表格单元格、页眉页脚都能靠这个逻辑大致还原成正确的阅读顺序。当然表格结构想还原得更好就得用PaddleOCR的表格识别能力或者干脆让大模型从纯文本里提取后者在实际使用中反而更稳。5. 大模型格式化把乱文本变成规整结构5.1 Prompt设计思路OCR输出的是原始文本大模型这步要把它变成我们想要的格式。很多人直接把文本丢给大模型说帮我整理一下结果输出五花八门一会JSON一会Markdown格式完全不可控。问题出在Prompt太模糊。我经过多次调优总结出一条好用的Prompt设计套路定义角色 明确输入 规定输出格式 给示例 限制自由度。先看一个整理发票信息的Prompt模板SYSTEM_PROMPT 你是一个文档信息提取助手。你的任务是从用户提供的OCR文本中提取结构化信息。 请严格按以下JSON格式输出不要输出任何额外内容 { document_type: 发票/合同/其他, 关键字段: { 发票号码: , 开票日期: , 购买方名称: , 销售方名称: , 金额(元): , 税额(元): , 价税合计(元): }, 备注: } 如果OCR文本中找不到某个字段对应位置填空字符串不要猜测。 这个Prompt里有几个细节很关键。第一定义了输出格式用JSON结构明确告诉模型要提取哪些字段。第二加了不要猜测的约束避免模型编造信息OCR没识别到的字段就空着。第三可选加一个few-shot示例告诉模型标准的输入输出长什么样对提升稳定性很有帮助。对于合同的场景Prompt又要调整。合同的核心是提取甲方、乙方、合同金额、签订日期、关键条款。这里我建议在Prompt里明确指出按条款顺序输出每一条要点而不是让模型自由归纳否则它会丢细节。5.2 调用大模型的代码实现调用层面我封装了一个兼容OpenAI协议的函数换模型服务只需要改配置import requests import json class LLMClient: def __init__(self, base_url, api_key, model, temperature0.2): self.base_url base_url.rstrip(/) self.api_key api_key self.model model self.temperature temperature def chat(self, prompt, system_promptNone, max_tokens2000): url f{self.base_url}/chat/completions headers { Authorization: fBearer {self.api_key}, Content-Type: application/json } messages [] if system_prompt: messages.append({role: system, content: system_prompt}) messages.append({role: user, content: prompt}) payload { model: self.model, messages: messages, temperature: self.temperature, max_tokens: max_tokens } resp requests.post(url, headersheaders, jsonpayload, timeout60) if resp.status_code ! 200: raise RuntimeError(fLLM接口调用失败: {resp.status_code} {resp.text}) data resp.json() content data[choices][0][message][content] return content def extract_json(self, prompt, system_promptNone): 调用大模型并尝试解析JSON输出带重试 for attempt in range(3): content self.chat(prompt, system_prompt) try: # 去掉可能的Markdown代码块包裹 cleaned content.strip() if cleaned.startswith(json): cleaned cleaned[7:] if cleaned.startswith(): cleaned cleaned[3:] if cleaned.endswith(): cleaned cleaned[:-3] return json.loads(cleaned.strip()) except json.JSONDecodeError as e: if attempt 2: raise RuntimeError(f大模型连续3次输出非法JSON: {content[:200]}) return Nonetemperature设成0.2是个重要细节。做信息提取这种任务我们需要的是确定性输出不是创造性发挥。温度调低一点模型会倾向于给出稳定一致的答案。我之前用默认温度1.0同一个文本跑两次输出的字段格式居然有差异后来调到0.2基本稳定了。5.3 输出解析与校验大模型输出的JSON偶尔会带Markdown代码块的包裹或者某个字段漏了直接json.loads会报错。上面的代码里我做了两步处理一是把代码块的标记剥掉二是重试机制最多试三次。校验环节更重要。有些模型会在字段里凭空生成内容比如OCR文本里明明没有发票号码它给你编一个2024-0001。所以提取出来的关键字段最好做一遍基础校验。发票号码常见格式是8到20位数字金额应该匹配小数格式。简单用正则校验一下不对就标记为待人工核对。import re def validate_invoice_data(data): issues [] invoice_no data.get(关键字段, {}).get(发票号码, ) if invoice_no and not re.match(r^\d{8,20}$, invoice_no): issues.append(f发票号码格式异常: {invoice_no}) amount data.get(关键字段, {}).get(金额(元), ) if amount and not re.match(r^\d(\.\d{1,2})?$, amount): issues.append(f金额格式异常: {amount}) return issues这一步能拦住大部分错误输出。业务数据最怕的就是模型一本正经地胡说八道校验逻辑是在交付前最后一道防线。6. 完整工作流串联6.1 主流程代码把前面所有环节串起来主流程大概长这样import os import json def process_document(file_path, engine, llm, doc_typeinvoice): # 1. 判断文件类型 ext os.path.splitext(file_path)[1].lower() images [] if ext in [.pdf]: images pdf_to_images(file_path) elif ext in [.jpg, .jpeg, .png, .bmp]: images [file_path] else: raise ValueError(f不支持的文件类型: {ext}) all_text [] for img_path in images: # 2. 预处理 preprocessed preprocess_image(img_path) # 3. 识别 lines engine.recognize(img_path) # 识别原图或预处理图 merged sort_and_merge(lines) all_text.append(merged) raw_text \n.join(all_text) # 4. 构造Prompt调用大模型 prompt f以下是OCR识别出的原始文本请提取结构化信息\n{raw_text} if doc_type invoice: system_prompt INVOICE_SYSTEM_PROMPT elif doc_type contract: system_prompt CONTRACT_SYSTEM_PROMPT else: system_prompt DEFAULT_SYSTEM_PROMPT result llm.extract_json(prompt, system_prompt) # 5. 校验 if result and doc_type invoice: issues validate_invoice_data(result) result[校验问题] issues return result这段代码把前面所有逻辑都编排好了。识别的时候有个小注意点预处理函数返回的是处理后的图像但识别的时候要用原图还是预处理图我的经验是对于对比度正常的原图直接识别原图效果更好对于光线不均、模糊的图用预处理后的图效果明显提升。更稳的做法是预处理图存储到临时路径然后识别预处理图。我在代码示例里保留了两种可能性你可以根据实际情况选。6.2 批量处理与文件管理单张处理搞定之后批量处理就简单了遍历文件夹即可def batch_process(input_dir, output_dir, engine, llm): os.makedirs(output_dir, exist_okTrue) results [] for filename in sorted(os.listdir(input_dir)): file_path os.path.join(input_dir, filename) if not os.path.isfile(file_path): continue try: print(f处理中: {filename}) result process_document(file_path, engine, llm) # 按原文件名保存结果 result[source_file] filename results.append(result) output_name os.path.splitext(filename)[0] .json with open(os.path.join(output_dir, output_name), w, encodingutf-8) as f: json.dump(result, f, ensure_asciiFalse, indent2) except Exception as e: print(f处理失败: {filename}错误: {e}) # 失败的文件记录到单独的目录方便后续补处理 with open(os.path.join(output_dir, errors.log), a, encodingutf-8) as f: f.write(f{filename}\t{e}\n) return results批量处理这里有个很实用的设计单个文件失败不能中断整个批处理要记录下来继续跑下一个。现实场景里总会有几张图特别糟糕要么是模糊到没法识别要么是PDF损坏。等批处理跑完再看errors.log去补处理那几张就行省得为了一张烂图反复重启整个流程。输出命名我用的是原文件名的前缀保持了文件之间的对应关系。如果后面要把JSON转成Excel直接读这些文件批量生成就行不用再重新识别一遍。7. 常见问题与排查技巧实录7.1 识别准确率低这是最常碰到的问题但准确率低要分情况看。是整页都乱码还是个别字段错整页乱码多半是图片质量问题或者文字不是中文。个别字段错往往是字体特殊或者字号过小。我总结的排查顺序是这样的先看原图清晰度再看是不是有复杂背景最后看字体是否太花哨。如果原图本身是手机随手拍的优先做预处理里的CLAHE和放大操作。如果文字颜色和背景对比度太低可以先用二值化把文字和背景彻底分离开。PaddleOCR对深色底浅色字的图片识别效果比白底黑字差不少二值化后再识别会好很多。另一个容易被忽略的点是图片方向。竖屏拍的合同文字可能是横向的这时候不开use_angle_cls整页识别结果就是乱的。确认初始化PaddleOCR时开了角度分类。7.2 内存占用过高PaddleOCR在CPU模式下单张图片识别大约吃1到2GB内存批量处理时如果连续加载内存可能一直累积。解决方案是定期重启进程或者用multiprocessing处理时不加载过多图片。还有个经验OCR实例初始化一次就够了别每次都new一个PaddleOCR对象。我见过有些代码在循环里创建OCR实例结果每个实例都有一份模型参数几十张图跑下来内存直接爆掉。正确做法是把OCR实例创建在循环外面复用同一个实例。7.3 大模型输出不稳定前面提到temperature调低能解决一部分问题但还有别的坑。一个是上下文过长导致模型开始敷衍输出质量下降。解决办法是把OCR文本控制在合理长度内比如每张A4纸识别出的文本单独调用一次模型再把结果汇总而不是一次性塞进几十页全文。另一个是输出JSON的字段名不一致。同一个模型你让它输出金额它给你金额(元)再跑一次又变成total_amount。我踩过这个坑后在Prompt的system里写死了字段名并且加了示例输出问题基本解决。实在不放心就做一次字段映射的清洗把常见别名统一起来。7.4 性能优化建议如果你处理的量比较大有几个优化思路可以试。第一用GPU。PaddleOCR支持CUDA加速GPU推理速度是CPU的5到10倍大批量场景差距明显。第二调整识别参数。PaddleOCR有det_limit_side_len参数限制检测的最大边长对于大尺寸图片能显著提升速度但注意尺寸设太大会损失精度。第三并行处理。多进程按文件分片处理每个进程独立跑OCR实例最后合并结果。优化的核心是找到速度与精度的平衡点。我自己的经验是优先保证精度性能不够再上并行和GPU。毕竟识别错了再返工比多等几秒更费时间。8. 几点经验之谈整套工作流跑起来已经半年多了从最初只能处理清晰发票到现在合同、会议纪要、手写便签都能应付中间迭代了不少版本。我个人体会最深的一点是OCR部分再怎么折腾也只是把准确率提到一定高度就够用了真正拉开效率差距的是后面的大模型整理环节。OCR把文本抠出来是搬砖大模型把文本整理成结构化数据才是加工后者才是这套工具的含金量所在。最后分享一个扩展思路。现在这套流程还可以往两个方向走一是把识别结果自动对接表格直接把每张发票的JSON汇总成一个Excel财务对账就不用逐行抄了二是加一个web界面拖拽上传文件就能看到识别结果不需要每个人都装Python环境。我是打算后面用轻量框架包一个简单的页面让团队里的人直接浏览器里用到时候再写一篇完整的实战记录。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑