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 的上限,不能把结构丢失归咎于向量模型。