面试知识库

检索与排序链路#

链路全景#

本图反映当前最新实现(四刀改造 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 作为置信度来源。

三条短路路径#

主流程详细分层#

SSE 事件完整时序(当前协议):

understanding → routing
→ [agentic × N]?(AGENTIC 路径每轮循环一次)
→ [code_exec × N]?(仅 AGENTIC 路径且 sandbox.enabled 开,每次沙箱代码执行一次)
→ retrieval → rerank → grader
→ start → token × N
→ done
plaintext

已删除:reflection_start / reflection_token / reflection_donemultihopplandone 载荷新增 citations / citationCoverage / invalidRefs

三条短路 vs 主流程对比

触发条件短路层LLM 调用次数特征事件
紧急词命中Phase 00done.isEmergency=true
闲聊 / 元对话 / KB 元信息 / 越界Phase 51(合并模式已含)scope + done.scopeShortCircuit=true
语义缓存命中缓存层0done.fromCache=true
知识查询(正常)主流程全走2+(理解 + 生成;AGENTIC 路径还含每轮工具调用的 LLM 决策)全事件序列

三条检索路径对比

维度文档直读agentic 循环一次性检索
触发条件用户选中文档 + 文档/摘要意图复杂 / 中等 / 歧义 / 多焦点SIMPLE 且非歧义
Worker / 调用方式无(DB 直读)LLM 工具调用循环(自驱拆解/多跳/web/停)并行 Worker 固定流水线
BM25由模型自决(searchDocs 内含混合检索)✅(默认)
HyDEsearchDocs 内 specificity=FUZZY 时✅(specificity=FUZZY 时)
Web 搜索由模型自决(webSearch 工具在白名单内)✅(timeAware=true 时)
Prompt 模板assembleassembleassemble
intentTypeselected_documentagentic_loopplanner / 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 精算 → 对候选集逐条打分排序
plaintext

BM25 公式实现

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-rerank
plaintext
  • 通过 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 量。四步

  1. 精确去重:用 dedupeKey()(id 优先,缺失退化内容指纹)判重,保留更高排名的
  2. 近重复去除compress.near_dup_enabled 默认开):精确去重抓不住”内容高度雷同但 id/全文不同”的冗余(父/子块重叠、web 模板 boilerplate)。输入已按 rerank 降序,对每个候选只与已保留的更高排名块比对:包含关系(短文落长文内)或 bigram Jaccard ≥ compress.near_dup_jaccard(默认 0.85,偏保守)即判冗余丢弃
  3. query-aware 截断(取代旧的”无脑保头”):单条超 800 字时,以命中段为中心取窗口而非保头——父块展开后命中正文常落在中后段,保头会把它砍掉,造成”检索到却答不出”。命中段定位优先用展开前的子块原文(getChildContent()),其次用 query 词命中位,都没有才退回保头;窗口边界做句界吸附snapStart/snapEnd,仅在 80 字窗内找句末标点)避免从句子中间切
  4. 总量控制:按 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 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,也没保留自研 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)承接。