知识关系可以为重构提供调查线索:相关笔记提醒我们查看调用方、状态和存储边界,接着再回到源码和故障测试。关联边本身不是调用图,也不能证明根因。原稿的合并事故、搜索分数和同事反馈没有可复核记录,本文把它们改为明确的合成练习。
先区分三种图与分数
server/src/services/relations.ts 从语义候选或最近笔记中取候选,让模型输出有限的关系类型、说明和 confidence。它只保留候选中的目标且 confidence 至少 0.5,最多五条;手工关系存为 confidence=1。这个字段表达关系建议,不是经校准的因果概率,手工的 1 也不是依赖验证。
server/src/routes/relations.ts 同时提供笔记关系投影和标签共现投影。笔记投影按项目、关系类型、confidence 和节点上限取可见子图,可能 truncated;标签边按共同笔记数除以两标签笔记数乘积的平方根算 score。当前 client/src/pages/RelationGraph.tsx 请求的是 level=tag、limit=120 的标签图,点击标签再列出笔记。不能把原稿“打开以 Embedding 笔记为中心的图”当作当前页面默认行为。
| 线索 | 当前含义 | 不能据此推断 |
|---|---|---|
| search score | 直接向量候选经词项匹配与阈值等逻辑排名 | 真实故障概率、跨查询统一置信度 |
| relation confidence | 模型建议或手工设置的关系字段 | 函数调用、状态写入或因果链已验证 |
| tag co-occurrence score | 笔记中标签的共现归一化值 | 某标签代表代码模块或依赖方向 |
搜索扩展是一个调查入口
server/src/services/embedding.ts 的关系扩展沿直接结果查 confidence>=0.5 的一跳笔记关系,链接笔记还需 embedding_status=done;折扣分数由直接分数、0.7 和 confidence 相乘。已出现的笔记不再被另一条路径更新,返回集也可能多于 topK。0.5 是包含的边界,不能写成“常规扩展必过滤这条边”。向量查询内部边界另有同一实现的说明。
当前 CLI search 没有 –expand 选项,读者可在确认连接与鉴权后按 REST 合同传 expand=true。这个命令是接口形式示例,没有在本文请求真实模型或数据:
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 搜索映射 返回 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 先逐个取得向量,再打开索引更新,写入新项,在 SQL 事务里交换映射并设 syncing;事务结束后保存数据库,接着删除旧索引项、endUpdate,最后 done 并保存。catch 中是两个独立条件:updateOpen 为 true 时先尝试 cancelUpdate,dbSwapped 为 true 时再标记 failed 并保存,两者可能在同一次失败中执行。重试也有已提交 syncing 的核对;它不是原稿的新 partial 状态方案,更不是跨 SQLite 文件和索引文件的完整原子事务。
server/src/queue/index.ts 的 cancelQueue 清除待执行任务并把 queued 标为失败,没有中断正在执行的任务。要验证中途取消、进程终止或索引提交失败,需要各自的真实故障入口;不能拿 pending 队列清空代替这些测试。
一段合成调查与故障顺序练习
用 Node 24 保存为 refactor.mjs,运行 node refactor.mjs。程序从合成笔记关系收集两跳问题,再以调用轨迹模拟请求前失败和插入中失败。它不运行 ChatCrystal 的搜索、图页面、generateEmbeddings、Provider、Vectra 或 SQL,也没有声称模拟 cancel 能撤销实际磁盘副作用。
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,失败路径的轨迹断言应失败。恢复后重跑。它验证的是这个例子的选边和顺序,不能代替真实调用链测试。
针对实际重构分别列出:调用方是否等待、状态什么时候保存、旧映射什么时候失效、索引提交失败会留下什么、重开后如何确认可搜索、重复请求如何处理、取消到底作用在哪一层。用固定样本和故障注入验证每一项,再把结果及源码版本写回笔记。关系图帮助提出问题,根因判断仍靠反例、控制流和可恢复证据。源码与文件边界可继续阅读摘要队列与索引修复方法。