面试知识库

优化迭代记录#

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


迭代记录#

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


#37 合订本切块「产品名缺失 + 父子跨产品串味」修复 + reranker v1→v2 持久化(2026-06-13)#

背景痛点(#36 的同一条 trace 2d41dce1 继续下钻)

#36 修好”AMBIGUOUS 不再丢证据”后,同一条 query 仍有两个症状:答案置信度还是 LOW,而且引用的来源是网络搜索而非知识库——可那篇产品条款明明已经入库(kb35)。继续扒 trace + 进 MySQL 看真实切块,挖出三层根因,全在「检索质量」而非「兜底逻辑」:

  1. reranker 还在用 v1(gte-rerank)→ 403 静默降级关键词排序,分数是假的。 直接 curl 复现:gte-rerank403 AccessDeniedgte-rerank-v2 返 200。CrossEncoderReranker 收到 403 抛异常 → 走 fallbackRerank(关键词重叠 score*0.6 + match*0.4)。trace 里那个 0.4636 是关键词兜底分,不是 cross-encoder 语义分。v2 的修复早写好但 application.yml 一直未提交(线上镜像从工作区构建才偶然带上),干净 checkout 重建会退回 v1。
  2. 产品名只活在 metadata.chapter,没进 content → embedding/BM25/rerank 都看不到 → 投保范围块排不进 top。 query「80 岁…国寿鑫缘宝终身寿险(万能型)(乐鑫版)」被产品专名主导,而答案块 content 只有「## 第二条 投保范围 凡出生…七十五周岁」,产品名一个字都没有gte-rerank-v2 实测该块仅 0.169,被一张现金价值大表格(第二十九条释义,0.382)和第一条套话(0.305)挤掉,“七十五周岁”只能从 web 捞回。
  3. 父子映射跨产品污染。 kb35 是合订本 PDF(个人保险基本条款 + 国寿鑫富宝年金 + 国寿鑫缘宝乐鑫版)。chunkWithParents 纯按 size ≤ 2000 贪心打包相邻子块,完全不看章节/子文档边界:投保范围(75 周岁)子块的 parent_chunk_id 指向「条款目录」,另一份的投保范围(70 周岁)指向「第十六条释义」,一个父块横跨两个产品——70/75 串味、父块展开后答案被目录表稀释。

共同根因一句话:chunker 把文档结构(heading 层级)只记进了 metadata,而真正决定检索的两处——子块 content、父块分组——都把结构丢了。

方案(一固化 + 两刀切块)

  • ① reranker v2 持久化application.ymlreranker.model: gte-rerank-v2 单独提交(commit aea88f0,只 stage 这一行、不夹带其它未提交改动,避免把 MinIO 删除/sandbox 等绑定到未提交代码的改动一起带进去导致干净构建启动失败)。
  • ② P0 — 产品名进 content(composeWithHeadingPath:子块 content 的标题前缀来源从 pendingHeadingPrefix(只含”上次 flush 后的新标题”)改为整条 headingStack 全栈渲染(每个非空层 → #×level 标题)。于是每个子块都自带从产品名 H1 到当前条款的完整路径,产品名进入 embedding/BM25/rerank 实际可匹配文本。保留 ## markdown 形式(不破既有表格测试),chapter 元数据逻辑不变。
  • ③ P1 — 父块按子文档边界硬断开chunkWithParents 打包判据除 size ≤ 2000,再加一条 chapter 根段(首个 > 前的产品标题)一变即强制起新父块(辅助 chapterRoot)。合订本里不同产品的条款不再混进同一父块;单产品文档根段恒定 → 行为退化为纯按 size,零回归。

设计取舍(为什么”硬断根边界” > “size 内跨根装满”)

讨论过一个替代:父块在 size 上限内尽量覆盖更多根标题。否决——父块的目标不是”装满”而是”给命中子块干净的同主题上下文”:① 召回排序在子块上做(P0 后已自带面包屑可独立命中),父块只是 LLM 上下文,且 compressfocus-truncate 会把 >800 字的父块以命中点为中心截到 ~800 字,装得再大模型也看不到更多;② 跨根装满 = 不同产品挤进一个父块,把刚修好的 70/75 串味 correctness bug 装回来。风险不对称:切碎只是”上下文略不满”(还被 focus-truncate 兜住),串味是”答错产品”。故根边界作硬约束。若普通多 H1 文档真被切太碎,正解是”同根内 MIN_PARENT 合并”而非跨根装满。

实现细节

数据对比(全部实测)

维度改动前改动后
rerankerv1 → 403 静默降级关键词(假分 0.4636)v2 → 200,真实语义分
投保范围块 rerank 分(v2)裸 content 0.169(排不进 top)带产品名路径 0.415(2.6×,反超现金价值表 0.197)
kb35 父块数13(跨产品混装)16(产品边界断开,70/75 各归各家)
端到端答案isFallback=true,“知识库未匹配…基于通用常识”,引用 webisFallback=false,“被保险人 75 周岁以下→80 岁不符”,4 条来源全是 kb35 向量、零 web
单测1820(+P0 产品名进每个子块、+P1 父块不跨产品)

面试话术(STAR)

“线上一条保险投保年龄的问题,答案明明在库里却回退到’通用常识 + 网络来源’。我先用 Langfuse trace 排除了’兜底逻辑丢证据’(那是另一刀修的),继续下钻发现是检索质量三连环:reranker v1 被云厂商下线、403 静默降级成关键词排序,分数全是假的——我直接 curl 复现 403、切 v2 并把配置单独提交固化;更深的是切块——产品名只存进了 metadata 面包屑、没进被 embedding/rerank 看到的 content,导致答案块语义分只有 0.17、排不进 top;而且那是一份合订本 PDF,父块按字节贪心打包跨了产品边界,一个产品的’投保范围’被映射到另一个产品的章节,75 周岁和 70 周岁串味。修法就是把已经提取到却只用于展示的结构信息接回检索链路:子块 content 带完整 heading 路径、父块在产品边界硬断开。实测语义分 0.17→0.42、答案从引用网络变成全部引用知识库并答对。我还专门论证了为什么父块边界要做硬约束而不是’在 size 内尽量装满’——因为 focus-truncate 已经兜住上下文,装满换不来收益,却会把串味的 correctness bug 装回来。”

涉及文件

  • service/knowledge/TextChunker.javacomposeWithHeadingPath(取代 composeWithHeadingPrefix,删 pendingHeadingPrefix)、chunkWithParentsdocBoundary + parentRoot)、chapterRoot
  • service/knowledge/TextChunkerTest.java — +2 用例(P0 产品名进每个子块 / P1 父块不跨产品),共 20 绿
  • resources/application.ymlreranker.model: gte-rerank-v2(commit aea88f0
  • 验证:重建 docmind-backend 镜像 + POST /api/knowledge/35/reprocess 重入库(13→16 父块)后端到端复跑

#36 CRAG 二信号一致性裁判 + 修 AMBIGUOUS 兜底丢证据 + Web 补偿覆盖率门控(2026-06-13)#

背景痛点(生产 trace 2d41dce1 驱动)

一条线上 query「80 岁的人可以投国寿鑫缘宝终身寿险(万能型)(乐鑫版)这款保险吗?」——答案明明在知识库文档里,最终却回了”⚠️ 当前知识库未匹配到相关内容,以下基于通用常识……”的 0-chunk 兜底。扒 Langfuse trace 还原出三个层层嵌套的问题:

  1. AMBIGUOUS 被强制翻成 0-chunk 兜底,丢弃了含答案的原文(核心 bug)。trace 显示检索其实成功了——两轮 MMR 各 embed 了 1 万+ token 的候选正文,compressed 非空。但二次 CRAG 把结果判为 AMBIGUOUS(top=0.649/avg=0.494,灰区),而 RagPipelinegrader.web_fallback_on_ambiguous=true(默认)时把 AMBIGUOUS 强制 needsFallback=true → 走 assembleFallback(0-chunk 模板)→ 把已检索到的原文整体丢弃,span 还误标成”无证据(KB/Web 空)“。这是语义倒挂:#28 已规定比 AMBIGUOUS 更差的 LOW 档(有证据)走 low-confidence 带证据作答,结果更好的 AMBIGUOUS 反而把证据全扔了。
  2. CRAG 信号不独立——是 CRAG-in-nameRetrievalGrader 原本只给 reranker 自己的分数(top/avg/gap)套阈值或多打一次 gte-rerank。但 CRAG 存在的全部理由是”检索相似度 ≠ 可答性”——需要一个独立 evaluator。给 reranker 的分再套阈值并不独立,捕捉不到”语义很像但事实不在”(rerank 高、实体却不在证据里)这类坑,而本 trace 恰是这一类的兄弟问题。
  3. Web 补偿只认 LOW、且 one-shot 与 agentic 不对齐web_fallback_on_ambiguous 这个 flag 名为”灰区触发 Web 检索补强”,却只在 RagPipeline 做兜底、从不真正触发 web;而 SupervisorAgent/AgenticSearchOrchestrator 的 web 补偿只在 LOW 触发,AMBIGUOUS 拿不到补强。名实不符 + 两路策略不一致。

方案(三刀一体)

  • ① 修兜底丢证据RagPipeline 两处(stagePromptAssembly + stageGenerateAndPersist 须同步)改为——灰区(LOW/AMBIGUOUS)+ 有证据 → low_confidence 带证据作答;compressed 真空才走 0-chunk 兜底。移除 AMBIGUOUS 强制翻 needsFallback 的逻辑。
  • ② CRAG 改二信号一致性裁判(不加 LLM 调用):给 RetrievalGrader 引入与 rerank 失败模式正交的第二信号——词项覆盖率:top≤3 chunk 对查询显著词的长度加权最佳覆盖率(命名实体/术语权重高、通用短词权重低),复用 BM25 同款 IK 分词器,纯 CPU、零网络/零 LLM。两信号一致性判档:
    • rerank 高 + 覆盖率近零(实体缺失)→ 降级 AMBIGUOUS(“语义像但事实不在”,触发 web 补强而非盲信)。
    • rerank 低 + 稀有实体命中 → 救回 AMBIGUOUS(rerank 漂移兜底,正是本类 bug 的根因方向)。
    • 灰区:覆盖强→HIGH、覆盖近零→LOW、其余退回 top/avg/gap heuristic。
    • 默认 arbitration_modecross_encoder(多一次 rerank API)改 heuristic(覆盖率优先的一致性,零额外调用);cross_encoder 保留为 opt-in,覆盖率纠偏在所有模式下都生效。
  • ③ Web 补偿覆盖率门控 + 两路对齐:触发策略 = tier==LOW || (tier==AMBIGUOUS && kbEntityWeak)——按”KB 到底缺不缺实体”触发而非粗粒度 tier。GradeResult 透出 lexCoverage/kbEntityWeak + webCompensationNeeded(lowEnabled, ambiguousEnabled)(策略集中、两路共用)。SupervisorAgent(one-shot)与 AgenticSearchOrchestrator(agentic)同调;agentic 额外保留 !usedWeb 守卫——模型本可自调 webSearch,补偿仅在”整轮没碰 web + KB 缺实体”时兜底,不二次质疑模型已用 web 的判断(有意的非对称,非 bug)。

结果

  • 本 trace 的 bug 根除:含答案的原文不再被 AMBIGUOUS 误判丢弃;置信度更高的档位不再反而比 LOW 更激进地丢证据。
  • CRAG 第一次成为名副其实的独立裁判:三档代表”两个正交信号是否一致”,而非 reranker 分数的再分桶——且零额外 LLM/网络成本。
  • Web 补偿按”KB 缺实体”精准触发:AMBIGUOUS·实体在场(KB 已有答案、rerank 漂移救回)不补,省掉白烧的 Tavily 配额;两路同策略,agentic 保留 backstop 守卫。
  • 配置:默认 arbitration_mode=heuristic;新增 grader.lex_cover_high(0.6)/lex_cover_low(0.2)/lex_min_salient(1)(AiConfigInitializer + docmind.sql 同步)。
  • 测试:RetrievalGraderTest 19 passed(+覆盖率纠偏 4 例 + web 补偿门控断言:实体缺失→补、实体在场→不补、LOW→必补、配额开关独立可关);相关 32 例全绿。

面试话术

“这刀是一条线上 trace 逼出来的——用户问某款具体保险 80 岁能不能投,答案明明在库里,系统却回了’知识库没匹配到,以下基于通用常识’。我扒 trace 发现检索其实成功了,两轮 MMR 都 embed 了上万 token 的候选,但 CRAG 把它判成灰区 AMBIGUOUS,而代码在灰区时强制走了 0-chunk 兜底,把检索到的原文整个丢了——更荒谬的是比它更差的 LOW 档反而保留证据作答,置信度越高越丢证据,语义是倒的。

修 bug 只是第一步。我顺势重新审视了 CRAG 本身:它号称是独立评估器,可实现上只是给 reranker 自己的分数套阈值,根本不独立,也抓不住’语义很像但事实不在’这种坑。我加了个和 rerank 失败模式正交、而且零额外调用的第二信号——词项覆盖率:检索回来的证据里到底有没有 literally 出现用户问的那个稀有实体,用 BM25 同款分词器算、长度加权让命名实体主导。两个信号做一致性:都强是 HIGH、都弱是 LOW、分歧才是 AMBIGUOUS。rerank 高但实体不在就降级去补 web,rerank 低但实体命中就救回来——后者正是这次 bug 的根因方向。

最后 web 补偿我改成按’KB 到底缺不缺实体’触发,而不是粗粒度的 tier:实体已经在 KB 里的灰区就不补,省 Tavily 配额。one-shot 和 agentic 两条路统一这个策略,但 agentic 多留一个守卫——它循环里模型本来就能自己调联网,我只在’模型整轮没碰 web 又确实缺实体’时兜底,不去二次质疑模型的判断。”

涉及文件:RagPipeline(兜底逻辑两处对齐)、RetrievalGraderlexicalCoverage 词项覆盖率 + arbitrateByEnsemble 二信号一致性 + grade() 单一收口打标)、GradeResultlexCoverage/kbEntityWeak + withLexSignal + webCompensationNeeded)、SupervisorAgent + AgenticSearchOrchestrator(web 补偿覆盖率门控两路对齐)、AiConfigInitializer + docs/docmind.sql(默认 heuristic + 三个覆盖率参数)、RetrievalGraderTest(+5 例)。

注:三刀同源于一条 trace,互为因果——修兜底 bug 暴露出 CRAG 信号不独立,重做信号又顺带让 web 补偿能按”缺实体”精准门控。承接 [#28](CRAG LOW 不丢证据)。


#35 上下文压缩 query-aware + MMR 多样性改向量 cosine(I-perf-4)(2026-06-13 补录,2026-06-12 落地)#

背景痛点(记忆/上下文三问题审查的「③ 上下文压缩」+ 检索性能改进 I-perf-4)

  1. 压缩”无脑保头”砍掉命中段CrossEncoderReranker.compress 对超长块(>800 字)一律保留前 800 字。但我们做了 Parent Document Retrieval——命中的子块正文展开成父块后,常落在父块的中后段;保头一刀就把真正命中的内容砍掉,表现为”检索到却答不出”。
  2. 精确去重抓不住父/子重叠与 web boilerplate。dedupe 只按 dedupeKey()(id/全文指纹),抓不住”id 不同但内容高度雷同”的冗余(父块与其子块、web 模板页眉页脚),这些冗余白占 token 预算。
  3. MMR 多样性用字符 bigram Jaccard 只看字面(I-perf-4)。“换了同义词讲同一件事”识别不出(漏判冗余),“字面重叠高但语义不同的中文片段”又被误判冗余——字面相似度对中文语义去重力不从心。

方案

  • query-aware 命中段居中截断:超长块以”命中段”为中心取 ≤800 字窗口。命中段定位优先用展开前的子块原文(RetrievedChunk.getChildContent()ParentChunkResolver 在覆盖父块内容前先存了子块原文),其次 query 词命中位,都没有才退回保头。窗口边界做句界吸附snapStart/snapEnd,仅在 80 字窗内找句末标点)避免从句中切;吸附后做防御性校验保证窗口非空。
  • 近重复去除compress.near_dup_enabled 默认开):输入已按 rerank 降序,对每候选只与已保留的更高排名块比对——包含关系(短文落长文内)或 bigram Jaccard ≥ compress.near_dup_jaccard(默认 0.85,保守)即判冗余丢弃。n 很小(≤ topK),O(n²) 可接受。
  • 总量控制 skip 而非 break:超预算的块跳过、让后续更短的高分块继续装箱(至少保留 1 条),避免一条长块卡死后续。
  • MMR 多样性改向量 cosine(I-perf-4)MMRDiversifier 多样性项 max_sim 默认用向量 cosine——候选集小(精排后 ≤ targetK),一次性批量 embed 全部候选(单次往返)再 O(K²) cosine。任何失败(embedding 异常 / 维度不一致 / mmr.similarity=jaccard)自动整体降级 Jaccard,零额外依赖、永不阻断;cosine 后端对零向量/空串占位有 NaN 防护。
  • λ clamp 修复getLambda() 读取后钳到 [0,1]——误配 >1 让多样性项 (1−λ) 变负反而奖励冗余、<0 让相关性项变负,均破坏 MMR 语义;读取异常退 0.7。

