Skip to content

第5章 文档解析:PDF、HTML、Markdown、代码仓库

"Garbage in, garbage out — but first, garbage parsing makes everything garbage." — 古老的 IR 格言的工程改写

本章要点

  • 文档解析是 RAG 的第一公里——这一步丢失的结构信息,下游检索和生成永远补不回来
  • 四类典型源头的挑战各不相同:PDF 缺结构、HTML 多噪声、Markdown 最干净、代码仓库需要 AST 辅助
  • 解析产出不是纯文本,是"结构化文本 + 稳定 metadata"——每一段都带类型、位置、层级
  • 工具选型没有银弹:通用方案(unstructured.io)覆盖 80% 常见场景,垂直场景(如表格密集 PDF)需要专门工具(marker / Nougat)
  • 可恢复性和幂等性是解析 pipeline 的两条底线——任何文档可重新解析、解析结果完全由输入决定

5.1 为什么解析是第一公里

假设一份 30 页的产品规格 PDF,里面有表格(定价矩阵)、图(架构图)、代码块(示例 API 调用)、带编号的列表(功能特性)、脚注(版本说明)。直接把这份 PDF 调 pdftotext 转出来,你会得到一份丢光结构的纯文本:

  • 表格变成每行一串数字
  • 代码块和正文混在一起,缩进丢失
  • 页眉页脚每页重复出现,干扰语义
  • 脚注插入到主段落中间
  • 多栏排版被错序读取

下游再分块、再 embedding、再 rerank,再怎么优化也补不回结构。检索时问"企业版价格"——表格已经变成了文字流,"企业版 $499 专业版 $299"拼成一行,模型看到的是噪声。

这是 RAG 的第一个致命陷阱:很多项目把文档解析当成"一个脚本跑一下"的杂活,但它决定了整条链路的数据质量上限。上限天花板一旦被劣质解析压低,下游所有章节讨论的优化技巧都无从发挥。第 3 章讨论的"找不到"家族里,子模式 2(chunk 边界切断证据)的上游根因就是解析阶段没保住段落结构。

这一章的目标:给四类典型源(PDF / HTML / Markdown / 代码仓库)各自一套解析方案,输出一致的结构化文本给下游。

5.2 解析的产出规范:结构化文本 + metadata

先定义"解析完成"的样子。下游(第 6 章 Chunking)需要从解析产出里拿到:

json
{
  "doc_id": "product-spec-v3.pdf",
  "source": {
    "type": "pdf",
    "uri": "s3://bucket/product-spec-v3.pdf",
    "version_hash": "a1b2c3d4..."
  },
  "metadata": {
    "title": "企业版产品规格 V3",
    "author": "产品部",
    "publish_date": "2026-03-15",
    "language": "zh",
    "access_level": "internal",
    "tags": ["规格", "企业版"]
  },
  "blocks": [
    { "type": "heading", "level": 1, "text": "产品概述", "pos": { "page": 1 } },
    { "type": "paragraph", "text": "企业版定位...", "pos": { "page": 1 } },
    { "type": "table", "rows": [...], "caption": "功能矩阵", "pos": { "page": 2 } },
    { "type": "code", "language": "python", "text": "import ...", "pos": { "page": 3 } },
    { "type": "list", "items": ["SSO", "SAML"], "pos": { "page": 3 } },
    { "type": "figure", "caption": "架构图", "alt_text": "...", "pos": { "page": 4 } }
  ]
}

六种 block 类型覆盖绝大多数结构化信息:heading / paragraph / table / code / list / figure。每个 block 带类型(供 chunking 决策)、内容(text / rows / items)、位置(供引用溯源)。

这份产出不是纯文本,也不是原始 JSON——它是格式无关的中间表示(Intermediate Representation, IR)。PDF、HTML、Markdown 解析出来都要归一到这个 IR。下游逻辑只认 IR,不关心原始格式。

这层抽象的好处:新增一种源(比如 Confluence 导出)只需要写一个 source → IR 的 adapter,下游完全不用改。reverse 回来也成立——同一个 IR 可以送到不同的下游消费者(chunking 之外的 summarization、classification)。

5.3 PDF:最难也最重要的源

PDF 是 RAG 里最常见也最难的源。难在哪里?PDF 本质是"图形指令流"——它记录的是"在坐标 (x, y) 画字符 A、再在 (x+10, y) 画字符 B",根本不记录"这是一段话""这是一个表格"。文档的视觉结构要靠解析器反推

PDF 的三个世界

  • "原生文本 PDF":Word / LaTeX / Markdown 导出的 PDF,内部含可选中文本。解析相对容易,主流工具(pypdf、pdfplumber)都能胜任。
  • "扫描件 PDF":把纸质书扫进去再保存的 PDF,本质是图片集合。必须走 OCR(Tesseract、PaddleOCR、阿里云 OCR、Google Vision)。OCR 质量决定下游一切。
  • "混合 PDF":部分页是原生文本、部分页是扫描。最坑——解析器可能在原生部分工作正常、到扫描页就静默输出空字符串。

生产实操:每份 PDF 先跑 is_scanned(pdf) 检测(看每页文本提取结果是否为空),分别走不同 pipeline。误判成本远大于多跑一次 OCR 的成本。

主流工具对比

工具适用优点缺点
pypdf / pdfplumber原生文本 PDF轻量、Python 纯依赖、快表格还原差、无结构恢复
unstructured.io通用、多格式接近即用、带 block 识别依赖重(pandoc + detectron2)
marker学术/技术 PDF表格 + 公式还原优秀GPU 推理、慢
Nougat(Meta)学术 PDF端到端 OCR + 结构化英文为主、中文能力有限
商业 API(Textract、Azure Form Recognizer)企业级稳定、SLA费用

选型经验:通用场景 unstructured.io + 表格密集场景切 marker + 合同/发票切商业 OCR。全套自托管的团队通常最终会组合两三个工具,对不同类型文档走不同 pipeline。

表格:PDF 的阿喀琉斯之踵

表格是 PDF 解析最容易出错的部分。两类难点:

  • 格线缺失:很多 PDF 表格没画表格线,靠对齐呈现结构。pdfplumber 需要手动调 extract_tables() 的策略参数(lines_strict / lines / words)。
  • 合并单元格:合并单元格丢失后下游无法重构表头-数据对应关系。marker 和 camelot 对合并单元格处理较好。

表格一旦解析错,下游完全没救——检索"企业版价格"可能拉到的是"专业版价格"对应的行。生产建议:表格密集场景用专门工具 + 人工抽样验证(随机抽 100 张表格,人工看解析对不对)。

多栏排版和阅读顺序

技术论文、杂志、报纸常用多栏排版。直接 pdftotext 会按页面坐标从上到下读取,把两栏内容交替混合——读起来完全错乱。解决:

  • pdfplumber 的 page.extract_text(layout=True) + 手动分栏逻辑
  • unstructured.io 会自动识别多栏
  • 最可靠:用 detectron2 / LayoutParser 做版面分析,识别每个 block 的 reading_order

