---
title: 图谱辅助重构：把关系线索变成故障检查
description: 区分标签共现、笔记关系和代码依赖，核对真实嵌入与取消链路，用合成故障顺序练习审查重构边界。
image: "https://zengliangyi.com/og/blog/graph-assisted-refactoring.png"
---

知识关系可以为重构提供调查线索：相关笔记提醒我们查看调用方、状态和存储边界，接着再回到源码和故障测试。关联边本身不是调用图，也不能证明根因。原稿的合并事故、搜索分数和同事反馈没有可复核记录，本文把它们改为明确的合成练习。

## 先区分三种图与分数

[server/src/services/relations.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/services/relations.ts) 从语义候选或最近笔记中取候选，让模型输出有限的关系类型、说明和 confidence。它只保留候选中的目标且 confidence 至少 0.5，最多五条；手工关系存为 confidence=1。这个字段表达关系建议，不是经校准的因果概率，手工的 1 也不是依赖验证。

[server/src/routes/relations.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/routes/relations.ts) 同时提供笔记关系投影和标签共现投影。笔记投影按项目、关系类型、confidence 和节点上限取可见子图，可能 truncated；标签边按共同笔记数除以两标签笔记数乘积的平方根算 score。当前 [client/src/pages/RelationGraph.tsx](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/client/src/pages/RelationGraph.tsx) 请求的是 level=tag、limit=120 的标签图，点击标签再列出笔记。不能把原稿“打开以 Embedding 笔记为中心的图”当作当前页面默认行为。

| 线索 | 当前含义 | 不能据此推断 |

| --- | --- | --- |

| search score | 直接向量候选经词项匹配与阈值等逻辑排名 | 真实故障概率、跨查询统一置信度 |

| relation confidence | 模型建议或手工设置的关系字段 | 函数调用、状态写入或因果链已验证 |

| tag co-occurrence score | 笔记中标签的共现归一化值 | 某标签代表代码模块或依赖方向 |

## 搜索扩展是一个调查入口

[server/src/services/embedding.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/services/embedding.ts) 的关系扩展沿直接结果查 confidence>=0.5 的一跳笔记关系，链接笔记还需 embedding_status=done；折扣分数由直接分数、0.7 和 confidence 相乘。已出现的笔记不再被另一条路径更新，返回集也可能多于 topK。0.5 是包含的边界，不能写成“常规扩展必过滤这条边”。[向量查询内部边界](/blog/vectra-search-internals/)另有同一实现的说明。

当前 CLI search 没有 –expand 选项，读者可在确认连接与鉴权后按 REST 合同传 expand=true。这个命令是接口形式示例，没有在本文请求真实模型或数据：



```bash
curl   --fail-with-body   --get   "http://localhost:3721/api/search"     --data-urlencode   "q=embedding search regression"     --data-urlencode   "expand=true"     --data-urlencode   "limit=5"
```



云端应改成经过确认的 HTTPS 目标并使用对应鉴权，不能将本地无 token 形式直接当云端部署方案。[REST 搜索映射](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/routes/notes.ts) 返回 note_id、conversation_id 和 via_relation；服务内部对应字段名为 viaRelation。沿笔记 ID 回看原始内容、关系说明和 created_by，再决定下一条源码检查；不要把 via_relation 当根因结论。

## 用控制流审查原稿的故障解释

原稿示意先 await Promise.all，再 beginUpdate。如果前者拒绝，代码根本没有到达打开索引更新的步骤；throw 之后的 done 赋值也不会自动执行。因此同一片段不能同时证明“模型请求失败、endUpdate 提交半批、状态仍 done”。Promise.all 拒绝还不等于取消其他已发出的请求。

当前 [server/src/services/embedding.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/services/embedding.ts) 先逐个取得向量，再打开索引更新，写入新项，在 SQL 事务里交换映射并设 syncing；事务结束后保存数据库，接着删除旧索引项、endUpdate，最后 done 并保存。catch 中是两个独立条件：updateOpen 为 true 时先尝试 cancelUpdate，dbSwapped 为 true 时再标记 failed 并保存，两者可能在同一次失败中执行。重试也有已提交 syncing 的核对；它不是原稿的新 partial 状态方案，更不是跨 SQLite 文件和索引文件的完整原子事务。

