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 解析中最棘手的格式。原因:

  1. PDF 是「显示格式」,不是「内容格式」——它记录的是「第 N 行第 M 列放一个字符」,不是「这是一段话」
  2. 编码多样——可能是文本型 PDF,也可能是扫描件(图片)
  3. 表格和图表——结构复杂,提取困难
  4. 多栏布局——阅读顺序需要推断

文本型 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)——怎么把长文档切成合适的小段。