#763·llm_wiki

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²)(这里忽略名称字符串长度因素)。

代码依据:

触发条件与影响边界

  • 检索图首次构建或 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 00042page-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,执行:

bash
node benchmark-link-resolution.mjs > results.json
# 可选:核对本地源码与固定基线完全相同,再运行
node benchmark-link-resolution.mjs --repo /path/to/llm_wiki > results.json

--repo 会对源码做精确断言,确保运行的是上述固定基线;源码不一致时停止。该脚本不需要 npm 依赖,默认不联网。

完整脚本(仅原实现:别名输入与精确 ID 输入)
javascript
#!/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));