外观
优化迭代记录
每次优化记录格式:背景(为什么要优化)→ 方案(怎么做)→ 结果(效果如何)→ 面试话术(怎么讲)
迭代记录
每次优化在此追加,从最新到最旧排列。
#20 Phase 6 前端思考时间线重构(2026-05-09)
背景痛点
用户在等待 Agent 回答时看到的是一个空白等待——不知道系统在做什么、做到哪一步了、为什么这次比较慢。虽然 SSE 事件流已经在发 understanding / retrieval / rerank 等事件,但前端只简单地把最终答案展示出来,中间过程完全不可见。
对于一个 Agent 系统来说,推理过程的透明度是用户信任的基础——用户需要知道系统确实在认真检索、评估、反思,而不是在"编故事"。
方案设计
在 ChatView.vue 中实现思考时间线组件:
- 实时展示:每收到一个 SSE 事件就在聊天气泡上方追加一个时间线节点
- 11 种步骤类型:scope / understand / rewrite / routing / plan / retrieval / grader / rerank / warning / reflection / generating
- 自动折叠:生成完成后,时间线自动折叠为一行摘要(如"理解 → 路由 → 检索 → 重排 → 生成"),用户可点击展开查看详细数据
- 路由徽章:scope 决策 + grader 评分以绿/黄/红三色徽章展示在气泡顶部,鼠标悬停看判定理由和置信度百分比
- 检索日志弹窗:点击 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:
- TracedOp(
support/TracedOp.java):消除 OTel 样板代码。用法:TracedOp.run(tracer, "rrf_fusion", Map.of("rag.fusion.vector_count", size), span -> { ... }),自动处理 span 开始/结束/异常/属性 - RootNameFilteringSpanProcessor(
config/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>来区分 - ChatModelObservationFilter(
config/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 延迟、结果不稳定、不可单元测试。
方案设计
两个新组件:
- PathDecision(
agent/PathDecision.java):record 类型,统一封装路径决策输出。Mode枚举三种路径(SELECTED_DOC / DECOMPOSED / RULE_PLANNER),reason字段记录机器可读的判定原因写入 trace 和 SSE - RetrievalPlanner(
service/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:用户在一段正常知识问答之后,输入"总结上面的对话"。系统的处理流程是:
- 把"总结"、"上面"、"对话"这几个词当成检索关键词,去 Milvus 做向量检索,召回 12 条切片
- 召回的内容跟用户的真实意图毫无关系——切片来自某些主题为"会议总结方法"或"对话系统"的文档
- 自纠错模块判断置信度 65% 不达标,触发重写
- 重写仍然基于同一批跑题切片,置信度降到 0%
- 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提示词和QueryClassificationrecord,原本的两次调用(ScopeRouter + QueryUnderstanding)合并成一次。结果缓存到AgentState.cachedUnderstanding,runReActLoop复用 RetrievalGrader:四种灰区仲裁模式可切换cross_encoder(默认)—— 把 top-3 切片拼接后让 reranker 单对打分。一次调用 ≈ 50ms,不需要模型生成llm—— 调小模型仲裁,保留为基线对照heuristic—— 用顶分与次分的差、前三平均分的规则判定,零额外调用disabled—— 直接判 AMBIGUOUS
- 三个短路 handler 镜像现有
handleEmergencyShortCircuit的 SSE 事件序列(start→token→done),前端零改动即可正常渲染 - 配置项全部走
sys_ai_config表 +AiConfigInitializer启动期增量补缺,老库自动兼容 - 前端
ChatView.vue监听新增的scope与graderSSE 事件,气泡顶部展示两枚徽章(范畴、检索置信度),鼠标悬停看判定理由
数据对比
| 指标 | 改造前 | 改造后(合并模式开启) | 变化 |
|---|---|---|---|
| 元对话路径调用 Milvus 次数 | 1 | 0 | 完全消除 |
| 元对话路径调用 BM25 次数 | 1 | 0 | 完全消除 |
| 元对话路径调用 reranker 次数 | 1 | 0 | 完全消除 |
| 知识查询路径路由调用次数 | 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_idParentChunkResolver:通过 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(保持关键词精准性)两个触发入口:
- 首轮 specificity == FUZZY → 自动开启
- 首轮 confidence < 0.4 → ObservationEvaluator 推荐 HyDE 重试
实现细节
HyDEGenerator:用callSmallModel()(qwen-turbo)生成假设文档,延迟 200-400msRetrievalWorker:新增useHyde参数,启用时向量路用 HyDE 文本、BM25 路保持原 querySupervisorAgent:首轮 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% |
| 首轮 confidence | 0.3-0.4 | 0.5-0.7 | +0.2 |
| 额外延迟 | 0 | 200-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、怎么融合)耦合在一个类中。具体问题:
- 新增检索策略(如 HyDE、多文档对比分析)需要修改核心类,风险高
- 单次 ReAct 循环没有置信度反馈机制——无论检索结果好坏都走完固定流程
- 复杂查询缺少多步推理能力——Query Decomposition 只拆问题,不支持异构步骤间的依赖关系
方案设计
将单体 Agent 拆分为 Supervisor-Worker 两层架构:
DocMindAgent (入口编排 + SSE + 持久化, ~900 行)
↓ 委托
SupervisorAgent (编排决策, ~300 行)
├─ 简单/中等 → 迭代 ReAct + IterationDecider + ObservationEvaluator
├─ 复杂 → PlanGenerator + PlanExecutor (依赖拓扑并行)
└─ 拆解 → 并行子问题
↓ 调度
Workers (统一接口, 各 80-120 行)
RetrievalWorker / WebWorker / MemoryWorker / AnalysisWorker4 个 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 + 依赖拓扑并行 | 新能力 |
| 动态降级 | 单一 fallback | 5 种推荐动作 + 策略切换 | 新能力 |
面试话术
"Phase 2 解决的核心问题是编排决策和执行逻辑的耦合。原来的 DocMindAgent 1400 行,检索代码和策略逻辑混在一起,想加一个新 Worker 要改核心类。
重构思路是经典的 Supervisor-Worker 拓扑:SupervisorAgent 只做决策(走迭代循环还是计划执行、什么时候终止、什么时候切策略),Worker 只做执行(检索/搜索/记忆/分析),通过统一的
Worker接口和Evidence累积协议解耦。几个关键设计值得展开:
- IterationDecider 基于置信度轨迹做终止判断——不是'跑完 N 轮'而是'够好了就停',还能检测置信度停滞提前切策略。
- PlanExecutor 依赖拓扑并行——按
dependsOn把计划分层,同层步骤用 CompletableFuture 并行,避免串行等待。- 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+MMRagent/worker/WebWorker.java— 封装 WebSearchToolagent/worker/MemoryWorker.java— 封装 MemoryToolagent/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)
背景痛点
评测对比最佳实践后发现三个基础短板:
- 候选池仅 Top-20,对复杂查询召回率不足(最佳实践建议 Top-50~100)
- 精排输出无多样性保证,同一文档相邻段落占满 Top-K 导致信息密度低
- 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 数组)、docVersion、effectiveDate、sourceFileNameTextChunker.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=40 | 50+50=100 | +150% |
| RRF 融合候选 | 20 | 30 | +50% |
| 精排输出 | 5 | 8(MMR 筛选后) | +60% |
| chunk 元数据字段 | 3 | 7 | +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关键设计决策
重写 Prompt 注入 issues:不盲目重生成,而是把审查发现的具体问题(如"事实不一致""缺少来源引用")注入 rewrite prompt,让 LLM 做针对性修正。这比全量重生成更节省 token,且能确保只改有问题的部分。
只用主模型重写:评分用小模型(qwen-turbo)省成本,但重写是答案本身,必须用主模型(qwen-plus)保证质量。
前端无缝替换:
reflection_start事件清空已显示的答案,reflection_token流式推送改进版,对用户体验影响最小——看起来像答案在"自我修正"。高分短路不变:rerank top-1 ≥ 0.85 时跳过整个反思(包括评分+重写),保证大多数查询零额外成本。只有 ~30% 真正需要评估的查询才触发,其中又只有 ~30-40% 会进入重写(即总查询的 ~10% 承担重写成本)。
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.java | runSelfReflection() 从纯评分改为"评分 + 条件流式重写",新增 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 才触发关键设计抉择
- 不复用 DocMindAgent,而是把组件重新组合 — Agent 自带 SSE / 缓存 / 早停 / 反思短路等机制,会污染对照。评测要求阶段可控、可计时、可裁剪。
- doc-level 召回标注 — chunk 级标注代价太高(需要标注员看每条 chunk);用「文档名子串匹配」做近似,足够区分"召回找对了文档"vs"完全没找到"。
- LLM-as-judge 用小模型 (qwen-turbo) — 评测本身要烧钱,主模型打分一次评测要花十几块;qwen-turbo 同样能给出稳定的 0.0-1.0 分数,单次评测压缩到 2-3 块。
- opt-in 触发 — 用
@EnabledIfEnvironmentVariable("EVAL_ENABLED", "true")把评测拦在普通 mvn test 之外,避免 CI 烧 token。 - Markdown 报告而非 JSON — 面试场景要"复制粘贴就能给人看",所以输出表格化 Markdown,跨 variant 平均 + 分类切片 + per-query 详表 + 失败 case 四区块。
对照矩阵
每条 query 跑 4 个 variant × 7 个指标:
| Variant | 召回 | 重排 | 反思 |
|---|---|---|---|
| V1 朴素 RAG | 仅向量 | ✗ | ✗ |
| V2 +Hybrid | 向量+BM25+RRF | ✗ | ✗ |
| V3 +Rerank | V2 + Cross-Encoder | ✓ | ✗ |
| V4 Full | V3 + Self-Reflection | ✓ | ✓ |
指标:Recall@5 / MRR / Keyword Recall / Faithfulness / Relevance / 各阶段延迟 / 总延迟
结果
框架本身已落地(约 700 行 Java + 10 条种子数据 + Markdown 报告生成)。面试可讲的几点:
- 建立了"会被复制粘贴的"评测产物 — 报告 Markdown 直接复制到这份文档来用
- 暴露了一个产品意义上的发现 — Self-Reflection 在流式模式下不重写答案,V3 与 V4 的答案质量必然相同。这个发现直接驱动了迭代 #11(条件重写),修复后忠实度 +0.037
- 数据集 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 的metadataJSON 都有"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核心设计要点
结构化元素的完整性优先于 chunk 大小均匀性
表格 / 代码块永远不切——即便 1500 字的大表格也保留为单 chunk。这违反"chunk 应该 400 字"的目标值,但损失 chunk 大小均匀性 < 损失结构化语义。1500 字表格作为单 chunk → 召回时 LLM 能完整看到表头-数据对应关系;切成两半 → LLM 拼不回来。日志里打 WARN 留观测口子。
句子切分不再用英文
.作为分隔原代码的
text.split("(?<=[。!?;\\.!?;\\n])")会把v1.2.3、Item 0.这样的 token 切碎。改为只用中文标点 + 换行((?<=[。!?;\\n]))。代价是某些纯英文超长段落可能切不开——但相比"代码 / 版本号被破坏",这个代价值得付。代码块本身已经走 CODE block 原子保留,根本不会进入这条路径。HEADING 切换 = 语义断点,强制 flush buffer
写第一版时漏了这个,单测立刻挂出来——
# 第一章\n## 第一节\ncontent1\n## 第二节\ncontent2会把 content1 和 content2 合并到同一 chunk,因为我只在 buffer 容量满时才 flush。修复方案是 HEADING 进来时无条件 flush 现有 buffer——heading 在语义上就是一个硬边界。这次单测帮我抓出了一个非常容易漏的设计 bug。chapter breadcrumb 用
>拼接,而非 Markdown 语法chapter = "第一章 权限管理 > 第一节 角色定义"—— 前端展示友好,LLM 也能看懂层级关系。同时每个 chunk 的内容里前缀一行 heading 文本(仅最近一次 heading 切换的那条),让 embedding 能拿到上下文,提升向量召回质量。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<KbChunk>做多重映射,poll 一次消费一次,避免一次匹配吃掉所有副本。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<String>),按 100 条/批分片,单批失败不阻塞其它批
- 新增
失败语义按场景分级
全量路径失败 → 清空 chunk + Milvus + 标 failed(保持原行为)。 增量路径失败 → 仅标 failed,不清空 chunk——因为旧 chunk 此刻可能还是可用状态,留给用户决定是否触发
reprocess全量重建。这避免了"增量更新一旦失败,整个文档突然不可检索"的连锁故障。API 入口区分 file 与 url
PUT /api/knowledge/{id}/file—— 上传新文件替换。强制要求新旧 fileType 一致(PDF 不能换成 PPT),否则 metadata 混乱POST /api/knowledge/{id}/refresh—— URL 类型专用,不需要文件,重新调 MinerU-HTML 抓取- 旧文件在事务提交后才删除,避免增量任务还在用旧路径就被清掉
零下游改动
BM25Retriever、VectorRetriever、SourcePayloadFactory都没动——它们读的是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<String>)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 模型?
候选方案:
| 方案 | 多栏 PDF | PPT | 图片 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 链路核心设计要点
降级策略按格式分级,不一刀切
- PDF —— MinerU 优先,失败降级 PDFBox 文本(次优但可用)
- PPT / 图片 / 网页 URL —— fail-fast,不静默降级。这些格式在 Java 生态没有等价替代,强行兜底(比如把图片当 binary 跳过)等于让用户看着文档"成功入库"但其实是空内容,比直接报错更危险
- 这是 #1 迭代里学到的"fail-silent 反模式"在新场景的重申
双层闸门,互不替代
- 静态层
docmind.mineru.enabled(env var)—— 部署时决定"这个环境允不允许出网调 MinerU"。token 缺失时即便置 true 也被视为不可用 - 动态层
parser.mineru.enabled(sys_ai_config)—— admin 面板可热切换。配额耗尽 / API 抖动时 ops 一键关闭,PDF 立刻全量降级 PDFBox,PPT / 图片 / URL 链路则停止接收新请求 - 密钥不入库,开关入库——secrets 永远从环境变量取,行为开关进数据库支持热切换
- 静态层
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<JSONObject, JSONObject> stateExtractor把"从顶层 JSON 抽出含 state 的对象"这步抽象掉,主轮询循环只写一遍,避免两份高度相似的代码漂移
- 文件流程 = 三步:
URL 入库走独立 entity 状态
KbKnowledgeBase.fileType="url"+fileUrl字段直接存原始 URL(不存本地副本)DocumentProcessTask按 fileType 分支:url→extractor.extractUrl(kb.getFileUrl()),本地文件 → 原 Path 路径deleteById跳过deleteLocalFile(URL 类型本来就没本地文件)- 新增
POST /api/knowledge/url控制器端点 + 前端"网页 URL"模式 tab,与文件上传 tab 共用同一个上传弹窗
下游零改动
- MinerU 输出的 Markdown 结构(heading / 段落 / 表格 / 图片引用之间都有空行)和
TextChunker现有的"按\n\n分段"策略天然契合——表格作为单段保留、heading 自成一段、图片引用因长度<20 自动过滤 - 这是这次升级最甜的一点:入库格式扩展了 N 倍,下游链路一行代码不动。MinerU 把"layout-aware 多模态文档 → Markdown"这个映射做到位之后,RAG 部分的复杂度被天然封住
- MinerU 输出的 Markdown 结构(heading / 段落 / 表格 / 图片引用之间都有空行)和
轮询而非 webhook
- MinerU API 设计成长轮询(5s 间隔,10min 超时上限可配)而非 callback。简单但占线程
- 放在
DocumentProcessTask的@Async池里跑,对主请求链路零阻塞 - 如果未来要支撑更高吞吐,可以改成 Reactor 化的
WebClient+ 非阻塞轮询,但当前规模没必要
VLM 模型默认
- MinerU 提供
vlm(多模态,layout + OCR + 公式一站式)和pipeline(CV 流水线,速度快但对图表敏感度低)两档;URL 流程独立用MinerU-HTML - 默认
vlm——本项目优先质量。docmind.mineru.model-version可切回pipeline应对配额紧张场景
- MinerU 提供
结果
| 维度 | 改动前(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 显式报错 |
| 解析延迟 | <1s | 5-30s(云端 + 轮询) |
| 解析成本 | 0 | 按页计费 |
| 下游 RAG 链路改动 | — | 零行(输入统一为 Markdown) |
降级与回滚
| 触发条件 | 处理 |
|---|---|
| MinerU API 配额耗尽 / 持续超时 | admin 面板把 parser.mineru.enabled 改 false。PDF 自动走 PDFBox 降级路径;PPT / 图片 / URL 入口报"未启用"明确拒绝(不静默) |
| 云端服务故障短时不可用 | 单请求级别异常隔离,不影响其它请求;持续故障靠总开关切断 |
| 网络断开(出网受限的私有化部署) | 部署时 MINERU_ENABLED=false,等价于回到改造前行为(PPT / 图片 / URL 入口将明确拒收) |
| 发现 MinerU 解析质量回退 | 切 model-version 到 pipeline,或干脆关总开关 |
面试话术
"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<JSONObject, JSONObject> 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端点 +UrlIngestRequestrecordapplication.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 调用数 / 单查询(命中短路) | 4 | 2-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实体
调研发现:
- 前端早已只调用 v2(
/api/v2/chat/*、/api/v2/kb完全无前端引用),旧 chat 路径事实上是 dead code - Med 与 Kb/Qa* 两套实体映射到同一张物理表**(
@TableName("kb_knowledge_base")/@TableName("qa_message")重复声明),所谓"Phase 1→Phase 2 迁移"只重命名了实体,没有真正迁移数据 - RagPipeline 存在 qa_message 双写 bug:
messageMapper.insert+qaMessageMapper.insert写入同一张表,每次旧路径调用都会产生重复行(因前端不调而未爆雷) - Query Decomposition 资产被困在 RagPipeline 内,DocMindAgent 主路径享受不到拆解能力
- KB 域
/api/v2/kb是孤儿 controller:前端走旧/api/knowledge,v2 KB 完全没人调
方案
合并为单一 Agentic 路径,思路是 "前端在用谁就保留谁的 URL,所有实体收敛到 Kb/Qa*"*:
| 域 | 保留 | 删除 |
|---|---|---|
| Chat | DocMindChatController (/api/v2/chat) + DocMindAgent | ChatController + RagPipeline |
| KB | KnowledgeBaseController (/api/knowledge,前端在用) | DocMindKnowledgeBaseController (孤儿) |
| 实体 | KbKnowledgeBase / QaConversation / QaMessage | MedKnowledgeBase / MedConversation / MedMessage 及对应 Mapper |
具体动作:
- 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 - KB 实体合并:
MedKnowledgeBase与KbKnowledgeBase字段 100% 相同,5 个文件批量替换 import + 类名(KnowledgeBaseService/Impl, Controller, DocumentProcessTask, StatsServiceImpl, Test) - Stats 实体迁移:
StatsServiceImpl把三个 Med* 都换成 Kb*/Qa* - 删除 dead code:8 个文件(ChatController, RagPipeline, MedConversation, MedMessage, MedKnowledgeBase + 3 个 Mapper, DocMindKnowledgeBaseController)
- 顺手修 build:补
pom.xml的annotationProcessorPaths显式声明 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 AgentDocMindAgent。前端切到 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 套参数预设:
| 查询画像 | vectorTopK | bm25TopK | rrfK | vectorWeight | bm25Weight | contextTokens | 设计意图 |
|---|---|---|---|---|---|---|---|
| SIMPLE×PRECISE | 10 | 25 | 40 | 0.3 | 0.7 | 2000 | BM25 主导,小 K 锐化头部 |
| SIMPLE×BROAD | 20 | 15 | 60 | 0.6 | 0.4 | 3000 | 向量主导,标准配置 |
| COMPLEX×BROAD | 25 | 20 | 60 | 0.5 | 0.5 | 5000 | 大召回量 + 宽 token 预算 |
| REALTIME | 15 | 10 | 60 | 0.5 | 0.3 | 3500 | 主动触发 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) 归属 + 显式要求覆盖性 |
关键设计要点
- 不变量友好:
DecompositionResult.subQueries永远非空(未拆解时返回单元素 list),上游可以无脑迭代,零特判 - 保底分配防止信息丢失:当子问题 A 的 chunk 整体分数都比子问题 B 高时,全局排序会让 B 完全消失。
floor = ceil(mergedTopK / N)强制每个子问题至少贡献 floor 条 chunk - 异常隔离:单个子问题失败用
SubQueryRetrievalResult.empty(sq)占位,不影响其它子问题;全部失败时返回空列表,上层SafetyGuard.needsFallback走兜底 prompt - 零额外 LLM 成本(简单查询):默认
rag.decompose.enabled=false+ 复杂度分类器规则优先,简单查询完全不会触发拆解 LLM 调用 - 子问题不再过 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— 复杂度判定 promptresources/prompts/query_decompose.txt— 拆解 prompt(含 3 个 few-shot 例子)resources/prompts/knowledge_qa_decomposed.txt— 拆解模式专用 prompt 模板lombok.config—@Qualifier透传配置test/.../ComplexityClassifierTest.java— 7 个 casetest/.../QueryDecomposerTest.java— 10 个 casetest/.../SubQueryMergerTest.java— 9 个 case
修改:
service/rag/RetrievedChunk.java— 新增servedSubQueryIndices: Set<Integer>字段service/rag/PromptAssembler.java— 新增assembleDecomposed()方法service/rag/RagPipeline.java— 注入 Decomposer/Merger/Executor,插入 Step 2.5 拆解,按decomposed分流到retrieveSingle()/retrieveDecomposed(),新增decomposeSSE 事件,sources 标注servedSubQueriesconfig/AiConfigInitializer.java— 新增 3 个配置项;增量插入逻辑(兼容老部署)
#3 Self-Reflection 置信度标记 —— 低置信度答案透明化(2026-04-27)
背景
SelfReflection 组件会对 LLM 生成的答案做多维度审查(事实一致性、完整性、来源匹配、表达质量),审查不通过时系统保留原答案继续返回。问题在于:用户看到的回答和通过审查的回答在外观上完全一样,无法分辨答案质量,等于自纠错机制"做了但白做"。
这在 LLM 幻觉场景中尤其危险——模型编造了事实,审查发现了问题,但用户毫不知情地信任了这个答案。
方案
端到端的置信度标记,让审查结果对用户可见:
后端(
DocMindAgent.java):在doneSSE 事件中新增两个字段lowConfidence: true/false—— 当反思执行过但未通过时为 trueconfidenceScore: 0.0-1.0—— 最后一轮审查的具体置信度得分
判定逻辑:
!state.isReflectionPassed() && state.getReflectionRound() > 0,确保只在"审查过且未通过"时标记,避免误标未执行审查的情况。前端(
ChatView.vue):done事件处理中捕获lowConfidence和confidenceScore- 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 + confidenceScoreChatView.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 形成父子关系具体改动:
- 依赖引入:
spring-boot-starter-actuator+opentelemetry-spring-boot-starter - 配置化:
LangfuseProperties封装连接参数,langfuse.enabled控制开关,默认关闭 - OTLP 导出器:
LangfuseOtelConfig条件化创建OtlpHttpSpanExporter,Base64 编码 publicKey:secretKey 作为 Basic Auth - ObservationFilter:
ChatModelObservationFilter将 Spring AI 的 prompt/completion 内容桥接到 OTel span attribute(不加这个 Langfuse 看不到 LLM 输入输出) - 自定义 span 埋点:在
DocMindAgent.runReActLoop()中创建 5 个 span:
| Span | 覆盖步骤 | 记录的属性 |
|---|---|---|
DocMindAgent.execute | 根 span | userId, sessionId, query, 总耗时 |
query_rewrite | Query 改写 | 原始 query, 改写后 query |
multi_retrieval | 多路召回 | 检索模式, chunk 数量, 工具列表 |
fusion_and_rerank | RRF + 重排 | 各路数量, 重排输出数, 压缩输出数 |
llm_generation | LLM 生成 | 答案长度, 异常记录 |
self_reflection | 自纠错 | 通过/不通过, 审查轮次 |
- Langfuse 特有属性:根 span 设置
langfuse.user.id、langfuse.session.id、langfuse.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 BeanChatModelObservationFilter.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 分数,但只在后台日志中,用户看不到。同时,用户阅读完答案后想继续深入某个主题,但不知道知识库里还有什么相关文档。
方案设计
置信度分级标注(无新增 LLM 调用,零额外延迟):
- 复用已有的
classifyConfidenceBand()分级逻辑(HIGH ≥0.85 / MEDIUM ≥0.60 / LOW) - 在 SSE
done事件新增confidenceLevel(高/中/低中文)供前端直接显示 - 前端用彩色徽章(绿/黄/红)直观呈现
- 复用已有的
推荐阅读生成(
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