欢迎光临
我们一直在努力

【AI应用开发实战】07_文档解析路由与质量评估:从传统PDF解析到Docling现代化方案

文档解析路由与质量评估:从传统PDF解析到Docling现代化方案

一句话摘要:本文深入剖析StockPilotX的文档解析架构,对比传统PDF解析与Docling现代化方案,详解多引擎路由策略、质量评估指标体系及生产环境回退机制。


目录

  • 一、技术背景与动机
    • 1.1 金融文档解析的业务场景
    • 1.2 传统解析方案的核心痛点
    • 1.3 为什么需要多引擎路由
  • 二、核心概念解释
    • 2.1 文档解析引擎的本质
    • 2.2 Docling现代化解析框架
    • 2.3 解析质量评估模型
    • 2.4 路由器架构设计
  • 三、技术方案对比
    • 3.1 传统PDF解析方案对比
    • 3.2 现代化解析框架对比
    • 3.3 Office文档解析方案对比
    • 3.4 StockPilotX的技术选型
  • 四、项目实战案例
    • 4.1 DocumentParsingRouter核心实现
    • 4.2 DoclingEngine现代化引擎
    • 4.3 LegacyParsingEngine传统引擎
    • 4.4 DocConvertEngine格式转换
    • 4.5 ParseQuality质量评估
    • 4.6 多引擎回退策略
  • 五、最佳实践
    • 5.1 解析引擎选择决策树
    • 5.2 质量阈值配置建议
    • 5.3 生产环境监控指标
    • 5.4 常见问题与解决方案

一、技术背景与动机

1.1 金融文档解析的业务场景

在StockPilotX金融分析系统中,文档解析是RAG(Retrieval-Augmented Generation)知识库的第一道关卡。用户上传的文档类型多样,包括:

研究报告类:

  • 券商研报(PDF格式,通常包含复杂表格和图表)
  • 财务报表(Excel格式,多工作表结构)
  • 行业分析(Word格式,长文本内容)

监管文件类:

  • 上市公司公告(PDF格式,可能是扫描件)
  • 招股说明书(PDF格式,数百页长文档)
  • 审计报告(PDF格式,包含签章和水印)

内部资料类:

  • 投资笔记(Markdown/TXT格式)
  • 会议纪要(DOCX格式)
  • 数据分析(XLSX格式,包含公式和图表)

这些文档的共同特点是:内容专业、格式复杂、质量要求高。如果解析出错,会直接影响后续的向量检索和LLM问答质量。

真实场景举例:

用户上传一份《平安银行2024年度财报分析》PDF文档,期望系统能回答:

  • “平安银行2024年净利润是多少?”
  • “不良贷款率相比去年有何变化?”
  • “零售业务占比提升了多少个百分点?”

如果文档解析环节出现问题:

  • 表格解析失败:财务数据变成乱码,LLM无法提取准确数字
  • OCR识别错误:扫描版PDF中的"净利润538亿"被识别成"净利润53B亿"
  • 格式丢失:多级标题结构丢失,系统无法定位"零售业务"章节
  • 编码问题:中文乱码,整个文档变成"���������"

这些问题在传统解析方案中非常常见,直接导致RAG系统"看不懂"文档内容。

1.2 传统解析方案的核心痛点

在引入Docling之前,StockPilotX使用的是传统解析方案,主要依赖以下工具:

PDF解析:PyPDF2/pypdf

  • 痛点1:表格识别能力弱

    • 无法识别跨页表格
    • 表格单元格顺序混乱
    • 合并单元格解析错误
    • 实际案例:某券商研报的财务对比表,3列×10行的表格被解析成一维文本流
  • 痛点2:扫描版PDF无能为力

    • 纯图片PDF提取为空
    • 需要额外集成OCR引擎(Tesseract)
    • OCR准确率依赖图片质量
    • 实际案例:某上市公司公告是扫描件,pypdf提取结果为空字符串
  • 痛点3:复杂布局处理差

    • 多栏排版顺序错乱
    • 页眉页脚干扰正文
    • 图表说明文字丢失
    • 实际案例:某行业报告采用双栏排版,解析后左右栏文字交错混乱

Office文档解析:python-docx/openpyxl

  • 痛点4:格式兼容性问题

    • .doc老格式需要转换
    • 复杂公式无法提取
    • 嵌入对象丢失
    • 实际案例:某Excel文件包含数据透视表,openpyxl只能读取原始数据
  • 痛点5:性能瓶颈

    • 大文件加载慢(100MB+的Excel)
    • 内存占用高
    • 无法并行处理
    • 实际案例:某财务模型Excel文件50MB,解析耗时超过30秒

统一的问题:

  • 缺乏质量评估:不知道解析结果是否可靠
  • 无法自动回退:解析失败后需要人工介入
  • 监控盲区:不知道哪些文档解析质量差
  • 维护成本高:每种格式需要单独处理逻辑

量化影响:

  • 解析失败率:约15%的PDF文档无法正确提取文本
  • 质量问题率:约30%的文档存在表格错乱、乱码等问题
  • 人工修复成本:每个问题文档平均需要10分钟人工检查和修复
  • 用户体验:RAG问答准确率因解析问题下降约20%

1.3 为什么需要多引擎路由

面对上述痛点,单一解析引擎无法满足需求。我们需要一个智能路由系统,具备以下能力:

能力1:引擎选择智能化

  • 根据文件类型自动选择最佳引擎
  • PDF优先使用Docling(支持表格和OCR)
  • Office文档根据复杂度选择引擎
  • 纯文本文件使用轻量级解析

能力2:自动回退机制

  • Docling解析失败时自动回退到传统引擎
  • 传统引擎失败时尝试备用方案
  • 记录回退原因用于后续优化

能力3:质量评估体系

  • 实时计算解析质量分数
  • 识别乱码、空白、格式错误
  • 为下游系统提供质量参考

能力4:可观测性

  • 记录每次解析的引擎、耗时、质量
  • 支持A/B测试对比不同引擎效果
  • 为引擎优化提供数据支持

架构设计目标:

用户上传文档

路由器判断文件类型

┌─────────────┬─────────────┐
│ Docling │ Legacy │
│ (现代化) │ (传统) │
└─────────────┴─────────────┘
↓ ↓
成功? 回退
↓ ↓
质量评估 ← ← ← ← ← ←

返回结果 + 质量分数

这种架构的核心思想是:优先使用现代化方案,保留传统方案作为兜底,通过质量评估确保可靠性。


二、核心概念解释

2.1 文档解析引擎的本质

在深入技术细节之前,我们先理解文档解析引擎到底在做什么。

类比理解:

想象你在阅读一本纸质书:

  • 人类阅读:眼睛识别文字 → 大脑理解语义 → 记忆关键信息
  • 机器解析:二进制数据 → 格式解码 → 文本提取 → 结构化输出

文档解析引擎就像是"机器的眼睛",它的任务是把人类可读的文档(PDF、Word、Excel)转换成机器可处理的纯文本或结构化数据。

技术层面的三个核心任务:

任务1:格式解码(Format Decoding)

  • PDF文件本质是PostScript指令集,需要解析页面对象、字体、图像
  • DOCX文件是ZIP压缩包,内部是XML文档,需要解析Office Open XML规范
  • XLSX文件也是ZIP+XML,但结构更复杂(工作表、样式、公式)

任务2:内容提取(Content Extraction)

  • 从页面对象中提取文本内容
  • 识别文本的位置、字体、大小
  • 处理特殊字符、编码转换
  • 提取图片、表格等非文本元素

任务3:结构重建(Structure Reconstruction)

  • 识别标题、段落、列表等文档结构
  • 重建表格的行列关系
  • 保持阅读顺序(多栏、跨页)
  • 处理页眉页脚、注释等辅助信息

为什么这很难?

以PDF为例,它的设计目标是"打印输出"而非"内容提取":

  • PDF存储的是"在(x,y)位置绘制字符’A’“,而不是"这是一个段落”
  • 表格在PDF中只是一堆线条和文本框,没有"单元格"概念
  • 阅读顺序需要根据坐标推断,容易出错

这就是为什么传统PDF解析工具经常出问题——它们在尝试从"绘图指令"反推"文档结构",这本身就是一个逆向工程问题。

2.2 Docling现代化解析框架

Docling是IBM Research开发的现代化文档解析框架,它的核心理念是:用AI技术解决传统规则引擎无法处理的问题。

Docling的三大创新:

创新1:基于深度学习的布局分析

传统方案:

根据坐标规则判断 → 容易出错
if text_y < 100: 认为是页眉
if text_x > 500: 认为是右栏

Docling方案:

训练神经网络模型 → 识别文档布局
输入:页面图像
输出:标题/段落/表格/图片的边界框和类型

这就像是从"手写规则"升级到"机器学习",能够处理各种复杂布局。

创新2:端到端的表格识别

传统方案:

1. 检测线条 → 推断单元格
2. 提取文本 → 匹配到单元格
3. 重建表格 → 经常出错

Docling方案:

1. 表格检测模型:识别表格区域
2. 表格结构识别模型:识别行列关系
3. 单元格内容提取:OCR + 文本提取
4. 输出结构化JSON

创新3:集成OCR能力

传统方案需要手动集成Tesseract等OCR引擎,Docling内置了OCR能力,能够:

  • 自动检测扫描版PDF
  • 对图片区域进行OCR
  • 合并OCR结果和原生文本

Docling的架构:

文档输入(PDF/DOCX/图片)

