
系列说明一个 Java 后端视角的 Spring AI 渐进式实战教程载体为开源项目「劳小司 · 智能法律助手」。序章技术栈全景与 AI 学习指南阶段一 · 流式对话内核篇1 SSE 流式·停止·思考可见化 / 篇2 会话记忆压缩与滚动体验阶段二 · 工具调用篇1 Function Calling 与法律计算器 / 篇2 联网搜索与工具预算阶段三 · RAG 知识库篇1 起步与底账化 /篇2 Agentic RAG 与引用可信度本文/ 篇3 检索质量与体验阶段四 · 多模型路由篇1 五路级联路由阶段五 · 安全与质量门篇1 安全层与强制检索 / 篇2 质量门与评估门禁 / 篇3 指代消解与阻塞隔离阶段六 · 产品化与用户体系篇1 认证·配额·门禁 / 篇2 前端·移动端·身份 / 篇3 劳动法专精与多模态阶段七 · 存储演进与部署篇1 存储迁移 / 篇2 部署契约本篇涉及tool/SearchLawTool.java、rag/LawCitation.java、rag/CitationRegistry.java。一、Naive RAG 的三个问题篇1 的每轮无条件前置检索 Top-K 拼 Prompt虽然能跑但有三个实打实的问题闲聊也白检索用户说你好你也调一次 embedding 向量检索纯浪费检索词就是用户原话用户问被公司辞退了能拿多少钱直接拿这句去向量库召回质量差只能检一轮复杂问题需要多个法条Naive RAG 一次性检索覆盖不了多跳追问。根因是检索的时机、检索词、次数都被代码写死了。能不能交给模型自己判断二、检索工具化从 Workflow 到 Agent把检索做成一个Tool——searchLaw让模型自主决定Tool(description检索与法律问题相关的法律条文。当用户咨询劳动合同、经济补偿、加班费、试用期、诉讼时效等法律问题时回答前必须先调用本工具获取条文依据并在回答中注明法律名称与条文编号。闲聊、问候、通用知识问题不需要调用。)publicStringsearchLaw(ToolParam(description检索关键词。不要照抄用户原话请提炼为法律术语例如用户问被公司辞退了能拿多少钱应检索解除劳动合同 经济补偿 标准)Stringquery,ToolParam(description可选业务领域过滤劳动/民事/商事/刑事…不限制时不传,requiredfalse)Stringcategory,ToolContexttoolContext){ToolBudgetbudget(ToolBudget)toolContext.getContext().get(ToolBudget.KEY);if(budget!null!budget.tryAcquire()){/* 预算耗尽劝退 */}ListLawCitationcitesretrievalService.retrieveCitations(query,category);CitationRegistryregistry(CitationRegistry)toolContext.getContext().get(CitationRegistry.KEY);if(registry!null)registry.register(cites);// 命中登记returncites.stream().map(LawCitation::text).reduce((a,b)-a\n\nb).orElse();}这一步之后三个问题一次性解决闲聊不调模型判断不需要就不调省 token、检索词由模型改写成法律术语、多问题可多次调用多跳。架构上这是一个质变从流程写死的 Workflow跨入模型自主决策步骤的 Agent。但要注意工具化后检索要不要做变成了模型的决策——模型可以不听。它可能觉得自己知道就跳过检索直接凭记忆作答幻觉又回来了。这个隐患本篇先埋下阶段五·篇1 用强制检索打底Grounded ReAct来实锤回收。三、引用可信度别让 UI 给幻觉盖权威章早期retrieve()返回ListStringDocument 的 metadataarticleId/lawName/score全丢了。前端引用卡怎么办只能用正则从模型输出里猜——模型写了《劳动合同法》第四十七条就渲染成一张 § 引用卡。问题很严重引用卡展示的是模型说了什么不是检索命中了什么。模型要是编造一个不存在的条文号UI 会把它包装成看起来权威的引用卡——等于给幻觉盖了个权威章。解法让引用以结构化形态贯穿全链路。定义LawCitationrecordpublicrecordLawCitation(LongarticleId,StringlawName,StringarticleNo,Stringcategory,doublescore,Stringtext,booleanchunked){publicStringkey(){returnlawName|articleNo;}// 去重与真伪校验的键}它一路带着走检索命中 → System Prompt 里以 [1][2][3] 编号注入 → SSEcitation事件推前端 → 审校校验真伪全链路同一份数据。前端引用卡展示的是检索真实命中了什么而不是模型嘴上说了什么。引用卡上的TOP 0.xx是真实相关度分。四、CitationRegistry请求级引用登记器有个现实问题命中产生在两处——一是 ChatService 的强制打底检索进模型前二是 ReAct 循环里模型调searchLaw的多跳检索发生在 Spring AI 工具调用内部ChatService 拿不到。审校时要把两处的命中合起来校验答案引用的真伪。解法是一个请求级登记器CitationRegistry经ToolContext注入两处命中都register进来publicclassCitationRegistry{publicstaticfinalStringKEYcitationRegistry;privatefinalSetStringkeysConcurrentHashMap.newKeySet();// lawName|articleNoprivatefinalListLawCitationcitationsCollections.synchronizedList(newArrayList());publicvoidregister(ListLawCitationhits){for(LawCitationc:hits){if(keys.add(c.key()))citations.add(c);// 去重同键只留一条}}publicbooleancontains(StringlawName,StringarticleNo){// 审校引用是否真实命中过returnkeys.contains(lawName|articleNo);}}生命周期 单次请求每轮 new 一个天然请求隔离内部用并发容器因为 Reactor 链和工具调用可能跨线程。它同时是 SSEcitation事件的数据源前端展示和 AnswerReviewer 的校验数据源阶段五·篇2。五、踩坑备忘① RedisVectorStore 读回时自定义 metadata 可能丢失。因为 metadata 没声明进索引 schema读回来是空的。兜底链articleId从文档 idlaw:article:{id}#cN解析法名/条号从固定文本格式法律名称《X》。条文编号Y正则解析。结构化引用要设计好metadata 丢了也能还原的兜底。② 双路命中会重复前端 v-for key 冲突。稠密路和稀疏路可能命中同一条文citation推给前端前必须按lawNamearticleNo去重保留相关度更高的一条否则CitationCard的:key冲突导致 Vue patch 错乱、引用卡子树渲染冻结。③ 工具化不等于可靠。Agentic RAG 让检索更聪明但把要不要检索交给了模型——这是灵活性的代价。生产级系统必须在架构层补一道确定性兜底就是阶段五的强制检索打底不能纯指望模型自觉。六、小结机制一句话Agentic RAG检索做成工具模型自主决定时机/检索词/次数Workflow → Agent检索决策从代码写死变为模型自主LawCitation结构化引用贯穿全链路展示命中了什么CitationRegistry请求级登记器打底多跳命中统一供审校埋的隐患模型可以不听阶段五·篇1 回收七、下篇预告检索交给模型、引用可溯源了但还差临门一脚检索本身准不准。单纯向量 Top-K 召回质量有限第四十七条这种离散编号更是命中不了。下一篇把检索管线升级到工业级五阶段。源码与体验Gitee国内快https://gitee.com/spaserby/laoxiaosi.git GitHub https://github.com/spaserby/laoxiaosi.git 在线演示https://laoxiaosi.noctisblue.com本系列全套代码皆开源觉得这篇有帮助欢迎顺手点颗 ⭐