结果

  • 压缩从”保头截断”升级为 query-aware,父块展开后的命中段不再被误砍;近重复去除回收被父/子重叠和 web 模板占用的 token 预算。
  • MMR 去重从字面 Jaccard 升级到语义 cosine(带零依赖降级兜底);λ 钳制堵住误配反向奖励冗余的隐患。
  • 测试:CrossEncoderRerankerCompressTest(命中段截断 / 近重复去除 / skip 装箱 / 估算器)、MMRDiversifierTest(cosine / 降级 / λ)。

面试话术

“这刀的核心是’压缩别把答案压没了’。最早截断是无脑保前 800 字,可我们做了父块展开,真正命中的子块往往落在父块中后段,保头一刀就砍掉了命中段——表现就是’明明检索到了却答不出’。我改成 query-aware 的命中段居中截断,优先按展开前的子块原文定位命中段取窗口,还做句界吸附避免从句子中间切。顺带加了近重复去除,专抓父/子块重叠和网页模板这种 id 不同但内容雷同的冗余。MMR 的多样性相似度我也从字符 bigram Jaccard 升级成向量 cosine——Jaccard 只看字面,识别不出同义改写、还会误判字面像但意思不同的中文,候选集小批量 embed 一次成本可忽略,保留 Jaccard 做降级。还修了个隐患:λ 没做边界钳制,误配 >1 多样性项会变负反而奖励冗余。”

涉及文件:CrossEncoderRerankercompress query-aware:focusTruncate/locateAnchor/snapStart/snapEnd/dropNearDuplicates/limitTotalTokens)、MMRDiversifier(向量 cosine + Jaccard 降级 + λ clamp)、RetrievedChunkchildContent 字段)、ParentChunkResolver(展开前存子块原文)、新增 CrossEncoderRerankerCompressTest + MMRDiversifierTest

注:本条与 [#34] [#33] 同属「记忆/上下文三问题审查」,分别对应「③ 上下文压缩」与检索性能 I-perf-4,代码 2026-06-12 落地、迭代条目 2026-06-13 补录。


#34 长期记忆存储介质下沉:Redis-as-SoR → MySQL SoR + Redis 缓存 + Milvus 向量(2026-06-13 补录,2026-06-12 落地)#

背景痛点(记忆/上下文三问题审查的「② 存储介质」)

长期记忆过去把 Redis 当系统级真相源(SoR),这是架构错配:

  1. 持久性不可靠:Redis 单节点 + 仅 RDB 快照 + 30d TTL——进程崩溃丢最后一段窗口、内存淘汰或 TTL 到期即全量蒸发。“长期记忆因一段时间没访问就消失”这个语义本身就违背”长期”。
  2. 访问计数读-改-写竞态:bump accessCount 是”读 JSON → 改字段 → 写回”,并发召回同一条会丢更新。
  3. 无审计、无容量治理:失效是物理删除(无追溯)、无上限(可无限膨胀)。

方案:三层存储,各司其职

  • MySQL user_memory(SoR):所有读写以此为准。失效用软删除invalidated_at != 0)保留审计,superseded_by 记取代链;持久层不设 TTL。访问计数用 DB 原子 access_count = access_count + 1setSql)异步更新(C2:替代旧”读 JSON→改→写回”全量写 + 死遥测,脱离召回关键路径),彻底消除读-改-写竞态
  • Redis(cache-aside 读缓存)Hash 逐条缓存某用户有效记忆(field = memoryId → recordJson),TTL 7d(仅缓存层)。读 miss 回源 DB 并回填;任何成员变更(save/invalidate/evict/clear)→ 失效整个缓存键,下次读重建;访问计数只 put 命中那一个 field(成员不变 → 仍一致)。这是 C1 的修法:旧”整用户一个 JSON blob 读改写回”在并发改两条不同记忆时会互相覆盖丢更新,改 Hash 逐条原子写后互不干扰。
  • Milvus docmind_memory(纯向量索引):仅供 semanticRecall 语义召回与 findMostSimilarByType 冲突检测,存 300 字截断版用于检索,命中后回 SoR 取全文失效时向量走物理删除deleteVectors)——与 SoR 软删保审计形成双层删除语义(C3):SoR 行不消失可追溯,索引里不留陈旧向量被召回;外加召回期对账(见下”不复活陈旧召回”)作第二道保险,兜底物理删除的滞后/失败。
  • 写入顺序:DB(提交点,失败即中止不写向量,杜绝”有向量无 SoR”孤儿)→ Milvus 向量(派生索引,失败仅降级召回)→ 失效缓存 → 淘汰。
  • recency×frequency 淘汰(I-evo-2):有效记忆超 200 条时按价值分软删归档最低价值的——evictionScore = exp(−ageDays/14) + 0.5·log1p(accessCount)(新鲜度指数衰减 + 频度加权)。归档而非物删,可审计。
  • 一致性级别:冲突检测 STRONG(必须读得到刚写入的近邻,否则快速连续写会漏判混入重复,I-evo-1);召回 BOUNDED(读路径秒级 staleness 可接受,换吞吐)。

修复:semanticRecall 不复活陈旧召回

召回命中 Milvus 后回 SoR 取全文。sorAvailable 必须区分两种”空”——DB 返回空(该用户确无有效记忆 → 应跳过任何陈旧向量)与 DB 不可达(才允许降级用 Milvus 300 字截断内容兜底)。原实现用 activeById.isEmpty() 推断,把”记忆已全部失效/被 supersede”误判为”DB 宕机”,会复活已失效的陈旧召回。修法:抽 loadActiveOrThrow(DB 读失败抛出,缓存读失败仍内部吞掉转 DB)+ 显式 try/catch 设 sorAvailable。利用不变式:backfillCache 对空集只删键不写,故缓存命中必非空 → 命中即代表有有效记忆,无需回 DB 验证。

结果

  • 记忆持久性、并发正确性、审计、容量治理一次补齐;Redis 从”错配的 SoR”退回它最擅长的”缓存”,Milvus 退为纯向量索引。
  • 修掉 semanticRecall 复活已失效记忆的正确性 bug。
  • 测试:MemoryStoreTest(精确/语义去重、冲突软删+删向量、embedding 失败仅落 DB、淘汰价值分、cache-aside 回源、loadActiveSwallowsDbErrorReturningEmpty 守”普通读吞 DB 异常”契约)。配置:user_memory DDL(uk_user_memory_id/idx_user_active)。

