14.04-文档解析
要点
- 文档解析是 RAG 管道的第二步——把各种格式(PDF、Word、HTML、Markdown)转成纯文本
- 不同格式的解析难度差异很大:纯文本最简单,HTML 要去标签,PDF 最复杂
- PDF 解析是业界痛点:扫描件需要 OCR,表格和图表的提取尤其困难
- 解析不只是提取文本——保留结构信息(标题层级、段落、列表)对后续切分很重要
- 主流方案:自研解析器、开源库、API 服务——按复杂度和成本选择
- 解析质量直接影响后续的切分和检索效果
1. 为什么解析很重要
RAG 的效果链条:
文档解析 → 文本切分 → 向量化 → 检索 → 生成
↑
如果这一步出错,后面全错
如果解析阶段丢掉了关键信息(表格数据、标题结构),后续的切分和检索都不可能找回来。
常见的问题:
- PDF 解析后表格数据丢失
- HTML 解析后把代码块也当正文处理了
- Word 解析后段落顺序乱了
- 图片里的文字被完全忽略
解析质量是 RAG 质量的地基。
2. 各格式的解析难度
| 格式 | 难度 | 主要挑战 |
|---|---|---|
| 纯文本 (.txt) | 低 | 没有结构,直接读取 |
| Markdown (.md) | 低 | 保留标题层级,去掉格式符号 |
| HTML (.html) | 中 | 去标签保留内容,处理 script/style |
| Word (.docx) | 中 | XML 解析,保留样式和结构 |
| PDF (.pdf) | 高 | 多种编码方式,表格提取困难 |
| 扫描件 PDF | 极高 | 需要 OCR,错误率较高 |
3. 纯文本和 Markdown
最简单的两种格式。
纯文本
// 直接读取,不需要特殊处理
const content = await file.text()
// 可选:标准化空白字符
const normalized = content
.replace(/\r\n/g, '\n') // 统一换行符
.replace(/\t/g, ' ') // Tab 转空格
.replace(/ +/g, ' ') // 多个空格合并
.trim()Markdown
Markdown 需要保留结构信息(标题层级对切分很有用)。
import { marked } from 'marked'
// 方案一:用 marked 转成纯文本
function parseMarkdown(md: string): ParsedDocument {
// 提取标题
const titleMatch = md.match(/^#\s+(.+)$/m)
const title = titleMatch?.[1] ?? 'Untitled'
// 提取章节结构(用于后续按章节切分)
const sections: Section[] = []
let currentSection: Section | null = null
for (const line of md.split('\n')) {
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/)
if (headingMatch) {
if (currentSection) sections.push(currentSection)
currentSection = {
level: headingMatch[1].length,
title: headingMatch[2],
content: '',
}
} else if (currentSection) {
currentSection.content += line + '\n'
}
}
if (currentSection) sections.push(currentSection)
// 去掉 Markdown 格式符号,保留纯文本
const plainText = md
.replace(/```[\s\S]*?```/g, '') // 去掉代码块(或保留为代码文本)
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // 链接保留文本
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '') // 去掉图片
.replace(/[*_~`]/g, '') // 去掉强调符号
.trim()
return {
id: '',
title,
content: plainText,
metadata: { sections, format: 'markdown' },
}
}保留标题层级的好处:后续可以按章节切分,而不是机械地按字符数切。
4. HTML 解析
HTML 解析的核心是:去掉 script、style、标签,保留正文内容。
import { JSDOM } from 'jsdom'
function parseHTML(html: string): ParsedDocument {
const dom = new JSDOM(html)
const document = dom.window.document
// 提取标题
const title = document.querySelector('title')?.textContent
?? document.querySelector('h1')?.textContent
?? 'Untitled'
// 去掉不需要的元素
const removeSelectors = ['script', 'style', 'nav', 'footer', 'header', 'iframe', 'noscript']
for (const selector of removeSelectors) {
document.querySelectorAll(selector).forEach((el) => el.remove())
}
// 提取正文区域(如果有的话)
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 解析的陷阱:
- 代码块:
<pre><code>里的代码应该保留还是去掉?看场景。技术文档应该保留 - 表格:HTML 表格的数据结构很重要,直接转纯文本会丢失行列关系
- 图片 alt 文本:有时候 alt 里包含重要信息
- 嵌套结构:列表、引用块、表格里的表格
5. Word (.docx) 解析
Word 文档本质是 ZIP 压缩的 XML 集合。
import JSZip from 'jszip'
async function parseDocx(buffer: ArrayBuffer): Promise<ParsedDocument> {
const zip = await JSZip.loadAsync(buffer)
// 主文档内容在 word/document.xml
const documentXml = await zip.file('word/document.xml')?.async('string')
if (!documentXml) throw new Error('Invalid docx file')
// 解析 XML,提取文本
const parser = new DOMParser()
const doc = parser.parseFromString(documentXml, 'application/xml')
// 每个 <w:p> 是一个段落
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 paragraphText = texts.join('').trim()
if (paragraphText) paragraphs.push(paragraphText)
})
// 提取标题(从 core.xml)
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 解析的挑战:
- 样式信息:标题样式、加粗、斜体——这些信息对结构识别有用,但纯文本处理用不到
- 嵌入对象:图片、表格、公式——需要单独处理
- 页眉页脚:通常不需要,但要识别并跳过
- 批注和修订:需要决定是否保留
6. PDF 解析:最难的部分
PDF 是 RAG 解析中最棘手的格式。原因:
- PDF 是「显示格式」,不是「内容格式」——它记录的是「第 N 行第 M 列放一个字符」,不是「这是一段话」
- 编码多样——可能是文本型 PDF,也可能是扫描件(图片)
- 表格和图表——结构复杂,提取困难
- 多栏布局——阅读顺序需要推断
文本型 PDF
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 坐标
if (lastY !== null && Math.abs(y - lastY) > 5) {
// Y 坐标变化超过阈值,说明换行了
lines.push(currentLine.trim())
currentLine = ''
}
currentLine += item.str
lastY = y
}
}
if (currentLine) 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(需要 OCR)
扫描件里的「文字」其实是图片,需要 OCR 识别。
import Tesseract from 'tesseract.js'
async function parseScannedPDF(buffer: ArrayBuffer): Promise<ParsedDocument> {
// 先把 PDF 转成图片(每页一张)
const pageImages = await convertPDFToImages(buffer)
const pages: string[] = []
for (const image of pageImages) {
// OCR 识别
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 的问题:
- 速度慢——每页可能要几秒
- 准确率——手写体、复杂排版、模糊扫描错误率高
- 成本——用云 OCR 服务按页计费
表格提取
PDF 表格是最难处理的部分。简单方案:
// 用 pdf-parse 或 camelot-py(Python)提取表格
// 这里示意:检测到表格区域时,转成 CSV 或 Markdown 表格
function extractTable(tableItems: TextItem[]): string {
// 按行列组织
const rows: Map<number, Map<number, string>> = new Map()
for (const item of tableItems) {
const row = Math.round(item.transform[5] / 20) // 按 Y 坐标分行
const col = Math.round(item.transform[4] / 100) // 按 X 坐标分列
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 表格提取建议用专门的工具:
- camelot(Python)——开源,效果好
- Adobe PDF Services API——商用,质量最高
- Unstructured.io——开源,覆盖多种格式
7. 用 API 服务简化解析
如果不想自己处理各种格式的复杂性,可以用现成的 API 服务。
方案一:Unstructured.io
import { partition } from 'unstructured-client'
async function parseWithUnstructured(file: File): Promise<ParsedDocument> {
const result = await partition({
files: [{ data: await file.arrayBuffer(), fileName: file.name }],
strategy: 'hi_res', // 高精度模式
})
// result 是一个元素列表,每个元素有类型(Title, NarrativeText, Table 等)
const sections = result.elements.map((el) => ({
type: el.type,
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: detectFormat(file.name), sections },
}
}方案二:LlamaParse
LlamaParse 是 LlamaIndex 提供的文档解析服务,对 RAG 场景优化。
async function parseWithLlamaParse(file: File): Promise<ParsedDocument> {
const formData = new FormData()
formData.append('file', file)
const response = await fetch('https://api.cloud.llamaindex.ai/api/parsing/upload', {
method: 'POST',
headers: { Authorization: `Bearer ${LLAMA_PARSE_KEY}` },
body: formData,
})
const { id } = await response.json()
// 轮询解析状态
let result
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))
}
// 获取解析结果
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 服务的好处是省心,坏处是成本(按页或按 MB 计费)和数据安全(文档要传到第三方)。
8. 统一的解析接口
把各种格式的解析逻辑统一到一个接口:
// src/services/rag/parsers/index.ts
export type ParsedDocument = {
id: string
title: string
content: string
metadata: {
format: string
pageCount?: number
sections?: Array<{ type: string; title: string; content: string }>
[key: string]: unknown
}
}
type Parser = (buffer: ArrayBuffer, fileName: string) => Promise<ParsedDocument>
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}`)
}
const parsed = await parser(buffer, fileName)
parsed.id = ''
return parsed
}
// 在文档处理管道中使用
async function processDocument(docId: string) {
const doc = await getDocument(docId)
const buffer = await storage.get(`documents/${docId}/raw`)
await transitionStatus(docId, 'parsing')
const parsed = await parseDocument(buffer, doc.mimeType, doc.fileName)
await storage.put(
`documents/${docId}/parsed.json`,
new TextEncoder().encode(JSON.stringify(parsed))
)
// 继续下一步:切分
await transitionStatus(docId, 'chunking')
// ...
}9. 解析质量的自检
解析完之后,可以做几个简单的自检:
function validateParsedDocument(parsed: ParsedDocument, original: File): void {
// 1. 内容不能为空
if (parsed.content.trim().length === 0) {
throw new Error('解析结果为空')
}
// 2. 内容长度应该合理(至少是原文件大小的 10%)
const originalSize = original.size
const contentSize = new TextEncoder().encode(parsed.content).length
if (contentSize < originalSize * 0.1 && originalSize > 10000) {
console.warn(`解析后的内容长度 (${contentSize}) 远小于原文件 (${originalSize})`)
}
// 3. 不应该包含太多乱码
const nonPrintableRatio = (parsed.content.match(/[^\x20-\x7E\n\r\t]/g)?.length ?? 0) / parsed.content.length
if (nonPrintableRatio > 0.1) {
console.warn(`解析结果包含 ${(nonPrintableRatio * 100).toFixed(1)}% 的非打印字符`)
}
}总结
回顾这一节的要点:
- 文档解析质量直接影响 RAG 整体效果
- 各格式解析难度差异大:纯文本简单,PDF 最难
- PDF 要区分文本型和扫描件,扫描件需要 OCR
- 保留结构信息(标题层级、段落)对后续切分很重要
- 可以用 API 服务(Unstructured.io、LlamaParse)简化解析,但有成本和数据安全考虑
- 统一解析接口 + 解析质量自检
下一篇讲文本切分(Chunking)——怎么把长文档切成合适的小段。