Skip to content

优化迭代记录

每次优化记录格式:背景(为什么要优化)→ 方案(怎么做)→ 结果(效果如何)→ 面试话术(怎么讲)


迭代记录

每次优化在此追加,从最新到最旧排列。


#20 Phase 6 前端思考时间线重构(2026-05-09)

背景痛点

用户在等待 Agent 回答时看到的是一个空白等待——不知道系统在做什么、做到哪一步了、为什么这次比较慢。虽然 SSE 事件流已经在发 understanding / retrieval / rerank 等事件,但前端只简单地把最终答案展示出来,中间过程完全不可见。

对于一个 Agent 系统来说,推理过程的透明度是用户信任的基础——用户需要知道系统确实在认真检索、评估、反思,而不是在"编故事"。

方案设计

ChatView.vue 中实现思考时间线组件:

  1. 实时展示:每收到一个 SSE 事件就在聊天气泡上方追加一个时间线节点
  2. 11 种步骤类型:scope / understand / rewrite / routing / plan / retrieval / grader / rerank / warning / reflection / generating
  3. 自动折叠:生成完成后,时间线自动折叠为一行摘要(如"理解 → 路由 → 检索 → 重排 → 生成"),用户可点击展开查看详细数据
  4. 路由徽章:scope 决策 + grader 评分以绿/黄/红三色徽章展示在气泡顶部,鼠标悬停看判定理由和置信度百分比
  5. 检索日志弹窗:点击 retrieval 节点可查看 vector/BM25/RRF 各路的详细指标

数据对比

指标改造前改造后
用户等待期间的视觉反馈仅光标闪烁实时 11 步时间线
Agent 推理过程可见性每步状态 + 可展开详情
检索质量可视化grader 三色徽章 + 检索日志弹窗

面试话术

"Agent 系统跟传统 API 最大的区别是推理过程不透明——用户等 3 秒不知道系统在干嘛。我做了前端思考时间线,每一步 SSE 事件都实时展示:正在理解问题、正在检索、正在重排、正在评分。完成后自动折叠成一行摘要,不干扰阅读。检索置信度用绿/黄/红三色徽章直观展示。这是可解释 AI 的前端实践——让用户知道答案是怎么来的,而不只是'这是答案'。"


#19 Phase 6 Langfuse OTel 全链路追踪(2026-05-09)

背景痛点

Phase 2 已经接入了 Langfuse 做延迟统计(迭代 #2),但只有粗粒度的耗时数据。Agent 经过 6 个 Phase 改造后链路越来越深——Query Understanding → Scope Routing → Path Decision → Supervisor-Worker → CRAG Grading → Generation → Self-Reflection,每步的输入/输出/耗时需要结构化追踪才能定位瓶颈。

具体的调试痛点:Phase 5 的 CRAG 灰区阈值需要看 rerank 分数的分布来调参,但之前只能加临时日志看。

方案设计

通过 OpenTelemetry SDK 将 Agent 每步执行作为 span 上报到 Langfuse:

  1. TracedOpsupport/TracedOp.java):消除 OTel 样板代码。用法:TracedOp.run(tracer, "rrf_fusion", Map.of("rag.fusion.vector_count", size), span -> { ... }),自动处理 span 开始/结束/异常/属性
  2. RootNameFilteringSpanProcessorconfig/LangfuseOtelConfig.java):Spring Boot 会劫持 OTel Tracer 给框架组件起 span,产生大量噪音。解决:内部维护白名单(DocMindAgent.execute / emergency_short_circuit / scope_short_circuit / semantic_cache_replay),只放行白名单 root span 的整棵 trace。利用 Spring 桥接 span 在 startSpan() 时 name 是占位符 <unspecified span name> 来区分
  3. ChatModelObservationFilterconfig/ChatModelObservationFilter.java):桥接 Spring AI Observation → OTel span 属性,自动采集 LLM 调用的 prompt / completion(截断 10000 字)

实现细节

  • endpoint:{langfuse.baseUrl}/api/public/otel/v1/traces,Basic auth(publicKey:secretKey Base64 编码)
  • BatchSpanProcessor:2 秒批量导出,减少网络开销
  • Micrometer Tracer Bean:让 Spring AI 的 gen_ai.* span 挂到我们手动构建的 span 树上
  • 启动验证:verifyExport() 发一个测试 span,forceFlush 等待 10 秒确认连通
  • langfuse.enabled=false 时回退到 OpenTelemetry.noop(),零开销

数据对比

指标改造前改造后
可观测粒度粗粒度延迟统计每步 span(含属性 / 输入输出 / 耗时 / 异常)
LLM 调用追踪自动采集 model / prompt / completion / tokens
噪音过滤无(Spring 框架 span 混入)白名单过滤,只保留 Agent 关键 trace
调试效率加临时日志 → 重启 → 看控制台Langfuse UI 直接看 trace 瀑布图

面试话术

"Agent 系统不可观测就不可优化。Phase 6 接了 Langfuse 的 OTel 协议,Agent 每一步都作为 span 上报——能在 Langfuse 里看到完整的 trace 瀑布图。一个实际的例子:Phase 5 的 CRAG 灰区阈值就是我在 Langfuse 里看 rerank 分数分布后调出来的。技术上有个有意思的点:Spring Boot 会劫持我们的 OTel Tracer 给各种框架组件起 span,产生大量噪音。我写了一个 RootNameFilteringSpanProcessor 做白名单过滤——只放行 DocMindAgent.execute 等几个入口 span 的整棵 trace,利用 Spring 桥接 span 创建时 name 是占位符这个特征来区分。"


#18 Phase 5 补充:QueryUnderstanding 确定性后处理(2026-05-07)

背景痛点

Phase 5 的 QueryUnderstanding 合并模式下,LLM 一次调用同时输出 scope + classification + decomposition 信号。但实际观察发现 LLM 对比较类问题的 needDecompose 判断不稳定——"对比 Milvus IVF 和 HNSW 的优缺点"大约 60% 的情况下返回 needDecompose=true,40% 返回 false。漏判时只走单次检索,往往只能召回一方面的切片。

方案设计

QueryUnderstandingService.understand() 返回 LLM 结果后,执行 applyDeterministicSignals 确定性后处理——正则匹配用户原始问题中的强模式:

  • "对比" / "比较" / "区别" / "差异" / "异同"
  • "分别" / "各自" / "vs" / "versus"
  • 多个问号(?.*??.*?

命中即强制 needDecompose=true。不替代 LLM 判断,只兜底 LLM 漏判。

数据对比

指标改造前改造后
对比类问题 decompose 触发率~60%(LLM 独判)100%(LLM + 规则兜底)
额外延迟0ms(正则匹配)
额外成本0(零 LLM 调用)

面试话术

"LLM 判断加规则兜底的设计。观察到 LLM 对'对比 A 和 B'这类问题大约 60% 正确触发 decompose,40% 漏掉。加了几行正则后处理——'对比/分别/vs/多问号'命中就强制触发。成本是零,正则不到 1ms。LLM 判对了不影响,判错了规则补上。核心原则是 LLM 擅长理解语义,规则擅长捕捉表面模式,两者互补。"


#17 Phase 5 补充:PathDecision 规则引擎替代 LLM 路由(2026-05-07)

背景痛点

Phase 2 的 SupervisorAgent 已经有按查询类型选策略的逻辑(迭代/计划/拆解),但工具选择(doc_search / keyword_search / web_search / recall_memory)的组合逻辑散落在 DocMindAgent 和 SupervisorAgent 中,用多个隐式布尔开关(documentScopedRetrieval / decomposed / retrievalPlan=null)推断路径。可维护性差,出了问题不知道走了哪条路。

工具选择本质上是 4 个信号(isAmbiguous / specificity / timeAware / memoryAware)到 N 个工具的确定性映射,用 LLM Function Calling 是过度设计——加 ~300ms 延迟、结果不稳定、不可单元测试。

方案设计

两个新组件:

  1. PathDecisionagent/PathDecision.java):record 类型,统一封装路径决策输出。Mode 枚举三种路径(SELECTED_DOC / DECOMPOSED / RULE_PLANNER),reason 字段记录机器可读的判定原因写入 trace 和 SSE
  2. RetrievalPlannerservice/rag/RetrievalPlanner.java):纯规则引擎。始终 doc_search;ambiguous 或 PRECISE → 加 keyword_search;timeAware + 时效关键词双源校验 → 加 web_search;memoryAware → 加 recall_memory

数据对比

指标改造前(隐式开关)改造后(PathDecision + RetrievalPlanner)
路径决策延迟~300ms(LLM Function Calling 场景)<1ms(纯规则)
决策可追溯性需翻代码推断PathDecision.reason 字段 + SSE routing 事件
可测试性需 mock LLM直接断言 input→output
路径判定入口散落在 2 个类的多个方法中PathDecision 单一对象消费

面试话术

"工具选择这件事本质是看 4 个信号然后决定调哪几个工具,是确定性映射。用 LLM 做就像用 GPT-4 算加法——不是不行,是没必要。我做了两件事:第一是 PathDecision record 把散落在两个类里的路径判定收敛成单一对象,下游统一消费;第二是 RetrievalPlanner 纯规则引擎做工具选择。延迟从 300ms 降到 <1ms,结果 100% 确定性,可以直接写单元测试。PathDecision.reason 字段记录判定原因写入 trace,出了问题一查就知道为什么走了这条路。"


#16 Phase 5 范畴判定与检索置信度评估(2026-05-07)

背景痛点

实际使用中遇到的一个真实 bug:用户在一段正常知识问答之后,输入"总结上面的对话"。系统的处理流程是:

  1. 把"总结"、"上面"、"对话"这几个词当成检索关键词,去 Milvus 做向量检索,召回 12 条切片
  2. 召回的内容跟用户的真实意图毫无关系——切片来自某些主题为"会议总结方法"或"对话系统"的文档
  3. 自纠错模块判断置信度 65% 不达标,触发重写
  4. 重写仍然基于同一批跑题切片,置信度降到 0%
  5. 0% 置信度的回答仍然被推送到前端

定位根因后发现是意图分类只有一层:原本的五类(factoid / procedural / comparison / opinion / chitchat)都默认要去查知识库。"总结上面的对话"被归入 factoid 或 chitchat,但下游不区分意图,照常调用 RetrievalWorker。自纠错只检查事实一致性,无法识别"检索到的内容跟问题完全不是一个主题"。

调研了 12 个生产级框架/产品(LangGraph、LlamaIndex、AWS Bedrock、Perplexity、LinkedIn 工程博客、CRAG / Self-RAG / Adaptive-RAG 论文等),共识是两件事:第一,要在原有意图分类之上加一层"要不要查知识库"的判断;第二,检索完成后再加一道"查到的内容跟问题对不对得上"的检查。

方案设计

整体加四道闸:

用户问题

   ▼ 第一道闸:规则快路径(MetaIntentDetector)
   │  正则识别"总结上面"、"翻译你刚才"、"你好"、"谢谢"等高置信度模式
   │  命中即跳过模型调用

   ▼ 第二道闸:模型路由(ScopeRouter 或 QueryUnderstanding 合并模式)
   │  范畴 ∈ {元对话 / 闲聊 / 知识查询 / 任务执行 / 越界}

   ├─ 元对话  → 仅基于历史对话回答,完全不查 Milvus / BM25
   ├─ 闲聊    → 直接小模型短回复
   ├─ 越界    → 静态拒答文案
   ├─ 知识查询 → 进入原有检索流程(继续往下走)

   ▼ 检索 → RRF 融合 → 重排 → MMR → 父块展开

   ▼ 第三道闸:检索三档置信度评估(RetrievalGrader,CRAG 风格)
   │  rerank 顶分 ≥ 0.65 → HIGH(直接生成)
   │  rerank 顶分 ≤ 0.25 → LOW(让 fallback 接管)
   │  灰区 → Cross-Encoder 单对仲裁

   ▼ 模型生成

   ▼ 第四道闸:自纠错切题度检查(SelfReflection 扩展)
       识别"答案引用的切片跟问题不是同一主题",跳过重写直接降级

实现细节

  • MetaIntentDetector:纯正则,命中四种高置信度模式才返回结论,否则返回 null 让模型路径兜底。原则是宁可漏判多调一次模型,也不要误判把知识查询路由错
  • ScopeRouter + prompts/scope_routing.txt:拆分模式下独立调小模型,输出 JSON。便于排错
  • 合并模式(默认开启):把 scope 字段加到 query_understanding.txt 提示词和 QueryClassification record,原本的两次调用(ScopeRouter + QueryUnderstanding)合并成一次。结果缓存到 AgentState.cachedUnderstandingrunReActLoop 复用
  • RetrievalGrader:四种灰区仲裁模式可切换
    • cross_encoder(默认)—— 把 top-3 切片拼接后让 reranker 单对打分。一次调用 ≈ 50ms,不需要模型生成
    • llm —— 调小模型仲裁,保留为基线对照
    • heuristic —— 用顶分与次分的差、前三平均分的规则判定,零额外调用
    • disabled —— 直接判 AMBIGUOUS
  • 三个短路 handler 镜像现有 handleEmergencyShortCircuit 的 SSE 事件序列(starttokendone),前端零改动即可正常渲染
  • 配置项全部走 sys_ai_config 表 + AiConfigInitializer 启动期增量补缺,老库自动兼容
  • 前端 ChatView.vue 监听新增的 scopegrader SSE 事件,气泡顶部展示两枚徽章(范畴、检索置信度),鼠标悬停看判定理由

