19 文本清洗
文本清洗
文本清洗的核心是在不破坏原文结构的前提下,去掉明显噪声,保留标题、段落、页码和来源信息,让后面的切片和检索更稳定。
[TOC]
1. 学完本节要达到什么程度
前面已经把文档解析成文本。
但解析出来的文本通常不干净。
常见问题:
空行太多
页眉页脚重复
页码混在正文里
PDF 换行很乱
中英文空格异常
连字符断词
标题层级丢失
来源信息丢失
本节的目标是:
raw_text
-> cleaned_text
不要追求一次清洗完美。
先做低风险、可解释、可回退的清洗。
2. 清洗原则
清洗最怕误删。
原则:
少做高风险改写
优先处理明显噪声
保留标题结构
保留页码线索
保留代码块
保留原始 raw_text清洗过程可重复执行
不要把清洗写成:
一堆看不懂的正则
每个规则都应该能解释。
3. raw_text 和 cleaned_text 都要保存
数据库里建议保留:
raw_text:解析原文
cleaned_text:清洗结果
原因:
清洗错了可以回退
能比较前后效果
方便排查 RAG 质量问题
不同策略可以重新清洗
不要覆盖原始解析文本。
4. 清洗流程
建议顺序:
统一换行
去除不可见字符
压缩连续空行
修复 PDF 断行
去除明显页码
去除重复页眉页脚
保留标题结构
生成清洗报告
清洗流水线:
graph LR;
A["raw_text<br/>解析原文"] --> B["统一换行"];
B --> C["去不可见字符"];
C --> D["压缩连续空行"];
D --> E["修复 PDF 断行"];
E --> F["去明显页码"];
F --> G["去重复页眉页脚"];
G --> H["保留标题结构"];
H --> I["cleaned_text<br/>清洗结果"];
I --> J["清洗报告<br/>记录修改次数和策略"];
其中“修复断行”和“去页眉页脚”风险稍高。
第一版可以做得保守一点。
5. 统一换行
不同系统换行不同:
\r\n
\r
\n
先统一成 \n:
def normalize_newlines(text: str) -> str:
return text.replace("\r\n", "\n").replace("\r", "\n")```
这是低风险规则。
几乎所有文本都可以做。
## 6. 去不可见字符
有些 PDF 会解析出奇怪字符。
可以先去掉部分控制字符:
```python
import re
def remove_control_chars(text: str) -> str:
return re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", text)```
注意不要删 `\n` 和 `\t`。
换行和缩进可能有结构意义。
## 7. 压缩空行
空行太多会影响切片。
可以把 3 个以上连续换行压成 2 个:
```python
import re
def collapse_blank_lines(text: str) -> str:
return re.sub(r"\n{3,}", "\n\n", text)```
保留两个换行,是为了保留段落边界。
不要压成一个换行。
## 8. 去除行首行尾空格
```python
def strip_lines(text: str) -> str:
lines = [line.strip() for line in text.split("\n")] return "\n".join(lines)```
这条规则简单,但要注意代码块。
如果文档里有大量代码,缩进可能有意义。
Markdown 文档可以选择保守处理:
```text
代码块内不 strip代码块外 strip
9. 保留 Markdown 代码块
清洗 Markdown 时,代码块要小心。
可以先识别代码块状态:
def strip_lines_keep_code(text: str) -> str:
result = [] in_code_block = False
for line in text.split("\n"): if line.strip().startswith("```"): in_code_block = not in_code_block result.append(line.rstrip()) continue
if in_code_block: result.append(line.rstrip()) else: result.append(line.strip())
return "\n".join(result)```
代码块里不做激进清洗。
否则示例代码可能被破坏。
## 10. 修复 PDF 断行
PDF 经常把一段话解析成多行:
```text
RAG 的核心是先检索相关内容
再把检索结果作为上下文交给模型
生成最终回答
有时可以合并成:
RAG 的核心是先检索相关内容再把检索结果作为上下文交给模型生成最终回答
但合并断行有风险。
标题、列表、表格不应该乱合并。
保守策略:
def merge_soft_line_breaks(text: str) -> str:
lines = text.split("\n") result = [] buffer = ""
for line in lines: stripped = line.strip()
if not stripped: if buffer: result.append(buffer) buffer = "" result.append("") continue
if stripped.startswith(("#", "-", "*", "1.", "[PAGE")): if buffer: result.append(buffer) buffer = "" result.append(stripped) continue
if buffer: buffer += stripped else: buffer = stripped
if stripped.endswith(("。", "!", "?", ".", "!", "?", ":", ":")):
result.append(buffer) buffer = ""
if buffer: result.append(buffer)
return "\n".join(result)```
这不是完美算法。
它只是一个学习阶段的保守起点。
## 11. 去页码
解析 PDF 后可能出现:
```text
1
2
第 3 页
Page 4
可以删除单独成行的页码:
import re
def remove_page_numbers(text: str) -> str:
lines = []
for line in text.split("\n"): stripped = line.strip()
if re.fullmatch(r"\d{1,4}", stripped): continue
if re.fullmatch(r"第\s*\d{1,4}\s*页", stripped):
continue
if re.fullmatch(r"Page\s+\d{1,4}", stripped, flags=re.IGNORECASE): continue
lines.append(line)
return "\n".join(lines)```
注意:
```text
不要删除正文里的数字
只删除单独成行的页码
12. 去重复页眉页脚
页眉页脚常常每页重复。
比如:
AI Agent 学习手册
每页都出现。
可以统计重复行:
from collections import Counter
def remove_repeated_lines(text: str, min_count: int = 3) -> str:
lines = text.split("\n") counter = Counter(line.strip() for line in lines if line.strip())
repeated = { line for line, count in counter.items() if count >= min_count and len(line) <= 80 }
result = [ line for line in lines if line.strip() not in repeated ]
return "\n".join(result)```
这条规则有风险。
如果某句话在正文里重复多次,也可能被删。
所以第一版可以只对 PDF 使用,并且把删除的行记录到 cleaning report。
## 13. 保留标题
标题对 RAG 很重要。
它能帮助后面按结构切片。
Markdown 标题:
```text
# 一级标题
## 二级标题
### 三级标题
不要清洗掉 #。
DOCX 如果能识别标题样式,可以在解析阶段转成:
# 标题 1## 标题 2
PDF 如果标题结构不好识别,第一版先不强求。
后面可以通过字体大小、目录、规则等方式增强。
14. 清洗报告
建议每次清洗返回一个报告:
class CleaningReport(BaseModel):
original_length: int cleaned_length: int removed_blank_lines: int removed_repeated_lines: list[str] rules: list[str]```
报告的作用:
```text
知道清洗做了什么
方便调试
避免误删难以发现
metadata 里也可以记录:
{
"cleaner": "basic-cleaner-v1", "rules": [ "normalize_newlines", "remove_control_chars", "collapse_blank_lines" ]}
15. 完整清洗函数
示例:
def clean_text(raw_text: str, file_type: str) -> tuple[str, dict]:
text = raw_text rules = []
text = normalize_newlines(text) rules.append("normalize_newlines")
text = remove_control_chars(text) rules.append("remove_control_chars")
if file_type.lower() == "md": text = strip_lines_keep_code(text) rules.append("strip_lines_keep_code") else: text = strip_lines(text) rules.append("strip_lines")
if file_type.lower() == "pdf": text = remove_page_numbers(text) rules.append("remove_page_numbers")
text = collapse_blank_lines(text) rules.append("collapse_blank_lines")
report = { "cleaner": "basic-cleaner-v1", "original_length": len(raw_text), "cleaned_length": len(text), "rules": rules, }
return text, report```
第一版先不要默认启用“去重复页眉页脚”和“合并软换行”。
可以作为可选规则。
## 16. Java / Python 协作
清洗也可以放 Python 服务。
接口:
```text
POST /api/rag/clean
请求:
{
"documentId": "xxx", "rawText": "...", "fileType": "pdf"}
响应:
{
"documentId": "xxx", "cleanedText": "...", "report": { "cleaner": "basic-cleaner-v1", "rules": [] }}
Java 收到后写入:
rag_document_text.cleaned_text
rag_document_text.metadata.cleaning
rag_document.status = CLEANED
17. 清洗前后对比
建议保存一个调试接口:
GET /api/documents/{documentId}/text
返回:
{
"rawTextPreview": "...", "cleanedTextPreview": "...", "metadata": {}}
不要一次返回超大全文给前端。
可以只返回前 2000 字。
调试时很有用。
18. 清洗策略的工程化管理
文本清洗会直接改变后续切片和检索结果,因此它应当像代码和数据库结构一样有版本,而不是散落在多个临时正则表达式里。
18.1 规则版本和幂等性
建议为每套清洗规则分配 cleanerVersion。同一份 raw_text 使用相同版本重复执行时,应得到相同结果;已经清洗过的文本再次清洗,也不应持续丢失内容。
修改断行、页眉页脚或空白规则后,不要直接覆盖而不留记录。应先用固定样本比较新旧版本,再决定是否批量重新清洗、切片和向量化。
18.2 可解释的清洗报告
清洗报告不仅记录“成功”,还应说明做了什么。建议统计原始字符数、清洗后字符数、删除行数、合并断行数、移除页眉页脚次数和异常字符数量。
如果文本缩短比例突然过高,应触发告警或人工复核。对于合同、制度和技术手册,误删一个编号、表格行或否定词,影响往往比保留少量噪声更严重。
18.3 回归样本
为 PDF、DOCX、Markdown 和 TXT 各准备一批代表性样本,覆盖中文标点、代码块、表格、页码、重复页眉和跨页断句。每次修改规则后比较关键段落是否仍然存在。
清洗质量的最低标准不是“看起来整齐”,而是信息不丢失、结构可识别、结果可复现,并且能支持下一步稳定切片。
19. 常见问题
19.1 清洗是不是越干净越好
不是。
清洗的目标是减少噪声。
不是重写原文。
过度清洗会破坏语义和结构。
19.2 页眉页脚一定要删吗
不一定。
如果它只是文档标题,保留也没问题。
如果它每页重复很多次,才会影响检索。
19.3 是否要用大模型清洗
第一版不建议。
用规则清洗更可控、成本更低、可重复。
复杂表格、扫描件、版面恢复可以后面再考虑模型辅助。
19.4 清洗失败怎么办
保留 raw_text。
把文档状态改为:
FAILED
并记录 error_message。
不要让失败文本继续进入切片。
20. 练习清单
完成几件事情:
实现 normalize_newlines实现 remove_control_chars实现 collapse_blank_lines实现 strip_lines_keep_code实现 remove_page_numbers实现 clean_text返回 cleaning report把 cleaned_text 写入 rag_document_text更新 rag_document.status = CLEANED保留 raw_text 不覆盖
能查看清洗前后预览
建议目录:
ai-agent-study
├── python
│ └── rag-service
│ ├── cleaners.py
│ ├── schemas.py
│ └── main.py
├── java
│ └── rag-api
│ └── PythonCleanClient.java
└── docs
└── text-cleaning.md
21. 小结
本节的结论:
文本清洗要保守、可解释、可回退,重点是去掉明显噪声并保留结构。
最小链路:
raw_text
-> 基础规则清洗
-> cleaned_text -> cleaning report -> status=CLEANED
清洗质量会直接影响后续切片质量,也会影响后面 RAG 检索效果。