这个标题我在写的时候犹豫了很久,因为“生产级”这个词确实被各种Demo用烂了。市面上说的生产级RAG,大部分其实是把一段开源示例跑通,问答看起来顺滑,可真放到业务里去处理长文档、多轮对话、表格引证、权限过滤、大并发调用,各种问题就藏不住了。我最近完整地从0到1搭了一套可上线的RAG系统,拆掉了很多想当然的假设,也踩了不少坑。这篇文章会把前面21篇散落的知识点真正串成一条能落地的链路,捋清楚每一步为什么这样做,以及哪些环节其实可以砍掉、哪些绝对省不得。
如果你是小团队的技术负责人,或者一个人要扛起整个问答系统的研发,这篇文章是按业务场景倒推出来的实战记录,不是教科书式的功能清单。读完后你至少能画出自己的架构图,并且知道从哪一步开始动手不会返工。
1. 先想清楚整体链路,再动手写代码
1.1 从Demo到生产,差的不是模型而是流程
很多人第一次做RAG,脑子里只有三个东西:向量库、Prompt、大模型。Demo确实只需要这三样,效果也够惊艳。但一旦进入生产环境,你会发现真正消耗时间的根本不是模型本身,而是围绕它外围的一大圈工程问题:文档格式五花八门、解析出来的内容带噪声、不同业务线的权限要隔离、同一篇文档有多个版本、用户问法跟原文差十万八千里、回答需要标注来源页码。
这些问题不解决,模型再强也白搭。生产级RAG的链路至少有六个模块:文档接入与解析、分块与结构化处理、向量化与索引、检索层、重排层、在线推理服务,再加上贯穿始终的评估和监控。这六个模块之间要定义清晰的输入输出接口,才能各改各的而不互相踩脚。
我的做法是先把数据流画出来。从原始文档进入系统,到最终回答展示给用户,中间每一步的输出是什么、字段长什么样、出错怎么兜底,全部先定好。哪怕是第一版做得粗糙一些,这个接口也一定要先固定,否则后面迭代的时候每改一次解析逻辑,下游的向量库和缓存全部要跟着动,那种痛苦谁经历谁知道。
1.2 模块边界划分的一个实用建议
在实践中,我把模块边界用“数据契约”来固定。每个模块只认标准结构,内部实现随意替换。举个例子,文档解析模块的输出不一定是一段纯文本,而是一个结构化的Document对象:
class Document: doc_id: str # 文档唯一标识 content: str # 正文内容 page_no: int # 属于第几页 section_path: list # 标题层级,比如 ["产品介绍", "功能模块", "权限控制"] metadata: dict # 来源、版本、所属业务线、负责人等分块模块的输入是Document列表,输出变成Chunk对象。Chunk至少要有chunk_id、text、doc_id、起始段落在原文档中的偏移位置。这样下游无论是做引用回源、人工标注、还是增量更新,都不需要重新解析原文档。
在线推理服务只依赖检索和重排模块的返回结果,不直接读取原始文件。这意味着如果你想从Milvus换成Elasticsearch,只需要改检索层内部实现,其他模块完全无感。我见过很多团队把文档加载、切分、向量化全部揉在一个脚本里,最后产品要加一个字段,结果整个链路推倒重来,非常不划算。
1.3 技术选型怎么定,才算对得起“生产级”
技术栈选型这块,我只说自己的选择逻辑,不一定适合所有场景。主语言用Python,这是绕不开的,因为Embedding模型和重排模型基本全是Python生态。在线服务用FastAPI,比较简单直接。离线任务,比如文档解析、批量向量化,用消息队列加Worker的方式做异步处理,而不是在API请求里同步去解析几百MB的PDF。
向量存储我用的是开源自托管的方案,而不是SaaS托管。原因很现实:企业内部文档对数据出境、权限控制要求很严格,自己部署更容易满足审计要求。存储层最容易被低估的部分其实是元数据过滤,很多向量库能传filter条件,但实际过滤效率并不好,后面我会单独讲这个坑。
一句话总结这个阶段:架构的复杂度是不可消除的,你早画图,后面就少返工,这个时间不要省。
2. 离线数据准备:文档解析与分块策略
2.1 文档解析的核心:不是提取文字,而是保住结构
最开始我天真地以为PDF转文本用开源库一两行代码就能搞定。结果发现那只是头10个页面的幻觉。真实世界的PDF大概有这几种灾难情况:扫描件根本没文本层、表格被切得七零八落、双栏排版导致阅读顺序错乱、版式里嵌了页眉页脚和页号、正文里混着水印。
用最基础的提取方式,出来的纯文本在搜索阶段没问题,但一旦涉及“回答要给出依据在第几页”、“这个表格数据要是有错怎么办”,就完全没有招架能力。所以解析层的重点不在“提取字符串”,而在“保留文档结构”。我的做法是优先用Markdown类工具做版式还原,输出完整的标题层级、列表项和表格结构;扫描版走OCR识别,优先用开源的OCR服务。解析完成后必须做抽检,至少抽5%的页面人工核对标题层级和表格结构是否正常,这一步不能省。
顺便说一个非常实用的小技巧:如果只需要精准引用某段文字在哪一页,可以先在解析结果里给每个段落绑定页码范围。后面检索命中某一句话时,直接把页码返回给前端,用户就能跳转到PDF原文对应位置。这是生产系统里最能提升信任感的功能之一,值得优先做。
2.2 分块策略:先按结构切,再按长度切
很多教程会把分块参数讲得很玄,什么256、512、1024都有人推荐。实际我用下来,固定长度分块是最差的方案,因为它会把段落、表格、代码块从中间切断,而且切断后的内容在语义上都是半截子。正确做法是先按文档结构分块,再对超长块按语义边界往下切。
具体执行顺序是这样:先根据Markdown的标题层级切出大节,比如“3.2 权限控制”可能是一个大节。然后看这个节的长度,如果超过阈值,再按段落边界切。如果段落也太长,再按句子边界切。最后考虑设置一个overlap重叠窗口,让相邻chunk之间共享一些文本,避免关键词刚好落在边界被漏掉。
我在实测里发现,处理中文文档时chunk_size取500到800个字比较稳,overlap取50到100个字。当然这只适合通用知识文档,如果是代码仓库问答、论文库、表格密集型文档,分块策略要做针对性调整。比如表格类内容,我单独设计了一套“行列结构化分块”,每一行加表头描述后作为独立chunk,检索命中率比整块表格切分高很多。
2.3 解析与分块的两个大坑,踩一次就够了
第一个坑是解析工具里的页码映射会漂移。有些工具会把封面、目录、空白页也算进去,导致文本内容和真实PDF页码对不上。处理方法很简单:解析时输出每个段落的“物理页码”和“逻辑页码”,在建立索引前自动校正一次。第二个坑是表格跨页。一个长表格可能从上页延续到下页,按页切分后下半段失去了表头,检索时就成了一堆无意义的数字。我最后的妥协方案是:表格尽量整体保留在一个chunk里,只有当它超过模型最大长度时才做行列切分。
另外,清洗环节别忘了去掉页眉页脚、页号、版本修订记录这些重复噪声。它们会污染向量表示,让检索结果里混进大量跟用户问题无关的页面边角料。我见过一个生产事故,用户问某个产品特性,检索命中的前三条全来自同一页的页眉,就是清洗没做干净导致的。
3. 向量化与索引构建
3.1 Embedding模型选型,不要再凭感觉
选Embedding模型时,我建议用“三个维度”来比:最大输入长度、向量维度、推理速度。最大输入长度决定了一个chunk最多能塞多少字进去,超过会截断;向量维度影响存储成本和相似度计算速度;推理速度在高并发下直接决定系统的吞吐上限。
中文场景里,我比较偏爱那种原生支持长文本的模型,比如能处理8K以上token的那类,这样分块策略可以轻松一点。很多人纠结向量维度是768还是1024,其实感知差异没有想象中那么大。相比维度,我更在意模型对中文长文本、表格、代码这些内容的表征能力,所以会用一批带噪声的真实query去评测。评测方法不复杂:准备100个问题,跑一遍召回,人工看Top5命中率,比一堆指标都直观。
我踩过一个坑:模型在所有benchmark上都很好看,但放到具体业务领域里效果一般。原因是通用模型对专业术语、缩写、内部黑话的理解不够。这种情况下,如果领域数据足够,可以基于领域语料继续预训练;数据不够就退一步,靠下面要说的混合检索来弥补。
3.2 向量库选型:别上来就无脑上向量数据库
选向量库要考虑的不只是搜索速度,还有三个隐藏因素:部署运维成本、和现有系统的集成难度、以及元数据过滤能力。Elasticsearch这类搜索引擎的优势在于能把关键词检索和向量检索放在同一套系统里,权限过滤、聚合统计逻辑一套搞定。很多场景里,我最后都选择了ES而不是单独的专用向量库,就是因为不用两套存储来回同步。
如果数据量确实很大,百万级以上,再考虑专门的向量数据库。按我的经验,几千到几万条文档的小规模场景,用ES绰绰有余;几十万条以上的高并发场景,专用向量库的索引效率和内存控制优势才真正体现出来。索引方法我优先推荐HNSW,它在查询速度和召回质量之间比较均衡。常用的参数设置是这样:M=32,efConstruction=200,ef_search=128;如果对内存高度敏感,M可以降到16,但召回率会有小幅损耗。
3.3 参数细节:相似度度量的那些暗坑
关于相似度度量,有一个非常容易踩的细节:使用Cosine相似度时如果不做向量归一化,内积和点积的结果会让人看不懂。向量库里的“距离”到底是越大越相似还是越小越相似,要先明确。不同向量库对距离的定义不一致,有的返回的是余弦相似度,有的返回的是L2距离,还有的返回内积。索引构建和查询时的参数必须保持一致,否则检索结果会全线漂移。
另一个坑叫拍脑袋归一化。有些向量库默认对向量做归一化,如果Embedding模型本身没有输出归一化向量,之后你拿离线评测集复现问题时,会发现线上线下的相似度分数对不上,排出来的结果也不一致。我在项目里专门加了一步:写入索引前统一对向量做L2归一化,并且所有距离度量和爬虫脚本、评测脚本保持一致。
最后提一个生产环境必须考虑的点:权限过滤和元数据过滤。向量库的ANN索引在带过滤条件时经常性能急剧下降,甚至返回空结果。我的规避方案是:权限过滤尽可能放在检索前面,先用倒排索引确定候选文档ID集合,再在候选集合内做向量检索。这个顺序调过来,效果天差地别。
4. 召回、重排与上下文组装,在线链路的心脏
4.1 混合召回:不要只依赖向量检索
只做向量检索的RAG有一个非常明显的短板:当用户问题里的关键实体是专有名词、缩写、版本号时,向量模型经常找不到精准匹配,反而带回来一堆语义相近但不相关的结果。这类场景恰恰是BM25这类稀疏检索的强项。生产环境我强烈建议做成混合召回。
做法不复杂:向量召回Top30,同时BM25关键词召回Top20,合并后按一定规则去重,得到一个大约50条的候选池。合并规则别搞太复杂,第一版直接取并集即可。BM25的文本来源优先使用文档的正文原文,对中文场景要注意分词效果,如果用jieba做分词,建议在自定义词典里加上业务专有词,否则“大模型”会被切成“大/模型”,专有缩写更是没法命中。
4.2 重排环节,回答质量的分水岭
候选池有50条,不可能全部塞进大模型上下文,重排就是把最相关的3到5条挑出来。行业里主流的做法是用Cross-Encoder类模型:把query和文档拼接成一个序列输入模型,直接计算相关性分数。相比双塔Embedding,它能对query和文档做token级别的交互建模,相关性判断准确率高一个级别。
生产上我推荐直接用开源的Reranker模型。这类模型有个特点,它虽然效果好,但推理速度明显比双塔模型慢,所以只对候选集重排,不对全量文档重排。候选集控制在50条以内,重排耗时基本在几十毫秒上下,可以接受。如果重排后还要做业务规则干预,比如某类文档必须置顶、某些来源要过滤掉,放在这一步统一处理就行。
重排完还有一个容易被忽略的细节:别直接用重排分数去做上下文窗口的取舍。我的做法是在重排分数的基础上,加一个“多样性惩罚”:如果取出的Top3 chunk都来自同一篇文档的相邻段落,就适当把其中一两条换成另一篇文档里的高相关片段,防止回答被单一来源带偏。
4.3 上下文窗口怎么裁剪,Bert和主流LLM都不宜全塞
很多新手第一步就把Top5的chunk全塞给大模型,结果一个chunk 1500字,Top5加起来有7500字,再算上问题和历史对话,很容易把上下文撑爆,或者超出成本预算。生产级系统必须有上下文管理机制。
我的策略是三步走:第一步,设定一个“硬上限”,比如不超过4000 token;第二步,每个chunk在写入上下文前先做截断,保留首尾的信息,因为很多关键结论往往在段落开头和结尾;第三步,给每条chunk生成一个引用编号,比如[1][2],在Prompt里明确要求模型回答时在依据句子后面标注编号,方便前端展示来源。
上下文顺序也有讲究。我通常按重排分数从高到低排列,但有时会把与历史对话最相关的那条提到最前,因为多轮对话里用户的问题常常依赖上一轮提到过的实体,模型在注意力上更容易照顾到最早出现的上下文。
5. 评测体系与上线后的持续迭代
5.1 评测集建设,别用一套FAQ糊弄自己
评测是生产级RAG和Demo之间最本质的一道分水岭。没有评测体系,你改了分块参数或换了Embedding模型,根本不知道是好是坏,只能凭感觉拍板。我见过最典型的失败案例就是参考文档只有几十条FAQ,评测指标好看,一上线被用户的真实问题打回原形。
我的建议是建三套评测数据。第一套是标准问答对,从历史日志和客服反馈里整理真实用户问题,人工标出黄金答案和对应文档位置。第二套是“拒答测试”,专门放一些跟业务无关或者文档里没有答案的问题,看系统会不会强行编造。第三套是“边界干扰测试”,问题文本很相似但答案完全不同,用来检验系统对细节的分辨能力。
不用一上来就追求几百条,50条标准问答、20条拒答、20条干扰题,已经可以跑轮评估了。之后每迭代一版,把这套集子跑一遍,对比效果趋势。
5.2 三层指标排查,快速定位烂在哪一个环节
评估不能只看最终答案。我分成三个层面来查:召回层看Top5命中率,看黄金答案所在的chunk有没有出现在召回候选里;排序层看MRR和重排后的Top1命中率;生成层看端到端的答案正确率。每一层都有对应的排查方向。
召回层如果命中率低,问题通常出在解析、分块或Embedding选择上。排序层命中率低,问题多半在重排模型或候选集太小。生成层答案不对但命中都正常,那问题在Prompt、上下文裁剪,或者干脆是大模型本身能力不足。用这个分层思路,出了问题能直接锁定模块,不用从头开始排查。我的经验是,大概七成问题出在召回和排序,真正需要换大模型的比例很低。
5.3 灰度上线与线上监控
系统上线不等于项目结束。检索链路中任何一环升级,比如换Embedding模型、调整分块参数、重排模型换版本,都应该走灰度对比。实现方式我建议做分桶对照:新老版本随机分给不同用户,从无结果率、引用点击率、用户反馈点赞点踩数据来对比效果。注意不要只对比回答的文本相似度,用户行为数据比自动指标更真实。
线上监控至少要有三个指标不能少:请求侧p95延迟、无结果率、上下文超限率。无结果率要从日志里区分是召回为空还是重排后为空,这两个问题指向不同的排查方向。日志记得要存原始query、候选chunk列表、重排分数、最终上下文摘要,这是事后复盘故障的唯一靠得住的数据。
6. 常见问题与排查技巧实录
我挑了几个实际项目中几乎必然碰到的问题,做成速查表,方便你直接对号入座。
| 现象 | 最常见原因 | 处理思路 |
|---|---|---|
| 解析出来的文本乱码、顺序错乱 | 扫描版PDF走了文本提取,或双栏排版未做还原 | 改用OCR和版式还原工具,抽检解析质量 |
| 检索Top5切中相关段落但答案依然不对 | 上下文裁剪把关键信息截断了 | 放宽chunk上限,或把关键chunk整体放在最前 |
| 关键词、缩写完全匹配不上 | 分词词典没有专有词,向量召回语义漂移 | 自定义词典、加BM25关键字召回 |
| 重排后结果还不如原来 | 候选池太小或重排模型对该领域不敏感 | 把候选池扩大到50条,换领域适配的Reranker |
| 向量库加了过滤条件后返回空 | 元数据过滤与ANN索引交互异常 | 先过滤后检索,或改用倒排索引预过滤 |
| 文档更新后旧回答还在 | 增量更新链路缺失或缓存未失效 | 建立文档版本校验机制,更新时同步清理相关chunk |
另外再分享一个排查技巧:做检索分析时,不要只看命中的chunk文本,还要看相似度分数分布。如果相关chunk和无关chunk的分数拉不开差距,基本可以判断Embedding的表征能力不足,换模型比调参更管用。反之如果相关结果分数很高但重排后掉下去了,要重点查重排模型的输入格式和候选集顺序问题。
我搭完这套系统有个很深的体会:RAG的本质不是一个检索器加一个大模型,而是一整套可持续迭代的生产工程。前面21篇拆出来的每个知识点,只有真正串联成链路、并且配上可观察可评估的闭环,才算真正发挥了作用。如果你也正在搭这样的系统,我最后给你的建议只有一句:先把评测集做出来,再谈优化。没有评测体系,上线速度再快,也只是在给自己埋坑。