☰
RAG落地实战:LangChain、向量检索与重排优化指南
2026/10/12 13:41:54 网站建设 项目流程

简介:面向大模型与人工智能开发者的实战型PDF文档,系统演示如何基于LangChain从零实现一个RAG问答应用。文档选取百度百科中的藜麦数据模拟个人或企业私域数据,完整覆盖项目策划、环境搭建(CUDA 11.7、Python 3.10、PyTorch 1.13.1+cu117、LangChain)、数据保存与本地加载、文档分割、向量化、向量数据库入库等全流程。实战环节给出了可直接运行的代码:用TextLoader读取本地txt文件,用CharacterTextSplitter按128字符长度切分,再以m3e-base模型生成Embedding并写入Chroma数据库;同时对OCR识别可能导致的文字错漏、依赖安装顺序等实操问题提供提醒,并为理解数据上下文补充了藜麦的背景知识。资源包仅含1个PDF文件,整包大小390KB,内容紧凑、步骤清晰,便于按章节对照练习。目前已有285人学习/浏览,既适合正在入门RAG应用开发、希望快速跑通LangChain检索问答链路的读者,也适合准备大模型岗位面试时梳理相关技术要点。

1. 大模型八股文面试里的 RAG 实战:这道题到底在考什么

这两年大模型面试几乎绕不开 RAG,尤其当你报的是应用方向岗位,面试官很少再问“什么是检索增强生成”这种概念题,而是直接甩过来一句:“你用 LangChain 搭过 RAG 问答吗?” 这个标题看起来像一份面试资料,其实背后是一条完整的技能链路:你要能在半小时内讲清楚 RAG 的检索、重排、生成三截流水线,还要能说出为什么用向量库而不是拿数据库硬撸,更要能现场给出一个能跑的代码骨架。单纯背八股文没用,因为追问全在细节里:分块尺寸设多少、Top-K 取几、召回率低是调 embedding 还是调重排、上下文爆掉怎么办。这篇文章就把我平时带人准备这套面试题时讲的完整方案写出来,从原理到最小可运行代码,再到参数边界和排错经验,读完你不仅答得上题,还能当场写出来。

2. 从八股到落地:RAG 的完整链路和选型理由

2.1 RAG 为什么不是“向量检索 + 塞进 Prompt”那么简单

很多面试者第一句话就说“RAG 就是先检索再生成”,这话没错,但等于没说。真正的 RAG 是一条有明确失败点的流水线:文档加载、文本分块、嵌入向量化、向量存储、相似度检索、候选重排、上下文组装、LLM 生成。任何一个环节偷懒,最终答案质量都会崩,而且崩法各不相同——分块太大导致检索命中噪声多,分块太小导致语义不完整,embedding 选错导致召回率低,重排缺失导致正确答案被埋在第 5 位之后。面试官追问的往往不是你对 RAG 有多乐观,而是你有没有被这些问题毒打过。

从工程选型角度看,我一般会把 RAG 拆成两个决策点:检索质量决策和生成质量决策。检索质量由 embedding 模型、分块策略、向量库、检索参数、重排模型共同决定;生成质量则由 Prompt 模板、上下文长度控制、引用来源标注、模型温度参数决定。LangChain 的价值不在于它多智能,而在于它把这几个环节抽象成了标准组件,让你能快速替换和对比。你在面试里如果能说出“我先用哪一层做实验、用什么指标判断好坏”,就已经超过大多数只会背流程的候选人。

2.2 LangChain 的组件抽象:从 Loader 到 RetrievalQA 的调用链

LangChain 的 RAG 链路在代码里是一条清晰的链式调用。最精简的骨架是这样:加载文档用DirectoryLoader或PyPDFLoader,分块用RecursiveCharacterTextSplitter,向量化用某个 embedding 模型,存储用Chroma或FAISS,检索后用RetrievalQA把检索结果和问题一起交给 LLM。这个骨架代码短,但每个环节都有默认参数埋着坑。

下面是本地跑通 RAG 问答的最小命令集。假设你有一个 PDF 文档目录,要把它变成能问能答的本地问答服务:

