本节摘要:翻到文件系统的「磁盘层」。
openviking/storage/是双层设计:上层viking_fs/是虚拟文件系统——封装 AGFS 绑定客户端,提供 viking:// URI 寻址、目录树操作与 sidecar 语义层;下层vectordb/是向量层——承载嵌入与相似度检索。两层各司其职:树管「在哪」,向量管「像什么」。本节精读 VikingFS 的操作面(read/write/mkdir/rm/mv 及其向量级联)、ls的 1000 条保护线、temp 目录转正协议,再看 queuefs 队列族与已被 Rust 层接管的路径锁事务。
内容来源:原项目源码 openviking/storage/(viking_fs/、content_write.py、semantic_sidecar.py、queuefs/、transaction/、vectordb/、vikingdb_manager.py)
⚠️ 注意:VikingFS 自己不存字节。文件与元数据的真正落地在 AGFS(下一节的 Rust RAGFS),向量在 vectordb 后端——VikingFS 是语义门面:URI 规范化、权限上下文(RquestContext)、sidecar 读写、向量同步编排都在这一层完成。把它理解为「带语义的驱动器接口」比「文件系统实现」更准确。
LS_ALL_NODES 哨兵、temp→persist 协议、snapshot diff。storage/transaction 为何只剩空壳。ls openviking/storage/ 会看到十几个条目,按职责分四堆:
| 模块 | 职责 |
|---|---|
viking_fs/ |
虚拟 FS 门面:URI 寻址、目录树操作、sidecar 读写、向量同步(8 个 mixin 拼装) |
content_write.py |
ContentWriteCoordinator:内容写入编排,直写/批量/标签/语义刷新 |
semantic_sidecar.py |
OKF 格式 sidecar 的解析/渲染/写入(第 3 章的落点) |
vectordb/ |
内置向量引擎的 Python 侧(collection/engine/index/store/meta) |
vectordb_adapters/ |
4+1 种向量后端适配器(03 节主角) |
vikingdb_manager.py |
管理器与 VikingDBManagerProxy:绑定请求上下文的租户代理 |
queuefs/ |
嵌入/语义/资源/提交四类队列与 DAG 执行器(上一节已见) |
transaction/ |
路径锁——已移交 Rust ragfs 层 |
ovpack/ |
快照打包(03 节) |
viking_fs/__init__.py 的 docstring 就是这一层的自我介绍:
""" VikingFS: OpenViking file system abstraction layer Responsibilities: - URI conversion (viking:// <-> /local/) - L0/L1 reading (.abstract.md, .overview.md) - Relation management (.relations.json) - Semantic search (vector retrieval + rerank) - Vector sync (sync vector store on rm/mv) """
注意最后一条:rm/mv 时同步向量库。文件系统里删一个文件容易,但向量库里它的记录若不跟着删,检索就会命中幽灵。双层的最终一致性由 VikingFS 亲自编排——这正是「门面」的价值。
viking_fs/ 由 8 个 mixin 拼成(_ops/_access/_grep/_semantic/_snapshot/_sync/_vector),_ops.py 是最像传统 FS 的部分。看它的方法签名,几乎就是一套带 ctx 的 POSIX:
class _OpsMixin: async def read(self, ...) async def write(self, ...) async def mkdir(self, ...) async def rm(self, ...) async def mv(self, ...) async def stat(self, ...) async def exists(self, uri, ctx=None) -> bool async def glob(self, ...) async def tree(self, ...) async def ls(self, ...) async def create_temp_uri(self, ctx=None) -> str async def persist_temp_tree(self, ...) async def delete_temp(self, ...)
三个设计点值得展开。其一,rm 的级联账本。删除目录前先 _estimate_deleted_count 估算规模,删完文件层再批量清向量层;mv 更讲究——_copy_for_mv/_copy_dir_through_vikingfs/_copy_file_through_vikingfs 三条复制路径,迁完再改写向量记录的 URI(不然第 4 章 search_children 的前缀匹配就断了)。其二,ls 的保护线。_base.py 顶部的注释讲了一个真实教训:
# Sentinel node_limit for internal callers that MUST enumerate an entire # directory. ``ls()`` defaults to ``node_limit=1000`` to protect agent-facing # context from being flooded, but internal system operations (parse merge, # temp->final sync, summary DAG, vectorization) must see every child or they # silently drop entries beyond the cap — e.g. a >1000-doc directory ingest only # materializes its first 1000 subdirectories. Pass this explicitly at those # call sites. LS_ALL_NODES = 2**31 - 1
面向 Agent 的 ls 默认最多 1000 条,防止上下文被灌爆;但内部管线(临时目录转正、语义 DAG、向量化)必须看全量,否则超 1000 文档的目录只入库前 1000 个——静默截断是最阴的 bug,于是有了 LS_ALL_NODES 哨兵。同一个 ls,内外两面:对外节流,对内全量。其三,temp→persist 协议。上一节代码仓库入库先落临时目录,persist_temp_tree 负责转正:把 temp 目录原子地合并进目标树(带精确锁迁移),_sync.py 的 sync_tree 做目录级 diff+merge——增量入库复用这条路径,只搬运变化过的文件。
_snapshot.py 则是给「看文件变化」用的:_prepare_snapshot_diff 对前后两版做 UTF-8 解码与行数上限(10 万行)防护,输出 added/deleted/modified/unchanged 四态——第 5 章 memory_diff 的底层原语同款。目录树的全量遍历另有一组常量兜底:单文件 diff 上限 10MB、输出上限 20MB、超时 500ms——所有「看一眼」的操作都有预算,这是面向 Agent 的系统区别于面向人的系统的分寸感。
文件层之上、业务之下的写入编排者是 content_write.py 的 ContentWriteCoordinator。它管四类写入:write(单文件)、batch_write(批量操作,带根校验)、set_tags、_write_memory_with_refresh(记忆写入)。批量的形态先规范化(_normalize_batch_operations)、校验批次根(_validate_batch_root)再执行。最有意思的是直写路径的容错:
async def _write_direct_with_refresh(self, ...) async def _rollback_direct_write(self, ...) async def _vectorize_written_file(self, ...) async def _vectorize_semantic_sidecar(self, *, uri, ctx, ...)
写文件成功但向量化失败怎么办?_rollback_direct_write 提供回退;语义刷新走队列(_enqueue_semantic_refresh_changes),_wait_for_queues 支持等待落地并 _raise_refresh_errors 上抛队列错误。写入不是一步,是「文件层落盘 → 语义刷新 → 向量化」的小事务,Coordinator 就是这个小事务的指挥——第 5 章记忆 patch-merge 最终也经由这里落库。
storage/transaction/__init__.py 如今只剩一个说明:
""" Path-lock management has been moved to the Rust ragfs layer. Python-side lock operations now go through RAGFSBindingClient.pathlock_* methods. """
Python 侧的路径锁事务已整体下沉到 Rust。多步操作(如 mv 目录+改向量)的原子性靠 AGFS 的 pathlock 族方法保证——LockAcquisitionError 异常类型在 Python 与 Rust 之间共享(ragfs-python 绑定在初始化时缓存 Python 异常类,让原生层抛出同一类型)。这是整个项目「重活下沉」策略的缩影:语义在 Python,机制在 Rust/C++,下一节展开。
队列侧的 queuefs/ 在上一节已经上场,这里补齐族谱。QueueManager 单例封装 AGFS QueueFS 插件,四类 NamedQueue 各带并发上限:
def init_queue_manager( agfs, timeout=10, mount_point="/queue", max_concurrent_embedding=10, max_concurrent_semantic=32, max_concurrent_external_parse=4, max_concurrent_add_resource=4, max_concurrent_session_commit=8, ) -> "QueueManager":
语义队列并发 32、嵌入 10、外部解析 4、资源入库 4、会话提交 8——数字背后是资源画像:语义任务轻(LLM 调用为主,IO 等待多)、解析重(CPU/内存),并发配额各按其性。semantic_dag.py 的 DirNode/DagWork 就是挂在语义队列上的执行单元,session_commit_processor 对接第 5 章的 auto-commit。队列本体仍是 AGFS 文件——队列也是文件系统里的目录,这是「一切皆文件」的又一处贯彻:消息持久在 /queue 挂载点下,进程重启队列不丢。
把双层串起来走一遍完整旅程。ov add doc.pdf:ResourceService 编排(上一节)→ 解析器建树 → ContentWriteCoordinator/batch_write 把文件树写进 viking_fs(AGFS 落盘)→ 嵌入队列算向量、语义 DAG 自底向上生成 sidecar → _vectorize_written_file/_vectorize_semantic_sidecar 把 L2 正文与 L0/L1 摘要的向量写进 vectordb 后端(经 VikingDBManagerProxy 绑定租户)。检索时(第 4 章)反着走:向量层出候选,viking_fs 补路径语境与原文。删除时 rm 两层联动清。树是唯一的真相源(在哪、层级、标签、sidecar),向量是派生索引(像什么)——所以 ovpack 能只靠文件层重建全部向量(03 节),所以向量库可换后端而业务无感。
💡 漫游要点:storage 的双层不是「两个数据库」,是一个文件系统范式的一体两面:VikingFS 是带语义的门面(URI 寻址、sidecar、rm/mv 的向量级联、temp 转正、snapshot diff),vectordb 是可插拔的派生索引。
ls的 1000 条默认上限与LS_ALL_NODES哨兵是内外有别的节流哲学;路径锁已下沉 Rust(机制下移),语义留在 Python;队列也是文件(/queue挂载点)。记住一句话:删了向量库可以重建,删了文件层才是真丢数据——下一节看这层「可重建的索引」在 C++ 里怎么长。
下一节:
02 C++ 原生引擎与 Rust RAGFS——向下一层:src/ 约 1.39 万行 C++ 原生向量引擎(abi3 稳定 ABI、SIMD 运行时检测、LevelDB 持久化)与 crates/ragfs 约 4.6 万行 Rust 聚合文件系统(shell/缓存/git/PyO3)。