前四篇文章里,我从第一性原理拆解了RAG的本质,梳理了技术进化史,手搓了一个Demo RAG系统,又踩了PDF解析的坑。这些都是在回答一个基础问题:RAG能不能跑起来。
但跑起来只是第一步。当我真正参与到一个企业级RAG项目时,才发现Demo和产品之间隔着的,不是什么高深的算法,而是一道又一道的工程决策。每一道决策都不是”我想用这个技术”,而是”问题逼着我必须这么做”。
这篇文章,我沿着一条数据从用户上传到最终被检索出来的完整链路,把这些工程决策串起来讲。
第一道关:文件上传——100MB的PDF,用户等不了三分钟
当用户把一份100MB的法规PDF拖进系统,接下来会发生什么?解析、分块、向量化——这套流程走下来可能要几分钟。但用户的耐心上限大概是一分钟,盯着转圈圈超过一分钟他就会觉得系统坏了。
所以第一个工程决策就来了:上传和处理必须解耦。
用户上传完文件后,系统只做两件事就立刻返回:接收文件、合并分片。至于解析分块和向量化,全部扔给Kafka,让后台的Consumer慢慢消费。用户看到的是”上传成功”,后台正在热火朝天地处理。
大文件怎么传?
100MB的文件一次性上传,如果中间断网了,就得从头再来。这显然不行。所以前端会把文件切成每片5MB,一片一片传。断了只需要重传那一片,而且多片可以并行传输,速度更快。
但分片上传又带来了新问题:断了重连之后,怎么知道哪些片已经传过了?
我一开始想的是每传一片就往数据库写一条记录,但后来发现一个更优雅的方案——Redis Bitmap。每个分片对应一个bit位,上传成功就把对应位置1。断线重连时,检查哪些位是0,只传没传过的。全部传完后合并分片,清理Bitmap。
Bitmap的key设计为 upload:{userId}:{fileMd5},用文件MD5作为唯一标识,同一个文件断点续传不会搞混。
消息丢了怎么办?
文件合并完要发一条Kafka消息通知Consumer去处理。但这时候可能会出三个问题:
消息重复:网络抖动导致Producer以为发送失败,重试了一次,实际上第一条已经发出去了。解决办法是开启Kafka的幂等机制(enable.idempotence),同一个消息不会被重复消费。
消息和文件不一致:文件合并成功了,但Kafka消息发送失败。这时候文件在那儿但没人处理。解决办法是事务投递——文件合并和消息发送要么都成功,要么都失败。
处理一直失败:Consumer消费时解析出错,重试几次还是不行。这时候不能无限重试,消息会进入死信队列,后续人工排查。Kafka的DefaultErrorHandler天然支持这个机制。
到这里,文件上传这道关就过了。完整链路是:
前端分片上传(每片 5MB) → MinIO 存储每个分片,Redis Bitmap 标记进度 → 全部传完后合并分片 → publish FileProcessingTask 到 Kafka,立即返回
=== 异步(Kafka Consumer)=== → 从 MinIO 下载文件 → 解析 + 分块 → 向量化写 ES第二道关:分块——切断一句话,后面全完了
文件到了Consumer手里,第一步是解析,第二步就是分块。
为什么必须分块?三个原因。第一,LLM的上下文窗口不是无限的,塞太多会稀释注意力;第二,Embedding模型向量维度有限(常见512到2048维),太长的内容压缩成向量后信息丢失严重;第三,粒度太粗浪费Token。所以必须把长文档切成小块,只把和用户问题强相关的部分喂给模型。
为什么不能用固定长度切?
最直觉的分块方式是每500字一刀。但问题在于,固定长度完全不考虑语义边界。一句话可能被拦腰截断,前半句在A块,后半句在B块。当向量检索时匹配到了A块,模型看到的只有”根据《安全生产法》第”,后半句是什么?不知道。语义断裂,后面的检索和生成就全废了。
三层递进:先试自然边界,不行再暴力切
所以我的策略是三层递进,优先尊重自然的语义边界:
第一层:按段落切。 以 \n\n+(空行分隔)作为切割点。段落是最自然的语义单元,一个段落通常围绕一个主题展开。如果段落长度在限制内,甚至可以把相邻的小段落合并,减少碎片化。
第二层:按句子切。 有些段落很长,比如法规条款动辄几百字。这时候降级到按句末标点(。!?;)切割。至少保证每个chunk是完整的句子。
第三层:按词切。 还是有极少数句子特别长,句子级别依然超限。这时候用HanLP做中文分词,按词积攒,至少不把一个词从中间切开。这是最后的兜底。
段落切 → 段落太长?→ 句子切 → 句子还是太长?→ 分词切每一层都是同一套逻辑:先试自然边界,不行再降级。
还有一点值得提:流式解析防OOM。不是把整个文件读进内存再切,而是Tika边解析边累积文本,累积到1MB就切一批。这样即使几百MB的大文件也不会撑爆内存。
分块这道关过了,数据变成了一个个语义完整的chunk,接下来要做向量化存进ES,然后等待被检索。
第三道关:检索——找到还不够,还要找得准
用户提问后,系统要在成千上万的chunk中找到最相关的几个。这就是检索环节。
在第三篇手搓RAG的文章里,我就踩过这个坑:纯向量检索擅长”找感觉”,但对数字和专有名词极度不敏感。搜”合同编号HT-123”,它可能给你匹配到”合同编号HT-456”,因为在向量空间里它们语义相似。而BM25恰好反过来,关键词必须出现才算命中,精确性很高,但换个说法就搜不到——问”薪酬标准”可能搜不到写着”工资待遇”的文档。
所以检索必须是混合的。但怎么混,是有讲究的。
先粗后精:两阶段混合方案
我采用的是两阶段的架构,而不是简单地把两个检索结果拼在一起:
第一阶段:KNN向量召回。 从ES里捞出 topK×30=150 个候选。为什么是30倍?这是召回窗口——宁可多捞一些,确保不漏掉真正相关的,后面还有精排来筛。这一步同时加了两个约束:must match确保候选必须包含用户问题中的关键词,以及权限过滤(后面第四道关会讲)。
第二阶段:BM25 Rescore精排。 对这150个候选,用BM25重新打分。关键参数是 queryWeight=0.2, rescoreQueryWeight=1.0——向量分只占20%权重,BM25分占100%权重。
为什么BM25权重给这么高?因为关键词命中的确定性更高。向量语义匹配可能”感觉像但其实不是”,但关键词出现了就是出现了。在精排阶段,确定性比可能性更重要。
最终从150个候选里取topK=5条返回。
向量服务挂了怎么办?
混合检索依赖向量服务,但Embedding API可能超时或异常。这时候系统会自动降级为纯文本搜索(BM25),保证服务可用。降级触发条件是向量生成失败或混合查询整体抛异常,catch里走纯文本检索。
用Rescore而不是两个独立查询再合并,是因为Rescore是ES的原生能力,在一个查询内完成粗筛和精排,性能比两次查询好得多。
第四道关:谁能看什么——企业里的隐形门槛
前三道关解决的是”能不能用”的问题,到了这一关,要解决的是”给不给你用”的问题。这也是第一篇文章里提到过的企业红线——权限隔离。
企业里法务部的合同不能被财务部搜到,但部门内部需要共享。而且人员会变动——张三从技术部调到了产品部,他的文档应该留在技术部,而不是跟着他走。反过来,张三到了产品部,就应该能看到产品部的文档。
双层隔离
存储层:每个chunk在ES里都记录了三个字段——userId(谁上传的)、orgTag(属于哪个组织)、isPublic(是否公开)。写入的时候就打上标签。
查询层:ES搜索时用filter过滤,三个条件是OR关系——满足任一即可:
- 文档是我上传的(userId匹配)
- 文档是公开的(isPublic = true)
- 文档属于我的组织(orgTag匹配)
你可能注意到了,前面第三道关的检索流程里,KNN召回阶段就已经在做权限过滤了。过滤不是事后拦截,而是直接写在查询条件里,性能更好。
OrgTag层级继承
组织是有层级的。“技术部-前端组”的人,应该能同时看到”技术部”和”前端组”的文档。所以OrgTag支持层级继承——OrgTagCacheService缓存了组织树结构,获取用户标签时会递归向上查找所有父级标签。文档绑定组织不绑定个人,人走了文档还在组织里。
这里有个细节:为什么不用Spring Security的数据权限?因为Spring Security管的是接口访问权限(“你能不能调这个接口”),这里管的是数据级别的可见性(“你能不能看到这条数据”),粒度完全不同。而且ES查询层的过滤必须在查询构建时做,不是后置过滤,这超出了Spring Security的能力范围。
Token限流:电商下单的逻辑
多用户并发使用RAG,还有一个容易被忽略的问题:Token额度控制。
LLM按Token计费,流式输出过程中,系统不知道最终要消耗多少Token。如果不提前冻结额度,多个用户同时提问,额度就可能被超出去。
这个问题的解法和电商的库存管理一模一样:
- 预扣:调用LLM之前,根据上下文窗口大小预估消耗量,冻结对应额度。就像下单时冻结库存。
- 结算:调用完成后,按实际消耗扣减,多余的退回。比如预扣了1000 Token,实际只用了953,退回47。
- 回滚:调用失败时,全额退回冻结的额度。就像取消订单释放库存。
这套预扣-结算-回滚的机制,保证了并发场景下额度不会被超用。
尾声
回过头看这四道关,其实是一条完整的因果链:
文件太大用户等不了 → Kafka异步 + MinIO分片上传 ↓长文档必须分块 → 三层递进保证语义完整 ↓纯向量检索不准 → 混合检索先粗后精 ↓企业多人使用 → 多租户隔离 + Token限流每一环都是被上一环的问题倒逼出来的。没有哪一步是”我觉得这个技术很酷所以用一下”,全是”这个问题不解决,后面就走不通”。这就是工程设计的本质——不是堆技术,而是用最简的方案解决最痛的问题。
跑通一个RAG Demo只需要半天,但从Demo到产品,每一道关都要花心思。这几道关跨过去之后,系统才算真正从”能用”变成了”可用”。