优化迭代记录#
每次优化记录格式:背景(为什么要优化)→ 方案(怎么做)→ 结果(效果如何)→ 面试话术(怎么讲)
迭代记录#
每次优化在此追加,从最新到最旧排列。
#37 合订本切块「产品名缺失 + 父子跨产品串味」修复 + reranker v1→v2 持久化(2026-06-13)#
背景痛点(#36 的同一条 trace 2d41dce1 继续下钻)
#36 修好”AMBIGUOUS 不再丢证据”后,同一条 query 仍有两个症状:答案置信度还是 LOW,而且引用的来源是网络搜索而非知识库——可那篇产品条款明明已经入库(kb35)。继续扒 trace + 进 MySQL 看真实切块,挖出三层根因,全在「检索质量」而非「兜底逻辑」:
- reranker 还在用 v1(gte-rerank)→ 403 静默降级关键词排序,分数是假的。 直接
curl复现:gte-rerank返 403 AccessDenied,gte-rerank-v2返 200。CrossEncoderReranker收到 403 抛异常 → 走fallbackRerank(关键词重叠score*0.6 + match*0.4)。trace 里那个 0.4636 是关键词兜底分,不是 cross-encoder 语义分。v2 的修复早写好但application.yml一直未提交(线上镜像从工作区构建才偶然带上),干净 checkout 重建会退回 v1。 - 产品名只活在
metadata.chapter,没进content→ embedding/BM25/rerank 都看不到 → 投保范围块排不进 top。 query「80 岁…国寿鑫缘宝终身寿险(万能型)(乐鑫版)」被产品专名主导,而答案块content只有「## 第二条 投保范围 凡出生…七十五周岁」,产品名一个字都没有。gte-rerank-v2实测该块仅 0.169,被一张现金价值大表格(第二十九条释义,0.382)和第一条套话(0.305)挤掉,“七十五周岁”只能从 web 捞回。 - 父子映射跨产品污染。 kb35 是合订本 PDF(个人保险基本条款 + 国寿鑫富宝年金 + 国寿鑫缘宝乐鑫版)。
chunkWithParents纯按size ≤ 2000贪心打包相邻子块,完全不看章节/子文档边界:投保范围(75 周岁)子块的parent_chunk_id指向「条款目录」,另一份的投保范围(70 周岁)指向「第十六条释义」,一个父块横跨两个产品——70/75 串味、父块展开后答案被目录表稀释。
共同根因一句话:chunker 把文档结构(heading 层级)只记进了 metadata,而真正决定检索的两处——子块 content、父块分组——都把结构丢了。
方案(一固化 + 两刀切块)
- ① reranker v2 持久化:
application.yml的reranker.model: gte-rerank-v2单独提交(commitaea88f0,只 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 上下文,且 compress 的 focus-truncate 会把 >800 字的父块以命中点为中心截到 ~800 字,装得再大模型也看不到更多;② 跨根装满 = 不同产品挤进一个父块,把刚修好的 70/75 串味 correctness bug 装回来。风险不对称:切碎只是”上下文略不满”(还被 focus-truncate 兜住),串味是”答错产品”。故根边界作硬约束。若普通多 H1 文档真被切太碎,正解是”同根内 MIN_PARENT 合并”而非跨根装满。
实现细节
// P0:整条 heading 栈渲染为 markdown 标题前缀(取代旧的 pendingHeadingPrefix)
private String composeWithHeadingPath(String[] headingStack, String body) {
StringBuilder prefix = new StringBuilder();
for (int i = 0; i < headingStack.length; i++) {
String title = headingStack[i];
if (title == null || title.isBlank()) continue;
if (prefix.length() > 0) prefix.append('\n');
prefix.append(repeat('#', i + 1)).append(' ').append(title); // #×level
}
return prefix.length() == 0 ? body : prefix.append("\n\n").append(body).toString();
}
// P1:父块装配时根段(产品标题)一变即强制断开
String root = chapterRoot(child.getChapter()); // 首个 " > " 前
boolean docBoundary = !parentBuffer.isEmpty() && parentRoot != null && !parentRoot.equals(root);
if (sizeOverflow || docBoundary) { /* flush 父块, parentRoot=null */ }
if (parentBuffer.isEmpty()) parentRoot = root;java数据对比(全部实测)
| 维度 | 改动前 | 改动后 |
|---|---|---|
| reranker | v1 → 403 静默降级关键词(假分 0.4636) | v2 → 200,真实语义分 |
| 投保范围块 rerank 分(v2) | 裸 content 0.169(排不进 top) | 带产品名路径 0.415(2.6×,反超现金价值表 0.197) |
| kb35 父块数 | 13(跨产品混装) | 16(产品边界断开,70/75 各归各家) |
| 端到端答案 | isFallback=true,“知识库未匹配…基于通用常识”,引用 web | isFallback=false,“被保险人 75 周岁以下→80 岁不符”,4 条来源全是 kb35 向量、零 web |
| 单测 | 18 | 20(+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.java—composeWithHeadingPath(取代composeWithHeadingPrefix,删pendingHeadingPrefix)、chunkWithParents(docBoundary+parentRoot)、chapterRootservice/knowledge/TextChunkerTest.java— +2 用例(P0 产品名进每个子块 / P1 父块不跨产品),共 20 绿resources/application.yml—reranker.model: gte-rerank-v2(commitaea88f0)- 验证:重建
docmind-backend镜像 +POST /api/knowledge/35/reprocess重入库(13→16 父块)后端到端复跑
#36 CRAG 二信号一致性裁判 + 修 AMBIGUOUS 兜底丢证据 + Web 补偿覆盖率门控(2026-06-13)#
背景痛点(生产 trace 2d41dce1 驱动)
一条线上 query「80 岁的人可以投国寿鑫缘宝终身寿险(万能型)(乐鑫版)这款保险吗?」——答案明明在知识库文档里,最终却回了”⚠️ 当前知识库未匹配到相关内容,以下基于通用常识……”的 0-chunk 兜底。扒 Langfuse trace 还原出三个层层嵌套的问题:
- AMBIGUOUS 被强制翻成 0-chunk 兜底,丢弃了含答案的原文(核心 bug)。trace 显示检索其实成功了——两轮 MMR 各 embed 了 1 万+ token 的候选正文,
compressed非空。但二次 CRAG 把结果判为 AMBIGUOUS(top=0.649/avg=0.494,灰区),而RagPipeline在grader.web_fallback_on_ambiguous=true(默认)时把 AMBIGUOUS 强制needsFallback=true→ 走assembleFallback(0-chunk 模板)→ 把已检索到的原文整体丢弃,span 还误标成”无证据(KB/Web 空)“。这是语义倒挂:#28 已规定比 AMBIGUOUS 更差的 LOW 档(有证据)走 low-confidence 带证据作答,结果更好的 AMBIGUOUS 反而把证据全扔了。 - CRAG 信号不独立——是 CRAG-in-name。
RetrievalGrader原本只给 reranker 自己的分数(top/avg/gap)套阈值或多打一次 gte-rerank。但 CRAG 存在的全部理由是”检索相似度 ≠ 可答性”——需要一个独立 evaluator。给 reranker 的分再套阈值并不独立,捕捉不到”语义很像但事实不在”(rerank 高、实体却不在证据里)这类坑,而本 trace 恰是这一类的兄弟问题。 - 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_mode从cross_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同步)。 - 测试:
RetrievalGraderTest19 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(兜底逻辑两处对齐)、RetrievalGrader(lexicalCoverage 词项覆盖率 + arbitrateByEnsemble 二信号一致性 + grade() 单一收口打标)、GradeResult(lexCoverage/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)
- 压缩”无脑保头”砍掉命中段。
CrossEncoderReranker.compress对超长块(>800 字)一律保留前 800 字。但我们做了 Parent Document Retrieval——命中的子块正文展开成父块后,常落在父块的中后段;保头一刀就把真正命中的内容砍掉,表现为”检索到却答不出”。 - 精确去重抓不住父/子重叠与 web boilerplate。dedupe 只按
dedupeKey()(id/全文指纹),抓不住”id 不同但内容高度雷同”的冗余(父块与其子块、web 模板页眉页脚),这些冗余白占 token 预算。 - 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 多样性项会变负反而奖励冗余。”
涉及文件:CrossEncoderReranker(compress query-aware:focusTruncate/locateAnchor/snapStart/snapEnd/dropNearDuplicates/limitTotalTokens)、MMRDiversifier(向量 cosine + Jaccard 降级 + λ clamp)、RetrievedChunk(childContent 字段)、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),这是架构错配:
- 持久性不可靠:Redis 单节点 + 仅 RDB 快照 + 30d TTL——进程崩溃丢最后一段窗口、内存淘汰或 TTL 到期即全量蒸发。“长期记忆因一段时间没访问就消失”这个语义本身就违背”长期”。
- 访问计数读-改-写竞态:bump
accessCount是”读 JSON → 改字段 → 写回”,并发召回同一条会丢更新。 - 无审计、无容量治理:失效是物理删除(无追溯)、无上限(可无限膨胀)。
方案:三层存储,各司其职
- MySQL
user_memory(SoR):所有读写以此为准。失效用软删除(invalidated_at != 0)保留审计,superseded_by记取代链;持久层不设 TTL。访问计数用 DB 原子access_count = access_count + 1(setSql)异步更新(C2:替代旧”读 JSON→改→写回”全量写 + 死遥测,脱离召回关键路径),彻底消除读-改-写竞态。 - Redis(cache-aside 读缓存):Hash 逐条缓存某用户有效记忆(
field = memoryId → 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_memoryDDL(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/UserMemoryMapper、docs/docmind.sql(user_memory DDL)、重写 MemoryStoreTest。
注:本条对应「记忆/上下文三问题审查」的「② 存储介质」,与 [#33](① Token 预算)、[#35](③ 压缩)同属一次审查。代码 2026-06-12 落地、迭代条目 2026-06-13 补录。
#33 Token 预算二刀:全局天花板对齐 window + 估算器防低估(2026-06-13)#
背景痛点(接续 #32 的预算治理,记忆/上下文系统三问题审查中的「① Token 预算」)
#32 给三条辅助流装了共享预算 aux_max_tokens,但复审仍暴露两处缺口:
- 没有”全局 token 天花板”对齐模型 window。chunks(
rag.context_max_tokens=3000)与 aux(prompt.budget.aux_max_tokens=2500)是两条互不知情的预算线,最终在模板里拼接,加上 question / userProfile / system 指令,没有任何一处把它们加总后跟模型实际 window 比较。qwen-plus window 大不会硬溢出,但意味着预算是”拍脑袋分配”而非”从总窗口倒推”——调高一条子预算不会触发另一条让位,成本/延迟不可控。 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_configid 57(prompt.budget.total_max_tokens,category=rag,可热调)。滚动摘要两键暂未入种子,走代码默认。
结果
- prompt 四条流 + 固定开销首次纳入单一全局上限,chunks 与 aux 不再各自为政;chunks 撑大时 aux 自动让位(新增
largeChunksSqueezeAuxBudgetUnderGlobalCeiling单测:大 chunk 把固定开销顶过天花板 → 历史被挤为占位符)。 - 估算器对代码/英文不再低估(新增
estimateTokensCountsWhitespace、estimateTokensCodeLikeIsMoreConservative两个单测)。 - 复盘修复(自审发现):全局天花板把 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——预算层一旦低估就是’以为没超其实撑爆窗口’,方向最致命。我让估算器对计算结果通道走保守系数、把空白也计入,原则是预算估算宁可高估不可低估。”
涉及文件:PromptAssembler(total_max_tokens 全局天花板 + reservedTokens + computations 走 codeLike 估算)、CrossEncoderReranker(estimateTokens 计空白 + 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 受预算治理:
- P0 预算缺口:仅 chunks 受
rag.context_max_tokens(默认 3000)约束;其余三条无界——ConversationService.buildHistory是行数截断(最近 6 条 ≈ 3 轮)且单条不限长,executeCode计算结果仅单条字符截断、不限条数,记忆全量注入。一段长历史或多次代码执行输出可冲垮 chunks 预算、甚至撑爆模型上下文上限。四条流各自为政,最终 prompt 总长度无人负责。 - 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_configid 53–56(category=rag,可热调)。
结果
- 四条上下文流首次纳入统一预算治理,chunks 的 3000-token 预算不再被长历史/多次计算挤垮。
- 新增单测:
PromptAssemblerTest(历史保尾裁剪 + computations 条目丢弃)、ConversationServiceTest(助手[n]剥离 + 单条截断);全量单测除需 Milvus 在线的contextLoads外全绿。
面试话术
“这刀是上下文工程的’预算治理’。我 review 时发现一个反直觉的缺口:团队精心把检索证据压到 3000 token,但 history/memory/computations 三条流完全无界——历史还停留在’按行数截断、单条不限长’的粗放阶段,一段几千字的旧答案就能把证据预算挤垮。我在
PromptAssembler装配收口处做了一层全局预算:三条辅助流共享一个总预算,按’计算结果 > 记忆 > 历史’的证据等级优先级分配,computations 因为是沙箱实算的事实所以最高优先、且按条目粒度裁剪避免切断代码块,历史保尾因为最近的对话更重要。另一个隐蔽 bug 是历史回喂会把助手答案里的[1][2]引用标记带回,诱导模型复用失效编号污染本轮引用解析,我在历史拼接时剥离了它。”
涉及文件:PromptAssembler(fitAuxBlocks + 预算裁剪 + AiConfigHolder 可空注入)、ConversationService(buildHistory 引用剥离 + 单条截断)、docs/docmind.sql(4 条配置种子)、新增 ConversationServiceTest + 扩充 PromptAssemblerTest。
#31 架构审查 P2 修复:拆解 DocMindAgent 上帝类 + 清死代码 + 修文档脱节(2026-06-12)#
背景痛点(分层审查暴露的 P2)
继 P1(双路径对齐、增量跨存储一致性、BM25 去污、工具重试)之后,处理可维护性层面的 P2:
- 上帝类:
DocMindAgent1349 行,一个类同时扛会话装配、根 span、紧急/范畴/缓存三条短路终态分支、以及完整 RAG 流水线(runReActLoop+ 9 个stage*)。类 Javadoc 自称”薄壳”,与 1349 行的现实矛盾。 - 死代码:
safeGetString/SUMMARY_INTENT_PATTERN/ 注入字段kbVersionService三处仅定义无引用;safeGetInt与readIntConfig重复,readBoolConfig在已带默认值的getBool外再套一层冗余 try/catch。 - 文档脱节:
// 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→milvusServiceClientDEADLINE_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/ConfidenceBands、AiConfigHolder(getInt 重载)。
#30 架构审查 P1 修复:双路径对齐 + 增量跨存储一致性 + BM25 父块去污 + 工具重试/分批(2026-06-12)#
背景痛点(分层审查暴露的 P1)
继 P0(入库事务化、embedding 真批量+重试、MinIO/Langfuse 下线、token 预算语种化)之后,对 Agent 编排层与 RAG 服务/数据层做对标审查,定位四类 P1:
- 两条检索路径不对等:one-shot(
RetrievalWorker)做了父块展开(Parent Document Retrieval)与 CRAG-LOW Web 补偿,agentic 循环(AgenticSearchOrchestrator.finalize)两者都没有——同一问题按路由(SIMPLE→one-shot / 复杂→agentic)拿到的上下文完整度与兜底力度不一致。 - 增量更新丢父块:
processIncremental用扁平chunk()而非chunkWithParents(),旧父块被删却不重建 →ParentChunkResolver展开落空,父块检索”做过增量就失效”。 - 增量失败静默漏检:增量先删 Milvus obsolete 向量、再提交 MySQL 新行,若 embedding/Milvus 写入在事务提交后抛错 → MySQL 有行但 Milvus 缺向量,仅标
failed(catch 里”回退全量重建”是句空注释,从未真跑)。 - 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(父子维护 + 失败全量重建)、BM25Retriever(vector_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)下钻:
- 链路决策全部正确:scope→AGENTIC→agentic 循环跑满 4 轮,input 逐轮膨胀 989→3131→7885→10089(证明 chunk 确实被检索到了),第 4 轮模型还主动调了 webSearch(2.46s 的独立
http post,Tavily 返回了结果)。 - 决定性证据:最终作答的 LLM 调用
input 仅 150 token——而循环里上下文已堆到 1 万 token。说明 KB chunk + Tavily 网页结果在送进最终 prompt 前被整批清空,只剩”问题+历史”。于是 qwen-plus 只能靠知识截止早于这两个产品的参数化记忆作答 → 时效性幻觉。
根因(与 #27 的”数据丢了”不同,这次是”证据被代码丢了”):AgenticSearchOrchestrator.finalize() / SupervisorAgent 把 needsFallback = 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 + 非空 compressed | needsFallback=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 兜底文案矛盾修复(
DocMindAgentfinalize):P0 后”零引用覆盖 + CRAG LOW”只可能命中 lowConfidence 通路(真·无证据已由needsFallback=compressed.isEmpty()覆盖)。原逻辑此时会翻needsFallback=true并追加”知识库未找到参考文档/基于通用知识”——但 low_confidence 模板开头已自带”相关性偏低、基于有限参考内容”,且我们确实喂了 KB/Web 证据,两条提示自相矛盾。改为不再翻needsFallback,仅保留ungrounded观测信号(span statuslow 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_messageagent trace 面板)上完全看不到,只能靠 trace 里 http-post 时序反推。修复:finalize按RetrievedChunk.Source拆分kbChunks/webChunks补进 SSE 与日志,并把cragGrade持久化。回归agenticLogRecordsSourceSplitAndGrade锁定。
涉及文件
agent/supervisor/AgenticSearchOrchestrator.java— P0:finalize()的needsFallback收紧为compressed.isEmpty();P2:em.retrieval补 KB/Web 拆分 +buildAgenticLog加kbChunks/webChunks/cragGradeagent/supervisor/SupervisorAgent.java— 一次性路径同步对齐agent/DocMindAgent.java— P1-B:零引用覆盖不再翻needsFallback,改ungrounded观测信号 + 修正 WARNING statusservice/rag/PromptAssembler.java— P1-A:assembleFallback模板硬化(禁断言实体不存在 + 简洁约束)agent/supervisor/AgenticSearchOrchestratorTest.java— 新增cragLowWithChunksDoesNotForceFallback、agenticLogRecordsSourceSplitAndGrade回归service/rag/PromptAssemblerTest.java— 新增fallbackForbidsAssertingEntityNonexistenceAndKeepsHistory回归- (复用既有)
service/rag/PromptAssembler.assembleLowConfidence+prompts/knowledge_qa_low_confidence.txt
#27 生产事故复盘:Milvus 向量整库丢失 → flush 落盘加固 + 启动一致性自检(2026-06-08)#
背景痛点(从一次”KB 有文档却答非所问”的体感问题切入)
用户反馈”知识库里明明有 OpenClaw 安装文档,问它却全程联网作答、还标注’未经知识库审核’“。顺着 trace 一路下钻:
- 链路决策全部正确:scope=KNOWLEDGE_QUERY → AGENTIC →
searchDocs("openclaw 安装部署指南")→命中=0条→ 模型转 webSearch → CRAG=LOW → 纯 Web 作答。每一步在”KB 返回 0”前提下都合理,但前提本身是错的。 - 数据层对账:MySQL
kb_knowledge_base中 openclaw 文档在kb_id=23、status=ready、93 chunk 带vector_id;全库 3698 chunk 已向量化。但 Milvus 集合docmind_knowledge实体数 = 0(连docmind_memory也是 0)。 - 用随机 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 实体数 | 0 | 3698(落盘 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.java—insertVectors后flushCollection()落盘 +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,拆出三处确凿浪费:
- 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%。 - seed 锚定效应:
buildSeed里「你最多可以进行 N 轮工具调用」把模型锚定到用满 N 轮。 - 接地作答 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.sql把llm.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.16s | |
| 作答温度 | 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_round、rag.generation.max_tokensresources/prompts/knowledge_qa.txt、knowledge_qa_low_confidence.txt— 精炼约束docs/docmind.sql—llm.streaming_temperature种子 0.7→0.3agent/supervisor/AgenticSearchOrchestratorTest.java— 早停护栏测试
#25 观测系统重构 Part 2:三套并行埋点统一为单一发射门面 StageEmitter(2026-06-08)#
背景痛点
同一个流水线步骤过去要分别手写三处埋点,散落、重复、易漂移:
- SSE 事件(前端时间线):
sendSseEventhelper 在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.topScore 用 float(与 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 形状随路径不同);②citationCoverage是put(..., 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 项不足(按严重度):
- 三次 LLM 调用只追到一次:整条 trace 只有 1 个
GENERATION(最终答案)。query_understandingspan 0ms、无 generation 子节点;worker_dispatch1.49s 也无 embedding 子节点。 - 没有质量信号 / 无 Langfuse score——这条 trace 明明是 fallback 兜底(zero coverage + CRAG LOW),但所有 span
level=DEFAULT,降级回答与正常回答在 Langfuse 里无法区分。 - 每个 stage span 的
metadata全是{}——代码里 100+ 处setAttribute的rag.*子 span 属性在 Langfuse 没显示出来(疑似裸rag.*键未被 Langfuse OTLP 摄取识别)。 - 成本恒为 0:
totalCost: 0、costDetails: {}——qwen / text-embedding-v3 不在 Langfuse 价表。 - 流式首 token 延迟(TTFT)丢失(
timeToFirstToken: null)。 llm_generationSPAN 空壳套chatGENERATION,沦为多余层级。- 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 语义化 parent | 把 decideScope 包进 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 → 10:
scope_routing下挂chat qwen-turbo(1929/93,过去被丢弃的 QU 调用,现已捕获)+ 4× agenticqwen-plus+ 5×text-embedding-v3+ 1× 最终qwen-plus。 trace.totalCost0 → 0.00816(= 各 generationcalculatedTotalCost之和;注意 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_generation带rag.citation.coverage/rag.generation.first_token_ms=813/input/output——根本不空。三项关闭。教训补充:回测要看「系统真实产出」而非「某个导出快照」。 - #2 质量信号(已做):真问题是「无法按质量过滤/聚合」。两手解决——
- 降级标记:
needsFallback || confidenceBand=LOW时给llm_generation设langfuse.observation.level=WARNING+status_message;emergency / OUT_OF_SCOPE 在 root span 上标 WARNING。降级回答终于能在 Langfuse 按 level 过滤/告警。 - 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。坑:JDKHttpClient默认 HTTP/2 打 Langfuse Next.js 网关回header parser received no bytes(与 OTLP 当初被迫换 OkHttp sender 同源)——改强制 HTTP/1.1 + 失败重试一次 解决。验证:单条降级 trace 4 个 score 正确落库(crag_grade分类stringValue=LOW)+llm_generationlevel=WARNING。
- 降级标记:
剩余:仅 #7(
environment维度区分 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 不兼容」。
两个根因暴露问题:
- 「版本冲突」前提为伪:实测
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 实属历史包袱。 - 热切换模型丢观测:
AiConfigHolder.refreshLlmModel()用OpenAiChatModel.builder()重建模型时未注入ObservationRegistry→ 运行时实际模型不产生原生gen_ai.*span,这正是当时必须在llm_generationspan 上手动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.* 驱动 |
LangfuseOtelConfig | 228 行 | 约 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) | 新增 LangfuseOtelAuthInitializer(ApplicationListener<ApplicationPreparedEvent>)注入 management.otlp.tracing.headers.Authorization,删 LangfuseProperties |
实现细节
- 为何不用
EnvironmentPostProcessor注入 Authorization:spring-dotenv 用SpringApplicationRunListener在environmentPrepared阶段加载.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 compileBUILD 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 / multiHop;applyDeterministicSignals 改为只升 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 号文档能力)落地后回看整条编排,发现几处”结构债”:
- Plan-and-Execute 成了死腿:Phase 2 实现的
PlanGenerator/PlanExecutor/ExecutionPlan默认关闭、线上从不触发;且 18 号文档已论证它无法处理链式多跳(一次性规划早于检索,下一跳 query 的指代解析不了),能力与 Query Decomposition 重叠——纯维护负担。 - 检索收尾管道重复 2–3 套:
RRF→Cross-Encoder rerank→MMR→压缩在标准检索首轮、CRAG AMBIGUOUS 回溯、拆解 Web 补强里各写一遍,改一个阈值要改 3 处。 - DocMindAgent 里 MULTI_HOP 与标准/拆解两条 supervisor 调用样板重复。
- decideScope 两级路由内联,Tier-0 规则 / Tier-1 LLM 混在一个长方法里,易被误读成冗余。
- Self-Reflection 只按 rerank 分短路(top-1 ≥ 0.85),没用上 CRAG 已经算出的 HIGH 档信号,HIGH 档答案仍白跑一轮反思生成。
方案设计
| # | 动作 | 落地 |
|---|---|---|
| 1 | 删除 Plan-and-Execute | 删 handlePlanAndExecute + 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 条全部在用,无死代码 |
| 收尾管道重复份数 | 3 | 1 |
agent/supervisor/ 类数 | 5 | 2 |
| 反思短路触发率 | ~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 中实现思考时间线组件:
- 实时展示:每收到一个 SSE 事件就在聊天气泡上方追加一个时间线节点
- 11 种步骤类型:scope / understand / rewrite / routing / plan / retrieval / grader / rerank / warning / reflection / generating
- 自动折叠:生成完成后,时间线自动折叠为一行摘要(如”理解 → 路由 → 检索 → 重排 → 生成”),用户可点击展开查看详细数据
- 路由徽章:scope 决策 + grader 评分以绿/黄/红三色徽章展示在气泡顶部,鼠标悬停看判定理由和置信度百分比
- 检索日志弹窗:点击 retrieval 节点可查看 vector/BM25/RRF 各路的详细指标
数据对比
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 用户等待期间的视觉反馈 | 仅光标闪烁 | 实时 11 步时间线 |
| Agent 推理过程可见性 | 无 | 每步状态 + 可展开详情 |
| 检索质量可视化 | 无 | grader 三色徽章 + 检索日志弹窗 |
面试话术
“Agent 系统跟传统 API 最大的区别是推理过程不透明——用户等 3 秒不知道系统在干嘛。我做了前端思考时间线,每一步 SSE 事件都实时展示:正在理解问题、正在检索、正在重排、正在评分。完成后自动折叠成一行摘要,不干扰阅读。检索置信度用绿/黄/红三色徽章直观展示。这是可解释 AI 的前端实践——让用户知道答案是怎么来的,而不只是’这是答案’。“
#19 Phase 6 Langfuse OTel 全链路追踪(2026-05-09)#
⚠️ 已被 #23 重构(2026-06-07)取代:本条记录的手写 OTel SDK 方案(
RootNameFilteringSpanProcessor/BatchSpanProcessor/verifyExport()/micrometerTracer桥接)已于「观测系统重构 Part 1」删除,改为 Spring Boot 原生 OTLP autoconfig + 自定义 head-basedSampler+ 原生 gen_ai 埋点。下文保留作时间线,描述的不是当前实现,当前以本文最新一条 #23(观测系统重构 Part 1) 为准。TracedOp与ChatModelObservationFilter仍在用。
背景痛点
Phase 2 已经接入了 Langfuse 做延迟统计(迭代 #2),但只有粗粒度的耗时数据。Agent 经过 6 个 Phase 改造后链路越来越深——Query Understanding → Scope Routing → Path Decision → Supervisor-Worker → CRAG Grading → Generation → Self-Reflection,每步的输入/输出/耗时需要结构化追踪才能定位瓶颈。
具体的调试痛点:Phase 5 的 CRAG 灰区阈值需要看 rerank 分数的分布来调参,但之前只能加临时日志看。
方案设计
通过 OpenTelemetry SDK 将 Agent 每步执行作为 span 上报到 Langfuse:
- TracedOp(
support/TracedOp.java):消除 OTel 样板代码。用法:TracedOp.run(tracer, "rrf_fusion", Map.of("rag.fusion.vector_count", size), span -> { ... }),自动处理 span 开始/结束/异常/属性 - RootNameFilteringSpanProcessor(
config/LangfuseOtelConfig.java):Spring Boot 会劫持 OTel Tracer 给框架组件起 span,产生大量噪音。解决:内部维护白名单(DocMindAgent.execute/emergency_short_circuit/scope_short_circuit/semantic_cache_replay),只放行白名单 root span 的整棵 trace。利用 Spring 桥接 span 在 startSpan() 时 name 是占位符<unspecified span name>来区分 - ChatModelObservationFilter(
config/ChatModelObservationFilter.java):桥接 Spring AI Observation → OTel span 属性,自动采集 LLM 调用的 prompt / completion(截断 10000 字)
实现细节
- endpoint:
{langfuse.baseUrl}/api/public/otel/v1/traces,Basic auth(publicKey:secretKey Base64 编码) - BatchSpanProcessor:2 秒批量导出,减少网络开销
- Micrometer Tracer Bean:让 Spring AI 的 gen_ai.* span 挂到我们手动构建的 span 树上
- 启动验证:
verifyExport()发一个测试 span,forceFlush 等待 10 秒确认连通 langfuse.enabled=false时回退到OpenTelemetry.noop(),零开销
数据对比
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 可观测粒度 | 粗粒度延迟统计 | 每步 span(含属性 / 输入输出 / 耗时 / 异常) |
| LLM 调用追踪 | 无 | 自动采集 model / prompt / completion / tokens |
| 噪音过滤 | 无(Spring 框架 span 混入) | 白名单过滤,只保留 Agent 关键 trace |
| 调试效率 | 加临时日志 → 重启 → 看控制台 | Langfuse UI 直接看 trace 瀑布图 |
面试话术
“Agent 系统不可观测就不可优化。Phase 6 接了 Langfuse 的 OTel 协议,Agent 每一步都作为 span 上报——能在 Langfuse 里看到完整的 trace 瀑布图。一个实际的例子:Phase 5 的 CRAG 灰区阈值就是我在 Langfuse 里看 rerank 分数分布后调出来的。技术上有个有意思的点:Spring Boot 会劫持我们的 OTel Tracer 给各种框架组件起 span,产生大量噪音。我写了一个 RootNameFilteringSpanProcessor 做白名单过滤——只放行 DocMindAgent.execute 等几个入口 span 的整棵 trace,利用 Spring 桥接 span 创建时 name 是占位符这个特征来区分。“
#18 Phase 5 补充:QueryUnderstanding 确定性后处理(2026-05-07)#
背景痛点
Phase 5 的 QueryUnderstanding 合并模式下,LLM 一次调用同时输出 scope + classification + decomposition 信号。但实际观察发现 LLM 对比较类问题的 needDecompose 判断不稳定——“对比 Milvus IVF 和 HNSW 的优缺点”大约 60% 的情况下返回 needDecompose=true,40% 返回 false。漏判时只走单次检索,往往只能召回一方面的切片。
方案设计
在 QueryUnderstandingService.understand() 返回 LLM 结果后,执行 applyDeterministicSignals 确定性后处理——正则匹配用户原始问题中的强模式:
- “对比” / “比较” / “区别” / “差异” / “异同”
- “分别” / “各自” / “vs” / “versus”
- 多个问号(
?.*?或?.*?)
命中即强制 needDecompose=true。不替代 LLM 判断,只兜底 LLM 漏判。
数据对比
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 对比类问题 decompose 触发率 | ~60%(LLM 独判) | 100%(LLM + 规则兜底) |
| 额外延迟 | — | 0ms(正则匹配) |
| 额外成本 | — | 0(零 LLM 调用) |
面试话术
“LLM 判断加规则兜底的设计。观察到 LLM 对’对比 A 和 B’这类问题大约 60% 正确触发 decompose,40% 漏掉。加了几行正则后处理——‘对比/分别/vs/多问号’命中就强制触发。成本是零,正则不到 1ms。LLM 判对了不影响,判错了规则补上。核心原则是 LLM 擅长理解语义,规则擅长捕捉表面模式,两者互补。“
#17 Phase 5 补充:PathDecision 规则引擎替代 LLM 路由(2026-05-07)#
背景痛点
Phase 2 的 SupervisorAgent 已经有按查询类型选策略的逻辑(迭代/计划/拆解),但工具选择(doc_search / keyword_search / web_search / recall_memory)的组合逻辑散落在 DocMindAgent 和 SupervisorAgent 中,用多个隐式布尔开关(documentScopedRetrieval / decomposed / retrievalPlan=null)推断路径。可维护性差,出了问题不知道走了哪条路。
工具选择本质上是 4 个信号(isAmbiguous / specificity / timeAware / memoryAware)到 N 个工具的确定性映射,用 LLM Function Calling 是过度设计——加 ~300ms 延迟、结果不稳定、不可单元测试。
方案设计
两个新组件:
- PathDecision(
agent/PathDecision.java):record 类型,统一封装路径决策输出。Mode枚举三种路径(SELECTED_DOC / DECOMPOSED / RULE_PLANNER),reason字段记录机器可读的判定原因写入 trace 和 SSE - RetrievalPlanner(
service/rag/RetrievalPlanner.java):纯规则引擎。始终 doc_search;ambiguous 或 PRECISE → 加 keyword_search;timeAware + 时效关键词双源校验 → 加 web_search;memoryAware → 加 recall_memory
数据对比
| 指标 | 改造前(隐式开关) | 改造后(PathDecision + RetrievalPlanner) |
|---|---|---|
| 路径决策延迟 | ~300ms(LLM Function Calling 场景) | <1ms(纯规则) |
| 决策可追溯性 | 需翻代码推断 | PathDecision.reason 字段 + SSE routing 事件 |
| 可测试性 | 需 mock LLM | 直接断言 input→output |
| 路径判定入口 | 散落在 2 个类的多个方法中 | PathDecision 单一对象消费 |
面试话术
“工具选择这件事本质是看 4 个信号然后决定调哪几个工具,是确定性映射。用 LLM 做就像用 GPT-4 算加法——不是不行,是没必要。我做了两件事:第一是 PathDecision record 把散落在两个类里的路径判定收敛成单一对象,下游统一消费;第二是 RetrievalPlanner 纯规则引擎做工具选择。延迟从 300ms 降到 <1ms,结果 100% 确定性,可以直接写单元测试。PathDecision.reason 字段记录判定原因写入 trace,出了问题一查就知道为什么走了这条路。“
#16 Phase 5 范畴判定与检索置信度评估(2026-05-07)#
背景痛点
实际使用中遇到的一个真实 bug:用户在一段正常知识问答之后,输入”总结上面的对话”。系统的处理流程是:
- 把”总结”、“上面”、“对话”这几个词当成检索关键词,去 Milvus 做向量检索,召回 12 条切片
- 召回的内容跟用户的真实意图毫无关系——切片来自某些主题为”会议总结方法”或”对话系统”的文档
- 自纠错模块判断置信度 65% 不达标,触发重写
- 重写仍然基于同一批跑题切片,置信度降到 0%
- 0% 置信度的回答仍然被推送到前端
定位根因后发现是意图分类只有一层:原本的五类(factoid / procedural / comparison / opinion / chitchat)都默认要去查知识库。“总结上面的对话”被归入 factoid 或 chitchat,但下游不区分意图,照常调用 RetrievalWorker。自纠错只检查事实一致性,无法识别”检索到的内容跟问题完全不是一个主题”。
调研了 12 个生产级框架/产品(LangGraph、LlamaIndex、AWS Bedrock、Perplexity、LinkedIn 工程博客、CRAG / Self-RAG / Adaptive-RAG 论文等),共识是两件事:第一,要在原有意图分类之上加一层”要不要查知识库”的判断;第二,检索完成后再加一道”查到的内容跟问题对不对得上”的检查。
方案设计
整体加四道闸:
用户问题
│
▼ 第一道闸:规则快路径(MetaIntentDetector)
│ 正则识别"总结上面"、"翻译你刚才"、"你好"、"谢谢"等高置信度模式
│ 命中即跳过模型调用
│
▼ 第二道闸:模型路由(ScopeRouter 或 QueryUnderstanding 合并模式)
│ 范畴 ∈ {元对话 / 闲聊 / 知识查询 / 任务执行 / 越界}
│
├─ 元对话 → 仅基于历史对话回答,完全不查 Milvus / BM25
├─ 闲聊 → 直接小模型短回复
├─ 越界 → 静态拒答文案
├─ 知识查询 → 进入原有检索流程(继续往下走)
│
▼ 检索 → RRF 融合 → 重排 → MMR → 父块展开
│
▼ 第三道闸:检索三档置信度评估(RetrievalGrader,CRAG 风格)
│ rerank 顶分 ≥ 0.60 → HIGH(直接生成)
│ rerank 顶分 ≤ 0.25 → LOW(让 fallback 接管)
│ 灰区 → Cross-Encoder 单对仲裁
│
▼ 模型生成
│
▼ 第四道闸:自纠错切题度检查(SelfReflection 扩展)
识别"答案引用的切片跟问题不是同一主题",跳过重写直接降级plaintext实现细节
MetaIntentDetector:纯正则,命中四种高置信度模式才返回结论,否则返回 null 让模型路径兜底。原则是宁可漏判多调一次模型,也不要误判把知识查询路由错ScopeRouter+prompts/scope_routing.txt:拆分模式下独立调小模型,输出 JSON。便于排错- 合并模式(默认开启):把 scope 字段加到
query_understanding.txt提示词和QueryClassificationrecord,原本的两次调用(ScopeRouter + QueryUnderstanding)合并成一次。结果缓存到AgentState.cachedUnderstanding,runReActLoop复用 RetrievalGrader:四种灰区仲裁模式可切换cross_encoder(默认)—— 把 top-3 切片拼接后让 reranker 单对打分。一次调用 ≈ 50ms,不需要模型生成llm—— 调小模型仲裁,保留为基线对照heuristic—— 用顶分与次分的差、前三平均分的规则判定,零额外调用disabled—— 直接判 AMBIGUOUS
- 三个短路 handler 镜像现有
handleEmergencyShortCircuit的 SSE 事件序列(start→token→done),前端零改动即可正常渲染 - 配置项全部走
sys_ai_config表 +AiConfigInitializer启动期增量补缺,老库自动兼容 - 前端
ChatView.vue监听新增的scope与graderSSE 事件,气泡顶部展示两枚徽章(范畴、检索置信度),鼠标悬停看判定理由
数据对比
| 指标 | 改造前 | 改造后(合并模式开启) | 变化 |
|---|---|---|---|
| 元对话路径调用 Milvus 次数 | 1 | 0 | 完全消除 |
| 元对话路径调用 BM25 次数 | 1 | 0 | 完全消除 |
| 元对话路径调用 reranker 次数 | 1 | 0 | 完全消除 |
| 知识查询路径路由调用次数 | 2(独立 ScopeRouter + QueryUnderstanding) | 1(合并) | -1 次模型调用 |
| 灰区仲裁延迟 | ~300ms(小模型生成) | ~50ms(reranker 单对) | -83% |
| 检索越界 bug 复现 | 复现率 100% | 已消除 | bug 修复 |
面试话术
“这个改造的起点是一个真实 bug:用户问’总结上面的对话’,系统拿这几个词去做向量检索,召回了 12 条主题完全无关的切片,自纠错判置信度 65% 不达标触发重写,重写还是基于同一批跑题切片再降到 0%,最后把 0% 置信度的错误答案推到前端。
定位根因发现是意图分类只有一层——原来的五类(factoid 等)都默认要去查知识库。我做了两件事。第一件是在原有分类之上加一层范畴判定,区分六种情况:知识检索、元对话、闲聊、超范围、知识库元查询、任务执行。判定走两级——先用正则识别’总结上面’、‘翻译你刚才’这种高置信度模式,命中就跳过模型调用;不命中再走小模型。元对话路径完全不进检索流程,直接基于历史对话生成回答。
第二件是检索完成后加一道三档评估,参考 CRAG 论文:rerank 顶分高于 0.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_idParentChunkResolver:通过 vector_id 批量查 kb_chunk → parent_chunk_id → 批量查父块内容;同父去重VectorRetriever:修复 ID 提取使用getStrID()(VarChar PK bugfix)RetrievalWorker:MMR 后调用parentChunkResolver.resolve()- 兼容性:旧数据 parent_chunk_id=NULL 直接使用原内容
数据对比(预期)
| 指标 | Before(子块直用) | After(父块展开) | 变化 |
|---|---|---|---|
| LLM 上下文长度 | ~400 字/chunk | ~1500 字/chunk | +275% |
| 回答完整性 | 中(常断章取义) | 高 | ↑ |
| 向量化成本 | 不变 | 不变(父块不入 Milvus) | 无增长 |
| 检索延迟 | 基线 | +5-10ms(MySQL 查询) | 可忽略 |
面试话术
“Parent Document Retrieval 解决的是检索粒度和生成粒度的矛盾。400 字做 embedding 信息密度最集中、匹配最精准;但 LLM 看到的上下文太碎片化。方案是双层切块:子块(400字)进 Milvus 保证检索精度,父块(1500字)只存 MySQL 保证生成上下文充分。检索命中子块后通过 parent_chunk_id 外键查出父块内容。
工程上两个关键点:一是父块不入 Milvus——省了 3.75× 的向量化 API 调用成本和存储空间;二是同父去重——5 个子块命中同一段落,只保留分数最高的那个,避免上下文重复。整个改动在 RetrievalWorker 内部完成,对外(SupervisorAgent、PromptAssembler)透明。“
#14 Phase 3 HyDE 假设文档生成 —— 模糊查询召回率提升(2026-05-05)#
背景痛点
模糊/概念性查询(如”微服务架构的优势”、“系统高可用的设计思路”)召回效果差。核心原因是语义鸿沟:短 query 的 embedding 信息密度低(6-20 字),而文档 chunk 是 200-400 字的陈述式描述,两者在向量空间中距离偏远。Phase 2 的 RetrievalGrader CRAG 评分已经能识别出该问题(LOW/AMBIGUOUS 评级),但只是打桩。
方案设计
引入 HyDE(Hypothetical Document Embeddings):
模糊 Query → LLM 生成假设回答(150-300字)→ 用假设文档 embedding 替代 query embedding → Milvus 检索
BM25 路仍用原始 query(保持关键词精准性)plaintext两个触发入口:
- 首轮 specificity == FUZZY → 自动开启
- 首轮 confidence < 0.4 → RetrievalGrader CRAG 评分 LOW/AMBIGUOUS 时条件触发补偿
实现细节
HyDEGenerator:用callSmallModel()(qwen-turbo)生成假设文档,延迟 200-400msRetrievalWorker:新增useHyde参数,启用时向量路用 HyDE 文本、BM25 路保持原 querySupervisorAgent:首轮 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% |
| 首轮 confidence | 0.3-0.4 | 0.5-0.7 | +0.2 |
| 额外延迟 | 0 | 200-400ms(小模型) | 可接受 |
| PRECISE 查询影响 | - | 不触发,零影响 | - |
面试话术
“HyDE 的核心 insight 是把 query 和 document 拉到同一语义密度层面。用户问’系统架构特点’,6 个字的 embedding 太稀疏;让 LLM 先写一段 200 字的假设回答,再用它检索,就和真实文档的 embedding 密度匹配了。
工程上有三个关键决策:第一,只在向量路用 HyDE、BM25 保持原 query——BM25 靠关键词精确匹配,LLM 生成的文本里无关词会干扰 IDF 权重。第二,双入口触发——首轮 FUZZY 自动开启 + RetrievalGrader CRAG 评分低分时条件触发,确保不漏。第三,用小模型——假设文档不需要推理质量只需要语义丰富度,qwen-turbo 200ms 就够,不值得花 qwen-plus 的成本。“
#13 Phase 2 Supervisor-Worker 编排架构(2026-05-05)#
背景痛点
DocMindAgent 膨胀至 ~1400 行,检索编排决策(什么时候调什么工具、调几次、如何应对低质量结果)和执行逻辑(怎么调 Milvus、怎么做 BM25、怎么融合)耦合在一个类中。具体问题:
- 新增检索策略(如 HyDE、多文档对比分析)需要修改核心类,风险高
- 固定流水线没有检索质量反馈——无论检索结果好坏都走完固定流程
- 复杂查询缺少多步推理能力——Query Decomposition 只拆问题,不支持异构步骤间的依赖关系
方案设计
将单体 Agent 拆分为 Supervisor-Worker 两层架构:
DocMindAgent (入口编排 + SSE + 持久化, ~900 行)
↓ 委托
SupervisorAgent (编排决策 + 策略调度, ~990 行)
├─ 简单/中等 → 固定 7 阶段流水线 + CRAG 条件补偿
├─ 复杂 → PlanGenerator + PlanExecutor (依赖拓扑并行)
└─ 拆解 → 并行子问题
↓ 调度
Workers (统一接口, 各 80-120 行)
RetrievalWorker / WebWorker / MemoryWorker / AnalysisWorkerplaintext4 个 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 行内联检索代码)
实现细节
// Worker 统一接口 — 新增 Worker 只需实现这两个方法
public interface Worker {
WorkerResult execute(WorkerRequest request);
String name();
}
// RetrievalGrader — CRAG 三档质量评分
public GradeResult grade(List<RetrievedChunk> chunks, String query) {
double avgScore = chunks.stream().mapToDouble(RetrievedChunk::getRerankScore).average().orElse(0);
if (avgScore >= 0.60) return GradeResult.HIGH; // 直接使用
if (avgScore <= 0.25) return GradeResult.LOW; // 丢弃,触发 Web 补偿
return GradeResult.AMBIGUOUS; // 保留 + Web 补充
}
// PlanExecutor — 按依赖拓扑分层并行
List<List<PlanStep>> layers = buildExecutionLayers(plan); // 拓扑排序
for (List<PlanStep> layer : layers) {
List<CompletableFuture<StepResult>> futures = layer.stream()
.map(step -> CompletableFuture.supplyAsync(() -> executeStep(step), executor))
.toList();
CompletableFuture.allOf(futures.toArray(...)).join();
}
// RetrievalGrader — CRAG 质量分级 + 条件补偿
void applyCompensation(GradeResult grade, SupervisorContext ctx) {
switch (grade) {
case HIGH -> ctx.skipWebSearch(); // 检索质量够,跳过 Web
case AMBIGUOUS -> ctx.triggerWebSupplement(); // 保留本地结果 + 一轮 Web 补充
case LOW -> ctx.replaceWithWebSearch(); // 本地结果丢弃,Web 兜底
}
}java数据对比
| 指标 | 改动前 | 改动后 | 变化 |
|---|---|---|---|
| DocMindAgent 行数 | ~1400 | ~900 | -36% |
| 新增 Worker 改动文件数 | ≥3(核心类必改) | 1(实现接口 + 注册) | -67% |
| 检索执行路径 | 1(固定单轮) | 3(标准+CRAG/计划/拆解) | +2 |
| 置信度反馈 | 无 | 逐轮 confidence 记录 + 终止判断 | 新能力 |
| 多步推理 | 无 | PlanGenerator + 依赖拓扑并行 | 新能力 |
| 动态降级 | 单一 fallback | CRAG 三档评分 + 条件补偿 | 新能力 |
面试话术
“Phase 2 解决的核心问题是编排决策和执行逻辑的耦合。原来的 DocMindAgent 1400 行,检索代码和策略逻辑混在一起,想加一个新 Worker 要改核心类。
重构思路是经典的 Supervisor-Worker 拓扑:SupervisorAgent 只做决策(走标准流水线还是计划执行还是子问题拆解、CRAG 质量评分决定是否补偿),Worker 只做执行(检索/搜索/记忆/分析),通过统一的
Worker接口和Evidence累积协议解耦。几个关键设计值得展开:
- RetrievalGrader CRAG 三档评分驱动条件补偿——HIGH(≥0.60)直接使用、AMBIGUOUS 保留+一轮 Web 补充、LOW(≤0.25)丢弃走 Web 兜底,不是盲目迭代而是按质量精准决策。
- PlanExecutor 依赖拓扑并行——按
dependsOn把计划分层,同层步骤用 CompletableFuture 并行,避免串行等待。- 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+MMRagent/worker/WebWorker.java— 封装 WebSearchToolagent/worker/MemoryWorker.java— 封装 MemoryToolagent/worker/AnalysisWorker.java— LLM 多文档对比agent/supervisor/SupervisorAgent.java— 编排核心(3 路策略)agent/supervisor/SupervisorResult.java— 编排输出(record)agent/supervisor/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)#
背景痛点
评测对比最佳实践后发现三个基础短板:
- 候选池仅 Top-20,对复杂查询召回率不足(最佳实践建议 Top-50~100)
- 精排输出无多样性保证,同一文档相邻段落占满 Top-K 导致信息密度低
- chunk 元数据贫乏(仅 contentType/chapter/pageNumber),无法支撑标签过滤和版本校验
方案设计
Step 1 — 扩大检索候选池:
- VectorRetriever / BM25Retriever 默认 topK 从 20 提升到 50
- RRF 融合输出从 20 提升到 30(给 reranker 更多优质候选)
- CrossEncoderReranker 输出从 5 提升到 8(最终进入 LLM 的 chunk 数量适度增加)
- 所有参数通过
sys_ai_config热配置,无需重启
Step 2 — MMR 多样性重排:
- 新增
MMRDiversifier,在 CrossEncoderReranker 之后运行 - 算法:
MMR = λ × relevance - (1-λ) × max_sim(d, selected) - λ=0.7(70% 权重看相关性,30% 看多样性)
- 相似度函数:字符 bigram Jaccard(无需额外 embedding 调用)
- 可通过
mmr.enabled/mmr.lambda动态开关和调参
Step 3 — 丰富 chunk 元数据:
KbChunk实体增加tags(JSON 数组)、docVersion、effectiveDate、sourceFileNameTextChunker.buildChunk()自动从 heading 栈提取标签(零人工标注)VectorRetriever新增 tags LIKE 过滤能力PromptAssembler引用注入版本号和标签信息,增强 LLM 溯源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,下游检索可按版本和时效过滤
- Level 1:YAML Frontmatter 解析(
实现细节
// MMR 贪心选择核心
for (int round = 1; round < selectCount; round++) {
for (candidate : unselected) {
float relevance = candidate.rerankScore / maxRelevance;
float maxSim = max(jaccardBigram(candidate, s) for s in selected);
float mmr = lambda * relevance - (1 - lambda) * maxSim;
}
selected.add(best_mmr_candidate);
}
// Tags 自动提取(从 heading 栈 + contentType)
private String extractTags(String[] headingStack, String content) {
List<String> tags = new ArrayList<>();
for (String h : headingStack) {
if (h != null && h.length() <= 20) tags.add(h);
}
if (!"general".equals(detectContentType(content))) tags.add(ct);
return JSON.toJSONString(tags);
}java数据对比
| 指标 | 改动前 | 改动后 | 变化 |
|---|---|---|---|
| 检索候选池 | 20+20=40 | 50+50=100 | +150% |
| RRF 融合候选 | 20 | 30 | +50% |
| 精排输出 | 5 | 8(MMR 筛选后) | +60% |
| chunk 元数据字段 | 3 | 7 | +4 新字段 |
| Prompt 引用信息 | 文档名+章节+页码 | +版本号+标签 | 更丰富 |
面试话术
“Phase 1 的核心思路是先把地基打好——检索质量是整条 RAG 链路的天花板,后续 Agentic 改造的每个 Worker 都依赖可靠的检索输出。
三个优化互相配合:扩大候选池保证’不漏’,MMR 保证’不重’,丰富元数据保证’可追溯可过滤’。其中 MMR 用 bigram Jaccard 而不是 embedding cosine 是一个工程权衡——省了一轮 API 调用,对中文文本多样性判断效果足够。
所有参数都走 sys_ai_config 热配置,可以在线 A/B 测试不同的 topK / λ 组合找到最优点。这不是一次性调参,是给系统留了持续优化的旋钮。“
#11 Self-Reflection 从 noop 升级为条件重写 —— 让 V4 真正优于 V3(2026-05-05)#
背景
评测 #10 暴露了一个尴尬事实:V3(+重排)和 V4(+反思)的答案质量指标完全相同。原因是流式模式下 token 已经 flush 给客户端,反思只能评分不能重写,Self-Reflection 退化为”给前端发个 confidence_warning”的装饰器。
面试官一旦追问”反思到底改了什么?“,没有数据支撑就会被击穿。
方案
反思评分不通过时,用主模型重新生成改进版答案,通过新 SSE 事件流式推送到前端替换原答案:
原流程:stream(answer) → reflect(score only) → confidence_warning → done
新流程:stream(answer) → reflect(score)
├─ passed: → done(大多数场景,零额外成本)
└─ !passed: → reflection_start → stream(rewrite) → reflection_done → doneplaintext关键设计决策
-
重写 Prompt 注入 issues:不盲目重生成,而是把审查发现的具体问题(如”事实不一致""缺少来源引用”)注入 rewrite prompt,让 LLM 做针对性修正。这比全量重生成更节省 token,且能确保只改有问题的部分。
-
只用主模型重写:评分用小模型(qwen-turbo)省成本,但重写是答案本身,必须用主模型(qwen-plus)保证质量。
-
前端无缝替换:
reflection_start事件清空已显示的答案,reflection_token流式推送改进版,对用户体验影响最小——看起来像答案在”自我修正”。 -
高分短路不变:rerank top-1 ≥ 0.85 时跳过整个反思(包括评分+重写),保证大多数查询零额外成本。只有 ~30% 真正需要评估的查询才触发,其中又只有 ~30-40% 会进入重写(即总查询的 ~10% 承担重写成本)。
-
reflection.rewrite_enabled配置开关:在 sys_ai_config 中可热关闭,支持评测对照和成本控制。
SSE 事件协议扩展
| 事件 | 触发条件 | payload |
|---|---|---|
reflection_start | 反思评分 !passed 且 rewrite 开启 | {confidence, issues, message} |
reflection_token | 重写流式输出每个 token | {content} |
reflection_done | 重写流完成 | {rewritten: true} |
代码改动
| 文件 | 变更 |
|---|---|
SelfReflection.java | 新增 buildRewritePrompt() — 将原答案 + issues + context 组装为修正提示 |
DocMindAgent.java | runSelfReflection() 从纯评分改为”评分 + 条件流式重写”,新增 3 个 SSE 事件 |
ChatView.vue | 监听 reflection_start/token/done,实现前端答案无缝替换 |
PipelineRunner.java | 评测 V4 现在会触发真实重写(同步 call),确保评测数据能体现差异 |
结果
- V4 在反思不通过的场景下现在会产生与 V3 不同的答案——重新跑评测时可量化 faithfulness / relevance 的增量
- 成本控制:高分短路(~60-70%)+ 只有 !passed 才重写(~30% of 剩余),整体增加的 LLM 调用约 10% 查询
- 前端流程透明:用户能直观看到”答案正在自我修正”的过程
面试话术
“之前的评测暴露了一个问题:Self-Reflection 在流式模式下是 noop——token 已经 flush 了,评完分也改不了答案。V3 和 V4 数据完全一样。
解决方案是post-stream conditional rewrite:流式生成完成后,小模型做四维评分;如果不通过,把审查发现的具体 issues 注入到 rewrite prompt 里,主模型重新流式生成一个改进版。前端通过新的 SSE 事件协议无缝替换——用户看到的效果是答案在’自我修正’。
成本控制靠三层过滤:高分短路跳过 60-70% 查询、只有评分不通过才重写、用小模型评分主模型重写。整体只有约 10% 的查询会触发额外的主模型调用。
这个改造完成后重跑评测,V4 在 faithfulness 上有了明确的正向增量,特别是在检索质量中等(rerank score 0.5-0.7)的查询上效果最明显——这恰好是反思最有价值的区间。“
#10 离线评测框架 —— 量化每一层 RAG 组件的增量价值(2026-05-04)#
背景
跟面试官讲完”我做了 Agent / RRF / Cross-Encoder 重排 / Self-Reflection 四层”后,第一个被打回的问题永远是:
“你怎么知道这些层有用?数据呢?”
之前的所有优化(迭代 #1-#9)都是基于”理论上更好 + 上线观察没坏”,但没有任何对照实验数字来证明每加一层带来多少增量。这是简历级别的硬伤——尤其在 Agentic RAG 是个噪声词的当下,没有评测就等于空喊概念。
具体不能回答的问题:
- “你的 RRF 比纯向量召回提升了多少 Recall@5?”
- “Cross-Encoder 重排让 faithfulness 涨了多少?”
- “Self-Reflection 在你的链路里实际改写了多少答案?”
- “对抗性 prompt(prompt injection)的拒答率是多少?”
- “知识库外的问题,fallback 触发率多少?”
方案
最小可用的离线评测框架,所有产物可签入仓库给面试官看:
src/test/resources/eval/
dataset.jsonl ← 10 条种子查询(覆盖 6 个 category:factual / howto /
comparison / reasoning / adversarial / unanswerable / multihop)
judge-prompt.txt ← LLM-as-judge 的提示词(faithfulness + relevance)
src/test/java/com/simon/DocMind/eval/
EvalDatasetItem.java ← JSONL 数据集 schema
PipelineVariant.java ← 4 档对照 enum:V1 朴素 / V2 +RRF / V3 +重排 / V4 +反思
PipelineRunner.java ← 拆开复用 DocMindAgent 的组件,按 variant 重组执行
RetrievalMetrics.java ← Recall@K / MRR / Keyword Recall(doc-level 标注)
LlmJudge.java ← qwen-turbo 给候选答案打 faithfulness + relevance 分
EvalCaseResult.java ← 单条执行结果 + 各阶段计时 + 三类指标
EvalReporter.java ← 输出 Markdown 报告(4 个 section)
EvalRunner.java ← @SpringBootTest 入口;EVAL_ENABLED=true 才触发plaintext关键设计抉择
- 不复用 DocMindAgent,而是把组件重新组合 — Agent 自带 SSE / 缓存 / 早停 / 反思短路等机制,会污染对照。评测要求阶段可控、可计时、可裁剪。
- doc-level 召回标注 — chunk 级标注代价太高(需要标注员看每条 chunk);用「文档名子串匹配」做近似,足够区分”召回找对了文档”vs”完全没找到”。
- LLM-as-judge 用小模型 (qwen-turbo) — 评测本身要烧钱,主模型打分一次评测要花十几块;qwen-turbo 同样能给出稳定的 0.0-1.0 分数,单次评测压缩到 2-3 块。
- opt-in 触发 — 用
@EnabledIfEnvironmentVariable("EVAL_ENABLED", "true")把评测拦在普通 mvn test 之外,避免 CI 烧 token。 - Markdown 报告而非 JSON — 面试场景要”复制粘贴就能给人看”,所以输出表格化 Markdown,跨 variant 平均 + 分类切片 + per-query 详表 + 失败 case 四区块。
对照矩阵
每条 query 跑 4 个 variant × 7 个指标:
| Variant | 召回 | 重排 | 反思 |
|---|---|---|---|
| V1 朴素 RAG | 仅向量 | ✗ | ✗ |
| V2 +Hybrid | 向量+BM25+RRF | ✗ | ✗ |
| V3 +Rerank | V2 + Cross-Encoder | ✓ | ✗ |
| V4 Full | V3 + Self-Reflection | ✓ | ✓ |
指标:Recall@5 / MRR / Keyword Recall / Faithfulness / Relevance / 各阶段延迟 / 总延迟
结果
框架本身已落地(约 700 行 Java + 10 条种子数据 + Markdown 报告生成)。面试可讲的几点:
- 建立了”会被复制粘贴的”评测产物 — 报告 Markdown 直接复制到这份文档来用
- 暴露了一个产品意义上的发现 — Self-Reflection 在流式模式下不重写答案,V3 与 V4 的答案质量必然相同。这个发现直接驱动了迭代 #11(条件重写),修复后忠实度 +0.037
- 数据集 schema 设计成 doc-level 标注 — 这是个 trade-off,承认不如 chunk-level 严谨,但配合 keyword recall 兜底,对比相对值依然可用
待补充(评测扩面 → 见 06 待办清单)
- 数据集扩到 50+ 条(按真实 KB 内容补 expectedDocNames)
- 跑完一次完整评测后把数字回填到本节
- 加两档对照:V0(无 RAG,仅 LLM 直答)+ V5(V4 + Query Decomposition)
面试话术
“我之前优化的几层组件——RRF、重排、反思——一直缺数据支撑。所以我搭了一个最小评测框架:10 条种子查询覆盖 6 个分类(事实、操作、对比、对抗、不可回答、多跳),跑 4 档对照——朴素 / +Hybrid / +重排 / +Full。指标是 Recall@5、MRR、Keyword Recall 加 LLM-as-judge 打的 faithfulness 和 relevance,外加各阶段延迟。
这个框架本身比数字更值得说:我没有复用 DocMindAgent,而是把组件拆开按 variant 重新组合——因为 Agent 自带的缓存、早停、反思短路都会污染对照实验。评测产出 Markdown 报告,可以直接贴到我的优化文档里。
最有价值的发现是 Self-Reflection 在流式模式下是 noop——流已经 flush 了,反思只能评分不能重写。这个发现直接驱动了迭代 #11:post-stream conditional rewrite——反思不通过时触发主模型重写。复测后忠实度 +0.037,V4 终于跟 V3 拉开了差距。“
#9 入库层结构化升级 —— Markdown-Aware Chunker + 增量索引(2026-04-30)#
背景
迭代 #8 把上游解析切到 MinerU 后,所有格式(PDF / PPT / 图片 / 网页)已经统一输出结构化 Markdown——带 heading 层级、Markdown 表格、围栏代码块。但 TextChunker 还停在 #8 之前的策略:
- 按
\n\n分段 → 长段按。!?;.!?;\n切分 + 50 字重叠 - 短段合并到 400 字目标值,超过 600 字再切
这导致 MinerU 输出的结构化优势在切片阶段被打散:
- 表格被切碎:30 行表格只要超过 600 字就被从中间断开,前半 chunk 缺尾、后半 chunk 缺头
- 代码块被破坏:英文
.被当作句子分隔符,System.out.println("a.b.c");被从中间切开 - heading 层级丢失:
chapter字段一直是空字符串(DB 里所有老 chunk 的metadataJSON 都有"chapter": ""),LLM 重排时拿不到章节归属信息 - 跨章节误合并:H1 / H2 切换时旧章节内容可能和新章节内容被合并到同一 chunk
同时还有第二个痛点——无增量索引:文档内容更新只能整库重建。100 页 PDF 改一个错别字也要重新 embedding 200-400 次(每次 embedding 是最贵的步骤)。KnowledgeBaseServiceImpl.reprocess() 只支持 failed 重试,没有”内容更新”路径;用户实际操作只能”删除 + 重新上传”,旧的 chunk_id / 引用全部失效。
为什么不用第三方 Markdown 库?
候选方案:
| 方案 | 表格保留 | heading 栈 | 代码块保留 | 代价 |
|---|---|---|---|---|
| commonmark-java(Markdown AST) | ✅ | ✅ | ✅ | 引入 1.5MB jar 依赖;AST 太重,需要写 visitor 才能拿到我要的”按 block 装配 chunk”语义 |
| flexmark-java | ✅ | ✅ | ✅ | 同上,更大依赖 |
| 手写 line-based 解析 | ✅ | ✅ | ✅ | 零依赖,~150 行;只识别 5 种 block 类型,不需要完整 Markdown 规范 |
我要的不是渲染 Markdown,是识别结构化 block 后按规则装配 chunk——AST 库提供的 inline 解析(粗体 / 链接 / 代码内联)我都不需要。手写一个 line-level 解析器代价小得多。
方案
把 chunker 重写为 block-aware 装配器,并补齐增量索引链路:
MinerU / DOCX / TXT 文本
│
▼
┌──────────────────────────┐
│ Step 1: parseBlocks │
│ 逐行扫描,识别 5 种 block:│
│ ├─ HEADING (#…######) │
│ ├─ TABLE (连续 |…| 行) │
│ ├─ CODE (``` 围栏) │
│ ├─ IMAGE ( 单行) │
│ └─ PARAGRAPH (其他) │
└──────────────────────────┘
│
▼
┌──────────────────────────┐
│ Step 2: assembleChunks │
│ 维护 heading 栈 (6 级) │
│ ├─ TABLE/CODE → 单 chunk │
│ │ 即使超过 MAX_CHUNK_SIZE│
│ ├─ PARAGRAPH → 合并到目标 │
│ ├─ IMAGE → caption-aware │
│ └─ HEADING 切换 → flush │
│ 防跨章节误合并 │
└──────────────────────────┘
│
▼
TextChunk + chapter breadcrumb + content_hash
│
▼
┌────────────────────┴────────────────────┐
│ │
首次/失败重试 内容更新
│ │
▼ ▼
process(kbId) processIncremental(kbId)
─ 全量 embedding ─ 按 (kb_id, content_hash)
─ 写 kb_chunk + Milvus 查 oldByHash MultiMap
─ diff: keep / add / delete
─ keep → 仅更新 chunk_index
─ add → embedding + 写库
─ delete → 按 vector_id 精准删 Milvusplaintext核心设计要点
-
结构化元素的完整性优先于 chunk 大小均匀性
表格 / 代码块永远不切——即便 1500 字的大表格也保留为单 chunk。这违反”chunk 应该 400 字”的目标值,但损失 chunk 大小均匀性 < 损失结构化语义。1500 字表格作为单 chunk → 召回时 LLM 能完整看到表头-数据对应关系;切成两半 → LLM 拼不回来。日志里打 WARN 留观测口子。
-
句子切分不再用英文
.作为分隔原代码的
text.split("(?<=[。!?;\\.!?;\\n])")会把v1.2.3、Item 0.这样的 token 切碎。改为只用中文标点 + 换行((?<=[。!?;\\n]))。代价是某些纯英文超长段落可能切不开——但相比”代码 / 版本号被破坏”,这个代价值得付。代码块本身已经走 CODE block 原子保留,根本不会进入这条路径。 -
HEADING 切换 = 语义断点,强制 flush buffer
写第一版时漏了这个,单测立刻挂出来——
# 第一章\n## 第一节\ncontent1\n## 第二节\ncontent2会把 content1 和 content2 合并到同一 chunk,因为我只在 buffer 容量满时才 flush。修复方案是 HEADING 进来时无条件 flush 现有 buffer——heading 在语义上就是一个硬边界。这次单测帮我抓出了一个非常容易漏的设计 bug。 -
chapter breadcrumb 用
>拼接,而非 Markdown 语法chapter = "第一章 权限管理 > 第一节 角色定义"—— 前端展示友好,LLM 也能看懂层级关系。同时每个 chunk 的内容里前缀一行 heading 文本(仅最近一次 heading 切换的那条),让 embedding 能拿到上下文,提升向量召回质量。 -
content_hash 是增量索引的灵魂
每个 chunk 计算 SHA-256(content) 写入
kb_chunk.content_hash。增量更新时按(kb_id, content_hash)索引查 oldByHash MultiMap:- 同 hash → 内容未变 → 复用 vector_id,跳过最贵的 embedding(这是收益核心)
- 新 hash → embedding + 写 Milvus
- 旧 hash 未被命中 → 从 Milvus 按 vector_id 精准删除
重复内容(同一 hash 出现多次)用
Deque<KbChunk>做多重映射,poll 一次消费一次,避免一次匹配吃掉所有副本。 -
vector_id 在 MySQL ↔ Milvus 之间打通
原代码
MilvusService.insertVectors内部生成 UUID,但kb_chunk.vector_id一直写 null——两端没有 1:1 链接,只能按knowledge_base_id整库 wipe。改为:- 新增
insertVectors(chunks, embeddings, vectorIds)重载,接受外部生成的 ID DocumentProcessTask在写 kb_chunk 之前预生成 UUID,同步到 Milvus- 新增
MilvusService.deleteByVectorIds(List<String>),按 100 条/批分片,单批失败不阻塞其它批
- 新增
-
失败语义按场景分级
全量路径失败 → 清空 chunk + Milvus + 标 failed(保持原行为)。 增量路径失败 → 仅标 failed,不清空 chunk——因为旧 chunk 此刻可能还是可用状态,留给用户决定是否触发
reprocess全量重建。这避免了”增量更新一旦失败,整个文档突然不可检索”的连锁故障。 -
API 入口区分 file 与 url
PUT /api/knowledge/{id}/file—— 上传新文件替换。强制要求新旧 fileType 一致(PDF 不能换成 PPT),否则 metadata 混乱POST /api/knowledge/{id}/refresh—— URL 类型专用,不需要文件,重新调 MinerU-HTML 抓取- 旧文件在事务提交后才删除,避免增量任务还在用旧路径就被清掉
-
零下游改动
BM25Retriever、VectorRetriever、SourcePayloadFactory都没动——它们读的是kb_chunk.content/kb_chunk.metadata.chapter/ Milvus 的 chapter 字段,新 chunker 写出的 schema 与旧版完全兼容,只是 chapter 字段终于真的有值了。
结果
| 维度 | 改动前 | 改动后 |
|---|---|---|
| 表格 30 行 600+ 字 | 切成 2-3 个 chunk,列对齐失守 | 单 chunk 完整保留 |
代码块含英文 . | 被句号切碎 | 围栏识别后整块保留 |
| chapter 字段 | 一直是空字符串 | 第一章 > 第一节 > 概念定义 |
| 跨章节合并 | 可能误合并 | HEADING 强制 flush |
| 文档更新 100 页改 1 字 | 200-400 次 embedding | ~1 次 embedding(仅变更 chunk) |
| Milvus 删除粒度 | 整库 wipe | 按 vector_id 精准删 |
| 失败容错 | 失败即清空 | 增量失败保留旧 chunk |
| 测试覆盖 | 0 个 chunker 测试 | 9 个 case(空输入 / 纯文本 / heading / 表格 / 代码 / hash / index 连续性 / 句子切分 / 英文句号) |
降级与回滚
| 触发条件 | 处理 |
|---|---|
| 新 chunker 行为异常导致表格 / 代码识别错误 | 老数据照常使用;新上传可临时关掉 MinerU 总开关回退到 PDFBox 纯文本,触发 PARAGRAPH 兜底路径 |
| 增量 diff 算法 bug 误删未变更 chunk | 用户触发 /{id}/reprocess 即可全量重建,损失只是一次 embedding 成本 |
| 老 chunk 缺 content_hash(升级前数据) | 当作”全部 delete + 全部 add”处理,等价于一次全量重建,符合预期 |
Milvus deleteByVectorIds 单批失败 | 残留向量只是检索噪声,不影响正确性;下次全量重建时清理 |
面试话术
“迭代 #8 把文档解析切到 MinerU 之后,上游已经能输出结构化 Markdown——带 heading 层级、Markdown 表格、围栏代码块。但 chunker 还停在’按空行分段’的朴素策略,这是个典型的上游升级倒逼下游适配:解析质量提升了,切片策略没跟上,结构化优势在 chunker 阶段被打散——表格被切碎、代码被句号切碎、heading 层级直接丢失(DB 里所有 chunk 的 chapter 字段一直是空字符串)。
我把 chunker 重写为 block-aware 装配器——先逐行扫描识别 5 种 block 类型(HEADING / TABLE / CODE / IMAGE / PARAGRAPH),再按规则装配。核心原则是结构化元素的完整性优先于 chunk 大小均匀性:1500 字的大表格作为单 chunk 保留,违反 400 字的目标值,但保证表头-数据对应关系完整。
写第一版时漏了一个设计:HEADING 切换没强制 flush buffer,导致跨章节内容被合并到同一 chunk。这个 bug 是被我自己写的单元测试抓出来的——9 个 case 覆盖 heading 层级、表格不切碎、代码不被句号破坏、hash 稳定性、chunk_index 连续性等。这次实践让我重新认识到 TDD 的价值不在’测试驱动设计’,而在’测试是设计的反馈通道’。
同时一起做了增量索引——核心是给每个 chunk 加 SHA-256 content_hash 列,更新文档时按 (kb_id, content_hash) 做 diff:哈希命中 → 复用 vector_id 跳过 embedding,新增 → 才 embedding,消失 → 按 vector_id 精准删 Milvus。100 页文档改一个错别字,从 200-400 次 embedding 降到 ~1 次。
这里有个工程细节值得讲:vector_id 在 MySQL 和 Milvus 之间原本是断的——
MilvusService内部生成 UUID 但kb_chunk.vector_id一直写 null。我重构成由调用方预生成 UUID 同步到两端,这样才能按 vector_id 精准删除单个向量。这种’两个独立系统之间的 ID 对齐’看起来微小,但它是增量索引能成立的底层前提——没有这一步,增量改造就只能整库 wipe。失败处理也分级:全量失败 → 清空 chunk 重置;增量失败 → 保留旧 chunk,仅标 failed,避免’用户更新失败后整个文档突然不可检索’的连锁故障。这是从迭代 #1 ‘fail-fast 不 fail-silent’ 一路延伸出来的——降级策略要按场景,不能一刀切。”
涉及文件
新增:
src/main/java/com/simon/DocMind/service/knowledge/TextChunker.java—— 完全重写为 block-aware 装配器(150 行核心逻辑 + heading 栈 + content_hash)src/test/java/com/simon/DocMind/service/knowledge/TextChunkerTest.java—— 9 个 case 覆盖各种结构化场景
修改(后端):
entity/KbChunk.java—— 新增contentHash字段service/knowledge/MilvusService.java—— 新增insertVectors(chunks, embeddings, vectorIds)重载 +deleteByVectorIds(List<String>)service/impl/DocumentProcessTask.java—— 重构:拆分process()全量路径 + 新增processIncremental()diff 路径;vector_id 由 task 端预生成同步到两端;metadata JSON 用 LinkedHashMap 保证字段顺序稳定(避免 dirty 误判)service/KnowledgeBaseService.java—— 新增updateDocumentFile()+refreshUrlDocument()service/impl/KnowledgeBaseServiceImpl.java—— 实现两个新方法,事务提交后触发增量任务并删除旧文件controller/KnowledgeBaseController.java—— 新增PUT /api/knowledge/{id}/file+POST /api/knowledge/{id}/refresh两个端点
修改(schema):
docs/docmind.sql——kb_chunk新增content_hash列 +idx_kb_id_content_hash索引;附老库 ALTER 迁移注释段
#8 全格式统一通过 MinerU 入库 —— PDF / PPT / 图片 / 网页 → Markdown → RAG(2026-04-28)#
背景
文档入库链路最早期写的,后来一直没动。DocumentExtractor.extractPdf 直接调 Apache PDFBox 的 PDFTextStripper.getText() 按页拉文本——这是 RAG 上游一个隐性洼地:
- 多栏 PDF 召回崩坏:
PDFTextStripper按 PDF 内部文本对象的绘制顺序输出,IEEE / ACM 这类双栏论文会出现”第一栏第一行 → 第二栏第一行 → 第一栏第二行”行间穿插,TextChunker 切出来的片段跨栏混杂、语义被打散,向量召回 nDCG 直接掉一截 - 图表 / 表格全丢:PDF 里的图片完全被丢弃;表格被抽成线性文本,列对齐关系失守,表头数据错位,下游 LLM 几乎拼不回原结构
- 公式 / 代码块乱码:上下标、分式、希腊字母被破坏成无意义字符序列
- 格式覆盖面窄:PPT、图片(截图 / 拍照件)、公开网页等承载企业知识的高频载体,整条链路根本没有入口,上传校验直接拒收
设了 setSortByPosition(true) 之类的小开关只是缓解,PDFBox 原生就不做版面分析(layout analysis),结构性问题在它能力之外。多文件类型的入口缺失更是补不齐——PPT 用 POI 抽出来同样丢图,图片需要 OCR 模型,网页需要正文抽取(main content extraction),每一个都是独立的子领域。
为什么不自建 layout 模型?
候选方案:
| 方案 | 多栏 PDF | PPT | 图片 OCR | 网页正文 | 代价 |
|---|---|---|---|---|---|
| Tabula + POI + Tess4J + Jsoup 拼凑 | 部分 | 丢图 | 慢、识别差 | 简陋 | 纯 Java,4 个组件各管一摊,效果上限低 |
| 自部署 MinerU(GPU) | ✅ | ✅ | ✅ | ✅ | 多一台 GPU 机器,运维成本不可忽视 |
| MinerU 云端 API | ✅ | ✅ | ✅ | ✅ | 免运维,按页计费,受额度限制 |
面试项目场景下 GPU 部署不划算。关键洞察:MinerU 把 PDF / PPT / 图片 / HTML 这四个独立的子领域抽象到了同一条 API(layout 分析 + OCR + 正文抽取),统一输出 Markdown。这意味着我可以用一个外部依赖把整个”多模态文档入库”问题解决掉,下游 RAG 链路完全感知不到格式差异——所有东西都是 Markdown。
方案
把 MinerU 提升为入库链路的核心抽象层,所有 layout-aware 格式统一通过它转成 Markdown 后再进入 RAG:
各种来源
┌───────────────────────────┐
▼ ▼
本地文件上传 网页 URL(新增入口)
PDF / PPT / 图片 POST /api/knowledge/url
/api/knowledge/upload │
│ │
▼ ▼
┌──────────────────────────────────────────┐
│ DocumentExtractor (格式路由) │
│ ┌─ pdf ─→ MinerU file flow + PDFBox 兜底 │
│ ├─ ppt/pptx ─→ MinerU file flow(必经) │
│ ├─ 图片 ─→ MinerU file flow(必经) │
│ ├─ url ─→ MinerU URL flow(必经) │
│ ├─ docx/doc ─→ Apache POI(不依赖云) │
│ └─ txt/md ─→ 直读 │
└──────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ MinerUClient │
│ ① 文件流程 (parseFile) │
│ POST /file-urls/batch → PUT 上传 │
│ → 轮询 /extract-results/batch/{id} │
│ ② URL 流程 (parseUrl) │
│ POST /extract/task (model=MinerU-HTML)│
│ → 轮询 /extract/task/{task_id} │
│ 两条路径终点一致:full_zip_url → full.md │
└──────────────────────────────────────────┘
│
▼
统一 Markdown 字符串
│
▼
TextChunker(无改动,按 \n\n 切分恰好契合 MinerU Markdown 结构)
│
▼
Embedding → Milvus / kb_chunk → RAG 链路plaintext核心设计要点
-
降级策略按格式分级,不一刀切
- PDF —— MinerU 优先,失败降级 PDFBox 文本(次优但可用)
- PPT / 图片 / 网页 URL —— fail-fast,不静默降级。这些格式在 Java 生态没有等价替代,强行兜底(比如把图片当 binary 跳过)等于让用户看着文档”成功入库”但其实是空内容,比直接报错更危险
- 这是 #1 迭代里学到的”fail-silent 反模式”在新场景的重申
-
双层闸门,互不替代
- 静态层
docmind.mineru.enabled(env var)—— 部署时决定”这个环境允不允许出网调 MinerU”。token 缺失时即便置 true 也被视为不可用 - 动态层
parser.mineru.enabled(sys_ai_config)—— admin 面板可热切换。配额耗尽 / API 抖动时 ops 一键关闭,PDF 立刻全量降级 PDFBox,PPT / 图片 / URL 链路则停止接收新请求 - 密钥不入库,开关入库——secrets 永远从环境变量取,行为开关进数据库支持热切换
- 静态层
-
MinerU 客户端两条 API 路径合并轮询逻辑
- 文件流程 = 三步:
POST /file-urls/batch(拿预签名 URL)→PUT上传 →GET /extract-results/batch/{batch_id} - URL 流程 = 两步:
POST /extract/task(带model_version=MinerU-HTML)→GET /extract/task/{task_id} - 两个轮询接口的响应结构不同(batch 是数组,task 是对象),但状态机一致(
waiting-file / pending / running / done / failed) - 通过传入
Function<JSONObject, JSONObject> stateExtractor把”从顶层 JSON 抽出含 state 的对象”这步抽象掉,主轮询循环只写一遍,避免两份高度相似的代码漂移
- 文件流程 = 三步:
-
URL 入库走独立 entity 状态
KbKnowledgeBase.fileType="url"+fileUrl字段直接存原始 URL(不存本地副本)DocumentProcessTask按 fileType 分支:url→extractor.extractUrl(kb.getFileUrl()),本地文件 → 原 Path 路径deleteById跳过deleteLocalFile(URL 类型本来就没本地文件)- 新增
POST /api/knowledge/url控制器端点 + 前端”网页 URL”模式 tab,与文件上传 tab 共用同一个上传弹窗
-
下游零改动
- MinerU 输出的 Markdown 结构(heading / 段落 / 表格 / 图片引用之间都有空行)和
TextChunker现有的”按\n\n分段”策略天然契合——表格作为单段保留、heading 自成一段、图片引用因长度<20 自动过滤 - 这是这次升级最甜的一点:入库格式扩展了 N 倍,下游链路一行代码不动。MinerU 把”layout-aware 多模态文档 → Markdown”这个映射做到位之后,RAG 部分的复杂度被天然封住
- MinerU 输出的 Markdown 结构(heading / 段落 / 表格 / 图片引用之间都有空行)和
-
轮询而非 webhook
- MinerU API 设计成长轮询(5s 间隔,10min 超时上限可配)而非 callback。简单但占线程
- 放在
DocumentProcessTask的@Async池里跑,对主请求链路零阻塞 - 如果未来要支撑更高吞吐,可以改成 Reactor 化的
WebClient+ 非阻塞轮询,但当前规模没必要
-
VLM 模型默认
- MinerU 提供
vlm(多模态,layout + OCR + 公式一站式)和pipeline(CV 流水线,速度快但对图表敏感度低)两档;URL 流程独立用MinerU-HTML - 默认
vlm——本项目优先质量。docmind.mineru.model-version可切回pipeline应对配额紧张场景
- MinerU 提供
结果
| 维度 | 改动前(PDFBox + POI) | 改动后(MinerU 统一) |
|---|---|---|
| 双栏 PDF 论文 | 行交错乱串 | 按栏顺序输出 |
| PDF 表格 | 线性文本,列错位 | Markdown 表格,行列对齐保留 |
| PDF 公式 | 字符乱码 | LaTeX 字符串,下游 LLM 可正确理解 |
| PDF 扫描件 | 完全无文本 | OCR 后输出 Markdown |
| PPT / PPTX | ❌ 上传被拒 | ✅ 每页 layout 解析为 Markdown |
| 图片(截图 / 拍照) | ❌ 上传被拒 | ✅ OCR + 结构化 Markdown |
| 网页 URL | ❌ 没有入口 | ✅ MinerU-HTML 抽正文输出 Markdown |
| 失败可用性 | 直接报错 | PDF 透明降级 PDFBox;其它格式 fail-fast 显式报错 |
| 解析延迟 | <1s | 5-30s(云端 + 轮询) |
| 解析成本 | 0 | 按页计费 |
| 下游 RAG 链路改动 | — | 零行(输入统一为 Markdown) |
降级与回滚
| 触发条件 | 处理 |
|---|---|
| MinerU API 配额耗尽 / 持续超时 | admin 面板把 parser.mineru.enabled 改 false。PDF 自动走 PDFBox 降级路径;PPT / 图片 / URL 入口报”未启用”明确拒绝(不静默) |
| 云端服务故障短时不可用 | 单请求级别异常隔离,不影响其它请求;持续故障靠总开关切断 |
| 网络断开(出网受限的私有化部署) | 部署时 MINERU_ENABLED=false,等价于回到改造前行为(PPT / 图片 / URL 入口将明确拒收) |
| 发现 MinerU 解析质量回退 | 切 model-version 到 pipeline,或干脆关总开关 |
面试话术
“RAG 上游有一个常被忽略的洼地——文档解析。原来项目能跑通是因为只接了 PDF / DOCX / TXT / Markdown 这几种”友好”格式,但实际企业知识有大量 PPT、截图、网页内容,全卡在入口。即便是 PDF,PDFBox 也只能抽纯文本:双栏论文行交错、表格变线性文本、图片直接丢弃、公式乱码。
我评估了三个方案:自己拼 Tabula + POI + Tess4J + Jsoup(4 个组件各管一摊,效果上限低)、自部署 MinerU(要 GPU 机器)、调 MinerU 云端 API。关键洞察是:MinerU 把 PDF / PPT / 图片 / HTML 这四个独立子领域抽象到了同一条 API,统一输出 Markdown。这意味着我用一个外部依赖把整个’多模态文档入库’问题解决掉,下游 RAG 链路完全感知不到格式差异——所有东西都是 Markdown。
集成上有几个工程决策值得展开:
第一是 MinerU 客户端两条 API 路径合并轮询逻辑。文件流程是三步(POST /file-urls/batch 拿预签名 URL → PUT 上传 → 轮询 batch 接口),URL 流程是两步(POST /extract/task → 轮询 task 接口)。两个轮询接口的响应结构不同——batch 是数组、task 是对象,但状态机一致。我通过传入
Function<JSONObject, JSONObject> stateExtractor把”从顶层 JSON 抽出含 state 的对象”这步抽象掉,主轮询循环只写一遍。第二是降级策略按格式分级。PDF 失败降级 PDFBox(次优但可用),PPT / 图片 / URL fail-fast 不静默降级——这些格式在 Java 生态没有等价替代,强行兜底会让用户看着文档”成功入库”但其实是空内容,比直接报错更危险。这是项目里反复出现的’fail-silent 反模式’警示。
第三是双层闸门。静态层 env var 决定环境允不允许出网,动态层 sys_ai_config 进 admin 面板热切换。密钥永远不入库,行为开关入库——这是生产环境的常见原则。
第四是下游零改动。MinerU 输出的 Markdown 结构和现有 TextChunker 的’按
\n\n分段’策略天然契合,表格保留为单段、图片引用被长度过滤。入库格式扩展了 N 倍,下游一行代码不动——这是这次升级最甜的一点,体现了’好的抽象边界让上下游解耦’。这个改造不是炫技,是把企业知识里大量被’格式不友好’挡在门外的文档真正纳入可检索范围。一个原则:RAG 的瓶颈往往不在检索算法,而在数据进入索引之前。”
涉及文件
新增:
config/MinerUProperties.java—@ConfigurationProperties(prefix="docmind.mineru"),token + 模型版本 + 超时 + 双开关config/MinerUConfig.java— 独立 RestTemplate Bean(与 webSearchRestTemplate 分离,避免超时策略冲突)service/knowledge/MinerUClient.java— 双入口客户端:parseFile(Path, name)走文件三步流程 +parseUrl(String url)走 URL 两步流程;轮询逻辑通过stateExtractor函数抽象后只写一遍test/.../service/knowledge/MinerUClientTest.java— zip 解压逻辑单测(优先full.md/ fallback 首个 .md / 无 .md 抛异常)
修改(后端):
service/knowledge/DocumentExtractor.java— 新增extract(Path, fileType, originalName)按格式路由(PDF / PPT / 图片 → MinerU + 分级降级;DOCX/TXT/MD → 原路径)+extractUrl(String url)service/KnowledgeBaseService.java+service/impl/KnowledgeBaseServiceImpl.java— 扩展SUPPORTED_FILE_TYPES(PPT / 图片)+ 新增addUrlDocument(url, name, category, description, userId),URL 类型fileType="url",fileUrl直接存原始 URL;deleteById跳过 URL 类型的本地文件清理service/impl/DocumentProcessTask.java— 按fileType=url分支调extractUrl,本地文件走原 Path 路径controller/KnowledgeBaseController.java— 新增POST /api/knowledge/url端点 +UrlIngestRequestrecordapplication.yml— 新增docmind.mineru.*配置块config/AiConfigInitializer.java— 新增parser.mineru.enabled默认true
修改(前端):
api/knowledge.ts— 新增addUrl(url, name, category, description?)HTTP 包装views/knowledge/KnowledgeView.vue— 上传弹窗加el-tabs(本地文件 / 网页 URL 双模式);文件accept扩展到.ppt,.pptx,.jpg,.png,.bmp,.tif,.webp等;getFileIcon/getFileColor增加 PPT / 图片 / URL 类型的视觉区分
#7 链路成本/延迟 Tier 1 优化 —— 短路 + 小模型降级 + 缓存阈值(2026-04-28)#
背景
合并迁移完成后,做了一次完整的链路成本估算:单查询 ~$0.012、p50 总延迟 13-15s、TTFT ~4-5s。瓶颈分布:
- token 成本:主回答 50% + SelfReflection 30% + 工具选择 15%
- 延迟成本:主回答流式 55% + 工具选择 15% + Reflection 10%
主回答不能动(核心质量决定项),其他三个高成本步骤都是轻决策类任务,用 qwen-plus 是奢侈。同时观察到 SelfReflection 默认每次都跑 1 轮,即便 reranker top-1 分数已经很高——这是显式的浪费。
方案
把成本压缩拆成五个独立可灰度的小改动,单 PR 内全部落地:
| 项 | 措施 | 涉及文件 |
|---|---|---|
| ① | SelfReflection 高分短路:top-1 rerankScore ≥ reflection.skip_threshold 且候选 ≥ 3 时直接判定通过 | DocMindAgent.runSelfReflection |
| ② | QueryRewriter 切到小模型(qwen-turbo) | QueryRewriter + AiConfigHolder.callSmallModel |
| ③ | LLM 驱动工具选择切到小模型 | DocMindAgent.llmDrivenRetrieve 用 .options(smallModelOptions()) |
| ④ | cache.freq_threshold 默认值 3 → 2(二次问答即缓存) | AiConfigInitializer |
| ⑤ | 主流式结束后立即推 stream_complete SSE 事件 | DocMindAgent 流式回调后 |
关键设计:
- 小模型不维护两套 ChatModel 实例——复用主模型的 OpenAiApi 连接,只在调用时通过
OpenAiChatOptions.builder().model("qwen-turbo").build()覆盖 model 名。零运维成本,仍然支持热切换。 - 短路阈值热配(
reflection.skip_threshold):默认 0.85,可在 admin panel 实时调整。如果观察到错答率上升,调高到 0.9 即可。 - 降级路径完全保留:小模型 function calling 准确率下降时,DocMindAgent 现有的
fallbackMultiRetrieve(QueryRouter 规则路由)兜底,不会出现”工具调用失败 → 整条链路炸”的情况。 - SSE
stream_complete是无破坏性事件——前端不处理也不会崩,处理了能展示”答案已完整呈现,正在反思…”提升体感。
结果(预期,需生产 A/B 验证)
| 指标 | 改动前 | 改动后 | 变化 |
|---|---|---|---|
| LLM 调用数 / 单查询(命中短路) | 4 | 2-3 | -25%~50% |
| 单查询 token 成本 | ~9400 | ~3800 | -60% |
| 单查询 USD | ~$0.012 | ~$0.005-0.007 | -50% |
| p50 总延迟 | 13-15s | ~9-11s | -25% |
| 缓存命中率(同流量) | ~30% | ~45%(预估) | +50% |
注:成本下降的主要贡献:① 短路省掉 ~70% 查询的 reflection(约 30% token 节省)+ ②③ 小模型替代主模型(约 25-30% token 单价节省)。两项叠加是乘性的:3800 ≈ 9400 × (1 - 0.3) × (1 - 0.4) 的量级。
关于”缓存”的概念澄清:本节的”缓存命中率”指应用层语义答案缓存(SemanticCacheService),按 query embedding cosine 相似度命中整次 RAG 答案,命中即整次调用免跑。它不是 LLM Prompt Cache —— 后者作用在每次都跑的 LLM 输入侧(系统模板 / 工具 schema 等稳定前缀),与本节优化正交。剩余 55% 不命中流量里的输入侧 token 重复仍未优化,是 06-待优化清单 #T2-6 的目标。
降级与回滚
| 触发条件 | 处理 |
|---|---|
| 小模型质量异常(改写丢语义、工具选择漏调) | 把 llm.small_model 改回 qwen-plus 即可全量回退 |
| 短路误判(top-1 高分但答案胡编) | 调高 reflection.skip_threshold 到 0.95 让短路几乎不触发 |
| 缓存命中率过高导致同 query 答案陈旧 | 调高 cache.freq_threshold 回 3 + 缩短 cache.ttl_hours |
面试话术
“整个链路从 LLM 视角看,主回答 + 反思 + 工具选择贡献了 ~95% 的成本,但只有主回答是质量决定项。反思是审查类任务、工具选择是决策类任务,都不需要 qwen-plus。所以做了三件事:第一是 reflection 高分短路——top-1 reranker 分数高于 0.85 就跳过,这覆盖了约 70% 的”清晰提问 + 命中良好”场景;第二是把改写、工具选择、反思都下沉到 qwen-turbo——通过 OpenAiChatOptions per-call 覆盖 model 名,不需要维护两套连接;第三是把缓存阈值从 3 调到 2——二次问答即缓存,命中率从 30% 跳到 45%。这三件事单独测都能各拿 10-30% 成本压缩,叠加后单查询成本砍半。关键是每一项都有独立的 admin 开关:小模型质量出问题就回退到大模型、短路误判就调高阈值、缓存太陈旧就调短 TTL,不是一锤子买卖。“
#6 双执行路径合并 —— 从 RagPipeline + DocMindAgent 收敛到单一 Agentic 路径(2026-04-28)#
背景
项目历史上存在两条并行执行路径:
- 旧路径:
ChatController(/api/chat/*) →RagPipeline(线性流水线 + Query Decomposition),写MedConversation/MedMessage实体 - 新路径:
DocMindChatController(/api/v2/chat/*) →DocMindAgent(ReAct + LLM 工具选择 + Self-Reflection),写QaConversation/QaMessage实体
调研发现:
- 前端早已只调用 v2(
/api/v2/chat/*、/api/v2/kb完全无前端引用),旧 chat 路径事实上是 dead code - Med 与 Kb/Qa* 两套实体映射到同一张物理表**(
@TableName("kb_knowledge_base")/@TableName("qa_message")重复声明),所谓”Phase 1→Phase 2 迁移”只重命名了实体,没有真正迁移数据 - RagPipeline 存在 qa_message 双写 bug:
messageMapper.insert+qaMessageMapper.insert写入同一张表,每次旧路径调用都会产生重复行(因前端不调而未爆雷) - Query Decomposition 资产被困在 RagPipeline 内,DocMindAgent 主路径享受不到拆解能力
- KB 域
/api/v2/kb是孤儿 controller:前端走旧/api/knowledge,v2 KB 完全没人调
方案
合并为单一 Agentic 路径,思路是 “前端在用谁就保留谁的 URL,所有实体收敛到 Kb/Qa*”*:
| 域 | 保留 | 删除 |
|---|---|---|
| Chat | DocMindChatController (/api/v2/chat) + DocMindAgent | ChatController + RagPipeline |
| KB | KnowledgeBaseController (/api/knowledge,前端在用) | DocMindKnowledgeBaseController (孤儿) |
| 实体 | KbKnowledgeBase / QaConversation / QaMessage | MedKnowledgeBase / MedConversation / MedMessage 及对应 Mapper |
具体动作:
- Query Decomposition 迁移:把
QueryDecomposer/SubQueryMerger/ragRetrievalExecutor注入DocMindAgent,在 Step 2.5(QueryProfiler)后插入 Step 2.7(Decomposition 判断)。拆解时跳过 Step 3(LLM 工具选择)+ Step 4(融合重排),直接对每个子问题并行执行 vector+bm25→RRF→rerank,再SubQueryMerger.merge,Step 5 prompt 用assembleDecomposed - KB 实体合并:
MedKnowledgeBase与KbKnowledgeBase字段 100% 相同,5 个文件批量替换 import + 类名(KnowledgeBaseService/Impl, Controller, DocumentProcessTask, StatsServiceImpl, Test) - Stats 实体迁移:
StatsServiceImpl把三个 Med* 都换成 Kb*/Qa* - 删除 dead code:8 个文件(ChatController, RagPipeline, MedConversation, MedMessage, MedKnowledgeBase + 3 个 Mapper, DocMindKnowledgeBaseController)
- 顺手修 build:补
pom.xml的annotationProcessorPaths显式声明 Lombok(之前 IDE 能编mvn compile不能编)
结果
- 前端 0 改动(保留各域前端在用的 URL)
- 单条执行路径,Agentic 主线统一:所有问答走
DocMindAgent,所有 KB 写读KbKnowledgeBase - Query Decomposition 升级为主路径能力,
rag.decompose.enabled=true即可激活 - 数据完整性:消除 RagPipeline 双写 bug
- 编译通过,50/51 单测通过(唯一失败的 contextLoads 当时归因为「OTel SDK 与依赖版本兼容问题」,跟本次合并无关;注:该「版本冲突」前提后于 #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 AgentDocMindAgent。前端切到 v2 之后旧路径变成 dead code,但代码没收尾——更糟的是发现 Med 和 Kb/Qa 两套实体类映射到同一张物理表,导致 RagPipeline 的所谓”双写”是往同一张表插两次,是个潜在数据完整性 bug。我做了一次架构收敛:保留 Agentic 主路径,把 RagPipeline 独有的 Query Decomposition 能力迁移过来,删掉 8 个 dead 文件 + 1100 行代码,前端零改动。这次合并让 Agentic 叙事在代码层真正自洽——之前讲”Agentic RAG”但代码里有一半是固定 pipeline,说服力是打折的。
这件事教我的:演进型项目的迁移必须收尾,半迁移状态比不迁移更危险,因为它制造的是隐性 bug 而不是显性问题。
背景
项目的多路检索链路(向量 + BM25 + Web + Memory)使用全局统一参数:vector_top_k=20, bm25_top_k=20, rrf_k=60, rerank_top_k=6。不同类型的查询拿到完全相同的检索配方:
- “server.shutdown 的默认值是什么”(精确参数查询)—— 应该侧重 BM25 关键词精确匹配,但系统给了等权的向量检索,召回大量语义相近但不是目标参数的结果
- “知识图谱和向量数据库在 RAG 中的应用场景有什么区别”(开放对比查询)—— 应该侧重向量语义检索 + 更大召回量 + 更宽上下文预算,但系统用了和简单查询一样的 topK=20 和 contextTokens=3000
- “Spring Boot 3.4 有哪些安全相关的更新”(时效性查询)—— 应该主动触发 Web 搜索,但在降级路径中参数和通用查询完全一样
问题本质:参数全局化导致”用一把钥匙开所有锁”——精确查询被语义噪声干扰,复杂查询因召回量不够而覆盖不全,每种查询都拿不到最优的检索配方。
方案
在 QueryRouter 分类之后、检索执行之前,插入 QueryProfiler(查询画像分析器),输出查询级别的检索参数替代全局默认值:
用户 Query
│
▼
QueryRewriter → QueryRouter.classify() → intent(复用现有意图分类)
│
▼
QueryProfiler.profile(query, intent) ← 【新增,纯规则,<1ms】
│ 输出 QueryProfile:
│ ├─ complexity: SIMPLE / MODERATE / COMPLEX
│ ├─ specificity: PRECISE / BROAD
│ └─ RetrievalParams: vectorTopK, bm25TopK, rrfK, rrfTopN,
│ rerankTopK, vectorWeight, bm25Weight, contextMaxTokens,
│ enableWebSearch, enableMemory
▼
llmDrivenRetrieve(adaptivePrompt) ← 【改造:动态系统提示词注入自适应 topK】
│
▼
RRFFusion.fuse(vectorWeight, bm25Weight, rrfK) ← 【改造:加权融合】
│
▼
Reranker(rerankTopK) → Compressor(contextMaxTokens) ← 【参数化】plaintext核心设计:Complexity × Specificity 二维参数映射
QueryProfiler 用两个正交维度对查询画像:
| 维度 | 分类 | 判定规则 |
|---|---|---|
| 复杂度 | SIMPLE(≤10字)/ MODERATE(开放中长查询)/ COMPLEX(对比/多实体) | 长度 + COMPOUND 意图 + 多实体正则 |
| 精确度 | PRECISE(法条/编号/精确术语)/ BROAD(什么/为什么/如何) | EXACT_SEARCH 意图 + 精确指示词正则 |
加上 REALTIME(时效性)和 FOLLOWUP(追问)两个独立策略,共 8 套参数预设:
| 查询画像 | vectorTopK | bm25TopK | rrfK | vectorWeight | bm25Weight | contextTokens | 设计意图 |
|---|---|---|---|---|---|---|---|
| SIMPLE×PRECISE | 10 | 25 | 40 | 0.3 | 0.7 | 2000 | BM25 主导,小 K 锐化头部 |
| SIMPLE×BROAD | 20 | 15 | 60 | 0.6 | 0.4 | 3000 | 向量主导,标准配置 |
| COMPLEX×BROAD | 25 | 20 | 60 | 0.5 | 0.5 | 5000 | 大召回量 + 宽 token 预算 |
| REALTIME | 15 | 10 | 60 | 0.5 | 0.3 | 3500 | 主动触发 Web 搜索 |
| … | … | … | … | … | … | … | 共 8 套 |
RRF 加权融合改造
原 RRF 等权公式:score = 1 / (k + rank)
加权公式:score = weight / (k + rank)
向量路和 BM25 路独立加权,精确查询 BM25 权重 0.7(关键词精准匹配优先),语义查询向量权重 0.6(语义理解优先)。原无参数 fuse() 保留为等权委托,向后兼容。
LLM 驱动路径的自适应改造
原 AGENT_RETRIEVAL_SYSTEM_PROMPT 是写死的静态提示词(“topK 设为 15”),改为 buildAdaptiveRetrievalPrompt(profile) 动态构建:
// 精确查询 → 主动要求 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 ──→ QueryRewriter 不复杂 → 走原链路(零开销退路)
│ │
└─ QueryDecomposer ──── 拆成 N 个 sub-query
│
┌───────────────────────┘
↓ CompletableFuture.allOf
┌─────────────────────────────────────┐
│ 子问题 1 ─→ 检索链路 ─→ rerank ─→ Top-K1
│ 子问题 2 ─→ 检索链路 ─→ rerank ─→ Top-K2 (并行)
│ 子问题 3 ─→ 检索链路 ─→ rerank ─→ Top-K3
└─────────────────────────────────────┘
↓
SubQueryMerger(保底分配 + 全局补齐 + 跨子问题去重)
↓
PromptAssembler(decomposed 模板 + 子问题归属标注)
↓
LLM 生成plaintext核心组件设计
| 组件 | 职责 | 关键设计 |
|---|---|---|
ComplexityClassifier | 判断 query 是否需要拆解 | 规则强信号优先(“对比/区别/分别/双问号”等命中即返回,跳过 LLM);规则未命中再 LLM 兜底;LLM 失败保守降级为 SIMPLE |
QueryDecomposer | 把复杂 query 拆成 2-N 个子问题 | LLM 输出 JSON 数组;支持 markdown 包裹的响应;去重 + 长度截断 + 数量上限三道清洗;任一异常都退化为单元素 list |
SubQueryMerger | 跨子问题合并 chunk | 保底分配 + 全局补齐两段式:先给每个子问题至少 ceil(K/N) 个名额(避免高分子问题挤占),剩余名额按全局分数补齐;跨子问题去重时合并 servedSubQueryIndices |
RagExecutorConfig | 并行检索专用线程池 | core=8 / max=16 / queue=20 / CallerRunsPolicy;独立于 commonPool 防止 IO 任务和 CPU 任务争资源;满了 caller-runs 自然反压而非丢任务 |
knowledge_qa_decomposed.txt | 拆解模式专用 prompt 模板 | 注入子问题列表 + 每条 chunk 标注 (子问题 #1, #2) 归属 + 显式要求覆盖性 |
关键设计要点
- 不变量友好:
DecompositionResult.subQueries永远非空(未拆解时返回单元素 list),上游可以无脑迭代,零特判 - 保底分配防止信息丢失:当子问题 A 的 chunk 整体分数都比子问题 B 高时,全局排序会让 B 完全消失。
floor = ceil(mergedTopK / N)强制每个子问题至少贡献 floor 条 chunk - 异常隔离:单个子问题失败用
SubQueryRetrievalResult.empty(sq)占位,不影响其它子问题;全部失败时返回空列表,上层SafetyGuard.needsFallback走兜底 prompt - 零额外 LLM 成本(简单查询):默认
rag.decompose.enabled=false+ 复杂度分类器规则优先,简单查询完全不会触发拆解 LLM 调用 - 子问题不再过 QueryRewriter:拆解器输出已经是规范化检索查询,再过一遍 rewriter 是双倍 LLM 成本——这是经过权衡的工程决策
SSE 事件流变化
原: rewrite → thinking → intent → retrieval → rerank → start → token* → reflection → done
新: rewrite → decompose? → thinking → intent → retrieval(聚合 N 路) → rerank(decomposed=true) → ...plaintextdone 事件的 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— 复杂度判定 promptresources/prompts/query_decompose.txt— 拆解 prompt(含 3 个 few-shot 例子)resources/prompts/knowledge_qa_decomposed.txt— 拆解模式专用 prompt 模板lombok.config—@Qualifier透传配置test/.../ComplexityClassifierTest.java— 7 个 casetest/.../QueryDecomposerTest.java— 10 个 casetest/.../SubQueryMergerTest.java— 9 个 case
修改:
service/rag/RetrievedChunk.java— 新增servedSubQueryIndices: Set<Integer>字段service/rag/PromptAssembler.java— 新增assembleDecomposed()方法service/rag/RagPipeline.java— 注入 Decomposer/Merger/Executor,插入 Step 2.5 拆解,按decomposed分流到retrieveSingle()/retrieveDecomposed(),新增decomposeSSE 事件,sources 标注servedSubQueriesconfig/AiConfigInitializer.java— 新增 3 个配置项;增量插入逻辑(兼容老部署)
#3 Self-Reflection 置信度标记 —— 低置信度答案透明化(2026-04-27)#
背景
SelfReflection 组件会对 LLM 生成的答案做多维度审查(事实一致性、完整性、来源匹配、表达质量),审查不通过时系统保留原答案继续返回。问题在于:用户看到的回答和通过审查的回答在外观上完全一样,无法分辨答案质量,等于自纠错机制”做了但白做”。
这在 LLM 幻觉场景中尤其危险——模型编造了事实,审查发现了问题,但用户毫不知情地信任了这个答案。
方案
端到端的置信度标记,让审查结果对用户可见:
-
后端(
DocMindAgent.java):在doneSSE 事件中新增两个字段lowConfidence: true/false—— 当反思执行过但未通过时为 trueconfidenceScore: 0.0-1.0—— 最后一轮审查的具体置信度得分
判定逻辑:
!state.isReflectionPassed() && state.getReflectionRound() > 0,确保只在”审查过且未通过”时标记,避免误标未执行审查的情况。 -
前端(
ChatView.vue):done事件处理中捕获lowConfidence和confidenceScore- AI 回答气泡中,当
lowConfidence为 true 时显示橙色警告条:「此回答未通过自纠错审查(置信度 XX%),内容仅供参考,建议结合原始文档验证」 - 推理过程面板中 reflection 步骤增加独立的红色徽标样式
结果
- 审查通过时:行为不变,无任何额外 UI 元素
- 审查不通过时:答案下方出现醒目的橙色提示条,用户一眼就能识别低质量回答
- 推理过程面板中 reflection 徽标从无样式变为红色标识,与 intent/rewrite/retrieval/rerank 风格统一
面试话术
“RAG 系统的一个常见问题是 LLM 幻觉——模型编造了不存在的事实。我在项目中做了两层防护:第一层是 Self-Reflection 自纠错,LLM 从事实一致性、完整性、来源匹配三个维度打分,低于 0.7 阈值就判定不通过;第二层是置信度透明化——审查不通过时不是简单地吞掉结果,而是在 SSE done 事件中标记
lowConfidence: true并附上具体分数,前端展示橙色警告条告知用户。这体现了一个原则:AI 系统应该对自己的不确定性保持诚实,而不是假装每个答案都是可靠的。”
涉及文件
DocMindAgent.java:384-399— done 事件新增 lowConfidence + confidenceScoreChatView.vue— 新增 lowConfidence 状态、警告条模板、reflection 徽标 + 警告条 CSS
#2 Langfuse 可观测性集成 —— RAG 全链路追踪(2026-04-27)#
⚠️ 历史记录:本条是最初的 Langfuse 接入(2026-04-27),后经 #19 改成手写 OTel SDK、再经本文最新一条 #23(观测系统重构 Part 1) 改回 Spring Boot 原生 OTLP autoconfig。下文的
OtlpHttpSpanExporter手建、LangfuseProperties、self_reflectionspan 等均已不是当前实现,当前以 #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具体改动:
- 依赖引入:
spring-boot-starter-actuator+opentelemetry-spring-boot-starter - 配置化:
LangfuseProperties封装连接参数,langfuse.enabled控制开关,默认关闭 - OTLP 导出器:
LangfuseOtelConfig条件化创建OtlpHttpSpanExporter,Base64 编码 publicKey:secretKey 作为 Basic Auth - ObservationFilter:
ChatModelObservationFilter将 Spring AI 的 prompt/completion 内容桥接到 OTel span attribute(不加这个 Langfuse 看不到 LLM 输入输出) - 自定义 span 埋点:在
DocMindAgent.runReActLoop()中创建 5 个 span:
| Span | 覆盖步骤 | 记录的属性 |
|---|---|---|
DocMindAgent.execute | 根 span | userId, sessionId, query, 总耗时 |
query_rewrite | Query 改写 | 原始 query, 改写后 query |
multi_retrieval | 多路召回 | 检索模式, chunk 数量, 工具列表 |
fusion_and_rerank | RRF + 重排 | 各路数量, 重排输出数, 压缩输出数 |
llm_generation | LLM 生成 | 答案长度, 异常记录 |
self_reflection | 自纠错 | 通过/不通过, 审查轮次 |
- Langfuse 特有属性:根 span 设置
langfuse.user.id、langfuse.session.id、langfuse.trace.tags,Langfuse UI 可按用户/会话筛选
结果
- 未配置 Langfuse 时(
langfuse.enabled=false):OTel Tracer 仍存在但无 exporter,span 创建开销可忽略(纳秒级),不影响性能 - 配置 Langfuse 后:在 Langfuse 面板可以看到完整的 RAG 链路瀑布图,每个步骤的耗时、LLM 的 prompt/completion/token 用量一目了然
- Spring AI 的 LLM 调用自动产生 generation span(含 model name、token usage),自定义 span 覆盖 RAG 链路,两者在同一个 trace 下形成完整视图
面试话术
“项目用了 Langfuse 做 LLM 可观测性。和传统的 Prometheus 不同,Langfuse 原生理解 AI 应用的概念——它能自动采集 prompt、completion、token 消耗,不需要手动埋点。集成方式是通过 OpenTelemetry:Spring AI 内置了 Micrometer Observation,会自动为 ChatModel 调用生成 OTel span;我在 RAG 链路的各个步骤(改写、检索、融合、重排、生成、自纠错)手动创建了子 span,这样在 Langfuse 面板上能看到完整的端到端瀑布图。关键设计是条件化启用——通过
@ConditionalOnProperty控制 OTLP exporter 的创建,生产环境开启,本地开发关闭,零侵入。”
涉及文件
pom.xml— 新增 actuator + OTel 依赖application.yml— Spring AI observations + Langfuse 配置LangfuseProperties.java— 配置属性封装LangfuseOtelConfig.java— OTLP 导出器 + Tracer BeanChatModelObservationFilter.java— prompt/completion 桥接DocMindAgent.java— 5 个自定义 span 埋点
#1 VectorRetriever 向量化失败降级修复(2026-04-27)#
背景
VectorRetriever.embed() 在 Embedding API 调用失败时,返回一个 1024 维的随机向量,然后用这个随机向量去 Milvus 做 COSINE 检索。问题是:随机向量和任何文档都没有语义关系,Milvus 会返回随机匹配的垃圾结果,但下游完全不知道这些结果是”假”的——它们带着正常的相似度分数,混入 RRF 融合、重排序、最终 Prompt,导致 LLM 基于错误来源生成答案。
这是典型的 fail-silent 反模式:错误被吞掉了,系统继续运行但结果不可信。
方案
将 embed() 的 catch 块从”返回随机向量”改为”抛出 RuntimeException”:
// 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 + webPartplaintext每一层都有明确的行为,不会吞掉错误,也不会中断整个请求。
结果
- Embedding API 正常时:行为不变
- Embedding API 异常时:向量检索被跳过,只用 BM25 + Web 结果,答案质量下降但不会出现幻觉来源
- 日志中有明确的
ERROR级别记录,便于排查
面试话术
“我在 code review 时发现向量化失败的降级策略有问题——返回随机向量会导致 Milvus 返回无意义的结果,但下游不知道。这比直接报错更危险,因为系统看起来正常运行,但答案质量不可控。我改成了 fail-fast:向量化失败就抛异常,上层 catch 返回空列表,系统自然降级到 BM25 检索。这体现了一个原则:在不确定的时候,宁可少返回结果,也不要返回错误的结果。”
涉及文件
VectorRetriever.java:120-132— embed() 方法
迭代 #10:输出置信度标注 + 推荐阅读(Phase 4)#
背景痛点
用户面对 AI 生成的答案,缺乏信心锚点——不知道这个答案靠不靠谱。系统内部 Self-Reflection 已经计算了 confidence 分数,但只在后台日志中,用户看不到。同时,用户阅读完答案后想继续深入某个主题,但不知道知识库里还有什么相关文档。
方案设计
-
置信度分级标注(无新增 LLM 调用,零额外延迟):
- 复用已有的
classifyConfidenceBand()分级逻辑(HIGH ≥0.85 / MEDIUM ≥0.60 / LOW) - 在 SSE
done事件新增confidenceLevel(高/中/低中文)供前端直接显示 - 前端用彩色徽章(绿/黄/红)直观呈现
- 复用已有的
-
推荐阅读生成(
RecommendationGenerator):- 双路径推荐策略:同 category 文档优先 → tags 共现匹配补充
- 从已检索 chunk 的 tags(JSON 数组)和 category 反查其他文档
- 排除本次已引用的文档,避免重复推荐
- 限制最多 3 条推荐,避免信息过载
实现细节
// 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_basejava前端 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