20 · 对标主流改造 · 实现设计(实施参考)#
配套文档:19-对标主流产品改造报告(调研与方案)。本篇是实施参考,落到具体文件/签名/删除清单/验证。
范围:架构层四刀。已排除:索引期 Contextual Retrieval、答案缓存→检索缓存/prompt caching、eval 护栏 + 反馈信号。
状态:A / C / B / E 已实施(2026-06-07);D 权限下推——数据权限部分已落地(KB 级「部门 + 公开/私有」,见 21-权限系统对齐报告)。详见文末「实施状态」节。
Context#
DocMind 现为「预定义推理型 RAG 管线」:4 前置闸 + 7 阶段主管线 + 4 路径 Mode + 大量 flag 短路。对标 Anthropic / Glean / RAGFlow / Vertex / Claude-agentic-search 后,本次做架构层四刀,目标是合并重复造的轮子、把软实现换成强实现,组件更少、能力更强、更贴主流。
已锁定的方向决策:
- 三条检索分支(多跳/拆解/CRAG-web)→ 真·LLM 工具调用循环(Spring AI
.tools()+ DashScope function calling,复用现有@Toolbean,模型自驱拆/跳/补/停)。 - 软
[n]引用 → 稳健化 [n]→结构化(单次生成,后端解析为结构化引用对象 + 前端可点击,置信度 = 引用覆盖率 × rerank-top1)。 - 纳入检索层权限下推(kbIds 归属校验 + MCP 端点鉴权;userId 已是 auth 派生)。
- 完全移除自反思子系统(生成即终态,靠 citations 覆盖率 + CRAG 兜底)。
关键技术事实(已验证):
- Spring AI 1.1.4:
OpenAiChatOptions implements ToolCallingChatOptions,支持internalToolExecutionEnabled、toolCallbacks、toolNames、ToolCallbacks.from(...)、ToolCallingManager.executeToolCalls。内部工具循环无迭代上限 → 必须internalToolExecutionEnabled(false)手动控环。 @Toolbean(DocSearchTool.searchDocs/WebSearchTool.webSearch/MemoryTool.recallMemory|storeMemory/KeywordSearchTool/KbMetaTool)已存在;执行时写AgentToolContext(ThreadLocal)并强制 context kbIds(覆盖 LLM 传入,安全边界)。AgentToolContextjavadoc 明言为「ChatClient function-calling loop」设计,已有activate/clear/get/isActive、getChunks/getMemoryContext/getCalledTools、addChunks/putMemory。- 契约
SupervisorResult(compressedChunks, needsFallback, retrievalLog, memoryContext, gradeResult),被DocMindAgent.stageRetrieval → RetrievalContext消费,必须保持。 - 引用契约:
PromptAssembler注入[i+1],SourcePayloadFactory产{index:i+1,...},sources[n-1]↔ prompt[n],顺序强相关。RetrievedChunk.id是稳定 id(dedupeKey()id 优先)。 - 权限:
DocMindChatController.stream用userService.getCurrentUserId()(JWT 派生)取 userId ✓;但kbIds来自请求参数KbIdsParser.parse,未校验归属 ✗。会话类接口均已做conv.getUserId().equals(userId)校验。
Stage A — 结构化 span 级引用(P0-1,先做,C 依赖它)#
思路:保持单次流式生成、模型仍输出 [n];生成结束后用纯解析(无 LLM)把 [n] 提取为结构化引用对象,算覆盖率,前端可点击。
文件与改动:
service/rag/SourcePayloadFactory.java:每个 source 增加id(chunk.getId())字段,让引用可锚定稳定 id(保留现有index)。- 新增
service/rag/CitationParser.java(纯 Java,无 LLM):- 入参:最终答案文本 +
sources(含 index/id)。 - 正则解析
\[(\d+)\](含连续[1][3])→ 映射sources[n-1];越界编号丢弃并计入invalidRefs(替代旧反思的编号校验)。 - 产出
CitationResult{ usedSources:List<{index,id,name}>, perOccurrence:List<{charStart,charEnd,index}>, coverage:double, invalidRefs:int }。 coverage= 含 ≥1 个有效[n]的句子数 / 实质陈述句数(按句末标点切句,过滤问候/过短句;阈值可配)。
- 入参:最终答案文本 +
agent/DocMindAgent.java#stageGenerateAndPersist:流式结束后调用CitationParser,将citations(结构化)+coverage放入donepayload;confidenceScore = clamp(coverage) × rerankTop1,confidenceBand由分数映射(复用现有bandToChinese/ band 阈值)。- Prompt 模板
resources/prompts/knowledge_qa.txt(及 low_confidence):[n]强制标注指令已存在(line 30),无需改;可补一句「每个事实句都要带[n]」以提高覆盖率。 - 前端
DocMind-frontend/src/views/chat/ChatView.vue:renderMd()后置处理:把渲染后 HTML 里的[n]包成<span class="citation-ref" data-index="n">[n]</span>。- 点击
.citation-ref→ 滚动/高亮对应来源卡(msg.sources[n-1],来源卡区已存在 ~274-304 行)。 done事件读取citations/confidenceScore(已有字段,无需新结构)。
复用:SourcePayloadFactory、来源卡组件、done payload 既有 confidenceScore/confidenceBand。
风险:qwen-plus 偶发漏标 [n] → 覆盖率偏低误判 → 用可配阈值 + 仅对「事实句」计覆盖;解析失败降级为「覆盖率=null,confidence 退回 rerankTop1」。
Stage B — Agentic 检索循环(P1-1,本次核心)#
新增 agent/supervisor/AgenticSearchOrchestrator.java,用手动控环的 ChatClient 工具调用替换三分支。
核心调用形态(手动循环,已验证 API):
OpenAiChatOptions opts = OpenAiChatOptions.builder()
.model(aiConfigHolder.getString("llm.model")).temperature(0.0)
.toolCallbacks(ToolCallbacks.from(docSearchTool, webSearchTool, memoryTool))
.toolNames(Set.of("searchDocs","webSearch","recall_memory")) // 白名单:排除 store_memory/kb_meta
.internalToolExecutionEnabled(false) // 关键:自己控环
.parallelToolCalls(true).build();
AgentToolContext.activate(state.getKbIds(), state.getUserId());
try {
Prompt prompt = new Prompt(List.of(new SystemMessage(SYS), new UserMessage(seed)), opts);
for (int i=0; i<maxIters && !Thread.currentThread().isInterrupted(); i++) {
ChatResponse resp = aiConfigHolder.getChatModel().call(prompt); // 每轮可加 orTimeout
if (!resp.hasToolCalls()) break;
emitAgenticStep(emitter, i, resp); // SSE
ToolExecutionResult ex = toolCallingManager.executeToolCalls(prompt, resp);
prompt = new Prompt(ex.conversationHistory(), opts);
}
return finalize(state, AgentToolContext.get(), emitter);
} finally { AgentToolContext.clear(); }java工具层:直接复用现有 MCP @Tool bean(已强制 kbIds、已写 AgentToolContext、已有好描述)。只暴露 3 个读工具(searchDocs 向量召回 / webSearch / recall_memory),用 toolNames 白名单硬排除 store_memory(防 LLM 误写记忆,CLAUDE.md 约束)和 kb_meta。BM25 关键词精度由 finalize 期补偿(见下),暂不暴露 keywordSearch。
System prompt 要点:说明三工具用途;策略=先 searchDocs、多焦点分别检索、多跳先查中间事实再构下一跳、KB 不足才 webSearch、够了立刻停且不重复检索;约束最多 N 轮、不负责作答、不调写工具。seed = rewrittenQuery + 原问题 + 已选 KB 名(复用 QueryUnderstandingService 的 kb-brief 渲染);不传 kbIds(工具强制)。
finalize(累积 chunks → SupervisorResult):
RetrievedChunk.copyAll防御拷贝 → 按dedupeKey()去重。- 对并集做一次 Cross-Encoder rerank(跨多次工具调用、KB vs Web 分数不可比,需统一标尺——同
CrossEncoderReranker设计意图)→MMRDiversifier.diversify→reranker.compress(maxTokens)。 RetrievalGrader.grade产GradeResult(契约需要)。memoryContext合并;needsFallback = compressed.isEmpty()(四刀落地时为|| grade.tier==LOW,#28 收紧为仅”无任何证据”——CRAG 判 LOW 但有证据改走assembleLowConfidence带证据作答,不再丢弃 KB/Web 证据)。retrievalLog由getCalledTools()+ 计数构建(retrievalMode="agentic_loop";#28 起补kbChunks/webChunks/cragGrade来源拆分与评分)。
ToolCallingManager bean:优先注入 Spring AI 自动配置的(classpath 有 spring-ai-autoconfigure-model-tool);缺失时在 config/McpToolsConfig.java 或新 AgenticToolingConfig 声明 DefaultToolCallingManager。
SSE/可观测:循环内每轮发新增 agentic 事件 {iteration,maxIterations,toolCalls:[{name,args}]}(替代旧 multihop);finalize 发既有 retrieval/rerank 事件(#28 起 retrieval 补 kbChunks/webChunks 来源拆分,前端可直接看出”KB 失明/纯联网”形态);grader 仍由 DocMindAgent.stageGrading 从返回的 GradeResult 发。OTel 包 agentic_search span,finalize 复用 cross_encoder_rerank/crag_grading span 名(Langfuse 看板不变)。
失败兜底:agentic.max_iterations(默认4)、agentic.iteration_timeout_ms(20000)、agentic.total_timeout_ms(45000);循环异常/空结果 → 回退一次性检索(复用 slim 后的 oneShotRetrieval);线程中断(SSE 断开)每轮顶检测 break;可选「重复 query 去抖」Set 防小模型重复检索。agentic.enabled(默认true) 总开关。
复用:现有 DocSearchTool/WebSearchTool/MemoryTool、CrossEncoderReranker、MMRDiversifier、RetrievalGrader、RetrievedChunk.copyAll/dedupeKey、AgentToolContext、SupervisorResult、runSupervisorRetrieval 包装(把其 Supplier 指向新 orchestrator)。
Stage C — 完全移除自反思(P1-2,依赖 A)#
文件与改动:
agent/DocMindAgent.java#stageGenerateAndPersist:删除runSelfReflection调用与self_reflectionspan;生成结束直接走 Stage A 的CitationParser算 confidence。删runSelfReflection、reflectionSkipReason、readReflectionConfidence/Band(confidence 改由覆盖率给)、topicMismatch→fallback 分支。- 删类
agent/SelfReflection.java及其测试(denial 正则、跑题护栏、重写、4 维打分全去)。 - SSE:移除
reflection_start/reflection_token/reflection_done事件;前端ChatView.vue删对应监听与reflection步骤渲染。 - 跑题/低质兜底改由:
coverage≈0 且 CRAG=LOW → needsFallback=true(既有 fallback 模板assembleFallback照常触发)。[#28 已修正] 此设计被证明会丢弃证据:CRAG 低分但有证据时清空上下文走 0-chunk 兜底,模型退回过时基座记忆产生时效性幻觉。现coverage≈0 且 CRAG=LOW不再翻needsFallback(真·无证据已由compressed.isEmpty()覆盖),仅记ungrounded观测信号,证据照常带进assembleLowConfidence。 - 死配置清理:
reflection.skip_on_high_grade、reflection.skip_threshold、reflection.rewrite_enabled、reflection.topic_check_enabled(config/AiConfigInitializer.java)。 - 更新记忆/文档:
project_supervisor_refactor.md中「reflection.skip_on_high_grade 默认 true」已过时。
保留:CRAG RetrievalGrader 作为生成前质量闸(不动)。
Stage D — 检索层权限下推(P2-2 部分,独立,可先做)#
文件与改动:
- kbIds 归属校验(核心洞):在
DocMindChatController.stream(或DocMindAgent.execute入口)解析 kbIds 后,用KbKnowledgeBaseMapper查id IN (kbIds) AND user_id = currentUserId AND deleted=0,剔除/拒绝不属于当前用户的 kbId(越权返回 PARAM_ERROR 或静默过滤)。可抽KbAccessGuard.filterOwned(userId, kbIds)复用。 - MCP 端点鉴权:
config/SecurityConfig.java给 spring-ai MCP server 路径(spring-ai-starter-mcp-server-webmvc暴露路径,确认实际 path)加 JWT/API-Key 过滤,禁止匿名访问 6 个工具端点。 - userId 已是 auth 派生(
getCurrentUserId),内部工具经AgentToolContext拿 userId,确认 recall/store memory 的 userId 不再接受外部传值覆盖(agentic 循环里recall_memory的 userId 由 context 注入,不用 LLM 传参)。
复用:userService.getCurrentUserId()、KbKnowledgeBaseMapper、既有会话归属校验范式。
Stage E — 分类器瘦身(P1-3,依赖 B)#
agentic 循环在运行时自决拆解/多跳/web,分类器不再需要这些路由字段。
文件与改动:
service/rag/QueryClassification.java:删needDecompose、multiHop字段。service/rag/QueryUnderstandingService.java:删 Phase 1bdecompose()/parseDecomposition()、applyDeterministicSignals的拆解强制逻辑、STRONG_DECOMPOSE_PATTERN、MIN/MAX_SUB_QUERIES;KB-brief 保留(rewrite 指代消解要用)。resources/prompts/query_understanding.txt:删needDecompose/multiHop字段与说明。- 删拆解链路(grep 确认仅 orchestrator 用后):
DecompositionResult、SubQuery、SubQueryMerger、SubQueryRetrievalResult、HopAnswerExtractor+resources/prompts/query_decompose.txt、hop_answer_extract.txt。 agent/DocMindAgent.java#routePath:简化为directRead→SELECTED_DOC(范围内);否则SIMPLE && !isAmbiguous → one-shot,else → agentic。agent/PathDecision.java:Mode删MULTI_HOP/DECOMPOSED,加AGENTIC(保留SELECTED_DOC/RULE_PLANNER)。- 保留字段:
rewritten/intent/complexity/specificity/timeAware/memoryAware/isAmbiguous/scope/scopeConfidence/memoryWriteHints(仍供 one-shot 的RetrievalPlanner选工具 + scope 路由 + 记忆写入)。
集中删除清单(Stage B+C+E 落地后)#
SupervisorAgent.java:handleDecomposedRetrieval、orchestrateMultiHop/handleSequentialMultiHop、retrieveOneSubQuery、SubQueryRun、downgradeIfDecomposedCoverageWeak、buildMultiHopLog、normalizeForDedup、handleStandardRetrieval内联 CRAG-web 回溯块;字段subQueryMerger/hopAnswerExtractor。保留并改名handleStandardRetrieval→oneShotRetrieval(去 web 回溯,留 dispatch+fuse+rerank+compress+grade)及其辅助(dispatchWorkers/fuseRerankCompress/rerankMmrCompress/safeGrade/withWorkerTimeout)。SelfReflection.java(整类)+ 测试。HopAnswerExtractor.java+SubQuery*/DecompositionResult/SubQueryMerger+ 相关 prompts + 测试。PromptAssembler.assembleDecomposed+knowledge_qa_decomposed.txt(agentic 产物为扁平 chunk,无 decomposed 路径)。
新增配置键(sys_ai_config,AiConfigInitializer)#
agentic.enabled(true)、agentic.max_iterations(4)、agentic.iteration_timeout_ms(20000)、agentic.total_timeout_ms(45000)、agentic.search_top_k(8)、agentic.web_max_results(5)、citation.coverage_min_sentence_len(可选)、citation.confidence_uses_coverage(true)。均经 aiConfigHolder 带默认值读取(同现有范式)。
实施顺序与依赖#
- Stage D(权限) — 独立、低风险、安全收益快,先落。
- Stage A(citations) — 独立;为 C 提供覆盖率信号。
- Stage C(删反思) — 依赖 A 的 confidence。
- Stage B(agentic 循环) — 核心;先
agentic.enabled灰度,与一次性路径并存。 - Stage E(分类器瘦身 + 删拆解链路) — 依赖 B 稳定后再删旧分支驱动字段。
验证#
- 单测(mock ChatModel):B 的环——返回「工具调用→无工具」断言执行一轮 + AgentToolContext 清理;返回 5 轮断言在
max_iterations截停;异常断言回退 one-shot 且SupervisorResult非空。 - 工具白名单:断言
store_memory/kb_meta不在toolNames;跑一次循环断言MemoryStore.save从未被调用。 - kbIds 越权:context kbIds=[1],mock 模型传
searchDocs(kbIds=[999]),断言底层用 [1](context 覆盖);Stage D:传他人 kbId 断言被过滤/拒绝。 - finalize 排序:构造 向量分 0.4 + web 原始分 1.0,断言并集 rerank 按 Cross-Encoder 重排而非原始分;compress 不超
rag.context_max_tokens。 - citations:构造含
[1][3]与越界[9]的答案,断言 usedSources 正确、invalidRefs 计数、coverage 合理;前端断言[n]渲染为可点击并定位来源卡。 - 契约:返回的
SupervisorResult有非空gradeResult/compressedChunks,stagePromptAssembly正常消费。 - SSE 顺序:2 轮运行断言
agentic*→retrieval→rerank→grader→start→token*→done(无reflection_*)。 - 端到端冒烟(真 qwen-plus):对比题「对比A和B」→ ≥2 次 searchDocs 后停;多跳「X的主教练在球员时代拿过几次世界杯」→ 串行 searchDocs;简单事实题 → one-shot,断言 orchestrator 的
getChatModel().call未被调用。 - 构建/回归:
mvn clean compile、mvn test、前端npm run type-check。
关键文件索引#
- 新增:
agent/supervisor/AgenticSearchOrchestrator.java、service/rag/CitationParser.java、(可选)config/AgenticToolingConfig.java、support/KbAccessGuard.java - 改:
agent/DocMindAgent.java(routePath/stageRetrieval/stageGenerateAndPersist)、agent/supervisor/SupervisorAgent.java(删分支+slim oneShot)、agent/PathDecision.java、service/rag/QueryUnderstandingService.java、service/rag/QueryClassification.java、service/rag/SourcePayloadFactory.java、config/SecurityConfig.java、config/AiConfigInitializer.java、controller/DocMindChatController.java、resources/prompts/query_understanding.txt、前端views/chat/ChatView.vue - 删:
agent/SelfReflection.java、service/rag/HopAnswerExtractor.java、service/rag/SubQueryMerger.java/SubQuery.java/SubQueryRetrievalResult.java/DecompositionResult.java、resources/prompts/query_decompose.txt/hop_answer_extract.txt/knowledge_qa_decomposed.txt及相关测试
实施状态(2026-06-07 落地)#
四刀已实施 A / C / B / E,D(权限下推)按用户要求暂缓(单独评估,详见下方)。全程 mvn test 126 个单测通过、前端 npm run type-check + 生产构建通过。
| 刀 | 状态 | 关键落地 |
|---|---|---|
| A 结构化引用 | ✅ 已实施 | 新增 CitationParser(纯解析,无 LLM):[n]→结构化 citations + 越界计入 invalidRefs + 覆盖率;SourcePayloadFactory 增 id;置信度 = clamp(coverage) × rerank-top1(解析失败退回 rerank-top1);前端 [n] 渲染为可点击 .citation-ref,点击滚动高亮来源卡。测试 CitationParserTest(6) |
| C 删自反思 | ✅ 已实施 | 删 SelfReflection 整类 + AgentState 反思字段 + reflection.* 死配置 + SSE reflection_* 事件 + 前端监听;跑题/低质兜底改由结构化引用覆盖率接管(后 #28 收紧:CRAG 低分但有证据不再兜底、走 assembleLowConfidence 带证据作答;零覆盖仅记 ungrounded 信号)。eval 压测哈帺整包删除(用户拍板,含 PipelineRunner/EvalRunner/PipelineVariant 等) |
| B agentic 循环 | ✅ 已实施 | 新增 AgenticSearchOrchestrator:Spring AI ToolCallingManager 手动控环(internalToolExecutionEnabled(false),agentic.max_iterations 默认 4);白名单过滤 toolCallbacks 只留 searchDocs/webSearch/recall_memory,硬排除 store_memory/kb_meta;finalize 对并集 rerank→MMR→压缩→CRAG;异常/空结果回退 oneShotRetrieval;agentic.enabled 灰度。新增 AgenticToolingConfig(@ConditionalOnMissingBean 兜底 ToolCallingManager)。测试 AgenticSearchOrchestratorTest(3:循环终止 / max_iters 截停 / 工具白名单) |
| E 分类器瘦身 | ✅ 已实施 | QueryClassification 删 needDecompose/multiHop(12→10 字段);QueryUnderstandingService 删 Phase 1b 拆解 + applyDeterministicSignals 改为只升 complexity;PathDecision.Mode 改为 SELECTED_DOC/RULE_PLANNER/AGENTIC;routePath 3 路;删 DecompositionResult/SubQuery/SubQueryMerger/SubQueryRetrievalResult/HopAnswerExtractor + 3 个 prompt;SupervisorAgent 删拆解/多跳/CRAG-web 分支,handleStandardRetrieval→oneShotRetrieval;rag.decompose.* 配置清理 |
| D 权限下推 | 🔶 部分落地 | 经 21-权限系统对齐报告 拍板:功能权限走 RBAC、数据权限按「KB 级 + 部门 + 公开/私有」,存量一律 PUBLIC(不破坏全员可读)。数据权限已落地:sys_dept + sys_user.dept_id + kb_knowledge_base.dept_id/visibility;新增 KbAccessGuard(数据权限唯一收口);DocMindChatController.stream 进 agent 前 filterAccessible 收敛 kbIds(空≠全库);listKnowledge 加部门过滤。关键洞察:KB 级粒度下只需收敛 kbIds,检索层/AgentToolContext 零改动即实现 security trimming。仍待办(P0,独立):RBAC 四表接线(UserDetailsServiceImpl 仍只读单 role)、/mcp/** 端点鉴权、Memory userId 收口 |
新路径决策(routePath):directRead 命中 → SELECTED_DOC;SIMPLE 且非歧义 → RULE_PLANNER(一次性检索);其余(复杂/中等/歧义/多焦点)→ AGENTIC。
SSE 协议变化:新增 agentic(每轮工具调用);移除 reflection_start/reflection_token/reflection_done、multihop、plan;done 增 citations/citationCoverage/invalidRefs。