面试话术

“这刀是给长期记忆换地基。原来把 Redis 当 SoR 是个典型的存储错配——单节点、只有 RDB、还挂了 30 天 TTL,崩一次或者淘汰一次记忆就全没了,而且’长期记忆因为一阵没访问就过期’这个语义本身就荒谬。我下沉成三层:MySQL 当 SoR,失效走软删保审计、不设 TTL;Redis 退回做 cache-aside 读缓存,成员一变就失效整个键保证一致;Milvus 退为纯向量索引,只存截断版做召回、命中后回 MySQL 取全文。访问计数从’读改写’改成 DB 端原子自增,消除竞态。还修了个隐蔽 bug:召回回源时我用’内存里没有这条 id’来推断 DB 健康,结果把’记忆已经全失效’误判成’DB 宕机’,反而把已经被取代的陈旧记忆复活了——我抽了个会抛异常的读方法,用真正的 DB 可达性来区分这两种’空’。容量上加了 recency×frequency 的淘汰,超 200 条按’新鲜度 + 频度’价值分归档。”

涉及文件:MemoryStore(三层重构 + loadActiveOrThrow + semanticRecall 修复 + evictionScore + 原子计数)、新增 entity/UserMemory + mapper/UserMemoryMapperdocs/docmind.sqluser_memory DDL)、重写 MemoryStoreTest

注:本条对应「记忆/上下文三问题审查」的「② 存储介质」,与 [#33](① Token 预算)、[#35](③ 压缩)同属一次审查。代码 2026-06-12 落地、迭代条目 2026-06-13 补录。


#33 Token 预算二刀:全局天花板对齐 window + 估算器防低估(2026-06-13)#

背景痛点(接续 #32 的预算治理,记忆/上下文系统三问题审查中的「① Token 预算」)

#32 给三条辅助流装了共享预算 aux_max_tokens,但复审仍暴露两处缺口:

  1. 没有”全局 token 天花板”对齐模型 window。chunks(rag.context_max_tokens=3000)与 aux(prompt.budget.aux_max_tokens=2500)是两条互不知情的预算线,最终在模板里拼接,加上 question / userProfile / system 指令,没有任何一处把它们加总后跟模型实际 window 比较。qwen-plus window 大不会硬溢出,但意味着预算是”拍脑袋分配”而非”从总窗口倒推”——调高一条子预算不会触发另一条让位,成本/延迟不可控
  2. estimateTokens 系统性低估 → 预算层最危险的偏差方向。①完全不计空白字符(CrossEncoderReranker.estimateTokens),英文被低估一档;②对代码/JSON/数字按拉丁 1 token≈4 字符算,而真实 BPE 对标点密集文本约 1 token≈2.5 字符——executeCode 计算结果通道(computations_max_tokens=1500 装 Python 代码 + JSON 输出)实际 token 可能是估算的 1.5–2 倍。低估方向是”以为没超、其实超了”,正是预算层最不该犯的错。

方案

  • 全局天花板PromptAssembler.fitAuxBlocks):新增 prompt.budget.total_max_tokens(默认 6000,对齐 window 安全余量)。装配收口先算”固定开销”= 模板脚手架常量 + question + userProfile + 已定稿的 chunks 上下文,再令 aux 可用额度 = total − 固定开销,与 aux 自身上限取小。效果:chunks 优先级最高,aux 总量被压到 total − 固定开销 − chunks 以内——“调高 chunks 预算 → aux 自动让位”,两条预算线从此互相知情、加总受控。
  • 估算器加固estimateTokens):①空白计入非 CJK 桶(真实 BPE 前导空白多并入相邻 token,代码缩进更直接贡献 token);②新增 estimateTokens(text, codeLike) 重载,代码型文本走更保守的 CODE_LATIN_CHARS_PER_TOKEN=2.5。formatComputations 对计算结果通道用 codeLike=true。预算估算从此宁可高估不可低估
  • 历史滚动摘要(I-perf-5,fitTailWithSummary:history 超预算时不再硬截断丢弃早期对话,而是把被裁掉的早期部分用廉价模型(extractModelOptions → qwen-flash)压成要点摘要塞回头部(【较早对话摘要】… ---(以下为最近原文)---)。摘要占从 history 预算划出的子预算 prompt.history.summary_max_tokens(默认 300)、开关 prompt.history.rolling_summary(默认 true)——二者走代码默认、无 DB 种子。仅长对话(history 确超预算)才同步调一次 qwen-flash;功能关闭 / 无配置上下文 / 未超预算 / 摘要失败任一 → 回退 fitTail 硬截断,永不阻断主流程。
  • 配置种子sys_ai_config id 57(prompt.budget.total_max_tokens,category=rag,可热调)。滚动摘要两键暂未入种子,走代码默认。

结果

  • prompt 四条流 + 固定开销首次纳入单一全局上限,chunks 与 aux 不再各自为政;chunks 撑大时 aux 自动让位(新增 largeChunksSqueezeAuxBudgetUnderGlobalCeiling 单测:大 chunk 把固定开销顶过天花板 → 历史被挤为占位符)。
  • 估算器对代码/英文不再低估(新增 estimateTokensCountsWhitespaceestimateTokensCodeLikeIsMoreConservative 两个单测)。
  • 复盘修复(自审发现):全局天花板把 aux 起始额度压小后,暴露了 fitHead 的潜伏 bug——它把 tokenBudget <= 0 当”不限”(与 fitTail 的语义相反)早返回原文。当 computations 吃满 aux、memBudget 被挤到 0 时,memory 会绕过预算整段泄漏。修法:去掉 tokenBudget <= 0 || 早返回条件,让 0 预算落到裁剪分支(与 fitTail 对齐)。largeChunksSqueezeAuxBudgetUnderGlobalCeiling 加了断言守这条回归(memBudget=0 时记忆内容必须被裁、不得泄漏)。
  • 全量单测 157 跑(含 ②/③ 新增用例),仅需 Milvus 在线的 contextLoads 报错(环境问题),其余全绿。

面试话术

“#32 我给辅助流装了共享预算,但复审发现两个洞。第一,chunks 和 aux 是两条互不知情的预算线,谁都没跟模型 window 对齐——调一条不会让另一条让位,本质是’拍脑袋分配’。我加了一个全局天花板:装配收口先扣掉 chunks + 问题 + 画像的固定开销,剩下的才给 aux,所以 chunks 优先级最高、aux 自动让位,两条线终于互相知情。第二个更隐蔽:token 估算器完全不计空白、对代码按英文的 4 字符/token 算,而代码真实是 2.5——预算层一旦低估就是’以为没超其实撑爆窗口’,方向最致命。我让估算器对计算结果通道走保守系数、把空白也计入,原则是预算估算宁可高估不可低估。”

涉及文件:PromptAssemblertotal_max_tokens 全局天花板 + reservedTokens + computations 走 codeLike 估算)、CrossEncoderRerankerestimateTokens 计空白 + codeLike 重载 + CODE_LATIN_CHARS_PER_TOKEN)、docs/docmind.sql(id 57 配置种子)、扩充 PromptAssemblerTest + CrossEncoderRerankerCompressTest

注:本条完成「记忆/上下文三问题审查」的「① Token 预算」。同一审查的「② 记忆存储介质」与「③ 上下文压缩」已分别补录为 [#34]、[#35](见上)。


#32 上下文系统全局 token 预算层 + 历史引用剥离(2026-06-12)#

背景痛点(上下文系统工程审查暴露的 P0/P1)

喂给 LLM 的 prompt 有四条上下文流——chunks / history / memory / computations,但只有 chunks 受预算治理:

  1. P0 预算缺口:仅 chunks 受 rag.context_max_tokens(默认 3000)约束;其余三条无界——ConversationService.buildHistory行数截断(最近 6 条 ≈ 3 轮)且单条不限长executeCode 计算结果仅单条字符截断、不限条数,记忆全量注入。一段长历史或多次代码执行输出可冲垮 chunks 预算、甚至撑爆模型上下文上限。四条流各自为政,最终 prompt 总长度无人负责
  2. P1 引用污染:历史以纯文本 用户:…/助手:… 回喂,助手历史答案里的 [1][2] 引用标记原样带回。本轮 CitationParser 解析 [n] 时模型可能复用旧编号,而旧编号对应的 source 不在本轮 sources 里 → 落入 invalidRefs,拉低引用覆盖率。

方案

  • 全局预算收口层PromptAssembler.assemble/assembleLowConfidence):新增 fitAuxBlocks,三条辅助流共享 prompt.budget.aux_max_tokens(默认 2500)总预算,按 computations > memory > history 优先级依次扣减——computations 是沙箱实算事实,最高优先;history 最旧轮次可丢,最低优先。各流再受自身上限钳制(prompt.budget.{computations|memory|history}_max_tokens = 1500/600/1000)。复用 CrossEncoderReranker.estimateTokens(语种感知)+ 二分定位裁剪点。computations 条目粒度裁剪(超预算整条丢弃 + 提示,不切断代码块),memory 保头、history 保尾(最近对话优先)。AiConfigHolder 设为可空 + 无参构造,配置缺失走默认,new PromptAssembler() 单测兼容。
  • 历史引用剥离ConversationService.buildHistory):回喂助手历史时正则 \[\d+\] 剥离 [n] 标记(仅助手消息,用户消息原样);并对单条消息做 600 字符尾部截断(粗粒度护栏,token 级总预算在 PromptAssembler 收口)。
  • 配置种子sys_ai_config id 53–56(category=rag,可热调)。

结果

  • 四条上下文流首次纳入统一预算治理,chunks 的 3000-token 预算不再被长历史/多次计算挤垮。
  • 新增单测:PromptAssemblerTest(历史保尾裁剪 + computations 条目丢弃)、ConversationServiceTest(助手 [n] 剥离 + 单条截断);全量单测除需 Milvus 在线的 contextLoads 外全绿。

面试话术

“这刀是上下文工程的’预算治理’。我 review 时发现一个反直觉的缺口:团队精心把检索证据压到 3000 token,但 history/memory/computations 三条流完全无界——历史还停留在’按行数截断、单条不限长’的粗放阶段,一段几千字的旧答案就能把证据预算挤垮。我在 PromptAssembler 装配收口处做了一层全局预算:三条辅助流共享一个总预算,按’计算结果 > 记忆 > 历史’的证据等级优先级分配,computations 因为是沙箱实算的事实所以最高优先、且按条目粒度裁剪避免切断代码块,历史保尾因为最近的对话更重要。另一个隐蔽 bug 是历史回喂会把助手答案里的 [1][2] 引用标记带回,诱导模型复用失效编号污染本轮引用解析,我在历史拼接时剥离了它。”

涉及文件:PromptAssemblerfitAuxBlocks + 预算裁剪 + AiConfigHolder 可空注入)、ConversationServicebuildHistory 引用剥离 + 单条截断)、docs/docmind.sql(4 条配置种子)、新增 ConversationServiceTest + 扩充 PromptAssemblerTest


#31 架构审查 P2 修复:拆解 DocMindAgent 上帝类 + 清死代码 + 修文档脱节(2026-06-12)#

背景痛点(分层审查暴露的 P2)

