☰
RAG实战教程:从原理到代码,解决大模型幻觉与知识过时
2026/10/19 2:27:50 网站建设 项目流程

你有没有遇到过这种场景:问某个内部知识库助手一个很具体的业务问题,它信誓旦旦地给出一整段专业回答,逻辑通顺、格式规整,连参数名都列得清清楚楚,结果你去翻原始文档,死活找不到对应的出处——因为它压根就是在编。大模型这种一本正经地胡说八道,业内叫幻觉。本质原因是模型把所有知识都压缩进了神经网络参数里,它记住的是训练语料里的统计规律,而不是事实本身;再加上训练数据有截止时间,新知识、私有知识它天然不知道。检索增强生成(RAG,Retrieval-Augmented Generation)就是冲着这两个痛点来的:把知识从模型参数里搬出来,放到一个可检索的外部知识库,每次回答前先查资料、再组织语言。这篇教程我会从原理讲到代码,再从代码讲到调优和踩坑,适合所有正在做知识库问答、智能客服、企业文档助手的同学参考。

1. 大模型的"记忆陷阱":知识都装在参数里,过时和编造就不可避免

1.1 幻觉不是模型蠢,是"记忆"这个机制天生就不靠谱

先搞清楚一件事:大模型回答问题的时候,没有一个"查资料"的动作。它做的是根据你给的上下文,逐个预测下一个最可能的词。训练阶段它看过海量文本,把这些文本里的模式、关联、表述方式全部压缩成了成千上亿个参数。所以你问它"某产品的配置参数是多少",它其实是调用自己参数里学到的"这类问题一般长什么样",拼出一段最像答案的话,而不是去某个数据库里翻出那条真实记录。

问题就出在这里:模型分不清"它真正知道"和"它只是学得像"。一个参数规模很大的模型,完全有能力把一段编造的内容组织得比真实文档还流畅。这也是为什么幻觉很难靠"加大模型"来解决——不是容量不够,是机制决定的。它学的是相关性,不是事实性。

我经常用一个类比:大模型像一位读了很多书但从不做笔记的专家。你问他问题,他凭印象侃侃而谈,讲得头头是道,但细节经常对不上。你指望他"想起来"某个数据,可他脑子里根本没有一个可以翻阅的笔记本。RAG干的事,就是给这位专家配一个随时可查的笔记本。

1.2 知识过时是结构性问题:改模型治标,改数据源才治本

除了幻觉,另一个躲不掉的痛点是知识过时。大模型的知识停留在训练数据截止那一刻,之后发生的产品更新、政策调整、内部流程变更,它一概不知道。你可能想:那就定期微调呗。可以,但微调有几个现实问题。

第一,成本高。一次全量微调要准备高质量数据集、要算力、要反复验证,周期通常是数周到数月。第二,覆盖不完。企业内部知识是持续增长的,今天上架一个产品、明天更新一个流程,你不可能为了每次变更都去重训一次模型。第三,微调的本质是"教模型说话的风格与规律",并不擅长往参数里塞具体事实。硬塞不仅容易学歪,还可能破坏原有能力,这就是常说的灾难性遗忘。

RAG的思路是反过来的:知识从参数里挪到外部知识库,模型只负责理解和生成。知识库的更新就是往里写文档,索引重建完,模型立刻就能"看到"新内容。知识过时的问题从"多久重训一次模型"变成了"多久同步一次文档",难度完全不是一个量级。理解到这个层面,你就明白为什么现在绝大多数知识库类应用都在走RAG路线了。

2. 动工前的四个选型决定:切分粒度、嵌入模型、向量库与检索策略

2.1 文档切分:块大小决定检索颗粒度,而不是字数

很多人第一次搭RAG,上来就写代码,结果效果差得莫名其妙。我建议先把选型想清楚,尤其是切分。切分是把长文档拆成小块,每块单独做向量化、单独入库。为什么必须切?因为嵌入模型对输入长度有限制,而且一次检索返回的是"一个块",如果一块覆盖一整本手册,召回了也等于没召回。

切分粒度直接影响检索颗粒度。切得太粗,比如每块2000字,检索回来的块里大量内容跟问题无关,会把正确答案淹掉;切得太细,比如每块几十个字,语义不完整,模型看到的信息碎片化,也没法给出完整的回答。我的经验是从两个维度找平衡:块大小落在256到512个token之间是比较稳的起点,配合10%到15%的overlap(相邻块之间重叠部分),防止一句话被从中间截断。

