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 refundlabs/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、前缀、缓存、provenancelabs/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-zhStep 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-zhevidence 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.minorcomplete 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-zhStep 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-zhevidence 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-levelscomplete 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-awarelabs/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 tokensfixed-192-0192 tokens 一块,不重叠40 块 · 17–193 tokensfixed-448-0448 tokens 一块,不重叠18 块 · 81–449 tokensfixed-96-096 tokens 一块,不重叠76 块 · 17–98 tokensfixed-96-3296 tokens 一块,相邻重叠 32 tokens108 块 · 36–98 tokensfixed-96-4896 tokens 一块,相邻重叠 48 tokens141 块 · 61–98 tokensfixed-96-6496 tokens 一块,相邻重叠 64 tokens207 块 · 66–98 tokensstructure-aware按文档结构切,上限 480 tokens60 块 · 63–402 tokens