跳到主要内容

写入路径:解析 → 建树 → L0/L1/L2 分层

30 秒导读: 你丢给 OpenViking 一个 PDF、一个 GitHub 仓库、或一个网页,它不会像传统 RAG 那样切碎成一堆向量块。它把内容还原成一棵目录树(章节即文件夹、内容即文件),再给 树里每个目录挂三层摘要:L0 极短、L1 中等、L2 全文。检索时先读便宜的 L0/L1 定位,真需要 细节才加载 L2——这样上下文按需加载,token 花在刀刃上。本章讲的就是:一份原始数据怎么变成 这棵带三层摘要的树

本章只讲写入侧。检索怎么在这棵树上递归下钻,见 03 目录递归检索; 树最终落到什么存储、向量库长什么样,见 04 存储层;会话记忆的抽取 (和资源写入共用后半段管线,但入口和策略不同),见 05 会话记忆自迭代; URI 范式与三类上下文的总览见 01 viking:// 范式


1. 这是什么(零基础也能懂)

  • 一句话定义: 写入路径 = 把「任意来源的原始数据」加工成「一棵每层都带三档摘要的目录树」的流水线。
  • 解决什么问题: Agent 的上下文窗口是稀缺资源。如果每次检索都把整篇文档塞进去,既贵又容易淹没 重点。OpenViking 的答案是先分层——写入时就为每块内容预生成"一句话版 / 一段话版 / 全文版", 检索时才好按需取用。
  • 给谁用: 给"要往 Agent 记忆库里灌资料"的开发者。你调 add_resource(...),剩下的解析、切分、 摘要、向量化全自动。

用起来什么样(最小示意):

# 示意,非源码:一次资源写入的对外接口
await client.add_resource("/path/to/api-guide.pdf", reason="API 文档")
# 或者直接喂一个 URL / 一个 git 仓库
await client.add_resource("https://github.com/volcengine/OpenViking")
  • 一句话直觉: 把它想成一个自动做读书笔记的图书管理员。你给它一摞书(原始文件),它先按 章节拆开摆进书架(建树),再给每个书架贴三张便签:一张写"这架讲啥"(L0)、一张写目录导读 (L1)、书本身是全文(L2)。以后找资料,先扫便签,不用每次翻完整本书。

本节不出现底层代码。记住一件事就够:写入的产物是"树 + 三层摘要",不是一堆碎块。


2. 顶层全景(它大概怎么转)

一条数据从进门到变成可检索的树,要过五关。从上往下读,命中即进下一关:

输入:本地文件 / URL / 目录(代码仓)/ 原始文本


[1] Accessor 取物 ── 把任何来源落成本地文件(LocalResource)
│ local / http / git / feishu / web-feed

[2] Parser 解析 ──── 按扩展名选解析器,切成"章节文件 + 子目录",
│ 写进 viking://temp/...(全程不调 LLM)

[3] TreeBuilder 定址 ─ 扫 temp 找唯一文档根,算出最终 viking:// URI(先不搬文件)


[4] Source Commit ── 把 temp 子树搬到最终路径(加资源锁,防并发踩踏)


[5] SemanticQueue ── 异步:自底向上为每个目录生成 L0/L1,再向量化
│ 文本走 VLM 摘要 / 代码走 AST 骨架

产物:带 L0/L1/L2 的目录树(等待被检索,见 03)

关键设计一句话:解析与语义分离。 第 [2] 关只做"切分 + 排版",绝不调用大模型;所有花钱的 摘要(L0/L1)都推迟到第 [5] 关异步做(依据 docs/en/concepts/06-extraction.md "Design Principle: Parsing and semantics are separated")。这样写入的同步链路快、可并发,大模型的慢和贵 被隔离在后台队列里。

各部件一句话职责:

部件干什么在哪个文件(符号)
AccessorRegistry把 URL/git/本地路径统一取成本地文件openviking/parse/accessors/registry.py:34 AccessorRegistry
DocumentConverterdocx/pptx/md 先转 PDF 求统一排版openviking/parse/converter.py:15 DocumentConverter
ParserRouter决定走内置解析器还是第三方理解 APIopenviking/parse/parser_router.py:22 ParserRouter
ParserRegistry按扩展名派发到具体解析器openviking/parse/registry.py:39 ParserRegistry
scan_directory目录/代码仓预扫:分类 + 尊重 .gitignoreopenviking/parse/directory_scan.py:176 scan_directory
TreeBuilder从 temp 定出最终 URI 元数据openviking/parse/tree_builder.py:39 TreeBuilder
BuildingTree内存里的树容器(父子/根/目录结构)openviking/core/building_tree.py:11 BuildingTree
Context树上一个节点(带 L0 abstract + level)openviking/core/context.py:61 Context
ResourceProcessor把上面几步串成一次完整写入openviking/utils/resource_processor.py:41 ResourceProcessor
SemanticProcessor后台自底向上生成 L0/L1 + 向量化openviking/storage/queuefs/semantic_processor.py:85 SemanticProcessor

3. 为什么要分层(L0/L1/L2 的第一性原理)

先讲为什么,再讲怎么建。这是理解整条写入路径的钥匙。

3.1 三层是什么

每个目录节点都固定挂三档信息,粒度从粗到细:

名字落地文件预算干什么用
L0Abstract 摘要.abstract.md约 100 token向量召回、快速过滤"相不相关"
L1Overview 概览.overview.md约 1–2k token重排、导航(告诉 Agent 里面有啥、怎么进去拿)
L2Detail 全文原始文件 / 子目录无上限确认要了,才加载的完整内容

依据:docs/en/concepts/03-context-layers.md(三层表)与代码枚举 openviking/core/context.py:34 ContextLevel(ABSTRACT=0 / OVERVIEW=1 / DETAIL=2)。

3.2 为什么这样省 token

核心直觉:便宜的先看,贵的后看,大多数内容根本不用展开到 L2。

检索一次的典型开销(按需加载):

向量召回 ── 只比对 L0(约100 token/节点) ──► 命中一小撮候选

重排/理解 ── 读候选的 L1(约1-2k token) ──────► 定位到真正相关的目录

真要细节 ── 才 read() 该节点的 L2 全文 ────────► 只对少数节点付全文的钱

对比传统 RAG 的"把命中块整段塞进上下文":分层让绝大多数节点停在 L0/L1,只有被确认相关的 少数才付 L2 的 token。README 把这条列为核心卖点之一:"Tiered Context Loading → Reduces Token Consumption:L0/L1/L2 three-tier structure, loaded on demand"。

3.3 谁生成、按什么顺序生成

  • 谁: 资源写入由后台的 SemanticProcessor 生成(会话归档时另有 SessionCompressor,属 05)。
  • 顺序:自底向上。 先给叶子目录生成 L0/L1,子目录的 L0 再被聚合进父目录的 L1,层层往上, 形成"上层导读、下层细节"的导航结构。依据 docs/en/concepts/03-context-layers.md "Leaf nodes → Parent directories → Root (bottom-up)"。

记住这条顺序,3.7 节会看到它在代码里怎么落地。


4. 核心机制(逐关拆解,由浅入深)

4.1 第 [1] 关:Accessor 取物 —— 把"任何来源"变成本地文件

要解决的小问题: 用户给的可能是本地路径、可能是 https://...、可能是 git@...。解析器只想 面对"一个躺在磁盘上的文件",不想操心怎么下载。

思路:两层架构。 先让 AccessorRegistry 把来源统一"取"成一个 LocalResource(本地临时文件/ 目录),再交给解析器。入口在 openviking/utils/media_processor.py:112 UnifiedResourceProcessor.process:先 registry.access(source) 拿到本地资源(:150),再走解析(:202)。

内置的 accessor 覆盖多种来源:

来源Accessor典型输入
本地文件/目录LocalAccessor/path/to/doc.pdf
普通网页/下载HTTPAccessorhttps://example.com/a.pdf
代码仓GitAccessorhttps://github.com/org/repo
飞书文档FeishuAccessor飞书链接
RSS/订阅WebFeedAccessorfeed URL

(注册见 openviking/parse/accessors/registry.py:53 _register_defaults。)

多格式的统一化: 对 docx/pptx/md 这类排版各异的格式,DocumentConverter.to_pdf (openviking/parse/converter.py:21)会先用 LibreOffice(_convert_with_libreoffice:35)或 pandoc 把它们转成 PDF,再走统一的 PDF 解析路径,保证不同来源的排版被拉平到同一套处理。

