1. 先看全局:四层存储到底在 RAG 系统里各自扛什么活
很多人第一次部署 RAGFlow 时,看着 docker-compose 里 mysql、minio、elasticsearch、redis 四个服务一脸懵:一个做 RAG(检索增强生成)问答的系统,为什么要同时拉起来数据库、对象存储、搜索引擎和缓存?我最早也是这么想的,直到后来排查“文档解析成功但检索为空”的问题时,才发现如果不把元数据、对象存储、检索、缓存这四层的分工搞明白,排错根本无从下手。
这篇文章把拆解 RAGFlow 四层存储的思路完整梳理一遍,会讲清楚每一层到底存了什么、为什么选这个组件、各层之间如何协作,也会分享几个真实遇到的故障现场。适合刚接触 ragflow 的开发者,也适合已经跑起来但想搞清内部数据流的运维同学。
1.1 一套 RAG 系统里其实混着三类完全不同性质的数据
先回答那个最基础的问题:为什么不能像传统 web 应用一样,把数据全塞进一个 MySQL?
因为 RAG 系统的数据,按性质至少分成三类:
- 关系型/状态型数据:知识库和文档的记录、解析状态、chunk 元信息、会话和消息。这类数据有强关联关系,需要事务、外键、按条件筛选、频繁小更新。
- 大文件二进制数据:原始 PDF、Word、PPT、解析出的图片和版面结果。这类数据体积大、不会跑 SQL 查询,只需要按 key 快速存取。
- 检索型数据:分块后的文本和对应的嵌入向量。这类数据需要全文检索、向量相似度计算,以“召回 top-K”为目标。
如果强行把这三类放一个库里,要么 MySQL 存大字段把 buffer pool 撑爆,要么搜索引擎去做事务导致索引频繁重建。RAGFlow 走的路线是典型的“专业组件干专业事”:MySQL 管元数据、MinIO/S3 管对象文件、Elasticsearch 管检索、Redis 管缓存与临时状态。四套系统组合起来,才构成一个完整的存储体系。
1.2 一条问答请求横跨四个存储组件的过程
先记住完整的数据流转:用户在界面上传 PDF,Web 服务把原始文件写入对象存储层(默认是 MinIO),同时在 MySQL 中插入一条 document 记录,状态为“解析中”。任务执行器拿到解析任务,用版面解析引擎把 PDF 拆成文本块、表格和图片;图片等中间产物再写回对象存储。每个文本块按知识库配置的分块策略切成 chunk,生成向量,连同文本一起写入 Elasticsearch,一个知识库对应一个索引。解析完成,MySQL 里的 document 状态更新为“完成”,chunk 数量和 token 数写回。
用户提问时,问题文本被向量化,ES 做向量检索加全文检索的混合召回,拿到 top-K chunk。会话上下文、限流状态、消息记录中的高频部分由 Redis 承担,最终结果落回 MySQL 的 message 表。这条链路里,任何一个存储环节出问题,对外表现都完全不同。用一个表格总结四层分工:
| 存储层 | 核心组件 | 存放内容 | 数据特点 | 典型操作 |
|---|---|---|---|---|
| 元数据层 | MySQL | 知识库、文档、chunk 元信息、会话消息 | 行式、强关系、频繁更新 | 增删改查、事务 |
| 对象存储层 | MinIO / 云 S3 | 原始文件、解析中间产物 | 大文件、不可变、按 key 读写 | PUT / GET / DELETE |
| 检索层 | Elasticsearch | chunk 文本与向量 | 倒排索引加向量字段 | 混合检索、召回 top-K |
| 缓存层 | Redis | 会话、限流计数、任务信号 | 临时状态、TTL 过期 | set / get / incr / pub-sub |
2. 元数据层:文档状态、知识库关系这类“消息”为什么必须走 MySQL
2.1 元数据层具体存了什么
以我部署的 ragflow 版本为例,MySQL 里核心数据大致包括:知识库表记录 kb 的 id、名称、embedding 模型、chunk 配置、权限范围;文档表记录文件名、文件类型、md5、解析状态、chunk 总数、token 总量、解析耗时、所属知识库;chunk 元数据表记录 chunk 的 id、所属文档、序号、关键词、重要关键词;会话与消息表记录 conversation 和 message;还有任务表和用户表,负责跟踪解析任务状态与 API 调用权限。
你会发现这些数据几乎全是“小而频繁更新”的行记录:文档解析状态从 parsing 变到 done,chunk 数随着重分块不断变化,会话消息不断追加。这些都需要行级事务保证一致性,这正是关系型数据库的主场,也是它和其他三层的本质区别。元数据层的设计原则是:永远只存“关于数据的描述”,不存数据的本体。
2.2 元数据和对象存储、检索层之间的映射关系
元数据层不是孤立存在的,它相当于整个系统的“主索引”,记录了另外三层的对应关系。document 表里有存储引用指向对象存储的某个 key;知识库表里记录了使用的 embedding 模型,这个模型决定 ES 索引里向量字段的维度;chunk 元数据通过文档 id 与 ES 索引中的文档建立映射,检索命中的 chunk 需要回表拿原文和业务信息。
因此一旦 MySQL 数据丢失,光有 MinIO 里的文件和 ES 里的向量,是拼不回一个可用系统的。对象存储和检索层是“结果”,元数据是“索引关系”。打个比方:MySQL 像图书馆的检索目录,对象存储像藏书库,ES 像按内容主题编排的卡片柜,Redis 是前台高频使用的热门书单。目录丢了,书还在,但你不知道哪本书在哪、哪本被谁借走;所有读者就只能凭运气乱翻。
3. 对象存储层:MinIO 与 S3 协议背后的二进制文件归宿
3.1 哪些文件会真正落进对象存储
很多人以为“对象存储 = 存上传的文档”,其实不止。RAGFlow 默认用 MinIO,遵循 S3 协议,落到这一层的内容主要有三类:一是原始上传文件,包括 PDF、Word、PPT、Excel、图片,按文档 id 或 md5 生成对象 key;二是解析中间产物,包括版面分析输出、OCR 结果和页面切图,部分版本里图片会单独存成对象,供文档预览和图文问答使用;三是知识库导出包和其他附件。
为什么要用对象存储而不是直接放服务器磁盘?最直接的理由是解耦与扩展:解析 Worker 可以横向扩容,多台机器共享一个 MinIO 就能一致地读写原始文件,不需要做共享磁盘;S3 协议意味着你可以无缝换成阿里云 OSS、腾讯 COS 等公有云对象存储服务。使用云上对象存储服务还有一个隐性好处:上传下载都走 HTTP 协议,和 Web API 天然契合,CDN 和网关都好接。对于内容不可变、只按 key 读写的文件,对象存储几乎是性价比最高的方案。
3.2 从本地 MinIO 切换到云 OSS/COS 的配置差异
本地测试用 MinIO 默认配置就能跑通,但上生产时很多人会换成云 OSS。切换的核心,是在 RAGFlow 的存储配置里设置 access_key、secret_key、bucket、endpoint 四个参数。其中有几个坑是我实际踩过的:
- endpoint 一定要写对协议:MinIO 常用
http://minio:9000这类地址,云 OSS 则是https://oss-cn-hangzhou.aliyuncs.com。漏掉协议前缀或把地域域名写错,会直接报连接错误,而且这个错误经常被误判为网络问题。 - 内网与公网 endpoint 要分清:RAGFlow 部署在云上时,解析服务与 OSS 之间应使用内网 endpoint,省流量且更快;但 Web 服务给前端生成临时下载链接时,要确认走外网 endpoint,否则用户拿到的 URL 在内网环境根本访问不了。
- bucket 权限遵循最小化:本地 MinIO 图省事可以给 full access key,但云对象存储建议只授予该 bucket 的 PutObject、GetObject、DeleteObject 权限,避免 key 泄露后影响同账号的其他资源。
- 数据迁移别重新上传:从本地 MinIO 切到云 OSS,建议用兼容 S3 的同步工具做增量同步,比如
mc mirror,可以直接把已有对象按 bucket 和前缀搬到云上,避免几十 GB 文档重新解析和向量化。
4. 检索层:Elasticsearch 上向量检索与全文检索的协同方式
4.1 为什么选 ES 而不是“专用向量数据库”
聊到 RAG,很多人第一反应是 Milvus、Qdrant、pgvector 这类专用向量库。但 RAGFlow 的检索层用的是 Elasticsearch,理由其实很朴素:真实知识库的检索需求从来不只是“向量相似度”。业务上经常要精确匹配某个专有名词、搜表格里的数字范围、按关键词权重过滤,这些是纯向量检索不擅长的;而 ES 天生就是全文搜索引擎,从 7.x 开始又支持 dense_vector 字段和 kNN 检索,等于一套系统同时提供 BM25 全文检索和向量检索。
RAGFlow 在召回阶段做的是“混合检索”:把全文分值和向量相似度按权重融合,再取 top-K。这种融合如果拆成两套系统,就要自己处理两个结果集的归并和权重平衡,工程复杂度直接翻倍。从运维角度看,ES 的成熟度、监控生态和故障恢复能力也比年轻的专用向量库完善。对大多数 GB 级知识库,ES 完全够用;只有到千万级 chunk、对延迟极度敏感的场景,才需要考虑引入独立向量数据库。
4.2 从文档入库到索引生效的完整过程
检索层不是简单把向量丢进去就完事,里面有一套明确流水线:文档解析产出结构化文本(去页眉页脚、表格转文本、图片单独抽离);按知识库的 chunk 策略分块,比如按标题层级、固定长度或 Q/A 对切分;每个 chunk 通过 embedding 模型生成向量;把 chunk 的文本、向量、文档 id、序号一起写入该知识库对应的 ES 索引;然后更新 MySQL 的 chunk 计数和文档状态;索引刷新后,文档才能被检索到。
这里有两个实际经验。一是refresh_interval会决定新写入数据多久可见,批量处理几十上百个文件时,建议调低刷新频率、分批提交向量,避免大量小请求把 ES 打爆;二是在你刚入库一个文件就想立刻验证效果时,可以直接对索引执行_refresh,不用干等。最容易被忽略的点:ES 索引的 mapping 一旦建立,vector 字段的维度就固定了,后面换了 embedding 模型但维度不一样,新数据写入会直接报 mapping 冲突,这个坑到第 6 节再展开。
4.3 混合检索的评分与相似度阈值
RAGFlow 的检索参数里有几个很关键:召回条数 top_k、相似度阈值、是否启用重排(rerank)。向量相似度和 BM25 的分值区间不同,直接相加没有意义,必须先做归一化,常见做法是 max-min 归一化或 rank 融合。相似度阈值做的是过滤:低于阈值的 chunk 哪怕排进 top-k 也会被丢弃,这就是“宁可少给也不能给错”的取舍。
我的调参经验是:知识库内容专业、用户问题句式规范时,阈值可以调低一点(0.1 到 0.2),多召回候选;知识库碎片化、噪声多时,阈值调高到 0.3 以上,减少无关片段进 prompt。启用 rerank 模型后,阈值的作用会减弱,因为重排模型会在更细粒度上过滤无关 chunk;此时建议把 top_k 调大(比如 50),让重排模型有更多候选可挑,再取前 3 到 5 个送 LLM。这其实就是“语义检索”落地时的最后一关:召回要宽,但以什么标准收窄,由这些参数决定。
5. 缓存层:Redis 在 RAGFlow 里缓存了什么,又坚决不碰什么
5.1 Redis 里的三类数据:会话、限流与任务信号
RAGFlow 的 Redis 不只是常见的“热点数据缓存”,从我观察到的实际用途看,至少承担四类工作。第一是会话上下文临时存取:多轮对话时,如果每轮都去 MySQL 翻全部历史消息,延迟高且数据库压力大,Redis 用 hash 或 list 结构暂存当前会话最近若干条消息,设置 TTL 自动过期,这就是典型的分布式缓存用法。第二是限流与配额:API 请求的令牌桶、用户调用次数统计放在 Redis 里做原子自增,多个 Web 实例共享同一份计数,保证限流全局生效。第三是任务信号与事件通知:文档解析任务的状态变更、客户端轮询任务进度,借助 Redis 的发布订阅或临时 key 广播。第四是可能的 embedding 或 LLM 结果缓存:相同问题在窗口内重复查询时直接返回缓存结果,节省模型调用费用。这一块不同版本实现差异较大,我会直接进 redis-cli 用KEYS看实际 key 分布来确认。
Redis 坚决不碰的是两类数据:文档原文和二进制文件那都是对象存储的活,最终的 chunk 全量和向量是 ES 的活。如果你发现生产环境 Redis 内存异常暴涨,首先要怀疑是不是有人往里面塞了 chunk 文本,很多“缓存层 OOM”其实都是用法不对,不是容量不够。
5.2 缓存穿透在 RAG 场景下的实际表现与规避
传统缓存穿透,是查询一个不存在的 key,每次都打到数据库。RAGFlow 里同样存在,只是表现不太一样。比如用户频繁问知识库之外的问题,如果系统对“无检索结果”也做缓存,那么缓存了“空结果”后,后续相同问题就不会再触发新的检索;如果不对空结果做缓存,每次都要走一遍完整检索链路。更常见的穿透是:用户删除或重建知识库后,Redis 里旧的会话缓存还指向旧知识库 id,导致对话返回“知识库不存在”或旧内容,这就是缓存与元数据层之间的失效联动没做好。
我的规避习惯是:会话类 key 一律带上知识库 id 和版本号;删除知识库时用DEL或通配匹配删除相关 key;对“无结果”这种空缓存设置很短的 TTL(比如 30 秒),既不穿库又能快速恢复。说白了,“缓存失效”和“缓存一致”在 RAG 系统里的落地规则,就是缓存必须跟着元数据层的事件走:文档变了、库删了、模型换了,缓存立刻失效,而不是靠自然过期。
6. 四层协作最容易出问题的三个现场
6.1 现场一:解析显示成功,ES 检索永远是空
第一次遇到这种事故,用户上传一份 PDF,界面显示解析完成,但在对话框里怎么问都返回“没有找到相关内容”。我当时按这个顺序排查:先看 MySQL 里 document 表的状态和 chunk 数,发现 status 是 done、chunk_num 是 124,说明解析和分块都完成了;再看 ES 索引是否存在,索引也在;但查索引里的文档数时,结果居然是 0。到这里基本锁定:解析任务崩在了向量生成之后、提交 ES 之前。翻任务执行器日志,发现是 embedding 接口超时,重试几次后任务被标记完成,但 ES 写入没成功。
也就是说 MySQL 元数据认为“完成”,检索层却是空的,两层状态不一致。修复动作本身很简单:把 document 状态改回 parsing,让任务重跑,ES 重新写入。但根子是 embedding 服务不稳定,后续我给调用加了更长的超时和更合理的重试策略。这个案例最能说明四层协作的特点:任何一层成功都不能代表整条链路成功,排错时必须跨层对账。
6.2 现场二:删除知识库后,对象存储空间越用越大
另一次线上知识库反复重建,跑了一周后发现磁盘占用比实际文档总量大出好几倍。查了一圈,发现删除知识库时 MySQL 的元数据和 ES 的索引都删了,但 MinIO 里的原始文件还留在桶里。原因是对象存储为了复用,经常用 md5 做去重,同一份文件可能被多个知识库引用,删除某一个知识库时不能直接删除对象文件;而本地 MinIO 又没有云对象存储那样的生命周期规则,删除操作被跳过后就产生了孤儿对象。
我后来的处理思路:给对象存储配置生命周期规则,只清理 multipart 残留和超过 N 天的临时对象,不动正常文件;再写一个对账脚本,从 MySQL 的 document 表里把仍在引用的对象 key 拉出来,用mc ls列出桶内全部 key,两者的差集就是可删除的孤儿对象。先导出清单人工确认一遍再删。这个经验对云 OSS 同样适用:别指望删除接口帮你做垃圾回收,GC 要自己设计。
6.3 现场三:更换 embedding 模型后,新老数据在同一个索引里打架
某次为了提升中文效果,我把知识库从 bge-base 换成 bge-large,结果新上传的文档全部入不了库,日志里报mapper_parsing_exception。原因很直接:bge-base 系列向量维度是 768,bge-large 是 1024,ES 索引的 mapping 不允许同一个向量字段出现两种维度。换模型这个动作,牵动了元数据层(知识库配置)、检索层(ES 索引)、缓存层(可能残留旧向量结果)三处。
处理办法有两种。一是换模型后新建一个知识库,让新库生成新索引,老知识库保留旧模型继续服务,这是最省事、零风险的;二是想统一模型,就把原知识库的文档全部删除重新上传,或者写脚本重建索引,但注意重建期间检索会短暂不可用,建议在低峰期操作。连带还要把 Redis 里对应知识库的缓存 key 清掉,否则会继续用旧模型时代的缓存结果。这个案例说明:模型层变更不是改个配置就完事,必须按“四层联动”的思路通盘考虑。
7. 部署与运维中的容量、备份和迁移实践
7.1 四层服务的资源规格参考
按我的部署经验,给一个内网私有化场景的资源参考:
| 规模 | MySQL | MinIO / 对象存储 | Elasticsearch | Redis |
|---|---|---|---|---|
| 演示 / 小库(1 万 chunk 以内) | 1C2G | 按文件量定磁盘 | 2C4G(堆 1 到 2G) | 1C1G |
| 中等知识库(50 万 chunk 以内) | 2C4G | 500G 到 1T 磁盘 | 4C8G(堆 3 到 4G) | 2C2G |
| 较大生产(百万 chunk 以上) | 4C8G+ | 独立对象存储集群 | 8C16G+ 多节点 | 4C4G+ |
注意 ES 的堆内存不要超过物理内存一半,且不要超过 32G,否则会踩到 JVM 压缩指针的坑。MinIO 的磁盘建议用独立数据盘,避免系统盘被日志撑满。Redis 大部分情况 1 到 2G 就够,除非你把整库会话缓存撑得很大。另外,ES 的数据目录和 MinIO 的数据目录一定要分开监控,这两个是最容易被文件撑爆的位置。
7.2 备份与迁移的实操清单
按重要性排序,备份优先级应该是 MySQL 大于对象存储大于 ES 大于 Redis。元数据层丢了整个系统就“失忆”;对象存储丢了文件可以重新上传;ES 索引丢了大不了重新向量化,但要花时间和模型调用费用;Redis 本身是可重建的缓存。我常用的方案:MySQL 每天凌晨mysqldump全量备份,保留最近 7 天;对象存储目录用mc mirror --watch增量同步到另一台机器或云存储,配合对象版本控制;ES 用 snapshot API 把索引快照备份到存储目录,数据量小就直接对数据目录做文件级快照;Redis 开启 AOF 持久化,保证崩溃时能恢复到分钟级。
迁移到新机器时,正确顺序是先起好四层服务且版本保持一致,再恢复 MySQL,然后校验对象存储的 key 路径是否一致(RAGFlow 内部存的路径如果带主机名或 IP 会出问题),最后恢复 ES 索引并重启 Web 服务。如果只是换机器没换版本,一般不需要重新向量化,耗时主要取决于对象存储数据的拷贝速度。
最后再补一句个人体会:RAGFlow 的四层存储看起来是四套独立系统,但排错时一定要把它们当成一个整体。先问自己“这条数据现在应该在哪一层”,再判断“它有没有真的到那一层”,远比盲目重启容器有效。希望这篇拆解能帮你少走点弯路。