文档加载器(DocumentLoader)

页面渲染(PageRenderer)

布局分析(LayoutAnalyzer)
├─ 标题检测
├─ 段落检测
├─ 表格检测
└─ 图片检测

内容提取(ContentExtractor)
├─ 文本提取
├─ 表格结构化
└─ OCR识别

文档对象(Document)
├─ export_to_text()
├─ export_to_markdown()
└─ export_to_json()

为什么选择Docling?

对比其他现代化方案:

  • Unstructured.io:功能全面但依赖较多,部署复杂
  • PyMuPDF:性能好但表格识别能力一般
  • Camelot:专注表格但不支持其他格式
  • Docling:平衡了功能、性能和易用性,且开源免费

在StockPilotX中,我们选择Docling作为现代化引擎,但保留传统引擎作为回退方案。

2.3 解析质量评估模型

解析完成后,如何判断结果是否可靠?这就需要质量评估模型。

质量评估的四个维度:

维度1:文本覆盖率(Text Coverage Ratio)

定义:提取的文本量与预期文本量的比值

计算方法:

normalized_text = re.sub(r"\\s+", " ", text).strip()
coverage = min(1.0, len(normalized_text) / 3000.0)

解释:

  • 假设一个正常文档至少有3000字符
  • 如果提取的文本少于3000字符,说明可能有内容丢失
  • 如果超过3000字符,覆盖率为1.0(满分)

维度2:乱码率(Garbled Ratio)

定义:乱码字符占总字符数的比例

计算方法:

garbled_count = sum(1 for ch in text if ch in {"�", "?"})
garbled_ratio = garbled_count / max(1, len(text))

解释:

  • "�"是Unicode替换字符,表示编码错误
  • 过多的"?"可能表示字符无法识别
  • 乱码率越高,质量越差

维度3:OCR置信度(OCR Confidence)

定义:OCR识别的平均置信度

计算方法:

if ocr_used and text:
ocr_confidence = 0.8 # 假设OCR有80%准确率
elif ocr_used and not text:
ocr_confidence = 0.0 # OCR失败
else:
ocr_confidence = 1.0 # 未使用OCR,原生文本

解释:

  • 原生文本(非扫描版)置信度为1.0
  • OCR文本置信度通常在0.7-0.9之间
  • OCR失败时置信度为0.0

维度4:综合质量分数(Quality Score)

定义:综合考虑上述三个维度的加权分数

计算方法:

quality_score = (
coverage * 0.65 + # 覆盖率权重65%
(1.0 garbled_ratio) * 0.35 # 乱码率权重35%
)

解释:

  • 覆盖率更重要(65%权重),因为内容完整性是第一位的
  • 乱码率次之(35%权重),因为少量乱码可以容忍
  • OCR置信度不直接参与计算,但会影响覆盖率和乱码率

质量分数的应用:

if quality_score >= 0.8:
# 高质量,直接使用
status = "excellent"
elif quality_score >= 0.6:
# 中等质量,可以使用但需要提示用户
status = "acceptable"
elif quality_score >= 0.4:
# 低质量,建议人工检查
status = "poor"
else:
# 极低质量,解析失败
status = "failed"

在StockPilotX中,我们会将质量分数存储到数据库,用于:

  • 向用户展示文档解析质量
  • 过滤低质量文档
  • 统计不同引擎的解析效果
  • 触发自动重试或人工审核

2.4 路由器架构设计

路由器(Router)是整个解析系统的核心,它负责协调多个引擎,实现智能选择和自动回退。

路由器的设计原则:

原则1:优先级策略(Priority Strategy)

优先级:Docling > Legacy > Fallback
条件:Docling可用 且 支持该格式

原则2:回退机制(Fallback Mechanism)

Docling失败 → 自动切换到Legacy
Legacy失败 → 尝试备用方案(如格式转换)
所有方案失败 → 返回错误信息

原则3:透明性(Transparency)

记录使用的引擎
记录回退原因
记录解析耗时
记录质量评估结果

路由决策流程:

开始

检查文件扩展名

是.doc格式?
├─ 是 → 转换为.docx → 继续
└─ 否 → 继续

Docling可用?
├─ 否 → 使用Legacy引擎
└─ 是 → 继续

Docling支持该格式?
├─ 否 → 使用Legacy引擎
└─ 是 → 尝试Docling

Docling解析成功?
├─ 是 → 返回结果
└─ 否 → 记录错误 → 使用Legacy引擎

Legacy解析成功?
├─ 是 → 返回结果
└─ 否 → 返回错误

路由器的数据结构:

在StockPilotX中,我们定义了三个核心数据模型:

@dataclass
class ParseTrace:
"""解析追踪信息"""
parser_name: str # 使用的引擎名称
parser_version: str # 引擎版本
ocr_used: bool # 是否使用了OCR
duration_ms: int # 解析耗时(毫秒)
notes: list[str] # 解析过程的备注

@dataclass
class ParseQuality:
"""解析质量评估"""
text_coverage_ratio: float # 文本覆盖率
garbled_ratio: float # 乱码率
ocr_confidence_avg: float # OCR平均置信度
quality_score: float # 综合质量分数

@dataclass
class ParseResult:
"""解析结果"""
plain_text: str # 提取的纯文本
parse_note: str # 解析备注
trace: ParseTrace # 追踪信息
quality: ParseQuality # 质量评估
blocks: list[dict] # 结构化块(可选)
pages: list[dict] # 页面信息(可选)

这种设计的好处是:

  • 类型安全:使用dataclass确保数据结构正确
  • 可序列化:提供to_dict()方法,方便存储和传输
  • 可扩展:可以轻松添加新的字段
  • 可观测:包含完整的追踪和质量信息

三、技术方案对比

3.1 传统PDF解析方案对比

在选择PDF解析方案时,我们对比了多个传统工具:

方案优势劣势适用场景StockPilotX的选择
PyPDF2 • 纯Python实现• 无外部依赖• 轻量级 • 不支持加密PDF• 表格识别差• 已停止维护 简单PDF文本提取 ❌ 不选原因:已停止维护,功能有限
pypdf • PyPDF2的继任者• 活跃维护• API兼容PyPDF2 • 表格识别仍然弱• 复杂布局处理差• 无OCR能力 基础PDF文本提取 ✅ 作为Legacy引擎原因:轻量级,适合简单PDF
PyMuPDF (fitz) • 性能优秀• 支持图片提取• 功能全面 • 依赖MuPDF C库• 表格识别需额外处理• 许可证限制(AGPL) 高性能PDF处理 ⚠️ 考虑但未采用原因:AGPL许可证限制
pdfplumber • 表格识别较好• 支持坐标提取• 易用性高 • 性能较慢• 依赖pdfminer.six• 大文件内存占用高 需要表格提取的场景 ⚠️ 考虑但未采用原因:性能问题
Camelot • 专注表格提取• 支持多种表格检测算法• 输出Pandas DataFrame • 只处理表格• 依赖OpenCV• 不支持扫描版PDF 纯表格提取场景 ❌ 不选原因:功能单一,不适合通用场景

