跳到主要内容

存储层: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.mdVikingFS → 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.py)│
│ ① 校验:能不能写这个命名空间 │
│ ② _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 …

部件一句话职责:

部件干什么在哪
VikingFSURI↔路径映射、命名空间守卫、加密写锁、rm/mv 时同步向量库openviking/storage/viking_fs.py:253
AGFSSyncClientProtocolPython 侧对"AGFS 客户端"的最小契约(同步接口)openviking/pyagfs/protocols.py:12
AsyncAGFSClient把同步客户端丢到线程池跑,并按路径注入 account_idopenviking/pyagfs/async_client.py:57
RAGFSBindingClientPyO3 原生类,Rust 侧实现上述契约crates/ragfs-python/src/lib.rs:849
FileSystem(trait)RAGFS 所有后端必须实现的统一文件接口crates/ragfs/src/core/filesystem.rs:97
MountableFS按挂载点路由,逐后端套 Cache/Encryptioncrates/ragfs/src/core/mountable.rs:62
VikingVectorIndexBackendper-account 向量后端门面:写/删/改 URI 映射openviking/storage/viking_vector_index_backend.py:644

3. 核心原理(逐个机制,由浅入深)

3.1 VikingFS 门面:三道关口

VikingFS 对上暴露 read/write/mkdir/rm/mv/grep/ls/stat 等(storage/viking_fs.py:476 起),但它自己不碰字节。它的价值全在"转交之前"和"转交之后"做的守卫与编排。

关口一: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_pathstorage/viking_fs.py:2367)。多租户隔离的物理基础就这一行:不同账号即便同名 URI,落盘目录也不同。规范化前还会拒绝 ./..\C: 这类穿越/平台特例段(_normalized_uri_partsstorage/viking_fs.py:317),从源头堵路径穿越。

关口二:命名空间写/删校验。 不是所有 URI 都能写或删。_ensure_supported_write_namespacestorage/viking_fs.py:449)与 _ensure_supported_delete_namespacestorage/viking_fs.py:425)在动手前拦截几类"会伤到共享根"的目标:

目标原因
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,写入不是"原地覆盖",而是"写临时文件再原子替换"。为避免并发写撞车,_run_with_encrypted_write_lockstorage/viking_fs.py:369)对最终路径 + 临时路径两条路径同时上 "exact" 锁:

# 示意,非源码
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_pathstorage/viking_fs.py:352):按最终路径的 mount 相对路径做 sha256,落到 .../temp/.encrypt_stage/{digest}.encrypt。确定性是为了让锁"同一个最终文件 → 同一个临时文件"能真正互斥。(锁管理器与租约见 storage/transaction/lock_manager.py:35。)

注意:加密本身发生在 Rust 层(按 account_id 派生密钥),Python 侧只负责加这道跨双路径的互斥锁;write() 把明文交给下层即可(storage/viking_fs.py:514)。


3.2 Python↔Rust 契约:一份协议 + 一个"账号从路径来"的约定

Python 不直接调 Rust,而是面向一个 Protocol(结构化鸭子类型)编程:AGFSSyncClientProtocolpyagfs/protocols.py:12)。它列出下层客户端必须提供的同步方法——ls / read / cat / write / mkdir / mv / grep / stat / rm / tree_directory 等。谁实现了这套签名,谁就能被塞进 VikingFS

这带来一个关键的解耦:同一份 Python 代码,既能对接进程内的 Rust 绑定,也能对接远程 HTTP AGFS——只要满足契约。生产用的是 Rust 绑定 RAGFSBindingClientcrates/ragfs-python/src/lib.rs:849)。

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_pathpyagfs/async_client.py:16)+ AsyncAGFSClientpyagfs/async_client.py:57,用 asyncio.to_thread 把同步调用挪出事件循环)。Rust 侧 write 收到 ctx 后,build_fs_context + run_scoped 把它绑到任务本地的 FS_CTXcrates/ragfs-python/src/lib.rs:1177):

// 示意,非源码;对应 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_idcrates/ragfs/src/core/context.rs:23),刻意不进 FileSystem 方法签名,而是走任务本地存储 FS_CTXcrates/ragfs/src/core/context.rs:18)。闭环: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:97):

方法干什么
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_stackcrates/ragfs/src/core/builder.rs:52)先注册内置插件(memfs/kvfs/queuefs/sqlfs/localfs/serverinfofs,register_builtin_plugins builder.rs:71),再组装:

StatsWrappedFS ← 顶层,端到端计时(含加密耗时)

MountableFS ← 按挂载点路由到具体后端
│ mount() 时逐后端套:
├── EncryptionWrappedFS (配了 root_key 时;localfs/s3fs/memfs 支持)
└── CachedFileSystem (开 cache feature 时,缓存密文)

后端插件(localfs / memfs / s3fs / kvfs …)

关键决定:加密/缓存是"逐挂载后端"套的,不是全局套一层builder.rs 顶部注释 + MountableFS::mount mountable.rs:252)。好处是共享缓存里只存密文,且控制型插件(queuefs/serverinfofs)可以明确跳过缓存与加密。supports_encrypted_publishmountable.rs:94)把"能不能加密发布"的判断前移到挂载时,不支持的后端 fail-fast。

四个支撑子模块(都在 crates/ragfs/src/):

子模块职责位置
multibackend/多后端挂载:一个挂载点可配主+备份,多写与同步状态(system_sync_status/retrysrc/multibackend/mod.rs
cache/可插拔缓存层(memory provider、policy、envelope)src/cache/wrapper.rs
git/内容寻址对象存储 + 命名引用,支持提交快照/checkout/历史src/git/mod.rs
shape/后端"存储形状"探针与校验:挂载前确认后端布局符合 manifestsrc/shape/mod.rs

shape 是加密路径的隐形守卫——mount() 里对非控制插件调 ensure_backend_shape,保证磁盘布局与加密期望一致后才挂上。


3.4 向量索引:把"文件语义"镜像成集合记录 + KV 行

这是存储层的第二条腿。它要解决的小问题:文件系统只能按路径取,但检索要按语义取。于是每份"有意义的内容"都镜像成向量库里的一条记录。

一份文件 → 最多三条记录(level 0/1/2)。 映射规则由一致性检查模块显式定义(storage/index_consistency.py:149 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:1265 附近,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:647),非法值直接被拒。

"向量集合 + KV" 的分工。 本地实现 LocalCollectionstorage/vectordb/collection/local_collection.py:133)内部同时持有:

LocalCollection
├── indexes : IIndex # 稠密向量 → ANN 索引(近邻搜索)
└── store_mgr : StoreManager # 标量字段 → KV 存储(按主键取整行)

也就是:向量进 ANN 索引管"按语义找",标量进 KV(IKVStorestorage/vectordb/store/store.py:8)管"按 id 取字段"。二者用同一个主键 id 对齐。ANN 与 KV 的底层引擎(C++ 索引 + RocksDB)属于 06,本章到"记录如何落成"为止。

多租户在向量侧的形态。 VikingVectorIndexBackend 是门面,内部按 account_id 懒创建 _SingleAccountBackendviking_vector_index_backend.py:93_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.py:514
├─ _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_lockviking_fs.py:369)通常由上层写编排(如分层写入路径)在调用点套上,锁住 [最终路径, 临时路径] 两条路径。

路径 B:一次 rm,两边一起动

rmviking_fs.py:549)是"先删向量、再删文件",且对目录用 "tree" 锁、对文件用 "exact" 锁:

rm(uri, recursive, ctx)
├─ _ensure_delete_access # 删除命名空间守卫(比写更严,见 3.1)
├─ stat 判断是不是目录(目录必须 recursive,否则 FailedPrecondition)
└─ 加锁后:
├─ uris = _collect_uris(path, recursive) # 递归列出所有子 URI viking_fs.py:2794
├─ _delete_from_vector_store(uris) # 先删向量记录 viking_fs.py:2819
│ └─ backend.delete_uris(ctx, uris) # 带 account_id 过滤 :1192
└─ _async_agfs.rm(path, recursive) # 再删文件字节

次序是有意的:先删向量(幂等、失败可重试且不留悬垂检索结果),再删真相字节。即使目标本不存在,也会走一遍"清理孤儿索引"(viking_fs.py:602 附近)——所以 rm 幂等。mv 同理:copy + rm,中途用 update_vector_store_urisviking_fs.py:2838)把向量记录的 URI/主键就地改写,不重算 embedding;向量更新失败则回滚掉刚拷的副本(viking_fs.py:656 起)。


