面试官问:RAG策略?说清这三层检索直接拿分!!!
很多人在准备大模型相关面试时,最怕遇到这类开放式问题:“讲讲你的 RAG 策略?”
如果只回答“文档切块 -> Embedding -> 向量检索 -> 拼接 Prompt -> 让大模型回答”,表面看流程完整,但面试官大概率不会满意。因为这套流程任何一个看过教程的人都能背出来,它没有体现你对 RAG 系统的真实理解。
面试官真正想听到的,是你在检索链路里做了哪些层次化的设计,以及你知不知道每一层分别解决什么问题。说得直白一点:RAG 的核心不是生成,而是检索;检索的核心不是单次向量匹配,而是分层递进地逼近用户真正想要的那段文本。
本文用一套可以落地的“三层检索”策略来拆解这个问题——召回层、精排层、增强层。每一层是什么、解决什么问题、常用方案有哪些、代码怎么实现、面试被追问时怎么答,全部展开讲清楚。读完这篇文章,你既能在面试时把 RAG 策略讲出层次感,也能在实际项目中把检索效果真正提上去。
1. 为什么 RAG 策略成了面试必考题
先说一个观察:这两年大模型岗位的面试题正在从“背模型架构”转向“考工程系统”。原因很简单,模型能力本身已经高度同质化,企业更关心的是你怎么把模型用起来、用得好、用得稳。
RAG 就是“把模型用起来”最典型的场景。它不依赖重新训练模型,也不需要昂贵的微调成本,而是通过外挂知识库的方式,让模型能回答私有领域问题、减少幻觉、支持知识更新。这套思路几乎适用于所有企业级 LLM 应用,所以面试官几乎必问。
但面试官问 RAG 策略,往往还会带一句潜台词:你只是用过 LangChain 的 vectorstore 做检索,还是真的理解检索质量怎么控制?
如果你只停留在“向量检索”这一步,说明你还没有遇到真实世界的检索问题。真实世界的问题包括:
- 用户问“苹果公司今年营收怎么样”,你库里存的是“Apple Inc. 2024 Q4 财报”,向量相似度可能没那么高,单纯向量检索可能召回不了;
- 用户问的是多个条件叠加的复合问题,一次检索往往不够;
- 向量检索召回的 Top-K 里,真正有用的可能只有一两条,其余全是“意思相近但不对题”的内容;
- 用户问题里带了特定术语、缩写、拼写变体,检索层很可能直接把关键信息过滤掉了。
这些问题的共同点是:单靠一层向量检索无法同时满足“召回全”和“排得准”。于是就有了分层的检索策略。
三层检索的结构可以这样理解:
| 层级 | 核心任务 | 解决的核心问题 |
|---|---|---|
| 第一层:召回层 | 从大规模文档库中快速找到候选集 | 高召回,不能漏掉关键信息 |
| 第二层:精排层 | 对候选集重新排序,让最相关的内容排前面 | 高精度,让真正有用的上下文进入 Prompt |
| 第三层:增强层 | 对用户问题和检索结果做改写、扩展、压缩 | 解决复杂查询、术语不一致、上下文超长等问题 |
这套结构的核心思想其实很好理解:用便宜快速的手段扩大候选范围,再用更精细的手段缩小答案范围。它和搜索引擎的“召回 + 排序”架构一脉相承,只是放在 RAG 场景里做了一些适配。
2. RAG 基础链路与面试官真正想听的答案
在展开三层检索之前,先把 RAG 整体链路交代清楚。这既是给基础稍弱的读者补背景,也是后面分析每层检索的起点。
一个完整的 RAG 流程通常分两阶段:
离线阶段(知识库构建):
- 加载文档,支持 PDF、Word、Markdown、HTML 等格式;
- 文本清洗,去掉页眉页脚、乱码、无关内容;
- 文本切块(chunking),把长文档切成适当大小的文本块;
- 对每个文本块做 Embedding,生成向量;
- 把向量和原始文本一起写入向量数据库。
在线阶段(查询问答):
- 用户输入问题;
- 对问题做 Embedding;
- 在向量数据库中执行相似度检索,取 Top-K 候选;
- 把候选文本作为上下文,拼接用户问题,构造 Prompt;
- 交给大模型生成回答。
很多面试者答到这里就停了,但这只是“标准流程”,不是“策略”。策略意味着你要在每一个环节做决策,并解释决策理由。
面试官真正想听的答案可以概括成一句话:“RAG 的检索环节我做了分层设计。召回层用向量检索保证 recall,精排层用重排序模型提升 precision,增强层用查询改写和混合检索处理复杂查询。这样既控制成本,又能把最终的上下文质量提上去。”
下面逐层展开。
3. 第一层:召回层——向量检索与切块策略
3.1 召回层解决什么问题
召回层的目标非常明确:从海量文本块中快速找到与用户问题相关的候选集。这一层的核心指标是召回率(Recall),而不是精确率。
场景化解释:你的知识库有 10 万条文本块,用户问了一个问题。召回层的任务是挑出最有可能包含答案的 20 条或 50 条,交给下一层精排。这一层如果漏召回了正确答案,后面无论怎么精排都救不回来。
所以召回层的核心矛盾是:候选集太小,容易漏;候选集太大,后续精排成本高,而且噪声多。通常的折中是控制在 20 到 100 条之间,具体取决于你的文档规模和业务场景。
3.2 向量检索不是唯一手段
一说到召回,很多人默认就是向量检索。实际上召回层可以组合多种方式:
- 纯向量检索:把问题和文档都变成向量,计算余弦相似度或内积,适合语义匹配场景。
- 关键词检索(BM25):基于词频和逆文档频率匹配,适合包含专有名词、编号、准确术语的查询。
- 向量 + 关键词混合检索:两种方式的结果做融合,兼顾语义和相关词匹配。
纯向量检索的典型弱点是“语义相近但关键词不重合”。比如用户问“怎么申请退款”,文档里写的是“退货流程”,向量检索大概率能匹配上。但如果用户问的是“SKU-2024-001 的库存”,文档里恰好用“货号 2024001”表示,向量可能匹配不准确,关键词检索反而更可靠。
所以现在主流的召回方案都会引入混合检索,而不是只依赖向量。
3.3 切块策略:RAG 里最容易忽略的细节
如果说召回层有一个最容易被忽略但又影响巨大的配置,那就是切块策略。这也是最近行业里讨论很多的话题。
为什么切块重要?因为向量检索的基本单位是“文本块”,而不是整篇文档。你切出的每个块的质量,直接决定向量检索的上限。
切块核心参数有三个:
Chunk Size(块大小):每个文本块包含多少字符或 token。太小,语义不完整;太大,向量表示被稀释,检索精度下降,还会浪费大模型上下文窗口。
Chunk Overlap(块重叠):相邻块之间重叠多少内容。没有重叠时,如果关键信息恰好被切断,这段内容就废了。加上重叠,等于给关键信息“上保险”。
切分粒度:按固定长度切,还是按段落、句子、Markdown 标题切。固定长度最简单,但容易把语义完整的段落拦腰截断。按结构切往往效果更好。
从实际项目经验看,切块并没有一个“一劳永逸”的万能参数。更靠谱的做法是把切块策略当成一个可调参数,配合评测集去验证。
推荐一个保守的起步方案:
| 文档类型 | 建议 chunk_size | 建议 overlap |
|---|---|---|
| 技术文档、说明书 | 500-800 字 | 50-100 字 |
| 问答对、FAQ | 200-300 字 | 20-30 字 |
| 长文章、报告 | 800-1200 字 | 100-150 字 |
但是这里要强调一点:不要照抄网上推荐值直接上生产。文本类型不同、Embedding 模型不同,最优参数都会变。要建立自己的验证集,用检索命中率评估不同参数组合。
3.4 召回层的代码示例
下面是一个基于 LangChain 的文档加载与切块示例。这里重点演示的是切块思路,而不是某个特定库的使用。
# 文件路径:src/rag_retrieval/chunking.py from langchain_community.document_loaders import TextLoader from langchain.text_splitter import RecursiveCharacterTextSplitter # 1. 加载原始文档 loader = TextLoader("data/company_manual.txt", encoding="utf-8") documents = loader.load() # 2. 配置切块策略 text_splitter = RecursiveCharacterTextSplitter( chunk_size=600, # 每个块约 600 字 chunk_overlap=80, # 块与块之间重叠 80 字 separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""], # separators 的优先级:先按段落切,再按句子切,最后按字符切 ) # 3. 执行切块 chunks = text_splitter.split_documents(documents) print(f"原始文档数: {len(documents)}") print(f"切块后文本块数: {len(chunks)}") print(f"第一个文本块前 100 字:\n{chunks[0].page_content[:100]}")说明几个关键点:
RecursiveCharacterTextSplitter是 LangChain 里最推荐的通用切分器,它会按 separators 列表里的优先级递归切分,尽量保证语义完整。chunk_size=600和chunk_overlap=80只是起步值,后续需要根据评测结果调整。- 如果文档本身有明确的标题结构(Markdown 标题、HTML 标签),可以考虑
MarkdownHeaderTextSplitter或按标题拆分的方案,让每个块自带语义边界。
切块之后,再由 Embedding 模型对每个块生成向量,写入向量数据库。这一步的代码如下:
# 文件路径:src/rag_retrieval/indexing.py from langchain_openai import OpenAIEmbeddings from langchain_community.vectorstores import FAISS # 初始化 Embedding 模型 # 生产环境建议用独立的 embedding 服务或本地模型,降低延迟和成本 embeddings = OpenAIEmbeddings(model="text-embedding-3-small") # 基于切块结果构建向量索引 vectorstore = FAISS.from_documents( documents=chunks, embedding=embeddings, ) # 保存索引到本地,方便后续加载 vectorstore.save_local("data/faiss_index")这个阶段输出的就是一个可检索的向量索引。后续在线查询时,直接加载索引做相似度检索即可。
3.5 召回层面试高频追问
面试官在这个环节最常追问的几个问题:
问:chunk_size 设置多大合适?不要直接说一个固定值。更好的回答是分两层:先说一个经验范围(比如 500 到 800 字),然后强调这个值需要根据文档类型、Embedding 模型和评测结果来调整,最后补一句“我会用验证集分别测试不同 chunk_size 下的召回命中率,选最优值”。
问:overlap 有什么用?overlap 是为了防止文本切分把关键上下文切断。如果某句话的上半句在一个块里、下半句在下一个块里,单独检索任一块都可能语义不完整。加了 overlap 之后,关键信息会以完整形式同时出现在相邻块中,提高召回概率。
问:为什么不用更大的 chunk_size?chunk_size 越大,单个向量表示的语义越模糊,检索精度下降。而且大块会占更多上下文窗口,导致可放入 Prompt 的块数变少。所以要在“语义完整性”和“向量精度”之间取平衡。
4. 第二层:精排层——重排序(Rerank)
4.1 为什么需要精排层
向量检索返回的 Top-K 只是“候选”,不是“答案”。它的问题在于:向量相似度高的文本,不一定真的能回答用户的问题。
举个例子。用户问“RAG 中向量检索和关键词检索有什么区别”,向量检索可能召回一段讲“向量检索的原理是……”“关键词检索的优点是……”的文本,这些文本单独看都和问题相关,但真正直接回答问题的可能只有一段。如果你把 Top-5 全部塞进 Prompt,既浪费上下文,还可能引入干扰信息,导致大模型答非所问。
精排层的任务就是解决这个问题:对召回层返回的候选集做一次更精细的相关性排序,把最可能包含答案的文本排到最前面。
4.2 重排序模型的原理
精排层最常用的方案是重排序模型(Reranker)。
与 Embedding 模型不同,重排序模型通常使用 Cross-Encoder 架构。它会把“用户问题 + 候选文本”作为一个整体输入模型,一次性计算它们之间的相关性得分。这样模型能看到问题和文档之间更细粒度的交互,效果比向量相似度更准,但代价是计算量大。
Embedding 模型和重排序模型的关键区别:
| 维度 | Embedding 模型(Bi-Encoder) | 重排序模型(Cross-Encoder) |
|---|---|---|
| 输入方式 | 问题和文档分别编码 | 问题和文档拼接后一起编码 |
| 计算效率 | 高,文档向量可离线预计算 | 低,需要在线逐一计算 |
| 语义匹配精度 | 一般 | 更高 |
| 适用场景 | 大规模召回 | 小规模精排 |
正因为重排序模型的精度高但成本高,它不适合对全量文档做计算,只适合在召回层已经缩小候选集的基础上,对几十条候选做精排。这也正是 RAG 链路中“粗排 + 精排”两层配合的典型架构。
4.3 精排层的代码实现
下面用一个完整的示例演示“召回 -> 精排”的流程。这里使用 sentence-transformers 库加载 Embedding 模型和重排序模型,并用 FAISS 做向量检索。
# 文件路径:src/rag_retrieval/retrieve_rerank.py from sentence_transformers import SentenceTransformer, CrossEncoder import faiss import numpy as np # ---------- 1. 加载模型 ---------- # Embedding 模型:用于向量召回 embed_model = SentenceTransformer("BAAI/bge-small-zh-v1.5") # 重排序模型:用于精排 rerank_model = CrossEncoder("BAAI/bge-reranker-base") # ---------- 2. 模拟候选文档 ---------- documents = [ "RAG(Retrieval-Augmented Generation)通过检索外部知识增强大模型生成能力。", "向量检索是将文本映射为高维向量,在向量空间中计算相似度。", "关键词检索(BM25)基于词频匹配,适合专有名词查询。", "重排序模型使用 Cross-Encoder 架构,精度更高但计算量更大。", "RAG 的检索策略通常分为召回层、精排层和增强层。", ] # ---------- 3. 离线向量化 ---------- doc_embeddings = embed_model.encode(documents, normalize_embeddings=True) dimension = doc_embeddings.shape[1] # 构建 FAISS 索引 index = faiss.IndexFlatIP(dimension) # IP:内积,配合归一化向量等价于余弦相似度 index.add(doc_embeddings) # ---------- 4. 在线查询:召回 ---------- query = "RAG 检索策略包含哪些层次?" query_embedding = embed_model.encode([query], normalize_embeddings=True) # 召回 Top-4 候选 top_k = 4 scores, indices = index.search(query_embedding, top_k) candidates = [(documents[i], float(score)) for i, score in zip(indices[0], scores[0])] print("===== 向量召回结果 =====") for idx, (doc, score) in enumerate(candidates): print(f"Top{idx + 1} 相似度={score:.4f} | {doc}") # ---------- 5. 精排 ---------- # 构造 rerank 模型需要的输入格式 rerank_inputs = [(query, doc) for doc, _ in candidates] rerank_scores = rerank_model.predict(rerank_inputs) # 按精排分数排序 reranked = sorted( zip(candidates, rerank_scores), key=lambda x: x[1], reverse=True, ) print("\n===== 重排序结果 =====") for idx, ((doc, vec_score), rerank_score) in enumerate(reranked): print(f"Rerank Top{idx + 1} 向量分={vec_score:.4f} 重排分={rerank_score:.4f} | {doc}")这个示例的精髓在最后一步:向量检索负责快速缩小范围,重排序负责重新排序。如果你的候选集是 Top-20,经过重排序后,通常只取前三到五条进入 Prompt,这样上下文质量会明显提升。
4.4 精排层的成本控制
面试官一定会问:重排序那么贵,线上扛得住吗?
一个成熟的回答是控制精排的候选数量。召回层返回 Top-50,精排层只对 Top-50 做计算,最后取 Top-3。这样每查询一次只做 50 次 Cross-Encoder 推理,延迟通常在几十毫秒级别,可以接受。
另一个常见优化是:只在候选数量多或者检索置信度不高的时候才启用重排序。如果简单查询一次向量检索的 Top-1 相似度已经很高,可以直接跳过重排序,大幅降低平均延迟。
5. 第三层:增强层——查询改写与混合检索
5.1 用户问题没那么“干净”
实际场景里,用户的问题往往不是理想化的简洁问句。你可能遇到:
- 口语化表达:“那个退款的流程是啥来着?”
- 指代不清晰:“它的效果怎么样?”——这里的“它”指什么?
- 复合问题:“对比一下 A 方案和 B 方案的成本和效果。”
- 拼写错误或术语变体:“SKU-2024 怎么同步”、“sku2024 价格”
- 问题缺少上下文:“推荐一下。”——没有任何领域背景。
如果直接拿用户原始问题去做向量检索,效果经常不好。因为 Embedding 模型面对口语、缩写、无上下文的问题时,很难准确映射到文档库里规范化的表述上。
增强层的作用就是在这里:在向量检索之前和之后,对问题和检索结果做额外的处理,提高最终上下文的匹配度。
5.2 查询改写:把问题变得更适合检索
查询改写(Query Rewriting)的思路是:让大模型把用户的原始问题改写成更适合向量检索的查询语句。
最典型的场景:
- 用户问“它支持并发吗?”——改写为“系统架构支持并发处理吗?”
- 用户问“怎么部署”前面聊的是某个具体系统——改写时补全主语
- 用户问“mac 上怎么装”——改写为“在 macOS 系统上安装的步骤是什么”
改写的常见实现方式是用大模型做一次轻量调用。代码示例:
# 文件路径:src/rag_retrieval/query_rewrite.py from openai import OpenAI import json client = OpenAI() # 根据实际环境配置 base_url 和 api_key def rewrite_query(original_query: str) -> str: """ 使用大模型对用户原始问题进行改写,使其更适合向量检索。 """ prompt = f"""你是一个检索查询改写助手。请把用户的问题改写成更适合文档检索的形式。 要求: 1. 补全缺失的主语和上下文 2. 把口语表达转换为书面表达 3. 保留原问题中的专有名词和关键数字 4. 只输出改写后的查询,不要解释 用户原始问题:{original_query} 改写后的查询:""" response = client.chat.completions.create( model="gpt-4o-mini", # 实际模型名以项目配置为准 messages=[{"role": "user", "content": prompt}], temperature=0, ) return response.choices[0].message.content.strip() # 示例 original = "它支持并发吗?" rewritten = rewrite_query(original) print(f"原始问题:{original}") print(f"改写后:{rewritten}")需要注意的是,查询改写不是每次都要做。它本身有成本:一次额外的大模型调用,少则几百毫秒,多则几秒。如果用户的查询已经非常明确、包含准确的专有名词,直接检索即可。常见的策略是:先直接检索,如果置信度低,再触发查询改写并重新检索。
5.3 混合检索与分数融合
增强层的另一个重要手段是混合检索。它的思路是把向量检索和关键词检索(BM25)结合起来,各取所长。
BM25 对专有名词、编号、精确匹配非常友好。比如用户查询“SKU-2024-001 库存”,BM25 能精确命中包含“SKU-2024-001”的文档块,而向量检索可能因为语义向量距离不够近而漏掉。
混合检索的实现方案很多,最简单的方式是:分别用向量检索引擎和 BM25 检索各自返回 Top-N,然后把两路结果合并去重,按融合分数重新排序。
常见的分数融合方法是Reciprocal Rank Fusion(RRF)。它的核心思想是:不看具体分数,只看排名。一个文档在两路检索中都排前面,那它的综合排名应该靠前。
# 文件路径:src/rag_retrieval/hybrid_search.py from rank_bm25 import BM25Okapi def rrf_fusion(ranked_lists, k=60): """ Reciprocal Rank Fusion 分数融合。 ranked_lists: 多个排序结果列表,每个列表是文档 id 列表 k: RRF 常数,通常取 60 """ scores = {} for ranked_list in ranked_lists: for rank, doc_id in enumerate(ranked_list): if doc_id not in scores: scores[doc_id] = 0 scores[doc_id] += 1.0 / (k + rank + 1) # 按融合分数降序排序 return sorted(scores.items(), key=lambda x: x[1], reverse=True) # 模拟两路检索返回的文档 id 列表 vector_results = ["doc_3", "doc_1", "doc_5", "doc_2"] bm25_results = ["doc_2", "doc_4", "doc_3", "doc_1"] fused = rrf_fusion([vector_results, bm25_results]) print("混合检索融合结果:") for doc_id, score in fused: print(f"{doc_id}: {score:.4f}")RRF 最大的好处是不需要对向量分数和 BM25 分数做归一化,因为两者量纲不同,直接相加没有意义。通过排名融合,天然规避了分数分布不一致的问题,在实际项目中非常实用。
5.4 上下文压缩
增强层还有一项容易被忽视的工作:检索结果的上下文压缩。
向量检索返回的文本块往往包含大量无关内容。比如用户问“退款的到账时间”,检索到的文本块可能有 500 字,但核心答案只有一句话。如果直接把整个文本块塞进 Prompt,大模型容易被无关信息干扰。
上下文压缩的思路是:用轻量模型或大模型对检索到的文本块做摘要或抽取,只保留与用户问题最相关的部分,再拼接进 Prompt。这样既减少了 token 消耗,又提高了大模型生成答案的准确率。
6. 完整示例:三层检索整合流程
上面三节分别介绍了每一层的思路和代码,这一节把三层整合成一个完整流程,方便你直接参考或在自己的项目里改造。
整体流程如下:
- 用户输入问题;
- 查询改写模块判断是否需要改写;
- 召回层执行向量检索 + BM25 混合检索,得到候选集;
- 精排层用 Reranker 对候选集重新排序;
- 取排序后的 Top-3 作为上下文,构造 Prompt;
- 大模型生成答案。
# 文件路径:src/rag_retrieval/pipeline.py from sentence_transformers import SentenceTransformer, CrossEncoder from rank_bm25 import BM25Okapi import faiss import numpy as np class ThreeLayerRetriever: def __init__(self, documents: list[str]): """ 初始化三层检索器。 documents: 已完成切块的文档列表 """ self.documents = documents # 向量检索模型 self.embed_model = SentenceTransformer("BAAI/bge-small-zh-v1.5") # 重排序模型 self.rerank_model = CrossEncoder("BAAI/bge-reranker-base") # 建立向量索引 doc_embeddings = self.embed_model.encode(documents, normalize_embeddings=True) self.dimension = doc_embeddings.shape[1] self.index = faiss.IndexFlatIP(self.dimension) self.index.add(doc_embeddings) # 建立 BM25 索引 tokenized_docs = [self._tokenize(doc) for doc in documents] self.bm25 = BM25Okapi(tokenized_docs) def _tokenize(self, text: str) -> list[str]: """简单中文分词,实际项目可替换为 jieba 等专业分词器""" # 这里用最简单的方式:按字符切分 + 保留英文单词 import re return re.findall(r"[\u4e00-\u9fff]|[a-zA-Z0-9]+", text) def recall(self, query: str, top_k: int = 20) -> list[int]: """召回层:向量检索 + BM25 混合召回""" # 向量检索 query_embedding = self.embed_model.encode([query], normalize_embeddings=True) vec_scores, vec_indices = self.index.search(query_embedding, top_k) vector_rank_list = [int(i) for i in vec_indices[0]] # BM25 检索 query_tokens = self._tokenize(query) bm25_scores = self.bm25.get_scores(query_tokens) bm25_rank_list = list(np.argsort(bm25_scores)[::-1][:top_k]) # RRF 融合 fused = rrf_fusion([vector_rank_list, bm25_rank_list]) return [doc_id for doc_id, _ in fused[:top_k]] def rerank(self, query: str, candidate_ids: list[int], top_n: int = 3) -> list[int]: """精排层:Cross-Encoder 重排序""" inputs = [(query, self.documents[i]) for i in candidate_ids] scores = self.rerank_model.predict(inputs) ranked = sorted(zip(candidate_ids, scores), key=lambda x: x[1], reverse=True) return [doc_id for doc_id, _ in ranked[:top_n]] def retrieve(self, query: str, final_top_k: int = 3) -> list[str]: """三层检索完整流程""" candidate_ids = self.recall(query, top_k=20) final_ids = self.rerank(query, candidate_ids, top_n=final_top_k) return [self.documents[i] for i in final_ids] def rrf_fusion(ranked_lists, k=60): scores = {} for ranked_list in ranked_lists: for rank, doc_id in enumerate(ranked_list): if doc_id not in scores: scores[doc_id] = 0 scores[doc_id] += 1.0 / (k + rank + 1) return sorted(scores.items(), key=lambda x: x[1], reverse=True) # ---------- 使用示例 ---------- documents = [ "RAG(Retrieval-Augmented Generation)通过检索外部知识增强大模型生成能力,减少幻觉。", "向量检索将文本映射到高维向量空间,通过余弦相似度衡量语义相关性。", "BM25 是一种基于词频的关键词检索算法,对专有名词和精确匹配更友好。", "重排序模型 Cross-Encoder 将问题和文档拼接输入,输出相关性分数,精度高于向量检索。", "RAG 检索策略通常分为召回层、精排层、增强层,每层解决不同问题。", "查询改写可以补充上下文、修正表达,使检索更准确。", ] retriever = ThreeLayerRetriever(documents) query = "RAG 三层检索架构的分工是什么?" results = retriever.retrieve(query) print("最终用于生成的上下文:") for doc in results: print(f"- {doc}")这段代码虽然简化了很多工程细节,但完整演示了三层检索的核心流程。实际项目中,你会在以下几个方面做扩展:
- 向量数据库换成 Milvus、Qdrant 或 Elasticsearch 的向量索引,而不是本地 FAISS;
- Embedding 模型和 Reranker 可能部署成独立服务;
- 查询改写模块接入大模型调用;
- 增加缓存层,避免相同或相似查询重复执行完整链路。
7. 三层检索的效果验证方法
面试能讲出来是一回事,项目里能不能用又是另一回事。聊完方案之后,面试官大概率会追问:你怎么证明你的检索效果是好的?
这是一个很容易拉开差距的问题。很多候选人只会说“效果还行”,而真正有工程经验的人会给出具体的评测方法。
7.1 建立检索评测集
无论做什么优化,第一步都是建立一个评测集。评测集的格式可以很简单:每一条记录包含一个用户查询、一个期望命中的文档 ID 列表。例如:
[ { "query": "如何配置 Apollo 配置中心", "relevant_doc_ids": ["doc_1024", "doc_1025"] }, { "query": "订单超时自动关单的机制是什么", "relevant_doc_ids": ["doc_2087"] } ]评测集的数据来源可以是历史日志中用户真实问过的问题,也可以由业务方整理。数量不需要特别多,但一定要覆盖典型场景:简单查询、复杂查询、专有名词查询、口语化查询等。
7.2 检索效果指标
对于 RAG 场景,建议关注的指标有:
Recall@K:在 Top-K 结果中,是否包含期望命中的文档。这是召回层最重要的指标。
MRR(Mean Reciprocal Rank):期望命中的文档排在第几位。排名越靠前,MRR 越高。
上下文利用率:检索结果中有多少文本最终被大模型回答引用。这个指标需要结合生成结果判断,但能从侧面上反映检索质量。
在项目中,通常的做法是:先固定切块策略和检索参数,跑出一版基线指标;然后单独调整某一个变量(比如 chunk_size、是否开启混合检索、是否加 Reranker),对比指标变化,保留有效优化、回滚无效改动。
7.3 用 LangChain 自带工具快速验证
如果不想从零写评测代码,LangChain 官方提供了检索评测相关模块,但也完全可以自己写一个简单的评测脚本:
# 文件路径:src/rag_retrieval/evaluate.py def evaluate_recall(retriever, eval_set, top_k=5): """简单评估 Recall@K""" hit_count = 0 total = len(eval_set) for item in eval_set: query = item["query"] relevant_ids = set(item["relevant_doc_ids"]) retrieved = retriever.recall(query, top_k=top_k) if relevant_ids & set(retrieved): hit_count += 1 recall = hit_count / total print(f"Recall@{top_k}: {recall:.2%} ({hit_count}/{total})") return recall这个脚本虽然简单,但足以支撑你在开发阶段快速验证切块参数、检索策略的调整效果。
8. RAG 策略常见问题与面试追问
这一节把开发者和面试者最容易出问题的点汇总成表,并给出排查思路和标准回答。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 检索结果完全不相关 | Embedding 模型与领域不匹配 | 抽查几个 query 的向量检索结果 | 换领域适配的 Embedding 模型,或增加混合检索 |
| 专有名词检索不到 | 向量检索对精确匹配不敏感 | 用 BM25 单独测同一 query | 启用 BM25 + 向量混合检索 |
| 检索结果相关但答案不准 | 上下文太多噪音,干扰大模型 | 查看最终进入 Prompt 的文本块 | 加 Reranker,只保留 Top-3 高质量上下文 |
| 切块后语义断裂 | chunk_size 太小或按固定长度切 | 查看切块结果中相邻块是否语义完整 | 调整 chunk_size,增加 overlap,按结构切分 |
| 精排后结果反而变差 | Reranker 模型领域不匹配 | 对比有 Reranker 和无 Reranker 的评测结果 | 换 Reranker 模型,或调整候选集大小 |
| 查询延迟过高 | 每轮都调用查询改写和 Reranker | 链路加耗时统计,定位瓶颈 | 增加缓存、启用条件触发、降低候选集大小 |
| 多轮对话指代不清 | 未处理对话历史 | 检查输入的 query 是否包含完整上下文 | 在改写阶段结合对话历史补全指代 |
这里重点讲几个面试官特别喜欢深挖的点。
追问一:向量检索到底用什么相似度度量?
常见的有余弦相似度、内积、欧氏距离。使用前提不同:如果你的 Embedding 模型输出的是归一化向量,内积和余弦等价;FAISS 的IndexFlatIP配合归一化向量,计算的就是余弦相似度。实际项目里,很多中文 Embedding 模型(如 BGE 系列)官方推荐直接用内积,因为模型在训练时已经做了归一化。
追问二:Reranker 和向量检索的区别,为什么不能只用 Reranker?
Reranker 精度更高,但计算复杂度与候选文档数量成正比。如果全库 10 万条文本都走 Reranker,单次查询延迟会到秒级以上,完全无法接受。向量检索可以通过 ANN(近似最近邻)索引在毫秒级扫完几百万向量,所以要先粗排后精排。
追问三:如果检索效果不好,你会先优化哪一块?
合理的顺序是:先看召回是否命中——如果 Recall@K 都上不去,再多精排也没用;确认召回没问题后,再看 Top-1、Top-3 的排序质量;最后才是调 Prompt 和生成策略。很多人一上来就换大模型、改 Prompt,这是本末倒置。
9. RAG 检索链路的工程最佳实践
最后补充一些工程落地时的建议。这些点不一定会在面试里被问到,但真实项目里非常关键。
建议一:把检索链路做成可观测的。
每一层都要打印耗时和关键指标。线上出现问题的时候,你才能快速判断是召回没命中、精排排错,还是生成环节出了问题。建议至少记录:
- 查询改写是否触发,改写前后的 query;
- 召回层的候选数量与 Top-K 范围;
- 精排层的候选数量与最终保留数量;
- 每一层的耗时。
建议二:切块策略要纳入版本管理。
你可能会惊讶,很多团队改 chunk_size 全凭感觉,改完没有记录、没有对比。建议把切块参数、Embedding 模型、Reranker 模型、评测指标都纳入配置管理,每次调整都跑一遍评测集,用数据说话。
建议三:不能忽视缓存层。
RAG 系统的检索链路里,Embedding 计算、Reranker 推理、大模型调用都有成本。对于高频重复的查询,做一个语义缓存非常划算。命中缓存时直接返回历史答案,可以省掉大量计算资源。
建议四:安全和权限必须前置。
企业级 RAG 一定会遇到权限问题:不同角色能检索的文档范围不同。如果你在检索层就把无权限的文档过滤掉,而不是等生成完再过滤,效果和安全性都会好很多。另外,Prompt 注入、恶意查询等安全问题也要在链路设计时考虑进去,不要等上线后再补。
建议五:建立回滚机制。
检索策略调整本质上是线上行为。任何模型或参数的变更,都要能快速回滚。建议在向量索引服务和重排序服务上保留上一版本,配合灰度发布逐步放量。一旦线上检索质量下降,可以快速切回旧版本,而不是紧急回滚代码。
10. 总结:三层检索策略怎么讲才能拿分
最后回到面试场景本身。如果面试官问你“RAG 策略”,你可以这样组织回答框架:
先一句话总述:我理解的 RAG 策略核心是检索质量,我会把检索拆成三个层次来设计和优化。
然后依次展开:
- 召回层:用向量检索 + BM25 混合召回,配合合理的切块策略,目标是高召回率;
- 精排层:用 Cross-Encoder Reranker 对候选集精排,目标是提升上下文精度;
- 增强层:用查询改写、上下文压缩解决复杂查询和长上下文问题。
最后补一句:每一层都可以独立评测、独立调优,我也会用检索评测集来验证改动是否有效,而不是靠感觉。
这个回答框架有层次、有细节、有工程思维,比单纯背流程要高出几个段位。
对于正在准备面试的读者,建议你把文中代码在本地跑一遍,亲手感受一下切块参数变化对检索结果的影响,再把三层检索的取舍逻辑用自己的话讲出来。面试的时候,能讲清楚“为什么这样设计”比“用了什么库”重要得多。
对于已经在做 RAG 项目的读者,建议从切块策略和评测集入手,先用数据找出当前检索链路里最薄弱的一环,再有针对性地引入混合检索或 Reranker,不要一上来就堆技术。
检索是 RAG 的地基,地基稳了,楼上大模型的生成能力才能真正发挥出来。