8.1.3 文档处理与向量化

完成本节后,你将能够:
- 理解为什么 RAG 效果很大程度取决于前处理
- 掌握文档清洗、切块、重叠和元数据的直觉
- 写出一个简单可运行的切块与检索示例
- 理解“向量化”到底在做什么
一、为什么 RAG 不是“文档直接塞进去”?
Section titled “一、为什么 RAG 不是“文档直接塞进去”?”因为真实文档往往很长、很乱、很杂。
例如一份 PDF 可能包含:
- 页眉页脚
- 目录
- 空行
- 标题层级
- 表格
- 重复文本
如果你把它原样塞给模型,常见问题包括:
- 上下文太长,塞不下
- 重点埋在长文里,不容易被检索到
- 噪声太多,影响检索质量
所以文档处理其实是在做一件事:
把资料整理成模型更容易找到、也更容易利用的知识块。
二、文档处理常见的 4 步
Section titled “二、文档处理常见的 4 步”去掉无关噪声,比如:
- 多余空格
- 页码
- 重复标题
切块(Chunking)
Section titled “切块(Chunking)”把长文切成适合检索的小片段。
给每块附加信息,如:
- 来源文件
- 标题
- 页码
- 标签
把文本块变成可做相似度检索的向量。

三、切块为什么这么重要?
Section titled “三、切块为什么这么重要?”切块大小很像“做笔记时一张卡片写多少内容”。
- 太大:一次装太多,检索不精准
- 太小:上下文不够,回答容易断裂
这没有唯一标准,但一定要围绕任务调。
类比一下:
做开卷考试笔记时,你不会把整本书粘成一张超大海报,也不会把每个字都剪成一张纸条。

四、一个最小可运行的切块示例
Section titled “四、一个最小可运行的切块示例”import re
text = """退款政策:课程购买后 7 天内,如果学习进度低于 20%,可以申请退款。超过 7 天后,不再支持无条件退款。
证书说明:完成所有必修项目并通过结课测试后,可以获得结业证书。
学习顺序:建议先学习 Python、数据分析、机器学习,再进入深度学习和大模型阶段。""".strip()
def split_into_sentences(text): parts = re.split(r"[。!?\\n]+", text) return [p.strip() for p in parts if p.strip()]
sentences = split_into_sentences(text)print("句子列表:")for s in sentences: print("-", s)预期输出:
句子列表:- 退款政策:- 课程购买后 7 天内,如果学习进度低于 20%,可以申请退款- 超过 7 天后,不再支持无条件退款- 证书说明:- 完成所有必修项目并通过结课测试后,可以获得结业证书- 学习顺序:- 建议先学习 Python、数据分析、机器学习,再进入深度学习和大模型阶段如果句子已经比较短,你可以直接把句子当 chunk。 但更多时候,我们会把几句组合成一个块。
五、带重叠的切块
Section titled “五、带重叠的切块”为什么很多 RAG 系统会做 chunk overlap?
因为信息可能刚好卡在块边界上。 加一点重叠,可以减少“上下文被切断”的概率。
def chunk_sentences(sentences, chunk_size=2, overlap=1): if chunk_size - overlap <= 0: raise ValueError("chunk_size 必须大于 overlap")
chunks = [] start = 0 while start < len(sentences): end = start + chunk_size chunk = " ".join(sentences[start:end]) chunks.append(chunk) start += chunk_size - overlap return chunks
chunks = chunk_sentences(sentences, chunk_size=2, overlap=1)
print("切块结果:")for i, chunk in enumerate(chunks): print(f"[chunk {i}] {chunk}")预期输出:
切块结果:[chunk 0] 退款政策: 课程购买后 7 天内,如果学习进度低于 20%,可以申请退款[chunk 1] 课程购买后 7 天内,如果学习进度低于 20%,可以申请退款 超过 7 天后,不再支持无条件退款[chunk 2] 超过 7 天后,不再支持无条件退款 证书说明:[chunk 3] 证书说明: 完成所有必修项目并通过结课测试后,可以获得结业证书[chunk 4] 完成所有必修项目并通过结课测试后,可以获得结业证书 学习顺序:[chunk 5] 学习顺序: 建议先学习 Python、数据分析、机器学习,再进入深度学习和大模型阶段[chunk 6] 建议先学习 Python、数据分析、机器学习,再进入深度学习和大模型阶段
这段输出也暴露了朴素切块的真实局限:标题会和正文粘在一起,标点可能被清掉。生产项目里要保留 source offset,并在入库前做 chunk 抽查。
六、元数据为什么重要?
Section titled “六、元数据为什么重要?”很多新人只关注文本内容,忽略元数据。 但元数据往往直接影响检索和展示体验。
一个 chunk 常见的元数据有:
source: 来自哪个文件section: 属于哪一节page: 来自哪一页tags: 属于什么主题
比如:
chunks_with_meta = [ { "text": "课程购买后 7 天内,如果学习进度低于 20%,可以申请退款", "source": "course_policy.pdf", "section": "退款政策", "page": 3 }, { "text": "完成所有必修项目并通过结课测试后,可以获得结业证书", "source": "course_policy.pdf", "section": "证书说明", "page": 5 }]
for item in chunks_with_meta: print(item)元数据的价值在于:
- 便于过滤
- 便于引用来源
- 便于后续 UI 展示
七、如果你的目标是“知识库驱动的 SOP 文档助手”,切块方式要多想一步
Section titled “七、如果你的目标是“知识库驱动的 SOP 文档助手”,切块方式要多想一步”这类项目和普通 FAQ 问答有一个很大的不同:
- 你不是只想“找到相关段落”
- 你还想把资料重新组织成“政策 / 处理案例 / 复核清单”
所以第一次做时,切块不要只按长度想, 还要按“内容类型”想。
更稳的默认思路通常是:
| 内容类型 | 更适合怎么切 |
|---|---|
| 政策条款 | 把条件、动作和例外保留在同一块 |
| 处理案例 | 把事件、判断、证据和结果放在同一块 |
| 复核清单 | 一项操作检查对应一块,方便后面放入固定栏目 |
| 流程摘要 | 保留标题和关键步骤 |
这张表很重要,因为它会帮新人意识到:
切块不是固定的文本操作,它其实在服务后面的生成目标。

