ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

RAG演进之路:从朴素检索到模块化架构的工程实践

RAG演进之路:从朴素检索到模块化架构的工程实践

在RAG(Retrieval-Augmented Generation,检索增强生成)这个方向上待得久一点,你会明显感受到一条演进曲线:早期大家忙着把检索和生成“拼起来跑通”,后来开始争论“检索要不要混合”“重排能不能救场”,再往后推演进到把整条流水线拆成可替换的模块。这篇先梳理上半场:从朴素RAG到精细化检索,再到模块化架构,把核心选型原因和落地经验一起讲透。适合正在做知识库问答、私有化文档分析、或者准备优化RAG效果的人,读完能照着一套进阶方案动手改造。

1. RAG为什么不得不演进

1.1 大模型天生的短板:知识停在训练截止日

我们用的生成式大模型,本质上是一个“超大规模词语接龙器”。它根据提问上下文,按概率预测下一个词,然后把词串成句子。这个机制带来的结果很稳定:能力再强,也只会回答它训练语料里见过的东西。训练语料有截止日期,那就意味着这个模型天然“不知道”截止日期之后发生的事;训练语料聚焦通用互联网,那就意味着它天然“不知道”你家内部的流程文档、产品手册、客户工单。

这不是缺陷,是机制本身造成的边界。我习惯用一个类比:把大模型当作一个很会背书的小孩,你问它背过的内容,它答得头头是道;你问它没背过的内容,它为了不让场面尴尬,会自己编一段听起来像样的答案。这个“编”就是大家常说的幻觉。很多项目一开始做知识库问答,第一反应就是“把文档喂给大模型”,实际上是治标不治本。喂再多的少样本示例,模型依然会把内部知识当成基准答案,该编还是编。

真正要解决的问题不是让模型“记住更多”,而是让它在回答之前“看到相关资料”。这就是RAG出现的最原始动力:给模型开卷考试,允许它现场翻参考书。

1.2 从“能回答”到“答得靠谱”:一次链路级升级

RAG把“大模型回答问题”拆成了两个步骤。第一步,根据用户的问题,从外部知识库检索一组相关文档片段;第二步,把这些文档片段作为参考资料,连同用户问题一起交给大模型,让它基于资料生成答案。这样一来,理论上答案有了依据,知识可以随时更新,还能在一定程度上做溯源。

我接触到的大部分团队,第一版RAG都是这么搭的:加载文档,切块,向量化,用户一提问就在知识库里用向量检索捞Top5,拼进Prompt里让模型回答。跑起来很快,demo效果也不错。但放到真实业务里就会发现,这个“查了再答”的朴素链路问题很多:切块切得太大,检索结果里一半是噪音;切块切得太小,语义不完整,模型看上下文看不懂;用户问“今年报销标准和去年有什么区别”,检索到的却是两段混在一起的历史版本;Top5里相关的内容只排到第7名,后面直接答错。这些问题一开始看起来是“运气不好”,实际是链路设计得太粗糙。

于是RAG开始演进。演进的方向不是把某一个环节换成更强大的模型,而是把整条链路拆得足够细,让每一个环节都可控、可调、可评估。从朴素检索到重排序、混合检索、查询改写、结构化切分,再到模块化编排,本质上都是在回答同一个问题:怎么让“查”更准,怎么让“答”更稳。

2. 先看清三个演进阶段:Naive、Advanced、Modular

2.1 朴素RAG:大模型第一次学会“现场翻书”

朴素RAG,也就是最经典的“文档切块→向量入库→TopK检索→拼接生成”。我把它叫作“首次通话方案”,它能解决最关键的问题,但通话质量不稳定。