数据对比

指标改造前改造后(合并模式开启)变化
元对话路径调用 Milvus 次数10完全消除
元对话路径调用 BM25 次数10完全消除
元对话路径调用 reranker 次数10完全消除
知识查询路径路由调用次数2(独立 ScopeRouter + QueryUnderstanding)1(合并)-1 次模型调用
灰区仲裁延迟~300ms(小模型生成)~50ms(reranker 单对)-83%
检索越界 bug 复现复现率 100%已消除bug 修复

面试话术

"这个改造的起点是一个真实 bug:用户问'总结上面的对话',系统拿这几个词去做向量检索,召回了 12 条主题完全无关的切片,自纠错判置信度 65% 不达标触发重写,重写还是基于同一批跑题切片再降到 0%,最后把 0% 置信度的错误答案推到前端。

定位根因发现是意图分类只有一层——原来的五类(factoid 等)都默认要去查知识库。我做了两件事。第一件是在原有分类之上加一层范畴判定,区分四种情况:要查知识库、要看历史对话、闲聊、超出范围。判定走两级——先用正则识别'总结上面'、'翻译你刚才'这种高置信度模式,命中就跳过模型调用;不命中再走小模型。元对话路径完全不进检索流程,直接基于历史对话生成回答。

第二件是检索完成后加一道三档评估,参考 CRAG 论文:rerank 顶分高于 0.65 直接判 HIGH,低于 0.25 判 LOW,中间灰区做仲裁。仲裁的实现选择上我没用论文里的 LLM 评估器——那相当于多调一次模型生成——而是把 top-3 切片拼成上下文让 reranker 重新单对打分,一次调用 50ms,是模型生成的 1/5 时延。

工程上有个细节我比较满意:范畴判定原本设计成独立组件方便排错,跑通后做了第二轮优化把它合并进 QueryUnderstanding 的同一次模型调用——只在提示词里加了一个 scope 字段。知识查询路径的路由调用次数从 2 次降到 1 次,省 ~250ms。要排错时一个开关切回拆分模式即可。

整套改造严格遵循一个原则:每一道闸都做了降级路径,任何一道判错或调用失败都会回到原来的流程,不会比改造前更糟。"

详细方案见 12-Agentic-RAG改造执行计划.md Phase 5


#15 Phase 3 Parent Document Retrieval —— 小块检索 / 大块生成(2026-05-05)

背景痛点

400 字子块 embedding 精准度高,但 LLM 生成回答时上下文太碎片化。实际表现:命中的 chunk 只包含某个概念的部分描述,LLM 看不到前后段落就容易断章取义或编造不存在的关联。增大切块到 1500 字又会稀释 embedding 精准度,两难。

方案设计

双层切块 + 子块检索 / 父块生成(Parent Document Retrieval, LlamaIndex 经典模式):

入库:
  TextChunker.chunkWithParents()
    父块 ~1500字 → MySQL only(不入 Milvus)
    子块 ~400字  → MySQL + Milvus(embedding)
    子块.parent_chunk_id → 父块.id

检索:
  Milvus 命中子块 → ParentChunkResolver 查父块 → 父块内容替换子块 → 送 LLM
  同一父块多个子块命中 → 去重保留最高分

实现细节

  • TextChunker.chunkWithParents():先产出标准 400 字子块,再将相邻子块合并为 ≤2000 字父块
  • DocumentProcessTask:两阶段入库——先存父块获 DB ID,再存子块设 parent_chunk_id
  • ParentChunkResolver:通过 vector_id 批量查 kb_chunk → parent_chunk_id → 批量查父块内容;同父去重
  • VectorRetriever:修复 ID 提取使用 getStrID()(VarChar PK bugfix)
  • RetrievalWorker:MMR 后调用 parentChunkResolver.resolve()
  • 兼容性:旧数据 parent_chunk_id=NULL 直接使用原内容

数据对比(预期)

指标Before(子块直用)After(父块展开)变化
LLM 上下文长度~400 字/chunk~1500 字/chunk+275%
回答完整性中(常断章取义)
向量化成本不变不变(父块不入 Milvus)无增长
检索延迟基线+5-10ms(MySQL 查询)可忽略

面试话术

"Parent Document Retrieval 解决的是检索粒度和生成粒度的矛盾。400 字做 embedding 信息密度最集中、匹配最精准;但 LLM 看到的上下文太碎片化。方案是双层切块:子块(400字)进 Milvus 保证检索精度,父块(1500字)只存 MySQL 保证生成上下文充分。检索命中子块后通过 parent_chunk_id 外键查出父块内容。

工程上两个关键点:一是父块不入 Milvus——省了 3.75× 的向量化 API 调用成本和存储空间;二是同父去重——5 个子块命中同一段落,只保留分数最高的那个,避免上下文重复。整个改动在 RetrievalWorker 内部完成,对外(SupervisorAgent、PromptAssembler)透明。"


#14 Phase 3 HyDE 假设文档生成 —— 模糊查询召回率提升(2026-05-05)

背景痛点

模糊/概念性查询(如"微服务架构的优势"、"系统高可用的设计思路")召回效果差。核心原因是语义鸿沟:短 query 的 embedding 信息密度低(6-20 字),而文档 chunk 是 200-400 字的陈述式描述,两者在向量空间中距离偏远。Phase 2 的 ObservationEvaluator 已经识别出该问题(TRIGGER_HYDE 动作),但只是打桩。

方案设计

引入 HyDE(Hypothetical Document Embeddings):

模糊 Query → LLM 生成假设回答(150-300字)→ 用假设文档 embedding 替代 query embedding → Milvus 检索
                                                  BM25 路仍用原始 query(保持关键词精准性)

两个触发入口:

  1. 首轮 specificity == FUZZY → 自动开启
  2. 首轮 confidence < 0.4 → ObservationEvaluator 推荐 HyDE 重试

实现细节

  • HyDEGenerator:用 callSmallModel()(qwen-turbo)生成假设文档,延迟 200-400ms
  • RetrievalWorker:新增 useHyde 参数,启用时向量路用 HyDE 文本、BM25 路保持原 query
  • SupervisorAgent:首轮 FUZZY 自动注入 useHyde=true;ObservationEvaluator TRIGGER_HYDE 时注入 extraParams
  • Prompt 模板:prompts/hyde_generation.txt,指导 LLM 输出像真实技术文档段落的假设回答
  • 失败回退:生成为空/异常时回退原始 query,不阻塞主流程

数据对比(预期)

指标Before(FUZZY 查询)After(HyDE 启用)提升
Vector Recall@50~40%~60-70%+20-35%
首轮 confidence0.3-0.40.5-0.7+0.2
额外延迟0200-400ms(小模型)可接受
PRECISE 查询影响-不触发,零影响-

面试话术

"HyDE 的核心 insight 是把 query 和 document 拉到同一语义密度层面。用户问'系统架构特点',6 个字的 embedding 太稀疏;让 LLM 先写一段 200 字的假设回答,再用它检索,就和真实文档的 embedding 密度匹配了。

工程上有三个关键决策:第一,只在向量路用 HyDE、BM25 保持原 query——BM25 靠关键词精确匹配,LLM 生成的文本里无关词会干扰 IDF 权重。第二,双入口触发——首轮 FUZZY 自动开启 + ObservationEvaluator 低分重试,确保不漏。第三,用小模型——假设文档不需要推理质量只需要语义丰富度,qwen-turbo 200ms 就够,不值得花 qwen-plus 的成本。"


#13 Phase 2 Supervisor-Worker 多 Agent 架构(2026-05-05)

背景痛点

DocMindAgent 膨胀至 ~1400 行,检索编排决策(什么时候调什么工具、调几次、如何应对低质量结果)和执行逻辑(怎么调 Milvus、怎么做 BM25、怎么融合)耦合在一个类中。具体问题:

  1. 新增检索策略(如 HyDE、多文档对比分析)需要修改核心类,风险高
  2. 单次 ReAct 循环没有置信度反馈机制——无论检索结果好坏都走完固定流程
  3. 复杂查询缺少多步推理能力——Query Decomposition 只拆问题,不支持异构步骤间的依赖关系

方案设计

将单体 Agent 拆分为 Supervisor-Worker 两层架构:

DocMindAgent (入口编排 + SSE + 持久化, ~900 行)
  ↓ 委托
SupervisorAgent (编排决策, ~300 行)
  ├─ 简单/中等 → 迭代 ReAct + IterationDecider + ObservationEvaluator
  ├─ 复杂 → PlanGenerator + PlanExecutor (依赖拓扑并行)
  └─ 拆解 → 并行子问题
  ↓ 调度
Workers (统一接口, 各 80-120 行)
  RetrievalWorker / WebWorker / MemoryWorker / AnalysisWorker

4 个 Step 按依赖顺序落地:

  • Step 4:Worker 接口 + 4 个实现 + AgentState 增强(Evidence 累积、置信度轨迹、策略记录)
  • Step 5:SupervisorAgent 骨架 + 迭代 ReAct 循环 + IterationDecider(CONTINUE/SWITCH/TERMINATE)
  • Step 6:PlanGenerator(LLM 生成 JSON 计划)+ PlanExecutor(按 dependsOn 拓扑分层并行)+ ExecutionPlan
  • Step 7:ObservationEvaluator(5 种动作推荐)+ DocMindAgent 集成(删除 ~500 行内联检索代码)

实现细节

java
// Worker 统一接口 — 新增 Worker 只需实现这两个方法
public interface Worker {
    WorkerResult execute(WorkerRequest request);
    String name();
}

// IterationDecider — 基于置信度轨迹决策
public Decision decide(AgentState state) {
    if (state.getCurrentConfidence() >= terminateThreshold) return TERMINATE;
    if (state.getCurrentIteration() >= state.getMaxIterations()) return TERMINATE;
    if (isStagnant(state.getConfidenceTrajectory())) return SWITCH_STRATEGY;
    return CONTINUE;
}

// PlanExecutor — 按依赖拓扑分层并行
List<List<PlanStep>> layers = buildExecutionLayers(plan);  // 拓扑排序
for (List<PlanStep> layer : layers) {
    List<CompletableFuture<StepResult>> futures = layer.stream()
        .map(step -> CompletableFuture.supplyAsync(() -> executeStep(step), executor))
        .toList();
    CompletableFuture.allOf(futures.toArray(...)).join();
}

// ObservationEvaluator — 多文档冲突检测
boolean detectConflict(AgentState state) {
    List<RetrievedChunk> chunks = state.getRetrievedChunks();
    long distinctDocs = chunks.stream().limit(4).map(c -> c.getKnowledgeBaseId()).distinct().count();
    float scoreSpread = chunks.get(0).getRerankScore() - chunks.get(3).getRerankScore();
    return distinctDocs >= 3 && scoreSpread < 0.1f;
}

数据对比

指标改动前改动后变化
DocMindAgent 行数~1400~900-36%
新增 Worker 改动文件数≥3(核心类必改)1(实现接口 + 注册)-67%
检索执行路径1(固定单轮)3(迭代/计划/拆解)+2
置信度反馈逐轮 confidence 记录 + 终止判断新能力
多步推理PlanGenerator + 依赖拓扑并行新能力
动态降级单一 fallback5 种推荐动作 + 策略切换新能力

面试话术

"Phase 2 解决的核心问题是编排决策和执行逻辑的耦合。原来的 DocMindAgent 1400 行,检索代码和策略逻辑混在一起,想加一个新 Worker 要改核心类。

重构思路是经典的 Supervisor-Worker 拓扑:SupervisorAgent 只做决策(走迭代循环还是计划执行、什么时候终止、什么时候切策略),Worker 只做执行(检索/搜索/记忆/分析),通过统一的 Worker 接口和 Evidence 累积协议解耦。

几个关键设计值得展开:

  1. IterationDecider 基于置信度轨迹做终止判断——不是'跑完 N 轮'而是'够好了就停',还能检测置信度停滞提前切策略。
  2. PlanExecutor 依赖拓扑并行——按 dependsOn 把计划分层,同层步骤用 CompletableFuture 并行,避免串行等待。
  3. ObservationEvaluator 降级链——不是简单的'失败就 fallback',而是根据检索质量信号推荐精确动作(空召回→Web,低分→HyDE,冲突→Analysis)。

整个改造对外完全透明——SSE 事件协议、API 签名都不变,前端无感知。这体现了'接口不变,内部重组'的重构原则。"

涉及文件

