主题
14.04-文档解析
要点
- 解析质量决定 RAG 管道的上限——丢掉的表格结构和标题层级,后续切分和 embedding 补不回来
- 不同格式的解析复杂度差几个数量级:纯文本几行代码,PDF 需要多种策略组合
- 解析的核心不是提取文本,是保留结构——标题层级、表格行列关系、段落边界直接决定 chunk 质量
- 统一接口 + 注册表模式让各格式解析器独立演进,新增格式不改动分发逻辑
- 解析质量不能靠人眼检查——管道内建验证可以在切分前拦截大部分严重错误
- API 服务(LlamaParse、Unstructured.io)省心但有成本、延迟和数据安全代价,只在格式种类多或工程资源紧时值得用
1. 解析出错,后面全白做
假设你的 RAG 知识库有一份产品定价表,存在 PDF 里,长这样:
| 套餐 | 月费 | 用户数 |
|--------|-------|--------|
| 基础版 | ¥99 | 5 |
| 专业版 | ¥299 | 50 |
| 企业版 | 联系销售 | 不限 |解析器处理这份 PDF 时,把表格拍成了纯文本:
套餐 月费 用户数 基础版 ¥99 5 专业版 ¥299 50 企业版 联系销售 不限行列关系丢了。下游切分器把这段文字切成一个 chunk,embedding 后存入向量库。用户问「专业版多少钱」,检索命中了这个 chunk,但模型从一堆混杂的数字里无法确定哪个价格属于哪个套餐。
这不是 embedding 模型的问题,不是切分策略的问题,也不是 reranking 的问题——问题出在解析阶段,表格的结构信息丢了。 后续无论怎么调优,都找不回来。
这类问题在排查时很容易被归因到下游环节,因为文本「看起来」提取出来了。但解析阶段的错误是静默的——不报错、不崩溃,只是信息丢了。
文档解析质量直接决定 RAG 管道上限。 这一篇的目标:把 PDF、Word、HTML、Markdown 可靠地转成带结构的纯文本,并在管道内建质量验证。
2. 各格式的解析难度差了几个数量级
| 格式 | 难度 | 核心挑战 | 典型工具 |
|---|---|---|---|
| 纯文本 (.txt) | 低 | 无结构,直接读取 | 原生 API |
| Markdown (.md) | 低 | 提取标题层级,去除格式符号 | marked / 正则 |
| HTML (.html) | 中 | 定位正文区域,去掉导航/侧栏/脚本 | JSDOM |
| Word (.docx) | 中 | 解析 ZIP + XML,识别标题样式 | JSZip + DOMParser |
| PDF(文本型) | 高 | 坐标推断行和段落,多栏布局 | pdfjs-dist |
| PDF(扫描件) | 极高 | OCR 识别,耗时长、准确率不稳定 | Tesseract / 云 OCR |
一个关键区分:文本型格式(Markdown、HTML、Word)和布局型格式(PDF)的难度差距,不是线性的。 前者有明确的语义标记——<h1> 就是标题,<p> 就是段落,<table> 就是表格——解析器直接读结构。后者只记录「第 X 坐标、第 Y 坐标放一个字符」,没有段落、表格、标题的概念,解析器必须从坐标反推结构关系。
这是 PDF 解析难度远高于其他格式的根本原因。
3. 解析的核心不是提取文本,是保留结构
所有格式的解析器都在做同一件事:从原始格式中提取文本,同时尽可能保留结构信息。
需要保留的结构:
- 标题层级:告诉切分器在哪里断章。
## 2.1和## 2.2之间的边界,是天然的切分点 - 表格行列关系:让模型能准确对应「专业版」和「¥299」,而不是一堆混杂的数字
- 段落分隔:防止语义不相关的两个段落被切进同一个 chunk
- 列表层级:保留步骤顺序和从属关系
同一个表格,保留结构和拍平文本的检索效果对比:
| 解析方式 | 检索「专业版多少钱」的效果 |
|---|---|
| 保留行列关系 | 模型直接匹配:专业版 → ¥299 |
| 拍平成纯文本 | 模型猜测:99 / 299 / 联系销售 中选一个 |
如果你的文档以表格为主,解析阶段的结构保留比后续任何优化都重要。
4. 统一解析接口:分发而非全能
先定义接口,再逐个实现。上游调用者不需要关心具体格式,每种格式的解析逻辑互相隔离——新增格式时只注册新解析器,不动分发逻辑。
typescript
// src/services/rag/parsers/types.ts
export type ParsedDocument = {
id: string
title: string
content: string
metadata: {
format: string
pageCount?: number
scanned?: boolean
sections?: Array<{
level: number
title: string
content: string
}>
[key: string]: unknown
}
}
// 解析器函数签名:接收文件内容和文件名,返回结构化结果
type Parser = (buffer: ArrayBuffer, fileName: string) => Promise<ParsedDocument>ParsedDocument 的设计要点:content 是纯文本,给切分器用;metadata.sections 保留结构信息,给需要按章节切分的场景用。两者并存,下游按需取用。
typescript
// src/services/rag/parsers/index.ts
const PARSERS: Record<string, Parser> = {
'text/plain': parsePlainText,
'text/markdown': parseMarkdown,
'text/html': parseHTML,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': parseDocx,
'application/pdf': parsePDF,
}
export async function parseDocument(
buffer: ArrayBuffer,
mimeType: string,
fileName: string,
): Promise<ParsedDocument> {
const parser = PARSERS[mimeType]
if (!parser) {
throw new Error(`Unsupported format: ${mimeType}`)
}
return parser(buffer, fileName)
}分发逻辑只做一件事:按 MIME 类型查表,找不到就报错。 不包含任何格式特定的处理。
typescript
// 在文档处理管道中使用
async function processDocument(docId: string) {
const doc = await getDocument(docId)
const buffer = await storage.get(`documents/${docId}/raw`)
await transitionStatus(docId, 'parsing')
try {
const parsed = await parseDocument(buffer, doc.mimeType, doc.fileName)
validateParsedDocument(parsed, doc.size)
await storage.put(
`documents/${docId}/parsed.json`,
new TextEncoder().encode(JSON.stringify(parsed)),
)
await transitionStatus(docId, 'parsed')
} catch (error) {
await transitionStatus(docId, 'parse_failed', { error: String(error) })
throw error
}
}管道里加 try-catch 和状态转换,解析失败时文档进入 parse_failed 状态,不会静默地把错误数据推进到切分阶段。
5. 纯文本和 Markdown:最确定的两步
5.1 纯文本
typescript
function parsePlainText(buffer: ArrayBuffer): ParsedDocument {
const content = new TextDecoder().decode(buffer)
const normalized = content
.replace(/\r\n/g, '\n')
.replace(/\t/g, ' ')
.replace(/ +/g, ' ')
.trim()
return {
id: '',
title: '',
content: normalized,
metadata: { format: 'text' },
}
}纯文本几乎没有解析难度。价值在于标准化:统一换行符、Tab 转空格、多余空格合并——减少后续切分时的噪音。
5.2 Markdown
Markdown 解析的重点不是去掉格式符号,而是提取标题层级。
typescript
type Section = { level: number; title: string; content: string }
function parseMarkdown(buffer: ArrayBuffer): ParsedDocument {
const md = new TextDecoder().decode(buffer)
// 提取一级标题作为文档标题
const titleMatch = md.match(/^#\s+(.+)$/m)
const title = titleMatch?.[1] ?? 'Untitled'
// 按标题拆章节——章节边界是天然的切分点
const sections: Section[] = []
let current: Section | null = null
for (const line of md.split('\n')) {
const match = line.match(/^(#{1,6})\s+(.+)$/)
if (match) {
if (current) sections.push(current)
current = { level: match[1].length, title: match[2], content: '' }
} else if (current) {
current.content += line + '\n'
}
}
if (current) sections.push(current)
// 纯文本用于 embedding 和全文搜索
const plainText = md
.replace(/```[\s\S]*?```/g, '') // 去掉代码块
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // 链接保留文本
.replace(/!\[[^\]]*\]\([^)]+\)/g, '') // 去掉图片
.trim()
return {
id: '',
title,
content: plainText,
metadata: { format: 'markdown', sections },
}
}保留标题层级的好处:后续可以按章节切分,而不是机械地按字符数切。一篇 5000 字的文档有 5 个二级标题,切成 5 个 chunk 比切成 10 个 500 字的碎块有意义得多。
示例可用 vs 生产可用:这里的正则去格式符号对简单 Markdown 够用,但处理嵌套列表、表格语法、HTML 内嵌时会出错。生产环境建议用
unified(remark)生态的 AST 解析器,先转语法树再提取文本,准确率更高。
6. HTML 解析:从噪声中定位正文
HTML 页面的问题不是没内容,是内容太多——导航栏、侧边栏、页脚、广告、Cookie 提示都在 HTML 里。解析的核心任务是定位正文区域,去掉噪声。
typescript
import { JSDOM } from 'jsdom'
function parseHTML(buffer: ArrayBuffer): ParsedDocument {
const html = new TextDecoder().decode(buffer)
const dom = new JSDOM(html)
const document = dom.window.document
// 标题:<title> 优先,<h1> 兜底
const title =
document.querySelector('title')?.textContent ??
document.querySelector('h1')?.textContent ??
'Untitled'
// 去掉非内容元素
const removeSelectors = [
'script', 'style', 'nav', 'footer',
'header', 'iframe', 'noscript', '.sidebar', '.ad',
]
for (const selector of removeSelectors) {
document.querySelectorAll(selector).forEach((el) => el.remove())
}
// 定位正文:<main> > <article> > [role="main"] > <body>
const mainContent =
document.querySelector('main') ??
document.querySelector('article') ??
document.querySelector('[role="main"]') ??
document.body
// 按块级元素提取文本,保留段落分隔
const textParts: string[] = []
mainContent
.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, pre, td')
.forEach((el) => {
const text = el.textContent?.trim()
if (text) textParts.push(text)
})
return {
id: '',
title,
content: textParts.join('\n\n'),
metadata: {
format: 'html',
url: document.querySelector('link[rel="canonical"]')?.getAttribute('href') ?? '',
},
}
}HTML 解析的四个陷阱:
- 正文定位:
<main>和<article>对标准站点有效;SPA 和客户端渲染页面可能拿不到内容,需要用 SSR 版本或 headless browser(成本高) - 表格:直接提取
<td>会丢行列关系。如果表格是核心数据,应该单独提取并转成 Markdown 表格格式 - 代码块:
<pre><code>里的代码在技术文档中是核心内容,不应去掉。可以加前缀标记[CODE]让切分器识别 - 图片 alt 文本:有些 alt 包含重要信息(如产品图的功能标注),需要判断是否保留
7. Word 解析:ZIP 里的 XML
Word 文档(.docx)本质是一个 ZIP 压缩包,里面是一组 XML 文件。主内容在 word/document.xml,元数据在 docProps/core.xml。
typescript
import JSZip from 'jszip'
async function parseDocx(buffer: ArrayBuffer): Promise<ParsedDocument> {
const zip = await JSZip.loadAsync(buffer)
// 主文档
const documentXml = await zip.file('word/document.xml')?.async('string')
if (!documentXml) throw new Error('Invalid docx: missing document.xml')
const parser = new DOMParser()
const doc = parser.parseFromString(documentXml, 'application/xml')
// 每个 <w:p> 是一个段落,<w:t> 是段落内的文本片段
const paragraphs: string[] = []
doc.querySelectorAll('w\\:p, p').forEach((p) => {
const texts: string[] = []
p.querySelectorAll('w\\:t, t').forEach((t) => {
texts.push(t.textContent ?? '')
})
const text = texts.join('').trim()
if (text) paragraphs.push(text)
})
// 提取文档标题
const coreXml = await zip.file('docProps/core.xml')?.async('string')
let title = 'Untitled'
if (coreXml) {
const coreDoc = parser.parseFromString(coreXml, 'application/xml')
title = coreDoc.querySelector('title')?.textContent ?? 'Untitled'
}
return {
id: '',
title,
content: paragraphs.join('\n\n'),
metadata: { format: 'docx' },
}
}Word 解析的难点:
- 页眉页脚和批注:需要从解析中过滤掉,否则会混入正文
- 嵌入图片和表格:图片在
word/media/目录,需要遍历关系文件提取;表格在 XML 中嵌套在段落里,需要单独处理 - 标题样式识别:Word 的标题不是通过 XML 标签而是通过样式名(如
Heading1)标识的。如果不识别标题样式,文档就变成扁平的段落列表,丢失章节结构
示例可用 vs 生产可用:以上实现能提取基本文本。生产环境的 Word 解析建议用
mammoth库,它能把 Word 文档直接转成 HTML 或 Markdown,保留标题层级、列表、表格和加粗等格式信息,避免手写 XML 遍历。
8. PDF 解析:最值得投入的难点
PDF 解析难在根源:PDF 是渲染格式,不是内容格式。 它存储的是「第 X 坐标、第 Y 坐标用 Npt 字号放字符 C」,不存储「这是一段话」「这是一个表格」。
这导致四个问题:
- 编码不统一——可能是 Unicode 文本,可能是自定义字体编码,可能是纯图片(扫描件)
- 表格和图表——结构复杂,提取困难
- 多栏布局——阅读顺序需要推断
- 字体编码——部分 PDF 使用自定义编码,文本提取可能乱码
8.1 文本型 PDF
用 pdfjs-dist 提取文本。关键是用 Y 坐标判断换行:
typescript
import { getDocument } from 'pdfjs-dist'
async function parseTextPDF(buffer: ArrayBuffer): Promise<ParsedDocument> {
const pdf = await getDocument({ data: buffer }).promise
const pages: string[] = []
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i)
const textContent = await page.getTextContent()
const lines: string[] = []
let currentLine = ''
let lastY: number | null = null
for (const item of textContent.items) {
if ('str' in item) {
const y = item.transform[5]
// Y 坐标变化超过 5px 视为换行
if (lastY !== null && Math.abs(y - lastY) > 5) {
lines.push(currentLine.trim())
currentLine = ''
}
currentLine += item.str
lastY = y
}
}
if (currentLine.trim()) lines.push(currentLine.trim())
pages.push(lines.join('\n'))
}
return {
id: '',
title: '',
content: pages.join('\n\n---\n\n'),
metadata: { format: 'pdf', pageCount: pdf.numPages },
}
}坐标行检测对文本型 PDF 能覆盖 80% 的场景。真正的工程复杂度在阈值参数调优和边界情况处理:连字符断行、脚注、页眉页脚、多栏阅读顺序。
8.2 扫描件 PDF:需要 OCR
扫描件里的「文字」是图片,需要 OCR 识别:
typescript
import Tesseract from 'tesseract.js'
async function parseScannedPDF(buffer: ArrayBuffer): Promise<ParsedDocument> {
const pageImages = await convertPDFToImages(buffer)
const pages: string[] = []
for (const image of pageImages) {
const result = await Tesseract.recognize(image, 'chi_sim+eng')
pages.push(result.data.text)
}
return {
id: '',
title: '',
content: pages.join('\n\n---\n\n'),
metadata: { format: 'pdf', scanned: true, pageCount: pageImages.length },
}
}OCR 的三个硬约束:
- 速度慢——每页 2-5 秒,100 页文档要 3-8 分钟
- 准确率不稳——手写体、复杂排版、模糊扫描件错误率可达 20%+
- 成本——云 OCR 按页计费,大规模使用时成本显著
判断阈值:如果你的文档中扫描件占比 < 10%,可以接受 OCR 的局限并做后处理;如果 > 30%,建议直接用 API 服务而不是自己维护 OCR 管道。
8.3 表格提取:PDF 最难的部分
PDF 里不存在「表格」这个概念——只有一堆在视觉上对齐的文本块。表格提取的本质是根据坐标把文本重新组织成行列结构:
typescript
function extractTable(
tableItems: Array<{ str: string; transform: number[] }>,
): string {
const rows = new Map<number, Map<number, string>>()
for (const item of tableItems) {
// 按 Y 坐标分行(20px 行高阈值),按 X 坐标分列(100px 列宽阈值)
const row = Math.round(item.transform[5] / 20)
const col = Math.round(item.transform[4] / 100)
if (!rows.has(row)) rows.set(row, new Map())
rows.get(row)!.set(col, (rows.get(row)!.get(col) ?? '') + item.str)
}
// 转成 Markdown 表格
const sortedRows = [...rows.entries()].sort(([a], [b]) => a - b)
return sortedRows
.map(([, cells]) => {
const sortedCells = [...cells.entries()].sort(([a], [b]) => a - b)
return '| ' + sortedCells.map(([, text]) => text).join(' | ') + ' |'
})
.join('\n')
}这个坐标聚类方案能用,但阈值依赖具体 PDF 的排版参数,换一个文档可能就错行。生产环境的 PDF 表格提取,建议用专业工具:
- camelot(Python)——开源方案中效果最好,支持流式和 lattice 两种模式
- Adobe PDF Services API——商用,质量最高,表格和表单提取准确率高
- Unstructured.io——开源,覆盖 PDF/Word/HTML 多种格式,有 Docker 部署方案
9. API 服务:省心但有代价
如果不想自己维护多种格式的解析器,可以用 API 服务把格式适配的复杂性交给第三方。
9.1 Unstructured.io
开源,支持 PDF、Word、HTML、Markdown 等格式,返回带类型标签的结构化元素:
typescript
async function parseWithUnstructured(file: File): Promise<ParsedDocument> {
const result = await partition({
files: [{ data: await file.arrayBuffer(), fileName: file.name }],
strategy: 'hi_res',
})
const sections = result.elements.map((el) => ({
type: el.type, // Title, NarrativeText, Table 等
text: el.text,
metadata: el.metadata,
}))
return {
id: '',
title: sections.find((s) => s.type === 'Title')?.text ?? '',
content: sections.map((s) => s.text).join('\n\n'),
metadata: { format: file.name.split('.').pop() ?? 'unknown', sections },
}
}Unstructured.io 可以 Docker 自部署,数据不出内网——对数据安全要求高的团队这是决定性优势。
9.2 LlamaParse
LlamaIndex 提供的解析服务,对 RAG 场景做了优化。异步任务模型,上传后轮询结果:
typescript
async function parseWithLlamaParse(file: File): Promise<ParsedDocument> {
const formData = new FormData()
formData.append('file', file)
// 上传文件
const uploadRes = await fetch('https://api.cloud.llamaindex.ai/api/parsing/upload', {
method: 'POST',
headers: { Authorization: `Bearer ${LLAMA_PARSE_KEY}` },
body: formData,
})
const { id } = await uploadRes.json()
// 轮询直到完成
let result: { status: string; error?: string }
while (true) {
const statusRes = await fetch(
`https://api.cloud.llamaindex.ai/api/parsing/job/${id}`,
{ headers: { Authorization: `Bearer ${LLAMA_PARSE_KEY}` } },
)
result = await statusRes.json()
if (result.status === 'SUCCESS') break
if (result.status === 'ERROR') throw new Error(result.error)
await new Promise((r) => setTimeout(r, 2000))
}
// 获取 Markdown 结果
const contentRes = await fetch(
`https://api.cloud.llamaindex.ai/api/parsing/job/${id}/result/markdown`,
{ headers: { Authorization: `Bearer ${LLAMA_PARSE_KEY}` } },
)
const content = await contentRes.text()
return {
id: '',
title: '',
content,
metadata: { format: 'llama-parse' },
}
}API 服务的代价:
- 按页/按量计费——1000 页 PDF 可能花费 $10-$50
- 延迟高——网络传输 + 排队 + 解析,单次请求可能 5-30 秒
- 数据安全——文档传到第三方服务器,合规要求高的场景需要评估
什么时候值得用:
| 场景 | 建议 |
|---|---|
| 文档量 < 100 页/月,格式 < 3 种 | 自己实现,用开源库 |
| 文档量 > 1000 页/月,格式 > 5 种 | API 服务更划算 |
| 文档涉及敏感数据,不能出内网 | 自部署 Unstructured.io |
| 扫描件占比 > 30% | API 服务的 OCR 质量更好 |
10. 解析质量自检:三道防线
解析完不验证就直接推进到切分,等于把错误放大。三道防线成本极低但能拦住大部分严重问题。
typescript
function validateParsedDocument(
parsed: ParsedDocument,
originalSize: number,
): void {
// 第一道:内容不能为空
if (parsed.content.trim().length === 0) {
throw new Error('Parse failed: extracted content is empty')
}
// 第二道:内容长度应该合理
// 解析后的纯文本通常小于原始文件大小(去掉了格式标记),
// 但如果不到原文件的 5%,大概率解析出了问题
const contentBytes = new TextEncoder().encode(parsed.content).length
if (originalSize > 10000 && contentBytes < originalSize * 0.05) {
throw new Error(
`Parse quality warning: content (${contentBytes} bytes) ` +
`is less than 5% of original file (${originalSize} bytes)`,
)
}
// 第三道:非打印字符比例不应过高
const printableChars = parsed.content.match(/[\x20-\x7E\n\r\t一-鿿]/g)
const printableRatio = printableChars
? printableChars.length / parsed.content.length
: 0
if (printableRatio < 0.8) {
throw new Error(
`Parse quality warning: ${(
(1 - printableRatio) *
100
).toFixed(1)}% non-printable characters detected`,
)
}
}三道防线各拦什么:
- 空内容:解析器完全不认识这个文件格式,或者文件本身就是空的
- 长度异常:解析器只提取了部分内容,或者编码错误导致大量内容丢失
- 乱码比例:字体编码不匹配(PDF 常见问题),或者把二进制数据当文本处理了
验证必须在管道内执行,不是管道外的人工检查。验证失败时直接标记文档为
parse_failed,让上传者立即看到错误,而不是等几天后发现检索结果不对才回头排查。
11. 生产化前需要补的几件事
本文的代码示例覆盖了核心逻辑,但从「能跑」到「生产可用」还有几个工程决策要做:
错误恢复:解析失败时应该重试还是直接标记失败?对于网络原因导致的失败(如 API 服务超时),重试有意义;对于格式不兼容导致的失败,重试没有意义。区分可重试和不可重试错误。
编码检测:纯文本解析假设 UTF-8,但用户上传的文件可能是 GBK、Shift_JIS 等编码。生产环境需要编码检测(如 chardet 或 iconv-lite),检测失败时回退到 UTF-8 并记录警告。
超时控制:PDF 解析和 OCR 可能很慢。大文件解析需要设置超时(如单文件 60 秒),超时后标记为失败并提示用户拆分文件。
资源隔离:解析是 CPU 密集型操作(特别是 PDF 和 OCR),不能阻塞主服务。生产环境应该用消息队列 + worker 进程,解析任务和 API 服务分开部署。
解析器版本管理:当解析器逻辑更新时,已解析的文档是否需要重新解析?建议给解析器加版本号,存在 ParsedDocument.metadata.parserVersion 中,方便追溯和批量重解析。
文档解析是 RAG 管道的第一步,但它的质量决定了后续所有步骤的上限。统一接口 + 注册表模式处理格式分发,每种格式用最合适的解析策略:纯文本和 Markdown 最简单,HTML 需要定位正文区域,Word 需要识别样式,PDF 需要区分文本型和扫描型。解析后跑过三道质量验证——非空、长度合理、乱码可控——才推进到下一步。格式复杂度高、工程资源紧时,API 服务是务实选择,但要为成本、延迟和数据安全做好准备。
下一步是文本切分(Chunking)——把解析后的长文本切成适合检索的小段。切分策略直接受解析质量影响:如果解析阶段保留了标题层级,按章节切分的效果会远好于固定字符数切分。