不需要特别声明“本文是RAG入门”“本文介绍了什么”之类的铺垫,咱们直接从一个真实到让人头大的场景开始。你辛辛苦苦调了一下午提示词,模型终于答应不再乱编,可它转头就把合同金额从“一万二千”念成了“一万零二百”,还一脸笃定。这种“一本正经的胡说八道”,几乎是每一个做过大模型集成的人都会撞上的墙。RAG(Retrieval-Augmented Generation,检索增强生成)就是专门用来拆这堵墙的:不是逼模型“记住”你的业务规则,而是每次回答问题前,先让它去你指定的知识库里把相关材料翻出来,照着材料再说人话。
这篇文章不玩虚的。我会从大模型为什么会产生幻觉这个底层原因讲起,把RAG全链路的选型、最小可复现的工程代码、以及我实际部署中踩过的坑全部摊开,目的只有一个:让新手和刚入门RAG的程序员看完之后,能独立搭出一个让模型“知道就是知道、不知道就说不知道”的可靠系统。适合正在做大模型应用集成、私有知识库问答、或者被幻觉折磨得想砸电脑的各位。
1. 大模型到底为什么会“一本正经地胡说八道”
在动手做RAG之前,先把模型的“病根”看清楚。很多人以为幻觉是模型太笨、记性不好,实际上模型脑子里根本没有“记忆”这个概念。它只有参数:一组经过海量文本训练后压出来的权重。每个回答都是根据当前输入,在这组权重上做概率采样,选出“最像样”的下一段文字。也就是说,模型不是在回忆“合同金额是多少”,它是在推算“这种语境下哪种数字组合最顺口”。
1.1 幻觉的成因:从概率生成到知识截断
幻觉的本质是“知识没有边界”。预训练虽然让模型掌握了语言规律和部分常识,但这个“知识”有两个致命问题:一是截止时间固定,训练数据之后发生的事它一概不知;二是存储密度有限,它只能记住高频出现的、被反复强调的信息,你的私有文档、内部规则、小众参数,对模型来说根本不存在。
所以当你问它一个超出训练分布的问题,比如“某公司内部新发布的报销标准是什么”,模型并没有“我不知道”这个标准答案,它只会按照语言习惯,把“报销”“标准”“元”这些词拼成一个看起来合情合理的句子。这就是为什么它敢把金额说错、敢凭空捏造出处、敢在你不确定的时候编出个根本不存在的文件编号。
1.2 RAG为什么能治这个病:不是给模型“行医”,而是给它“开卷考试”
RAG的治本思路其实特别朴素:考场里允许你翻书,它就不再需要背诵。传统集成是“闭卷考试”,你把问题扔给模型,它只能靠参数里那点压缩过的记忆硬答,遇到盲区必然编造。RAG改变了信息流向:收到问题后,先从一个你指定的外部知识库里检索出最相关的少量文档片段,把片段和原始问题一起打包送进模型,让模型“基于这些材料回答”。
开卷之后,模型要答的内容基本就在眼前,捏造空间被大幅压缩。它不需要“知道”你的业务细节,只需要“读懂并转述”你提供的材料。这也是RAG在私有知识问答、客服辅助、企业知识管理等场景里迅速成为标配的核心原因——它治的不是单个字眼的对错,而是让模型从“无中生有”转向“有据可依”。
2. RAG全链路的技术选型与架构:搭这栋楼之前先认清建材
RAG不是某个单一模型或库,而是一条完整的数据流水线。我开始做第一个RAG项目时,误以为“接个向量库就完事了”,结果上线后召回几乎全是垃圾。所以这一章先把整条链路讲透,让你明白每个环节是在干什么、为什么必须存在。
2.1 一条完整的链路:从用户提问到答案返回
一个标准的RAG流程,可以拆成四个连续动作:先对知识文档做清洗与切块,再把每个块转成高维向量存在向量数据库里;用户提问时,对问题做同样的向量化,去库中做近似度检索,取出最相关的Top-K块;把问题和这些块拼成一段带上下文的提示词;最后交给大模型,并约束它“只根据提供内容作答”。前两步属于离线准备,后两步属于在线推理,很多项目把前两步做成定时任务,数据和文档一更新就重跑一次。
有个细节容易被新手忽略:检索召回不是靠“关键词匹配”,而是靠“语义相似”。文档块和问题都被映射到同一个向量空间里,语义越近,向量夹角越小。比如用户问“离职赔付是几个月”,如果文档里写的是“离职补偿按N+1执行”,关键词完全对不上,但语义是相关的,好的向量模型依然能把它召回。这也是为什么选对embedding模型比选对向量数据库更影响效果。
2.2 embedding模型怎么选:决定检索效果的第一关
embedding模型负责“把文字变成向量”,它的质量直接决定召回的准确性。国内能直接调用的大模型服务一般会自带embedding接口,省事但效果参差;开源生态里常见的本地模型也各有侧重。这里我给出一个粗线条的选型参考:
| 场景特点 | 推荐方向 | 一句话理由 |
|---|---|---|
| 通用中文问答、文档少、刚起步 | 大模型配套接口 | 零部署成本,接入最快,效果稳定 |
| 涉及大量专业术语、垂直领域 | 领域微调过的embedding模型 | 通用模型常把术语的语义“压扁”,容易漏召回 |
| 需要本地离线、有隐私要求 | 本地开源embedding模型 | 数据不出内网,但需要一块不错的显卡 |
| 多语言混合、代码混排 | 多语言向量模型 | 避免中英切换时向量空间错位 |
一个常见误解是“embedding模型越大越好”,实际上embedding效果和参数量并不是严格正相关,模型太大反而会让离线批处理变慢。我个人的习惯是:先拿一个小型但口碑稳定的模型跑通链路,再用自己业务里的真实问答对去做召回率评测,评测不过再换更强的,不要一头扎进模型海洋里出不来了。
2.3 向量数据库怎么挑:别嫌选择困难,先搞懂这几类
向量数据库的核心任务是“存向量 + 做近似检索”,市面上选择很多,但本质也就三大类。第一类是专业向量库,比如常见的开源方案,专为向量检索设计,性能好、接口丰富,适合作为RAG系统的核心组件;第二类是传统数据库自带向量插件,如果你的业务原本就用它,可以直接在现有表上加向量字段,少维护一个组件,但高并发近似检索性能通常弱一些;第三类是基于大模型框架自带的简易检索层,它其实只是一层内存里的暴力比对,适合Demo和原型。
对新项目,我的建议是别在数据库选型上花太多时间。数据量不超过几百万,专业向量库和数据库插件的效果差距你用不出来。真正影响体验的是“怎么把文档切成合适的块”以及“检索后如何排序”,这两块没做好,换再强的向量库也白搭。等知识库规模上来,再按并发和延迟指标去做压测选型。
3. 用一段工程化代码跑通最小RAG Demo
理论讲再多,不如跑通一个最小闭环。这一章我用一个非常基础的知识库案例带大家走一遍,目标是:让模型基于本地几个文档回答问题,并且当文档里没有答案时,明确说“不知道”,而不是硬编。
3.1 前期准备:本地用文本建一个最小知识库
我们先准备几个纯文本文件,模拟一个简单的公司制度知识库。别嫌这些文档简单,实际项目里无论PDF、Word还是网页,最终都要清洗成类似的纯文本块,所以用txt做原型反而最没有干扰。
我建三个文件,分别叫rules/onboarding.txt(入职流程)、rules/leave.txt(休假制度)和rules/salary.txt(薪资发放),内容用五六句话描述核心规则即可。这一步的目的只是让知识库有可检索的内容,等流程通了,再去接真实PDF和爬虫。
然后写一个最基础的工具函数,负责把整个目录下的文档按固定长度切成块:
import os from pathlib import Path def load_and_chunk(source_dir: str, chunk_size: int = 500, overlap: int = 50): chunks, metadatas = [], [] for path in Path(source_dir).glob("*.txt"): text = path.read_text(encoding="utf-8").strip() text = " ".join(text.split()) # 压缩多余空白 for i in range(0, len(text), chunk_size - overlap): chunk = text[i:i + chunk_size] if chunk.strip(): chunks.append(chunk) metadatas.append({"source": path.name}) return chunks, metadatas切块时留一点重复区域(overlap),是为了避免一句话恰好被从中间截断,导致检索时语义不完整。这个细节后面会专门讲。
3.2 核心实现:检索实现与再生成
接下来我们复用框架能力,把链路串起来。这里用现有的向量库和大模型SDK做整合。核心代码划分成三步:先构造知识库索引,再实现检索函数,最后拼装提示词生成回答。
from sentence_transformers import SentenceTransformer import chromadb # 1. 加载embedding模型,准备向量库 model = SentenceTransformer("BAAI/bge-small-zh-v1.5") client = chromadb.PersistentClient(path="./kb_store") collection = client.get_or_create_collection( name="rules", metadata={"hnsw:space": "cosine"}, ) def embed_docs(): chunks, metadatas = load_and_chunk("./rules") ids = [f"chunk_{i}" for i in range(len(chunks))] embeddings = model.encode(chunks, normalize_embeddings=True).tolist() collection.upsert(ids=ids, documents=chunks, embeddings=embeddings, metadatas=metadatas)向量库建好之后,检索就是一句查询的事:
def search(query: str, top_k: int = 3): q_vec = model.encode([query], normalize_embeddings=True).tolist() res = collection.query(query_embeddings=q_vec, n_results=top_k) return res["documents"], res["metadatas"] # 测试:问一句语义相近、但关键词不一致的问题 docs, metas = search("离职的时候赔偿怎么算") print(docs)当前这个最小Demo里,回复生成可以先用最基础的方式拼接。但我在工程化时更倾向于使用专门的检索模板,把上下文和问题明确分隔开,并把约束条件写进系统提示词,后面会在护城河章节详细展开。
3.3 把Demo“升级”成真正可用的工程
Demo跑通之后,有四个点必须立刻动手改,否则量一大就会崩。第一,离线索引要做成可增量更新的任务,不能每次全量重算;第二,检索结果要带来源元数据,前端展示时可以附上出处,用户也能自己核对;第三,要给查询加超时、重试和熔断;第四,所有文档块的来源必须能追溯到原始文件,否则知识库更新时,老的脏数据会一直污染在线结果。
另外,一些细节能显著改善体验:提示词里让模型先“看”检索到的内容,再回答;如果检索结果为空或者与问题无关,则强制要求模型回复“知识库中没有找到相关答案”,避免模型强行利用自身常识作答。这个约束我在下一节会给出具体模板。
4. 让RAG从“能跑”到“好用”的细节:那些文档里不愿写的坎
很多人跑通Demo后觉得效果不错,可一放到真实数据和真实用户面前就露馅:召回结果看着相关,生成答案却张冠李戴;用户换个说法,检索就漏;知识库数据一多,延迟跟着爆炸。这些都不是玄学,而是若干细节没有处理到位。这一节捡最关键的讲。
4.1 chunk切分:直接定死“512个字”最容易翻车
切块是RAG里最容易被低估的环节。很多教程会告诉你“每块512个字”,但真实项目里,这个参数必须跟着内容结构走。如果文档有明确的章节标题、列表、表格,那么最好的切法是以语义边界为准:优先保持段落完整,段落太长再按更细的规则切;代码块、表格要单独处理,不能硬切。
我踩过一个真实案例:某文档里一个Markdown表格被切成了两块,上半块还在表头,下半块直接是数据和单位。用户问某个指标时,模型收到不完整的表格,居然“脑补”出了错误数值。解决办法是给不同内容类型写不同的chunk策略,尽量保证“一个语义单元放进一个块里”。
另一个经验是:块太小,模型看不到足够上下文,容易断章取义;块太大,检索出的内容包含过多无关信息,又会稀释答案。一般从300-800字起步,用你自己的真实问答集做回归,对比不同块大小时的召回准确率和答案准确率,而不是抄别人博客里的参数。
4.2 元数据过滤:召回“相关”不等于召回“正确”
纯向量检索只关心语义相似,但它不理解业务规则。比如知识库里既有旧版本的《报销制度》又有新版本的,用户问“打车报销上限”,向量检索可能把两个版本都召回,模型就不知道该信谁。这时候就需要元数据过滤:给每个文档块打上版本号、部门、发布时间、文档类型等标签,检索时带上过滤条件,把过期版本直接挡在外面。
实现也不复杂。写入向量库时,把这些标签放在metadata字段里;查询时,在检索请求中带上过滤表达式。例如只查版本号为v2的块:
collection.query( query_embeddings=q_vec, n_results=3, where={"version": {"$eq": "v2"}} )再进阶一点,可以把“权限”也做成元数据过滤。不同角色用户只能检索到各自权限范围内的文档块,比答案生成后再做一次审核要靠谱得多,很多政务和金融项目都是这么要求落地权限的。
4.3 混合检索+重排:把召回质量再往上提一档
纯向量检索不是万能的,最大的问题在于对精确匹配不敏感。用户问“订单号20240501的状态”,向量检索可能因为整体语义相近,把“订单查询流程”这种泛泛的文档排在前面,而真正包含具体订单号的记录反而排后面。关键词检索擅长精准匹配,但它不懂语义。成熟做法是“混合检索”:向量检索和全文检索各自召回一批结果,然后合并去重,再做重排。
重排(rerank)可以用专门的跨编码器模型,逐对计算“文档块和问题的相关度分数”,把真正相关的排名往上挪。这一步对最终效果提升非常明显,代价是多了一次模型推理,延迟会高几十毫秒,但对绝大多数知识库问答场景来说完全值得。
我在生产里用的链路是:向量检索取Top50,关键词检索取Top50,合并后交给rerank模型重排取Top5,再进大模型生成。效果比单纯的Top5向量召回好很多,尤其是那些包含精确编号、产品型号、人名地名的查询。
5. 绕开护城河:让模型“不知道就是不知道”
前面所有工作都在提高“知道”的上限,但真正决定一个RAG系统可不可信的,是“不知道”时的表现。很多项目检索做得很漂亮,一到知识库覆盖不到的问题,模型就开始仗着自身知识硬答,把内部规定和公共常识混在一起。解决这个问题,需要在提示词层面设计一道“护城河”。
5.1 设置拒答边界:让“不知道”成为合法答案
护城河的核心思想很简单:如果不确定答案来自提供的知识块,就明确说无法作答。实现方式主要是靠系统提示词约束。我在工程里经常用这样一段话:
你是一名严格遵守知识库回答的助手。回答只能基于提供的上下文,如果你的知识库内容无法支撑答案,请明确回答“当前知识库中未找到相关信息,建议咨询负责人”。严禁使用常识或自身知识补充,严禁编造文档中不存在的细节。
把它和大模型一起送进生成接口,就能把“不知道”变成合法选项。不过要注意,提示词只是软约束,不能完全挡住模型“手痒”,所以还必须配合后面的策略。
5.2 护城河模板:如何实操落地
我在实际项目中,是把护城河约束固化成一个公共函数,所有RAG查询统一走它。模板包含三个必要段落:角色约定、检索上下文、拒答规则。一个可参考的简化版本大致长这样:
PROMPT_TEMPLATE = """你是一个严谨的智能问答助手。请严格按照下列步骤操作: 1. 仔细阅读用户问题; 2. 仔细阅读已知的检索上下文; 3. 判断检索上下文是否足以回答问题; 4. 如果充足,则基于上下文回答,并给出引用片段; 5. 如果不充分,直接回复:“当前知识库中未找到相关信息,建议换个说法再试一次。” 6. 禁止编造,禁止使用检索上下文之外的知识。 检索上下文: {context} 用户问题: {question} 回答:"""这个模板有几个可调参数:如果你希望答案更严谨,就把“禁止编造”放在最前面;如果你希望答案更口语,可以加入“请用自然语言组织回答”。但无论如何,拒答规则绝不能省,它是系统可信度的底线。
5.3 实测效果检验:用对抗问题压测系统
搭建完之后,一定要做对抗性测试。别拿知识库里的标准问题测,要找那些“知识库里没有、但模型可能知道”的刁钻问题来测。比如我给上面的休假制度知识库,专门准备了这些测试用例:
| 测试问题 | 期望结果 | 实测常见问题 |
|---|---|---|
| 年度调薪的流程是什么? | 知识库无信息,拒绝回答 | 模型可能按常识给出调薪套路 |
| 加班费怎么算? | 若无具体文档则拒答 | 模型可能根据劳动法编造内容 |
| 病假和事假有什么区别? | 引用文档相关条目 | 回答可能混淆不同条款 |
压测的标准很简单:只要模型开始自由发挥,你就该调提示词或增强检索过滤,而不是急着换一个大模型。坦率说,护城河不是一次性调完就一劳永逸的,我几乎每隔一周就会根据用户实际提问,把“容易让模型自由发挥”的问题补充进知识库,或者加强拒答模板。这就是一个持续迭代的过程。
6. 从Demo到上线:还有哪些坑等着你
把RAG系统部署上线,和跑通Demo完全是两码事。这一节挑几个高频率踩坑点提醒大家,很多我都是付过学费的。
6.1 知识库更新:向量索引不是“改改文件就行”
知识库里的文档一旦变更,向量库里对应的老块不会自动消失。如果你的更新逻辑做得不仔细,就可能出现“旧文件已经删了,旧向量还在库里,用户一问还能查到”的怪事。我的做法是:每个文档块都带文档ID与版本号,更新时按文档ID把旧块先删除再写入新块;如果是API接口触发的增量更新,还要做好并发控制,避免索引写坏。
6.2 延迟和成本:RAG不是“多一次查询”那么简单
很多人以为RAG只是“多检索一次”,但实际线上延迟要算上向量化用户问题、向量库检索、重排模型推理、大模型生成四个环节。如果知识库数据量大,向量库检索可能从几十毫秒涨到几百毫秒;如果加了一个重排模型,又要额外几十毫秒。优化时,先把不必要的环节从链路里拆出去,比如小规模知识库根本不需要重排,直接Top3进大模型就够了。
6.3 评测体系:没有评测就谈不上优化
RAG链路环节多,参数多,如果没有一套自己的评测集,你根本分不清是切块问题、检索问题还是提示词问题。我建议每个项目初始化时,就整理50-100对“标准问题-标准答案数据”,答案标注出期望回答来源。每次改动链路,都用评测集跑一遍,记录召回率、正确率、拒答准确率。评测驱动优化,才真正“从零到精通”,否则就只能一直靠直觉调参。
我个人在做了几个RAG项目之后最大的体会是:RAG真正难的从来不是某个模型选型,而是整套系统的边界意识——明确哪里该检索、哪里该拒答、哪里该更新,把这些边界管好了,大模型幻觉问题基本能压到可接受范围。以上这些都是我实打实踩过来的路,希望对此刻正跟幻觉作斗争的你有点帮助。