Skip to content

落地实战二:文档智能——从提取文本到结构化理解 ​

落地实战系列第二篇。前置技能:第 9 周的 Pydantic、第 11 周 Day 3 的结构化输出、第 14 周的文档解析与 RAG。本篇只解决一个问题:第 14 周 Day 2 你学会了把 PDF 变成干净文本,但业务方要的从来不是文本,是「甲方是谁、金额多少、期限几年」——从提取文本到结构化理解这一跳,这一篇补齐。

产品定义与边界 ​

先把这个产品说清楚。文档智能(Document Intelligence)做的事一句话:把非结构化文档变成两样东西——结构化数据和可问答知识。前者是字段,能进数据库、能导 Excel、能过业务流程;后者是语义,能支撑「这份报告讲了什么」的问答。第 14 周练的 RAG 只覆盖了后半句,前半句是这一篇的主场。

典型场景有四个,难度依次递增:

  1. 发票字段提取:抬头、税号、金额、开票日期,字段少且格式相对固定,最适合练手
  2. 合同要素抽取:甲方、乙方、金额、期限,字段不多但表述花样百出(「合同总价」「含税价款」「人民币壹佰贰拾万元整」说的可能是同一个字段)
  3. 招标文件对比:两版标书条款哪里改了、改了什么,抽取之外还要做对齐
  4. 报告问答:几百页的研报、尽调报告,问什么答什么,这条就是 RAG,接回第 14 到 15 周的线

边界也要先划死:本篇不做纯 OCR 引擎的研发。OCR 是个深度学习专业赛道,检测、识别、版面分析每个环节都有人在刷论文、拼榜单,从零自研等于跟 PaddleOCR、云厂商的专职团队赛跑,跑不赢也没必要。我们的定位是工程师:拿现成的 OCR 和版面分析当零件,用 LLM 做它最擅长的理解和结构化,把管线拼出来、质量管起来。这个定位决定了后面所有的技术选型。

文档的四副面孔 ​

教学核心一。拿到一摞文档,第一个动作不是抽,是分诊。文档在工程上就四种形态,走错路线,后面全白干。像医院挂号先分诊:挂错科,大夫再高明也白排一上午队。

面孔一:原生电子 PDF ​

Word 导出的、LaTeX 编译的、打印服务生成的 PDF,内部有真实字符流,pypdf 直接提。这就是第 14 周 Day 2 练过的内容,parse_doc.py 原样能用,本篇不重复。唯一要补的是分诊动作——怎么判断一份 PDF 属于这副面孔:

python
from pypdf import PdfReader

def detect_route(path: str) -> str:
    """抽前 3 页算平均字符数,判断走文本层还是走 OCR"""
    reader = PdfReader(path)
    n = min(3, len(reader.pages))
    chars = sum(len(p.extract_text() or "") for p in reader.pages[:n])
    avg = chars / n
    return "electronic" if avg > 100 else "scan"

关键一行是 p.extract_text() or "",老坑不赘述。阈值 100 是经验值:电子版随便一页都有几百字符,扫描件提出来接近零。混装文档(前几页电子、附录扫描)会在第 3 页露馅,所以分诊要贯穿整批页而不是只看第一页。

面孔二:扫描件 ​

纸质合同拍照、扫描仪出的 PDF,里面没有字符,只有图片。extract_text() 返回空,必须先 OCR。开源路线首推 PaddleOCR,中文效果是开源里的第一梯队,CPU 就能跑:

bash
pip install paddlepaddle paddleocr

最小代码(以 paddleocr 2.x 接口为例,3.x 的接口有调整,装了新版以官方 quick start 为准):

python
from paddleocr import PaddleOCR

ocr = PaddleOCR(use_angle_cls=True, lang="ch")  # 中文 + 方向分类,首次运行自动下载模型
result = ocr.ocr("invoice_scan.png", cls=True)
for line in result[0] or []:
    box, (text, conf) = line          # box 是四个角的坐标,conf 是识别置信度
    print(f"置信度 {conf:.2f}|{text}")

三件事要心里有数。一,首次运行会自动下载预训练模型,几十兆到两百兆,内网环境要提前准备。二,OCR 的输出不是干净的段落,是「一行一行的碎片 + 坐标 + 置信度」,顺序大致按从上到下、从左到右,但双栏照样可能交错,第 14 周的清洗规则得再上一遍。三,conf 这个置信度后面有大用——整页平均置信度低(比如低于 0.85),说明扫描质量差,抽出来的字段不可全信,这是后面人工复核队列的触发信号之一。

