一个文件就是整张代码图
CodeGraph 不用图数据库,而是把整张代码知识图谱塞进一个本地 SQLite 文件——靠 Node 内置的 node:sqlite + FTS5 全文索引,靠 files 表的内容哈希做增量同步。本页基于真实的 schema.sql(Version 1)与 src/sync/ 拆解:表怎么设计、FTS 怎么保持同步、检索怎么三级回退、增量 sync 怎么只碰变了的文件。
四张核心表 + 元数据表
图的全部状态落在五张表里:nodes(符号)、edges(关系)、files(被追踪的源文件)、unresolved_refs(待解析引用),外加一张 project_metadata 记版本/来源;另有 schema_versions 跟踪迁移版本。nodes 的列直接反映了抽取层的产物:
CREATE TABLE nodes (
id TEXT PRIMARY KEY, -- 稳定符号 ID
kind TEXT NOT NULL, -- function / class / route …(22 种)
name TEXT, qualified_name TEXT,
file_path TEXT, language TEXT,
start_line INTEGER, end_line INTEGER, start_column INTEGER, end_column INTEGER,
docstring TEXT, signature TEXT, visibility TEXT,
is_exported INTEGER, is_async INTEGER, is_static INTEGER, is_abstract INTEGER,
decorators TEXT, -- JSON 数组
type_parameters TEXT, -- JSON 数组
return_type TEXT, -- 归一化返回类型,供接收者类型推断
updated_at INTEGER NOT NULL
);
edges 用自增主键,带 source/target/kind/metadata(JSON)/line/provenance,并对 source、target 都加了 ON DELETE CASCADE 外键——删一个节点,连它的边自动清掉,这让增量重建一个文件时不留悬挂边。files 表是增量同步的账本:
CREATE TABLE files (
path TEXT PRIMARY KEY,
content_hash TEXT NOT NULL, -- ← 变更检测的核心
language TEXT, size INTEGER,
modified_at INTEGER, indexed_at INTEGER,
node_count INTEGER, errors TEXT -- JSON
);
CREATE TABLE unresolved_refs ( -- 全量索引后才能解析的引用
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_node_id TEXT, reference_name TEXT, reference_kind TEXT,
line INTEGER, col INTEGER, candidates TEXT, -- JSON 候选
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
FTS5 外部内容表 + 三个触发器
全文检索不是另存一份文本,而是用 FTS5 的外部内容(external-content)模式——虚拟表 nodes_fts 声明 content='nodes'、content_rowid='rowid',索引的列是 name / qualified_name / docstring / signature。它不存自己的副本,只对 nodes 表的这几列建倒排索引,省一份存储。
CREATE VIRTUAL TABLE nodes_fts USING fts5(
id, name, qualified_name, docstring, signature,
content='nodes', content_rowid='rowid'
);
-- 插入:把新行喂进 FTS
CREATE TRIGGER nodes_ai AFTER INSERT ON nodes BEGIN … END;
-- 删除:用 'delete' 特殊指令把旧行从 FTS 撤回
CREATE TRIGGER nodes_ad AFTER DELETE ON nodes BEGIN … END;
-- 更新:先 'delete' 旧行,再插新行
CREATE TRIGGER nodes_au AFTER UPDATE ON nodes BEGIN … END;
外部内容 FTS5 有个众所周知的坑:删/改时必须显式用 INSERT INTO nodes_fts(nodes_fts, …) VALUES ('delete', …) 把旧行撤回,否则倒排索引会和真实数据漂移。三个触发器 nodes_ai / nodes_ad / nodes_au 正是把这件事下沉到数据库层自动完成——应用代码只管增删改 nodes,FTS 永远一致。
三级检索:prefix → LIKE → fuzzy
db/queries.ts 的 searchNodes() 不是"FTS 查一把就完",而是一条带回退的级联,专门对付 agent 那种记不准全名的查询:
- ① FTS5 前缀匹配:先用
query*做词首匹配(searchNodesFTS),用 BM25 排序。 - ② LIKE 子串回退:FTS 无果时退到
LIKE子串匹配——例如signIn能命中signInWithGoogle(词首匹配做不到的)。 - ③ 模糊兜底:前两者都空时才上 Levenshtein 模糊匹配;且只在查询够长、值得模糊时触发(1 字符查询会匹配太多,跳过)。
- 精确名补充:BM25 会把短的精确名(如
getBean)埋在几百个结果之下,所以无论走哪条路,精确名匹配总会被补进候选。
过滤条件(kinds / languages)与文本检索组合:若用户只给了过滤、没给文本,则走一条"空文本"路径返回所有匹配过滤的节点。打分由 search/query-utils 的 kindBonus / nameMatchBonus / scorePathRelevance 叠加——FTS 的 BM25 只是起点,路径相关度与符号种类还会再加权。
索引设计的取舍:少建一个索引
schema 里最"数据库工程师"的一笔,是 故意不建 idx_edges_source 和 idx_edges_target 这两个窄索引。注释解释得很清楚:复合索引 (source, kind) 和 (target, kind) 已经能靠 SQLite 的最左前缀扫描覆盖"只按 source"或"只按 target"的查询——再单建窄索引就是纯粹给写入加负担的死重量。迁移 v4 还会在已有库上把它们 DROP 掉。
其余索引覆盖了所有热查询路径:nodes 上有 kind / name / qualified_name / file_path / language、复合 (file_path, start_line),以及一个 表达式索引 lower(name) 支持大小写不敏感查找;edges 上有 kind / (source,kind) / (target,kind) / provenance(provenance 索引正是为了一句 SQL 筛出全部 heuristic 合成边);unresolved_refs 上有 from_node_id / reference_name / file_path 及复合 (from_node_id, reference_name)。
git 感知的增量 sync:只碰变了的文件
src/sync/ 把"保持索引新鲜"拆成几块协作的机制,核心是用 files.content_hash 做变更检测,而不是每次全量重建:
- 内容哈希 + 增量重索引:sync 时比对每个文件当前内容哈希与
files表里存的哈希——没变就跳过,变了才重抽取;删除的文件连带其nodes(及级联的edges/unresolved_refs)一起清掉。 - git sync hooks(
sync/git-hooks.ts):当文件监视关闭时,可选地挂上 commit / merge / checkout 钩子,在这些时点触发一次增量 sync。 - 文件监视 + 监视策略(
watcher.ts/watch-policy.ts):用fs.watch,但带策略——例如 WSL2 +/mnt/*上递归fs.watch太慢直接关掉(issue #199),避免启动慢到撞穿 host 的握手超时。 - worktree 感知(
sync/worktree.ts):检测一个查询是不是在"借用"另一棵 git worktree 的索引,避免跨 worktree 串味。
与 resolver 的衔接:unresolved_refs 与 provenance 列
存储层和解析层是通过两个 schema 设计点咬合的:
unresolved_refs表是抽取与解析之间的暂存区:抽取时遇到的、当下还无法定位定义的引用先连同candidates(JSON) 落这张表;全量索引完成后,resolver 再回头消解它们、写成正式edges。这把"单文件抽取"和"跨文件解析"在存储上解耦开。edges.provenance列 +idx_edges_provenance:回调合成器写出的边统一带provenance='heuristic'与metadata.synthesizedBy,索引让"筛出/审计全部合成边"成为一句WHERE provenance='heuristic'。换言之,"这条边是谁推断出来的"被持久化进了 schema,可复现、可进 git、可追责。
意义:把图数据库的需求,压进一个 SQLite 文件
"代码知识图谱"听起来像要上 Neo4j,CodeGraph 的回答是——一个 SQLite 文件足矣。节点/边/文件/待解析引用四张表 + 外键级联给了图语义,FTS5 外部内容表 + 三触发器给了零额外存储的全文检索,复合索引的精打细算压住写入开销,files.content_hash 把同步变成增量,provenance 列把"推断来源"沉淀成可查询的事实。这套设计让 CodeGraph 的索引可进 git、可复现、零外部依赖——一个 .codegraph/codegraph.db 文件,就是 agent 共享的那张"代码索引标准件"。