from langchain_community.document_loaders import PyPDFLoader from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_community.vectorstores import FAISS from langchain_openai import OpenAIEmbeddings from langchain_openai import ChatOpenAI from langchain.chains import RetrievalQA # 1. 加载 PDF 文档 loader = PyPDFLoader("docs/employee_handbook.pdf") documents = loader.load() # 2. 分块:每块 500 字符,重叠 80 字符 text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, separators=["\n\n", "\n", "。", "!", "?", ".", "!", "?", " ", ""] ) chunks = text_splitter.split_documents(documents) # 3. 向量化并存入 FAISS 本地索引 embeddings = OpenAIEmbeddings(model="text-embedding-ada-002") vectorstore = FAISS.from_documents(chunks, embeddings) vectorstore.save_local("faiss_index") # 4. 构建检索问答链 llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) qa_chain = RetrievalQA.from_chain_type( llm=llm, chain_type="stuff", retriever=vectorstore.as_retriever(search_kwargs={"k": 4}), return_source_documents=True ) # 5. 提问 response = qa_chain.invoke({"query": "年假可以累计到下一年吗?"}) print(response["result"])

这段代码的逻辑并不复杂:先是把 PDF 解析成 LangChain 的 Document 对象,再按递归分隔符切块,接着把每个块转成向量写入本地索引,最后把“检索器 + 大模型”组装成问答链。这里有两个参数值得你单独解释给面试官听:chunk_size=500决定了每个检索单元的信息密度,chunk_overlap=80是为了避免句子在切分处被拦腰截断导致语义残缺,k=4则控制送入 Prompt 的候选片段数量。面试官如果接着问“为什么用 FAISS 不用别的”,你要答得出来:FAISS 是 Meta 开源的向量检索库,支持余弦相似度和 L2 距离,索引在本地文件里落地,对快速 Demo 最友好;生产环境换 Milvus 或 Elasticsearch 的向量索引也只是替换一个类的问题。

2.3 Embedding 选型:决定召回上限的第一步

很多人把 RAG 的成败押在 Prompt 上,但真正决定检索上限的是 embedding 模型。同一个问题,不同的 embedding 模型召回的 Top-5 可能完全不一样。中文场景尤其明显:通用英文 embedding 对中文长句的语义区分度差,检索出来的片段经常是字面重合但语义无关。我的选择标准是:优先在 MTEB 中文榜单上有稳定表现的开源模型,然后用自己的测试集实际跑一遍 Recall@K,不要只看榜单数字。

实际代码里需要改的只是 embeddings 对象:

from langchain_community.embeddings import HuggingFaceEmbeddings embeddings = HuggingFaceEmbeddings( model_name="BAAI/bge-large-zh-v1.5", encode_kwargs={"normalize_embeddings": True} )

这里normalize_embeddings=True容易被忽略,但它很关键:归一化之后,用内积计算相似度等价于余弦相似度,在 FAISS 里可以直接用METRIC_INNER_PRODUCT加速检索。面试末了聊性能时,这个细节能说明你不是只会调包。另外,本地加载 embedding 模型需要 GPU 或较好的 CPU,首次运行会下载权重,这些环境问题要提前说清楚,别在面试现场卡住。

3. 从 PDF 到可问答知识库:完整落地步骤

3.1 文档加载的五个隐蔽问题

PyPDFLoader看起来简单,但真实 PDF 里全是坑。我遇到过扫描版 PDF 需要 OCR、表格被解析成乱序文本、多栏排版导致文字跨栏拼接、加密 PDF 直接抛异常、PDF 内嵌字体缺失导致中文变成空白。面试里你可以不说这些,但动手做项目时你一定会碰到。

我一般建议按文档类型分流:文本型 PDF 直接用PyPDFLoader,扫描版走RapidOCR或PaddleOCR,Word 文档用Docx2txtLoader,Markdown 和 HTML 用各自的 Loader。更稳妥的做法是先把所有文档统一转成 Markdown 或纯文本,再做清洗、去页眉页脚、合并断行,最后进入 LangChain。这个预处理步骤在项目里大概占 30% 的工作量,但很少有人写进简历。

