
快速阅读(30 秒速览)
- 同一份代码:开发用内存、演示用 JSON 文件、生产用 PostgreSQL、全量再加 Neo4j------切换只改 .env,不改代码
- 秘诀:所有存储能力抽象为 4 个核心 trait(VectorStorage / KVStorage / GraphStorage / DocStatusStorage),上层只依赖接口
- 双开关设计:Cargo feature 决定编译期"有什么",配置决定运行期"用什么"
- trait 继承层次 + 默认实现 + upcasting(需要 Rust 1.86)支撑"基座能力统一下发"
- 数据载体统一用
JsonMap:半结构化数据在任意后端间自由流动
前情提要 :上一篇用评测闭环结束了核心原理篇。工程实战篇第一站,聊支撑这一切的骨架------可插拔存储。
一个真实的需求冲突
产品经理:演示版要"下载即用,零依赖"。
运维:生产必须上 PostgreSQL,数据要可备份。
算法:要试 GraphRAG,得上 Neo4j。
你:不想维护四套代码。
EasyRAG 的答案是把存储做成"插座"------接口是标准的,插头随便换。
抽象层:四个 trait 定义世界
easyrag-core/src/storage/traits.rs 是整个存储体系的宪法。先看基座:
rust
#[async_trait]
pub trait StorageNameSpace: Send + Sync {
fn namespace(&self) -> &str;
fn workspace(&self) -> &str; // 工作区:结构性隔离维度
fn global_config(&self) -> &JsonMap;
async fn initialize(&self) -> crate::Result<()> { Ok(()) } // 默认实现
async fn finalize(&self) -> crate::Result<()> { Ok(()) }
async fn index_done_callback(&self) -> crate::Result<()>; // 批量提交钩子
async fn drop_pending_index_ops(&self) -> crate::Result<()> { Ok(()) }
async fn drop(&self, biz_id: &str)
-> crate::Result<HashMap<String, String>>;
}
四个具体存储 trait 都继承它:
StorageNameSpace(基座:命名空间/生命周期/批量提交)
├── VectorStorage 向量:query / upsert / delete / 时间范围过滤
├── KVStorage 键值:文档与分块元数据的读写
│ └── DocStatusStorage 文档状态机:认领、分页查询
└── GraphStorage 图谱:节点边 upsert、批量邻居查询
三个设计决策值得展开:
决策 1:生命周期钩子放基座,批量提交降 IO
index_done_callback 是"一批文档入库结束"的统一信号。不同后端在这里做不同的事:Tantivy 提交索引、JSON 后端刷盘落文件、PG 后端可以什么都不做。写入攒批、一次落盘,比每条落盘快得多。
决策 2:批量接口是"义务",不是"建议"
GraphStorage 强制提供 get_all_edges 这类批量方法。反面教材是"查实体 A 的邻居、查实体 B 的邻居"循环单查------图扩展一次就是几十次数据库往返(N+1 问题)。接口形状决定了实现的性能上限,所以要在抽象层就把批量形态定下来。
决策 3:数据载体用 JsonMap
rust
async fn query(&self, ...) -> crate::Result<Vec<JsonMap>>;
async fn upsert(&self, data: HashMap<String, JsonMap>, biz_id: &str) -> ...;
JsonMap 就是 serde_json::Map,镜像 Kotlin 版的 Map<String, Any?>。为什么不用强类型结构体?
- 文档、分块、实体这些数据的字段集合会随功能演进(今天加标题路径,明天加多模态描述),强类型会导致全链路改动;
- 所有后端最终都是"存 JSON"------PG 用 jsonb、Neo4j 用属性、JSON 后端直接落盘、内存后端存 Map,JsonMap 是最大公约数;
- 强类型放在 REST 边界(DTO)做,内部流动保持灵活。这是"边缘严格、核心灵活"的经典取舍。
实现层:一个后端一个文件
easyrag-storage 里每个后端独立成模块:
| 后端 | 覆盖的 trait | 依赖 | 场景 |
|---|---|---|---|
| memory/ | 全部 | 无 | 开发、单元测试 |
| json | KV(向量/文档状态仍在内存) | 无 | 单机演示、零依赖交付 |
| postgres | KV + 向量(pgvector) + 图 + DocStatus + 关键词(tsvector) | sqlx | 生产主力 |
| neo4j | Graph | neo4rs | GraphRAG 生产 |
| tantivy | 关键词检索 | tantivy | 无 PG 时的嵌入式全文索引 |
关键词检索的后端选择本身就是个降级链:PG 可用用 tsvector,否则回退嵌入式 Tantivy------功能不打折,只是换个发动机。
双开关:编译期裁剪 + 运行期装配

