perf: 检索图链接别名解析逐边扫描全部节点,导致 O(N×E) 同步耗时
Author: xinyuan0801Created Sep 16, 2026Updated Sep 16, 2026
问题
buildRetrievalGraph() 为每条 wikilink 调用 resolveTarget();当目标不是大小写敏感的精确 ID 时,后者会扫描全部节点 ID,并在循环中反复执行大小写和空白规范化。
例如 [[Page 00042]] 指向 page-00042.md,属于当前实现已经支持的合法别名匹配,而不是异常输入。链接数为 E、页面数为 N 时,这段处理最坏为 O(N×E);每页链接数固定时退化为 O(N²)(这里忽略名称字符串长度因素)。
代码依据:
- graph-relevance.ts:124–138:resolveTarget 的逐节点扫描
- graph-relevance.ts:buildRetrievalGraph 的第二遍逐链接解析及 dataVersion 缓存
- wiki-graph.ts:buildWikiGraphUncached 中的 buildRetrievalGraph 调用
触发条件与影响边界
- 检索图首次构建或
dataVersion缓存失效,且链接包含大小写/空白别名;不存在的目标同样会走完整扫描。 - 这不是孤立辅助函数:
buildWikiGraphUncached()在可视化图节点数不超过MAX_WEIGHTED_GRAPH_NODES = 3_000时会调用检索图构建。缓存命中可以避开重建,但不能消除冷构建或失效后的开销。 - 精确 ID 匹配通过
Set.has()提前返回,不受逐节点扫描影响。
实际用户场景影响
| 用户场景 | 实际触发链路 | 用户受到的影响 |
|---|---|---|
| 启动应用后,首次打开知识图谱 | GraphView.loadGraph() → buildWikiGraph() → buildRetrievalGraph() → 逐链接 resolveTarget() | 图谱停留在“正在构建”状态更久;这段同步循环占用前端主线程,执行期间点击、切换视图等事件也不能及时处理。 |
| 批量导入资料后,切到图谱页观察知识生成过程 | 摄入写入 Wiki → refreshProjectFileTree({ bumpDataVersion: true }) → 图谱监听版本变化 → 重新构建 | 不是只有第一次打开慢:后续内容提交触发缓存失效时,会再次承担扫描成本,可能反复打断图谱浏览;并非每个导入文件都必然对应一次重建。 |
| 保持图谱页打开,在外部编辑器中修改一个 Wiki 页面 | 文件监听收到 file-sync://changed → 合并变更 → 更新 dataVersion → 图谱重新构建 | 即使只是改一篇文章的一句话,仍可能触发整份知识库的链接重新解析;操作很小,但付出的扫描成本取决于整个知识库,而非这次改动量。 |
可量化证据
从上述固定提交提取原函数,仅去掉 TypeScript 类型后执行;输入完全合成,不使用用户数据、不调用 LLM、不包含文件读取或 UI 渲染。
环境:Node v22.16.0,Linux x64,AMD EPYC 9V74 80-Core Processor。
方法:N = 1,000 / 2,000 / 3,000,每页 8 个非自链接,目标均匀覆盖所有 ID;目标采用 Page 00042 → page-00042 形式。每组数据对原函数预热 2 轮,再测量 5 轮,取中位数。
| 页面数 N | 链接数 E | 原函数耗时中位数 | 原算法候选 ID 遍历次数(由 fixture 推导) |
|---|---|---|---|
| 1,000 | 8,000 | 242.09 ms | 4,004,000 |
| 2,000 | 16,000 | 941.64 ms | 16,008,000 |
| 3,000 | 24,000 | 2,215.37 ms | 36,012,000 |
该 fixture 中每个 ID 被引用 8 次,候选遍历次数严格为 8 × N × (N + 1) / 2;这不是依靠机器速度推测的复杂度。
独立复现脚本
将下面内容保存为 benchmark-link-resolution.mjs,执行:
node benchmark-link-resolution.mjs > results.json
# 可选:核对本地源码与固定基线完全相同,再运行
node benchmark-link-resolution.mjs --repo /path/to/llm_wiki > results.json--repo 会对源码做精确断言,确保运行的是上述固定基线;源码不一致时停止。该脚本不需要 npm 依赖,默认不联网。
#!/usr/bin/env node
/**
* Baseline: e8082119649e6a8e1cf85eaf289adcabfdf39d4e
* Source: src/lib/graph-relevance.ts:124-138
* Runs only the original resolver, with generated alias and exact-ID inputs.
* Synchronous CPU microbenchmark; excludes I/O, LLM calls and UI rendering.
*/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { performance } from 'node:perf_hooks';
const COMMIT = 'e8082119649e6a8e1cf85eaf289adcabfdf39d4e';
const embeddedSource = `function resolveTarget(
raw: string,
nodeIds: ReadonlySet<string>,
): string | null {
if (nodeIds.has(raw)) return raw
const normalized = raw.toLowerCase().replace(/\\s+/g, "-")
for (const id of nodeIds) {
const idLower = id.toLowerCase()
if (idLower === normalized) return id
if (idLower === raw.toLowerCase()) return id
if (idLower.replace(/\\s+/g, "-") === normalized) return id
}
return null
}`;
let source = embeddedSource;
const repoArg = process.argv.indexOf('--repo');
if (repoArg !== -1) {
const root = process.argv[repoArg + 1];
if (!root) throw new Error('--repo requires a repository directory');
const text = fs.readFileSync(path.join(root, 'src/lib/graph-relevance.ts'), 'utf8');
const match = text.match(/function resolveTarget\([\s\S]*?\n\}/);
if (!match) throw new Error('resolveTarget function not found');
source = match[0].replace(/\r\n/g, '\n');
assert.equal(source, embeddedSource, 'Source differs from pinned baseline; review benchmark before use.');
}
const jsSource = source.replace('raw: string', 'raw')
.replace('nodeIds: ReadonlySet<string>', 'nodeIds')
.replace('): string | null {', ') {');
const originalResolve = new Function(`return (${jsSource});`)();
function makeFixture(n, mode = 'alias') {
const ids = Array.from({ length: n }, (_, i) => `page-${String(i).padStart(5, '0')}`);
const nodeIds = new Set(ids);
const targets = [], expected = [];
const offsets = [1, 7, 17, 31, 53, 97, 193, 389];
for (let i = 0; i < n; i++) {
for (const offset of offsets) {
const j = (i + offset) % n;
targets.push(mode === 'exact' ? ids[j] : `Page ${String(j).padStart(5, '0')}`);
expected.push(ids[j]);
}
}
return { nodeIds, targets, expected };
}
function runOriginal({ nodeIds, targets }) {
const output = new Array(targets.length);
for (let i = 0; i < targets.length; i++) output[i] = originalResolve(targets[i], nodeIds);
return output;
}
const median = values => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)];
let sink;
function timed(fixture) {
const start = performance.now();
sink = runOriginal(fixture);
return performance.now() - start;
}
function measure(n, mode = 'alias') {
const fixture = makeFixture(n, mode);
assert.deepEqual(runOriginal(fixture), fixture.expected);
for (let i = 0; i < 2; i++) timed(fixture);
const samples = Array.from({ length: 5 }, () => timed(fixture));
return {
pages: n,
links: fixture.targets.length,
mode,
// Derived from uniform target coverage, not measured with instrumentation.
baselineCandidateVisits: mode === 'alias' ? 8 * n * (n + 1) / 2 : 0,
baselineMedianMs: median(samples),
baselineSamplesMs: samples,
};
}
const rows = [1000, 2000, 3000].map(n => measure(n));
const exactMatchControl = measure(3000, 'exact');
console.log(JSON.stringify({
baselineCommit: COMMIT,
baselinePath: 'src/lib/graph-relevance.ts:124-138',
environment: {
node: process.version,
platform: process.platform,
architecture: process.arch,
cpu: os.cpus()[0]?.model,
},
method: {
linksPerPage: 8,
alias: 'Page 00042 -> page-00042',
warmupPasses: 2,
measuredPasses: 5,
reportedStatistic: 'median',
userDataUsed: false,
scope: 'synchronous link resolution only; excludes I/O, LLM, UI rendering',
},
rows,
exactMatchControl,
sinkLength: sink.length,
}, null, 2));Source: nashsu/llm_wiki