学术论文场景建议直接用 Nougat 或 marker,它们把多栏处理做进了模型。

公式和图片

学术 PDF 里的 LaTeX 公式是另一个坑。pdftotext 会把 ∫f(x)dx 变成乱码,像 "ò f ( x ) dx" 或干脆丢失。这对物理、数学、机器学习类知识库是致命的。解决方案:

  • Nougat / marker 能直接输出 LaTeX 源码格式($\int f(x) dx$),下游存成 markdown
  • 商业工具(Mathpix)专门做公式识别,准确率高但付费
  • 混合策略:先 pdftotext 快速过一遍,失败的页再走 Nougat

图片的处理有两种策略:存 caption + alt_text 入索引(下游检索图文对),或用 vision model 做 image captioning 再索引。第一种便宜且可控,第二种能提供 "这张图讲什么" 的语义检索,但需要额外模型推理成本。

OCR 的准确率陷阱

OCR 准确率在 demo 看起来 95-98%,生产部署会发现关键字段错误率远高于平均。原因是关键字段(日期、数字、金额、合同编号)通常用小字号、字体特殊、可能手写签字——这些区域的 OCR 准确率可能只有 70-80%。一份合同整篇 98% 准、但"甲方:XX公司"的"公司"被识别成"公,"——下游检索就错位。

对策:

  • 关键字段区域用专门的 field extractor(Textract 的 AnalyzeDocument、阿里云 Form Recognizer)
  • 金额/日期等结构化字段走 regex 后校验——如果 OCR 结果不匹配模式("12O0 元"里的 O 应该是 0),标记为低置信度
  • 对高风险文档(合同、处方、财务报告)采用 双引擎冗余——两个 OCR 结果不一致的部分人工 review

PDF 解析的延迟特征

PDF 解析是 RAG 离线链路里最慢的一环。实测数据:

工具原生文本 PDF (50 页)扫描 PDF (50 页)GPU 需求
pypdf0.5s不支持
pdfplumber2-5s不支持
unstructured.io hi_res15-30s30-60s(含 OCR)推荐
marker30-90s60-120s必需
Tesseract OCRN/A60-180s否(CPU 够用)
PaddleOCRN/A30-90sGPU 加速 5x

100 万页级的知识库用 marker 全量跑一次需要几天。生产通常分层解析——先用 pypdf 快速过一遍抽文本层、失败或质量差的走 unstructured、表格密集的走 marker、扫描件走 OCR。每份文档只走它需要的 pipeline。

这个分层有额外好处:成本可控。全走 marker 的成本可能是全走 pypdf 的 50 倍。按文档类型路由能把成本压到可接受范围。

5.4 HTML:结构丰富但噪声多

HTML 是相对友好的源——标签直接编码了结构(<h1> / <table> / <code>)。但噪声问题严重:广告、导航、侧边栏、"相关文章推荐"、弹窗、cookie 同意框全混在主内容里。直接把 HTML 扔给解析器会让 80% 的 token 是噪声。

主内容抽取

从带噪 HTML 中提取主内容是经典问题。四类方案:

  • 启发式规则readability.js 原理):按密度评分——含较多 <p> 和长段文本的节点更可能是主内容。快、不需训练。
  • 机器学习分类:boilerpy3 / dragnet 等库训练过分类器,识别 boilerplate vs content。准确率更高但依赖重。
  • 基于站点模板:对已知站点写 CSS selector(e.g., article.main-content)。最准但维护成本高。
  • LLM 抽取:把 HTML 扔给 LLM 要求抽主内容。灵活但贵且慢。

生产选型:知道 domain list 且数量有限的用站点模板(新闻聚合、RSS 爬虫);通用爬虫用 trafilatura(开源、C++ 加速、在 CleanEval benchmark 上 F1 0.9+、github.com/adbar/trafilatura)。

Markdown 化:HTML 最好的中间形态

HTML → Markdown 是一个被低估的解析策略。好处:

  • Markdown 保留了标题层级、列表、代码块、链接等结构
  • Markdown 是纯文本、没有标签噪声
  • 下游 chunking 和 embedding 处理 Markdown 比处理 HTML 或纯文本效果更好(LLM 训练语料里 Markdown 比例高)

常用工具:html2text(Python)、turndown(JS)、pandoc(全能但慢)。生产 pipeline 常见组合:trafilatura 抽主内容 → html2text 转 Markdown → 下游处理。

代码块保留

