Appearance
第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 需求 |
|---|---|---|---|
| pypdf | 0.5s | 不支持 | 否 |
| pdfplumber | 2-5s | 不支持 | 否 |
| unstructured.io hi_res | 15-30s | 30-60s(含 OCR) | 推荐 |
| marker | 30-90s | 60-120s | 必需 |
| Tesseract OCR | N/A | 60-180s | 否(CPU 够用) |
| PaddleOCR | N/A | 30-90s | GPU 加速 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 份有类似问题
解决路径:
- 短期:把合同类 PDF 切到
camelot专门处理(它对无线框表格好),增加表格结构校验(表头 × 数据行的单元格数一致) - 中期:新增 metadata
doc_type = "contract",contract 类文档走专用 pipeline - 长期:引入 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-cmark、comrak - 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-code、CodeBERT、UniXcoder
代码模型在代码检索任务上普遍比通用模型高 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-magic或file命令检测真实类型 - 统一调度:所有解析器包装成同一 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 | 分页解析 |
| 解析器 bug | unstructured 在某类文档上 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].textVLM 的优势:
- 准确率高:中文扫描件错误率 1-3%、比 Tesseract 低 5-10 倍
- 版式保留:自动识别标题、段落、表格并转成 markdown
- 处理手写:手写体准确率 70-85%、可用
- 理解图表:能描述图表内容、虽然不是精确数字但辅助检索够用
VLM 的成本考量
VLM 不是免费午餐:
| 方案 | 单页成本 | 速度 | 准确率 |
|---|---|---|---|
| Tesseract(自托管) | ~$0.0001 | 0.5s | 85-95% |
| PaddleOCR(自托管) | ~$0.0001 | 0.3s | 90-97% |
| Claude Vision | ~$0.015 | 3-5s | 97-99% |
| GPT-4V | ~$0.02 | 3-8s | 97-99% |
| AWS Textract | ~$0.015 | 1-2s | 95-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 blocksxlsx 的解析
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.00vs1000——保留一个语义化版本 - 跨表引用:
=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 sheetsxlsx 分块时、每 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) # 未预期 crashFuzz 能发现人类 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.0001 | 0.5s | 60-80% |
| pdfplumber | ~$0 | 0.1s | 70-90% |
| AWS Textract | $0.015 | 2s | 85-95% |
| Claude Vision | $0.015-0.03 | 3-8s | 95-99% |
| GPT-4V | $0.02 | 3-8s | 95-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 本章小结
- 文档解析是 RAG 的第一公里——这一步丢失的结构信息下游补不回来
- 四类源(PDF / HTML / Markdown / 代码)各有挑战——PDF 缺结构、HTML 多噪声、Markdown 最干净、代码需要 AST
- 解析产出不是纯文本,是格式无关的 IR(blocks + metadata),下游只认 IR
- 工具选型没有银弹:通用 + 垂直组合,针对不同文档类型走不同 pipeline
- 可恢复 + 幂等 是解析 pipeline 的两条底线
下一章进入 Chunking——拿到 IR 之后,如何把每个 block 切成既保留语义完整性又适合 embedding 的小块。