做 RAG(Retrieval-Augmented Generation,检索增强生成)项目,很多团队把精力花在选模型、调 prompt、做 rerank 上,结果检索效果还是不稳定。问题往往不在模型,而在文档预处理阶段就埋了雷。这次我们聊一个几乎所有 RAG 项目都会遇到的话题:重叠区(chunk overlap)到底是不是玄学?PDF、PPT 是不是直接按页切割就行?
先说结论:重叠区不是玄学,它解决的是“切分边界处语义断裂”这个具体问题;按页分割也不是万能方案,它只适用于语义单元和页面边界基本重合的文档。本文会用工程视角拆解一套可落地的文档加载预处理与切片流程,覆盖 PDF、PPT 的适用场景、重叠区和 chunk_size 的设计思路、批量处理与检索评测方法。
适合读者:正在搭建 RAG 知识库的工程师、需要处理 PDF/PPT 语料的产品和技术负责人、对“文档切片到底该怎么切”有疑问的开发者。
1. 核心能力速览
先给一张表,把本文方案的关键信息列出,方便你判断这套思路是否适合你的项目。
| 能力项 | 说明 |
|---|---|
| 方案定位 | RAG 文档加载预处理与切片策略 |
| 覆盖格式 | PDF、PPT、Word、TXT 等常见文本类文档 |
| 切片方式 | 按页分割 / 按标题结构分割 / 定长滑动窗口 |
| 重叠区支持 | 支持,chunk_size 的 10%~25% 通常作为起点 |
| 输出形态 | 带文档名、页码、章节号等元数据的 chunk 列表 |
| 批量能力 | 可通过脚本批量处理本地目录 |
| 接口扩展 | 可封装为 HTTP 接口,供上层知识库调用 |
| 合规提醒 | 处理前需确认素材版权与授权范围 |
这套方案不依赖特定 GPU,文档预处理本身是 CPU 任务,主要的算力消耗在后面的 embedding 和问答推理环节。下面逐块讲清楚设计思路和实现方式。
2. 直接回答:重叠区不是玄学,它解决的是切点断裂问题
重叠区有明确的工程作用。
假设一段文本是 1000 个字,chunk_size 取 200,overlap 取 40。滑动窗口每步前进 160 个字,也就是说每个 chunk 会保留上一个 chunk 末尾的 40 个字作为上下文衔接。这样做最直接的价值是:当一个知识点刚好落在两个 chunk 的切点上时,至少有一个 chunk 保留了足够多的上下文,不至于让检索和生成阶段完全丢失切点处的信息。
举个例子,原文有一句话是“该模型的参数量为 700 亿,训练数据规模约为 2TB”。如果切点刚好落在“700 亿”和“训练数据”之间,前一个 chunk 只知道参数量,后一个 chunk 只知道训练数据规模,两个 chunk 都没有完整描述这句话。加上 overlap 之后,后一个 chunk 会带着前一句的尾部进入,从而保留“参数量为 700 亿”这个信息。
那重叠区是不是越大越好?不是。overlap 过大带来三类问题:
- Token 和存储成本增加,索引膨胀,检索耗时变长。
- 向量检索时,过多的重叠会让相邻 chunk 在向量空间里高度相似,top-k 结果容易被同一段内容占据,召回覆盖度下降。
- Embedding 模型有输入长度限制,overlap 挤占有效内容空间。
但如果文档本身结构非常独立,比如每条 FAQ 都是完整的问答对,段落之间没有强依赖,overlap 的价值就明显降低。这种情况下,与其纠结重叠区,不如先做好按语义块切分。
所以重叠区不是玄学,它是可以直接评测和调整的参数。关键在于:先用一套固定的测试问题集去测,再决定重叠区设多少,而不是凭感觉拍脑袋。
3. PDF、PPT 按页分割的适用场景
“按页分割”是最容易被误解的操作。按页切不代表每一页必须等于一个 chunk,更合理的说法是:先把页面文本抽取出来作为中间结构,再根据语义边界决定如何组装成最终 chunk。
3.1 PPT 按页切:先当默认选项,再处理边界
PPT 是文档切片里最适合按页处理的格式。原因很简单:PPT 的每一页通常是演讲者设计好的一个语义单元,由“标题 + 要点”构成,天然适合作为 RAG 的检索单元。
但 PPT 按页切有几个坑要处理:
- 装饰性文字。很多 PPT 的母版里会带固定的 logo 文案、页脚、装饰字符,需要清洗掉。
- 文本框乱序。页面上的多个文本框在读取时不一定按视觉顺序返回,最好记录每个 shape 的位置坐标,按坐标排序后再拼接文本。
- 一个页面塞了太多内容。有些页面是密集的架构图或对比表格,一页之内还能继续拆分。
- 跨页连续观点。比如结论在第一页末尾,解释在第二页开头,这种跨页内容在按页切时会断裂,需要额外把上一页的末尾部分拼进下一页。
用 python-pptx 读取 PPT 文本是一个常见做法。下面的代码演示了如何按页抽取文本:
from pptx import Presentation def extract_slides(pptx_path: str) -> list[dict]: prs = Presentation(pptx_path) slides = [] for idx, slide in enumerate(prs.slides, start=1): parts = [] for shape in slide.shapes: if shape.has_text_frame: for para in shape.text_frame.paragraphs: line = "".join(run.text for run in para.runs) if line.strip(): parts.append(line) if shape.has_table: for row in shape.table.rows: cells = [cell.text.strip() for cell in row.cells] parts.append(" | ".join(cells)) slides.append({"page": idx, "text": "\n".join(parts)}) return slides这段代码会把每一页的文本框和表格内容拼接成一个文本块。实际项目中,还可以在parts.append(line)之前判断当前 shape 是否属于固定的母版元素,从而跳过装饰性内容。
3.2 PDF 按页切:先区分文字版和扫描版
PDF 是所有文档格式里最容易“看似简单、实际复杂”的类型。按页切之前,先要判断 PDF 是文字版还是扫描版:
- 文字版 PDF:文本可以被直接提取,按页切出来的内容基本可读。
- 扫描版 PDF:页面是图片,直接抽取文本大概率得到空字符串,必须先接 OCR。
对于文字版 PDF,按页切是否合适,取决于文档类型:
- 产品手册、操作指南、答辩 PPT 导出的 PDF:通常按页切可用。
- 合同、招标文件、政府公文、规章制度:按页切会切断条款和章节逻辑,应该优先按标题结构切。
- 学术论文:推荐按章节、段落切,而不是按物理页切。
- 数据手册、规格书:页面里表格密集,按页切之前最好先定位表格区域,按表格结构单独抽取。
下面用 pdfplumber 做一个文字版 PDF 的按页抽取与基础清洗:
import pdfplumber import re def extract_clean_pages(pdf_path: str) -> list[dict]: pages = [] with pdfplumber.open(pdf_path) as pdf: for page_num, page in enumerate(pdf.pages, start=1): text = page.extract_text() or "" # 清理多余空行 text = re.sub(r"\n{3,}", "\n\n", text) # 清理页眉页脚等全局重复内容,按实际文档调整 lines = [line.strip() for line in text.splitlines() if line.strip()] clean_text = "\n".join(lines) pages.append({"page": page_num, "text": clean_text}) return pages这段代码只做了两层清洗:合并多余空行、去掉空白行。真正的页眉页脚过滤需要根据文档样本单独写规则,比如识别每页重复出现的公司名、文档编号、版本号。
3.3 什么时候不要按页切
如果文档的语义边界和物理页面边界明显不一致,就不要按页切。典型情况包括:
- 学术论文的结论可能跨页分布,按页切会把一个完整结论拆到多个 chunk。
- 长篇小说一个章节往往跨多页,按页切会把叙事逻辑打散。
- 表格密集的 PDF,按页切会把表格结构切断,导致检索结果残缺。
- 用户问题通常是以知识点为单位提出的,不是以页为单位提出的。
所以,“按页分割”更准确的定位是:它是文档结构化过程中的一个中间产物,不是终点。
4. RAG 文档加载与预处理的完整流程
把视野拉高一点,重叠区和按页切都只是“文档加载预处理”链路中的一环。一个完整的 RAG 预处理流程通常包含六个阶段:
- 文件收集与格式识别:确定来源目录,识别 PDF、PPT、Word、TXT 等格式。
- 文本抽取:从不同格式中提取出可读文本,扫描版 PDF 走 OCR。
- 文本清洗:移除页眉页脚、水印、装饰性文字、多余空行,统一换行符。
- 结构化切分:按页、按标题、按滑动窗口等方式切出候选 chunk。
- 元数据标注:给每个 chunk 标记文档名、页码、章节号、切片方式等。
- 向量化与入库:用 embedding 模型生成向量,写入向量库,同时保存原始文本。
下面是一个批量预处理脚本的骨架,它会遍历 input 目录下的文件,按格式调用不同的解析函数,输出带元数据的 chunk:
import json import logging from pathlib import Path INPUT_DIR = Path("data/raw") OUTPUT_DIR = Path("data/chunks") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") def extract_pdf_pages(pdf_path: str) -> list[dict]: # 按 3.2 节的思路实现 pass def extract_pptx_pages(pptx_path: str) -> list[dict]: # 按 3.1 节的思路实现 pass def split_pages_to_chunks(pages: list[dict], chunk_size: int, overlap: int) -> list[dict]: # 按第 5 节的思路实现 pass def process_one_file(path: Path): if path.suffix.lower() == ".pdf": pages = extract_pdf_pages(str(path)) elif path.suffix.lower() == ".pptx": pages = extract_pptx_pages(str(path)) else: logging.warning(f"unsupported format: {path.suffix}") return chunks = split_pages_to_chunks(pages, chunk_size=500, overlap=100) out_file = OUTPUT_DIR / f"{path.stem}.json" with open(out_file, "w", encoding="utf-8") as f: json.dump(chunks, f, ensure_ascii=False, indent=2) logging.info(f"processed {path.name}, chunks: {len(chunks)}") for path in INPUT_DIR.glob("*.*"): try: process_one_file(path) except Exception as e: logging.error(f"failed: {path} -> {e}")这个脚本的关键点是:每个文件单独用 try/except 包住,单个文件解析失败不会中断整个目录的批量任务;每个文件输出一个独立 JSON,便于定位问题。
5. 重叠区与 chunk_size 的设计思路
5.1 chunk_size 怎么定
chunk_size 的取值主要受三个因素影响:
- Embedding 模型的最大输入长度。不同模型的上限不同,如果 chunk 长度接近或超过模型上限,向量质量会明显下降。
- LLM 的上下文窗口。chunk 太长,塞进 prompt 后留给回答生成的空间就小;chunk 太短,单条上下文信息量不足。
- 问答场景的粒度。短问题、明确的知识点适合短 chunk;跨多页的综合问题适合长 chunk。
中文场景下,一个常见起点是从 256 或 512 开始试。这里的单位可能是字符数,也可能是 token 数,取决于你的切分器和 embedding 模型的计量方式。实际调优时不要只看参数绝对值,要看“检索召回率”和“问答正确率”两条指标。
5.2 overlap 怎么定
overlap 的经验起点是 chunk_size 的 10%~25%。比如 chunk_size 为 500 时,overlap 取 50~125。
但这只是起点。更合理的做法是让 overlap 和“句子边界”对齐,而不是机械地截断字符。一个句子很长、超过 overlap 长度时,机械截断很可能把句子从中间切开,产生语义不完整的文本。比较好的顺序是:
- 先把文本按句子切分。
- 再按 chunk_size 将句子组装成 chunk。
- 组装时,让新 chunk 携带上一个 chunk 末尾的一句话或几句话,代替固定字符数 overlap。
这里有一个可以落地的滑动窗口切分实现,它先按中英文句末标点切句,再用滑动窗口组装:
import re def split_sentences(text: str) -> list[str]: raw = re.split(r"(?<=[。!?!?])", text) return [s.strip() for s in raw if s.strip()] def sliding_window_chunk(sentences: list[str], chunk_size: int = 500, overlap: int = 100) -> list[str]: chunks = [] current = "" for sent in sentences: if len(current) + len(sent) <= chunk_size: current += sent else: if current: chunks.append(current) if overlap > 0 and current: tail = current[-overlap:] current = tail + sent else: current = sent if current: chunks.append(current) return chunks text = "这是第一句话。这是第二句话,介绍模型参数。这是第三句话,讨论训练数据。" sentences = split_sentences(text) chunks = sliding_window_chunk(sentences, chunk_size=30, overlap=10) for i, chunk in enumerate(chunks): print(f"chunk {i}: {chunk}")这个函数有一个明显的边界问题:如果某个句子本身超过 chunk_size,这个句子会单独成为一个 chunk。实际项目中还要进一步处理超长句子的截断,以及避免 overlap 截断到半个词。需要根据业务语料调整。
5.3 重叠区包含的内容要清洗
重叠区本质上是从前文复制到后文的一段文本。如果前文里包含页眉、页脚、固定的文档编号,这些内容会跟着 overlap 一起进入多个 chunk,造成重复 embedding。所以重叠区的设计必须放在文本清洗之后,而不是之前。
6. 按页分割与重叠区的组合设计
按页分割和重叠区不是两个互相排斥的方案,而是可以组合使用的两个层级。
整体思路是:
- 第一级切分:按页面粒度抽取文本,保留每页的独立结构。
- 第二级切分:在页内或跨页边界处,用重叠区缓解边界断裂。
对 PPT 而言,一页通常是一个完整语义单元。如果页与页之间有连续逻辑,可以在下一页的 chunk 顶部拼接上一页的最后一句话。比如第一页末尾是“因此我们决定采用双塔模型”,第二页开头讲解双塔模型结构,检索“为什么选双塔模型”时,如果第二页的 chunk 不携带上页结尾,就无法回答“因此”指代的是什么。
对 PDF 而言,如果按页切,每页的页眉和页脚要提前清洗。清洗后,可以在相邻页之间做少量重叠:取上一页最后一段文本作为下一页 chunk 的前缀。如果文档是按标题结构切的,重叠区的必要性会降低很多,因为标题本身就是很好的边界信号。
无论哪种方式,chunk 的元数据都要保留页面信息。推荐的数据结构如下:
{ "doc_id": "2024-RAG-guide", "file_name": "RAG实践指南.pdf", "page_range": [1, 2], "section": "3.2 重叠区设计", "chunk_index": 12, "text": "重叠区的设计必须放在文本清洗之后..." }元数据能让检索到结果后直接定位到原文页码,方便用户溯源。这对企业知识库、政务知识库这类对溯源要求高的场景尤其重要。
7. 批量预处理与工程化实践
真实业务里不会有单独一个 PDF 等你处理,通常是一整个目录,甚至是一条持续更新的数据管道。工程化实践需要注意四个点:目录分层、增量处理、失败重试、日志可追踪。
一套推荐的目录结构:
data/ raw/ # 原始文件 extracted/ # 抽取出的页级文本 chunks/ # 最终 chunk json logs/ # 处理日志增量处理的思路是记录每个源文件的 hash。文件内容没变就不重新处理,文件内容变了才更新对应 chunk。这样即使知识库新增文件,也不会触发全量重建。
import hashlib import json from pathlib import Path STATE_FILE = Path("data/process_state.json") def file_hash(path: Path) -> str: return hashlib.md5(path.read_bytes()).hexdigest() def load_state() -> dict: if STATE_FILE.exists(): return json.loads(STATE_FILE.read_text(encoding="utf-8")) return {} def save_state(state: dict): STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") state = load_state() for path in Path("data/raw").glob("*.*"): current_hash = file_hash(path) if state.get(path.name) == current_hash: continue # 这里执行处理逻辑 state[path.name] = current_hash save_state(state)如果希望把文档预处理封装成服务,可以加一层 HTTP 接口,输入文件路径和切片参数,输出 chunk 列表。给一个 FastAPI 的骨架,实际接口路径和参数需要按项目调整。
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class PreprocessRequest(BaseModel): file_path: str chunk_size: int = 500 overlap: int = 100 @app.post("/preprocess") def preprocess(req: PreprocessRequest): # 按实际项目实现:解析文件 -> 清洗 -> 切分 -> 返回 chunk return {"status": "ok", "file_path": req.file_path}接口化的好处是:上游可以把 PDF、PPT 直接传给预处理服务,下游拿到 chunk 后统一写入向量库,解耦整个知识库构建流程。
8. 检索质量验证与评测思路
重叠区设多少、按页切还是按标题切,最终要回到一个问题上:检索质量是否达标。推荐用三层评测法验证切分效果。
第一层:切片完整性抽查。人工阅读一批 chunk,看每个 chunk 是否包含一个相对完整的知识点,是否出现句子被拦腰截断的情况。这一层成本最低,适合切分规则刚确定时先跑一遍。
第二层:检索召回测试。准备一批测试问题,每个问题对应原文中正确答案所在的页码或 chunk ID,跑检索看 top-k 是否命中正确答案。这一层直接反映切分和 embedding 组合的效果。
第三层:端到端问答测试。把检索到的 chunk 喂给 LLM,看最终回答是否正确。这一层最接近用户真实体验,但耗时也最高。
下面是一个召回率测试脚本的骨架,使用 sentence-transformers 做向量化,用 numpy 计算余弦相似度:
import numpy as np from sentence_transformers import SentenceTransformer model = SentenceTransformer("BAAI/bge-m3") def cosine_similarity(a, b): return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))) def recall_at_k(question: str, doc_chunks: list[dict], correct_ids: set, k: int = 5) -> bool: q_vec = model.encode(question) scored = [] for chunk in doc_chunks: score = cosine_similarity(q_vec, chunk["vector"]) scored.append((score, chunk["id"])) scored.sort(reverse=True) return any(chunk_id in correct_ids for _, chunk_id in scored[:k])对比实验设计可以参考三组变量:
- chunk_size:256 / 512 / 1024
- overlap:0 / 64 / 128
- 切分方式:按页切 / 按标题切 / 滑动窗口按句切
每组实验使用同一套测试问题集和同一个 embedding 模型,记录 Recall@1 和 Recall@5,输出到 CSV 做横向对比:
chunk_size,overlap,split_method,recall@1,recall@5 256,0,page,0.42,0.68 256,64,page,0.45,0.71 512,128,sliding,0.51,0.78注意,表格里的数字只是列名示例,不代表任何真实实验结论。实际数值必须以你的语料和问题集为准。评测要做成可重复的固定流程,否则每次换参数都靠感觉,问题很难定位。
9. 常见问题与排查方法
文档预处理阶段的问题通常不会报明显的错误,而是以“检索不到内容”的形式出现。下表覆盖了最常见的几类问题。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| PDF 抽取不到文本 | 扫描版 PDF 或加密 PDF | 用 PDF 阅读器打开确认页面是否为图片 | 先接 OCR,或确认文件权限 |
| PPT 文本顺序混乱 | 文本框按创建顺序返回,未按坐标排序 | 打印每个 shape 的 left/top 坐标 | 按坐标排序后再拼接文本 |
| 切分后句子断裂 | 切分规则没有考虑句末标点 | 抽查 chunk 首尾字符 | 先按句子边界切,再滑动窗口 |
| 重叠区导致大量重复内容 | 页眉页脚进入重叠区 | 打印 overlap 包含的文本片段 | 先清洗页眉页脚再做 overlap |
| 检索结果集中在同一段 | overlap 过大,向量相似度过高 | 查看 top-k 结果对应的页码分布 | 减小 overlap,或检索后做去重 |
| 批量处理中途中断 | 某个文件格式异常 | 查看日志定位失败文件 | 单文件 try/except,失败重试 |
| 换 embedding 模型后效果变差 | chunk 长度与新模型输入限制不匹配 | 查看新模型文档和 max_seq_length | 重新评估 chunk_size |
10. 最佳实践与使用建议
结合多个 RAG 知识库项目的常见问题,这里整理几条适合起步阶段的实践建议。
先做小规模人工切片检查,再进入自动化批量流程。不要一开始就全量灌入数百个文件,先挑 5~10 个有代表性的 PDF、PPT,跑完切片后人工读一遍,确认没有明显的文本乱序和语义断裂。
保留原始文本、清洗后文本、最终 chunk 三级中间结果。后续发现问题时,可以逐级回溯,判断问题出在抽取、清洗还是切分环节。
把切片配置记录下来。chunk_size、overlap、切分方式、清洗规则、使用的 embedding 模型,这些参数组成了知识库的“配方”。换模型或调参数时,可以直接对比新旧配置的效果。
涉及人脸、声音、版权素材时,必须确认授权。PDF、PPT 可能是出版物、内部文档或商业资料,在企业知识库和政务知识库中,要特别关注素材来源、敏感信息脱敏和发布边界。用真实业务数据做评测时,建议先脱敏再入库。
对政务、企业规章制度这类标题层级严格的文档,不要机械按页切。先解析标题层级,建立文本的标题树,再按章节决定 chunk 边界,效果通常比固定大小滑动窗口更好。
检索策略要和切片策略一起考虑。如果业务中允许多条 chunk 同时进入 LLM,重叠区可以适当小一些;如果每次都只取单条 chunk 生成答案,重叠区的价值会更高。
11. 总结与下一步
这篇文章最想传递的一个观点是:重叠区和按页分割都不是独立的“玄学参数”,它们服务于同一个目标——让每个 chunk 尽量成为一个完整的语义单元,同时保留切点附近的上下文。
如果你正要开始调 RAG 文档预处理,建议第一件事不是调 overlap,而是把一份典型的 PDF 和一份 PPT 跑通“抽取 -> 清洗 -> 切分 -> 人工检查”这条链路,先把中间产物看清。最容易踩的坑是把 PDF 的物理页码当成语义边界,扫描版 PDF 不接 OCR 就灌入知识库。后续可以考虑扩展的方向包括:自建标题检测模型、版面分析、表格结构化抽取,以及把评测问题集做成自动回归测试,每次改参数后自动比对 Recall 指标。