DocMind 当前架构事实卡(长期基准)#
用途:本卡是 DocMind 当前架构的唯一权威事实基准,供三类场景复用——①改完架构后据此同步 interview-docs / CLAUDE.md / 简历;②面试前快速对齐”系统现在到底长什么样”;③新会话/新协作者快速定位。
使用规则:本目录其它文档、
CLAUDE.md、简历的所有架构描述都必须与本卡一致;不得自行发明新事实 / 指标 / 组件名。代码层面以仓库根CLAUDE.md+ 实际源码为准;本卡与代码冲突时以代码为准,并回头更新本卡。维护流程:每次架构变更后——先更新本卡 → 再同步
CLAUDE.md→ 再同步受影响的 interview-docs(含本目录)→ 历史/评测档只追加迁移说明、不改史实。最后同步:2026-06-13(四刀改造 A/C/B/E + 真实 trace P0 #26/#27/#28 + 架构审查 P1 #30〔双路径对齐 / 增量跨存储一致性 / BM25 去污 / 工具重试分批〕 + 架构审查 P2 #31〔拆解
DocMindAgent上帝类→~135 行薄壳〕 + #32:上下文全局 token 预算层〔fitAuxBlocks三辅助流共享aux_max_tokens〕+ 历史[n]剥离 + #33:Token 预算二刀〔prompt.budget.total_max_tokens全局天花板对齐 window +estimateTokens计空白/codeLike 防低估;含fitHead0 预算泄漏修复〕 + #34:长期记忆存储介质下沉 — Redis-as-SoR 改为 MySQLuser_memorySoR + Redis 读缓存 + Milvus 纯向量索引〔含semanticRecall不复活陈旧召回修复 + recency×frequency 淘汰 I-evo-2〕 + #35:上下文压缩 query-aware〔命中段居中截断 + 近重复去除 + skip 装箱〕+ MMR 多样性改向量 cosine〔I-perf-4,Jaccard 兜底〕+ λ clamp + I-perf-5:历史滚动摘要〔超预算早期对话用 qwen-flash 压成摘要塞回头部,失败回退硬截断〕 + 记忆存储层 code review 三修复显式化〔C1 Redis blob→Hash 逐条原子写防丢更新 / C2 访问计数 DB 原子自增脱关键路径 / C3 SoR 软删 vs Milvus 向量物理删除双层语义 + 召回对账〕 落地后;D 权限下推暂缓)。
一、已删除的组件 / 概念(文档中如把它们当「当前特性」描述,必须改)#
- SelfReflection(自反思 / 自纠错子系统) —— 整类删除。生成即终态,不再有生成后的二次审查 / 条件重写 / 四维打分 / 切题度检查(topicMismatch)。
- 串行多跳(Self-Ask/MultiStep 自研实现) ——
HopAnswerExtractor/SupervisorAgent.handleSequentialMultiHop/orchestrateMultiHop删除。多跳能力改由 agentic 循环在运行时自驱。 - Query Decomposition(查询拆解) ——
DecompositionResult/SubQuery/SubQueryMerger/SubQueryRetrievalResult删除。多焦点拆解改由 agentic 循环在运行时自驱。 - Plan-and-Execute ——
PlanGenerator/PlanExecutor/ExecutionPlan早已删除(迭代 #21,2026-06-06)。 - 路径模式 MULTI_HOP / DECOMPOSED —— 删除。
- 分类器字段 needDecompose / multiHop —— 删除。
- SSE 事件 reflection_start / reflection_token / reflection_done、multihop、plan —— 删除。
- 死配置
reflection.*(skip_threshold / skip_on_high_grade / rewrite_enabled / topic_check_enabled)、rag.decompose.*、multihop.*、plan_execute.enabled、retrieval.sub.*—— 清理。 - Prompt 模板 query_decompose.txt / hop_answer_extract.txt / knowledge_qa_decomposed.txt —— 删除;
PromptAssembler.assembleDecomposed删除。 - eval 压测哈帺 —— 整包删除(用户拍板)。
二、当前架构(权威描述)#
路径决策三模式(PathDecision.Mode,RagPipeline.routePath,#31 后由 RagPipeline 持有)#
- SELECTED_DOC —— 用户选中文档 + 命中文档/摘要意图 →
DocumentDirectReader直读 DB chunks + 均匀采样,跳过检索。 - RULE_PLANNER ——
SIMPLE且非歧义 → 一次性检索SupervisorAgent.oneShotRetrieval,工具由RetrievalPlanner规则引擎选。 - AGENTIC —— 其余(复杂 / 中等 / 歧义 / 多焦点)→ agentic 检索循环。
routePath 逻辑:directRead 命中 → SELECTED_DOC;否则 SIMPLE && !isAmbiguous → RULE_PLANNER;其余 → AGENTIC。
AGENTIC:AgenticSearchOrchestrator(本次核心,真·LLM 工具调用循环)#
- Spring AI
ToolCallingManager手动控环:internalToolExecutionEnabled(false)(Spring AI 内部工具循环无迭代上限,必须自己控环),上限agentic.max_iterations(默认 4)。 - 每轮:
chatModel.call(prompt)→ 若!hasToolCalls()则停 → 发 SSEagentic事件 →toolCallingManager.executeToolCalls→ 用ex.conversationHistory()构造下一轮 prompt。 - 工具白名单:暴露 4 个读工具
searchDocs/keywordSearch/webSearch/recall_memory+ 1 个算工具executeCode(进程外 OpenSandbox 跑 LLM 写的 Python,受动态sandbox.enabled默认关;沙箱关时 system prompt 动态剔除 executeCode 描述,避免模型浪费轮次);硬排除store_memory/kb_meta(防 LLM 误写记忆,CLAUDE.md 约束)。注意 Spring AItoolNames是「附加」语义不是「过滤」,故实现上是过滤 toolCallbacks 列表来排除 store_memory。 - 模型自驱:拆解 / 多跳 / web 补充 / 何时停,全部由模型运行时决定,不再有手写分支。
finalize():累积所有工具调用产出的 chunks →RetrievedChunk.copyAll防御拷贝 +dedupeKey()去重 → 对并集做一次 Cross-Encoder rerank(跨多次工具调用、KB vs Web 分数不可比,需统一标尺)→MMRDiversifier.diversify→ParentChunkResolver.resolve(命中子块展开为父块,#30 与 one-shot 对齐) →reranker.compress→RetrievalGrader.grade(CRAG)→ 产出SupervisorResult。- Web 补偿(#30 对齐 one-shot,#36 改覆盖率门控):触发条件 =
tier==LOW || (tier==AMBIGUOUS && kbEntityWeak)——按”KB 是否缺实体”触发而非粗粒度 tier(GradeResult.webCompensationNeeded,两路共用;LOW→retrieval.web_compensation_on_low,AMBIGUOUS→grader.web_fallback_on_ambiguous)。追加一轮webSearch、合并后重排/重展开父块/重压缩/重评。agentic 额外保留!usedWeb守卫——模型本可自调 web,仅”整轮没碰 web + KB 缺实体”才兜底(有意非对称)。HyDE 有意不下沉 agentic——LLM 自驱循环本身承担查询改写/扩写角色,HyDE 仅在 one-shot 无 agency 路不可替代。 needsFallback = compressed.isEmpty()(仅”无任何证据”才置位;CRAG 判 LOW 但 compressed 非空 → 不置位,交由RagPipeline走assembleLowConfidence把 KB/Web 证据带进 prompt 据实作答并[n]引用,不丢弃证据——见 #28 P0。“rerank 分低 ≠ 不相关”,联网时效内容尤其易被 reranker 打低分)。finalize按RetrievedChunk.Source拆kbChunks/webChunks,补进 SSEretrieval事件与持久化retrievalLog,并记cragGrade——“KB 失明 / 纯联网作答”在前端 agent trace 面板直接可见(#28 P2 可观测补齐)。- 异常 / 空结果 → 回退一次性检索
oneShotRetrieval(agentic.enabled灰度总开关,默认 on)。 - 新增
AgenticToolingConfig(@ConditionalOnMissingBean兜底ToolCallingManagerbean)。 - 实测行为(真 qwen-plus 冒烟):简单题 1 次 searchDocs 即停;对比题模型自主多焦点检索(≈4 次);多跳题模型自主串行多跳 + 自我验证(≈4 次)。
RULE_PLANNER / 兜底:SupervisorAgent.oneShotRetrieval#
- 并行 Worker 派发(
RetrievalWorker/WebWorker/MemoryWorker)→ RRF 融合 → Cross-Encoder rerank → MMR → 父块展开(RetrievalWorker内ParentChunkResolver) → 压缩 → CRAG grade。 - worker→编排层边界做
RetrievedChunk.copy()防御拷贝。 - BM25(
BM25Retriever)候选仅取子块(vector_id IS NOT NULL),父块不入候选——父块只作展开目标,避免与子块重叠污染(#30)。 - 即原
handleStandardRetrieval改名而来(去掉了 CRAG-web 回溯块)。SupervisorAgent已从 ~1683 行瘦身到 ~595 行。
结构化引用 + 置信度:CitationParser(纯 Java,无 LLM)#
- 解析最终答案里的
[n]标记 → 结构化 citations(含 index/id/name);越界编号丢弃并计入invalidRefs。 - 句子级 coverage = 含 ≥1 个有效
[n]的实质陈述句数 / 实质陈述句总数(按句末标点切句,过滤过短句)。 confidenceScore = clamp(coverage) × rerank-top1;coverage 不可算时退回 rerank-top1。- 零引用覆盖 + CRAG LOW:不再翻
needsFallback(真·无证据已由needsFallback=compressed.isEmpty()覆盖),仅记ungrounded观测信号(Langfuse spanlevel=WARNING,statuslow confidence answer (ungrounded: 零引用覆盖))。原因:此处必走 low-confidence 通路,而该模板已自带”相关性偏低、基于有限参考内容”开头,且确实喂了 KB/Web 证据,再追加”知识库未找到参考文档”会与事实矛盾(见 #28 P1-B)。 SourcePayloadFactory每个 source 增加id字段(锚定稳定 id)。- 前端:
[n]渲染为可点击.citation-ref,点击滚动高亮对应来源卡。 - 测试
CitationParserTest(6);置信度由覆盖率给,替代了原 SelfReflection 的置信度来源。
分类器瘦身:QueryClassification(10 字段)#
- 保留字段:
rewritten / intent / complexity / specificity / timeAware / memoryAware / isAmbiguous / scope / scopeConfidence / memoryWriteHints。 - 删
needDecompose/multiHop。 QueryUnderstandingService.applyDeterministicSignals现在只做 complexity 升级:多焦点信号(对比 / 分别 / vs / 多问号,STRONG_COMPLEX_PATTERN)把 SIMPLE 升到 ≥ MEDIUM,从而路由进 agentic 循环;不再强制 needDecompose。- 拆解 / 多跳是否发生,由 agentic 循环运行时自决,分类器不再判。
CRAG 检索质量闸:RetrievalGrader(#36 改二信号一致性裁判)#
- 三档评分 HIGH / AMBIGUOUS(灰区) / LOW,作为生成前质量闸。
- 二信号一致性(#36):除 cross-encoder rerank 分外,引入与之失败模式正交的第二信号——词项覆盖率(top≤3 chunk 对查询显著词的长度加权最佳覆盖率,复用 IK 分词器,纯 CPU 零额外调用)。两信号都强→HIGH、都弱→LOW、分歧→AMBIGUOUS。rerank 高但实体缺失→降级 AMBIGUOUS(“语义像但事实不在”);rerank 低但稀有实体命中→救回 AMBIGUOUS(rerank 漂移兜底)。
- 灰区仲裁模式:
heuristic(默认,覆盖率优先的一致性,零额外调用)/cross_encoder(opt-in,多一次 rerank API)/disabled。覆盖率纠偏在所有模式下都生效。 GradeResult透出lexCoverage/kbEntityWeak,驱动 Web 补偿覆盖率门控(见上「Web 补偿」)。
Prompt 装配的全局 token 预算层(PromptAssembler,#32)#
- 问题:喂给 LLM 的 prompt 有四条上下文流——chunks / history / memory / computations。过去只有 chunks 受
rag.context_max_tokens(默认 3000)约束,其余三条无界:history 是行数截断(最近 6 条 ≈ 3 轮)且单条不限长,computations 仅单条字符截断、不限条数,memory 全量注入。一段长历史或多次executeCode输出可冲垮 chunks 预算、撑爆模型上下文。 - 修复:
PromptAssembler.assemble/assembleLowConfidence收口处统一做fitAuxBlocks——三条辅助流共享prompt.budget.aux_max_tokens(默认 2500)总预算,按 computations > memory > history 优先级依次扣减(computations 是沙箱实算事实,最高优先;history 最旧轮次可丢,最低优先),各流再受自身上限钳制(prompt.budget.{computations|memory|history}_max_tokens= 1500 / 600 / 1000)。复用CrossEncoderReranker.estimateTokens(语种感知,二分定位裁剪点)。computations 在条目粒度裁剪(超预算整条丢弃 + 提示,不切断代码块),memory 保头、history 保尾(最近对话优先)。配置缺失走默认(AiConfigHolder可空,new PromptAssembler()单测兼容)。 - 历史引用剥离(#32):
ConversationService.buildHistory回喂助手历史时剥离[n]引用标记(正则\[\d+\],仅助手消息),避免旧编号被模型复用 → 落入本轮invalidRefs;并对单条消息做 600 字符尾部截断(粗粒度护栏,token 级总预算在PromptAssembler收口)。 - 测试:
PromptAssemblerTest(历史保尾裁剪 / computations 条目丢弃)、ConversationServiceTest(助手[n]剥离 + 单条截断)。配置种子sys_ai_configid 53–56(category=rag)。 - 全局天花板(#33):#32 的 aux 与 chunks 仍是两条互不知情的预算线(谁都没跟模型 window 对齐)。
fitAuxBlocks新增prompt.budget.total_max_tokens(默认 6000,seed id 57)——装配收口先算”固定开销 = 模板脚手架常量 + question + userProfile + 已定稿 chunks 上下文”,令aux 可用 = total − 固定开销与 aux 自身上限取小:chunks 优先级最高,aux 自动让位,两条线从此互相知情、加总受控。修复期间发现并修掉fitHead的 “预算≤0 即放行” 泄漏(与fitTail语义不一致,被全局天花板放大→memory 可绕过预算泄漏)。 - 估算器防低估(#33):
CrossEncoderReranker.estimateTokens过去完全不计空白、对代码/JSON 按英文 4 字符/token 估——预算层低估方向最致命(“以为没超、其实撑爆”)。改为:①空白计入非 CJK 桶;②新增estimateTokens(text, codeLike)重载,代码型文本(executeCode计算结果通道)走更保守的CODE_LATIN_CHARS_PER_TOKEN=2.5。原则”宁可高估不可低估”。测试新增largeChunksSqueezeAuxBudgetUnderGlobalCeiling/estimateTokensCountsWhitespace/estimateTokensCodeLikeIsMoreConservative。 - 历史滚动摘要(I-perf-5,
PromptAssembler.fitTailWithSummary):history 超预算时不再硬截断丢弃早期对话,而是把被裁掉的早期部分用廉价模型(extractModelOptions→ qwen-flash)压成一段要点摘要塞回头部,拼成【较早对话摘要】… ---(以下为最近原文)--- {最近原文}。摘要占从 history 预算划出的子预算prompt.history.summary_max_tokens(默认 300,与开关prompt.history.rolling_summary默认 true 一样走代码默认、无 DB 种子),其余留最近原文,总量仍受history_max_tokens约束。仅长对话(history 确超预算)才触发一次同步廉价 LLM;功能关闭 / 无aiConfigHolder/ 文本未超预算 / 摘要调用失败任一→ 回退fitTail硬截断(保留”…(较早对话已省略)“标记),永不阻断主流程。
长期记忆三层存储(MemoryStore,#34 ② 持久化下沉)#
- 问题:旧实现把 Redis 当 SoR(单节点 + 仅 RDB + 30d TTL)——崩溃/淘汰/过期即全量蒸发,且”长期记忆因一段时间没访问就消失”语义本身就错。
- 三层:① MySQL
user_memory(SoR)——所有读写以此为准,失效用软删除(invalidated_at != 0)保留审计,superseded_by记取代链,持久层不设 TTL;访问计数用 DB 原子access_count = access_count + 1(setSql)异步更新(C2:替代旧”读 JSON→改→写回”全量写 + 死遥测,彻底消除读-改-写竞态,脱离召回关键路径)。② Redis(cache-aside 读缓存)——Hash 逐条缓存某用户有效记忆(field = memoryId),TTL 7d,任何成员变更(save/invalidate/evict/clear)失效整个键;访问计数只put命中那一个 field(C1:相对旧”整用户一个 JSON blob 读改写回”,并发改不同记忆不再互相覆盖丢更新)。③ Milvusdocmind_memory(纯向量索引)——仅供semanticRecall召回与findMostSimilarByType冲突检测,存 300 字截断版,命中后回 SoR 取全文;失效时向量走物理删除(deleteVectors)。 - 双层删除语义(C3):SoR 软删保审计(行不消失) vs Milvus 向量物理删除(避免陈旧向量在索引里堆积、被语义召回命中);外加召回期对账(见下)作第二道保险,兜底物理删除可能的滞后/失败。
- 写入顺序:DB(提交点,失败即中止不写向量,杜绝”有向量无 SoR”孤儿)→ Milvus 向量(派生索引,失败仅降级召回)→ 失效缓存 → recency×frequency 淘汰(
evictionScore = exp(−ageDays/14) + 0.5·log1p(accessCount),超 200 条软删归档,I-evo-2)。 semanticRecall不复活陈旧召回(#34 修复):召回命中后回 SoR 取全文,sorAvailable须区分”DB 返回空(确无有效记忆 → 跳过陈旧向量)“与”DB 不可达(才用 Milvus 截断兜底)“——不能用activeById.isEmpty()推断,否则把”记忆已全部失效”误判为”DB 宕机”而复活已 supersede 的旧召回。用loadActiveOrThrow+ 显式 try/catch 修正(缓存命中必非空,故命中即代表有有效记忆)。- 冲突检测两层:本层 embedding 阈值(cos≥0.75 取代、≥0.85 去重)处理语义近邻冲突;与
MemoryExtractor的 LLM 级 supersedes 互补(处理语义远但逻辑矛盾)。一致性级别:冲突检测 STRONG(读得到刚写入的近邻)、召回 BOUNDED(换吞吐)。 - 测试:
MemoryStoreTest(save 精确/语义去重、冲突软删、embedding 失败仅落 DB、淘汰价值分、cache-aside 回源、loadActiveSwallowsDbErrorReturningEmpty)。
MMR 多样性重排(MMRDiversifier,#35 I-perf-4 改向量 cosine)#
- 多样性项
max_sim默认用向量 cosine(不再是字符 bigram Jaccard):MMR 候选集小(精排后 ≤ targetK),一次性批量 embed 全部候选(单次往返)再做 O(K²) cosine——能识别”换了同义词讲同一件事”的语义重复、也不会把字面重叠高但语义不同的中文误判冗余。 - 任何失败(embedding 异常 / 维度不一致 /
mmr.similarity=jaccard)自动整体降级 Jaccard,零额外依赖、永不阻断;cosine 后端对零向量/空串占位有 NaN 防护。 mmr.lambda读取后钳制到 [0,1](#35 修复):误配 >1 让多样性项(1−λ)变负反而奖励冗余、<0 让相关性项变负,均破坏 MMR 语义;读取异常退 0.7。测试MMRDiversifierTest。
上下文压缩 query-aware(CrossEncoderReranker.compress,#35 ③)#
- 原
retrieval/ContextCompressor独立组件已下沉进 reranker(6 条精排去重很少触发,独立组件价值低)。流程:精确去重(dedupeKey)→ 近重复去除(包含关系 + bigram Jaccard ≥compress.near_dup_jaccard默认 0.85,抓父/子重叠、web boilerplate)→ query-aware 截断 → token 预算控总量(skip 而非 break,让后续更短高分块继续装箱)。 - 截断不再无脑保头:超长块以”命中段”为中心取 ≤800 字窗口(父块展开后命中正文常落中后段,保头会砍掉→检索到却答不出)。命中段定位优先用展开前子块原文(
getChildContent()),其次 query 词命中位,都没有才退回保头;窗口做句界吸附(snapStart/snapEnd,80 字窗内)避免从句中切。测试CrossEncoderRerankerCompressTest。
保留且未变的组件(如文档已正确描述,不要动)#
混合检索(Vector + BM25)、RRF 融合(含加权 / 自适应 K)、Cross-Encoder rerank(gte-rerank)、TextChunker、Parent Document Retrieval、HyDE、丰富元数据、语义缓存(含 userId 隔离)、范畴判定(MetaIntentDetector Tier-0 + QueryUnderstanding Tier-1 的 scope,六范畴短路)、三层模型成本分层(主回答 qwen-plus / 轻决策 qwen-turbo llm.small_model / 记忆提取 qwen-flash memory.extract_model)、AgentToolContext ThreadLocal、MCP 双路复用与安全边界、Langfuse OTel 可观测、RBAC、文档直读(DocumentDirectReader)。
18 号文档的工程加固——保留在代码里,描述为已落地(不是随多跳删除)#
防御性拷贝 RetrievedChunk.copy()/copyAll()、统一 dedupeKey()、HTTP 超时(reranker + allOf 部分降级)、SSE 生命周期(onTimeout/onError + future.cancel)、语义缓存 userId 多租户隔离。这些是真实保留的;被删的只是「串行多跳机制本身」(HopAnswerExtractor / handleSequentialMultiHop)。
三、SSE 事件协议(当前)#
understanding → routing →(agentic* 每轮 agentic 循环一次)→(code_exec* 每次沙箱代码执行一次,仅 AGENTIC 路径且 sandbox.enabled 开时)→ retrieval → rerank → grader → start → token* → done
done 载荷含:citations(结构化已用来源)、citationCoverage、invalidRefs、confidenceScore、confidenceBand(由覆盖率派生)。无 reflection_* / multihop / plan。
四、四刀状态#
- A 结构化引用 ✅ 已实施
- C 删自反思 ✅ 已实施
- B agentic 循环 ✅ 已实施
- E 分类器瘦身 ✅ 已实施
- D 权限下推 ⏸ 暂缓(用户单独评估)。关键发现:现状为「全局共享知识库」(
KnowledgeBaseServiceImpl.listDocuments不按 user_id 过滤、种子数据全归 user_id=1),与「按用户归属」假设冲突 → kbIds 归属校验语义需产品先拍板。
调研报告见 19-对标主流产品改造报告,实现设计 + 实施状态见 20-改造实现设计,迭代记录见 04-优化迭代记录 #21/#22。
全程 mvn test 单测通过(四刀改造基线 126;经 #30–#35 扩充后当前 157,唯一失败是需 Milvus 在线的 contextLoads 环境用例)+ 前端 type-check / 生产构建通过 + 真 qwen-plus 端到端冒烟通过。