继 P1(双路径对齐、增量跨存储一致性、BM25 去污、工具重试)之后,处理可维护性层面的 P2:

  1. 上帝类DocMindAgent 1349 行,一个类同时扛会话装配、根 span、紧急/范畴/缓存三条短路终态分支、以及完整 RAG 流水线(runReActLoop + 9 个 stage*)。类 Javadoc 自称”薄壳”,与 1349 行的现实矛盾。
  2. 死代码safeGetString / SUMMARY_INTENT_PATTERN / 注入字段 kbVersionService 三处仅定义无引用;safeGetIntreadIntConfig 重复,readBoolConfig 在已带默认值的 getBool 外再套一层冗余 try/catch。
  3. 文档脱节// Stage 7: Generation + Reflection + Persist 仍写”Reflection”,但自反思(Self-Reflection)早在 #22 删除;末尾一行孤儿 Javadoc(/** 解析用户 ID … */)下无方法。

方案

  • 抽 4 个无状态 @Service 协作者(均在 agent/ 包),DocMindAgent 收缩为 ~135 行真·薄壳,只做会话装配 + 根 span + 顶层分流:
    • ScopeRouter——Tier-0 规则 + Tier-1 合并模式 LLM 的范畴路由;
    • ShortCircuitResponder——紧急词 / META_CONVERSATION / CHITCHAT / KB_META / OUT_OF_SCOPE 五条短路终态分支(各自带 span + SSE 时序 + 持久化);
    • SemanticCacheReplayer——语义缓存命中回放;
    • RagPipeline——知识问答主流水线(热路径),run(...) 显式接 Span rootSpan 形参替代隐式 Span.current(),去掉对调用线程 Context 的隐式耦合。
  • 去重 + 收口AiConfigHolder 新增 getInt(String,int) 重载(catch-all 默认,与原 safeGetInt/readIntConfig 行为逐字一致),全仓改直调;readBoolConfig 退回 getBool(k,d)bandToChinese/bandFromScore 抽到 support/ConfidenceBands,三条终态分支的 done payload 共用同一实现,保证 confidenceLevel 输出逐字一致(前端 SSE 契约依赖)。
  • 零行为变更execute 签名不动(唯一调用方 DocMindChatController);所有 SSE 事件名/payload 字段、stageGenerateAndPersist 既有的”生成失败自 catch 不外抛”语义逐字保留。删死代码、修 Reflection/薄壳 注释、清未用 import。

结果

  • DocMindAgent 1349 → ~135 行;新增 4 个职责单一的协作者 + 1 个 ConfidenceBands 工具。Spring 接线零循环(启动时依赖图正常解析到 Milvus 边界)。
  • 全量单测 138/139 通过(唯一失败仍是需 Milvus 在线的 contextLoads,根因链 docMindAgent→shortCircuitResponder→memoryTool→memoryStore→milvusServiceClient DEADLINE_EXCEEDED,反证新 bean 接线正确)。

面试话术

“P2 是纯可维护性重构,主线是把一个 1349 行的上帝类拆成职责单一的协作者。难点不在’拆’而在’拆得安全’:① 热路径流水线对 OTel 的 root span 有隐式依赖(Span.current() 取的是调用线程 Context),我把它改成显式形参传入,既消除耦合又自文档化;② 三条短路分支各自构造 SSE done payload,前端按字段名逐字解析,所以我把 confidenceLevel 这类共享格式化集中到一个工具类杜绝分叉,并逐字搬迁 payload 不动 key 顺序;③ getInt 去重时注意到原 try/catch 会吞畸形配置返默认,新重载特意保持同语义,避免’去重’夹带隐性行为变更。最后用 138/139 单测 + 启动依赖图验证零回归——那个 contextLoads 失败的根因链反而证明了我新加的 4 个 bean 接线是对的。”

涉及文件:DocMindAgent(收缩为薄壳)、新增 ScopeRouter / ShortCircuitResponder / SemanticCacheReplayer / RagPipeline / support/ConfidenceBandsAiConfigHoldergetInt 重载)。


#30 架构审查 P1 修复:双路径对齐 + 增量跨存储一致性 + BM25 父块去污 + 工具重试/分批(2026-06-12)#

背景痛点(分层审查暴露的 P1)

继 P0(入库事务化、embedding 真批量+重试、MinIO/Langfuse 下线、token 预算语种化)之后,对 Agent 编排层与 RAG 服务/数据层做对标审查,定位四类 P1:

  1. 两条检索路径不对等:one-shot(RetrievalWorker)做了父块展开(Parent Document Retrieval)与 CRAG-LOW Web 补偿,agentic 循环(AgenticSearchOrchestrator.finalize)两者都没有——同一问题按路由(SIMPLE→one-shot / 复杂→agentic)拿到的上下文完整度与兜底力度不一致
  2. 增量更新丢父块processIncremental 用扁平 chunk() 而非 chunkWithParents(),旧父块被删却不重建 → ParentChunkResolver 展开落空,父块检索”做过增量就失效”。
  3. 增量失败静默漏检:增量先删 Milvus obsolete 向量、再提交 MySQL 新行,若 embedding/Milvus 写入在事务提交后抛错 → MySQL 有行但 Milvus 缺向量,仅标 failed(catch 里”回退全量重建”是句空注释,从未真跑)。
  4. BM25 父块污染 + 工具无重试/分批:MySQL FULLTEXT 索引 kb_chunk 全表,父块(1500 字)与其子块(400 字)同时命中、内容大面积重叠污染候选;webSearch/rerank 单次失败即降级、rerank 一把梭全部候选无上限。

方案

  • 路径对齐AgenticSearchOrchestrator.finalize 在 MMR 后接入 ParentChunkResolver.resolve,并在初评 CRAG=LOW、整轮未用 web 时追加一轮 webSearch → 合并重排重评(复用 one-shot 的 retrieval.web_compensation_on_low 开关)。HyDE 不强行下沉 agentic——agentic 的 LLM 本身承担”查询改写/扩写”角色(HyDE 在 one-shot 这种无 agency 的规则路才不可替代),属有意保留的非对称
  • 增量父子processIncremental 改用 chunkWithParents;子块层仍按 content_hash 做 diff 复用昂贵 embedding,父块层随子块边界变化整体重建并重连所有子块childSetChanged 才重建,纯 metadata 漂移保持父块不动,最省)。
  • 增量失败 → 真·全量重建:catch 块改为同步调用 process()(同 bean 自调用绕过 @Async,当前 worker 线程内跑完,清两库后重建使三存储恢复一致)。
  • BM25 去污:FULLTEXT 与 fallback 两路均加 vector_id IS NOT NULL——只检索子块,父块仅作 ParentChunkResolver 的展开目标,不入候选。
  • 工具加固webSearch 远程失败重试 1 次(指数退避)后维持”降级为空”契约;CrossEncoderReranker 加 HTTP 重试(reranker.max_attempts)+ 候选超 reranker.max_batch_size(默认 64)自动分批 rerank 再按绝对相关度分全局取 topK。

结果

  • 复杂/多焦点查询(走 agentic)现与简单查询(走 one-shot)拿到同等完整的父块上下文,并共享同一条 CRAG-LOW 联网兜底;来源拆分(KB/Web)按最终 compressed 统计透出 SSE。
  • 增量更新后父块检索不再失效;增量失败不再留半截跨存储状态(自动全量重建 → ready/failed 终态)。
  • BM25 候选不再被父块重复项污染;rerank/webSearch 吸收瞬时限流抖动,候选过多时不再单请求打爆。
  • 全量单测 138/139 通过(唯一失败是需 Milvus 在线的 contextLoads,与本次改动无关);新增 WebSearchTool 重试恢复用例。

面试话术

“这批 P1 的主线是消除路径间的能力漂移。系统有两条检索路径——规则驱动的 one-shot 和 LLM 自驱的 agentic——审查发现父块展开、Web 补偿这些质量手段只长在 one-shot 上,导致用户问一个复杂问题反而拿不到父块上下文。我把这些能力对齐到 agentic 的收尾管道,但HyDE 故意没对齐:agentic 的 LLM 本身就在改写查询,HyDE 的价值在 one-shot 这种无 agency 的路才不可替代——对齐不是无脑抄,是分清哪些是路径本质差异。另一条线是跨存储一致性:增量更新原来会丢父块、且失败后留下 MySQL 有行/Milvus 缺向量的半截状态还谎称’已回退全量重建’,我让它真的去跑全量重建。这些都是’看着能跑、其实按场景时灵时不灵’的隐性 bug,靠分层审查 + 真实 trace 才挖得出来。”

涉及文件:AgenticSearchOrchestrator(父块展开 + Web 补偿)、DocumentProcessTask.processIncremental(父子维护 + 失败全量重建)、BM25Retrievervector_id IS NOT NULL 去污)、CrossEncoderReranker(分批 + 重试)、WebSearchTool(重试)。


#28 基于真实 trace 的 P0 修复:CRAG LOW 不再丢弃证据 → 联网结果带进 low-confidence 作答(2026-06-08)#

背景痛点(“联网搜到了,却没用上”)

用户问”codex 和 claude code 怎么选?各自的优势是什么?“——知识库有 Claude 相关内容、无 Codex 内容(对比类问题天然半命中)。系统返回了一段自信但过时错误的答案:声称”Claude Code 是非官方命名 / Anthropic 未发布该产品”、“Codex 是 OpenAI 2021 年那个已退役的模型”,完全没意识到用户问的是 2025 年的两个 CLI 编码 agent。顺 trace(trace-bba9b9f3...,40.2s)下钻:

  1. 链路决策全部正确:scope→AGENTIC→agentic 循环跑满 4 轮,input 逐轮膨胀 989→3131→7885→10089(证明 chunk 确实被检索到了),第 4 轮模型还主动调了 webSearch(2.46s 的独立 http post,Tavily 返回了结果)。
  2. 决定性证据:最终作答的 LLM 调用 input 仅 150 token——而循环里上下文已堆到 1 万 token。说明 KB chunk + Tavily 网页结果在送进最终 prompt 前被整批清空,只剩”问题+历史”。于是 qwen-plus 只能靠知识截止早于这两个产品的参数化记忆作答 → 时效性幻觉。

