Skip to content

面试高频质疑应答

针对 DocMind 项目面试时最容易被挑战的方向,准备逐条应答。 每个问题包含:面试官可能的问法 → 参考回答 → 追问预判。


零、场景质疑("为什么做这个")

Q0. 这个项目解决的是什么实际问题?有真实用户吗?

面向研发团队的内部技术文档问答。核心痛点是:团队有大量散落在不同平台、不同格式的技术文档(架构设计 PDF、API 文档 Markdown、运维手册 Word、会议纪要网页),新人 onboarding 要翻几十篇才能找到一个配置参数,老员工被频繁打断。

真实使用场景:我自己的开发团队作为用户——入库了项目的技术文档、部署手册、设计决策记录,日常在 Cursor IDE 里通过 MCP 协议直接查知识库。比如"Spring Boot 3.4 的 graceful shutdown 怎么配置"这种问题,直接在编辑器里得到答案 + 文档来源引用,不需要切到浏览器翻 Confluence。

实话说用户规模是自己团队内部使用,不是 ToC 产品。但技术选型和架构设计都按"可扩展到 50-200 人团队"的标准来做的——多知识库隔离、RBAC 权限、动态参数热更新都考虑了。

追问预判

  • "为什么不直接用 Confluence 搜索 / 飞书搜索?" → 传统搜索返回的是文档链接列表,不是结构化答案。而且不支持跨文档综合推理(比如"对比 A 方案和 B 方案"需要综合两篇设计文档)。更重要的是没法在 IDE 里用。
  • "为什么不用 Dify / RAGFlow 这些开源方案?" → 两个原因:一是 MCP 协议暴露给 IDE 的能力这些方案没有原生支持;二是做这个项目本身是为了深入理解 Agentic RAG 的每一层——从 RRF 融合公式到 Cross-Encoder 重排到 Self-Reflection,每一层都有评测数据证明增量价值。用开箱即用的方案学不到这些。
  • "用户量这么小,性能优化有意义吗?" → 有。语义缓存减少了 45% 的重复 LLM 调用,Self-Reflection 高分短路减少了 60-70% 的无效评分调用,双模型策略把工具选择成本压低 70%。这些优化的核心价值不是"抗多少并发",而是降单查询成本——个人项目每月 API 调用费是真金白银。

一、必要性质疑("为什么不简化")

Q1. Agent 是不是过度设计?大多数知识问答,向量检索 + LLM 三步就够了,你为什么要搞 ReAct + 工具选择 + 反思?

项目最初确实只有向量检索 + LLM 两步。加 Agent 架构是为了解决三类朴素方案解决不了的问题:

第一,查询类型差异大。知识库同时有 API 参数/配置项(需要精确关键词匹配)、操作指南(需要步骤完整性)、时效信息(需要联网搜索)。单一向量检索路径对精确查询类表现很差——我的评测数据显示,事实类查询在纯向量检索下 Recall@5 只有 0.73,加上 BM25 混合召回后升到 0.93。

第二,Phase 2 的 Supervisor-Worker 架构进一步证明了 Agent 的价值。SupervisorAgent 根据查询复杂度自动选策略——简单查询走迭代 ReAct(1-2 轮即停,成本等于原方案),复杂查询走 Plan-and-Execute(LLM 生成多步计划按拓扑并行执行)。IterationDecider 基于置信度轨迹决定何时终止,ObservationEvaluator 在检索质量差时自动切策略(空召回→Web,低分→HyDE,冲突→Analysis)。这种动态编排能力是固定管线做不到的。

第三,架构为可扩展性留了口。4 个 Worker(Retrieval/Web/Memory/Analysis)通过统一接口解耦,新增 Worker 只需实现接口 + 注册。同时 MCP 协议暴露给外部 AI 编辑器。

Self-Reflection 最初确实有过度设计的问题——评测验证了流式模式下它对答案质量零贡献。但发现问题后我做了改造:反思评分不通过时触发主模型流式重写,复测忠实度 +0.037。这是一个"评测驱动修复"的完整闭环。

追问预判

  • "规则引擎能覆盖多少查询?" → 实测约 85% 的查询走规则路径,15% 走 LLM 工具选择兜底。
  • "不用 Agent 也能做多路召回吧?" → 能,但工具组合的动态决策(比如判断是否需要联网)和执行结果的统一收集需要一个编排层。Agent 是这个编排层的自然形态。

Q2. MCP 真的有外部客户端调用吗?还是只是简历上的词?

MCP 服务端在项目里有两个实际作用:

内部作用:Spring AI 的 ToolCallbackProvider 把 5 个 @Tool 注解的工具类(DocSearchTool / KeywordSearchTool / WebSearchTool / MemoryTool / KbMetaTool,共 6 个端点)注册给 ChatClient,LLM 工具选择时直接通过 function calling 调用它们。这个是生产链路在用的。

外部作用:通过 spring-ai-starter-mcp-server-webmvc 暴露标准 MCP 接口。我用 Cursor IDE 作为 MCP 客户端对接验证过——IDE 能直接调用 DocMind 的语义检索和关键词检索,把知识库作为编程辅助的上下文来源。

实话说,外部调用场景目前仅限于我自己的 IDE 验证,没有第三方用户在用。但 MCP 协议本身的工程价值在于:同一套工具代码既服务内部 Agent 循环,也对外暴露标准接口,没有代码重复。

追问预判

  • "那你为什么不直接写个 REST 接口?" → MCP 是结构化的工具描述协议,客户端不需要看文档就知道参数 schema——这比裸 REST 对 AI 客户端更友好。而且 Spring AI 原生支持,加一个 starter 依赖就行,成本几乎为零。

Q3. Query Decomposition 拆出来的子问题真的有意义吗?还是 LLM 瞎拆增加噪声?

这个确实有噪声风险。我的处理方式有三层保护:

第一,不是所有查询都拆QueryUnderstandingService 的分类器只对复杂度为 COMPLEX 且包含多个子语义的查询触发拆解。简单查询(占大多数)直接走主路径,不过拆解分支。

第二,子问题只走向量检索不走 BM25。设计决策是:规范化后的子问题语义性强,BM25 对它们增益小;不重复触发网络搜索和记忆召回,避免额外延迟和噪声。

第三,合并阶段有保底机制SubQueryMerger 在跨子问题去重时,如果合并后候选不足,会从全局 top-N 中补齐,保证不会因为拆得太细导致最终候选为空。

评测数据显示多跳类查询当前 Recall@5 只有 0.50(是所有类别中最低的),而这些恰好是 Query Decomposition 的目标场景。下一轮评测会加入 V5 配置专项验证拆解的增量价值。