比固定长度更优的是结构化切分:按Markdown标题、段落、章节边界来切。比如一份操作手册,按"二级标题+下面的段落"为一块,语义完整性远好于按字符数硬切。很多开源解析框架都支持递归切分器,它会优先按段落、句子边界切,切不动再按字符切。这个逻辑值得记住:切分的第一原则是保住语义边界,第二原则才是控制长度。

2.2 嵌入模型:选型看语言、维度与检索效果

嵌入模型把文本变成一串浮点数向量,语义相近的文本向量距离近。这个模型选得好不好,直接决定检索的上限——后面的排序、生成都建立在这个"相似度"之上。选型时我主要看四点。

一看语言覆盖。做中文知识库,优先选在中文语料上训练过的向量模型,英文模型跑中文,效果会有肉眼可见的差距。二看向量维度。主流开源中文向量模型的维度通常在几百到上千之间,维度越高,单条存储空间越大,检索计算量也越大。如果知识库规模不大、几百MB级别,维度影响不大;如果准备做千万级向量,维度直接决定成本。三看检索效果,最靠谱的办法是拿自己的知识库抽一批问题,跑一遍召回,肉眼看返回结果靠不靠谱。四看部署成本,向量模型一般不大,CPU也能跑,但吞吐量比GPU低,如果查询量高,考虑单独部署或走托管的向量化服务。

还有一个容易被忽略的细节:向量一定要做归一化。归一化之后,用内积和用余弦相似度得到的结果一致,很多向量库默认用内积计算,如果你忘了归一化,检索结果可能会跟预期不符。

2.3 向量库:从内存数组到分布式集群怎么选

向量库的选择取决于数据规模。做原型验证,几百几千条向量,用一个内存里的简单结构完全够用,没必要上重型组件。数据到十万级、百万级,就需要专门的向量检索库了,这类库底层用近似最近邻索引,典型的是HNSW(分层可导航小世界图)。HNSW有几个参数值得理解:M控制每个节点的连接数,越大召回越准、内存越高;efConstruction控制建索引时的搜索范围,影响索引质量;efSearch控制查询时的搜索范围,影响查询精度与延迟。实操中,M在16到32之间、efConstruction在100到200之间是比较常见的起点。

到千万级以上,或者有高并发、高可用要求,就得考虑分布式向量数据库了,节点扩容、分片、副本都是必须的能力。这里要给个实用建议:不要一开始就上重型分布式方案。很多团队把大量时间花在运维向量集群上,结果发现业务检索量根本没到那个量级。从单机索引起步,性能不够了再迁移,是成本最低的路径。

2.4 检索策略:相似度计算与TopK的取舍

检索的核心动作是:把用户问题向量化,在向量库里找出最相似的K个块。相似度计算方式主要有三种:余弦相似度、内积、欧氏距离。向量归一化之后,余弦和内积等价,一般选内积,因为很多向量库对它的底层优化最成熟。欧氏距离更关注绝对距离,适合向量本身就很有区分度的场景,但实际用的少。

TopK怎么定?K太小,可能漏掉正确答案;K太大,噪声块挤占上下文窗口。我的默认值是5到10之间,具体取决于块的大小:块越大,K可以越小,因为每块信息量更大;块越小,K要适当调大,才能覆盖足够的信息。调参阶段可以做一个简单实验:固定问题集,从K=3到K=10逐个跑一遍,用"正确答案是否被召回"这个指标来定K,而不是靠感觉。

3. 手搭一条最小可用RAG流水线:从导入文档到输出带引用的回答

3.1 索引构建:把文档变成可检索的向量集合

选型定完之后,真正写代码就快了。下面这套是最小可用版本:读文档、切块、向量化、存索引。我故意不引入重型组件,让你先把链路跑通,理解每一步在干什么。

# -*- coding: utf-8 -*- # 最小可用RAG:索引构建部分(伪代码,省略鉴权与异常处理) import hashlib def build_index(doc_paths): chunks = [] for path in doc_paths: raw_text = read_file(path) # 读取文档,docx/pdf/md 各自处理 doc_chunks = split_by_structure(raw_text) # 结构化切分,回退到定长切分 for chunk in doc_chunks: chunk_id = hashlib.md5(chunk.encode()).hexdigest() chunks.append({"id": chunk_id, "text": chunk, "source": path}) # 批量向量化 vectors = embedding_model.encode([c["text"] for c in chunks]) store = SimpleVectorStore() for chunk, vec in zip(chunks, vectors): store.add(chunk["id"], vec, chunk) return store

