外观
检索与排序链路
链路全景
本图反映当前最新实现(2026-05-09)。原 QueryRewriter / ExplicitMemoryExtractor / QueryProfiler / QueryDecomposer 四个独立组件已合并为一次
QueryUnderstandingService.understand()调用。Phase 5 新增顶层范畴路由(6 种范畴)、PathDecision 规则引擎路由、CRAG 三档置信度分级、QueryUnderstanding 确定性后处理。Phase 6 新增 Langfuse OTel 全链路追踪。
三条短路路径
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()主流程详细分层
runReActLoop()
│
├─ Step 1 QueryUnderstandingService.understand(合并模式复用短路②缓存,0 次额外 LLM)
│ 输出: rewrittenQuery / intent / complexity / specificity
│ needDecompose / memoryWriteHints / scope
│ ├─ memoryWriteHints 非空 → MemoryTool.storeMemory(写 Redis 长期记忆)
│ └─ SSE: understanding
│ {original, rewritten, intent, complexity, specificity,
│ degraded, subQueries?}
│
├─ Step 1.5 确定性后处理(Phase 5,零 LLM)
│ └─ applyDeterministicSignals:正则匹配"对比/分别/vs/多问号"
│ → 强制 needDecompose=true(兜底 LLM 漏判)
│
├─ Step 2 PathDecision 路径决策(`agent/PathDecision.java`,纯规则,无 LLM)
│ ├─ SELECTED_DOC:SelectedDocumentScopeDecider(kbIds 1-8 + 文档/摘要意图)→ 文档直读路径
│ ├─ DECOMPOSED:hasDecomposition && subQueries≥2 → 拆解路径
│ └─ RULE_PLANNER:RetrievalPlanner.plan(规则引擎生成工具列表)
│ ├─ 始终 doc_search
│ ├─ ambiguous 或 PRECISE → + keyword_search
│ ├─ timeAware + 时效关键词 → + web_search(双源校验:原始+改写)
│ └─ memoryAware → + recall_memory
│ └─ SSE: routing {mode, reason, tools}
│
├─ Step 3 SupervisorAgent.orchestrate(检索编排)
│ │
│ ├─ 路径 A 文档直读(documentScopedRetrieval=true)
│ │ ├─ kbChunkMapper.selectList(DB 直读,按 chunk_index 排序)
│ │ │ └─ 或 DocumentExtractor.extract + TextChunker.chunk(文件不在 DB 时)
│ │ ├─ sampleEvenly(均匀采样,保证头/中/尾覆盖)
│ │ ├─ assignSequentialScores
│ │ └─ CrossEncoderReranker.compress(token 预算截断)
│ │ └─ SSE: retrieval / rerank
│ │
│ ├─ 路径 B 拆解检索(decomposed=true)
│ │ ├─ CompletableFuture × N 子问题(ragRetrievalExecutor 8-16 线程并行)
│ │ │ └─ 每个子问题 → RetrievalWorker.execute(仅 vector,跳过 BM25)
│ │ ├─ 低分子问题 Web 补偿(topScore < 0.45 → WebWorker 补充)
│ │ ├─ SubQueryMerger.merge(`service/rag/SubQueryMerger.java`)
│ │ │ ├─ Pass 1:保底分配 ⌈K/N⌉ 个席位 / 子问题
│ │ │ ├─ Pass 2:剩余名额全局按分数补齐
│ │ │ └─ Round-robin 输出(sub0_top1 → sub1_top1 → sub0_top2 → ...)
│ │ │ 防止 ContextCompressor 截断时挤掉少数子问题的全部切片
│ │ └─ CrossEncoderReranker.compress
│ │ └─ SSE: retrieval / rerank
│ │
│ ├─ 路径 C Plan-and-Execute(complexity=COMPLEX)
│ │ ├─ PlanGenerator.generate(小模型 qwen-turbo 生成 JSON 执行计划)
│ │ ├─ SSE: plan {steps, reasoning, fallback}
│ │ ├─ PlanExecutor.execute(按依赖拓扑分层,同层并行)
│ │ ├─ CrossEncoderReranker.rerank(Top-K)
│ │ ├─ MMRDiversifier.diversify(λ=0.7,bigram Jaccard)
│ │ ├─ EarlyStopGate.shouldStopAfterRerank(早停关 2)
│ │ └─ CrossEncoderReranker.compress(token 预算)
│ │ └─ SSE: retrieval / rerank
│ │
│ └─ 路径 D 标准检索(迭代式 ReAct,max 3 轮)
│ ├─ 首轮 Worker 并行派发(CompletableFuture,ragRetrievalExecutor)
│ │ ├─ RetrievalWorker(Vector Milvus COSINE + BM25 Lucene SmartCN)
│ │ │ └─ specificity=FUZZY → HyDE 假设文档生成(小模型,仅向量路)
│ │ ├─ WebWorker(timeAware=true → Tavily 网络搜索)
│ │ └─ MemoryWorker(memoryAware=true → Redis 长期记忆召回)
│ ├─ 后续轮次策略决策
│ │ └─ ObservationEvaluator.evaluate
│ │ ├─ 召回为空 → SWITCH_TO_WEB(WebWorker)
│ │ ├─ 顶分 < 0.4 → TRIGGER_HYDE(RetrievalWorker + HyDE)
│ │ ├─ 多文档竞争 → INVOKE_ANALYSIS(AnalysisWorker)
│ │ └─ REPLAN/PROCEED → IterationDecider.recommendNextStrategy
│ ├─ IterationDecider.decide(每轮:confidence ≥ 阈值 / 停滞 → TERMINATE)
│ └─ 融合 + 精排
│ ├─ EarlyStopGate.shouldStopBeforeRerank(早停关 1:零召回)
│ ├─ RRFFusion.fuse(vector + BM25 → 加权 RRF,k=60,自适应权重)
│ ├─ mergeForRerank(并入 web 结果)
│ ├─ CrossEncoderReranker.rerank(DashScope gte-rerank,Top-8)
│ ├─ MMRDiversifier.diversify(λ=0.7,bigram Jaccard)
│ ├─ EarlyStopGate.shouldStopAfterRerank(早停关 2:质量不足)
│ └─ CrossEncoderReranker.compress(token 预算截断)
│ └─ SSE: retrieval / rerank
│
├─ Step 4 CRAG 置信度分级(Phase 5)
│ └─ extractGrade(从 Evidence.metadata 读 graderTier / topScore / avgScore)
│ ├─ HIGH / MEDIUM → 继续生成
│ └─ LOW → needsFallback = true(走兜底 Prompt 模板)
│ └─ SSE: grader {tier, topScore, avgScore, reason}
│
├─ Step 5 Prompt 组装
│ └─ PromptAssembler
│ ├─ needsFallback → assembleFallback(兜底模板)
│ ├─ decomposed → assembleDecomposed(子问题标注来源服务哪个子问题)
│ └─ else → assemble(标准 RAG 模板,含历史 + 记忆上下文)
│
├─ Step 6 LLM 流式生成
│ └─ ChatModel.stream(prompt)(主模型 qwen-plus)→ Reactor doOnNext
│ └─ SSE: start / token × N
│
├─ Step 7 Self-Reflection(自纠错)
│ ├─ 高分短路:top-1 rerankScore ≥ 0.85 且 chunks ≥ 3 → 直接通过(0 LLM)
│ ├─ Round 1:SelfReflection.reflect(小模型 qwen-turbo 评分)
│ │ ├─ isTopicMismatch → Output Rail,跳过重写(重写救不回跑题)
│ │ └─ isPassed=false → Round 2
│ └─ Round 2:低置信度且有明确问题 → 流式重写
│ └─ SSE: reflection_start / reflection_token × N / reflection_done
│
├─ Step 8 置信度分级 + 低置信警告
│ ├─ readReflectionConfidence(反思日志最后轮 or top-1 rerankScore)
│ ├─ classifyConfidenceBand(HIGH ≥ 0.85 / MEDIUM ≥ 0.60 / LOW)
│ └─ 非 HIGH → SSE: confidence_warning {score, band, message}
│
├─ Step 9 语义缓存写入
│ └─ SemanticCacheService.putIfFrequent(频次门控,存 query embedding + answer + sources)
│
├─ Step 10 持久化 + 推荐阅读
│ ├─ saveQaMessage(含 sources / agentTrace / mcpCalls / reflectionLog / confidenceBand)
│ ├─ updateConversation(messageCount + 2,lastActive 更新)
│ └─ RecommendationGenerator.generate(基于 compressed chunks 推荐关联文档)
│
└─ SSE: done
{sources, isFallback, responseTime, retrievalLog, agentTrace,
confidenceScore, confidenceBand, confidenceLevel, recommendations, intentType}SSE 事件完整时序:
understanding → [plan?] → retrieval → [grader?] → rerank
→ start → token × N
→ [reflection_start → reflection_token × N → reflection_done]?
→ [confidence_warning]?
→ done三条短路 vs 主流程对比:
| 触发条件 | 短路层 | LLM 调用次数 | 特征事件 |
|---|---|---|---|
| 紧急词命中 | Phase 0 | 0 | done.isEmergency=true |
| 闲聊 / 元对话 / KB 元信息 / 越界 | Phase 5 | 1(合并模式已含) | scope + done.scopeShortCircuit=true |
| 语义缓存命中 | 缓存层 | 0 | done.fromCache=true |
| 知识查询(正常) | 主流程全走 | 2–4(理解+生成+可选反思+可选重写) | 全事件序列 |
四条检索路径对比:
| 维度 | 文档直读 | 拆解检索 | Plan-and-Execute | 标准迭代 |
|---|---|---|---|---|
| 触发条件 | 用户选中 ≤3 份文档 | needDecompose=true | complexity=COMPLEX | 其余情况 |
| Worker 调用 | 无(DB 直读) | RetrievalWorker × N(并行) | PlanExecutor 拓扑调度 | 1-3 轮迭代 ReAct |
| BM25 | ❌ | ❌(子问题已规范化) | 由计划决定 | ✅(默认) |
| HyDE | ❌ | ❌ | 由计划决定 | ✅(specificity=FUZZY 时) |
| Web 搜索 | ❌ | ❌ | 由计划决定 | ✅(timeAware=true 时) |
| Prompt 模板 | assemble | assembleDecomposed | assemble | assemble |
intentType | selected_document | decomposed | plan_and_execute | 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 预算花在主回答上更值得。"
Query Decomposition(查询拆解,复杂查询专用)
问题:QueryRewriter 是 1→1 改写(解决"表达不规范"),但对于信息焦点分散的查询,单次 Top-K 召回物理上覆盖不了所有焦点:
- "Spring AI 的 ChatClient 和 LangChain4j 的 AiServices 在工具调用上有什么区别?"——单次向量检索的 Top-K 大概率被 ChatClient 相关 chunk 占满,LangChain4j 的资料根本进不了候选集
- "MilvusService 用什么索引?这种索引在高维下的性能特点?"——multi-hop 推理,第一跳和第二跳的最相关 chunk 不在同一个语义空间
- "分别总结这三份文档"——聚合类,需要对每份文档独立检索后合并
为什么不是 Plan-and-Execute?
调研对比了通用 Agent 的 Plan-and-Execute 范式(LangChain、AutoGPT 风格的 DAG 调度)和 RAG-native 的 Query Decomposition:
| 方案 | 适用场景 | 对 DocMind 的匹配度 |
|---|---|---|
| Plan-and-Execute | 任务异构(订机票 / 写代码 / 查数据库混合)+ 子任务有依赖 | ❌ RAG 子任务是同构的(都是检索),没 DAG 必要 |
| Query Decomposition | 多焦点查询、multi-hop 推理、聚合类问题 | ✅ 论文支撑充足(Self-Ask、Decomposed Prompting、IRCoT) |
结论:用 Plan-and-Execute 是给 RAG 套通用 Agent 范式,不是对症下药。
实现:
java
// ComplexityClassifier:规则强信号优先(命中即返回 COMPLEX,跳过 LLM)
private static final Pattern STRONG_COMPLEX_PATTERN = Pattern.compile(
"对比|比较|区别|异同|优缺点|分别(说明|介绍|总结|列出|讲讲)|" +
"和.*有什么不同|与.*相比|vs\\.?|" +
"(以及|另外|还有|此外).*\\?|.*[??].+[??]"
);
// QueryDecomposer:LLM 拆解 + 三道清洗(去重 / 截断 / 上限)
DecompositionResult result = decomposer.decompose(question);
if (result.decomposed()) {
// 并行 N 路检索
futures = subQueries.map(sq -> CompletableFuture.supplyAsync(
() -> retrieveOneSubQuery(sq), ragRetrievalExecutor));
CompletableFuture.allOf(futures).join();
// 合并
topChunks = subQueryMerger.merge(subResults, mergedTopK);
}核心设计:保底分配 + 全局补齐(SubQueryMerger)
简单的"全局按分数排序取 Top-K"有陷阱——当某个子问题整体相关性偏低(领域生僻),它的 chunk 全部会被高分子问题挤出 Top-K,违背拆解的初衷(覆盖完整性)。
两段式合并:
Pass 1(保底分配):
每个子问题至少占 floor = ceil(mergedTopK / N) 个名额
按分数降序从该子问题贡献,不够 floor 个就停(不抢别人的名额)
Pass 2(全局补齐):
剩余名额从所有未入选 chunk 中按 rerankScore 全局取 Top
跨子问题去重时合并 servedSubQueryIndices专用线程池设计
java
@Bean
public Executor ragRetrievalExecutor() {
return new ThreadPoolExecutor(
8, 16, 60s,
new LinkedBlockingQueue<>(20),
...,
new ThreadPoolExecutor.CallerRunsPolicy() // 满了反压而非丢任务
);
}不用 ForkJoinPool.commonPool() 的原因:检索是 IO 密集(HTTP 调 LLM、Milvus、Lucene),用 commonPool 会和 CPU 密集任务争资源。CallerRunsPolicy 让上游"自然反压"——LLM 异常导致线程打满时不丢任务,让调用方阻塞自动限流。
降级与开销控制
| 触发条件 | 行为 |
|---|---|
rag.decompose.enabled=false(默认) | 完全跳过,零开销 |
| ComplexityClassifier 判定为简单 | 跳过拆解 LLM 调用 |
| LLM 拆解失败 / 返回 < 2 条 / JSON 解析失败 | 退化到原单查询链路 |
| 单个子问题检索失败 | 用空结果占位,其它子问题正常 |
| 全部子问题都失败 | 触发 SafetyGuard.needsFallback 走兜底 prompt |
面试话术:
"Query Decomposition 不是 Plan-and-Execute 的山寨版——它是 RAG-native 的设计。Plan-and-Execute 的核心收益是任务异构 + 并行依赖管理,但 RAG 的子任务是同构的(都是检索),上 DAG 是过度抽象。
工程上有几个细节值得展开:第一是保底分配——直接全局排序会让低分子问题完全消失,所以每个子问题至少分配 ceil(K/N) 个名额,再用全局分数补齐剩余。第二是专用线程池 + CallerRunsPolicy——检索是 IO 密集,不能和 commonPool 抢资源;满了反压而非丢任务。第三是子问题不再过 QueryRewriter——拆解器输出已经是规范化查询,再过一遍是双倍 LLM 成本。
整个改造对底层组件零侵入——VectorRetriever / BM25Retriever / RRFFusion / CrossEncoderReranker 都没动,只是新增了 Decomposer 和 Merger 加上调度逻辑。"
HyDE 假设文档生成(Phase 3 新增)
问题:模糊/概念性查询(specificity == FUZZY)与文档之间存在语义鸿沟——用户问"微服务架构的优势",但文档里写的是具体的"服务解耦、独立部署、故障隔离"等描述。短 query 的 embedding 和长文档 chunk 的 embedding 天然不在同一语义密度层面。
方案:HyDE(Hypothetical Document Embeddings,Gao et al. 2022)——先让 LLM 生成一段假设性回答(150-300字),再用该回答的 embedding 做向量检索。假设文档与真实文档在语义空间中更接近(都是陈述式长文本),从而提升召回率。
触发条件(两个入口):
- 首轮自动触发:
QueryProfiler判定 specificity == FUZZY 时,SupervisorAgent 自动为 RetrievalWorker 开启 HyDE - 重试触发:ObservationEvaluator 检测到首轮 confidence < 0.4 且尚未尝试 HyDE 时,推荐 TRIGGER_HYDE 动作
实现:
java
// 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, ...);关键设计决策:
- 向量路用 HyDE,BM25 路用原始 query:HyDE 文本是 LLM 生成的自然段落,做 BM25 关键词匹配反而引入噪声
- 用小模型(qwen-turbo)生成:假设文档不需要高质量推理,只需要语义密度,小模型够用(延迟 200-400ms)
- 失败回退:生成失败或为空时,回退使用原始 query,不阻塞主流程
- 不对 PRECISE 查询使用:精确查询(含具体编号/术语/API名)向量检索已足够准确,HyDE 反而可能泛化丢精度
配置:
hyde.enabled=true:全局开关agent.observation.low_score_threshold=0.4:重试触发阈值
面试话术:
"HyDE 解决的是模糊查询的语义鸿沟问题。用户问'系统架构的特点',query 只有 6 个字,embedding 信息密度很低;而文档里是 200 字的技术描述,embedding 信息密度高。让 LLM 先生成一段假设回答,就把 query 的语义密度拉到和文档同一层面。
工程上有三个细节:第一,只在向量路用 HyDE、BM25 路保持原 query——BM25 靠关键词精准匹配,给它 LLM 生成的文本反而引入无关词干扰 IDF。第二,用小模型不用大模型——假设文档只需要语义丰富度不需要推理质量,200ms 就够了。第三,有两个触发入口——首轮 specificity=FUZZY 自动开启,加上 ObservationEvaluator 在首轮低分时推荐重试,形成双保险。"
向量检索(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 精算 → 对候选集逐条打分排序BM25 公式实现:
score = IDF × (tf × (K1+1)) / (tf + K1 × (1 - B + B × docLen/avgLen))- 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)) ← 自适应检索启用时- 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-rerank- 通过 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)- λ = 0.7(可通过
mmr.lambda配置动态调整) - 相似度使用字符 bigram Jaccard(无需额外 embedding API 调用)
- 贪心选择:先选 top-1,再逐步选 MMR 值最高的候选
- 可通过
mmr.enabled开关关闭
解决的问题:当 Top-K 候选全部来自同一文档的相邻段落时,内容高度重叠,送入 LLM 是浪费 token。MMR 确保最终输入 LLM 的 chunk 既相关又多样。
面试话术:
"精排后再做 MMR 多样性重排是标准做法——Cross-Encoder 保证相关性,MMR 保证信息密度。λ=0.7 意味着 70% 权重看相关性、30% 看多样性,在'准'和'全'之间取平衡。相似度用 bigram Jaccard 而不是 embedding cosine,省了一轮 API 调用,对中文文本效果足够。"
上下文压缩(Context Compressor)
重排后的结果还需要压缩,控制送入 LLM 的 token 量:
三步压缩:
- 去重:内容前 50 字相同视为重复,保留分数更高的
- 截断:单条 chunk 超过 800 字则截断(保留前 800 字)
- 总量控制:按分数从高到低累加,直到总字符数达到 token 上限(默认 3000 token ≈ 6000 字)
面试话术:
"上下文压缩是成本控制的关键。如果把 20 条 chunk 全部塞给 LLM,token 消耗翻倍且会引入噪声。压缩后只保留最相关的 4-6 条,既省钱又提升答案质量。"
文本切分(Text Chunker)
文档上传时的预处理步骤:
混合切分策略:
段落分割(\n\n)
├─ 短段落 → 合并到目标大小(400字)
└─ 长段落 → 按句子切分(。!?;\n),保留50字重叠内容类型自动检测:根据文本特征标记 chunk 类型
- procedure(步骤/流程)
- warning(警告/注意事项)
- example(示例/案例)
- definition(定义/概念)
- general(通用)
丰富元数据(Phase 1 新增):每个 chunk 携带以下结构化元数据,支持精确过滤和溯源:
tags:JSON 数组,从章节标题 + contentType 自动提取(如["架构设计","definition"])docVersion:文档版本号,辅助增量更新判断effectiveDate:文档生效日期,支持时效性过滤sourceFileName:原始文件名,溯源展示
面试话术:
"切分策略直接影响检索质量。太大的 chunk 噪声多,太小的 chunk 丢失上下文。400字 + 50字重叠是中文文档的经验值。内容类型标记让后续的检索和展示可以针对性优化。
丰富元数据是 Phase 1 基础铺垫的一部分——标签从 heading 栈自动提取,零人工标注成本。有了 tags 就可以在 Milvus 里做标量过滤,比如用户搜'架构相关'可以直接缩小候选集;docVersion 配合语义缓存的版本校验,确保知识库更新后旧缓存自动失效。"
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
多个子块指向同一父块时自动去重(只保留分数最高的)核心设计:
| 层 | 大小 | 存储 | 用途 |
|---|---|---|---|
| 父块 | ~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 Agent,而是 Query Decomposition?
A: 看场景。Plan-and-Execute 的核心收益是任务异构 + 并行依赖管理(订机票要先查日期再付款再发邮件),但 DocMind 的子任务全部是检索,是同构的。给同构任务上 DAG 是过度抽象,会引入维护成本和延迟(多一次 LLM 规划调用)。Query Decomposition 是更对症的方案——只解决"信息焦点分散"这一个问题,组件复用度高、降级路径短。
Q: SubQueryMerger 为什么要做保底分配?直接全局排序不行吗?
A: 不行。假设子问题 A 来自常见领域(chunk 都是 0.8 分),子问题 B 来自小众领域(chunk 都是 0.4 分),全局排序取 Top-K 会让 B 的 chunk 全部消失。但用户拆出 B 就是因为它是回答的必要部分——丢了 B 等于拆解白做。保底分配
floor = ceil(K / N)强制每个子问题至少贡献 floor 条,剩余名额再按全局分数补齐,确保覆盖完整性。
Q: 子问题为什么不也过 QueryRewriter?
A: 双倍 LLM 成本,收益不明确。QueryDecomposer 的 prompt 里已经要求"输出能直接送入检索系统的查询",输出本身就是规范化的检索查询。再过一遍 Rewriter 就是 N 次额外的 LLM 调用,对召回率的提升非常有限。这是经过权衡的工程决策,不是遗漏。