编译期(Cargo feature):
toml
# easyrag-storage/Cargo.toml
[features]
default = ["memory", "json"]
memory = []
json = []
postgres = ["dep:sqlx", "dep:pgvector"]
neo4j = ["dep:neo4rs", "dep:futures"]
tantivy-backend = ["dep:tantivy"]
不开 feature,对应后端的代码和依赖根本不参与编译------轻量交付版的二进制里连 sqlx 的影子都没有。
运行期 (配置装配,easyrag-app/src/storage.rs):
STORAGE_BACKEND=memory | json | postgres → KV/向量/状态后端
图存储走降级链:GRAPH_STORAGE_TYPE=neo4j → Neo4j,
否则有 PG → PgGraphStorage,否则 → 内存图
KEYWORD_SEARCH_ENABLED=true → tsvector 或 Tantivy(false 则关闭关键词检索)
启动时按配置构造具体实例,以 Arc<dyn VectorStorage> 的形式注入管线。管线代码里搜不到任何一个具体后端类型------它只和 trait 对话。
一个让移植卡了三天的细节:Trait Upcasting
有一个操作很朴素:拿到一个 Arc<dyn VectorStorage>,想调用它基座 StorageNameSpace 上的 drop 方法。
在 Rust 1.86 之前,dyn VectorStorage 不能直接转成 dyn StorageNameSpace(trait object 向上转型不稳定),只能用绕路方案(在每个子 trait 里复制方法、或用 as_any 黑魔法)。
Rust 1.86 稳定了 trait upcasting,一行搞定:
rust
// 子 trait 对象 → 父 trait 对象,编译器原生支持
let ns: &dyn StorageNameSpace = vector_storage.as_ref();
ns.drop(biz_id).await?;
这也是为什么 Cargo.toml 里写着 rust-version = "1.86" 并附了注释------语言特性直接影响架构选型的自由度。
踩坑记录
坑 1:内存后端的"假持久化"。 演示时服务重启数据全没,用户以为数据丢了。修复:JSON 后端作为演示默认,内存后端仅限测试;启动日志明确提示当前后端的持久化语义。
坑 2:不同后端的初始化时机。 PG 要建表建扩展(pgvector),Tantivy 要开索引目录,内存后端什么都不用。统一到 initialize() 钩子、在装配阶段串行调用,任一失败快速失败并回滚------不要带病启动。
坑 3:drop 语义不一致。 有的后端删除是异步缓冲的,用户删完立刻查还能查到。约定 drop 必须同步生效或明确返回状态 {"status": ...},前端按状态提示。
写在最后
| 手段 | 解决的问题 |
|---|---|
| trait 抽象 | 上层与具体后端解耦 |
| 批量接口进抽象 | 性能下限有保障 |
| JsonMap 数据载体 | 字段演进不伤全链路 |
| feature 条件编译 | 二进制按需瘦身 |
| 运行期装配 | 一份代码多种部署 |
"给系统留后路"不是过度设计------在存储选型这件事上,你今天确定的每一个"就用 XX",都是明天的一次重构。
下一篇玩点花的:把你的知识库伪装成 Ollama,让所有现成客户端零改造接入。
你的系统存储层换过后端吗?换的时候改了多少代码?评论区聊聊。
本文为《手撸一个生产级 RAG:EasyRAG 实战系列》第 8 篇。上一篇:Golden Set 评测 | 下一篇:[伪装成 Ollama](#本文为《手撸一个生产级 RAG:EasyRAG 实战系列》第 8 篇。上一篇:Golden Set 评测 | 下一篇:伪装成 Ollama)
开源仓库 :haibingzhao/easyrag ------ 欢迎 Star、Fork、提 Issue 和 PR。