根因(与 #27 的”数据丢了”不同,这次是”证据被代码丢了”)AgenticSearchOrchestrator.finalize() / SupervisorAgentneedsFallback = compressed.isEmpty() || grade==LOW。只要 CRAG 判 LOW(rerank top1≤0.25),哪怕 compressed 非空也置位兜底;下游 DocMindAgent.stagePromptAssembly 读到该标志便调 assembleFallback()0 chunk),鼓励模型”基于通用常识作答”。问题是:联网搜到的时效内容 rerank 分本就偏低(gte-rerank 对对比/口语化网页打分不高),“分低 ≠ 不相关”却被一刀切当成”无证据”。而项目里早已存在 assembleLowConfidence()(带 chunk 注入 + “严禁否定来源中出现的实体” + “A/B 对比仅一方有覆盖时如实告知”规则,专为此类场景而建),却因 needsFallback 抢先置位永远走不到。

方案(最小改动,解锁既有 low-confidence 通路)

  • 将两处 finalize 的 needsFallback 语义收紧为仅代表”无任何证据”needsFallback = compressed.isEmpty()(删掉 || grade==LOW)。
  • CRAG LOW 但 compressed 非空 → 命中 DocMindAgent 既有的 lowConfidence 分支 → 走 assembleLowConfidence,把 KB+Web 证据带进 prompt,让模型据实作答并 [n] 引用,而非退回基座模型幻觉。
  • grade 仍如实透传 LOW(前端置信度/降级提示不变),只是不再据此丢弃上下文

数据对比

指标修复前修复后
CRAG LOW + 非空 compressedneedsFallback=true → 0-chunk 兜底needsFallback=false → low-confidence 带证据
最终 prompt 喂入证据0 条(KB+Web 全丢)KB+Web 全量注入
作答依据陈旧基座模型参数化记忆联网检索到的时效内容 + [n] 引用
对比类半命中(A 有 B 无)否定缺失方/全凭脑补如实告知缺失方,仅基于有覆盖方受限作答

面试话术

“又一个’答非所问’,但和上一个 Milvus 丢数据是完全不同的根因。这次 trace 显示链路全对、模型甚至主动联网搜到了内容——可最终作答的 LLM input 只有 150 token,而循环里上下文已经一万 token。我意识到证据是在送进 prompt 前被代码丢掉的:兜底判定把’CRAG 评分低’等同于’没有证据’,于是清空上下文让模型凭记忆答,而模型的知识截止早于用户问的这两个新产品,必然幻觉。但’rerank 分低’不等于’不相关’——尤其联网搜来的时效内容本就容易被 reranker 打低分。修复其实只改一行语义:兜底只在’真的一条证据都没有’时触发,CRAG 低分但有证据就走我们早就写好、却一直够不到的 low-confidence 模板——带着证据作答并强制引用。这件事让我把’置信度低’和’无证据’彻底拆开:前者该降级提示,后者才该兜底,绝不能用低分当借口把检索结果扔了。”

P1 续(同会话,防御纵深——让”低证据”场景诚实)

P0 解决了”有证据被丢弃”,但还剩两个相关短板,一并收口:

  • P1-A 真·无证据兜底防幻觉PromptAssembler.assembleFallback 模板硬化):原兜底 prompt 只说”基于通用常识作答”,于是基座模型对知识截止后的新实体(codex CLI / Claude Code)会自信地断言”不存在/未发布”并堆细节制造权威感。新增 4 条约束,关键两条:① 严禁断言具体产品/工具/模型/版本/事件”不存在/未发布/查无此项/拼写错误”,不确定就说”建议查官方/联网核实”;② 保持简洁,不确定宁可少说,不用细节填充。即使联网失败真走到兜底,也不再产出”自信的错误”。
  • P1-B 兜底文案矛盾修复DocMindAgent finalize):P0 后”零引用覆盖 + CRAG LOW”只可能命中 lowConfidence 通路(真·无证据已由 needsFallback=compressed.isEmpty() 覆盖)。原逻辑此时会翻 needsFallback=true 并追加”知识库未找到参考文档/基于通用知识”——但 low_confidence 模板开头已自带”相关性偏低、基于有限参考内容”,且我们确实喂了 KB/Web 证据,两条提示自相矛盾。改为不再翻 needsFallback,仅保留 ungrounded 观测信号(span status low confidence answer (ungrounded: 零引用覆盖)),前端 isFallback 也回归真实(低置信≠兜底)。
  • P1 源感知分级:经复核已被 P0 顺带解决——联网结果存活即 compressed 非空 → 直接走 lowConfidence 带证据,无需为”Web 逃逸 KB 阈值”再加分支。

P2 续(可观测补齐——让”知识库失明/纯联网作答”在前端直接可见)

复盘时一个体感是”trace 看不出每刀检索到底命中了 KB 还是 Web”。核查发现两件事:

  • JSON 导出本身的局限:导出的 28 个 observation 的 metadata 全为 {},但 trace 级 attributes 正常。即代码里 rerankSpan.setAttribute("rag.rerank.top_score",…)graderSpan.setAttribute("rag.grader.tier",…)确有埋点,只是这份”导出 JSON”不含 span 级属性(Langfuse UI 里能看到)。所以”trace 一片空”主要是导出格式问题,不是埋点缺失——避免了盲目加埋点。
  • 真实代码缺口(已修):agentic 路径的 em.retrieval(...) 此前传 null, null, null(一次性路径是有 KB/BM25/Web 拆分的),且持久化 retrievalLog 不含来源拆分与 CRAG 评分。于是”KB 0 命中、纯联网”这种关键形态在可靠落地面(SSE 时间线 + qa_message agent trace 面板)上完全看不到,只能靠 trace 里 http-post 时序反推。修复:finalizeRetrievedChunk.Source 拆分 kbChunks/webChunks 补进 SSE 与日志,并把 cragGrade 持久化。回归 agenticLogRecordsSourceSplitAndGrade 锁定。

涉及文件

  • agent/supervisor/AgenticSearchOrchestrator.java — P0:finalize()needsFallback 收紧为 compressed.isEmpty();P2:em.retrieval 补 KB/Web 拆分 + buildAgenticLogkbChunks/webChunks/cragGrade
  • agent/supervisor/SupervisorAgent.java — 一次性路径同步对齐
  • agent/DocMindAgent.java — P1-B:零引用覆盖不再翻 needsFallback,改 ungrounded 观测信号 + 修正 WARNING status
  • service/rag/PromptAssembler.java — P1-A:assembleFallback 模板硬化(禁断言实体不存在 + 简洁约束)
  • agent/supervisor/AgenticSearchOrchestratorTest.java — 新增 cragLowWithChunksDoesNotForceFallbackagenticLogRecordsSourceSplitAndGrade 回归
  • service/rag/PromptAssemblerTest.java — 新增 fallbackForbidsAssertingEntityNonexistenceAndKeepsHistory 回归
  • (复用既有)service/rag/PromptAssembler.assembleLowConfidence + prompts/knowledge_qa_low_confidence.txt

#27 生产事故复盘:Milvus 向量整库丢失 → flush 落盘加固 + 启动一致性自检(2026-06-08)#

背景痛点(从一次”KB 有文档却答非所问”的体感问题切入)

用户反馈”知识库里明明有 OpenClaw 安装文档,问它却全程联网作答、还标注’未经知识库审核’“。顺着 trace 一路下钻:

  1. 链路决策全部正确:scope=KNOWLEDGE_QUERY → AGENTIC → searchDocs("openclaw 安装部署指南")命中=0条 → 模型转 webSearch → CRAG=LOW → 纯 Web 作答。每一步在”KB 返回 0”前提下都合理,但前提本身是错的。
  2. 数据层对账:MySQL kb_knowledge_base 中 openclaw 文档在 kb_id=23status=ready、93 chunk 带 vector_id;全库 3698 chunk 已向量化。但 Milvus 集合 docmind_knowledge 实体数 = 0(连 docmind_memory 也是 0)。
  3. 用随机 1024 维向量直查 Milvus → code:0, data:[](Success、零结果,非报错)+ loadState:Loaded证明向量库功能完全正常,只是没有数据

根因MilvusService.insertVectors 插入后从不 flush。Milvus standalone 的未落盘数据只在 milvus_data(WAL/growing segment)里,一旦 Milvus 容器非优雅重启 / milvus_data 卷被重置就全丢;而集合元数据在外置 etcd(存活)→ 形成”表在、schema 在、实体为 0”的隐性故障。kb_chunk.vector_id 全部成为悬空指针。agentic 路径只暴露向量 searchDocs(不含 BM25 keyword_search),于是 KB 整体”失明”、静默退化到 Web。

方案设计(恢复 + 防复发 + 早发现,三层)

  • 恢复(一次性脚本 scripts/reindex_milvus.py:直接 MySQL kb_chunk → DashScope text-embedding-v3 重嵌入 → Milvus REST upsert复用原 vector_id 作主键让悬空指针复活;从 kb_chunk.metadata 还原 chapter/contentType/pageNumber、category 取自 KB。不依赖源文件 / 登录态。先重建 kb_23 验证(真实检索 top 0.866 全来自 kb_23),再全量 3698。
  • 防复发(flush 落盘)insertVectors 末尾 flushCollection(),单文档入库一次 flush,把 growing segment 落盘对象存储(MinIO),段落即可跨重启 / 卷重置存活。
  • 早发现(启动一致性自检)AppInitConfig 启动时 flushCollection()(把上次残留落盘)+ 对比 Milvus 实体数 vs MySQL 已向量化 chunk 数,失衡(Milvus < 50%)打 ERROR 提示重建——否则只表现为 KB 检索静默全空。

数据对比

指标修复前修复后
Milvus docmind_knowledge 实体数03698(落盘 3791,含待 compaction 的 upsert 旧版本)
openclaw 查询 searchDocs命中 0 → 纯 Web命中 5、top COSINE 0.866 → CRAG HIGH
插入持久化仅 WAL,重启即丢flush 落盘 MinIO,跨重启存活
数据丢失可发现性无(静默退化 Web)启动 ERROR 告警
启动自检日志Milvus 实体数=3791 / MySQL 向量化 chunk 数=3698 ✓

面试话术

“一个’知识库有文档却联网作答’的体感问题,我没停在’模型不行’,而是顺 trace 下钻:链路每步决策都对,问题是 searchDocs 命中 0。再对账发现 MySQL 有 3698 个带 vector_id 的 chunk,但 Milvus 整库是空的——我用一个随机向量直查 Milvus,返回 Success 但零结果,证明是’库正常、数据没了’。根因是插入从不 flush,Milvus 未落盘数据只在 WAL,容器重启 + 数据卷重置就全丢,而 etcd 里的表结构还在,于是成了’表在数据空’的隐性故障,vector_id 全悬空。修复分三层:写脚本从 MySQL 重嵌入、复用原 vector_id 让指针复活;代码层插入后 flush 落盘;再加一个启动自检对比 Milvus 与 MySQL 计数,失衡就告警——因为这种故障最可怕的是静默,系统分不清’KB 真没有’和’KB 数据丢了’,只会默默退化到联网。结论:向量库要么 flush 落盘 + 优雅关停,要么就得有一致性兜底,不能让数据丢失表现为’检索质量下降’。”

涉及文件

  • service/knowledge/MilvusService.javainsertVectorsflushCollection() 落盘 + countEntities() 统计
  • config/AppInitConfig.java — 启动期 flush + 向量一致性自检
  • scripts/reindex_milvus.py — 一次性向量重建脚本(MySQL→嵌入→Milvus upsert,复用 vector_id)

#26 基于真实 trace 的 P0 提效:agentic 空轮护栏 + 接地作答降温限长(2026-06-08)#

背景痛点

复盘一条生产 Langfuse trace(query=「如何安装部署 openclaw」,该实体不在任何知识库),全链路 33.38s,拆出三处确凿浪费:

  1. agentic 循环跑满 4 轮、从不早停,且重复搜空知识库。从 token 增量反推每轮产出:iter1 搜 KB(context 890→937,仅 +47 token ⇒ KB 空)→ iter2 搜 Web(937→5204)→ iter3 又搜一遍 KB(5204→5254,仅 +50 ⇒ 再次空)→ iter4 搜 Web → 撞 max_iterations 强停。SYS_PROMPT 明明写了「搜够就停、别重复检索」,但模型一路跑满预算——纯 prompt 约束不可靠。4 次决策 LLM(in 890/937/5204/5254,out 仅 33~41)占了循环 9.62s 的 ~60%。
  2. seed 锚定效应buildSeed 里「你最多可以进行 N 轮工具调用」把模型锚定到用满 N 轮
  3. 接地作答 21.22s / 902 token、temperature=0.7。答案堆砌 ✅🛠️⚠️ 分节 + 重复免责声明。根因双重:① 流式生成 .stream(new Prompt(prompt)) 不传 options,继承模型 default 的 chat_temperature(生产=0.7);② 专为流式准备的 llm.streaming_temperature(0.3) 从未被套用——是一处死配置。

方案设计(确定性护栏 > prompt 自觉)

  • 空轮提前收尾AgenticSearchOrchestrator 循环内):每轮工具执行后比对 AgentToolContext 的 chunk 数,本轮零新增已累积到资料now>0)→ 直接 break。命中场景正是 iter3「KB 二次搜索又落空」。第 1 轮 KB 空时 now==0 不触发(要给模型换 Web 的机会),逻辑闭合。开关 agentic.early_stop_on_empty_round(默认 true)。
  • seed 去锚定:不再向模型暴露轮数上限(上限由外层 for 强制),改引导「尽量少轮、搜空即止、别近义改写重复搜同一来源」;SYS_PROMPT 第 4 条同步强化。
  • 作答降温 + 限长:新增 AiConfigHolder.generationOptions()——显式套用 llm.streaming_temperature(0.3) 修复死配置 + rag.generation.max_tokens(1024) 防跑飞上限(保留 streamUsage(true) 不丢 token 统计),生成处改 .stream(new Prompt(prompt, generationOptions()))。真正的篇幅控制放在 prompt 模板:knowledge_qa.txt/_low_confidence.txt 加「精炼、避免无关小节/表情堆砌/重复免责声明、正文≤600 字」。