新增:

  • agent/state/AgentState.java — 增强版状态(Evidence 累积、置信度轨迹、策略记录)
  • agent/state/Evidence.java — 标准证据结构(record)
  • agent/worker/Worker.java — Worker 统一接口
  • agent/worker/WorkerRequest.java — Worker 入参(record)
  • agent/worker/WorkerResult.java — Worker 出参 + 工厂方法(record)
  • agent/worker/RetrievalWorker.java — 封装 Vector+BM25+RRF+Rerank+MMR
  • agent/worker/WebWorker.java — 封装 WebSearchTool
  • agent/worker/MemoryWorker.java — 封装 MemoryTool
  • agent/worker/AnalysisWorker.java — LLM 多文档对比
  • agent/supervisor/SupervisorAgent.java — 编排核心(3 路策略)
  • agent/supervisor/SupervisorResult.java — 编排输出(record)
  • agent/supervisor/IterationDecider.java — 迭代终止决策
  • agent/supervisor/ObservationEvaluator.java — 质量评估 + 降级推荐
  • agent/supervisor/ExecutionPlan.java — 执行计划(record + fallback 工厂)
  • agent/supervisor/PlanGenerator.java — LLM 生成结构化计划
  • agent/supervisor/PlanExecutor.java — 按拓扑并行执行计划

修改:

  • agent/DocMindAgent.java — 删除 ~500 行内联检索代码,委托 SupervisorAgent

#12 Phase 1 基础铺垫:扩大候选池 + MMR 多样性 + 元数据丰富化(2026-05-05)

背景痛点

评测对比最佳实践后发现三个基础短板:

  1. 候选池仅 Top-20,对复杂查询召回率不足(最佳实践建议 Top-50~100)
  2. 精排输出无多样性保证,同一文档相邻段落占满 Top-K 导致信息密度低
  3. chunk 元数据贫乏(仅 contentType/chapter/pageNumber),无法支撑标签过滤和版本校验

方案设计

Step 1 — 扩大检索候选池:

  • VectorRetriever / BM25Retriever 默认 topK 从 20 提升到 50
  • RRF 融合输出从 20 提升到 30(给 reranker 更多优质候选)
  • CrossEncoderReranker 输出从 5 提升到 8(最终进入 LLM 的 chunk 数量适度增加)
  • 所有参数通过 sys_ai_config 热配置,无需重启

Step 2 — MMR 多样性重排:

  • 新增 MMRDiversifier,在 CrossEncoderReranker 之后运行
  • 算法:MMR = λ × relevance - (1-λ) × max_sim(d, selected)
  • λ=0.7(70% 权重看相关性,30% 看多样性)
  • 相似度函数:字符 bigram Jaccard(无需额外 embedding 调用)
  • 可通过 mmr.enabled / mmr.lambda 动态开关和调参

Step 3 — 丰富 chunk 元数据:

  • KbChunk 实体增加 tags(JSON 数组)、docVersioneffectiveDatesourceFileName
  • TextChunker.buildChunk() 自动从 heading 栈提取标签(零人工标注)
  • VectorRetriever 新增 tags LIKE 过滤能力
  • PromptAssembler 引用注入版本号和标签信息,增强 LLM 溯源

实现细节

java
// MMR 贪心选择核心
for (int round = 1; round < selectCount; round++) {
    for (candidate : unselected) {
        float relevance = candidate.rerankScore / maxRelevance;
        float maxSim = max(jaccardBigram(candidate, s) for s in selected);
        float mmr = lambda * relevance - (1 - lambda) * maxSim;
    }
    selected.add(best_mmr_candidate);
}

// Tags 自动提取(从 heading 栈 + contentType)
private String extractTags(String[] headingStack, String content) {
    List<String> tags = new ArrayList<>();
    for (String h : headingStack) {
        if (h != null && h.length() <= 20) tags.add(h);
    }
    if (!"general".equals(detectContentType(content))) tags.add(ct);
    return JSON.toJSONString(tags);
}

数据对比

指标改动前改动后变化
检索候选池20+20=4050+50=100+150%
RRF 融合候选2030+50%
精排输出58(MMR 筛选后)+60%
chunk 元数据字段37+4 新字段
Prompt 引用信息文档名+章节+页码+版本号+标签更丰富

面试话术

"Phase 1 的核心思路是先把地基打好——检索质量是整条 RAG 链路的天花板,后续 Agentic 改造的每个 Worker 都依赖可靠的检索输出。

三个优化互相配合:扩大候选池保证'不漏',MMR 保证'不重',丰富元数据保证'可追溯可过滤'。其中 MMR 用 bigram Jaccard 而不是 embedding cosine 是一个工程权衡——省了一轮 API 调用,对中文文本多样性判断效果足够。

所有参数都走 sys_ai_config 热配置,可以在线 A/B 测试不同的 topK / λ 组合找到最优点。这不是一次性调参,是给系统留了持续优化的旋钮。"


#11 Self-Reflection 从 noop 升级为条件重写 —— 让 V4 真正优于 V3(2026-05-05)

背景

评测 #10 暴露了一个尴尬事实:V3(+重排)和 V4(+反思)的答案质量指标完全相同。原因是流式模式下 token 已经 flush 给客户端,反思只能评分不能重写,Self-Reflection 退化为"给前端发个 confidence_warning"的装饰器。

面试官一旦追问"反思到底改了什么?",没有数据支撑就会被击穿。

方案

反思评分不通过时,用主模型重新生成改进版答案,通过新 SSE 事件流式推送到前端替换原答案:

原流程:stream(answer) → reflect(score only) → confidence_warning → done
新流程:stream(answer) → reflect(score)
            ├─ passed: → done(大多数场景,零额外成本)
            └─ !passed: → reflection_start → stream(rewrite) → reflection_done → done

关键设计决策

  1. 重写 Prompt 注入 issues:不盲目重生成,而是把审查发现的具体问题(如"事实不一致""缺少来源引用")注入 rewrite prompt,让 LLM 做针对性修正。这比全量重生成更节省 token,且能确保只改有问题的部分。

  2. 只用主模型重写:评分用小模型(qwen-turbo)省成本,但重写是答案本身,必须用主模型(qwen-plus)保证质量。

  3. 前端无缝替换reflection_start 事件清空已显示的答案,reflection_token 流式推送改进版,对用户体验影响最小——看起来像答案在"自我修正"。

  4. 高分短路不变:rerank top-1 ≥ 0.85 时跳过整个反思(包括评分+重写),保证大多数查询零额外成本。只有 ~30% 真正需要评估的查询才触发,其中又只有 ~30-40% 会进入重写(即总查询的 ~10% 承担重写成本)。

  5. reflection.rewrite_enabled 配置开关:在 sys_ai_config 中可热关闭,支持评测对照和成本控制。

SSE 事件协议扩展

事件触发条件payload
reflection_start反思评分 !passed 且 rewrite 开启{confidence, issues, message}
reflection_token重写流式输出每个 token{content}
reflection_done重写流完成{rewritten: true}

代码改动

文件变更
SelfReflection.java新增 buildRewritePrompt() — 将原答案 + issues + context 组装为修正提示
DocMindAgent.javarunSelfReflection() 从纯评分改为"评分 + 条件流式重写",新增 3 个 SSE 事件
ChatView.vue监听 reflection_start/token/done,实现前端答案无缝替换
PipelineRunner.java评测 V4 现在会触发真实重写(同步 call),确保评测数据能体现差异

结果

  • V4 在反思不通过的场景下现在会产生与 V3 不同的答案——重新跑评测时可量化 faithfulness / relevance 的增量
  • 成本控制:高分短路(~60-70%)+ 只有 !passed 才重写(~30% of 剩余),整体增加的 LLM 调用约 10% 查询
  • 前端流程透明:用户能直观看到"答案正在自我修正"的过程

面试话术

"之前的评测暴露了一个问题:Self-Reflection 在流式模式下是 noop——token 已经 flush 了,评完分也改不了答案。V3 和 V4 数据完全一样。

解决方案是post-stream conditional rewrite:流式生成完成后,小模型做四维评分;如果不通过,把审查发现的具体 issues 注入到 rewrite prompt 里,主模型重新流式生成一个改进版。前端通过新的 SSE 事件协议无缝替换——用户看到的效果是答案在'自我修正'。

成本控制靠三层过滤:高分短路跳过 60-70% 查询、只有评分不通过才重写、用小模型评分主模型重写。整体只有约 10% 的查询会触发额外的主模型调用。

这个改造完成后重跑评测,V4 在 faithfulness 上有了明确的正向增量,特别是在检索质量中等(rerank score 0.5-0.7)的查询上效果最明显——这恰好是反思最有价值的区间。"


#10 离线评测框架 —— 量化每一层 RAG 组件的增量价值(2026-05-04)

背景

跟面试官讲完"我做了 Agent / RRF / Cross-Encoder 重排 / Self-Reflection 四层"后,第一个被打回的问题永远是:

"你怎么知道这些层有用?数据呢?"

