← RAG 02 · 资料到底应该怎么切RAG 系列

RAG 02 配套 · Chunking Lab · 可阅读

把 RAG 切坏一次:一次真实切分实验的完整记录

这不是游戏说明,而是一份实验记录:同一份知识库、同一个 embedding 模型、同一个 top-k,只改切分方式,看检索结果怎么变。

实验在一台笔记本的 CPU 上跑完,不需要 API key,除了第一次下载模型也不需要联网;一整套评测大约十秒。

这是 RAG 02 · 资料到底应该怎么切 的配套 Lab。 游戏回答「为什么会这样」,这一页回答「这些数字是怎么跑出来的」。先玩再读,或者直接从这里开始,都可以。

现在你可以做什么

按顺序读完 12 步。每一步都有为什么、命令、真实输出和读法,不用安装任何东西,就能看到切法怎样改变检索。

如果你有自己的 RAG 项目

可以用同样的方法做一次受控对比:固定知识库、问题集、embedding 模型(钉住 revision)和 top-k,只改切分方式,逐个问题记录取回了什么。页尾「固定不变的那一半」列出了这次用的全部参数。

本站源码状态

实验代码(labs/rag-lab)和知识库目前没有公开,页面上的命令只能在作者自己的仓库里运行,这里也没有下载入口。门店与政策全部虚构。

在作者的仓库里重跑需要什么
  • Python 3.10 以上(下面的输出来自 3.11.15)
  • 约 3 GB 磁盘:依赖 2.5 GB(主要是 torch)+ 模型 470 MB
  • 一个终端。不需要 GPU,不需要账号,不需要 API key

Step 0

环境准备

为什么

一次性。虚拟环境装在 labs/rag-lab/.venv 里,和系统 Python 分开。

pip install -e . 那一行是为了之后可以直接写 python -m rag_lab,不用每次设 PYTHONPATH。

你的机器上如果不是 3.11,把第二行的 python3.11 换成你自己的(3.10 以上都行,通常是 python3)。下面所有输出来自 3.11.15。

命令

cd labs/rag-lab
python3.11 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
pip install -e .

读这几行

  • 依赖版本在 requirements.txt 里全部钉死 —— 半年后再装一次,拿到的是同一组轮子,算出的是同一批向量。
  • 模型还没下载。它在第一条需要它的命令里下载,约 470 MB。

自己改一改

后面每一条命令都假设这个虚拟环境是激活的。新开一个终端,先 source labs/rag-lab/.venv/bin/activate。

Step 1

读取 Knowledge Base

为什么

先确认知识库和评测问题本身是对的:section 编号不重复、每个问题的 Ground Truth 都指向真实存在的段落。

这一步不加载模型,不联网,一秒以内返回。

命令

python -m rag_lab validate

你应该看到

knowledge base  zh  7 documents, 61 sections
  allergens.md              7 sections   1296 chars  过敏原与交叉接触说明
  delivery.md               9 sections   1243 chars  外卖配送规则
  membership.md            10 sections   1154 chars  会员制度问答
  menu.md                   8 sections   1430 chars  菜单与可调整项
  promotion-policy.md       8 sections   1252 chars  优惠与券规则
  refund-policy.md         11 sections   1777 chars  退款与补偿政策
  store-operations.md       8 sections   1407 chars  门店操作手册
query set       11 queries, schema rag-lab.queries.v1

every evidence section resolves; ids unique; concepts agree. OK

读这几行

  • 7 份文档,61 段。「段」指的是 stable semantic section —— 知识库里每一段都有一个不随切分变化的编号,例如 refund.missing.combo-core-item。
  • Ground Truth 绑的是这些编号,不是 chunk。所以下面无论怎么改切分,评判标准都不动。

自己改一改

把 content/rag-lab/evaluation/queries.json 里某个 evidence_sections 改成一个不存在的编号,再跑一次 —— 它会指名道姓地报出来。

  • content/rag-lab/knowledge/zh/七份中文文档,section 编号写在不可见的 HTML 注释里
  • content/rag-lab/evaluation/queries.json11 个问题、期望答案、证据段落、干扰段落