面孔三:图片混排 ​

产品手册、研报这种,文字、图表、截图搅在一起。正经做法是版面分析:用版面检测模型(PaddleOCR 家族的 PP-Structure、微软的 LayoutLM 系列都是现成的)把页面切成「正文 / 标题 / 表格 / 图片」区域,再按区域分别处理。一句话说清原理:模型对页面每个区域做分类,输出带类型标签的框。

但工程上有个先按块切的降级打法,多数场景够用:文本块走文本管线,图片块要么丢弃要么单独送多模态模型看一眼,表格块进下一副面孔的管线。别一上来就跟版面检测模型较劲,先问业务:图片里的信息要不要?多数合同、发票场景里,答案是要的只是文字和表格,图片本身不是知识。那就切了完事。

面孔四:表格(最难) ​

表格是文档智能的硬骨头,也是客户最在意的地方——发票金额、合同价款、报价明细全在表格里。分两种情况。

有线框的表格,pdfplumber 能直接按线还原出单元格:

python
import pdfplumber

with pdfplumber.open("contract.pdf") as pdf:
    for page in pdf.pages:
        for table in page.extract_tables():   # 检测到的每张表
            for row in table:
                print(row)   # 每行是单元格列表,如 ['项目', '总价', None]

它能用是因为线框表格的横线竖线在 PDF 里有真实的绘图指令,坐标都在,按线切格子就行。注意 None 会出现在合并单元格和空格子里,下游处理要判空。

无线框的表格(简历、报价单常见的「对齐排版假装表格」)没有线可依,pdfplumber 直接失明。这时候 LLM 兜底:把这一页的文本行原样喂给模型,让它重建结构:

python
TABLE_PROMPT = """下面是一段从 PDF 里按行提取的文本,其中包含一个没有线框的表格。
请把表格内容重建成 JSON 数组,第一行是表头,其余每行一条记录。
只依据文本,不要编造没出现的值。

【文本】
{lines}
"""

效果出乎意料地好,因为「哪几列是一列」这件事,语义本身就是最强的线索,而这正是 LLM 的主场。代价是 token 和幻觉风险,所以只对无框表格开,别拿它处理所有页面。

LLM 结构化抽取管线 ​

教学核心二。四副面孔解决的是「文本怎么来」,这一节解决「文本怎么变成字段」。以合同要素抽取贯穿全程:甲方、乙方、金额、期限,四个字段,一条完整管线。

第 1 步:字段 Schema 定义 ​

第 9 周练的 Pydantic 直接复用,但比当时多两个讲究:

python
from pydantic import BaseModel, Field

class ContractInfo(BaseModel):
    party_a: str = Field(description="甲方全称,通常在合同抬头「甲方:」之后")
    party_b: str = Field(description="乙方全称,通常在合同抬头「乙方:」之后")
    amount: str | None = Field(default=None, description="合同总金额,保留原文写法,如「人民币壹佰贰拾万元整」")
    duration_months: int | None = Field(default=None, description="合同期限,统一换算成月;原文只写起止日期的自行计算")
    missing_fields: list[str] = Field(default_factory=list, description="原文中确实找不到的字段名,逐个列在这里")

讲究一,description 不是注释,是给 LLM 看的抽取说明,「统一换算成月」这种归一化规则就写在这里。讲究二,missing_fields 这个字段是防幻觉的关键设计:给模型一条「找不到就承认」的正规出口。不给出口,它就自己编一个填上;给了出口,「留空」从失败变成合法动作。

第 2 步:Prompt 带字段说明与示例 ​

Schema 管字段定义,Prompt 管行为规矩:

python
EXTRACT_PROMPT = """你是合同要素抽取器。从下面的合同文本中抽取字段,规则:
1. 只依据原文,原文没有的字段留空,并把字段名写进 missing_fields
2. 金额保留原文写法,不要换算
3. 输出必须是符合 ContractInfo 的 JSON

【示例】
原文:……甲方:华信科技有限公司 乙方:李明 …… 服务费共计人民币 8 万元整……
输出:{{"party_a": "华信科技有限公司", "party_b": "李明", "amount": "人民币 8 万元整", "duration_months": null, "missing_fields": ["duration_months"]}}

【合同文本】
{content}
"""

