1. 面试官到底想听什么:RAG 的本质拆解
面试里被问到 RAG,很多人第一反应是背概念:“检索增强生成,就是把文档切块、向量化、存进向量库,用户提问时检索相关片段拼进 Prompt 让大模型回答。”这段话本身没错,但面试官听完通常不会有任何反应,因为它只说明了“是什么”,没有说明“为什么这样做”“哪里会出问题”“你怎么解决”。
我在带团队和参与面试的过程中发现,能把 RAG 讲清楚的人,往往不是背概念最熟的人,而是真正动手搭过、踩过坑、调过参数的人。RAG 的全称是 Retrieval-Augmented Generation,检索增强生成。它的核心逻辑可以用一个生活场景来类比:你是一个客服,客户问你一个非常冷门的售后政策。你不可能把所有政策全背下来,但你可以先翻手册找到相关那几页,然后照着那几页的内容组织语言回答客户。RAG 里的“翻手册”就是检索,“组织语言回答”就是生成。
这个类比里藏着 RAG 要解决的三个核心问题。第一,大模型的知识是冻结的,训练完成之后它不知道你公司内部的最新文档;第二,大模型的上下文窗口有限,你不可能把几百页手册全塞进去;第三,大模型会“编”,也就是幻觉,当它不知道答案时可能一本正经地胡说。RAG 通过“先检索、再生成”的方式,把外部知识动态注入到生成过程中,让模型基于真实资料回答,从而缓解知识过时、上下文受限和幻觉这三个问题。
面试官真正想考察的,是你对这条链路上每个环节的理解深度。比如:文档怎么切?切多大?Embedding 模型怎么选?向量检索和关键词检索各自适合什么场景?检索回来的内容怎么排序?如果检索结果不相关怎么办?这些问题才是区分“用过”和“懂”的分水岭。下面我会按照一条完整的 RAG 链路,把每个环节的原理、选型逻辑、实操代码和踩坑经验逐一拆开讲。
1.1 RAG 和微调到底该怎么选
面试里高频出现的一个对比题是:RAG 和微调(Fine-tuning)有什么区别,什么时候用哪个。这个问题如果只回答“RAG 是外挂知识,微调是改模型参数”,虽然对,但不够。我一般会从四个维度来对比。
| 对比维度 | RAG | 微调 |
|---|---|---|
| 知识更新 | 改文档库即可,实时生效 | 需要重新训练,周期长 |
| 成本 | 低,主要是向量库和推理成本 | 高,需要 GPU 和标注数据 |
| 幻觉控制 | 较好,有原文可溯源 | 一般,仍可能编造 |
| 适合场景 | 知识频繁更新、需要溯源 | 固定风格、特定格式输出 |
实际项目中,两者不是互斥的。我做过的一个企业知识库项目,就是用 RAG 做知识检索,同时用少量微调让模型学会公司特定的回答格式和语气。面试时如果你能说出“RAG 管知识,微调管风格,两者可以叠加”,面试官通常会眼前一亮。
还有一个热词叫 Agentic RAG,指的是把 RAG 和 Agent 结合,让模型自己决定什么时候检索、检索几轮、要不要换查询词再检索。传统 RAG 是一次检索就生成,Agentic RAG 是“检索—判断—再检索—再判断”的循环。这个方向在面试里如果被问到,能讲清楚“多轮检索”和“查询改写”的价值,基本就能拉开差距。
1.2 一条完整 RAG 链路的五个阶段
把 RAG 拆开,其实就五个阶段:文档加载、文本切分、向量化与存储、检索、生成。每个阶段都有它的核心决策点。
文档加载阶段,你要处理 PDF、Word、Markdown、网页等各种格式。PDF 里还有扫描件和文字版之分,扫描件需要 OCR。文本切分阶段,你要决定按字符切、按句子切还是按语义切,切多大块,块之间要不要重叠。向量化阶段,你要选 Embedding 模型,是选开源的还是调 API,中文场景和英文场景选型不同。存储阶段,你要选向量库,FAISS、Chroma、Milvus、Qdrant 各有适用场景。检索阶段,你要决定用向量检索、关键词检索还是混合检索,要不要加 Rerank。生成阶段,你要设计 Prompt 模板,控制上下文长度,处理“检索不到”的情况。
这五个阶段里,切分和检索是最容易出问题的两个环节。切分切得不好,检索再准也白搭;检索策略不对,生成质量直接崩。下面我会重点讲这两个环节,同时把其他环节的实操代码也补上。
2. 文档切分:RAG 效果的地基
很多人搭 RAG 时最不重视的就是切分,觉得随便按 500 字切一刀就行。但我可以负责任地说,切分策略直接决定了 RAG 的上限。检索的本质是“用问题去匹配文本块”,如果文本块本身语义不完整,匹配出来的结果自然也是残缺的。
2.1 固定长度切分为什么不够用
固定长度切分是最简单的做法,比如每 500 个字符切一块,块之间重叠 50 个字符。重叠的目的是防止一句话被切断后,前后两块都丢失了完整语义。这种做法实现简单,适合快速验证,但问题也很明显。
假设你的文档里有一句话:“本产品的保修期为自购买之日起 12 个月,但人为损坏不在保修范围内。”如果按 500 字切分,恰好切在“12 个月”和“但人为损坏”之间,那么第一块讲保修期,第二块讲免责,用户问“保修期多久”时,检索到的第一块可能没有提到免责条件,模型就会给出不完整的回答。
更麻烦的是,固定长度切分不考虑段落和标题结构。一份技术文档里,标题“安装步骤”和下面的正文被切到不同块里,检索时标题块可能被匹配到,但正文块没有,回答就缺了操作细节。
2.2 递归字符切分的实操与参数选择
LangChain 里最常用的切分器是RecursiveCharacterTextSplitter,它的思路是“按优先级依次尝试分隔符”。默认的分隔符列表是["\n\n", "\n", " ", ""],也就是先按段落切,段落太大再按行切,行太大再按空格切,最后按字符切。这样能尽量保持语义单元的完整性。
from langchain.text_splitter import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, separators=["\n\n", "\n", "。", "!", "?", " ", ""], length_function=len, ) chunks = splitter.split_text(long_document)这里有几个参数需要解释。chunk_size=500指的是每块最多 500 个字符,这个值不是拍脑袋定的。太小了,一块里信息不够,检索出来回答不完整;太大了,一块里混入无关信息,检索精度下降,而且占用上下文窗口。我的经验是中文场景 300 到 600 字比较合适,英文场景 500 到 1000 个 token。chunk_overlap=80是块之间的重叠字符数,一般取 chunk_size 的 10% 到 20%,目的是防止边界处的语义丢失。
分隔符列表我加了中文标点“。”“!”“?”,因为中文文档里句号是最自然的语义边界。如果你的文档是代码,分隔符应该改成["\nclass ", "\ndef ", "\n\n", "\n", " ", ""],优先按类和函数切。如果是 Markdown,可以用MarkdownHeaderTextSplitter按标题层级切,这样每个块都带着标题信息,检索时更容易匹配。
注意:chunk_overlap 不要设得太大,否则相邻块内容重复过多,检索时会返回一堆相似结果,浪费上下文窗口。我一般控制在 chunk_size 的 15% 左右。
2.3 语义切分和父子块策略
固定长度和递归切分都是基于字符的,还有一种更高级的做法叫语义切分。它的思路是先用 Embedding 模型把每个句子向量化,然后计算相邻句子的相似度,相似度低的地方就是语义转折点,在那里切一刀。这样切出来的块,内部语义高度一致。
语义切分的效果通常更好,但计算成本高,因为要对每个句子做 Embedding。我的做法是混合使用:先用递归切分做粗切,再对每个块做语义合并,把相似度高的小块合并成一个大块。
另一个实用策略是父子块(Parent-Child Chunk)。具体做法是:把文档切成大块(父块)和小块(子块),子块用于检索,父块用于生成。检索时用子块匹配问题,命中后返回它对应的父块给模型。这样既保证了检索精度(小块语义集中),又保证了生成质量(大块上下文完整)。LangChain 里的ParentDocumentRetriever就是干这个的。
from langchain.retrievers import ParentDocumentRetriever from langchain.storage import InMemoryStore from langchain.vectorstores import FAISS from langchain.embeddings import OpenAIEmbeddings parent_splitter = RecursiveCharacterTextSplitter(chunk_size=2000) child_splitter = RecursiveCharacterTextSplitter(chunk_size=400) vectorstore = FAISS.from_texts(["placeholder"], OpenAIEmbeddings()) store = InMemoryStore() retriever = ParentDocumentRetriever( vectorstore=vectorstore, docstore=store, child_splitter=child_splitter, parent_splitter=parent_splitter, )这段代码里,子块 400 字用于检索,父块 2000 字用于生成。实际测试下来,这种策略在长文档问答场景里,回答完整度比单纯用 500 字块提升明显。
3. Embedding 与向量库:检索的发动机
切分完之后,下一步是把文本块变成向量。这一步的核心是 Embedding 模型的选择,它直接决定了检索的语义匹配能力。
3.1 Embedding 模型选型的三个维度
选 Embedding 模型,我一般看三个维度:语言支持、维度大小、是否可本地部署。
语言支持方面,中文场景优先选对中文优化过的模型。比如 BGE 系列(BAAI/bge-large-zh-v1.5)在中文语义匹配上表现很好,MTEB 中文榜单上长期靠前。英文场景可以用 OpenAI 的 text-embedding-3-small 或 text-embedding-3-large,也可以用开源的 all-MiniLM-L6-v2。多语言场景可以选 multilingual-e5 系列。
维度大小影响存储和检索速度。维度越高,表达能力越强,但存储成本也越高。比如 text-embedding-3-small 是 1536 维,bge-large-zh 是 1024 维,all-MiniLM-L6-v2 只有 384 维。384 维的模型检索速度快,适合大规模数据;1024 维以上适合对精度要求高的场景。
是否可本地部署,取决于你的数据敏感度。如果文档涉及内部资料,不能调外部 API,那就必须选开源模型本地跑。Ollama 现在支持直接拉取 Embedding 模型,比如ollama pull nomic-embed-text,然后通过 LangChain 的OllamaEmbeddings调用,零成本搭建本地 RAG。
from langchain_community.embeddings import OllamaEmbeddings embeddings = OllamaEmbeddings(model="nomic-embed-text")提示:Embedding 模型一旦选定,索引和查询必须用同一个模型。换了模型就要重新建索引,否则向量空间不一致,检索结果会完全乱掉。这是新手最容易犯的错误之一。
3.2 FAISS 上手:从建索引到相似度检索
FAISS 是 Facebook 开源的向量检索库,特点是快、轻量、无需独立服务。它适合中小规模数据(百万级以内),可以直接嵌在 Python 进程里用。LangChain 对 FAISS 的封装很友好。
from langchain_community.vectorstores import FAISS from langchain_community.embeddings import OllamaEmbeddings embeddings = OllamaEmbeddings(model="nomic-embed-text") vectorstore = FAISS.from_texts( texts=chunks, embedding=embeddings, metadatas=[{"source": f"doc_{i}"} for i in range(len(chunks))], ) vectorstore.save_local("faiss_index")建索引时传入metadatas是个好习惯,可以把来源文件名、页码、章节等信息存进去,检索时一并返回,方便溯源和展示引用。
检索时:
query = "保修期是多久" results = vectorstore.similarity_search_with_score(query, k=5) for doc, score in results: print(f"分数: {score:.4f}") print(f"内容: {doc.page_content[:100]}") print(f"来源: {doc.metadata}")similarity_search_with_score返回的分数是距离,FAISS 默认用 L2 距离,分数越小越相似。如果你用余弦相似度,需要归一化向量。LangChain 的FAISS.from_texts默认会对向量做归一化,所以分数可以直接比较。
3.3 向量检索的瓶颈在哪里
向量检索不是万能的。它的核心问题是:语义相似不等于关键词匹配。比如用户问“RAG 的 hit rate 怎么提升”,向量检索可能返回一堆讲“检索优化”的段落,但没有一段提到“hit rate”这个具体术语。因为 Embedding 模型把“hit rate”和“检索优化”映射到了相近的向量空间,但具体术语的精确匹配能力弱。
另一个瓶颈是长尾查询。如果用户问的是一个非常具体的产品型号,比如“X200-Pro 的电池容量”,向量检索可能匹配到“X200 系列电池”的段落,但具体型号的精确信息可能被淹没。
解决这两个瓶颈的常见方案是混合检索:向量检索负责语义匹配,关键词检索(BM25)负责精确匹配,两路结果融合后排序。LangChain 里的EnsembleRetriever就是做这个的。
from langchain.retrievers import EnsembleRetriever from langchain_community.retrievers import BM25Retriever bm25_retriever = BM25Retriever.from_texts(chunks) bm25_retriever.k = 5 faiss_retriever = vectorstore.as_retriever(search_kwargs={"k": 5}) ensemble_retriever = EnsembleRetriever( retrievers=[bm25_retriever, faiss_retriever], weights=[0.4, 0.6], )权重可以根据场景调。如果查询里术语多,BM25 权重大一些;如果查询偏口语化,向量检索权重大一些。实测下来,混合检索在 hit rate 上通常比单路检索高 10 到 20 个百分点。
4. 检索优化与生成:从能用到好用
检索回来内容之后,还有两步能显著提升效果:Rerank 重排序和 Prompt 设计。
4.1 Rerank 重排序:把最相关的顶上来
向量检索返回的 top-k 结果,排序依据是向量距离,但向量距离不完全等于相关性。Rerank 的做法是:先用向量检索召回较多候选(比如 20 个),再用一个交叉编码器(Cross-Encoder)对每个候选和问题做精细打分,重新排序,取 top-5 给模型。
交叉编码器和 Embedding 模型的区别在于:Embedding 是分别编码问题和文档,然后算距离;交叉编码器是把问题和文档拼在一起输入模型,直接输出相关性分数。后者精度更高,但速度慢,所以只适合对少量候选做重排。
from langchain.retrievers import ContextualCompressionRetriever from langchain.retrievers.document_compressors import CrossEncoderReranker from langchain_community.cross_encoders import HuggingFaceCrossEncoder model = HuggingFaceCrossEncoder(model_name="BAAI/bge-reranker-base") compressor = CrossEncoderReranker(model=model, top_n=5) compression_retriever = ContextualCompressionRetriever( base_compressor=compressor, base_retriever=ensemble_retriever, )Rerank 的代价是增加一次模型推理,延迟会上升。如果对延迟敏感,可以只在检索结果分数分布比较分散时启用,或者用更小的 reranker 模型。
4.2 Prompt 模板设计:让模型基于资料回答
检索到内容后,最后一步是拼 Prompt。Prompt 设计的目标是让模型严格基于检索内容回答,不知道就说不知道,不要编。
from langchain.prompts import ChatPromptTemplate template = """你是一个严谨的助手。请仅根据以下参考资料回答问题。 如果参考资料中没有相关信息,请直接说“根据现有资料无法回答”,不要编造。 参考资料: {context} 问题:{question} 回答:""" prompt = ChatPromptTemplate.from_template(template)这个模板里有三个关键点。第一,“仅根据以下参考资料”是约束模型不要用内部知识。第二,“没有相关信息就说无法回答”是防幻觉。第三,把{context}放在问题前面,是因为很多模型对上下文末尾的内容注意力更强,问题放在最后更容易被关注。
实际使用中,我还会在 context 里给每个片段加上来源标记,比如[来源1],这样模型回答时可以引用来源,用户也能核对。
4.3 检索不到怎么办:兜底策略
RAG 系统上线后,一定会遇到检索不到的情况。这时候如果直接把空 context 传给模型,模型要么说“无法回答”,要么开始编。我的做法是加一层判断:如果检索结果的最高分低于某个阈值,就不走生成,直接返回“未找到相关资料,请换个问法或联系人工”。
results = vectorstore.similarity_search_with_score(query, k=5) if not results or results[0][1] > 1.5: return "未找到相关资料,请换个问法。"阈值需要根据你的 Embedding 模型和距离度量方式来调。L2 距离下,1.5 是一个经验值,实际项目里我会用一批测试问题跑一遍,看正样本和负样本的分数分布,取一个能分开两者的值。
5. 常见问题与排查技巧实录
RAG 搭起来容易,调好难。下面是我在实际项目中遇到的高频问题和排查思路。
5.1 检索结果不相关:从切分和模型两头查
检索不相关是最常见的问题。排查顺序是:先看切分,再看 Embedding 模型,最后看检索策略。
切分方面,检查块大小是否合适,块之间是否有重叠,分隔符是否匹配文档格式。我遇到过一次,PDF 解析出来的文本没有换行符,所有内容挤成一行,递归切分失效,切出来的块全是乱的。后来在解析后加了一步文本清洗,把连续空格和异常换行处理掉,问题就解决了。
Embedding 模型方面,检查模型是否支持你的语言。用英文模型处理中文,效果会差很多。另外检查索引和查询是否用了同一个模型。
检索策略方面,试试混合检索和 Rerank。如果单路向量检索 hit rate 只有 60%,加上 BM25 和 Rerank 后通常能到 80% 以上。
5.2 回答不完整或答非所问:上下文和 Prompt 的问题
检索到了相关内容,但回答不完整,通常是上下文拼接的问题。比如检索了 5 个块,但 Prompt 里只放了 3 个,或者块之间的顺序乱了,模型理解出现偏差。
我的做法是:按相关性分数排序后拼接,每个块之间加分隔符,并在每个块前标注来源。如果上下文太长超过模型窗口,优先保留分数高的块,或者用模型做一次摘要压缩。
答非所问则可能是 Prompt 约束不够。检查模板里是否有“仅根据资料回答”的约束,是否给了模型“不知道就说不知道”的出口。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| 检索结果完全不相关 | 索引和查询模型不一致 | 检查 Embedding 模型 | 统一模型,重建索引 |
| 检索结果部分相关 | 切分粒度不合适 | 检查块大小和重叠 | 调整 chunk_size 和 overlap |
| 回答不完整 | 上下文拼接丢失 | 检查 Prompt 里的 context | 按分数排序,补全块 |
| 模型编造答案 | Prompt 约束不足 | 检查模板 | 加“仅根据资料”和兜底话术 |
| 检索速度慢 | 向量库规模大 | 检查索引类型 | 用 IVF 或 HNSW 索引 |
| 中文效果差 | 用了英文模型 | 检查模型语言支持 | 换 BGE 等中文模型 |
5.4 几个容易被忽略的实操心得
第一个心得:建索引时一定要存 metadata。来源、页码、章节这些信息,在调试和展示引用时非常有用。我见过有人只存文本不存来源,出了问题根本不知道是哪篇文档的哪一段。
第二个心得:准备一批测试问题。RAG 调优不能靠感觉,要有一批带标准答案的测试问题,每次调整后跑一遍,看 hit rate 和回答准确率的变化。测试问题要覆盖简单查询、复杂查询、多跳查询和无关查询。
第三个心得:日志要记全。每次查询的原始问题、改写后的问题、检索到的块、分数、最终回答,都要记下来。出问题时可以回溯,也能用来做后续的微调数据。
第四个心得:不要追求一步到位。先搭一个能跑的最小版本,用固定长度切分加 FAISS 加基础 Prompt,跑通之后再逐步优化切分、加混合检索、加 Rerank。每加一个组件,都要用测试集验证是否真的有提升,避免过度工程。
6. 从零搭建一个本地 RAG 知识库的完整流程
把上面的内容串起来,一个可复制的本地 RAG 流程是这样的。
第一步,准备文档。把 PDF、Markdown、TXT 等文件放到一个目录下。用 LangChain 的DirectoryLoader加载。
from langchain_community.document_loaders import DirectoryLoader, TextLoader loader = DirectoryLoader("./docs", glob="**/*.md", loader_cls=TextLoader) documents = loader.load()第二步,切分。用RecursiveCharacterTextSplitter,中文场景 chunk_size 设 500,overlap 设 80。
第三步,向量化并建索引。用 Ollama 的 nomic-embed-text 或 BGE 模型,存到 FAISS。
第四步,检索。先用向量检索,再加 BM25 做混合,最后加 Rerank。
第五步,生成。用 ChatPromptTemplate 拼上下文,调本地模型或 API 生成回答。
第六步,加兜底。检索分数低于阈值时直接返回未找到。
这套流程我在几个内部知识库项目里用过,文档量在几千到几万块之间,效果稳定。如果文档量超过百万块,FAISS 需要换 IVF 索引,或者迁移到 Milvus、Qdrant 这类支持分布式和持久化的向量库。
最后分享一个我在面试里常用的回答框架:当被问到 RAG 时,先讲它解决什么问题(知识更新、上下文限制、幻觉),再讲一条链路的五个阶段,然后重点讲切分和检索这两个最容易出问题的环节,最后补一个你实际踩过的坑和解决方法。这样讲下来,面试官基本能判断你是真做过还是只背过。