流程看着很简单,但里面每一步都有隐藏陷阱。文档切块时,如果无脑按固定字符数切,很有可能把一段完整表格拆成两截,或者把一个条目的标题和正文拆散,导致检索命中片段却看不懂上下文。向量化时,选择嵌入模型如果不匹配你的语料语言和领域,召回效果会直接崩盘。检索时,只用向量往往忽略精确关键词匹配,导致产品编号、日期、人名这类信息召回不到。生成时,Prompt如果只是简单把资料拼在后面,模型还是会把资料当参考而不是依据,照样可能自由发挥。

我见过一个典型项目:知识库里存的是产品维修手册,用户检索“型号A的电源模块更换步骤”。朴素RAG召回了几个片段,长得都跟“电源模块”相关,但有一个片段讲的是型号B的注意事项,一个片段没有完整步骤。大模型把这些拼接起来,生成了一个看似合理但步骤顺序混乱的回答。问题不在生成模型,而在检索质量。于是行业顺理成章进入了下一阶段。

2.2 进阶RAG:把“查资料”这件事做精细

进阶RAG的改进集中在检索链路,核心思路是“不要在出生在false start之后直接放弃”。我在实际项目中最常见的一组组合是:

  • 查询改写:用户原始问题可能是口语、指代、反问,比如“那它贵吗?”这时候需要先改写为适合检索的独立问题。可以用规则,也可以用大模型做轻量改写。
  • 混合检索:同时跑关键词检索和语义检索,再把两路结果融合。
  • 重排序:先召回一个较大的候选集,比如100条,再用一个更强的排序模型逐条比对query和文档,挑出最相关的TopN给生成阶段。
  • 结构化解耦:不再固定切块大小,而是根据文档层级、目录、表格、思维导图等结构感知切分。可以做成“小片段负责命中,大片段负责上下文”的父子结构。
  • 元数据过滤:检索前先根据业务字段过滤,比如只看最新版本、只看当前部门、只看上架商品,而不是全局搜索。

进阶RAG解决的问题非常明确:把Naive阶段“检索结果一路滚雪球”的错误分摊到各个可控环节。查询改写解决“问题问得不对”,混合检索解决“一种检索武器不够用”,重排序解决“粗召回精度不够”,元数据过滤解决“范围没圈定”。这一阶段的工程价值远大于算法价值。

2.3 模块化RAG:把流水线变成乐高积木

模块化RAG是这两年明显的一个演进方向。它不再规定“必须先检索再生成”,而是把能力拆成独立模块,按需组合。除了常规的检索、重排、生成,还出现了查询路由、记忆管理、多路检索、外部工具调用、反思反馈等模块。

举个最简单的例子:用户说“早上好”,完整跑一遍检索和生成纯属浪费。这时候路由模块可以先判断意图,如果只是寒暄,直接走闲聊分支;如果是具体业务问题,再走检索分支。复杂一点的需求,比如“对比一下我们产品和某竞品的参数差异”,路由模块可以把问题拆成子查询,分两路检索,再合并结果给生成模块。再往后,还可以把结构化数据库查询、外部API调用都接进来,让RAG不再局限于静态文档。

模块化带来的直接好处是可维护性。每个模块独立升级、独立测试,出了问题能单独定位。它还为大模型Agent化留了接口:Agent的核心就是“规划—调用工具—观察—再行动”,模块化RAG本身就具备了工具化基础。很多团队实际落地时未必一步到位,但架构上会预留路由层和编排层,方便后续演进。

3. 支撑演进的关键技术选型与实践取舍

3.1 向量检索与嵌入模型:为什么要拥抱“语义匹配”

在RAG链路里,最核心的检索器是向量检索。它把一段文本转换为一个高维向量,然后通过向量之间的距离找到“语义相近”的文本。比起传统关键词匹配,向量检索能解决“说的是同一个意思、但用词完全不同”的问题。比如用户问“这个本子多少钱”,知识库里写的是“售价199元”,关键词检索很难把“多少钱”和“售价”挂上钩,向量检索则可以做到。

