RAG生产级优化:从文本切分、混合检索到Rerank的完整链路拆解
我一直觉得RAG项目跟谈恋爱有点像——大头都花在“怎么把人追到手”上也就是把检索质量做上去至于最后能不能顺利走下去反而很少有人花心思去琢磨。最近带团队把几个RAG项目从demo推到生产从召回率、幻觉率到响应延迟一项项抠下来发现真正把效果拉开差距的往往不是用了多牛的模型而是一些看起来不起眼的工程细节。这篇东西我不打算写那种“调个参数就起飞”的玄学而是把一套从检索到生成的优化链路完整拆开文本切分怎么做、混合检索如何搭配、rerank要不要上、提示词怎么跟上下文配合以及最后怎么用评估指标说服自己和老板系统真的变好了。内容比较长涉及的代码也都是直接能跑的片段建议先收藏再慢慢看。1. 先搞清楚你的RAG到底“病”在哪拆解检索质量的三个短板很多人一上来就埋头调参但我觉得第一步应该是建立一套诊断框架。RAG的效果最终由三个环节共同决定:检索到的内容准不准(retrieval quality)、塞进上下文的文本有没有用(context quality)、以及大模型有没有被无关信息带偏(generation quality)。三个环节互相牵制你不拆开看很容易出现“修了A坏了B”的尴尬局面。1.1 第一个短板召回“够用”但“不够准”我见过太多团队在评估时只用recallk,也就是看正确答案在不在召回结果里。这个指标好看,但其实掩盖了大量问题。比如你问“这家公司的营收连续三年下滑的原因”系统召回了一堆关于这家公司的介绍、行业背景、甚至创始人的采访真正讲到财务数据的片段只排在第7位虽然recall10是100%但大模型在生成时根本不会认真看第7位之后的材料效果可想而知。精确率(precision)才是生成质量更相关的指标,如果你把top-k从5调到3只要precision能稳住甚至提升生成质量通常会更好。我自己常用的一个粗暴办法是人工标注20~30个问题跑一轮检索后直接看top3片段的相关性。如果top3里有两段是“沾边但不直接”说明你的检索链路存在系统性偏差这时候调生成侧的提示词是治标不治本。1.2 第二个短板上下文塞得太多太杂上下文长度是RAG的隐形杀手。很多人的思维还停留在“LLM上下文越长越好”的阶段但实际测试下来你会发现大模型对中间位置的注意力显著弱于开头和结尾而且越靠后的无关信息越容易引发幻觉。我做过一个对比实验同样一个问题一个只塞3段高相关片段另一个塞8段含2段弱相关后者虽然理论上信息更全但生成结果里出现了更多原文没有的细节也就是俗称的“脑补”。所以优化RAG的第一步不是“怎么把答案生成得更好”而是怎么保证送进模型的每一段文本都是高价值的。这直接引出了检索侧的两个核心动作切分策略和重排策略。1.3 第三个短板提示词没有“承上启下”检索做得再好提示词写得稀烂照样前功尽弃。我见过不少团队的提示词就一句话“根据上下文回答问题。”这句话几乎等于没说模型只能猜你要什么格式、要不要引用来源、遇到上下文矛盾时怎么办。后面我会专门用一节讲怎么把提示词跟检索结果“咬合”起来。你可以在动手之前先做一件事把最近20条用户问题分为“事实型”答案固定比如某个API怎么用、“推理型”需要综合多段信息和“综述型”需要长文本归纳。你会发现不同类型的问题对检索和生成的要求完全不同后面很多优化决策都要回到这个分类上来。2. 文本切分是地基级优化块大小、重叠和语义边界怎么定检索效果的上限在切分这一步就被锁死了——如果切分出来的块本身语义破碎后面向量模型再强也白搭。可惜这恰恰是最容易被忽略的一环很多人直接按固定字符数硬切连段落标题都切飞了。2.1 为什么“固定512字符”的思路值得怀疑固定字符切分最大的问题在于它完全无视文本自身的语义结构。比如一个Python函数的文档字符串里包含了参数说明、返回值说明和异常说明如果被拦腰切成两块向量检索时无论哪一块都不具备完整语义查“这个函数的返回值”时匹配度天然偏低。我自己踩过的一个坑是早期用固定1024字符切一个技术FAQ文档结果大量问答对被拦腰切断最直接的后果是检索召回率看起来还行但生成侧经常把“问”的回答安到另一个“问”上。后来换成按段落标题层级切分同样的问题基本消失。2.2 语义切分的落地套路层级切分重叠窗口我在生产环境最常用的一套组合是先按文档结构粗切识别Markdown/HTML的标题层级一级标题对应一个大章节二级标题对应小节以此类推。把整个文档拆成“有明确边界的语义块”。对超长块做二次细切如果某个语义块比如一篇没有小标题的长文超过了模型embedding的输入上限再按段落或句子边界二次切分而不是硬按字符数。块与块之间保留15%~20%的重叠重叠部分的目的是防止跨段语义被拦腰截断。实际上重叠不应该只是字符重叠而是把上一块的结尾两三句话拼到下一块开头这样能最大程度保留上下文线索。这里需要解释一个关键参数——块大小。块太小比如小于200字符向量表达的语义粒度太细检索时容易出现“点到为止”但信息不足的片段块太大比如超过1000字符向量的语义被稀释跟query的相关性区分度反而下降。一般建议在300~800字符之间做实验我常用的是512字符左右但也见过法律文书类内容用800~1000更合适因为条款本身是个完整语义单元。2.3 代码和表格是切分的“特等生”如果你处理的文档里包含代码片段或表格别急着用通用方案。代码切片最好的方式是按函数/类定义切每个函数注释的第一段往往就是最精准的检索锚点。表格则建议逐行转成自然语言描述之后再做切分比如“产品A在第一季度的销售额是1200万元”这种格式而不是把表格原样塞进模型——后者既费token又不好检索。这里给个基于markdown标题层级和文本块的语义切分示例import re from typing import List, Dict def split_by_headings(text: str, max_chunk_size: int 800, overlap: int 100) - List[str]: 基于标题层级做语义切分保留标题信息作为chunk前缀。 lines text.split(\n) chunks: List[str] [] current_section: List[str] [] current_heading: str for line in lines: # 检测markdown标题作为新chunk的开始 if re.match(r^#{1,4}\s, line) and current_section: if current_heading: chunks.append(current_heading \n \n.join(current_section)) current_heading line current_section [] else: current_section.append(line) if current_heading or current_section: if current_heading: chunks.append(current_heading \n \n.join(current_section)) else: chunks.append(\n.join(current_section)) # 二次切分过长的chunk final_chunks [] for chunk in chunks: while len(chunk) max_chunk_size: split_pos chunk.rfind(\n, 0, max_chunk_size) if split_pos -1: split_pos max_chunk_size final_chunks.append(chunk[:split_pos]) chunk chunk[split_pos - overlap:] # 保留重叠部分 final_chunks.append(chunk) return final_chunks这段代码只是一个起点你完全可以根据自己的文档类型去调整标题正则、分块逻辑和重叠策略。3. 向量检索VS传统关键词混合检索为什么是稳健之选纯向量检索在遇到专业术语、编号、缩写时经常翻车比如用户在企业知识库里搜“ISO 9001条款8.3”向量模型可能把它跟“质量体系概述”搞混但BM25能精确命中“8.3”。反过来语义检索能匹配“怎么投诉快递延误”和“物流服务不满意如何维权”这种说法不同但语义相同的情况关键词检索则无能为力。所以我的观点很直接能上混合检索就上别把鸡蛋放在一个篮子里。3.1 混合检索不是简单地相加很多初学者以为混合检索就是把向量分数和BM25分数加在一起然后排序。实际上两种分数分布完全不同——BM25打分可以到十几甚至几十而余弦相似度通常在0到1之间直接相加等于让BM25主导整个排序。我建议的标准化方案是分别对两类分数做min-max归一化或z-score然后加权平均权重可以按业务场景调。比如代码检索场景BM25权重可能要到0.4~0.5而面对语义比较开放的FAQ场景向量权重可以调到0.7以上。3.2 引入RRFReciprocal Rank Fusion更省心如果你不想调权重直接上RRF是个更稳的方案。RRF的核心思想是只看排名不看分数避免不同分数分布带来的偏差score(chunk) Σ (1 / (k rank_i(chunk)))这里k一般取60。每个召回通道向量、BM25各自给出一个排名列表然后对每个chunk在两个列表中的名次做上述计算最后统一排序。这种做法抗干扰能力强不会因为某个通道打分尺度差异而崩掉。我在实际项目里测试过RRF的结果通常比手动调权重来得更稳定尤其是两个通道的效果比较均衡的时候。3.3 别忘了向量检索自身的两个调优按钮第一个是embedding模型的选型。如果你处理的都是中文内容建议优先考虑针对中文优化过的embedding模型而不是直接用通用的多语言模型。判断标准很简单直接拿你自己领域的20个问题100个候选文本段做一轮检索测试对比召回率。第二个是索引类型的选择。数据量小于100万条时暴力检索flat就够了精确度最高数据量再大再考虑HNSW、IVF这类近似索引。但要注意近似索引的召回率并不是100%如果业务对精确度极其敏感graph索引比如HNSW的曝光参数要调小一点。结合上面两种召回方式我实际用的方案通常是这样from rank_bm25 import BM25Okapi import numpy as np def hybrid_search(query: str, chunks: List[str], embedder, top_k: int 5, rrf_k: int 60): # 1. 向量召回 query_vec embedder.embed(query) chunk_vecs [embedder.embed(c) for c in chunks] sims [cosine_similarity(query_vec, cv) for cv in chunk_vecs] vec_rank sorted(range(len(sims)), keylambda i: -sims[i]) # 2. BM25召回 tokenized_chunks [chunk.split() for chunk in chunks] bm25 BM25Okapi(tokenized_chunks) bm25_scores bm25.get_scores(query.split()) bm25_rank sorted(range(len(bm25_scores)), keylambda i: -bm25_scores[i]) # 3. RRF融合 rrf_scores {} for rank, idx in enumerate(vec_rank): rrf_scores[idx] rrf_scores.get(idx, 0) 1 / (rrf_k rank 1) for rank, idx in enumerate(bm25_rank): rrf_scores[idx] rrf_scores.get(idx, 0) 1 / (rrf_k rank 1) # 4. 按融合分数排序取top_k ranked sorted(rrf_scores.items(), keylambda x: -x[1])[:top_k] return [chunks[idx] for idx, _ in ranked]这个写法虽然简单但已经是生产环境的可用骨架了你可以在它的基础上加过滤条件比如按文档来源、时间范围做pre-filter和post-filter。4. Rerank是性价比最高的一环为什么top5重排能顶过一大堆提示词工程混合检索能保证“相关的东西被捞上来”但排序依然不是理想的。向量和BM25都是“粗召回”真正能对语义相关性做细粒度判断的是跨编码器cross-encoder结构的rerank模型。这一节我会说清楚它到底解决什么问题以及怎么接入。4.1 Cross-encoder与bi-encoder的本质差异向量检索用的是bi-encoderquery和文档各自独立编码然后算相似度。这种方式速度快可以预先对所有文档建索引但query和文档之间的交互信息在编码阶段是缺失的。情理之中地很多细微的语义关系比如“A不是B的原因”和“A是B的原因”靠向量方向很难区分。Rerank用的cross-encoder则不同它把query和文档拼在一起送进同一个模型做交叉注意力计算相当于让模型“逐字对比”q和d的关系。计算量更大但精度上一个台阶。所以正确的姿势是先用bi-encoder做粗召回可能召回20~50条再用cross-encoder精排取前3~5条。4.2 在RAG链路里插入Rerank的具体做法很多框架比如LangChain都内置了rerank的接口但你自己写也不难。核心就是把query和每个candidate文本段拼成对送进rerank模型得到相关性分数按分数重排取top_k。需要注意的细节是candidate文本段的长度不能超过rerank模型的输入上限。像bge-reranker这类常见中文rerank模型长度上限通常几千字符超长的文本要先截断。另外rerank的输入最好保留原始文本格式包括标题、代码块标记等而不是用embedding阶段切好的chunk因为rerank需要更完整的上下文做判断。我建议的生产配置是向量召回50条 → BM25召回50条 → RRF融合取20条 → Rerank精排取3~5条。如果你的系统延迟预算很紧也可以把召回量缩小到20条但效果可能会略有下降。4.3 Rerank模型的选型与微调思路选型方面通用的中文rerank模型对于大多数场景已经够用。如果效果还不够好有两个优化方向收集业务数据做微调获取历史上“用户点击/采纳”的query, chunk对作为正样本随机采样或人工标注的弱相关对作为负样本用cross-entropy loss微调。规模不需要很大几千对就能见效。在prompt里让LLM自己选这其实算一种“穷人的rerank”——把召回结果直接给LLM让它排序或挑出最相关的3段。效果不错但token成本和延迟很高一般只作为兜底方案。在接入rerank之后你大概率会发现生成质量有一个肉眼可见的提升因为模型终于能在更靠谱的上下文上做推理了。5. 把检索结果翻译成大模型能高效利用的上下文提示词与上下文协同设计检索到的文本再高质量如果提示词不会“引导模型用好”相当于把上等食材炒成了食堂大锅菜。这一节讲的是我用过之后觉得最有效的几个策略核心思路是让模型知道该信什么、不信什么以及用什么形式输出。5.1 把“检索片段引用”结构化地写进提示词我强烈建议在送入模型的上下文中给每个检索片段编号并在提示词里要求模型“仅在引用某个片段的内容时标注编号”。实践中我的做法是这样构建提示词你是一名严谨的技术问答助手。请基于以下资料片段回答问题。 资料片段用[编号]标记每个片段之间用分隔线隔开。 [1] RAG的检索阶段通常包括query改写、混合召回和重排序。 [2] 重排序模型通常采用cross-encoder结构直接建模query与文档的相关性。 [3] 上下文长度过长会导致大模型对中间位置的注意力下降。 资料片段结束。 问题重排序为什么比向量检索更准 要求 1. 如果资料中有依据请直接回答并在句末标注依据编号如[1]。 2. 如果资料不足以回答直接说“资料中未找到充分依据”不要自行脑补。 3. 如果资料内部存在矛盾请分别列出不同观点并指出矛盾点。这么做有几个好处一是模型被迫逐条核对依据而不是含糊带过二是方便你做溯源和质检生成结果里带[编号]后你可以用程序校验声称的内容是否真的存在于对应片段中;三是幻觉率明显下降因为“不确定就直说”被写成了显式指令。5.2 针对不同问题类型切换提示词模板我前面提到过把问题分成事实型、推理型和综述型。Rerank之后的上下文处理应当根据不同类型做差异化的策略事实型问题提示词里要强调“答案必须来自资料片段不要添加额外信息”甚至可以让模型输出“直接引用原文”。这样最稳因为答案本身就该有唯一性。推理型问题强调“请先列出与问题相关的所有片段再逐步说明推理过程”并允许模型适当组织语言但推理的每个关键步骤都要有依据。综述型问题这类问题对上下文的需求往往很大如果只取top3片段信息可能不够。我的做法是提高检索的召回数比如top7~8但是用提示词要求模型先做“信息去重和归纳”再输出结构化综述。有一个工具性的小技巧RAG的生成器输出最好能包含一个“依据列表”技术实现就是在提示词里加一个输出格式要求。我常用JSON格式{ answer: 正文回答内容, evidence_ids: [1, 2], confidence: high|medium|low }这样做的好处是后续不管是要做人机审核、还是做答案置信度过滤都很方便。成本也很低只是让模型多输出几个字段而已。5.3 上下文太长时的压缩策略从“全塞”到“择优”我们前面提过大模型对长上下文的利用率不是线性的。经验值是当总token超过模型上下文窗口的一半时质量就开始明显下降。生产级RAG必须要解决三个问题万一召回结果超过上下文限制怎么办召回结果里确实有噪音怎么办用户问题很长本身占据大量上下文怎么办我的做法分三层上下文裁剪根据rerank分数设定一个动态截断阈值。比如只保留分数高于0.5的片段。这样即使召回20条最后送进去的可能只有5条。摘要压缩如果多个片段都在讲类似内容先让一个小模型或同一模型的轻量调用对这部分做摘要压缩再送进最终生成器。query改写清障对长query先做一轮问题理解提取关键实体和意图再拿去检索。这比拿原始长query去检索要准得多。6. 从“看起来聪明”到“可量化”用RAGAS搭建一套评估闭环没有评估就没有优化。很多团队在RAG项目上反复横跳就是因为凭感觉觉得“好像好了点”但说不清到底哪里好了。RAGAS是当前比较主流的开源评估框架它把RAG拆成忠实度(faithfulness)、答案相关性(answer relevancy)、上下文相关性(context relevancy)等维度并且可以用LLM-as-a-judge的方式自动打分。这一节我会给出一个精简但可落地的评估方案。6.1 评估指标不只是“答得对不对”RAGAS的核心指标里我重点盯三个忠实度(faithfulness)生成答案中的每一条陈述是否都能在检索片段中找到依据。这个指标能直接反映幻觉程度。我见过不少RAG系统答案看着有理有据但其实有一半是模型自己脑补的忠实度一测就露馅。答案相关性(answer relevancy)生成的答案是否真的回答了用户问题而不是答非所问或只回答了问题的一部分。上下文相关性(context relevancy)检索到的片段里有多少是真正必要的。这个指标偏低时说明你的召回/重排环节有冗余得回前面几节找原因。这三者是有层次的上下文相关性管“捞得对不对”忠实度管“生成有没有跑偏”答案相关性管“用户满不满意”。层次清晰了问题定位就快。6.2 一套最小可行的评估代码骨架评估的关键是准备一个评估集包含用户问题、标准答案可选、检索片段可动态生成。下面的代码展示了如何用RAGAS跑一遍核心指标from ragas import evaluate from ragas.metrics import faithfulness, answer_relevancy, context_relevancy from datasets import Dataset # 准备评估数据 eval_data { question: [ 重排序为什么比向量检索更准, RAG如何减少幻觉, ], answer: [ 因为重排序使用cross-encoder能直接建模query与文档的交互关系。, 通过提高上下文质量、添加引用标记、以及拒绝回答机制来减少幻觉。, ], contexts: [ [重排序模型通常采用cross-encoder结构直接建模query与文档的相关性。], [RAG应当在资料不足时拒绝回答并且要求模型输出依据编号。], ], } dataset Dataset.from_dict(eval_data) result evaluate( dataset, metrics[faithfulness, answer_relevancy, context_relevancy], ) print(result)在实际使用中评估集至少准备50条以上的问题覆盖前面讲的问题类型分类。并且每个版本迭代后都跑一遍记录分数变化而不是只看一两个案例“感觉变好了”。6.3 离线评估和线上监控缺一不可离线评估是用来做版本迭代的但上线后还需要监控真实用户的表现。我的经验是做两个层面的线上监控一是埋点统计用户对答案的点赞/点踩、复制行为这算隐式反馈二是采样一部分线上问答定期做人工标注再用评估框架跑分。只有这两个数据同时反馈你才知道离线优化方向跟线上真实需求是不是一致——如果不一致通常是评估集本身覆盖不够得回去补数据。另外一个容易被忽略的点评估集要跟着文档库更新。很多团队的评估集做了一次之后半年不变文档库快照都迭代好几轮了测试分数自然失真。建议每次文档更新量超过10%时就重新审视评估集把过时的query和expected answer同步更新。我在实际项目里用这套方式跑过好几轮第一轮的基础分context relevancy只有0.4左右忠实度0.65。经过混合检索rerank改造后context relevancy涨到0.72忠实度到了0.83。这个涨幅比我调过任何提示词都明显也从侧面说明了前面的链路优化比最后一公里的prompt更值得投入。7. 工程化落地要躲开的几个坑从延迟、成本到动态文档更新前面的优化围绕“效果”到生产环境你会发现“稳定”和“成本”同样决定项目成败。这一节我整理几个常见坑都是我们团队亲测踩过的。7.1 延迟预算怎么分配假设你的线上服务要求2秒内返回那么embedding query约50~100ms混合检索约100~300msrerank约200~500msLLM生成根据输出长度从800ms到数秒不等。这么一算就知道主要瓶颈在生成阶段而检索阶段即使上了rerank占比也不算大。所以我不建议为了省延迟把rerank去掉而应该优化生成侧比如用流式输出提升首字延迟体验或者控制max token上限。如果业务场景对延迟极敏感备选方案是并发调用多个切片生成答案再做合并或者先返回一个“粗答案”再后台生成精修版。7.2 缓存别让重复查询反复烧钱RAG生产环境有两大成本一是embedding API调用二是LLM生成token消耗。对于高频重复问题加一层缓存非常划算。缓存key可以用query的向量哈希或者RRF排序结果的指纹。命中缓存时直接返回历史答案延迟和成本都大幅下降。我见过一个比较简单粗暴但有效的做法把query去停用词、做归一化后作为缓存key的输入配合过期时间比如24小时。效果已经足够好没必要为了追求语义相似做太复杂的缓存逻辑。7.3 文档库是活的增量更新与失效处理如果你的知识库文档会定期更新务必处理好“向量索引同步”的问题。我的经验是每条文档记录一个版本号或更新时间戳增量更新跑一个定时任务只对变更部分重新做embedding和索引。同时删除也要处理——被删除的片段如果不从向量库里移除会继续被检索到用户就会看到过期信息。更隐蔽的一个问题是文档间的相互覆盖。比如新版FAQ说“退货政策已调整”但旧版FAQ没删检索时可能同时召回新旧两段导致生成矛盾答案。解决思路是在提示词里加一句“如果资料片段之间存在时间或版本冲突以最新版本为准”并给每个chunk加上时间戳元数据让retrieval能按时间过滤。我在生产配置里通常会维护一个简单的元数据过滤来源、部门、版本号、更新时间检索时按这些字段做硬过滤既能提升相关性又能减少矛盾。8. 进阶优化方向从Query改写、HyDE到多跳检索最后聊几个进阶方向如果你已经把基础链路调顺了可以往这几个方向投入。它们在特定场景下的提升非常显著。8.1 Query改写问题不清晰怎么检索都白搭真实用户输入往往充满歧义、指代和口语化表达。比如“它这个版本还能用吗”这句话里的“它”单独拎出来完全无法检索。我的一般做法是加一个轻量的query改写模块用LLM把用户输入改写成若干个适合检索的独立query再去执行检索。改写后的query可以是一个也可以是多个multi-query分别检索后合并结果。代价是增加一次LLM调用大概100~200ms但召回率提升通常非常可观。8.2 HyDE让检索匹配“答案的样子”而不是“问题的样子”HyDE的思路很反直觉先用LLM基于用户问题生成一个假想的答案文档然后用这个假想答案去做向量检索。因为向量检索更擅长匹配“语义相近”的文本而假想答案往往和真实答案在措辞上更接近所以能捞回很多直接拿问题检索时漏掉的片段。我试过在某个内部知识库上做对比同一个问题直接用query检索的top5里只有1段相关换HyDE后变成3段相关。缺点是多一次LLM调用延迟增加而且对生成质量差的假想答案效果会打折。适合对延迟不那么敏感的离线分析场景。8.3 多跳检索解决“需要把好几个信息串起来”的问题比如用户问“采用A方案的公司里哪几家在B指标上提升了”这个问题需要先检索出“采用A方案的公司”再从这些公司的文档里找出B指标相关的记录两跳组合才能回答。单跳RAG只能拿到其中一部分。我建议的兜底方案是第一轮检索完如果LLM判定“当前信息不足以回答”就让它生成一个“还需要什么信息”的需求列表然后拿着这个列表去检索第二轮。虽然复杂但在专业领域的复杂问答中多跳带来的提升是无可替代的。不过别急着上这些进阶方案。我的建议顺序是先做混合检索和rerank再把提示词跟上下文协同调好最后评估集跑分。如果分数已经达到业务线进阶方案反而会让系统变复杂增加运维负担。最后再分享一个实操层面的技巧在收尾之前我想提一个很多教程不会讲但实际极其有用的细节把检索链路单独抽成一个可观测的日志模块。每一条用户请求把query改写结果、各召回通道的top结果、rerank分数、最终送入模型的片段、生成答案、以及评估分数全部记录成结构化日志。这样一来任何业务侧的“答错了”反馈你都能回到日志里定位到底是哪一环出了问题——是检索没召回到、还是排序排低了、还是模型没按提示词要求做。我目前所有RAG项目的调试流程基本都依赖这套日志系统。它比任何“凭感觉调参”都快得多也是你能把项目从demo推到生产环境的关键支撑。这篇指南写到这里基本上覆盖了一条从检索到生成的完整优化链路。如果你只是刚接触RAG建议先跑通基础流程再做混合检索rerank的组合改造如果已经在生产环境了不妨对照着检查一下你的切分策略和评估体系这两块往往是最容易被忽视却最值得投入的地方。