Step 2

量一量文档:chunk size 从哪里来

为什么

「切多大」不该凭手感。先用这个模型自己的 tokenizer量一遍真实文档,再决定候选值。

这一步会下载模型(第一次约一分钟),之后都在本地。

命令

python -m rag_lab stats

你应该看到


tokenizer: XLMRobertaTokenizer (intfloat/multilingual-e5-small)
model window: 512 tokens, 384 dimensions

document                     chars   tokens  sections    section tokens (min/med/max)
allergens.md                  1296      976         7                  73 / 131 / 183
delivery.md                   1243      864         9                  63 / 102 / 120
membership.md                 1154      803        10                   65 / 76 / 108
menu.md                       1430     1102         8                  90 / 125 / 256
promotion-policy.md           1252      896         8                  75 / 116 / 144
refund-policy.md              1777     1308        11                  86 / 116 / 156
store-operations.md           1407     1025         8                  92 / 128 / 160

61 sections, 6974 tokens total
section tokens: min 63, median 111, mean 114, max 256
chars per token: 1.37

读这几行

  • 一段规则的中位数是 111 tokens,最短 63,最长 256(菜单那张表)。
  • 所以后面的候选值是这样挑的:64 明显小于一段,192 大约一段半,448 大约四段并且贴近模型 512 的窗口。不是「常见的 500」。
  • 1.37 个字符一个 token —— 中文大致一字一 token,但「大致」不是单位,所以全程用 token 计数。

自己改一改

这份知识库不大。换成你自己的文档跑一次 stats,中位数会完全不同,候选值也就不同。

Step 3

Fixed chunking:把文档切开

为什么

先只切,不检索。看看一把固定长度的尺子落在了哪里。

--document 只是过滤显示;索引仍然是整个知识库。

命令

python -m rag_lab chunk --strategy fixed --chunk-size 64 --overlap 0 --document refund

你应该看到


strategy fixed-64-0  {"id": "fixed-64-0", "splitter": "fixed", "unit": "tokens", "chunk_size": 64, "overlap": 0}
113 chunks, tokens min 2 / median 65 / max 66
chunks holding more than one section: 52

fixed-64-0:refund-policy#000                   64t  ## 适用范围
                                                   sections: refund.scope
fixed-64-0:refund-policy#001                   65t  规定"退多少钱、由谁发起"。配送环节本身的延误、取消与补偿,按《外卖配送规则》处理;因原
                                                   sections: refund.scope
fixed-64-0:refund-policy#002                   65t  对应规则判断是否成立,再按本政策计算金额,不重复退款。
                                                   sections: refund.scope, refund.missing.standard
fixed-64-0:refund-policy#003                   65t  付金额。实付金额指该商品在本单中实际承担的价格,已含平摊后的优惠。顾客可以选择退款,也可
                                                   sections: refund.missing.standard
fixed-64-0:refund-policy#004                   65t  ,订单中其余已送达的商品不退。顾客要求把整单退掉时,按下文"整单退款"判断是否成立。

