数据截至 (上游 commit 3590b47a25bd)
存储层:VikingFS 门面、RAGFS 虚拟文件系统与向量库
30 秒导读: 上层看到的是
viking://user/alice/notes/a.md这样的虚拟路径(范式见 01)。这一章讲它到底落到哪、怎么落:VikingFS是 Python 侧的门面,把 URI 翻成按账号隔离的物理路径、守住"哪些命名空间能写能删"、给加密写加锁;再通过一份 Python↔Rust 契约把活交给RAGFS——一个用 Rust 重写的 AGFS 虚拟文件系统,负责多后端挂载、缓存、版本化与真正的字节读写。与此并行,同一个文件动作还会被镜像进向量索引后端:一份文件语义 = 向量集合里的若干条 level 0/1/2 记录 + KV 里的标量行。
1. 这是什么(零基础也能懂)
一句话定义: 存储层 = "把虚拟文件语义变成真实字节 + 可检索向量"的那一层。它夹在上层的 URI 语义和最底层的 ANN/KV 引擎(见 06)之间。
它同时干两件互相独立又必须同步的事:
| 落到哪 | 存什么 | 谁负责 |
|---|---|---|
| 文件系统(字节) | 文件原文、目录树、.abstract.md/.overview.md | VikingFS → RAGFS → 后端插件 |
| 向量库(可检索) | 每份内容的稠密向量 + 标量字段(uri/level/tags…) | VikingVectorIndexBackend → collection + KV |
为什么要两套? 文件系统回答"给我 a.md 的内容";向量库回答"和'退款政策'语义最近的是哪几份内容"。检索靠后者(算法见 03),但真相永远以文件系统为准——所以删一个文件时,两边都得动,且不能删歪账号。
一句话直觉: 把 VikingFS 当收发室——它不亲自搬箱子,只做三件事:查你有没有权限往这个格子放东西、给箱子贴上带账号的物理货位号、然后喊 RAGFS 这个仓库机器人去搬。搬完顺手更新一张语义索引卡(向量库),方便以后按"意思"找货。
本节不出现底层代码;只要记住:一层门面(VikingFS)、一份契约(pyagfs)、一个仓库(RAGFS)、一套语义索引(向量库)。
2. 顶层全景(它大概怎么转)
怎么读这张图: 从上到下是一次写入的下沉路径;左边是字节主线,右边是它触发的向量镜像。左右在 VikingFS 里被同一个方法编排。
上层调用 viking://user/alice/notes/a.md , data
│
┌─────────────────────▼──────────────────────┐
│ VikingFS(Python 门面 storage/viking_fs/ 包)│
│ ① 校验:能不能写这个命名空间 │
│ ② _uri_to_path:URI → /local/{account}/… │
│ ③ 加密写加锁(配了 encryptor 时) │
└───────┬───────────────────────────┬─────────┘
│ 字节主线 │ 向量镜像(rm/mv 时)
▼ ▼
┌───────────────────────┐ ┌──────────────────────────────┐
│ pyagfs 契约 │ │ VikingVectorIndexBackend │
│ AGFSSyncClientProtocol │ │ (per-account 后端门面) │
│ ls/read/write/mv/grep… │ │ upsert / delete_uris / │
│ + ctx={account_id} │ │ update_uri_mapping │
└──────────┬─────────────┘ └───────────────┬──────────────┘
│ PyO3 绑定 │
▼ ▼
┌───────────────────────────┐ ┌───────────────────────────┐
│ RAGFS(Rust · crates/ragfs)│ │ collection + KV │
│ Stats→Mountable→per-mount: │ │ 稠密向量→ANN 索引 │
│ Cache / Encryption 包装 │ │ 标量字段→KV(RocksDB) 行 │
│ multibackend/git/shape │ │ 一份文件 = level 0/1/2 记录 │
└──────────┬────────────────┘ └───────────────────────────┘
▼ (ANN/KV 引擎细节见 06)
localfs / memfs / s3fs / kvfs …
部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
VikingFS | URI↔路径映射、命名空间守卫、加密写锁、rm/mv 时同步向量库 | openviking/storage/viking_fs/__init__.py:109 |
AGFSSyncClientProtocol | Python 侧对"AGFS 客户端"的最小契约(同步接口) | openviking/pyagfs/protocols.py:12 |
AsyncAGFSClient | 把同步客户端丢到线程池跑,并按路径注入 account_id | openviking/pyagfs/async_client.py:57 |
RAGFSBindingClient | PyO3 原生类,Rust 侧实现上述契约 | crates/ragfs-python/src/lib.rs:1220 |
FileSystem(trait) | RAGFS 所有后端必须实现的统一文件接口 | crates/ragfs/src/core/filesystem.rs:127 |
MountableFS | 按挂载点路由,逐后端套 Cache/Encryption | crates/ragfs/src/core/mountable.rs:67 |
VikingVectorIndexBackend | per-account 向量后端门面:写/删/改 URI 映射 | openviking/storage/viking_vector_index_backend.py:756 |
3. 核心原理(逐个机制,由浅入深)
3.1 VikingFS 门面:三道关口
VikingFS 对上暴露 read/write/mkdir/rm/mv/grep/ls/stat 等(storage/viking_fs/_ops.py:42 起),但它自己不碰字节。它的价值全在"转交之前"和"转交之后"做的守卫与编排。
关口一:URI → account 隔离的物理路径。 映射是纯前缀替换——viking://{余下} → /local/{account_id}/{余下},account_id 来自请求上下文:
# 示意,非源码;对应 _uri_to_path
def _uri_to_path(uri, ctx):
account_id = ctx.account_id # 租户身份
_, parts = normalized_uri_parts(uri) # 拆 viking:// 之后的段
return f"/local/{account_id}/" + "/".join(parts)
真实实现在 _uri_to_path(storage/viking_fs/_access.py:430)。多租户隔离的物理基础就这一行:不同账号即便同名 URI,落盘目录也不同。规范化前还会拒绝 ./..、\、C: 这类穿越/平台特例段(_safe_uri_parts,storage/viking_fs/_access.py:99-122),从源头堵路径穿越。
关口二:命名空间写/删校验。 不是所有 URI 都能写或删。_ensure_supported_write_namespace(storage/viking_fs/_access.py:222)与 _ensure_supported_delete_namespace(storage/viking_fs/_access.py:198)在动手前拦截几类"会伤到共享根"的目标:
| 目标 | 写 | 删 | 原因 |
|---|---|---|---|
viking://user(裸根) | 拒 | 拒 | 会跨用户误伤,必须给到具体用户命名空间 |
viking://agent(裸根) | 拒 | 拒 | 递归会抹掉每个账号的 skills/endpoints/tools/payments |
viking://agent/{id}(旧格式) | 拒(写) | — | 已废弃,改用 viking://user/.../peers/{id} |
viking://session/... | 拒 | — | session 只读,改用 user 命名空间 |
viking://temp(非 root) | 拒 | 拒 | temp 根对非 root 只读 |
关口三:加密写锁。 当配置了 encryptor,写入不是"原地覆盖",而是"写临时文件再原子替换"。为避免并发写撞车,当前 commit 把这套协议搬进了 openviking/storage/content_write.py:对最终路径 + 临时路径两条路径同时上 "exact" 锁(pathlock_acquire_exact,content_write.py:763/1176;临时路径推导 _encrypted_temp_path 仍在 viking_fs/_access.py:135):
# 示意,非源码
async def _run_with_encrypted_write_lock(path, op):
if encryptor is None:
return await op() # 明文栈:不加这层锁
lock_paths = [path, _encrypted_temp_path(path)] # 两条路径一起锁
async with LockContext(mgr, lock_paths, lock_mode="exact"):
return await op()
临时路径是确定性推导的(_encrypted_temp_path,storage/viking_fs/_access.py:135):按最终路径的 mount 相对路径做 sha256,落到 .../temp/.encrypt_stage/{digest}.encrypt。确定性是为了让锁"同一个最终文件 → 同一个临时文件"能真正互斥。(锁管理已下沉到 Rust ragfs 层:storage/transaction/__init__.py:5-9 注明 Python 侧改走 RAGFSBindingClient.pathlock_*。)
注意:加密本身发生在 Rust 层(按
account_id派生密钥),Python 侧只负责加这道跨双路径的互斥锁;write()把明文交给下层即可(storage/viking_fs/_ops.py:80)。
3.2 Python↔Rust 契约:一份协议 + 一个"账号从路径来"的约定
Python 不直接调 Rust,而是面向一个 Protocol(结构化鸭子类型)编程:AGFSSyncClientProtocol(pyagfs/protocols.py:12)。它列出下层客户端必须提供的同步方法——ls / read / cat / write / mkdir / mv / grep / stat / rm / tree_directory 等。谁实现了这套签名,谁就能被塞进 VikingFS。
这带来一个关键的解耦:同一份 Python 代码,既能对接进程内的 Rust 绑定,也能对接远程 HTTP AGFS——只要满足契约。生产用的是 Rust 绑定 RAGFSBindingClient(crates/ragfs-python/src/lib.rs:1220)。
account_id 怎么过河? 这是最巧的一处。VikingFS 已经把 account_id 编进了物理路径(/local/{account_id}/…),于是异步包装层再从路径把它抠出来,作为 ctx 传给 Rust:
# 示意,非源码;对应 fs_ctx_from_agfs_path
def fs_ctx_from_agfs_path(path):
parts = path.strip("/").split("/")
if len(parts) >= 2 and parts[0] == "local":
return {"account_id": parts[1]} # 账号身份直接从物理路径读回
return {"account_id": SYSTEM_ACCOUNT_ID}
真实实现 fs_ctx_from_agfs_path(pyagfs/async_client.py:16)+ AsyncAGFSClient(pyagfs/async_client.py:57,用 asyncio.to_thread 把同步调用挪出事件循环)。Rust 侧 write 收到 ctx 后,build_fs_context + run_scoped 把它绑到任务本地的 FS_CTX(crates/ragfs-python/src/lib.rs:1642):
// 示意,非源码;对应 RAGFSBindingClient::write
let fs_ctx = build_fs_context(ctx); // {account_id} → FsContext
self.run_scoped(py, fs_ctx, move || async move {
top.write(&path, &data, 0, WriteFlag::Create).await // 作用域内 FS_CTX 可读
})
这样加密层无需在 trait 里多加参数就能拿到租户密钥——FsContextInner 只装 account_id(crates/ragfs/src/core/context.rs:33),刻意不进 FileSystem 方法签名,而是走任务本地存储 FS_CTX(crates/ragfs/src/core/context.rs:19)。闭环:URI 里的账号 →(VikingFS)写进路径 →(async_client)从路径读回 →(Rust)绑进 FS_CTX → 加密层派生 per-account 密钥。
3.3 RAGFS:一个 Rust 重写的分层虚拟文件系统
它是什么。 RAGFS 是 AGFS(原作者 c4pt0r 的 Go 项目)的 Rust 重写,源出仓库内 third_party/agfs/(crates/ragfs/ORIGIN.md)。可用 RAGFS_IMPL=rust|go|auto 切换实现。
核心抽象 = FileSystem trait。 所有后端插件都实现同一套 async 方法(crates/ragfs/src/core/filesystem.rs:127):
| 方法 | 行 | 干什么 |
|---|---|---|
mkdir / create | :118 / :107 | 建目录 / 建空文件 |
read / write | :152 / :168 | 带 offset/size 的字节读写 |
read_dir / stat | :181 / :193 | 列目录 / 取元数据 |
rename / replace | :204 / :212 | 移动;replace 允许覆盖已存在目标(加密发布靠它) |
grep / tree_directory | :278 / :433 | 递归正则搜索 / 目录树(均有默认实现,插件可覆盖) |
分层是"包装栈"(wrapper stack)。 RAGFS 不是一个大类,而是一串都实现 FileSystem、层层包裹的装饰器。构建入口 build_default_stack(crates/ragfs/src/core/builder.rs:71)先注册内置插件(memfs/kvfs/queuefs/sqlfs/localfs/serverinfofs,register_builtin_plugins builder.rs:118),再组装 :
StatsWrappedFS ← 顶层,端到端计时(含加密耗时)
│
MountableFS ← 按挂载点路由到具体后端
│ mount() 时逐后端套:
├── EncryptionWrappedFS (配了 root_key 时;localfs/s3fs/memfs 支持)
└── CachedFileSystem (开 cache feature 时,缓存密文)
│
后端插件(localfs / memfs / s3fs / kvfs …)
关键决定:加密/缓存是"逐挂载后端"套的,不是全局套一层(builder.rs 顶部注释 + MountableFS::mount mountable.rs:292)。好处是共享缓存里只存密文,且控制型插件(queuefs/serverinfofs)可以明确跳过缓存与加密。supports_encrypted_publish(mountable.rs:102)把"能不能加密发布"的判断前移到 挂载时,不支持的后端 fail-fast。
四个支撑子模块(都在 crates/ragfs/src/):
| 子模块 | 职责 | 位置 |
|---|---|---|
multibackend/ | 多后端挂载:一个挂载点可配主+备份,多写与同步状态(system_sync_status/retry) | src/multibackend/(mod.rs) |
cache/ | 可插拔缓存层(memory provider、policy、envelope) | src/cache/(wrapper.rs) |
git/ | 内容寻址对象存储 + 命名引用,支持提交快照/checkout/历史 | src/git/mod.rs |
shape/ | 后端"存储形状"探针与校验:挂载前确认后端布局符合 manifest | src/shape/mod.rs |
shape 是加密路径的隐形守卫——mount() 里对非控制插件调 ensure_backend_shape,保证磁盘布局与加密期望一致后才挂上。
3.4 向量索引:把"文件语义"镜像成集合记录 + KV 行
这是存储层的第二条腿。它要解决的小问题:文件系统只能按路径取,但检索要按语义取。于是每份"有意义的内容"都镜像成向量库里的一条记录。
一份文件 → 最多三条记录(level 0/1/2)。 映射 规则由一致性检查模块显式定义(storage/index_consistency.py:152 build_index_expectations):
| level | 代表的文件语义 | 内容来源 | 算 id 用的 seed_uri |
|---|---|---|---|
| 0 | 目录摘要 | 该目录下 .abstract.md | {uri}/.abstract.md |
| 1 | 目录总览 | 该目录下 .overview.md | {uri}/.overview.md |
| 2 | 叶子文件本体 | 文本文件内容 | {uri} 本身 |
(分层写入路径怎么产出这些 L0/L1/L2,见 02;这里只讲它们如何落成向量记录。)
主键是确定性的。 记录 id = md5(f"{account_id}:{seed_uri}")(viking_vector_index_backend.py:1461 附近,update_uri_mapping 内 _seed_uri_for_id + md5)。确定性 id 有两个好处:同一 (账号, 语义位置) 永远映射到同一条记录(天然幂等 upsert);且 mv 改路径时可重算新 id、迁移旧记录而不必重新做 embedding。
一条记录 = 稠密向量 + 一排标量字段。 collection schema(storage/collection_schemas.py context_collection)定义字段:id(主键) / uri(path 类型) / context_type / vector(稠密向量) / abstract / level / active_count / search_tags / account_id / owner_user_id 等。context_type 只允许三类(ALLOWED_CONTEXT_TYPES = {"resource","skill","memory"},viking_vector_index_backend.py:759),非法值直接被拒。
"向量集合 + KV" 的分工。 本地实现 LocalCollection(storage/vectordb/collection/local_collection.py:219)内部同时持有:
LocalCollection
├── indexes : IIndex # 稠密向量 → ANN 索引(近邻搜索)
└── store_mgr : StoreManager # 标量字段 → KV 存储(按主键取整行)
也就是:向量进 ANN 索引管"按语义找",标量进 KV(IKVStore,storage/vectordb/store/store.py:8)管"按 id 取字段"。二者用同一个主键 id 对齐。ANN 与 KV 的底层引擎(C++ 索引 + RocksDB)属于 06,本章到"记录如何落成"为止。
多租户在向量侧的形态。 VikingVectorIndexBackend 是门面,内部按 account_id 懒创建 _SingleAccountBackend(viking_vector_index_backend.py:137、_get_backend_for_account :689)。但所有账号后端共享同一个 adapter/底层 store(_shared_adapter,:667),以避免多个 RocksDB 实例抢 LOCK;隔离靠每次操作强制带 account_id 过滤(_tenant_filter :1354),而非物理分库。
4. 两条真实路径走读
路径 A:一次加密写入
VikingFS.write(uri, data, ctx) viking_fs/_ops.py:85
├─ _ensure_mutable_access(uri, ctx) # 命名空间 + 权限三关口
├─ path = _uri_to_path(uri, ctx) # → /local/{account}/…
└─ _async_agfs.write(path, data)
└─ AsyncAGFSClient.write async_client.py
├─ ctx = fs_ctx_from_agfs_path(path) # 从路径抠回 account_id
└─ to_thread → RAGFSBindingClient.write(path, data, ctx)
└─ run_scoped(FS_CTX=ctx): lib.rs:1177
top.write → Stats→Mountable→Encryption(按 account_id 派生密钥)→localfs
注意加密写锁 _run_with_encrypted_write_lock(viking_fs/_access.py:155(见下))通常由上层写编排(如分层写入路径)在调用点套上,锁住 [最终路径, 临时路径] 两条路径。
路径 B:一次 rm,两边一起动
rm(viking_fs/_ops.py:117)是"先删向量、再删文件",且对目录用 "tree" 锁、对文件用 "exact" 锁:
rm(uri, recursive, ctx)
├─ _ensure_delete_access # 删除命名空间守卫(比写更严,见 3.1)
├─ stat 判断是不是目录(目录必须 recursive,否则 FailedPrecondition)
└─ 加锁后:
├─ uris = _collect_uris(path, recursive) # 递归列出所有子 URI viking_fs/_ops.py:833
├─ _delete_from_vector_store(uris) # 先删向量记录 viking_fs/_vector.py:17
│ └─ backend.delete_uris(ctx, uris) # 带 account_id 过滤 :1192
└─ _async_agfs.rm(path, recursive) # 再删文件字节
次序是有意的:先删向量(幂等、失败可重试且不留悬垂检索结果),再删真相字节。即使目标本不存在,也会走一遍"清理孤儿索引"(viking_fs/_ops.py:167 附近)——所以 rm 幂等。mv 同理:copy + rm,中途用 update_vector_store_uris(viking_fs/_vector.py:37)把向量记录的 URI/主键就地改写,不重算 embedding;向量更新失败则回滚掉刚拷的副本(viking_fs/_ops.py:226 起)。
5. 巧妙之处(可借鉴)
- 账号身份"写进路径、再从路径读回",让 Rust 的
FileSystemtrait 保持无 ctx 参数的干净签名,租户密钥走任务本地FS_CTX