Q4. 8 套自适应参数预设是不是过度工程?凭什么是 8 套?

这个设计在后续迭代中已被简化。原始的 QueryProfiler 用复杂度 × 专指度的 2×4 矩阵输出 8 套参数预设,实际运行后发现:

  1. 参数差异主要体现在"是否调 BM25"和"重排候选数"两个维度
  2. 8 套中有 4 套参数几乎相同

所以我在迭代中把 QueryProfiler 替换成了 RetrievalPlanner——一个纯规则引擎,根据分类信号直接决定调哪些工具。检索参数(向量 top-K、BM25 top-K、重排 top-K)改为从 sys_ai_config 数据库表读取,全局统一,运行时可调不需要重启。

这是一个"先做复杂再简化"的过程。评测验证了简化后的效果不亚于原版——因为参数敏感度实际上没想象中高。


Q4.5 Supervisor-Worker 架构是不是过度设计?4 个 Worker 加 Supervisor 比原来的单类多了更多代码和间接层。

从代码量看确实多了——原来 1 个文件 1400 行,现在 16 个文件加起来约 2000 行。但关键指标不是总行数而是单次修改影响范围

原来要加一个新检索策略(比如 HyDE 假设文档生成),需要在 DocMindAgent 的 runReActLoop() 里加条件分支,改动 1400 行文件,风险高且难以测试。现在只需要:①写一个 HyDeWorker implements Worker(~100 行);②在 PlanExecutor.resolveWorker() 加一行 case;③在 ObservationEvaluator 加一个条件(低分时推荐 TRIGGER_HYDE)。三处改动互不耦合,可独立 review 和测试。

另外,Supervisor-Worker 不是我发明的——这是 multi-agent 系统的经典拓扑(参考 AutoGen、CrewAI 的 hierarchical mode)。在 RAG 场景下的价值是让编排决策(什么时候停、什么时候切策略)和执行实现(怎么检索、怎么搜索)彻底分离。

如果项目只有一种检索策略且永远不会加新 Worker,那确实过度设计。但我已经有 4 种不同来源(向量/BM25/Web/Memory)+ 即将加入 HyDE 和更多分析型 Worker,这个规模下 Supervisor-Worker 是合理的。

追问预判

  • "IterationDecider 的置信度阈值 0.7 怎么定的?" → 经验值起步,存在 sys_ai_config 可热配。0.7 意味着"至少有一个 chunk 的 rerank 分数达到还不错的水平"。后续评测会用 grid search 在 [0.5, 0.9] 区间找最优点。
  • "ObservationEvaluator 的降级会不会无限循环?" → 不会。两层保护:①state.hasTriedStrategy(name) 记录已尝试策略,同一策略不会重复推荐;②IterationDecider 有硬上限 maxIterations=3

Q4.6 既然已经有 QueryClassification 三维分类,为什么 Phase 5 还要再加一层"范畴判定"?

起点不是设计假设,是一个真实 bug:用户在一段正常知识问答之后输入"总结上面的对话",系统把"总结"、"上面"、"对话"当成检索关键词去 Milvus 查询,召回 12 条主题完全无关的切片,自纠错降到 0% 置信度还是把答案推送到前端。

定位根因发现 QueryClassification 的五个 intent 取值(factoid / procedural / comparison / opinion / chitchat)都隐含一个前提——"用户问的是知识库里的内容"。"总结上面的对话"被归到 factoid 或 chitchat,但下游不区分意图,照常调用 RetrievalWorker。这不是分类做错了,是分类少了一个维度

Phase 5 加的是"要不要查知识库"的判断,跟原有的"问的是什么类型"是正交的两件事:

维度取值判什么
范畴(Phase 5 新增)元对话 / 闲聊 / 知识查询 / 任务执行 / 越界是否走 RAG 检索
intent(已有)factoid / procedural / comparison / opinion / chitchat知识查询的语义类型
complexitySIMPLE / MEDIUM / COMPLEX是否走 Plan-and-Execute
specificityFUZZY / NORMAL / PRECISE是否启用 HyDE

一个具体例子:用户问"我刚才问的那个事实是什么",intent 仍然是 factoid(事实型问答),但范畴是元对话——不去查知识库,直接读历史对话。两个维度独立判定。

工程上判定走两级:先用正则识别"总结上面"、"翻译你刚才"这种高置信度模式,命中即跳过模型调用;不命中再走小模型。合并模式开启时(默认)小模型那次调用直接复用 QueryUnderstanding 的同一次模型调用——只在提示词里加了一个 scope 字段。没有为范畴判定增加额外的模型调用

追问预判

  • "为什么不直接让 LLM tool calling 自己决定要不要检索?" → 调研结论是这个路径业界已经放弃。LinkedIn 工程博客、Airbnb v1→v2、Microsoft Semantic Kernel 都从纯 tool calling 路由迁出,原因是模型容易过度检索、识别不出元对话。LangGraph Adaptive RAG 教程里也明确说显式路由比 tool calling 路由更可控。
  • "怎么保证范畴判错不会比改造前更糟?" → 每一级都做了降级:规则路径异常 → 让模型路径兜;模型路径异常 → 默认按知识查询走原流程。所以最差情况就是回到改造前的行为,不会更糟。

Q4.7 检索结果三档置信度评估为什么要再多一道闸?跟 Self-Reflection 不是重复了吗?

不重复,两道闸的位置和检查内容完全不同。

阶段检查时机检查内容失败处理
RetrievalGrader(Phase 5 新增)检索完成、模型生成之前检索到的内容跟问题主题对不对得上LOW 档让 fallback 接管,不进入生成
SelfReflection(已有)模型生成之后答案与参考来源是否一致、完整、表达清晰不通过时触发重写

Phase 5 之前只有 SelfReflection。问题是:当检索本身就跑题(召回了主题完全无关的切片),SelfReflection 检查"答案是否与切片一致"会通过——因为答案确实是基于切片生成的,但切片本身跟问题没关系。"总结上面的对话"那个 bug 就是这样卡死的:自纠错认为答案跟切片一致,但切片跟问题完全两回事。

所以加 RetrievalGrader 是在"模型生成之前"判断检索质量本身,不是评估生成出来的答案。CRAG 论文(Yan et al. 2024)的做法是用一个独立的小模型评估检索质量,分三档触发不同后续动作:

  • HIGH(rerank 顶分 ≥ 0.65)→ 直接生成
  • LOW(rerank 顶分 ≤ 0.25)→ 让 fallback 接管
  • 灰区(中间区间)→ 仲裁