HTML 里的代码块(<pre><code><pre>)必须原封保留。转 Markdown 时要保证缩进、换行、语言提示(<code class="language-python">```python)。丢失代码块结构是很多爬虫 pipeline 的隐藏 bug。

链接和锚点处理

HTML 的链接和锚点信息在 RAG 场景有两种用法:

  • 作为证据溯源:保留原始 URL 让用户可以点回原文。这要求 metadata.source.uri 记录页面 URL
  • 作为图结构:同站点内部链接构成一张图,图结构可用于 query-aware ranking(问题提到某概念,命中的页面和讨论该概念的页面距离更近)

第二种用法对应第 19 章的 GraphRAG。多数项目只用第一种。

动态渲染内容

SPA 应用(React / Vue 站点)的内容是 JS 运行时渲染的,直接 requests.get 拿不到正文。两种方案:

  • headless 浏览器:playwright / puppeteer / selenium。准确但慢(单页 2-5s)、资源消耗高
  • 找 API 端点:逆向前端代码,直接调用后端 JSON API。快但脆弱,API 变了就坏

生产建议:小规模、准确要求高用 headless;大规模、结构化数据多找 API。如果站点提供 RSS / Sitemap / JSON-LD,一律优先用这些结构化端点。

一个 PDF 解析失败的真实案例

某企业客服 RAG 上线后,客户经常反馈"问合同条款答错"。归因排查发现:

  • 合同 PDF 解析阶段表格识别错位——第 3 列数据被归到第 2 列表头下
  • 原因:表格有合并单元格且无表格线,pdfplumber 的 default 策略把列对齐搞错
  • 验证:人工抽样 50 份合同,13 份有类似问题

解决路径:

  1. 短期:把合同类 PDF 切到 camelot 专门处理(它对无线框表格好),增加表格结构校验(表头 × 数据行的单元格数一致)
  2. 中期:新增 metadata doc_type = "contract",contract 类文档走专用 pipeline
  3. 长期:引入 Textract 商业 OCR 做双引擎,分歧的部分人工 review

这个案例说明两点:解析错误的表现是"答错"而不是"报错"——不做抽样永远不会发现;解析方案随文档类型分化——一个银弹工具处理所有类型注定某一类出问题。

5.5 Markdown:最干净的源

Markdown 是 RAG 的"黄金标准"源格式——结构明确、噪声少、LLM 友好。但实操中仍有几类陷阱。

Frontmatter 和 metadata

Markdown 文件通常带 YAML frontmatter:

markdown
---
title: 产品规格
author: 产品部
date: 2026-03-15
tags: [规格, 企业版]
---

# 产品概述
...

frontmatter 是 metadata 的一手来源——解析时要先 parse YAML、再抽正文。frontmatter 字段直接入 chunk metadata,不要当正文处理。本书用的就是这个方案——每章 frontmatter 的 title/date/tags 被 VitePress 用来生成侧边栏和搜索。

嵌入式 HTML 和自定义容器

很多 Markdown 方言允许嵌入 HTML 或自定义语法(VitePress 的 :::tip、MDX 的 JSX 组件、GitBook 的 {% hint %})。解析器必须选择:

  • 严格模式:只支持 CommonMark,嵌入 HTML 当普通文本处理
  • 宽容模式:识别常见自定义容器,提取其内容 + 标记 block 类型

RAG 场景推荐宽容模式——:::tip 里的内容通常是关键要点,不识别等于丢信息。但不能识别所有方言——选定源格式后固定一套方言处理规则。

代码块和语言标签

Markdown 代码块的 ```python 标签是 chunking 的重要信号——代码 chunk 和正文 chunk 的分块策略不同(第 6 章)。解析时保留语言标签到 block.metadata.language。

常用工具

  • Python:markdown-it-py(CommonMark + plugin)、mistune(快)
  • Rust:pulldown-cmarkcomrak
  • Node:remark(生态最丰富,支持 AST 操作)

remark 生态的优势是把 Markdown 解析成 AST后可以任意改写、插入 metadata、转换成其他格式。这比字符串正则处理稳健得多。

5.6 代码仓库:AST 辅助下的语义分块

代码仓库(GitHub repo、GitLab instance)是 AI 助手场景最常见的源。问题和前三类完全不同——代码的"意义单元"是函数、类、模块,而不是段落或句子。

为什么不能按行分块

git clone 代码后按固定 300 行切分是错误做法。常见问题:

  • 一个函数被切成三段,中间段 embedding 不完整
  • import 语句单独成块,无语义价值
  • 单元测试代码和主代码混分,污染检索
  • 二进制文件(图片、lock 文件)被当文本处理

代码仓库的正确分块单位是:函数 / 方法 / 类 / 顶层语句块。这需要 AST 辅助。

tree-sitter:语言无关的 AST 工具

tree-sitter(tree-sitter.github.io)是 RAG 代码解析的事实标准。它提供:

  • 对 200+ 种语言的一致 AST API
  • 增量解析(代码改动后只重解析变动部分)
  • C 实现 + 各语言绑定(Python / Node / Rust)
  • 错误恢复——有语法错误的文件也能解析出部分 AST

基于 tree-sitter 的代码分块伪代码:

python
parser = tree_sitter.Parser(language=PYTHON)
tree = parser.parse(source_code)

# 找所有顶层定义节点
for node in tree.root_node.children:
    if node.type in ('function_definition', 'class_definition'):
        chunk_text = source_code[node.start_byte:node.end_byte]
        yield Chunk(
            text=chunk_text,
            metadata={
                'language': 'python',
                'symbol_name': get_symbol_name(node),
                'symbol_kind': node.type,
                'file_path': path,
                'start_line': node.start_point[0],
                'end_line': node.end_point[0],
            }
        )

每个代码 chunk 天然带上 symbol_name 和 symbol_kind——检索时可以按符号精确过滤。"User.login 方法怎么实现"的查询可以先精确匹配 symbol_name == 'login' AND symbol_kind == 'function_definition'

代码 embedding 模型

代码检索最好用专门的代码 embedding 模型而非通用文本模型:

  • OpenAI text-embedding-3-large:通用,代码一般
  • Voyage voyage-code-2:专为代码训练
  • Cohere embed-v3.0 的 code 版本
  • 开源:nomic-embed-codeCodeBERTUniXcoder

代码模型在代码检索任务上普遍比通用模型高 10-20 个百分点 recall。GitHub Copilot Chat 和 Cursor 的检索都用专门代码模型。

二进制文件和特殊路径

代码仓库的解析要显式排除:

  • 二进制文件(用 file 命令或魔数识别)
  • lock 文件(package-lock.json、Cargo.lock、yarn.lock)
  • 生成文件(dist/、build/、pycache/)
  • 大文件(>1MB 的单文件几乎全是 lock 或数据 dump)
  • .gitignore 匹配的文件

常见 gitignore 风格的忽略规则可以用 pathspec 库直接复用。

代码仓库的 metadata 特殊性

代码仓库的 metadata 比文档更丰富:

  • git 历史:每个 chunk 可以关联到 last_modified_commit、last_author、change_frequency。高频改动的代码可能更"活",但也可能 bug 多
  • symbol 图:函数调用关系、import 依赖、继承层级。用 LSP(language server protocol)或 Sourcegraph 的 SCIP 能拿到完整 symbol 图
  • 测试覆盖:chunk 是否有对应测试?有测试的代码通常更稳定、更值得参考
  • README 和 doc-comment:顶层文档、module docstring、函数 docstring——这些是关键语义信号

Cursor、Continue、Aider 这类代码助手都在 index 里保存上述 metadata。用户问 "find me all callers of login" 可以先精确定位函数定义、再用 symbol 图找调用者,而不是靠向量搜索。

Monorepo 的挑战

超大仓库(Google、Facebook 规模的 monorepo)不能全量索引——成本和延迟都不允许。策略:

  • 按变化频率分层:近 3 个月改动的代码 full-index;6 个月前的代码 summary-index;1 年前的冷代码按需 on-demand 解析
  • 按语言分离索引:Python/Rust/JS 各自独立索引,查询时按 query 语言倾向选择
  • 按 service/domain 分区:微服务架构里一个 service 的代码一般只查自己那块,跨 service 查询不频繁

Google 内部的 Code Search 系统公开过部分设计思路——核心是把检索能力下沉到 AST 级索引,而非纯文本 grep。

5.7 多格式混合:真实知识库的常态

企业知识库从来不是单一格式。典型组成:

  • 30% PDF(合同、规格、报告)
  • 25% HTML(内部 wiki、Confluence 导出)
  • 20% Markdown(技术文档、README)
  • 15% DOCX / PPT(产品文档、培训资料)
  • 10% 其他(工单 CSV、邮件、聊天记录)

混合源带来的挑战:

  • 格式识别:必须先判断文件类型再选解析器。不要信任文件后缀——.txt 可能是 JSON,.doc 可能是 HTML。用 python-magicfile 命令检测真实类型
  • 统一调度:所有解析器包装成同一 interface(输入路径,输出 IR),调度层不关心具体格式
  • 失败隔离:一份 PDF 解析失败不能让整批任务失败。每个文件独立 try-except,失败记录在 DLQ(dead letter queue)人工 review
  • metadata 合并:同一份文档可能有多个源的 metadata——文件系统的 mtime、PDF 内部的 author、Confluence API 返回的 last_edit——合并规则要显式

5.8 解析质量的可观测性

解析 pipeline 必须有可观测指标,不然出错了才发现。四个关键指标:

  • 解析成功率:按文档类型细分。PDF 成功率通常低于 HTML/Markdown,定期看趋势
  • 空产出率:解析完 block 数为 0 的文档比例。通常是扫描件没走 OCR 或格式识别错误
  • 异常 block 率:长度为 0 的 block、缺必要 metadata 的 block、类型未知的 block 占比
  • token 膨胀系数:解析产出的 token 数 / 原文件字节数。偏离典型值(文本类约 0.25,代码约 0.4)说明解析有问题

每日 pipeline 结束自动跑 parse_quality_report.py,对比昨日、告警偏离 2σ 以上的文档类型。长期看这些指标能发现"某个 domain 突然出了一堆乱 HTML"之类的系统性问题。

人工抽样 review 的价值

自动指标只能发现"明显的错"。细致错误(表格合并单元格误读、中英文混排顺序、公式识别漂移)必须靠人工抽样。建议:

  • 每周从当周解析量里随机抽 50 份
  • 每份人工检查 3-5 个关键字段是否正确
  • 记入 spreadsheet,追踪"发现-修复"周期

这个活儿听起来原始,但是持续性质量监控的底线。很多团队上线半年才发现有一整类文档解析错(比如所有带表格的 PPT 导出 PDF),就是因为一直没做人工抽样。

解析失败的分类学

解析失败不是一个"error"——它有若干种类,处理方式不同:

失败类型示例处理
格式不支持.hwp 朝鲜语文档跳过、记 DLQ
文件损坏PDF 头字节缺失重传或手动
加密/密码带密码的 PDF联系 owner 或跳过
OCR 置信度低扫描件模糊降级到 caption-only 索引
格式识别错.txt 实际是二进制用 file 命令再次检测
内容过大1GB PDF分页解析
解析器 bugunstructured 在某类文档上 crash切换 fallback 解析器

每种失败有明确的 error code 和处理脚本。生产 pipeline 的 DLQ 按 error code 分桶,运维每周处理。

5.9 可恢复性和幂等性

解析 pipeline 的两条底线:

可恢复:任何文档任何时候都能重新解析。意味着原始文件必须可访问(不要解析完删原文)、解析器版本可追溯(记在 IR metadata 里)、失败时有明确错误类别和可重试标志。

幂等:同一输入跑两次,产出完全一致。意味着解析过程不依赖当前时间、不依赖随机数、不依赖外部易变状态。OCR 模型要固定 version、解析参数要固定随机种子。

这两条看似基础、实操中经常破坏。常见反模式:

  • 解析时给 chunk 打 UUID 作 ID——不幂等,每次重跑 ID 都变
  • 解析依赖当前机器的 locale——不同机器结果不同
  • 文件 hash 用 mtime 作为一部分——不幂等

正确做法:chunk_id 从内容 hash 派生(见第 4 章 §4.5),解析器强制 UTF-8,所有随机种子固定到 version_hash

文档版本管理

文档是会变的——PDF 会被替换,Wiki 页面会被编辑,代码会被 commit。版本管理的两个核心问题:

  • 何时算"新版本":只有内容变了才算。metadata 变(重新下载、换了文件名)不算。用 内容 hash 判断而不是文件 mtime
  • 旧版本怎么办:三种策略——硬删除(只保留最新)、软标记(留旧版本但标 deprecated)、分版本索引(每个版本独立可检索)。企业场景推荐软标记:用户可能问 "上一版怎么规定的"——需要历史记录,但默认不检索

重新解析的触发条件

什么情况要强制重新解析?

  • 解析器版本升级:unstructured 0.10 → 0.11 修了一个 table 识别 bug,需要重跑所有历史 PDF
  • OCR 模型升级:Tesseract 4 → 5,扫描件质量有明显提升
  • 解析策略变更:从 "保留页眉" 改成 "剔除页眉",全量 re-parse

这些变更要在 pipeline metadata 里留痕——parser_version 字段。未来回看历史 bug 时能知道 "这份 PDF 是 v1 解析器产的、有已知的表格识别问题"。

并发解析的坑

大规模解析的吞吐 bottleneck 通常不是 CPU,是:

  • 外部 API 限流:商业 OCR API 都有 QPS 限制,必须加 rate limiter
  • GPU 资源竞争:marker、Nougat 都要 GPU,并发解析时要排队
  • 网络 I/O:从 S3 下载大 PDF 是串行开销,预取能提升吞吐
  • 内存峰值:部分 PDF 在 pdfplumber 里会触发内存溢出(遇到过 300MB 的扫描 PDF 把 2GB 容器打爆)

工程对策:每类解析器独立 worker pool、外部 API 走专门的 proxy(mitmproxy / envoy)做全局限流、大文件走 fallback 策略(直接走低成本的 text-only 解析)。

5.10 扫描 PDF 与 OCR 工程:从 Tesseract 到 VLM

§5.3 把 PDF 分成"数字 PDF"和"扫描 PDF"、说后者要走 OCR。企业知识库里扫描 PDF 的比例比多数人预期高——合同扫描件、旧政策文档、签字版 SOP、第三方报告 PDF、甚至图书扫描——对这一类文档、OCR 质量决定 RAG 能否用起来。2024 年后视觉 LLM(VLM)的出现改变了 OCR 工程的格局、这节把传统 OCR 和 VLM 两条路讲清楚。

扫描 PDF 和数字 PDF 的区别

两种 PDF 的判定方法:

python
def is_scanned_pdf(pdf_path):
    doc = fitz.open(pdf_path)
    total_pages = len(doc)
    pages_with_text = sum(1 for page in doc if len(page.get_text().strip()) > 50)
    return pages_with_text / total_pages < 0.3  # < 30% 有文本 → 扫描

数字 PDF 用 §5.3 的方法即可。扫描 PDF 才进入这节 OCR 讨论。

传统 OCR 路线

主流传统 OCR 工具:

  • Tesseract(Google 开源):最老牌、免费、100+ 语言。准确率中等、版式识别弱
  • PaddleOCR(百度):中文支持好、速度快、开源
  • AWS Textract / Google Document AI:商用 API、版式识别强、贵
  • Azure Form Recognizer:表格 / 表单类结构化 OCR 最强

DPI 重要——扫描件默认 150 DPI 不够、OCR 质量差。转 300-400 DPI 再 OCR、质量提升 5-10%。

传统 OCR 的局限

  • 错误率高:中文扫描件典型 5-15% 字符错误、印章 / 手写 / 低质量扫描更差
  • 版式破坏:多栏、表格、页眉页脚混一起、输出是乱的
  • 手写字几乎识别不了:Tesseract 对印刷体训练、手写准确率 < 50%
  • 数字和字母混淆:0/O、1/l、2/Z 常混
  • 表格结构丢失:表格变成行-列错乱的纯文本

传统 OCR 只是"能用"——RAG 场景下这些错误会累积成严重 retrieval 问题。

VLM 路线

2024 年后视觉 LLM 能力爆发——GPT-4V、Claude 3.5+ Sonnet、Gemini Vision 都能直接看图产生文本、而且保留版式:

python
def ocr_with_vlm(pdf_path, page_num):
    img_b64 = pdf_page_to_base64(pdf_path, page_num)
    response = claude.messages.create(
        model="claude-sonnet-4.6",
        messages=[{
            "role": "user",
            "content": [
                {"type": "image", "source": {"data": img_b64, "media_type": "image/png"}},
                {"type": "text", "text": "提取这张图里的所有文字、保留段落结构、表格用 markdown 表示。"}
            ]
        }]
    )
    return response.content[0].text

VLM 的优势:

  • 准确率高:中文扫描件错误率 1-3%、比 Tesseract 低 5-10 倍
  • 版式保留:自动识别标题、段落、表格并转成 markdown
  • 处理手写:手写体准确率 70-85%、可用
  • 理解图表:能描述图表内容、虽然不是精确数字但辅助检索够用

VLM 的成本考量

VLM 不是免费午餐:

方案单页成本速度准确率
Tesseract(自托管)~$0.00010.5s85-95%
PaddleOCR(自托管)~$0.00010.3s90-97%
Claude Vision~$0.0153-5s97-99%
GPT-4V~$0.023-8s97-99%
AWS Textract~$0.0151-2s95-98%

100 万页的批量 OCR:

  • Tesseract:$100 + 运维
  • Claude Vision:$15000

差 150 倍——不能无脑全用 VLM。

混合策略

生产最优做法是混合

策略:

  • 快路径(Tesseract / PaddleOCR)处理 80% 简单页面
  • 慢路径(VLM)处理复杂页面 + 快路径失败的页面
  • 质量判断可以用规则(乱码比例、字数过少等)或小分类器

这种分层让总成本降到纯 VLM 的 10-20%、质量接近纯 VLM。

表格 OCR 的特殊挑战

扫描 PDF 里的表格是 OCR 最难的——传统工具几乎都坏。解决方案:

  • 专用表格 OCR:AWS Textract 表格 API、Azure Form Recognizer、Table Transformer 开源模型
  • VLM 识别表格:prompt 要求"表格用 markdown 输出"、效果好但贵
  • 版式切割 + 单格 OCR:先识别表格边框、再对每个 cell OCR、最后重组

医疗 / 金融 / 法律文档里表格密集——这一步不能省。

手写和签字的处理

手写字符对所有 OCR 都是难题:

  • 纯手写表单:VLM 是唯一勉强可用的方案(70-85% 准确率)
  • 签字:不尝试识别为文字、作为"签字图片"元数据保留
  • 批注 / 修订:现代 VLM 能识别红色批注和手写注释——但 prompt 要显式要求

版面错误的后处理

OCR 出来的文本可能有系统性错误——需要后处理:

  • 乱码过滤:连续 5+ 个非 ASCII / 非 CJK 字符的段落直接丢弃
  • 重复段 detect:OCR 有时把页眉识别 N 次(N = 页数)、去重
  • 页码剥离:页脚的 "Page 3 of 47" 这类、按位置和模式剥离
  • 常见错字纠正:领域词典 + 模糊匹配替换

OCR 质量的监控

生产 OCR pipeline 必需的指标:

  • ocr_page_success_rate:成功产出文本的页面比例
  • avg_chars_per_page:均值低异常说明 OCR 失败
  • garbage_ratio:乱码字符占比、按文档 / 按批次看
  • vlm_cost_share:VLM 占 OCR 总成本比例、控制预算

特定场景的 OCR 策略

不同业务的 OCR 策略:

  • 合同 / 法律:VLM 必选、不能有 OCR 错误、质量 > 成本
  • 历史文献 / 研究:Tesseract + 人工抽查、成本敏感
  • 手写表单:VLM + 专家审核、可能不能 100% 自动化
  • 一次性 onboarding:投入 VLM 一次性处理、之后增量少
  • 每日大量扫描件:Tesseract 快路径 + VLM 慢路径混合

OCR 没有"最佳方案"——要按业务选。

5.11 Office 格式的解析:docx、xlsx、pptx

§5.3-5.6 讲了 PDF / HTML / Markdown / 代码四类——但企业文档的主力是 Word / Excel / PowerPoint。中国企业尤其如此——合同是 docx、报表是 xlsx、培训材料是 pptx。这些 Office 格式有独特结构、通用解析器处理不好。这节把三类 Office 格式的解析讲清楚。

三类格式的结构特点

三者都是 ZIP 包装的 XML——但内部结构和语义重点完全不同、要分别处理。

docx 的解析

docx 内部是 word/document.xml,核心是段落(paragraph)的序列。解析工具:

  • python-docx:轻量、Python 原生、提取文本 + 基础样式
  • mammoth:docx → HTML / markdown、保留样式好
  • pandoc:跨格式转换、最强但重
  • unstructured:docx 支持成熟、自动切 block

推荐:unstructured + python-docx 组合——unstructured 做结构化、python-docx 补细节。

docx 的特殊内容

  • 样式层级:标题 1/2/3 → 对应章节层级、是分块的天然边界
  • 表格:必须保留表头行 + 单元格结构、不要展平成行流
  • 批注 / 修订:可选保留——法律 / 合同审查场景有用
  • 图片 / 图表:内嵌 image、需要单独抽取 + OCR(见 §5.10)
  • TOC / 交叉引用:"见第 3 章"这类需要解析成稳定链接
python
import docx

def parse_docx(path):
    doc = docx.Document(path)
    blocks = []
    for para in doc.paragraphs:
        if para.style.name.startswith("Heading"):
            level = int(para.style.name[-1])
            blocks.append({"type": "heading", "level": level, "text": para.text})
        elif para.text.strip():
            blocks.append({"type": "paragraph", "text": para.text})
    # 表格
    for table in doc.tables:
        blocks.append({"type": "table", "data": extract_table(table)})
    return blocks

xlsx 的解析

xlsx 是表格主导——每个 sheet 里成千上万单元格。解析工具:

  • openpyxl:Python 主流、功能全
  • pandas(read_excel):直接转 DataFrame、数据分析友好
  • Aspose / LibreOffice(headless):复杂公式 / 样式场景

xlsx 的特殊问题

  • 多 Sheet:一个文件多个 sheet、每个 sheet 是独立表——要分开处理
  • 公式=SUM(A1:A10)——解析时要求值后的结果(openpyxl 的 data_only=True)、不是公式字符串
  • 合并单元格:合并单元格的值只在左上角、其他位置为空——要展开回原始结构
  • Formatted 数字¥1,000.00 vs 1000——保留一个语义化版本
  • 跨表引用=Sheet2!A1——依赖另一个 sheet、要整合
  • 命名范围=VLOOKUP(ID, Customer_Info, 2, 0) 里的 Customer_Info 是命名范围

每个 xlsx 适合的 chunk 策略:一个 sheet 一个 chunk(如果 sheet 小)或 一个表格段落一个 chunk(如果 sheet 大)。

python
import openpyxl

def parse_xlsx(path):
    wb = openpyxl.load_workbook(path, data_only=True)  # data_only 拿公式结果
    sheets = []
    for sheet_name in wb.sheetnames:
        sheet = wb[sheet_name]
        rows = []
        for row in sheet.iter_rows(values_only=True):
            if any(cell for cell in row):
                rows.append(row)
        sheets.append({"name": sheet_name, "rows": rows})
    return sheets

xlsx 分块时、每 chunk 必须带表头(ch6 §6.11 / ch16 §16.15 呼应)。

pptx 的解析

pptx 是视觉主导——每个 slide 是一页、有 shape / 文本 / 图片。解析工具:

  • python-pptx:Python 原生、功能全
  • LibreOffice impress(headless):复杂 layout

pptx 的特殊内容

  • Slide 层级:一页一个 slide、是天然的 chunk 单位
  • Shape 和文本框:同 slide 内多个 shape、按位置排序
  • Speaker notes:演讲者备注、包含的信息可能比 slide 本身多
  • 母版 / 模板:重复的版式元素(页码、logo)——通常过滤
  • 图片 / SmartArt / 图表:不是文字、需要 OCR 或 VLM 描述

一个 slide 的解析输出典型:

json
{
  "slide_number": 5,
  "title": "企业版 SSO 架构",
  "text_content": "...正文...",
  "speaker_notes": "演讲时提到的细节...",
  "images": ["slide5-image1.png"],
  "layout": "title-content"
}

Speaker notes 特别重要——很多培训材料的精华在备注里、slide 本身只是提纲。

批注和修订

docx 和 pptx 都支持批注。处理:

  • 生产文档里的批注:通常不入索引(批注是讨论、不是事实)
  • 合规场景:保留批注作为审批记录
  • 内部文档:保留批注能追溯"谁说过什么"

默认不入索引、按业务开关。

混合 Office pipeline

企业知识库常同时有三类——统一 pipeline:

每类走自己的解析器、输出到统一 IR(§5.2)、下游不管输入格式。

企业场景的文件组织

真实企业场景的典型分布:

  • 60-70% docx(合同、规范、SOP)
  • 15-20% xlsx(数据表、规格)
  • 10-15% pptx(培训、提案)
  • 5% 其他

xlsx 虽占比小、但单个价值高——往往是关键数据。不能因为占比低就随便处理。

Office 解析的常见坑

  • 嵌入 OLE 对象:Excel 里嵌了 Word 文档、或 PowerPoint 里嵌了 Excel——需要递归解析
  • 加密文档:密码保护的 docx、解析前要先解密(需业务提供密钥)
  • 旧格式 .doc / .xls / .ppt:二进制格式、不是 XML、解析工具不同(用 libreoffice / antiword)
  • 宏和 VBA 代码:生产文档可能含宏、解析时应跳过代码、避免执行风险
  • 中文字体 / 编码问题:个别旧版 Office 文件编码非 UTF-8、要自动检测

Office 文档的 metadata

每个 Office 文件都带核心 metadata(文档属性):

python
core_props = doc.core_properties
metadata = {
    "author": core_props.author,
    "created_at": core_props.created,
    "modified_at": core_props.modified,
    "title": core_props.title,
    "subject": core_props.subject,
    "keywords": core_props.keywords,
    "revision": core_props.revision,
}

这些字段是免费的 metadata——自动抽取、用于后续 filter 和 rerank。

和 PDF 解析的协同

很多企业文档同时有 docx 源文件和 PDF 导出版——都入库吗?

  • 只入 docx:结构完整、PDF 是派生
  • 只入 PDF:统一格式、但丢失 docx 的某些元素
  • 只入一种 + 标注类型:按可得性、推荐

原则:有结构化源文件(docx)优先、PDF 作 fallback

Office 解析的投入

完整 Office pipeline 工程成本:

  • 初版:2-3 人周
  • 每格式维护:每季度 0.5 人天
  • 特殊需求(加密 / 宏 / 旧格式):按需加

这是大多数企业 RAG 必做的——不上 Office 解析、60%+ 的知识进不了索引。

5.12 解析的自动化测试与回归

解析是 RAG 的第一道 pipeline——上游错、下游全错。但解析代码的测试经常被忽略:parser 版本升级、新文档格式、解析库改动——每次都可能 silently 改变输出、污染索引。这节给解析 pipeline 建一套自动化测试体系、让 "parser 升级" 从"提心吊胆"变成"一键验证"。

为什么 parser 的回归很常见

这些变化都可能让某类文档的解析结果静默变化——没有测试就查不出。

Golden output 回归测试

核心做法:保留一组代表性文档的"标准解析输出"、每次改代码比对:

python
# tests/golden_set/
#   sample-doc-001.pdf
#   sample-doc-001.expected.json  # 正确解析结果
#   sample-doc-002.docx
#   sample-doc-002.expected.json
#   ...

def test_parser_regression():
    for pdf_path in glob("tests/golden_set/*.pdf"):
        expected_json = pdf_path.replace(".pdf", ".expected.json")
        with open(expected_json) as f:
            expected = json.load(f)
        
        actual = parse_pdf(pdf_path)
        
        assert actual == expected, f"Regression in {pdf_path}"

跑在 CI——每次 PR 都验证:解析结果没变

Golden set 的构造

挑哪些文档进 golden set?

  • 覆盖常见格式:PDF 数字 / PDF 扫描 / docx / xlsx / pptx / markdown 各几条
  • 覆盖难 case:带表格、多栏、手写批注、复杂布局
  • 覆盖边界:空文档、超长文档、加密文档(授权的)、损坏文档
  • 业务代表性:抽样自真实生产数据(脱敏后)

数量:20-50 条起步、关键场景补齐到 100-200 条。

Expected output 的维护

第一次生成 expected 可以用当前 parser 输出作基准:

bash
# 第一次运行:生成 golden
python gen_golden.py --output tests/golden_set/

# 人工 review 生成的 expected——确认是"正确"的而非"当前"的
# (否则只是 snapshot 测试、不是 regression 测试)

每次 parser 有意升级、输出预期变化时、主动更新 golden

bash
python update_golden.py --test case-42
# 更新 case-42 的 expected.json
# 提交时必须审查 diff、确认变化合理

"静默变化"是 bug、"有意变化"是 feature——靠 review 过程区分。

Property-based testing

Golden set 只覆盖具体 case——用 property-based test 补充通用约束:

python
@property_test
def test_chunk_count_reasonable(doc):
    chunks = parse_and_chunk(doc)
    assert 1 <= len(chunks) <= 10000  # 合理范围

@property_test
def test_no_null_text(doc):
    chunks = parse_and_chunk(doc)
    for c in chunks:
        assert c.text and len(c.text) > 0

@property_test
def test_metadata_complete(doc):
    chunks = parse_and_chunk(doc)
    for c in chunks:
        assert c.doc_id and c.chunk_id
        assert c.source_url or c.source_path

@property_test
def test_order_preserved(doc):
    # 相邻 chunk 在原文中应该相邻
    chunks = parse_and_chunk(doc)
    for i in range(len(chunks) - 1):
        assert chunks[i].end_pos <= chunks[i+1].start_pos

这些 property 对所有文档都应该成立——遇到反例即 bug。

解析的 fuzz 测试

生产里偶尔会遇到"奇怪文档"——fuzz 测试提前发现:

python
def fuzz_parser(iterations=10000):
    for _ in range(iterations):
        # 生成随机但合法的 PDF / docx / ...
        doc = generate_fuzz_doc()
        try:
            result = parse(doc)
            validate_property(result)  # 通用 property
        except ExpectedException:
            pass  # 某些错误可接受
        except Exception as e:
            report_bug(doc, e)  # 未预期 crash

Fuzz 能发现人类 gold set 没覆盖的问题——crash / 死循环 / 内存爆。

Parser 升级的测试流程

有新 parser 版本、走完整流程:

每一步都有明确门槛——不是"跑一下没问题就上"。

对比新老 parser

Staging 阶段、同一批文档用新老 parser 各跑一遍、对比输出:

python
def compare_parsers(docs, old_parser, new_parser):
    diffs = []
    for doc in docs:
        old_result = old_parser.parse(doc)
        new_result = new_parser.parse(doc)
        if old_result != new_result:
            diffs.append({
                "doc": doc.id,
                "chunk_count_change": len(new_result) - len(old_result),
                "text_similarity": similarity(old_result, new_result),
            })
    return diffs

总结:

  • 平均 chunk 数变化 > 10%:parser 行为大改、要谨慎
  • 文本相似度 < 90%:真的改了、需要全面 review
  • 某些 doc 直接 fail:修 parser 或 blacklist

生产端的解析监控

CI 测试之外、生产运行时监控:

  • parse_success_rate:成功解析比例、应 > 99%
  • parse_failure_reason:失败分类(文件损坏 / 格式不支持 / OOM)
  • avg_chunks_per_doc:均值、突变说明 parser 行为变
  • parse_latency_p99:解析延迟、突增说明某类文档慢
  • chunk_text_length_distribution:chunk 文本长度分布、畸变说明切分错

这些指标长期看——变化趋势比绝对值更重要。

测试的 ROI

Parser 测试的投入:

  • Golden set 构造:1-2 人周
  • Property test:1 人周
  • Fuzz:1 人周
  • CI 集成:3-5 人天

收益:

  • Parser 升级有信心
  • 回归 bug 在 CI 挡下、不到生产
  • 新文档格式快速验证

一次 parser 回归事故的损失(解析错 → 索引污染 → 用户答错 → 工程紧急修复)可能几十人天——测试投入 2-4 人周预防一次就回本

常见反模式

  • 只测 happy path:正常 PDF 过了就行、不测边界
  • Golden 不更新:意图变化时 golden 过期、测试失去意义
  • 只跑 CI 不看趋势:pass/fail 之外、输出是否 drift 没人看
  • 不对比新老:新 parser 上了不知道影响多大
  • Fuzz 不做:某天奇怪文档 crash 生产

测试文化

Parser 测试体现的不只是技术——是文化

  • "改 parser 要写测试" 是 baseline
  • "golden 不能随意改" 是纪律
  • "生产发现 bug 进 golden" 是闭环

没这些文化、技术再好也落不实。

和其他 pipeline 组件的联动

Parser 的测试思路可以用到所有 pipeline 组件:

  • Chunker:同样 golden + property(§6.14)
  • Embedder:向量分布的 property(§9.14)
  • Retriever:gold set 的 recall property(§20)

建一个组件、就写一套测试——这是 RAG 工程纪律的底色。

5.13 LLM 辅助解析:传统工具失败时的 fallback

前面章节讲了 unstructured / pdfplumber / Tesseract 等传统 parser——它们覆盖 95% 的文档。剩下 5% 呢?版式极怪的 PDF、手写涂改的扫描件、复杂嵌套表格、失去源头的老格式——传统 parser 束手无策。2024 年后的答案是 用 LLM(尤其 VLM)作 fallback——这是 parsing 工程的新角色。这节讲 LLM 辅助解析的工程实践。

传统 parser 失败的典型场景

这些场景 Tesseract 输出乱码、unstructured 识别为普通段落——信息大量丢失

LLM 能做什么

VLM(Claude Vision / GPT-4V / Gemini)直接看文档图像:

  • 理解版式:多列、边栏、注脚正确识别
  • 解构表格:嵌套表格转成规整 markdown
  • 手写 + 打印:两者识别成文字
  • 图表描述:图表里的关键数据点
  • 退化内容识别:老文档、模糊扫描也能尽力恢复

GPT-4V / Claude 3.5+ 在这类任务上比 Tesseract 好 5-10 倍——不是"快一点"、是"质的差别"

混合策略

不是"全用 LLM"——成本太高。典型分层

  • Fast path:简单文档用 traditional——快、便宜、够用
  • Slow path:复杂 / 失败的文档用 VLM——慢但质量好
  • 质量检查:自动判断 traditional 是否成功、失败 escalate 到 VLM

判断 "简单 vs 复杂"

触发 VLM 的启发式:

python
def needs_vlm_fallback(doc, traditional_result):
    # 1. Traditional 解析出错或输出为空
    if not traditional_result or traditional_result.error:
        return True
    
    # 2. 输出质量差
    if has_high_garbage_ratio(traditional_result):
        return True
    
    # 3. 有特殊标识(复杂版式 / 扫描 PDF)
    if doc.is_scanned_pdf() and doc.has_complex_layout():
        return True
    
    # 4. 低置信度(ocr_confidence < 0.7)
    if traditional_result.ocr_confidence < 0.7:
        return True
    
    return False

不是所有文档都走 VLM——只失败的才 escalate。

VLM 解析的 prompt 设计

核心 prompt:

python
prompt = """
仔细阅读这张文档图像、提取所有文字内容。要求:
1. 保留原文档的结构(标题、段落、列表、表格)
2. 表格用 markdown 格式
3. 代码用代码块
4. 如果有图表、在图表位置简短描述关键数据
5. 如果文字模糊不确定、用 [?] 标注
6. 手写内容和打印内容都识别、区分标记

输出 JSON:
{
  "title": "...",
  "sections": [
    {"type": "heading", "text": "...", "level": 1},
    {"type": "paragraph", "text": "..."},
    {"type": "table", "markdown": "..."},
    ...
  ],
  "confidence": 0.85,
  "notes": "观察到的异常"
}
"""

结构化输出(§16.19)——后续处理容易。

成本考量

传统 parser vs VLM 的成本:

方案每页成本速度质量
Tesseract$0.00010.5s60-80%
pdfplumber~$00.1s70-90%
AWS Textract$0.0152s85-95%
Claude Vision$0.015-0.033-8s95-99%
GPT-4V$0.023-8s95-99%

VLM 比 Tesseract 贵 100-300 倍——只在值得时用

什么时候值得用 LLM fallback

判断依据:

  • 业务场景:合规 / 医疗 / 法律、质量 > 成本——值得
  • 文档量:每月几千份复杂文档——值得批量
  • 业务价值:每份文档对 RAG 价值高(如合同)——值得单份投入
  • 可替代性:传统 parser 真的都失败了

场景:

  • 客户上传的 legacy PDF:VLM 处理、一次性投入
  • 日常自动 ingest 的 wiki:传统 parser、量大不走 VLM
  • 法律合同 / 医疗记录:VLM、质量第一

LLM 输出的验证

VLM 也会犯错——不是 100% 可信。验证:

  • Schema 合规:输出必须符合预定义 JSON schema
  • 内容合理性:长度 / 字符分布合理、不是乱码
  • 关键字段完整:title 非空、至少 1 段内容
  • 偶尔重跑对比:同一文档多次跑、看一致性

输出不合验证 → 人工 review 或重跑。

和传统 parser 的结果对比

做 fallback 时、对比新老结果:

  • 完全不同:VLM 质量应更好、看是否符合
  • 略有不同:VLM 可能补了 traditional 遗漏
  • 完全一致:证明 traditional 足够

偶尔抽样对比——校准 fallback 触发阈值。

VLM 的限制

  • 长文档慢:每页 3-8s、100 页要 5-15 分钟
  • context 限制:单次处理有页数上限
  • 一致性:同一文档多次跑、output 略不同
  • 隐私:发给云 LLM、企业文档脱敏

长文档策略:分页独立调用 → 合并 → 后处理。

Batch processing 的优化

大量文档用 VLM、batch 优化:

  • 并行调用:10-50 个文档同时发
  • 使用 Batch API(Anthropic/OpenAI 都有):24h 内完成、价格 50% 折扣
  • 缓存重复文档:content_hash 命中缓存、不重跑

一次 ingest 1 万份文档、batch API 比单个调便宜几千美元。

自托管 VLM

成本敏感或隐私要求高的场景——自托管:

  • LLaVA / Qwen-VL:开源 VLM、7B-13B 规模
  • GPU 要求:A100 能处理几 QPS
  • 性能:比 Claude Vision 略差、但接近

自托管经济拐点:每月 > 10 万页 VLM 处理——值得投 GPU 基础设施。

VLM 解析的长期趋势

2025-2026 年趋势:

  • VLM 能力快速提升:1 年前不行的今年能行
  • 价格持续降:和 LLM 一样、每年 50%+ 降价
  • 专用 VLM:针对文档的(如 Mistral OCR)、比通用的准

2027+ 可能:VLM 成主流、传统 parser 退居次要——目前混合是过渡。

对 RAG 架构的影响

有 LLM 辅助解析的 RAG:

  • "困难文档"不再是盲区:传统过不去的、VLM 救场
  • Ingest 更慢、更贵:对 VLM 流量的 SLA 放宽
  • 质量上限提高:整个 RAG 的召回 +3-5 点(源于 parsing 质量)

这笔账对追求质量 > 成本的企业 RAG 划算。

实施步骤

上 LLM fallback 的渐进方式:

  • Week 1:传统 parser 的输出加质量评分
  • Week 2:对低分文档手动跑 VLM、看质量差多少
  • Week 3:配置 fallback 规则、自动触发 VLM
  • Week 4:监控触发率和质量、调阈值
  • Month 2:覆盖更多场景、优化 prompt
  • Month 3:持续积累、batch API 降成本

不是一次性切换——渐进引入、监控观察。

传统 parser 仍重要

尽管 VLM 强——不要抛弃传统 parser

  • 传统 parser 处理 95% 文档、成本极低
  • VLM 是 5% 难文档的 fallback
  • 混合成本 < 纯 VLM 成本的 10%

混合架构——不是非此即彼。

失败模式的观察

LLM 辅助 parsing 的典型失败:

  • VLM 幻觉:读不清的字、VLM 编造——要监控 "[?]" 的使用率
  • 结构错乱:嵌套表格识别成扁平
  • 漏页:多页 PDF 漏几页
  • 语言错乱:中英文混文识别错

这些错误比 Tesseract 少、但仍需人工抽检

LLM 辅助解析的 ROI

对一个 50 万页复杂文档库:

  • 传统 parser only:80% 质量、低成本
  • 加 VLM fallback:95% 质量、成本翻倍

RAG 质量提升(recall +3-5 点)带来的业务价值——视业务——可能远超成本。

文档智能的未来

传统 parser → LLM 辅助 → 端到端 LLM 理解文档——趋势清晰。2-3 年内、"parser"的概念可能被淘汰——LLM 直接"读文档"、产出结构化输出。

作为 RAG 工程师、要跟进这个趋势——不是死守传统 parser、也不是盲目全上 VLM、是按业务选当下最优解

5.14 跨书关联:解析 IR 和编译器 IR 同构

本章的"统一 IR"思路和编译器里的 IR 思路同构。《Rust 编译器与运行时揭秘》第 4 章讨论的 HIR/MIR 层级(源码 → HIR → MIR → LLVM IR)就是多阶段 IR 转换——每一层剥离一部分源语言特性,下游只关心自己需要的抽象。

RAG 的解析 IR 同样是为了下游只关心自己需要的抽象——chunking 不管 PDF 原始坐标、检索不管 HTML 标签、LLM prompt 只看 block.text。这种分层解耦的威力在任何大型数据管道里都被反复验证。

5.15 本章小结

  1. 文档解析是 RAG 的第一公里——这一步丢失的结构信息下游补不回来
  2. 四类源(PDF / HTML / Markdown / 代码)各有挑战——PDF 缺结构、HTML 多噪声、Markdown 最干净、代码需要 AST
  3. 解析产出不是纯文本,是格式无关的 IR(blocks + metadata),下游只认 IR
  4. 工具选型没有银弹:通用 + 垂直组合,针对不同文档类型走不同 pipeline
  5. 可恢复 + 幂等 是解析 pipeline 的两条底线

下一章进入 Chunking——拿到 IR 之后,如何把每个 block 切成既保留语义完整性又适合 embedding 的小块。

基于 VitePress 构建