W12 · 检索基础:没 embedding 服务,也要把切分测明白
本周交付 :
src/main/resources/policies/三篇政策文档(5414 字 / 38 条款)+rag/包 8 个类 + 30 条评测集 +drill w12演练(19 条断言)预计耗时 :主块 4h(读 0.5h / 建知识库 1h / 写切分 1.5h / 标评测集 1h)+ 轻任务 2×1h
前置 :W11 检查点 2 达标(
bash selfcheck-b.sh绿)实测结论:最优 recall@5 = 1.000;固定长度切分把 12/38 条条款劈成半条
1. 一句话
这周不是学 RAG 原理,是搞明白一件事:检索不准,八成不是模型不行,是切分把规则劈坏了。
而且这周有一个现实约束:公司没有 embedding 服务。你要在这种条件下把切分测明白------这是真实工程的常态,不是理想环境。
2. 先解决"没资源"这个问题
2.1 三条路,选第三条
| 方案 | 代价 | 判断 |
|---|---|---|
| 等公司给 embedding 服务 | 一周起,W12 直接停摆 | ✗ |
| 本地跑 ONNX / DJL 模型 | 下 100MB+ 模型、Windows 上踩编译坑、向量维度还未必对 | ✗ |
| 自己实现确定性 embedding | 120 行代码,零依赖,秒级跑完 | ✓ |
选了第三条:字符 n-gram + 特征哈希 + IDF 加权 + L2 归一化,4096 维,纯 Java。
理由不是"这样也行",而是"这周要学的东西和 embedder 是谁无关"。 切分、评测集标注、recall 口径、拒答设计------这些工作 95% 不依赖你用哪个 embedder。等网关开通,换 HttpEmbedder 一行的事(我已经写好了)。反过来,如果你为了等账号把 W12 停掉,等你拿到账号时你已经不想做了。
2.2 它的能力边界,必须写进代码注释
HashingEmbedder 捕捉的是字面重合,不是语义。它分不清"退货"和"退款",也理解不了"我不想买了"等于"七天无理由"。
所以:
- ✅ 可以用它横向比较切分策略------切分好坏与语义能力正交。
- ❌ 不可以拿它的绝对 recall 说"我的 RAG 准确率 100%"。
演练输出末尾我把这句话打了出来,教程里再说一遍,因为这是最容易自欺的地方。
2.3 中文检索必须按"字"切,不能按空格
这是中文 RAG 的第一个坑,而且很隐蔽:
java
"退款政策".split(" ") // → ["退款政策"] 一个 token,等于没切
HashingEmbedder.units() 把每个汉字当一个 unit,连续字母数字当一个 unit(PLUS、99 不拆,否则 99元 和 199元 会混)。
n-gram 用 1/2/3 阶混合,权重 0.35 / 0.55 / 0.25:
- 纯 1-gram 太泛("的""了""是"到处都是,把有区分度的词淹没)
- 纯 3-gram 太稀疏(问题短、条款长,共现的 3 字串很少,召回率崩)
- 2-gram 权重最高,因为它既保留词形又不稀疏
用 IDF 加权,不用停用词表。 停用词表要人工维护、换领域就失效("退货"在普通语料是低频词,在售后语料是高频词)。IDF 自动算,这也解释了 Embedder.fit() 为什么存在------真模型不需要它,但本地实现需要先统计语料。
3. 知识库:为什么文档要造得像真的
三篇文档:ret-return.md(退货换货)、ship-freight.md(配送运费)、sla-delivery.md(时效赔付)。
不是写成三段话,而是照真实企业政策的形态写:
### 2.2 不适用商品(负面清单)
下列商品不适用七天无理由退货:
1. 消费者定作的商品,包括但不限于刻字、定制尺码、定制图案的商品;
...
**注**:负面清单商品若存在质量问题,仍按第 4 条处理,不受本条限制。
四个细节,每个都会在后面的周里咬你一口:
| 细节 | 现在的作用 | 后面咬你的地方 |
|---|---|---|
条款编号 2.2 |
切分边界 + 评测标注锚点 | W14 引用要精确到条款 |
| 交叉引用「按 3.2 条执行」 | 制造"一个答案在两处"的真实情况 | W13 混合检索要处理 |
| 表格(续重单价) | 固定长度切分会把表劈开 | W14 答案要从表里取数 |
4.3 旧版规则废止 |
埋一条已废止的规则 | W15 政策时效过滤 |
另外 front matter 里必须有 valid_from / valid_to,W12 用不上,但要现在就存。等 W15 加时效过滤时再改数据结构,你要同时动切分、Chunk、评测集三处,返工成本远高于现在多写 5 行。
policies/index.txt显式列文件,不扫目录。fat jar 里目录项不可靠------W5 的 prompt 加载已经踩过,直接沿用。
4. 切分:三种策略,同一个语料
4.1 固定长度(FIXED)
每 300 字一刀,重叠 60。重叠是为了避免关键词正好落在接缝处被两边都切掉。
实现 10 行,块大小均匀。问题是它不认识文档结构。
4.2 按条款(BY_CLAUSE)
以 ### 2.1 为边界,一块 = 一条完整规则。加了两个东西:
java
// (1) 第一块从正文开头起
int start = (i == 0) ? 0 : sp.start();
// (2) 上下文头:把所属节标题拼到块首
text = "## " + sec.num() + ". " + sec.title() + "\n\n" + raw;
这两个不是装饰。第一版没有它们,跑出来正文字符覆盖只有 93.3%------文档标题、导语、所有节标题都不属于任何 chunk,检索永远看不到。而"七天无理由退货"这种关键词恰恰在节标题里,用户又正是用"能退吗"来问的。
4.3 按标题(BY_HEADING)
以 ## 2 为边界,一块 = 一整节;单节超过 600 字则回落到按条款拆。保留上下文,指代("见 2.2 条")不断链。
5. 评测集:标注必须锚定在条款上
这是 W12 唯一一个"写错了整周作废"的设计决定。
错误做法:题面写"召回 chunk-37 算对"。换切分策略后 chunk-37 根本不存在,三种策略没法用同一套题比。
正确做法 :题面标 RET-2.2,chunk 只负责声明"我包含哪些条款"。判定变成------召回的前 k 个结果里,有没有谁的 clauseIds 含目标条款。
这样换切分、换 embedder、换 k,题目都不用改。
30 条题的构成:
- 28 条有答案:12 条退货政策 + 9 条运费规则 + 7 条时效承诺
- 2 条库里没有 (
订单能开发票吗、会员积分怎么兑换)------正确答案是拒答
为什么必须有后两条:如果 30 条全都"库里有答案",这套评测集永远测不出拒答能力,而拒答恰恰是售后最要命的能力------答错一句"可以退"是要赔钱的。W14 正式做拒答,W12 先把这个缺口摆出来。
还有 3 条题是故意多 gold 的:
java
new Question("Q06", "换货要自己出运费吗", Set.of("RET-3.2", "SHIP-3.1"), "两处都能答,交叉引用")
《退货政策》3.2 讲清了权益,《运费规则》3.1 也有对应条款并指向 3.2。两处都能回答,只认一处是评测自己写错,不是检索错了。W3 的评测集已经踩过这个坑,直接沿用。
6. 结果
策略 块数 均块长 recall@5 等字数召回 MRR@5
固定长度 300 字 / 重叠 60 24 278 0.964 0.964(k=5) 0.851
按条款切分(### 2.1) 38 149 0.821 0.929(k=10) 0.688
按二级标题切分(## 2),超长节回落条款 16 337 1.000 1.000(k=4) 0.887
6.1 为什么要多一列"等字数召回"
块越少,recall@5 越容易虚高。 16 个块里取 5 个 = 拿到全库 31%;38 个块里取 5 个 = 只拿到 13%。只看这一列,等于在奖励"块大",而不是奖励"切分好"。
但也不能简单统一成"块数的固定比例",因为真正受限的不是块数,是上下文长度------塞给模型的 prompt 有 token 上限。所以按 1500 字固定预算反推 k:长块取得少(k=4)、短块取得多(k=10)。
这一列直接把 BY_CLAUSE 的 0.821 修正到 0.929。不要用一个不公平的 k 去判一种策略死刑。
6.2 条款完整率:比"字符覆盖率"有用
| 策略 | 条款完整率 |
|---|---|
| FIXED | 68.4% |
| BY_CLAUSE | 100% |
| BY_HEADING | 100% |
「条款完整率」= 全文原样落在某一个 chunk 里的条款占比。
我先写的是"字符覆盖率",跑出来 FIXED 是 100%,一切正常。这个指标是假的:一条 400 字的规则被 300 字窗口劈成两半,两个窗口并起来字符覆盖当然是 100%,但没有任何一个 chunk 拿到完整规则。
后果很具体:用户问"大件家电能退吗",检索命中前半段(7 日内可退),丢掉后半段(8--15 日只能换),模型给出一个自信的错误答案。
6.3 三个数字说明什么
1)FIXED 的 recall 0.964 看着不差,但它把 12/38 条条款劈成了半条。
recall 这个指标测不出这种伤害------只要一块里有过半内容就算"命中"。recall 高 ≠ 能答对。
2)BY_CLAUSE 最干净,但块太碎(149 字),与问题的词汇重合少。
它输在 k 上,不是输在切分上。给它同等字数额度就是 0.929。
3)BY_HEADING 赢在块大且完整,但块大也是隐患。
一个块常含 2--4 条规则,整块塞给模型会带进无关条款;引用时也说不清"依据具体是哪一条"。W14 的引用准确率会把这个代价暴露出来。
→ 所以 W13 的方向不是"三选一",是组合:小块检索、大块喂模型(small-to-big)。 用 BY_CLAUSE 做召回单元保证精确与可引用,命中后再把它所属的节补进上下文。
7. 验收
bash
cd 教程/W1-W4/ecom-agent-lab
mvn -q -o clean compile
bash selfcheck-c.sh
要看到:
--- 2. 结构完整性(切分有没有把条款劈成半条) ---
固定长度 300 字 / 重叠 60 条款 38 有归属 38 | 条款完整率 68.4%
按条款切分(### 2.1) 条款 38 有归属 38 | 条款完整率 100.0%
按二级标题切分(## 2),... 条款 38 有归属 38 | 条款完整率 100.0%
--- 3. recall@5 对比(同一批 30 问) ---
...
最优策略(按等字数口径):按二级标题切分(## 2),超长节回落条款
recall@5 = 1.000 | 等字数召回 = 1.000 (k=4) | MRR@5 = 0.887
W12 切分对比:通过 19 / 失败 0
19 条断言里,最重要的是这四条:
| 断言 | 它在防什么 |
|---|---|
BY_CLAUSE / BY_HEADING 条款完整率 100% |
切分把规则劈坏 |
FIXED 存在被劈成半条的条款 |
如果它变成 100%,整周的立论就崩了,必须回头看 |
gold 条款编号全部真实存在 |
评测集标注写错(指向不存在的条款,则 recall 永远算不出来) |
打乱 gold 后 recall 至少下降 0.25 |
评测本身无效:如果乱标 gold 分数还高,前面那张表全是废数据 |
8. 本周踩的坑
坑 1:按条款切分会把节标题和导语全丢掉
第一版 byClause 从 sp.start() 开始切,跑出来字符覆盖 93.3%。丢的是什么?文档标题、导语、以及每一个 ## 2. 七天无理由退货 节标题。
这些文字不属于任何 ### 条款,所以不属于任何 chunk。而"退货"这个词在节标题里,条款正文里反而可能是"退回商品"。
修法:第一块从正文开头起 + 给每块拼上下文头。
坑 2:"字符覆盖率"是个假指标
它告诉我 100%,而实际上 12/38 条条款被劈成两半。换成"条款完整率"才看得见。
教训:指标的选法决定了你能看见什么。 一个让你"看起来没问题"的指标,比没有指标更危险。
坑 3:改进不是免费的,而且我一次改了两处
加"上下文头"和"第一块从 0 开始"是一起改的。recall 从 0.857 降到 0.821(一条题翻转)。
我不能确定是头导致的(头让同一节的块共享"退货"这组词,稀释了区分度),还是别的。这就是一次改两处的代价:你失去了归因能力。
正确做法是一次改一处、各跑一遍。这周为了赶进度没做,记在这里当反例。
坑 4:hashing embedder 会让"块大"占便宜
Q13「多少钱免运费」三种策略里 FIXED 和 BY_CLAUSE 都漏了,都召回到 SHIP-3.1(运费承担规则)------因为那个块里"运费"出现了五六次,词频压过了 IDF。
真 embedding 会区分"包邮门槛"和"运费承担"。所以这周的排序结论,接上真网关后必须重跑,重点看排序有没有变化,而不只是看数字高低。
9. 单步调试
bash
# 看一篇文档被切成了什么样(改成你要看的策略)
./run.sh drill w12 2>&1 | head -30
# 只看 CSV 汇总
cat w12-chunking.csv
# 想深入看某个 chunk 的原文,在 Drill 里加一行
# System.out.println(c.label() + " → " + c.text());
10. 接上真 embedding 时怎么改
bash
export EMBED_BASE_URL=https://your-gateway/v1
export EMBED_API_KEY=xxx
export EMBED_MODEL=text-embedding-3-small
然后 W12ChunkDrill.evaluate() 里把
java
HashingEmbedder embedder = new HashingEmbedder();
换成
java
Embedder embedder = HttpEmbedder.fromEnvOrNull(); // null 就回落 hashing
要观察的是排序差异,不是分数差异。 如果真 embedding 下 BY_CLAUSE 反超 BY_HEADING,说明语义能力弥补了块碎的问题------那 W13 的 small-to-big 方案就可以更激进。
11. 自检清单
-
bash selfcheck-c.sh全绿,19 条断言 - 能说出三种切分各适合什么语料(两句话)
- 能解释为什么评测集标注要锚定条款、不能锚定 chunk
- 能解释"条款完整率"为什么比"字符覆盖率"有用
- 能解释为什么要多一列"等字数召回"
- 知道 30 条题里有 2 条库里没有答案,以及为什么必须有它们
- 清楚本周所有数字来自 hashing embedding,不能当准确率用
- 能说出 W13 的方向是 small-to-big,以及为什么
12. 下一步
W13:混合检索 + rerank。关键词与向量各召回一批,合并后重排。
本周已经暴露了两个问题,正好是 W13 的入口:
- BY_CLAUSE 的块太碎 → 需要 rerank 把"确实相关"往前顶
- Q13「多少钱免运费」被"运费"高词频带偏 → 需要关键词检索(BM25)来提供精确匹配信号
目标:recall@5 相对本周最优再提升 ≥ 10 个百分点,并记录延迟增量。