写入路径:解析 → 建树 → 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 |
| DocumentConverter | docx/pptx/md 先转 PDF 求统一排版 | openviking/parse/converter.py:15 DocumentConverter |
| ParserRouter | 决定走内置解析器还是第三方理解 API | openviking/parse/parser_router.py:22 ParserRouter |
| ParserRegistry | 按扩展名派发到具体解析器 | openviking/parse/registry.py:39 ParserRegistry |
| scan_directory | 目录/代码仓预扫:分类 + 尊重 .gitignore | openviking/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 三层是什么
每个目录节点都固定挂三档信息,粒度从粗到细:
| 层 | 名字 | 落地文件 | 预算 | 干什么用 |
|---|---|---|---|---|
| L0 | Abstract 摘要 | .abstract.md | 约 100 token | 向量召回、快速过滤"相不相关" |
| L1 | Overview 概览 | .overview.md | 约 1–2k token | 重排、导航(告诉 Agent 里面有啥、怎么进去拿) |
| L2 | Detail 全文 | 原始文件 / 子目录 | 无上限 | 确认要了,才加载的完整内容 |
依据: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 |
| 普通网页/下载 | HTTPAccessor | https://example.com/a.pdf |
| 代码仓 | GitAccessor | https://github.com/org/repo |
| 飞书文档 | FeishuAccessor | 飞书链接 |
| RSS/订阅 | WebFeedAccessor | feed 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:36should_use_understanding_api(读ov.conf的parser_api白名单)。 - 扩展名 → 解析器:
openviking/parse/registry.py:191get_parser_for_file;注册表在ParserRegistry.__init__(:63起)一次性登记全部内置解析器。 - 目录/裸内容的兜底:
openviking/parse/registry.py:210ParserRegistry.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/excludeglob 过滤; 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 做四件事:
- 找唯一文档根: 扫 temp,确认里面恰好一个文档子目录,否则报错(
:173)。 - 算最终 URI:
resolve_target_uri(:85)按scope定 base URI——资源进viking://resources,用户内容进viking://user(_get_base_uri:66);代码仓则从 URL 解析出org/repo作为目录名(:100)。 - 建内存树容器: new 一个
BuildingTree,把_root_uri指向算出的目标。 - 给根建一个占位 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):它带 uri、parent_uri、
abstract(即 L0)、level(L0/L1/L2)、is_leaf、meta 等字段。写入阶段先建出骨架,L0/L1 的
真实内容留到第 [5] 关填。
4.6 第 [4] 关:Source Commit —— 带锁把 temp 搬到最终路径
到这里内容还在 viking://temp。ResourceProcessor.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_1、name_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:1083 调 extract_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:51IngestOrchestrator;backfill(:127)对每个 启用的源调backfill_source(:57),内部_backfill_one(:104)按read_messages分页读、 用持久游标记进度(advance_cursor),崩溃重启后从游标续读,不丢不重。 - 监听轮询:
openviking/ingest/poller.py:39IngestPoller,run(:57)是 asyncio 轮询循环,_poll_session(:92)每轮 reconcile + 读增量 + 追加;空闲到阈值或 token 到阈值才 commit (_commit_idle:126)——"游标 + 轮询"让它自愈:漏一拍、睡眠、重启都只是下次多读一段。 - 源适配器:一个 harness = 一个
@register_source("name")装饰的LogSource子类(注册openviking/ingest/registry.py:24register_source,枚举iter_enabled_sources:44);抽象接口openviking/ingest/sources/base.py:46LogSource(discover_sessions:69/read_messages:73); 内置 claude_code / codex / cursor / opencode 等(ingest/sources/)。 - 归一化:
openviking/ingest/normalize.py:18to_add_message_request把各家格式的NormalizedMessage拍平成统一的AddMessageRequest——只保留有价值的 user/assistant 文本,工具 I/O 当低价值丢弃(文件头注释已点明)。
一句话 定位:ingest 是"把外部对话拉进来"的写入前端,资源写入是"把文件/网页灌进来"的写入前端; 两者汇入后都靠 SemanticQueue 生成分层摘要。
6. 巧妙之处(可借鉴的技术)
- 解析/语义两段分离,把慢和贵隔离到异步队列。 同步链路(取物→解析→切分→定址→提交)全程不碰
大模型,快且可并发;所有 VLM 调用集中在后台
SemanticProcessor,还能限流、熔断、重试。 依据docs/en/concepts/06-extraction.md与tree_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.py | ResourceProcessor.process_resource |
| 取物+解析两层入口 | openviking/utils/media_processor.py | UnifiedResourceProcessor.process |
| 来源接入注册表 | openviking/parse/accessors/registry.py | AccessorRegistry |
| 格式转 PDF | openviking/parse/converter.py | DocumentConverter.to_pdf |
| 解析路由 | openviking/parse/parser_router.py | ParserRouter.should_use_understanding_api |
| 解析器注册/派发 | openviking/parse/registry.py | ParserRegistry.get_parser_for_file |
| Markdown 智能切分 | openviking/parse/parsers/markdown.py | MarkdownParser._build_structure / DEFAULT_MAX_SECTION_SIZE |
| 目录/代码仓预扫 | openviking/parse/directory_scan.py | scan_directory / _classify_file |
| 解析结果数据结构 | openviking/parse/base.py | ParseResult / ResourceNode |
| 定址(不搬文件) | openviking/parse/tree_builder.py | TreeBuilder.finalize_from_temp |
| 内存树容器 | openviking/core/building_tree.py | BuildingTree.add_context / get_path_to_root / to_directory_structure |
| 树节点 + L0/level | openviking/core/context.py | Context / ContextLevel |
| 写目标解析 | openviking/core/content_targets.py | ContentTargetSpec.from_fields |
| L0/L1 自底向上生成 | openviking/storage/queuefs/semantic_processor.py | SemanticProcessor.on_dequeue / _generate_overview / _extract_abstract_from_overview |
| 目录 DAG 遍历 | openviking/storage/queuefs/semantic_dag.py | SemanticDagExecutor.run |
| 代码 AST 骨架 | openviking/parse/parsers/code/ast/ | extract_skeleton |
| 多源回填编排 | openviking/ingest/orchestrator.py | IngestOrchestrator.backfill |
| 增量监听轮询 | openviking/ingest/poller.py | IngestPoller.run |
| 源适配器注册 | openviking/ingest/registry.py | register_source / iter_enabled_sources |
| 消息归一化 | openviking/ingest/normalize.py | to_add_message_request |
| 源适配器接口 | openviking/ingest/sources/base.py | LogSource.read_messages |
参考概念文档:docs/en/concepts/03-context-layers.md(L0/L1/L2 模型)、
docs/en/concepts/06-extraction.md(解析与抽取)。