4.2 第 [2] 关:Parser 解析 —— 选对解析器,只切分不摘要

派发有两跳:

ParserRouter.parse
│ should_use_understanding_api()? ── 看 ov.conf 的白名单
├─ 是 ──► 第三方 UnderstandingAPI(外部理解服务)
└─ 否 ──► ParserRegistry.parse ── 按扩展名选内置解析器

├─ .md/.markdown ─► MarkdownParser
├─ .pdf ──────────► PDFParser
├─ .docx/.pptx ───► Word/PowerPointParser
├─ .html/.htm ────► HTMLParser
├─ 代码文件 ───────► CodeRepositoryParser
├─ 图/音/视 ───────► Image/Audio/VideoParser
└─ 目录 ──────────► DirectoryParser
  • 路由决策:openviking/parse/parser_router.py:36 should_use_understanding_api(读 ov.confparser_api 白名单)。
  • 扩展名 → 解析器:openviking/parse/registry.py:191 get_parser_for_file;注册表在 ParserRegistry.__init__(:63 起)一次性登记全部内置解析器。
  • 目录/裸内容的兜底:openviking/parse/registry.py:210 ParserRegistry.parse——是目录就转 DirectoryParser(:236),没匹配到就退回 TextParser(:248)。

这一关的产物是一个 ParseResult(openviking/parse/base.py:348),关键字段: root(ResourceNode 文档树)、temp_dir_path(解析器已经把切好的文件全写进了这个临时目录)、 source_format。注意:此时还没有任何 L0/L1——解析器不调大模型。

4.3 建树切分:smart splitting(以 Markdown 为例)

要解决的小问题: 一篇长文该怎么变成"文件夹 + 文件"?切太碎丢了上下文,切太粗又超预算。

思路:按文档天然结构切(标题),再按大小做合并/再拆。 OpenViking 沿用 PageIndex 思路—— "保留文档的自然结构,而不是任意切块"(openviking/parse/base.py:3 文件头注释)。

Markdown 解析器的规则(真实常量,openviking/parse/parsers/markdown.py:144-145):

情况处理依据
文档 ≤ max_section_size(默认 2048 token)且未超字符上限存成单个文件,保留原名markdown.py:1142
大文档、有标题按标题切成 section;有子标题的 section 变成子目录_build_structure markdown.py:1099
section < MIN_SECTION_TOKENS(512)与相邻 section 合并,避免碎片markdown.py:145
section 过大又无子标题段落再拆(_smart_split_content)markdown.py:483
完全没有标题直接按段落拆成 _1/_2/...markdown.py:1149

提示:概念文档 06-extraction.md 里写的阈值是 1024/512,与当前源码常量(2048/512)略有出入; 以源码常量为准,且实际值可被 ParserConfig 覆盖。

切分只规划"谁是文件、谁是目录"(_build_structure 把 mkdir/write 累积成一串 _LayoutOp 操作), 然后一把写进 viking://temp。这一步的结果就是一棵已经按章节铺开的临时目录树

4.4 目录/代码仓的预扫:scan_directory

要解决的小问题: 灌一整个代码仓时,不能把 node_modules.git、二进制垃圾也当内容。

openviking/parse/directory_scan.py:176 scan_directory 做"第一遍走查",把每个文件分成 processable / unsupported,并且:

  • 跳过 IGNORE_DIRS、点目录、软链、空文件(_should_skip_file:55 / _should_skip_directory:73);
  • 尊重 .gitignore(GitignoreMatcher);
  • 支持 include/exclude glob 过滤;
  • strict=True 时遇到不支持文件直接报错,否则记 warning 继续。

分类逻辑:注册表有解析器、或是文本文件,就算 processable(_classify_file:159)。这一步保证进入 建树的只有"能被理解的内容"。

4.5 第 [3] 关:TreeBuilder 定址 —— 算出最终 URI,先不搬文件

关键设计(v5.0):TreeBuilder 不搬文件,只定"该放哪"。tree_builder.py:16 的架构注释 "TreeBuilder does not move files; source commit is handled after URI metadata is built"。

