如果最近你在捣鼓大模型应用,身边一定有人在聊 RAG。它全称是 Retrieval-Augmented Generation,检索增强生成,解决的问题一句话就能说清:让大模型开口回答之前,先去你的资料库里查一圈,再基于查到的内容说话。这篇就是写给想动手搭第一个 RAG 系统的人。我会按照自己做一遍的顺序,从为什么需要它、系统怎么拆、关键环节怎么选,到最小可运行版本怎么落地,再到实际运行常见的坑,一次性讲清楚。无论你是做后端、做数据、还是产品想了解原理,这篇都能帮你少走几晚弯路。
1. 为什么先做 RAG,而不是微调模型
1.1 一句话说清 RAG 解决的问题
大多数把 RAG 搬进生产环境的团队,最初遇到的场景都很朴素:公司内部有一堆手册、合同、工单、产品文档,领导说“你们做个客服机器人,要能根据我们自己的资料回答问题”。
直接让大模型硬答,问题很明显。模型训练时根本没见过这些内部资料,你问它“咱们这个 API 的限流策略是什么”,它只能凭类似经验编一个。编出来的东西往往格式工整、语气自信,但数字和信息全是错的。这就是大模型幻觉最典型的来源。
RAG 的思路是把“知识”和“说话能力”拆开。知识存在外部资料库里,说话能力由大模型提供。每次用户提问,系统先去资料库检索最相关的几段内容,再把“资料原文 + 用户问题”一起塞给大模型,让它看着资料回答。这样回答有出处、可追溯,知识更新时不用重新训练模型,改索引就行。
一句话概括:RAG 是给模型外挂了一个可随时增删改查的动态知识库。
1.2 RAG 和微调不是互斥关系
很多人第一次了解 RAG 时会反问:那我直接微调模型,把它训练成本公司专家不行吗?行,但要分场景。
微调适合改变模型的表达风格、输出格式、固定办事流程。比如要求模型每次都以 JSON 结构化输出,或者严格模仿某种客服口吻,这类“行为层”的问题,微调更合适。但它不适合承载频繁变动的业务知识。假设你每周更新一次价目表,每次更新都微调一遍,成本和时间都扛不住。
RAG 适合知识密度高、变动频繁、需要引用的场景。资料库更新只需要重新写入新的文档片段,模型本身不用动。代价是每次回答都要多一步检索,耗时更长,还要维护一套向量索引和查询链路。
在实际项目里,两者经常组合使用。先用微调让模型学会“怎么说话”和“按什么格式输出”,再用 RAG 决定“说什么内容”。对第一次搭 RAG 系统的人来说,不要一上来就把微调也加进来,否则问题排查时变量太多,定位不到根因。
1.3 第一个系统不追求指标
我见过不少朋友搭第一个 RAG 系统,目标定得特别大:要支持十万份文档、要回答准确率 95%、要支持流式输出、要能引用多级来源。结果搭了三天还没跑通。
第一个 RAG 系统的目标应该收敛成一句话:给定一个小型资料目录,系统能根据用户提问,返回一段有依据、不像在胡扯的回答。哪怕只有几百个片段,哪怕回答偶尔不完美,只要整条链路能通,就已经赢了一大半。
后续优化指标、扩展规模,都是在这个最小闭环上做加法。先保证“数据能进、检索能出、模型能用”,再谈准确率。
2. 动手前先把整条管线画明白
2.1 RAG 系统由哪几部分组成
RAG 表面上就是“检索 + 生成”,但拆开看至少有六段:文档加载、文本清洗、分块、向量化、向量存储、检索与生成。
文档加载负责把 PDF、Word、HTML、Markdown 等格式读成纯文本。这步看起来简单,实际坑最多。PDF 可能是扫描件,Word 里可能有图片和表格,HTML 里混着大量导航和广告文本。第一个系统可以先只用 Markdown 和纯文本文件,避开格式解析的大坑。
文本清洗是把加载出来的噪声去掉。表格转为缩进排列、多余空行删掉、页眉页脚剔除。很多人忽略这步,导致向量库里有大量“联系电话:xxx”和“第 3 页共 12 页”之类的碎片,检索时容易浑水摸鱼。
分块是决定检索上限的关键动作,后面单独讲。向量化是把每块文本变成一串数字。向量存储负责把大量向量组织成可快速检索的索引。检索与生成是问答接口侧的逻辑:把用户问题变成向量、在索引里找最相似片段、拼成提示词交给大模型。
用图书管理员类比:资料库像藏书、向量索引像目录卡片、大模型像前台客服。客服不能背下整座图书馆,但能根据目录迅速翻到相关几页,再照着念给你听。
2.2 技术选型的三条取舍线
选型不需要一步到位。第一个系统建议遵循一个原则:能本地跑的就不上分布式,能先用简单方案就不急着引入重组件。
第一条取舍线是向量数据库。轻量级方案把索引存在本地文件或内存里,适合百万级以下向量,部署简单;重量级方案适合团队协作和数据量持续增长,但运维成本和复杂度明显更高。第一个系统用轻量级就可以,后面数据量上来再迁移。
第二条取舍线是向量化模型。有通用嵌入模型,也有针对中文语料优化过的模型。如果你的资料全是中文,优先选中文表现更好的模型;如果是中英混杂,再考虑通用模型。不要只看榜单分数,拿自己业务里的几段文字实际试一下检索相关性,比任何评测数字都直观。
第三条取舍线是大模型。商业化模型 API 效果稳定、省事,但数据会出公网,对隐私敏感场景不友好;本地部署开源模型保密性好,但需要 GPU 资源,效果也通常弱一些。第一个系统如果只是为了验证流程,用哪个都行,关键是先跑通。
2.3 提前准备一组验收问题
搭系统之前,先准备几组测试问题。这一步被很多人跳过,但我觉得这是最值钱的前置工作。
从资料目录里挑几个有明显答案的问题,比如“退货政策是什么”“超时时间是多少秒”“鉴权有哪几种方式”。再准备几个资料里完全没有的伪问题,比如“我们公司什么时候上市”(假设资料里没有)。
验收标准也要简单一点:资料里有的问题,系统能不能答出关键信息;资料里没有的问题,系统能不能老老实实说“资料中未找到”,而不是硬编一个答案。这两条达到了,最小闭环就算成功。这套测试问题集后面还能复用,改参数、换模型时,用同一组问题对比效果,比拍脑袋判断靠谱得多。
3. 决定 RAG 效果好坏的四个关键环节
3.1 Embedding 模型怎么选
Embedding 模型负责把文本变成语义向量,它直接决定了“检索这一步能找到什么”。如果模型没理解“退款手续费”和“退款扣款”是同一个意思,那检索环节的分再高,后面生成也是无米下锅。
选型时看三个点。第一,是否覆盖你的语言。中文资料多,就选中文语料训练充分的模型,通用英文模型在中文语义上的表现往往比较飘。第二,向量维度。维度高通常表达能力更强,但存储和计算开销也大。第一次做不要追求高维大模型,中等规模的嵌入模型足够验证流程。第三,近期有没有更新。嵌入模型也在迭代,新模型在长文本和细粒度语义上普遍更好。
我实际用下来的经验是:不要直接信某一个模型的公开评测,拿自己的五六个典型问题去测。把资料库里对应的正确片段混进几十个不相关片段里,看排序后到底排在第几位。这个动作半小时就能做完,但能帮你避开很多无效调参。
另外特别注意:查询端的文本和资料端的文本最好用同一个嵌入模型做向量化。如果查询用模型 A、资料用模型 B,向量空间不对齐,检索结果会非常随机,而且这种 bug 肉眼很难发现。
3.2 分块策略是第一个深水区
分块就是把长文档切成适合检索的小段。这步看起来像体力活,实际是决定 RAG 效果最关键的环节之一。
切太大会有两个问题。一是块里包含太多无关信息,检索时看起来命中了,但回答时信息被噪声稀释;二是超出模型上下文窗口,需要截断。切太小则容易把一句话的逻辑拦腰截断,比如把“如果不满足条件,则不退款”切成“如果不满足条件”和“则不退款”两段,单独检索哪段都缺上下文。
比较稳妥的起步参数是:块大小 500 到 800 字,重叠 50 到 100 字。重叠的意思是相邻两个块共享一部分文本,用来保住边界处的语义连贯性。这个参数不是拍脑袋定的,要和嵌入模型的能力匹配。模型一般能理解 300 到 500 字左右的完整语义,太长它会“记不住”重点。
除了按字数切,还要尽量保留文档结构。如果能把每个块和它所属的标题、章节绑定,检索时就能带上更丰富的语义。比如“售后政策”下面切出来的块,即使块里没写“售后”两个字,标题信息也能帮它被正确召回。
3.3 检索参数:Top-K 和相似度阈值
检索阶段常见的参数就两个:返回多少个候选片段,以及相似度最低多少才认为是相关片段。
Top-K 太小,可能把一个正确信息漏掉;太大,会把一堆不太相关的内容塞进上下文,大模型容易分不清重点。第一次做先取 3 到 5 就行。如果资料片段的粒度小,可以适当增加到 8;如果单块已经很长,3 个就够。
相似度阈值是用来挡住明显不相关内容的。比如用户问“今天天气怎么样”,资料库里全是产品手册,理论上不应该返回任何片段。但向量检索永远会返回“最相似”的 Top-K,哪怕相似度只有 0.2。这时候没有阈值过滤,模型就会被垃圾上下文带偏。
阈值的具体数字和嵌入模型强相关,没有通用值。有的模型相似度普遍在 0.7 到 0.9,有的普遍在 0.3 到 0.5。我的建议是,先不设阈值,跑一批问题,统计命中的相似度区间,然后取一个“能覆盖大多数正确结果、又能挡住明显跑题”的值。
3.4 生成环节的提示词决定下限
检索做得再好,提示词写不对,回答照样会崩。生成环节的任务是告诉大模型:把什么当依据、按什么格式说话、遇到资料外的问题怎么办。
我的提示词模板一般包含四部分:角色、资料正文、用户问题、边界约束。角色告诉模型“你是客服助手,只能根据给定资料回答”。资料正文要放在问题前面,并且明确说“以下是资料”。边界约束最关键,明确要求“如果资料中没有相关信息,请直接回答未找到,不要自行推断”。
模板大概是这样的:
你是一名严谨的资料问答助手。请根据下面提供的资料回答用户问题。 如果资料中没有任何相关内容,请直接回复“资料中未找到”,不要编造。 资料: {context} 问题: {question}这里有个容易被忽略的点:资料里如果包含多条候选片段,要用明显的分隔符隔开,并标记来源编号。比如“资料片段 1:”“资料片段 2:”。大模型对并列文本的注意力分配有限,不加分隔的话,可能只盯着前面或最后一段看。
上下文里的不相关内容也要控制。给模型的上下文越精炼,它的注意力越集中在有效信息上。很多人习惯把 Top-K 检索到的所有片段全部塞进去,结果模型被无关片段干扰,回答了错误内容。
4. 实操:从零搭一个最小可运行版本
4.1 先确定自己的技术边界
开始写代码之前,先确认自己手上有哪些零件。你需要三个基础能力:读文档的代码、把文本变成向量的模型接口、调用大模型生成文本的接口。
这三个零件都有很多成熟方案,不同方案代码略有差异,但整体逻辑完全一致。下面的骨架代码用占位函数表示每个环节,你可以按需替换成你自己环境里对应的接口。我建议第一次做的时候,资料先用少量 Markdown 或纯文本文件,放在一个目录里,能省掉大半解析问题。
还要想清楚数据量级。第一个系统我强烈建议控制在一百段以内,跑通为主。数据量一大,分块策略和索引参数都会影响效果,排查时很难判断是配置问题还是数据问题。
4.2 文档加载与清洗:先给文本洗澡
加载文档就是把目录里所有文件读成字符串。如果你用的是 Markdown 或纯文本,这一步非常简单。如果将来要支持 PDF,需要额外处理扫描件 OCR 和版式解析,那是一个独立的大话题。
清洗阶段至少要做三件事:删除空行和连续空白、去除明显的页眉页脚、把表格手工转换为“字段:值”的文本形式。表格如果直接按原始排版切进去,很容易变成一串没有语义的字符碎片。
下面是一段示意代码:
def load_and_clean(file_path): with open(file_path, "r", encoding="utf-8") as f: text = f.read() # 去掉页眉页脚等噪声 text = remove_header_footer(text) # 多行空白压缩成一行 text = re.sub(r"\n\s*\n+", "\n", text) return text.strip()清洗完成后,最好每处理一个文件就打印一段抽样文本出来人工检查。这一步不花多少时间,但能避免下游所有环节被格式噪声带偏。
4.3 分块:把长文档切成可检索的片段
分块函数要同时保存“文本”和“来源信息”。来源信息至少包含文档名和块序号,以后回答带引用时全靠它。
按字数切是最容易上手的方案。实现时注意两个细节:一是尽量不在单词或标点中间硬切,二是利用重叠保证边界语义连续。你可以先按句子或段落拆,再把过长的段落继续切成小块。
一段示意代码:
def split_into_chunks(documents, chunk_size=600, overlap=50): chunks = [] for doc in documents: text = doc["text"] start = 0 idx = 0 while start < len(text): end = min(start + chunk_size, len(text)) chunks.append({ "text": text[start:end], "source": doc["source"], "chunk_id": idx, }) idx += 1 if end == len(text): break start = end - overlap return chunks如果你发现固定字数切出来的块经常截断表格或代码,就改成“按小标题先分段,再对超过上限的段继续切”。我自己的经验是,技术文档按标题层级切,效果普遍优于纯按字数切。
4.4 向量化与入库:把文本变成索引
向量化就是把每一块文本送给嵌入模型,拿回一个数字数组。调用时有两个实操要点:批量调用而不是逐条调用,以及处理限速。
批量调用能大幅缩短索引时间。但嵌入接口通常有单次请求的文本条数限制,超出会报错,所以要用循环分批。限速问题在文档量大时尤其明显,需要加指数退避重试。
入库时除了向量本身,还要保存原始文本和 metadata。很多嵌入向量库只要求你传向量和 ID,但如果你不把原文一起存进去,检索命中后还得重新根据 ID 回源查文档,多一道维护负担。正确的做法是将“向量、原文、来源文档名、块序号”一并存入库中。
示意代码:
vectors = [] metas = [] for chunk in chunks: vec = embedding_model.encode(chunk["text"]) vectors.append(vec) metas.append({ "text": chunk["text"], "source": chunk["source"], "chunk_id": chunk["chunk_id"], }) vector_store.add(vectors, metas)入库完成后,写一个简单查询函数验证:拿一个查询文本做向量化,再调检索接口,看返回片段是不是你预期的那几段。如果这一步就不对,先回去检查清洗和分块,别急着调大模型。
4.5 在线问答:把检索和生成串起来
在线问答流程是:问题进来,向量化;向量库检索 Top-K;把片段拼成上下文;连同问题一起发给大模型;拿到回答后附带返回来源信息。
这一段是整个系统的核心入口。先看完整骨架:
def answer_question(question, top_k=5, similarity_threshold=0.0): q_vec = embedding_model.encode(question) hits = vector_store.search(q_vec, top_k=top_k) valid_hits = [h for h in hits if h.score >= similarity_threshold] if not valid_hits: return "资料中未找到相关内容。", [] context = "\n\n".join( f"资料片段 {i + 1}:\n{h.metadata['text']}" for i, h in enumerate(valid_hits) ) prompt = f"""你是一名严谨的资料问答助手。 请仅根据下面资料回答用户问题。如果资料中没有相关内容, 请直接回复“资料中未找到”,不要编造。 {context} 问题:{question} """ answer = llm_generate(prompt) sources = [(h.metadata["source"], h.metadata["chunk_id"]) for h in valid_hits] return answer, sources这里两个参数很容易被忽略。相似度阈值初始可以设很低甚至为 0,先跑一批问题收集分数分布,再慢慢提高。Top-K 建议从 5 开始,看回答质量再调整。还有,返回来源信息这一步从第一天就要做,否则后面任何回答错了,你都很难定位是检索问题还是生成问题。
4.6 三个测试问题验证闭环
系统搭完,用前面说的验收问题跑一遍。我建议从这三个角度各选一题:简单事实题、资料缺失题、同义改写题。
简单事实题用来验证基本链路,比如“退货需要满足什么条件”。资料缺失题用来检验提示词约束,比如“我们没有收录的价格表”。同义改写题最容易被忽略,比如资料里写“退款”,问题里说“退钱”,看系统能不能给到正确片段。
跑通之后不要急着美化。先看两个地方:检索返回的片段是否真的相关,模型生成的回答是否基于片段。如果片段相关但回答偏了,改提示词;如果片段本身就不相关,改嵌入、分块或 Top-K。系统化排查,比碰运气调参数高效得多。
5. 常见问题与排查技巧实录
5.1 召回结果像是随机抽取,相关性很差
这是第一个 RAG 系统最高频的问题。优先怀疑三件事:嵌入模型和查询端是否一致、分块策略是否把语义切碎、文档清洗是否带着大量噪声。
排查步骤可以固定成一套:取一个典型问题,单独看检索返回的前 5 段,肉眼判断相关性。如果前 5 段里连一段都不沾边,问题是检索链路;如果前 5 段里相关片段排在第 3 位以后,问题通常是相似度计算或阈值;如果前 5 段都很相关但回答不好,问题在生成环节。
还有一个容易踩的坑:资料目录里混入了大量重复的导航文案,比如每页都有相同的“欢迎联系我们”,导致检索结果被这些重复碎片霸榜。清洗阶段把它们去掉,症状立刻缓解。
5.2 资料里明明有答案,但系统说找不到
最常见的原因是查询表述和资料原文差异太大。用户问“怎么退钱”,资料写的是“退款申请流程”。嵌入模型虽然对语义有一定理解,但差异过大时依然会失手。
对应的常规做法是混合检索。在向量检索之外,再加一层关键词匹配,然后把两种结果合并排序。比如用户问题里的“退钱”“申请”“流程”这些词直接在文本里匹配,能把文档里的相关段落找出来,然后与向量相似度得分加权融合。
另外检查一下分块大小。如果某些块只有一两个短句,语义信息不足,嵌入模型很难给出有效向量。我会建议把短段落相邻的几段合并成一个稍大的块,效果往往立竿见影。
5.3 检索片段相关,大模型却没有按资料回答
这是生成阶段的典型问题。先看提示词是否明确约束了“只能根据资料回答”。很多模型默认会自行补充常识性知识,提示词里不写清楚,它就会放飞。
再检查上下文长度。如果 Top-K 取到 5,每段 800 字,上下文就有 4000 字,其中真正相关的可能只有一段。大模型看到太长的不相关内容时,注意力会被稀释。调低 Top-K,或者先把检索片段用重排序器重新排一遍,只留最相关两三段给模型。
我的一个固定习惯是:提示词末尾加一句“回答中不要出现资料中没有的信息”。这句话简单直接,能明显减少幻觉输出。
5.4 向量库内存和索引体积涨得飞快
向量库的体积和嵌入维度、数据量强相关。假设一亿参数级别的嵌入模型输出 768 维向量,一万段文本算下来存储量不小,内存里做全量检索会越来越吃力。
第一个系统不急着换分布式方案。先做两件事:一是定期清理向量库中的重复片段和旧版本内容;二是把向量索引持久化到磁盘,每次启动从磁盘加载,而不是在内存里重算。
如果数据继续增长到几十万段以上,再考虑把索引升级为支持分片和服务化的方案。到那一步之前,轻量方案完全够用,换架构的成本远大于收益。
5.5 中文场景的几个特有坑
中文文本没有天然空格分词,清洗和分块时很容易把一句话切在语义边界外。按字符数硬切时,最好在句子结束符“。!?”之后再尝试找边界,而不是固定字符位置生切。
中文标点也是坑。英文嵌入模型可能在处理全角标点时表现不稳定,所以尽量让文本统一使用全角标点,不要混入半角逗号。这个细节看起来小,实际检索时影响不小。
表格类中文内容建议单独处理。把表格按行读出来,拼成“第一列标题:对应值”的格式。否则表格被切碎后,每一块只有孤立的数字和字段名,谁也没法理解它在说什么。
6. 扩展路线与个人踩坑体会
6.1 后续可以从三个方向做加法
第一个系统跑通后,我会建议按顺序考虑三个扩展方向:加重排序、加引用溯源、加知识更新机制。
重排序是性价比最高的优化。先用轻量方法粗召回 20 到 30 个候选片段,再交给更强的排序模型重新精排,最后只用前 3 个片段生成。它能把“相关但排位靠后”的正确答案捞起来,面对大规模资料库时效果尤其明显。
引用溯源是让 RAG 变成可信系统的关键。每个答案后面附带引用来源,用户能直接跳转到原文。实现并不复杂,只要在入库时把来源文档名、页码、块序号作为 metadata 保存,回答时拼到输出里就行。
知识更新则是从“能用”走向“好用”的分水岭。你需要定义文档的版本、过期时间、替换规则。否则同一个问题在不同时间可能检索到互相矛盾的旧版内容,第一个系统还发现不了,等到真实用户来用,问题就会爆发。
6.2 我实际动手后的几点体感
如果你只记住一件事,我建议记住这个:把索引、检索、生成三段链路分别记日志。第一次跑系统时,问题几乎一定不会出在你预期的地方。有了日志,你能清楚看到“片段是否召回正确”“模型是否用了正确上下文”,五分钟定位问题;没有日志,你可能调一个晚上都不知道在调什么。
分块参数不要迷信固定值。500 字是常见起点,但你的资料是说明书、合同还是技术博客,最优块大小差异很大。我自己最开始用统一参数处理混合文档,效果惨不忍睹,后来按文档类型分开切分,明显改善。
最后说一点心态层面的体会:RAG 看着简单,但每个环节都值得较真。你从“跑通 demo”到“回答稳定可用”之间,隔着大量细节打磨。这不是坏事。正因为它可以把问题拆成独立环节,你才能一步步定位、优化、验证,最终搭出一套真正靠谱的知识问答系统。希望这篇经验能让你少踩几个我已经替你踩过的坑。