实现细节与边界

  • 护栏度量用 chunk 数增量而非解析工具返回,零侵入 ToolCallingManager;chunk 只增不减,roundYield≥0,无需处理负值。dedupe 仍在 finalize 统一做,单轮重复 chunk(yield>0)不误停。
  • generationOptions 与既有 smallModelOptions 同构:复用主连接、仅覆盖少量参数;temperature/maxTokens 缺失各自回退 0.3 / 不封顶,启动期热路径安全。
  • docs/docmind.sqlllm.streaming_temperature 种子值 0.7→0.3 对齐 AiConfigInitializer 默认(注意:已有 DB 不会被增量插入覆盖,需在 AI 配置面板手动改)。

数据对比(基于该 trace 的预期收益)

指标改动前(实测)改动后(预期)
agentic 轮数(本 query)4(跑满)2(iter3 零增益即停)
省去的调用1 次决策 LLM + 1 次 KB embedding/Milvus + 1 次 Web + 5K+ token 重发
首 token 前等待12.16s89s(砍 1 轮 + 早停)
作答温度0.7(误用 chat_temperature)0.3(streaming_temperature 生效)
作答篇幅902 token / 多余小节≤600 字目标 + 1024 硬顶

验证AgenticSearchOrchestratorTest 新增 earlyStopsOnZeroYieldRoundAfterAccumulatingChunks(第 1 轮写 1 chunk、第 2 轮零增益 → 断言只跑 2 轮而非 4),4 测全过;日志实证 第 2 轮零增益且已累积 1 条资料 → 提前收尾

面试话术

“我拿一条真实 trace 复盘,33 秒里发现三处浪费:agentic 循环对一个知识库根本没有的实体跑满 4 轮、还把空知识库搜了两遍;最终作答 21 秒、902 token 还在堆表情和重复免责声明。最关键的认知是——prompt 里写『搜够就停』模型并不照做,它倾向于用满预算。所以我没有继续调 prompt,而是加确定性护栏:每轮比对累积 chunk 数,零增益且已有资料就代码层强制 break,第一轮空时不触发以保留换 Web 的机会。同时把 seed 里『最多 N 轮』这种锚定话术删掉。作答这块我挖出一个死配置——专门的 streaming_temperature=0.3 因为流式调用没传 options 而从未生效,一直在用 0.7 的对话温度;我补了个 generationOptions 把它接上,再加 token 上限和模板里的篇幅约束。结论:agentic 系统的预算控制要靠代码护栏兜底,不能只信模型自觉。”

涉及文件

  • agent/supervisor/AgenticSearchOrchestrator.java — 空轮早停护栏 + seed 去锚定 + SYS_PROMPT 强化
  • config/AiConfigHolder.java — 新增 generationOptions()(接通 streaming_temperature + maxTokens)
  • agent/DocMindAgent.java — 流式生成传入 generationOptions()
  • config/AiConfigInitializer.java — 新增 agentic.early_stop_on_empty_roundrag.generation.max_tokens
  • resources/prompts/knowledge_qa.txtknowledge_qa_low_confidence.txt — 精炼约束
  • docs/docmind.sqlllm.streaming_temperature 种子 0.7→0.3
  • agent/supervisor/AgenticSearchOrchestratorTest.java — 早停护栏测试

#25 观测系统重构 Part 2:三套并行埋点统一为单一发射门面 StageEmitter(2026-06-08)#

背景痛点

同一个流水线步骤过去要分别手写三处埋点,散落、重复、易漂移:

  • SSE 事件(前端时间线):sendSseEvent helper 在 DocMindAgent/SupervisorAgent/AgenticSearchOrchestrator 复制了 3 份,12 个事件名、~34 调用点。
  • agentTrace(DB 持久化 + 回放):addTrace/updateTrace 复制了 2 份
  • OTel span 属性span.setAttribute 散落 100+ 处。

改一处忘改另两处即漂移。目标:抽单一发射门面,每步只发一次、由门面扇出。

方案设计

新增 agent/emit/StageEmitter.java(每请求一个普通对象,持 (SseEmitter, AgentState)):

  • 收口单一 sse()(合并 3 份 sendSseEvent)+ trace()/traceObs()(合并 2 份 addTrace/updateTrace)。
  • 每个 SSE 事件的 payload 形状在门面集中定义一次understanding/routing/retrieval(三形状,可空参)/rerank/grader/confidenceWarning(两形状)/scope/agenticStep/start/token/done/error)——契约从 34 个散落调用点收敛成单一事实源。
  • 三个编排类各处 new StageEmitter(emitter, state) 局部包装后调用,旧 helper 全删

诚实的边界(务实门面,不强行合并非点事件):OTel span 是「时长」(需 TracedOp 的 start/scope/end 生命周期),不属点事件,仍由 TracedOp 收口、setAttribute 留原位——门面只统一两类点事件(SSE + agentTrace)。done(20+ 字段、按路径定制) 由调用方构造、门面只负责发送。rerank.topScorefloat(与 RetrievedChunk.rerankScore 一致)防 JSON 值漂移。

验证:golden SSE 契约 diff(本次最关键的工程手法)

SSE 契约 byte-identical 是硬约束(前端按事件名 + payload key 硬编码消费)。改代码先抓 6 条代表路径(agentic / simple / chitchat / oos / emergency / cache×4)的 {scenario → {event → payload key 集合} + 事件序列} 存为 golden 基线;分阶段(主路径 → 全量)重抓 diff:

  • 主路径迁移后:per-scenario 契约 + 顺序 9/9 全等
  • 全量迁移后:per-scenario 仅 cache3/cache4 报差——经查是运行时非确定性而非回归:① LLM 把同类查询一次路由 one-shot、一次路由 agentic(retrieval/confidence_warning 形状随路径不同);② citationCoverageput(..., coverageComputed()? : null),fastjson2 省略 null 值键,其有无取决于该次答案是否解析出引用。
  • 改用 global-union 契约(跨场景池化 {event → 所有出现过的 key})再 diff → 逐事件全等,证明零契约回归。

回归 #1/#2/#4 全过:scope_routing span、多 generation、cost 非零、llm_generation level=WARNING、4 个 score、qa_message.agent_trace step0–6 持久化均在。

面试话术

“三套埋点(SSE 给前端、agentTrace 入库回放、OTel span 给 Langfuse)过去在三个类里各写一遍,同一步要发三次、改一处忘两处。我抽了个每请求的发射门面 StageEmitter,把单一发送实现 + 每个事件的 payload 形状收敛到一处,业务每步只调一次。难点是 SSE 是前端硬编码的契约,错一个字段就静默失效——我用 golden diff 守门:改前把六条代表路径的『事件名→payload key 集合』抓成基线,迁移后重抓比对。有意思的是全量迁移后两条缓存路径报差,查下来是 LLM 路由漂移 + 一个 null 值键被 JSON 省略的数据依赖,不是回归;我换成跨场景池化的 global-union 契约比对才得到逐事件全等的铁证。结论:观测重构的验证一定要能区分『契约漂移』和『运行时非确定性』。另外 span 是时长、不是点事件,我没硬塞进门面,仍由 TracedOp 收口——务实地只统一该统一的。”

收尾 #7(同会话完成):trace 的 environment 维度 dev/prod 分流——application.yml 加 OTel resource 属性 deployment.environment.name(env 驱动,本地 development、容器 production),Langfuse 按 environment 过滤。至此可观测路线图(Part1 + #1/#2/#4 + Part2 + #7)全部清零。


#24 观测系统重构 续:基于真实 trace 找回丢失的 LLM 调用 + 成本计费(2026-06-07)#

背景痛点

#23(Part 1)把基础设施换成原生 autoconfig 后,从 Langfuse 导出一条真实 rag-pipeline trace 复盘,发现 7 项不足(按严重度):

  1. 三次 LLM 调用只追到一次:整条 trace 只有 1 个 GENERATION(最终答案)。query_understanding span 0ms、无 generation 子节点;worker_dispatch 1.49s 也无 embedding 子节点。
  2. 没有质量信号 / 无 Langfuse score——这条 trace 明明是 fallback 兜底(zero coverage + CRAG LOW),但所有 span level=DEFAULT降级回答与正常回答在 Langfuse 里无法区分
  3. 每个 stage span 的 metadata 全是 {}——代码里 100+ 处 setAttributerag.* 子 span 属性在 Langfuse 没显示出来(疑似裸 rag.* 键未被 Langfuse OTLP 摄取识别)。
  4. 成本恒为 0totalCost: 0costDetails: {}——qwen / text-embedding-v3 不在 Langfuse 价表。
  5. 流式首 token 延迟(TTFT)丢失(timeToFirstToken: null)。
  6. llm_generation SPAN 空壳套 chat GENERATION,沦为多余层级。
  7. trace.name 与 root span name 不一致、environment=default 等小问题。

用户选「先做 #4 + #1」(投入小、收益立竿见影)。

