跳到主要内容

创建日期: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 张表格切散)
PDF先清洗再切,清洗没做完不要往下走抽取物里混着控制符和「形近字」,实测 457 个控制符、261 个康熙部首字符
表格 / 电子表格要么补列名、要么句子化;精确数值查询交给结构化/关键词列的数值部分没有列名,query 里的「地区名」无从匹配;纯向量检索分不清日期和数值
Word / DOCX用样式(标题/列表)当切分依据,别拍平成纯文本样式信息一旦丢掉,就只剩一堆没边界的段落
代码按函数/类边界切,超长函数再按语句块切函数长度差异极大(实测我自己两个文件:一个 120 行的函数,另一份 6 个定义各自很短)
对话 / 日志按「轮次」或「时间窗」切,别按字数一轮问答被劈成两半,谁问的、答的是什么全没了

贯穿全篇的三条硬约束:

  1. embedding 的长度上限是按 token 算的,超了不报错、静默丢弃尾部(实测:一个 955 token 的块,答案在第 920 个 token 上,等于没进模型);
  2. 块越大,检索命中率越低(实测 8 个 query:128 token 的块命中 4/8,1024 token 的块只命中 1/8)——但块太小又会缺上下文,这是切片的根本矛盾;
  3. 稠密向量擅长「是不是同一件事」,不擅长「到底是哪一天、哪个数」(实测:不同日期不同价格的句子,相似度差 0.0001)。

1. 先搞清一个量纲问题:token ≠ 字符​

所有 embedding 模型的长度上限(max_length)都是按 token 算的,而人写文档时习惯按字符想事情。这两种量纲在不同内容上差得很远:

内容类型字符数token 数字符/token
中文正文35351.00
英文正文118373.19
Python 代码89442.02
Markdown 表格行66421.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 字符4926333089
① 定长硬切 800 字符2553163964
② 递归分隔 256 token6820225567
② 递归分隔 512 token2944951140
③ 按标题切(不限长)27710134700
④ 按标题切 + 超长再切 5126438450746

「未闭合代码围栏」= 该块里 ``` 出现奇数次,说明一个代码块被切成了两半。 「表格残片」= 该块有表格行却没有表头分隔行,说明一张表被切散了。

两个读数:

  • 定长硬切把结构切得最碎:8 个代码块被劈开、9 张表格变成「没有表头的残片」。这不是理论风险,是这份文档上真实发生的事。
  • 按标题切完整性满分(0 / 0),但最大块 1347 token —— 是模型上限 512 的两倍多,直接命中 §1.1 那个静默截断。所以「按结构切」必须配一个上限:超长章节再递归切(也就是表格里的第 ④ 行)。

3. 块大小与重叠:一条实测曲线​

同一份语料、8 个 query(答案位置在语料中唯一,脚本自动断言),比较 hit@1 / hit@3:

切法块数hit@1hit@3
② 递归 128 token,重叠 0%1494/85/8
② 递归 128 token,重叠 20%2023/85/8
② 递归 256 token,重叠 0%683/84/8
② 递归 256 token,重叠 20%853/84/8
② 递归 512 token,重叠 0%292/83/8
② 递归 512 token,重叠 20%405/85/8
② 递归 1024 token,重叠 0%141/82/8
② 递归 1024 token,重叠 20%172/83/8
③ 按标题切(不限长)273/84/8
④ 按标题切 + 超长再切 512643/84/8

⚠️ 样本量只有 8 个 query,请只看趋势、不要读单点。 相邻两行差 1 个命中的差异完全在噪声里(比如「512 + 重叠 20%」的 5/8 很可能是运气好)。这类实验要做到能信,query 得按百算——这也是下一篇(召回评测)要处理的问题。把它放出来,是因为趋势是稳的,而且和已知结论一致。

能读出来的两点:

  1. 块越大,命中越低(128 token 4/8 → 1024 token 1/8)。原因不难理解:块一大,一段里往往混了好几个主题,向量被稀释成一个「平均语义」,哪个主题都不像。这与「块大 = 信息全 = 检索好」的直觉相反。
  2. 重叠(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+2F64KANGXI RADICAL USE31
⽂U+2F42KANGXI RADICAL SCRIPT21
⼀U+2F00KANGXI RADICAL ONE20
⾏U+2F8FKANGXI RADICAL WALK ENCLOSURE19
⽣U+2F63KANGXI RADICAL LIFE19

对照一下:正常汉字「网」是 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 按行拍平(无列名)4000/4
C 每行带列名4000/4
D Markdown 表格(每 10 行带表头)400/4
E 句子化(一格一句)52000/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 的真正原因,不是切法的问题,是模型的边界:稠密向量擅长「说的是不是同一件事」,不擅长「到底是哪一天、哪个数」。

所以表格这类内容有两条路(推断 + 公开做法归纳):

  1. 不要只靠向量检索。 精确事实查询要配关键词通道(BM25 对「2014年01月02日」这种串是精确匹配)或结构化过滤(把日期/地区当成 filter 字段),也就是混合检索——这也正好解释了为什么 Anthropic 的方案里是「Contextual Embeddings + Contextual BM25」而不是只加向量;
  2. 要切就切得「像一句话」:一格一句、补全列名和单位(「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.py13566 块,各自都很短,切得很自然
bench_chunking.py1882其中 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 字符 ≈ 1 token,英文 1 token ≈ 3.2 字符,同一份中英混排文档会切出完全不同的 token 分布。
  2. 块超过模型 max_length。 不报错,静默丢掉尾部——实测答案在第 920 个 token 时,相似度从 0.70 掉到 0.28。
  3. 把代码块和表格从中间切开。 实测定长硬切把 8 个代码块、9 张表格切散。
  4. PDF 抽完直接用。 457 个控制符、261 个康熙部首字符、关键词检索 0 命中、embedding 拿到 [UNK]。
  5. 表格不补列名。 数值列没有列名,query 里的地区名无从匹配(相似度 0.35 vs 0.88)。
  6. 指望向量检索做精确匹配。 不同日期不同价格的句子相似度差 0.0001——该上 BM25 或结构化过滤的地方,换再大的模型也没用。

8. 来源与核对​

引用来源存档
递归切分的默认分隔符 ["\n\n", "\n", " ", ""]LangChain 源码 libs/text-splitters/langchain_text_splitters/character.pyofficial-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 指定)。