5. 巧妙之处(可借鉴)

  • 账号身份"写进路径、再从路径读回",让 Rust 的 FileSystem trait 保持无 ctx 参数的干净签名,租户密钥走任务本地 FS_CTX。跨语言、跨进程都不用改签名(async_client.py:16 + context.rs:18)。
  • 确定性主键 md5(account:seed_uri) 把"幂等写"和"改名即改 id 迁移"两件事一次性解决,mv 不需要重新 embedding(viking_vector_index_backend.py:1265)。
  • 加密/缓存逐后端包装而非全局一层,让共享缓存只存密文、控制型插件天然豁免(builder.rs + mountable.rs:252)。
  • 删向量在删字节之前,配合"目标不存在也清孤儿索引",让 rm/mv 幂等且不留悬垂检索命中(viking_fs.py:549)。
  • 一致性可自检check_index_consistencyindex_consistency.py:201)用文件系统内容反推"应有哪些 level 记录",再和向量库实际记录比对,直接给出缺失清单——运维层面把"两条腿是否同步"变成可观测。

6. 边界与局限(诚实)

  • 两套存储只是"尽力同步",非事务。 rm/mv 里向量与文件的更新分两步,靠加锁 + 次序 + 回滚兜底,但底层无跨库事务;极端崩溃仍可能留下"文件在、向量缺"或反之,故需要 index_consistency 事后核对。(viking_fs.py:2819_delete_from_vector_store 失败仅 warning 不抛。)
  • _collect_uris 吞异常。 递归收集子 URI 时 except Exception: passviking_fs.py:2813),列举中途出错会静默漏收,可能导致部分向量记录未被清理。
  • ls 默认 node_limit=1000,内部系统操作必须显式传 LS_ALL_NODESviking_fs.py:81),否则 >1000 子项的目录会被静默截断——注释明确点了这个坑。
  • account 隔离靠过滤,不靠物理分库。 所有账号共享一个底层 store(_shared_adapter),隔离正确性完全依赖每次操作都带对 account_id 过滤;漏带即越权。
  • 不覆盖的部分: 稠密向量的 ANN 算法与 KV 引擎实现在 06;检索排序策略在 03;本章只讲"文件语义如何落成字节与记录"。

7. 代码地图(导航索引)

主题文件路径符号名
VikingFS 门面主类openviking/storage/viking_fs.pyVikingFS
单例初始化openviking/storage/viking_fs.pyinit_viking_fs / get_viking_fs
URI→account 路径映射openviking/storage/viking_fs.py_uri_to_path / _normalized_uri_parts
写/删命名空间守卫openviking/storage/viking_fs.py_ensure_supported_write_namespace / _ensure_supported_delete_namespace
加密写锁openviking/storage/viking_fs.py_run_with_encrypted_write_lock / _encrypted_temp_path
rm/mv 向量同步openviking/storage/viking_fs.py_delete_from_vector_store / _update_vector_store_uris / _collect_uris
Python↔Rust 契约openviking/pyagfs/protocols.pyAGFSSyncClientProtocol
账号从路径抠回 + 异步包装openviking/pyagfs/async_client.pyfs_ctx_from_agfs_path / AsyncAGFSClient
Rust 绑定类crates/ragfs-python/src/lib.rsRAGFSBindingClient
RAGFS 核心 traitcrates/ragfs/src/core/filesystem.rsFileSystem
栈构建 / 内置插件crates/ragfs/src/core/builder.rsbuild_default_stack / register_builtin_plugins / RagfsStack
挂载路由 + 逐后端包装crates/ragfs/src/core/mountable.rsMountableFS / MountableFS::mount
任务本地租户上下文crates/ragfs/src/core/context.rsFsContextInner / FS_CTX
多后端 / 版本化 / 形状crates/ragfs/src/multibackend/mod.rs · src/git/mod.rs · src/shape/mod.rsMetaStateStore / StorageShape
RAGFS 来源说明crates/ragfs/ORIGIN.md
向量后端门面openviking/storage/viking_vector_index_backend.pyVikingVectorIndexBackend / _SingleAccountBackend
写/删/改 URI 映射openviking/storage/viking_vector_index_backend.pyupsert / delete_uris / update_uri_mapping
collection 字段定义openviking/storage/collection_schemas.pyCollectionSchemas.context_collection
本地集合(向量+KV)openviking/storage/vectordb/collection/local_collection.pyLocalCollection / PersistCollection
KV 存储接口openviking/storage/vectordb/store/store.pyIKVStore / IMutiTableStore
文件↔索引一致性openviking/storage/index_consistency.pybuild_index_expectations / check_index_consistency