Skip to content

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」,不存储「这是一段话」「这是一个表格」。

这导致四个问题:

  1. 编码不统一——可能是 Unicode 文本,可能是自定义字体编码,可能是纯图片(扫描件)
  2. 表格和图表——结构复杂,提取困难
  3. 多栏布局——阅读顺序需要推断
  4. 字体编码——部分 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 等编码。生产环境需要编码检测(如 chardeticonv-lite),检测失败时回退到 UTF-8 并记录警告。

超时控制:PDF 解析和 OCR 可能很慢。大文件解析需要设置超时(如单文件 60 秒),超时后标记为失败并提示用户拆分文件。

资源隔离:解析是 CPU 密集型操作(特别是 PDF 和 OCR),不能阻塞主服务。生产环境应该用消息队列 + worker 进程,解析任务和 API 服务分开部署。

解析器版本管理:当解析器逻辑更新时,已解析的文档是否需要重新解析?建议给解析器加版本号,存在 ParsedDocument.metadata.parserVersion 中,方便追溯和批量重解析。

文档解析是 RAG 管道的第一步,但它的质量决定了后续所有步骤的上限。统一接口 + 注册表模式处理格式分发,每种格式用最合适的解析策略:纯文本和 Markdown 最简单,HTML 需要定位正文区域,Word 需要识别样式,PDF 需要区分文本型和扫描型。解析后跑过三道质量验证——非空、长度合理、乱码可控——才推进到下一步。格式复杂度高、工程资源紧时,API 服务是务实选择,但要为成本、延迟和数据安全做好准备。

下一步是文本切分(Chunking)——把解析后的长文本切成适合检索的小段。切分策略直接受解析质量影响:如果解析阶段保留了标题层级,按章节切分的效果会远好于固定字符数切分。

基于 MIT 协议开源