面试被问 RAG 说不清楚?这是我见过最靠谱的“故事 + 代码”拆解法
先说自己踩过的坑。早些年我在面算法岗,十次有八次被问到“讲讲检索增强生成”。我那时候张嘴就是“RAG 就是把检索和生成结合起来”,然后背一段 LangChain 代码,最后面试官一句“所以它解决了大模型什么问题?检索的性能瓶颈你怎么评估?”直接把我问懵。后来我在真实项目里被线上幻觉数据反复教育,才彻底把 RAG 的每个环节串成一条线。今天我不讲空话,直接用一个图书馆的比喻加一套能跑的最小代码,把这个概念从面试问到生产踩坑都聊透。全文适合正被 RAG 面试题困扰的同学、想给知识库做落地原型的产品技术,还有那些既不想看翻译腔论文、也不想只看“五分钟学会”视频的人。
1. 用一个图书馆故事讲透 RAG 的核心逻辑
1.1 为什么大模型会一本正经地胡说八道
先问一个问题:一个刚入职的新员工,被问“公司今年的报销流程是什么”,他大概率会胡编。因为他脑子里只有入职前培训的通用知识,没有公司内部的规章制度。你问他“报销差旅费要几步”,他可能一本正经地说“打开审批系统,提交表单”,但具体表单里要填哪些单据、财务什么时候审核,他完全不知道,于是只能靠推理补出细节。这就是大模型的“幻觉”——它不是在撒谎,而是在用已经学过的语言规律补全一个它不知道的空缺。
预训练模型的知识来源于公开语料,知识有截止日期,也没有企业内部数据。所以当回答涉及最新事件、私有知识、专业表格或长尾问题,模型就会开始“编”。而 RAG 做的事情,本质上就是给模型配了一个“动手查资料”的能力:回答之前,先从外部知识源里检索相关片段,再把片段塞进上下文里,让它基于证据作答。这样幻觉自然被压制,因为答案有了出处,模型不再是闭卷考试,而是开卷考试。
1.2 从记忆库到借书台:检索增强生成的本质流程
RAG 全称 Retrieval-Augmented Generation,检索增强生成。你可以把它想象成一座巨大的图书馆:大模型是坐在借书台后面的讲解员,图书馆仓库里堆着所有文档,而“检索”就是图书管理员在仓库中快速找到你需要的几本书,然后把书递给讲解员,最后讲解员翻开这几本书总结给你听。
具体拆成三步就是:导入资料库的时候给每本书做索引卡片,问问题的时候按卡片索引找出最相关的几本书,最后把书里的段落连同问题一起交给讲解员组织语言。用技术黑话说是:索引(Indexing)、检索(Retrieval)、生成(Generation)。就这么简单。你背一遍这三个步骤,面试官知道你会背;你能把这三步对应到代码里的具体函数,面试官才相信你真的做过。
1.3 三个角色:索引员、图书管理员、讲解员
我再把角色拆开,因为所有代码都可以对号入座。索引员负责把每一页文档做摘要,并且标注关键词,这一动作对应文本切分和向量化;图书管理员负责在索引库里快速查找,对应相似度计算和检索排序;讲解员则负责把找来的材料重新组织成通顺、准确的答案,对应大模型生成。你写 RAG 代码时,会发现自己只干三件事:给文档建立索引、根据 query 检索、把结果拼给 LLM。这三个角色一旦分清楚,任何 RAG 框架你都看得懂。
有些同学会问:既然有向量库,为什么还要用大模型生成?因为直接返回检索到的原文虽然准确,但可能包含冗余信息且格式不符合用户期望。讲解员的价值在于“浓缩和改写”——把多段材料合并成清晰有条理的回答,并可以附上来源。所以 RAG 不是“检索”替代“生成”,而是两者配合。
2. 开始动手:先写一个最小可用的 RAG 原型
2.1 技术选型:为什么我用 FAISS + sentence-transformers + 大模型 API
网上 RAG 教程动不动就上 LangChain、LlamaIndex,对新手很不友好。如果你只是想理解原理,最小可运行版本的选型应该是:用sentence-transformers做文本向量化,用FAISS做向量存储和检索,用大模型 API(OpenAI、DeepSeek、智谱等接口均可)做最终生成。如果你没有 API key,也可以临时用本地小模型(如 Ollama 拉一个 Qwen 2.5),但代码路径会多一块,为了讲清原理我先按 API 来。
为什么是 FAISS 而不是 Pinecone、Milvus?因为 FAISS 是 Meta 开源的向量搜索库,直接 pip 装就能在本地跑,不需要服务端;它对小规模数据(几万条)完全够用,而且 API 简洁,好调试。大规模生产再迁移到 Milvus 也不难,因为接口语义相似。而sentence-transformers是当前对新手最友好的句向量工具,加载一个开源模型(例如BAAI/bge-small-zh-v1.5)就能把中文句子转成 512 维向量,你能直观看到“相似文本向量距离近”这个性质。
2.2 文档切分:文本拆解的粒度玄机
这一步很多人会忽略,却是 RAG 效果好坏的分水岭。假设你要把一个 5000 字的公司制度文档做索引,直接整篇变成一个向量行不行?行,但检索会很糊。因为当你问“差旅费报销要提前几天申请”时,整篇文档的语义向量会被其他无关章节稀释,相似度算出来不高不低,TopK 召回时可能排不进前几。相反,如果切得太碎(比如按句号切),每个句子向量虽然精确,但上下文信息缺失——比如文件里写“该流程同样适用于驻外人员”,单独拿出来你不知道“该流程”指什么。
我用默认的推荐方案:先按 Markdown 标题或段落结构切块,如果不存在明确结构,则按固定长度切块,并设置重叠窗口。边距重叠是防止关键句正好被截断。常规参数是chunk_size=300(字符)、overlap=50,中文场景按 300 到 500 字比较稳。注意不要照抄英文教程的chunk_size=1000,英文字符和汉字信息密度不同,中文 300 字已经是一段话的完整信息量了。
2.3 向量化与索引构建(附代码)
我们用一个虚构的“员工手册片段”来实验。假设知识库里有这几段话:
1. 员工每年享有 5 天带薪年假,入职满一年后生效。 2. 差旅费报销需提前 3 个工作日在 OA 系统提交申请,并上传电子发票。 3. 新员工入职需完成 3 天培训,并签订保密协议。 4. 弹性工作时间为早上 9 点到 9 点半之间打卡上班。现在开始写最核心的代码。先安装依赖:
pip install sentence-transformers faiss-cpu openai构建向量索引的代码段:
from sentence_transformers import SentenceTransformer model = SentenceTransformer('BAAI/bge-small-zh-v1.5') documents = [ "员工每年享有 5 天带薪年假,入职满一年后生效。", "差旅费报销需提前 3 个工作日在 OA 系统提交申请,并上传电子发票。", "新员工入职需完成 3 天培训,并签订保密协议。", "弹性工作时间为早上 9 点到 9 点半之间打卡上班。", ] doc_vectors = model.encode(documents, normalize_embeddings=True)注意normalize_embeddings=True是因为 FAISS 用内积距离时,归一化后内积等价于余弦相似度,数值更稳定。
索引构建:
import faiss import numpy as np dim = doc_vectors.shape[1] index = faiss.IndexFlatIP(dim) index.add(np.array(doc_vectors)) print(f"知识库总文档数:{index.ntotal}")这里我用的是IndexFlatIP(内积),如果你归一化了向量,这等同于余弦相似度。对于几万条数据,暴力检索足够快,不需要 IVF。
2.4 检索与生成拼接(附代码)
用户输入一个 query,我们需要向量化后去库里查,拿到最相近的文本片段。
def retrieve(query, top_k=2): query_vec = model.encode([query], normalize_embeddings=True) scores, indices = index.search(np.array(query_vec), k=top_k) results = [(documents[idx], scores[0][i]) for i, idx in enumerate(indices[0])] return results query = "怎么报销差旅费?" for doc, score in retrieve(query): print(f"相似度: {score:.4f}\t内容: {doc}")输出应该是类似:
相似度: 0.7421 内容: 差旅费报销需提前 3 个工作日在 OA 系统提交申请,并上传电子发票。 相似度: 0.2110 内容: 员工每年享有 5 天带薪年假,入职满一年后生效。然后把检索结果塞进 prompt,调用大模型生成:
from openai import OpenAI client = OpenAI(api_key="你的key", base_url="https://api.deepseek.com/v1") def rag_answer(query): contexts = [doc for doc, _ in retrieve(query, top_k=2)] context_block = "\n".join(f"[知识片段{i+1}] {c}" for i, c in enumerate(contexts)) prompt = f"""请根据下面提供的知识片段回答问题。 如果知识片段中没有相关信息,请直接说“根据现有资料无法回答”,不要编造。 知识片段: {context_block} 问题:{query} 回答:""" resp = client.chat.completions.create( model="deepseek-chat", messages=[{"role": "user", "content": prompt}] ) return resp.choices[0].message.content print(rag_answer(query))这里有几个容易被面试追问的点。第一,top_k为什么取 2?太小容易漏,太大上下文会被无关信息污染,2 到 5 都是常见值。第二,为什么 prompt 中强调“没有相关信息就直说”?这可以显著降低幻觉率,让模型不必强答。第三,把检索原文用“知识片段”标注,让模型知道这些都是可信参考,但也要允许它判断是否相关,而不是无脑抄。
3. 面试官最爱追问的 5 个 RAG 细节
3.1 为什么检索结果可能很差?Hit Rate 和 MRR 是什么
面试官通常会问:“有一个 RAG 系统,怎么判断它好不好?”你如果只说“看回答准不准”,那就落了下乘。严谨的评估分两块:检索质量和生成质量。检索质量最基础的两个指标是 Hit Rate(命中率)和 MRR(Mean Reciprocal Rank,平均倒数排名)。
Hit Rate 的意思是:对于标准答案相关的文档,TopK 结果里是否包含它。举个例子,测试集里有 100 个问题,每个问题标注了“它对应的知识片段ID”。如果 Top5 检索结果包含这个ID,记为一次命中,最后命中次数除以 100 就是 Hit Rate。MRR 更进一步:如果命中的位置是第一名,得 1 分;第二名得 1/2,第三名得 1/3。这样能看出系统把正确答案排得多靠前。
我在项目里常用ragas框架去算这些指标,但它初学时比较绕。最简单的方式是自己准备 10 个问题,人工标注正确答案出自哪段文本,然后跑检索函数,计算命中率。等你调好 chunk_size、embedding 模型、检索方式,这些指标会有直观变化。这一点你要写进简历里:你不仅会搭 RAG,还会用指标评估它。
3.2 RAG 的瓶颈:召回率、上下文窗口和噪声
面试官第二个杀手问题是:“RAG 的瓶颈是什么?”绝大多数人会说“检索不准”。但严格来说,RAG 有三大瓶颈:第一是信息召回不全,也就是相关文档存在,但向量检索没找出来;第二是上下文窗口限制,即使召回很多,也不能把知识库整本塞进去,因此必须在召回阶段控制数量;第三是噪声干扰,召回的内容里可能有一半是无关或过时的信息,大模型会被噪声带偏。
实际排查时,我建议你先问自己:是“库里根本没有答案”还是“有答案但没检索出来”?如果是后者,问题多半出在切分或 embedding。如果是“检索到了但回答没用上”,则要检查 prompt 和生成模型对上下文的利用能力。还有一类特殊情况:多个意图同时出现在一个问题里,比如“我入职满一年了,想报销差旅费并申请年假”,这需要把检索拆成多路再融合,单次 TopK 会顾此失彼。
3.3 进阶形态:Agentic RAG / GraphRAG / 本体 RAG
现在面试越来越喜欢问 RAG 的进化形态。我整理了一个速查表:
| 形态 | 核心思路 | 与基础 RAG 的区别 |
|---|---|---|
| 朴素 RAG | 检索 + 拼接 + 生成 | 一次性完成,不迭代 |
| Agentic RAG | Agent 自主决定搜什么、搜几次、是否需要工具 | 把检索决策交给大模型,支持多跳查询 |
| GraphRAG | 从文档中抽取实体和关系构建知识图谱 | 可以回答跨实体关系类问题,比如“谁和谁合作过哪些项目” |
| 本体 RAG(Ontology RAG) | 先定义领域概念、关系和属性,再基于本体检索 | 适合专业领域,比如医疗、金融,用本体约束召回语义 |
面试官问“如何解决多跳问题”时,你可以说基础 RAG 一次检索只能拿到局部片段,Agentic RAG 则会先初步检索,如果发现信息不足,再生成新的搜索词继续检索,直到拼凑出完整答案,这很像你查资料时不断更换关键词的行为。至于 GraphRAG,它的价值在于关系推理:向量库擅长找相似文本,但不擅长找“A 的同事是 B,B 参与过 C 项目”这类关系链,而图谱可以把实体连接起来。
3.4 知识割裂问题:多路召回和重排序
如果你搜一句话,它涉及多个来源,比如“产品说明书里的安装方法”和“客服补充的注意事项”分别在两个文档中,TopK 可能只覆盖其中一种,造成知识割裂。解决办法是多路召回(关键词检索 + 向量检索 + 结构化数据过滤)后合并,再用重排序模型(比如bge-reranker)调整顺序。
我实际测试过,向量检索对语义相似但字面不匹配的情况很有效,比如“换了屏幕之后怎么开机”,它能找到“更换显示器后启动”这样的文本;但字面匹配很强的专有名词(如产品型号“A310”)有时反而会输给 BM25。所以生产级系统普遍采用混合检索。你在代码里可以先用向量检索取 Top10,再用关键词检索取 Top10,合并去重后再用 reranker 取 Top3。这一步能大幅提升 Hit Rate。
4. 生产环境落地:从 Demo 到可用的 RAG 系统
4.1 切分策略的工程调优
你在 Demo 里可以固定 300 字切分,但真实项目里文档格式千奇百怪:PDF 有表格,Markdown 有嵌套标题,公司 Wiki 有代码块。我的经验是:先按文档结构切,再按长度补救。比如先识别 Markdown 标题,以二级标题为单位切分;如果一个二级标题下的内容超过 800 字,再按 400 字子切分并重叠。表格不能简单转成纯文本,最好把一行记录转成“字段名:值;字段名:值”的格式,否则向量化会失效。
另一个工程细节是去重。很多企业知识库有同一份文档的历史版本,或不同目录下的拷贝。你不做去重,检索结果里可能前几名都是相似内容。简单思路是在入库前计算文本 hash,相同 hash 只保留一份;更智能的是用向量去重,同一文档的段落相似度大于 0.95 就合并。
4.2 混合检索:关键词 + 向量
我常用rank_bm25支持 BM25 中文关键词检索,它可以和 FAISS 一起用。BM25 是经典的相关性排序算法,它的效果原理是基于词频和逆文档频率,很像搜索引擎。你可以这样写:
from rank_bm25 import BM25Okapi tokenized_docs = [doc.split() for doc in documents] bm25 = BM25Okapi(tokenized_docs) def hybrid_search(query, top_k=3): tokenized_query = query.split() bm25_scores = bm25.get_scores(tokenized_query) # 归一化 BM25 分数,以便和向量分数比较 bm25_top_indices = sorted(range(len(bm25_scores)), key=lambda i: bm25_scores[i], reverse=True)[:top_k*2] # 结合向量检索结果,去重后决定最终顺序 ...中文环境下直接split()只能按空格切,实际要换成 jieba 分词。不过这里为了让代码简洁,先用空格演示。BM25 对精确型号、编号类关键词很有用,弥补向量检索对拼写细节的钝感。两者合并后,重排序尤其重要,否则两种分数量纲不同,简单相加会偏向猛烈一方。可以用设置权重的方式:final_score = 0.4 * normalized_bm25_score + 0.6 * cosine_score,或者在召回的候选集上跑bge-reranker。
4.3 测评指标:怎么评估你的系统
做 RAG 项目最怕自我感觉良好。真实评估可以从四层入手。第一层是检索层,用 Hit Rate@K 和 MRR;第二层是生成层,用忠实度(faithfulness)和答案相关性(answer relevance),通俗说就是“生成的内容是不是严格依赖检索片段”和“答案有没有正面回答用户”;第三层是端到端效果,可以请业务方对样例进行人工标注;第四层是回归测试,把线上高频问题存成一个测试集,每次改动跑一遍看指标变化。
我强烈建议你一开始就准备 20 个左右的测试问题,越贴近真实用户越好。别问“什么是报销制度”这么泛,要问“我昨天入职的,今天要出差两天,怎么准备报销材料?”问题越具体,越能反映检索真实能力。如果你的系统连测试集都不能稳定通过,就不要谈上线。
4.4 常见故障排查表(附问题速查)
根据我亲历过的线上事故,这里整理了一份排查速查表,面试也能用来救场:
| 现象 | 可能原因 | 优先排查动作 |
|---|---|---|
| 回答错了但来源看起来相关 | PDF 表格被错误切块 | 检查切分后文本片段是否有乱序 |
| 明明知识库里有,却回答“无法回答” | 检索 TopK 太小,相关片段没召回 | 增大 TopK 或改混合检索 |
| 同一个问题每次回答不一致 | 模型随机性太高 | 将 temperature 调低到 0.1 左右 |
| 回答内容吞掉了细节 | 切块太长,关键信息被稀释 | chunk_size 减小到 200 字再试 |
| 知识库更新后回答还按旧资料 | 索引未同步更新 | 做增量入库,注意删除旧版本 |
| 问答涉及多个人名/项目名的关系 | 向量检索天然处理不了关系链 | 换 GraphRAG 或建立实体关联索引 |
还有个小技巧:检索结果一定把 chunk 的元数据(来源链接、更新时间)一起带到 prompt 里,这样模型可以回答“根据 XX 文件”,也能在回答中提示“该制度已于 2025 年 6 月更新”。我们甚至遇到过用户因回答里没有来源而质疑系统的情况,加上元数据后信任度提高很多。
5. 把“说不清楚”变成“讲出细节”:我的实操体会
最后分享一点个人感悟。当初我把 RAG 流程跑通时候,觉得自己会了。但后来面试官问我“embedding 模型要换怎么评估”,我开始答不上来。于是我花了两个周末把替换模型、调切分、换检索方式全过了一遍,用 30 个测试问题记录指标,突然发现以前看论文里模糊的概念全都落地了。建议你也照着这个路径试一遍:不要只改代码里的参数,要真正去看每个参数改变后测试集命中率的变化。
如果你现在正被面试困扰,试着把这篇里的图书馆故事讲给朋友听,讲完再默写一遍检索、生成的代码流程。能说出来和能写出来是两回事,能写出来和能写对又是两回事。当面到“为什么”的时候,你就把这个库类比搬出来,把IndexFlatIP和normalize_embeddings=True的原因一讲,面试官就知道你不是背题的。RAG 没那么玄,它就是“先查书再说话”这套人类本能的技术化,一旦你想通了这一点,剩下的都是工程细节,而细节,恰恰是区分在职和转行的那道分水岭。