ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RAG系统知识检索增强设计:从架构原理到落地实操与避坑指南

RAG系统知识检索增强设计:从架构原理到落地实操与避坑指南

做了一段时间大模型应用开发后,我越来越确信一件事:在很多生产场景里,真正决定结果质量的不是模型本身,而是你有没有给它足够对的上下文。RAG(Retrieval-Augmented Generation,检索增强生成)系统要解决的就是这个问题——它把外部知识库变成模型可随时调用的“参考资料”,让生成过程不再完全依赖模型脑子里那点参数化记忆。这一节我重点聊聊RAG系统背后的知识检索增强设计,从架构原理到落地实操,再到我踩过的坑,都会一一摊开说。

如果你正打算给自己的助手接上企业文档、产品手册、私有知识库,或者只是想把模型从“什么都懂但经常胡说”的状态拉回专业轨道,那这篇内容应该能帮你省下不少试错时间。我会尽量把每个关键选择背后的“为什么”也讲清楚,而不是只丢给你一份配置清单。

1. 为什么需要RAG:大模型的天生短板

1.1 参数化知识的局限

先看一个容易被忽略的事实:大模型训练完之后,它的“知识”全部被压缩在权重里。别人问它什么问题,它其实是根据概率分布逐字生成回答,并不是真的去数据库里查资料。这种被压缩进参数的知识有三个天然硬伤。

第一是时效性。模型训练截止之后,世界上发生的很多事情它都不知道。你可以靠更新模型解决问题,但重新训练或全量微调的成本极高,而且过几个月又过时了。第二是幻觉。当模型遇到训练数据里没覆盖、或者覆盖面很少的内容时,它会用“合理的编造”来填补空白,回答得一本正经但全是假的。第三是私域知识。企业内部文档、某个产品的操作手册、某个人积累的经验——这些东西根本不在公开训练语料里,模型再大也无从知晓。

这三个问题不是靠换一个更大的模型就能解决的,因为大模型的“记忆”本质上是模糊的,它不擅长精确复述一段你喂给它的细节内容,尤其是需要引用原文、展示表格、列出具体编号的时候。

1.2 RAG的核心思路:把知识从参数中搬到外部数据库

RAG的思路非常直白:既然参数里的知识不可靠,那就把知识放在外部——一个可控的、可随时更新的知识库。每次来问题,先从这个库里检索出最相关的片段,把这些片段和用户问题一起塞给大模型,让它基于谁提供的资料“照本宣科”地组织答案。

这样做有四个明显好处:

  • 知识可以实时更新,改文档、加新内容后不需要重新训练成本;
  • 回答可溯源,模型说“根据公司第X章规定”时可以真正指向原文;
  • 幻觉率显著下降,因为模型不再自由发挥,而是有明确的上下文约束;
  • 私有知识变得可用,你完全可以构建一个本地知识库,不依赖公开训练数据。

简单说,RAG是从“让模型记住一切”转向“让模型学会查资料”。模型从一个背诵型选手,变成了一个开卷考试的选手。

1.3 和微调、长上下文窗口相比,RAG赢在哪

当然,有人会问:我把知识直接塞进prompt里行不行?或者干脆微调一个模型行不行?这里我给出自己的对比经验。

长上下文窗口确实越来越长,几百万token的输入也有产品了。但有一个实际问题:当输入内容特别长时,模型对中间部分的注意力会稀释,它经常“看过但没用上”。而且每次都把几十万字文档塞进prompt,算力成本和延迟都很高,简单问题也被拖慢。

微调则更适合“改变模型的表达风格、角色设定、输出格式”,不适合“让模型记住大量事实细节”。微调模型很容易把新知识学成一个似是而非的“感觉”,精确的日期、版本号、条款内容还是记不住。而且每更新一次企业知识库,你都要重新微调一次,这个迭代速度在生产环境里是灾难。

RAG把“知识存储”和“模型生成”解耦,文档随便改,索引重建就行;模型可以保持通用能力,不用为每份文档重新训练。所以我在实际项目里默认优先选RAG,只有在需要大量“格式化输出风格”或“领域行话表达”时才会考虑叠加一层微调。

2. RAG系统整体架构与关键模块拆解

