
1. 三个项目踩出来的血泪共识1.1 为什么Demo跑得通上线就崩我前后经手过三个企业级RAG项目行业分别是制造业设备运维、金融合规问答、以及一个内部IT服务台。三个项目有一个共同点立项时都拿开源Demo做过POC演示效果惊艳领导拍板然后进入生产环境然后开始崩。崩的方式还不太一样。第一个项目是检索召回率断崖式下跌Demo阶段用几十份干净PDF测试召回率能到85%以上接入真实知识库之后——两万多份文档格式涵盖Word、Excel、扫描件、邮件归档——召回率直接掉到40%出头。第二个项目是响应时间失控Demo阶段单次问答2秒出结果生产环境并发一上来P95延迟飙到18秒用户直接弃用。第三个项目最隐蔽上线两周没出大问题第三周突然开始返回其他部门的敏感数据一查才发现权限过滤形同虚设。这三个坑每一个都跟模型能力无关全部是工程问题。而市面上绝大多数RAG教程和Demo方案恰恰不讲工程。1.2 生产环境和Demo环境的五个本质差异我把这三个项目里遇到的差异整理了一下大致可以归为五类维度Demo环境生产环境数据规模几十到几百份文档万级到百万级文档数据质量人工清洗过的干净文本格式混乱、噪声多、重复率高并发量单人测试数十到数百QPS权限要求无多租户、多角色、字段级隔离更新频率一次性导入每日增量、实时同步这五个差异里数据质量和权限控制是最容易被低估的。大部分团队在POC阶段把精力花在选模型、调Prompt上等到上线才发现Embedding模型选哪个其实影响没那么大真正决定成败的是数据管道和权限架构。1.3 这篇文章要解决什么问题接下来我会按实际项目的时间线把每个环节踩过的坑和最终采用的方案拆开讲。包括文档解析怎么做才不会丢信息、分块策略怎么定、Embedding和BM25怎么配合、Rerank模型怎么选、ACL权限怎么在检索链路里落地、以及并发上来之后怎么保证延迟可控。每个环节我都会给出具体的参数、配置和代码片段能直接抄的我就直接贴出来。但更重要的是我会解释每个决策背后的原因——因为你的数据分布和我的不一样照搬参数不一定有效理解逻辑才能自己调。2. 数据管道90%的Demo死在这一步2.1 文档解析不是“读文本”那么简单Demo方案通常用PyPDF2或者pdfplumber直接把PDF转成文本然后就开始分块。这在干净的单栏PDF上没问题但企业环境里的文档类型远超想象。我第二个项目金融合规的知识库构成大概是这样的PDF占45%Word占25%Excel占15%HTML占10%剩下5%是扫描件和邮件。PDF里面又有大量双栏排版、表格嵌套、页眉页脚、水印。直接用PyPDF2读出来的文本表格全乱双栏内容交错在一起页眉页脚混在正文里。我的处理方案是分层解析# 文档解析路由策略简化版 def parse_document(file_path): ext get_extension(file_path) if ext .pdf: # 先判断是否为扫描件 if is_scanned_pdf(file_path): return parse_with_ocr(file_path) # OCR路径 else: return parse_with_layout_aware(file_path) # 版面分析路径 elif ext in [.docx, .doc]: return parse_with_python_docx(file_path) elif ext in [.xlsx, .xls]: return parse_with_openpyxl(file_path) elif ext .html: return parse_with_trafilatura(file_path) else: return parse_with_fallback(file_path)对于PDF我用的是版面分析OCR兜底的方案。具体来说先用pdfplumber做版面分析识别出文本块、表格块、图片块分别处理。表格用camelot或者ppstructure提取图片块如果包含文字就走OCR。判断扫描件的逻辑很简单如果pdfplumber提取出的文本字符数少于每页50个基本可以判定为扫描件。注意OCR不要全量跑。我第一个项目犯过这个错两万份文档全部走OCR跑了两天两夜而且OCR出来的文本质量参差不齐反而拉低了检索效果。正确做法是先做版面分析只对图片区域和扫描件做OCR。2.2 分块策略固定长度是最偷懒也最坑的做法Demo方案最常用的分块方式是固定长度切分比如每500个字符切一块重叠50个字符。这个方法在技术文档上勉强能用但在企业知识库里问题很大。我遇到过几个典型问题一个操作步骤被从中间切断检索到前半段但后半段丢了一个表格被切散表头和表体分到不同块里一个章节的标题和内容分离检索时匹配不到。后来我改成基于文档结构的分块核心思路是先识别文档的层级结构标题、段落、表格、列表然后在结构边界处切分而不是在字符数边界处切分。# 基于结构的分块策略 def structural_chunk(document): chunks [] current_chunk [] current_size 0 max_size 800 # 最大块大小 min_size 200 # 最小块大小 for element in document.elements: # 遇到标题强制切分 if element.type heading: if current_size min_size: chunks.append(merge(current_chunk)) current_chunk [] current_size 0 current_chunk.append(element) current_size element.token_count # 遇到表格独立成块 elif element.type table: if current_chunk: chunks.append(merge(current_chunk)) current_chunk [] current_size 0 chunks.append(format_table(element)) # 普通段落累积到阈值再切 else: if current_size element.token_count max_size: chunks.append(merge(current_chunk)) current_chunk [element] current_size element.token_count else: current_chunk.append(element) current_size element.token_count if current_chunk: chunks.append(merge(current_chunk)) return chunks这里有几个关键参数需要根据你的数据调max_size我一般设在600到1000个token之间太小会导致上下文不完整太大会稀释语义。min_size设在200左右避免产生大量碎片块。表格独立成块是因为表格的语义完整性和文本完全不同混在一起会互相干扰。另外每个块都要带上元数据来源文档、章节路径、页码、文档类型、更新时间。这些元数据在后面做权限过滤和结果排序时都会用到。2.3 数据清洗不洗数据等于给自己埋雷企业知识库里充斥着各种噪声重复文档、过期版本、测试数据、空文件、乱码。我第二个项目接手时知识库里有一份制度文档存在7个版本内容互相矛盾检索时随机返回其中一个用户直接投诉。清洗环节我一般做这几件事去重用SimHash或者MinHash做近似去重阈值设在0.85左右。完全重复的直接删近似重复的保留最新版本。版本管理同一文档的多个版本只保留最新版旧版本标记为归档检索时默认不返回。空内容过滤解析后字符数少于50的块直接丢弃。乱码检测用字符集检测困惑度过滤把乱码块清掉。时效性标注每个块带上文档的生效日期和失效日期检索时可以根据当前时间过滤。实操心得数据清洗这一步我建议单独跑一个离线管道不要和在线检索混在一起。清洗结果落库在线检索只读清洗后的数据。这样清洗逻辑可以随时调整重跑不影响线上服务。3. 检索链路Embedding、BM25和Rerank的三角配合3.1 Embedding模型选型别只看排行榜Embedding模型排行榜上的分数是在通用基准上测的和你的业务数据分布可能差很远。我第一个项目一开始用的是某个排行榜上排名很高的模型结果在设备运维领域的专业术语上表现很差“轴承温度异常”和“轴温过高”检索不到一起。后来我换了一个在中文技术文档上表现更好的模型同时用业务数据做了一轮微调。微调的方法很简单从历史工单里抽取“问题描述-解决方案”对构造正负样本用对比学习微调。数据量不用很大几千对就能看到明显提升。选型时我一般看这几个维度维度说明建议中文能力中文语义相似度优先选中文语料训练的模型维度向量维度768或1024太高存储成本大推理速度单条编码耗时生产环境建议10ms最大长度支持的最大token数至少512最好支持8192部署方式是否支持本地部署企业环境通常要求本地化维度这块补充一下1024维的向量100万条数据就是约4GB的存储float32如果用量化可以压到1GB左右。检索时的计算量也和维度成正比所以不是越高越好。3.2 BM25不是过时技术是必要补充很多Demo方案只用向量检索不用BM25。这在语义匹配场景下没问题但企业知识库里有大量精确匹配需求产品型号、错误码、人名、编号。这些用向量检索经常匹配不准因为Embedding模型对这类token的语义表征能力有限。我的方案是向量检索BM25混合检索两路各取TopK然后融合。融合算法我用的是RRFReciprocal Rank Fusion简单有效def rrf_fusion(vector_results, bm25_results, k60): RRF融合算法 vector_results: [(doc_id, score), ...] bm25_results: [(doc_id, score), ...] scores {} for rank, (doc_id, _) in enumerate(vector_results): scores[doc_id] scores.get(doc_id, 0) 1.0 / (k rank 1) for rank, (doc_id, _) in enumerate(bm25_results): scores[doc_id] scores.get(doc_id, 0) 1.0 / (k rank 1) # 按融合分数排序 sorted_docs sorted(scores.items(), keylambda x: x[1], reverseTrue) return sorted_docsRRF的好处是不需要调权重两路检索的分数尺度不一样也没关系只看排名。k值一般设60这是原论文的推荐值实测下来在大多数场景都适用。BM25的实现我用的是rank_bm25库中文需要先分词。分词器我试过jieba和HanLPjieba够用HanLP效果稍好但慢一些。如果对延迟敏感jieba是更稳妥的选择。注意BM25的索引需要和向量索引同步更新。我建议把两者放在同一个文档存储里用同一个doc_id关联更新时一起更新避免数据不一致。3.3 Rerank精度提升的最后一道关卡混合检索出来的结果Top20里通常有3到5个是真正相关的。Rerank模型的作用就是把这几个真正相关的排到最前面。Rerank模型我用过几个BGE-Reranker、Cohere Rerank、以及一些开源的cross-encoder模型。效果最好的是BGE-Reranker-v2中文场景下提升明显。但它的推理速度是个问题单条推理在GPU上大概20到50毫秒如果Top20全部过一遍就是400到1000毫秒。我的优化策略是分级Rerank先用一个轻量模型比如BGE-Reranker-base对Top50做粗排取Top20再用重量模型BGE-Reranker-v2对Top20做精排取Top5。这样总延迟控制在200毫秒以内精度损失很小。# 分级Rerank def hierarchical_rerank(query, candidates, top_k5): # 粗排轻量模型Top50 - Top20 coarse_results light_reranker.rerank( query, candidates[:50], top_k20 ) # 精排重量模型Top20 - Top5 fine_results heavy_reranker.rerank( query, coarse_results, top_ktop_k ) return fine_resultsRerank的输入是query和document的拼接输出是一个相关性分数。这里有个细节document不要用整个块用块的前512个token就够了因为Rerank模型通常有长度限制而且块的后半部分往往是补充说明对相关性判断影响不大。4. ACL权限最容易被忽视的生产级需求4.1 权限模型设计从第一天就要考虑Demo方案通常没有权限概念所有用户看到所有数据。生产环境这是致命的。我第三个项目上线两周后出的敏感数据泄露就是因为权限过滤没做。权限模型我一般用RBACABAC混合RBAC管角色ABAC管属性。具体来说每个文档块带上以下元数据tenant_id租户ID多租户隔离department所属部门role_whitelist允许访问的角色列表sensitivity_level敏感级别公开/内部/机密/绝密owner文档所有者检索时在向量检索和BM25检索的前置阶段就做过滤而不是检索完再过滤。这一点很关键如果检索完再过滤一是会浪费检索配额二是可能因为过滤导致结果不足。# 权限过滤条件构造 def build_acl_filter(user): 根据用户信息构造检索过滤条件 filters { tenant_id: user.tenant_id, sensitivity_level: {$lte: user.clearance_level}, $or: [ {department: user.department}, {role_whitelist: {$in: user.roles}}, {owner: user.user_id} ] } return filters这个过滤条件会作为向量检索的pre-filter传入。Milvus、Qdrant、Weaviate这些向量数据库都支持pre-filter但性能表现不一样。Milvus的pre-filter在过滤比例高的时候性能下降明显Qdrant的过滤性能相对稳定。选型时如果权限过滤是刚需建议优先考虑Qdrant。4.2 权限过滤在检索链路中的位置权限过滤有三个可能的插入点检索前、检索中、检索后。检索前过滤在构造检索请求时就带上过滤条件数据库只返回有权限的结果。优点是效率高缺点是如果过滤条件太严格可能返回结果不足。检索中过滤在向量检索的ANN搜索过程中做过滤。这需要数据库支持Milvus和Qdrant都支持但实现方式不同。Milvus是在搜索时传入filter表达式Qdrant是用filter参数。检索后过滤检索完再过滤。这是最差的做法但很多Demo方案就是这么干的。问题是如果Top10里有8个没权限过滤完只剩2个召回率直接崩。我的做法是检索前过滤为主检索后过滤兜底。检索前过滤保证效率检索后过滤做二次校验防止过滤条件有漏洞。实操心得权限过滤条件一定要做单元测试。我第三个项目就是过滤条件写错了$or写成了$and导致只有同时满足所有条件的文档才能被检索到大部分用户什么都搜不到。上线前用不同角色的测试账号跑一遍全量检索确认每个角色只能看到该看的数据。4.3 多租户场景下的索引隔离如果是SaaS产品多租户隔离是必须的。隔离方式有三种隔离方式实现优点缺点独立索引每个租户一个索引隔离彻底资源消耗大共享索引过滤一个索引tenant_id过滤资源利用率高过滤性能有损耗分片隔离按租户分片平衡实现复杂我一般用共享索引过滤因为大部分租户的数据量不大独立索引浪费资源。但如果某个租户数据量特别大超过100万块我会给它单独开索引。共享索引方案下tenant_id的过滤是强制的每个检索请求都必须带上。为了防止代码遗漏我在检索层做了一个封装所有检索请求必须经过这个封装封装里强制注入tenant_id过滤条件。5. 性能优化并发上来之后怎么扛5.1 延迟拆解每个环节花了多少时间生产环境P95延迟要控制在3秒以内理想是1秒以内。我把RAG链路的延迟拆开看环节典型耗时优化空间查询改写50-200ms缓存、小模型向量编码10-50msGPU加速、批处理向量检索20-100ms索引优化、量化BM25检索10-30ms倒排索引优化Rerank100-500ms分级、模型裁剪LLM生成500-3000ms流式输出、缓存LLM生成是大头但这是用户可感知的流式输出可以让首token时间控制在500毫秒以内。Rerank是第二大耗时分级策略能压到200毫秒以内。检索环节加起来控制在200毫秒以内。5.2 缓存策略哪些能缓存哪些不能缓存是降低延迟最有效的手段。我一般做三层缓存查询缓存完全相同的query直接返回缓存结果。命中率在内部知识库场景下能到20%到30%。缓存key用query的hashTTL设1小时。Embedding缓存相同文本的向量编码结果缓存。这个命中率很高因为很多query是重复的。用LRU缓存容量设10万条。检索结果缓存query权限过滤条件的组合缓存。这个命中率低一些但在热点问题上有效。# 查询缓存示例 import hashlib from functools import lru_cache def get_cache_key(query, user): raw f{query}:{user.tenant_id}:{user.department}:{user.roles} return hashlib.md5(raw.encode()).hexdigest() lru_cache(maxsize100000) def cached_embedding(text): return embedding_model.encode(text)注意缓存要考虑权限。不同用户即使query相同如果权限不同结果也可能不同。所以缓存key必须包含权限信息。我第三个项目就犯过这个错缓存key只用了query导致低权限用户拿到了高权限用户的缓存结果。5.3 批处理和异步提升吞吐量的关键单次请求的延迟优化到极限后吞吐量的提升要靠批处理和异步。批处理Embedding编码和Rerank都支持批处理。把多个请求的文本攒在一起一次编码能显著提升GPU利用率。我一般设batch_size为32延迟增加不多但吞吐量能提升5到8倍。异步向量检索和BM25检索可以并行执行用asyncio.gather同时发起总耗时取两者最大值而不是之和。import asyncio async def hybrid_retrieve(query, filters, top_k50): # 并行执行向量检索和BM25检索 vector_task asyncio.create_task( vector_search(query, filters, top_k) ) bm25_task asyncio.create_task( bm25_search(query, filters, top_k) ) vector_results, bm25_results await asyncio.gather( vector_task, bm25_task ) # RRF融合 return rrf_fusion(vector_results, bm25_results)这个改动看起来简单但在高并发下效果很明显。我第二个项目做了这个优化后P95延迟从18秒降到了4秒。6. 常见问题与排查技巧实录6.1 检索效果差从哪开始查检索效果差是最常见的问题排查顺序我一般是这样先看数据随机抽10个query人工看Top10结果里有没有相关文档。如果没有说明数据里根本没有答案或者解析/分块把答案弄丢了。再看分块把相关文档的分块结果打出来看确认答案没有被切散。如果被切散了调分块策略。再看Embedding把query和文档的向量算出来看余弦相似度。如果相关文档的相似度低于不相关文档说明Embedding模型不适合你的数据。最后看Rerank如果前几步都没问题但最终结果还是不对看Rerank的打分。有时候Rerank模型会把相关文档排到后面这时候考虑换模型或者调阈值。6.2 常见问题速查表问题现象可能原因排查方法解决方案召回率低分块太大/太小检查块大小分布调整分块参数召回率低Embedding不匹配人工评估相似度换模型或微调精确匹配差缺少BM25测试型号/编号查询加入BM25混合检索结果不相关Rerank失效检查Rerank分数换模型或调阈值延迟高Rerank耗时打点计时分级Rerank延迟高检索过滤慢检查过滤条件优化索引或换库权限泄露过滤条件错误多角色测试修复过滤逻辑数据不一致索引未同步对比源数据和索引重建索引6.3 几个我踩过的坑坑一过度依赖Rerank。我第一个项目把Rerank的TopK设得很大Top50全部过Rerank结果延迟爆炸。后来改成Top20过Rerank效果几乎没降延迟降了一半。坑二忽略BM25的索引更新。向量索引更新了BM25索引忘了更新导致新文档检索不到。后来我把两个索引的更新放在同一个事务里要么都成功要么都失败。坑三权限过滤用后置。前面说过检索完再过滤召回率崩。改成前置过滤后召回率恢复正常。坑四缓存没带权限。这个最危险直接导致数据泄露。缓存key必须包含权限信息这一点怎么强调都不为过。坑五分块重叠太大。重叠50个字符看起来不多但100万块就是5000万字符的冗余存储和检索都浪费。后来我把重叠降到20个字符效果没降存储省了30%。6.4 监控和告警上线之后怎么知道有没有问题上线不是终点是起点。我一般会监控这几个指标检索命中率有结果的query占比低于90%要告警Rerank分数分布Top1的Rerank分数如果持续偏低说明检索质量下降P95延迟超过3秒告警权限过滤比例过滤掉的文档占比突然升高说明权限配置可能有问题缓存命中率低于20%说明缓存策略需要调整这些指标我一般用PrometheusGrafana做可视化告警走企业内部的告警通道。每周review一次指标趋势发现异常及时排查。7. 一些个人体会三个项目做下来我最大的体会是RAG的难点不在模型在工程。Embedding模型、Rerank模型、LLM这些都是可以替换的组件真正决定项目成败的是数据管道、权限架构、性能优化这些“脏活累活”。Demo方案之所以扛不住生产环境是因为它跳过了所有这些工程环节直接展示最理想情况下的效果。而生产环境恰恰相反它把所有最坏情况都摆在你面前。如果让我给正在做RAG项目的同行一个建议我会说把70%的精力花在数据管道和权限架构上20%花在检索链路调优上10%花在模型选型上。这个比例和Demo方案的精力分配正好相反但它是生产环境验证过的。另外不要追求一步到位。我第一个项目试图一次性把所有环节都做到完美结果拖了三个月才上线。第二个项目改成迭代式第一版只做基础检索权限过滤两周上线然后根据用户反馈逐步优化分块、加入BM25、加入Rerank。这样风险更可控用户也能更早用上。最后再分享一个小技巧建立一套离线评估集。从历史工单里抽200到500个query人工标注正确答案每次改动检索链路后跑一遍评估集看召回率和准确率的变化。这套评估集是我做RAG项目最有价值的资产没有它所有优化都是盲调。