05-PDF-Word-Excel接入RAG知识库

PDF、Word、Excel 如何接入 RAG 知识库

系列:从零构建企业 RAG 知识库(第 5 篇)

1. 文件接入不是"读取字符串"

企业文件包含标题、页码、表格、合并单元格、扫描图片和权限信息。解析阶段如果丢失结构,后续再强的向量模型也无法恢复原始关系。

统一加载结果至少应保留:

  • 文档 ID、租户和版本;
  • 文件名、媒体类型和哈希;
  • 页码、段落、工作表、行列等定位;
  • 原始文本和结构类型;
  • 解析器名称、版本和警告。

2. 统一解析契约

python 复制代码
from dataclasses import dataclass, field
from pathlib import Path


@dataclass(frozen=True)
class TextBlock:
    text: str
    block_type: str
    location: dict[str, str | int]
    metadata: dict[str, str] = field(default_factory=dict)


@dataclass(frozen=True)
class ParsedDocument:
    source_name: str
    media_type: str
    blocks: tuple[TextBlock, ...]
    warnings: tuple[str, ...] = ()


def validate_source(path: Path, max_bytes: int = 30 * 1024 * 1024) -> None:
    if not path.is_file():
        raise FileNotFoundError(path)
    if path.stat().st_size > max_bytes:
        raise ValueError("文件超过允许大小")
    if path.suffix.lower() not in {".pdf", ".docx", ".xlsx"}:
        raise ValueError("不支持的文件类型")

扩展名只能用于路由,生产还要验证真实文件签名和媒体类型。

3. PDF:保留页码

python 复制代码
from pypdf import PdfReader


def parse_pdf(path: Path) -> ParsedDocument:
    validate_source(path)
    reader = PdfReader(str(path))
    blocks: list[TextBlock] = []
    warnings: list[str] = []

    if reader.is_encrypted:
        raise ValueError("加密 PDF 需要经过授权的解密流程")

    for page_number, page in enumerate(reader.pages, start=1):
        text = (page.extract_text() or "").strip()
        if not text:
            warnings.append(f"第 {page_number} 页没有可提取文本,可能需要 OCR")
            continue
        blocks.append(
            TextBlock(
                text=text,
                block_type="page_text",
                location={"page": page_number},
            )
        )
    return ParsedDocument(path.name, "application/pdf", tuple(blocks), tuple(warnings))

pypdf 对文本型 PDF 有效,但扫描 PDF 需要 OCR。多栏排版、页眉页脚和表格仍需专项评测。

4. Word:保留标题和表格

python 复制代码
from docx import Document as WordDocument


def parse_docx(path: Path) -> ParsedDocument:
    validate_source(path)
    document = WordDocument(str(path))
    blocks: list[TextBlock] = []

    for index, paragraph in enumerate(document.paragraphs):
        text = paragraph.text.strip()
        if not text:
            continue
        style = paragraph.style.name if paragraph.style else "unknown"
        block_type = "heading" if style.lower().startswith("heading") else "paragraph"
        blocks.append(
            TextBlock(
                text=text,
                block_type=block_type,
                location={"paragraph": index},
                metadata={"style": style},
            )
        )

    for table_index, table in enumerate(document.tables):
        for row_index, row in enumerate(table.rows):
            values = [cell.text.strip() for cell in row.cells]
            blocks.append(
                TextBlock(
                    text=" | ".join(values),
                    block_type="table_row",
                    location={"table": table_index, "row": row_index},
                )
            )
    return ParsedDocument(
        path.name,
        "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
        tuple(blocks),
    )

标题层级应进入 Chunk 元数据。复杂合并单元格、文本框、批注和修订内容要按业务需求单独处理。

5. Excel:不要把整张表拼成一段

python 复制代码
from openpyxl import load_workbook


def parse_xlsx(path: Path, max_rows_per_sheet: int = 100_000) -> ParsedDocument:
    validate_source(path)
    # read_only 降低大文件内存占用,data_only 读取公式缓存值
    workbook = load_workbook(
        filename=path,
        read_only=True,
        data_only=True,
    )
    blocks: list[TextBlock] = []

    for sheet in workbook.worksheets:
        if sheet.max_row > max_rows_per_sheet:
            raise ValueError(f"工作表 {sheet.title} 行数超过限制")

        rows = sheet.iter_rows(values_only=True)
        headers = next(rows, None)
        if headers is None:
            continue
        header_names = [
            str(value).strip() if value is not None else f"column_{index}"
            for index, value in enumerate(headers, start=1)
        ]

        for row_number, values in enumerate(rows, start=2):
            pairs = [
                f"{header}: {value}"
                for header, value in zip(header_names, values)
                if value is not None and str(value).strip()
            ]
            if pairs:
                blocks.append(
                    TextBlock(
                        text="; ".join(pairs),
                        block_type="spreadsheet_row",
                        location={"sheet": sheet.title, "row": row_number},
                    )
                )
    workbook.close()
    return ParsedDocument(
        path.name,
        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        tuple(blocks),
    )