2.1 离线索引阶段:先给知识库建一套地图

RAG系统虽然看起来是“查资料+回答”两步,但完整架构要分成离线索引和在线查询两个阶段。

离线索引的核心任务是把原始文档变成可供检索的结构化表示。你可以想象成给一个图书馆里的每本书建立精确的“目录+摘要+书架编号”。

第一步是文档解析与清洗。PDF、Word、HTML、Markdown,各自格式千奇百怪,需要提取正文、去水印、去掉页眉页脚、识别表格。这一步看起来不起眼,但很多RAG效果差的原因就是这一关没过——你喂进去的是排版混乱的文字,后面切出来的片段质量必然差。

第二步是分块(chunking)。一份长文档不可能整体embedding成一个向量,因为生成向量时模型有长度限制,而且长文本压缩成一个向量会丢失太多细节。常规做法是把文档切成若干个固定大小的块,每块单独向量化。

第三步是向量化,也就是embedding。用预训练的文本向量模型把每一块文字转成一个高维向量。这个向量的核心属性是:语义相近的文本,向量距离更近。

第四步是建立索引并存储。向量数据库会为这些向量建立ANN(近似最近邻)索引,方便后续毫秒级检索。同时你还需要保存每个向量对应的原始文本和元数据——比如来自哪个文档、第几章、页码等。

这一步做完了,知识库就从一个“文档仓库”变成了“可定位的检索库”,后面所有回复都基于这张地图来指路。

2.2 在线查询阶段:把问题变成一次精准的“查资料”动作

当用户发来一个问题,在线流程会经历五个动作:查询理解、向量召回、候选融合、重排序、生成回答。

查询理解不是简单的把用户query拿去匹配。用户输入往往带口语、指代、错别字。比如用户问“那个上个月发的通知里说域名怎么办?”,系统首先需要把“那个”“上个月”补全成具体时间或具体文档指代,才能做有效检索。这一步通常叫query改写或query扩展。

接下来是召回。系统用改写后的query生成一个查询向量,然后在向量库里找到最相近的一批文本块。这里有个关键参数topK,一般取20~50个,不宜太多也不要太少。

召回的20~50个块里,可能有大量噪声,如果全部塞进prompt,模型会被无关信息带偏。因此需要重排序(rerank)。用一个更精细的模型对这批候选逐条打分,选出最终3~5个最关键片段。这一步对效果影响极大,后面我会专门细讲。

最后,把这几个片段按逻辑顺序拼接成上下文,连同用户原始问题、系统提示词一起交给大模型,让它生成最终回答。

2.3 为什么选择向量检索:语义匹配是核心

很多人刚开始会有疑问:用传统的关键词搜索不也行吗?比如用户搜“苹果售后政策”,就用“苹果 售后 政策”这几个词去数据库里匹配文档,不是一样能找?

关键词检索(如BM25)确实在某些场景下很强,尤其当知识库里的用词和用户问题里的用词高度重合时。但它有个致命弱点:无法处理“同义不同词”。用户问“手机坏了怎么修”,文档里写的是“设备故障维修流程”,关键词重叠度很低,BM25很难命中。向量检索通过embedding把语义相似度计算出来,“手机坏了”和“设备故障”在语义空间里距离可能非常近,哪怕完全没有共同关键词。

但这不代表向量搜索能完全替代关键词搜索。我遇到很多生产场景,精确型号、编号、规则条款这类短文本,关键词匹配往往更准。所以成熟的RAG系统通常做混合检索:同时跑BM25和向量召回,再把两边结果合并去重、重排。后面第四章会讲具体怎么做。

3. 实操:从零搭建一个最小可用的RAG系统

3.1 环境与选型:别一上来就上全家桶

很多初学者一接触RAG就直奔某个大型框架,跑去配置一堆组件,结果连最基础的链路都没跑通。我的建议是:先用最少的依赖,徒手走通一遍,再去套框架。

最小系统只需要三个东西:一个embedding模型、一个向量存储组件、一个大模型接口。embedding我用的是开源的BGE-M3或同类的国产中文向量模型,它对中文的支持要比一些通用英文模型好不少,而且支持8000+长度的输入。向量存储先用FAISS这种本地库就行,单机几百万条向量都扛得住,完全不需要一开始上分布式集群。大模型接口你可以接任意一种商用API,也可以本地部署一个开源模型,效果差异主要在最后生成阶段。

