Skip to content

检索与排序链路

链路全景

本图反映当前最新实现(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 00done.isEmergency=true
闲聊 / 元对话 / KB 元信息 / 越界Phase 51(合并模式已含)scope + done.scopeShortCircuit=true
语义缓存命中缓存层0done.fromCache=true
知识查询(正常)主流程全走2–4(理解+生成+可选反思+可选重写)全事件序列

四条检索路径对比

维度文档直读拆解检索Plan-and-Execute标准迭代
触发条件用户选中 ≤3 份文档needDecompose=truecomplexity=COMPLEX其余情况
Worker 调用无(DB 直读)RetrievalWorker × N(并行)PlanExecutor 拓扑调度1-3 轮迭代 ReAct
BM25❌(子问题已规范化)由计划决定✅(默认)
HyDE由计划决定✅(specificity=FUZZY 时)
Web 搜索由计划决定✅(timeAware=true 时)
Prompt 模板assembleassembleDecomposedassembleassemble
intentTypeselected_documentdecomposedplan_and_executeplanner/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 做向量检索。假设文档与真实文档在语义空间中更接近(都是陈述式长文本),从而提升召回率。

触发条件(两个入口):

  1. 首轮自动触发QueryProfiler 判定 specificity == FUZZY 时,SupervisorAgent 自动为 RetrievalWorker 开启 HyDE
  2. 重试触发: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 量:

三步压缩

  1. 去重:内容前 50 字相同视为重复,保留分数更高的
  2. 截断:单条 chunk 超过 800 字则截断(保留前 800 字)
  3. 总量控制:按分数从高到低累加,直到总字符数达到 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 onlyLLM 生成上下文
子块~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 调用,对召回率的提升非常有限。这是经过权衡的工程决策,不是遗漏