openviking/parse/tree_builder.py:146 finalize_from_temp 做四件事:

  1. 找唯一文档根: 扫 temp,确认里面恰好一个文档子目录,否则报错(:173)。
  2. 算最终 URI: resolve_target_uri(:85)按 scope 定 base URI——资源进 viking://resources,用户内容进 viking://user(_get_base_uri:66);代码仓则从 URL 解析出 org/repo 作为目录名(:100)。
  3. 建内存树容器: new 一个 BuildingTree,把 _root_uri 指向算出的目标。
  4. 给根建一个占位 Context: Context(uri=planned_uri, temp_uri=temp_doc_uri)(:207),让 tree.root 不为空,同时记住 temp 位置供下一关搬运。

BuildingTree 本身是个轻量树容器(openviking/core/building_tree.py:11),提供三个写入路径常用的 导航能力:

方法作用位置
add_context往树里加节点(维护 uri→Context 映射)building_tree.py:33
get_path_to_root从任意节点回溯到根(检索/定位祖先用)building_tree.py:65
to_directory_structure导出成"目录结构"字典(uri/title/children)building_tree.py:77

树上每个节点是一个 Context(openviking/core/context.py:61):它带 uriparent_uriabstract(即 L0)、level(L0/L1/L2)、is_leafmeta 等字段。写入阶段先建出骨架,L0/L1 的 真实内容留到第 [5] 关填。

4.6 第 [4] 关:Source Commit —— 带锁把 temp 搬到最终路径

到这里内容还在 viking://tempResourceProcessor.process_resource (openviking/utils/resource_processor.py:109)是把全程串起来的编排者,它的阶段划分:

Phase 1 解析 media_processor.process → 写入 temp (:149)
Phase 3 定址 tree_builder.finalize_from_temp (:225)
Phase 3.5 提交 加资源锁 → persist_temp_tree → 删 temp (:274)
Phase 4 可选 summarize / build_index(触发第[5]关) (:363)

为什么要锁: 并发写同名资源会互相踩。提交前先拿每资源的 TreeLock (acquire_resource_lock:452);目标已存在时还会走 reserve_unique_candidate(:420)自动改名成 name_1name_2… 避免覆盖。真正的搬运是 persist_temp_tree(:333),搬完 delete_temp 清理临时目录、并 rewrite_image_uris 把文档里的本地图片引用改写成最终 URI(:334)。

