先给大家交个底:这篇文章不是让你复制粘贴就能跑通那么简单,而是希望你能真正理解 RAG 检索增强生成到底在解决什么问题、每一步代码在做什么、遇到报错该怎么处理。我会用一套完整可运行的代码带你走一遍“文档加载 → 文本切分 → 向量化 → 检索 → 生成回答”的全流程,适合刚入门大模型应用开发、想把自己私有文档变成问答机器人的朋友,也适合已经写过简单 Demo 但想系统梳理原理的开发者。我见过太多人把 RAG 当成“调用一个库、输入一段文档、得到一个答案”的魔法,结果一换数据集就崩,一调参数就废。这次我也把自己实际研发过程中踩过的坑、调过的参数、取舍过的方案,一并整理成文字写下来。你按这篇文章的思路做过一遍之后,再去看别的 RAG 框架,基本不会再是黑盒。1. RAG 到底是什么:先建立整体认知1.1 为什么大模型需要“外挂知识库”大语言模型训练的语料是某个时间点之前的公开数据,哪怕今天最强的模型,你问它一件上周刚发生的事情,它也只能胡说。更现实的问题是,你手上有一批内部产品手册、历史项目文档、私有技术规范,这些数据模型根本没学过,直接问它就是编。我跟你讲一个最典型的场景:我的一位前同事在内部运维知识库上直接集成通用大模型,不接入检索,只是把企业文档拼到系统提示词里。文档一长,模型就开始胡说八道,要么忽略你贴进去的内容,要么被冗长的上下文干扰。这个问题不是“换个大模型”就能解决的。RAG 检索增强生成的核心思路是:别让模型凭空回忆知识,而是先从一个“外挂知识库”里检索出和问题最相关的片段,再把片段连同问题一起交给模型,让它基于这些片段作答。这样做的价值很直白:你的知识可以随时更新,不用重训模型;来源可追溯,模型说一句“根据资料A”,你能看到是哪一段原文支撑了它;成本也低,不用花昂贵的算力去微调一个百亿参数模型。RAG 方案对“答案要准、要有时效性、要有据可查”的需求天然契合,这也是它在企业知识库、客服问答、智能助手里应用这么广的原因。1.2 RAG 的核心链路拆解要把 RAG 讲透彻,我建议你把它当作一条“流水线”而不是一个“函数”,因为它的每一环都是独立的,可以单独优化。整条链路拆开看是四步:第一步:内容入库。把你手头的 PDF、Word、Markdown、TXT 文档读进来,切成小段。为什么必须切段?因为模型对输入长度有上限,而且一个大段落里往往包含多个主题,切得不合理,检索噪声会很大。第二步:向量化。把每个文本片段用嵌入模型转成一串浮点数向量。你可以粗暴地把向量理解为“语义坐标”,语义相近的文本,在坐标空间里距离就近。这一步是检索的基础。第三步:检索。用户输入一个问题后,把问题也转成向量,然后在前面建好的向量库里做相似度搜索,找出最相关的 K 个文本片段。这一步决定了模型“能看到什么”,是整个系统质量的上限。第四步:生成。把检索结果和用户问题组装成特定格式的提示词,交给大语言模型,模型依据检索片段生成答案。这也是“检索增强生成”里“增强”二字的最终体现。我在实际开发中经常提醒自己一句话:检索不到的内容,模型再强也答不出来。很多人前期花大心思调生成提示词,却忽视了检索阶段的准确率,这是典型的用力用错了地方。1.3 RAG 和模型微调该怎么选有不少人问我:“那我直接把文档拿去微调模型不就行了吗?为什么还要费劲做 RAG?”我的回答通常是:你得先想清楚你到底要解决什么问题。模型微调适合的场景是“改变模型的风格、格式、推理模式”,比如让模型学会用某种模板输出报告、用某种语气说话。而 RAG 适合的场景是“给模型补充它没学过的知识”,比如企业内部文档、实时信息、个人笔记。两者并不互斥,很多时候是配合使用:微调负责“让模型更听话”,RAG 负责“让模型懂更多”。成本上差异也很明显。微调一次大模型,即使是用开源模型做 LoRA,也需要 GPU 资源和训练时间,而且知识更新一次就要重新训练一次。RAG 方案里,你只要往向量库里新增文档、重建索引即可,一分钟内就能让模型“知道”新内容。这也是很多团队优先落地 RAG 的原因。不过 RAG 也不是没有短板。它强依赖检索质量,如果文档切得乱七八糟,或者嵌入模型本身语义理解能力弱,那答出来的东西一样不对。它也无法像微调那样改变模型底层的语言习惯。所以我现在遇到业务需求,第一件事是先归类需求,再选方案,而不是拿着一个方案到处套。2. 动手实践前的准备:环境搭建与工具选型2.1 需要准备哪些依赖要跑通本文的完整代码,你需要准备好下面三类东西。第一类是语言环境。我用的是 Python 3.10 及以上版本,这是目前大模型生态兼容性最好的版本区间,不建议用 3.7 或 3.8,很多新库已经不再支持。你可以先用python --version检查一下当前版本,如果过低,建议直接换一个干净的环境。第二类是核心库。RAG 链路里我们主要用几个主流开源库:加载文档用 LangChain 的文档加载器,文本切分用 LangChain 的文本分割器,向量化用 Hugging Face 的嵌入模型,向量存储用 Chroma,大模型调用用 LangChain 的 ChatOpenAI 接口。这些库现在是社区里最常见的组合,资料多,遇到问题也好搜。第三类是模型资源。嵌入模型我推荐用中文效果比较好的 BGE 系列,比如BAAI/bge-large-zh-v1.5,它对中文语义的捕捉能力比早期的 text2vec 系列稳定不少。大模型这边,为了让你能直接跑通,代码里我按 OpenAI 兼容接口的方式写,你可以替换成任何兼容接口的服务地址,也可以换成本地部署的开源模型。2.2 安装步骤与版本坑安装其实就三条命令的事,但你得注意版本兼容性,这是我被坑过最多次的地方。pip install -U langchain pip install -U langchain-community pip install -U chromadb pip install -U sentence-transformers pip install -U openai这里有两个特别值得注意的坑。第一个是 LangChain 的模块结构发生了很大变化,早期版本的from langchain.embeddings import HuggingFaceEmbeddings这种导入在新版本中行不通了,要改为from langchain_community.embeddings import HuggingFaceEmbeddings。Chroma 的导入路径也类似。第二个是sentence-transformers库会依赖 PyTorch,如果你机器上没装过 PyTorch,它会自动下载一个 CPU 版本的,体积比较大,第一次安装要有耐心。如果你用阿里云或者国内服务器,Hugging Face 官方源常常连不上去,可以提前设置镜像环境变量:export HF_ENDPOINT=https://hf-mirror.com设置完成后再启动 Python,这个办法我在国内环境下跑了无数次,实测有效。还有一处可能踩坑的地方是 Chroma 的持久化目录,新版本里它会自动在指定目录写入向量数据文件,你不需要手动创建目录,但第一次运行时如果目录有残留的旧数据,可能导致新数据查询不到。我的习惯是每次换数据源时,先删掉整个向量库目录,再重新入库。2.3 中文嵌入模型的选择思路嵌入模型是 RAG 系统里非常容易被低估的组件。很多人随便选一个模型,发现检索结果总是不太相关,然后开始怀疑切分参数不对、怀疑向量库有问题,其实问题很可能出在嵌入模型本身。中文嵌入模型的选择,我的判断标准是看三点:中文语义理解能力、向量维度、本地运行开销。算力有限的情况下,BGE 系列是最均衡的选择,bge-large-zh-v1.5效果不错但模型较大,如果你的机器内存紧张,可以用BAAI/bge-small-zh-v1.5,速度更快,效果稍弱但可用。至于 OpenAI 的text-embedding-3-small这类在线接口,优点是不占本地资源、效果好,缺点是每次调用要网络请求、数据也存在隐私风险,企业场景需要谨慎。选完模型后,一个容易忽略的细节是:查询和文档的正反向表示。BGE 系列模型在检索时建议给查询文本加上一个提示前缀“为这个句子生成表示以用于检索相关文章:”,文档入库的文本不需要加。这个细节很多人不知道,加上前缀后检索效果会有肉眼可见的提升。不过这个前缀不是所有模型都适用,具体要看模型的文档说明。3. 完整代码实战:三步搭建一个 RAG 问答系统3.1 文档加载与切分:决定检索上限的第一步RAG 的质量上限在检索,检索的质量上限在文本切分。很多人的 RAG 效果差,不是模型不行,而是文档加载和切分这一步就没做好。先看文档加载。我们以最常见的文本文件为例,加载器代码如下:from langchain_community.document_loaders import TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter loader = TextLoader("knowledge.txt", encoding="utf-8") documents = loader.load()TextLoader加载出来的每个文档对象里包括page_content(正文)和metadata(元信息)。元信息很重要,后续做答案溯源时,你要靠它告诉用户“答案来自哪段资料”。现在很多企业文档是 PDF,PDF 加载要单独处理,推荐用PyPDFLoader,但中文 PDF 可能会有排版错乱问题,这一点下面章节详细说。再看切分。文本切分最常用的方案是RecursiveCharacterTextSplitter,它会按一系列分隔符(换行、句号、逗号、字符)递归切分,尽量把语义完整的内容留在同一个块里:text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", ".", "!", "?", ";", ";"] ) chunks = text_splitter.split_documents(documents) print(f"文档被切成 {len(chunks)} 块")这个切分器的核心思想是:先按段落分,段落太长再按句子分,句子还太长再按标点分。比单纯按字符数硬切要合理得多。我给你的chunk_size=500, chunk_overlap=50这个组合是通用起点,不是最优解,不同文档类型需要不同策略,这个我在第四节专门讲。3.2 向量化入库:把文字变成“坐标”切好的文本块要转成向量并存入向量库。向量化这一步的代码如下:from langchain_community.embeddings import HuggingFaceEmbeddings embeddings = HuggingFaceEmbeddings( model_name="BAAI/bge-large-zh-v1.5", encode_kwargs={"normalize_embeddings": True} )normalize_embeddings这个参数容易被忽略。设置为 True 后,向量会做 L2 归一化,把模长变为 1,后续用余弦相似度计算时结果更稳定,也能加快检索速度。我建议这个参数默认保持打开。接下来把文本块向量化并写入 Chroma:import chromadb from langchain_chroma import Chroma vectorstore = Chroma.from_documents( documents=chunks, embedding=embeddings, persist_directory="./chroma_db" ) print("向量库构建完成,文档块数量:", vectorstore._collection.count())Chroma.from_documents会一次性完成两件事:把每个文本块编码成向量,然后写入本地目录./chroma_db。第二次运行时,如果你想复用已有向量库而不是重复构建,可以用Chroma(persist_directory="./chroma_db", embedding_function=embeddings)直接加载。这里我解释一下“向量化”粗暴类比。你可以把每个文本块想象成三维空间里的一个点,不同语义的点落在不同区域。比如“如何重置路由器密码”和“路由器登录地址是多少”这两个句子,它们的向量距离会非常近,而“今天天气怎么样”这个句子离它们就非常远。检索的本质就是在这个空间里找距离最近的邻居。3.3 检索器构建:找到最相关的片段向量库建好后,下一步是构建检索器。检索器的作用是:给定用户问题,返回最相关的 K 个文本块。retriever = vectorstore.as_retriever( search_type="similarity", search_kwargs={"k": 4} )similarity是默认的相似度检索,它会返回与问题向量余弦相似度最高的 4 个块。还有一种常用的检索方式是mmr(最大边际相关性),它在保证相关性的前提下尽量让结果之间有多样性,避免 4 个返回结果都在讲同一件事。但 MMR 有时会牺牲精准度,所以我一般先用similarity调基线,再根据效果决定要不要切到mmr。你可以直接测试检索效果:question = "公司团建报销的流程是什么?" docs = retriever.invoke(question) for i, doc in enumerate(docs): print(f"[第 {i+1} 个结果] {doc.page_content[:120]}")这一步是调试整个 RAG 最重要的环节。如果打印出来的结果和问题不相关,那你不用继续往下做了,调生成提示词是在一个坏地基上盖房子。我的经验是:先把检索结果调到自己满意,再去做生成环节,顺序不能反。3.4 提示词设计与问答循环相关性高的片段检索出来了,最后一步是把这些片段和问题组装成提示词交给大模型。这里我直接给你一个我自己用过很多次、结构清晰的提示词模板:from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser from langchain_core.runnables import RunnablePassthrough llm = ChatOpenAI( model="gpt-4o-mini", base_url="你的兼容接口地址", api_key="你的密钥", temperature=0.2 ) prompt = ChatPromptTemplate.from_messages([ ("system", """你是一个知识库问答助手。请严格基于下面的资料片段回答问题。 如果资料中没有相关信息,请直接回答“根据现有资料无法回答”,不要编造。 回答时请尽量使用资料中的原文表述,必要时简短总结。 资料片段: {context} """), ("human", "问题:{question}") ]) rag_chain = ( {"context": retriever, "question": RunnablePassthrough()} | prompt | llm | StrOutputParser() )这段代码里最核心的地方是{"context": retriever, "question": RunnablePassthrough()}。它意味着:用户输入的问题原样传给后续环节,同时问题会自动进入检索器,把检索到的文档块填充到提示词里的{context}位置。这是 LangChain 新版本用 LCEL 表达式组装链路的标准写法,逻辑清晰,比老的RetrievalQA链更容易理解和定制。问答循环也很简单:while True: question = input("请输入你的问题(输入 exit 退出):") if question.strip().lower() == "exit": break result = rag_chain.invoke(question) print("回答:", result) print("-" * 60)在提示词里,我特别强调了两件事:一是“资料没有相关信息就直接说无法回答”,这能有效抑制大模型幻觉;二是“尽量使用资料原文”,这让答案的可溯源性更强,用户能快速核实。3.5 完整代码整合:一键跑通上面几段代码分散在各小节里,我把它们整合成一个完整的 Python 脚本。你只需要修改文件路径、接口地址和密钥,就能直接运行。from langchain_community.document_loaders import TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_chroma import Chroma from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser from langchain_core.runnables import RunnablePassthrough # 1. 加载文档 loader = TextLoader("knowledge.txt", encoding="utf-8") documents = loader.load() # 2. 切分文本 text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", ".", "!", "?", ";", ";"] ) chunks = text_splitter.split_documents(documents) # 3. 向量化入库 embeddings = HuggingFaceEmbeddings( model_name="BAAI/bge-large-zh-v1.5", encode_kwargs={"normalize_embeddings": True} ) vectorstore = Chroma.from_documents( documents=chunks, embedding=embeddings, persist_directory="./chroma_db" ) # 4. 构建检索器 retriever = vectorstore.as_retriever( search_type="similarity", search_kwargs={"k": 4} ) # 5. 定义大模型 llm = ChatOpenAI( model="gpt-4o-mini", base_url="你的兼容接口地址", api_key="你的密钥", temperature=0.2 ) # 6. 组装提示词 prompt = ChatPromptTemplate.from_messages([ ("system", """你是一个知识库问答助手。请严格基于下面的资料片段回答问题。 如果资料中没有相关信息,请直接回答“根据现有资料无法回答”,不要编造。 回答时请尽量使用资料中的原文表述。 资料片段: {context} """), ("human", "问题:{question}") ]) # 7. 组装链路 rag_chain = ( {"context": retriever, "question": RunnablePassthrough()} | prompt | llm | StrOutputParser() ) # 8. 问答循环 while True: question = input("请输入你的问题(输入 exit 退出):") if question.strip().lower() == "exit": break result = rag_chain.invoke(question) print("回答:", result) print("-" * 60)这个脚本不长,但它覆盖了 RAG 的全部核心环节。你可以先拿一个几百 KB 的文本文档跑通,比如公司规章制度、产品说明、技术手册,再逐步换成自己的真实数据。4. 参数调优与效果评估:如何让回答更准确4.1 两个最重要的切分参数很多人的 RAG 效果不好,第一反应是换更大的模型,其实先动一动chunk_size和chunk_overlap往往性价比更高。chunk_size控制每个文本块的最大字符数。设太大,比如 2000,一个块里可能包含多个主题,检索时模型会把无关内容也带进上下文,同时可能超出模型的上下文窗口;设太小,比如 100,一个块可能只是一个不完整的句子,语义信息不充分,检索准确率也会下降。chunk_overlap控制相邻两个文本块之间的重叠字符数。它的作用是防止某个完整关键句恰好落在切分边界上被截断。设了重叠之后,这个关键句能同时出现在前后两个块里,至少有一个块保留了完整语义。我的经验是:内容结构化强、每段主题独立的文档,chunk_size可以稍微大一点,比如 600 到 800:内容连续性强、依赖上下文的文档,可以适当缩小到 300 到 500。判断标准很简单:抽查几个切分结果,看每个块是否语义完整、是否一句完整的话被拦腰截断。如果被截断了,就加大 overlap。4.2 检索数量 top_k 的影响top_k决定每次检索返回几个文本块给模型。这个参数对答案质量的影响非常直接。k设太小,比如 1,模型可能只看到一段不完整的资料,信息不足以支撑完整回答。k设太大,比如 10,模型要处理大量可能不相关的片段,提示词变长,生成速度变慢,还可能被噪声片段干扰,输出反而变差。我自己的经验是:文档块大小为 500 字左右时,k=4是一个好的起点。如果一个问题的答案分散在多个文档块里,可以适当调到 6。但不要无脑调大,因为模型并不会因为你给了 10 个片段就更聪明,它只是更容易迷茫。配合k还有一个重要概念叫“相关性阈值”。Chroma 支持用score_threshold过滤低相似度结果:retriever = vectorstore.as_retriever( search_type="similarity_score_threshold", search_kwargs={"k": 4, "score_threshold": 0.5} )这样设置后,低于 0.5 相似度的结果会被过滤掉,宁可没结果也不要给模型一堆不相关内容。阈值要根据你的嵌入模型来调,BGE 模型的相似度分数普遍偏高,需要 0.5 以上才有参考价值,如果换了小模型,阈值要往下调。4.3 生成参数 temperature 的取舍temperature控制模型生成的随机性。数值越高,回答越发散、越有创造性;数值越低,回答越保守、越贴近已有资料。RAG 场景我建议把temperature设在 0.1 到 0.3 之间,理由很简单:你要的不是模型“发挥”,而是模型“引用”。我自己一般固定用 0.2,既能保证答案基本稳定,又不会像 0 那样过于机械。还有一个容易忽略的参数是max_tokens或max_new_tokens,它限制生成回答的最大长度。如果知识库里某些答案比较长,而你不限制长度,模型可能会在回答中途被掐断,输出不完整。我的建议是设为 1024 左右,给足空间。另外,如果你用的是本地开源模型,中文生成能力也要考虑。不少开源模型中文回答时偶尔会蹦出英文或重复内容,这时候temperature=0.1会有帮助,当然更根本的办法是换一个中文语料训练更充分的基础模型。4.4 用简单指标评估检索质量评估 RAG 效果是个大话题,但入门阶段你不必引入太复杂的评估框架。先用最朴素的两个指标干起来。第一个是“检索命中率”。你准备一批测试问题,手动标出每个问题正确答案对应的文档段落序号,然后看在top_k返回结果里是否包含这个段落。包含就算命中,统计命中率。这个指标直接衡量检索器好坏,是整个系统的天花板。第二个是“回答正确率”。针对测试问题集合,看模型生成的答案能否覆盖标准答案里的关键信息。这个可以通过人工判分实现,也可以用大模型自动打分,但我建议初期用人工判分,更靠谱。我通常的做法是维护一个测试集:20 到 50 个有代表性的问题,覆盖知识库的不同章节。每调整一次切分参数、嵌入模型或top_k,就跑一遍测试集,对比检索命中率和回答正确率的变化。用数据说话,而不是凭感觉调参。5. 常见问题排查:我踩过的坑和解决办法5.1 问了问题却检索不到相关内容这个问题的表现是:无论你问什么,检索出的片段都跟问题没什么关系。我排查时按顺序检查三件事。第一,检查文档切分结果。看切出来的文本块是不是语义混乱。比如 PDF 表格被切得七零八落,或者一整个章节因为没有换行被塞进一个 5000 字的块里。解决办法是换加载器、调整分隔符,或者对特殊文档做预处理。第二,检查嵌入模型本身的中文语义能力。早期一些通用多语言模型对中文支持并不好,换 BGE 中文系列模型后效果通常会明显改善。第三,检查问题本身和文档的用词差异。如果文档里写的是“报销流程”,你问的是“怎么走账”,语义接近但字面差异大,嵌入模型可能检索不到。这时候可以尝试在问题侧加关键词改写,或者用mmr检索提高结果多样性。实在不行就把同义词都写进文档,或者建一个关键词映射表。5.2 回答没有引用知识库里的原文大模型看完检索片段后自己重新组织语言,有时候会抛开原文自由发挥,或者在原文信息不足时硬着头皮编。这属于典型的“幻觉抑制失败”。我解决这个问题有两个步骤。第一步,在提示词里把“必须基于资料原文作答”写得更绝对,并明确要求“如果资料没有相关内容,直接说无法回答”。第二步,在代码层面做溯源校验:让模型在回答的同时输出它依据的片段编号,然后在后端做一个简单的比对,看回答中的关键实体是否真的出现在对应片段里。如果对不上,就放弃这次生成,改用更保守的提示词重新跑一遍。这个方法不能 100% 杜绝幻觉,但能在很大比例上防止模型“自信地胡说”。5.3 首次运行特别慢首次运行慢绝大多数情况发生在嵌入模型的加载阶段。BAAI/bge-large-zh-v1.5光模型文件就有 1GB 多,首次加载需要下载和初始化,在普通 CPU 机器上可能要一两分钟。之后每次启动虽然模型文件已缓存,但加载到内存依然需要几十秒。进程退出后重新启动脚本,向量库不需要重建(因为已经持久化到磁盘),但嵌入模型要重新加载。如果你做的是服务化应用,建议把嵌入模型常驻内存,或者在 FastAPI 里用全局变量持有一份实例,避免每次请求都重新加载。另外你可以考虑把模型换成bge-small-zh-v1.5,加载速度和推理速度都快好几倍,效果差距在可接受范围内。5.4 中文文档切得乱七八糟这是中文 RAG 最容易踩的坑。英文按空格切词很规矩,中文词与词之间没有空格,如果切分器只按固定字符长度硬切,很容易把一个完整句子里的一部分截到上一块、另一部分截到下一块。我的解决方案是给切分器显式指定中文标点作为分隔符,让每个切出来的块尽量以句子或段落为边界。具体做法就是 3.1 节代码里separators参数传入中文的句号、感叹号、问号、分号。你也可以进一步把chunk_overlap调大一些,比如 80 到 100,避免关键句落在边界。还有一个实战技巧:在入库之前,先把文档里的多余空行、首尾空格、全角半角符号统一处理一遍。文档越规整,切分质量越高。我见过有人拿一份带多层表格嵌套的 PDF 直接去切,切出来的东西连人看都费劲,模型自然更答不对。写在最后的个人体会这一套 RAG 代码看起来简单,但把它从“能跑”做到“好用”,中间隔着的就是对每一层参数的持续调优。我自己的习惯是先把检索器单独调好,再调生成环节,每次改动只碰一个变量,用固定的测试集验证效果,绝不多个参数一起改。检索结果不满意时就停下来,回到切分和嵌入模型去复盘,而不是靠改提示词硬撑。另外给你一条扩展建议:当你把单文档 RAG 跑顺后,可以尝试把它升级成“多文件 + 元数据过滤”的模式,在向量库里给不同来源的文档打上标签,用户提问时按标签做范围限制。这个方向上手后,你会发现 RAG 的适用范围一下子开阔很多,从个人笔记问答到企业内部知识助手,其实就是同一套思路在不同数据规模下的延伸。先把这一版跑通,后面的路会越走越顺。
先给大家交个底:这篇文章不是让你复制粘贴就能跑通那么简单,而是希望你能真正理解 RAG 检索增强生成到底在解决什么问题、每一步代码在做什么、遇到报错该怎么处理。我会用一套完整可运行的代码带你走一遍“文档加载 → 文本切分 → 向量化 → 检索 → 生成回答”的全流程,适合刚入门大模型应用开发、想把自己私有文档变成问答机器人的朋友,也适合已经写过简单 Demo 但想系统梳理原理的开发者。我见过太多人把 RAG 当成“调用一个库、输入一段文档、得到一个答案”的魔法,结果一换数据集就崩,一调参数就废。这次我也把自己实际研发过程中踩过的坑、调过的参数、取舍过的方案,一并整理成文字写下来。你按这篇文章的思路做过一遍之后,再去看别的 RAG 框架,基本不会再是黑盒。1. RAG 到底是什么:先建立整体认知1.1 为什么大模型需要“外挂知识库”大语言模型训练的语料是某个时间点之前的公开数据,哪怕今天最强的模型,你问它一件上周刚发生的事情,它也只能胡说。更现实的问题是,你手上有一批内部产品手册、历史项目文档、私有技术规范,这些数据模型根本没学过,直接问它就是编。我跟你讲一个最典型的场景:我的一位前同事在内部运维知识库上直接集成通用大模型,不接入检索,只是把企业文档拼到系统提示词里。文档一长,模型就开始胡说八道,要么忽略你贴进去的内容,要么被冗长的上下文干扰。这个问题不是“换个大模型”就能解决的。RAG 检索增强生成的核心思路是:别让模型凭空回忆知识,而是先从一个“外挂知识库”里检索出和问题最相关的片段,再把片段连同问题一起交给模型,让它基于这些片段作答。这样做的价值很直白:你的知识可以随时更新,不用重训模型;来源可追溯,模型说一句“根据资料A”,你能看到是哪一段原文支撑了它;成本也低,不用花昂贵的算力去微调一个百亿参数模型。RAG 方案对“答案要准、要有时效性、要有据可查”的需求天然契合,这也是它在企业知识库、客服问答、智能助手里应用这么广的原因。1.2 RAG 的核心链路拆解要把 RAG 讲透彻,我建议你把它当作一条“流水线”而不是一个“函数”,因为它的每一环都是独立的,可以单独优化。整条链路拆开看是四步:第一步:内容入库。把你手头的 PDF、Word、Markdown、TXT 文档读进来,切成小段。为什么必须切段?因为模型对输入长度有上限,而且一个大段落里往往包含多个主题,切得不合理,检索噪声会很大。第二步:向量化。把每个文本片段用嵌入模型转成一串浮点数向量。你可以粗暴地把向量理解为“语义坐标”,语义相近的文本,在坐标空间里距离就近。这一步是检索的基础。第三步:检索。用户输入一个问题后,把问题也转成向量,然后在前面建好的向量库里做相似度搜索,找出最相关的 K 个文本片段。这一步决定了模型“能看到什么”,是整个系统质量的上限。第四步:生成。把检索结果和用户问题组装成特定格式的提示词,交给大语言模型,模型依据检索片段生成答案。这也是“检索增强生成”里“增强”二字的最终体现。我在实际开发中经常提醒自己一句话:检索不到的内容,模型再强也答不出来。很多人前期花大心思调生成提示词,却忽视了检索阶段的准确率,这是典型的用力用错了地方。1.3 RAG 和模型微调该怎么选有不少人问我:“那我直接把文档拿去微调模型不就行了吗?为什么还要费劲做 RAG?”我的回答通常是:你得先想清楚你到底要解决什么问题。模型微调适合的场景是“改变模型的风格、格式、推理模式”,比如让模型学会用某种模板输出报告、用某种语气说话。而 RAG 适合的场景是“给模型补充它没学过的知识”,比如企业内部文档、实时信息、个人笔记。两者并不互斥,很多时候是配合使用:微调负责“让模型更听话”,RAG 负责“让模型懂更多”。成本上差异也很明显。微调一次大模型,即使是用开源模型做 LoRA,也需要 GPU 资源和训练时间,而且知识更新一次就要重新训练一次。RAG 方案里,你只要往向量库里新增文档、重建索引即可,一分钟内就能让模型“知道”新内容。这也是很多团队优先落地 RAG 的原因。不过 RAG 也不是没有短板。它强依赖检索质量,如果文档切得乱七八糟,或者嵌入模型本身语义理解能力弱,那答出来的东西一样不对。它也无法像微调那样改变模型底层的语言习惯。所以我现在遇到业务需求,第一件事是先归类需求,再选方案,而不是拿着一个方案到处套。2. 动手实践前的准备:环境搭建与工具选型2.1 需要准备哪些依赖要跑通本文的完整代码,你需要准备好下面三类东西。第一类是语言环境。我用的是 Python 3.10 及以上版本,这是目前大模型生态兼容性最好的版本区间,不建议用 3.7 或 3.8,很多新库已经不再支持。你可以先用python --version检查一下当前版本,如果过低,建议直接换一个干净的环境。第二类是核心库。RAG 链路里我们主要用几个主流开源库:加载文档用 LangChain 的文档加载器,文本切分用 LangChain 的文本分割器,向量化用 Hugging Face 的嵌入模型,向量存储用 Chroma,大模型调用用 LangChain 的 ChatOpenAI 接口。这些库现在是社区里最常见的组合,资料多,遇到问题也好搜。第三类是模型资源。嵌入模型我推荐用中文效果比较好的 BGE 系列,比如BAAI/bge-large-zh-v1.5,它对中文语义的捕捉能力比早期的 text2vec 系列稳定不少。大模型这边,为了让你能直接跑通,代码里我按 OpenAI 兼容接口的方式写,你可以替换成任何兼容接口的服务地址,也可以换成本地部署的开源模型。2.2 安装步骤与版本坑安装其实就三条命令的事,但你得注意版本兼容性,这是我被坑过最多次的地方。pip install -U langchain pip install -U langchain-community pip install -U chromadb pip install -U sentence-transformers pip install -U openai这里有两个特别值得注意的坑。第一个是 LangChain 的模块结构发生了很大变化,早期版本的from langchain.embeddings import HuggingFaceEmbeddings这种导入在新版本中行不通了,要改为from langchain_community.embeddings import HuggingFaceEmbeddings。Chroma 的导入路径也类似。第二个是sentence-transformers库会依赖 PyTorch,如果你机器上没装过 PyTorch,它会自动下载一个 CPU 版本的,体积比较大,第一次安装要有耐心。如果你用阿里云或者国内服务器,Hugging Face 官方源常常连不上去,可以提前设置镜像环境变量:export HF_ENDPOINT=https://hf-mirror.com设置完成后再启动 Python,这个办法我在国内环境下跑了无数次,实测有效。还有一处可能踩坑的地方是 Chroma 的持久化目录,新版本里它会自动在指定目录写入向量数据文件,你不需要手动创建目录,但第一次运行时如果目录有残留的旧数据,可能导致新数据查询不到。我的习惯是每次换数据源时,先删掉整个向量库目录,再重新入库。2.3 中文嵌入模型的选择思路嵌入模型是 RAG 系统里非常容易被低估的组件。很多人随便选一个模型,发现检索结果总是不太相关,然后开始怀疑切分参数不对、怀疑向量库有问题,其实问题很可能出在嵌入模型本身。中文嵌入模型的选择,我的判断标准是看三点:中文语义理解能力、向量维度、本地运行开销。算力有限的情况下,BGE 系列是最均衡的选择,bge-large-zh-v1.5效果不错但模型较大,如果你的机器内存紧张,可以用BAAI/bge-small-zh-v1.5,速度更快,效果稍弱但可用。至于 OpenAI 的text-embedding-3-small这类在线接口,优点是不占本地资源、效果好,缺点是每次调用要网络请求、数据也存在隐私风险,企业场景需要谨慎。选完模型后,一个容易忽略的细节是:查询和文档的正反向表示。BGE 系列模型在检索时建议给查询文本加上一个提示前缀“为这个句子生成表示以用于检索相关文章:”,文档入库的文本不需要加。这个细节很多人不知道,加上前缀后检索效果会有肉眼可见的提升。不过这个前缀不是所有模型都适用,具体要看模型的文档说明。3. 完整代码实战:三步搭建一个 RAG 问答系统3.1 文档加载与切分:决定检索上限的第一步RAG 的质量上限在检索,检索的质量上限在文本切分。很多人的 RAG 效果差,不是模型不行,而是文档加载和切分这一步就没做好。先看文档加载。我们以最常见的文本文件为例,加载器代码如下:from langchain_community.document_loaders import TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter loader = TextLoader("knowledge.txt", encoding="utf-8") documents = loader.load()TextLoader加载出来的每个文档对象里包括page_content(正文)和metadata(元信息)。元信息很重要,后续做答案溯源时,你要靠它告诉用户“答案来自哪段资料”。现在很多企业文档是 PDF,PDF 加载要单独处理,推荐用PyPDFLoader,但中文 PDF 可能会有排版错乱问题,这一点下面章节详细说。再看切分。文本切分最常用的方案是RecursiveCharacterTextSplitter,它会按一系列分隔符(换行、句号、逗号、字符)递归切分,尽量把语义完整的内容留在同一个块里:text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", ".", "!", "?", ";", ";"] ) chunks = text_splitter.split_documents(documents) print(f"文档被切成 {len(chunks)} 块")这个切分器的核心思想是:先按段落分,段落太长再按句子分,句子还太长再按标点分。比单纯按字符数硬切要合理得多。我给你的chunk_size=500, chunk_overlap=50这个组合是通用起点,不是最优解,不同文档类型需要不同策略,这个我在第四节专门讲。3.2 向量化入库:把文字变成“坐标”切好的文本块要转成向量并存入向量库。向量化这一步的代码如下:from langchain_community.embeddings import HuggingFaceEmbeddings embeddings = HuggingFaceEmbeddings( model_name="BAAI/bge-large-zh-v1.5", encode_kwargs={"normalize_embeddings": True} )normalize_embeddings这个参数容易被忽略。设置为 True 后,向量会做 L2 归一化,把模长变为 1,后续用余弦相似度计算时结果更稳定,也能加快检索速度。我建议这个参数默认保持打开。接下来把文本块向量化并写入 Chroma:import chromadb from langchain_chroma import Chroma vectorstore = Chroma.from_documents( documents=chunks, embedding=embeddings, persist_directory="./chroma_db" ) print("向量库构建完成,文档块数量:", vectorstore._collection.count())Chroma.from_documents会一次性完成两件事:把每个文本块编码成向量,然后写入本地目录./chroma_db。第二次运行时,如果你想复用已有向量库而不是重复构建,可以用Chroma(persist_directory="./chroma_db", embedding_function=embeddings)直接加载。这里我解释一下“向量化”粗暴类比。你可以把每个文本块想象成三维空间里的一个点,不同语义的点落在不同区域。比如“如何重置路由器密码”和“路由器登录地址是多少”这两个句子,它们的向量距离会非常近,而“今天天气怎么样”这个句子离它们就非常远。检索的本质就是在这个空间里找距离最近的邻居。3.3 检索器构建:找到最相关的片段向量库建好后,下一步是构建检索器。检索器的作用是:给定用户问题,返回最相关的 K 个文本块。retriever = vectorstore.as_retriever( search_type="similarity", search_kwargs={"k": 4} )similarity是默认的相似度检索,它会返回与问题向量余弦相似度最高的 4 个块。还有一种常用的检索方式是mmr(最大边际相关性),它在保证相关性的前提下尽量让结果之间有多样性,避免 4 个返回结果都在讲同一件事。但 MMR 有时会牺牲精准度,所以我一般先用similarity调基线,再根据效果决定要不要切到mmr。你可以直接测试检索效果:question = "公司团建报销的流程是什么?" docs = retriever.invoke(question) for i, doc in enumerate(docs): print(f"[第 {i+1} 个结果] {doc.page_content[:120]}")这一步是调试整个 RAG 最重要的环节。如果打印出来的结果和问题不相关,那你不用继续往下做了,调生成提示词是在一个坏地基上盖房子。我的经验是:先把检索结果调到自己满意,再去做生成环节,顺序不能反。3.4 提示词设计与问答循环相关性高的片段检索出来了,最后一步是把这些片段和问题组装成提示词交给大模型。这里我直接给你一个我自己用过很多次、结构清晰的提示词模板:from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser from langchain_core.runnables import RunnablePassthrough llm = ChatOpenAI( model="gpt-4o-mini", base_url="你的兼容接口地址", api_key="你的密钥", temperature=0.2 ) prompt = ChatPromptTemplate.from_messages([ ("system", """你是一个知识库问答助手。请严格基于下面的资料片段回答问题。 如果资料中没有相关信息,请直接回答“根据现有资料无法回答”,不要编造。 回答时请尽量使用资料中的原文表述,必要时简短总结。 资料片段: {context} """), ("human", "问题:{question}") ]) rag_chain = ( {"context": retriever, "question": RunnablePassthrough()} | prompt | llm | StrOutputParser() )这段代码里最核心的地方是{"context": retriever, "question": RunnablePassthrough()}。它意味着:用户输入的问题原样传给后续环节,同时问题会自动进入检索器,把检索到的文档块填充到提示词里的{context}位置。这是 LangChain 新版本用 LCEL 表达式组装链路的标准写法,逻辑清晰,比老的RetrievalQA链更容易理解和定制。问答循环也很简单:while True: question = input("请输入你的问题(输入 exit 退出):") if question.strip().lower() == "exit": break result = rag_chain.invoke(question) print("回答:", result) print("-" * 60)在提示词里,我特别强调了两件事:一是“资料没有相关信息就直接说无法回答”,这能有效抑制大模型幻觉;二是“尽量使用资料原文”,这让答案的可溯源性更强,用户能快速核实。3.5 完整代码整合:一键跑通上面几段代码分散在各小节里,我把它们整合成一个完整的 Python 脚本。你只需要修改文件路径、接口地址和密钥,就能直接运行。from langchain_community.document_loaders import TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_chroma import Chroma from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser from langchain_core.runnables import RunnablePassthrough # 1. 加载文档 loader = TextLoader("knowledge.txt", encoding="utf-8") documents = loader.load() # 2. 切分文本 text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", ".", "!", "?", ";", ";"] ) chunks = text_splitter.split_documents(documents) # 3. 向量化入库 embeddings = HuggingFaceEmbeddings( model_name="BAAI/bge-large-zh-v1.5", encode_kwargs={"normalize_embeddings": True} ) vectorstore = Chroma.from_documents( documents=chunks, embedding=embeddings, persist_directory="./chroma_db" ) # 4. 构建检索器 retriever = vectorstore.as_retriever( search_type="similarity", search_kwargs={"k": 4} ) # 5. 定义大模型 llm = ChatOpenAI( model="gpt-4o-mini", base_url="你的兼容接口地址", api_key="你的密钥", temperature=0.2 ) # 6. 组装提示词 prompt = ChatPromptTemplate.from_messages([ ("system", """你是一个知识库问答助手。请严格基于下面的资料片段回答问题。 如果资料中没有相关信息,请直接回答“根据现有资料无法回答”,不要编造。 回答时请尽量使用资料中的原文表述。 资料片段: {context} """), ("human", "问题:{question}") ]) # 7. 组装链路 rag_chain = ( {"context": retriever, "question": RunnablePassthrough()} | prompt | llm | StrOutputParser() ) # 8. 问答循环 while True: question = input("请输入你的问题(输入 exit 退出):") if question.strip().lower() == "exit": break result = rag_chain.invoke(question) print("回答:", result) print("-" * 60)这个脚本不长,但它覆盖了 RAG 的全部核心环节。你可以先拿一个几百 KB 的文本文档跑通,比如公司规章制度、产品说明、技术手册,再逐步换成自己的真实数据。4. 参数调优与效果评估:如何让回答更准确4.1 两个最重要的切分参数很多人的 RAG 效果不好,第一反应是换更大的模型,其实先动一动chunk_size和chunk_overlap往往性价比更高。chunk_size控制每个文本块的最大字符数。设太大,比如 2000,一个块里可能包含多个主题,检索时模型会把无关内容也带进上下文,同时可能超出模型的上下文窗口;设太小,比如 100,一个块可能只是一个不完整的句子,语义信息不充分,检索准确率也会下降。chunk_overlap控制相邻两个文本块之间的重叠字符数。它的作用是防止某个完整关键句恰好落在切分边界上被截断。设了重叠之后,这个关键句能同时出现在前后两个块里,至少有一个块保留了完整语义。我的经验是:内容结构化强、每段主题独立的文档,chunk_size可以稍微大一点,比如 600 到 800:内容连续性强、依赖上下文的文档,可以适当缩小到 300 到 500。判断标准很简单:抽查几个切分结果,看每个块是否语义完整、是否一句完整的话被拦腰截断。如果被截断了,就加大 overlap。4.2 检索数量 top_k 的影响top_k决定每次检索返回几个文本块给模型。这个参数对答案质量的影响非常直接。k设太小,比如 1,模型可能只看到一段不完整的资料,信息不足以支撑完整回答。k设太大,比如 10,模型要处理大量可能不相关的片段,提示词变长,生成速度变慢,还可能被噪声片段干扰,输出反而变差。我自己的经验是:文档块大小为 500 字左右时,k=4是一个好的起点。如果一个问题的答案分散在多个文档块里,可以适当调到 6。但不要无脑调大,因为模型并不会因为你给了 10 个片段就更聪明,它只是更容易迷茫。配合k还有一个重要概念叫“相关性阈值”。Chroma 支持用score_threshold过滤低相似度结果:retriever = vectorstore.as_retriever( search_type="similarity_score_threshold", search_kwargs={"k": 4, "score_threshold": 0.5} )这样设置后,低于 0.5 相似度的结果会被过滤掉,宁可没结果也不要给模型一堆不相关内容。阈值要根据你的嵌入模型来调,BGE 模型的相似度分数普遍偏高,需要 0.5 以上才有参考价值,如果换了小模型,阈值要往下调。4.3 生成参数 temperature 的取舍temperature控制模型生成的随机性。数值越高,回答越发散、越有创造性;数值越低,回答越保守、越贴近已有资料。RAG 场景我建议把temperature设在 0.1 到 0.3 之间,理由很简单:你要的不是模型“发挥”,而是模型“引用”。我自己一般固定用 0.2,既能保证答案基本稳定,又不会像 0 那样过于机械。还有一个容易忽略的参数是max_tokens或max_new_tokens,它限制生成回答的最大长度。如果知识库里某些答案比较长,而你不限制长度,模型可能会在回答中途被掐断,输出不完整。我的建议是设为 1024 左右,给足空间。另外,如果你用的是本地开源模型,中文生成能力也要考虑。不少开源模型中文回答时偶尔会蹦出英文或重复内容,这时候temperature=0.1会有帮助,当然更根本的办法是换一个中文语料训练更充分的基础模型。4.4 用简单指标评估检索质量评估 RAG 效果是个大话题,但入门阶段你不必引入太复杂的评估框架。先用最朴素的两个指标干起来。第一个是“检索命中率”。你准备一批测试问题,手动标出每个问题正确答案对应的文档段落序号,然后看在top_k返回结果里是否包含这个段落。包含就算命中,统计命中率。这个指标直接衡量检索器好坏,是整个系统的天花板。第二个是“回答正确率”。针对测试问题集合,看模型生成的答案能否覆盖标准答案里的关键信息。这个可以通过人工判分实现,也可以用大模型自动打分,但我建议初期用人工判分,更靠谱。我通常的做法是维护一个测试集:20 到 50 个有代表性的问题,覆盖知识库的不同章节。每调整一次切分参数、嵌入模型或top_k,就跑一遍测试集,对比检索命中率和回答正确率的变化。用数据说话,而不是凭感觉调参。5. 常见问题排查:我踩过的坑和解决办法5.1 问了问题却检索不到相关内容这个问题的表现是:无论你问什么,检索出的片段都跟问题没什么关系。我排查时按顺序检查三件事。第一,检查文档切分结果。看切出来的文本块是不是语义混乱。比如 PDF 表格被切得七零八落,或者一整个章节因为没有换行被塞进一个 5000 字的块里。解决办法是换加载器、调整分隔符,或者对特殊文档做预处理。第二,检查嵌入模型本身的中文语义能力。早期一些通用多语言模型对中文支持并不好,换 BGE 中文系列模型后效果通常会明显改善。第三,检查问题本身和文档的用词差异。如果文档里写的是“报销流程”,你问的是“怎么走账”,语义接近但字面差异大,嵌入模型可能检索不到。这时候可以尝试在问题侧加关键词改写,或者用mmr检索提高结果多样性。实在不行就把同义词都写进文档,或者建一个关键词映射表。5.2 回答没有引用知识库里的原文大模型看完检索片段后自己重新组织语言,有时候会抛开原文自由发挥,或者在原文信息不足时硬着头皮编。这属于典型的“幻觉抑制失败”。我解决这个问题有两个步骤。第一步,在提示词里把“必须基于资料原文作答”写得更绝对,并明确要求“如果资料没有相关内容,直接说无法回答”。第二步,在代码层面做溯源校验:让模型在回答的同时输出它依据的片段编号,然后在后端做一个简单的比对,看回答中的关键实体是否真的出现在对应片段里。如果对不上,就放弃这次生成,改用更保守的提示词重新跑一遍。这个方法不能 100% 杜绝幻觉,但能在很大比例上防止模型“自信地胡说”。5.3 首次运行特别慢首次运行慢绝大多数情况发生在嵌入模型的加载阶段。BAAI/bge-large-zh-v1.5光模型文件就有 1GB 多,首次加载需要下载和初始化,在普通 CPU 机器上可能要一两分钟。之后每次启动虽然模型文件已缓存,但加载到内存依然需要几十秒。进程退出后重新启动脚本,向量库不需要重建(因为已经持久化到磁盘),但嵌入模型要重新加载。如果你做的是服务化应用,建议把嵌入模型常驻内存,或者在 FastAPI 里用全局变量持有一份实例,避免每次请求都重新加载。另外你可以考虑把模型换成bge-small-zh-v1.5,加载速度和推理速度都快好几倍,效果差距在可接受范围内。5.4 中文文档切得乱七八糟这是中文 RAG 最容易踩的坑。英文按空格切词很规矩,中文词与词之间没有空格,如果切分器只按固定字符长度硬切,很容易把一个完整句子里的一部分截到上一块、另一部分截到下一块。我的解决方案是给切分器显式指定中文标点作为分隔符,让每个切出来的块尽量以句子或段落为边界。具体做法就是 3.1 节代码里separators参数传入中文的句号、感叹号、问号、分号。你也可以进一步把chunk_overlap调大一些,比如 80 到 100,避免关键句落在边界。还有一个实战技巧:在入库之前,先把文档里的多余空行、首尾空格、全角半角符号统一处理一遍。文档越规整,切分质量越高。我见过有人拿一份带多层表格嵌套的 PDF 直接去切,切出来的东西连人看都费劲,模型自然更答不对。写在最后的个人体会这一套 RAG 代码看起来简单,但把它从“能跑”做到“好用”,中间隔着的就是对每一层参数的持续调优。我自己的习惯是先把检索器单独调好,再调生成环节,每次改动只碰一个变量,用固定的测试集验证效果,绝不多个参数一起改。检索结果不满意时就停下来,回到切分和嵌入模型去复盘,而不是靠改提示词硬撑。另外给你一条扩展建议:当你把单文档 RAG 跑顺后,可以尝试把它升级成“多文件 + 元数据过滤”的模式,在向量库里给不同来源的文档打上标签,用户提问时按标签做范围限制。这个方向上手后,你会发现 RAG 的适用范围一下子开阔很多,从个人笔记问答到企业内部知识助手,其实就是同一套思路在不同数据规模下的延伸。先把这一版跑通,后面的路会越走越顺。