但向量检索不是万能的。它对嵌入模型的质量非常敏感。选嵌入模型时,我会看三个点:第一,是否支持你业务里的主要语言,中文场景别选一个以英文为主的模型;第二,维度是否适合你的检索规模,常用的中文模型一般是768维到1024维,维度越高精度潜力大,但存储和计算开销也大;第三,是否和你的领域语料匹配,通用模型放到医疗、法律、制造等专业术语密集的语料里,效果会明显打折。做法是先拿一小批真实问题做召回测试,而不是只看榜单分数。

相似度度量也要选对。绝大多数场景用余弦相似度就够稳定,它对向量长度不敏感,适合文本语义匹配。内积速度更快,但需要向量归一化之后才能和余弦结果一致;欧式距离在个别场景下会偏向量模长,文本检索很少用。具体的嵌入计算不需要我们去实现,但调用接口时要知道向量库默认距离算法是什么,别拿内积库硬套余弦。

关于检索类型的选择,可以看下表:

维度传统关键词检索(BM25)向量语义检索
原理基于词频和文档频率打分基于语义向量距离
优势精确匹配强,可解释,无训练成本语义相似召回强,跨语言通用
劣势同义词、意译问题明显对模型依赖高,不擅长精确数字匹配
适用场景产品名、编号、错误码、法律条款开放式问答、意图相近的文档召回

真实项目里很少有人只用一个。这也是下一步要说的混合检索。

3.2 重排序:检索的最后一公里,别把TopK当最终答案

向量检索的优势是“广泛撒网”,代价是不够精细。候选取回100条甚至更多,里面真正和用户问题强相关的可能只有三四条。如果直接截断Top5进入生成,很可能丢掉真正命中的那条;如果塞进Top50,生成模型又要被大量噪音干扰。重排序就是解决这个二段式问题的标准手段。

具体做法是:向量检索先粗召回100到200条候选,然后交给一个重排序模型,对每条候选和用户问题逐对计算相关性分数,按分数重新排序,最后取Top5到Top10交给生成模型。重排序模型通常采用交叉编码结构,让问题与候选文本完整交互计算,相关性判断比“先向量化再比距离”的双塔方案更准。

实际选型时要注意性能和精度的平衡。重排序模型一般比嵌入模型重,不能直接跑在海量文档上,所以它只能作用于“小规模候选重排”。我在一个私有化知识库项目里用过的参数组合是:向量召回Top100,BM25召回Top100,融合后保留Top50,重排后再取Top6。这个组合既避免了“只靠一路召回”的偏科,又控制了重排阶段的耗时。需要注意,重排模型对输入长度有上限,超过512个token的长文本要先截断或者抽关键句子,否则容易报错或丢失关键信息。

3.3 混合检索与Score融合:给关键词和语义各留一席之地

混合检索说白了就是“一条腿不够,两条腿走路”。向量检索擅长语义,但遇到精确匹配容易翻车;BM25擅长精确匹配,但理解不了同义改写。两个结果合并时,最怕两路返回的分数量纲不一致,没法直接相加。这时候用RRF(Reciprocal Rank Fusion,倒数排名融合)是个稳妥的办法。

RRF不关心原始分数,只看每条结果在两路检索结果里的排名。公式很简单:score = 1 / (k + rank),其中k是常数,一般取60。把每路结果按排名计算得分,同一文档在两路都出现时得分相加,最后按融合分排序。举个小例子:某文档在向量检索里排第2,在BM25里排第10,它的RRF得分是1/(60+2) + 1/(60+10) ≈ 0.0161 + 0.0143 = 0.0304;另一条只在向量检索里排第1的文档得分是1/61 ≈ 0.0164,前者就能反超。这样关键词命中和语义命中都能贡献分,融合算法也不需要调权,工程上好维护。

也有人用归一化加权:把两路分数各自归一化到0到1再乘权重相加。这种方案可以做精细调优,但权重很敏感,换个语料就得重新调,容易过拟合。我的建议是:第一版先用RRF,快速看到融合效果,等发现某种检索明显更可信时,再考虑加权或级联式检索。