一个容易忽略的点是 PDF 里的页眉页脚会被当成正文读进来。比如每页都有的“第 3 页/共 20 页”和公司名,会让检索结果频繁命中这些噪声。处理方式是在分块之前清洗:

import re cleaned_docs = [] for doc in documents: text = re.sub(r"\n\s*第\s*\d+\s*页[^\n]*", "", doc.page_content) text = re.sub(r"[\u00a0\u200b]+", " ", text) doc.page_content = text.strip() cleaned_docs.append(doc)

这段正则把常见的中文页脚“第 3 页”字样删掉,同时去掉零宽空格和非法空白字符。注意这里不要用一行正则解决一切,PDF 解析出来的换行很乱,建议先把文本打印出来看两页,再写针对性规则。面试官很喜欢问“你清洗过哪些脏数据”,你能说出页眉页脚、全角半角混杂、OCR 的错别字这三类,就已经有真实项目味道了。

3.2 分块策略:chunk_size 与 chunk_overlap 的调参逻辑

分块是整个 RAG 里最像“玄学”的环节。没有哪个参数对所有文档最优,但有一套经验法则:

参数建议范围设置依据
chunk_size300–800 字符(中文)500 左右能保持语义完整,又不至于引入过多噪声
chunk_overlap50–150 字符至少覆盖一个句子的长度,避免断句丢主语
separators 顺序先按段落,再按句子,最后按词优先保持自然语义边界

为什么中文按字符算而不是按 token 算?因为 LangChain 的RecursiveCharacterTextSplitter默认按字符长度切分,而中文字符和英文字符的信息密度差异很大。如果你直接用英文教程里的 1000 字符,中文文档会被切得过碎。另一个细节:separators列表里的顺序就是切分优先级,如果你把空格放在句号前面,英文文档会在单词边界被切得乱七八糟。我把中文句号、感叹号、问号放在英文标点前面,就是为了让中文文档优先在完整句子处断开。

如果你的文档是结构化问答或者 FAQ 格式,分块策略可以更激进——直接按条目切。下面这段代码展示了一个简单但有效的处理方式:

from langchain.schema import Document def split_faq(documents): result = [] for doc in documents: # 假设每个 FAQ 条目以数字序号开头 blocks = re.split(r"\n(?=\d+\.\s)", doc.page_content) for block in blocks: if len(block.strip()) > 30: result.append(Document(page_content=block.strip(), metadata={"source": doc.metadata.get("source", "")})) return result

这种按结构切分的方式比统一chunk_size效果更好,因为每个块本身就是一个完整问答对,检索命中后直接作为上下文,几乎不需要 LLM 做二次理解。面试里能讲出“我按文档结构切分而非统一长度切分”,是一个明显的加分项。

3.3 向量库与检索参数:k 值、相似度阈值、返回字段

向量库选型在面试里是个高频追问。我把常用方案列了个对照表:

方案适合场景主要限制
FAISS单机原型、百万元素内不支持增量删改,需重建索引
Chroma本地快速迭代、元数据过滤大规模并发性能一般
Milvus生产环境、千万级向量、高并发需要独立部署和维护
Elasticsearch + knn已有 ES 体系的团队向量性能依赖插件和配置

我平时做面试 Demo 用 FAISS 最多,因为save_local一个文件夹就能带走,部署到服务器也方便。但你要知道 FAISS 不支持真正意义上的单条删除,如果文档更新频繁,常见做法是重建索引或者用分段索引。这个问题在面试里常被用来考察你的生产意识。

检索参数里最值得调的是k和相似度阈值。k=4是我习惯的初始值,面试时可以这样说:k 太小容易漏掉正确答案,k 太大容易把无关片段塞进上下文,导致 LLM 被噪声干扰。更好的做法是加一个相似度分数过滤,低于阈值的片段直接丢弃:

retriever = vectorstore.as_retriever( search_kwargs={"k": 8, "score_threshold": 0.5} )