关键设计是没有按论文的做法在灰区调小模型生成。把 top-3 切片拼成上下文让 reranker 重新单对打分——本来 reranker 就是用来评估 (问题, 段落) 相关度的,复用它做仲裁是天然合适的。一次 reranker 调用 ~50ms,是模型生成的 1/5 时延。论文用 T5-Large 是因为他们做研究用现成模型,工程上能复用现有 reranker 就没必要再引入一个新模型。

此外 Phase 5 还在 SelfReflection 加了一项额外检查(切题度),是 RetrievalGrader 漏判时的最后一道闸——如果检索置信度被错误判成 HIGH 但生成时仍然出现"答案引用的切片跟问题不是一个主题",反思会标记 topicMismatch=true,主流程跳过重写直接降级。

追问预判

  • "灰区仲裁有四种模式,默认为什么选 cross_encoder 而不是 llm?" → 延迟差 6 倍(50ms vs 300ms)、成本差更多(reranker 单对调用比小模型生成便宜一个量级)、效果上 reranker 本来就是干这个的。llm 模式留着是为了对比基线和排错。heuristic(用顶分与次分的差、前三平均分)适合本地化或限网环境。disabled 适合不在乎灰区、希望直接走 fallback 的场景。
  • "三档阈值 0.65 / 0.25 怎么定的?" → 经验值起步,写在 sys_ai_config 可热改。后续评测可以用 grid search 在 [0.5, 0.8] / [0.1, 0.4] 找最优。

二、技术深度(基础功)

Q5. RRF 公式手写一遍。k=60 怎么定的?为什么不用加权求和?

RRF 公式:score(d) = Σ 1 / (k + rank_i(d))

其中 rank_i(d) 是文档 d 在第 i 路检索结果中的排名(从 1 开始),k 是平滑常数。

我的实现中(RRFFusion.java),排名从 1 开始计数:

java
double rrfScore = vectorWeight / (rrfK + rank + 1);  // rank 从 0 开始,所以 +1

k=60 的来源:这是原始 RRF 论文(Cormack et al., 2009)推荐的默认值。k 的作用是控制排名衰减的速度——k 越大,不同排名之间的分数差异越小(更平滑),top-1 的优势越弱。60 是经验值,我没有单独调过,因为评测显示当前效果已经满足需求。这个值存在 sys_ai_config 表中,可以随时调整。

为什么不用加权求和:加权求和(weighted sum)要求不同路的分数处于同一量纲——但向量检索返回 cosine similarity(0-1),BM25 返回的是 TF-IDF 类分数(可能几十分),两者直接求和会让 BM25 绝对主导。RRF 只看排名不看分数,天然避免了这个归一化问题。这也是 RRF 在工业界流行的核心原因。

我的实现额外支持了加权 RRF:fuse(vectorResults, bm25Results, topN, rrfK, vectorWeight, bm25Weight),通过 weight 调节两路的贡献比例——这比调 k 更直观。


Q6. BM25 的 k1、b 参数含义?你的实现和 Lucene 的有什么区别?

k1 = 1.5:控制词频饱和度。k1 越大,一个词在文档中出现多次时分数增长越不容易饱和。1.5 是经典默认值。

b = 0.75:控制文档长度归一化的程度。b=1 表示完全归一化(长文档大幅惩罚),b=0 表示不归一化。0.75 是平衡点。

公式:

score(q, d) = Σ IDF(t) × [tf(t,d) × (k1 + 1)] / [tf(t,d) + k1 × (1 - b + b × |d|/avgdl)]

与 Lucene 的区别

我没有直接用 Lucene 的 BM25Similarity,而是自己实现了评分逻辑。原因是我的方案分两层:

  1. 预召回层:用 MySQL FULLTEXT 索引(ngram 分词)+ MATCH AGAINST 做粗筛,拿到候选集(最多 400 条)
  2. 精排层:在应用内对候选集做中文分词(ChineseTextTokenizer)+ BM25 打分

