检索与排序链路#
链路全景#
本图反映当前最新实现(四刀改造 A/C/B/E 落地后)。原 QueryRewriter / ExplicitMemoryExtractor / QueryProfiler / QueryDecomposer 四个独立组件已合并为一次
QueryUnderstandingService.understand()调用。Phase 5 新增顶层范畴路由(6 种范畴)、PathDecision 规则引擎路由、CRAG 三档置信度分级、QueryUnderstanding 确定性后处理。Phase 6 新增 Langfuse OTel 全链路追踪。四刀改造后的关键变化:路径决策收敛为三模式(SELECTED_DOC / RULE_PLANNER / AGENTIC);自研「查询拆解 / 串行多跳 / 自反思」子系统已整类移除——多跳 / 拆解能力改由 agentic 循环(
AgenticSearchOrchestrator,真·LLM 工具调用循环)在运行时自驱;生成即终态(无生成后二次审查 / 重写);结构化引用CitationParser(纯 Java)替代原 SelfReflection 作为置信度来源。
三条短路路径#
DocMindAgent.execute(userId, conversationId, question, kbIds, emitter)
│
├─ [初始化] getOrCreateConversation + saveQaMessage(user)
│ + SemanticCacheService.normalize + incrementFrequency
│
├─ [短路①] SafetyGuard.isEmergency ──── 紧急词命中
│ └─ SSE: start / token / done (emergency=true, 0 LLM 调用)
│
├─ [短路②] 顶层范畴路由(Phase 5 Adaptive Routing)
│ ├─ Tier-0 规则:MetaIntentDetector.detect(纯正则,零 LLM)
│ └─ Tier-1 LLM(合并模式,默认):QueryUnderstandingService.understand
│ → 一次调用同时输出 scope + 分类结果,缓存至 AgentState
│ ├─ META_CONVERSATION → answerFromHistoryOnly(仅历史,不查知识库)
│ ├─ CHITCHAT → answerChitchat + memoryWriteHints 写 Redis
│ ├─ KB_META → KbMetaTool 直接查询知识库元数据("有哪些知识库""多少份文档")
│ └─ OUT_OF_SCOPE → 静态拒答文案
│ → SSE: scope / start / token / done
│
├─ [短路③] SemanticCacheService.lookup(query embedding 近邻 + KB version 校验)
│ ├─ KbVersionService.snapshot():构建 {kbId → version} 映射
│ │ └─ Caffeine 5 秒 TTL 防 DB 频繁查询,chunk 增删时 bump(kbId)
│ └─ 命中 → replayFromSemanticCache(模拟分块流式回放)
│
└─ [主流程] runReActLoop()plaintext主流程详细分层#
runReActLoop()
│
├─ Step 1 QueryUnderstandingService.understand(合并模式复用短路②缓存,0 次额外 LLM)
│ 输出: rewritten / intent / complexity / specificity
│ timeAware / memoryAware / isAmbiguous / scope / scopeConfidence
│ memoryWriteHints(10 字段,已删 needDecompose / multiHop)
│ ├─ memoryWriteHints 非空 → 经 DocMindAgent.stageMemoryWrite 写长期记忆(SoR=MySQL user_memory,Redis 仅缓存)
│ └─ SSE: understanding
│ {original, rewritten, intent, complexity, specificity, degraded}
│
├─ Step 1.5 确定性后处理(Phase 5,零 LLM)
│ └─ applyDeterministicSignals:正则匹配"对比/分别/vs/多问号"(STRONG_COMPLEX_PATTERN)
│ → 升级 complexity(SIMPLE → ≥MEDIUM),从而路由进 AGENTIC 循环
│ (只升级 complexity,不再强制 needDecompose;拆解/多跳由 agentic 循环运行时自决)
│
├─ Step 2 PathDecision 路径决策(`agent/PathDecision.java`,纯规则,无 LLM)
│ ├─ SELECTED_DOC:SelectedDocumentScopeDecider(kbIds + 文档/摘要意图)→ 文档直读路径
│ ├─ RULE_PLANNER:SIMPLE 且非歧义 → 一次性检索,工具由 RetrievalPlanner 规则引擎选
│ │ ├─ 始终 doc_search
│ │ ├─ ambiguous 或 PRECISE → + keyword_search
│ │ ├─ timeAware + 时效关键词 → + web_search(双源校验:原始+改写)
│ │ └─ memoryAware → + recall_memory
│ └─ AGENTIC:其余(复杂 / 中等 / 歧义 / 多焦点)→ 真·LLM 工具调用循环
│ └─ SSE: routing {mode, reason, tools}
│
├─ Step 3 检索编排(按 PathDecision 模式分三路)
│ │
│ ├─ 路径 A 文档直读(SELECTED_DOC,DocumentDirectReader)
│ │ ├─ kbChunkMapper.selectList(DB 直读,按 chunk_index 排序)
│ │ │ └─ 或 DocumentExtractor.extract + TextChunker.chunk(文件不在 DB 时)
│ │ ├─ sampleEvenly(均匀采样,保证头/中/尾覆盖)
│ │ ├─ assignSequentialScores
│ │ └─ CrossEncoderReranker.compress(token 预算截断)
│ │ └─ SSE: retrieval / rerank
│ │
│ ├─ 路径 B AGENTIC 循环(AgenticSearchOrchestrator,真·LLM 工具调用循环)
│ │ ├─ Spring AI ToolCallingManager 手动控环:internalToolExecutionEnabled(false)
│ │ │ (Spring AI 内部工具循环无迭代上限,必须自己控环)
│ │ │ 上限 agentic.max_iterations(默认 4)
│ │ ├─ 每轮:chatModel.call(prompt) → 若 !hasToolCalls() 则停
│ │ │ → 发 SSE agentic → executeToolCalls → 用 conversationHistory 构造下轮 prompt
│ │ ├─ 工具白名单:4 个【读】工具 searchDocs / keywordSearch / webSearch / recall_memory
│ │ │ + executeCode 沙箱计算工具(sandbox.enabled 默认关,进程外隔离)
│ │ │ (硬排除 store_memory / kb_meta,防 LLM 误写记忆)
│ │ ├─ 模型自驱:拆解 / 多跳 / web 补充 / 何时停,全部运行时由模型决定(无手写分支)
│ │ └─ finalize():
│ │ ├─ 累积所有工具调用产出的 chunks → RetrievedChunk.copyAll 防御拷贝
│ │ ├─ dedupeKey() 去重
│ │ ├─ Cross-Encoder rerank(跨多次工具调用、KB vs Web 分数不可比 → 统一标尺)
│ │ ├─ MMRDiversifier.diversify(向量 cosine,I-perf-4;失败降级 bigram Jaccard)
│ │ ├─ ParentChunkResolver.resolve(命中子块展开为父块,#30 与 one-shot 对齐)
│ │ ├─ CrossEncoderReranker.compress(query-aware 命中段居中截断 + 近重复去除)
│ │ ├─ RetrievalGrader.grade(CRAG,#36 二信号一致性:rerank 分 × 词项覆盖率)
│ │ └─ Web 补偿(#36 覆盖率门控):tier==LOW || (AMBIGUOUS && kbEntityWeak) 且整轮未用 web → 追加一轮 webSearch 重排重评(保留 !usedWeb 守卫)
│ │ └─ needsFallback = compressed.isEmpty()(仅无证据才置位;CRAG LOW/AMBIGUOUS 但有证据 → low-confidence 带证据作答,#28/#36)
│ │ ├─ 异常 / 空结果 → 回退一次性检索 oneShotRetrieval(agentic.enabled 灰度开关,默认 on)
│ │ └─ SSE: agentic × N / retrieval(带 kbChunks/webChunks 来源拆分)/ rerank / grader
│ │
│ └─ 路径 C 一次性检索(RULE_PLANNER & agentic 兜底,SupervisorAgent.oneShotRetrieval)
│ ├─ Worker 并行派发(CompletableFuture,ragRetrievalExecutor 8-16 线程)
│ │ ├─ RetrievalWorker(Vector Milvus COSINE + BM25 Lucene SmartCN)
│ │ │ └─ specificity=FUZZY → HyDE 假设文档生成(小模型,仅向量路)
│ │ ├─ WebWorker(timeAware=true → Tavily 网络搜索)
│ │ └─ MemoryWorker(memoryAware=true → 长期记忆召回:Milvus 向量检索 → 回 MySQL SoR 取全文,Redis cache-aside)
│ │ └─ worker → 编排层边界做 RetrievedChunk.copy() 防御拷贝
│ └─ 融合 + 精排 + CRAG 质量闸
│ ├─ RRFFusion.fuse(vector + BM25 → 加权 RRF,k=60,自适应权重)
│ ├─ mergeForRerank(并入 web 结果)
│ ├─ CrossEncoderReranker.rerank(DashScope gte-rerank,Top-8)
│ ├─ MMRDiversifier.diversify(λ=0.7 clamp[0,1],向量 cosine I-perf-4,失败降级 Jaccard)
│ ├─ CrossEncoderReranker.compress(query-aware 命中段居中截断 + 近重复去除 + token 预算 skip 装箱)
│ (注:父块展开 ParentChunkResolver 在上游 RetrievalWorker 内完成,非此处)
│ ├─ RetrievalGrader.grade(CRAG 三档质量闸:HIGH / AMBIGUOUS / LOW,#36 二信号一致性)
│ └─ Web 补偿(#36 覆盖率门控):tier==LOW || (AMBIGUOUS && kbEntityWeak) 且首轮未含 web → 追加一轮 WebWorker,合并后重排/重压缩/重评(GradeResult.webCompensationNeeded,与 AGENTIC 同策略)
│ └─ SSE: retrieval / rerank / grader
│
├─ Step 4 CRAG 检索质量闸(RetrievalGrader,生成前质量闸)
│ └─ extractGrade(从 Evidence.metadata 读 graderTier / topScore / avgScore)
│ ├─ HIGH → 继续生成(assemble 标准模板)
│ ├─ LOW / AMBIGUOUS(灰区)+ 有证据 → 走 assembleLowConfidence 带证据作答(#36:AMBIGUOUS 不再被强制兜底丢证据)
│ └─ 仅 compressed 空(真·无证据)才 needsFallback=true 走 assembleFallback(#28/#36)
│ └─ SSE: grader {tier, topScore, avgScore, reason}
│
├─ Step 5 Prompt 组装
│ └─ PromptAssembler
│ ├─ needsFallback → assembleFallback(兜底模板)
│ ├─ else → assemble(标准 RAG 模板,含历史 + 记忆上下文)
│ │ (assembleDecomposed 已随拆解子系统删除)
│ └─ fitAuxBlocks 全局预算收口(#32/#33):chunks + 三辅助流(computations>memory>history)
│ 共享 prompt.budget.total_max_tokens(默认6000),chunks 优先、aux 让位;
│ history 超预算走滚动摘要(I-perf-5:早期对话 qwen-flash 压成摘要塞回头部,失败回退硬截断)
│
├─ Step 6 LLM 流式生成(终态,无生成后二次审查 / 重写)
│ └─ ChatModel.stream(prompt)(主模型 qwen-plus)→ Reactor doOnNext
│ └─ SSE: start / token × N
│
├─ Step 7 结构化引用解析(CitationParser,纯 Java,无 LLM)
│ ├─ 解析最终答案里的 [n] 标记 → 结构化 citations(含 index / id / name)
│ │ 越界编号丢弃并计入 invalidRefs
│ ├─ 句子级 coverage = 含 ≥1 个有效 [n] 的实质陈述句数 / 实质陈述句总数
│ └─ 无 SSE 事件;citations / citationCoverage / invalidRefs 随 done 下发
│
├─ Step 8 置信度分级(由覆盖率派生,替代原 SelfReflection 置信度来源)
│ ├─ confidenceScore = clamp(coverage) × rerank-top1(coverage 不可算时退回 rerank-top1)
│ ├─ 零覆盖 + CRAG LOW → 仅记 ungrounded 观测信号(不再翻 needsFallback,#28 P1-B)
│ └─ classifyConfidenceBand → confidenceBand(随 done 下发)
│
├─ Step 9 语义缓存写入
│ └─ SemanticCacheService.putIfFrequent(频次门控,存 query embedding + answer + sources)
│
├─ Step 10 持久化 + 推荐阅读
│ ├─ saveQaMessage(含 sources / agentTrace / mcpCalls / confidenceBand;reflectionLog 列保留但不再写入,仅兼容)
│ ├─ updateConversation(messageCount + 2,lastActive 更新)
│ └─ RecommendationGenerator.generate(基于 compressed chunks 推荐关联文档)
│
└─ SSE: done
{sources, citations, citationCoverage, invalidRefs,
isFallback, responseTime, retrievalLog, agentTrace,
confidenceScore, confidenceBand, confidenceLevel, recommendations, intentType}plaintextSSE 事件完整时序(当前协议):
understanding → routing
→ [agentic × N]?(AGENTIC 路径每轮循环一次)
→ [code_exec × N]?(仅 AGENTIC 路径且 sandbox.enabled 开,每次沙箱代码执行一次)
→ retrieval → rerank → grader
→ start → token × N
→ doneplaintext已删除:
reflection_start / reflection_token / reflection_done、multihop、plan。done载荷新增citations / citationCoverage / invalidRefs。
三条短路 vs 主流程对比:
| 触发条件 | 短路层 | LLM 调用次数 | 特征事件 |
|---|---|---|---|
| 紧急词命中 | Phase 0 | 0 | done.isEmergency=true |
| 闲聊 / 元对话 / KB 元信息 / 越界 | Phase 5 | 1(合并模式已含) | scope + done.scopeShortCircuit=true |
| 语义缓存命中 | 缓存层 | 0 | done.fromCache=true |
| 知识查询(正常) | 主流程全走 | 2+(理解 + 生成;AGENTIC 路径还含每轮工具调用的 LLM 决策) | 全事件序列 |
三条检索路径对比:
| 维度 | 文档直读 | agentic 循环 | 一次性检索 |
|---|---|---|---|
| 触发条件 | 用户选中文档 + 文档/摘要意图 | 复杂 / 中等 / 歧义 / 多焦点 | SIMPLE 且非歧义 |
| Worker / 调用方式 | 无(DB 直读) | LLM 工具调用循环(自驱拆解/多跳/web/停) | 并行 Worker 固定流水线 |
| BM25 | ❌ | 由模型自决(searchDocs 内含混合检索) | ✅(默认) |
| HyDE | ❌ | searchDocs 内 specificity=FUZZY 时 | ✅(specificity=FUZZY 时) |
| Web 搜索 | ❌ | 由模型自决(webSearch 工具在白名单内) | ✅(timeAware=true 时) |
| Prompt 模板 | assemble | assemble | assemble |
intentType | selected_document | agentic_loop | planner / supervisor |
Query Rewrite(查询改写)#
问题:用户口语化表达检索效果差(“这个规范怎么配” vs “该规范的配置步骤与参数说明”)。
实现:
- 使用 LLM 将用户 query 改写为检索优化表达
- 小模型调用(迭代 #7):通过
aiConfigHolder.callSmallModel(prompt)走llm.small_model(默认 qwen-turbo),1→1 改写不需要 qwen-plus 质量,单次成本省 75% - 过短查询(≤10字)跳过改写(改写反而会引入噪声)
- 改写失败自动回退原始 query
- Prompt 模板外置于
src/main/resources/prompts/query_rewrite.txt
面试话术:
“Query Rewrite 是 RAG 性价比最高的优化——一次 LLM 调用就能显著提升后续所有检索路径的召回率。但要注意两点:第一是短 query 不改写,因为信息量太少时 LLM 容易过度发散;第二是用小模型不用大模型——1→1 改写本质是文本规范化,小模型够用,省下来的 token 预算花在主回答上更值得。“
Agentic 检索循环(复杂 / 多焦点 / 多跳查询)#
问题:QueryRewriter 是 1→1 改写(解决”表达不规范”),但对于信息焦点分散 / 需要多跳推理的查询,单次 Top-K 召回物理上覆盖不了所有焦点:
- “Spring AI 的 ChatClient 和 LangChain4j 的 AiServices 在工具调用上有什么区别?“——单次向量检索的 Top-K 大概率被 ChatClient 相关 chunk 占满,LangChain4j 的资料根本进不了候选集
- “MilvusService 用什么索引?这种索引在高维下的性能特点?“——multi-hop 推理,第一跳和第二跳的最相关 chunk 不在同一个语义空间
- “分别总结这三份文档”——聚合类,需要对每个焦点独立检索后合并
演进:从「自研拆解 / 多跳」收敛到真·LLM 工具调用 agentic 循环
调研期对比过三类方案,结论决定了最终形态:
| 方案 | 适用场景 | 对 DocMind 的取舍 |
|---|---|---|
| Plan-and-Execute(LangChain / AutoGPT 风格 DAG 调度) | 任务异构(订机票 / 写代码 / 查数据库混合)+ 子任务有依赖 | ❌ RAG 子任务同构(都是检索),上 DAG 是过度抽象 |
| 自研 Query Decomposition / 串行多跳(QueryDecomposer / SubQueryMerger / HopAnswerExtractor) | 多焦点 / multi-hop / 聚合 | ⚠ 对症但「拆几路 / 跳几次」靠手写分支与阈值硬判,适应性差、维护成本高 |
| 真·LLM 工具调用 agentic 循环(最终方案) | 上述全部 | ✅ 把「拆 / 跳 / 补 / 停」交给模型运行时自决,组件复用度高、分支收敛 |
最终既没用 Plan-and-Execute,也没保留自研拆解 / 多跳分支——而是收敛到一个受约束的真·LLM 工具调用 agentic 循环:只给模型 4 个读工具 + 1 个沙箱计算工具的白名单,让模型在运行时自己决定是否拆解、是否多跳、是否补充 Web、何时停止。自研的 QueryDecomposer / SubQueryMerger / HopAnswerExtractor 已整类删除。
实现:AgenticSearchOrchestrator(手动控环)
// Spring AI ToolCallingManager 手动控环
// internalToolExecutionEnabled(false):Spring AI 内部工具循环无迭代上限,必须自己控环
for (int i = 0; i < maxIterations; i++) { // agentic.max_iterations 默认 4
ChatResponse resp = chatModel.call(prompt);
if (!resp.hasToolCalls()) break; // 模型不再调工具 → 停
emit("agentic", ...); // 每轮一个 SSE 事件
ToolExecutionResult ex = toolCallingManager.executeToolCalls(prompt, resp);
prompt = new Prompt(ex.conversationHistory()); // 用对话历史构造下一轮
}java工具白名单(4 读工具 + executeCode 计算工具)
searchDocs / keywordSearch / webSearch / recall_memory ← 暴露(读)
executeCode ← 暴露(沙箱计算,sandbox.enabled 默认关)
store_memory / kb_meta ← 硬排除(防 LLM 误写记忆 / 元信息)plaintext注意 Spring AI 的
toolNames是「附加」语义而非「过滤」,所以实现上是过滤toolCallbacks列表来排除store_memory,而不是靠白名单字符串。内部写记忆只走DocMindAgent.stageMemoryWrite()的直接 Java 调用。
finalize():跨工具调用的统一收口
多轮工具调用产出的 chunks 来自不同来源(多次 searchDocs、webSearch),分数不可比,必须做一次统一收口:
累积所有工具调用产出的 chunks
→ RetrievedChunk.copyAll 防御拷贝 + dedupeKey() 去重
→ Cross-Encoder rerank(对并集一次,统一标尺——KB vs Web 分数不可比)
→ MMRDiversifier.diversify
→ CrossEncoderReranker.compress
→ RetrievalGrader.grade(CRAG)
needsFallback = compressed.isEmpty() // 仅"无任何证据"才兜底;CRAG LOW 但有证据 → low-confidence 带证据作答(#28)plaintext降级与开销控制
| 触发条件 | 行为 |
|---|---|
agentic.enabled=false(灰度总开关,默认 on) | 直接走一次性检索 |
| AGENTIC 路径异常 / 空结果 | 回退一次性检索 SupervisorAgent.oneShotRetrieval |
达到 agentic.max_iterations(默认 4) | 强制停环,用已累积 chunks finalize |
compressed 为空(真·无证据) | needsFallback = true,走 assembleFallback 兜底 prompt(0 chunk) |
CRAG 评 LOW / AMBIGUOUS 但 compressed 非空 | needsFallback = false,走 assembleLowConfidence 带证据作答(#28/#36,不丢弃 KB/Web 证据;#36 修掉了 AMBIGUOUS 曾被强制兜底丢证据的 bug) |
专用线程池设计(服务于一次性检索的并行 Worker)
一次性检索路径(RULE_PLANNER 及 agentic 兜底)的并行 Worker 派发跑在专用线程池上:
@Bean
public Executor ragRetrievalExecutor() {
return new ThreadPoolExecutor(
8, 16, 60s,
new LinkedBlockingQueue<>(20),
...,
new ThreadPoolExecutor.CallerRunsPolicy() // 满了反压而非丢任务
);
}java不用 ForkJoinPool.commonPool() 的原因:检索是 IO 密集(HTTP 调 LLM、Milvus、Lucene),用 commonPool 会和 CPU 密集任务争资源。CallerRunsPolicy 让上游”自然反压”——LLM 异常导致线程打满时不丢任务,让调用方阻塞自动限流。
实测行为(真 qwen-plus 端到端冒烟):简单题 1 次 searchDocs 即停;对比题模型自主多焦点检索(≈4 次);多跳题模型自主串行多跳 + 自我验证(≈4 次)。
面试话术:
“复杂 / 多焦点 / 多跳查询,我没用 Plan-and-Execute,也没保留自研的 Query Decomposition / 串行多跳——而是收敛到一个受约束的真·LLM 工具调用 agentic 循环。
取舍逻辑是这样的:Plan-and-Execute 的收益是任务异构 + DAG 依赖管理,但 RAG 子任务是同构的(都是检索),上 DAG 是过度抽象;自研拆解 / 多跳虽然对症,但’拆几路、跳几次’靠手写阈值硬判,适应性差。真·LLM 工具调用循环把’拆 / 跳 / 补 / 停’交给模型运行时自决,分支全部收敛掉。
工程上有三个关键点:第一是手动控环——Spring AI 内部工具循环没有迭代上限,所以我设
internalToolExecutionEnabled(false)自己控环,上限 4 轮,避免模型死循环烧 token。第二是工具白名单只给 4 个读工具 + 一个沙箱计算工具——searchDocs / keywordSearch / webSearch / recall_memory加 executeCode(受sandbox.enabled默认关、进程外隔离),硬排除store_memory / kb_meta防止模型误写记忆;而且 Spring AI 的 toolNames 是附加语义不是过滤,所以是过滤 toolCallbacks 列表实现的。第三是 finalize 统一标尺——多轮工具调用的 chunks 来源不同、分数不可比,去重后对并集做一次 Cross-Encoder rerank 拉到统一标尺,再 MMR + 压缩 + CRAG。异常或空结果回退一次性检索兜底。“
HyDE 假设文档生成(Phase 3 新增)#
问题:模糊/概念性查询(specificity == FUZZY)与文档之间存在语义鸿沟——用户问”微服务架构的优势”,但文档里写的是具体的”服务解耦、独立部署、故障隔离”等描述。短 query 的 embedding 和长文档 chunk 的 embedding 天然不在同一语义密度层面。
方案:HyDE(Hypothetical Document Embeddings,Gao et al. 2022)——先让 LLM 生成一段假设性回答(150-300字),再用该回答的 embedding 做向量检索。假设文档与真实文档在语义空间中更接近(都是陈述式长文本),从而提升召回率。
触发条件:
- specificity == FUZZY 自动触发:QueryUnderstanding 判定 specificity == FUZZY 时,一次性检索路径自动为 RetrievalWorker 开启 HyDE(仅向量路)
- CRAG(
RetrievalGrader)作为生成前质量闸对最终候选评 HIGH / AMBIGUOUS / LOW(#36 起为 rerank 分 × 词项覆盖率的二信号一致性裁判);旧的「CRAG AMBIGUOUS 触发 WebWorker 的多轮迭代回溯循环」已随四刀改造删除。注意区分:#30 起新增「单轮 Web 补偿」,#36 改为覆盖率门控触发——tier==LOW || (AMBIGUOUS && kbEntityWeak)(按”KB 是否缺实体”而非粗粒度 tier),单轮非迭代,与旧的多轮回溯机制不同
实现:
// HyDEGenerator:用小模型生成假设文档
String hypothesis = aiConfigHolder.callSmallModel(hydePrompt);
// RetrievalWorker:HyDE embedding 替代原始 query embedding
String vectorQuery = useHyde ? hydeGenerator.generate(query) : query;
List<RetrievedChunk> vectorResults = vectorRetriever.retrieve(vectorQuery, ...);
// BM25 路仍用原始 query(关键词精确性不能被 HyDE 稀释)
List<RetrievedChunk> bm25Results = bm25Retriever.retrieve(query, ...);java关键设计决策:
- 向量路用 HyDE,BM25 路用原始 query:HyDE 文本是 LLM 生成的自然段落,做 BM25 关键词匹配反而引入噪声
- 用小模型(qwen-turbo)生成:假设文档不需要高质量推理,只需要语义密度,小模型够用(延迟 200-400ms)
- 失败回退:生成失败或为空时,回退使用原始 query,不阻塞主流程
- 不对 PRECISE 查询使用:精确查询(含具体编号/术语/API名)向量检索已足够准确,HyDE 反而可能泛化丢精度
配置:
hyde.enabled=true:全局开关- HyDE 仅在 specificity=FUZZY 时自动触发(无独立”重试触发阈值”——旧的 observation 重试循环已随四刀改造删除,相关
agent.observation.*已是无消费者的死配置)
面试话术:
“HyDE 解决的是模糊查询的语义鸿沟问题。用户问’系统架构的特点’,query 只有 6 个字,embedding 信息密度很低;而文档里是 200 字的技术描述,embedding 信息密度高。让 LLM 先生成一段假设回答,就把 query 的语义密度拉到和文档同一层面。
工程上有三个细节:第一,只在向量路用 HyDE、BM25 路保持原 query——BM25 靠关键词精准匹配,给它 LLM 生成的文本反而引入无关词干扰 IDF。第二,用小模型不用大模型——假设文档只需要语义丰富度不需要推理质量,200ms 就够了。第三,触发条件收敛——specificity=FUZZY 时自动开启;CRAG 现在只作为生成前的质量闸(HIGH / AMBIGUOUS / LOW),删掉了旧的 AMBIGUOUS 多轮回溯循环,改为单轮 Web 补偿、且 #36 起按覆盖率门控(LOW 或 AMBIGUOUS 缺实体才补),机制更简单。“
向量检索(Vector Retriever)#
- 使用 text-embedding-v3 将 query 向量化(1024维)
- Milvus COSINE 相似度检索
- 支持 knowledge_base_id 标量过滤(只在指定知识库内检索)
- 支持 tags 字段 LIKE 过滤(基于 chunk 自动提取标签)
- 默认 topK = 50(扩大候选池以提升召回率,精排阶段再筛选至 Top-8)
关键细节:Milvus 的 filter 表达式是字符串拼接,但输入是 Long 类型的 kbIds,不存在注入风险。
BM25 检索(BM25 Retriever)#
两级候选召回:
1. MySQL FULLTEXT 索引 → 快速缩小候选集(默认 400 条)
2. 应用内 BM25 精算 → 对候选集逐条打分排序plaintextBM25 公式实现:
score = IDF × (tf × (K1+1)) / (tf + K1 × (1 - B + B × docLen/avgLen))plaintext- K1 = 1.2, B = 0.75(经典参数)
- IDF 使用 log((N - df + 0.5) / (df + 0.5) + 1) 防止负值
短语覆盖加分:对包含更多查询词的文档额外加分,提升多词查询效果。
降级策略:MySQL FULLTEXT 无结果时,退化为应用内全文分词 + 模糊匹配。
面试话术:
“为什么不直接用 Elasticsearch?因为数据量在中等规模(几十万条 chunk),内嵌 Lucene + MySQL FULLTEXT 足够,不需要额外部署和维护一个 ES 集群。如果数据量上去了,替换成 ES 只需要改 BM25Retriever 的实现。“
RRF 融合(Reciprocal Rank Fusion)#
问题:向量检索和 BM25 的分数尺度不同(COSINE 0-1 vs BM25 任意正数),无法直接比较。
方案:RRF 只看排名不看分数:
等权公式:RRF_score(d) = Σ 1 / (k + rank_i(d))
加权公式:RRF_score(d) = Σ weight_i / (k + rank_i(d)) ← 自适应检索启用时plaintext- k = 60(经典值,可由 QueryProfile 自适应调整为 40-60)
- 加权融合由
QueryProfiler驱动:精确查询 bm25Weight=0.7 / vectorWeight=0.3,语义查询反转
实现细节:
- 先对 Vector 和 BM25 结果按各自分数排序
- 计算每条文档在两路中的加权 RRF 分数
- 合并后按 RRF 总分降序排列
- 取 Top-N(自适应,12-20 不等)进入重排
- 原等权
fuse(vector, bm25, topN)保留为加权方法的等权委托,向后兼容
面试话术:
“初版 RRF 是等权的——不需要学习权重,对不同尺度的分数天然兼容。但等权也意味着对查询类型不敏感:精确查询’server.shutdown 默认值’里向量检索召回的语义相近但非目标参数的结果,和 BM25 精准匹配到的目标配置项,被同等对待。
所以我做了加权 RRF 扩展:QueryProfiler 根据查询画像输出 vectorWeight 和 bm25Weight,精确查询给 BM25 路 0.7 权重让关键词精准匹配占主导,语义查询给向量路 0.6 权重让语义理解优先。同时 rrfK 也自适应——精确查询用 K=40 锐化头部优势,开放查询用 K=60 保持平衡。原来的等权方法保留为加权方法的委托,完全向后兼容。“
Cross-Encoder 重排序#
RRF 融合后的 Top-30 结果送入 Cross-Encoder 精排:
Input: (query, chunk) pair
Output: relevance score (0-1)
Model: DashScope gte-rerankplaintext- 通过 HTTP API 调用,不需要本地部署模型
- 返回 Top-K(默认 8)最相关的结果
- 降级策略:API 不可用时,用关键词覆盖度打分(查询词在 chunk 中出现的比例)
为什么需要 Cross-Encoder?
Bi-Encoder(向量检索)速度快但精度有限——query 和 document 独立编码,无法捕捉细粒度交互。Cross-Encoder 同时编码 query+document,精度更高但速度慢,所以只用在 Top-N 精排阶段。
MMR 多样性重排#
Cross-Encoder 精排输出的 Top-8 结果还需要经过 MMR(Maximal Marginal Relevance)多样性筛选:
MMR(d) = λ × relevance(q,d) − (1−λ) × max_sim(d, already_selected)plaintext- λ = 0.7(可通过
mmr.lambda配置动态调整;读取后钳制到 [0,1]——误配 >1 会让多样性项(1−λ)变负反而奖励冗余、<0 让相关性项变负,均破坏 MMR 语义) - 相似度默认用向量 cosine(I-perf-4,
mmr.similarity切换;候选集小,一次性批量 embed 全部候选再做 O(K²) cosine);任何失败(embedding 异常 / 维度不一致 / 显式配 jaccard)自动整体降级字符 bigram Jaccard——降级零额外依赖、永不阻断主流程 - 贪心选择:先选 top-1,再逐步选 MMR 值最高的候选
- 可通过
mmr.enabled开关关闭
解决的问题:当 Top-K 候选全部来自同一文档的相邻段落时,内容高度重叠,送入 LLM 是浪费 token。MMR 确保最终输入 LLM 的 chunk 既相关又多样。
为什么从 Jaccard 升级到向量 cosine(I-perf-4):字符 bigram Jaccard 只看字面重叠——“换了同义词讲同一件事”识别不出(漏判冗余),“字面重叠高但语义不同的中文片段”又会误判冗余。MMR 候选集很小(精排后 ≤ targetK,通常几十条),一次批量 embed 的成本可忽略,换来语义级去重;保留 Jaccard 作零依赖降级兜底。
面试话术:
“精排后再做 MMR 多样性重排是标准做法——Cross-Encoder 保证相关性,MMR 保证信息密度。λ=0.7 意味着 70% 权重看相关性、30% 看多样性,在’准’和’全’之间取平衡。相似度后端我从字符 bigram Jaccard 升级成了向量 cosine:Jaccard 只看字面,识别不出’同义改写’的语义重复、还会把字面像但意思不同的中文误判冗余;MMR 候选集很小、批量 embed 一次成本可忽略,所以换成 cosine 更准,同时保留 Jaccard 做零依赖降级兜底。另外 λ 我做了 [0,1] 钳制——误配 >1 多样性项会变负,反而奖励冗余。“
上下文压缩(query-aware,CrossEncoderReranker.compress)#
组件下沉:原
retrieval/ContextCompressor独立组件已合并进CrossEncoderReranker(精排后的 6 条去重很少触发,独立组件价值低,省一层抽象)。下方为当前 query-aware 实现。
重排后的结果还需要压缩,控制送入 LLM 的 token 量。四步:
- 精确去重:用
dedupeKey()(id 优先,缺失退化内容指纹)判重,保留更高排名的 - 近重复去除(
compress.near_dup_enabled默认开):精确去重抓不住”内容高度雷同但 id/全文不同”的冗余(父/子块重叠、web 模板 boilerplate)。输入已按 rerank 降序,对每个候选只与已保留的更高排名块比对:包含关系(短文落长文内)或 bigram Jaccard ≥compress.near_dup_jaccard(默认 0.85,偏保守)即判冗余丢弃 - query-aware 截断(取代旧的”无脑保头”):单条超 800 字时,以命中段为中心取窗口而非保头——父块展开后命中正文常落在中后段,保头会把它砍掉,造成”检索到却答不出”。命中段定位优先用展开前的子块原文(
getChildContent()),其次用 query 词命中位,都没有才退回保头;窗口边界做句界吸附(snapStart/snapEnd,仅在 80 字窗内找句末标点)避免从句子中间切 - 总量控制:按 token 预算(语种感知
estimateTokens)贪心累加,至少保留 1 条;超预算的块跳过而非中断——让排在其后、更短的高分块继续填充预算,避免一条长块卡死后续装箱
面试话术:
“上下文压缩是成本控制的关键,但我这版的重点是’压缩别把答案压没了’。最早的截断是无脑保前 800 字——可我们做了父块展开,真正命中的子块正文往往落在父块的中后段,保头一刀就把命中段砍掉了,表现就是’明明检索到了却答不出来’。所以我改成 query-aware 的命中段居中截断:优先按展开前的子块原文定位命中段,取它周围的窗口,还做句界吸附避免从句子中间切。另外加了一步近重复去除,专门抓父/子块重叠和网页模板这种’id 不同但内容雷同’的冗余;总量控制用的是 skip 而非 break,一条超长块不会卡死后面更短的高分块装箱。“
文本切分(Text Chunker)#
文档上传时的预处理步骤:
混合切分策略:
段落分割(\n\n)
├─ 短段落 → 合并到目标大小(400字)
└─ 长段落 → 按句子切分(。!?;\n),保留50字重叠plaintext内容类型自动检测:根据文本特征标记 chunk 类型
- procedure(步骤/流程)
- warning(警告/注意事项)
- example(示例/案例)
- definition(定义/概念)
- general(通用)
丰富元数据(Phase 1 新增):每个 chunk 携带以下结构化元数据,支持精确过滤和溯源:
tags:JSON 数组,从章节标题 + contentType 自动提取(如["架构设计","definition"])docVersion:文档版本号,支持按版本过滤和语义缓存失效effectiveDate:文档生效日期,支持时效性过滤sourceFileName:原始文件名,溯源展示
文档元信息自动提取(两级策略):
TextChunker.chunk(text, kbId, category)
│
├─ Level 1: YAML Frontmatter 解析(优先级最高)
│ ├─ 识别 --- 包裹的 YAML 块,支持中/英文字段名
│ ├─ 提取 version/版本 → docVersion
│ ├─ 提取 date/生效日期/发布日期 → effectiveDate
│ └─ 剥离 frontmatter,不计入 chunk 正文
│
└─ Level 2: 正文头部正则提取(frontmatter 缺失时降级)
├─ 扫描前 500 字,匹配版本号模式("版本 1.2.3" / "Version v2.0")
└─ 匹配日期模式("2025-06-01" / "生效日期:2025年6月1日")plaintext支持的 Frontmatter 示例:
---
title: 部署手册
version: 2.1.0
date: 2025-06-01
---yaml设计决策:
- Frontmatter 优先于正文提取,避免正文中出现的历史版本号覆盖实际文档版本
- 两级策略覆盖所有文档类型:
.md带 frontmatter、PDF/DOCX 经 MinerU 转 Markdown 后头部通常有版本信息、纯文本正文匹配 - 提取结果注入到每个 chunk 的
docVersion/effectiveDate字段,持久化到kb_chunk表和 metadata JSON 中
面试话术:
“切分策略直接影响检索质量。太大的 chunk 噪声多,太小的 chunk 丢失上下文。400字 + 50字重叠是中文文档的经验值。内容类型标记让后续的检索和展示可以针对性优化。
丰富元数据是 Phase 1 基础铺垫的一部分——标签从 heading 栈自动提取,零人工标注成本。有了 tags 就可以在 Milvus 里做标量过滤,比如用户搜’架构相关’可以直接缩小候选集。
文档元信息提取采用两级策略:优先解析 Markdown frontmatter(YAML 块),缺失时降级到正文头部正则匹配。这样不管文档原始格式是什么(PDF 经 MinerU 转换后也带 heading),都能自动提取版本号和生效日期。docVersion 配合语义缓存的版本校验,确保知识库更新后旧缓存自动失效;effectiveDate 支持’最新版本’这类带时效性的查询做精确过滤。“
Parent Document Retrieval(Phase 3 新增)#
问题:400 字的子块适合检索(embedding 粒度精准),但送入 LLM 生成回答时上下文太短——LLM 看不到段落前后的关联信息,容易断章取义。
方案:双层切块 + 子块检索 / 父块生成:
文档入库:
TextChunker.chunkWithParents()
├─ 父块(1500字)—— 只存 MySQL,不存 Milvus,用于 LLM 上下文
└─ 子块(400字)—— 存 MySQL + Milvus embedding,用于向量检索
每个子块记录 parent_chunk_id
检索时:
Milvus 命中子块 → ParentChunkResolver 查 MySQL 获取父块内容 → 用父块内容送 LLM
多个子块指向同一父块时自动去重(只保留分数最高的)plaintext核心设计:
| 层 | 大小 | 存储 | 用途 |
|---|---|---|---|
| 父块 | ~1500 字 | MySQL only | LLM 生成上下文 |
| 子块 | ~400 字 | MySQL + Milvus | 向量检索(embedding 精准度高) |
为什么不直接用大块检索?
- 大块 embedding 会”稀释”——1500 字文本的 embedding 是多个语义的加权平均,精准度下降
- 小块匹配精准但上下文不足;Parent Document Retrieval 兼顾两者:小块保证匹配精度,大块保证生成质量
实现细节:
TextChunker.chunkWithParents():先切出标准 400 字子块,再将相邻子块合并为 ≤2000 字父块DocumentProcessTask:两阶段入库——先存父块获得 DB ID,再存子块并设置parent_chunk_id外键ParentChunkResolver:通过vector_id批量查kb_chunk获取parent_chunk_id,再批量查父块内容;同父去重只保留最高分子块- 兼容旧数据:
parent_chunk_id = NULL的 chunk 直接使用原内容,无父块查询
面试话术:
“Parent Document Retrieval 解决的是检索粒度和生成粒度的矛盾。400 字切块做 embedding 精准度最高——信息密度集中,语义向量不被无关内容稀释。但 LLM 生成回答时只看 400 字太碎片化,容易断章取义。
方案是双层切块:入库时同时产出 400 字子块(用于检索)和 1500 字父块(用于生成)。子块进 Milvus 做 embedding,父块只存 MySQL 省向量化成本。检索命中子块后,ParentChunkResolver 通过 parent_chunk_id 外键查出父块内容,用父块送 LLM。多个子块指向同一父块时自动去重,避免重复上下文浪费 token。
这比’直接用大块检索’好在哪?1500 字 embedding 是多个语义的加权平均,匹配精度必然下降。小块检索 + 大块生成是目前 RAG 的 best practice。“
面试 Q&A#
Q: 为什么选 RRF 而不是学习排序(Learning to Rank)?
A: 学习排序需要标注数据训练排序模型,在项目初期没有足够的点击/反馈数据。RRF 是无参数方法,开箱即用。等积累了足够的用户反馈数据后,可以切换到 LTR。
Q: Cross-Encoder 延迟怎么控制?
A: 两个手段:①只对 RRF Top-15 精排,不是对全量候选精排;②超时降级到关键词打分。实际测试 15 条精排约 200-400ms,在可接受范围内。
Q: 向量检索和 BM25 的权重怎么调?
A: 初版 RRF 是等权的(
1/(k+rank)),后来做了 Query-Aware Adaptive Retrieval 优化——QueryProfiler 根据查询画像自动选权重:精确查询给 BM25 路 0.7 权重(关键词精准匹配优先),语义查询给向量路 0.6 权重(语义理解优先)。同时 K 值也自适应,精确查询用 K=40 锐化头部,开放查询用 K=60 保持平衡。所有参数都通过纯规则映射,不调 LLM、零额外延迟。
Q: 复杂 / 多焦点 / 多跳查询怎么处理?为什么不做 Plan-and-Execute,也没保留自研 Query Decomposition?
A: 最终既没用 Plan-and-Execute,也没保留自研的 Query Decomposition / 串行多跳,而是收敛到一个受约束的真·LLM 工具调用 agentic 循环(
AgenticSearchOrchestrator)。原因:Plan-and-Execute 的收益是任务异构 + DAG 依赖管理,但 RAG 子任务是同构的(都是检索),上 DAG 是过度抽象;自研拆解 / 多跳虽然对症,但’拆几路、跳几次’靠手写阈值硬判,适应性差、维护成本高。真·LLM 工具调用循环把’拆 / 跳 / 补 / 停’交给模型运行时自决——只给 4 个读工具白名单(searchDocs / keywordSearch / webSearch / recall_memory,外加默认关的 executeCode 沙箱计算工具),手动控环(internalToolExecutionEnabled(false),上限 4 轮)兼顾适应性与可控性,异常或空结果回退一次性检索兜底。
历史说明:早期自研拆解里做过「保底分配 + 全局补齐」的 SubQueryMerger(避免低分子问题被全局排序挤光),子问题也刻意不再过 QueryRewriter(避免双倍 LLM 成本);这些组件已随 agentic 循环改造整体移除,相关取舍逻辑现由 agentic 循环的 finalize 统一收口(去重 → 统一标尺 rerank → MMR → 压缩 → CRAG)承接。