八、一个更像 SOP 文档项目的知识块示例
Section titled “八、一个更像 SOP 文档项目的知识块示例”sop_chunks = [ { "topic": "退款升级", "content_type": "policy", "section": "政策规则", "page": 1, "text": "重复扣费退款必须带交易证据升级处理。", }, { "topic": "退款升级", "content_type": "case", "section": "处理案例", "page": 2, "text": "一次失败结账后客户被扣款两次,客服核对两笔扣款后升级给 billing。", }, { "topic": "退款升级", "content_type": "checklist", "section": "复核清单", "page": 3, "text": "确认交易号、支付渠道状态、退款窗口和升级负责人。", },]
for item in sop_chunks: print(item["content_type"], "->", item["text"])这个例子最值得新人注意的是:
- 同一个主题下,知识块最好还能再分政策、处理案例、复核清单
- 这样后面生成 Word SOP 时,系统就知道什么该放进哪个栏目
九、向量化到底在做什么?
Section titled “九、向量化到底在做什么?”向量化的核心,是把文本块映射到一个“语义空间”里。
这样查询和文档块都能变成向量,然后比较相似度。
为了保证代码直接能跑,我们先用一个极简的词袋向量来模拟这个过程。
import mathimport refrom collections import Counter
chunks = [ "课程购买后 7 天内,如果学习进度低于 20%,可以申请退款", "完成所有必修项目并通过结课测试后,可以获得结业证书", "建议先学习 Python、数据分析、机器学习,再进入深度学习和大模型阶段"]
def tokenize(text): words = re.findall(r"[a-zA-Z0-9_]+", text.lower()) cjk_chars = re.findall(r"[\u4e00-\u9fff\u3040-\u30ff]", text) cjk_bigrams = ["".join(cjk_chars[i:i + 2]) for i in range(len(cjk_chars) - 1)] return words + cjk_bigrams
vocab = sorted(set(token for chunk in chunks for token in tokenize(chunk)))vocab_index = {word: idx for idx, word in enumerate(vocab)}
def vectorize(text): vec = [0] * len(vocab) counts = Counter(tokenize(text)) for word, count in counts.items(): if word in vocab_index: vec[vocab_index[word]] = count return vec
def cosine_similarity(a, b): dot = sum(x * y for x, y in zip(a, b)) norm_a = math.sqrt(sum(x * x for x in a)) norm_b = math.sqrt(sum(y * y for y in b)) if norm_a == 0 or norm_b == 0: return 0.0 return dot / (norm_a * norm_b)
query = "怎么申请退款"query_vec = vectorize(query)
scores = []for chunk in chunks: score = cosine_similarity(query_vec, vectorize(chunk)) scores.append((score, chunk))
scores.sort(reverse=True)for score, chunk in scores: print(round(score, 4), "->", chunk)预期输出:
0.3693 -> 课程购买后 7 天内,如果学习进度低于 20%,可以申请退款0.0 -> 建议先学习 Python、数据分析、机器学习,再进入深度学习和大模型阶段0.0 -> 完成所有必修项目并通过结课测试后,可以获得结业证书这就是“检索”的最小原理版。
十、真实项目里通常会更复杂
Section titled “十、真实项目里通常会更复杂”真实 RAG 系统里,向量化一般会用专门的 embedding 模型,而不是简单词频。
但思路是一致的:
- 查询转向量
- 文档块转向量
- 在向量空间里找最相近的块
所以别被“向量数据库”这个词吓到。 它本质上还是在做相似度检索,只是规模更大、效率更高。
十一、文档处理最容易出问题的地方
Section titled “十一、文档处理最容易出问题的地方”chunk 太大
Section titled “chunk 太大”召回不精准,浪费上下文。
chunk 太小
Section titled “chunk 太小”信息不完整,模型看到的片段支离破碎。
把标题、层级、表格结构等有价值信息也删掉了。
后面很难解释“答案来自哪里”。
只按长度切块,不按任务切块
Section titled “只按长度切块,不按任务切块”对 SOP 文档生成项目来说,这会导致:
- 案例和决策证据被拆散
- 政策和清单混在一起
- 后面很难稳定组装成固定格式文档
文档处理验收表
Section titled “文档处理验收表”做完文档处理后,不要只看“生成了多少 chunk”,而要检查这些 chunk 是否真的能支撑后续问答。
| 检查项 | 合格表现 | 常见问题 |
|---|---|---|
| 文本清洗 | 去掉页眉页脚、重复空白、无意义噪声 | 清洗过头,把标题和表格结构删掉 |
| chunk 完整性 | 一个 chunk 能表达完整事实或完整步骤 | 关键条件被切到相邻 chunk |
| chunk 粒度 | 能被准确召回,也不会太碎 | 太大召回不准,太小证据不完整 |
| 元数据 | 保留 source、section、page、topic、content_type | 答案无法引用来源,无法按主题过滤 |
| 样例抽查 | 随机抽 10 个 chunk 人工看一遍 | 只看数量,不看质量 |
最实用的做法是先做一份“chunk 抽查表”。每次改切块规则后,随机抽几条 chunk,判断它们是否适合被检索、引用和展示。
一个 chunk 质量抽查脚本
Section titled “一个 chunk 质量抽查脚本”下面这个脚本不依赖外部库,只用于帮你建立检查习惯。真实项目里可以把抽查结果写入 CSV 或 Markdown。
chunks_with_meta = [ { "id": "policy_001_01", "text": "课程购买后 7 天内,如果学习进度低于 20%,可以申请退款", "source": "course_policy.pdf", "section": "退款政策", "page": 3, "content_type": "policy", }, { "id": "policy_001_02", "text": "完成所有必修项目并通过结课测试后,可以获得结业证书", "source": "course_policy.pdf", "section": "证书说明", "page": 5, "content_type": "rule", },]
required_fields = {"id", "text", "source", "section", "page", "content_type"}
for chunk in chunks_with_meta: missing = required_fields - set(chunk) too_short = len(chunk["text"]) < 10 too_long = len(chunk["text"]) > 300 print({ "id": chunk.get("id"), "missing_fields": sorted(missing), "too_short": too_short, "too_long": too_long, "preview": chunk["text"][:40], })预期输出:
{'id': 'policy_001_01', 'missing_fields': [], 'too_short': False, 'too_long': False, 'preview': '课程购买后 7 天内,如果学习进度低于 20%,可以申请退款'}{'id': 'policy_001_02', 'missing_fields': [], 'too_short': False, 'too_long': False, 'preview': '完成所有必修项目并通过结课测试后,可以获得结业证书'}这个脚本不会替你判断语义质量,但能先发现一类基础问题:字段缺失、chunk 过短、chunk 过长、来源不可追踪。
切块策略对比记录
Section titled “切块策略对比记录”建议每次尝试一种切块策略,都用固定格式记录结果。
- 按句子切:1 句 1 块。简单、召回精准,但很多证据会不完整,只适合短 FAQ。
- 滑动窗口:2-4 句,overlap 1。不容易切断上下文,但 chunk 数量会增加,适合作为 baseline。
- 按标题层级切:把 H2/H3 下内容作为块。能保留结构,但长章节可能过大,适合教程和文档。
- 按内容类型切:把政策、案例、清单分开。适合生成 SOP 文档,但需要解析或标注,适合结构化项目。
如果你不知道从哪里开始,建议先用“标题层级 + 滑动窗口”作为 baseline,再根据评估集调整。
学完这一页,至少保留这张证据卡:
- 查询
- 一个用户问题或测试用例
- 已检索分块
- 分块 ID、分数和来源标题
- 答案
- 带引用或来源说明的最终回答
- 失败检查
- 缺少证据、切分错误、文档过时或论断无依据
- 下一步动作
- 分块、embedding、重排、Prompt 或评估改动
这节课最关键的认识是:
RAG 的前处理不是配角,而是效果上限的重要来源。
检索做不好,生成几乎不可能稳定做好。 所以文档清洗、切块、元数据、向量化,都是必须认真设计的环节。
- 调整
chunk_size和overlap,观察切块结果有什么变化。 - 往
chunks里加入一条和退款完全无关的文本,再看检索分数排序。 - 思考:如果一个政策条款跨了两段,怎么设计 chunk 才不容易把关键信息切断?
- 如果你的目标是生成 SOP 文档,想一想:政策、处理案例、复核清单为什么不适合完全用同一种切块方式?
参考实现与讲解
- 小 chunk 更容易精确检索,但可能丢上下文;大 chunk 保留更多上下文,但信号容易被稀释。Overlap 可以降低边界信息被切掉的风险。
- 与退款无关的文本在退款问题下应排得很低。如果它排得很高,说明 embedding 或 scoring 方法没有很好地区分意图。
- 优先按语义边界切分,再用 overlap 或 parent-child chunks 处理跨段条款。目标是让每个可检索单元都包含足以支撑回答的信息。
- 政策需要完整条件和例外,案例需要证据和结果,清单需要清楚的操作检查。统一切法可能会切断决策证据,也可能让政策检索变得太噪。