失败路径也处理得干净:定址或提交出错都会兜底删掉 temp 树,避免孤儿目录堆积(:255:346, 对应 issue #2478)。

4.7 第 [5] 关:SemanticProcessor —— 自底向上,VLM 生成 L0/L1

这是"分层"真正诞生的地方,异步跑在 SemanticQueue 后台。入口 openviking/storage/queuefs/semantic_processor.py:306 SemanticProcessor.on_dequeue,核心遍历 交给 SemanticDagExecutor(storage/queuefs/semantic_dag.py:153,run:216)。

单个目录的处理步骤(类注释 semantic_processor.py:85 + DAG 执行器):

对一个目录:
1. 并发为目录内每个文件生成"文件摘要" (受 max_concurrent_llm 限流)
2. 收集各子目录已生成的 .abstract.md (子先于父完成 → 自底向上)
3. 用 VLM 把"文件摘要 + 子目录摘要"合成 .overview.md(L1)
4. 从 overview 抽出开头段做 .abstract.md(L0)
5. 写回 AGFS,并把该目录 Context 送去向量化

对应符号:

  • 文件摘要:_generate_text_summary(:1040)——文本/文档/代码各用不同 prompt。
  • L1 生成:_generate_overview(:1294)/_single_generate_overview(:1404);目录太大时 分批生成再合并(_batched_generate_overview:1441),防止 prompt 超预算。
  • L0 抽取:_extract_abstract_from_overview(:1203)——直接取 overview 的引言段做摘要;尺寸由 _enforce_size_limits(:1232,读 semantic.abstract_max_chars/overview_max_chars)裁到预算内。
  • 向量化:_vectorize_directory(:1552)把 L0/L1 送进 EmbeddingQueue(细节见 04)。

代码文件的省钱捷径:AST 骨架代替 LLM。 对 ≥100 行的代码文件,默认(code_summary_mode="ast") 不调大模型,而是用 tree-sitter 抽"模块 docstring + import + 类/方法签名 + 顶层函数签名"当摘要 (semantic_processor.py:1083extract_skeleton;数据结构见 openviking/parse/parsers/code/ast/)。 不支持的语言、解析失败、空骨架会自动回退 LLM(依据 docs/en/concepts/06-extraction.md "Code Skeleton Extraction (AST Mode)")。

健壮性: 处理器带熔断器(CircuitBreaker),API 挂了就重入队并退避(_reenqueue_semantic_msg:223); "输入过大/永久错误"直接丢弃、瞬时错误重试(on_dequeue 的异常分类 :477 起);父目录会在子目录 更新后被"刷新"重算(_enqueue_parent_refresh:265),保证 L1 始终反映最新的子层。


5. 另一条写入入口:ingest 多源摄取编排

上面讲的是"喂一份文件/URL"的资源写入。OpenViking 还有第二个入口:主动去外部 Agent 的日志里 把对话历史捞进来——这是 openviking/ingest/ 模块。它产出的是会话消息(进而由 05 讲的记忆 抽取变成记忆树),所以这里只讲"怎么摄取",抽取细节留给 05

它解决什么: 你在 Claude Code / Codex / Cursor 等 CLI Agent 里的历史对话散落在各自的本地日志 里。ingest 模块用统一的适配器把它们增量读进 OpenViking 的 session。

两种运行模式:

模式谁负责干什么
一次性回填IngestOrchestrator.backfill遍历所有会话,从游标读到末尾,提交
持续监听IngestPoller.run定时轮询,读增量、推进游标、按阈值/空闲提交
  • 回填编排:openviking/ingest/orchestrator.py:51 IngestOrchestrator;backfill(:127)对每个 启用的源调 backfill_source(:57),内部 _backfill_one(:104)按 read_messages 分页读、 用持久游标记进度(advance_cursor),崩溃重启后从游标续读,不丢不重。
  • 监听轮询:openviking/ingest/poller.py:39 IngestPoller,run(:57)是 asyncio 轮询循环, _poll_session(:92)每轮 reconcile + 读增量 + 追加;空闲到阈值或 token 到阈值才 commit (_commit_idle:126)——"游标 + 轮询"让它自愈:漏一拍、睡眠、重启都只是下次多读一段。
  • 源适配器:一个 harness = 一个 @register_source("name") 装饰的 LogSource 子类(注册 openviking/ingest/registry.py:24 register_source,枚举 iter_enabled_sources:44);抽象接口 openviking/ingest/sources/base.py:46 LogSource(discover_sessions:69 / read_messages:73); 内置 claude_code / codex / cursor / opencode 等(ingest/sources/)。
  • 归一化:openviking/ingest/normalize.py:18 to_add_message_request 把各家格式的 NormalizedMessage 拍平成统一的 AddMessageRequest——只保留有价值的 user/assistant 文本,工具 I/O 当低价值丢弃(文件头注释已点明)。

一句话定位:ingest 是"把外部对话拉进来"的写入前端,资源写入是"把文件/网页灌进来"的写入前端; 两者汇入后都靠 SemanticQueue 生成分层摘要。


6. 巧妙之处(可借鉴的技术)

  • 解析/语义两段分离,把慢和贵隔离到异步队列。 同步链路(取物→解析→切分→定址→提交)全程不碰 大模型,快且可并发;所有 VLM 调用集中在后台 SemanticProcessor,还能限流、熔断、重试。 依据 docs/en/concepts/06-extraction.mdtree_builder.py:9 架构注释。
  • "先定址、后搬运"配资源锁。 finalize_from_temp 只算 URI 不动文件,真正搬运在带锁的 source commit 阶段——把"决定放哪"和"落盘"解耦,既能自动改名去重,又能安全并发 (resource_processor.py:274 起)。
  • 自底向上聚合摘要,天然形成导航层级。 子目录的 L0 被喂进父目录的 L1,越往上越像"目录页", 越往下越是细节——检索时可以先读上层导读再决定下钻(docs/en/concepts/03-context-layers.md)。
  • 代码走 AST 骨架,不为每个文件烧一次 LLM。 ≥100 行代码文件用 tree-sitter 抽签名当摘要, 失败才回退大模型,灌整个代码仓时省下大量 token(semantic_processor.py:1083)。
  • 大目录分批生成 L1 再合并,防止 prompt 爆预算。 _batched_generate_overview(:1441)把文件 摘要分批、各生成部分概览、再合并成最终 L1。

7. 边界与局限(诚实说)

  • 格式转换依赖外部二进制。 docx/pptx 转 PDF 要 LibreOffice(soffice),Markdown 转 PDF 要 pandoc;缺了就 to_pdf 返回 None 走降级(converter.py:58:80)。
  • finalize 要求 temp 里恰好一个文档根,否则直接报错(tree_builder.py:173)——多根/零根不 兼容。
  • L0/L1 质量取决于 VLM;VLM 不可用则退化为空摘要/占位概览(_generate_text_summary:1069 "VLM not available, using empty summary";_generate_overview:1321)。分层召回的效果因此依赖模型。
  • 摘要是异步的,存在可见性延迟。 写入同步返回时,L0/L1 可能还没生成完;要等 SemanticQueue 跑完才完全可检索。
  • 文档阈值与源码常量有出入。 06-extraction.md 写 1024,源码 DEFAULT_MAX_SECTION_SIZE=2048; 以源码 + ParserConfig 为准。
  • 切分本质是启发式。 按标题/段落/token 阈值切,遇到结构混乱(无标题的超长文档、奇形表格)时 可能切得不理想,只能靠字符级强拆兜底(_smart_split_content:510)。

8. 横向对比(同 shelf 兄弟)

同在 memory-context 区、都做"把资料变成可检索记忆"的项目里,OpenViking 的取舍很鲜明:

  • claude-mem / cognee / zep 等偏"抽取要点 → 存进 SQLite/图/向量库"的扁平或图式记忆。
  • OpenViking 坚持文件系统范式:内容保留成目录树而非碎块,并为每个目录预生成 L0/L1/L2 三层——检索是"在树上递归下钻"(见 03),而不是一次性向量 近邻。分层 + 树结构是它区别于传统 RAG 的核心押注。

跨库原理的统一视角见本 shelf 总库 doc 的"上下文分层 / 结构化记忆"条目。


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

按符号名可 grep,比行号抗漂移。

主题文件符号
写入总编排(五阶段)openviking/utils/resource_processor.pyResourceProcessor.process_resource
取物+解析两层入口openviking/utils/media_processor.pyUnifiedResourceProcessor.process
来源接入注册表openviking/parse/accessors/registry.pyAccessorRegistry
格式转 PDFopenviking/parse/converter.pyDocumentConverter.to_pdf
解析路由openviking/parse/parser_router.pyParserRouter.should_use_understanding_api
解析器注册/派发openviking/parse/registry.pyParserRegistry.get_parser_for_file
Markdown 智能切分openviking/parse/parsers/markdown.pyMarkdownParser._build_structure / DEFAULT_MAX_SECTION_SIZE
目录/代码仓预扫openviking/parse/directory_scan.pyscan_directory / _classify_file
解析结果数据结构openviking/parse/base.pyParseResult / ResourceNode
定址(不搬文件)openviking/parse/tree_builder.pyTreeBuilder.finalize_from_temp
内存树容器openviking/core/building_tree.pyBuildingTree.add_context / get_path_to_root / to_directory_structure
树节点 + L0/levelopenviking/core/context.pyContext / ContextLevel
写目标解析openviking/core/content_targets.pyContentTargetSpec.from_fields
L0/L1 自底向上生成openviking/storage/queuefs/semantic_processor.pySemanticProcessor.on_dequeue / _generate_overview / _extract_abstract_from_overview
目录 DAG 遍历openviking/storage/queuefs/semantic_dag.pySemanticDagExecutor.run
代码 AST 骨架openviking/parse/parsers/code/ast/extract_skeleton
多源回填编排openviking/ingest/orchestrator.pyIngestOrchestrator.backfill
增量监听轮询openviking/ingest/poller.pyIngestPoller.run
源适配器注册openviking/ingest/registry.pyregister_source / iter_enabled_sources
消息归一化openviking/ingest/normalize.pyto_add_message_request
源适配器接口openviking/ingest/sources/base.pyLogSource.read_messages

参考概念文档:docs/en/concepts/03-context-layers.md(L0/L1/L2 模型)、 docs/en/concepts/06-extraction.md(解析与抽取)。