1. 先聊聊大模型那个“要命的缺陷”
这两年做大模型应用,大家应该都有过这种体验:模型聊得头头是道,但一问到具体业务细节,就开始一本正经地胡说八道。尤其是内部文档、售后FAQ、产品手册这些内容,模型根本没见过,却照样给你编出一套方案来。
问题出在哪?大模型的“记忆”是训练时固化的。训练语料的截止日期一到,它的知识就冻结了。你问它十天前发生的事,它只能靠推测;你问它你们公司内部的报销流程,哪怕文档写得清清楚楚,它也只会东拼西凑给你一个看起来合理、实际上全是错的答案。
检索增强生成(Retrieval-Augmented Generation,RAG)就是冲着这个缺陷去的。思路说白了很简单:模型不知道没关系,我主动去找资料喂给它。在模型开口之前,先从外部知识库里检索出和问题最相关的内容,把检索结果拼进提示词,让模型基于这些材料作答。这样模型不再依赖自己那份“过期”的记忆,而是随时可以从外部资料库获取最新的、最准确的上下文。很多人把这个过程叫作“给AI外挂长期记忆”,我觉得更准确的说法是——给AI配了个可以随时翻阅的专属图书馆。
这篇文章不打算只讲概念。我会把RAG从检索到生成的完整链路拆开,讲清楚每一步在干什么、为什么这么干,再给出一套可以直接上手复现的最小实现,最后聊聊我在实际项目里踩过的坑。无论你是刚接触这个概念的新人,还是已经在调RAG但效果一直不理想的开发者,这篇应该都能给你一些能落地的参考。
2. 拆开RAG:索引、检索、生成,三个角色一台戏
2.1 整体流程:先准备知识库,再回答“基于资料”的问题
RAG的完整流程可以分成两大阶段:准备阶段和推理阶段。
准备阶段要做的事是建立知识索引。把原始文档清洗、分块、向量化,然后把向量存进向量数据库。这个过程是离线的,只在知识更新时重跑。
推理阶段是在每一次用户提问时实时发生。用户问一个问题,系统先把问题转成向量,然后在向量数据库里找相似度最高的几个片段,接着把这些片段作为附加上下文,连同原始问题一起拼接成提示词,最后交给大模型生成回答。
这段话里有几个关键词值得标记一下:“向量化”“相似度检索”“上下文拼接”。这三个动作分别对应着索引、检索、生成三个阶段,也是整个RAG系统最核心的三个模块。理解了它们,你就理解了RAG的骨架。
2.2 索引阶段:人类语言如何变成机器能算的向量
索引阶段的第一步是文档加载与清洗。你手里的资料可能是PDF、Word、Markdown,也可能是有几百个字段的数据库记录。清洗的意义是去掉页眉页脚、表格错乱、乱码字符,把有效正文提取出来。这一步听着不起眼,但实际做的时候特别影响后面分块和检索的效果。我见过不少团队跳过清洗直接切分,结果检索出来的片段里带着残缺的表格,模型拿到这种上下文,出来的答案自然也是跑偏的。
第二步是分块。大模型对输入长度有限制,而一篇完整的文档动辄几千上万字,不可能整篇塞进去。常见的做法是按固定长度切分,或者按标题、段落结构来切。这个“块”的大小直接决定了检索的最小粒度。切大了,块里信息冗余多,检索精度下降;切小了,单块信息不完整,模型拿到的是支离破碎的片段。折中方案是给每个块加一个重叠区域,避免关键句正好被拦腰切断。
第三步是向量化。需要用一个embedding模型把每个文本块映射为一个高维向量,比如768维或1024维。向量有个重要性质:语义相近的文本,向量之间的空间距离也更近。“苹果”和“水果”的向量距离会小于“苹果”和“汽车”。这就是之后能用向量检索相关文档的根本原因。
第四步是入库。把向量和对应的原文片段、元数据一起写入向量数据库。库的作用是提供高效的相似度检索能力,而不是像MySQL那样做精确等值查询。
2.3 检索阶段:把“语义距离”当成搜索依据
当用户提出问题,系统会用同一个embedding模型把问题也转成向量。之后在向量数据库里执行近似最近邻搜索,找出距离最近的一组向量,再映射回对应的文本片段。
这里有个关键点:检索用的是“语义相似度”,而不是“关键词匹配”。这意味着用户不必使用和原文完全一致的说法也能命中。你问“这个月报销截止到几号”,知识库里存的可能是“月度费用提交截止时间为每月25日”,语义向量能建立起这种关联。
但语义检索也有它的盲区。比如精确的产品型号、编号这种强关键词场景,向量检索的表现有时反而不如传统的BM25关键词检索。这也是很多人后来做所谓“混合检索”的原因:既用向量找语义相近的内容,又用关键词找精确匹配的内容,最后合并结果再统一排序。后面我会展开讲。
2.4 生成阶段:上下文拼接与“萧规曹随”的答案
生成阶段看起来最简单,其实最容易翻车。流程是:把检索到的片段按相关性顺序拼成一个“参考材料”段落,加上用户问题,构造一条完整提示词,让模型给出答案。
很多人以为把资料塞进提示词就完事了,结果回答里还是出现幻觉。原因通常出在两个环节:一是提示词里没有明确约束模型的作答边界,二是检索回来的片段本身就不相关。模型很诚实,它只是基于你给的资料尽力圆话,资料质量差,答案自然差。生成阶段一定要在提示词里写清楚:只依据给定的资料内容回答,资料中找不到答案时,直接说明“未在资料中找到相关内容”,不要自行编造。这一步能极大降低幻觉率。
3. 从零搭建一个最小RAG系统:代码、选型与调参
3.1 技术选型:理解原理之后,工具只是顺手的问题
聊完原理,我们直接动手。我建议搭建一个最小闭环系统,端到端跑通“文档入库-问题检索-生成回答”全过程。技术栈我选择目前社区里比较成熟的几项:
- embedding模型:用开源的通用中文向量模型,可以本地部署,不依赖外部服务
- 向量数据库:用一个支持本地运行的开源向量引擎,支持余弦距离和持久化存储
- 大模型:接一个支持提示词注入的对话模型,可以是云端API,也可以是本地模型
- 文本处理:用通用的文档加载和文本分割工具库
选型逻辑只有一条:先跑通,再优化。不要一上来就堆重型组件,分布式向量库、微调模型这些前期都用不上。等最小系统跑通,你理解了每个环节,再按需替换。
3.2 文档预处理与分块实现
我准备了一份模拟的企业内部FAQ文档,用来演示。假设文档是Markdown格式,每一步流程都是可复现的。先做文档加载与分块:
# 文档加载 with open("enterprise_faq.md", "r", encoding="utf-8") as f: content = f.read() # 文本分块:按递归字符切分,优先照顾段落边界 from langchain.text_splitter import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=300, # 每个块的目标长度 chunk_overlap=50, # 块之间重叠长度 separators=["\n## ", "\n### ", "\n\n", "\n", "。", " "] ) chunks = splitter.split_text(content) print(f"文档被切分为 {len(chunks)} 个块")这里chunk_size我设置为300字符左右,chunk_overlap为50。300字符大约能容纳一段相对完整的说明,而50字符的重叠能保证跨块的关键句不会丢失。separators里的顺序有讲究:程序会优先用标记符号划分,然后是段落、句子,实在不行才按空格切。这样分出来的块比纯按字数硬切要干净得多。
3.3 向量化与入库实现
分块完成后,把每块的文本转成向量,并写入向量数据库:
from sentence_transformers import SentenceTransformer import chromadb # 加载embedding模型 embed_model = SentenceTransformer("BAAI/bge-m3") # 创建向量库客户端和集合 client = chromadb.PersistentClient(path="./vectordb") collection = client.get_or_create_collection( name="enterprise_faq", metadata={"hnsw:space": "cosine"} # 使用余弦距离 ) # 向量化并入库 for i, chunk in enumerate(chunks): vec = embed_model.encode(chunk).tolist() collection.add( ids=[f"chunk_{i}"], embeddings=[vec], documents=[chunk], metadatas=[{"source": "enterprise_faq.md", "index": i}] ) print(f"已完成 {len(chunks)} 个块入库")SentenceTransformer是加载嵌入模型的常用工具类,bge-m3是一个开源的中文向量模型。向量库中选用余弦距离计算相似度,因为它在处理文本向量时更关注方向一致性而不是绝对距离,实际效果优于欧氏距离。
3.4 检索与生成的完整链路
接下来实现推理阶段的流程。把用户问题向量化,检索相似片段,构造提示词并交给模型回答:
# 阶段1:问题向量化 question = "报销的截止时间是哪天?" question_vec = embed_model.encode(question).tolist() # 阶段2:向量检索,取top-5 results = collection.query( query_embeddings=[question_vec], n_results=5, include=["documents", "distances"] ) hits = results["documents"][0] # 阶段3:构造上下文与提示词 context = "\n\n".join([f"[文档片段{i+1}]\n{hit}" for i, hit in enumerate(hits)]) prompt = f"""你是一位企业助手。请严格依据以下素材回答问题。 素材内容: {context} 用户问题:{question} 要求: 1. 只依据素材内容作答,不得补充素材之外的信息。 2. 如果素材中没有答案,请回复:未在资料中找到相关信息。 3. 回答要简洁准确。 作答:""" # 阶段4:调用大模型生成 response = chat_model(prompt) # chat_model为已配置的模型接口 print(response)到了这一步,一个最小RAG系统就通了。你问“报销截止时间”,系统会自动找库里和报销、时间相关的段落,把材料喂给模型,模型再组织语言输出答案。整个链路从检索到生成不超过几秒。
3.5 参数调整的核心逻辑
跑通之后,真正花时间的是调参。我建议重点关注这几个参数:
Top-k:检索返回的片段数量。太少了信息不足,太多了噪音增加。我的经验是5到10之间起步,再根据回答质量加减。如果文档内容较长且问题多为综合型,Top-k可以适当加大;如果问题比较具体直接,Top-k小一点反而更精准。chunk_size:分块大小。300到500字符是很多场景的甜点区。块太小语义不完整,块太大检索精度下降。常见做法是用不同块大小各跑一轮离线测试,比较检索命中率。chunk_overlap:重叠长度建议设为块大小的15%到25%。这个值主要防止关键句被切分边界截断。- 相似度阈值:检索结果里相关性很差的片段,不如直接丢弃。我一直建议给检索设置一个最低相似度阈值,比如0.7,低于阈值的片段直接不进上下文。这样能明显减少模型被无关信息带偏的概率。
参数速查表(基于个人实践经验): chunk_size 300-500字符 chunk_overlap 50-100字符 Top-k 5-10个片段 相似度阈值 0.7-0.8 embedding维度 768或1024(由模型决定)4. 我在实际项目里踩过的五个坑
4.1 分块切得又小又多,检索效果反而变差
最初做分块时,我担心块太大导致信息混杂,所以把块切得很小,大概每个块不到100字。结果检索回来的片段确实“精准”,但往往是只言片语。模型拿到一个孤立的句子,根本看不出前因后果,回答起来就像复读机一样,只会重复那句话,回答质量惨不忍睹。
后来我意识到,分块的粒度取决于下游生成需要的最小理解单元。比如一段关于报销流程的文字,你需要的是“流程介绍+条件说明”这样完整的语义单元,而不是一句断开的“报销需附发票”。现在我的习惯是,先按文档结构分块,结构不清晰的再按固定长度兜底,块大小控制在能包含一个完整要点。
4.2 检索命中“关键词相关”,但内容完全对不上问题
有一次我查“员工年假计算方式”,检索回来的片段里包含“年假”和“计算”两个词,乍看相关,其实那段内容讲的是“离职时年假折算工资”。关键词撞上了,但语义偏离了。向量检索有时候会高语义相关度,把包含相同关键词但话题不同的文档排在前面。
这个问题的解法是加一层重排序。第一轮向量检索多取一些候选,比如Top-20,然后用一个更强的排序模型把这20个候选重新打分,筛掉那些“表面相关、实质无关”的片段,最后只把Top-5喂给模型。代价是多一次模型调用,但对回答精度的提升非常明显。
4.3 上下文塞得太满,模型反而开始“迷失重点”
早期调试时,为了提高召回率,我把Top-k调到15,觉得给模型看足够多的资料就能答好。结果模型经常答非所问,甚至开始自己编造材料里没有的内容。后来细想,这就像让一个人同时读15份资料再回答问题,信息过载,注意力被分散,关键的几条反而没被利用。
上下文不是越多越好。对这问题的正确做法是,先想清楚回答一个具体问题最少需要几块信息,然后再决定Top-k。我现在的经验是,对大多数单点查询问题,Top-3到Top-5足够了。只有那种需要综合多份文档才能回答的问题,才需要调大。
4.4 知识库更新之后,旧索引成了“僵尸记忆”
RAG的初衷是让AI用最新资料回答问题。但我们最初只做了入库流程,没做更新流程。文档改了,索引里的还是旧块。用户问新政策,模型拿着旧政策回答,错得理直气壮。这个坑特别隐蔽,因为系统一切看起来都在正常工作,只有答案是对不上的。
后来我把知识更新管了起来,划分为三个动作:
- 删除失效文档对应的向量
- 重新对新文档做分块和向量化
- 增量写入向量库,而不是全量重建
同时给每个向量打上版本号和更新时间,检索时可以优先筛最新版本。不要小看这个版本管理,没有它,RAG系统跑一个月之后就会积累大量过期知识,你根本不知道模型在用哪份资料回答。
4.5 没有评估日子,调参全凭感觉,效果稳定不下来
调了几个参数之后,我发现自己陷入一个困境:改了分块方式,这次效果好一点,下次又差一点;换了个embedding模型,某些问题变好了,另一些又变差了。因为没有一套固定评估集,我根本不知道改动到底是优化还是恶化。
后来我花了半天搭了一套评估集:从知识库里整理出50个典型的用户问题,每个问题标注了标准答案和对应的参考文档片段。每次改动后,跑一遍这50个问题,统计“回答正确率”“检索命中率”“空答率”三项指标。有了这个基线,改动的效果一目了然。这是我从“瞎调参数”到“系统优化”的分水岭。
评估指标参考: 回答正确率:模型给出答案与标准答案语义一致的占比 检索命中率:top-5结果中包含参考文档片段的占比 空答率:模缝说“未找到信息”,但标准答案中确实存在该信息的占比5. 让RAG从“能用”到“好用”的几个进阶技巧
5.1 混合检索:向量检索加关键词检索双管齐下
向量检索擅长语义联想,但碰上精确的编号、型号、人名时经常打偏。我遇到过一个场景:用户问“服务器SNMP-2024-089的保修截止日期”,知识库里的产品序列号精确匹配才能命中。向量检索把“序列号”和“保修”之类的关系找出来了,但精准的那一个编号反而没排到前面。
解法是并行跑两路检索。一路用向量找语义相似,一路用BM25做关键词精确匹配,然后把两路的Top结果合并,用重排序模型统一打分。实践下来,混合检索对真实场景的帮助很直观——它让RAG既懂语义、也认数字。
5.2 重排序:别让好答案藏在第三页
重排序本质上是引入一个更强大的排序模型,对第一轮粗排的结果做精细化打分。向量检索的粗排速度很快,但精度有限;重排序模型一般更重,但能够更好地理解查询与文档之间的细粒度相关性。
一个实际例子:我处理某售后服务知识库时,向量检索出来的第4个片段其实是最相关的一条,但被排到了后面。加入重排序之后,它被提到第1位,最终回答质量明显改善。重排序虽然多花了一点时间,但比起错答返工的成本,这点延迟完全可以接受。
5.3 多轮对话里的上下文管理,别让前几轮问题污染检索
用户和AI的对话通常不是单轮的。上一轮问“报销流程”,下一轮接着问“那发票怎么贴”。如果只拿最后一句话去检索,“发票怎么贴”单独去看语义不完整,检索结果大概率是偏的。
应该做的是把对话历史压缩成检索上下文。方法可以很简单:把前几轮的用户问题加上当前问题一起丢给模型,让模型提炼出一个独立的检索查询语句。这样做既保留了对话意图,又让检索关键词足够清晰。千万不要把模型生成的冗长回答也塞进检索条件里,那些内容污染性很强。
5.4 RAG、微调、知识图谱,三条路各管一摊子事
这是很多新人会纠结的问题:有了RAG还需要微调吗?我的理解是,两者的定位完全不同。
- RAG负责“记忆外部化”:知识更新频繁、内容以文档形式存在、强调可追溯性,这种情况用RAG最合适。
- 微调负责“风格与能力塑造”:比如让模型学会特定领域的输出格式、口吻、逻辑结构,这种能力不是检索能塞给你的。
- 知识图谱负责“多跳推理”:当问题需要跨越多个实体和关系才能得到答案时,比如“A部门的负责人是不是曾经负责过B项目”,纯RAG的平面检索很难兜住。
在实际项目里,它们不是二选一,而是组合使用。比如一个医疗问答系统,主体是RAG,病历模块用向量检索,药名匹配用关键词检索,输出格式规范依赖微调。把每条路用在它该用的地方,效果才能最大化。
6. 最后再分享一点个人体会
做了这么久的RAG项目,我最深的感受是:这技术看起来结构简单,但每一个环节都有坑。文档清洗做了没、分块粒度合不合理、embedding模型选得对不对、检索阈值调得准不准、提示词写得严不严,每一个细节都在暗中决定最终回答的质量。网上很多教程把RAG讲得像一个一键部署的黑盒,但真正用过的人都知道,它更像是手搓一辆自行车——零件不多,但每个螺丝都得上紧。
建议第一次上手的读者,先别急着堆功能。按这篇文章的思路搭一个最小系统,跑通一遍全流程,然后把你的业务文档丢进去,实测几个问题。看完检索回来的片段你心里大概就有数了:该调的是分块,还是该换个embedding模型,还是该加重排序,每一步都能对照着去改。等你把这套流程跑熟了,再考虑混合检索、知识更新机制、评估集这些进阶玩法,会顺手很多。RAG不是什么玄学,它就是一个需要细心伺候的工程系统。