上章思考题答案
- 去重按文档 id 保留第一次出现。两份相同 id 的 R-01 会变成一份。id 不同的两块即使正文一样也会都留下。S-01 若分数低于预算截断线,应在排序之后被去掉,不能只因为「也召回了」就送进提示词。
- 没有召回的那一条问法不该贡献「请根据常识回答」。只把仍然有文档的问法留下。全部问法都空时,走固定拒答,不把原问题交给模型发挥。
1. 项目背景
1.1 业务场景
多查询会把 R-01 召回两次,偶尔再带一条分数很低的 S-01。若全部拼进提示词,模型看见两段相同的未发货条款和一段库存条款,可能在回答退款时引用 S-01。另一边,天气问题一个文档都没有。默认空模板是英文,而且顾问仍会调用模型去朗读它。邻里仓要的是一句固定中文:「手册里没有对应条款,请换个问法或联系人工。」这句由应用返回,提示词里不能出现「请根据常识回答」。
本章处理召回之后的列表,再决定是否调用模型。
1.2 痛点
不去重时,相同条款占满 topK,真正相关的下一条进不来,同时 token 被重复正文吃掉。只按字符数从头截断,可能把高分的 R-01 截掉、留下先到达的低分 S-01。空列表若打开allowEmptyContext,原问题进模型,模型编造退款规则并写一个不存在的条款编号。
没有后处理时的路径:重复片段占满上下文 → 低分条款混入 → 空检索仍生成 → 用户看到编造的编号。编造的编号在手册里查不到,客服无法解释。固定拒答虽然没有编号,但日志里的种类是空检索,和「引用了不存在的 R-99」不是同一类事故。后者要拦在调用之前。空检索的日志种类用NO_EVIDENCE,引用成功用条款编号。两种都不要记成普通聊天。否则账单上分不清哪些 token 本不该出现。本章的正式路径让空检索的 token 为零。去重测试不下载嵌入模型,天气测试才检索。两类失败要能从测试名字上分开。
2. 项目设计
小胖说:「这不就是把好几张相同的菜单叠在一起,先抽掉重复的,再按口味从重到轻只留前两张吗?一张都没有,就告诉客人今天没有这道菜,别让师傅现编。」
小白问:「抽重复按的是菜名还是纸张编号?两张纸都写着未发货,编号却是 R-01 和 R-01 的另一刀切块,算一张还是两张?一张都没有时,师傅若仍被叫来念一句『没有』,算不算现编?」
小胖又说:「叫师傅念一句固定的话,至少语气自然。」
小白追问:「语气自然就会改字。验收要的是那句原文。改一个字,测试就分不清是拒答还是模型发挥。而且念一句也是一次调用。」
大师把两段处理分开:「ConcatenationDocumentJoiner.join接收多路查询的文档。相同文档 id 只留第一次,分数保持原样,结果按分数从高到低排。id 不同的两块不会被当成重复。DocumentPostProcessor.process在这之后再截断。本章的处理器先按分数过滤,再按正文 token 累加,超过预算就停止。预算用一个小整数,例如 80,让测试能看见 S-01 被丢掉、R-01 被留下。空列表不进入QueryAugmenter的生成路径。应用直接返回固定中文。若仍想使用ContextualQueryAugmenter,把emptyContextPromptTemplate设成这句中文,并且allowEmptyContext为 false。默认英文空模板不要用。更严格的做法是文档为空时不要调用ChatClient。测试用计数模型证明天气问题的次数为 0,退款问题的次数为 1,且送给模型的文本包含R-01、不包含「请根据常识回答」。」
技术映射:抽掉重复菜单是ConcatenationDocumentJoiner,只留吃得下的几张是DocumentPostProcessor。没有菜单就不要叫师傅。
小白问顺序:「先截断再去重,会不会把唯一的一份 R-01 截掉,重复的低分块还在?预算按什么单位,按字符还是按 token?」
小胖说:「按字符最简单,数长度就行。」
小白说:「手册是中文。字符数和模型 token 不是同一个数。预算若按字符卡得很死,高分条款的后半句条件会被切掉,编号还在,条件没了。」
大师规定顺序和单位:「先去重,再按分数从高到低装进预算。装入时以整块为单位,不要把一块从中间切开。一块放进去就会超过预算,就停,留下已经装入的完整块。预算的单位在测试里用字符数做近似,变量名叫maxChars,不要假装它是精确 token。真正的 token 计数可以沿用第 14 章的切块器思路,本章为了验收稳定,用字符上限卡住『不能把 S-01 也装进去』。空列表的固定句不占这个预算,因为它不进提示词。」
技术映射:先收起重复的纸,再按重要性装进袋子。袋子装不下下一张整纸,就停止,不要把纸撕开。
小胖问固定句:「固定句算不算一种提示词?写在空模板里,模型还是会看见。」
小白问:「用户看见的如果是模型的转述,和固定句不一致,缺陷记在模板还是记在没有短路?日志要不要把空检索记成一次模型调用?」
大师把可见结果说死:「用户看见的必须等于那句固定中文,所以空列表在调用模型之前返回。模板可以同时改成这句,用来防止有人绕过短路、仍把空上下文送给模型时,提示词里也不会出现「请根据常识回答」。两条都要测。绕过短路的对照用例里,计数是 1,但用户文本等于固定句,且不含常识发挥。正式路径计数是 0。」
技术映射:告示直接贴在柜台上。师傅可以有一份同样的告示备用,但柜台有货时才叫师傅,没货时不叫。
小白又问重复 id:「两路查询返回的是同一个对象,还是两个相等的副本?joiner 比的是引用还是 id?」
小胖说:「看起来一样就删。」
小白说:「看起来一样但 id 不同,是两刀切块。删错了会丢条件。只按眼睛看正文,测试写不稳。」
大师说:「joiner 用文档 id 做键,第一次出现的留下。测试里两份 R-01 必须显式设成同一个 id,才能证明去重。id 不同的用例单独断言大小为 2,避免以后有人把实现改成按正文去重还不自知。按clauseId的第二道去重放在处理器里,和 joiner 的 id 去重不要混成一个断言。第二道只看条款编号:同一编号留分数更高的一份,编号不同即使正文接近也留下,交给预算去截。」
3. 项目实战
3.1 环境准备
沿用内存向量库和计数模型。准备三份文档对象:两份相同 id 的 R-01,分数分别是 0.9 和 0.8;一份 S-01,分数 0.2。分数用检索结果上的getScore()。构造阶段若分数为空,测试辅助方法在传入处理器前使用带分数的文档。空检索用天气问题,阈值沿用第 17 章。固定句为「手册里没有对应条款,请换个问法或联系人工。」
预算测试不访问嵌入模型,直接把文档列表交给BudgetProcessor,这样分数是测试自己设定的,不会被本机余弦波动影响。天气问题的零调用测试才走检索器。两条测试分开,避免预算用例因为模型没下载而失败,也避免天气用例因为手造分数而失去意义。
3.2 分步实现
步骤 1:按 id 合并
步骤目标:两路都召回的 R-01 只留一份。
Documentr1=Document.builder().id("R-01").text("未发货订单可以申请退款。").metadata("clauseId","R-01").build();Map<Query,List<List<Document>>>groups=Map.of(newQuery("怎么退款"),List.of(List.of(r1)),newQuery("未发货能否退款"),List.of(List.of(r1)));List<Document>joined=newConcatenationDocumentJoiner().join(groups);运行结果(文字描述):joined的大小为 1,id 为R-01。再放入一份 id 为S-01的文档,大小变为 2,且按分数高的在前。分数相同或为空时不要依赖不稳定的次序,测试只断言 id 集合。
可能的坑:用正文相等去重,却忘了 id。两块正文相同、id 不同会都被留下,这是实现的真实行为。若业务上它们是同一条款的两刀,应在切块时让同一条款共享可识别的 id,或在后处理器里按clauseId再去一次。本章先演示 id 去重,并额外写一个按clauseId去重的处理器,避免两刀切块绕过 joiner。
步骤 2:按分数装入字符预算
步骤目标:预算只够 R-01 时,S-01 不进入提示词。
publicclassBudgetProcessorimplementsDocumentPostProcessor{privatefinalintmaxChars;publicBudgetProcessor(intmaxChars){this.maxChars=maxChars;}publicList<Document>process(Queryquery,List<Document>documents){List<Document>sorted=documents.stream().sorted(Comparator.comparing((Documentd)->d.getScore()==null?0:d.getScore()).reversed()).toList();List<Document>kept=newArrayList<>();intused=0;for(Documentdoc:sorted){intlen=doc.getText().length();if(used+len>this.maxChars){break;}kept.add(doc);used+=len;}returnkept;}}把maxChars设成 R-01 正文的长度,S-01 就放不进去。
运行结果(文字描述):输出只有 R-01。把maxChars加大到两段之和,两段都在,且 R-01 在前。没有从句子中间截断。
可能的坑:先按列表原顺序累加,S-01 若排在前面会占满预算,R-01 被丢掉。处理:处理器内部按分数重新排序,不信任上游偶然的顺序。另一个坑是break写成continue,跳过超长块之后又装入更后面的块,预算被绕开。超预算就停止。
步骤 3:空检索直接返回固定句
步骤目标:天气问题的模型次数为 0,文本不含常识发挥。
publicStringanswer(Stringquestion,CountingChatModelmodel){List<Document>docs=newBudgetProcessor(80).process(newQuery(question),retriever.retrieve(newQuery(question)));if(docs.isEmpty()){return"手册里没有对应条款,请换个问法或联系人工。";}Stringcontext=docs.stream().map(d->d.getMetadata().get("clauseId")+"\n"+d.getText()).collect(Collectors.joining("\n"));returnmodel.call(promptWith(context,question)).getResult().getOutput().getText();}promptWith的系统句是「只根据条款回答,写出编号」。不要写「请根据常识回答」。
运行结果(文字描述):天气问题返回固定句,model.calls()为 0。退款问题计数为 1,用户文本含R-01,不含「请根据常识回答」,也不含S-01。
可能的坑:空列表仍调用ChatClient,让模型「用自己的话」说没有条款。计数变成 1,返回句和固定句不一致。处理:空列表在call之前返回。
步骤 4:备用空模板也不含常识发挥
步骤目标:即使有人绕过短路,提示词仍是固定句。
ContextualQueryAugmenteraugmenter=ContextualQueryAugmenter.builder().allowEmptyContext(false).emptyContextPromptTemplate(newPromptTemplate("手册里没有对应条款,请换个问法或联系人工。")).build();Stringfallback=augmenter.augment(newQuery("今天天气怎么样"),List.of()).text();运行结果(文字描述):fallback等于固定句,不含「请根据常识回答」,也不等于原问题。正式answer不使用这条路径调用模型。对照测试可以故意把fallback送给计数模型,次数变为 1,用来说明「只改模板仍会调用模型」。
可能的坑:空模板里写了「若知道也可以回答」。这就是常识发挥的另一种说法。模板只放固定句,不放条件句。
3.3 完整代码清单
BudgetProcessor、GroundedAnswer、测试。源码对照:
spring-ai-rag/.../retrieval/join/ConcatenationDocumentJoiner.javaspring-ai-rag/.../postretrieval/document/DocumentPostProcessor.javaspring-ai-rag/.../generation/augmentation/ContextualQueryAugmenter.java
RetrievalAugmentationAdvisor可以通过documentPostProcessors挂上BudgetProcessor,通过queryAugmenter挂上备用模板。空列表的零调用要写在顾问外面或更靠前的短路里,因为这个顾问的before改写查询后仍会继续链。
3.4 测试验证
@TestvoidbudgetDropsLowScoreClause(){List<Document>kept=newBudgetProcessor(r1.getText().length()).process(newQuery("怎么退款"),List.of(lowScore(s1,0.2),highScore(r1,0.9)));assertThat(kept).extracting(d->d.getMetadata().get("clauseId")).containsExactly("R-01");}@TestvoidemptyRetrievalDoesNotCallModel(){CountingChatModelmodel=newCountingChatModel();Stringtext=grounded.answer("今天天气怎么样",model);assertThat(text).isEqualTo("手册里没有对应条款,请换个问法或联系人工。");assertThat(model.calls()).isZero();}mvn -Dtest=GroundedAnswerTest test不需要密钥。退款路径另断言提示词含R-01且不含常识那句。天气路径计数必须是 0。若计数是 1 且文案恰好等于固定句,说明走了备用模板而没有短路,仍然算失败。固定句相同不能代替「没有调用」。两条断言都要在。退款路径除了编号,还要断言预算之后的列表大小。若 S-01 仍在列表里,是maxChars设得太大,或处理器用了continue跳过超长块后又装入后面的块。把 R-01 的长度打出来,预算就设成这个长度,再跑一次。不要为了留下两段而把预算改到很大,然后删掉「不含 S-01」的断言。固定句放在常量NO_EVIDENCE,短路和备用模板引用同一句。测试用全等,不用包含,避免半句也变绿。
去重用例用同一个文档编号造两份,合并后大小为 1。编号不同、正文相同的两份必须仍是 2,不能按正文再并一次。预算用例先按分数从高到低排,R-01 在前、S-01 在后。预算等于 R-01 的字符数时,结果只剩 R-01。把break改成continue会把后面更短的低分块装进来,这条用例就是为了抓住它。天气问题的回答全等于固定句,并且calls()为 0。只改空模板、不短路的对照允许计数为 1,但那个方法要标明是反例,不能当成邻里仓的正式答案。预算按字符计,不按模型的 token 计。打印 R-01 的字符数,把maxChars设成这个数再跑。分数排序必须发生在截断之前,否则低分块先占满预算,高分的 R-01 进不来。空检索短路返回前不要调用augment。一旦调用,默认链路会继续到模型,计数就不再是 0。天气用例和退款用例各用一个新的计数模型,避免上一问留下的 1 被当成这一问的结果。
4. 项目总结
4.1 优点与缺点
| 对比项 | 召回全部拼进提示词 | 去重、截断、空结果短路 |
|---|---|---|
| 重复 R-01 | 占 token | 按 id 留一份 |
| 低分 S-01 | 可能被引用 | 预算不够就不装 |
| 空检索 | 模型发挥 | 固定中文,次数为 0 |
| 模板备用 | 英文默认 | 同一句中文且无常识 |
| 切块 | 可能把条件切断 | 整块装入,不从中间撕 |
优点:
- 多查询的重复条款不会占满上下文。
- 低分条款在预算外就被丢掉。
- 空检索的用户可见文本稳定,且不调用模型。
- 备用模板避免绕过短路时出现常识发挥。
- 整块装入,编号和条件不会被截成两半。
缺点:
- id 不同的重复正文去不掉,还要按
clauseId再处理。 - 字符预算不是精确 token。
- 顾问默认仍会在空上下文时继续调用模型,短路要自己写。
- 分数为空时排序不稳定。
- 固定句没有条款编号,日志里要单独记「空检索」,不能假装引用了某条。
4.2 适用场景
适用:
- 多查询召回需要合并。
- 提示词有明确的长度预算。
- 天气类问题必须固定拒答。
- 需要证明提示词没有常识发挥。
- 低分库存条款不能混进退款回答。
不适用:
- 还没做元数据过滤,门店条款和 App 条款靠分数硬分。那是下一章。
- 把字符预算当成计费 token 的精确值。计费仍看
Usage。
4.3 注意事项
- 先去重,再按分数装预算,超了就停。
- 空列表在
call之前返回固定句。 - 空模板与固定句一致,且不含「请根据常识回答」。
allowEmptyContext保持 false。
4.4 常见踩坑
- 退款回答引用了 S-01。根因是低分文档排在列表前面,处理器没有按分数排序。处理:排序后再装预算。
- 天气问题的计数是 1,文案还被改写。根因是把空模板交给了模型。处理:正式路径短路,计数断言为 0。
- 两段几乎相同的未发货条款都进了提示词。根因是切块生成了两个 id,joiner 只按 id 去重。处理:按
clauseId再去一次。
4.5 思考题
- App 用户问退款,库里还有一条只给门店的「现场验货后退货」。它和问句更像。只靠分数和预算,能不能保证回答不引用它?
- 过滤表达式写成
channel == 'app' || docType == 'refund'。门店的退款条款会不会进入 App 的检索?
答案在第 20 章开头。
4.6 推广计划
| 角色 | 本章必读 | 协作事项 |
|---|---|---|
| 开发 | 去重、预算、空列表短路 | 固定句不交给模型改写 |
| 测试 | 重复 id、低分被丢、天气次数为 0 | 提示词不含常识发挥 |
| 运维 | 空检索不应产生 token | 日志分开记空检索和正常引用 |
阅读顺序:先看相同 id 被收成一份,再看预算丢掉 S-01,然后看天气问题次数为 0,最后看备用模板也不含常识那句。