切分函数乍一看不起眼,但它是整条链路里最影响效果的一环。我的做法是:优先按Markdown标题层级切,标题加正文作为一个块;遇到没有结构的纯文本,才回退到定长切分加overlap。千万别在第一步就贪多,把什么文档解析、版面还原全都塞进去,先让链路转起来,再逐步替换切分逻辑。

向量化时注意批量处理而不是逐条调用,能省大量时间。另外建议把原始文本和向量一起存,因为后续生成阶段需要把原始文本注入提示词,只存向量不存文本等于白存。

3.2 查询链路:召回、拼提示词、生成

索引建好了,查询链路就是它的逆过程:问题向量化、检索、拼提示词、调用大模型。下面这段代码把每一步都拆开了,方便你看清楚数据是怎么流动的。

# 最小可用RAG:查询链路部分(伪代码) def ask(question, store, top_k=5): q_vec = embedding_model.encode([question])[0] hits = store.search(q_vec, top_k) # 返回最相似的K个块 context = "\n\n".join( f"[{i+1}] {hit['text']}" for i, hit in enumerate(hits) ) prompt = f"""请仅根据以下资料回答问题。 资料: {context} 问题:{question} 要求: 1. 只使用资料中的信息回答,不要编造; 2. 回答末尾标注信息来源编号,如[1]、[2]; 3. 如果资料中没有答案,直接说"资料中未找到相关信息"。""" answer = llm_generate(prompt) return answer, hits

这里最关键的设计是提示词里的三条约束。第一条把模型的回答范围死死框住,降低编造概率;第二条要求标注来源编号,既方便用户溯源,也让你能事后检查引用是否真实;第三条给模型一个体面的"退路",它不必为了显得有用而硬编一个答案。这三条是我反复调整之后留下的最小集合,多了模型会犯糊涂,少了约束不住。

关于llm_generate,你既可以用商用API模型,也可以用开源模型本地部署,对RAG链路本身没有影响。链路是同一套,替换的只是生成引擎。

3.3 第一个版本跑通后的验收清单

代码能跑通,不等于完成任务。我通常按下面五条做第一轮验收,全部过关再谈优化。

  • 抽样10到20个真实业务问题,确认正确答案对应的文档块能被召回。如果召不回,后面生成再好也没用。
  • 逐条看回答里有没有出现资料里完全没有的信息。哪怕是合理的补充,也算幻觉,要记下来。
  • 核对引用编号对应的资料内容,确认模型引用的段落确实支撑它的回答。
  • 记录一次查询的耗时。一般来说,向量检索在几十毫秒级别,大头在大模型生成上,如果检索本身超过200毫秒,要检查向量库配置。
  • 拿几个"资料里没有答案"的问题测试,看模型能不能老实说不知道。

这套验收清单的用意是:先确认链路的每个环节都诚实可靠,再追求回答的花哨程度。很多人一上来就追求回答漂亮,结果幻觉问题被漂亮的外表掩盖了,后面返工成本更高。

4. 检索调优的核心矛盾:召回质量与上下文窗口的平衡

4.1 无关片段挤占窗口,答案就开始漂移

RAG的一条铁律是:生成质量的上限由召回质量决定。哪怕生成模型再强,一旦检索回来的上下文里混入了无关内容,模型就可能在无关片段里"找灵感"。为什么?因为上下文窗口是个稀缺资源,模型对每个token的注意力是有限的。你把5个块拼进去,其中3个块跟问题无关,模型就要花一部分注意力在无关内容上,被带偏的概率大幅上升。

这也是为什么我特别强调检索,而不是生成。很多团队花大量时间调提示词,效果还是不理想,回头查才发现召回回来的块里一半是噪声。先把召回做干净,提示词只需要最简单的约束就够。

控制噪声有几个直接手段:调低TopK,比如从5降到3;提高相似度阈值,过滤掉明显不相关的块;或者做检索后处理,比如对召回结果再做一次轻量过滤。这些手段各有利弊,下一节细说。

4.2 阈值与TopK:宁可少给,不可乱给

关于相似度阈值,我先说一个常见误区:有人把阈值设得很高,比如0.8以上才算相关,结果很多语义表述不同但意思相同的问题被滤掉了,召回率骤降。向量相似度不是"文本相同度",它衡量的是语义距离,同一个意思完全可以用完全不同的词表达。阈值设太高,等于要求用户的问题必须跟你知识库的写法高度一致,这在真实场景里根本不现实。

更合理的做法是:先不看阈值,用TopK召回,然后人工检查一批结果,观察"不相关块"的相似度大概落在什么区间,再决定阈值。比如你发现正常相关的块相似度普遍在0.55以上,低于0.45的基本都不相关,那阈值可以设在0.45到0.5之间。这个值不是算出来的,是你自己的数据测出来的。