选择pypdf作为Legacy引擎的原因:

  • 轻量级,无复杂依赖
  • 对于简单PDF(纯文本,无复杂表格)效果足够好
  • 作为Docling的回退方案,不需要太强的功能
  • 开源且活跃维护
  • 3.2 现代化解析框架对比

    现代化解析框架通常基于深度学习,能够处理更复杂的文档:

    方案优势劣势适用场景StockPilotX的选择
    Docling • IBM Research开源• 支持多种格式• 内置表格识别• 集成OCR能力 • 依赖较多• 首次加载慢• 需要下载模型 需要高质量解析的场景 ✅ 选择原因:功能全面,质量高,开源免费
    Unstructured.io • 功能最全面• 支持20+格式• 云端API可用 • 依赖非常多• 部署复杂• 云端API收费 企业级文档处理 ⚠️ 考虑但未采用原因:依赖太多,部署复杂
    LlamaParse • LlamaIndex官方• 与RAG深度集成• 云端处理 • 必须使用云端API• 收费• 无本地部署 LlamaIndex用户 ❌ 不选原因:我们使用LangChain,且需要本地部署
    Nougat • Meta开源• 专注学术论文• 支持LaTeX输出 • 只支持PDF• 模型较大• 速度较慢 学术论文解析 ❌ 不选原因:场景不匹配,金融文档非学术论文
    LayoutParser • 布局分析专用• 支持自定义模型• 灵活性高 • 需要自己训练模型• 学习曲线陡• 维护成本高 需要定制化的场景 ❌ 不选原因:维护成本高,不适合快速迭代

    选择Docling的关键因素:

  • 开源免费:MIT许可证,无商业限制
  • 功能平衡:不像Unstructured那么重,也不像Nougat那么专一
  • 质量保证:IBM Research背书,代码质量高
  • 社区活跃:GitHub上持续更新,问题响应快
  • 易于集成:API设计简洁,与现有系统集成容易
  • 3.3 Office文档解析方案对比

    Office文档(Word、Excel、PowerPoint)的解析相对简单,因为它们本质是结构化的XML:

    方案优势劣势适用场景StockPilotX的选择
    python-docx • 纯Python• API简洁• 支持样式提取 • 只支持.docx• 不支持.doc DOCX文档解析 ✅ 在Legacy引擎中使用原因:轻量级,足够用
    openpyxl • 功能全面• 支持公式• 支持样式 • 大文件慢• 内存占用高 XLSX文档解析 ✅ 在Legacy引擎中使用原因:功能全面,社区成熟
    python-pptx • 官方推荐• API友好 • 功能有限• 不支持复杂动画 PPTX文档解析 ✅ 在Legacy引擎中使用原因:简单够用
    LibreOffice (soffice) • 支持所有Office格式• 可转换格式 • 需要安装LibreOffice• 调用外部进程 .doc转.docx ✅ 用于格式转换原因:唯一能可靠转换.doc的方案
    win32com (Windows) • 调用原生Office• 兼容性最好 • 只支持Windows• 需要安装Office• 性能差 Windows环境 ❌ 不选原因:跨平台需求,不能依赖Windows

    Office文档解析的特殊处理:

    对于老格式(.doc、.xls、.ppt),我们采用两步策略:

  • 格式转换:使用LibreOffice将.doc转为.docx
  • 标准解析:使用python-docx解析转换后的.docx
  • 这种方案的好处是:

    • 统一处理流程,减少代码复杂度
    • 利用现代格式的结构化优势
    • 避免直接处理二进制格式的复杂性

    3.4 StockPilotX的技术选型

    综合上述对比,StockPilotX的最终技术选型如下:

    三层架构:

    ┌─────────────────────────────────────┐
    │ DocumentParsingRouter (路由层) │
    │ – 格式判断 │
    │ – 引擎选择 │
    │ – 回退控制 │
    └─────────────────────────────────────┘
    ↓ ↓
    ┌──────────────┐ ┌──────────────┐
    │ DoclingEngine│ │ LegacyEngine │
    │ (现代化层) │ │ (传统层) │
    │ │ │ │
    │ • PDF │ │ • PDF │
    │ • DOCX │ │ • DOCX │
    │ • PPTX │ │ • PPTX │
    │ • XLSX │ │ • XLSX │
    │ • 图片 │ │ • 纯文本 │
    └──────────────┘ └──────────────┘
    ↓ ↓
    ┌─────────────────────────────────────┐
    │ DocConvertEngine (转换层) │
    │ – .doc → .docx │
    │ – 使用LibreOffice │
    └─────────────────────────────────────┘

    选型决策矩阵:

    文件类型优先引擎回退引擎转换需求预期质量
    PDF (原生文本) Docling pypdf 高 (0.8+)
    PDF (扫描版) Docling 中 (0.6+)
    DOCX Docling python-docx 高 (0.9+)
    DOC Docling python-docx .doc→.docx 中 (0.7+)
    XLSX Docling openpyxl 高 (0.8+)
    PPTX Docling python-pptx 中 (0.7+)
    图片 Docling 低 (0.5+)
    纯文本 Legacy 高 (1.0)

    配置参数:

    # 路由器配置
    PREFER_DOCLING = True # 优先使用Docling
    DOCLING_TIMEOUT = 45 # Docling超时时间(秒)
    LEGACY_TIMEOUT = 30 # Legacy超时时间(秒)

    # 质量阈值
    QUALITY_EXCELLENT = 0.8 # 优秀质量
    QUALITY_ACCEPTABLE = 0.6 # 可接受质量
    QUALITY_POOR = 0.4 # 低质量
    QUALITY_FAILED = 0.2 # 失败

    # 文本覆盖率基准
    TEXT_COVERAGE_BASELINE = 3000 # 假设正常文档至少3000字符

    # 乱码检测
    GARBLED_CHARS = {"�", "?"} # 乱码字符集

    这种架构的优势:

    • 灵活性:可以轻松添加新引擎或调整优先级
    • 可靠性:多层回退确保解析成功率
    • 可观测性:完整的追踪和质量评估
    • 可维护性:清晰的分层,易于测试和调试

    四、项目实战案例

    现在我们深入StockPilotX的实际代码,看看这套架构是如何实现的。

    4.1 DocumentParsingRouter核心实现

    路由器是整个系统的入口,负责协调各个引擎。让我们看看它的核心代码:

    # backend/app/rag/parsing/router.py
    from pathlib import Path
    from .doc_convert_engine import DocConvertEngine
    from .docling_engine import DoclingEngine
    from .legacy_engine import LegacyParsingEngine
    from .models import ParseResult

    class DocumentParsingRouter:
    """Route uploaded bytes to parser engines with deterministic fallback."""

    def __init__(self, *, prefer_docling: bool = True) > None:
    self._prefer_docling = bool(prefer_docling)
    self._docling = DoclingEngine()
    self._legacy = LegacyParsingEngine()
    self._doc_convert = DocConvertEngine()

    设计解读:

    关键点1:依赖注入模式

    • 在初始化时创建所有引擎实例
    • 避免每次解析都创建新实例(性能优化)
    • 引擎实例可以缓存模型和配置

    关键点2:配置驱动

    • prefer_docling参数控制是否优先使用Docling
    • 可以在运行时动态调整策略
    • 方便A/B测试和灰度发布

    现在看核心的parse方法:

    def parse(self, *, filename: str, raw_bytes: bytes, content_type: str = "") > ParseResult:
    safe_name = str(filename or "uploaded.bin")
    ext = str(Path(safe_name).suffix or "").lower()
    notes: list[str] = []

    payload_bytes = raw_bytes
    payload_filename = safe_name

    # 步骤1:处理.doc格式转换
    if ext == ".doc":
    converted, note = self._doc_convert.convert_doc_to_docx(
    raw_bytes=raw_bytes, filename=safe_name
    )
    notes.append(note)
    if converted:
    payload_bytes = converted
    payload_filename = f"{Path(safe_name).stem}.docx"

    # 步骤2:尝试Docling引擎
    if self._prefer_docling and self._docling.available and \\
    self._docling.supports(filename=payload_filename):
    try:
    result = self._docling.extract(
    filename=payload_filename, raw_bytes=payload_bytes
    )
    # 合并转换过程的notes
    if notes:
    merged = [n for n in notes if n]
    if result.parse_note:
    merged.append(result.parse_note)
    result.parse_note = ",".join(merged)
    result.trace.notes = merged + [
    x for x in result.trace.notes if x not in merged
    ]
    return result
    except Exception as ex:
    notes.append(f"docling_fallback:{str(ex)[:120]}")

    # 步骤3:回退到Legacy引擎
    result = self._legacy.extract(
    filename=payload_filename, raw_bytes=payload_bytes, content_type=content_type
    )
    if notes:
    merged = [n for n in notes if n]
    if result.parse_note:
    merged.append(result.parse_note)
    result.parse_note = ",".join(merged)
    result.trace.notes = merged + [
    x for x in result.trace.notes if x not in merged
    ]
    return result

    代码解读:

    步骤1:格式预处理

    if ext == ".doc":
    converted, note = self._doc_convert.convert_doc_to_docx(...)

    • 检测到.doc格式时,先转换为.docx
    • 转换结果和状态都会记录到notes中
    • 转换成功后,后续流程使用转换后的.docx文件

    为什么要转换?

    • .doc是二进制格式,解析复杂且容易出错
    • .docx是XML格式,结构清晰,解析可靠
    • 统一格式可以简化后续处理逻辑

    步骤2:Docling优先策略

    if self._prefer_docling and self._docling.available and \\
    self._docling.supports(filename=payload_filename):

    三个条件必须同时满足:

  • prefer_docling=True:配置允许使用Docling
  • docling.available:Docling依赖已安装
  • docling.supports():Docling支持该文件格式
  • 为什么要检查available?

    • Docling依赖较多(深度学习模型、OCR引擎等)
    • 在某些环境下可能无法安装(如轻量级容器)
    • 优雅降级:Docling不可用时自动使用Legacy引擎

    步骤3:异常捕获与回退

    try:
    result = self._docling.extract(...)
    return result
    except Exception as ex:
    notes.append(f"docling_fallback:{str(ex)[:120]}")

    • 捕获所有异常,避免单个引擎失败导致整个解析失败
    • 记录异常信息(截断到120字符,避免日志过长)
    • 自动回退到Legacy引擎

    步骤4:Notes合并机制

    if notes:
    merged = [n for n in notes if n]
    if result.parse_note:
    merged.append(result.parse_note)
    result.parse_note = ",".join(merged)

    这段代码确保解析过程的所有信息都被记录:

    • 格式转换信息(如"doc_converted_to_docx")
    • 引擎回退信息(如"docling_fallback:…")
    • 引擎自身的解析信息(如"docx_xml_extract")

    完整的解析流程示例:

    假设用户上传一个.doc文件:

    1. 检测到.doc格式
    → notes: ["doc_converted_to_docx"]

    2. 转换为.docx成功
    → payload_filename: "report.docx"

    3. 尝试Docling解析
    → 成功
    → notes: ["doc_converted_to_docx", "docling_extract"]

    4. 返回结果
    → parse_note: "doc_converted_to_docx,docling_extract"
    → trace.parser_name: "docling"

    如果Docling失败:

    3. 尝试Docling解析
    → 失败(异常:模型加载失败)
    → notes: ["doc_converted_to_docx", "docling_fallback:模型加载失败"]

    4. 回退到Legacy引擎
    → 成功
    → notes: ["doc_converted_to_docx", "docling_fallback:…", "docx_xml_extract"]

    5. 返回结果
    → parse_note: "doc_converted_to_docx,docling_fallback:…,docx_xml_extract"
    → trace.parser_name: "legacy_parser"

    这种设计的优势:

    • 透明性:用户可以看到完整的解析路径
    • 可调试性:出问题时可以快速定位是哪个环节失败
    • 可监控性:可以统计各个引擎的使用率和成功率

    4.2 DoclingEngine现代化引擎

    DoclingEngine是现代化解析的核心,让我们看看它的实现:

    # backend/app/rag/parsing/docling_engine.py
    class DoclingEngine:
    """Optional parser backed by Docling."""

    _SUPPORTED_EXT = {
    ".pdf", ".docx", ".pptx", ".xlsx", ".xlsm",
    ".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".webp",
    }

    def __init__(self) > None:
    self._converter_cls: Any | None = None
    self._load_error = ""
    try:
    from docling.document_converter import DocumentConverter
    self._converter_cls = DocumentConverter
    except Exception as ex:
    self._load_error = str(ex)
    self._converter_cls = None

    设计解读:

    关键点1:延迟导入(Lazy Import)

    try:
    from docling.document_converter import DocumentConverter
    self._converter_cls = DocumentConverter
    except Exception as ex:
    self._load_error = str(ex)

    为什么不在文件顶部导入?

    • Docling是可选依赖,可能未安装
    • 如果在顶部导入,未安装时整个模块无法加载
    • 延迟导入允许系统在Docling不可用时仍能正常运行

    关键点2:错误信息保存

    self._load_error = str(ex)

    • 保存加载失败的原因
    • 方便调试和监控
    • 可以向用户展示为什么Docling不可用

    关键点3:支持的格式

    _SUPPORTED_EXT = {
    ".pdf", ".docx", ".pptx", ".xlsx", ".xlsm",
    ".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".webp",
    }

    • 明确列出支持的格式
    • 图片格式需要OCR能力
    • Office格式需要布局分析能力

    现在看核心的extract方法:

    def extract(self, *, filename: str, raw_bytes: bytes) > ParseResult:
    if not self.available:
    raise RuntimeError(f"docling_not_available:{self._load_error}")

    started = time.perf_counter()
    ext = str(Path(filename).suffix or "").lower()
    converter = self._converter_cls()
    notes: list[str] = []
    ocr_used = ext in {".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".webp"}

    # 创建临时文件
    with tempfile.NamedTemporaryFile(
    prefix="rag-docling-", suffix=ext or ".bin", delete=False
    ) as tmp:
    tmp.write(raw_bytes)
    tmp_path = Path(tmp.name)

    try:
    # 调用Docling转换
    result = converter.convert(str(tmp_path))
    text = self._extract_text_from_docling_result(result)

    if not text.strip():
    notes.append("docling_empty_text")
    else:
    notes.append("docling_extract")

    # 计算质量指标
    duration_ms = int((time.perf_counter() started) * 1000)
    normalized = re.sub(r"\\s+", " ", str(text or "")).strip()
    coverage = min(1.0, len(normalized) / 3000.0)
    garbled_count = sum(1 for ch in normalized if ch in {"�", "?"})
    garbled_ratio = (garbled_count / max(1, len(normalized))) if normalized else 0.0
    ocr_conf = 0.8 if ocr_used and normalized else (0.0 if ocr_used else 1.0)
    quality_score = max(0.0, min(1.0, coverage * 0.65 + (1.0 garbled_ratio) * 0.35))

    # 构建结果
    trace = ParseTrace(
    parser_name="docling",
    parser_version="1",
    ocr_used=ocr_used,
    duration_ms=duration_ms,
    notes=notes,
    )
    quality = ParseQuality(
    text_coverage_ratio=coverage,
    garbled_ratio=garbled_ratio,
    ocr_confidence_avg=ocr_conf,
    quality_score=quality_score,
    )
    return ParseResult(
    plain_text=text,
    parse_note=",".join(notes),
    trace=trace,
    quality=quality,
    )
    finally:
    # 清理临时文件
    try:
    os.unlink(tmp_path)
    except Exception:
    pass

    代码解读:

    为什么需要临时文件?

    with tempfile.NamedTemporaryFile(...) as tmp:
    tmp.write(raw_bytes)
    tmp_path = Path(tmp.name)

    Docling的API设计要求传入文件路径而非字节流:

    • Docling内部需要多次读取文件(布局分析、OCR等)
    • 某些底层库(如PDF解析库)只接受文件路径
    • 临时文件在finally块中清理,确保不泄漏

    OCR检测逻辑:

    ocr_used = ext in {".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".webp"}

    • 图片格式必然使用OCR
    • PDF可能包含图片,但Docling会自动检测
    • OCR标志影响质量评估的置信度计算

    文本提取的容错处理:

    Docling的API在不同版本中可能有变化,我们使用了一个健壮的提取方法:

    @staticmethod
    def _extract_text_from_docling_result(result: Any) > str:
    if result is None:
    return ""

    document = getattr(result, "document", None)
    if document is not None:
    # 尝试多种导出方法
    for method_name in ("export_to_text", "to_text"):
    fn = getattr(document, method_name, None)
    if callable(fn):
    try:
    payload = fn()
    if payload is not None:
    text = str(payload)
    if text.strip():
    return text
    except Exception:
    continue

    # 尝试Markdown格式
    for method_name in ("export_to_markdown", "to_markdown"):
    fn = getattr(document, method_name, None)
    if callable(fn):
    try:
    payload = fn()
    if payload is not None:
    text = str(payload)
    if text.strip():
    return text
    except Exception:
    continue

    # 最后尝试直接转字符串
    text = str(document)
    if text.strip():
    return text

    # 尝试result对象的属性
    for attr in ("text", "markdown", "content"):
    value = getattr(result, attr, None)
    if value is not None:
    text = str(value)
    if text.strip():
    return text

    return str(result or "")

    为什么要这么复杂?

  • API兼容性:Docling在不同版本中API可能变化
  • 多种格式支持:优先纯文本,其次Markdown
  • 容错性:即使某个方法失败,也尝试其他方法
  • 防御性编程:使用getattr和callable检查,避免AttributeError
  • 这种设计确保了:

    • 即使Docling升级,代码仍能工作
    • 即使某个方法抛异常,仍能尝试其他方法
    • 最坏情况下返回空字符串,而不是崩溃

    4.3 LegacyParsingEngine传统引擎

    LegacyParsingEngine是系统的兜底方案,它使用轻量级的传统工具处理各种格式:

    # backend/app/rag/parsing/legacy_engine.py
    class LegacyParsingEngine:
    """Best-effort local parsing engine with lightweight fallbacks."""

    _TEXT_EXT = {".txt", ".md", ".csv", ".json", ".log", ".html", ".htm",
    ".ts", ".js", ".py"}
    _IMAGE_EXT = {".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".webp"}

    def supports(self, *, filename: str) > bool:
    return True # 支持所有格式

    def extract(self, *, filename: str, raw_bytes: bytes,
    content_type: str = "") > ParseResult:
    started = time.perf_counter()
    ext = str(Path(filename).suffix or "").lower()
    notes: list[str] = []
    ocr_used = False

    # 纯文本文件
    if ext in self._TEXT_EXT:
    notes.append("plain_text_decode")
    text = self._decode_text_bytes(raw_bytes)
    return self._build_result(text=text, notes=notes,
    ocr_used=False, started=started)

    # DOCX文件
    if ext == ".docx":
    try:
    with zipfile.ZipFile(io.BytesIO(raw_bytes)) as zf:
    xml_bytes = zf.read("word/document.xml")
    root = ET.fromstring(xml_bytes)
    text = " ".join(x.strip() for x in root.itertext()
    if str(x).strip())
    notes.append("docx_xml_extract")
    return self._build_result(text=text, notes=notes,
    ocr_used=False, started=started)
    except Exception:
    notes.append("docx_extract_failed_fallback_decode")
    text = self._decode_text_bytes(raw_bytes)
    return self._build_result(text=text, notes=notes,
    ocr_used=False, started=started)

    # … 其他格式处理

    设计解读:

    关键点1:全格式支持

    def supports(self, *, filename: str) > bool:
    return True

    • Legacy引擎是最后的兜底,必须支持所有格式
    • 即使无法正确解析,也要尝试提取一些内容
    • 最坏情况返回空文本,而不是抛异常

    关键点2:DOCX的轻量级解析

    with zipfile.ZipFile(io.BytesIO(raw_bytes)) as zf:
    xml_bytes = zf.read("word/document.xml")
    root = ET.fromstring(xml_bytes)
    text = " ".join(x.strip() for x in root.itertext() if str(x).strip())

    这段代码直接解析DOCX的XML结构:

    • DOCX本质是ZIP压缩包,包含多个XML文件
    • word/document.xml是主文档内容
    • 使用标准库xml.etree.ElementTree解析XML
    • itertext()遍历所有文本节点

    为什么不用python-docx?

    • python-docx功能强大但依赖较多
    • 对于简单的文本提取,直接解析XML更快
    • 减少依赖,提高系统稳定性

    关键点3:XLSX的表格处理

    if ext in {".xlsx", ".xlsm"} and load_workbook is not None:
    try:
    wb = load_workbook(filename=io.BytesIO(raw_bytes),
    read_only=True, data_only=True)
    lines: list[str] = []
    for ws in wb.worksheets[:8]: # 最多处理8个工作表
    lines.append(f"[sheet:{ws.title}]")
    max_rows = 1500
    max_cols = 32
    for row_idx, row in enumerate(ws.iter_rows(values_only=True), start=1):
    if row_idx > max_rows:
    break
    cells = [str(c).strip() for c in row[:max_cols]
    if c is not None and str(c).strip()]
    if cells:
    lines.append(" | ".join(cells))
    text = "\\n".join(lines)
    notes.append("xlsx_extract")
    return self._build_result(text=text, notes=notes,
    ocr_used=False, started=started)
    except Exception:
    notes.append("xlsx_extract_failed_fallback_decode")
    text = self._decode_text_bytes(raw_bytes)
    return self._build_result(text=text, notes=notes,
    ocr_used=False, started=started)

    设计亮点:

  • 限制处理范围:

    • 最多8个工作表(避免处理超大文件)
    • 每个工作表最多1500行
    • 每行最多32列
    • 这些限制确保性能可控
  • 只读模式:

    wb = load_workbook(..., read_only=True, data_only=True)

    • read_only=True:只读模式,内存占用更少
    • data_only=True:只读取值,不读取公式(避免公式计算)
  • 表格格式化:

    lines.append(f"[sheet:{ws.title}]") # 工作表标题
    lines.append(" | ".join(cells)) # 单元格用|分隔

    • 保留工作表结构信息
    • 使用|分隔单元格,便于后续解析
    • 输出格式类似Markdown表格
  • 关键点4:PDF的多层回退

    if ext == ".pdf":
    try:
    import pypdf
    except Exception:
    notes.append("pdf_parser_unavailable")
    else:
    try:
    reader = pypdf.PdfReader(io.BytesIO(raw_bytes))
    pages = [str(page.extract_text() or "") for page in reader.pages]
    text = "\\n".join(x for x in pages if x.strip())
    if text.strip():
    notes.append("pdf_pypdf_extract")
    return self._build_result(text=text, notes=notes,
    ocr_used=False, started=started)
    else:
    notes.append("pdf_pypdf_empty")
    except Exception:
    notes.append("pdf_pypdf_failed")

    # 回退:尝试直接解码
    notes.append("pdf_fallback_decode")
    text = self._decode_text_bytes(raw_bytes)
    return self._build_result(text=text, notes=notes,
    ocr_used=False, started=started)

    三层回退策略:

  • 第一层:pypdf解析

    • 尝试导入pypdf库
    • 使用pypdf提取文本
    • 适用于简单的PDF文档
  • 第二层:检测空结果

    • pypdf成功但提取为空(可能是扫描版PDF)
    • 记录"pdf_pypdf_empty"
    • 继续尝试其他方法
  • 第三层:直接解码

    • 所有方法失败后,尝试直接解码二进制数据
    • 可能提取到一些元数据或文本片段
    • 总比完全失败好
  • 关键点5:文本解码的智能处理

    def _decode_text_bytes(self, raw_bytes: bytes) > str:
    for encoding in ("utf-8", "utf-8-sig", "gbk", "gb2312", "latin1"):
    try:
    text = raw_bytes.decode(encoding, errors="ignore")
    if self._looks_like_valid_text(text):
    return text
    except Exception:
    continue
    return raw_bytes.decode("utf-8", errors="replace")

    def _looks_like_valid_text(self, text: str) > bool:
    if not text:
    return False
    normalized = re.sub(r"\\s+", " ", text).strip()
    if len(normalized) < 4:
    return False
    printable_count = sum(1 for ch in normalized[:300]
    if ch.isprintable() or ch.isspace())
    ratio = printable_count / min(len(normalized), 300)
    return ratio >= 0.7 and len(normalized) >= 300

    编码检测逻辑:

  • 尝试多种编码:

    • UTF-8(最常见)
    • UTF-8-sig(带BOM的UTF-8)
    • GBK/GB2312(中文编码)
    • Latin1(西文编码)
  • 验证文本有效性:

    • 检查可打印字符比例(≥70%)
    • 检查文本长度(≥300字符)
    • 避免将二进制数据误认为文本
  • 最终兜底:

    return raw_bytes.decode("utf-8", errors="replace")

    • 使用UTF-8强制解码
    • errors="replace"将无法解码的字节替换为"�"
    • 确保不会抛异常
  • 这种设计的优势:

    • 鲁棒性:处理各种边缘情况
    • 性能:轻量级,无重依赖
    • 可靠性:总能返回结果,即使质量不高

    4.4 DocConvertEngine格式转换

    DocConvertEngine专门处理老格式(.doc)到新格式(.docx)的转换:

    # backend/app/rag/parsing/doc_convert_engine.py
    class DocConvertEngine:
    """Convert legacy .doc files into .docx for downstream parsing."""

    def __init__(self) > None:
    self._cmd = self._detect_soffice_cmd()

    @staticmethod
    def _detect_soffice_cmd() > str:
    for cmd in ("soffice", "libreoffice"):
    path = shutil.which(cmd)
    if path:
    return path
    return ""

    @property
    def available(self) > bool:
    return bool(self._cmd)

    def convert_doc_to_docx(self, *, raw_bytes: bytes,
    filename: str) > tuple[bytes | None, str]:
    if not self.available:
    return None, "doc_convert_unavailable"

    suffix = ".doc"
    try:
    with tempfile.TemporaryDirectory(prefix="rag-doc-convert-") as tmp_dir:
    tmp_root = Path(tmp_dir)
    src = tmp_root / (Path(filename).stem + suffix)
    src.write_bytes(raw_bytes)

    # 调用LibreOffice转换
    proc = subprocess.run(
    [self._cmd, "–headless", "–convert-to", "docx",
    "–outdir", str(tmp_root), str(src)],
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    timeout=45,
    check=False,
    )

    if proc.returncode != 0:
    return None, "doc_convert_failed"

    dst = tmp_root / (src.stem + ".docx")
    if not dst.exists():
    return None, "doc_convert_missing_output"

    return dst.read_bytes(), "doc_converted_to_docx"
    except Exception:
    return None, "doc_convert_exception"

    设计解读:

    关键点1:命令检测

    for cmd in ("soffice", "libreoffice"):
    path = shutil.which(cmd)
    if path:
    return path

    • LibreOffice在不同系统中命令名不同
    • Linux/Mac:通常是soffice
    • Windows:可能是libreoffice
    • 使用shutil.which检测命令是否存在

    关键点2:无头模式转换

    [self._cmd, "–headless", "–convert-to", "docx",
    "–outdir", str(tmp_root), str(src)]

    LibreOffice命令行参数:

    • –headless:无GUI模式,适合服务器环境
    • –convert-to docx:转换为DOCX格式
    • –outdir:指定输出目录
    • 最后一个参数是输入文件路径

    关键点3:超时控制

    proc = subprocess.run(..., timeout=45, check=False)

    • 设置45秒超时,避免转换卡死
    • check=False:不自动抛异常,手动检查返回码
    • 大文件转换可能需要较长时间

    关键点4:临时目录管理

    with tempfile.TemporaryDirectory(prefix="rag-doc-convert-") as tmp_dir:
    # 转换操作
    # 退出with块时自动清理目录

    • 使用临时目录隔离转换过程
    • prefix便于识别和调试
    • 自动清理,避免磁盘空间泄漏

    关键点5:详细的错误状态

    返回值是tuple[bytes | None, str]:

    • 第一个元素:转换后的字节(成功)或None(失败)
    • 第二个元素:状态码字符串

    状态码设计:

    • doc_convert_unavailable:LibreOffice未安装
    • doc_convert_failed:转换进程返回非0
    • doc_convert_missing_output:转换成功但输出文件不存在
    • doc_converted_to_docx:转换成功
    • doc_convert_exception:发生异常

    这种设计的好处:

    • 调用方可以根据状态码判断失败原因
    • 便于监控和告警
    • 便于向用户展示友好的错误信息

    为什么选择LibreOffice?

    对比其他方案:

    • win32com:只支持Windows,需要安装Office
    • unoconv:基于LibreOffice,但是Python包装,增加依赖
    • antiword:只支持.doc,功能有限
    • LibreOffice CLI:跨平台,功能全面,无额外依赖

    生产环境部署注意事项:

  • Docker镜像:

    RUN apt-get update && apt-get install -y libreoffice-writer –no-install-recommends

  • 性能优化:

    • LibreOffice首次启动较慢(加载配置)
    • 可以考虑预热(启动时转换一个测试文件)
    • 或者使用LibreOffice服务模式(常驻进程)
  • 并发控制:

    • LibreOffice不支持高并发
    • 建议使用信号量限制并发转换数
    • 或者使用队列异步处理
  • 4.5 ParseQuality质量评估

    质量评估是整个解析系统的关键,它决定了我们是否可以信任解析结果。让我们看看质量评估的实现:

    # backend/app/rag/parsing/models.py
    @dataclass(slots=True)
    class ParseQuality:
    text_coverage_ratio: float = 0.0
    garbled_ratio: float = 0.0
    ocr_confidence_avg: float = 0.0
    quality_score: float = 0.0

    def to_dict(self) > dict[str, Any]:
    return {
    "text_coverage_ratio": float(max(0.0, min(1.0, self.text_coverage_ratio))),
    "garbled_ratio": float(max(0.0, min(1.0, self.garbled_ratio))),
    "ocr_confidence_avg": float(max(0.0, min(1.0, self.ocr_confidence_avg))),
    "quality_score": float(max(0.0, min(1.0, self.quality_score))),
    }

    设计解读:

    关键点1:使用dataclass

    • @dataclass(slots=True):自动生成__init__、__repr__等方法
    • slots=True:减少内存占用,提高性能
    • 类型注解:明确每个字段的类型

    关键点2:数值范围限制

    float(max(0.0, min(1.0, self.text_coverage_ratio)))

    • 所有指标都限制在[0.0, 1.0]范围内
    • 避免异常值影响后续计算
    • 确保序列化后的数据合法

    质量评估的计算逻辑:

    在_build_result方法中实现:

    def _build_result(self, *, text: str, notes: list[str],
    ocr_used: bool, started: float) > ParseResult:
    duration_ms = int((time.perf_counter() started) * 1000)

    # 1. 文本标准化
    normalized = re.sub(r"\\s+", " ", str(text or "")).strip()

    # 2. 计算覆盖率
    coverage = min(1.0, len(normalized) / 3000.0)

    # 3. 计算乱码率
    garbled_count = sum(1 for ch in normalized if ch in {"?", "\\ufffd"})
    garbled_ratio = (garbled_count / max(1, len(normalized))) if normalized else 0.0

    # 4. 计算OCR置信度
    ocr_conf = 0.75 if ocr_used and normalized else (0.0 if ocr_used else 1.0)

    # 5. 计算综合质量分数
    quality_score = max(0.0, min(1.0, coverage * 0.65 + (1.0 garbled_ratio) * 0.35))

    # 6. 特殊情况处理
    if ocr_used and not normalized:
    quality_score = 0.0 # OCR失败
    if "pdf_binary_stream_detected" in notes:
    quality_score = 0.0 # PDF是纯二进制流

    # 7. 构建结果
    trace = ParseTrace(
    parser_name="legacy_parser",
    parser_version="1",
    ocr_used=ocr_used,
    duration_ms=duration_ms,
    notes=list(notes),
    )
    quality = ParseQuality(
    text_coverage_ratio=coverage,
    garbled_ratio=garbled_ratio,
    ocr_confidence_avg=ocr_conf,
    quality_score=quality_score,
    )
    return ParseResult(
    plain_text=str(text or ""),
    parse_note=",".join(notes),
    trace=trace,
    quality=quality,
    )

    计算逻辑详解:

    步骤1:文本标准化

    normalized = re.sub(r"\\s+", " ", str(text or "")).strip()

    • 将所有连续空白字符(空格、换行、制表符)替换为单个空格
    • 去除首尾空白
    • 便于后续统计字符数

    步骤2:覆盖率计算

    coverage = min(1.0, len(normalized) / 3000.0)

    • 假设正常文档至少3000字符
    • 少于3000字符:覆盖率 = 实际字符数 / 3000
    • 超过3000字符:覆盖率 = 1.0(满分)

    为什么是3000?

    • 经验值:一页A4纸约1500-2000字符
    • 金融文档通常至少2-3页
    • 可以根据实际情况调整

    步骤3:乱码率计算

    garbled_count = sum(1 for ch in normalized if ch in {"?", "\\ufffd"})
    garbled_ratio = garbled_count / max(1, len(normalized))

    • \\ufffd是Unicode替换字符(�)
    • ?可能是无法识别的字符
    • 乱码率 = 乱码字符数 / 总字符数

    步骤4:OCR置信度

    ocr_conf = 0.75 if ocr_used and normalized else (0.0 if ocr_used else 1.0)

    三种情况:

    • 未使用OCR:置信度1.0(原生文本)
    • 使用OCR且成功:置信度0.75(OCR有一定误差)
    • 使用OCR但失败:置信度0.0(完全失败)

    步骤5:综合质量分数

    quality_score = coverage * 0.65 + (1.0 garbled_ratio) * 0.35

    权重分配:

    • 覆盖率:65%(内容完整性最重要)
    • 乱码率:35%(少量乱码可以容忍)

    为什么这样分配权重?

    • 内容完整性是第一位的,没有内容就没有意义
    • 少量乱码(如特殊符号)不影响整体理解
    • 可以根据业务需求调整权重

    步骤6:特殊情况处理

    if ocr_used and not normalized:
    quality_score = 0.0
    if "pdf_binary_stream_detected" in notes:
    quality_score = 0.0

    强制设置质量分数为0的情况:

    • OCR使用但提取为空(OCR完全失败)
    • PDF是纯二进制流(无法提取文本)

    质量分数的应用场景:

  • 用户界面展示:

    if quality_score >= 0.8:
    status = "✓ 解析质量:优秀"
    elif quality_score >= 0.6:
    status = "⚠ 解析质量:良好"
    elif quality_score >= 0.4:
    status = "⚠ 解析质量:一般,建议检查"
    else:
    status = "✗ 解析质量:差,建议重新上传"

  • 自动过滤:

    if quality_score < 0.4:
    # 不加入向量库,避免污染检索结果
    logger.warning(f"Document {filename} quality too low: {quality_score}")
    return

  • 监控告警:

    if quality_score < 0.6:
    metrics.increment("document_parsing_low_quality")
    alert.send(f"Document {filename} has low quality: {quality_score}")

  • A/B测试:

    # 对比不同引擎的质量
    docling_avg_quality = sum(q for q in docling_qualities) / len(docling_qualities)
    legacy_avg_quality = sum(q for q in legacy_qualities) / len(legacy_qualities)
    print(f"Docling平均质量: {docling_avg_quality:.2f}")
    print(f"Legacy平均质量: {legacy_avg_quality:.2f}")

  • 4.6 多引擎回退策略

    多引擎回退是整个系统的核心设计,让我们通过一个完整的例子来理解它:

    场景:用户上传一个复杂的PDF研报

    # 假设这是一个券商研报PDF
    filename = "平安银行2024年度分析报告.pdf"
    raw_bytes = open(filename, "rb").read()

    # 创建路由器
    router = DocumentParsingRouter(prefer_docling=True)

    # 开始解析
    result = router.parse(filename=filename, raw_bytes=raw_bytes)

    执行流程:

    第1步:格式检测

    ext = ".pdf"
    notes = []

    • 不是.doc格式,跳过转换
    • payload保持原样

    第2步:检查Docling可用性

    self._prefer_docling = True ✓
    self._docling.available = True ✓
    self._docling.supports(".pdf") = True ✓

    • 三个条件都满足,尝试Docling

    第3步:Docling解析

    try:
    result = self._docling.extract(filename="平安银行2024年度分析报告.pdf",
    raw_bytes=raw_bytes)
    # 假设成功
    return result
    except Exception as ex:
    # 如果失败,会执行这里
    notes.append(f"docling_fallback:{str(ex)[:120]}")

    假设Docling成功,返回结果:

    ParseResult(
    plain_text="平安银行2024年度分析报告\\n\\n一、公司概况\\n平安银行…",
    parse_note="docling_extract",
    trace=ParseTrace(
    parser_name="docling",
    parser_version="1",
    ocr_used=False,
    duration_ms=2500,
    notes=["docling_extract"]
    ),
    quality=ParseQuality(
    text_coverage_ratio=1.0,
    garbled_ratio=0.0,
    ocr_confidence_avg=1.0,
    quality_score=0.97
    )
    )

    场景变化:Docling失败

    假设Docling因为某种原因失败(如模型加载失败):

    第3步(失败):Docling抛异常

    except Exception as ex:
    notes.append("docling_fallback:模型加载失败")

    第4步:回退到Legacy引擎

    result = self._legacy.extract(filename="平安银行2024年度分析报告.pdf",
    raw_bytes=raw_bytes)

    Legacy引擎尝试pypdf:

    import pypdf
    reader = pypdf.PdfReader(io.BytesIO(raw_bytes))
    pages = [str(page.extract_text() or "") for page in reader.pages]
    text = "\\n".join(x for x in pages if x.strip())

    假设pypdf成功,返回结果:

    ParseResult(
    plain_text="平安银行2024年度分析报告 一、公司概况 平安银行…",
    parse_note="docling_fallback:模型加载失败,pdf_pypdf_extract",
    trace=ParseTrace(
    parser_name="legacy_parser",
    parser_version="1",
    ocr_used=False,
    duration_ms=800,
    notes=["docling_fallback:模型加载失败", "pdf_pypdf_extract"]
    ),
    quality=ParseQuality(
    text_coverage_ratio=0.95,
    garbled_ratio=0.02,
    ocr_confidence_avg=1.0,
    quality_score=0.85
    )
    )

    对比两次结果:

    指标Docling成功Legacy回退
    解析器 docling legacy_parser
    耗时 2500ms 800ms
    质量分数 0.97 0.85
    覆盖率 1.0 0.95
    乱码率 0.0 0.02
    备注 docling_extract docling_fallback:…,pdf_pypdf_extract

    分析:

    • Docling质量更高(0.97 vs 0.85),但耗时更长(2500ms vs 800ms)
    • Legacy作为回退方案,虽然质量略低,但仍然可用
    • 通过notes可以清楚看到解析路径

    最坏情况:所有方法都失败

    假设这是一个扫描版PDF,pypdf提取为空:

    Legacy引擎的最终回退:

    # pypdf提取为空
    notes.append("pdf_pypdf_empty")

    # 尝试直接解码
    notes.append("pdf_fallback_decode")
    text = self._decode_text_bytes(raw_bytes)

    返回结果:

    ParseResult(
    plain_text="", # 或者一些元数据片段
    parse_note="docling_fallback:…,pdf_pypdf_empty,pdf_fallback_decode",
    trace=ParseTrace(
    parser_name="legacy_parser",
    parser_version="1",
    ocr_used=False,
    duration_ms=500,
    notes=["docling_fallback:…", "pdf_pypdf_empty", "pdf_fallback_decode"]
    ),
    quality=ParseQuality(
    text_coverage_ratio=0.0,
    garbled_ratio=0.0,
    ocr_confidence_avg=1.0,
    quality_score=0.0
    )
    )

    系统行为:

    • 不会抛异常,总是返回ParseResult
    • 质量分数为0.0,明确表示解析失败
    • notes记录了完整的尝试路径
    • 调用方可以根据质量分数决定如何处理

    回退策略的优势:

  • 高可用性:单个引擎失败不影响整体
  • 透明性:完整记录解析路径
  • 可观测性:便于监控和调试
  • 灵活性:可以动态调整优先级

  • 五、最佳实践

    5.1 解析引擎选择决策树

    在实际应用中,如何选择合适的解析策略?这里提供一个决策树:

    开始

    文件大小 > 100MB?
    ├─ 是 → 使用Legacy引擎(轻量级)
    └─ 否 → 继续

    是否需要表格识别?
    ├─ 是 → 优先Docling
    └─ 否 → 继续

    是否是扫描版PDF?
    ├─ 是 → 必须使用Docling(OCR能力)
    └─ 否 → 继续

    是否是Office文档?
    ├─ 是 → Legacy引擎足够
    └─ 否 → 优先Docling

    是否对质量要求极高?
    ├─ 是 → 优先Docling
    └─ 否 → Legacy引擎

    具体建议:

    场景1:金融研报(PDF,包含表格)

    • 推荐:Docling
    • 原因:表格识别能力强,质量高
    • 回退:Legacy引擎(pypdf)

    场景2:财务报表(Excel)

    • 推荐:Legacy引擎(openpyxl)
    • 原因:Excel结构化,不需要AI解析
    • 回退:无需回退

    场景3:公司公告(扫描版PDF)

    • 推荐:Docling(必须)
    • 原因:需要OCR能力
    • 回退:无(Legacy无OCR能力)

    场景4:会议纪要(Word)

    • 推荐:Legacy引擎
    • 原因:简单文本,不需要复杂解析
    • 回退:Docling(如果Legacy失败)

    场景5:超大文件(>100MB)

    • 推荐:Legacy引擎
    • 原因:内存占用低,速度快
    • 回退:分块处理或拒绝

    5.2 质量阈值配置建议

    根据不同的业务场景,质量阈值应该有所不同:

    严格模式(金融合规场景):

    QUALITY_THRESHOLDS = {
    "excellent": 0.9, # 优秀:可直接使用
    "good": 0.8, # 良好:可使用但需标注
    "acceptable": 0.7, # 可接受:需人工审核
    "poor": 0.6, # 差:拒绝或重新上传
    }

    适用场景:

    • 监管报告
    • 审计文档
    • 法律合同
    • 财务报表

    标准模式(一般业务场景):

    QUALITY_THRESHOLDS = {
    "excellent": 0.8, # 优秀
    "good": 0.6, # 良好
    "acceptable": 0.4, # 可接受
    "poor": 0.3, # 差
    }

    适用场景:

    • 研究报告
    • 行业分析
    • 新闻资讯
    • 内部文档

    宽松模式(探索性场景):

    QUALITY_THRESHOLDS = {
    "excellent": 0.7, # 优秀
    "good": 0.5, # 良好
    "acceptable": 0.3, # 可接受
    "poor": 0.2, # 差
    }

    适用场景:

    • 历史文档归档
    • 参考资料收集
    • 初步信息筛选
    • 测试和实验

    动态阈值策略:

    根据文档类型动态调整:

    def get_quality_threshold(filename: str, content_type: str) > float:
    ext = Path(filename).suffix.lower()

    # PDF通常质量要求高
    if ext == ".pdf":
    return 0.7

    # Office文档结构化,质量通常较高
    if ext in {".docx", ".xlsx", ".pptx"}:
    return 0.8

    # 纯文本文件质量稳定
    if ext in {".txt", ".md", ".csv"}:
    return 0.9

    # 图片需要OCR,质量要求可以放宽
    if ext in {".png", ".jpg", ".jpeg"}:
    return 0.5

    # 默认阈值
    return 0.6

    质量分级处理策略:

    def handle_parse_result(result: ParseResult, filename: str) > str:
    score = result.quality.quality_score

    if score >= 0.8:
    # 优秀质量:直接使用
    return "approved"

    elif score >= 0.6:
    # 良好质量:使用但标注
    logger.info(f"Document {filename} has acceptable quality: {score:.2f}")
    # 添加质量标签到元数据
    metadata = {"quality_score": score, "quality_level": "good"}
    return "approved_with_note"

    elif score >= 0.4:
    # 可接受质量:需要人工审核
    logger.warning(f"Document {filename} needs review: {score:.2f}")
    # 发送到审核队列
    review_queue.add(filename, result)
    return "pending_review"

    else:
    # 低质量:拒绝
    logger.error(f"Document {filename} quality too low: {score:.2f}")
    # 通知用户重新上传
    notify_user(f"文档 {filename} 解析质量过低,请检查文档是否损坏或重新上传")
    return "rejected"

    5.3 生产环境监控指标

    在生产环境中,需要监控以下关键指标:

    1. 解析成功率

    # 按引擎统计
    docling_success_rate = docling_success / docling_total
    legacy_success_rate = legacy_success / legacy_total

    # 按文件类型统计
    pdf_success_rate = pdf_success / pdf_total
    docx_success_rate = docx_success / docx_total

    告警阈值:

    • 总体成功率 < 95%:警告
    • 总体成功率 < 90%:严重
    • 某个引擎成功率 < 80%:警告

    2. 平均质量分数

    # 按引擎统计
    docling_avg_quality = sum(docling_qualities) / len(docling_qualities)
    legacy_avg_quality = sum(legacy_qualities) / len(legacy_qualities)

    # 按文件类型统计
    pdf_avg_quality = sum(pdf_qualities) / len(pdf_qualities)
    docx_avg_quality = sum(docx_qualities) / len(docx_qualities)

    告警阈值:

    • 平均质量 < 0.7:警告
    • 平均质量 < 0.6:严重
    • 质量下降趋势(连续3天下降):警告

    3. 解析耗时

    # P50、P95、P99耗时
    p50_duration = np.percentile(durations, 50)
    p95_duration = np.percentile(durations, 95)
    p99_duration = np.percentile(durations, 99)

    # 按引擎统计
    docling_avg_duration = sum(docling_durations) / len(docling_durations)
    legacy_avg_duration = sum(legacy_durations) / len(legacy_durations)

    告警阈值:

    • P95耗时 > 10秒:警告
    • P99耗时 > 30秒:严重
    • 平均耗时增长 > 50%:警告

    4. 回退率

    # Docling回退到Legacy的比例
    fallback_rate = docling_fallback_count / docling_total

    # 按失败原因统计
    fallback_reasons = Counter(reason for _, reason in fallback_logs)

    告警阈值:

    • 回退率 > 20%:警告
    • 回退率 > 40%:严重
    • 某个原因占比 > 50%:需要针对性优化

    5. 资源使用

    # 内存使用
    memory_usage_mb = process.memory_info().rss / 1024 / 1024

    # CPU使用
    cpu_percent = process.cpu_percent(interval=1)

    # 临时文件数量
    temp_file_count = len(list(Path("/tmp").glob("rag-*")))

    告警阈值:

    • 内存使用 > 2GB:警告
    • 内存使用 > 4GB:严重
    • 临时文件 > 100:可能有泄漏

    监控仪表板示例:

    # Prometheus指标定义
    from prometheus_client import Counter, Histogram, Gauge

    # 解析计数器
    parse_total = Counter(
    "document_parse_total",
    "Total document parse attempts",
    ["engine", "file_type", "status"]
    )

    # 解析耗时
    parse_duration = Histogram(
    "document_parse_duration_seconds",
    "Document parse duration",
    ["engine", "file_type"],
    buckets=[0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0]
    )

    # 质量分数
    parse_quality = Histogram(
    "document_parse_quality_score",
    "Document parse quality score",
    ["engine", "file_type"],
    buckets=[0.0, 0.2, 0.4, 0.6, 0.8, 1.0]
    )

    # 回退计数器
    parse_fallback = Counter(
    "document_parse_fallback_total",
    "Total fallback from Docling to Legacy",
    ["reason"]
    )

    # 使用示例
    def parse_with_metrics(filename: str, raw_bytes: bytes) > ParseResult:
    ext = Path(filename).suffix.lower()
    start = time.time()

    try:
    result = router.parse(filename=filename, raw_bytes=raw_bytes)

    # 记录指标
    parse_total.labels(
    engine=result.trace.parser_name,
    file_type=ext,
    status="success"
    ).inc()

    parse_duration.labels(
    engine=result.trace.parser_name,
    file_type=ext
    ).observe(time.time() start)

    parse_quality.labels(
    engine=result.trace.parser_name,
    file_type=ext
    ).observe(result.quality.quality_score)

    # 检测回退
    if "docling_fallback" in result.parse_note:
    reason = result.parse_note.split("docling_fallback:")[1].split(",")[0]
    parse_fallback.labels(reason=reason).inc()

    return result

    except Exception as ex:
    parse_total.labels(
    engine="unknown",
    file_type=ext,
    status="error"
    ).inc()
    raise

    5.4 常见问题与解决方案

    问题1:Docling加载慢

    现象:

    • 首次调用Docling耗时超过10秒
    • 后续调用正常

    原因:

    • Docling需要加载深度学习模型
    • 模型文件较大(几百MB)
    • 首次加载需要初始化

    解决方案:

    # 应用启动时预热Docling
    def warmup_docling():
    router = DocumentParsingRouter(prefer_docling=True)
    if router._docling.available:
    # 使用一个小的测试文件预热
    test_pdf = b"%PDF-1.4\\n1 0 obj\\n<<\\n/Type /Catalog\\n>>\\nendobj\\n"
    try:
    router.parse(filename="warmup.pdf", raw_bytes=test_pdf)
    logger.info("Docling warmed up successfully")
    except Exception as ex:
    logger.warning(f"Docling warmup failed: {ex}")

    # 在应用启动时调用
    if __name__ == "__main__":
    warmup_docling()
    app.run()

    问题2:内存占用过高

    现象:

    • 解析大文件后内存不释放
    • 内存使用持续增长

    原因:

    • 临时文件未清理
    • Docling模型缓存
    • Python垃圾回收延迟

    解决方案:

    import gc

    def parse_with_cleanup(filename: str, raw_bytes: bytes) > ParseResult:
    try:
    result = router.parse(filename=filename, raw_bytes=raw_bytes)
    return result
    finally:
    # 强制垃圾回收
    gc.collect()

    # 清理临时文件
    for tmp_dir in ["/tmp", tempfile.gettempdir()]:
    for pattern in ["rag-docling-*", "rag-doc-convert-*"]:
    for path in Path(tmp_dir).glob(pattern):
    try:
    if path.is_file():
    path.unlink()
    elif path.is_dir():
    shutil.rmtree(path)
    except Exception:
    pass

    问题3:LibreOffice转换失败

    现象:

    • .doc转.docx失败
    • 错误信息:“doc_convert_failed”

    原因:

    • LibreOffice未安装
    • LibreOffice版本不兼容
    • 文件损坏

    解决方案:

    # 1. 检查LibreOffice安装
    def check_libreoffice():
    engine = DocConvertEngine()
    if not engine.available:
    logger.error("LibreOffice not found. Install with:")
    logger.error(" Ubuntu: apt-get install libreoffice-writer")
    logger.error(" macOS: brew install libreoffice")
    logger.error(" Windows: download from https://www.libreoffice.org/")
    return False
    return True

    # 2. 添加重试机制
    def convert_with_retry(raw_bytes: bytes, filename: str, max_retries: int = 3) > tuple[bytes | None, str]:
    engine = DocConvertEngine()
    for attempt in range(max_retries):
    result, note = engine.convert_doc_to_docx(raw_bytes=raw_bytes, filename=filename)
    if result is not None:
    return result, note
    logger.warning(f"Conversion attempt {attempt + 1} failed: {note}")
    time.sleep(1) # 等待1秒后重试
    return None, "doc_convert_failed_after_retries"

    # 3. 提供备用方案
    def parse_doc_file(filename: str, raw_bytes: bytes) > ParseResult:
    # 尝试转换
    converted, note = convert_with_retry(raw_bytes, filename)

    if converted:
    # 转换成功,解析.docx
    return router.parse(filename=f"{Path(filename).stem}.docx", raw_bytes=converted)
    else:
    # 转换失败,尝试直接解析(质量可能较差)
    logger.warning(f"Failed to convert {filename}, trying direct parse")
    return router._legacy.extract(filename=filename, raw_bytes=raw_bytes)

    问题4:扫描版PDF识别率低

    现象:

    • OCR提取的文本乱码多
    • 质量分数低于0.5

    原因:

    • 图片分辨率低
    • 图片倾斜或模糊
    • 字体特殊(手写体、艺术字)

    解决方案:

    # 1. 图片预处理(需要PIL/Pillow)
    from PIL import Image, ImageEnhance
    import io

    def preprocess_scanned_pdf(raw_bytes: bytes) > bytes:
    """对扫描版PDF进行预处理,提高OCR识别率"""
    try:
    # 这里简化处理,实际需要更复杂的图像处理
    # 可以使用OpenCV进行去噪、二值化、倾斜校正等
    return raw_bytes
    except Exception:
    return raw_bytes

    # 2. 降低质量阈值
    def parse_scanned_pdf(filename: str, raw_bytes: bytes) > ParseResult:
    # 预处理
    processed_bytes = preprocess_scanned_pdf(raw_bytes)

    # 解析
    result = router.parse(filename=filename, raw_bytes=processed_bytes)

    # 对于扫描版PDF,降低质量要求
    if result.trace.ocr_used and result.quality.quality_score < 0.6:
    logger.warning(f"Scanned PDF {filename} has low quality: {result.quality.quality_score}")
    # 但仍然接受,因为这可能是最好的结果

    return result

    # 3. 提示用户
    def suggest_improvement(result: ParseResult) > str:
    if result.trace.ocr_used and result.quality.quality_score < 0.5:
    return (
    "文档识别质量较低,建议:\\n"
    "1. 提供更高分辨率的扫描件(建议300dpi以上)\\n"
    "2. 确保扫描件清晰、无倾斜\\n"
    "3. 如有原始电子版,请直接上传电子版"
    )
    return ""

    问题5:并发解析性能差

    现象:

    • 多个文档同时解析时速度慢
    • CPU利用率不高

    原因:

    • Python GIL限制
    • Docling不支持多进程
    • 临时文件IO竞争

    解决方案:

    import asyncio
    from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor

    # 方案1:使用线程池(适合IO密集型)
    thread_pool = ThreadPoolExecutor(max_workers=4)

    async def parse_async(filename: str, raw_bytes: bytes) > ParseResult:
    loop = asyncio.get_event_loop()
    return await loop.run_in_executor(
    thread_pool,
    router.parse,
    filename,
    raw_bytes
    )

    # 方案2:使用进程池(适合CPU密集型)
    process_pool = ProcessPoolExecutor(max_workers=2)

    def parse_in_process(filename: str, raw_bytes: bytes) > ParseResult:
    # 每个进程创建独立的router
    router = DocumentParsingRouter(prefer_docling=True)
    return router.parse(filename=filename, raw_bytes=raw_bytes)

    async def parse_async_multiprocess(filename: str, raw_bytes: bytes) > ParseResult:
    loop = asyncio.get_event_loop()
    return await loop.run_in_executor(
    process_pool,
    parse_in_process,
    filename,
    raw_bytes
    )

    # 方案3:使用队列异步处理
    from queue import Queue
    from threading import Thread

    parse_queue = Queue(maxsize=100)
    result_dict = {}

    def parse_worker():
    while True:
    task_id, filename, raw_bytes = parse_queue.get()
    try:
    result = router.parse(filename=filename, raw_bytes=raw_bytes)
    result_dict[task_id] = ("success", result)
    except Exception as ex:
    result_dict[task_id] = ("error", str(ex))
    finally:
    parse_queue.task_done()

    # 启动工作线程
    for _ in range(4):
    Thread(target=parse_worker, daemon=True).start()

    def submit_parse(filename: str, raw_bytes: bytes) > str:
    task_id = str(uuid.uuid4())
    parse_queue.put((task_id, filename, raw_bytes))
    return task_id

    def get_parse_result(task_id: str, timeout: float = 60.0) > ParseResult:
    start = time.time()
    while time.time() start < timeout:
    if task_id in result_dict:
    status, result = result_dict.pop(task_id)
    if status == "success":
    return result
    else:
    raise RuntimeError(result)
    time.sleep(0.1)
    raise TimeoutError(f"Parse task {task_id} timeout")


    总结

    本文深入剖析了StockPilotX的文档解析架构,从业务痛点出发,详细讲解了多引擎路由、质量评估、回退策略等核心技术。

    核心要点回顾:

  • 多引擎架构:Docling现代化引擎 + Legacy传统引擎,优势互补
  • 智能路由:根据文件类型、大小、复杂度自动选择最佳引擎
  • 质量评估:四维度评估(覆盖率、乱码率、OCR置信度、综合分数)
  • 自动回退:Docling失败自动回退到Legacy,确保高可用性
  • 可观测性:完整的追踪信息,便于监控和调试
  • 技术价值:

    • 可靠性:多层回退确保解析成功率
    • 灵活性:可根据场景动态调整策略
    • 可维护性:清晰的分层架构,易于扩展
    • 生产就绪:完善的监控、告警、容错机制

    适用场景:

    本文介绍的架构不仅适用于金融文档解析,也适用于:

    • 法律文档管理系统
    • 医疗病历解析系统
    • 教育资料管理平台
    • 企业知识库系统

    希望本文能帮助你构建一个高质量、高可用的文档解析系统。


    参考资源:

    • Docling官方文档:https://github.com/DS4SD/docling
    • pypdf文档:https://pypdf.readthedocs.io/
    • openpyxl文档:https://openpyxl.readthedocs.io/
    • LibreOffice CLI文档:https://help.libreoffice.org/latest/en-US/text/shared/guide/start_parameters.html

    StockPilotX项目地址:

    • 代码路径:backend/app/rag/parsing/
    • 相关文件:
      • router.py:路由器实现
      • docling_engine.py:Docling引擎
      • legacy_engine.py:Legacy引擎
      • doc_convert_engine.py:格式转换
      • models.py:数据模型

    作者:StockPilotX团队 日期:2026-02-21 版本:v1.0


    项目地址:https://github.com/luguochang/StockPilotX

    赞(0)
    未经允许不得转载:171主机测评 » 【AI应用开发实战】07_文档解析路由与质量评估:从传统PDF解析到Docling现代化方案
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址