Java转AI Agent教程(第十二周)检索与切分

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 的入口:

  1. BY_CLAUSE 的块太碎 → 需要 rerank 把"确实相关"往前顶
  2. Q13「多少钱免运费」被"运费"高词频带偏 → 需要关键词检索(BM25)来提供精确匹配信号

目标:recall@5 相对本周最优再提升 ≥ 10 个百分点,并记录延迟增量。

相关推荐
泯泷3 分钟前
大模型会吃掉 Harness 吗?Agent 天空上的“两朵乌云”
人工智能·llm·agent
LightBreeze20025 分钟前
深入理解 Leaf-Spine:从网络拓扑到 AI 数据中心 Fabric
人工智能·fabric·spine·叶脊网络
瑞雪兆丰年兮42 分钟前
JavaWeb-day10-SpringBootWeb案例
java·开发语言·javaweb·web
效率工作实验室1 小时前
企业级AI Agent平台评测:核心指标、能力对比与选型路径
ai·企业级ai·agent选型·agent平台评测·agent平台对比
Elastic 中国社区官方博客1 小时前
更少的位数,相同的召回率:DiskBBQ 更保守的自动校准扫描
大数据·运维·开发语言·人工智能·elasticsearch·搜索引擎
程序员cxuan1 小时前
再见了 Typora
人工智能·后端·程序员
Rain的Java大神之路1 小时前
你背的Spring答案,全是5.x时代的:7.0.8源码一翻,80%的博客都过时了
java·后端·面试
在所不辞兄1 小时前
为什么在标签噪声场景下,识别双重下降拐点有助于防止模型记忆异常数据?
人工智能·深度学习·神经网络·机器学习
三雷科技1 小时前
第6章第4节:自定义分词器
开发语言·c#