3.4 切分策略与元数据过滤:知识不能变成断片

切分是RAG里最不起眼却最容易出问题的环节。切块太小,一段话的语义不完整,检索命中一个碎片模型看不懂;切块太大,块内部噪音多,检索分数被无关信息稀释。我常用的基线是递归字符切分,chunk_size设为512个token,overlap设为50。overlap就是为了避免句子被一刀切断,相邻两个块有少量重叠,能缓解这个痛苦。

进阶一点的做法是根据文档结构切分。比如Markdown按标题层级切,PDF先解析段落和表格后按版面切,合同按条款切。如果文档有明显的“标题—正文”结构,可以把标题作为元数据绑定到下面的正文块,检索时能知道这个块属于哪个章节。再进一步,可以做父子块结构:小片段用于精确匹配,命中后返回其所属的大上下文块给生成模型,兼顾“命中准确”和“上下文完整”。

元数据同样不能省。来源、页码、更新时间、所属部门、文档类型都是很有用的过滤字段。比如一个企业知识库里同时存在旧版和新版制度文件,用户问“现行报销标准”,如果不过滤版本号,检索就会把旧版也召回,模型就分不清到底该信哪一份。元数据过滤要在向量检索之前做,把查询限定在当前时间、当前版本,召回效果会立刻提升一个档次。

4. 手把手搭一个进阶RAG流水线

4.1 技术栈与环境约定

搭一条进阶RAG,不需要从零写算法,重点是串好组件。以下我以一个通用配置为例:一个外部大模型API兼容服务端负责生成,一个开源向量数据库负责索引和检索,一个RAG编排脚本负责串联,嵌入模型用开源中文通用模型。组件名不关键,你可以换成自己熟悉的那一套,关键是理解每一段代码背后的阶段作用。

环境上,建议准备一个Python环境,安装调用大模型接口的SDK、向量数据库客户端、文档解析库。切分可以用递归字符分割器,也可以自己写基于正则的切分。额外准备一个重排序模型接口,装好之后先单独测一路,再进流水线。

4.2 文档加载与切分实操

文档加载一般分成两类:干净Markdown或TXT,直接读文本;PDF或扫描件,需要先做版面解析。我的习惯是先转成Markdown格式再切分,这样能保留标题结构。

切分时要记录每个chunk的来源和标题。下面是伪代码逻辑,方便替换成你自己的类名:

# 伪代码:请替换为实际可用的组件 from doc_loader import load_docs from text_splitter import RecursiveCharSplitter docs = load_docs("data/knowledge_base/") splitter = RecursiveCharSplitter(chunk_size=512, chunk_overlap=50) chunks = [] for doc in docs: for chunk in splitter.split_text(doc.content): chunk.metadata = { "source": doc.filename, "title": doc.title, "page": doc.page_number, "updated_at": doc.updated_at, } chunks.append(chunk) print(f"文档切分完成,共生成 {len(chunks)} 个片段")

这里的chunk_size为什么是512?因为大多数开源嵌入模型在512个token以内的语义表示比较稳定,超过这个长度,向量会稀释成“一锅粥”,检索精度下降。overlap选50是为了让断句处有缓冲。如果切出来发现大量块只有十几个token,说明chunk_size偏小,或者文档本身很碎,可以适当调大。

4.3 索引构建:向量化、存储、元数据过滤

切分完成后,下一步是向量化入库。嵌入式模型一般有接口函数,输入文本列表,输出向量列表。调用时建议批量处理,比如一次传64条文本,不要一条一条调,那样接口压力大且速度慢。向量化完成后写入向量数据库,同时把元数据字段建立过滤索引。

索引参数也需要说清楚。绝大多数向量库在近似检索时使用HNSW图索引,有两个关键参数:M(每个节点的最大连接数)和efConstruction(构建索引时候选集的动态大小)。一般经验是M设16,efConstruction设100,检索时efSearch设64。M越大索引越精确但内存占用越高;efSearch越大召回越准但延迟变高。如果文档规模不大,甚至可以直接暴力检索,反而最准。