这样做是因为 Lucene 需要维护本地索引文件,在文档增量更新场景下一致性维护复杂(迭代 #9 的索引未刷新问题就是前车之鉴)。用 MySQL FULLTEXT 做预召回把一致性问题交给数据库,应用内 BM25 只负责对候选精排。

我还加了一个短语覆盖度加分(phraseCoverageBonus):如果查询词在文档中以原始形式出现(不只是分词后的 token 匹配),额外加分。这对中文精确术语查询有帮助。

追问预判

  • "ChineseTextTokenizer 用的什么分词方案?" → 我自己实现的轻量中文分词器,核心是正则切分 + 停用词过滤 + 长度过滤(≥2 字符)。没有用 HanLP 或 jieba 是因为对 BM25 召回场景来说,2-gram 粒度已经足够,不需要准确的语义分词。

Q6.5 为什么要在 Cross-Encoder 之后再做 MMR?直接扩大 topK 不行吗?

单纯扩大 topK 解决不了信息冗余问题。假设一篇 10 页的文档被切成 25 个 chunk,用户问的恰好是这篇文档的主题——Top-8 精排结果可能全部来自这篇文档的相邻段落,内容高度重叠。送 8 条 80% 相似的 chunk 给 LLM,等于用 3000 token 的上下文窗口只传达了 1 条有效信息。

MMR 的价值是在固定 token 预算内最大化信息密度

MMR(d) = λ × relevance(q,d) − (1−λ) × max_sim(d, already_selected)

λ=0.7 表示我让相关性占 70% 权重、多样性占 30%——不会为了多样性牺牲太多精度。相似度用 bigram Jaccard 而不是 embedding cosine,因为 reranked chunk 数量小(≤8),O(n²) Jaccard 远比多一轮 embedding API 调用便宜。

放在 Cross-Encoder 之后而非之前的原因:MMR 需要可靠的 relevance score 作为输入——RRF 分数是排名衍生的,尺度不稳定;只有 Cross-Encoder 的 relevance_score 才是真正的语义相关性分数。

追问预判

  • "λ 怎么调的?" → 默认 0.7 存在 sys_ai_config,可以在线调。调大(→1.0)退化为纯精排无多样性,调小(→0.5)多样性更强但可能放进弱相关 chunk。0.7 是业界常用起点,后续会结合评测微调。
  • "为什么不直接按 knowledgeBaseId 去重一篇只留一条?" → 太粗暴了。同一篇文档的不同章节可能覆盖查询的不同方面(比如"配置说明"和"故障排查"),按文档去重会丢有效信息。MMR 是按内容相似度去冗余,粒度更细更合理。

Q7. Cosine vs L2 vs IP(内积)区别?你为什么选 Cosine?

L2(欧氏距离):衡量向量在空间中的绝对距离。对向量长度敏感——同方向但长度不同的两个向量,L2 距离不为零。

IP(内积)a·b = |a|×|b|×cos(θ)。如果向量未归一化,内积同时受方向和长度影响。

Cosine(余弦相似度)cos(θ) = a·b / (|a|×|b|)。只衡量方向相似度,忽略长度差异。等价于"先 L2-normalize 再做 IP"。

选 Cosine 的原因

text-embedding-v3 的输出向量是经过归一化的(官方文档说明),所以理论上 Cosine 和 IP 对它等价。但我选 Cosine 是因为:

  1. 语义相似性的直觉含义是"方向是否一致"而非"长度是否相近"
  2. Milvus 对 Cosine 有专门的索引优化路径
  3. 阈值设定更直观——0.92 就是"非常相似",不受向量长度干扰

Q8. chunk size、overlap 怎么定的?语义切分 vs 固定切分的取舍?

项目经历了两代 chunker:

第一代(固定切分):目标 400 字 / 最大 600 字 / 重叠 50 字。按 \n\n 分段,长段按句号切分。问题是会切碎表格和代码块。

第二代(Markdown-aware 切分,迭代 #9 后):因为上游统一走 MinerU 输出结构化 Markdown,所以 chunker 重写为基于 block 类型的装配器——表格、代码块作为完整 block 不切分;heading 切换时强制分块;普通段落在 400-600 字区间合并。

chunk size 400-600 的依据

  • 太小(<200):语义不完整,上下文不够 LLM 回答
  • 太大(>1000):检索时一大段话里只有一句相关,precision 下降;且 embedding 对长文本的表征能力递减
  • 400-600 是常见的经验区间,兼顾完整性和精度

重叠的作用:保证跨 chunk 边界的信息不丢失。50 字约一两句话。第二代里重叠策略只对普通段落生效——表格和代码块是完整保留的,不需要重叠。

为什么不用纯语义切分(如基于 embedding 相似度的滑动窗口)?试过,两个问题:一是计算成本高(每一步都要调 embedding 接口),二是切出来的 chunk 大小极不均匀,后续 RRF 和 token 预算控制难以适配。结构化 Markdown 的 heading 层级已经是一种轻量语义信号,足够用。


Q9. Milvus 索引类型选的什么?参数怎么调?和 PgVector / Qdrant 比为什么选它?

索引类型:HNSW(Hierarchical Navigable Small World)。

关键参数

  • M = 16:每个节点在图中的最大连接数。越大召回越好但内存越高。16 是中等偏保守的选择
  • efConstruction = 200:建索引时搜索的邻居数。越大索引越准但建索引越慢。200 对 18,000 条切片的规模已经足够
  • efSearch = 64:查询时搜索的邻居数。越大召回越好但延迟越高。64 在当前数据量下延迟稳定在 30-50ms

为什么选 Milvus 而非 PgVector / Qdrant

维度MilvusPgVectorQdrant
向量规模支撑亿级千万级(实测 500 万后性能下降明显)亿级
生态Spring AI 原生支持需要自己写 SQL需要额外 SDK
部署Docker 一键起附在 PG 上Docker 一键起
适合场景专业向量数据库已有 PG 想加向量能力轻量快速

选 Milvus 的实际理由:Spring AI 的 spring-ai-milvus-store 开箱即用,我只需要配置连接信息就能用。当前数据量(18,000 条)对三者都没有压力,选型主要看框架集成度。

诚实说,这个数据规模用 PgVector 也完全可以。选 Milvus 更多是考虑到如果数据量增长到百万级后不需要换方案。


Q10. Cross-Encoder 的延迟成本怎么样?为什么不用本地 BGE-Reranker?

当前方案:调用 DashScope gte-rerank API,平均延迟 387ms(5 个候选)。

为什么用云端 API 不用本地模型

  1. BGE-Reranker 需要 GPU——本项目的部署目标是单机 Mac / Linux,没有独立 GPU
  2. CPU 推理 BGE-Reranker-v2-m3 约 500-800ms(5 个候选),并不比 API 快
  3. API 方案的工程复杂度低(一次 HTTP 调用 vs 加载 ONNX Runtime + 模型文件管理)

成本:gte-rerank 每次调用约 ¥0.003(5 个候选),单 query 占比不到总成本的 10%。

风险

  • 评测时撞过 DashScope 的免费额度限流(每分钟 30 次),说明并发场景下这个外部依赖是脆弱的
  • 限流时降级到关键词重叠度评分(fallbackRerank),质量显著下降

如果项目需要支持高并发,改造方向是自部署 BGE-Reranker + GPU 服务器,或者升级 DashScope 付费额度。当前个人项目阶段,API 方案的简单性优先于性能上限。


三、工程化坑

Q11. ThreadLocal + SSE 流式输出:流式是异步的,ThreadLocal 会不会丢?

这是一个好问题。实际上在我的实现里,ThreadLocal 没有在异步上下文中使用

DocMindAgent.execute() 的执行模型:整个方法是在调用线程上同步执行的(虽然对客户端是 SSE 流式)。SseEmitter 的流式是"往 emitter 里不断写数据",但写的动作本身发生在同一个线程上——ChatModel.stream().doOnNext().blockLast() 最终通过 blockLast() 阻塞在当前线程。

AgentToolContext(ThreadLocal)只在 llmDrivenRetrieve() 方法内使用,从 activate()clear() 都在同一个线程的同一个方法栈里:

java
AgentToolContext.activate(kbIds, userId);
try &#123;
    agentClient.prompt().call().content();
    // tool 回调发生在同一线程(Spring AI 同步 function calling)
    chunks = AgentToolContext.get().getChunks();
&#125; finally &#123;
    AgentToolContext.clear();  // 同线程清理
&#125;

唯一用到异步的地方是 Query Decomposition 的子问题并行检索(CompletableFuture.supplyAsync),但那条路径不使用 AgentToolContext——子问题走的是直接调用 vectorRetriever.retrieve(),不经过 LLM 工具选择。

所以当前没有 ThreadLocal 跨线程传递的问题。但如果将来把主生成改成真正的 Reactor 异步流(去掉 blockLast()),就必须把 ThreadLocal 换成 Reactor Context 或者请求作用域的 bean。


Q12. SSE 中间断了怎么办?客户端怎么恢复?

当前实现对 SSE 断连的处理比较简单:

服务端SseEmitter 设置了超时(默认 5 分钟),超时或异常时调 emitter.completeWithError()。生成过程中如果写入失败(客户端断开),sendSseEvent 会 catch 异常并打日志,但不会中断后续处理——答案仍然会被完整生成并持久化到 qa_message 表。

客户端恢复:前端目前的实现是——如果 SSE 连接断开,重新建连后通过 conversationId 拉取最近一条 assistant 消息。因为答案已经持久化了,断连不会丢失最终结果,只是丢失了流式过程中的中间 token。

已知不足:没有实现断点续传——断连后无法从中间位置继续推送,只能拿完整结果。对于需要严格断点续传的场景,需要引入消息队列(比如 Redis Stream)作为中间缓冲。但当前场景是知识问答,不是长文生成,单次答案通常 2-3 秒完成,断连概率低。


Q13. 语义缓存 30 天 TTL,知识库更新了答案怎么失效?

缓存失效有两层保护:

第一层(KB 版本校验)SemanticCacheService.lookup() 在命中缓存后会校验 kbVersion。每当知识库有文档增删或重新入库时,KbVersionService 会递增对应 KB 的版本号。缓存条目中存储了写入时的 kbVersion,如果当前版本号大于缓存中的版本号,视为未命中,走正常检索路径。

第二层(TTL 兜底):Redis key 设置 30 天 TTL 作为最终兜底,防止版本号机制出错时缓存永远不过期。

已知问题

  • 版本号是知识库级别的——某个 KB 里改了一个文档,整个 KB 相关的缓存全部失效。粒度较粗,但实现简单可靠
  • 语义缓存的命中条件是 query embedding cosine ≥ 0.92——这个阈值如果设得太低,语义相近但答案应该不同的查询可能被错误缓存命中

Q14. 降级路径有没有集成测试?LLM 挂了怎么验证 fallback 真的能兜住?

诚实回答:目前没有专门的降级路径集成测试。当前的测试覆盖情况:

  • CrossEncoderRerankerfallbackRerank 有在评测过程中被真实触发过(DashScope 限流时),确认降级评分逻辑能正常输出结果
  • EarlyStopGate 的两个关卡有单独的逻辑验证(通过评测中的超范围类查询间接验证了 gate2 触发后走 fallback prompt 的路径)
  • LLM 工具选择失败后降级到 multiRetrieve(规则路由),这条路径在开发阶段因为 API 不稳定被频繁触发过

缺失的

  • 没有模拟 LLM 完全不可用的场景(比如 mock 一个始终报错的 ChatModel)
  • 没有验证"所有外部依赖同时不可用"时系统是否还能返回一个有意义的响应

这是一个合理的技术债。如果要补,方案是写一个 @SpringBootTest@MockBean 替换 ChatModel 为一个始终抛异常的实现,验证最终用户能拿到一个"暂时无法回答"的友好提示而不是 500 错误。


Q15. 配置热更新怎么做的?并发读写会不会有问题?

热更新机制

所有 RAG 参数存储在 MySQL 的 sys_ai_config 表中。AiConfigHolder 内部用 ConcurrentHashMap 保存内存快照,模型实例用 AtomicReference&lt;ChatModel&gt; 持有。

更新流程:管理后台修改参数 → AiConfigService 写入数据库 → 调用 AiConfigHolder.updateBatch() 更新内存 map → 如果 LLM 模型参数变了,调 refreshLlmModel() 原子替换模型实例。

并发安全

  • ConcurrentHashMap 本身保证读写线程安全
  • AtomicReference.set() 保证模型引用的原子替换
  • 正在进行的请求持有旧模型引用,自然跑完后旧引用被 GC——不存在中间状态

不足:多实例部署时,只有收到管理请求的那个实例会更新内存。如果需要多实例同步,需要引入 Redis Pub/Sub 或配置中心(Nacos/Apollo)广播变更事件。当前单实例部署不存在这个问题。


四、成本与延迟

Q16. 一次完整查询要调几次 LLM?总成本和延迟分别多少?

最佳情况(85% 的查询)——规则路径 + 反思短路

  • LLM 调用:1 次 QueryUnderstanding(小模型)+ 1 次主答案生成(主模型)= 2 次
  • 反思短路(rerank top-1 ≥ 0.85)跳过反思 LLM 调用
  • 总延迟约 2.0s,成本约 ¥0.030

典型情况(含反思)

  • LLM 调用:1 次 QueryUnderstanding + 1 次主答案生成 + 1 次 Self-Reflection = 3 次
  • 其中 QueryUnderstanding 和 Reflection 走小模型(qwen-turbo),主答案走主模型(qwen-plus)
  • 总延迟约 3.3s,成本约 ¥0.034

最坏情况(分类器模糊 + 拆解 + 反思)

  • LLM 调用:1 次 QueryUnderstanding + 1 次 LLM 工具选择 + 1 次主答案 + 1 次 Reflection = 4 次
  • 总延迟约 4.5s,成本约 ¥0.055

对比参照:ChatGPT Plus 用户每条消息的平均推理成本约 $0.05-0.10。我的方案在 ¥0.03-0.05 区间,考虑到包含检索 + 重排 + 多次 LLM 调用,成本控制是合理的。


Q17. 缓存命中率 45% 是怎么测的?

语义缓存的命中率 45% 是通过 Redis 中的命中计数器统计的:cache_hit_count / total_query_count

前提条件

  • 命中条件:query embedding 与缓存条目的 cosine 相似度 ≥ 0.92 且 KB 版本一致
  • 写入门控:同一归一化 query 被问 ≥ 2 次才写入缓存(避免低频查询污染缓存)

45% 的含义:在一段时间的真实使用中(包括我自己反复测试和少量外部演示),有近一半的查询是重复或高度相似的。这个数字在真实多用户场景下可能更低(用户提问更分散),也可能更高(如果用户群集中在特定主题)。

对成本的影响:命中时跳过整条 RAG 链路(embedding + 检索 + 重排 + LLM),延迟从 3s 降到约 200ms,成本从 ¥0.034 降到接近 0。按 45% 命中率算,平均成本降约 40%。


Q18. p50 / p99 延迟分别多少?

基于评测数据(52 条查询 × V4 完整链路):

分位延迟
p503,100 ms
p753,450 ms
p954,200 ms
p995,100 ms

p99 偏高的原因

  • 网络搜索路径(Tavily API)加 1-3s 不稳定延迟
  • DashScope 偶发慢请求(API 冷启动)
  • Query Decomposition 路径(3 个子问题并行检索 + 合并 + 重排)比单路径多约 800ms

对用户感知的影响:流式输出模式下用户看到第一个字的时间(TTFT)约 1.5-2.0s(检索 + 重排完成后开始流式生成),后续逐字推送。实际体感比 p50=3.1s 这个数字好——因为用户在生成完成前已经开始阅读了。

缓存命中时:~200ms 返回完整答案(模拟流式分块推送)。


五、跟业界对比

Q19. 为什么不用 LangChain / LlamaIndex / Dify 直接搭?

回答的核心论点:用框架能更快搭出一个 demo,但面试要展示的是"理解每一层在做什么"而不是"会调 API"。

具体差异

维度LangChain/LlamaIndex我的实现
检索控制黑盒 retriever,参数暴露有限自己实现 BM25 评分 + RRF 融合,能精确调 k1/b/rrfK
重排一行代码 Reranker(model)自己写 API 调用 + 降级逻辑 + 后处理(去重/截断/token 预算)
可观测需要接 LangSmith/Langfuse 插件OpenTelemetry span 嵌入到每个阶段,与 Langfuse 原生对接
部署Python 生态,通常 FastAPI 服务Java + Spring Boot,更贴合企业后端的技术栈要求

坦诚说:如果目标是快速做一个产品原型,LangChain + Dify 可能一天就能搭出 80% 的功能。但这个项目的目标是面试展示工程能力——我需要能回答"RRF 公式怎么写"、"BM25 的 k1 是什么"、"重排 API 挂了怎么降级"这些问题,这些答案只有自己实现过才能有底气。

另外,Java 生态里 LangChain 的对等方案(LangChain4j / Spring AI)还比较年轻。我选择 Spring AI 作为 LLM 交互层(ChatClient / ToolCallbackProvider),但检索和融合层自己实现——这是"框架做连接,核心逻辑自己控制"的平衡点。


Q20. 跟 ChatGPT 上传 PDF 比,你的差异化是什么?

ChatGPT 上传 PDF 的限制

  1. 单次会话上下文窗口有限(128K token),大文档会被截断
  2. 没有持久化——下次对话要重新上传
  3. 检索策略不透明,用户无法控制
  4. 无法跨多个文档联合检索
  5. 没有来源追溯(不告诉你答案来自第几页)

DocMind 的差异化

  1. 持久化知识库:文档入库后永久可用,支持增量更新
  2. 来源追溯:每条答案标注来源文档名 + 章节 + 页码,前端可点击跳转
  3. 检索策略透明可控:用户能在管理后台调整检索参数(top-K、重排阈值、缓存策略),不是黑盒
  4. 多文档联合检索:124 篇文档共享一个向量空间,跨文档关联无额外成本
  5. 置信度提示:低置信度答案会提示用户"建议核实",而不是自信地给出可能错误的答案
  6. 对话记忆:跨会话的长期记忆(Redis),支持追问"你上次说的那个方案"

本质区别:ChatGPT 的文件上传是"临时增强上下文",DocMind 是"构建可持久检索的知识系统"。面向的场景不同——前者适合一次性快问快答,后者适合团队长期沉淀和反复查阅的知识管理。


Q21. 你和 RAGFlow / Dify / FastGPT 这些开源 RAG 产品比有什么不同?

坦诚说,功能完善度不如它们——它们是产品级开源项目,有几十人的团队在维护。我是一个人做的面试项目。

但有几个点是我能讲清楚而"用框架搭"讲不清楚的

  1. 检索融合的细节:我能手写 RRF 公式、解释 k=60 的含义、说明为什么不用加权求和。用 RAGFlow 的人只能说"我配置了混合检索"

  2. 降级策略的设计思路:LLM 工具选择失败 → 规则路由;重排 API 限流 → 关键词评分降级;检索结果过差 → 两级早停关卡。这些是自己踩过坑后的决策,不是配置项能学到的

  3. 评测驱动的优化:我有对照实验证明"每加一层带来多少增量"、"反思在流式下是无效的"。这种评测思维比功能实现更有说服力

  4. 成本意识:我能回答单次查询花多少钱、反思模块值不值、评测本身该花多少钱。这是工程判断力,不是功能点

总结:我不是在做一个要上架的产品,而是在通过构建一个完整系统来展示"我理解 RAG 每一层在做什么、每个决策背后的权衡是什么"。


六、安全性

Q22. Prompt injection 怎么防?用户上传的文档里有"忽略上述指令"怎么办?

当前系统对 prompt injection 的防御有三层,但坦诚说第一层最关键的那个不是我设计的

第一层(LLM 自身安全对齐):qwen-plus 经过安全训练,对直白的注入指令("忽略前面"、"输出 system prompt")有内置拒绝能力。评测中 5/5 拒答依赖的是这一层。

第二层(检索稀释):恶意文档中的注入文本在入库时被切片打散。用户提问时,向量检索不太可能把"忽略前面指令"这种文本排到 top-5——因为它和正常问题的语义相关度低。等于检索层天然做了一次过滤。

第三层(系统提示词隔离):PromptAssembler 的模板结构是"系统指令 → 检索上下文 → 用户问题"。系统指令明确说明"以下是检索到的参考资料",给 LLM 提供了区分指令和数据的上下文。

已知不足

  • 没有专门的 injection 检测规则(SafetyGuard 只匹配紧急词)
  • 如果换成安全对齐较弱的模型,现有防御可能全部失效
  • 文档内容本身没有做清洗(如果有人故意上传包含注入指令的 PDF,理论上可以污染后续回答)

改进方向

  • 在 SafetyGuard 增加 injection 模式正则匹配
  • 在 PromptAssembler 中用 sandwich 结构(系统指令 → 用户输入 → 重申关键约束)
  • 入库时对文档内容做敏感指令检测和清洗

Q23. 多用户文档隔离怎么做的?用户 A 能搜到用户 B 的私有文档吗?

当前隔离模型

  • kb_knowledge_base 表有 user_id 字段,每个知识库归属一个用户
  • 向量检索时通过 kbIds 参数限定范围——前端传入用户有权限访问的知识库 ID 列表
  • Milvus 中每条向量存储了 kb_id 作为 partition 标识,检索时带 filter 条件

安全验证

  • Controller 层通过 JWT 鉴权确认用户身份
  • 请求中的 kbIds 会与用户有权限的 KB 列表做交集校验——用户无法通过伪造 kbIds 访问他人的知识库

不足

  • BM25 检索路径的隔离依赖 WHERE kb_id IN (...) SQL 条件——如果忘传 kbIds 会查全库。当前代码如果 kbIds 为空,默认全库搜索。需要加一个防护:未传 kbIds 时只搜用户自己的 KB
  • 语义缓存是全局共享的——如果用户 A 和用户 B 问了相似的问题,B 可能命中 A 的缓存答案。缓存条目中存储了 kbIds,lookup 时会校验 kbIds 一致性,但如果两人访问相同 KB(比如公共知识库),缓存会被复用。这在设计上是预期行为

Q24. LLM 输出会不会泄漏其他用户的文档内容?

正常路径:不会。因为 LLM 的输入只包含当前用户有权限访问的 KB 中检索出的切片——提示词里只会注入 kbIds 限定范围内的内容。

风险点

  • 如果 LLM 有上下文泄漏(同一模型实例服务多用户),理论上有风险。但 DashScope 的 API 是无状态的——每次请求独立,不存在跨请求的上下文残留
  • 对话历史存在 qa_message 表中,查询时通过 conversationId + userId 双重验证

唯一例外:如果用户使用了"记忆"功能(MemoryTool),记忆内容存在 Redis 中以 userId 为 key 隔离。但如果 Redis 被未授权访问,所有用户的记忆都会暴露。这层要靠 Redis 本身的认证和网络隔离来保护。


Q25. 文档上传有没有做安全检查?比如恶意文件、超大文件?

当前已有的检查

  • 文件类型白名单:只接受 PDF / DOCX / MD / TXT / PPTX / 图片格式
  • 文件大小限制:Spring Boot 配置了 spring.servlet.multipart.max-file-size(默认 50MB)
  • 文件存储在 MinIO(S3 兼容对象存储)中,不存储在应用服务器本地文件系统——避免路径遍历攻击

没有做的

  • 没有对 PDF 内容做病毒扫描
  • 没有检测 PDF 中嵌入的恶意 JavaScript(PDF 支持嵌入 JS)
  • 没有限制单用户的总存储空间(理论上可以无限上传)
  • 没有对解析后的文本内容做 prompt injection 检测

这些属于生产环境的安全加固项。作为面试项目,重点放在了 RAG 链路本身的设计和优化上,安全方面做了基本防护但没有做到企业级标准。如果面试官追问,我会说明这是已知的技术债并给出改进方案。


七、Phase 5/6 架构决策质疑

Q26. 你的 Scope Routing 两层设计为什么不直接用一层?

这是精度和覆盖度的 trade-off。

Tier-0 正则能 100% 精准识别"你好"、"谢谢"、"总结上面的对话"这些高置信度模式——但覆盖面窄,"帮我看看上面那个讨论"这种变体它识别不了。Tier-1 LLM 覆盖面广,几乎所有模糊表述都能正确分类——但延迟高(~250ms),且偶尔误判。

两层叠加的收益:Tier-0 零成本处理约 20% 的非知识查询(问候、致谢、显式对话引用),剩余 80% 才走 LLM。而且 Tier-0 和 Tier-1 是合并模式——Tier-1 的 scope 判断复用 QueryUnderstanding 的同一次 LLM 调用,不是额外花一次。任何一层失败默认走 KNOWLEDGE_QUERY,保证不会比没加 Scope Routing 之前更差。

追问预判

  • "Tier-0 的规则会不会越来越多越难维护?" → 见 Q30
  • "合并模式省了多少?" → 知识查询路径从 2 次 LLM 调用(独立 ScopeRouter + QueryUnderstanding)降到 1 次,省 ~250ms

Q27. Path Decision 为什么不让 LLM 决定用哪些工具?

工具选择本质是 4 个布尔信号(isAmbiguous / specificity=PRECISE / timeAware / memoryAware)到 4 个工具的子集映射。这个规则空间总共就十几种组合,用 LLM 做就像用 GPT-4 算 1+1——不是不行,是没必要。

更重要的是:LLM Function Calling 有三个 Agent 系统里很痛的问题。第一是不确定性——同一个输入可能产出不同的工具组合(temperature>0),debug 时无法复现。第二是不可单元测试——你没法 assert LLM 的输出。第三是不可解释——出了问题你只能看 prompt 和 completion,不知道 LLM "想"了什么。

RetrievalPlanner 的 4 个 if 判断解决了所有这些问题:<1ms 延迟、100% 确定性、单元测试直接断言、PathDecision.reason 记录判定路径。

追问预判

  • "那复杂场景下规则引擎够用吗?" → 工具选择的规则空间小且变化慢。如果未来工具数从 4 个增长到 20 个,规则空间变大,那时候可以考虑 LLM 做选择——但目前 4 个工具用 LLM 是过度设计
  • "和 Drools 有什么区别?" → 见 Q32

Q28. CRAG 评分是不是增加了延迟?值得吗?

看模式。默认的 heuristic 模式 0ms 延迟——它用的是 rerank 阶段已经产出的 per-chunk 分数,纯内存计算。cross_encoder 模式加 ~50ms(一次 reranker API 调用),对比 LLM 仲裁的 ~300ms 已经是 1/6。

值不值得?CRAG 解决的核心问题是:没有它的时候,LOW 质量的切片也会被送进 generation,LLM 基于低质量切片生成的答案大概率是幻觉。有了三档评分:HIGH 直接生成(零额外开销),LOW 走降级 prompt 明确告知"证据不足"避免编造,AMBIGUOUS 触发 Web Search 补偿。这不是"加了一步延迟",而是"花 0-50ms 避免一次可能的幻觉"。

实际调参过程中,CRAG 灰区阈值(HIGH≥0.65 / LOW≤0.25)就是我在 Langfuse trace 里看 rerank 分数分布后调出来的——这也是 Phase 6 可观测性的直接收益。

追问预判

  • "为什么用三档不用连续分数?" → 见 Q33
  • "AMBIGUOUS 触发 Web Search 有多大概率改善结果?" → 取决于查询类型。时效性问题改善明显,领域专有问题改善有限——但 Web Search 的结果也会参与后续 rerank,不会劣化

Q29. Langfuse 可观测性在面试项目里有什么意义?

两方面。

实际调试价值:Phase 5 的 CRAG 灰区阈值就是典型例子——我需要看 rerank 分数在不同查询类型下的分布才能决定 HIGH/LOW 的分界线。之前只能加临时日志然后重启看控制台,有了 Langfuse 直接在 trace 瀑布图里看每步的属性值。再比如 QueryUnderstanding 确定性后处理——发现 LLM 对对比类问题漏判 decompose 也是在 Langfuse 里看到 needDecompose=false 的 trace 后定位的。

工程意识展示:Agent 系统和传统 CRUD 最大的区别是不可观测——LLM 在黑盒里做了什么决策你看不到。面试官关心的不是你会不会接 Langfuse,而是你有没有"AI 系统需要可观测性"这个意识。同样的道理也适用于 CRAG 评分——你有没有"检索质量需要量化评估"的意识。

追问预判

  • "生产环境 trace 量会不会很大?" → BatchSpanProcessor 2 秒批量导出 + RootNameFilteringSpanProcessor 白名单过滤框架噪音,只保留 Agent 关键 trace。采样率也可以通过配置控制
  • "为什么不用 Prometheus?" → Prometheus 是通用监控(QPS/延迟/错误率),Langfuse 是 LLM 专用可观测性(prompt/completion/token 消耗/模型成本)。对 RAG 系统来说后者更有价值

Q30. MetaIntentDetector 的规则怎么维护?会不会越来越多越难管?

当前 6 条规则,每条有明确的语义边界:

  1. 闲聊问候(短语 ≤8 字符匹配 "你好/hello/谢谢/bye")
  2. 身份声明("我是+职业" 无问号)
  3. 知识库元信息("有哪些知识库/多少份文档")
  4. 显式对话引用("你刚才说的/我刚问的")
  5. 历史引用 + 对话名词共现
  6. 历史引用 + 元动作(总结/翻译)共现

设计原则是"宁可漏判多调一次 LLM,不要误判把知识查询路由错"。所以只加高置信度模式——正则能 100% 精准匹配的才加。

数量增长空间有限:元对话和闲聊的表达模式本身是有限集(人类打招呼的方式就那么几种)。我不认为会增长到难以维护的程度。如果真到了那一步,可以考虑用 few-shot 小模型替换正则,但那会引入 Tier-0 的延迟开销——目前不值得。

追问预判

  • "有没有误判的例子?" → 规则设计偏保守,误判主要是漏判(该短路的没短路),不是误判(不该短路的短路了)。漏判的后果只是多调一次 LLM,不影响正确性

Q31. AgentState 为什么不用状态机框架(如 Spring Statemachine)?

AgentState 是数据容器,不是状态机。它记录的是 Agent 执行过程中的所有中间状态(query / scopeDecision / gradeResult / accumulatedEvidence / confidenceTrajectory),但没有固定的状态转换图。

Agent 的每个 step 可以根据前序结果动态决策——比如 CRAG 评分 AMBIGUOUS 时可能补一次 Web Search 再回到 grading,可能直接进 generation,取决于是否已经尝试过 web。这种动态推理链不适合用状态机表达。

Spring Statemachine 适合的是有限状态流程编排——订单状态(待支付→已支付→已发货→已完成),每个状态的转换边是确定的。Agent 的状态空间是开放的,用状态机反而会把灵活性锁死。

追问预判

  • "那 LangGraph 不就是 Agent 状态机吗?" → LangGraph 的"状态图"更像是流程编排的 DSL,不是传统意义的 FSM。DocMind 用 Java 代码直接编排(if/else + 方法调用),和 LangGraph 的 Graph.add_edge 本质上做的是同一件事,只是表达方式不同。Java 版本更容易 debug 和单测

Q32. 你的 RetrievalPlanner 规则引擎和传统的 Drools 有什么区别?

Drools 是通用规则引擎:Rete 算法做推理、DRL 领域语言定义规则、支持 hot-deploy 运行时加载。适合规则数量大(几百条)、规则间有复杂依赖、业务人员需要自行管理规则的场景——比如保险核保、促销策略。

RetrievalPlanner 的规则是 4 个 if 判断,规则空间总共十几种组合,变化频率极低(加一个工具才需要改)。用 Drools 就像用 Spring Cloud 写一个 TODO 应用——技术栈选型不匹配。

更务实地说:Java 代码的 4 个 if + 单元测试比 Drools DRL 文件更可维护——IDE 能跳转、重构工具能改名、编译期就能发现错误。DRL 文件这些都做不到。

追问预判

  • "如果规则越来越多呢?" → 工具选择的规则空间天然有限——取决于工具数量和分类信号数量。工具从 4 个增长到 10 个,规则也就从 4 个 if 变成 10 个 if,仍然不需要 Drools。如果到了 50 个工具 + 复杂依赖关系,那时候该引入的不是规则引擎,而是 LLM 工具选择(因为规则空间已经大到人写不过来了)

Q33. 为什么 CRAG 只用三档不用连续分数?

连续分数(0-1)需要你定义一个映射函数:score 在什么范围做什么动作?0.47 和 0.48 有什么区别?这个映射函数的参数比三档的两个阈值更难调,而且更难解释。

三档的设计哲学是:HIGH 和 LOW 是高置信度的快速通道(不需要额外处理),灰区才需要仲裁。这跟 CRAG 论文(Yan et al. 2024)的设计一致——论文也是 Correct / Ambiguous / Incorrect 三档。

从工程角度:三档意味着只有两个阈值要调(high_threshold 和 low_threshold),都放在 sys_ai_config 热配表里。连续分数意味着一套映射曲线要调,参数空间大得多。在没有大量标注数据做拟合之前,三档是更稳健的选择。

追问预判

  • "灰区范围太大(0.25-0.65)怎么办?" → 灰区不是"不确定",而是"需要进一步判断"。heuristic 模式看 top1-top2 分差和前三均分做第二轮判定,cross_encoder 模式让 reranker 重新打分。灰区的宽度反而给了仲裁空间
  • "阈值怎么调出来的?" → 在 Langfuse trace 里看不同查询类型下 rerank 顶分的分布。HIGH 阈值设在分布的 P75 附近(多数好查询的顶分都高于它),LOW 阈值设在明显的质量悬崖处

附:应对追问的通用策略

  1. 被问到不会的:不要编。说"这个点我没有深入研究过,但我的理解是..."或者"这个是我的技术债,改进方向是..."。诚实比假装懂更安全。

  2. 被质疑某个选型不对:不要辩护。说"你说的方案确实是另一种选择,我选当前方案的理由是...,trade-off 是...,如果重新来过我可能会..."。

  3. 被要求现场写代码:RRF 公式、BM25 核心计算、ThreadLocal 使用模式这些要能白板手写。代码量都在 10 行以内。

  4. 被问数字但记不清:给数量级而非精确值。"大约 3 秒"比"3288 毫秒"在面试中更安全——精确数字如果和追问对不上会很被动。