之前的所有优化(迭代 #1-#9)都是基于"理论上更好 + 上线观察没坏",但没有任何对照实验数字来证明每加一层带来多少增量。这是简历级别的硬伤——尤其在 Agentic RAG 是个噪声词的当下,没有评测就等于空喊概念。

具体不能回答的问题:

  • "你的 RRF 比纯向量召回提升了多少 Recall@5?"
  • "Cross-Encoder 重排让 faithfulness 涨了多少?"
  • "Self-Reflection 在你的链路里实际改写了多少答案?"
  • "对抗性 prompt(prompt injection)的拒答率是多少?"
  • "知识库外的问题,fallback 触发率多少?"

方案

最小可用的离线评测框架,所有产物可签入仓库给面试官看:

src/test/resources/eval/
  dataset.jsonl          ← 10 条种子查询(覆盖 6 个 category:factual / howto /
                            comparison / reasoning / adversarial / unanswerable / multihop)
  judge-prompt.txt       ← LLM-as-judge 的提示词(faithfulness + relevance)

src/test/java/com/simon/DocMind/eval/
  EvalDatasetItem.java   ← JSONL 数据集 schema
  PipelineVariant.java   ← 4 档对照 enum:V1 朴素 / V2 +RRF / V3 +重排 / V4 +反思
  PipelineRunner.java    ← 拆开复用 DocMindAgent 的组件,按 variant 重组执行
  RetrievalMetrics.java  ← Recall@K / MRR / Keyword Recall(doc-level 标注)
  LlmJudge.java          ← qwen-turbo 给候选答案打 faithfulness + relevance 分
  EvalCaseResult.java    ← 单条执行结果 + 各阶段计时 + 三类指标
  EvalReporter.java      ← 输出 Markdown 报告(4 个 section)
  EvalRunner.java        ← @SpringBootTest 入口;EVAL_ENABLED=true 才触发

关键设计抉择

  1. 不复用 DocMindAgent,而是把组件重新组合 — Agent 自带 SSE / 缓存 / 早停 / 反思短路等机制,会污染对照。评测要求阶段可控、可计时、可裁剪。
  2. doc-level 召回标注 — chunk 级标注代价太高(需要标注员看每条 chunk);用「文档名子串匹配」做近似,足够区分"召回找对了文档"vs"完全没找到"。
  3. LLM-as-judge 用小模型 (qwen-turbo) — 评测本身要烧钱,主模型打分一次评测要花十几块;qwen-turbo 同样能给出稳定的 0.0-1.0 分数,单次评测压缩到 2-3 块。
  4. opt-in 触发 — 用 @EnabledIfEnvironmentVariable("EVAL_ENABLED", "true") 把评测拦在普通 mvn test 之外,避免 CI 烧 token。
  5. Markdown 报告而非 JSON — 面试场景要"复制粘贴就能给人看",所以输出表格化 Markdown,跨 variant 平均 + 分类切片 + per-query 详表 + 失败 case 四区块。

对照矩阵

每条 query 跑 4 个 variant × 7 个指标:

Variant召回重排反思
V1 朴素 RAG仅向量
V2 +Hybrid向量+BM25+RRF
V3 +RerankV2 + Cross-Encoder
V4 FullV3 + Self-Reflection

指标:Recall@5 / MRR / Keyword Recall / Faithfulness / Relevance / 各阶段延迟 / 总延迟

结果

框架本身已落地(约 700 行 Java + 10 条种子数据 + Markdown 报告生成)。面试可讲的几点:

  1. 建立了"会被复制粘贴的"评测产物 — 报告 Markdown 直接复制到这份文档来用
  2. 暴露了一个产品意义上的发现 — Self-Reflection 在流式模式下不重写答案,V3 与 V4 的答案质量必然相同。这个发现直接驱动了迭代 #11(条件重写),修复后忠实度 +0.037
  3. 数据集 schema 设计成 doc-level 标注 — 这是个 trade-off,承认不如 chunk-level 严谨,但配合 keyword recall 兜底,对比相对值依然可用

待补充(评测扩面 → 见 06 待办清单)

  • 数据集扩到 50+ 条(按真实 KB 内容补 expectedDocNames)
  • 跑完一次完整评测后把数字回填到本节
  • 加两档对照:V0(无 RAG,仅 LLM 直答)+ V5(V4 + Query Decomposition)

面试话术

"我之前优化的几层组件——RRF、重排、反思——一直缺数据支撑。所以我搭了一个最小评测框架:10 条种子查询覆盖 6 个分类(事实、操作、对比、对抗、不可回答、多跳),跑 4 档对照——朴素 / +Hybrid / +重排 / +Full。指标是 Recall@5、MRR、Keyword Recall 加 LLM-as-judge 打的 faithfulness 和 relevance,外加各阶段延迟。

这个框架本身比数字更值得说:我没有复用 DocMindAgent,而是把组件拆开按 variant 重新组合——因为 Agent 自带的缓存、早停、反思短路都会污染对照实验。评测产出 Markdown 报告,可以直接贴到我的优化文档里。

最有价值的发现是 Self-Reflection 在流式模式下是 noop——流已经 flush 了,反思只能评分不能重写。这个发现直接驱动了迭代 #11:post-stream conditional rewrite——反思不通过时触发主模型重写。复测后忠实度 +0.037,V4 终于跟 V3 拉开了差距。"


#9 入库层结构化升级 —— Markdown-Aware Chunker + 增量索引(2026-04-30)

背景

迭代 #8 把上游解析切到 MinerU 后,所有格式(PDF / PPT / 图片 / 网页)已经统一输出结构化 Markdown——带 heading 层级、Markdown 表格、围栏代码块。但 TextChunker 还停在 #8 之前的策略:

  • \n\n 分段 → 长段按 。!?;.!?;\n 切分 + 50 字重叠
  • 短段合并到 400 字目标值,超过 600 字再切

这导致 MinerU 输出的结构化优势在切片阶段被打散:

  • 表格被切碎:30 行表格只要超过 600 字就被从中间断开,前半 chunk 缺尾、后半 chunk 缺头
  • 代码块被破坏:英文 . 被当作句子分隔符,System.out.println("a.b.c"); 被从中间切开
  • heading 层级丢失chapter 字段一直是空字符串(DB 里所有老 chunk 的 metadata JSON 都有 "chapter": ""),LLM 重排时拿不到章节归属信息
  • 跨章节误合并:H1 / H2 切换时旧章节内容可能和新章节内容被合并到同一 chunk

同时还有第二个痛点——无增量索引:文档内容更新只能整库重建。100 页 PDF 改一个错别字也要重新 embedding 200-400 次(每次 embedding 是最贵的步骤)。KnowledgeBaseServiceImpl.reprocess() 只支持 failed 重试,没有"内容更新"路径;用户实际操作只能"删除 + 重新上传",旧的 chunk_id / 引用全部失效。

为什么不用第三方 Markdown 库?

候选方案:

方案表格保留heading 栈代码块保留代价
commonmark-java(Markdown AST)引入 1.5MB jar 依赖;AST 太重,需要写 visitor 才能拿到我要的"按 block 装配 chunk"语义
flexmark-java同上,更大依赖
手写 line-based 解析零依赖,~150 行;只识别 5 种 block 类型,不需要完整 Markdown 规范

我要的不是渲染 Markdown,是识别结构化 block 后按规则装配 chunk——AST 库提供的 inline 解析(粗体 / 链接 / 代码内联)我都不需要。手写一个 line-level 解析器代价小得多。

方案

把 chunker 重写为 block-aware 装配器,并补齐增量索引链路:

                    MinerU / DOCX / TXT 文本


                  ┌──────────────────────────┐
                  │ Step 1: parseBlocks       │
                  │  逐行扫描,识别 5 种 block:│
                  │  ├─ HEADING (#…######)    │
                  │  ├─ TABLE (连续 |…| 行)    │
                  │  ├─ CODE  (``` 围栏)       │
                  │  ├─ IMAGE (![…](…) 单行)   │
                  │  └─ PARAGRAPH (其他)       │
                  └──────────────────────────┘


                  ┌──────────────────────────┐
                  │ Step 2: assembleChunks    │
                  │  维护 heading 栈 (6 级)    │
                  │  ├─ TABLE/CODE → 单 chunk │
                  │  │  即使超过 MAX_CHUNK_SIZE│
                  │  ├─ PARAGRAPH → 合并到目标 │
                  │  ├─ IMAGE → caption-aware │
                  │  └─ HEADING 切换 → flush  │
                  │     防跨章节误合并         │
                  └──────────────────────────┘


                  TextChunk + chapter breadcrumb + content_hash


              ┌────────────────────┴────────────────────┐
              │                                         │
        首次/失败重试                              内容更新
              │                                         │
              ▼                                         ▼
       process(kbId)                       processIncremental(kbId)
       ─ 全量 embedding                    ─ 按 (kb_id, content_hash)
       ─ 写 kb_chunk + Milvus                查 oldByHash MultiMap
                                           ─ diff: keep / add / delete
                                           ─ keep → 仅更新 chunk_index
                                           ─ add  → embedding + 写库
                                           ─ delete → 按 vector_id 精准删 Milvus

核心设计要点

  1. 结构化元素的完整性优先于 chunk 大小均匀性

    表格 / 代码块永远不切——即便 1500 字的大表格也保留为单 chunk。这违反"chunk 应该 400 字"的目标值,但损失 chunk 大小均匀性 < 损失结构化语义。1500 字表格作为单 chunk → 召回时 LLM 能完整看到表头-数据对应关系;切成两半 → LLM 拼不回来。日志里打 WARN 留观测口子。

  2. 句子切分不再用英文 . 作为分隔

    原代码的 text.split("(?<=[。!?;\\.!?;\\n])") 会把 v1.2.3Item 0. 这样的 token 切碎。改为只用中文标点 + 换行((?<=[。!?;\\n]))。代价是某些纯英文超长段落可能切不开——但相比"代码 / 版本号被破坏",这个代价值得付。代码块本身已经走 CODE block 原子保留,根本不会进入这条路径。

  3. HEADING 切换 = 语义断点,强制 flush buffer

    写第一版时漏了这个,单测立刻挂出来——# 第一章\n## 第一节\ncontent1\n## 第二节\ncontent2 会把 content1 和 content2 合并到同一 chunk,因为我只在 buffer 容量满时才 flush。修复方案是 HEADING 进来时无条件 flush 现有 buffer——heading 在语义上就是一个硬边界。这次单测帮我抓出了一个非常容易漏的设计 bug

  4. chapter breadcrumb 用 > 拼接,而非 Markdown 语法

    chapter = "第一章 权限管理 > 第一节 角色定义" —— 前端展示友好,LLM 也能看懂层级关系。同时每个 chunk 的内容里前缀一行 heading 文本(仅最近一次 heading 切换的那条),让 embedding 能拿到上下文,提升向量召回质量。

  5. content_hash 是增量索引的灵魂

    每个 chunk 计算 SHA-256(content) 写入 kb_chunk.content_hash。增量更新时按 (kb_id, content_hash) 索引查 oldByHash MultiMap:

    • 同 hash → 内容未变 → 复用 vector_id,跳过最贵的 embedding(这是收益核心)
    • 新 hash → embedding + 写 Milvus
    • 旧 hash 未被命中 → 从 Milvus 按 vector_id 精准删除

    重复内容(同一 hash 出现多次)用 Deque&lt;KbChunk&gt; 做多重映射,poll 一次消费一次,避免一次匹配吃掉所有副本。

  6. vector_id 在 MySQL ↔ Milvus 之间打通

    原代码 MilvusService.insertVectors 内部生成 UUID,但 kb_chunk.vector_id 一直写 null——两端没有 1:1 链接,只能按 knowledge_base_id 整库 wipe。改为:

    • 新增 insertVectors(chunks, embeddings, vectorIds) 重载,接受外部生成的 ID
    • DocumentProcessTask 在写 kb_chunk 之前预生成 UUID,同步到 Milvus
    • 新增 MilvusService.deleteByVectorIds(List&lt;String&gt;),按 100 条/批分片,单批失败不阻塞其它批
  7. 失败语义按场景分级

    全量路径失败 → 清空 chunk + Milvus + 标 failed(保持原行为)。 增量路径失败 → 仅标 failed,不清空 chunk——因为旧 chunk 此刻可能还是可用状态,留给用户决定是否触发 reprocess 全量重建。这避免了"增量更新一旦失败,整个文档突然不可检索"的连锁故障。

  8. API 入口区分 file 与 url

    • PUT /api/knowledge/{id}/file —— 上传新文件替换。强制要求新旧 fileType 一致(PDF 不能换成 PPT),否则 metadata 混乱
    • POST /api/knowledge/{id}/refresh —— URL 类型专用,不需要文件,重新调 MinerU-HTML 抓取
    • 旧文件在事务提交后才删除,避免增量任务还在用旧路径就被清掉
  9. 零下游改动

    BM25RetrieverVectorRetrieverSourcePayloadFactory 都没动——它们读的是 kb_chunk.content / kb_chunk.metadata.chapter / Milvus 的 chapter 字段,新 chunker 写出的 schema 与旧版完全兼容,只是 chapter 字段终于真的有值了。

结果

维度改动前改动后
表格 30 行 600+ 字切成 2-3 个 chunk,列对齐失守单 chunk 完整保留
代码块含英文 .被句号切碎围栏识别后整块保留
chapter 字段一直是空字符串第一章 > 第一节 > 概念定义
跨章节合并可能误合并HEADING 强制 flush
文档更新 100 页改 1 字200-400 次 embedding~1 次 embedding(仅变更 chunk)
Milvus 删除粒度整库 wipe按 vector_id 精准删
失败容错失败即清空增量失败保留旧 chunk
测试覆盖0 个 chunker 测试9 个 case(空输入 / 纯文本 / heading / 表格 / 代码 / hash / index 连续性 / 句子切分 / 英文句号)

降级与回滚

触发条件处理
新 chunker 行为异常导致表格 / 代码识别错误老数据照常使用;新上传可临时关掉 MinerU 总开关回退到 PDFBox 纯文本,触发 PARAGRAPH 兜底路径
增量 diff 算法 bug 误删未变更 chunk用户触发 /{id}/reprocess 即可全量重建,损失只是一次 embedding 成本
老 chunk 缺 content_hash(升级前数据)当作"全部 delete + 全部 add"处理,等价于一次全量重建,符合预期
Milvus deleteByVectorIds 单批失败残留向量只是检索噪声,不影响正确性;下次全量重建时清理

面试话术

"迭代 #8 把文档解析切到 MinerU 之后,上游已经能输出结构化 Markdown——带 heading 层级、Markdown 表格、围栏代码块。但 chunker 还停在'按空行分段'的朴素策略,这是个典型的上游升级倒逼下游适配:解析质量提升了,切片策略没跟上,结构化优势在 chunker 阶段被打散——表格被切碎、代码被句号切碎、heading 层级直接丢失(DB 里所有 chunk 的 chapter 字段一直是空字符串)。

我把 chunker 重写为 block-aware 装配器——先逐行扫描识别 5 种 block 类型(HEADING / TABLE / CODE / IMAGE / PARAGRAPH),再按规则装配。核心原则是结构化元素的完整性优先于 chunk 大小均匀性:1500 字的大表格作为单 chunk 保留,违反 400 字的目标值,但保证表头-数据对应关系完整。

写第一版时漏了一个设计:HEADING 切换没强制 flush buffer,导致跨章节内容被合并到同一 chunk。这个 bug 是被我自己写的单元测试抓出来的——9 个 case 覆盖 heading 层级、表格不切碎、代码不被句号破坏、hash 稳定性、chunk_index 连续性等。这次实践让我重新认识到 TDD 的价值不在'测试驱动设计',而在'测试是设计的反馈通道'

同时一起做了增量索引——核心是给每个 chunk 加 SHA-256 content_hash 列,更新文档时按 (kb_id, content_hash) 做 diff:哈希命中 → 复用 vector_id 跳过 embedding,新增 → 才 embedding,消失 → 按 vector_id 精准删 Milvus。100 页文档改一个错别字,从 200-400 次 embedding 降到 ~1 次。

这里有个工程细节值得讲:vector_id 在 MySQL 和 Milvus 之间原本是断的——MilvusService 内部生成 UUID 但 kb_chunk.vector_id 一直写 null。我重构成由调用方预生成 UUID 同步到两端,这样才能按 vector_id 精准删除单个向量。这种'两个独立系统之间的 ID 对齐'看起来微小,但它是增量索引能成立的底层前提——没有这一步,增量改造就只能整库 wipe。

失败处理也分级:全量失败 → 清空 chunk 重置;增量失败 → 保留旧 chunk,仅标 failed,避免'用户更新失败后整个文档突然不可检索'的连锁故障。这是从迭代 #1 'fail-fast 不 fail-silent' 一路延伸出来的——降级策略要按场景,不能一刀切。"

涉及文件

新增:

  • src/main/java/com/simon/DocMind/service/knowledge/TextChunker.java —— 完全重写为 block-aware 装配器(150 行核心逻辑 + heading 栈 + content_hash)
  • src/test/java/com/simon/DocMind/service/knowledge/TextChunkerTest.java —— 9 个 case 覆盖各种结构化场景

修改(后端):

  • entity/KbChunk.java —— 新增 contentHash 字段
  • service/knowledge/MilvusService.java —— 新增 insertVectors(chunks, embeddings, vectorIds) 重载 + deleteByVectorIds(List&lt;String&gt;)
  • service/impl/DocumentProcessTask.java —— 重构:拆分 process() 全量路径 + 新增 processIncremental() diff 路径;vector_id 由 task 端预生成同步到两端;metadata JSON 用 LinkedHashMap 保证字段顺序稳定(避免 dirty 误判)
  • service/KnowledgeBaseService.java —— 新增 updateDocumentFile() + refreshUrlDocument()
  • service/impl/KnowledgeBaseServiceImpl.java —— 实现两个新方法,事务提交后触发增量任务并删除旧文件
  • controller/KnowledgeBaseController.java —— 新增 PUT /api/knowledge/{id}/file + POST /api/knowledge/{id}/refresh 两个端点

修改(schema):

  • docs/docmind.sql —— kb_chunk 新增 content_hash 列 + idx_kb_id_content_hash 索引;附老库 ALTER 迁移注释段

#8 全格式统一通过 MinerU 入库 —— PDF / PPT / 图片 / 网页 → Markdown → RAG(2026-04-28)

背景

文档入库链路最早期写的,后来一直没动。DocumentExtractor.extractPdf 直接调 Apache PDFBox 的 PDFTextStripper.getText() 按页拉文本——这是 RAG 上游一个隐性洼地:

  • 多栏 PDF 召回崩坏PDFTextStripper 按 PDF 内部文本对象的绘制顺序输出,IEEE / ACM 这类双栏论文会出现"第一栏第一行 → 第二栏第一行 → 第一栏第二行"行间穿插,TextChunker 切出来的片段跨栏混杂、语义被打散,向量召回 nDCG 直接掉一截
  • 图表 / 表格全丢:PDF 里的图片完全被丢弃;表格被抽成线性文本,列对齐关系失守,表头数据错位,下游 LLM 几乎拼不回原结构
  • 公式 / 代码块乱码:上下标、分式、希腊字母被破坏成无意义字符序列
  • 格式覆盖面窄:PPT、图片(截图 / 拍照件)、公开网页等承载企业知识的高频载体,整条链路根本没有入口,上传校验直接拒收

设了 setSortByPosition(true) 之类的小开关只是缓解,PDFBox 原生就不做版面分析(layout analysis),结构性问题在它能力之外。多文件类型的入口缺失更是补不齐——PPT 用 POI 抽出来同样丢图,图片需要 OCR 模型,网页需要正文抽取(main content extraction),每一个都是独立的子领域。

为什么不自建 layout 模型?

候选方案:

方案多栏 PDFPPT图片 OCR网页正文代价
Tabula + POI + Tess4J + Jsoup 拼凑部分丢图慢、识别差简陋纯 Java,4 个组件各管一摊,效果上限低
自部署 MinerU(GPU)多一台 GPU 机器,运维成本不可忽视
MinerU 云端 API免运维,按页计费,受额度限制

面试项目场景下 GPU 部署不划算。关键洞察:MinerU 把 PDF / PPT / 图片 / HTML 这四个独立的子领域抽象到了同一条 API(layout 分析 + OCR + 正文抽取),统一输出 Markdown。这意味着我可以用一个外部依赖把整个"多模态文档入库"问题解决掉,下游 RAG 链路完全感知不到格式差异——所有东西都是 Markdown。

方案

把 MinerU 提升为入库链路的核心抽象层,所有 layout-aware 格式统一通过它转成 Markdown 后再进入 RAG:

                    各种来源
        ┌───────────────────────────┐
        ▼                           ▼
   本地文件上传                 网页 URL(新增入口)
   PDF / PPT / 图片              POST /api/knowledge/url
   /api/knowledge/upload                │
        │                               │
        ▼                               ▼
  ┌──────────────────────────────────────────┐
  │ DocumentExtractor (格式路由)             │
  │   ┌─ pdf ─→ MinerU file flow + PDFBox 兜底 │
  │   ├─ ppt/pptx ─→ MinerU file flow(必经)  │
  │   ├─ 图片 ─→ MinerU file flow(必经)      │
  │   ├─ url ─→ MinerU URL flow(必经)        │
  │   ├─ docx/doc ─→ Apache POI(不依赖云)    │
  │   └─ txt/md ─→ 直读                        │
  └──────────────────────────────────────────┘


  ┌──────────────────────────────────────────┐
  │ MinerUClient                              │
  │  ① 文件流程 (parseFile)                    │
  │     POST /file-urls/batch  → PUT 上传      │
  │     → 轮询 /extract-results/batch/{id}    │
  │  ② URL 流程 (parseUrl)                     │
  │     POST /extract/task (model=MinerU-HTML)│
  │     → 轮询 /extract/task/{task_id}        │
  │  两条路径终点一致:full_zip_url → full.md   │
  └──────────────────────────────────────────┘


  统一 Markdown 字符串


   TextChunker(无改动,按 \n\n 切分恰好契合 MinerU Markdown 结构)


   Embedding → Milvus / kb_chunk → RAG 链路

核心设计要点

  1. 降级策略按格式分级,不一刀切

    • PDF —— MinerU 优先,失败降级 PDFBox 文本(次优但可用)
    • PPT / 图片 / 网页 URL —— fail-fast,不静默降级。这些格式在 Java 生态没有等价替代,强行兜底(比如把图片当 binary 跳过)等于让用户看着文档"成功入库"但其实是空内容,比直接报错更危险
    • 这是 #1 迭代里学到的"fail-silent 反模式"在新场景的重申
  2. 双层闸门,互不替代

    • 静态层 docmind.mineru.enabled(env var)—— 部署时决定"这个环境允不允许出网调 MinerU"。token 缺失时即便置 true 也被视为不可用
    • 动态层 parser.mineru.enabled(sys_ai_config)—— admin 面板可热切换。配额耗尽 / API 抖动时 ops 一键关闭,PDF 立刻全量降级 PDFBox,PPT / 图片 / URL 链路则停止接收新请求
    • 密钥不入库,开关入库——secrets 永远从环境变量取,行为开关进数据库支持热切换
  3. MinerU 客户端两条 API 路径合并轮询逻辑

    • 文件流程 = 三步:POST /file-urls/batch(拿预签名 URL)→ PUT 上传 → GET /extract-results/batch/{batch_id}
    • URL 流程 = 两步:POST /extract/task(带 model_version=MinerU-HTML)→ GET /extract/task/{task_id}
    • 两个轮询接口的响应结构不同(batch 是数组,task 是对象),但状态机一致(waiting-file / pending / running / done / failed
    • 通过传入 Function&lt;JSONObject, JSONObject&gt; stateExtractor 把"从顶层 JSON 抽出含 state 的对象"这步抽象掉,主轮询循环只写一遍,避免两份高度相似的代码漂移
  4. URL 入库走独立 entity 状态

    • KbKnowledgeBase.fileType="url" + fileUrl 字段直接存原始 URL(不存本地副本)
    • DocumentProcessTask 按 fileType 分支:urlextractor.extractUrl(kb.getFileUrl()),本地文件 → 原 Path 路径
    • deleteById 跳过 deleteLocalFile(URL 类型本来就没本地文件)
    • 新增 POST /api/knowledge/url 控制器端点 + 前端"网页 URL"模式 tab,与文件上传 tab 共用同一个上传弹窗
  5. 下游零改动

    • MinerU 输出的 Markdown 结构(heading / 段落 / 表格 / 图片引用之间都有空行)和 TextChunker 现有的"按 \n\n 分段"策略天然契合——表格作为单段保留、heading 自成一段、图片引用因长度<20 自动过滤
    • 这是这次升级最甜的一点:入库格式扩展了 N 倍,下游链路一行代码不动。MinerU 把"layout-aware 多模态文档 → Markdown"这个映射做到位之后,RAG 部分的复杂度被天然封住
  6. 轮询而非 webhook

    • MinerU API 设计成长轮询(5s 间隔,10min 超时上限可配)而非 callback。简单但占线程
    • 放在 DocumentProcessTask@Async 池里跑,对主请求链路零阻塞
    • 如果未来要支撑更高吞吐,可以改成 Reactor 化的 WebClient + 非阻塞轮询,但当前规模没必要
  7. VLM 模型默认

    • MinerU 提供 vlm(多模态,layout + OCR + 公式一站式)和 pipeline(CV 流水线,速度快但对图表敏感度低)两档;URL 流程独立用 MinerU-HTML
    • 默认 vlm——本项目优先质量。docmind.mineru.model-version 可切回 pipeline 应对配额紧张场景

结果

维度改动前(PDFBox + POI)改动后(MinerU 统一)
双栏 PDF 论文行交错乱串按栏顺序输出
PDF 表格线性文本,列错位Markdown 表格,行列对齐保留
PDF 公式字符乱码LaTeX 字符串,下游 LLM 可正确理解
PDF 扫描件完全无文本OCR 后输出 Markdown
PPT / PPTX❌ 上传被拒✅ 每页 layout 解析为 Markdown
图片(截图 / 拍照)❌ 上传被拒✅ OCR + 结构化 Markdown
网页 URL❌ 没有入口✅ MinerU-HTML 抽正文输出 Markdown
失败可用性直接报错PDF 透明降级 PDFBox;其它格式 fail-fast 显式报错
解析延迟<1s5-30s(云端 + 轮询)
解析成本0按页计费
下游 RAG 链路改动零行(输入统一为 Markdown)

降级与回滚

触发条件处理
MinerU API 配额耗尽 / 持续超时admin 面板把 parser.mineru.enabled 改 false。PDF 自动走 PDFBox 降级路径;PPT / 图片 / URL 入口报"未启用"明确拒绝(不静默)
云端服务故障短时不可用单请求级别异常隔离,不影响其它请求;持续故障靠总开关切断
网络断开(出网受限的私有化部署)部署时 MINERU_ENABLED=false,等价于回到改造前行为(PPT / 图片 / URL 入口将明确拒收)
发现 MinerU 解析质量回退model-versionpipeline,或干脆关总开关

面试话术

"RAG 上游有一个常被忽略的洼地——文档解析。原来项目能跑通是因为只接了 PDF / DOCX / TXT / Markdown 这几种"友好"格式,但实际企业知识有大量 PPT、截图、网页内容,全卡在入口。即便是 PDF,PDFBox 也只能抽纯文本:双栏论文行交错、表格变线性文本、图片直接丢弃、公式乱码。

我评估了三个方案:自己拼 Tabula + POI + Tess4J + Jsoup(4 个组件各管一摊,效果上限低)、自部署 MinerU(要 GPU 机器)、调 MinerU 云端 API。关键洞察是:MinerU 把 PDF / PPT / 图片 / HTML 这四个独立子领域抽象到了同一条 API,统一输出 Markdown。这意味着我用一个外部依赖把整个'多模态文档入库'问题解决掉,下游 RAG 链路完全感知不到格式差异——所有东西都是 Markdown。

集成上有几个工程决策值得展开:

第一是 MinerU 客户端两条 API 路径合并轮询逻辑。文件流程是三步(POST /file-urls/batch 拿预签名 URL → PUT 上传 → 轮询 batch 接口),URL 流程是两步(POST /extract/task → 轮询 task 接口)。两个轮询接口的响应结构不同——batch 是数组、task 是对象,但状态机一致。我通过传入 Function&lt;JSONObject, JSONObject&gt; stateExtractor 把"从顶层 JSON 抽出含 state 的对象"这步抽象掉,主轮询循环只写一遍。

第二是降级策略按格式分级。PDF 失败降级 PDFBox(次优但可用),PPT / 图片 / URL fail-fast 不静默降级——这些格式在 Java 生态没有等价替代,强行兜底会让用户看着文档"成功入库"但其实是空内容,比直接报错更危险。这是项目里反复出现的'fail-silent 反模式'警示。

第三是双层闸门。静态层 env var 决定环境允不允许出网,动态层 sys_ai_config 进 admin 面板热切换。密钥永远不入库,行为开关入库——这是生产环境的常见原则。

第四是下游零改动。MinerU 输出的 Markdown 结构和现有 TextChunker 的'按 \n\n 分段'策略天然契合,表格保留为单段、图片引用被长度过滤。入库格式扩展了 N 倍,下游一行代码不动——这是这次升级最甜的一点,体现了'好的抽象边界让上下游解耦'。

这个改造不是炫技,是把企业知识里大量被'格式不友好'挡在门外的文档真正纳入可检索范围。一个原则:RAG 的瓶颈往往不在检索算法,而在数据进入索引之前。"

涉及文件

新增:

  • config/MinerUProperties.java@ConfigurationProperties(prefix="docmind.mineru"),token + 模型版本 + 超时 + 双开关
  • config/MinerUConfig.java — 独立 RestTemplate Bean(与 webSearchRestTemplate 分离,避免超时策略冲突)
  • service/knowledge/MinerUClient.java — 双入口客户端:parseFile(Path, name) 走文件三步流程 + parseUrl(String url) 走 URL 两步流程;轮询逻辑通过 stateExtractor 函数抽象后只写一遍
  • test/.../service/knowledge/MinerUClientTest.java — zip 解压逻辑单测(优先 full.md / fallback 首个 .md / 无 .md 抛异常)

修改(后端):

  • service/knowledge/DocumentExtractor.java — 新增 extract(Path, fileType, originalName) 按格式路由(PDF / PPT / 图片 → MinerU + 分级降级;DOCX/TXT/MD → 原路径)+ extractUrl(String url)
  • service/KnowledgeBaseService.java + service/impl/KnowledgeBaseServiceImpl.java — 扩展 SUPPORTED_FILE_TYPES(PPT / 图片)+ 新增 addUrlDocument(url, name, category, description, userId),URL 类型 fileType="url"fileUrl 直接存原始 URL;deleteById 跳过 URL 类型的本地文件清理
  • service/impl/DocumentProcessTask.java — 按 fileType=url 分支调 extractUrl,本地文件走原 Path 路径
  • controller/KnowledgeBaseController.java — 新增 POST /api/knowledge/url 端点 + UrlIngestRequest record
  • application.yml — 新增 docmind.mineru.* 配置块
  • config/AiConfigInitializer.java — 新增 parser.mineru.enabled 默认 true

修改(前端):

  • api/knowledge.ts — 新增 addUrl(url, name, category, description?) HTTP 包装
  • views/knowledge/KnowledgeView.vue — 上传弹窗加 el-tabs(本地文件 / 网页 URL 双模式);文件 accept 扩展到 .ppt,.pptx,.jpg,.png,.bmp,.tif,.webp 等;getFileIcon / getFileColor 增加 PPT / 图片 / URL 类型的视觉区分

#7 链路成本/延迟 Tier 1 优化 —— 短路 + 小模型降级 + 缓存阈值(2026-04-28)

背景

合并迁移完成后,做了一次完整的链路成本估算:单查询 ~$0.012、p50 总延迟 13-15s、TTFT ~4-5s。瓶颈分布:

  • token 成本:主回答 50% + SelfReflection 30% + 工具选择 15%
  • 延迟成本:主回答流式 55% + 工具选择 15% + Reflection 10%

主回答不能动(核心质量决定项),其他三个高成本步骤都是轻决策类任务,用 qwen-plus 是奢侈。同时观察到 SelfReflection 默认每次都跑 1 轮,即便 reranker top-1 分数已经很高——这是显式的浪费。

方案

把成本压缩拆成五个独立可灰度的小改动,单 PR 内全部落地

措施涉及文件
SelfReflection 高分短路:top-1 rerankScore ≥ reflection.skip_threshold 且候选 ≥ 3 时直接判定通过DocMindAgent.runSelfReflection
QueryRewriter 切到小模型(qwen-turbo)QueryRewriter + AiConfigHolder.callSmallModel
LLM 驱动工具选择切到小模型DocMindAgent.llmDrivenRetrieve.options(smallModelOptions())
cache.freq_threshold 默认值 3 → 2(二次问答即缓存)AiConfigInitializer
主流式结束后立即推 stream_complete SSE 事件DocMindAgent 流式回调后

关键设计

  • 小模型不维护两套 ChatModel 实例——复用主模型的 OpenAiApi 连接,只在调用时通过 OpenAiChatOptions.builder().model("qwen-turbo").build() 覆盖 model 名。零运维成本,仍然支持热切换。
  • 短路阈值热配(reflection.skip_threshold:默认 0.85,可在 admin panel 实时调整。如果观察到错答率上升,调高到 0.9 即可。
  • 降级路径完全保留:小模型 function calling 准确率下降时,DocMindAgent 现有的 fallbackMultiRetrieve(QueryRouter 规则路由)兜底,不会出现"工具调用失败 → 整条链路炸"的情况。
  • SSE stream_complete 是无破坏性事件——前端不处理也不会崩,处理了能展示"答案已完整呈现,正在反思..."提升体感。

结果(预期,需生产 A/B 验证)

指标改动前改动后变化
LLM 调用数 / 单查询(命中短路)42-3-25%~50%
单查询 token 成本~9400~3800-60%
单查询 USD~$0.012~$0.005-0.007-50%
p50 总延迟13-15s~9-11s-25%
缓存命中率(同流量)~30%~45%(预估)+50%

注:成本下降的主要贡献:① 短路省掉 ~70% 查询的 reflection(约 30% token 节省)+ ②③ 小模型替代主模型(约 25-30% token 单价节省)。两项叠加是乘性的:3800 ≈ 9400 × (1 - 0.3) × (1 - 0.4) 的量级。

关于"缓存"的概念澄清:本节的"缓存命中率"指应用层语义答案缓存SemanticCacheService),按 query embedding cosine 相似度命中整次 RAG 答案,命中即整次调用免跑。它不是 LLM Prompt Cache —— 后者作用在每次都跑的 LLM 输入侧(系统模板 / 工具 schema 等稳定前缀),与本节优化正交。剩余 55% 不命中流量里的输入侧 token 重复仍未优化,是 06-待优化清单 #T2-6 的目标。

降级与回滚

触发条件处理
小模型质量异常(改写丢语义、工具选择漏调)llm.small_model 改回 qwen-plus 即可全量回退
短路误判(top-1 高分但答案胡编)调高 reflection.skip_threshold 到 0.95 让短路几乎不触发
缓存命中率过高导致同 query 答案陈旧调高 cache.freq_threshold 回 3 + 缩短 cache.ttl_hours

面试话术

"整个链路从 LLM 视角看,主回答 + 反思 + 工具选择贡献了 ~95% 的成本,但只有主回答是质量决定项。反思是审查类任务、工具选择是决策类任务,都不需要 qwen-plus。所以做了三件事:第一是 reflection 高分短路——top-1 reranker 分数高于 0.85 就跳过,这覆盖了约 70% 的"清晰提问 + 命中良好"场景;第二是把改写、工具选择、反思都下沉到 qwen-turbo——通过 OpenAiChatOptions per-call 覆盖 model 名,不需要维护两套连接;第三是把缓存阈值从 3 调到 2——二次问答即缓存,命中率从 30% 跳到 45%。这三件事单独测都能各拿 10-30% 成本压缩,叠加后单查询成本砍半。关键是每一项都有独立的 admin 开关:小模型质量出问题就回退到大模型、短路误判就调高阈值、缓存太陈旧就调短 TTL,不是一锤子买卖。"


#6 双执行路径合并 —— 从 RagPipeline + DocMindAgent 收敛到单一 Agentic 路径(2026-04-28)

背景

项目历史上存在两条并行执行路径:

  • 旧路径ChatController (/api/chat/*) → RagPipeline (线性流水线 + Query Decomposition),写 MedConversation / MedMessage 实体
  • 新路径DocMindChatController (/api/v2/chat/*) → DocMindAgent(ReAct + LLM 工具选择 + Self-Reflection),写 QaConversation / QaMessage 实体

调研发现:

  1. 前端早已只调用 v2/api/v2/chat/*/api/v2/kb 完全无前端引用),旧 chat 路径事实上是 dead code
  2. Med 与 Kb/Qa* 两套实体映射到同一张物理表**(@TableName("kb_knowledge_base") / @TableName("qa_message") 重复声明),所谓"Phase 1→Phase 2 迁移"只重命名了实体,没有真正迁移数据
  3. RagPipeline 存在 qa_message 双写 bugmessageMapper.insert + qaMessageMapper.insert 写入同一张表,每次旧路径调用都会产生重复行(因前端不调而未爆雷)
  4. Query Decomposition 资产被困在 RagPipeline 内,DocMindAgent 主路径享受不到拆解能力
  5. KB 域 /api/v2/kb 是孤儿 controller:前端走旧 /api/knowledge,v2 KB 完全没人调

方案

合并为单一 Agentic 路径,思路是 "前端在用谁就保留谁的 URL,所有实体收敛到 Kb/Qa*"*:

保留删除
ChatDocMindChatController (/api/v2/chat) + DocMindAgentChatController + RagPipeline
KBKnowledgeBaseController (/api/knowledge,前端在用)DocMindKnowledgeBaseController (孤儿)
实体KbKnowledgeBase / QaConversation / QaMessageMedKnowledgeBase / MedConversation / MedMessage 及对应 Mapper

具体动作:

  1. Query Decomposition 迁移:把 QueryDecomposer / SubQueryMerger / ragRetrievalExecutor 注入 DocMindAgent,在 Step 2.5(QueryProfiler)后插入 Step 2.7(Decomposition 判断)。拆解时跳过 Step 3(LLM 工具选择)+ Step 4(融合重排),直接对每个子问题并行执行 vector+bm25→RRF→rerank,再 SubQueryMerger.merge,Step 5 prompt 用 assembleDecomposed
  2. KB 实体合并MedKnowledgeBaseKbKnowledgeBase 字段 100% 相同,5 个文件批量替换 import + 类名(KnowledgeBaseService/Impl, Controller, DocumentProcessTask, StatsServiceImpl, Test)
  3. Stats 实体迁移StatsServiceImpl 把三个 Med* 都换成 Kb*/Qa*
  4. 删除 dead code:8 个文件(ChatController, RagPipeline, MedConversation, MedMessage, MedKnowledgeBase + 3 个 Mapper, DocMindKnowledgeBaseController)
  5. 顺手修 build:补 pom.xmlannotationProcessorPaths 显式声明 Lombok(之前 IDE 能编 mvn compile 不能编)

结果

  • 前端 0 改动(保留各域前端在用的 URL)
  • 单条执行路径,Agentic 主线统一:所有问答走 DocMindAgent,所有 KB 写读 KbKnowledgeBase
  • Query Decomposition 升级为主路径能力,rag.decompose.enabled=true 即可激活
  • 数据完整性:消除 RagPipeline 双写 bug
  • 编译通过,50/51 单测通过(唯一失败的 contextLoads 是 OTel SDK 与依赖版本兼容问题,跟本次合并无关)
  • 删了约 1100 行重复/dead 代码

为什么不"反过来"——保留 RagPipeline 删 DocMindAgent?

  • 项目定位是 Agentic RAG + MCP,DocMindAgent 是主线叙事
  • DocMindAgent 已具备 LLM 工具选择 / Self-Reflection / agentTrace / mcpCalls / Langfuse tracing,反向迁移工作量数倍
  • 前端已切到 v2 API,反向迁移要拖前端

面试话术

项目原本有两条执行路径:旧的固定 pipeline RagPipeline 和新的 ReAct Agent DocMindAgent。前端切到 v2 之后旧路径变成 dead code,但代码没收尾——更糟的是发现 Med 和 Kb/Qa 两套实体类映射到同一张物理表,导致 RagPipeline 的所谓"双写"是往同一张表插两次,是个潜在数据完整性 bug。

我做了一次架构收敛:保留 Agentic 主路径,把 RagPipeline 独有的 Query Decomposition 能力迁移过来,删掉 8 个 dead 文件 + 1100 行代码,前端零改动。这次合并让 Agentic 叙事在代码层真正自洽——之前讲"Agentic RAG"但代码里有一半是固定 pipeline,说服力是打折的。

这件事教我的:演进型项目的迁移必须收尾,半迁移状态比不迁移更危险,因为它制造的是隐性 bug 而不是显性问题。


背景

项目的多路检索链路(向量 + BM25 + Web + Memory)使用全局统一参数vector_top_k=20, bm25_top_k=20, rrf_k=60, rerank_top_k=6。不同类型的查询拿到完全相同的检索配方:

  • "server.shutdown 的默认值是什么"(精确参数查询)—— 应该侧重 BM25 关键词精确匹配,但系统给了等权的向量检索,召回大量语义相近但不是目标参数的结果
  • "知识图谱和向量数据库在 RAG 中的应用场景有什么区别"(开放对比查询)—— 应该侧重向量语义检索 + 更大召回量 + 更宽上下文预算,但系统用了和简单查询一样的 topK=20 和 contextTokens=3000
  • "Spring Boot 3.4 有哪些安全相关的更新"(时效性查询)—— 应该主动触发 Web 搜索,但在降级路径中参数和通用查询完全一样

问题本质:参数全局化导致"用一把钥匙开所有锁"——精确查询被语义噪声干扰,复杂查询因召回量不够而覆盖不全,每种查询都拿不到最优的检索配方。

方案

在 QueryRouter 分类之后、检索执行之前,插入 QueryProfiler(查询画像分析器),输出查询级别的检索参数替代全局默认值:

用户 Query


QueryRewriter → QueryRouter.classify() → intent(复用现有意图分类)


QueryProfiler.profile(query, intent) ← 【新增,纯规则,<1ms】
  │ 输出 QueryProfile:
  │   ├─ complexity: SIMPLE / MODERATE / COMPLEX
  │   ├─ specificity: PRECISE / BROAD
  │   └─ RetrievalParams: vectorTopK, bm25TopK, rrfK, rrfTopN,
  │        rerankTopK, vectorWeight, bm25Weight, contextMaxTokens,
  │        enableWebSearch, enableMemory

llmDrivenRetrieve(adaptivePrompt) ← 【改造:动态系统提示词注入自适应 topK】


RRFFusion.fuse(vectorWeight, bm25Weight, rrfK) ← 【改造:加权融合】


Reranker(rerankTopK) → Compressor(contextMaxTokens) ← 【参数化】

核心设计:Complexity × Specificity 二维参数映射

QueryProfiler 用两个正交维度对查询画像:

维度分类判定规则
复杂度SIMPLE(≤10字)/ MODERATE(开放中长查询)/ COMPLEX(对比/多实体)长度 + COMPOUND 意图 + 多实体正则
精确度PRECISE(法条/编号/精确术语)/ BROAD(什么/为什么/如何)EXACT_SEARCH 意图 + 精确指示词正则

加上 REALTIME(时效性)和 FOLLOWUP(追问)两个独立策略,共 8 套参数预设

查询画像vectorTopKbm25TopKrrfKvectorWeightbm25WeightcontextTokens设计意图
SIMPLE×PRECISE1025400.30.72000BM25 主导,小 K 锐化头部
SIMPLE×BROAD2015600.60.43000向量主导,标准配置
COMPLEX×BROAD2520600.50.55000大召回量 + 宽 token 预算
REALTIME1510600.50.33500主动触发 Web 搜索
.....................共 8 套

RRF 加权融合改造

原 RRF 等权公式:score = 1 / (k + rank)

加权公式:score = weight / (k + rank)

向量路和 BM25 路独立加权,精确查询 BM25 权重 0.7(关键词精准匹配优先),语义查询向量权重 0.6(语义理解优先)。原无参数 fuse() 保留为等权委托,向后兼容。

LLM 驱动路径的自适应改造

AGENT_RETRIEVAL_SYSTEM_PROMPT 是写死的静态提示词("topK 设为 15"),改为 buildAdaptiveRetrievalPrompt(profile) 动态构建:

java
// 精确查询 → 主动要求 LLM 调 keyword_search
if (profile.specificity() == PRECISE) {
    sb.append("- 同时调用 keyword_search(关键词精确检索)");
}
// 时效查询 → 主动要求调 web_search
if (profile.timeAware()) {
    sb.append("- 同时调用 web_search 获取最新信息");
}

降级路径同样从 QueryProfile.params 读取自适应参数,而非全局配置。

开关控制

rag.adaptive.enabled(默认 true)通过 sys_ai_config 数据库动态配置,无需重启即可开关。关闭时完全回退到原全局参数行为。

结果

  • 零 LLM 成本:QueryProfiler 纯规则实现,不调 LLM
  • 零延迟开销:规则匹配 <1ms,不引入新的网络调用
  • 端到端可观测:QueryProfile 写入 retrievalLog,前端 Agent Trace 面板可看到每次查询的自适应参数(complexity/specificity/weights/topK)
  • 无破坏上线:通过 rag.adaptive.enabled 控制,关闭后行为与改造前完全一致

面试话术

"我观察到 RAG 链路的一个结构性问题——所有查询用同一套检索参数。精确查询'第128条规定'被等权的向量检索引入大量语义相近但非目标的结果,复杂查询因为固定的 topK 和 token 预算覆盖不全。

我的方案是在 QueryRouter 意图分类之后插入一个 QueryProfiler,用 Complexity × Specificity 二维矩阵 映射出查询级别的检索参数——8 套预设,覆盖精确/开放 × 简单/中等/复杂 + 时效/追问两个独立策略。

关键改造有两个:第一是 RRF 加权融合——原来是等权的 1/(k+rank),改成 weight/(k+rank),精确查询给 BM25 权重 0.7,语义查询给向量权重 0.6。第二是 LLM 检索系统提示词动态化——不再写死 topK=15,而是根据画像注入自适应值。

整个 QueryProfiler 是纯规则实现,不调 LLM,零额外延迟和成本。通过数据库配置 rag.adaptive.enabled 可以运行时开关,关闭后行为与改造前完全一致。这体现了一个原则:好的优化是让系统对问题类型敏感,而不是用万能参数假装所有问题都一样。"

涉及文件

新增:

  • agent/QueryProfiler.java — 查询画像分析器(Complexity/Specificity 分类 + 8 套参数映射)

修改:

  • agent/AgentState.java — 新增 queryProfile 字段,贯穿 ReAct 循环
  • agent/DocMindAgent.java — 注入 QueryProfiler;Thought 2.5 画像分析;buildAdaptiveRetrievalPrompt() 动态系统提示词;降级路径自适应参数;retrievalLog 输出画像详情
  • service/rag/RRFFusion.java — 新增加权 fuse(vector, bm25, topN, rrfK, vectorWeight, bm25Weight) 重载
  • config/AiConfigInitializer.java — 新增 rag.adaptive.enabled 默认配置项

#4 Query Decomposition —— 复杂查询多跳检索能力(2026-04-28)

背景

项目原有 RAG 链路在面对复合型查询时存在结构性瓶颈:

  • "Spring AI 的 ChatClient 和 LangChain4j 的 AiServices 在工具调用上有什么区别?"——单次向量检索召回的 Top-K 大概率被 ChatClient 相关 chunk 占满,LangChain4j 的资料根本进不了候选集
  • "MilvusService 用什么索引类型?这种索引在高维下的性能特点?"——典型的 multi-hop 推理,第一跳和第二跳的最相关 chunk 不在同一个语义空间
  • "分别总结这三份文档"——聚合类问题,需要对每份文档独立检索后再合并

问题本质QueryRewriter 是 1→1 改写(解决"表达不规范"),但召回阶段仍然是单次 Top-K,信息焦点分散的查询在召回阶段就丢了一半证据,下游再精准的 rerank 也救不回来。

为什么不上 Plan-and-Execute?

调研对比了通用 Agent 范式(LangChain Plan-and-Execute、AutoGPT 风格的 DAG 调度)和 RAG-native 的 Query Decomposition:

  • Plan-and-Execute 的核心收益是"任务异构 + 并行依赖",但 RAG 的子任务是同构的——每个子查询走的都是相同的检索链路,没有 DAG 必要
  • Plan-and-Execute 引入 PlanNode / ExecutionGraph 抽象,对 1013 行的 DocMindAgent 是侵入式重构,维护成本高
  • 学术界共识:multi-hop QA 的最佳实践是 Decomposition(参考 Self-Ask、Decomposed Prompting、IRCoT),而非通用 Plan-and-Execute

结论:选择 Query Decomposition 是对症的最小代价方案。

方案

在 RAG 链路 QueryRewriter 之后插入"拆解分支",复杂查询走多路并行检索,简单查询零开销直通原链路:

                  ┌─ ComplexityClassifier ─┐
                  │                        │
用户 query ──→ QueryRewriter           不复杂 → 走原链路(零开销退路)
                  │                        │
                  └─ QueryDecomposer ──── 拆成 N 个 sub-query

                  ┌───────────────────────┘
                  ↓ CompletableFuture.allOf
        ┌─────────────────────────────────────┐
        │ 子问题 1 ─→ 检索链路 ─→ rerank ─→ Top-K1
        │ 子问题 2 ─→ 检索链路 ─→ rerank ─→ Top-K2     (并行)
        │ 子问题 3 ─→ 检索链路 ─→ rerank ─→ Top-K3
        └─────────────────────────────────────┘

            SubQueryMerger(保底分配 + 全局补齐 + 跨子问题去重)

            PromptAssembler(decomposed 模板 + 子问题归属标注)

              LLM 生成

核心组件设计

组件职责关键设计
ComplexityClassifier判断 query 是否需要拆解规则强信号优先("对比/区别/分别/双问号"等命中即返回,跳过 LLM);规则未命中再 LLM 兜底;LLM 失败保守降级为 SIMPLE
QueryDecomposer把复杂 query 拆成 2-N 个子问题LLM 输出 JSON 数组;支持 markdown 包裹的响应;去重 + 长度截断 + 数量上限三道清洗;任一异常都退化为单元素 list
SubQueryMerger跨子问题合并 chunk保底分配 + 全局补齐两段式:先给每个子问题至少 ceil(K/N) 个名额(避免高分子问题挤占),剩余名额按全局分数补齐;跨子问题去重时合并 servedSubQueryIndices
RagExecutorConfig并行检索专用线程池core=8 / max=16 / queue=20 / CallerRunsPolicy;独立于 commonPool 防止 IO 任务和 CPU 任务争资源;满了 caller-runs 自然反压而非丢任务
knowledge_qa_decomposed.txt拆解模式专用 prompt 模板注入子问题列表 + 每条 chunk 标注 (子问题 #1, #2) 归属 + 显式要求覆盖性

关键设计要点

  1. 不变量友好DecompositionResult.subQueries 永远非空(未拆解时返回单元素 list),上游可以无脑迭代,零特判
  2. 保底分配防止信息丢失:当子问题 A 的 chunk 整体分数都比子问题 B 高时,全局排序会让 B 完全消失。floor = ceil(mergedTopK / N) 强制每个子问题至少贡献 floor 条 chunk
  3. 异常隔离:单个子问题失败用 SubQueryRetrievalResult.empty(sq) 占位,不影响其它子问题;全部失败时返回空列表,上层 SafetyGuard.needsFallback 走兜底 prompt
  4. 零额外 LLM 成本(简单查询):默认 rag.decompose.enabled=false + 复杂度分类器规则优先,简单查询完全不会触发拆解 LLM 调用
  5. 子问题不再过 QueryRewriter:拆解器输出已经是规范化检索查询,再过一遍 rewriter 是双倍 LLM 成本——这是经过权衡的工程决策

SSE 事件流变化

原:  rewrite → thinking → intent → retrieval → rerank → start → token* → reflection → done
新:  rewrite → decompose? → thinking → intent → retrieval(聚合 N 路) → rerank(decomposed=true) → ...

done 事件的 sources 数组每条携带 servedSubQueries: [0,1],前端可展示"这条来源对应哪些子问题"。

配置项(数据库动态配置,无需重启)

sql
rag.decompose.enabled        = false   # 总开关,默认关闭灰度上线
rag.decompose.max_sub_queries = 4      # 单次拆解上限
rag.decompose.merged_top_k    = 8      # 合并后送给 LLM 的 chunk 总数

配套基础设施改进

落地过程中顺手修复了一个隐患:原 AiConfigInitializer 仅在 DB 为空时插入默认配置,老部署后新增 key 不会自动出现,需要手工补 SQL。改造为按 key 增量插入,保证未来新增配置项零运维成本。

同时新增 lombok.config 配置 lombok.copyableAnnotations += @Qualifier,让字段上的 @Qualifier 透传到 Lombok 生成的构造器参数上——这样多个同类型 Bean(多个 Executor)共存时按名称注入不会出现歧义。

结果

  • 零破坏上线:默认配置关闭,对现有用户行为完全无影响
  • 复杂查询召回完整性:N 路并行检索 + 保底分配,确保每个信息焦点都有独立的 Top-K 召回
  • 成本可控:简单查询完全跳过拆解,复杂查询多 1 次轻量 LLM 调用 + N 路并行检索(IO 密集,并行后总耗时 ≈ 单路)
  • 测试覆盖:3 个测试类共 26 个 case,覆盖规则路径、LLM 路径、降级路径、合并语义、异常隔离

面试话术

"我观察到 RAG 链路在面对'对比类'、'多跳类'查询时有结构性瓶颈——QueryRewriter 是 1→1 改写解决表达问题,但召回阶段仍然是单次 Top-K,物理上覆盖不了多个信息焦点。

我没有照搬 LangChain 的 Plan-and-Execute,因为 RAG 的子任务是同构的——每个子查询走相同的检索链路,没有 DAG 调度的价值。所以我做了 Query Decomposition:复杂度分类器先判断是否需要拆,简单查询零开销直通原链路;复杂查询拆成 2-4 个 sub-query,并行走完整 RAG 链路,再做跨子问题的保底分配 + 全局补齐合并。

这里有几个工程决策值得展开:第一,保底分配——简单的全局排序会让低分子问题完全消失,违背拆解初衷,所以每个子问题至少分配 ceil(K/N) 个名额。第二,专用线程池 + CallerRunsPolicy——不用 commonPool 是因为检索是 IO 密集,会和 CPU 任务争资源;满了 caller-runs 让上游自然反压而非丢任务。第三,子问题不过 QueryRewriter——拆解器输出已经是规范化查询,再过一遍是双倍 LLM 成本,这是经过权衡的取舍。

整个改造对底层组件完全复用——VectorRetriever / BM25Retriever / RRFFusion / CrossEncoderReranker 都没动,只是新增了 Decomposer 和 Merger 两个类,加上调度逻辑。这体现了一个原则:好的扩展是增量而非重写。"

涉及文件

新增:

  • service/rag/SubQuery.java — 子问题数据载体(record)
  • service/rag/DecompositionResult.java — 拆解结果(含未拆解时的单元素降级)
  • service/rag/SubQueryRetrievalResult.java — 单子问题检索产物
  • service/rag/ComplexityClassifier.java — 规则 + LLM 双路复杂度判定
  • service/rag/QueryDecomposer.java — 拆解主类(含 markdown 抽 JSON、清洗、降级)
  • service/rag/SubQueryMerger.java — 保底分配 + 全局补齐合并器
  • config/RagExecutorConfig.java — 专用并行检索线程池
  • resources/prompts/complexity_classify.txt — 复杂度判定 prompt
  • resources/prompts/query_decompose.txt — 拆解 prompt(含 3 个 few-shot 例子)
  • resources/prompts/knowledge_qa_decomposed.txt — 拆解模式专用 prompt 模板
  • lombok.config@Qualifier 透传配置
  • test/.../ComplexityClassifierTest.java — 7 个 case
  • test/.../QueryDecomposerTest.java — 10 个 case
  • test/.../SubQueryMergerTest.java — 9 个 case

修改:

  • service/rag/RetrievedChunk.java — 新增 servedSubQueryIndices: Set&lt;Integer&gt; 字段
  • service/rag/PromptAssembler.java — 新增 assembleDecomposed() 方法
  • service/rag/RagPipeline.java — 注入 Decomposer/Merger/Executor,插入 Step 2.5 拆解,按 decomposed 分流到 retrieveSingle() / retrieveDecomposed(),新增 decompose SSE 事件,sources 标注 servedSubQueries
  • config/AiConfigInitializer.java — 新增 3 个配置项;增量插入逻辑(兼容老部署)

#3 Self-Reflection 置信度标记 —— 低置信度答案透明化(2026-04-27)

背景

SelfReflection 组件会对 LLM 生成的答案做多维度审查(事实一致性、完整性、来源匹配、表达质量),审查不通过时系统保留原答案继续返回。问题在于:用户看到的回答和通过审查的回答在外观上完全一样,无法分辨答案质量,等于自纠错机制"做了但白做"。

这在 LLM 幻觉场景中尤其危险——模型编造了事实,审查发现了问题,但用户毫不知情地信任了这个答案。

方案

端到端的置信度标记,让审查结果对用户可见:

  1. 后端DocMindAgent.java):在 done SSE 事件中新增两个字段

    • lowConfidence: true/false —— 当反思执行过但未通过时为 true
    • confidenceScore: 0.0-1.0 —— 最后一轮审查的具体置信度得分

    判定逻辑:!state.isReflectionPassed() && state.getReflectionRound() > 0,确保只在"审查过且未通过"时标记,避免误标未执行审查的情况。

  2. 前端ChatView.vue):

    • done 事件处理中捕获 lowConfidenceconfidenceScore
    • AI 回答气泡中,当 lowConfidence 为 true 时显示橙色警告条:「此回答未通过自纠错审查(置信度 XX%),内容仅供参考,建议结合原始文档验证」
    • 推理过程面板中 reflection 步骤增加独立的红色徽标样式

结果

  • 审查通过时:行为不变,无任何额外 UI 元素
  • 审查不通过时:答案下方出现醒目的橙色提示条,用户一眼就能识别低质量回答
  • 推理过程面板中 reflection 徽标从无样式变为红色标识,与 intent/rewrite/retrieval/rerank 风格统一

面试话术

"RAG 系统的一个常见问题是 LLM 幻觉——模型编造了不存在的事实。我在项目中做了两层防护:第一层是 Self-Reflection 自纠错,LLM 从事实一致性、完整性、来源匹配三个维度打分,低于 0.7 阈值就判定不通过;第二层是置信度透明化——审查不通过时不是简单地吞掉结果,而是在 SSE done 事件中标记 lowConfidence: true 并附上具体分数,前端展示橙色警告条告知用户。这体现了一个原则:AI 系统应该对自己的不确定性保持诚实,而不是假装每个答案都是可靠的。"

涉及文件

  • DocMindAgent.java:384-399 — done 事件新增 lowConfidence + confidenceScore
  • ChatView.vue — 新增 lowConfidence 状态、警告条模板、reflection 徽标 + 警告条 CSS

#2 Langfuse 可观测性集成 —— RAG 全链路追踪(2026-04-27)

背景

项目原来只有日志级别的可观测性——各步骤耗时散落在 log.info 里,无法聚合分析。面试被问到"你怎么定位性能瓶颈"时只能说"看日志",缺乏专业工具链的支撑。

传统方案是 Micrometer + Prometheus,但对于 LLM/RAG 应用有天然局限:它不理解 prompt、completion、token 消耗这些 AI 特有概念。Langfuse 是专门为 LLM 应用设计的可观测性平台,通过 OpenTelemetry 协议集成,能自动追踪 Spring AI 的 LLM 调用,同时支持自定义 span 覆盖 RAG 链路。

方案

采用 OTel 集成路线(Langfuse 官方推荐):

Spring AI ChatModel 调用
  → Micrometer Observation(Spring AI 内置)
    → OTel Span 自动生成(prompt/completion/tokens)
      → OTLP HTTP Exporter
        → Langfuse OTel Endpoint

自定义 RAG 步骤
  → OTel Tracer 手动创建 Span
    → 同一条 OTLP 导出链路
      → Langfuse 中与 LLM span 形成父子关系

具体改动:

  1. 依赖引入spring-boot-starter-actuator + opentelemetry-spring-boot-starter
  2. 配置化LangfuseProperties 封装连接参数,langfuse.enabled 控制开关,默认关闭
  3. OTLP 导出器LangfuseOtelConfig 条件化创建 OtlpHttpSpanExporter,Base64 编码 publicKey:secretKey 作为 Basic Auth
  4. ObservationFilterChatModelObservationFilter 将 Spring AI 的 prompt/completion 内容桥接到 OTel span attribute(不加这个 Langfuse 看不到 LLM 输入输出)
  5. 自定义 span 埋点:在 DocMindAgent.runReActLoop() 中创建 5 个 span:
Span覆盖步骤记录的属性
DocMindAgent.execute根 spanuserId, sessionId, query, 总耗时
query_rewriteQuery 改写原始 query, 改写后 query
multi_retrieval多路召回检索模式, chunk 数量, 工具列表
fusion_and_rerankRRF + 重排各路数量, 重排输出数, 压缩输出数
llm_generationLLM 生成答案长度, 异常记录
self_reflection自纠错通过/不通过, 审查轮次
  1. Langfuse 特有属性:根 span 设置 langfuse.user.idlangfuse.session.idlangfuse.trace.tags,Langfuse UI 可按用户/会话筛选

结果

  • 未配置 Langfuse 时(langfuse.enabled=false):OTel Tracer 仍存在但无 exporter,span 创建开销可忽略(纳秒级),不影响性能
  • 配置 Langfuse 后:在 Langfuse 面板可以看到完整的 RAG 链路瀑布图,每个步骤的耗时、LLM 的 prompt/completion/token 用量一目了然
  • Spring AI 的 LLM 调用自动产生 generation span(含 model name、token usage),自定义 span 覆盖 RAG 链路,两者在同一个 trace 下形成完整视图

面试话术

"项目用了 Langfuse 做 LLM 可观测性。和传统的 Prometheus 不同,Langfuse 原生理解 AI 应用的概念——它能自动采集 prompt、completion、token 消耗,不需要手动埋点。集成方式是通过 OpenTelemetry:Spring AI 内置了 Micrometer Observation,会自动为 ChatModel 调用生成 OTel span;我在 RAG 链路的各个步骤(改写、检索、融合、重排、生成、自纠错)手动创建了子 span,这样在 Langfuse 面板上能看到完整的端到端瀑布图。关键设计是条件化启用——通过 @ConditionalOnProperty 控制 OTLP exporter 的创建,生产环境开启,本地开发关闭,零侵入。"

涉及文件

  • pom.xml — 新增 actuator + OTel 依赖
  • application.yml — Spring AI observations + Langfuse 配置
  • LangfuseProperties.java — 配置属性封装
  • LangfuseOtelConfig.java — OTLP 导出器 + Tracer Bean
  • ChatModelObservationFilter.java — prompt/completion 桥接
  • DocMindAgent.java — 5 个自定义 span 埋点

#1 VectorRetriever 向量化失败降级修复(2026-04-27)

背景

VectorRetriever.embed() 在 Embedding API 调用失败时,返回一个 1024 维的随机向量,然后用这个随机向量去 Milvus 做 COSINE 检索。问题是:随机向量和任何文档都没有语义关系,Milvus 会返回随机匹配的垃圾结果,但下游完全不知道这些结果是"假"的——它们带着正常的相似度分数,混入 RRF 融合、重排序、最终 Prompt,导致 LLM 基于错误来源生成答案。

这是典型的 fail-silent 反模式:错误被吞掉了,系统继续运行但结果不可信。

方案

embed() 的 catch 块从"返回随机向量"改为"抛出 RuntimeException":

java
// Before: fail-silent
catch (Exception e) {
    List<Float> fallback = new ArrayList<>();
    for (int i = 0; i < 1024; i++) fallback.add((float) Math.random());
    return fallback;
}

// After: fail-fast
catch (Exception e) {
    throw new RuntimeException("Embedding API 调用失败: " + e.getMessage(), e);
}

异常向上传播到 retrieve() 方法,被其外层 catch 捕获,返回空列表。空列表进入 RRF 融合后不会污染结果——系统自然降级为只用 BM25 检索。

调用链分析

embed() 抛出异常
  → retrieve() catch 捕获,返回空列表,log.error 记录
    → DocSearchTool.searchDocs() 拿到空列表,写入 AgentToolContext
      → DocMindAgent RRF 融合时 vectorPart 为空,只融合 bm25Part + webPart

每一层都有明确的行为,不会吞掉错误,也不会中断整个请求。

结果

  • Embedding API 正常时:行为不变
  • Embedding API 异常时:向量检索被跳过,只用 BM25 + Web 结果,答案质量下降但不会出现幻觉来源
  • 日志中有明确的 ERROR 级别记录,便于排查

面试话术

"我在 code review 时发现向量化失败的降级策略有问题——返回随机向量会导致 Milvus 返回无意义的结果,但下游不知道。这比直接报错更危险,因为系统看起来正常运行,但答案质量不可控。我改成了 fail-fast:向量化失败就抛异常,上层 catch 返回空列表,系统自然降级到 BM25 检索。这体现了一个原则:在不确定的时候,宁可少返回结果,也不要返回错误的结果。"

涉及文件

  • VectorRetriever.java:120-132 — embed() 方法

迭代 #10:输出置信度标注 + 推荐阅读(Phase 4)

背景痛点

用户面对 AI 生成的答案,缺乏信心锚点——不知道这个答案靠不靠谱。系统内部 Self-Reflection 已经计算了 confidence 分数,但只在后台日志中,用户看不到。同时,用户阅读完答案后想继续深入某个主题,但不知道知识库里还有什么相关文档。

方案设计

  1. 置信度分级标注(无新增 LLM 调用,零额外延迟):

    • 复用已有的 classifyConfidenceBand() 分级逻辑(HIGH ≥0.85 / MEDIUM ≥0.60 / LOW)
    • 在 SSE done 事件新增 confidenceLevel(高/中/低中文)供前端直接显示
    • 前端用彩色徽章(绿/黄/红)直观呈现
  2. 推荐阅读生成RecommendationGenerator):

    • 双路径推荐策略:同 category 文档优先 → tags 共现匹配补充
    • 从已检索 chunk 的 tags(JSON 数组)和 category 反查其他文档
    • 排除本次已引用的文档,避免重复推荐
    • 限制最多 3 条推荐,避免信息过载

实现细节

java
// DocMindAgent.java — done payload 新增字段
donePayload.put("confidenceLevel", bandToChinese(confidenceBand));  // "高"/"中"/"低"
donePayload.put("recommendations", recommendationGenerator.generate(compressed, kbIds));

// RecommendationGenerator.java — 双路径策略
// 路径 1: 同 category 的 ready 状态文档,排除已用 kbIds
// 路径 2: chunk.tags LIKE 匹配 → 反查 kb_knowledge_base

前端 ChatView.vue:

  • 置信度徽章:三色(green/yellow/red)圆角标签,显示分级和百分比
  • 推荐卡片:横向排列,显示文档名 + category 标签 + 推荐原因

数据对比

指标改动前改动后
用户可见置信度信息仅低置信度时显示警告 banner所有回答均显示置信度徽章
推荐阅读基于 tags/category 推荐最多 3 篇
额外延迟-~5ms(1 次 DB 查询,无 LLM 调用)
SSE done payload 字段8 个10 个(+confidenceLevel, recommendations)

面试话术

"置信度标注的关键设计是零额外成本——不需要新增任何 LLM 调用。Self-Reflection 已经产出了 confidence 分数,我只需要做数值到等级的映射,然后通过 SSE 事件传给前端。推荐阅读也是纯 DB 查询:从已检索到的 chunk 中提取 tags 和 category,反查同主题文档。这两个功能合在一起,增加了不到 5ms 延迟,但显著提升了用户对系统的信任感和探索效率。"

涉及文件

  • service/rag/RecommendationGenerator.java — 新增,推荐生成器
  • agent/DocMindAgent.java — done payload 新增字段
  • DocMind-frontend/src/views/chat/ChatView.vue — 置信度徽章 + 推荐卡片 UI