最近大模型的应用越来越普及,但大家很快发现一个尴尬的现实:模型再聪明,也只学到了某个截止时间之前的公开知识。你问最近几天发生的事、公司内部文档里的内容,它要么答不上来,要么一本正经地编答案。RAG(Retrieval-Augmented Generation,检索增强生成)就是为解决这个问题而生的主流方案——给大模型外挂一个可检索的知识库,让模型先“查资料”再“写答案”,而不是靠“闭卷硬编”。
这篇指南写给想把RAG落地成真实系统的程序员。我会从原理讲到代码,再讲到我实际踩过的坑和调优路径。不整虚的,都是可以直接抄作业的内容。无论你是第一次听说RAG,还是已经跑通了一个Demo但效果惨不忍睹,这篇文章都值得看完。
1. RAG到底是什么:不只是“检索 + 生成”的拼凑
1.1 一个开卷考试的生活类比
理解RAG最好的方式,是把它想成一场开卷考试。
传统的大模型使用方式,相当于闭卷考试。模型把“学过的知识”都记在参数里,你问什么它答什么。这带来一个很自然的问题:如果一道题它没学过,或者学过的内容已经过时了,它只能硬着头皮瞎编。
RAG的思路是改开卷。考试时允许你带一摞参考资料,遇到问题先翻书,找到相关章节,再结合题目组织答案。这套流程落到系统里,就是“先从外部知识库检索出与问题相关的文本片段,再把这些片段和原始问题一起喂给大模型,让模型基于这些资料作答”。
所以RAG不是“检索技术”和“生成模型”的简单拼接,而是一条完整的数据管道:外面挂一个知识库,里面跑两段逻辑,一段负责“找”,一段负责“写”。
1.2 RAG真正解决的三个核心痛点
我做过的RAG项目里,几乎都是为了解决下面这三个问题之一。
第一个是知识时效性。大模型的训练数据有截止日期,新出的产品、新发的政策、刚改的接口文档,它一概不知道。你不能为了问一个问题就重新训练一次模型,但你可以把最新文档丢进RAG的知识库,效果是立竿见影的。
第二个是幻觉问题。模型不知道答案时,会倾向于“编一个听起来合理的答案”。尤其在专业领域,这种一本正经的胡说八道非常危险。RAG通过给模型限定“只能基于检索资料回答”,把编造空间压缩到最小。注意是压缩,不是消除,后面我会讲如何进一步约束。
第三个是垂直领域知识缺失。通用大模型对公开的通用知识表现很好,但对你公司内部的SOP、某产品的操作手册、某个领域的专业术语基本一无所知。RAG让你不用微调模型,就能把私域知识“塞”进问答链路。
1.3 为什么先选RAG而不是微调
很多新手会问:这些需求直接用微调(Fine-tuning)不行吗?也行,但要看场景。
微调的本质是改变模型的参数,让模型“记住”新知识。它的缺点是显而易见的:
- 训练成本高,需要标注数据、GPU资源、训练流程;
- 知识更新麻烦,文档改版后要重新训练;
- 容易“灾难性遗忘”,学新知识的同时可能丢掉旧知识;
- 无法提供证据链,用户问你“这个结论哪来的”,你说不上来。
RAG没有这些问题。知识更新就是往向量库里加一篇新文档,几秒钟生效;回答时还能把命中的原文片段展示给用户,可溯源、可解释。
| 对比项 | RAG | 微调 |
|---|---|---|
| 更新知识 | 换文档、刷新索引即可 | 重新训练模型 |
| 硬件成本 | 常规服务器即可 | 需要训练资源 |
| 幻觉控制 | 强约束(基于检索内容回答) | 弱(靠参数记忆) |
| 可解释性 | 高,可溯源到原文 | 低,黑盒 |
| 擅长任务 | 知识问答、文档问答 | 风格模仿、特定任务格式学习 |
我的建议是:优先RAG,微调只在需要改变模型“行为方式”时才考虑。比如你要让模型按固定模板写周报,这属于行为对齐,微调更合适;如果是“知道某件事的答案”,那RAG的性价比碾压微调。
2. RAG系统完整架构:索引管道和查询管道拆解
2.1 整体流程:两条管道串起一个闭环
RAG系统的结构可以用两条管道来理解。
第一条是离线索引管道(Indexing Pipeline),负责把原始文档变成可检索的向量索引:
原始文档(PDF/Word/HTML/TXT) → 解析与清洗 → 文本切分(Chunking) → 向量化(Embedding) → 存入向量数据库(附带元数据)第二条是在线查询管道(Querying Pipeline),负责处理用户问题并生成回答:
用户提问 → 问题理解/改写 → 向量化 → 候选召回(Top-K) → 重排序(Rerank) → 组装Prompt → 大模型生成回答这两条管道的核心区别在于:索引管道是“预习”,把资料事先整理好放在那里;查询管道是“临场发挥”,每次用户提问都要跑一遍。系统性能的优化点,大部分都藏在这两条管道的细节里。
2.2 文档加载与解析:最容易低估的第一步
很多人做RAG,第一步就直接切分文档,这是大坑。
你要喂给RAG的原始文档可能包含PDF、Word、Markdown、扫描件、网页。PDF有文本型和扫描型,文本型可以直接抽取,扫描型必须走OCR,否则抽出来全是一堆乱码。Word文档里面有表格,表格内容如果被暴力抽成普通文本,行列关系就全丢了,检索效果直接受损。
实际项目中我一般会先做一层“清洗”:去页眉页脚、去水印、去重复段、修正OCR错字、统一编码格式。这一层看起来很琐碎,但它决定了后续所有环节的上限。垃圾进,垃圾出,索引管道里体现得淋漓尽致。
2.3 文本切分:chunk的哲学
文档清洗完,下一个问题是:把它切成多大一块?
大模型有上下文窗口限制,你不能把一本几百页的手册全塞给它。而且检索单元的大小直接决定召回质量——如果一块文本太短,信息不完整,模型看了也不知道在说什么;太长,这块文本里可能混杂了多个主题,检索时相似度被稀释。
我常用的切分策略有三种:
固定长度切分:按照指定字符数切块,可以设置重叠(overlap)。实现最简单,但容易在句子中间“拦腰斩断”,破坏语义。
递归字符切分:按分隔符的优先级依次切分——先按段落\n\n切,再按换行\n切,再按句号、空格。这种方式在保持语义完整性上比固定切分好得多,是多数项目的默认选择。
语义切分:用Embedding模型计算相邻句子的向量相似度,相似度高就合并,低就断开。效果最好,但计算成本高,文档量大时需要考虑耗时。
此外还有一个非常实用的思路叫父子分块:把文档切成分层结构,小分块(比如200字符)用于检索,大分块(比如1000字符)用于生成。检索时用小分块去比对,命中后返回它所属的大分块作为上下文,这样模型能看到更完整的背景信息,同时匹配精度也不会下降。
2.4 向量化:Embedding模型怎么选
切完的文本块要变成向量,才能做语义相似度计算。这一步用的模型叫Embedding模型。
选型的核心考量点是语言匹配。如果用英文Embedding模型处理中文文档,效果会差到让你怀疑人生。中文场景建议直接选择为中文优化的模型,比如常见的bge系列、m3e等,这些模型对中文近义词、口语化表达的理解明显更强。有多语言需求,就选多语言模型,但要注意多语言模型通常比单一语言模型精度略低,因为参数要兼顾更多语言。
另外维度也很关键。高维度向量精度更高,但存储占用和计算耗时也随之上升。小项目几百MB的向量库无所谓,上了千万条数据,维度每翻一倍,资源开销都很可观。按自己的数据量选合适的维度,不要盲目追大。
2.5 向量数据库:主流方案怎么选
向量数据库是索引管道的终点,也是查询管道的起点。市面上的方案很多,我按场景给你分个类:
| 方案 | 特点 | 适合场景 |
|---|---|---|
| Chroma | 轻量级、零配置、起步快 | 本地开发、小项目、教学Demo |
| pgvector | 直接在PostgreSQL里加向量类型 | 已有PostgreSQL,想统一存储 |
| Elasticsearch | 老牌搜索,支持全文+向量混合检索 | 已有ES,有复杂检索需求 |
| Milvus | 分布式、千万级向量、高并发 | 正式生产环境、数据量大 |
| Redis向量模块 | 低延迟、内存型 | 对实时性要求极高的场景 |
小项目和个人学习,我建议直接用Chroma,省去部署复杂度,跑通流程后再迁移到生产级方案。别一上来就上一个重量级分布式向量库,维护成本足够劝退新人。
2.6 召回与生成:Top-K取值和Prompt约束
检索阶段,系统从向量库里取出与问题最相似的K个文本块,这个K就是常说的Top-K。
K的取值有讲究。K太小,可能漏掉关键信息;K太大,噪音增多,大模型的注意力会被无关信息干扰,生成质量反而下降。一般闲聊类问答K取3到5,复杂文档分析可以适当放大到8到10,但建议不要超过10。我在实际项目中,默认都是取3到5,然后根据评测结果微调。
生成阶段,则是把检索到的文本块和用户问题组装成一个Prompt,交给大模型。Prompt里必须明确几条规则:只能使用资料中的信息、不能编造、资料不足时明确说不知道、必要时附上来源编号。这些约束写在Prompt里和不写,效果天差地别。我在下一部分会给出可以直接套用的Prompt模板。
3. 实操:从零搭建一个可运行的RAG系统
3.1 技术栈与安装
先说明,这个实操示例聚焦RAG核心流程,不绑定任何特定厂商的付费服务。向量化我用开源本地模型,大模型部分留一个抽象接口,你手上有什么就用什么。
需要安装的依赖:
pip install langchain chromadb sentence-transformers这里LangChain负责文档切分、Prompt组装这些胶水逻辑,Chroma做向量存储和检索,sentence-transformers加载本地Embedding模型。三条都是成熟稳定的开源组件。
3.2 文档加载与切分
假设你已经有一份产品手册product_manual.txt。先把它加载进来,然后切分:
from langchain.text_splitter import RecursiveCharacterTextSplitter with open("product_manual.txt", "r", encoding="utf-8") as f: content = f.read() text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""], ) chunks = text_splitter.split_text(content) print(f"切分得到 {len(chunks)} 个文本块")这里的chunk_size=500是中文场景的一个稳妥起点。500字符大约能容纳一段完整的说明文字,既不会太短导致信息不足,也不会太长导致向量相似度被稀释。chunk_overlap=50是为了避免句子正好被切断在边界上,让相邻块之间有部分重叠。
为什么不直接用固定大小切分?因为容易把一句话从中间砍断,用户问“这个功能怎么开启”,命中的块刚好只有半句话,大模型看了都头大。递归切分按标点和段落层次去切,语义完整性明显更好。
3.3 Embedding与入库
接下来把每个文本块转成向量,存入Chroma:
from sentence_transformers import SentenceTransformer import chromadb # 加载本地Embedding模型,可以换成其他适合你语言的模型 embedder = SentenceTransformer("bge-small-zh-v1.5") client = chromadb.PersistentClient(path="./rag_demo_db") collection = client.get_or_create_collection( name="manual", metadata={"hnsw:space": "cosine"}, ) # 为每个文本块生成向量 embeddings = embedder.encode(chunks, normalize_embeddings=True) # 写入向量库,记录文本内容和来源元数据 collection.add( ids=[f"chunk_{i}" for i in range(len(chunks))], documents=chunks, embeddings=embeddings, metadatas=[{"source": "product_manual.txt", "chunk_index": i} for i in range(len(chunks))], )我强烈建议你把原文和元数据都存进去,而不只是存向量。原因后面讲,先记住这个习惯:元数据是你的救命稻草,它能让你之后按来源过滤、按文档维度删除、给用户展示引用出处。
3.4 检索与生成串联主流程
现在进入查询阶段。用户来了一个问题,先做向量化,再从库里召回,最后组装Prompt交给大模型:
def search_rag(query, top_k=5): query_vec = embedder.encode([query], normalize_embeddings=True).tolist() results = collection.query( query_embeddings=query_vec, n_results=top_k, ) return results def generate_answer(query): results = search_rag(query) # 组装上下文 context = "\n\n---\n\n".join( [doc for doc in results["documents"][0]] ) # 构造Prompt,核心是强约束 prompt = f"""请基于下面的资料回答用户问题。 要求: 1. 只能使用资料中提供的信息,不要编造任何内容; 2. 如果资料中没有相关信息,请直接回答“资料中未找到相关信息”; 3. 引用资料时,在对应句末标注来源编号 [1]、[2] 等。 资料: {context} 用户问题:{query} """ # 这里替换成你实际使用的大模型接口 answer = call_llm(prompt) return answer, resultscall_llm是一个抽象函数,你可以对接任何大模型SDK,或者本地部署的开源模型。RAG的核心架构不受底层模型厂商影响,这是这套方案最舒服的地方。
3.5 实操心得:第一次跑通时的三个意外
我第一次用Chroma跑通RAG时,犯过三个很低级的错误,列出来给你避坑。
第一,忘记normalize_embeddings=True。Cosine相似度假设向量已经归一化,如果不做归一化,向量长度不同的情况下相似度计算会有偏差,召回的排序可能不对。这是个很小但影响很大的参数。
第二,没有存原文,只存了向量。中间调试时想看命中的到底是什么内容,发现库里只有向量,一脸懵。Chroma的documents参数就是存原文的,一定要带上。
第三,切分参数拍脑袋。一开始用了chunk_size=1000,结果发现检索出来的块内容太多,大模型回答时啰嗦不说,还频繁引用到不相关的段落。后来改成500加50的overlap,效果立刻好了。切分参数不是越大越好,是根据数据和任务调出来的。
4. 进阶优化:把RAG从“能跑”调到“好用”
4.1 混合检索:向量检索救不了精确匹配
纯向量检索擅长语义匹配,但它有一个致命弱点:对精确匹配不敏感。
举个例子。用户问“产品型号 ABC-2000 的说明书”,向量检索很可能把“ABC-2000”这个词向量化后,匹配到一堆语义相近但型号不同的内容。人名、产品型号、订单编号、法律条款编号这些需要精确匹配的场景,向量检索经常翻车。
解决办法是混合检索:同时跑向量检索和关键词检索(BM25或Elasticsearch),把两路结果融合。融合算法最常用的是RRF(Reciprocal Rank Fusion,倒数排名融合),公式很简单:
RRF(d) = Σ 1 / (k + rank_i(d))其中rank_i(d)是文档d在第i路检索结果中的排名,k是一个常数,通常取60。这个公式的本质逻辑是:一个文档在每路检索中的排名越靠前,融合分数越高;只要它在任意一路中排名靠前,就能获得不错的分数。
RRF的好处是无需调权重,直接把两路排名列表合并即可。我实战中经常感受到,加了RRF混合检索之后,精确问法的命中率明显提升,而且实现起来不过几十行代码。
4.2 重排序:粗召回之后必须精排
粗召回阶段,向量库帮你从几百万个块里捞出Top50,但这50条结果里可能有一半是噪音。直接把这50条都塞给大模型,既浪费token,又干扰注意力。正确的方式是加一层重排序。
这里用到一个叫cross-encoder(交叉编码器)的模型。它和检索阶段常用的bi-encoder(双塔模型)不同:bi-encoder把问题和文档分别编码成向量再做相似度,速度快但精度粗;cross-encoder把问题和文档拼在一起送入模型,直接预测相关度分数,精度高但速度慢。
所以流程是:第一步用bi-encoder做快速召回,从百万级候选缩到Top50;第二步用cross-encoder对这50条逐条打分,取Top5;第三步才把Top5交给大模型。
实际工程中,推荐用一层轻量级cross-encoder模型做重排,比如各种基于BERT的rerank模型。重排之后的检索质量提升非常显著,我自己的项目里,加了重排之后答案相关性提升了一个档次。代价仅仅是多花零点几秒,在知识库问答场景完全可接受。
4.3 高级切分策略:结构化文档和父子分块
前面提到的固定长度切分只适合纯文本,遇到结构化文档必须升级策略。
按标题层级切分适合Markdown、HTML这类结构清晰的文档。你可以解析出H1 -> H2 -> H3的层级,再按最小标题单元切块,同时把标题路径作为元数据保存。这样检索到某个子章节时,模型能知道它属于哪个更大的章节,上下文理解更准确。
父子分块前面提过:检索用小块,生成用大块。这是个很实用的工程手段。举个例子,某公司产品手册中一个章节有2000字,切成10个200字的小块。用户问一个细节,小块的语义匹配精准定位到具体位置,然后系统自动定位到所属的500字父块,把父块作为上下文给模型。这样既保证了匹配精度,又不丢失背景信息。
表格处理是很多RAG项目翻车的地方。把表格拍平成纯文本,列名和单元格的对应关系全丢,检索和回答都像盲人摸象。更好的办法是把表格结构化转成JSON或键值对文本,比如{"产品型号": "ABC-2000", "功率": "200W"},再作为独立文本块入库。模型看到结构化的表格文本,理解能力好非常多。
4.4 查询改写:让问题先被“理解”,再被检索
真实用户问问题时非常随意,经常是口语化的、带指代和省略的。
比如多轮对话里,用户先问“你们的服务器支持多大内存”,再问“价格呢”。第二个问题如果不结合上下文,检索系统根本不知道“价格”指的是什么的价格。这里就需要查询改写:把当前问题结合历史对话,改写成一句独立的、包含完整信息的问题,再做向量化检索。
rewrite_prompt = """根据对话历史,把用户当前问题改写成一个可以独立检索的完整问题。 对话历史: {history} 当前问题:{query} 请只输出改写后的问题,不要输出任何其他内容。 """查询改写除了处理多轮对话,还可以做同义扩展。比如用户问“怎么退换货”,你可以通过大模型生成几个同义问题:“退货流程是什么”“换货需要什么条件”“售后怎么处理”。然后多路检索,取并集后重排。这个方法能把中小知识库的召回率拉到很高的水平,代价只是一次额外的大模型调用,在性能允许范围内非常划算。
4.5 Prompt层面的生成质量约束
很多人把优化精力都放在检索上,忽略了生成端。其实生成端的Prompt同样决定了天花板。
我总结一个比较稳的Prompt模板,你可以在项目中直接改:
你是一名专业的客服助手,只能依据“资料”中的内容回答用户问题。 规则: 1. 必须严格基于资料作答,禁止编造任何资料中不存在的信息。 2. 如果资料中没有能回答问题的内容,请直接回答:“我无法从现有资料中找到答案。” 3. 回答时按句子引用资料编号 [n]。 4. 如果资料内容有冲突,在回答中明确说明。 资料: {context} 用户问题: {query}有几个细节值得注意。temperature尽量设低(0到0.3),避免模型自由发挥。只传重排后的TopK条候选,不要贪多。同时,把“资料中没有就直说不知道”写进规则,这句话能把幻觉压到很低的水平。
4.6 动态上下文裁剪:应对超长文档
检索命中的文本块如果很长,或者命中数量多,拼接起来的上下文可能超过模型窗口。粗暴截断会把关键信息切掉。
更好的做法是动态裁剪:先按段落结构拆分命中的文档,再根据可用token预算,从最相关的部分开始截取,优先保留与问题共现关键词最多的段落。这本质上是“在有限的上下文窗口里做信息密度最大化”,比固定长度的前N个token效果好得多。如果你的问答场景经常涉及长文档,这个优化值得做。
5. 避坑手册:RAG落地中的高频问题与排查实战
5.1 高频问题速查表
我整理了RAG项目里出现频率最高的几类问题,先给一个速查表,再针对最典型的几个展开讲。
| 症状 | 常见原因 | 处理方案 |
|---|---|---|
| 检索结果全是无关内容 | Embedding模型语言不匹配、chunk太大、未做重排 | 换中文Embedding模型、缩小chunk、加Rerank |
| 专有名词查不到 | 纯向量检索不擅长精确匹配 | 加BM25混合检索、加同义词扩展 |
| 模型回答和检索结果不符 | Prompt约束太弱、上下文里噪音多 | 强化Prompt规则、减少TopK、降低temperature |
| 资料有但答案说“不知道” | 检索没召回正确块、K值太小 | 增加K或open多路召回、检查切分是否破坏语义 |
| 回答啰嗦、抓不住重点 | TopK太多、chunk太大、生成无结构要求 | 减少候选、缩chunk、要求按条分点输出 |
| 文档更新后旧答案仍出现 | 旧chunk未删除、没有版本管理 | 按文档id删旧索引、建立增量更新机制 |
| 查询响应太慢 | Embedding耗时、Rerank全量候选 | 缓存query向量、只对Top50重排、用更高性能库 |
5.2 排查实录一:检索结果“看似相关但其实是噪音”
我朋友A做某公司的内部问答系统时,发现用户问“报销单多久能审批”,系统答得牛头不对马嘴。查了一圈,问题出在切分上:他们把一份包含大量制度文件的文档按1000字符固定切分,一段500字的报销流程被切成了三块,其中一块混入了“出差住宿标准”的内容。向量检索时,这个混杂块和报销问题的相似度也挺高,被召回了,但内容是错的。
后来调整成按章节和段落递归切分,并对制度文档做父子分块,报销流程作为一个子块被完整保留在父块里,问题一下就解决了。
这个案例说明一个道理:很多检索质量问题,根源不在检索算法,而在文档切分。切分是RAG的地基,地基歪了,上面再聪明的算法也白搭。
5.3 排查实录二:模型总是编造不存在的规格参数
另一个常见问题是:检索明明命中了正确文档,模型还是答错或编造。原因多半出在Prompt约束上。
默认情况下,大模型的“创作欲”很强。你给它几个上下文片段,它会把片段当素材,加上自己的知识库储备,混合出一个看似合理的答案。解决办法就是前面说的强约束Prompt,明确“只能使用资料”“没有就直说不知道”,并且要求回答按编号引用原文。还有一招:在生成之后做一次答案可验证检查,把答案里的关键断言和检索到的原文片段做一次相似度比对,低相似度就拒绝输出或降级处理。
我见过不少团队在生产环境用这个“生成后再校验”的思路来兜底,它需要一个轻量级的校验模型,但效果确实稳。如果你对幻觉零容忍,这层校验值得做。
5.4 建立评测闭环:拍脑袋调参是最大的坑
很多RAG项目死在“调参靠感觉”上。今天觉得chunk改成800好,明天觉得TopK从3改成5好,感觉来感觉去,最后哪个版本效果最好完全说不清。
我的做法是建一个回归评测集。从真实用户问题里挑出20到50条覆盖各种类型的样本,整理成标准问答对,每改一次系统配置,就跑一遍这组问题,记录命中率、回答相关度、是否引用正确来源。用这组数据做横向对比,任何改动是变好还是变坏,一目了然。
评测标准可以很简单:检索阶段看Recall@K,即正确答案是否出现在召回的K个结果里;生成阶段让大模型当裁判,按“是否准确、是否基于资料、是否完整”三个维度打分。这套流程虽然朴素,但非常有效,已经成为我所有RAG项目的标配。
最后分享一点个人体会
做了一堆RAG项目之后,我的最大感受是:RAG的瓶颈不在大模型,而在数据工程和评测闭环。很多人一上来就追最新的模型、最贵的向量库,其实先把切分、检索、评测这三件事做扎实,效果提升立竿见影,而且成本极低。
还有一个土办法,但对我帮助特别大:每次上线RAG系统之前,收集一批真实用户问题,自己手工把标准答案写出来,建一个最小回归集。每次改完检索逻辑或Prompt,花十分钟跑一遍回归,看有没有变差。这个习惯帮我拦下了好几次“优化完反而更差”的尴尬局面。做RAG,别靠感觉,靠数据。就讲到这里,剩下的坑,你踩过了自然就懂了。