1. 为什么“裸奔”的大模型总在胡说八道
很多人第一次用大模型做知识问答,都会经历一个相似的失望时刻:你问它一个公司内部文档里写得清清楚楚的问题,它却一本正经地编出一个完全错误的答案,语气还特别自信。这不是模型“坏”,而是它的工作方式决定的——它本质上是在做“下一个词的概率预测”,而不是在查数据库。它脑子里的知识来自训练时见过的海量文本,一旦你问的东西超出了它的训练范围,或者涉及私有资料、最新资料,它就只能靠“脑补”来填空,这就是所谓的幻觉。
我最早接触这个问题的场景,是帮一个团队做一个内部规章制度的问答助手。文档加起来几十万字,直接塞进提示词根本不现实,就算塞得下,成本和响应速度也扛不住。更麻烦的是,模型对文档里的具体条款记不准,经常把两条不相干的规定混在一起。后来我们换了个思路:不让模型“记住”这些知识,而是让它在回答之前先去“查资料”,把查到的原文片段作为参考再作答。这个思路就是RAG(Retrieval-Augmented Generation,检索增强生成)。
RAG 这个词听起来很学术,但拆开看特别朴素:检索(Retrieval)+ 增强(Augmented)+ 生成(Generation)。翻译成人话就是:先去你的知识库里把相关内容找出来,再把这些内容连同用户的问题一起交给大模型,让它基于这些“证据”来回答。这样一来,模型不需要记住所有知识,它只需要具备“阅读理解”和“组织语言”的能力,事实准确性由检索到的原文来兜底。
这篇文章我想讲清楚三件事:RAG 到底是怎么运转的、一条完整的链路包含哪七个关键环节、以及怎么用最小的代码量把它跑起来。适合两类人看:一类是刚听说 RAG、想搞明白它到底解决什么问题的新手;另一类是已经动手做过、但效果不稳定、想搞清楚每个环节该怎么调的老手。我不会只给你一堆概念,而是把每个环节背后的“为什么”讲透,再配上可以直接抄的代码和踩坑经验。
提示:RAG 不是万能药。它能解决“知识不在模型里”和“知识更新太快”这两类问题,但解决不了“问题本身有歧义”“检索库质量差”这类上游问题。理解它的边界,比盲目上马更重要。
2. 把RAG拆开看:一条链路上的七个关键环节
在动手写代码之前,我强烈建议先把整条链路在脑子里过一遍。很多人一上来就调库、拼提示词,结果出了问题根本不知道是哪一步坏了。RAG 的链路其实很像一个图书馆的借阅流程:你带着问题进馆(用户提问),管理员理解你要什么(查询处理),去书架找相关的书(检索),把最相关的几本挑出来(重排),翻到具体页码摘抄段落(上下文组装),最后基于摘抄内容给你讲明白(生成),讲完还要检查有没有讲错(评估与迭代)。
2.1 环节一:文档加载与清洗
这是最容易被轻视、却最影响最终效果的一步。你的原始资料可能是 PDF、Word、网页、数据库导出,格式五花八门。加载的目标是把它们统一成纯文本。但这里有个大坑:PDF 里的表格、页眉页脚、双栏排版,直接抽取出来往往是乱的。我见过一个案例,一份双栏排版的说明书被抽成了左右栏交错,语义完全错乱,后面检索再准也没用,因为原文本身就是垃圾。
清洗的核心原则是:保留语义完整的段落,去掉噪声。页眉页脚、页码、水印文字、重复的免责声明,这些都要清掉。表格如果结构重要,最好单独处理成结构化文本,而不是硬塞进段落里。
2.2 环节二:文本分块(Chunking)
为什么要分块?因为大模型的上下文窗口有限,而且检索的粒度太粗会导致“找回来一大段,但只有一句有用”。分块就是把长文档切成一段段适合检索和喂给模型的小块。这里有几个关键参数:块大小(chunk size)和块重叠(chunk overlap)。
块太小,语义不完整,检索出来断章取义;块太大,噪声多,还会挤占上下文窗口。我的经验是,中文场景下块大小在 300 到 800 字之间比较稳妥,重叠 50 到 100 字。重叠的作用是防止一个完整的语义被硬生生切断——比如一句话正好跨在两个块的边界上,有重叠就能保证至少有一个块包含完整句子。
2.3 环节三:向量化(Embedding)
分好块之后,要把每个块转成一串数字,也就是向量。这个向量的妙处在于:语义相近的文本,向量距离也近。这样用户提问时,我们把问题也转成向量,然后去向量库里找“距离最近”的几个块,就完成了语义检索。
这里要理解一个反直觉的点:向量检索找的是“语义相似”,不是“关键词匹配”。你问“怎么退货”,它可能召回“商品退回流程”,即使字面上没有“退货”两个字。这是它的优势,也是它的坑——如果 embedding 模型对某个领域不擅长,语义相似度就会失真。
2.4 环节四:向量存储与索引
向量算出来得有地方存。小规模场景(几千个块以内)用内存里的列表加暴力计算就够了;规模上去之后,就需要专门的向量数据库,比如 FAISS、Milvus、Chroma 这类。它们用近似最近邻(ANN)算法,牺牲一点点精度换取巨大的速度提升。
选型时别盲目追新。我个人的判断标准很简单:数据量在十万级以下、单机部署,FAISS 或 Chroma 足够;要分布式、要动态增删、要多租户,再考虑更重的方案。
2.5 环节五:检索与重排
检索分两步走效果最好:先用向量检索快速召回一批候选(比如 top 20),再用重排模型(Reranker)对这批候选精排,挑出最相关的 top 3 到 5 个。为什么需要重排?因为向量检索是“粗筛”,它把问题和文档块分别编码,丢失了细粒度的交互信息;而重排模型会把问题和每个候选块放在一起做交叉编码,判断更准,但速度慢,所以只适合对少量候选做精排。
2.6 环节六:上下文组装与生成
把重排后的块拼成一段上下文,连同用户问题一起塞进提示词,交给大模型生成答案。提示词里要明确要求:只依据提供的资料回答,资料里没有就说不知道。这一句约束能大幅降低幻觉。
2.7 环节七:评估与迭代
上线不是终点。你需要一套评估机制,看检索召回率、答案准确率、幻觉率。没有评估的 RAG 就是盲人摸象,你永远不知道改了一个参数是变好还是变坏。
下面这张表把七个环节和它们的核心作用、常见坑整理在一起,方便你对照排查:
| 环节 | 核心作用 | 常见坑 |
|---|---|---|
| 文档加载清洗 | 统一格式、去噪 | PDF 表格错乱、页眉页脚混入 |
| 文本分块 | 切成语义单元 | 块太大噪声多、块太小断章取义 |
| 向量化 | 语义转数字 | 模型不匹配领域、维度选择不当 |
| 向量存储 | 高效检索 | 选型过重、索引未更新 |
| 检索重排 | 精准召回 | 只做粗筛、top k 设置不合理 |
| 上下文生成 | 基于证据作答 | 提示词约束不足、上下文超长 |
| 评估迭代 | 持续优化 | 没有评估集、凭感觉调参 |
3. 最小可用代码:从零跑通一条RAG链路
光讲原理容易飘,我们直接上手写一版最小可用的 RAG。为了让你在任何环境都能跑,我尽量用最少的依赖。核心思路是:用本地内存做向量库,用简单的余弦相似度做检索,把整条链路串起来。等你跑通了,再替换成生产级组件就是水到渠成的事。
3.1 环境准备与依赖选择
最小依赖只需要三样:一个 embedding 模型、一个向量计算库、一个大模型接口。为了不绑定具体厂商,我用伪代码风格展示接口调用,你替换成自己手头的服务即可。
# 依赖:numpy 用于向量计算 # embedding 和 LLM 用你手头的服务替换 import numpy as np # 假设这是你的 embedding 接口,输入文本列表,返回向量列表 def get_embeddings(texts): # 实际调用你的 embedding 服务 # 返回 shape 为 (len(texts), dim) 的数组 pass # 假设这是你的大模型接口,输入提示词,返回回答 def call_llm(prompt): # 实际调用你的 LLM 服务 pass这里有个选型心得:embedding 模型和 LLM 最好来自同一技术栈或至少语义空间兼容,否则检索到的内容和模型理解之间会有偏差。另外,embedding 模型的维度不是越高越好,768 或 1024 维在多数中文场景已经够用,维度太高反而增加存储和计算成本。
3.2 文档分块与向量化的具体实现
分块我推荐按“段落优先、长度兜底”的策略:先按换行切段落,如果某段太长再按句子切,保证每块语义相对完整。
def split_text(text, chunk_size=500, overlap=80): """按段落切分,超长段落再按长度切,带重叠""" paragraphs = [p.strip() for p in text.split("\n") if p.strip()] chunks = [] buffer = "" for para in paragraphs: if len(buffer) + len(para) <= chunk_size: buffer += para + "\n" else: if buffer: chunks.append(buffer.strip()) # 超长段落单独切 if len(para) > chunk_size: start = 0 while start < len(para): chunks.append(para[start:start + chunk_size]) start += chunk_size - overlap buffer = "" else: buffer = para + "\n" if buffer: chunks.append(buffer.strip()) return chunks这段代码里overlap的作用前面讲过,是防止语义被切断。实测下来,中文文本 overlap 设成 chunk_size 的 10% 到 15% 比较合适。太小起不到保护作用,太大则重复内容多、浪费存储。
向量化就是把每个 chunk 过一遍 embedding 接口:
def build_index(text): chunks = split_text(text) vectors = get_embeddings(chunks) vectors = np.array(vectors) # 归一化,方便用点积算余弦相似度 norms = np.linalg.norm(vectors, axis=1, keepdims=True) vectors = vectors / (norms + 1e-8) return chunks, vectors归一化这一步很多人会漏。归一化之后,余弦相似度就等价于点积,计算更快,也避免了向量长度对相似度的干扰。
3.3 检索、重排与提示词拼装
检索就是拿问题向量和所有块向量做点积,取 top k:
def retrieve(query, chunks, vectors, top_k=5): q_vec = np.array(get_embeddings([query])[0]) q_vec = q_vec / (np.linalg.norm(q_vec) + 1e-8) scores = vectors @ q_vec idx = np.argsort(scores)[::-1][:top_k] return [(chunks[i], scores[i]) for i in idx]如果要做重排,就在这一步之后加一个交叉编码模型,对 top 20 精排取 top 5。最小版本可以先不做重排,等效果不满意再加。
提示词拼装是决定答案质量的关键:
def build_prompt(query, contexts): context_text = "\n\n".join([c for c, _ in contexts]) prompt = f"""请严格依据以下资料回答问题。如果资料中没有相关信息,请直接回答"资料中未提及",不要编造。 资料: {context_text} 问题:{query} 回答:""" return prompt那句“资料中未提及,不要编造”是灵魂。我做过对比测试,加上这句约束后,模型在资料缺失时的幻觉率能下降一大截。另外,把资料放在问题前面、问题放在最后,通常比反过来效果更好,因为模型对末尾内容的注意力更强。
3.4 完整调用与实测效果
把上面串起来:
def rag_query(query, chunks, vectors): contexts = retrieve(query, chunks, vectors, top_k=5) prompt = build_prompt(query, contexts) answer = call_llm(prompt) return answer, contexts跑通之后你会发现,最小版本已经能解决大部分“知识不在模型里”的问题。但它也有明显短板:检索只靠向量,遇到专有名词、编号、代码这类需要精确匹配的内容容易翻车。这时候就要引入混合检索——向量检索加关键词检索(比如 BM25),两路结果融合。这是从“能用”到“好用”的关键一步。
4. 效果不稳?先别急着换模型,查这几个地方
RAG 最让人抓狂的地方是:它有时候答得特别好,有时候又离谱得不行。很多人第一反应是“模型不行,换个更大的”,但根据我的经验,八成问题出在检索环节,而不是生成环节。下面按排查优先级从高到低讲。
4.1 检索召回不准的三种典型表现
第一种是答非所问:检索回来的块和问题主题不沾边。这通常是 embedding 模型和你的领域不匹配。比如你用通用中文 embedding 去检索法律条文,专业术语的语义区分度不够。解决办法是换一个在你领域数据上微调过的 embedding 模型,或者补充关键词检索兜底。
第二种是漏召回:明明库里有正确答案,就是没检索出来。常见原因是分块把关键信息切碎了,或者 top k 设得太小。我一般会把 top k 设大一点(比如 20),再用重排收敛到 5,宁可多召回也别漏。
第三种是召回了但没用上:检索结果里有正确内容,但模型没采纳。这往往是上下文太长、正确内容被淹没,或者提示词约束不够。可以试试把最相关的块放在上下文最前面或最后面。
4.2 分块策略对最终答案的隐性影响
分块是隐形的杀手。我踩过一个坑:一份产品手册按固定 500 字切块,结果一个完整的操作步骤被切成了两半,前半段在块 A,后半段在块 B。用户问这个步骤,检索只召回了块 A,模型就只答了前半段,看起来像“答了一半”。后来改成按标题层级切块,保证每个操作步骤完整落在一个块里,问题就消失了。
所以分块不能只按长度,要结合文档结构。有标题的按标题切,有列表的按列表项切,代码块单独成块。这个投入在前期,回报在后期。
4.3 提示词里那句“不知道就说不知道”有多重要
我再强调一次这句约束的价值。大模型的默认行为是“尽量给出一个答案”,哪怕它没把握。你不明确禁止它编造,它就会编。除了“资料中未提及就说不知道”,还可以加一句“回答时请引用资料中的原句作为依据”,这样既降低幻觉,又方便你事后核对。
有个细节:约束要放在提示词里显眼的位置,最好在资料之前和问题之后各强调一次。实测下来,双重强调比单次强调的约束力更强。
5. 从能跑到好用:几个提升RAG质量的实战技巧
最小版本跑通只是起点。真正让 RAG 在生产环境稳定输出,还需要一些进阶手段。这些手段不是花架子,每一个都对应着具体的痛点。
5.1 混合检索:向量加关键词的双保险
纯向量检索对语义敏感,但对精确匹配不敏感。用户问“型号 X200 的保修期”,向量检索可能召回一堆讲保修政策的块,却没召回专门讲 X200 的那块。这时候关键词检索(BM25)就能精准命中“X200”。把两路结果用加权融合(比如各占 0.5)或者倒数排名融合(RRF),效果通常比单路好。
RRF 的公式很简单:每个文档的得分等于它在各路结果中排名的倒数之和。它不需要调权重,鲁棒性好,我一般优先用它。
5.2 查询改写:让用户的问题更好被检索
用户的提问往往口语化、有指代、有省略。比如“它多少钱”里的“它”指什么,检索系统不知道。查询改写就是用大模型把用户问题改写成几个更完整、更适合检索的查询。常见做法是生成多个改写版本,分别检索后合并结果。这一步能明显提升召回率,代价是多花一点模型调用成本。
5.3 上下文压缩:把有限的窗口留给最有用的内容
检索回来的块里,往往只有一两句是真正相关的。全塞进去既浪费窗口又引入噪声。上下文压缩就是用模型或规则,把每个块里与问题无关的部分删掉,只保留关键句。这样同样的窗口能塞更多有效信息,答案质量也跟着提升。
5.4 评估集:没有它你就是在盲调
最后说评估。我建议你从真实用户问题里挑 50 到 100 条,人工标注每条的标准答案和应该召回的文档块。然后每次改动都跑一遍,看召回率和答案准确率的变化。没有这个评估集,你调参数全靠感觉,今天觉得好了,明天换个问题又崩了,根本不知道是进步还是退步。
评估指标我一般看三个:召回率(该找的找到了没)、答案准确率(答对了没)、幻觉率(编造了没)。三个指标一起看,才能全面判断。
6. 我在实际项目里踩过的那些坑
说几个具体的、文档里不会写的教训。
第一个坑是embedding 模型换了但索引没重建。有次我换了个更好的 embedding 模型,检索效果反而变差了,排查半天才发现向量库里的向量还是旧模型算的,新旧向量不在一个语义空间,相似度计算完全错乱。换 embedding 模型必须重建整个索引,这是铁律。
第二个坑是块重叠设太大导致重复召回。overlap 设成 200 字之后,相邻块内容高度重复,检索 top 5 里有三块是同一段内容的不同切片,真正有用的其他内容反而被挤掉了。重叠不是越大越好,10% 到 15% 足够。
第三个坑是忽略了大模型自身的上下文长度限制。检索 top 10 的块拼起来超过了模型窗口,结果被截断,关键信息正好在被截断的部分。后来我加了 token 计数,动态控制塞入的块数量,保证不超限。
第四个坑是用错了相似度度量。有人用欧氏距离去比较未归一化的向量,结果长度长的向量天然占优,检索结果偏向长文本。统一归一化后用余弦或点积,才是正确姿势。
这些坑的共同点是:它们都不在“RAG 是什么”的科普里,只有真正动手做过、被坑过的人才知道。希望你看完能少走几步弯路。
最后分享一个我常用的小技巧:在检索结果里保留每个块的来源标识(比如文件名加段落号),生成答案时让模型标注引用来源。这样用户能自己核对,你排查问题时也能快速定位是哪个块出了问题。这个习惯一旦养成,调试效率会高很多。