创建日期:2026-09-23 | 最近更新:2026-09-23 官方说法均为逐字原文(LangChain 源码 / Anthropic Contextual Retrieval,抓取日期 2026-09-23,留档于
source/rag-chunking-lab/official-sources.md);所有数字为本机实测(RTX 3090 / torch 2.10.0 / transformers 5.7.0 / bge-small-zh-v1.5,脚本与真实输出source/rag-chunking-lab/run.log)。我自己的推断会明确标成推断。
切片与向量化:不同类型文档怎么切、怎么算成向量
一句话:切片是把「文档」变成「可检索单元」的那一步,切错了后面全白搭——检索、重排、生成,没有任何一环能补救「答案所在的块根本没被正确表达出来」这件事。
入门篇说链路有五个环节,这篇拆第一环和它后面紧挨着的那半环:怎么切(chunking)、怎么算成向量(embedding)。两件事必须一起讲,因为切法决定了向量能不能表达出内容——它们不是先后关系,是同一个决定的两面。
0. 先给结论
| 文档类型 | 怎么切 | 最容易踩的坑 |
|---|---|---|
| Markdown / 技术文档 | 按标题层级切;超长章节再按段落递归 | 代码块和表格被切两半(实测:定长切法把 8 个代码块、9 张表格切散) |
| 先清洗再切,清洗没做完不要往下走 | 抽取物里混着控制符和「形近字」,实测 457 个控制符、261 个康熙部首字符 | |
| 表格 / 电子表格 | 要么补列名、要么句子化;精确数值查询交给结构化/关键词 | 列的数值部分没有列名,query 里的「地区名」无从匹配;纯向量检索分不清日期和数值 |
| Word / DOCX | 用样式(标题/列表)当切分依据,别拍平成纯文本 | 样式信息一旦丢掉,就只剩一堆没边界的段落 |
| 代码 | 按函数/类边界切,超长函数再按语句块切 | 函数长度差异极大(实测我自己两个文件:一个 120 行的函数,另一份 6 个定义各自很短) |
| 对话 / 日志 | 按「轮次」或「时间窗」切,别按字数 | 一轮问答被劈成两半,谁问的、答的是什么全没了 |
贯穿全篇的三条硬约束:
- embedding 的长度上限是按 token 算的,超了不报错、静默丢弃尾部(实测:一个 955 token 的块,答案在第 920 个 token 上,等于没进模型);
- 块越大,检索命中率越低(实测 8 个 query:128 token 的块命中 4/8,1024 token 的块只命中 1/8)——但块太小又会缺上下文,这是切片的根本矛盾;
- 稠密向量擅长「是不是同一件事」,不擅长「到底是哪一天、哪个数」(实测:不同日期不同价格的句子,相似度差 0.0001)。
1. 先搞清一个量纲问题:token ≠ 字符
所有 embedding 模型的长度上限(max_length)都是按 token 算的,而人写文档时习惯按字符想事情。这两种量纲在不同内容上差得很远:
| 内容类型 | 字符数 | token 数 | 字符/token |
|---|---|---|---|
| 中文正文 | 35 | 35 | 1.00 |
| 英文正文 | 118 | 37 | 3.19 |
| Python 代码 | 89 | 44 | 2.02 |
| Markdown 表格行 | 66 | 42 | 1.57 |
同一个「1000 字符」,中文块是约 1000 token,英文块只有约 313 token。中文语料里按字符切 ≈ 按 token 切,英文语料里差三倍——一份中英混排的文档,用同一个字符数去切,两边的 token 数会差出量级。
所以第一件事:块大小要用 token 计量,用目标模型的 tokenizer 数出来。(代码里就是 len(tokenizer(text)["input_ids"]),别用 len(text)。)
1.1 超长块的后果:不报错,只是悄悄丢掉尾部
把答案放在一个 955 token 块的最后 35 个 token 上,用同一个 query 去检索,实测:
长块:955 tokens(答案在最后 35 tokens)
短块:35 tokens(只有答案)
模型 max_length = 512 → 长块实际参与编码 512 / 955 tokens,丢了 443 tokens
答案起始位置在第 920 个 token ⚠️ 超出 512,根本进不了模型
查询:重排的分差中位数是多少?
+0.7010 短块(只有答案)
+0.2822 长块(答案在尾部)
+0.2083 无关块
长块的相似度(0.28)甚至还不如它的「只有答案」的短版本(0.70)高。 因为长块送进模型的是前半段那堆填充文本,答案那 35 个 token 被截掉了。
这是切片里最容易被忽略的一条:块太大不是「上下文更全」,而是「尾部内容根本没进模型」。而且它不抛异常、不报警告,只是检索不到——排查起来非常费劲。
2. 四种切法
| 切法 | 切点由什么决定 | 优点 | 代价 |
|---|---|---|---|
| ① 定长硬切 | 字数 | 实现最简单、块大小最可控 | 会把句子、代码块、表格从中间劈开 |
| ② 递归分隔 | 分隔符优先级 | 尽量落在段落/句子边界;大小仍可控 | 不认文档结构(标题、表格边界它看不见) |
| ③ 按结构切 | 文档自身的结构(标题层级、函数、轮次) | 语义单元最完整 | 块大小不受控,可能远超模型上限 |
| ④ 语义切分 | 相邻句子的语义相似度(在语义转折处下刀) | 理论上最贴合「一个块一个主题」 | 要先跑一遍 embedding,成本高;本篇没实测 |
② 的典型实现是 LangChain 的 RecursiveCharacterTextSplitter,它的默认分隔符是(逐字,来自源码 langchain_text_splitters/character.py):
self._separators = separators or ["\n\n", "\n", " ", ""]
顺序即优先级:先按空行(段落)切,段落还超长就按单换行、再按空格、最后是空字符串(硬切兜底)。中文语料要把它改掉——中文没有空格分隔词,句号才是天然的断句点,所以实测脚本里用的是:
CN_SEPARATORS = ["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""]
2.1 实测:四种切法在同一份文档上的形态
语料是本专题自己的两篇文档(真实技术 Markdown:有多级标题、代码块、表格、mermaid 图),共 19437 字符 / 12147 tokens。
| 切法 | 块数 | token 中位 | token 最大 | 未闭合代码围栏 | 表格残片 |
|---|---|---|---|---|---|
| ① 定长硬切 400 字符 | 49 | 263 | 330 | 8 | 9 |
| ① 定长硬切 800 字符 | 25 | 531 | 639 | 6 | 4 |
| ② 递归分隔 256 token | 68 | 202 | 255 | 6 | 7 |
| ② 递归分隔 512 token | 29 | 449 | 511 | 4 | 0 |
| ③ 按标题切(不限长) | 27 | 710 | 1347 | 0 | 0 |
| ④ 按标题切 + 超长再切 512 | 64 | 384 | 507 | 4 | 6 |
「未闭合代码围栏」= 该块里 ``` 出现奇数次,说明一个代码块被切成了两半。 「表格残片」= 该块有表格行却没有表头分隔行,说明一张表被切散了。
两个读数:
- 定长硬切把结构切得最碎:8 个代码块被劈开、9 张表格变成「没有表头的残片」。这不是理论风险,是这份文档上真实发生的事。
- 按标题切完整性满分(0 / 0),但最大块 1347 token —— 是模型上限 512 的两倍多,直接命中 §1.1 那个静默截断。所以「按结构切」必须配一个上限:超长章节再递归切(也就是表格里的第 ④ 行)。
3. 块大小与重叠:一条实测曲线
同一份语料、8 个 query(答案位置在语料中唯一,脚本自动断言),比较 hit@1 / hit@3:
| 切法 | 块数 | hit@1 | hit@3 |
|---|---|---|---|
| ② 递归 128 token,重叠 0% | 149 | 4/8 | 5/8 |
| ② 递归 128 token,重叠 20% | 202 | 3/8 | 5/8 |
| ② 递归 256 token,重叠 0% | 68 | 3/8 | 4/8 |
| ② 递归 256 token,重叠 20% | 85 | 3/8 | 4/8 |
| ② 递归 512 token,重叠 0% | 29 | 2/8 | 3/8 |
| ② 递归 512 token,重叠 20% | 40 | 5/8 | 5/8 |
| ② 递归 1024 token,重叠 0% | 14 | 1/8 | 2/8 |
| ② 递归 1024 token,重叠 20% | 17 | 2/8 | 3/8 |
| ③ 按标题切(不限长) | 27 | 3/8 | 4/8 |
| ④ 按标题切 + 超长再切 512 | 64 | 3/8 | 4/8 |
⚠️ 样本量只有 8 个 query,请只看趋势、不要读单点。 相邻两行差 1 个命中的差异完全在噪声里(比如「512 + 重叠 20%」的 5/8 很可能是运气好)。这类实验要做到能信,query 得按百算——这也是下一篇(召回评测)要处理的问题。把它放出来,是因为趋势是稳的,而且和已知结论一致。
能读出来的两点:
- 块越大,命中越低(128 token 4/8 → 1024 token 1/8)。原因不难理解:块一大,一段里往往混了好几个主题,向量被稀释成一个「平均语义」,哪个主题都不像。这与「块大 = 信息全 = 检索好」的直觉相反。
- 重叠(overlap)在中等块上帮助明显(512 token:2/8 → 5/8),在小块上几乎无效(128 token 时 4/8 → 3/8)。因为重叠解决的是「答案正好跨在两个块的边界上」——块越小,边界越多,重叠会带来大量重复内容,反而稀释。
3.1 那为什么不干脆全切成小块
因为检索和生成要的东西不一样:检索要「块小、专一、好匹配」,生成要「上下文完整、能回答」。小块检索得准,但把小块喂给模型,它可能缺背景(这段说的是哪家公司、哪一年、哪份文件?)。
Anthropic 官方文档对这一环的表述是(逐字,Contextual Retrieval):
Break down the knowledge base (the “corpus” of documents) into smaller chunks of text, usually no more than a few hundred tokens
他们给的标准解法是在块前面补一段「这个块在讲什么」的上下文再嵌入——原文叫 Contextual Retrieval,补的内容「通常是 50-100 tokens」,是拿文档全文当背景让模型生成的。官方给出的效果(逐字):
Contextual Embeddings reduced the top-20-chunk retrieval failure rate by 35% (5.7% → 3.7%). Combining Contextual Embeddings and Contextual BM25 reduced the top-20-chunk retrieval failure rate by 49% (5.7% → 2.9%).
Contextual Retrieval ... can reduce the number of failed retrievals by 49% and, when combined with reranking, by 67%.
三个数字连起来看很有意思:上下文增强 −49%,再叠上重排 −67%。 也就是说「切得好」和「排得好」是叠加收益,不是二选一。成本方面官方给的是「每百万文档 token 约 $1.02」(一次性预处理)。
3.2 工程上的常见折中:小块检索、大块供给
既然两者的偏好相反,实践中常见三种做法(以下是我对公开做法的归纳,非某份文档原文):
| 做法 | 思路 |
|---|---|
| 父子块(small-to-big) | 索引小块用于匹配,命中后按小块找到它所属的大块,把大块喂给模型 |
| 句子窗口 | 命中某句后,把它的前后 N 句一起给模型 |
| 块 + 标题路径 | 在每个块前面拼上它的标题路径(入门 > 五、切片 > 3.1 块大小),用极低成本补回一部分上下文 |
第三种在实测里就是「④ 按标题切」那个版本——脚本里给每个块加了 [标题路径] 前缀。它便宜、可解释,而且顺带让「块属于哪一节」变得可检索。
4. 按文档类型分:解析坑 + 切法
4.1 Markdown / 技术文档
优势:结构是显式的。 # 就是层级,``` 就是代码块,| 就是表格。切分器可以直接用这些标记,不必猜。
要点:
- 按标题切,并把标题路径拼进块首;
- 代码块和表格是原子单元,不许切开——判据很简单:块里 ``` 的个数必须是偶数;表格块的头部必须带表头分隔行;
- 超长章节再上递归切(否则命中 §1.1 的截断);
- 切之前先剥掉渲染-only 的东西(徽章、导入语句、目录),它们会污染向量。
4.2 PDF:最容易埋雷的一类
PDF 是「打印格式」,不是「内容格式」——它只记录字画在纸上的位置,不记录段落、标题、表格结构。所以 PDF 抽取出来的文本,带着一堆版式噪声。
我拿本机一本 81 页的中文技术书 PDF 实测(抽第 10~39 页,共抽出 10432 字符),三类污染:
① 控制字符。 457 个 \x01:
'...Docker version 20.10.6, build 370c289\n代理服务\x01\x01\n通过以上步骤成功安装\x01\ndocke...'
② 康熙部首字符——看着是汉字,其实不是。 落在 Unicode「康熙部首 / CJK 部首补充」区间的字符有 41 种、共 261 次:
| 抽出来的字符 | 码点 | Unicode 名称 | 次数 |
|---|---|---|---|
| ⽤ | U+2F64 | KANGXI RADICAL USE | 31 |
| ⽂ | U+2F42 | KANGXI RADICAL SCRIPT | 21 |
| ⼀ | U+2F00 | KANGXI RADICAL ONE | 20 |
| ⾏ | U+2F8F | KANGXI RADICAL WALK ENCLOSURE | 19 |
| ⽣ | U+2F63 | KANGXI RADICAL LIFE | 19 |
对照一下:正常汉字「网」是 U+7F51,而 PDF 里的「⽹」是 U+2F79。字形几乎一样,码点完全不同。
③ 后果是双重的:
'网络' 在抽取文本中出现 0 次
'⽹络' 在抽取文本中出现 2 次
分词器(bge-small-zh-v1.5):
'网络配置' → ['网', '络', '配', '置']
'⽹络配置' → ['[UNK]', '络', '配', '置'] ← 部首字符变成未知词
- 关键词 / BM25 检索「网络」,在这份文档里命中 0 次——不是文档没写,是那几处被抽成了「⽹络」;
- embedding 侧,那个字符直接变成
[UNK],语义丢了。
这类问题不会报错,也不会在抽样人眼检查时暴露(字形太像了)。能做的是在清洗阶段统一处理:按 Unicode 区间把康熙部首(U+2E80–U+2FDF)做 NFKC 归一化或映射回对应汉字,再删掉控制字符。这一步必须在切片之前做完——切完再发现,就得重切重嵌,索引全部作废。
⚠️ 这是一份 PDF 的实测,不是所有 PDF 都这样(取决于生成器、字体、是否扫描件)。但「先检查抽取质量、再决定切法」这个顺序是通用的:打印版式 → 文本流,这一步的信息损失永远存在。
4.3 表格:别指望向量检索做精确匹配
拿本机一份真实的日度价格统计表实测(xlsx,2679 行 × 14 列,2014–2021 年全国鸡蛋批发价,本次取前 400 行)。四种切法,两组 query:
| 切法 | 块数 | ① 带日期 query 的 hit@1 |
|---|---|---|
| B 按行拍平(无列名) | 400 | 0/4 |
| C 每行带列名 | 400 | 0/4 |
| D Markdown 表格(每 10 行带表头) | 40 | 0/4 |
| E 句子化(一格一句) | 5200 | 0/4 |
四种切法全灭。 于是去看第二个问题:列名到底能不能被检索到?(query 只提地区名、不提日期)
| 切法 | top-1 相似度 | top-1 里出现该地区名? |
|---|---|---|
| B 按行拍平(无列名) | 0.3462 | ❌ 没有 |
| C 每行带列名 | 0.3638 | 有 |
| D Markdown 表格 | 0.3886 | 有 |
| E 句子化 | 0.8781 | 有 |
B 的块里根本没有「天津市」这三个字(那一列只有数字),所以这条 query 无从匹配,返回的是一堆和天津无关的行。列名是表格能被检索的前提。
那为什么 E(句子化)在带日期的查询上也是 0/4?追下去看它 top-4 的分数:
query: 2014年01月02日天津市的鸡蛋批发价格是多少?
gold : 2014年01月02日,天津市的鸡蛋批发价格为 8.9 元/公斤。
+0.9064 2014年01月26日,天津市的鸡蛋批发价格为 9.1 元/公斤。
+0.9064 2014年01月23日,天津市的鸡蛋批发价格为 9.1 元/公斤。
+0.9064 2014年01月25日,天津市的鸡蛋批发价格为 9.1 元/公斤。
+0.9063 2014年01月03日,天津市的鸡蛋批发价格为 8.9 元/公斤。
top-4 的分数极差:0.0001
不同日期、不同价格,四条候选的相似度几乎完全相同(差 0.0001)。 这就是 0/4 的真正原因,不是切法的问题,是模型的边界:稠密向量擅长「说的是不是同一件事」,不擅长「到底是哪一天、哪个数」。
所以表格这类内容有两条路(推断 + 公开做法归纳):
- 不要只靠向量检索。 精确事实查询要配关键词通道(BM25 对「2014年01月02日」这种串是精确匹配)或结构化过滤(把日期/地区当成 filter 字段),也就是混合检索——这也正好解释了为什么 Anthropic 的方案里是「Contextual Embeddings + Contextual BM25」而不是只加向量;
- 要切就切得「像一句话」:一格一句、补全列名和单位(「2014年01月02日,天津市的鸡蛋批发价格为 8.9 元/公斤。」)。实测里这一招把「地区名可检索性」的相似度从 0.35 拉到 0.88,代价是块数从 400 涨到 5200。
4.4 Word / DOCX:样式是免费的切分信号
DOCX 是结构化的——段落带样式名。实测本机一份真实 DOCX 样本(15 个段落):
样式分布:{List Bullet: 8, Heading 2: 4, Heading 1: 1, Normal: 2}
15 段里有 5 段是标题样式、8 段是列表项——这些信息足够直接拿来当切分依据(Heading 1/2 分层、List Bullet 聚成一组)。
反面做法是「先转成纯文本再按字数切」:一转换,样式全丢,剩下的就是一串没有边界的段落。能用结构就用结构,字数切分是最后手段。
(这一节是小样本观察:只看了 1 份 DOCX 的样式分布,没有跑检索效果对比。)
4.5 代码:按定义边界切,但函数长度差异很大
代码文档的天然切点是函数/类边界。但这条路有个现实问题——函数长度差异极大。实测我自己两个脚本文件:
| 文件 | 行数 | 顶层定义数 | 块大小情况 |
|---|---|---|---|
chunkers.py | 135 | 6 | 6 块,各自都很短,切得很自然 |
bench_chunking.py | 188 | 2 | 其中 main() 一个函数就 120 行 |
所以「按定义切」不能单独用:一个 120 行的函数切出来还是一大块(很可能超 token 上限)。可行的组合是按定义切 + 超长定义再按语句块/空行切,切的时候把函数签名(def rerank(query, docs, top_n=5))复制到每个子块开头——否则子块会变成一堆不知道自己在哪个函数里的语句。
5. 向量化策略
切法定了,还有一半决定在「怎么算成向量」上。
5.1 模型选择
| 场景 | 建议 | 依据 |
|---|---|---|
| 中文语料 | 中文/多语语料训练的模型(如 bge-zh 系列、bge-m3) | 本专题实测都用 BAAI/bge-small-zh-v1.5 |
| 中英混排 | 多语言模型,别用纯英文模型 | 纯英文模型对中文基本等于随机 |
| 需要长块 | 先看模型的 max_length,再看有没有支持长上下文的型号 | §1.1 的截断就是没看这个造成的 |
选型时先确认
max_length,再决定块大小。顺序反了就要重切重嵌。
5.2 query 侧指令:同一个模型,query 和文档的写法不一样
BGE 中文系列的模型卡给了 query 的指令前缀(逐字):
为这个句子生成表示以用于检索相关文章:
用法是只加在 query 上,文档侧不加。这类「query 指令」是训练时约定的,加了才在同一个语义空间里;加反了、或者两边都加,检索质量会掉。
5.3 归一化与相似度
把向量归一化(L2)之后,余弦相似度和点积等价,可以用点积直接算(更快)。本专题的脚本全部这么写:
v = model(**feat).last_hidden_state[:, 0] # cls pooling
v = torch.nn.functional.normalize(v, dim=1) # 归一化
# 之后用矩阵乘法:sim = query_vec @ chunk_vecs.T
注意 pooling 方式要跟模型训练时一致(BGE 用 cls pooling)。用错 pooling 不会报错,只会让效果悄悄变差。
5.4 必须配一条关键词通道
§4.3 已经用实测说明了:日期、编号、型号、专有名词这类「精确串」,稠密向量分不出来。这不是模型不够大能解决的,是这类表示方式的固有边界。
所以工程上的标准答案不是「换更大的 embedding 模型」,而是混合检索:向量通道负责「语义相近」,BM25/关键词通道负责「精确命中」,再用 RRF 之类的方法融合。Anthropic 的官方方案也是这么做的(Contextual Embeddings + Contextual BM25)。
5.5 上下文增强:让块「说清自己在讲什么」
§3.1 引的 Anthropic 数据(失败率 5.7% → 2.9% → 叠加重排 2.9% 再降)就是这条路线的收益。核心动作只有一句话:在嵌入之前,给每个块补一段「它是从哪来的、在讲什么」。补的内容 50–100 token,生成一次、永久受益。
6. 参数速查
| 参数 | 起点建议 | 怎么调 |
|---|---|---|
| 块大小 | 按 token 计,先看模型 max_length,取它的 1/2 ~ 1/4 | 实测趋势是「越大越差」,从小往大调,别从大往小调 |
| 重叠 | 10%~20%,且只在块偏小时有效 | 重叠把块数推高(实测 512 token:29 → 40 块),要连索引体积一起算 |
| 切法 | 文档有结构就用结构(标题/样式/函数),没有才退回递归 | 结构性切法必须配「超长再切」的上限 |
| 表格 | 补列名;能句子化就句子化 | 精确数值查询另配关键词/结构化通道 |
| 预处理 | PDF/HTML 先清洗(控制符、形近字、页码页眉) | 清洗在前,切片在后,顺序不能换 |
7. 六个坑
- 按字符数切块。 中文 1 字符 ≈ 1 token,英文 1 token ≈ 3.2 字符,同一份中英混排文档会切出完全不同的 token 分布。
- 块超过模型
max_length。 不报错,静默丢掉尾部——实测答案在第 920 个 token 时,相似度从 0.70 掉到 0.28。 - 把代码块和表格从中间切开。 实测定长硬切把 8 个代码块、9 张表格切散。
- PDF 抽完直接用。 457 个控制符、261 个康熙部首字符、关键词检索 0 命中、embedding 拿到
[UNK]。 - 表格不补列名。 数值列没有列名,query 里的地区名无从匹配(相似度 0.35 vs 0.88)。
- 指望向量检索做精确匹配。 不同日期不同价格的句子相似度差 0.0001——该上 BM25 或结构化过滤的地方,换再大的模型也没用。
8. 来源与核对
| 引用 | 来源 | 存档 |
|---|---|---|
递归切分的默认分隔符 ["\n\n", "\n", " ", ""] | LangChain 源码 libs/text-splitters/langchain_text_splitters/character.py | official-langchain-character.py |
| 「几百 token 的块」、Contextual Retrieval 定义与成本、失败率 5.7%→3.7%→2.9%、叠加重排 −67% | Anthropic《Contextual Retrieval in AI Systems》 | official-anthropic-contextual-retrieval.txt |
| query 指令「为这个句子生成表示以用于检索相关文章:」 | HF 模型卡 BAAI/bge-small-zh-v1.5 | 见 Rerank 篇的留档 |
实测脚本与真实输出:source/rag-chunking-lab/
bench_chunking.py(§1/§2/§3 的数字)、bench_table.py(§4.3)、pdf_artifacts.py(§4.2)、chunkers.py(四种切法实现)、run.log。
复现环境:python 3.10、torch 2.10.0+cu126、transformers 5.7.0、pypdf 6.19.0、openpyxl 3.1.5、NVIDIA RTX 3090。跑法 bash source/rag-chunking-lab/run.sh。
样本说明:Markdown 语料 = 本专题自己的两篇文档(随仓库);xlsx = 本机一份真实价格统计表;PDF = 本机一本 81 页中文技术书(样本文件未随仓库分发,脚本支持 PDF_PATH 指定)。