做RAG最头疼的,往往不是模型选型,也不是向量库调参,而是最底层的文档切分。文档切分做不好,召回率和准确率会一起崩。这次单独把PDF、Word、TXT三种格式拎出来,聊聊各自的最优切分方案。这三种格式在办公场景里出现频率最高,结构差异却极大:PDF排版固定但提取麻烦,Word自带段落样式但很容易被忽略,TXT没有格式却最容易被“硬切”。我会先说清每个格式的提取方式,再给出一套可落地的切分骨架,最后附实测参数和避坑清单。不管你现在用LangChain、LlamaIndex还是自研切分器,这套思路都能直接搬。
1. 为什么格式决定切分策略
1.1 RAG切分的本质:别把“语义积木”砸碎
想理解不同格式为什么要用不同切分策略,得先回到RAG的检索单位——“块”。块是向量化的最小语义单元,也是最容易被忽略的变量。
块太小,语义不完整,“该花的钱不该花”被切成“该花”和“钱不该花”,检索召回一堆碎片;块太大,一段话里塞了三四个主题,向量平均化之后谁都代表不了,召回精度直线下降。所以切分的核心不是“按字符数切”,而是“把语义完整的段落保留下来”。
那格式和语义完整性有什么关系?关系太大了。PDF往往以“页”为单位呈现内容,表格、页眉页脚、多栏混排会让文本顺序错乱;Word天然带有标题样式和段落边界,这是现成的语义骨架;TXT则什么都没有,只能靠空行、缩进、编号等痕迹去推断边界。格式不同,能获得的语义线索完全不同,照搬同一套切分器就是在糟蹋文档。
1.2 PDF、Word、TXT的“性格差异”
用一个生活类比来解释:
- PDF像一张打印好的报纸,版式已经固定,你能看到什么内容取决于排版师,但你无法轻易改动它。想要按语义切分,先得从版面中把文本按视觉块“抠”出来。
- Word像一份带目录和标题的电子文稿,段落、章节、表格都有标签。切分时可以直接借力这些结构标签,而不是用正则去猜。
- TXT像一叠白纸打印稿,没有任何标记,只能靠空行和缩进判断段落边界,切分方案要更保守、更依赖启发式规则。
所以我的基本判断是:PDF的难点在“提取”,Word的难点在“结构利用”,TXT的难点在“边界推断”。按这个判断去设计切分流程,比对着字符数调参会靠谱得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三种格式的“文本提取”实操
2.1 PDF:先解决“能不能拿到干净文本”
PDF是所有格式里最容易被高估的。很多人以为pdfplumber或者PyMuPDF直接读文本就行,真正跑起来才发现乱序、丢行、表格错位一大把。
我的做法分两步。第一步用 get_text("blocks") 拿到带坐标的视觉块,第二步按坐标排序并过滤页眉页脚。直接看代码:
python复制import fitz # PyMuPDF
doc = fitz.open("sample.pdf")
page_blocks = []
for page in doc:
blocks = page.get_text("blocks")
page_height = page.rect.height
for block in blocks:
x0, y0, x1, y1, text, block_no, block_type = block
if block_type != 0: # 0 是文本块,图片块和其他块先跳过
continue
# 过滤页眉页脚:靠近页面顶部或底部、字符重复率高的块
if y0 < 40 or y1 > page_height - 40:
continue
page_blocks.append({
"page": page.number + 1,
"bbox": (x0, y0, x1, y1),
"text": text.strip(),
})
这里有两个关键点:
一是 blocks 返回的已经是视觉块,比 get_text("text") 逐行输出更接近真实阅读顺序。二是页眉页脚过滤最好基于坐标和文本重复性一起判断,只靠坐标会把正文顶部误杀。如果文档页眉很长,阈值要根据实际版心调整。实测下来,大多数标准PDF的页眉页脚高度在35到60像素之间,取40到50比较稳妥。
拿到这些块之后,还不能直接拼文本。很多PDF的双栏排版、表格竖排,会导致块的顺序错乱。你需要按 (y0, x0) 排序,优先按垂直坐标,同一行内再按水平坐标,这样至少能还原“从上到下、从左到右”的阅读顺序。
2.2 Word:把段落和表格分开读
Word相对友好,因为python-docx能拿到段落样式。很多人写RAG切分时,把Word转成纯文本再处理,白白丢掉了最关键的标题层级信息。
我的建议是,用python-docx读取文档时,把段落、样式、层级一起保留下来:
python复制from docx import Document
from docx.table import Table
from docx.text.paragraph import Paragraph
doc = Document("sample.docx")
def iter_block_items(parent):
# 按文档内容顺序同时遍历段落和表格
from docx.oxml.ns import qn
parent_elm = parent.element.body
for child in parent_elm.iterchildren():
if child.tag == qn('w:p'):
yield Paragraph(child, parent)
elif child.tag == qn('w:tbl'):
yield Table(child, parent)
for block in iter_block_items(doc):
if isinstance(block, Paragraph):
style = block.style.name if block.style else ""
print("STYLE:", style, "TEXT:", block.text.strip())
else:
rows = []
for row in block.rows:
rows.append("| " + " | ".join(cell.text.strip() for cell in row.cells) + " |")
print("TABLE:\n" + "\n".join(rows))
这段代码的关键是 iter_block_items,它能按文档的真实阅读顺序混排输出段落和表格,比只遍历 doc.paragraphs 更接近人眼看到的顺序。
样式名记得留意:python-docx把“标题1”显示为“Heading 1”,“正文”是“Normal”。但很多人自建模板后标题样式叫“标题 1”或自定义名,这时不要硬编码,而是判断样式名的字符里有没有“Heading”或“标题”关键字,更稳。
2.3 TXT:先把编码和脏字符收拾干净
TXT看起来最简单,其实暗坑最多。常见的坑:GBK编码读到乱码、Windows换行变成CRLF、从网页复制进来一堆零宽字符和不可见控制符。
我一般先做一次清洗,再做切分:
python复制import re
def clean_text(raw: str) -> str:
# 统一换行符
text = raw.replace("\r\n", "\n").replace("\r", "\n")
# 去掉零宽字符、BOM、软连字符等
text = re.sub(r"[\ufeff\u200b\u200c\u200d\u2060\u00ad]", "", text)
# 把多个空行压缩为两个,保留段落边界
text = re.sub(r"\n{3,}", "\n\n", text)
# 去掉行尾多余空格
text = re.sub(r"[ \t]+$", "", text, flags=re.MULTILINE)
return text.strip()
编码问题建议读取时用 chardet 或 cchardet 检测,不要迷信文件后缀。实际测试中,很多“.txt”文件其实是GB18030或Big5编码。读错编码会导致切分出来全是错字,向量化也毫无意义。简单方案是把读取逻辑抽成函数:
python复制with open("sample.txt", "rb") as f:
raw_bytes = f.read()
try:
text = raw_bytes.decode("utf-8")
except UnicodeDecodeError:
import chardet
encoding = chardet.detect(raw_bytes)["encoding"] or "utf-8"
text = raw_bytes.decode(encoding, errors="replace")
清洗之后,TXT才算是真正“可切分”的状态。这一步省不得,脏字符会直接影响后续正则匹配,也会污染向量。
3. 结合场景选择切分方案
3.1 PDF最优切分流程:视觉块 → 段落 → 块大小
PDF的切分尽量“先还原语义结构,再切块”。我推荐的完整流程分四步:
第一步,把每页文本块按视觉位置还原成接近人眼的阅读顺序。
第二步,把视觉块合并成“行”,再按空行和缩进识别段落。这一步可以用块之间的y坐标距离来判断是否属于同一段落:两个连续块之间的垂直间距明显小于正常行距时,合并;间距大于正常段落间距时,断开。
第三步,用段落作为基本单元,判断段落长度是否落在目标块大小区间内。
第四步,对超长段落做递归二级切割,切割点优先选句号、分号、冒号,而不是从中间硬切。
整合起来大概是这样:
python复制def split_pdf_segments(pages_blocks, chunk_size=1000, overlap=150):
# 先把 blocks 排好序,按页拼接为完整文档文本
ordered_blocks = sorted(pages_blocks, key=lambda b: (b["page"], b["bbox"][1], b["bbox"][0]))
# 按空行分段(这里用简单的空白判断)
paragraphs = []
buf = []
for i in range(len(ordered_blocks)):
cur = ordered_blocks[i]["text"]
if not cur:
continue
if not buf:
buf.append(cur)
else:
# 如果与上一块距离较大,认为新段落
prev_y1 = ordered_blocks[i - 1]["bbox"][3]
cur_y0 = ordered_blocks[i]["bbox"][1]
if cur_y0 - prev_y1 > 15: # 阈值根据实际PDF调整
paragraphs.append("\n".join(buf))
buf = [cur]
else:
buf.append(cur)
if buf:
paragraphs.append("\n".join(buf))
# 段落长度小于 chunk_size 的直接保留,大于的递归切分
chunks = []
for para in paragraphs:
if len(para) <= chunk_size:
chunks.append(para)
else:
# 这里用 LangChain 的递归切分器兜底
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=overlap,
separators=["\n\n", "。", ";", ":", ",", " ", ""],
)
chunks.extend(splitter.split_text(para))
return chunks
这个流程的好处是“能天然成段的文档段就不拆,实在超长才拆”,语义完整性比纯字符切分好很多。
3.2 Word最优切分流程:标题驱动 + 表格独立
Word切分最理想的是“标题驱动”。原因很简单:Word段落自带样式,标题1、标题2、正文之间的层级关系几乎就是文档的语义大纲。按大纲切,每一块都覆盖一个完整小节,检索时用户问“A问题的解决办法”,召回的就是B问题所在的小节,而不是跨小节的混合内容。
具体做法:
- 遍历段落,识别 Heading 1、Heading 2、Heading 3 等样式。
- 维护一个“当前标题栈”,每遇到更高层级标题就开新块。
- 正文段落填充到当前标题块里。
- 表格单独成块,不塞进段落流。
- 如果某个标题下的内容超过块大小阈值,再递归切分。
python复制def split_word_by_headings(doc, chunk_size=1000, overlap=150):
blocks = []
current_block = None
for block in iter_block_items(doc):
if isinstance(block, Paragraph):
style = block.style.name if block.style else ""
text = block.text.strip()
if not text:
continue
if "Heading" in style or "标题" in style:
if current_block:
blocks.append(current_block)
current_block = {"title": text, "content": [text]}
else:
if current_block is None:
current_block = {"title": "", "content": []}
current_block["content"].append(text)
else:
# 表格转成 Markdown 格式,单独成块
rows = []
for row in block.rows:
rows.append("| " + " | ".join(cell.text.strip() for cell in row.cells) + " |")
table_text = "\n".join(rows)
if current_block:
blocks.append(current_block)
current_block = None
blocks.append({"title": "[TABLE]", "content": [table_text]})
if current_block:
blocks.append(current_block)
chunks = []
for b in blocks:
raw = "\n".join(b["content"])
if len(raw) <= chunk_size:
chunks.append(raw)
else:
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(chunk_size=chunk_size, chunk_overlap=overlap)
chunks.extend(splitter.split_text(raw))
return chunks
注意:表格不要和标题内容粘连,否则表格内部逻辑会被正文打断,检索时表格语义就碎了。表格本身可以转成管道表格(Markdown表格),也算一种结构化文本,向量化效果比纯字符拼接好。
3.3 TXT最优切分流程:正则分段 + 倒序合并
TXT没有样式标签,只能靠“段落痕迹”推断。我实践下来最稳的方案是:空行分段 + 短段倒序合并 + 超长段递归切分。
空行分段是最自然的语义边界,但会有大量单行短段,比如联系方式、页脚、无编号的标题。直接每个短段单独成块会导致信息碎片化,所以要做“倒序合并”:从短段往前找,合并到上一个完整段落中。
python复制def split_txt_by_paragraphs(text, chunk_size=1000, overlap=150):
cleaned = clean_text(text)
paragraphs = re.split(r"\n\s*\n", cleaned)
paragraphs = [p.strip() for p in paragraphs if p.strip()]
# 合并过短的段
merged = []
for p in paragraphs:
if merged and len(p) < 50 and len(merged[-1]) + len(p) < chunk_size:
merged[-1] += "\n" + p
else:
merged.append(p)
chunks = []
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(chunk_size=chunk_size, chunk_overlap=overlap)
for p in merged:
if len(p) <= chunk_size:
chunks.append(p)
else:
chunks.extend(splitter.split_text(p))
return chunks
这里的“短段”阈值50字符需要看语料调整。如果文档里大量短标题,可以提高到100;如果是一封普通的几百字邮件,50就够了。合并的原则是“宁可把短标题和首段合并,也不要让它单独成为无检索意义的小碎片”。
4. 统一中间表示与块大小参数
4.1 先把三种格式收敛成一种“带结构文本”
接完三种方案后,你会发现代码里有重复逻辑:都要判断块大小、都要递归切分、都要处理超长段落。我建议把三种格式的提取结果统一转成“带结构文本”的中间表示,最通用的是Markdown。
PDF的视觉块还原后,识别疑似标题的行,前面加上 #;Word直接映射标题样式;TXT则把连续的短行和重复模式标成引用块。转换后,切分器只处理一种统一结构,后续不管接什么向量库,都只需要维护一套块切割逻辑。
这种做法的最大好处是“调试成本断崖式下降”。以前三种格式各跑各的代码,出了问题不好定位是提取问题还是切分问题。统一转Markdown后,可以先人工检查转出来的Markdown是否正确,再检查切出来的块是否合理。定位一清二楚。
4.2 块大小与重叠:不同文档类型怎么定
块大小没有绝对万能参数,但可以根据文档类型定一个可复用的基准。我的默认参数如下:
| 文档类型 | 建议块大小 | 建议重叠 | 切分优先级 |
|---|---|---|---|
| 技术手册/教程 | 1200-1500字符 | 200-250 | 标题结构 > 段落 > 标点 |
| 合同/协议/公文 | 800-1000字符 | 100-150 | 条款编号 > 段落 |
| 论文/期刊 | 800-1000字符 | 150-200 | 段落 > 章节标题 |
| 对话/访谈记录 | 1500-2000字符 | 300 | 说话人 > 空行 > 标点 |
| 短邮件/通知 | 300-500字符 | 50 | 整封成块 |
| FAQ/问答对 | 300-500字符 | 0-50 | 按问题独立成块 |
为什么参数差这么多?因为不同文档的“语义边界”密度不一样。合同里的条款号是强边界,条款之间语义独立,块切大了会把无关条款揉在一起;技术手册里的段落相对较长,切小了反而打断操作步骤。
重叠也不是越多越好。重叠500以上会让同一条信息被多个块重复索引,检索时同一个答案被召回好几次,浪费上下文窗口。一般重叠控制在块大小的10%到20%之间,够用。
5. 常见问题排查实录
5.1 PDF表格内容被拦腰截断
这是PDF切分里最常见的翻车现场。直接调用 get_text("text") 时,表格列会以奇怪的顺序输出,比如第一列的文字跑到第二列后面;按字符数硬切时,一个单元格内容被切到两个块里,检索时字段对不上。
我的排查思路是:先用 get_text("blocks") 输出看坐标,确认表格是按单元格块还是整行块输出。如果表格每列都是一个独立块,切分时优先把表格整体转成Markdown管道表格,然后作为单独一条数据入库,不要和正文混在一起。如果是扫描件PDF,表格区域还得额外走OCR,视觉块提取完全无效。
5.2 Word文档中图片里的文字丢了
很多人处理Word转文本,图片里的文字会直接消失。这在RAG场景里很致命,比如组织架构图、流程图里的关键信息,如果被丢掉,用户提问时检索不到。
排查技巧是:在转换中间表示时,检查文档里有没有 inline_shapes 或图片元素。如果有,先用OCR(比如PaddleOCR)提取图片里的文字,再把文字以“图片说明”形式补充到相邻段落中。纯OCR接口不一定准,但至少比彻底丢失强得多。图片里的文字作为一种补充文本,在切块时可以加入搜索上下文,但不要直接拼进正文,避免破坏自然语言流畅性。
5.3 TXT出现编码乱码 / 特殊字符
TXT切分时最常见的异常是“看起来是空行,正则切不开”,或者“切出来的块开头有个奇怪符号”。前者多半是CRLF换行符没统一,后者多半是BOM或零宽字符残留在文本开头。
我排查时会在清洗前先打印 repr(text[:200]),直接看换行符和控制字符。看到 \ufeff 就正则去掉,看到 \r\n 就统一转换。还有个容易被忽略的点:如果文本里存在全角空格,用 \s 匹配时会漏掉,最好用 [\s\u3000] 一起处理。
5.4 用肉眼验证切分质量:把块输出成JSON
无论用什么方案,我都强烈建议把切分结果输出成JSON来人工检查。格式很简单:
json复制[
{
"chunk_id": 1,
"source_file": "contract.pdf",
"page_range": "3-4",
"chunk_text": "……"
},
{
"chunk_id": 2,
"source_file": "contract.pdf",
"page_range": "5",
"chunk_text": "……"
}
]
把每个块对应的源文件、页码、标题、正文都打出来,然后随机抽查20到30个块,看有没有以下问题:
- 一个块里混入了两个标题的内容;
- 表格第一行在块A,第二行在块B;
- 正文被从句子中间硬切断;
- 某块只有几个字,明显是误切出来的边角料。
肉眼检查这一步比任何指标都管用。你觉得“视觉上读起来是不是一块完整的内容”,其实是判断切分质量最直接的标准。
6. 个人实操总结
6.1 三套方案的优先级建议
如果让我给一个刚上手RAG的人提建议,优先级是这样的:
- PDF:先确保
blocks提取和坐标排序逻辑正确,再调切分参数。坐标没排好,后面一切都是白费。 - Word:先利用标题样式切,切完再用文本长度阈值兜底。Word本身结构就能给你80分,不要丢掉优势。
- TXT:先清洗文本,再按空行和短段合并切,最后用递归切分器处理超长段落。清洗比切分更重要。
三套方案都建议统一输出成带 source_file 和 page_range 的块JSON,这样后面无论做召回评估还是人工检查,都能快速定位来源。
6.2 几个经验教训
第一,不要迷信单一“最优参数”。同一份PDF,如果排版是单栏,和双栏论文,切出来的最佳块大小能差一倍。参数能动态设置最好,不能动态设置也要留配置入口。
第二,PDF切分前一定要确认是“文本型PDF”还是“扫描型PDF”。文本型直接提取,扫描型需要OCR。如果混在一起,要不要先做价格昂贵的OCR,这是个取舍问题。我一般用一个低成本判断:随机取5页,调用块提取,如果有一半以上页面返回空文本,基本可以认定是扫描件。
第三,格式转换时尽量保留元数据。我在切块时会把来源文件、页码、章节路径都编码进JSON里。有人觉得这是浪费存储,实际排查问题时就知道值多少钱了。没有来源信息,一个糟糕的召回结果根本没法定位是哪步处理出了问题。
这套方法跑通之后,你再遇到一份三五百页的手册,无论它是PDF、Word还是TXT,心里都会有个数:数据结构是什么,语义边界在哪,切出来之后能不能读。文档切分这事,说穿了就是在“语义完整”和“检索效率”之间找平衡。每一步都多保留一点结构信息,最终检索质量就会好一个档次。
