
1. 三个项目踩下来Demo和生产之间隔着什么我前后经手过三个企业级RAG落地项目行业跨度不小一个是制造业的设备运维知识库一个是金融合规的文档问答还有一个是大型集团的内部制度检索。这三个项目有一个共同点——立项时都拿某个开源Demo跑通过演示效果惊艳领导拍板然后交到我手上做生产化。结果每一次Demo方案都在真实数据面前碎得干干净净。先说一个最直观的数字对比。Demo阶段我们通常拿几十到几百条干净文档做测试Top-5召回率能到85%以上回答看起来有模有样。但切到生产环境文档量级直接跳到几万到几十万条格式从纯Markdown变成PDF扫描件、Word嵌套表格、Excel附件、甚至邮件导出的乱码文本召回率断崖式跌到40%以下。更致命的是Demo里没人关心权限生产环境里一个普通员工能检索到高管薪酬文档这是要出事的。所以这篇文章不是讲RAG怎么搭而是讲Demo方案为什么扛不住生产以及我在三个项目里实际怎么补这些窟窿。核心会围绕几个关键词展开Embedding模型选型、BM25混合检索、Rerank重排、ACL权限控制。如果你正准备把RAG从POC推向生产或者已经在生产环境被召回率和权限问题折磨这篇应该能帮你少走几个月的弯路。我先把结论摆出来Demo方案的本质是检索增强生成的玩具版它假设数据干净、用户可信、查询简单。而生产环境要求的是检索增强生成的工程版它必须处理脏数据、做权限隔离、扛住复杂查询。这两者之间的差距不是调几个参数能填平的而是架构层面的重构。下面我按三个项目里踩坑最深的几个维度逐个拆解。2. 文档解析这一关Demo方案基本是裸奔2.1 为什么Demo里的文档解析在生产环境必然翻车几乎所有RAG Demo的文档加载环节都是这么写的用LangChain的DirectoryLoader配一个UnstructuredFileLoader或者干脆TextLoader一把梭把整个文件夹的文档读进来按固定字符数切分完事。这套流程在Demo里跑得通因为Demo用的文档通常是团队自己写的Markdown或者干净的TXT。但生产环境的文档是什么样我拿制造业那个项目举例。设备运维手册有PDF扫描件里面是图片格式的文字TextLoader读出来全是空白有Word文档里面嵌了Excel表格表格里的参数是设备型号和对应扭矩值按字符切分直接把表格切碎检索出来的片段是型号A 扭矩 120和型号B 扭矩分在两段里模型根本拼不出完整信息还有一批老文档是PDF里套了文本框文字顺序完全乱掉读出来是步骤三 步骤一 注意事项 步骤二这种鬼东西。金融合规项目更夸张。合同PDF里有大量双栏排版TextLoader按行读会把左右两栏交叉混在一起一句话读出来是甲方应于每月五日前乙方有权终止合同支付服务费。这种文本喂给Embedding模型向量本身就是错的检索出来的结果自然驴唇不对马嘴。2.2 生产级文档解析的实操方案我在三个项目里最终统一了一套解析策略核心原则是按文档类型分流而不是一把梭。对于PDF先判断是文本型还是扫描型。文本型用PyMuPDFfitz提取它比pdfplumber快而且对双栏排版的阅读顺序处理更好。扫描型必须走OCR我用的是PaddleOCR中文识别率在实测中比Tesseract高出一截尤其是表格和手写体。这里有个坑OCR出来的文本没有段落结构需要额外做一次版面分析我用PP-Structure把标题、正文、表格分开表格单独走结构化提取。对于Word不要用python-docx直接读段落那样会丢掉表格和嵌套结构。我的做法是用docx2python把文档转成带层级标记的文本表格转成Markdown格式保留行列关系。Excel附件单独处理用pandas读成DataFrame每一行转成字段名值的文本片段这样检索时能命中具体参数。对于HTML和邮件用BeautifulSoup去掉导航栏和页脚只保留正文区域。邮件导出的文本通常有大量引用符号需要正则清洗。这里给一个我实际用的解析分流伪代码逻辑def parse_document(file_path): ext file_path.suffix.lower() if ext .pdf: doc fitz.open(file_path) if is_scanned(doc): # 判断是否扫描件 return parse_with_ocr(file_path) else: return parse_with_pymupdf(doc) elif ext in [.docx, .doc]: return parse_with_docx2python(file_path) elif ext in [.xlsx, .xls]: return parse_excel_as_text(file_path) elif ext .html: return parse_html_main_content(file_path) else: return parse_plain_text(file_path)注意解析阶段一定要保留文档的元数据包括来源文件名、页码、章节标题。这些元数据在后续的权限过滤和引用溯源里是刚需Demo方案通常只保留文本内容生产环境必须补上。2.3 分块策略固定长度切分是召回率杀手Demo里最常见的分块方式是RecursiveCharacterTextSplitterchunk_size设500或1000overlap设50或100。这套参数在干净文档上勉强能用但在生产环境有两个致命问题。第一它按字符数切不按语义切。一个完整的操作步骤可能跨两个chunk检索时只命中前半段模型回答就缺了后半段。第二它不保留上下文。每个chunk独立Embedding丢失了它在文档中的位置信息导致检索时无法区分这个片段是第一章的概述还是第五章的详细步骤。我在金融项目里改用语义分块层级分块的组合。先用SemanticChunker基于Embedding相似度找语义边界把文档切成语义完整的段落。然后对每个段落保留它的父级标题路径比如第三章 合规要求 3.2 反洗钱 3.2.1 客户身份识别。这个路径会拼接到chunk文本前面一起做Embedding这样检索时客户身份识别这个查询能同时命中标题和正文。分块大小也不是固定的。技术手册的步骤类内容chunk可以小到200字保证一个步骤完整制度类文档的条款chunk可以大到800字保证条款上下文完整。我一般会按文档类型预设几套分块参数而不是全局一套。3. Embedding模型选型排行榜第一不等于生产可用3.1 我踩过的Embedding选型坑第一个项目立项时团队直接选了当时MTEB排行榜上中文第一的模型具体名字不点了反正是个参数量很大的开源模型。离线测试确实好检索准确率比text-embedding-ada-002高。但上线后问题来了这个模型单条推理要200毫秒我们生产环境每天要处理几十万次查询GPU资源根本扛不住。而且它的向量维度是1024存到向量数据库里索引体积比768维的模型大了近一倍内存成本直接翻倍。第二个坑是领域适配。金融合规项目里风险这个词在通用语料里和危险接近但在金融语境里它和敞口拨备资本充足率更相关。通用Embedding模型学不到这种领域语义检索信用风险时经常召回操作风险的文档因为两者在通用语义空间里太近了。第三个坑最隐蔽Embedding模型的输入长度限制。很多模型标称支持512个token但实际超过256个token后后面的内容对向量的贡献急剧衰减。我们有个chunk是600字Embedding出来只反映了前200字的信息后面的关键参数完全没进向量。这个问题在Demo阶段根本发现不了因为Demo的chunk都很短。3.2 生产环境Embedding选型的四个硬指标我现在选Embedding模型不看排行榜看四个硬指标指标要求原因推理延迟单条50msGPU生产环境QPS高延迟直接决定用户体验向量维度768或以下维度越高索引和内存成本越高768是性价比拐点最大输入长度至少512 token且衰减平缓长chunk需要完整编码领域微调支持支持继续预训练或LoRA微调通用模型在垂直领域必然不够用基于这四个指标我在三个项目里的实际选择是制造业项目用bge-base-zh-v1.5768维延迟低中文效果好金融项目用bge-large-zh-v1.5微调后的版本金融语料继续预训练了一轮集团制度项目用text-embedding-3-small1536维但支持维度压缩到512且API延迟稳定。这里重点说金融项目的微调。我们收集了大概5万条金融问答对用bge-large做基础模型加了一个LoRA适配器训练了3个epoch。微调后的模型在内部测试集上Top-10召回率从62%提升到81%。微调的成本其实不高一张A100跑了6个小时但效果提升非常明显。提示微调Embedding模型时负样本的构造比正样本更重要。我们用了in-batch negatives加上难负样本挖掘难负样本是从检索结果里挑那些排名靠前但实际不相关的文档。这一步做了之后模型区分度提升很大。3.3 向量维度压缩的取舍text-embedding-3系列支持维度压缩可以把1536维压到512维检索效果损失大概3-5个百分点但索引体积减少三分之二查询速度提升明显。我在集团项目里做了AB测试512维的版本在10万文档规模下召回率只比1536维低2.8%但P99延迟从180ms降到65ms。这个取舍在生产环境是划算的因为用户对延迟的敏感度远高于那2.8%的召回率差异。但要注意维度压缩不是所有模型都支持。开源模型里bge系列不支持原生压缩只能通过PCA降维但PCA会破坏向量空间的语义结构效果损失比原生压缩大得多。所以如果要用压缩优先选原生支持压缩的模型。4. 混合检索BM25不是备胎是主力4.1 纯向量检索在生产环境的三个盲区Demo方案几乎清一色是纯向量检索文档Embedding存向量库查询Embedding后做相似度搜索。这套方案在语义匹配上确实强但在生产环境有三个盲区。盲区一精确匹配失效。用户查设备型号XG-2000的扭矩参数向量检索会把XG-2000和XG-1000当成相似向量因为它们在语义空间里都是设备型号。但用户要的是精确的XG-2000不是XG-1000。这种精确匹配场景向量检索天然弱势。盲区二低频词被淹没。金融项目里有个术语叫拨备覆盖率在通用语料里出现频率极低Embedding模型没怎么见过向量表示很粗糙。用户查这个词向量检索经常召回覆盖率相关的通用文档而不是拨备相关的专业文档。盲区三否定和条件查询。不含利息的收益和含利息的收益向量检索出来的结果几乎一样因为Embedding模型对否定词的敏感度很低。但生产环境里这种查询很常见。4.2 BM25在RAG里的正确用法BM25是经典的关键词检索算法它基于词频和逆文档频率打分。在RAG里BM25不是用来替代向量检索的而是和向量检索做混合召回。我的做法是查询同时走两路一路向量检索取Top-50一路BM25检索取Top-50然后用RRFReciprocal Rank Fusion融合两路结果。RRF的公式很简单每个文档的最终得分是1/(krank)的累加k通常取60。这个融合方式不需要调权重对两路检索的分数尺度不敏感实测比加权求和稳定得多。def hybrid_retrieve(query, vector_store, bm25_index, top_k50): vector_results vector_store.search(query, top_ktop_k) bm25_results bm25_index.search(query, top_ktop_k) # RRF融合 scores {} for rank, doc in enumerate(vector_results): scores[doc.id] scores.get(doc.id, 0) 1 / (60 rank) for rank, doc in enumerate(bm25_results): scores[doc.id] scores.get(doc.id, 0) 1 / (60 rank) return sorted(scores.items(), keylambda x: x[1], reverseTrue)[:top_k]BM25的实现我用的是rank_bm25库但生产环境更推荐Elasticsearch的BM25因为它支持分布式和增量索引。中文分词用jieba但要注意加自定义词典把领域术语加进去否则拨备覆盖率会被切成拨备覆盖率两个词BM25打分就不准了。4.3 混合检索的实测效果在金融项目里我做了纯向量、纯BM25、混合检索三组对比。测试集是200条真实用户查询人工标注了正确答案。检索方式Top-5召回率Top-10召回率精确匹配查询召回率纯向量58%71%32%纯BM2549%63%78%混合RRF76%89%85%可以看到混合检索在整体召回率上比纯向量提升了18个百分点在精确匹配查询上提升了53个百分点。这个提升在生产环境是质变因为用户查询里大概有30%是包含精确术语或型号的。注意BM25的索引需要和向量索引同步更新。文档新增或删除时两边都要更新否则会出现向量检索能查到但BM25查不到的情况。我在项目里用了一个消息队列做双写保证最终一致性。5. Rerank召回之后的最后一道精度闸门5.1 为什么召回之后必须加Rerank混合检索把Top-50召回出来了但这50条里真正相关的可能只有5条而且排序不一定对。向量检索和BM25都是双塔模型查询和文档分别编码交互发生在最后一步的相似度计算这种架构快但精度有限。Rerank用的是交叉编码器查询和文档拼在一起过模型能捕捉细粒度的交互信息精度高但速度慢。生产环境的做法是召回阶段用双塔快速筛出Top-50Rerank阶段用交叉编码器精排Top-50取Top-5给大模型。这样兼顾了速度和精度。我在制造业项目里实测不加Rerank时Top-5里平均只有2.3条是真正相关的加了Rerank之后Top-5里平均4.1条相关。这个提升直接反映在最终回答质量上用户满意度从3.2分5分制提升到4.3分。5.2 Rerank模型的选择和延迟控制Rerank模型我用过三个bge-reranker-base、bge-reranker-large和Cohere Rerank。bge-reranker-base延迟最低单条50ms左右适合对延迟敏感的场景bge-reranker-large精度更高但延迟到150msCohere Rerank是API调用精度最好但依赖网络且成本高。生产环境我一般用bge-reranker-base因为Top-50精排的延迟是50ms乘以50条如果串行就是2.5秒太慢了。我的做法是批量推理把50条查询-文档对打包成一个batch一次前向传播GPU上大概200ms能跑完。这样整体延迟可控。如果Top-50还是太多可以先做一次粗排比如用向量相似度取Top-20再Rerank。但粗排会损失召回我一般只在QPS极高的场景才这么做。5.3 Rerank的输入构造细节Rerank模型的输入是(query, document)对但document怎么构造有讲究。如果直接把chunk文本扔进去模型看不到上下文。我的做法是把chunk的父级标题路径拼在chunk前面比如第三章 合规要求 3.2 反洗钱 3.2.1 客户身份识别客户身份识别应遵循以下步骤...。这样Rerank模型能利用标题信息判断相关性。另外query也要做处理。用户原始查询可能很短比如拨备直接拿去Rerank效果不好。我会先用大模型做一次查询改写把拨备扩展成拨备覆盖率 监管要求 计算方法再拿去Rerank。这一步在金融项目里让Rerank准确率又提升了7个百分点。6. ACL权限Demo方案里根本不存在的维度6.1 权限问题为什么是生产环境的生死线Demo方案里所有文档在一个向量库里任何用户查询都能召回所有文档。这在演示时没问题但在生产环境是重大安全事故。我经手的集团项目制度文档分三个密级公开、内部、机密。机密文档只有高管能看内部文档只有正式员工能看公开文档所有人能看。如果普通员工查询薪酬制度向量检索召回了机密级的高管薪酬方案大模型直接把这个内容生成到回答里这就是泄密。更麻烦的是权限不是简单的文档级过滤。同一个文档里不同章节的密级可能不同。比如一份公司制度汇编第一章是公开的考勤制度第五章是机密的股权激励方案。如果按文档级过滤要么整份文档都看不到要么整份都能看到都不对。6.2 ACL在RAG里的实现方案ACLAccess Control List在RAG里的实现核心是在检索阶段做过滤而不是在生成阶段做过滤。因为如果检索阶段不滤掉无权限文档大模型可能已经看到了这些内容即使生成时不输出也存在泄露风险。我的实现方案是元数据过滤向量库原生过滤双保险。第一步文档解析时给每个chunk打上权限标签。标签包括密级public/internal/confidential、部门finance/hr/tech、角色employee/manager/executive。这些标签存在chunk的元数据里。第二步向量库查询时带上过滤条件。我用的是Milvus它支持expr参数做元数据过滤。查询时根据当前用户的权限构造过滤表达式比如密级 in [public, internal] and 部门 in [finance, hr]。这样向量检索只会在有权限的chunk里做相似度搜索。def build_acl_filter(user): allowed_levels [public] if user.is_employee: allowed_levels.append(internal) if user.is_executive: allowed_levels.append(confidential) allowed_depts user.departments [public] expr f密级 in {allowed_levels} and 部门 in {allowed_depts} return expr # 查询时 results milvus.search(query_vector, filterbuild_acl_filter(current_user))第三步BM25索引也要做同样的过滤。Elasticsearch支持terms查询做权限过滤和向量库的逻辑一致。6.3 权限变更和缓存失效生产环境里权限是动态的。员工转岗了部门变了员工离职了权限要回收。如果权限信息缓存在检索层必须有一套失效机制。我的做法是权限信息不缓存在检索层每次查询都从权限服务实时获取。权限服务用Redis缓存TTL设5分钟。这样权限变更最多5分钟后生效对大多数场景够用。如果对实时性要求极高可以用消息队列推送权限变更事件检索层订阅事件后主动失效缓存。提示ACL过滤会增加检索延迟因为向量库需要在过滤后的子集里做相似度搜索。如果权限过滤后剩下的文档很少检索质量会下降。我的经验是如果过滤后文档数少于1000条考虑降级为纯BM25检索因为向量检索在小数据集上优势不明显。7. 三个项目里那些文档不会写的教训7.1 查询改写比检索算法更重要我一开始把大量精力花在调检索算法上换Embedding模型、调BM25参数、加Rerank。后来发现用户查询的质量才是瓶颈。真实用户的查询往往很短、有错别字、指代不明。比如那个报销的流程、上次说的那个参数。这种查询直接拿去检索什么算法都救不了。后来我在检索前加了一个查询改写环节用大模型把用户查询改写成完整、明确的检索查询。这一步在三个项目里都是ROI最高的优化。改写后的查询召回率平均提升25个百分点比换任何Embedding模型都管用。查询改写的prompt我调了很多版最终稳定用的是这个结构给大模型提供对话历史如果有、领域术语表、以及改写指令。改写指令要求模型补全指代、纠正错别字、扩展同义词、但不要改变原意。7.2 大模型的上下文窗口不是越大越好Demo方案喜欢把Top-10甚至Top-20的chunk全塞给大模型觉得上下文越多越好。但实测下来上下文超过一定长度后大模型的注意力会分散回答质量反而下降。而且长上下文意味着高延迟和高成本。我在项目里的做法是Rerank后取Top-5每个chunk控制在500字以内总上下文控制在2500字左右。这个长度下大模型能充分利用每个chunk的信息回答质量最好。如果Top-5的信息还不够说明检索有问题应该回去优化检索而不是无脑增加上下文。7.3 评估体系必须从第一天就建Demo阶段没人建评估体系因为Demo只需要看起来能用。但生产环境必须有一套量化的评估指标否则你根本不知道每次改动是变好了还是变差了。我在第二个项目开始强制要求建评估集。评估集包括200条真实用户查询、每条查询的标准答案、以及标准答案对应的文档片段。评估指标包括检索召回率Top-5里有多少条是相关的、回答准确率大模型回答和标准答案的语义相似度、引用准确率大模型引用的文档片段是否真的支持它的回答。这套评估体系建起来大概花了一周但后面每次调参、换模型、改prompt都能快速验证效果避免了很多无效改动。没有评估体系的RAG优化就是盲人摸象。7.4 日志和可观测性决定你能不能排障生产环境出问题时用户只会说回答不对不会告诉你哪里不对。如果没有详细的日志你根本不知道是检索没召回、Rerank排错了、还是大模型生成错了。我在项目里埋了三层日志检索层记录查询、召回文档ID、BM25分数、向量相似度、Rerank分数生成层记录最终prompt、大模型原始输出、引用来源用户层记录用户反馈点赞/点踩。这三层日志串起来任何一个bad case都能快速定位到具体环节。这套日志系统在金融项目里救过我好几次。有一次用户投诉回答错误我查日志发现是BM25把拨备和设备混了因为分词词典里没有拨备这个词。加了自定义词典后问题解决。如果没有日志这个问题可能要排查好几天。8. 从Demo到生产我的落地检查清单三个项目做完我整理了一份从Demo到生产的检查清单。每次新项目立项我都会拿这份清单过一遍确保没有遗漏。文档解析层是否按文档类型做了分流解析扫描件是否走了OCR表格是否保留了行列结构是否保留了文档元数据来源、页码、标题路径分块层是否用了语义分块而非固定长度切分是否保留了父级标题路径分块大小是否按文档类型做了区分Embedding层模型延迟是否满足生产QPS要求向量维度是否在成本可接受范围是否在领域语料上做了微调输入长度是否覆盖了chunk长度检索层是否用了混合检索向量BM25BM25分词词典是否包含领域术语融合算法是否用了RRF是否加了RerankRerank是否做了批量推理优化权限层是否在检索阶段做了ACL过滤权限标签是否细到chunk级权限变更是否有失效机制BM25索引是否同步做了权限过滤生成层是否做了查询改写上下文长度是否控制在合理范围是否要求大模型引用来源评估和运维层是否有量化评估集是否埋了检索、生成、用户三层日志是否有bad case快速定位流程这份清单不是每个项目都要全做但每一项没做都要清楚风险在哪里。Demo方案之所以扛不住生产就是因为上面这些维度它一个都没覆盖。而生产环境的RAG本质上是一个系统工程检索算法只是其中一环文档解析、权限控制、评估体系、可观测性每一环都决定最终能不能用。我在第三个项目做完后回头再看第一个项目的Demo方案感觉就像拿玩具车去跑拉力赛。不是玩具车不好是赛道根本不一样。如果你正在做RAG落地建议从第一天就用生产环境的视角去设计哪怕数据量还小、用户还少但架构上的坑越早填成本越低。