TopK的调法我前面提过,这里补充一种实用技巧:先设一个偏大的K,比如10,保证不漏;然后增加一个"截断"逻辑,只有相似度超过阈值的块才进入上下文,否则宁可少用几块。这套组合拳兼顾了召回率和精确率,实现起来也很简单。

4.3 提示词里的"证据边界"怎么写才算数

提示词设计在RAG里不是单纯的指令工程,它是上下文与模型之间的"契约"。我的核心原则是:让模型意识到它没有权限使用资料以外的知识。除了前面提到的三条约束,还有几个细节值得打磨。

一是明确"不知道"的处理方式。光说"不知道请说不知道"不够,最好给它一个固定句式,比如"资料中未找到相关信息",这样程序可以识别这个句式做后续处理,比如转人工或触发补充检索。二是来源编号的格式要固定。要求模型用带方括号的数字标注引用来源,并且声明"每个引用编号必须来自上面的资料列表"。这句话能显著减少编造引用的情况。三是当用户问题涉及多轮对话时,要把历史对话也拼进上下文,并让模型在检索时考虑指代关系——用户说"那它的价格呢","它"指代什么,需要提前做指代消解,否则检索词就是错的。

很多RAG失败的案例,问题不在模型,而在提示词没有把"证据边界"讲清楚。模型不是故意编,是你根本没告诉它边界在哪。

5. 真实项目里的四个大坑:切分灾难、同义改写、引用造假和索引漂移

5.1 表格和扫描件的切分灾难

做企业知识库,最头疼的不是长文本,而是表格和扫描件。一份产品参数表,按字符切分之后,表头和数据行被切到不同的块里,检索的时候只召回到数据行,模型根本不知道那些数字代表什么字段,自然答非所问。扫描件更麻烦,本质是图片,直接切分得到的是乱码。

我的处理经验是:表格在切分前先做结构还原,转成Markdown表格或键值对列表,最好是按"一个完整表格区域"作为一个块,特殊情况下可以把一个表格拆成几个"表头+若干行"的小块,保证每块里都有表头。扫描件先走OCR识别,再做版面还原,把标题、段落、表格区分出来,最后才进入切分流程。这一步看起来很繁琐,但它决定了你的知识库能覆盖什么类型的内容,跳过它,表格类问题基本没救。

5.2 同义改写与口语化查询导致召回失败

有一个经典场景:知识库里写的是"设备故障申报流程",用户问的是"东西坏了怎么报修"。两句话语义接近,但表述差异大,向量检索有时能召回到,有时召不回来,表现不稳定。为什么?因为嵌入模型对"同义不同形"的支持有限,尤其是一些短查询,向量距离很容易被拉远。

解决方案有两个方向。一是查询改写:在检索前让模型把用户问题改写得更规范,比如补全指代、扩写同义词、转化为更书面化的表达。二是索引侧扩充:在切块时给每个块生成几个"别名块"或关键词标签,比如同一个流程块,额外存一份"报修、故障、维修"作为扩展检索词。实际项目中,查询改写是最常用也最有效的,因为它不需要改动索引,成本低、见效快。

多轮对话里的指代问题也属于这一类。用户上一句提了"A系统",下一句说"它的权限怎么配置",如果不做指代消解,检索词就变成了"它的权限怎么配置",召回效果可想而知。简单的做法是把最近两轮对话拼接成检索query,复杂一点可以调模型做query rewrite。

5.3 引用造假:检索对了,生成却还是编

这个坑最隐蔽。你检查召回结果,正确答案明明在Top1,但模型给出的回答却引用了错误的来源,甚至引用了根本不存在的段落。这种情况的本质是模型没有严格遵守"只用资料里的信息"的约束,它在生成时还是习惯性地从自己的参数里调用知识。

应对手段有几层。第一层在提示词里强化约束,比如"回答中的每个事实必须能在上面的资料[编号]中找到对应表述"。第二层是做生成后的校验,写一个小的检查函数,把模型标注的来源编号与实际召回的块对应起来,如果引用了超出范围的编号,就重新生成一次。第三层是更严格的方案:要求模型先抽取出支撑性句子,再基于这些句子组织回答,把"推理"和"表述"分开。这个方案实现成本高一点,但对幻觉的压制最有效。

我个人的建议是,第一层和第二层先做,覆盖绝大部分场景;如果领域对准确性要求极高,比如医疗、金融类,再上第三层。