[server/src/queue/index.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/queue/index.ts) 的 cancelQueue 清除待执行任务并把 queued 标为失败，没有中断正在执行的任务。要验证中途取消、进程终止或索引提交失败，需要各自的真实故障入口；不能拿 pending 队列清空代替这些测试。

## 一段合成调查与故障顺序练习

用 Node 24 保存为 refactor.mjs，运行 `node refactor.mjs`。程序从合成笔记关系收集两跳问题，再以调用轨迹模拟请求前失败和插入中失败。它不运行 ChatCrystal 的搜索、图页面、generateEmbeddings、Provider、Vectra 或 SQL，也没有声称模拟 cancel 能撤销实际磁盘副作用。



```js
import   assert   from   'node:assert/strict'  ;  
  
  // Synthetic note links identify questions, never prove code dependencies.  
  const   edges   =   [  
    { from:   'embedding'  , to:   'queue'  , type:   'SIMILAR_TO'  , confidence:   0.8   },  
    { from:   'queue'  , to:   'index'  , type:   'REFERENCES'  , confidence:   0.5   },  
    { from:   'queue'  , to:   'unrelated'  , type:   'SIMILAR_TO'  , confidence:   0.49   },  
  ];  
  function   candidates  (  start  ,   maxDepth   =   2  ) {  
    const   seen   =   new   Map  ([[start,   0  ]]);  
    let   frontier   =   [start];  
    for   (  let   depth   =   1  ; depth   <=   maxDepth; depth  ++  ) {  
      const   next   =   [];  
      for   (  const   node   of   frontier) {  
        for   (  const   edge   of   edges) {  
          if   (edge.confidence   <   0.5  )   continue  ;  
          const   neighbor   =   edge.from   ===   node   ?   edge.to   :   edge.to   ===   node   ?   edge.from   :   null  ;  
          if   (  !  neighbor   ||   seen.  has  (neighbor))   continue  ;  
          seen.  set  (neighbor, depth);  
          next.  push  (neighbor);  
        }  
      }  
      frontier   =   next;  
    }  
    return   seen;  
  }  
  const   clues   =   candidates  (  'embedding'  );  
  assert.  equal  (clues.  get  (  'index'  ),   2  );  
  assert.  equal  (clues.  has  (  'unrelated'  ),   false  );  
  
  async   function   exercise  (  fault  ) {  
    const   calls   =   [];  
    let   done   =   false  ;  
    const   embed   =   async   (  i  )   =>   {  
      calls.  push  (  `embed:${  i  }`  );  
      if   (fault   ===   'embed'   &&   i   ===   1  )   throw   new   Error  (  'embed failed'  );  
      return   [i,   1  ];  
    };  
    try   {  
      const   vectors   =   await   Promise  .  all  ([  0  ,   1  ].  map  (embed));  
      calls.  push  (  'beginUpdate'  );  
      try   {  
        for   (  let   i   =   0  ; i   <   vectors.  length  ; i  ++  ) {  
          calls.  push  (  `insert:${  i  }`  );  
          if   (fault   ===   'insert'   &&   i   ===   1  )   throw   new   Error  (  'insert failed'  );  
        }  
        calls.  push  (  'endUpdate'  );  
        done   =   true  ;  
      }   catch   (error) {  
        calls.  push  (  'cancelUpdate'  );  
        throw   error;  
      }  
    }   catch   (error) {  
      return   { calls, done, error: error.message };  
    }  
    return   { calls, done, error:   null   };  
  }  
  const   beforeBegin   =   await   exercise  (  'embed'  );  
  assert.  equal  (beforeBegin.done,   false  );  
  assert.  equal  (beforeBegin.calls.  includes  (  'beginUpdate'  ),   false  );  
  assert.  equal  (beforeBegin.calls.  includes  (  'endUpdate'  ),   false  );  
  const   duringInsert   =   await   exercise  (  'insert'  );  
  assert.  equal  (duringInsert.done,   false  );  
  assert.  equal  (duringInsert.calls.  includes  (  'cancelUpdate'  ),   true  );  
  assert.  equal  (duringInsert.calls.  includes  (  'endUpdate'  ),   false  );  
  const   healthy   =   await   exercise  (  'none'  );  
  assert.  equal  (healthy.done,   true  );  
  assert.  equal  (healthy.calls.  filter  ((  x  )   =>   x   ===   'endUpdate'  ).  length  ,   1  );  
  console.  log  (  
    'two-hop clues and simulated failure ordering pass; no product graph or index executes'  
  );
```