还有一个需要提前估算的指标:内存占用。假设你有10万个切块,嵌入维度768,每个float占4字节,向量矩阵大约需要 100000 × 768 × 4 = 307MB。加上索引和原始文本,单机内存基本能扛住。如果到百万级,就要考虑分片或者降维。

代码流程如下:

# 伪代码:批量向量化并写入向量库 embedding_model = load_embedding_model("chinese-general-768") vectors = embedding_model.encode([c.text for c in chunks], batch_size=64) vector_db = connect_vector_store("local_index") for chunk, vec in zip(chunks, vectors): vector_db.insert( id=chunk.id, vector=vec, text=chunk.text, metadata=chunk.metadata, ) vector_db.create_index(metric="cosine", params={"M": 16, "efConstruction": 100})

4.4 查询链路:改写、召回、融合、重排、生成

查询阶段是展示RAG技术演进最直观的地方。我按步骤拆开:

第一步,查询改写。如果是多轮对话,把历史信息和当前问题合成一个独立query;如果是大段问题,提取核心检索关键词。这一步可以用大模型做,也可以写规则,核心目的是让检索更稳。

第二步,并行召回。向量检索和BM25检索同时跑,各自取Top100。

第三步,RRF融合。对两路rank list计算倒数排名融合,取Top50。

第四步,重排序。把Top50交给交叉编码器,逐条和问题计算相关性,排序后取Top6。

第五步,生成。把Top6拼入Prompt,设置角色和约束,调用大模型生成答案。

# 伪代码:完整查询链路 def rag_answer(query, history=""): refined_query = rewrite_query(query, history) vector_hits = vector_db.search(refined_query, top_k=100) bm25_hits = bm25_index.search(refined_query, top_k=100) fused = rrf_merge([vector_hits, bm25_hits], k=60, top_n=50) reranked = reranker.rerank(query, fused, top_n=6) context = "\n\n".join([hit.text for hit in reranked]) prompt = f""" 你是一个企业知识库助手。请只根据下面的资料回答用户问题。 如果资料中没有相关信息,请回答:资料中未找到相关信息,请不要编造。 资料: {context} 用户问题: {query} """ return llm_chat(prompt)

Prompt模板这段值得多说。很多人以为RAG里Prompt就是“把内容拼上去”,其实生成阶段的约束直接决定有没有幻觉。我在模板里明确写了“只根据资料回答”和“未找到要明说”,这两句话能大幅降低编造概率。如果业务允许,还可以再加一步“让模型先判断资料是否充分,再回答”,虽然多一次调用,但稳定性更好。

4.5 评估闭环:不量化效果的RAG都是盲盒

没有评估的RAG项目,优化起来全凭感觉。我见过团队改了切分参数,觉得“好像好了一点”,但一问之前哪些badcase修过、哪些变差,完全说不清。正确的做法是准备一套固定测试集,至少50到100个问题,每条问题标注正确答案或相关参考段落,每次改动后统一跑一遍。

常见的自动化评估指标有三个:忠实度,看生成的答案是否严格基于检索到的上下文;答案相关性,看答案是否回答了用户问题;上下文相关性,看检索到的片段和问题相关度。忠实度用大模型打分是目前比较主流的做法,虽然不能完全替代人工,但能快速挑出明显坏case。我习惯先自动跑分,再人工抽检20条,最后把所有badcase归类成表格:是召回没召回对应段落,还是重排把正确段落排在后面,还是Prompt约束没生效。

评估还有一个隐藏作用:推动演进方向。当你发现badcase集中在“上下文切碎导致看不懂”时,你会主动去做父子块结构;当你发现“特定版本数据召回错误”时,你会去加元数据过滤。RAG的演进不是追新名词,是评估结果驱动的工程迭代。

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