下面这个示例用Python写,只依赖几个基础库。代码里的向量库是FAISS,文档加载和分块逻辑手工实现。

3.2 文档切分细节:chunk size和overlap是关键

切分粒度直接决定检索质量。如果切分太粗,比如把一篇5000字的文章整个变成一个向量,那检索时虽然一定能“命中”这篇文章,但到底哪一段是用户想要的答案,向量本身很难区分。如果切分太细,比如每100字一块,单个块包含的信息量不够,回答问题时缺少上下文。

我常用的起点是chunk_size=512字符(中文场景),overlap=64字符。512个字大约能容纳一个完整的条款或段落;64个字的重叠可以避免中间的关键句被拦腰截断造成的信息丢失。具体数值要按文档类型调,技术文档可以适当放大到768,短问答类数据可以缩小到256。

切分不要无脑按字符数硬切。我实测下来,按段落、标题、章节结构切分的效果通常更好。比如用Markdown的标题层级分块,或按句子边界对齐。下面这段代码展示了按字符切分并带重叠的实现,你可以在此基础上加入高级规则。

def split_text_with_overlap(text, chunk_size=512, overlap=64): if len(text) <= chunk_size: return [text] chunks = [] start = 0 while start < len(text): end = min(start + chunk_size, len(text)) # 尝试在边界处切分,避免切断完整语义 if end < len(text): last_newline = text.rfind("\n", start, end) if last_newline != -1 and last_newline > start + chunk_size * 0.5: end = last_newline chunks.append(text[start:end]) if end >= len(text): break start = max(0, end - overlap) return chunks

3.3 构建索引与查询链路:一段代码看懂全流程

先看索引构建部分。假设你已经把文档读取成纯文本,第一步是切分,第二步是embedding,第三步是存储进FAISS。代码大致长这样:

import faiss import numpy as np from sentence_transformers import SentenceTransformer # 加载embedding模型,这里用BGE-M3开源模型 embedder = SentenceTransformer("BAAI/bge-m3") # 假设docs是切分好的文本块列表 def build_index(docs): embeddings = embedder.encode(docs, normalize_embeddings=True) dim = embeddings.shape[1] index = faiss.IndexFlatIP(dim) # 内积距离,配合归一化向量就是余弦相似度 index.add(embeddings) return index

查询阶段要做的是:把query也embedding成向量,然后在index里检索,拿到原始文本块,再交给LLM。

def query_rag(query, index, docs, top_k=5): query_vec = embedder.encode([query], normalize_embeddings=True) distances, indices = index.search(query_vec, top_k) retrieved_docs = [docs[i] for i in indices[0]] context = "\n\n".join(retrieved_docs) prompt = f"""请根据以下资料回答问题。如果资料中没有相关内容,请直接说“未找到相关信息”,不要编造。 资料: {context} 问题:{query} 回答:""" # call_your_llm在这里替换成你所使用的大模型调用 response = call_your_llm(prompt) return response

当然,这只是最小链路,生产环境里还得处理query改写、混合检索、重排、缓存等。但把这个跑通之后,你就有了一条可以迭代的基线。

3.4 一个具体例子:让系统回答企业知识库问题

假设知识库里存了一批公司制度文档,用户问:“年假可以跨年吗?”

离线索引阶段先把《员工休假管理办法.pdf》解析成文本,切成若干块。其中一块可能是“年假原则上应在当年休完,因工作原因未休完的,经批准后可延期至次年3月底前使用。”

查询阶段,用户query“年假可以跨年吗”的向量和这块文本的向量距离很近,被召回。重排后保留。LLM看到这块资料和自己的问题,就能生成:“根据公司制度,原则上应当年休完,因工作原因经批准可延期到次年3月底。”

整个过程没有让模型凭空猜,而是让模型做“读材料回答问题”这件事,答案自然更可控。

4. 检索质量优化:召回率与准确率的博弈

4.1 embedding模型决定召回上限