## 从线索到可审查的重构清单

把 `confidence<0.5` 的排除条件改为 `<=0.5`，第二跳线索断言应失败；在插入故障 catch 中把 cancelUpdate 换成 endUpdate，失败路径的轨迹断言应失败。恢复后重跑。它验证的是这个例子的选边和顺序，不能代替真实调用链测试。

针对实际重构分别列出：调用方是否等待、状态什么时候保存、旧映射什么时候失效、索引提交失败会留下什么、重开后如何确认可搜索、重复请求如何处理、取消到底作用在哪一层。用固定样本和故障注入验证每一项，再把结果及源码版本写回笔记。关系图帮助提出问题，根因判断仍靠反例、控制流和可恢复证据。源码与文件边界可继续阅读[摘要队列](/blog/pqueue-task-queues/)与[索引修复方法](/blog/vectra-index-recovery/)。

分享：

##  相关文章

[##  AI 对话格式差异：事件、快照与跨库消息定位

对照五个固定适配器，区分 JSONL 容器、事件语义与 SQLite 键布局，用两个内存数据库观察消息索引、孤立数据和未知版本。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#Node.js

#SQLite](/blog/ai-conversation-format-divergence)    [##  AI 对话管理方案：四种存储路径与迁移验收

比较 Claude Code、Cursor、Copilot、Trae 的固定适配器输入，区分原始副本、可读导出与知识库，并用合成清单验证身份和信息保留。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#Node.js

#SQLite](/blog/ai-conversation-management-options)    [##  五种 AI 对话解析器：字段保留与损失边界

从 Claude Code、Codex、Cursor、Trae 和 Copilot 的实际解析器建立验收矩阵，检查工具标记、思考文本、顺序与快照覆盖范围。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#TypeScript

#Node.js](/blog/ai-conversation-parser-boundaries)

##  从文章到代码

到我的 GitHub 看看 ChatCrystal、开发者工具和日常学习记录，欢迎交流实现细节。

[访问我的 GitHub](https://github.com/ZengLiangYi)

```json
{
  "@context": "https://schema.org",
  "@type": "WebSite",
  "name": "ZengLiangYi Blog",
  "alternateName": [
    "Zeng Liangyi",
    "ZengLiangYi",
    "zengliangyi.com"
  ],
  "url": "https://zengliangyi.com",
  "description": "曾亮懿的个人网站。记录 AI Native 探索、开发者工具与全栈产品实践。"
}
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "图谱辅助重构：把关系线索变成故障检查",
  "description": "区分标签共现、笔记关系和代码依赖，核对真实嵌入与取消链路，用合成故障顺序练习审查重构边界。",
  "url": "https://zengliangyi.com/blog/graph-assisted-refactoring/",
  "image": "https://zengliangyi.com/images/blog/graph-assisted-refactoring.svg",
  "datePublished": "2026-10-07T00:00:00.000Z",
  "dateModified": "2026-10-07T00:00:00.000Z",
  "author": {
    "@type": "Person",
    "name": "曾亮懿",
    "url": "https://zengliangyi.com/about"
  },
  "publisher": {
    "@type": "Organization",
    "name": "ZengLiangYi Blog",
    "logo": {
      "@type": "ImageObject",
      "url": "https://zengliangyi.com/favicon-96x96.png"
    }
  },
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://zengliangyi.com/blog/graph-assisted-refactoring/"
  }
}
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "首页",
      "item": "https://zengliangyi.com/"
    },
    {
      "@type": "ListItem",
      "position": 2,
      "name": "文章",
      "item": "https://zengliangyi.com/blog"
    },
    {
      "@type": "ListItem",
      "position": 3,
      "name": "图谱辅助重构：把关系线索变成故障检查",
      "item": "https://zengliangyi.com/blog/graph-assisted-refactoring/"
    }
  ]
}
```