注意 FAISS 的score_threshold在不同检索类型下含义不同:默认的similarity_search里分数是距离或相似度,取决于索引类型;similarity_search_with_score返回的分数范围也和 embedding 模型有关系。很多初学者在这里翻车:bge 模型默认输出相似度分数较高,而 ada-002 的分数整体偏低,用一个固定阈值套所有模型就会出问题。所以我后面用重排替代阈值过滤,这是更稳的方案。

4. 检索质量不高怎么办:重排与混合检索的实战补救

4.1 重排(Rerank)为什么比单纯调 k 值有效

向量检索的 Top-K 结果往往只有前两三条是准的,后面几条开始跑偏。单纯把 k 从 4 调到 8,只会让更多噪声进入 Prompt。重排模型的作用是拿一个更强的交叉编码器,对候选片段和 query 做逐对匹配,重新打分数。这一步很便宜,因为只需要对已经召回的 10–20 条做过一次精排。

LangChain 里接入重排的常见做法是ContextualCompressionRetriever:

from langchain.retrievers.document_compressors import CrossEncoderReranker from langchain_community.cross_encoders import HuggingFaceCrossEncoder reranker = HuggingFaceCrossEncoder(model_name="BAAI/bge-reranker-v2-m3") compressor = CrossEncoderReranker(model=reranker, top_n=4) compression_retriever = ContextualCompressionRetriever( base_compressor=compressor, base_retriever=vectorstore.as_retriever(search_kwargs={"k": 20}) )

这段代码的逻辑是:底层先用向量检索召回 20 条,再用重排模型精排,只保留前 4 条。top_n=4决定了最终进 Prompt 的片段数,和直接调 k 的效果完全不同——召回范围变大了,但真正送入模型的还是最相关的几条。用重排之后,k=20不再是噪声来源,而是候选池。面试里如果你说出“我宁可召回 20 条但重排取 4 条,也不直接取 4 条”,面试官会认为你有真实调优经验。

重排模型也要选型。bge-reranker 系列在中文上表现比较稳,但模型体积从几百 MB 到 1 GB 以上不等,本地部署要能接受首次加载时间。如果你在 API 环境里,也可以调用付费的 rerank 接口,但面试 Demo 一般本地跑开源模型就够了。

4.2 混合检索:BM25 + 向量的融合是保底方案

向量检索对同义改写很友好,但精确匹配却经常输给关键词检索。比如用户问“试用期是几个月”,文档里写的是“试用期为三个自然月”,向量检索能找到,因为语义相近;但如果用户问的是“试用期 3 个月”而文档里只有“3-month probation”,向量检索表现看 embedding,而 BM25 几乎必定命中。混合检索就是把两者结果合并去重,再做重排。

LangChain 的EnsembleRetriever就是干这个的:

