RAG系统七步链路实战:从文档加载到答案生成的完整拆解
RAG 这个词这两年被聊得太多多到有点变味。有人把它当成万能药觉得只要把文档塞进向量库、接上大模型问答系统就自动变聪明也有人踩了一堆坑之后逢人就说 RAG 没用检索出来的全是噪音。我自己前后搭过七八套不同规模的 RAG 系统从个人知识库到几十万条文档的客服问答都做过最大的体会是RAG 的成败几乎不在模型上而在那条从原始文档到最终答案的链路上每一环都在悄悄决定结果质量。这篇就把我实际在用的七步链路完整拆一遍从原理讲到最小可用代码尽量让刚接触的人能照着跑起来也让已经踩过坑的人找到自己那一步到底错在哪。1. 先搞清楚 RAG 到底在解决什么问题1.1 大模型的两个硬伤大模型有两个绕不过去的毛病。第一是知识有截止日期训练数据停在某个时间点之后发生的事情它一概不知。第二是它不知道你的私有数据你公司内部的文档、你个人的笔记、某个垂直领域的专业资料它训练时压根没见过。你直接问它它要么说不知道要么更糟——一本正经地编一个看起来很像那么回事的答案这就是幻觉。微调是很多人第一反应想到的方案但微调有几个现实问题。成本高要准备大量标注数据要租算力训练一轮下来时间和钱都烧掉不少。更麻烦的是更新困难你今天新增了一份文档想让它进模型就得重新训练或者至少再跑一轮增量。而且微调本质上是把知识压进参数里压进去之后你很难知道它到底记住了哪条、忘了哪条可解释性很差。RAG 走的是另一条路。它不改模型参数而是在模型回答之前先去一个外部知识库里把相关资料检索出来连同问题一起塞给模型让模型看着资料答题。这就像开卷考试和闭卷考试的区别——闭卷考的是你脑子里记了多少开卷考的是你会不会查、会不会用查到的资料。RAG 把大模型从记忆者变成了阅读理解者而阅读理解恰恰是它最擅长的事情。1.2 检索增强的核心直觉核心直觉其实很朴素与其让模型记住所有知识不如在需要的时候把相关知识递给它。这个思路带来几个直接好处。知识更新只需要更新知识库不用动模型今天加文档今天就能生效。答案可以附带引用来源用户能点开原文核对可信度大幅提升。成本也低得多一套向量库加一个推理接口就能跑起来不需要 GPU 训练集群。但这里有个容易被忽略的前提检索必须准。如果检索环节捞回来一堆不相关的片段模型再强也会被带偏它会认真地基于错误资料编出一个错误答案而且看起来特别有说服力。所以 RAG 系统的质量上限其实是由检索质量决定的。这也是为什么后面七步链路里一大半的功夫都花在怎么把对的资料捞出来上。1.3 什么场景适合上 RAG不是所有场景都值得上 RAG。我一般用几个问题来判断知识是不是经常变如果一年都不更新一次那可能直接写进提示词或者微调更省事。知识量是不是超过了模型上下文能塞下的量如果总共就几千字直接全塞进去就行没必要搞检索。答案是不是需要精确引用来源如果是法律、医疗、金融这类要求可溯源的场景RAG 的引用能力就是刚需。用户的问题是不是集中在某个垂直领域领域越垂直通用模型越容易答错RAG 的价值越大。反过来如果任务是纯创作、纯推理、纯代码生成跟外部知识关系不大那 RAG 帮不上什么忙硬上只会增加系统复杂度和延迟。我见过有人给一个写诗的应用硬套 RAG检索一堆古诗塞进去结果模型被带得只会模仿反而丢了原本的创造力。工具要用对地方。2. 七步链路全景拆解2.1 链路总览与数据流向我把一套完整的 RAG 拆成七步文档加载、文本切分、向量化、向量存储、查询检索、重排序、答案生成。前四步属于离线索引阶段文档进来之后处理一次存进向量库后三步属于在线查询阶段每次用户提问都跑一遍。这两条线是分开的理解这一点很重要因为很多性能问题都出在把离线该做的事放到了在线做。数据流向大致是这样原始文档经过加载器变成纯文本切分成一个个小块chunk每块通过嵌入模型转成一个向量存进向量数据库并建立索引。用户提问时问题同样被转成向量去向量库里找最相似的若干块这些块经过重排序精选出最相关的几条最后和问题一起拼成提示词交给大模型生成答案。2.2 为什么是这七步而不是别的这七步不是我拍脑袋定的而是每一环都对应一个必须解决的问题。加载解决文档格式五花八门切分解决文本太长塞不进模型也检索不准向量化解决怎么用数学表示语义存储解决几十万条向量怎么快速找最近邻检索解决给定问题找候选重排序解决候选里挑最准的生成解决把资料变成人话。少任何一步都会出问题。跳过切分直接把整篇文档向量化检索粒度太粗捞回来一大段但只有一句有用。跳过重排序向量检索的精度往往不够尤其是候选多的时候。跳过加载直接手动复制粘贴文本文档一多就崩溃。这七步是一个最小完备集每一步都有它不可替代的位置。2.3 离线与在线阶段的职责划分离线阶段的核心目标是把知识整理成可检索的形态它追求的是索引质量和覆盖度慢一点没关系反正只跑一次或者定期跑。在线阶段的核心目标是又快又准地找到并生成答案它追求的是延迟和精度用户等不了几秒。我踩过的一个典型坑是把嵌入计算放在了在线阶段。早期图省事用户提问时现算文档向量结果每次查询都要把整个知识库重新嵌入一遍慢到没法用。正确做法是文档向量离线算好存起来在线只算问题向量一次嵌入调用就够了。这个分工一定要在架构设计时就定清楚。3. 第一步到第三步文档加载、切分与向量化3.1 文档加载的格式适配加载这一步看着简单实际最烦。你的文档可能是 PDF、Word、Markdown、HTML、Excel甚至是一堆聊天记录。不同格式要用不同的加载器。PDF 尤其麻烦扫描版的需要 OCR带复杂表格的解析出来经常错位双栏排版的可能把左右栏文字交错读进来。我的经验是PDF 尽量找文字版而不是扫描版表格单独抽出来处理别指望一个加载器搞定所有格式。加载完还要做清洗。去掉页眉页脚、去掉重复的空行、统一全角半角、处理乱码。这些脏数据如果不清理会直接污染后面的向量让检索结果莫名其妙。我一般会写一个清洗函数把连续多个换行压成一个去掉纯符号行去掉过短的碎片行。import re def clean_text(text): text re.sub(r\n{3,}, \n\n, text) # 压缩多余空行 text re.sub(r[ \t]{2,}, , text) # 压缩多余空格 lines [ln.strip() for ln in text.split(\n)] lines [ln for ln in lines if len(ln) 2] # 去掉过短碎片 return \n.join(lines)3.2 文本切分的策略选择切分是 RAG 里最被低估的一步。切得太碎每块信息不完整检索出来答非所问切得太大一块里混了好几个主题向量表示被平均掉检索精度下降。常见的切分策略有几种固定长度切分、按标点/段落切分、递归切分、按语义切分。固定长度最简单按字符数硬切但容易把一句话从中间切断。递归切分是我最常用的它按优先级依次尝试用段落、句子、逗号来切尽量在自然边界断开。语义切分最理想用模型判断句子之间的语义相似度相似度骤降的地方就是切分点但计算成本高一般只在质量要求极高的场景用。关于块大小没有万能值。我一般从 500 到 800 字符起步配合 10% 到 20% 的重叠。重叠是为了防止关键信息正好落在切分边界上被切断让相邻块共享一部分内容。中文的话字符数可以适当调小因为中文信息密度比英文高。切分策略优点缺点适用场景固定长度实现简单、速度快易切断语义结构松散的纯文本递归切分兼顾边界与效率参数需调优通用场景首选语义切分边界最自然计算成本高高质量问答按文档结构保留层级信息依赖文档规范Markdown、HTML3.3 向量化的模型选择与维度权衡向量化就是把文本变成一串数字让语义相近的文本在向量空间里距离也相近。选嵌入模型主要看几个维度语言支持、向量维度、最大输入长度、推理速度、是否本地可跑。中文场景一定要选对中文友好的模型很多英文模型在中文上表现会明显掉档。向量维度不是越高越好。高维度理论上能表达更多信息但存储和检索成本都上去了而且到了一定维度之后边际收益递减。常见的维度在 384 到 1536 之间。如果知识库不大、延迟要求高选个中等维度的模型就够了。另外要注意模型的 max tokens如果块切得比模型能接受的长度还长超出的部分会被截断等于白切。# 以本地可跑的嵌入模型为例实际选型按语言和场景定 from sentence_transformers import SentenceTransformer model SentenceTransformer(paraphrase-multilingual-MiniLM-L12-v2) chunks [第一段文本, 第二段文本] vectors model.encode(chunks, normalize_embeddingsTrue) print(vectors.shape) # (2, 384)注意嵌入模型一旦选定索引阶段和查询阶段必须用同一个模型。中途换模型会导致新旧向量不在同一空间检索直接失效必须全量重建索引。4. 第四步到第五步向量存储与查询检索4.1 向量库的选型考量向量库的选择取决于数据规模和部署条件。数据量小、想快速验证用内存版的 FAISS 或者直接 numpy 算余弦相似度就行几百条数据完全够用。数据量上万、需要持久化和并发就上专业的向量数据库。选型时我主要看几点是否支持元数据过滤、是否支持增量更新、索引类型是否可调、部署是否轻量。元数据过滤这个能力特别重要很多人一开始忽略。比如你只想在某个部门的文档里检索或者只查某个时间段的内容如果没有元数据过滤就只能全库检索再手动筛效率极低。所以入库时一定要把来源、时间、分类这些信息作为元数据一起存进去。4.2 相似度度量与索引类型相似度度量常见的有余弦相似度、点积、欧氏距离。文本检索里余弦相似度用得最多因为它只看方向不看长度对文本长度不敏感。如果嵌入时做了归一化余弦相似度和点积是等价的计算更快。索引类型决定了检索速度和精度的平衡。暴力检索Flat精度最高但最慢适合小数据量。倒排文件索引IVF通过聚类加速牺牲一点精度换速度。HNSW 是基于图的索引查询快、精度高是目前主流选择代价是内存占用大一些。我的建议是数据量小于十万用 Flat 或 HNSW超过百万再考虑 IVF 加量化。4.3 查询改写与多路召回用户的问题往往和文档里的表述对不上。用户问怎么退钱文档里写的是退款流程字面不一样但语义相近向量检索能处理一部分但不够。查询改写就是让模型先把用户问题改写成更适合检索的形式比如扩展同义词、拆解成多个子问题、补全省略的上下文。多路召回是另一个提升召回率的技巧。同时用向量检索和关键词检索BM25两路结果合并去重。向量检索擅长语义匹配关键词检索擅长精确匹配专有名词和数字两者互补。我做过对比混合召回比单用向量召回在包含大量专有名词的场景下召回率能提升一截。import numpy as np def cosine_search(query_vec, doc_vecs, top_k5): # 向量已归一化点积即余弦相似度 scores doc_vecs query_vec idx np.argsort(scores)[::-1][:top_k] return idx, scores[idx]提示查询改写会增加一次模型调用带来额外延迟。如果对延迟敏感可以只对短问题或包含代词的问题做改写长问题直接检索。5. 第六步到第七步重排序与答案生成5.1 重排序为什么必要向量检索是粗筛它把问题和文档分别压成一个向量再比距离这个过程中细节信息丢失了不少。所以粗筛出来的 top 20 里真正最相关的可能排在第 8 位。重排序是精筛它用一个交叉编码器cross-encoder把问题和每个候选文档拼在一起送进模型让模型逐对打分。这种方式精度高得多但计算量大所以只对粗筛出来的少量候选做。流程是这样向量检索先召回 20 到 50 条候选重排序模型对这几十条逐一打分重新排序后取前 3 到 5 条送给大模型。这个两阶段设计是精度和效率的经典平衡。我实测下来加了重排序之后最终答案的准确率提升非常明显尤其是候选里存在语义相近但主题不同的干扰项时。5.2 上下文组装与提示词设计检索出来的片段不能直接一股脑塞给模型要组装成结构清晰的上下文。我一般给每个片段编号标注来源然后明确告诉模型只根据以下资料回答资料里没有的信息就说不知道。这个约束非常重要不加的话模型很容易自由发挥把检索资料和自己的记忆混在一起。提示词里还要处理一个常见问题检索到的资料可能互相矛盾。这时候要么在提示词里说明如资料冲突请指出要么在重排序阶段就把低质量来源过滤掉。另外资料顺序也有讲究把最相关的放在最前面和最后面模型对首尾位置的注意力更强这是有研究支持的。def build_prompt(question, contexts): ctx \n\n.join( f[资料{i1}] {c} for i, c in enumerate(contexts) ) return f仅根据以下资料回答问题资料中没有的信息请回答资料中未提及。 {ctx} 问题{question} 回答5.3 引用溯源与幻觉抑制引用溯源是 RAG 相对微调的一大优势。生成答案时让模型标注每条信息来自哪个资料编号用户就能点开核对。实现上可以在提示词里要求模型输出引用标记也可以在生成后做一次对齐把答案里的句子和资料做匹配。抑制幻觉还有几个实用技巧。一是降低生成温度让输出更保守。二是要求模型在资料不足时明确说不知道而不是硬编。三是做答案校验生成完再用一次模型调用检查答案是否都能在资料里找到依据。这些手段叠加起来能把幻觉率压到很低。6. 最小可用代码从零跑通一条链路6.1 环境准备与依赖下面这套代码是我用来快速验证的最小实现不依赖任何外部向量数据库服务全部本地跑适合学习和原型验证。依赖就几个嵌入模型、numpy 做向量计算、一个能调用的生成模型接口。pip install sentence-transformers numpy生成模型部分我用一个抽象接口表示你可以接任何兼容的推理服务本地部署的或者云端的都行只要能把提示词发过去拿回文本就可以。6.2 完整代码实现import numpy as np from sentence_transformers import SentenceTransformer class MiniRAG: def __init__(self, embed_model_name): self.embedder SentenceTransformer(embed_model_name) self.chunks [] self.vectors None def split(self, text, size500, overlap80): chunks, start [], 0 while start len(text): end start size chunks.append(text[start:end]) start end - overlap return chunks def index(self, documents): for doc in documents: self.chunks.extend(self.split(doc)) self.vectors self.embedder.encode( self.chunks, normalize_embeddingsTrue ) def retrieve(self, query, top_k5): qv self.embedder.encode([query], normalize_embeddingsTrue)[0] scores self.vectors qv idx np.argsort(scores)[::-1][:top_k] return [(self.chunks[i], float(scores[i])) for i in idx] def answer(self, query, llm_call, top_k5): hits self.retrieve(query, top_k) contexts [h[0] for h in hits] prompt self.build_prompt(query, contexts) return llm_call(prompt), hits staticmethod def build_prompt(question, contexts): ctx \n\n.join( f[资料{i1}] {c} for i, c in enumerate(contexts) ) return ( 仅根据以下资料回答问题资料中没有的信息请回答 \资料中未提及\。\n\n f{ctx}\n\n问题{question}\n回答 )6.3 跑通与验证用几段测试文档跑一遍看看检索出来的片段是不是真的相关生成的答案是不是真的基于资料。验证的时候我习惯做两件事一是故意问一个资料里没有的问题看模型会不会老实说不知道二是问一个资料里有明确答案的问题看它答得对不对、引用对不对。docs [ 本产品的退款流程为用户在购买后七天内可申请无理由退款 需在订单页面提交申请审核通过后三个工作日内原路退回。, 本产品的保修期为一年自购买之日起计算 保修范围内非人为损坏可免费维修。, ] rag MiniRAG(paraphrase-multilingual-MiniLM-L12-v2) rag.index(docs) def fake_llm(prompt): return 此处为模型返回的答案实际接入推理服务 ans, hits rag.answer(怎么申请退款, fake_llm) for text, score in hits: print(round(score, 3), text[:40])跑通之后你会发现检索出来的第一条大概率就是退款那段说明链路是通的。接下来就是在这个骨架上逐步加东西加元数据过滤、加重排序、加查询改写、换更好的嵌入模型。7. 常见问题与排查技巧实录7.1 检索不准的排查顺序检索不准是最常见的问题排查要有顺序别一上来就换模型。先看切分块是不是太大或太小边界是不是切断了关键信息。再看嵌入模型是不是跟语言不匹配是不是 max tokens 太小导致截断。然后看查询用户问题是不是太短太模糊需不需要改写。最后才看相似度度量和索引参数。我遇到过的检索问题里一半以上是切分没做好换模型反而解决不了。7.2 答案跑偏与幻觉的处理答案跑偏通常是检索和生成两个环节的问题叠加。先确认检索出来的资料是不是真的相关如果不相关问题在检索如果相关但答案还是错问题在提示词或模型。提示词里一定要有仅根据资料回答的硬约束温度调低必要时加答案校验。还有一种情况是资料本身有错那检索再准也没用得从数据源头治理。7.3 性能与成本优化性能优化主要抓两点离线该做的别放线上能缓存的别重复算。嵌入结果缓存起来相同问题直接命中缓存。重排序只对少量候选做别对全库做。如果延迟还是高考虑减小 top_k、换更小的嵌入模型、用更快的索引类型。成本上嵌入调用和生成调用是大头嵌入可以本地跑省掉 API 费用生成调用可以通过精简上下文长度来降本。问题现象可能原因排查方向检索结果不相关切分不当/模型不匹配先查切分再查嵌入模型答案编造提示词约束弱/温度高加硬约束降温度响应慢在线算嵌入/候选过多离线索引减小 top_k召回不全单路检索/查询模糊加混合召回做查询改写更新不生效索引未重建检查增量更新逻辑7.4 我踩过的几个真实坑第一个坑是嵌入模型换了没重建索引检索结果全乱排查了半天才发现是新旧向量不在同一空间。第二个坑是块切得太大一块里塞了三四个主题检索出来答非所问后来把块调小、加重叠才好转。第三个坑是没做元数据过滤用户问某个产品的退款结果把另一个产品的退款政策也捞出来了答案自相矛盾。第四个坑是提示词没加仅根据资料的约束模型自由发挥把训练时的旧知识混了进来。这些坑的共同点是都不是模型能力问题而是链路设计问题。RAG 的功夫在链路不在模型这句话我越做越有体会。把七步链路每一步都做扎实比换一个更强的生成模型带来的提升大得多。最后分享一个我常用的小技巧给检索结果加一个相似度阈值低于阈值的直接丢弃宁可让模型说不知道也别让它基于垃圾资料硬答。这个阈值需要根据你的数据和嵌入模型调一般从 0.3 到 0.5 之间试起观察一段时间再定。这个简单的过滤能挡掉相当一部分低质量回答。