根因(#1)

QueryUnderstanding 的 LLM 调用在 DocMindAgent.execute() 第 122 行的 decideScope() 发起(合并模式:scope + classification 一次出),早于旧 root span(在 runReActLoop 内才创建)。此刻无 current span → 该 generation 自成一个无名 root span → 名字不在 BusinessRootSpanSampler 白名单 → 被 head-based Sampler DROP。同理语义缓存查询、检索阶段的 query embedding 也都发生在 root span 之前,一并被丢。结论:任何在业务 root span 作用域之外发起的模型调用都被静默丢弃——这是 #23 的 Sampler 白名单设计 + 「QU 早于 root」叠加出的结构性盲区。

方案设计

做法
#1 root span 上提DocMindAgent.execute root span 从 runReActLoop 提到 execute() 顶部(step ④ 前),用 try-with-resources 包住 emergency / scope / cache / 主流程全部分支runReActLoop 改用 Span.current() 不再自建 root
#1 语义化 parentdecideScope 包进 scope_routing TracedOp span,让 QU 的 LLM 调用挂在它下面
#1 trace.name由 root 按命中路径动态设(emergency / chitchat / cache_replay / rag-pipeline),短路 handler 的 child span 保留(trace 级属性值与 root 一致、无害)
#4 成本计费纯 Langfuse 侧、零代码scripts/langfuse-register-models.sh(幂等,分页 limit=100,按 modelName 删旧自定义定义再重建)经 Public API 注册 4 个 DashScope 模型的 ¥/token 计费(qwen-plus / turbo / max + text-embedding-v3,标准价)

验证(Docker 实跑,对接自托管 Langfuse v3)

驱动一次知识库查询,同一条 trace 对比:

  • generation 数 1 → 10scope_routing 下挂 chat qwen-turbo(1929/93,过去被丢弃的 QU 调用,现已捕获)+ 4× agentic qwen-plus + 5× text-embedding-v3 + 1× 最终 qwen-plus
  • trace.totalCost 0 → 0.00816(= 各 generation calculatedTotalCost 之和;注意 Langfuse 把成本放 calculatedTotalCost / costDetails,observation 级 totalCost 字段为 null)。顺带发现 QU 走的是更便宜的 qwen-turbo,现在成本看板能区分。
  • 历史 trace 不回填,仅新查询生效。

本条同时把 #23 那条「待补的实跑验证」补上了:关闭/开启态、OkHttp、Sampler 噪声过滤、原生 gen_ai token 用量在 Docker 全部跑通。

面试话术

“Part 1 把可观测的’管道’修好后,我导出一条真实 trace 复盘,发现一个反直觉的洞:一次问答明明有三次模型调用,Langfuse 里只看到一次。顺着 span 时间线查,是 QueryUnderstanding 的 LLM 调用发生在我创建业务 root span 之前——它没有 parent,就自己变成一个无名 root,又被我 Part 1 写的 Sampler 白名单当噪声丢了。修法是把 root span 上提到请求最顶端,让 scope 路由、embedding 这些前置调用都有合法 parent。改完单条 trace 的 generation 从 1 个变 10 个,token/成本第一次完整可见。另外 qwen 不在 Langfuse 价表导致成本恒为 0,我写了个幂等脚本把 DashScope 价格按 ¥/token 注册进去,成本就对上了。教训是:可观测系统建好之后,一定要用真实数据回测覆盖率,否则’管道通了’不等于’数据全了’。”

追加:#2 质量信号(同会话完成)

承上「用真实数据回测」,对剩余 6 项逐一核验后大幅收敛

  • #3 stage 属性丢失 / #5 TTFT 丢失 / #6 llm_generation 空壳 —— 经实跑确认均为伪问题:当初分析基于用户从 UI 导出的 trace JSON,那份导出把每条 observation 的属性 stripped 成 metadata:{},误导了判断。直接打 Langfuse API 看活 trace,子 span 的 rag.* 全在(metadata.attributes)、llm_generationrag.citation.coverage/rag.generation.first_token_ms=813/input/output——根本不空。三项关闭。教训补充:回测要看「系统真实产出」而非「某个导出快照」。
  • #2 质量信号(已做):真问题是「无法按质量过滤/聚合」。两手解决——
    1. 降级标记needsFallback || confidenceBand=LOW 时给 llm_generationlangfuse.observation.level=WARNING + status_message;emergency / OUT_OF_SCOPE 在 root span 上标 WARNING。降级回答终于能在 Langfuse 按 level 过滤/告警。
    2. Langfuse Score:新增 LangfuseScoreClient,把 confidence(NUMERIC) / citation_coverage(NUMERIC) / rerank_top1(NUMERIC) / crag_grade(CATEGORICAL) 经 POST /api/public/scores 按 traceId 推送(Langfuse OTel 属性→score 的映射约定,只能走 Scores API)。fire-and-forget、langfuse.enabled=false 退 no-op。:JDK HttpClient 默认 HTTP/2 打 Langfuse Next.js 网关回 header parser received no bytes(与 OTLP 当初被迫换 OkHttp sender 同源)——改强制 HTTP/1.1 + 失败重试一次 解决。验证:单条降级 trace 4 个 score 正确落库(crag_grade 分类 stringValue=LOW)+ llm_generation level=WARNING。

剩余:仅 #7environment 维度区分 dev/prod,trace.name 已随 #1 root 上提统一)+ Part 2 主体(SSE/agentTrace/OTel 三套埋点统一为单一发射器,见 06-待优化清单.md M6)。


#23 观测系统重构 Part 1:删手写 OTel SDK → Spring Boot 原生 OTLP autoconfig(2026-06-07)#

背景痛点

#19(Phase 6)的 Langfuse 接入是手写 OpenTelemetry SDK:旧 LangfuseOtelConfig(228 行)手动 new OtlpHttpSpanExporter + SdkTracerProvider + BatchSpanProcessor + RootNameFilteringSpanProcessor + micrometerTracer 桥接 + verifyExport() 启动验证。pom 注释把这套手写归因为「Spring Boot 的 OTel autoconfig 与 OTel core 不兼容」。

两个根因暴露问题:

  1. 「版本冲突」前提为伪:实测 mvn dependency:tree 显示 OTel core 全部统一解析为 1.43.0,与 Spring Boot 3.4.13 自带的 micrometer-tracing-bridge-otel 1.4.13 完全对齐,不存在运行时冲突。真正的隐患是 pom 在 dependencyManagement 多 import 了一个未使用的 opentelemetry-instrumentation-bom:2.17.0(潜在版本偏斜源)。手写 SDK 实属历史包袱。
  2. 热切换模型丢观测AiConfigHolder.refreshLlmModel()OpenAiChatModel.builder() 重建模型时未注入 ObservationRegistry → 运行时实际模型不产生原生 gen_ai.* span,这正是当时必须在 llm_generation span 上手动 setAttribute("gen_ai.usage.*") 的原因。

方案设计

维度旧(手写 SDK)新(原生 autoconfig)
SDK 构建手动 new exporter/provider/processor(228 行)Spring Boot 3.4 原生 OTLP tracing autoconfig(actuator + micrometer-tracing-bridge-otel + opentelemetry-exporter-otlp),management.otlp.tracing.* 驱动
LangfuseOtelConfig228 行约 90 行,仅 2 Bean:langfuseTracer(tracing 关闭退 noop)+ businessRootSpanSampler
噪声过滤RootNameFilteringSpanProcessor 事后过滤(traceId Set + 2000 上限清空 + 占位符 name 区分)自定义 head-based Sampler(root span name 白名单 + ParentBased),覆盖 autoconfig 默认 otelSampler@ConditionalOnMissingBean),命中才采样否则 DROP
gen_ai 埋点手动 setAttribute("gen_ai.system/request.model/usage.*")refreshLlmModel()ObservationRegistry → Spring AI 原生 gen_ai instrumentation 自动产 span + token 用量;删手动埋点,激活原本休眠的 ChatModelObservationFilter
Langfuse 认证手算 base64 + 手建 exporter(LangfuseProperties新增 LangfuseOtelAuthInitializerApplicationListener<ApplicationPreparedEvent>)注入 management.otlp.tracing.headers.Authorization,删 LangfuseProperties

实现细节

  • 为何不用 EnvironmentPostProcessor 注入 Authorization:spring-dotenv 用 SpringApplicationRunListenerenvironmentPrepared 阶段加载 .env,晚于触发 EPP 的 EventPublishingRunListener——即 EPP 会先于 .env 运行,导致「仅在 .env 配 key」时读不到。ApplicationPreparedEvent 触发于 .env 已加载、Bean 尚未创建之间,才是 .env-safe 时机。保留双 key DX + 「启用却缺 key → 关 tracing」降级。
  • pom:删 opentelemetry-instrumentation-bom:2.17.0;显式新增 opentelemetry-exporter-sender-okhttp——Langfuse 的 Next.js 网关静默关闭 keep-alive,默认 JDK HttpClient sender 会 EOF。
  • 保持不变:所有业务 tracer.spanBuilder / TracedOp / rag.* 自定义 span 属性原样保留——它们是 Langfuse trace 的实际价值。

验证

  • mvn clean compile BUILD SUCCESS;dependency:tree 确认 OTel 全 1.43.0 + 含 opentelemetry-exporter-sender-okhttp + 无 jdk sender;反编译核实 autoconfig 链路(exporter 消费 properties.headers + endpoint、otelSampler/otelTracer@ConditionalOnMissingBean)。
  • 需实跑(基础设施 + Langfuse key)的关闭态启动 / 开启态导出 / SSE 契约回归 / OkHttp 长闲置无 EOF 待补。

面试话术

“项目最初接 Langfuse 是手写 OTel SDK,228 行手动拼 exporter + provider + processor,理由是’Spring Boot 3.4 的 autoconfig 跟 spring-ai 版本冲突’。后来我去查依赖树,发现 OTel core 其实全统一在 1.43.0、跟 micrometer-tracing-bridge 对齐,根本没冲突——手写 SDK 是个被错误前提固化下来的历史包袱。所以我删掉它改用 Spring Boot 原生 OTLP autoconfig,代码从 228 行瘦到 90 行;噪声过滤从事后的 SpanProcessor 换成 head-based 的自定义 Sampler,更优雅;再把热切换模型接上 ObservationRegistry,让 Spring AI 原生产 gen_ai span,删掉一堆手动 setAttribute。这是’先质疑前提、再做减法’的重构。”

下一步(Part 2):SSE / agentTrace / OTel 三套埋点统一为单一事件发射器(见 06-待优化清单.md M6)。


#22 对标主流四刀改造:真·LLM 工具调用 agentic 循环 + 结构化引用 + 删自反思 + 分类器瘦身(2026-06-07)#

背景痛点