读这几行

  • 整个知识库变成 113 块,其中 52 块横跨了不止一段规则。
  • 看第 3 块(#002):它的开头是上一段的半句话,结尾进了下一段。切口不认识段落。
  • 加 --full 可以逐块打印全文。

自己改一改

把 --chunk-size 改成 448,同一份文件从 21 块变成 3 块。

python -m rag_lab chunk --strategy fixed --chunk-size 448 --overlap 0 --document refund
  • labs/rag-lab/src/rag_lab/chunking.py两个切分器,一共不到两百行

Step 4

Embedding + 检索:一条命令里的四件事

为什么

下一条命令会依次做四件事,代码里也是分开的四段:

① 每一块加上 passage: 前缀送进模型,得到 384 维向量(embedding.py);② 问题加上 query: 前缀,同样得到一个向量;③ 向量都做了 L2 归一化,所以余弦相似度就是一次点积(retrieval.py);④ 排序,取前 3。

前缀不是装饰,是 e5 这一族模型要求的输入格式。向量缓存在 results/.cache/,换一个 chunk size 不会重算没变的文字。

  • labs/rag-lab/src/rag_lab/embedding.py模型、tokenizer、前缀、缓存、provenance
  • labs/rag-lab/src/rag_lab/retrieval.py一个 numpy 矩阵和一次点积,没有向量数据库
  • labs/rag-lab/configs/experiment.json固定不变的那一半:模型、revision、top_k

Step 5

第一次 Top-K:证据被切开的样子

为什么

问题是游戏里的情境 1:套餐主饮漏送,能不能退整单。回答它需要两段规则 —— 一般情形和套餐例外。

命令

python -m rag_lab retrieve --strategy fixed --chunk-size 64 --overlap 0 --query-id Q01-zh

你应该看到


strategy   fixed-64-0  {"id": "fixed-64-0", "splitter": "fixed", "unit": "tokens", "chunk_size": 64, "overlap": 0}
index      113 chunks
query      Q01-zh  套餐里的主饮没送到,小吃都收到了,能不能把整个套餐退掉?
top_k      3

#1  0.9393  fixed-64-0:refund-policy#005  (65 tokens)
    sections: refund.missing.combo-core-item
    主饮漏送时,不按单件漏送处理。主饮是套餐的核心商品,缺少主饮后剩余附件不构成一份可交付的套餐,因此顾客有权要求退还整个套餐的实付金额,已送达的附件无需退回。顾客也可以选择只补做

#2  0.9278  fixed-64-0:store-operations#007  (65 tokens)
    sections: ops.stockout.combo, ops.stockout.no-confirm
    差价。顾客不接受任何方案的,整份套餐按《退款与补偿政策》的整单情形退款,已制作完成的附件不要求退回。
    
    套餐主饮本身售罄的,不做替换,直接进入退款流程。
    
    ## 联系不上顾客时

#3  0.9275  fixed-64-0:refund-policy#004  (65 tokens)
    sections: refund.missing.standard, refund.missing.combo-core-item
    ,订单中其余已送达的商品不退。顾客要求把整单退掉时,按下文"整单退款"判断是否成立。
    
    ## 套餐主饮漏送
    
    套餐按整体定价,其中的饮品称为主饮,小吃、配料杯等称为附件。
    
    套餐

evidence needed:  refund.missing.standard, refund.missing.combo-core-item
  ✗ refund.missing.standard                        22% in top-3   (not complete in any chunk)
  ✓ refund.missing.combo-core-item                 61% in top-3   (not complete in any chunk)

evidence coverage@3 50%   complete False   retrieved 195 tokens   redundancy 0%   signal 66%

读这几行

  • 第 1 名念到「顾客也可以选择只补做」就断了 —— 这是 64 tokens 的尽头,不是句子的尽头。
  • 最后三行是判定:基础规则只取回 22%,例外取回 61%,complete False。
  • signal 是取回的文字里属于证据段落的比例;redundancy 是其中重复的比例。这一次没有重叠,所以是 0%。

自己改一改

换一个问题:--query-id Q06-zh(花生过敏那题,需要三段证据)。也可以直接问你自己的问题:--query "生日券过期了还能补发吗",只是这样没有 Ground Truth 可以判分。

Step 6

改变 chunk size

为什么

只改一个数字,其它全都不动 —— 同一个问题、同一个模型、同样的 top-k。

命令

python -m rag_lab retrieve --strategy fixed --chunk-size 192 --overlap 0 --query-id Q01-zh

你应该看到

前面还有前三名的原文,这里只截取最后的判定部分。

evidence needed:  refund.missing.standard, refund.missing.combo-core-item
  ✓ refund.missing.standard                        69% in top-3   (first complete at rank 2)
  ✓ refund.missing.combo-core-item                100% in top-3   (first complete at rank 2)

evidence coverage@3 100%   complete True   retrieved 579 tokens   redundancy 0%   signal 45%

读这几行

  • 两段规则这次落在同一块里(第 2 名),complete True。
  • 代价在同一行:上下文从 195 tokens 变成 579,signal 从 66% 降到 45%。

自己改一改

再试 448。complete 还是 True,上下文继续涨,signal 继续掉。没有一个数字对所有问题都最好 —— 第 10 步会把 11 个问题一起跑出来看。

python -m rag_lab retrieve --strategy fixed --chunk-size 448 --overlap 0 --query-id Q01-zh

Step 7

加入 overlap

为什么

换一个问题:外卖晚了 40 分钟、骑手还没取餐能不能取消。答案要两句话 —— 一句定义(超过 30 分钟算严重延误),一句结论(取餐前可以取消)。

先不重叠跑一次,再加 32 tokens 重叠跑一次。

命令

python -m rag_lab retrieve --strategy fixed --chunk-size 96 --overlap 0 --query-id Q03-zh

你应该看到

前面还有前三名的原文,这里只截取最后的判定部分。

evidence needed:  delivery.delay.severe, delivery.delay.cancel-before-pickup
  ✗ delivery.delay.severe                          45% in top-3   (not complete in any chunk)
  ✓ delivery.delay.cancel-before-pickup           100% in top-3   (not complete in any chunk)

evidence coverage@3 50%   complete False   retrieved 291 tokens   redundancy 0%   signal 50%

读这几行

  • complete False:定义那一节只取回 45%,切口落在它中间。

自己改一改

把 --overlap 改成 32,再跑一次:

python -m rag_lab retrieve --strategy fixed --chunk-size 96 --overlap 32 --query-id Q03-zh
evidence needed:  delivery.delay.severe, delivery.delay.cancel-before-pickup
  ✓ delivery.delay.severe                          84% in top-3   (first complete at rank 1)
  ✓ delivery.delay.cancel-before-pickup            68% in top-3   (first complete at rank 4)

evidence coverage@3 100%   complete True   retrieved 291 tokens   redundancy 0%   signal 49%
also retrieved (listed as not-the-answer): delivery.delay.minor

complete True。第 1 名那一块现在同时装着定义和结论,相似度也从 0.9361 升到 0.9399。上下文没有变长 —— 还是 291 tokens,只是装的东西对了。

Step 8

overlap 加过头

为什么

上一步的结论很像「重叠越大越好」。把它再加一点,从 32 到 48,别的都不改。

命令

python -m rag_lab retrieve --strategy fixed --chunk-size 96 --overlap 48 --query-id Q03-zh

你应该看到

前面还有前三名的原文,这里只截取最后的判定部分。

evidence needed:  delivery.delay.severe, delivery.delay.cancel-before-pickup
  ✗ delivery.delay.severe                           0% in top-3   (first complete at rank 5)
  ✓ delivery.delay.cancel-before-pickup            92% in top-3   (first complete at rank 3)

evidence coverage@3 50%   complete False   retrieved 292 tokens   redundancy 18%   signal 41%

读这几行

  • complete False —— 同一个问题又答不上来了。
  • redundancy 18%:第 1 名和第 3 名是挨着的两块,有一大半是同一段原文。前三个位置只装了两块不同的内容。
  • 定义那一节掉到了第 5 名。它一直在知识库里,只是没进前三。
  • 索引也在涨:同样的 61 段知识,重叠 0 是 76 块,32 是 108 块,48 是 141 块。

自己改一改

接着试 64。它在这个问题上又变回 complete True —— overlap 不是一个单调的旋钮。把 0 / 32 / 48 / 64 四个值都跑一遍,这件事比任何一句结论都清楚。

python -m rag_lab retrieve --strategy fixed --chunk-size 96 --overlap 64 --query-id Q03-zh

Step 9

structure-aware:让文档自己决定切口

为什么

换一种切法:不数 token,改成在 Markdown 的标题处切开,并且让一张表格和紧跟它的注释留在一起。

这个切分器读不到 section 编号。它和固定长度切分器拿到的输入完全一样 —— 去掉编号注释之后的正文。否则它就是照着答案切。

命令

python -m rag_lab chunk --preset structure-aware --document menu

你应该看到


strategy structure-aware  {"id": "structure-aware", "splitter": "structure", "unit": "tokens", "label": "按结构切", "role": "boundary", "max_tokens": 480, "min_tokens": 40, "note": "Boundaries come from the Markdown — headings, paragraphs, a table and its note. The ceiling is the model's window, not a target size."}
60 chunks, tokens min 63 / median 111 / max 402
chunks holding more than one section: 1

structure-aware:menu#000                       90t  ## 适用范围
                                                   sections: menu.scope
structure-aware:menu#001                      402t  ## 主要饮品
                                                   sections: menu.drinks.table, menu.drinks.table-notes
structure-aware:menu#002                      123t  ## 甜度档位

读这几行

  • 菜单被切成 7 块,第 2 块 402 tokens —— 整张价目表加它下面那段注释,在同一块里。

自己改一改

拿这个切法去问「黑糖珍珠鲜奶能不能做全无糖」:

python -m rag_lab retrieve --preset structure-aware --query-id Q09-zh
evidence needed:  menu.drinks.table, menu.drinks.table-notes
  ✓ menu.drinks.table                             100% in top-3   (first complete at rank 1)
  ✓ menu.drinks.table-notes                       100% in top-3   (first complete at rank 1)

evidence coverage@3 100%   complete True   retrieved 653 tokens   redundancy 0%   signal 63%
also retrieved (listed as not-the-answer): menu.sugar-levels

complete True,表格和它的注释都 100% 取回。同一个问题在 192 tokens 的固定切法下是 complete False —— 那一刀落在了表格中间。

  • labs/rag-lab/configs/strategies.json八个切法,分成三组受控对比

Step 10

跑全部 11 个问题

为什么

单个问题的结果容易被一次巧合骗到。把 8 种切法 × 11 个问题全跑一遍,大约十秒。

命令

python -m rag_lab evaluate

你应该看到

fixed-64-0                 113 chunks   complete 1/11   coverage 27%   194 tokens   redundancy 0%   signal 42%
fixed-192-0                 40 chunks   complete 6/11   coverage 71%   578 tokens   redundancy 0%   signal 27%
fixed-448-0                 18 chunks   complete 10/11   coverage 91%   1209 tokens   redundancy 0%   signal 18%
fixed-96-0                  76 chunks   complete 2/11   coverage 41%   280 tokens   redundancy 0%   signal 43%
fixed-96-32                108 chunks   complete 4/11   coverage 55%   290 tokens   redundancy 10%   signal 41%
fixed-96-48                141 chunks   complete 2/11   coverage 36%   290 tokens   redundancy 18%   signal 40%
fixed-96-64                207 chunks   complete 5/11   coverage 59%   290 tokens   redundancy 22%   signal 45%
structure-aware             60 chunks   complete 6/11   coverage 71%   396 tokens   redundancy 0%   signal 42%

wrote labs/rag-lab/results/manifest.json
wrote labs/rag-lab/results/evaluation.json
wrote labs/rag-lab/results/chunks.json
wrote labs/rag-lab/results/retrieval.json
wrote labs/rag-lab/results/evaluation.md

读这几行

  • 没有综合分。每一列往不同方向走,这正是要看的东西:大块取全的最多(10/11),信号也最低(18%);重叠那一组不是单调的(2 → 4 → 2 → 5)。
  • 可读版本写在 labs/rag-lab/results/evaluation.md,逐题一张表。
  • 所有产物都带同一份 manifest:知识库 sha256、问题集 sha256、模型 id 与 revision、依赖版本、时间。知识库改一个字,哈希就变了。

自己改一改

只跑其中几种:--preset 可以重复。这样 results/ 里就只剩这几种,下一步的 export 会指名报错(它需要游戏用到的全部切法)—— 跑一次完整的 evaluate 就好。也可以改 --top-k,但改完之后就不能再和上面的数字比了,那是另一个变量。

python -m rag_lab evaluate --preset fixed-192-0 --preset structure-aware
  • labs/rag-lab/results/evaluation.md逐题逐切法的可读报告
  • labs/rag-lab/results/manifest.json只有 provenance:哈希、模型、版本、时间

Step 11

重新生成网站上的那些结果

为什么

游戏页面读的是静态产物,不在浏览器里跑模型。这一条是从 results/ 到网站的唯一一条路。

命令

python -m rag_lab export

你应该看到

wrote src/data/rag02/generated/manifest.json
wrote src/data/rag02/generated/game.json
wrote src/data/rag02/generated/evaluation-matrix.json

读这几行

  • configs/site.json 只决定展示哪几次运行;每一个分数、排名、片段原文都是从 results/ 里读回来的。
  • 跑完 git diff src/data/rag02/generated/:如果你没改过任何参数,唯一的差异应该是 manifest 里的时间戳。

自己改一改

把 configs/strategies.json 里 fixed-192-0 的 chunk_size 改成 256,重跑 evaluate 和 export,再 npm run build —— 网站会在构建时报错,因为手顺页面引用的数字已经对不上了。这是故意的。

跑完以后

跑完以后

  • 结果和这一页应该完全一样:同样的相似度、同样的排名、同样的 token 数。模型 revision 是钉死的,向量是确定的,排序的并列也按块的顺序打破。唯一会变的是 manifest 里的时间戳。
  • 如果不一样,先看 results/manifest.json 里的依赖版本和模型 revision 对不对得上。
  • 这套 Lab 不是为 Chunking 一个专题写的。后面的 Query Rewrite 改的是 query 那一段,Hybrid Search 加第二条检索路径,Rerank 在检索之后加一层 —— 知识库、问题集、指标都不动。

这一轮没有做的

没有生成步骤。把 top-k 交给一个本地 LLM 写答案是可以做的,但这一轮没有做:生成会在要测量的那个变量之上再叠一个变量,而「证据有没有回来」这个问题,在任何模型写出一句话之前就已经有答案了。

参考

固定不变的那一半

只有切分在变。下面这些在每一次运行里都一样 —— 否则结果变化就有了两种可能的原因,实验也就不说明任何事情。

Embedding 模型
intfloat/multilingual-e5-small @ 614241f622f53c4eeff9890bdc4f31cfecc418b3(MIT · 384 维 · 窗口 512 tokens · XLMRobertaTokenizer)
前缀
query: / passage: —— e5 这一族模型要求的输入格式
相似度
cosine (dot product of L2-normalized vectors)
top_k
3
覆盖阈值
一段规则至少 60% 的字数落在 top-k 里,才算「取回了」
知识库
7 份文档 · 61 段 · sha256 46d3bdea87debd98
问题集
11 个 · sha256 bdd2a597778ca588
运行环境
Python 3.11.15 · torch 2.14.0 · sentence-transformers 5.7.0 · numpy 2.4.6

在变的那一半 —— 8 个切法,三组受控对比:

  • fixed-64-064 tokens 一块,不重叠113 块 · 2–66 tokens
  • fixed-192-0192 tokens 一块,不重叠40 块 · 17–193 tokens
  • fixed-448-0448 tokens 一块,不重叠18 块 · 81–449 tokens
  • fixed-96-096 tokens 一块,不重叠76 块 · 17–98 tokens
  • fixed-96-3296 tokens 一块,相邻重叠 32 tokens108 块 · 36–98 tokens
  • fixed-96-4896 tokens 一块,相邻重叠 48 tokens141 块 · 61–98 tokens
  • fixed-96-6496 tokens 一块,相邻重叠 64 tokens207 块 · 66–98 tokens
  • structure-aware按文档结构切,上限 480 tokens60 块 · 63–402 tokens