from langchain.retrievers import BM25Retriever, EnsembleRetriever bm25_retriever = BM25Retriever.from_documents(chunks) bm25_retriever.k = 10 vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 10}) ensemble_retriever = EnsembleRetriever( retrievers=[bm25_retriever, vector_retriever], weights=[0.4, 0.6] # 关键词和语义的权重分配 )

注意BM25Retriever需要把分块后的chunks传进去,它会在内存里建立倒排索引。weights是融合权重,我一般从 0.4/0.6 起步,然后根据测试集调整;如果文档里专业名词多,适当调高 BM25 权重。这一步会让检索召回更全面,但也会把 Retriever 的内存占用抬上去——文档量超过十万块时,建议把 BM25 换成 Elasticsearch 的查询,而不是继续在 Python 内存里硬扛。

4.3 评估检索质量:写个脚本看 Recall@K

不量化就无法调参。我习惯在项目里写一个小脚本,准备二十个真实问题,每个问题标出参考答案所在的文档块,然后跑检索看答案是否出现在 Top-K 里。计算 Recall@K 的脚本很简单:

def recall_at_k(retrieved_docs, golden_chunk_ids, k): hit = 0 for doc in retrieved_docs[:k]: if doc.metadata.get("chunk_id") in golden_chunk_ids: hit += 1 break return 1 if hit > 0 else 0 # 使用示例 retrieved = retriever.invoke("年假未休完会作废吗?") score = recall_at_k(retrieved, ["chunk_023", "chunk_024"], k=5) print(f"Recall@{5}: {score}")

这里的关键在于给每个分块打上chunk_id元数据,才能把召回结果和人工标注对上。如果没有标注,你可以做粗粒度评估:看召回的文本里是否包含答案中的关键名词。这个脚本的作用是让你在换 embedding、调分块、加重排之后,有数字告诉你到底变好还是变坏,而不是靠感觉“好像更准了”。

5. 上下文组装与生成:Prompt 模板、引用溯源、超长截断避坑

5.1 用 stuff 还是 map_reduce:上下文组装三种方式的取舍

RetrievalQA的chain_type决定上下文如何进 Prompt。stuff是把所有检索片段全部塞进一个 Prompt,适合片段少、总 token 不超限的场景;map_reduce是每个片段单独跑一遍 LLM 再汇总,适合片段多但总长度爆掉的情况;refine则是逐片段迭代式修改答案。面试题里最常见的追问是“如果检索出来的内容超过模型上下文窗口怎么办”。

我的建议很直接:先算 token。如果总上下文在模型窗口的 60% 以内,用stuff质量最高,因为 LLM 能看到所有原文;如果超过,要么降低top_n,要么上map_reduce。大部分 RAG 问答场景,答案依赖的片段通常不超过 4 个,每个片段 500 字符,总 token 也就 1500 左右,根本不需要map_reduce。面试里如果一上来就选map_reduce,说明你还没意识到 stuff 的语义连贯性优势。

5.2 Prompt 模板:让模型“只根据资料回答,不编造”的写法

不写 Prompt 模板的 RAG 等于裸奔。默认的 LangChain 模板能用,但回答里没有引用来源,遇到文档中没有答案的问题还会胡编。我的模板里至少包含三件事:角色限定、检索资料区、回答规则。

from langchain.prompts import PromptTemplate prompt_template = PromptTemplate.from_template( """你是一个企业内部知识库问答助手。请只根据以下检索资料回答用户问题。 检索资料: {context} 用户问题:{question} 回答规则: 1. 如果资料中有明确答案,直接回答,并在末尾标注 [来源1][来源2]。 2. 如果资料中没有答案,明确回答“资料中未找到相关信息”,禁止编造。 3. 回答不超过 3 个要点,使用简洁的书面语。 """ ) qa_chain = RetrievalQA.from_chain_type( llm=llm, chain_type="stuff", retriever=retriever, chain_type_kwargs={"prompt": prompt_template}, return_source_documents=True )

这里的{context}是 LangChain 自动填充的检索片段拼接结果,{question}就是用户问题。关键在于第 2 条规则,它能在很大程度上防止模型瞎编。但注意这只是一个软约束,模型仍可能无视它,所以更可靠的兜底是在代码里做检查:如果检索片段和问题的相关性分数普遍低于阈值,直接返回“未找到资料”,不调用 LLM。

5.3 引用溯源与源码标注:让回答可验证

纯文本回答在面试 Demo 里够用,但一旦做企业应用,没有来源标注的回答没人敢信。我在代码里通常这样处理:

result = qa_chain.invoke({"query": "年假可以累计吗?"}) print("回答:", result["result"]) print("引用来源:") for doc in result["source_documents"]: print("-", doc.metadata.get("source"), "| 片段:", doc.page_content[:60])

return_source_documents=True是 RetrievalQA 的参数,当你设为 True 之后,返回结果里会带上source_documents字段。每个Document里的metadata保存着来源路径,这在加载时是自动写入的。如果源是 PDF,metadata 里还有page页码字段,输出时可以直接拼成“来源:xxx.pdf 第 3 页”。面试官看到你输出引用来源,会认为你考虑到了 RAG 在生产环境中的可信度问题。

5.4 上下文超长截断:文本压缩检索器的另一种玩法

如果重排后仍然有片段长度不一、总长容易超限的问题,可以用 LangChain 的LLMChainExtractor做上下文压缩:让一个轻量模型把每个检索片段压缩成和 query 相关的摘要,只保留关键信息。这个思路在长文档问答里很实用:

from langchain.retrievers.document_compressors import LLMChainExtractor compressor = LLMChainExtractor.from_llm(llm) compression_retriever = ContextualCompressionRetriever( base_compressor=compressor, base_retriever=vectorstore.as_retriever(search_kwargs={"k": 8}) )

不过要注意:压缩过程本身也会调用 LLM,意味着每个问题多了一轮模型调用,延迟和成本都会上涨。我在项目里只在 Token 预算极紧时才用这个方案,常规情况下优先缩减top_n。面试里把这个取舍讲出来,比单纯背组件名更有说服力。

6. 必踩的 6 个坑:现象、原因与解决办法

6.1 坑一:检索出的片段牛头不对马嘴,但换 embedding 也没用

现象:用户问“报销流程”,召回的片段全是“报销制度概述”,没有具体步骤。换更贵的 embedding 模型,问题依旧。原因:分块把步骤拆散到了不同的 chunk 里,query 与任何一个单块的语义重合都不高,而制度概述这种块反而因为词汇重合度高出险。解决:改用按结构切分,把每个流程步骤作为一个独立 chunk,或者加大chunk_overlap,使得步骤边界不会被一刀切开。另一个补救是引入混合检索,让 BM25 的“报销流程”关键词命中更准。最常见的原因是分块问题,其次是检索方式,最后才是 embedding,排查顺序别反了。

6.2 坑二:模型回答引用了不存在的内容,即幻觉

现象:文档里根本没有“年假可折算工资”这句话,模型却言之凿凿回答了,还编了个 [来源1]。原因:Prompt 里的“禁止编造”是软约束,在 context 中有一段和问题沾边、但实际不含答案的内容时,模型倾向于强行总结。解决:第一,把来源标注改成代码强制输出——只允许模型在给定片段内找答案,如果找不到必须在回答里写“未找到”;第二,增加一个相关性校验,对召回的 Top-N 片段计算与 query 的相似度分数,最高分仍低于阈值时直接拒绝回答。这个校验逻辑放在调用 LLM 之前,最可靠。

6.3 坑三:Chroma/FAISS 索引更新后,旧数据还在

现象:替换了文档内容,重新运行脚本,问新闻题时模型还是回答旧信息。原因:FAISS 的save_local是覆盖整个目录,但如果你在原来索引基础上调用add_documents,旧 doc 并没有被删除,只是新向量追加进去了。解决:更新文档时直接删除本地索引目录再重建,或者使用带版本号的索引目录路径。我习惯用一个新目录名如faiss_index_v2,同时维护一个映射文件记录版本与文档时间;越简单越不容易出错,生产环境再考虑向量库的增量更新能力。

6.4 坑四:中文问句检索不如英文准,找不到同义表达

现象:文档写“员工离职需提前一个月申请”,用户问“辞职要多久才批”,召回结果完全不相关。原因:embedding 模型对口语化、同义改写表达的中文泛化能力不够,公共模型在这种场景下容易只匹配字面词。解决:两步走,第一步在检索前加一个查询改写环节——用一个便宜模型把口语化问题改写成关键词更明确的书面表达;第二步用混合检索兜底。改写 Prompt 很简单:“把用户问题改写成一个更适合检索的关键词组合”。这一招对 RAG 的中文效果提升非常明显,但很少有人提。

6.5 坑五:文档量一大,检索变慢甚至内存溢出

现象:几千页 PDF 全部加载分块后,跑一次检索要好几秒,加载 embedding 模型时内存直接飙到 8GB。原因:HuggingFace embedding 模型加载到 CPU 内存,加上向量索引和 BM25 倒排索引全部驻留内存,小机器扛不住。解决:把 embedding 模型换成一个更小的蒸馏版本,或者用 API 调用在线 embedding;向量库从 FAISS 切到支持磁盘映射的方案;把 BM25 部分移到 SQLite FTS5 或 Elasticsearch 上。调试时先跑一个小批次,比如前 50 页,确认链路没问题再全量加载。这个坑在面试里往往会以“你的方案能支撑多少文档量”这种方式问出来,答出内存瓶颈和替代方案就过关。

6.6 坑六:接口返回超时,以为是 LangChain 问题,其实是重排器太慢

现象:本地问答响应要 5 秒,其中 LLM 只花了 1 秒。现象:本地问答响应要 5 秒,其中 LLM 只花了 1 秒。排查后是重排器在 CPU 上跑,bge-reranker 模型体积大,每次要对 20 个片段逐对推理。解决:把重排候选数从 20 降到 10,减少无效计算;或者把重排模型换成更小的 distilled 版本;再或者把重排放到 GPU 上加载。实际项目里,我宁愿多召回少重排,因为再好的重排也救不回糟糕的召回;如果延迟指标紧张,可以先用 BM25 粗筛 50 条,再用向量精排 20 条,最后重排 5 条,每层各司其职。

7. 进阶验证:用自定义评测集给 RAG 一个量化分数

聊完避坑,最后说一个我每次做 RAG 必做、但很多教程不教的事:给整个问答系统建一个微型评测集。这个评测集的形态很简单——20 到 30 条问答对,每一条包含输入问题、期望答案里的必须包含的关键词、期望检索命中的文档块。评测时跑一遍全流程,对每一条输出三份记录:检索命中了没、答案关键词覆盖了几个、答案有没有胡编。用一个小脚本汇总成百分比,就是你这个 RAG 系统的基线分。

这个脚本我不建议做得太复杂,够用就行:

eval_questions = [ {"question": "离职需要提前多久申请?", "must_keywords": ["30天", "一个月"], "gold_chunks": ["chunk_012"]}, {"question": "报销上限是多少?", "must_keywords": ["5000", "5000元"], "gold_chunks": ["chunk_045"]}, ] all_scores = [] for item in eval_questions: q = item["question"] result = qa_chain.invoke({"query": q}) answer = result["result"] retrieved_chunks = [d.metadata.get("chunk_id") for d in result["source_documents"]] recall_hit = item["gold_chunks"][0] in retrieved_chunks keyword_hit = sum(1 for kw in item["must_keywords"] if kw in answer) all_scores.append({ "question": q, "recall": recall_hit, "keyword_ratio": keyword_hit / len(item["must_keywords"]) }) recall_rate = sum(s["recall"] for s in all_scores) / len(all_scores) keyword_avg = sum(s["keyword_ratio"] for s in all_scores) / len(all_scores) print(f"Recall Rate: {recall_rate:.2%}") print(f"Keyword Coverage: {keyword_avg:.2%}")

这套小评测有三个好处:第一,改任何参数后,跑一遍就能知道是变好还是变坏,不用靠主观感受;第二,面试官问“你怎么证明你的 RAG 好用”,你直接甩出 Recall Rate 和 Keyword Coverage 两个数字,比一万句描述都顶用;第三,它能倒推你该优化哪一环:如果 recall 低,回去调分块、换 embedding;如果 recall 高但 keyword 低,问题在 Prompt 或生成参数上。

我还习惯把每次评测的分数和当时的参数配置写进一个固定格式的笔记里,比如chunk_500_overlap_80_k4_rerank_bge_v2,下次再调参数,一眼就能看出哪个组合的分数最高。这个过程听起来麻烦,但一次调参可能要跑十几遍,没有记录很容易今天觉得这个好、明天觉得那个好,最后项目烂尾在参数选择上。

最后一个个人习惯:每次跑新文档集,先不追求高分数。我会先拿一条文档里明显存在答案的问题做冒烟测试,链路通了,再上评测集。因为这个领域里,八股文背得再熟,也抵不过一次次参数对比练出来的手感。多跑几遍,你会慢慢发现哪些指标受chunk_size影响大,哪些受重排模型影响大,哪种文档又需要用混合检索兜底。这才是别人说的“玄学”背后的真实规律。希望这篇笔记能帮你把 RAG 从背诵题变成实操题,也祝你在下一次面试或项目里少踩几个坑。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询