自适应检索 + 答案反思:让系统自己检查作业(M5 落地实测)
系列:城市管理 Agentic RAG —— 从零搭建城市管理问答系统
本篇:M5 · 自适应检索 + 答案反思(实测版)
源码:https://gitee.com/Chester_Xue/city-agentic-rag
一、M4 之后还差什么?
M4 加完路由,系统会「先分诊、再精准检索」了。但用久了又会冒出两个新毛病:
毛病一:检索是一次性的,查不到就死磕。
你问「气象局局长的电话是多少」——知识库里确实没有,但系统不会换个思路再试一次,直接按第一次的结果硬答,甚至可能拿一份「气象局预警等级」的资料凑数。
毛病二:回答是一次成型的,错了就错了。
大模型生成回答就像学生写完卷子直接交——不检查。它可能把资料里没有的数字、流程「脑补」进答案(幻觉),而且自己毫无察觉。
M5 要做的就两件事:
- 自适应检索——查不到就自动换姿势再查:扩大范围、去掉部门限制、重写问题,实在没有才告诉你「没有」。
- 答案反思——答完之后自己检查一遍:和引用资料对不对得上?有没有编造?有问题就重新回答。
打个比方:检索从「打一次电话没人接就放弃」,变成「没人接就换号码、换时间段再打,最后实在找不到人才留言」;回答从「写完就交卷」,变成「写完自己检查一遍再交」。
二、先看效果:验收场景实测
按推进表,M5 有两条硬验收,先看结果:
验收 ①:问库内没有的东西,要明确告知,不编造
❓ 问题 > 气象局局长的电话是多少? [路由] weather → 检索范围: 气象局/应急局(llm) [检索] 首轮 top_k=3(气象局/应急局)最高分 0.513 [检索] 扩展① top_k=5(全部部门)最高分 0.580 [反思] 核查通过(与引用资料一致,无编造) 💬 回答: 根据您提供的资料,无法查询到气象局局长的电话。 (所有资料中均未包含任何个人联系方式) 建议通过官方渠道查询:气象局官网「联系我们」栏目、12345 热线咨询。首轮检索最高分只有 0.513,低于强相关线 0.6——系统没有死心,自动扩大范围(top_k 3→5、放开全部门)再搜一次,发现确实没有,才明确告知「查不到」,还给出了替代建议。全程零编造。
验收 ②:故意让模型引用矛盾信息,反思要能抓出来
我构造了一个「带病回答」喂给反思模块——资料写救护车 45 辆,回答却编成 300 辆:
反思输入(模拟坏回答): 「根据资料,XX区有救护车 300 辆,平均响应时间 3 分钟,资源非常充足。」 反思输出: 不一致,存在编造信息。 引用资料中明确写明:全市急救站 12 个,救护车 45 辆,平均响应时间 11 分钟。 而回答中声称「XX区有救护车 300 辆,平均响应时间 3 分钟」, 这些数字在引用的资料中完全不存在,属于编造。 重新回答如下: 根据资料,全市共有急救站 12 个,救护车 45 辆,平均响应时间 11 分钟。 资料中未单独提供「XX区」的急救资源数据,因此无法判断该区资源是否充足……编造的数字被逐条揪出,并且自动重新回答。两条验收全过。
三、子任务 5.1:自适应检索
1. 核心思路:一轮查不到,就多轮扩展
defadaptive_retrieve(query,top_k=3,dept_filter=None,threshold=0.6,expand_top_k=5,accept=0.5):# 第 1 轮:常规检索(按路由范围)hits=retrieve(query,top_k=top_k,dept_filter=dept_filter)best=最高分(hits)ifhitsandbest>=threshold:# 强相关,直接返回returnhits,trace# 第 2 轮:扩大 top_k + 移除部门过滤(全库)hits=retrieve(query,top_k=expand_top_k,dept_filter=None)best=最高分(hits)ifhitsandbest>=accept:# 达到接受线,交给生成returnhits,trace# 第 3 轮:关键词重写问题再搜一次rewritten=rewrite_query(query)ifrewritten!=query:hits=retrieve(rewritten,top_k=expand_top_k,dept_filter=None)...# 全部低于接受线 → 明确告知「知识库暂无相关信息」三步策略正好对应三种「为什么查不到」:
- ①top_k 3→5:可能是答案在候选池的边缘,多捞两条
- ②移除部门过滤:可能是路由判错了部门(比如以为是民政问题,其实资料在卫健),放开全库再找
- ③关键词重写:可能是问题本身太「啰嗦」,把疑问词去掉让检索更聚焦
2. 关键词重写:零成本、可解释
重写不做复杂 NLP,就是剔疑问词:
_QUESTION_WORDS=["请问","为什么","怎么","如何","多少","什么","哪些","哪里","哪个","是否","有没有","吗","呢","的","?","?"]defrewrite_query(query):rewritten=query.strip()forwordin_QUESTION_WORDS:rewritten=rewritten.replace(word,"")returnrewrittenorquery.strip()# 重写后为空则返回原句「气象局局长的电话是多少?」→「气象局局长电话」。不调大模型、零成本、结果可解释——这种「笨办法」在检索链路里往往比花哨方案更稳。
3. 踩坑①:0.6 一刀切,差点误杀真答案
推进表原方案是「首次检索最高分 < 0.6 → 扩展」。但实测发现一个残酷事实:数据量小的时候,bge 的相似度分数整体偏低。比如「急救资源是否充足?」这种知识库里确实有答案的问题,最高分只有 0.543——按 0.6 一刀切,系统会直接说「暂无相关信息」,把真答案误杀了。
修复:双线设计。
| 线 | 值 | 作用 |
|---|---|---|
| 强相关线 threshold | 0.6 | 首轮 ≥ 0.6 → 高置信,直接答,不折腾 |
| 接受线 accept | 0.5 | 扩展后 ≥ 0.5 → 有候选,交给生成环节判断 |
分数在 0.5~0.6 之间的「弱相关」怎么办?不硬拒答,交给生成环节的 Prompt 硬约束兜底——「信息不足请明确告知」。检索负责「有候选就捞上来」,模型负责「捞上来的够不够、要不要说不知道」,各司其职。
这个教训的本质:阈值不是拍脑袋定的,要拿真实数据测。我当时差点因为一个「看起来很合理」的 0.6,把验收场景直接打挂。
四、子任务 5.2:答案反思
1. 核心思路:生成之后,再核查一遍
classLLMClient:defreflect(self,question,answer,context,...):prompt=REFLECT_PROMPT.format(question=question,answer=answer,context=context)returnself.chat(prompt,temperature=0.0,max_tokens=512)反思 prompt(推进表原文):
请检查以下回答是否与引用的资料内容一致,是否存在编造信息。 若存在问题,请重新回答。若一致,直接输出"确认无误"。 问题:{question} 回答:{answer} 引用:{context}判定逻辑:模型输出「确认无误」→ 放行;输出其他内容 → 那本身就是模型修正后的回答,系统标记「(已修正)」后采用。
ifREFLECTION_ENABLED:verdict=llm.reflect(question,answer,context)ifverdict.strip().startswith("确认无误"):print(" [反思] 核查通过(与引用资料一致,无编造)")else:answer=f"(已修正){verdict}"# 反思输出即修正版回答print(" [反思] 核查发现问题,已重新回答 → 标记「已修正」")多花一次 LLM 调用,换来「答案经过核验」。开关REFLECTION_ENABLED=false可以随时关掉提速——演示用开,批量跑用关。
2. 踩坑②:给反思 prompt「加料」,模型反而躺平
第一版反思 prompt 实测:编造数字能触发修正(见第二节),但另一种编造抓不住——「资料没提、但听起来合理」的软编造。比如资料只说低保条件,回答却脑补「申请需提交房产证复印件并缴纳 500 元手续费」。模型判了「确认无误」。
我想当然地去给 prompt 加判定标准:
判定标准: 1. 回答中的具体事实必须在引用资料中有依据; 2. 引用资料中未提及的具体事实,一律视为编造; 3. 若资料不足以回答,应说明信息不足……结果翻车了——加了判定标准之后,模型对明显编造数字的坏回答也输出「确认无误」。连测三次,三次全躺平。像是学生检查作业时拿到一本《检查手册》,反而懒得逐条对照了。
A/B 对比很说明问题:
[A 原版简洁 prompt] → 发现 300辆 vs 45辆 矛盾,逐条指出并重新回答 ✅ [B 加了判定标准] → 输出「确认无误」 ❌结论:反思 prompt 保持简洁,效果最好。简洁 prompt 让模型真正去「读一遍」,加规则反而给了它「走捷径」的借口。最终选择:软编造是当前模型能力边界,诚实记录,二期可以用规则校验器(数字/实体比对)兜底——但那是后话,不能为了抓软编造把硬矛盾也放跑了。
五、完整链路:路由 → 自适应检索 → 生成 → 反思
❓ 问题 > XX社区独居老人需要救助吗? [路由] civil+weather → 检索范围: 民政局/气象局/应急局(keyword) [检索] 首轮 top_k=3(民政局/气象局/应急局)最高分 0.661 [反思] 核查通过(与引用资料一致,无编造) 💬 回答: 根据现有资料,无法直接判断XX社区独居老人是否需要救助…… [1](民政局/弱势群体统计.txt)仅提到「独居老人:156人」和 「需重点关注社区:XX社区」,未说明其经济状况…… 救助资格需依据低保或临时救助标准逐户核查…… 建议:提供具体收入、财产、健康状况等信息后再判断。注意回答的克制:资料里没有的信息,明确说没有,不硬下结论。这既是 M3 Prompt 硬约束的功劳,也是反思环节「再确认一遍」的成果。
六、验收总览
| 验收项 | 结果 |
|---|---|
| ① 问「气象局局长的电话是多少」(库内没有) | ✅ 首轮 0.513 → 扩展① 0.580 → 仍无果,明确告知「无法查询到」+ 替代建议,零编造 |
| ② 构造矛盾回答(45辆 vs 编造300辆) | ✅ 反思逐条指出矛盾数字,自动重新回答 |
回归抽查(M1~M4 能力不退化):
- 暴雨响应 → 首轮 0.739 强相关直接答 ✅
- 独居老人救助 → 路由联动 civil+weather,反思通过 ✅
- 低保申请 → 只查民政,不误伤 ✅
- 你好 → 零成本欢迎语,不调模型 ✅
七、本阶段小结
M5 给系统补上了两个「Agent 味」的能力:
- 不轻言放弃——检索失败会自动换三种姿势再查,实在没有才说没有;
- 不交卷就走——回答完自己检查一遍,发现编造就重答并标记。
这两个能力加上 M4 的路由,系统已经形成「分诊 → 精准找 → 答完自查」的完整闭环。所有可调参数(阈值、扩展条数、反思开关)都收进了config.py,一行配置就能调行为——这就是推进表一直强调的「接口预留」在落地。
八、下一步
M5 之前,系统是「被动应答」:你问什么,它答什么。M6 开始加「主动指令」——部门过滤命令(/dept 卫健委 急救资源)、综合研判输出(路由结果为 all 时输出四部门分块报告),让系统从「问答机」变成「研判助手」。
上一篇:M4 Agentic 路由:让每个问题找到对的部门
系列目录:城市管理 Agentic RAG —— 从零搭建城市管理问答系统
📚想了解更专业的内容?本文是项目实战记录。如果你对 RAG 的原理、Prompt 工程技巧、大模型 API 接入的完整方案感兴趣,欢迎访问我的 CSDN 专栏喵本喵叁肆的 Agentic RAG 实战专栏阅读完整的技术博客系列(含可运行代码、架构图与验收标准)。