说实话,最开始看到“闭卷考试”这个说法的时候,我还觉得只是大家调侃大模型的一个梗。直到有一次,我拿自己写的一份私有技术文档去问当时最火的几个大模型,回答得那叫一个自信——连版本号都能给你编出来,但跟文档里的真值差了十万八千里。那一刻我意识到,这事不能只当段子看。
大模型的“闭卷考试”翻车,翻的不是智商,而是机制。它脑子里的知识,是训练时压缩进去的“旧闻摘要”,不是可以随手翻开查阅的数据库。想让它答好自己知识范围之外的问题,唯一靠谱的思路,就是让它“开卷答题”——这也正是RAG(检索增强生成)真正被大规模应用起来的原因。
这篇文章我不会从概念PPT讲起,而是直接带你走一遍:大模型为什么闭卷必挂、RAG开卷的原理是什么、怎么从零搭一个能用能测的RAG系统,以及我在实操里踩过的那些坑。不管是刚入门的开发者,还是想在公司内部快速落地知识问答的你,照着这篇的链路走,至少能少走两三个星期弯路。
1. 大模型“闭卷考砸”的真相:幻觉不是bug,是机制
很多人第一次遇到大模型一本正经地胡说八道,第一反应是“这模型是不是坏了”。其实没有坏,它只是在一个本质上不适合“准确回忆细节”的机制里,被逼着给一个概率最大的回答。
1.1 模型脑子里的“知识”到底是什么
如果你把大模型理解成一个压缩过的知识库,虽然是简化,但方向是对的。训练阶段,它看过海量文本,然后把其中的统计规律、词与词之间的关联模式,都压缩进了神经网络的上百亿个参数里。
问题在于,这种压缩是有损的。它记住的不是“XX产品V3版本最大并发是5000”这种精确记录,而是一种“大概齐”的语义倾向。你问它“写一段SQL的思路”,它能给你写得有模有样,因为这类通用模式在训练数据里反复出现,规律性强,压缩起来不费劲。但如果你问的是某个内部系统的字段含义、某份合同条款的确切表述、或者昨天刚上线的新品参数,它就完全没有可靠来源可依据了。
这时候模型会做什么?它不会老老实实说“我不知道”,因为它没有“自我觉察”的能力。它只会继续顺着语言模式往后“编”,把看起来合理的词串起来。这就是幻觉的直接来源。我把闭卷考试的场景化描述为——它脑子里装着一本几周前印刷的百科词典,还是缩印版,你问它词典里写得清清楚楚的细节,它只能凭印象给个差不多。
1.2 什么时候你必须放弃“闭卷”,转向“开卷”
不是所有问题都需要RAG。你问“怎么写一个Python装饰器”,模型闭卷答得比你还标准,完全没有检索的必要。但当问题满足下面三个特征里的任意一个,闭卷模式就撑不住了:
- 私有知识:知识不在公开语料里,只在你们公司的文档、代码库、数据库里。模型压根没看过,闭卷就只能编。
- 实时信息:训练截止时间是硬的,之后发生的事情模型一概不知。你问它昨天发布的产品价格,它只能给你一个“参考价”,而且不会主动告诉你这只是编的。
- 长尾细节:公开语料里虽然有,但太过冷门,训练压缩时根本没保留足够权重。比如某份早期协议里的例外条款、某个小众开源项目的配置项。
我见过很多团队非要用“闭卷”硬扛这些场景,结果就是做一个看起来有问必答的ChatBot,实际上就是一台高级瞎话生成器。内部测试时大家讲个笑话就过了,真上了生产环境,一线业务人员拿着它答错的结果去处理客户问题,那后果就完全不搞笑了。
所以,RAG的思路很朴素:既然模型背不住,那就让它现查。你给它配一本实时更新的“参考书”,它答题的时候先翻书,再照着书上的内容组织语言。这就是“开卷答题”。
2. RAG的“开卷”原理:一次查询的完整旅程
先别急着写代码,把下面这个流程在脑子里走一遍,之后每一行代码你都能看得懂在干嘛。
2.1 三步主链路:检索、增强、生成
一个最简RAG系统,回答一个问题只需要三步。
- 检索:把用户的问题转换成一种机器好比较的形式,去你那堆“参考资料”里捞出最相关的几段文本。
- 增强:把捞出来的文本连同原始问题一起,拼成一段结构化的提示词(Prompt),塞给大模型。相当于把参考书翻开的页码,直接摆到考生面前。
- 生成:大模型基于这段“参考资料+问题”来生成回答。它不再是凭记忆编,而是以你给定的材料为事实依据进行归纳和总结。
这三步看着简单,但每一步都有不少工程细节。尤其是第一步“检索”的质量,直接决定了整个系统是“开了个假卷”还是“真的翻到了那一页”。
2.2 Embedding的直觉:把文字变成可计算的坐标
检索的核心不是“关键词匹配”,而是“语义匹配”。你问“最近有没有什么省钱的办法”,文档里写“如何降低日常开销”,两者字面不一样,但意思接近。要机器理解这种接近程度,就得靠嵌入模型(Embedding)。
嵌入模型做的事,是把每段文本变成一个几百维甚至上千维的浮点数向量。这个向量是模型训练出来的语义坐标:语义越相近的文本,向量在多维空间里的距离越近。你跟用户的问题也各自转化成一个向量,然后用“余弦相似度”或者“点积相似度”来度量距离,取最相近的top-k个片段作为参考材料。
这一步我做个类比:你不再去图书馆的书架上按标题翻书,而是把每本书的内容都抽象成一张“气味卡”,读者进门之后递给检索员一张“气味卡”,检索员进库房把所有气味相近的书全部抱出来给你。这样即使书名完全对不上,也能检索到真正相关的章节。
2.3 向量检索之外,为什么还提到“混合检索”和“重排序”
实际做RAG,只用向量检索一种手段往往不够。原因很简单:向量模型对语义相近的段落召回效果好,但对精确的关键词匹配反而容易漏。比如某份配置文档里的参数名“max_retry_count”,你用向量检索可能召回一堆讲重试机制的泛泛内容,但恰恰没召回那条写满具体参数定义的记录。
所以生产环境里我通常建议做混合检索:向量检索找语义相近的,关键词检索(BM25)找字面精确匹配的,两者结果合并,再用一个重排序模型把合并结果重新打分排序。重排序模型更精细,能理解“用户要的是参数定义,不是重试理论”这种细粒度差别。
如果你还在起步阶段,可以先只做向量检索,把整条链路跑通。等发现“明明有相关内容,但答案就是差那么点意思”的时候,再上混合检索和重排序。后面我会专门讲这个优化节奏。
3. 从零搭一个能跑的RAG系统:实操链路与代码骨架
下面这部分是硬核实操。我以“给一份私有产品说明文档搭建问答助手”为场景,带你写一个mini的RAG流程。为了让你看见全貌又不会被无关依赖绑住,代码会用可替换的接口骨架,你换成自己熟悉的依赖就能跑。
3.1 第一步:文档准备与切分(Chunking)
很多新手直接拿整篇PDF丢进向量数据库,结果检索时召回的是“整个文档”,不仅塞爆上下文窗口,答案还稀碎。正确的做法是先切块。
为什么要切块?因为嵌入模型一次能编码的文本长度有限,而且太长的文本向量化后,语义会被稀释——一段5000字的文档,向量最后变成了一团“五味杂陈”的平均值,跟任何具体问题的相关性都会被摊平。反过来,切得太碎也不好,比如把一句话切成一块,上下文信息断裂,检索到了也读不懂。
我常用的配置是:每块256到512个token,相邻块之间重叠50个token左右。重叠是为了避免关键信息刚好被切刀的边界劈开。代码长得这样:
def split_text(text, chunk_size=500, overlap=50): """ 按字符数切块,保留overlap避免上下文断裂。 实际生产可以按章节、段落、句子做结构化切分,效果更好。 """ chunks = [] start = 0 while start < len(text): end = start + chunk_size chunks.append(text[start:end]) # 下一次起始位置要回溯overlap个字符 start = end - overlap return chunks如果你的源文档本身就是结构化的(有标题、有章节),强烈建议先解析出结构,再按“章节优先,字符兜底”的方式切分。这比纯按字符硬切强太多了。比如Markdown文档,可以先把二级标题拆出来作为大块,再在每个大块内部按段落切。这样切出来的每一块都有相对完整的逻辑边界,检索命中率会显著提升。
3.2 第二步:向量化与入库
文档切好之后,要分批送给嵌入模型转成向量,然后写进向量数据库。这个阶段的注意点有两个,一个是批处理大小别设太大,免得触发嵌入服务的限流或者内存溢出;另一个是原始文本必须跟着向量一起存,因为向量是给人看的坐标,用户最终要读的是原始段落,检索结果最终还是要用原始文本拼Prompt。
from embedding_client import get_embedding_model # 示意:替换为你的嵌入服务 from vector_store import VectorStore # 示意:替换为你的向量数据库客户端 def build_index(chunks, metadata_list, store): """ 把chunks批量向量化并写入向量数据库。 metadata里至少保留来源文档名、页码/章节号,方便溯源。 """ model = get_embedding_model() batch_size = 32 for i in range(0, len(chunks), batch_size): batch = chunks[i:i+batch_size] vectors = model.encode(batch) metas = metadata_list[i:i+batch_size] store.add(vectors=vectors, texts=batch, metadata=metas)入库完成之后,你就有了一本“语义索引字典”。这里有一个特别容易被忽略的点:向量数据库的索引类型和相似度算法要跟你选的嵌入模型匹配。有的嵌入模型训练时用余弦相似度,你入库时却选了内积,看起来差不多,但检索排序结果可能差很多。我建议统一用余弦相似度,兼容性最好。
3.3 第三步:查询端的检索与生成
用户输入问题之后,查询端的代码逻辑是这样的:
def retrieve(query: str, store: VectorStore, k: int = 4): """把问题向量化,检索最相关的k块文本。""" model = get_embedding_model() q_vec = model.encode([query])[0] hits = store.search(q_vec, top_k=k) # hits里每个元素包含text和metadata,按相关性从高到低排列 return hits def generate_with_rag(query: str, store: VectorStore, llm, k: int = 4): """检索增强生成主函数。""" hits = retrieve(query, store, k=k) context = "\n\n---\n\n".join( f"[来源: {h.metadata['source']}]\n{h.text}" for h in hits ) prompt = f"""你是一名技术问答助手。请严格依据下面提供的参考材料回答问题。 如果参考材料中没有足够信息,请直接说“根据当前资料无法回答”,不要自行编造。 参考材料: {context} 用户问题: {query} """ answer = llm.generate(prompt) return answer, hits这一步最核心的是Prompt的写法。你必须在Prompt里明确告诉模型三件事:一是“你的答案要以参考资料为准”,二是“资料不够时承认不知道”,三是在生成时尽量引用来源。我试过好几种Prompt风格,最省心的就是上面这种直白写法,既不绕弯,又能显著压低幻觉。
3.4 跑一次完整测试:从闭卷翻车到开卷正常
我用一份虚构的“某产品V3版本技术说明”做了个对照实验,问题问的是“V3版本支持的最大并发连接数是多少”。这个数据在文档里明确写了5000,但模型训练数据里根本没有这份私有文档。
| 测试方式 | 模型回答 | 是否正确 |
|---|---|---|
| 闭卷(不加检索,直接问) | 根据架构设计推测,最大并发连接数可能为1000到2000左右,建议进行压测验证。 | 错,且语气极其自信 |
| 开卷(接RAG链路) | 根据参考资料“某产品V3版本技术说明”第2.3节,V3版本最大并发连接数为5000,同时支持水平扩展。 | 对,且给出来源位置 |
这个对比我每次做分享都会拿出来用。它非常直观地说明了一件事:RAG的价值不是让模型变聪明,而是让模型的输出有据可依,从“猜一个看起来合理的答案”变成“归纳一段真实存在的资料”。
4. 实测中绝不省心的那些坑:检索质量与幻觉残留
跑通Demo只要半天,真正花时间的全在优化细节上。下面这几个坑,我基本都踩过一轮,直接给你避雷。
4.1 上下文塞得越多,回答越散
新手最容易犯的错,是top-k恨不得设成20,想把所有相关片段都塞给模型,让它“全面一点”。实际效果往往是灾难性的:上下文里混进来好几段相关性不高又不互斥的内容,模型最后只能东拼一句西凑一句,回答又长又没有重点。
我的经验是:针对大部分知识问答场景,top-k设为3到6之间最合适。检索质量高时甚至3就够,检索质量一般时最多到6。不要试图用上下文长度去弥补检索的不足,检索不到,就该先优化检索,而不是暴力堆料。
另外,塞进去的片段最好在Prompt里明确标注“优先级从高到低”,并告诉模型“优先采用排序靠前的资料”。这能有效避免模型把次要背景信息当成重点来答。
4.2 切片边界把答案劈成了两半
这是一个特别隐蔽的问题。我遇到过一次:用户问“导出功能支持哪些格式”,文档里其实写了“支持PDF、Word、Excel,其中Excel导出仅限企业版”。但我的切片策略刚好在这一句中间切开了,第一块只到“推荐使用PDF或Word”,第二块开头是“Excel导出仅限企业版”。
结果就是模型拿到两块碎片,第一块相关性打分还更高,它就直接答成“支持PDF和Word”,把Excel给漏了。这就是典型的切片边界问题。
解决办法有两层:第一层是切分时尽可能按语义边界(段落、列表项、表格行)来切,而不是纯字符长度硬切;第二层是在检索时把相邻几块文本一起召回,比如命中第7块时,把第6、8块也带上,给模型一个完整的局部上下文。这两个方案可以叠加用,效果很稳。
4.3 模型还是偶尔“自由发挥”:如何用带引用对抗幻觉
即使检索成功了,模型在生成时还是可能为了“把话说顺”而添油加醋。对抗这个问题的第一道防线是Prompt里强制加“引用来源”的要求,我一般这样约束:
- 每一句关键结论后面,都要用“(来源:文档名,章节号)”标注。
- 如果回答里出现了参考资料没有的信息,整段标记为“推测内容”,并在开头明说“以下为推测”。
这个做法的妙处在于:一旦模型被要求每条结论必须关联到具体来源,它就没有空间去编造作者、版本号、参数值这类具体事实了,因为编出来的来源对不上时,一眼就能看出来。
如果你连“带引用式生成”都压不住幻觉,那就要回到检索端找原因——大概率是召回的内容不够直接命中问题,模型只能靠推理来弥补信息缺口。这时候优化重点不是Prompt,而是嵌入模型、切片粒度、重排序这些检索链路环节。
4.4 别拿感觉当评估:给RAG系统做次“体检”
我见过很多团队在“感觉不错”的时候就宣布项目完工,结果换一批问题就问崩了。RAG系统必须有一份自己的评估基准。
我的做法是:为当前场景手工准备50到100个真实问题,每个问题标注预期答案和答案所在的源文档位置。然后分两个维度做评测:
- 检索命中率:正确答案所在的文档片段,是否出现在召回的top-k里。这个指标衡量的是“书有没有翻到正确页码”。
- 生成准确率:生成的最终答案里,关键事实是否与源文档完全一致。这个指标衡量的才是“答案答没答对”。
下表是我整理的一份简化评测记录:
| 评估维度 | 评测方法 | 判定标准 | 我的建议阈值 |
|---|---|---|---|
| 检索命中率 | 人工核对top-k片段里是否包含答案来源 | 包含即命中 | 不低于90% |
| 答案准确率 | 人工比对最终回答与源文档事实 | 关键事实一致 | 不低于95% |
| 顽固幻觉率 | 回答中出现源文档不存在的事实 | 每10题不超过1次 | 越低越好 |
这个评测集最好能沉淀下来,之后每次调整切分策略、换嵌入模型、改Prompt,都跑同一份评测集,用数字对比效果,而不是靠“感觉这次好了点”。我吃过这个亏——有一次优化后主观感觉变好了,跑评测才发现检索命中率反而掉了8个百分点,就是因为加了太激进的文本清洗规则,把关键参数行给删了。
5. RAG之外的选择:什么时候微调,什么时候混着用
写了这么多RAG的妙处,但我不想把它吹成万能药。在实际项目里,“给模型开卷”和“让模型背熟”从来不是二选一,而是看问题类型分工合作。
5.1 RAG比微调强在哪,又弱在哪
RAG的强项非常突出:一是知识更新零成本,文档变了,索引重新跑一遍就行,模型本身不用动;二是可溯源,回答的每句话都能指向某个来源,这对企业合规和信任建设非常重要;三是门槛低,不需要训练GPU资源,只要会调嵌入接口和模型接口就能上手。
但它也有明显的短板。开卷答题要求“考场里必须有书”,如果用户问的东西你的知识库里压根没有,RAG再强也无能为力。而且每次回答都带着一坨检索结果,延迟和token成本都比闭卷要高。
微调则刚好相反。它适合的场景是:模型的“表达风格”和“行为模式”需要固定,比如你要模型始终用某种行业黑话回复、严格按照某种JSON结构输出、或者在特定领域的语言习惯上要更像“自己人”。微调是把这些行为倾向刻进模型的参数里,它回答起来自然流畅,不需要每次带外部材料。
5.2 按职责分工的混合架构
在比较完整的系统里,我习惯这样分工:
- 事实型问题(参数、价格、流程、政策条款、代码报错信息)→ 交给RAG,答案必须有来源。
- 风格型问题(欢迎语、邮件写作、公文润色、固定格式报告)→ 交给微调过的模型,保持风格统一。
- 混合型问题(比如“按这份故障报告,帮我写一封给客户的安抚邮件”)→ 先用RAG把故障处置结论捞出来,再由微调过的模型组织成符合公司风格的对外文本。
这样一个系统里,RAG负责“肚子里有货”,微调负责“说话有范儿”,各司其职,效果比单押一边好很多。做技术选型时,你先问自己一个问题:这个场景的核心矛盾,是“知识不够”还是“表达不对”?前者找RAG,后者找微调,两个矛盾都在就两边都上。
另外一个成本提醒:RAG的工程复杂度并不低,知识库维护、质量评估、检索调优都是持续投入。如果只是给一百份固定文档做个内部问答,RAG是性价比之王;如果目标是训练一个懂你们行业黑话的对话助手,那先把微调提上日程更实际。
最后再分享一点个人体会。跑通一个RAG demo真的不难,难的永远是检索质量、评估集和持续迭代。我建议你第一版先用最朴素的流程手写一遍,把每个环节都摸透,再去看那些成熟的编排框架。自己亲手实现过一遍检索和增强的拼接逻辑之后,你会对“为什么框架要这么设计”“为什么这么配置”有完全不一样的理解。开卷答题这件事,本质上就是把知识的存取权交还给使用者,大模型不再是学习者留下的一个静态倒影,而是一个随时能查阅最新资料的研究员。这个思路接下来还能扩展成多路召回、Agent式多轮检索、结构化知识图谱增强等等,但万变不离其宗——先把“翻书”这一步翻好,后面一切都好说。