data_only=True 读取的是文件中保存的公式缓存值,并不会在 Python 中重新计算公式。

6. 解析器路由与规范化

python 复制代码
from collections.abc import Callable


PARSERS: dict[str, Callable[[Path], ParsedDocument]] = {
    ".pdf": parse_pdf,
    ".docx": parse_docx,
    ".xlsx": parse_xlsx,
}


def normalize_text(text: str) -> str:
    """统一空白,但不删除有业务意义的标点。"""
    return " ".join(text.replace("\u00a0", " ").split())


def load_document(path: Path) -> ParsedDocument:
    parser = PARSERS.get(path.suffix.lower())
    if parser is None:
        raise ValueError("没有对应解析器")
    parsed = parser(path)
    cleaned_blocks = tuple(
        TextBlock(
            text=normalize_text(block.text),
            block_type=block.block_type,
            location=block.location,
            metadata=block.metadata,
        )
        for block in parsed.blocks
        if normalize_text(block.text)
    )
    return ParsedDocument(
        parsed.source_name,
        parsed.media_type,
        cleaned_blocks,
        parsed.warnings,
    )

7. 不依赖外部文件的契约测试

python 复制代码
def test_normalization_keeps_punctuation() -> None:
    assert normalize_text("退款:  7 天。\n需要审核。") == "退款: 7 天。 需要审核。"


def test_unknown_extension_is_rejected() -> None:
    try:
        load_document(Path("malware.exe"))
    except ValueError:
        pass
    else:
        raise AssertionError("未知文件类型必须拒绝")

要验证真实解析效果,应准备无敏感信息的 PDF、DOCX、XLSX 固定样本,测试页码、标题、表格、公式、空页和扫描页。本篇没有读取真实企业文件,不能声称完成端到端解析验证。

8. 对抗性审查

  • 上传目录使用随机服务端文件名,防止路径穿越;
  • 限制压缩包展开大小,DOCX/XLSX 本质上是 ZIP 容器;
  • 不执行宏、嵌入对象、外部链接和公式;
  • OCR 结果可能错字,需保留置信度和原页定位;
  • 文档权限必须传递到每一个 Chunk;
  • 解析器运行在低权限、资源受限环境;
  • 文件删除时同步清理原文件、解析结果、向量和缓存。

9. 总结

文件接入的核心是把不同格式转换成统一、可定位、带权限的 TextBlock。解析质量决定了 RAG 的上限,不能把结构丢失归咎于向量模型。

相关推荐
zzzll11111 小时前
RAG(检索增强生成)技术详解:从原理到实践
java·人工智能
Python私教1 小时前
Django 做一个 AI 销售助手:读取客户记录,自动生成跟进计划
人工智能·python·django
leikooo1 小时前
ARTS 0802: 合并有序链表、AI 时代的技术断层与 TCP 200ms 延迟之谜
人工智能·tcp/ip·链表
动物园猫1 小时前
塑料瓶目标检测数据集:3,000张图像 | 目标检测
人工智能·目标检测·计算机视觉
zzzzzz3101 小时前
用 TRAE Work 拆解一次“老订单系统加退款状态机”的紧急方案
人工智能·程序员·workflow
LitchiCheng1 小时前
DGX Spark 进行 Comfyui 文生图,5秒一张图
人工智能·python
只是甲10 小时前
Text2SQL 系列博客 02:技术原理深度剖析 - 从自然语言到 SQL 的完整链路
人工智能·text2sql·nl2sql·ai agent·自助数据分析·data agent·agentic 数据洞察
懷淰メ10 小时前
【AI赋能】基于PyQt+YOLO+DeepSeek水上漂浮物检测系统(详细介绍)
人工智能·yolo·目标检测·计算机视觉·pyqt·漂浮物·水上漂浮物
EQUINOX110 小时前
【论文精读】| CLIP精读
大数据·人工智能