5.1 检索不到内容:先别怪模型,先看召回

这大概是RAG项目里最高频的问题。表现是:用户问了一个明显在知识库里存在答案的问题,模型却回答“未找到相关资料”或答非所问。排查顺序有几步。

第一步,打印检索结果,看Top50里到底有没有相关内容。如果连Top50都没有,问题出在向量化或切分,而不是重排。第二步,尝试用关键词直接搜原文,如果BM25能搜到而向量搜不到,说明嵌入模型不理解这个领域的术语表达,考虑换领域适配模型或加强混合检索。第三步,检查切分后的片段内容,如果一条完整信息被拆成几个残缺片段,需要调整切分策略。还有一招很实用:把用户的问题改写一下再检索,比如“它便宜吗”改成“价格是否实惠”,很多时候召回就通了。

5.2 检索到了但答案还是胡诌:重排和Prompt一起查

另一种典型现象是:检索结果里明明有正确答案,生成结果却跑偏了。先检查重排后的Top6里,正确答案是在还是不在。如果正确答案被排到第7名以后,问题在重排模型,换一个更强的排序模型,或者调整召回候选集规模。

如果正确答案排在很前面,问题多半在Prompt。模板里如果没有明确“仅依据资料回答”,或者上下文太长导致模型注意力分散,模型就可能自由发挥。我踩过一次坑:上下文拼接了10个片段,正确答案在第2段,但第1段和第7段看起来很像,模型最终按第7段编了一个错误方案。后来把TopN从10调到5,并且每段前加了“文档片段n:”标注,问题率明显下降。

5.3 知识库更新不及时:索引生命周期不能忘

RAG最大的卖点是知识可更新,但很多项目上线后根本没有做增量更新机制。今天改了制度文件,明天用户问的还是旧答案,因为向量库里的旧版没删,新文档没入库。

实际方案是:入库时给每个文档生成唯一ID,并记录更新时间。每次更新时,先按ID删除旧向量,再插入新向量。如果不能精确定位,可以按document_id前缀删除。更稳妥的做法是按批次重建索引,比如每天晚上定时增量同步。我这里有个小建议:在一个真实业务里,我们对知识文档做了“审核后发布”流程,只有审核通过的版本才进入向量库,避免了半成品文档污染检索结果。

5.4 避坑速查表

把前面常见问题整理成一张表,方便排查时对照。

现象可能原因排查与解决
检索Top50没有相关片段切块过小/嵌入模型不适配/查询口语化调整切分策略、换领域模型、加查询改写
关键词都有但语义检索不到忽略了关键词精确匹配启用BM25混合检索
正确片段被排到后面重排模型能力不足换更强的交叉编码器,或减小候选集
生成答案跑题Prompt约束不够/上下文过长明确“仅依据资料”,缩减TopN,加片段标注
旧版数据反复出现元数据没有版本过滤建立版本元数据,检索前过滤
答案和资料对不上大模型自由发挥了加“未找到请说明”指令,必要时二次判断
索引更新后旧答案还在没有删除旧向量按文档ID做增量覆盖或重建索引

这张表是我在日常排障时最常用的checklist。每个问题往往不是单一原因,所以排查的时候一个个环节验证、缩小范围,比原地空想要快得多。

在RAG这个领域,有一个观点我一直很认同:不要试图用一个超强模型一次性解决检索和生成,而是把每个环节的误差控制在可接受范围内。朴素RAG告诉我们可以这么做;进阶RAG告诉我们应该做得更好;模块化RAG则给了我们未来可扩展的骨架。上篇把主线路和排障经验讲完,后续可以再往GraphRAG、Agent化调度、以及更细粒度的评估方向展开。我自己最大的体会是,RAG的演进本质是一轮又一轮“发现badcase—修复—评估”的循环,技术名词会变,但这套迭代方法不会变。

RELATED READING

延伸阅读

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