拆解RAG三层检索:查询理解、多路召回与上下文精修

拆解RAG三层检索:查询理解、多路召回与上下文精修 面试官问 RAG 策略时最怕的不是答不上来而是只说出“先检索再生成”这个框架。这个框架没有错但信息量不够。真正能拿分的回答是把检索拆成有层次的动作并解释每一层解决什么问题、用了什么技术、最后怎么验证。这里给出一个可以直接用在面试里的答案结构RAG 可以理解为三层检索分别是查询理解层、多路召回层、候选重排与上下文精修层。后续关于切块、向量库、重排、prompt 设计的问题也都可以挂到这条链路上。1. RAG 在解决问题时把回答分成了两道工序检索是第一道很多候选人被问到 RAG 时会先从全称开始背Retrieval-Augmented Generation检索增强生成。这没有错但面试官更想确认的是你是否理解“检索”在整个系统里到底承担什么角色。RAG 不是让大模型自己去网上搜索而是由外部系统先把知识取回来再让大模型基于取回来的内容完成生成。检索是外部动作生成是模型动作这两道工序必须配合好。1.1 RAG 的本质是给大模型补上下文不是让大模型自己搜索纯大模型生成有一个天然问题模型只会输出训练时见过的知识超出训练截止时间的信息、内部文档、业务数据库里的内容模型并不知道。如果强行回答就很容易出现幻觉或者给出过时结论。RAG 的思路是在生成前增加一个外部记忆环节。用户提出问题后系统先从知识库、文档库、数据库等外部来源中检索出相关片段再把片段和大模型本身的指令能力组合成 prompt最后让大模型基于这些片段生成答案。这样答案不是模型“回忆”出来的而是模型从给定证据里“推理”出来的。这也是为什么 RAG 方案能缓解幻觉、支持知识更新、降低微调成本。1.2 三层检索是一个漏斗结构检索如果只做一次很难同时兼顾召回率和准确率。直接从底层文档里筛一遍确实能捞出候选但候选排序和上下文质量不一定好。把检索拆成三层本质上是在搭一个漏斗用户问题 - 第一层查询理解与改写 - 第二层多路召回与候选融合 - 第三层重排与上下文精修 - 构造 Prompt - 大模型生成 - 带引用的回答第一层解决“检索目标不对”的问题第二层解决“该捞的没捞到”的问题第三层解决“捞到了但模型不该全看”的问题。层级核心目标常见技术面试最容易忽略的点查询理解层让检索目标更准确query 改写、指代消解、HyDE、意图分类多轮对话里的“它”“这个”需要还原多路召回层尽量不错过候选答案BM25、向量检索、知识图谱召回、RRF 融合分数不能直接相加需要归一化或融合策略重排与精修层决定大模型最终看到什么内容cross-encoder 重排、去重、上下文裁剪、引用溯源不是 top-k 越多越好上下文噪声会拉低答案质量1.3 面试先给一句话结论如果面试官让你总结 RAG 策略可以先给一个完整但不啰嗦的结论“我一般把 RAG 策略概括为三层检索。第一层是查询理解把原始问题改写成适合检索的形态第二层是多路召回用 BM25、向量检索等方式拿到足够多的候选第三层是重排和上下文精修从候选中挑出信息量最高、噪声最低的片段交给大模型。最终大模型看到的是一段高信噪比的证据而不是一堆相关文档。”这句话的作用是给面试官一个框架锚点后面他追问任何细节你都能回到三层结构里继续展开。2. 第一层检索查询理解与改写决定检索方向很多人做 RAG 时第一个动作是直接给 query 做 embedding然后去向量库做 similarity search。这个流程在简单 demo 里能跑通但在真实场景里经常检索不到原因往往不是向量库选错了而是 query 本身不适合检索。2.1 原始 query 为什么不能直接用用户的问题通常很短而且带有口语、缩写、指代和上下文省略。比如“我们准备用向量数据库它的性能怎么样”在这个问题里“它”指的是哪个向量数据库模型并不知道。如果不做指代消解在召回层会得到一个非常模糊的向量检索结果自然不准。再比如中文里“苹果”可能是水果可能是手机品牌也可能是某家公司。如果知识库面向特定领域一个词在不同上下文里语义差异很大。直接在原始文本上做 embedding语义可能被平均到多个含义上。更麻烦的是长 query。用户把完整背景都写在问题里直接拿去跑 BM25词频会被无关词汇稀释直接拿去跑向量检索又没有很好地把问题拆成可检索的子意图。2.2 查询理解的常见做法查询理解并不只有“大模型改写”一种方式。实际项目通常按成本从低到高选择策略。策略做法适用场景风险规则改写拼接多轮对话历史、正则替换同义词多轮问答、固定术语场景复杂语言表达覆盖不足小模型改写用轻量模型把 query 补全、去噪延迟敏感、成本受限需要额外维护一个模型大模型改写让 LLM 生成适合检索的查询复杂意图、跨领域问题增加一次模型调用和延迟HyDE先生成一段虚构答案再对虚构答案做 embedding语义检索、候选排序虚构答案方向不对时反而引入噪声子问题拆分把复杂问题拆成多个子查询多跳问答子问题之间结果需要再合并其中 HyDE 值得多说一句。HyDE 的做法不是直接对原始问题做 embedding而是先让 LLM 针对问题写一段“假设性的回答”然后用这段回答去检索相似文档。背后的直觉是问题和文档在向量空间里的形态差别很大但“答案”和“文档”的形态更接近。这样检索可以先保住语义再通过重排过滤掉不相关片段。2.3 一个最小改写实现如果是多轮对话场景最简单的改写方式是把最近几轮历史拼到 query 前面。这样做成本低也能解决一部分指代问题。from typing import Optional def rewrite_query(raw_query: str, history: Optional[list[str]] None) - str: 把最近的历史拼到当前问题前用于消解指代。 if not history: return raw_query recent \n.join(history[-2:]) return f参考历史\n{recent}\n当前问题{raw_query}这段代码只是最基础的规则改写。生产环境如果要让 LLM 改写可以给它一个更明确的 prompt请把下面的用户问题改写成一条可以独立检索的查询。 要求 1. 保留原问题的实体和限定词。 2. 如果问题里有代词结合对话历史补全。 3. 不要编造原问题没有的信息。 4. 只输出改写后的查询不要解释。 用户问题{raw_query}这样改写后的结果会相对干净也能防止大模型在改写过程中加入无关信息。2.4 第一层的验证方式查询理解层不能只看“改写后的句子通不通顺”。更有效的验证方式是把改写前后分别跑一遍召回对比标准答案在前 N 个候选里的命中率。比如准备 100 条评测问题每条问题标注一个或多个标准文档 id。用原始 query 做召回统计 recall10再用改写后的 query 做召回统计 recall10。如果改写后 recall 没有提升说明改写策略并没有给检索带来增益反而可能在增加延迟。这里要特别注意一个常见坑改写不能太激进。有些模型会把“RAG 策略”改写成“检索增强生成技术的设计思路和应用策略”虽然语义更像文档但丢失了用户原本想问的“策略”视角。所以改写之后一定要做等价性检查。3. 第二层检索多路召回与候选融合先保证不漏查询理解解决方向问题多路召回解决“覆盖率”问题。很多系统只用向量检索理由是人人都说 embedding 能理解语义。但生产环境里向量检索并不是万能的。3.1 为什么不能只做向量检索向量检索擅长处理“意思相近但表达不同”的查询比如“如何提升模型回答准确性”和“模型经常答错怎么优化”。但向量检索对精确字符匹配并不敏感尤其是产品型号、报错码、法条编号、身份证号这类内容。举个例子用户输入 “ERROR_CODE_10086” 去查错误码文档如果 embedding 模型没有见过这个 token向量会散到语义空间里检索结果可能是一堆无关错误处理文章。此时 BM25 这种基于词频的检索反而更可靠因为它能把“10086”当成精确关键词去匹配。所以在真实 RAG 系统里一般不会只跑一路召回。常见组合是 BM25 加向量检索条件允许时再加入知识图谱召回最后用融合策略把多路结果合并。3.2 BM25 和向量检索各自的优势BM25 是一种基于词频和逆文档频率的排序算法它不依赖模型只对文本分词后统计词项。核心公式如下score(D, Q) sum( IDF(q) * (tf(q, D) * (k1 1)) / (tf(q, D) k1 * (1 - b b * len(D) / avgdl)) )其中tf(q, D)是词项在文档中的出现次数len(D)是文档长度avgdl是平均文档长度k1和b是控制词频饱和度和长度惩罚的超参。对于中文检索需要先分词再交给 BM25常用的是 jieba 或领域分词器。向量检索则是把 query 和 doc 分别编码成向量然后计算余弦相似度或内积。它的优势是能匹配同义词和语义相近的表达缺点是对精确词、罕见词、代码片段不太敏感。两类方法不是替代关系而是互补关系。这也是“向量混合检索加 BM25 多路召回”在生产环境里越来越常见的原因。3.3 多路召回结果融合RRF多路召回做完之后需要把 BM25 的结果和向量检索的结果合并。最直接的方式是给两路分数做加权求和但两路分数的分布完全不一样直接加权重会导致某一方主导。更常见的做法是 RRFReciprocal Rank Fusion它只使用排名不使用原始分数。def rrf_fuse(rank_lists, k: int 60) - list[tuple[str, float]]: RRF 融合多路召回结果rank_lists 是 doc_id 的排名列表。 fused: dict[str, float] {} for ranks in rank_lists: for rank, doc_id in enumerate(ranks): fused[doc_id] fused.get(doc_id, 0.0) 1.0 / (k rank 1) return sorted(fused.items(), keylambda x: x[1], reverseTrue)这个实现里k通常取 60。k越大排名差异对分数的影响越平滑k越小排名越靠前的文档优势越大。RRF 的好处是稳定而且不关心每路召回的量纲。缺点是它只用了排名信息如果某一路召回能力特别强RRF 可能把这一路的高排位结果过度放大因此实际调参时仍要结合评测。3.4 召回层参数不能拍脑袋召回层的常见参数有三个候选数量、最终送入模型的 top-k、以及是否做 metadata 过滤。它们的含义和影响不同。参数含义常见值设置过小设置过大召回候选数多路召回融合后保留多少候选50 到 200正确文档可能被过滤掉重排和生成阶段耗时增加最终 top-k重排后送进大模型的片段数3 到 10证据不足答案容易缺少细节上下文噪声变多模型容易跑偏metadata 过滤按板块、时间、类型、权限过滤按业务设计会召回跨领域或越权内容正确内容可能被误过滤在 demo 阶段召回候选数可以先取 50最终 top-k 取 5把链路跑通后再用评测集调参。不要一上来就追求极致的 top-k因为每个知识库的文档长度和切块质量不同参数需要跟着数据走。4. 第三层检索重排与上下文精修决定模型看到什么召回层拿到一批候选后常见错误是直接把前 5 个或前 10 个片段拼进 prompt。这种做法在简单测试里能出结果但在复杂知识库里会暴露出两个问题候选排序不够准、上下文噪声太多。4.1 为什么召回结果不能直接进大模型召回层使用的是双塔类模型或者 BM25它们的定位是“快速筛掉明显不相关内容”而不是“精确排序”。向量召回时query 和 doc 是分别编码的编码过程没有让 query 和 doc 做深度融合BM25 则完全只做字面匹配。这两种方式都会把一些“看起来相关但实际用不上”的片段排到高位。如果把这些片段全部塞给大模型大模型会被不相关信息干扰甚至从噪声片段里摘出错误结论。所以召回层之后的第三层要做的是“用更精细的模型重新排序”和“把排序后的上下文按生成需求做精修”。4.2 重排模型从双塔到交叉编码重排阶段常见的做法是使用 cross-encoder 模型。它和双塔模型最大的区别是把 query 和 doc 拼接成一个序列输入模型让模型充分计算两者之间的交互特征。这样排序精度更高但推理成本也更高所以通常只在候选集较小的阶段使用。from sentence_transformers import CrossEncoder def rerank_chunks(query: str, chunks: list[str], top_k: int 5) - list[str]: # 模型名只是示意落地前要根据语言和领域选型 reranker CrossEncoder(cross-encoder/ms-marco-MiniLM-L-6-v2) pairs [(query, chunk) for chunk in chunks] scores reranker.predict(pairs, batch_size16) order scores.argsort()[::-1][:top_k] return [chunks[i] for i in order]这段代码把 query 和每个候选片段组成 pair一次性交给 cross-encoder 打分然后取分数最高的 top_k。如果知识库是中文需要换成中文交叉编码模型或者基于自己的业务数据做微调。模型名并不重要重点是“双塔做粗排交叉编码做精排”这条链路。4.3 上下文精修不只是排序重排只是第三层的一部分。真正决定大模型输出质量的是进入 prompt 的上下文是否干净。精修阶段至少要做三件事第一去重。召回和重排的结果里可能有大量重复片段尤其是同一篇文档的不同切块。如果不做去重同一个信息会被重复提交浪费上下文窗口。第二裁剪与补全。某些片段太短单独看缺少上下文某些片段太长占满上下文。实际项目里可以使用 parent-child 切块策略用小子块做召回找到后再把对应的父块或相邻块补进上下文让模型看到更完整的语境。第三元数据过滤。如果知识库有来源、时间、权限、业务线等字段重排之后还要按这些字段做过滤。比如“2025 年产品介绍”就不能用 2023 年的旧文档回答。4.4 构造带约束的 Prompt上下文精修完成后prompt 的构造方式会直接影响答案的 grounding。下面是一个常见的带引用约束的 promptdef build_prompt(query: str, chunks: list[str]) - str: block \n\n.join( f[{idx 1}] {chunk} for idx, chunk in enumerate(chunks) ) prompt ( 请只基于以下资料回答问题。 如果资料中没有相关信息请直接回答资料中未找到相关依据。\n\n f资料\n{block}\n\n f问题{query}\n\n 回答要求\n 1. 使用中文回答。\n 2. 每条结论后面用 [编号] 标注引用的资料。\n 3. 不要编造资料中不存在的信息。 ) return prompt这里把“资料编号”写进 prompt并在回答要求里强制模型标注引用是控制幻觉比较有效的手段。注意 prompt 中“资料中未找到相关依据”这类的兜底指令会让模型更谨慎但也可能让部分有把握的问题回答得偏保守需要根据产品定位取舍。4.5 groundedness 检查重排之后还可以在生成阶段或生成完成后做 groundedness 检查也就是验证回答里的每个结论是否真的能从引用片段里找到来源。常见做法是把回答拆成短句再让一个审核模型判断每个短句是否被某条引用支持。对于高风险场景比如医疗、金融、法律这个步骤不能省。5. 一个最小可运行的 RAG 三层检索骨架理论讲完下面用一个最小骨架把三层检索串起来。这个骨架适合本地跑通流程也适合作为面试里讲“系统性实现思路”的素材。它不是生产级代码但结构和生产实现是一致的。5.1 环境准备本地建议使用 Python 3.10 以上版本。核心依赖可以先用下面这一组pip install fastapi uvicorn rank-bm25 jieba faiss-cpu sentence-transformers生成部分可以使用 OpenAI 兼容接口。如果没有现成大模型服务也可以先用本地服务例如 llama.cpp 启动一个支持 OpenAI 兼容接口的模型。不同依赖的版本在落地前要确认相互兼容尤其是 faiss 和 sentence-transformers 的版本。5.2 切块策略切块是 RAG 里最容易出问题但最容易被忽略的环节。先给一个基于滑动窗口的简单切块函数def split_text(text: str, chunk_size: int 500, overlap: int 80) - list[str]: chunks [] start 0 while start len(text): end min(start chunk_size, len(text)) chunks.append(text[start:end]) if end len(text): break start chunk_size - overlap return chunks核心参数是chunk_size和overlap。chunk_size控制每个片段长度overlap控制相邻片段的重叠量避免一句话或一个关键信息被切碎。这个版本只做字符切分真实环境里要优先按 Markdown 标题、段落、代码块边界切分。切块方式优点缺点适用场景固定长度实现简单、延迟稳定容易切断语义长文本快速验证递归字符按结构边界切分需要设计分隔符优先级普通 Markdown 文档语义切块边界更自然计算成本高高质量知识库parent-child召回小粒度、生成看大粒度实现复杂文档结构复杂、问答需要上下文5.3 主流程串联最小主流程可以抽象成四步改写 query、多路召回、RRF 融合、重排后生成。def rag_pipeline(query: str, history: list[str] | None None) - dict: # 第一层查询理解 rewritten_query rewrite_query(query, history) # 第二层多路召回 bm25_ranked bm25_search(rewritten_query) vector_ranked vector_search(rewritten_query) fused rrf_fuse([bm25_ranked, vector_ranked], k60) # 只保留前 50 个候选进重排 candidate_texts [chunk_id_to_text[doc_id] for doc_id, _ in fused[:50]] # 第三层重排与精修 top_chunks rerank_chunks(rewritten_query, candidate_texts, top_k5) # 构造 prompt 并生成 prompt build_prompt(rewritten_query, top_chunks) answer llm_generate(prompt) return { answer: answer, chunks: top_chunks, rewritten_query: rewritten_query, }这里bm25_search、vector_search、llm_generate需要根据自己的索引和模型接口补全。重点是整个函数的结构体现了一层一层收窄候选集的过程这比把所有代码塞在一个函数里更适合后续维护和排查。5.4 接口验证用 FastAPI 暴露一个简单的 HTTP 接口from fastapi import FastAPI from pydantic import BaseModel, Field app FastAPI() class QueryIn(BaseModel): query: str history: list[str] Field(default_factorylist) app.post(/rag) def rag_endpoint(payload: QueryIn): return rag_pipeline(payload.query, payload.history)启动服务后用 curl 发送一个测试请求curl -X POST http://127.0.0.1:8000/rag \ -H Content-Type: application/json \ -d {query: 什么是 RAG 三层检索, history: []}正常情况下会返回类似下面的 JSON{ answer: RAG 三层检索可以拆成查询理解、多路召回、候选重排与上下文精修三层。, chunks: [ 查询理解层负责改写和消解指代。, 多路召回层负责用 BM25 和向量检索获取候选。, 重排层用 cross-encoder 精排候选并控制上下文噪声。 ], rewritten_query: 什么是 RAG 三层检索 }如果响应的chunks里没有相关内容不要急着调 prompt应该先回头检查召回层和重排层。6. 生产环境里的 RAG 实战难点与优化方向面试里说完最小实现后面试官通常都会追问一句“如果上生产你还会考虑什么”。这个问题考察的不是你会不会调 API而是有没有踩过真实的坑。6.1 切块策略是最先暴露问题的环节很多 RAG 系统上线后才发现问题不在大模型而在切块。固定长度切块可能会把一句话拆成两半导致单块信息不完整也可能把标题和正文拆开导致模型不知道这段内容属于哪个章节。生产环境建议优先使用结构感知的切块方式。对 Markdown 文档先按标题层级拆再按段落拆最后按句子边界处理。对表格尽量保证一行或一个表格块不被拆散。对代码文档要保留代码块整体。切块大小也不能拍脑袋。通常可以先从 300 到 800 字起步再根据检索命中率和答案质量调整。重点是建立一套 small set of golden questions用它来对比不同切块策略的效果。6.2 向量库选型和元数据过滤本地 demo 用 FAISS 足够但生产环境要面对增量更新、并发访问、多租户隔离、权限过滤等问题这时候通常需要 Qdrant、Milvus、pgvector 等产品。向量库选型时要关注几个点索引类型、维度上限、过滤能力和运维成本。如果技术栈是 Java也可以关注 Spring AI 2.0 配合 Qdrant 这类组合。这里要澄清一点无论用什么框架三层检索的思想不会变框架只是把 query transformer、retriever、reranker 等组件封装成了接口。生产环境非常重要的一点是 metadata 过滤。不要每次都在全量知识库里检索应该先按业务线、时间范围、文档类型、权限范围过滤再进入检索。否则检索结果很容易混入跨业务、跨权限的内容。6.3 延迟、缓存和成本控制RAG 的延迟来自四个部分查询改写、召回、重排、生成。项目里可以按 p95 延迟来观察每一部分而不是只看总耗时。常见优化手段包括查询改写使用更小的模型避免每次都调用最大的生成模型。文档切块后的 embedding 提前算好并缓存不要在请求链路里实时计算。BM25 索引和向量索引都放到内存或高速存储避免频繁 IO。重排只处理前 50 到 100 个候选不要对全量候选做 cross-encoder。生成阶段限制输出 token 数并设置超时和重试策略。成本上embedding 费用和 LLM token 费用是最明显的两块。缓存命中率越高成本越低。对热门问题做答案缓存是很多系统中见效最快的手段。6.4 线上评估和监控RAG 上线不能只看“回答得对不对”还要看检索链路每一步的指标。建议记录以下日志字段query rewritten_query recall_ids rerank_ids final_chunk_ids answer user_feedback latency_ms评估指标可以按三种类型来看指标含义用途recallk标准文档是否出现在前 k 个召回结果中衡量召回层质量MRR第一条相关文档出现的排名倒数衡量排序质量groundedness回答结论是否被引用片段支持衡量幻觉程度生产环境里可以把用户反馈和标注 badcase 回放进测试集每隔一段时间重新跑一轮评估避免改了一个参数后其他场景变差。6.5 企业级 RAG 的常见痛点企业级 RAG 不只是“文档检索加生成”。权限控制、数据更新、多源异构文档、引用溯源、回答口径统一都是比检索算法更早暴露的问题。权限控制是最容易被忽略的。如果知识库里有不同部门的文档检索层不做权限过滤大模型就可能把别的部门数据生成出来。这个问题非常严重必须在召回前就通过 metadata 过滤掉。数据更新也是。文档更新后旧的切块和 embedding 如果不做同步清理检索结果会一直带上旧版本内容。增量更新、删除失效切块、版本号比对这些在 demo 里不会出现但生产环境必须有。7. 面试官追问怎么回答RAG 策略讲完后面试官大概率会继续追问几个扩展方向。这里把高频追问整理成一种回答思路。7.1 Agentic RAG 是什么Agentic RAG 是 RAG 的进阶形态。普通 RAG 是一条固定 pipelinequery 进来检索一次生成一次。但很多问题不是一次检索能解决的。比如用户问“哪些城市的上季度销售额超过了目标”系统需要先确定城市列表再逐个查询销售数据最后汇总比较。这种多跳问题普通 RAG 只检索一次很容易漏信息。Agentic RAG 的思路是让 Agent 决定“什么时候检索、检索什么、要不要再检索一次”并把多轮检索结果整合后生成答案。面试里可以这样回答普通 RAG 是“一次检索、一次生成”Agentic RAG 是“计划、检索、观察、再