18 文档解析
文档解析
文档解析的核心是把 PDF、Word、Markdown、TXT 等不同格式统一转换成可处理的文本,同时尽量保留页码、标题、来源等元数据。
[TOC]
1. 学完本节要达到什么程度
前面已经完成上传。
上传后,我们有:
documentId
originalName
storagePath
contentType
fileSize
status=UPLOADED
本节要做的是:
读取原始文件
-> 根据文件类型选择解析器
-> 抽取文本
-> 保留元数据
-> 写入 rag_document_text -> 更新文档状态为 PARSED
先不要清洗。
先把“能解析出来”做好。
2. 解析和清洗的区别
解析是把文件格式转成文本。
比如:
PDF -> 文本
DOCX -> 文本
Markdown -> 文本
TXT -> 文本
清洗是处理文本质量。
比如:
去空行
去页眉页脚
修复换行
保留标题层级
删除重复页码
两者不要混在一起。
本节做解析。
下一节做清洗。
3. 支持格式
第一版支持:
.pdf
.docx
.md
.txt
每种格式的特点:
PDF:可能有页码,可能有复杂排版
DOCX:段落结构较清楚
Markdown:标题结构清楚
TXT:最简单,但编码可能有问题
扫描版 PDF 暂时不支持。
扫描版需要 OCR,复杂度会高很多。
4. 解析结果结构
建议统一成这样的结构:
from pydantic import BaseModel
class ParsedDocument(BaseModel):
document_id: str text: str metadata: dict```
metadata 可以放:
```text
parser
source_path
file_type
page_count
encoding
title
如果能按页解析,可以保留页面信息:
class ParsedPage(BaseModel):
page_number: int text: str metadata: dict```
第一版可以先存全文。
后面切片时再进一步保留页码。
## 5. Python 解析服务
文档解析更适合放 Python。
原因是 Python 生态里解析库更多:
```text
pypdf
pymupdf
python-docx
docx2txt
markdown
langchain-community loaders
unstructured
在本路线里,可以由 Java 上传文件,Python 负责解析:
Java 上传并保存文件
-> Java 调 Python parse 接口
-> Python 读取 storagePath -> Python 返回解析文本
-> Java 写入数据库
Java 调 Python 解析时序图:
sequenceDiagram
participant J as Spring Boot participant DB as PostgreSQL participant P as FastAPI 解析服务
participant FS as 文件存储
J->>DB: 读取 documentId 对应的 storagePath J->>P: POST /api/parse,传 documentId 和 storagePath P->>FS: 读取原始文件
P->>P: 按文件类型选择解析器
P-->>J: 返回 text 和 metadata J->>DB: 写入 rag_document_text J->>DB: 更新文档状态为 PARSED
也可以 Python 直接写数据库。
学习阶段建议先让 Java 控制入库,Python 只做解析能力。
6. FastAPI 解析接口
Python 接口:
from pathlib import Path
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI(title="RAG Parser Service")
class ParseRequest(BaseModel):
document_id: str = Field(min_length=1) storage_path: str = Field(min_length=1) file_type: str = Field(min_length=1)
class ParseResponse(BaseModel):
document_id: str text: str metadata: dict
@app.post("/api/rag/parse", response_model=ParseResponse)
def parse_document(request: ParseRequest):
path = Path(request.storage_path) if not path.exists(): raise HTTPException(status_code=404, detail="文件不存在")
text, metadata = parse_by_type(path, request.file_type)
return ParseResponse( document_id=request.document_id, text=text, metadata=metadata, )```
解析函数:
```python
def parse_by_type(path: Path, file_type: str) -> tuple[str, dict]:
normalized = file_type.lower().lstrip(".")
if normalized == "pdf": return parse_pdf(path) if normalized == "docx": return parse_docx(path) if normalized == "md": return parse_markdown(path) if normalized == "txt": return parse_txt(path)
raise HTTPException(status_code=400, detail=f"不支持的文件类型:{file_type}")
7. 解析 TXT
TXT 最简单。
但要注意编码。
def parse_txt(path: Path) -> tuple[str, dict]:
for encoding in ("utf-8", "utf-8-sig", "gb18030"): try: text = path.read_text(encoding=encoding) return text, { "parser": "plain_text", "encoding": encoding, "source_path": str(path), } except UnicodeDecodeError: continue
raise HTTPException(status_code=400, detail="无法识别 TXT 文件编码")
为什么尝试 gb18030?
因为中文 Windows 环境下有些文本不是 UTF-8。
8. 解析 Markdown
Markdown 可以先按纯文本读取。
def parse_markdown(path: Path) -> tuple[str, dict]:
text = path.read_text(encoding="utf-8") title = ""
for line in text.splitlines(): if line.startswith("# "): title = line.removeprefix("# ").strip() break
return text, { "parser": "markdown_text", "title": title, "source_path": str(path), }```
Markdown 的标题结构很重要。
后面可以按标题切片。
所以解析阶段不要把 `#`、`##` 全删掉。
## 9. 解析 DOCX
可以先用 `python-docx`。
安装:
```bash
python -m pip install python-docx```
代码:
```python
from docx import Document
def parse_docx(path: Path) -> tuple[str, dict]:
document = Document(path) paragraphs = []
for paragraph in document.paragraphs: text = paragraph.text.strip() if text: paragraphs.append(text)
return "\n\n".join(paragraphs), { "parser": "python-docx", "paragraph_count": len(paragraphs), "source_path": str(path), }```
第一版先处理段落。
表格可以后面再补。
如果文档里很多表格,解析质量会受影响。
## 10. 解析 PDF
PDF 可以先用 PyPDF。
安装:
```bash
python -m pip install pypdf```
代码:
```python
from pypdf import PdfReader
def parse_pdf(path: Path) -> tuple[str, dict]:
reader = PdfReader(str(path)) pages = []
for index, page in enumerate(reader.pages, start=1): text = page.extract_text() or "" if text.strip(): pages.append(f"\n\n[PAGE {index}]\n{text.strip()}")
return "\n".join(pages), { "parser": "pypdf", "page_count": len(reader.pages), "source_path": str(path), }```
这里保留 `[PAGE 1]`。
后面切片时可以根据它推断页码。
PDF 解析常见问题:
```text
换行很乱
表格顺序错
页眉页脚重复
扫描版没有文本
这些是正常的。
后续再处理一部分。
11. 使用 LangChain Loader
如果想和 LangChain RAG 流程衔接,也可以使用 Document Loader。
示例:
from langchain_community.document_loaders import PyPDFLoader
def parse_pdf_with_loader(path: Path):
loader = PyPDFLoader(str(path)) docs = loader.load()
text = "\n\n".join(doc.page_content for doc in docs) metadata = { "parser": "PyPDFLoader", "page_count": len(docs), "source_path": str(path), }
return text, metadata```
LangChain 的 Document 通常包含:
```text
page_content
metadata
这和 RAG 后续流程很匹配。
但学习阶段不要被 Loader 种类绕进去。
先保证自己的解析接口稳定。
12. Java 调 Python 解析
Java 可以在上传后或手动接口里调用 Python:
public record ParseRequest(
String documentId, String storagePath, String fileType) {
}
public record ParseResponse(
String documentId, String text, Map<String, Object> metadata) {
}
调用:
ParseResponse response = restClient.post()
.uri("/api/rag/parse") .body(new ParseRequest(documentId.toString(), storagePath, fileType)) .retrieve() .body(ParseResponse.class);
拿到结果后:
写入 rag_document_text.raw_text更新 rag_document.status = PARSED
如果失败:
更新 rag_document.status = FAILED写入 error_message
13. 解析质量检查
解析完不要直接相信结果。
至少检查:
text 是否为空
text 长度是否合理
是否包含大量乱码
PDF 页数是否正确
DOCX 段落数是否合理
metadata 是否包含 source_path
简单判断:
def validate_parsed_text(text: str):
if not text.strip(): raise HTTPException(status_code=400, detail="未解析到有效文本")
if len(text.strip()) < 20: raise HTTPException(status_code=400, detail="解析文本过短,请检查文档内容")
不要把空文本送去切片。
否则后面每一步都会出奇怪问题。
14. 解析服务的工程化设计
解析器不是一个简单的“文件转字符串”函数。它需要稳定的输入输出协议、明确的版本和可追踪的质量信息,否则同一份文件在不同时间可能得到无法解释的结果。
14.1 解析契约
Java 服务与 Python 解析服务之间应约定统一返回结构,至少包含正文、页数、段落或页面信息、解析器名称、解析器版本、警告列表和错误类型。
解析失败要区分可重试错误和永久错误。例如服务超时、临时磁盘不足可以重试;文件损坏、密码保护、格式不支持通常应直接标记失败,避免无意义地重复执行。
14.2 版本和可复现性
升级 PDF、DOCX 或 Markdown 解析库后,文本顺序和空白处理可能变化。建议把 parserName、parserVersion 和解析配置写入 metadata,并保留原始文件与 raw_text。
当解析结果发生变化时,应能够回答三个问题:使用了哪个解析器、配置是否改变、旧结果是否需要重新切片和向量化。
14.3 隔离、超时和质量门槛
复杂文件可能消耗大量 CPU 和内存。解析任务应设置文件大小限制、执行超时、并发上限和临时目录配额。对不可信文件,最好在独立进程或容器中运行解析器。
解析完成后不要立刻进入清洗。可以先检查文本长度、空白占比、乱码比例、页数是否合理以及关键页是否为空。质量检查不通过时进入人工排查或专用 OCR 流程。
15. 常见问题
15.1 扫描版 PDF 为什么解析为空
因为扫描版 PDF 本质上是图片。
普通文本解析器读不到文字。
需要 OCR。
第一版可以返回:
未解析到有效文本,可能是扫描版 PDF
15.2 PDF 表格乱怎么办
第一版先接受。
后面可以考虑:
pymupdf
pdfplumber
unstructured
Docling
专用表格抽取
不要一开始就把表格解析作为主线。
15.3 Markdown 要不要转 HTML
不用。
RAG 更需要语义结构。
保留 Markdown 标题通常更有价值。
15.4 Java 解析还是 Python 解析
都可以。
但 Python 的文档解析生态更丰富。
本路线建议 Java 负责业务和入库,Python 负责 AI 和解析能力。
16. 练习清单
完成几件事情:
实现 FastAPI /api/rag/parse支持 txt 解析
支持 md 解析
支持 docx 解析
支持 pdf 解析
解析结果包含 metadata空文本返回明确错误
Java 能调用 Python parse 接口
解析结果写入 rag_document_text更新 rag_document.status = PARSED解析失败写入 FAILED 和 error_message
建议目录:
ai-agent-study
├── python
│ └── rag-service
│ ├── main.py
│ ├── parsers.py
│ └── schemas.py
├── java
│ └── rag-api
│ └── PythonParseClient.java
└── docs
└── document-parsing.md
17. 小结
本节的结论:
文档解析要把不同文件格式统一变成文本,并保留足够的 metadata,方便后面清洗、切片和定位来源。
最小链路:
storagePath
-> parse_by_type -> raw_text -> metadata -> rag_document_text -> status=PARSED
解析质量越清楚,后面的 RAG 调试越轻松。