5.4 索引漂移与脏数据:知识库更新引发的连锁问题

知识库不是建完就完事的,它是活的数据。产品文档更新了、旧的流程废弃了、同一份资料在不同目录里出现了新老两个版本,这些情况都会污染索引。最典型的翻车现场是:文档更新后只增量加入了新块,老块继续留在索引里,用户搜出来的还是旧答案,你以为RAG解决了知识过时,结果它给你来一个"知识混合过时"。

我的建议是建立一套索引生命周期管理机制:每个块不仅存文本和向量,还要存文档版本号、入库时间、来源路径。更新时按文档维度整体处理——先删除该文档的所有旧块,再插入新块,从根源上避免新旧版本共存。删除操作很容易被忽略,很多向量库的删除是逻辑删除,索引文件不会立刻变小,定期做一次compact(压缩)操作,保持检索性能稳定。

还有一个容易被忽略的细节:文档去重。同一份资料被上传了三次,嵌入模型对完全相同的文本生成的向量是一样的,检索时三条记录会同时出现在结果里,白白浪费上下文窗口。所以入库前做一次内容哈希去重,是个低成本高收益的习惯。

6. 从能用到好用:混合检索、重排序与评估闭环

6.1 混合检索:关键词与向量各救各的命

纯向量检索有个天然短板:对专有名词、编号、型号这类"精确匹配"不敏感。用户问"型号ABC-200的功率是多少",向量检索可能召回语义相近但型号对不上的块;而传统的关键词检索BM25,在精确匹配上反而很稳。所以进阶方案几乎都会走向混合检索:向量检索负责语义相似,BM25负责词面匹配,两路结果融合排序。

融合排序最简单有效的方法是加权合并(RRF,Reciprocal Rank Fusion)。它对每路结果按排名倒数打分,比如某条记录在向量路排第2,在关键词路排第5,那它的融合分就是1/2加1/5。RRF的好处是不需要统一两路的相似度分数,因为向量分数和BM25分数根本不是同一个量纲。实现起来也就十几行代码,却能把召回质量明显往上拉一截。

6.2 重排序:粗召回交给快模型,精排交给强模型

混合检索把候选集从Top5扩到了Top20甚至Top50,召回率上去了,但把这些候选全部塞进上下文不现实。这时候需要重排序:先用轻量级方式做粗召回,再从候选集里用更强的排序模型做精排,挑出真正的Top5。

重排序模型通常叫cross-encoder(交叉编码器):它不是把文本和查询分别编码成向量再算距离,而是把查询和候选块拼在一起,输入给模型做相关性打分。这种方式精度更高,但计算量也大,所以只能对粗召回的小批量结果用。重排序的收益非常大,尤其在知识库内容高度相似、区分度低的场景下,没有重排序,模型很容易被检索噪声带偏。

6.3 评估闭环:没有指标就没有优化

最后说一个很多人不爱做、但必须做的事:评估。RAG的效果评估不是靠"感觉回答变好了",而是要靠一套可复用的指标和测试集。最少要盯三个指标:上下文召回率,即正确答案对应的文档块有没有出现在召回结果里;答案忠实度,即回答里的信息能不能在参考文档里找到依据;答案相关性,即回答是否切题、是否完整覆盖用户问题。

做法是抽50到100个真实问题,人工标注标准答案和对应的支持文档块,作为基准测试集。每次改动切分、检索或提示词,就在这个测试集上跑一遍,对比指标变化。评估的方式可以有两种:规则匹配,比如检查回答里是否包含标准答案的关键信息;或者让一个更强的模型当裁判,对回答打分。让模型当裁判省人力,但要警惕裁判模型本身的偏好偏差,最好的做法是抽样做人工复核。

有了这个闭环,调优就不是玄学了。你会发现,大部分时候效果提升最大的动作,不是换更强的生成模型,而是调整切分策略和检索融合方式——这跟很多人直觉相反,但在我做过的大小项目里反复被验证。

最后再分享一点个人心得:我做RAG项目的习惯是,头一个版本永远用最朴素的方案——定长切分、开源向量模型、TopK检索、简单提示词。先跑通,再拿测试集量一遍,然后才谈混合检索和重排序。很多人一上来就堆各种高级组件,最后说不清到底是哪个组件起了作用,出了问题也不知道从哪里排查。RAG说到底是个系统工程,每一个环节都会影响最终效果,但正因如此,你才更需要在每个环节都有可量化的判断依据。把基础链路做扎实,比追逐任何花哨的方案都管用。

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

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

立即咨询