很多人以为RAG效果不好是LLM的问题,结果换了更强的LLM,发现还是答非所问,因为根本没把对的资料检索出来。检索是RAG的天花板,embedding是检索里的第一步。

中文场景下,首选支持中文的向量模型,比如BGE系列、M3E等。做技术咨询时,我建议用在特定领域语料上微调过的embedding,或者自己标注一批“问句-文档块”pair做领域微调。

这里有一个重要技巧:如果你的知识库里有大量短文本,比如FAQ问答对,普通向量模型可能区分度不够。这时候可以把FAQ的“答案”部分也拼进embedding的输入,或者构建单独的倒排索引。我的经验是,对FAQ类数据,用“问题+答案”一起embedding,比单独embedding问题效果要好很多。

4.2 混合检索:向量和关键词不是二选一

我强烈建议在生产环境用混合检索。做法很简单:对同一个query,分别跑一次BM25检索(基于倒排索引,匹配词项)和一次向量检索,然后把两边的结果合并,在重排阶段统一打分。

为什么能提升?向量检索处理同义词、语义相近的表达;BM25处理精确匹配、专有名词、编号。比如设备型号“XG-2000”,向量模型可能把它当成普通词,但BM25能精准锁定文档。反过来,用户说“显示器不亮了”,文档里写的是“屏幕黑屏”,BM25很难命中,向量就能匹配。

合并时要注意去重。同一文本块可能同时被BM25和向量召回,用代码或库函数去重后,再进入重排。召回数量可以略多,比如各取30条,合并去重后留下50条,重排后只留top3。

下面是一个典型的混合检索示例:

def hybrid_search(query, top_k=50): bm25_results = bm25_index.search(query, top_k) vector_results = vector_index.search(query, top_k) merged = merge_and_dedupe(bm25_results, vector_results) return rerank(query, merged)[:5]

4.3 重排序模型:最后一公里的精挑细选

召回阶段为了不漏掉,宁可多召回。但top50直接全塞给LLM,prompt会很长,而且无关信息会干扰回答。重排序就是在这时候登场:它用交叉编码器(cross-encoder)把query和每个候选文本块拼在一起,走一遍完整模型,输出一个相关度分数。这个计算比向量检索的“算距离”耗时更久,但准确率更高。

重排模型的选择上,可以用专门的中文RAG重排模型。它不同于embedding模型,embedding模型是“把文本映射到向量”,重排模型是“逐对判断相关性”。我实测,一个质量不错的重排模型能在原有基础上再提升至少10到20个百分点的检索准确率。

重排之后到底取几个片段给LLM?我建议取3~5块。如果问题比较复杂,涉及多个知识点,可以取到5块。注意要按得分顺序排列,最相关的内容放在最前面,因为LLM对上下文开头的注意力更强,这一点我后面会在常见问题里再讲。

4.4 查询改写与多路召回

用户query往往不适合直接检索。第一类问题是口语化,第二类是指代不清,第三类是意图太宽泛。

查询改写的常用方法有两种:一种是基于规则,比如把“上个月”换算成具体月份,把“那个文档”扩展成文档名称;另一种是用大模型来改写,让LLM根据历史对话或业务背景把query改写成适合检索的形式。

有一种我称之为“多路召回”的做法也值得体验:同一个query,同时用它本身、改写后的专业版、抽取出的关键词组合版,分别去检索,再把所有结果合并重排。这样能覆盖用户口语表达和文档书面语表达之间的语义差距。代价是多路召回会引入更多噪声,所以重排环节一定要跟上。

4.5 调参实战:一张表看清主要优化点

优化点推荐做法效果成本
embedding模型用领域微调过的中文模型召回率提升明显低
分块策略按结构切分,chunk=512,overlap=64命中相关性提升低
混合检索BM25 + 向量 + 合并去重精确匹配和语义匹配都覆盖中
重排序交叉编码器模型准确率大幅提升中
查询改写LLM改写或规则改写解决口语、指代不清中
多路召回多query分别检索后合并提高召回率但噪点增加高

这张表不是让你全上,而是看自己的瓶颈在哪。如果检索结果里经常没有相关文档,优先优化embedding和分块;如果检索到了但排序靠后,优先加重排;如果检索结果很乱,优先做查询改写。