示例给一个就够,但必须给:模型对「留空时输出什么」的猜测空间,被一个示例瞬间归零。注意示例里的 JSON 花括号在 Python 字符串里要写成 {{。

第 3 步:结构化输出约束 ​

第 11 周 Day 3 练过的结构化输出在这里收账:把 ContractInfo 的 JSON Schema 随请求发给模型,开 JSON mode 或工具调用模式,让接口直接返回可解析的结构化对象,而不是让你对着自由文本写正则。调用骨架长这样(具体客户端各家不同,凡支持 JSON Schema 约束的模型服务都行):

python
def extract_contract(content: str) -> ContractInfo:
    prompt = EXTRACT_PROMPT.format(content=content)
    # 第 11 周 Day 3 的结构化输出:传入 schema,返回已校验的对象
    return llm_structured_call(prompt, schema=ContractInfo)

解析失败(返回的不是合法 JSON 或字段类型不对)自动重试一次,两次都失败就把这份文档扔回复核队列,别让它悄悄死在日志里。

第 4 步:校验与置信度 ​

LLM 说抽到了不等于抽对了,出口处再加一道人工可读的检查:

python
import re

AMOUNT_RE = re.compile(r"^[0-9,.,]+\s*(万|亿)?(元|圆)(整)?$")

def post_check(info: ContractInfo, raw_text: str) -> str:
    """返回置信档位:high 直接入库,low 进人工复核队列"""
    issues = []
    if info.amount and not AMOUNT_RE.match(info.amount):
        issues.append("金额格式可疑")
    for name in (info.party_a, info.party_b):
        if name and name not in raw_text:      # 字段值在原文里定位不到 → 幻觉嫌疑
            issues.append(f"{name} 在原文定位不到")
    if info.missing_fields:
        issues.append(f"缺失字段:{info.missing_fields}")
    return "low" if issues else "high"

核心思想就一条:字段值应该能在原文里找到出处。公司名、金额这种照抄型字段,如果原文里字面搜不到,多半是模型编的。加上前面 OCR 环节的平均置信度,低置信的信号凑齐了:格式可疑、原文定位不到、字段缺失、扫描质量差。命中任何一条,这份文档不报错、不丢弃,进人工复核队列——产品形态上这是个待办列表,点开左边原文、右边抽取值,人改一个字段点确认。置信度分档不是技术炫技,是把人的精力只花在机器没把握的那 10% 上。

抽取质量评估(简版) ​

管线跑通了,问题变成「抽得有多准」。第 16 周的评估思想搬过来:没有度量的管线,每次改 prompt 都是在赌。

做法朴素到不像技术活:人工标 20 份金标准。挑 20 份有代表性的合同——不同模板、电子扫描混着来、故意放两份质量差的——人眼逐字段抄下正确值,存成一张表。这就是考卷的标准答案,一次劳动,长期复用。

然后跑抽样表,按字段统计:

字段抽检命中 / 总数字段准确率主要错误类型
party_a19 / 2095%漏抽 1(扫描件抬头模糊)
party_b20 / 20100%—
amount17 / 2085%错抽 2(把单价比当总价)、格式 1
duration_months18 / 2090%换算错 2(起止日期算月数出错)

字段级准确率是跟业务方对话的唯一语言。「大概挺准的」换不来信任,「金额 85%,错的两单都是含税总价和小计打架」才能换来下一步的预算。此后每次改 prompt、换模型、调阈值,都在这 20 份上回归一遍,数字涨了才算改进。金标准不是一次性耗材,是这条管线的血压计。

三条产品形态 ​

管线有了,卖法有三种,按客户要什么组装:

  1. 文档问答:文档切块入库,问答走 RAG,第 14 到 15 周的全部积累直接复用。适合「几百页报告问哪答哪」的场景,卖点是溯源
  2. 字段提取:本篇主线,产出结构化数据进业务系统。导 Excel 是最朴素的上线方式,一行搞定:
python
import pandas as pd
rows = [c.model_dump() for c in contracts]
pd.DataFrame(rows).to_excel("contracts.xlsx", index=False)
  1. 文档对比:两版合同的差异高亮。思路是把两版按条款切块、逐对对齐,再让 LLM 判定每对语义是否变化,输出「第 3 条金额从 8 万改成 12 万」的差异清单。这是抽取的进阶玩法,招标文件对比、合同改版审查都是这条线

三种形态底层共用同一条解析管线,这也是文档智能作为「产品线」而不只是「脚本」的原因:解析一次,变现三次。

成本与选型 ​

最后一个决策:云 OCR / 文档理解 API,还是开源自建?不贴任何一家的调用代码,取舍表自己填:

维度云 API开源自建(PaddleOCR + LLM)
效果上限高,重排版、复杂表格是主场中等,常规文档够用,疑难杂症要自己磨
数据安全文档要出域,合同财报可能是硬伤全程不出内网,敏感数据唯一解
成本模型按量付费,量大账单线性涨机器和人力的固定投入,边际成本趋零
运维零运维,接口升级厂商管模型、GPU、版本升级全是自己的活
上手速度当天出 demo装环境到跑通至少几天

按量级给建议。每天百份以内、POC 阶段:开源自建,成本几乎为零,还顺便攒下管线经验,本篇全部代码就是为这一档准备的。每天数千到数万份、数据不敏感(如公开资料整理):云 API 按量付费反而划算,自建养 GPU 和人的钱够买很多次调用。数据敏感(合同、财报、病历):不用算账,出域就是红线,本地部署是合规问题不是成本问题。中间地带最常见也最纠结,做法是拿同一批文档两边各跑一遍,用金标准算准确率,再拿差价除以差多少个百分点,让数字替你拍板。

自测 5 题 ​

先自己答,再展开对照。答不上来的,回对应小节看一遍。

  1. 文档智能的产出是哪两样东西?为什么「纯 OCR 引擎研发」不在产品边界内?
参考答案

结构化数据(能进业务系统的字段)和可问答知识(支撑 RAG 的语义)。不做纯 OCR 研发,因为 OCR 是深度学习专业赛道,从零自研等于跟 PaddleOCR 和云厂商的专职团队赛跑;本篇定位是拿现成 OCR 当零件、用 LLM 做理解和结构化,把管线拼出来、质量管起来。

  1. 拿到一份 PDF,分诊动作是什么?四种面孔分别走什么路线?
参考答案

抽前 3 页用 extract_text() 算平均字符数,过百走电子版路线(pypdf 直接提),接近零走扫描件路线(PaddleOCR 先识别);图片混排要做版面分析(版面检测模型,或工程上先按块切);表格再单独分流——有线框的用 pdfplumber 按线提取,无框的把文本行喂给 LLM 重建结构。

  1. 无框表格为什么 pdfplumber 提不出来?LLM 兜底的原理和代价是什么?
参考答案

pdfplumber 依靠 PDF 里的真实绘图指令(横竖线坐标)来切单元格,无框表格没有线可依所以失明。LLM 兜底的原理是「哪几列属于一列」的判断本身就是语义问题,是 LLM 的强项。代价是消耗 token,且有幻觉风险,所以只对确认包含无框表格的页面开,不能全文档无差别使用。

  1. Schema 里为什么要有 missing_fields 字段?低置信的判定信号有哪些?
参考答案

它是给模型的「承认找不到」的正规出口:没有这条出口,模型倾向于编一个值填满;有了它,留空从失败变成合法动作,防幻觉。低置信信号:金额等字段格式可疑、字段值在原文里字面定位不到(幻觉嫌疑)、missing_fields 非空、OCR 整页平均置信度低。命中即进人工复核队列,不报错不丢弃。

  1. 云 API 和自建按什么量级分档?中间地带怎么决策?
参考答案

每天百份以内或 POC:开源自建,近乎零成本;每天数千到数万份且数据不敏感:云 API 按量付费更划算;数据敏感(合同财报):出域是红线,必须本地部署,这是合规问题不是成本问题。中间地带拿同一批文档两边各跑,用 20 份金标准算字段准确率,再用成本差价除以准确率差值,让数字决策。

收尾 ​

这一篇补上了文档智能产品线的核心缺口:第 14 周把文档变成干净文本,这一篇把干净文本变成结构化字段——Pydantic 定 Schema、Prompt 立规矩、结构化输出上约束、置信度分档分流,四步都是前几周攒下的旧零件,新学的只是「OCR 分诊」和「表格两副处理」这两个入口动作。把 extract_contract.py 和那 20 份金标准留好,落地实战系列后面做文档问答产品化时,这条管线就是现成的地基。