本文面向:想了解 ChatCrystal 如何自动发现笔记间关联的开发者。 预计阅读时间:10 分钟
什么是「知识图谱」?
在 ChatCrystal 的语境下,知识图谱不是 Neo4j 那种企业级图数据库,而是一张笔记关系网络:节点是你的笔记,边是笔记之间的语义关系。每条边带三个属性------关系类型(比如「被解决」)、置信度(0-1)、来源(LLM 自动发现或手动创建)。
存储层很简单,一张 note_relations 表就够了:
sql
CREATE TABLE note_relations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_note_id INTEGER NOT NULL,
target_note_id INTEGER NOT NULL,
relation_type TEXT NOT NULL,
confidence REAL DEFAULT 1.0,
description TEXT,
created_by TEXT NOT NULL DEFAULT 'manual', -- 'manual' 或 'llm'
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(source_note_id, target_note_id, relation_type),
FOREIGN KEY (source_note_id) REFERENCES notes(id) ON DELETE CASCADE,
FOREIGN KEY (target_note_id) REFERENCES notes(id) ON DELETE CASCADE
);
注意 UNIQUE 约束:同一对笔记、同一种关系类型只能存在一条记录。created_by 字段区分来源------manual 是用户手动创建的,llm 是自动发现的。
八种关系类型
ChatCrystal 定义了 8 种关系类型,覆盖了日常开发中笔记之间最常见的语义联系:
| 类型 | 含义 | 例子 |
|---|---|---|
CAUSED_BY |
A 的问题由 B 引起 | 「CORS 报错」← 由 ← 「Nginx 反代配置修改」 |
LEADS_TO |
A 导致了 B 的结果 | 「升级 Node 20」→ 导致 → 「ESM 模块加载失败」 |
RESOLVED_BY |
A 的问题被 B 解决 | 「SQLite 内存溢出」→ 被解决 → 「切换 sql.js WASM」 |
SIMILAR_TO |
主题或技术相似 | 「React 状态管理方案对比」≈「Vue Pinia 选型分析」 |
CONTRADICTS |
结论互相矛盾 | 「JWT 无状态优于 Session」╳「Session 更安全」 |
DEPENDS_ON |
A 依赖于 B | 「GraphQL 分页实现」← 依赖 ←「Apollo Client 缓存策略」 |
EXTENDS |
A 是 B 的扩展 | 「Tailwind v4 迁移踩坑」← 扩展 ←「Tailwind v3 自定义主题」 |
REFERENCES |
A 引用了 B 的内容 | 「CI 流水线优化」← 引用 ←「GitHub Actions 缓存机制」 |
这 8 种类型由 Zod schema 约束,LLM 只能从这个枚举里选择,不会自由发挥。
自动发现:LLM 如何分析笔记关系
这是整个系统的核心。当你生成一条新笔记时,ChatCrystal 可以自动发现它与已有笔记之间的关系。整个流程分三步。
第一步:候选笔记筛选
discoverRelations(noteId) 首先获取源笔记的完整信息------标题、摘要、关键结论、标签,然后去数据库里找「可能有关系」的候选笔记:
typescript
async function findCandidateNotes(noteId: number, noteTitle: string): Promise<CandidateNote[]> {
// 优先用语义搜索找相似笔记
let candidateIds: number[] = [];
try {
const searchResults = await semanticSearch(noteTitle, MAX_CANDIDATES);
candidateIds = searchResults.map(r => r.noteId).filter(id => id !== noteId);
} catch {
// 语义搜索不可用,走降级逻辑
}
if (candidateIds.length > 0) {
// 按语义相似度选出来的候选
// ...
}
// 降级:取最近的 20 条笔记
// ...
}
这里有两个关键设计:
- 语义搜索优先:用 Embedding 模型把笔记标题向量化,在 vectra 索引里找到最相似的 20 条。这意味着如果两条笔记讨论的是同一个技术问题,即使措辞完全不同,也能被选为候选。
- 降级兜底:如果 Embedding 模型没配置、索引为空,就退化为按时间倒序取最近 20 条。覆盖率低但不会报错。
第二步:LLM 分析
拿到候选列表后,系统构造一个结构化 prompt 发给 LLM。prompt 包含源笔记的详细信息和候选笔记的摘要列表,每条候选标注了 ID、标题、摘要(截取前 200 字)和标签:
makefile
新笔记:
标题: SQLite WASM 内存优化方案
摘要: 在处理大规模数据导入时发现 sql.js 的内存占用过高...
标签: sqlite, wasm, performance
已有笔记:
[id=42] "Nginx 反代导致 WebSocket 断连" - 生产环境出现频繁断连... [nginx, websocket]
[id=43] "数据导入管道重构" - 将批量插入改为分批事务... [sqlite, batch]
LLM 使用 Vercel AI SDK 的 generateObject() 返回结构化 JSON 数组,每条关系包含 target_note_id、relation_type、confidence(0-1)和 description(20 字以内)。系统 prompt 明确要求:
- 只返回置信度 >= 0.5 的关系
- 最多返回 5 条
- 不要编造不存在的关系
第三步:过滤与持久化
LLM 的输出不是直接入库,还要过两道过滤:
- 候选 ID 验证 :
target_note_id必须在候选集合里------防止 LLM 幻觉出一个不存在的笔记 ID - 置信度阈值 :
confidence >= 0.5的才保留,最多 5 条
通过过滤的关系以 INSERT OR IGNORE 写入数据库。OR IGNORE 处理 UNIQUE 约束冲突------如果同一条关系已经存在,跳过而不是报错。写入后立即 saveDatabase() 持久化到磁盘。
typescript
const filteredRelations = rawRelations
.filter(rel => candidateIdSet.has(rel.target_note_id) && rel.confidence >= MIN_CONFIDENCE)
.slice(0, MAX_RELATIONS);
手动创建关系
除了 LLM 自动发现,你也可以通过 API 手动创建关系。这在 LLM 漏掉了一些你认为重要的关联时很有用:
bash
curl -X POST http://localhost:3721/api/notes/42/relations \
-H "Content-Type: application/json" \
-d '{
"target_note_id": 58,
"relation_type": "RESOLVED_BY",
"description": "被新的 WASM 方案解决"
}'
手动创建的关系 confidence 默认为 1.0,created_by 为 manual。API 会验证两端笔记都存在,且不允许自引用(source_note_id === target_note_id)。
删除关系同样简单:
bash
curl -X DELETE http://localhost:3721/api/relations/7
图数据 API:nodes + edges
知识图谱的可视化需要一个专门的图数据接口。GET /api/relations/graph 返回完整的节点和边集合:
bash
# 获取全量图数据
curl http://localhost:3721/api/relations/graph
# 按项目过滤
curl http://localhost:3721/api/relations/graph?project=ChatCrystal
返回格式:
json
{
"success": true,
"data": {
"nodes": [
{
"id": 42,
"title": "SQLite WASM 内存优化方案",
"project_name": "ChatCrystal",
"tags": ["sqlite", "wasm", "performance"]
}
],
"edges": [
{
"source": 42,
"target": 58,
"type": "RESOLVED_BY",
"confidence": 0.85
}
]
}
}
节点数据来自 notes 和 conversations 表的 JOIN 查询,通过子查询聚合标签。边数据从 note_relations 表取出后,会过滤掉两端节点不在当前视图中的边------如果你按项目过滤了节点,孤立的边不会出现在结果里。
关系扩展搜索
这是知识图谱最实用的功能之一。语义搜索只能找到与查询词直接匹配的笔记,但很多时候你需要的是「关联知识」。比如你搜「SQLite 内存问题」,直接命中的可能是「sql.js WASM 内存优化」,但沿着图谱边走下去,你可能还会发现「数据导入管道重构」------它通过 EXTENDS 关系连接到内存优化笔记。
当搜索请求带 expandRelations=true 参数时,ChatCrystal 会沿着图谱边扩展搜索结果:
- 从直接命中的笔记出发,遍历所有关联边
- 只扩展置信度 >= 0.5 的边
- 关联笔记的得分 = 原始得分 x 0.7 x 边置信度
0.7 的折扣系数是关键:它确保关联笔记永远排在直接命中之后,但又不至于被完全忽略。这个设计让搜索结果既精准又有广度。
批量发现
如果你的笔记库里已经有大量笔记但还没有关系,可以一键触发批量发现:
bash
curl -X POST http://localhost:3721/api/relations/batch-discover
这个接口会找出所有「没有作为任何关系的 source」的笔记,为它们逐个排队触发 LLM 发现。任务进入 p-queue 队列(并发度 1,每秒 1 次请求),不会打爆你的 LLM API。返回值告诉你排队了多少任务:
json
{
"success": true,
"data": {
"queued": 15,
"queue": { "pending": 3, "running": 1 }
}
}
批量发现的筛选逻辑很精确------只处理完全没有出边的笔记,不会重复处理已有关系的笔记。如果你想重新发现某个笔记的关系,可以先删除旧关系再单独触发。
可视化
Web 界面的知识图谱页面消费的就是 GET /api/relations/graph 返回的 nodes + edges 数据。前端用力导向图(force-directed layout)渲染,节点大小反映关系数量,边的颜色区分关系类型,悬停显示置信度和描述。
你可以通过项目过滤器切换视图------只看某个项目的知识图谱,避免节点过多导致图谱混乱。
下一步
知识图谱系统目前的几个已知方向:
- 双向关系 :当前边是有方向的(source -> target),但某些关系类型(如
SIMILAR_TO)天然是双向的。未来可以考虑在查询时自动对称展开。 - 关系强度衰减:随着时间推移,旧的关系可能不再重要。可以引入时间衰减因子,让近期的关系在搜索扩展中权重更高。
- 图谱分析:基于图结构做一些简单的分析------比如找出「枢纽笔记」(关系最多的节点)、发现「知识孤岛」(没有任何关系的笔记)。
- 增量发现:当前批量发现是全量扫描,未来可以在每次导入新对话后自动触发增量发现,只处理新增笔记。
知识图谱把 ChatCrystal 从一个「笔记搜索工具」提升为「知识网络」。当你积累了足够的笔记和关系,它就能帮你发现那些你自己都忘了的关联------这恰恰是 AI 对话知识管理最有价值的地方。