5. 常见问题与排查技巧实录

5.1 检索到了正确文档,但回答还是在胡编

这个现象我见过很多次。系统明明召回了一段包含正确答案的文本,LLM却自己发挥,生成的回答和原文对不上。

第一个原因通常是prompt里没有强制约束。你的提示词只说了“请根据资料回答”,但没有说“只能根据资料回答,如果资料中没有就明确说不存在”。模型在模糊空间里就容易自由发挥。我的建议是提示词写得绝对一点,甚至可以用“严格基于以下资料,不要使用你自己的知识”这类表述。

第二个原因是上下文拼接顺序。你把最相关的片段放在了最中间,但LLM对开头和结尾内容的注意力更强。按相关度降序排列上下文,把最重要的一块放最前面。

第三个原因是片段里混入了大量无关文本。召回5块,只有1块有用,其他4块是噪声。即使同样的字数,噪声会分散模型的注意力。这时候应该减少topK,或者优化重排效果。

5.2 检索不到相关内容,答非所问

如果LLM回答得比较流畅,但你一眼就知道知识库里有正确答案它没命中,问题出在检索侧。

先检查分块粒度。一个长段落长时间被切成很多块,正确答案被切碎在某个块里,query向量和这个块的距离可能没被算出来。可以增大overlap,或者用基于语义边界的切分。

再检查query是否需要改写。用户问“这个怎么操作”,文档里都写“某功能的路径是”,向量差距很大。开启query改写后往往能解决。

还有一个容易忽略的点:embedding模型对文档的语言风格敏感。如果文档是标题式、碎片化的表达,而你用的embedding模型主要是在连贯文本上训练的,效果会打折。可以把文档块的重心放在“句子完整、信息集中”的段落上,适当忽略没有实质内容的标题和说明性文字。

5.3 性能慢:延迟从哪来吃掉了

RAG系统的延迟主要由三部分构成:检索、重排、LLM生成。检索通常是毫秒级,但如果向量库索引没有优化,数据量大时也可能飙到几百毫秒。重排模型通常比embedding模型重很多,如果对50个候选都做重排,延迟会明显上升。LLM生成则是大头,尤其输出内容较长时。

我建议:

  • 把FAISS索引先量化压缩,比如用IVF或HNSW索引,检索速度能提升一个数量级;
  • 重排的候选数量不要盲目设太大,50个已经是上限,一般20~30个足够;
  • 用Cache缓存高频query的结果,比如用户经常问同一个制度条款,完全可以缓存最终回答;
  • 如果大模型服务支持流式输出,尽早把已生成内容流式返回,用户体验会好很多。

5.4 怎么评估RAG系统:别只看感觉

很多人做RAG评估只靠“人工看几个例子”,这不够。我至少会用三个指标:

  • 召回命中率:在测试集里,有多少问题检索到了包含正确答案的文档块;
  • 回答正确率:生成的答案是否与标准答案一致,或是否涵盖标准答案的关键点;
  • 幻觉率:生成的答案里有事实错误的比例,需要人工或另一个模型来判断。

在我自己的项目里,我会构建一个几百条的问题测试集,每条包含“问题、标准答案、所在文档路径”。先跑检索,看召回命中的比例;再跑生成,把回答与标准答案做关键词覆盖匹配,再人工抽检。做调优时,每次只改一个变量,记录指标变化,避免“玄学调参”。

这里有一个我常用的评估小技巧:当你看不到哪个环节问题时,把检索结果直接打印出来看。如果检索结果与问题相关,说明问题在生成;如果检索结果本身乱七八糟,说明问题在索引或召回。把链路拆开,逐段观察,是排查RAG问题最有效的方法。

最后再分享一个从实战中得到的经验:RAG系统的知识检索增强,永远不要迷信“最先进的模型”或“最全的框架”。我见过很多团队砸了大量算力在调LLM上,最后发现效果差只是因为文档解析时把表格内容弄丢了,或分块时把关键条款切成了两半。先把基础链路做扎实,把检索质量做到“精准命中”,再回来优化生成——这条路线几乎不会走偏。希望我这篇内容能帮你早点避开这些坑,把时间花在更有价值的事情上。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进