回看整条编排,发现仍是「预定义推理型 RAG 管线」:三条手写检索分支(自研串行多跳 #18 + Query Decomposition #4 + CRAG-web 回溯)各自维护一套规划/拆解/补强逻辑,外加一整套 Self-Reflection 子系统(生成后二次审查 / 条件重写 / 四维打分 / 切题度检查)。这些都是「用规则和 flag 模拟智能体决策」,本质上在重复造轮子。对标 Anthropic《Building Effective Agents》、Glean、RAGFlow 后判断:模型已经能在工具调用循环里自驱拆/跳/补/停,手写分支应当收敛掉。

方案设计

动作落地
A 结构化引用把软 [n] 引用换成稳健结构化CitationParser(纯 Java,无 LLM)解析最终答案里的 [n] → 结构化 citations(index/id/name),越界编号丢弃计入 invalidRefs;句子级 coverage = 含有效 [n] 的实质陈述句 / 实质陈述句总数;confidenceScore = clamp(coverage) × rerank-top1(coverage 不可算时退回 rerank-top1);前端 [n] 渲染为可点击锚点
C 删自反思完全移除自反思子系统删整类 SelfReflection + 死配置 reflection.* + SSE reflection_start/token/done;生成即终态,兜底改为「零覆盖 + CRAG LOW → fallback 提示」(后 #28 修正:CRAG LOW 但有证据不再兜底、零覆盖仅记 ungrounded 信号),置信度来源由覆盖率接管
B agentic 循环三条手写分支 → 真·LLM 工具调用循环AgenticSearchOrchestrator:Spring AI ToolCallingManager 手动控环(internalToolExecutionEnabled(false),因内部工具循环无迭代上限),上限 agentic.max_iterations=4;白名单暴露 4 个工具 searchDocs/keywordSearch/webSearch/recall_memory + executeCode 沙箱计算工具(sandbox.enabled 默认关),硬排除 store_memory/kb_meta(防 LLM 误写);模型自驱拆/跳/补/算/停;finalize() 累积所有工具产出 chunks → 去重 → 一次 Cross-Encoder rerank(统一标尺)→ MMR → compress → CRAG;异常 / 空结果回退 oneShotRetrieval
E 分类器瘦身砍掉运行时该自决的字段QueryClassification.needDecompose / multiHopapplyDeterministicSignals 改为只升 complexity(多焦点信号把 SIMPLE 升到 ≥ MEDIUM,路由进 agentic);PathDecision.Mode 改为 SELECTED_DOC / RULE_PLANNER / AGENTIC 三模式

D 权限下推暂缓:关键发现是现状为「全局共享知识库」(KnowledgeBaseServiceImpl.listDocuments 不按 user_id 过滤、种子数据全归 user_id=1),与「按用户归属」假设冲突——kbIds 归属校验语义需产品先拍板,故本轮不做。

数据对比

指标改造前改造后
检索分支3 条手写(多跳 / 拆解 / CRAG-web 回溯)1 条 agentic 循环 + 1 条 one-shot 兜底
agent/supervisor/ 编排多跳 / 拆解 / Plan 专用类删拆解 / 多跳 / Plan,保留 oneShotRetrieval
SupervisorAgent 行数~1683~595
Self-Reflection 子系统整套(审查 / 重写 / 打分 / 切题度)删除,置信度由引用覆盖率给
SSE 事件reflection_* / multihop / plan删上述,新增 agentic(每轮一次)
单测126 通过
真 qwen-plus 冒烟通过(简单题 1 次 / 对比题 ≈4 次 / 多跳题 ≈4 次 searchDocs 自驱)

面试话术

“这一轮最值得讲的是一个’加了又删’的取舍故事。我先后自己写了串行多跳、查询拆解、CRAG-web 回溯三条检索分支,还有一整套自反思子系统——都是用规则和 flag 去’模拟’一个智能体该有的决策。后来对标 Anthropic《Building Effective Agents》才想清楚:模型本身就能在一个有界的工具调用循环里自己决定拆不拆、跳不跳、要不要补 web、什么时候停,我那三条手写分支其实是在重复造轮子。所以我把它们收敛成一条真·LLM 工具调用的 agentic 循环(Spring AI ToolCallingManager 手动控环、白名单只给 3 个读工具、硬排除写记忆工具防误触发),并顺手删掉了自反思——置信度改由引用覆盖率给。SupervisorAgent 从 ~1683 行瘦到 ~595 行,126 单测全绿,真 qwen-plus 冒烟里模型确实会对多跳题自主串行检索 4 次。这正呼应 Anthropic 的核心观点:简单可组合的模式胜过复杂抽象——能删的复杂度就是最好的优化。”

关联:调研与实现设计见 19-对标主流产品改造报告20-改造实现设计;被取代的自研串行多跳见 18-串行多跳检索与RAG链路生产加固,删除的 Plan-and-Execute 见本文 #21。


#21 编排链路精简:删冗余路径 + 收敛重复管道 + 条件反思(2026-06-06)#

背景痛点

串行多跳(迭代 #20 之后的 18 号文档能力)落地后回看整条编排,发现几处”结构债”:

  1. Plan-and-Execute 成了死腿:Phase 2 实现的 PlanGenerator / PlanExecutor / ExecutionPlan 默认关闭、线上从不触发;且 18 号文档已论证它无法处理链式多跳(一次性规划早于检索,下一跳 query 的指代解析不了),能力与 Query Decomposition 重叠——纯维护负担。
  2. 检索收尾管道重复 2–3 套RRF→Cross-Encoder rerank→MMR→压缩 在标准检索首轮、CRAG AMBIGUOUS 回溯、拆解 Web 补强里各写一遍,改一个阈值要改 3 处。
  3. DocMindAgent 里 MULTI_HOP 与标准/拆解两条 supervisor 调用样板重复
  4. decideScope 两级路由内联,Tier-0 规则 / Tier-1 LLM 混在一个长方法里,易被误读成冗余。
  5. Self-Reflection 只按 rerank 分短路(top-1 ≥ 0.85),没用上 CRAG 已经算出的 HIGH 档信号,HIGH 档答案仍白跑一轮反思生成。

方案设计

#动作落地
1删除 Plan-and-ExecutehandlePlanAndExecute + orchestrate 分支 + PlanGenerator/PlanExecutor/ExecutionPlan 三个类 + 配置 plan_execute.enabled;链式多跳由串行有界迭代(Self-Ask/MultiStep)覆盖
2抽统一收尾管道fuseRerankCompress / rerankMmrCompress,三处重复收敛为一处,行为不变
3统一 supervisor 调用样板DocMindAgent.runSupervisorRetrieval(TracedOp + 并状态 + 建上下文);多跳串行循环体与拆解并行体刻意不合并(强行合并会退化成 flag 巨型方法,违背”简单可组合”原则)
4两级路由抽方法tier0RuleScope / tier1LlmScope + 契约注释,明确”非冗余级联”——删任一级都是退化
5反思按 CRAG 档位短路新增 reflection.skip_on_high_grade(默认 true):CRAG=HIGH 时也短路,仍要求候选 ≥ 3 防胡编;短路原因记入 trace

数据对比

指标改造前改造后
PathDecision 实际生效路径标准/拆解/多跳/直读 + 1 条死腿4 条全部在用,无死代码
收尾管道重复份数31
agent/supervisor/ 类数52
反思短路触发率~70%(仅 rerank≥0.85)~75%(叠加 CRAG=HIGH)
单测全绿145/146 全绿(唯一失败为需 Milvus 的 contextLoads,环境问题)

面试话术

“我做完串行多跳后专门回头精简了一轮链路。最值得讲的是删掉了自己 Phase 2 写的 Plan-and-Execute——它默认关闭、线上从不走,而且我后来想清楚它根本处理不了链式多跳:计划是一次性生成的,规划那一刻还没检索,下一跳 query 只能写成带未解析指代的样子,召回极差。它和 Query Decomposition 能力还重叠。所以与其留着当’看起来很厉害’的死代码,不如删掉,用串行有界迭代覆盖链式多跳。这其实呼应了 Anthropic《Building Effective Agents》的观点:简单可组合的模式胜过复杂抽象。同一轮我还把重复 3 套的 RRF→rerank→压缩收敛成一个方法,把反思短路接上了 CRAG 的 HIGH 信号。”

关联:删除的 Plan-and-Execute 设计选型对比见 03-检索与排序链路18-串行多跳检索与RAG链路生产加固


#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)#

⚠️ 已被 #23 重构(2026-06-07)取代:本条记录的手写 OTel SDK 方案(RootNameFilteringSpanProcessor / BatchSpanProcessor / verifyExport() / micrometerTracer 桥接)已于「观测系统重构 Part 1」删除,改为 Spring Boot 原生 OTLP autoconfig + 自定义 head-based Sampler + 原生 gen_ai 埋点。下文保留作时间线,描述的不是当前实现,当前以本文最新一条 #23(观测系统重构 Part 1) 为准。TracedOpChatModelObservationFilter 仍在用。

背景痛点

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:纯正则,命中四种高置信度模式才返回结论,否则返回 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.60 直接判 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
  同一父块多个子块命中 → 去重保留最高分
plaintext

实现细节

  • 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 的 RetrievalGrader CRAG 评分已经能识别出该问题(LOW/AMBIGUOUS 评级),但只是打桩。

方案设计

引入 HyDE(Hypothetical Document Embeddings):

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

两个触发入口:

  1. 首轮 specificity == FUZZY → 自动开启
  2. 首轮 confidence < 0.4 → RetrievalGrader CRAG 评分 LOW/AMBIGUOUS 时条件触发补偿

实现细节

  • HyDEGenerator:用 callSmallModel()(qwen-turbo)生成假设文档,延迟 200-400ms
  • RetrievalWorker:新增 useHyde 参数,启用时向量路用 HyDE 文本、BM25 路保持原 query
  • SupervisorAgent:首轮 FUZZY 自动注入 useHyde=true;RetrievalGrader CRAG 评分 AMBIGUOUS 时注入 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 自动开启 + RetrievalGrader CRAG 评分低分时条件触发,确保不漏。第三,用小模型——假设文档不需要推理质量只需要语义丰富度,qwen-turbo 200ms 就够,不值得花 qwen-plus 的成本。“


#13 Phase 2 Supervisor-Worker 编排架构(2026-05-05)#

背景痛点

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

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

方案设计

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

DocMindAgent (入口编排 + SSE + 持久化, ~900 行)
  ↓ 委托
SupervisorAgent (编排决策 + 策略调度, ~990 行)
  ├─ 简单/中等 → 固定 7 阶段流水线 + CRAG 条件补偿
  ├─ 复杂 → PlanGenerator + PlanExecutor (依赖拓扑并行)
  └─ 拆解 → 并行子问题
  ↓ 调度
Workers (统一接口, 各 80-120 行)
  RetrievalWorker / WebWorker / MemoryWorker / AnalysisWorker
plaintext

4 个 Step 按依赖顺序落地:

  • Step 4:Worker 接口 + 4 个实现 + AgentState 增强(Evidence 累积、置信度轨迹、策略记录)
  • Step 5:SupervisorAgent 骨架 + 7 阶段顺序流水线 + CRAG 三档评分条件补偿
  • Step 6:PlanGenerator(LLM 生成 JSON 计划)+ PlanExecutor(按 dependsOn 拓扑分层并行)+ ExecutionPlan
  • Step 7:CRAG 条件补偿(RetrievalGrader 三档评分)+ DocMindAgent 集成(删除 ~500 行内联检索代码)

实现细节

数据对比

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

面试话术

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

重构思路是经典的 Supervisor-Worker 拓扑:SupervisorAgent 只做决策(走标准流水线还是计划执行还是子问题拆解、CRAG 质量评分决定是否补偿),Worker 只做执行(检索/搜索/记忆/分析),通过统一的 Worker 接口和 Evidence 累积协议解耦。

几个关键设计值得展开:

  1. RetrievalGrader CRAG 三档评分驱动条件补偿——HIGH(≥0.60)直接使用、AMBIGUOUS 保留+一轮 Web 补充、LOW(≤0.25)丢弃走 Web 兜底,不是盲目迭代而是按质量精准决策。
  2. PlanExecutor 依赖拓扑并行——按 dependsOn 把计划分层,同层步骤用 CompletableFuture 并行,避免串行等待。
  3. CRAG 条件补偿链——不是简单的’失败就 fallback’,而是根据检索质量分级触发精确补偿(空召回/低分→Web 兜底,模糊→Web 补充保留本地结果)。

整个改造对外完全透明——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/RetrievalGrader.java — CRAG 三档质量评分(HIGH/AMBIGUOUS/LOW)
  • agent/supervisor/RetrievalPlanner.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 溯源
  • TextChunker 新增两级文档元信息提取:
    • Level 1:YAML Frontmatter 解析(--- 包裹),提取 version/date/title,剥离后不计入 chunk 正文
    • Level 2:正文头部 500 字正则匹配(“版本 x.y.z” / “2025-06-01”),覆盖非 Markdown 格式
    • Frontmatter 优先级高于正文匹配,结果注入所有 chunk 的 docVersion / effectiveDate
    • 持久化到 kb_chunk 表字段 + metadata JSON,下游检索可按版本和时效过滤

实现细节

数据对比

指标改动前改动后变化
检索候选池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
plaintext

关键设计决策

  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 才触发
plaintext

关键设计抉择

  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 装配器,并补齐增量索引链路:

核心设计要点

  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<KbChunk> 做多重映射,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<String>),按 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<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 模型?

候选方案:

方案多栏 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:

核心设计要点

  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<JSONObject, JSONObject> 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<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 端点 + 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 与依赖版本兼容问题」,跟本次合并无关;注:该「版本冲突」前提后于 #23 被证伪——OTel core 实测统一 1.43.0,根因实为手写 SDK 装配,删手写 SDK 改原生 autoconfig 后已消除)
  • 删了约 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(查询画像分析器),输出查询级别的检索参数替代全局默认值:

核心设计: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) 动态构建:

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

降级路径同样从 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 字段,贯穿 7 阶段流水线
  • 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 是否需要拆解规则强信号优先(“对比/区别/分别/双问号”等命中即返回,跳过 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) → ...
plaintext

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

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

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

配套基础设施改进

落地过程中顺手修复了一个隐患:原 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<Integer> 字段
  • 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)#

⚠️ 历史记录:本条是最初的 Langfuse 接入(2026-04-27),后经 #19 改成手写 OTel SDK、再经本文最新一条 #23(观测系统重构 Part 1) 改回 Spring Boot 原生 OTLP autoconfig。下文的 OtlpHttpSpanExporter 手建、LangfusePropertiesself_reflection span 等均已不是当前实现,当前以 #23 为准。

背景

项目原来只有日志级别的可观测性——各步骤耗时散落在 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 形成父子关系
plaintext

具体改动:

  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”:

// 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);
}
java

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

调用链分析

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

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

结果

  • 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 条推荐,避免信息过载

实现细节

// 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
java

前端 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