知识图谱抽取
图谱抽取是「把一篇笔记变成节点和边」的单轮任务:先用算法级压缩缩短输入,再让模型只返回 JSON,最后做名称归一化、按名去重、按(源, 目标, 关系)去重后落库。它不做多轮对话,也没有工具调用,所以设计上刻意保持可重复、可清理、可局部修复。
触发入口
| 入口 | 行为 |
|---|---|
POST /api/graph/extract/{noteId} | 同步抽取单篇笔记,无请求体 |
POST /api/graph/extract/batch | 同步批量,请求体 { noteIds: [] },适合小批量 |
POST /api/graph/extract/batch-queue | 入后台队列,服务端对 NoteIds 去重后逐个入队;大批量走这条 |
| 后台任务类型 | GraphExtract,由后台任务处理器分发到抽取服务,可在「后台任务」页看状态与失败原因 |
| 前端入口 | 知识图谱页「从笔记抽取」,弹窗支持按笔记搜索与按笔记本分组、全选 / 取消全选 |
入队去重是双重的:同一批次内按
(Type, EntityId) 去重,数据库中已存在「排队中 / 运行中」的同目标任务也会跳过。所以反复点「抽取」不会堆出一队重复任务。提示词与输出契约
| 项 | 取值 |
|---|---|
| 返回结构 | { entities: [{ name, type, description }], relations: [{ source, target, relation, description }] } |
| 实体类型白名单 | concept / person / organization / technology / project,对外另有 custom |
| 关系类型白名单 | belong_to / related_to / depends_on / contains / compared_with,对外另有 custom |
| 别名归一化 | org → organization,tech → technology |
| 缺省值 | 未识别的实体类型落 concept,未识别的关系类型落 related_to |
| 解析容错 | 先剥 ```json 代码围栏,再截取最外层 {...} / [...];仍失败则返回 null 并向上报 graph.parseFailed |
模型选择顺序是「图谱用途模型」→ 回落「会话用途模型」;两者都不可用时直接返回 graph.modelUnavailable,不做任何落库。抽取调用的用量记在来源 Graph 下,含输入 token、压缩后 token、延迟与内容预览(笔记标题)。
去重、合并与清理
| 对象 | 规则 |
|---|---|
| 实体去重 | 名做 Trim() + 小写比较;同名已存在则复用,不新建;同一轮抽取内再用 createdEntities 二次去重 |
| 关系去重 | 按 (SourceEntityId, TargetEntityId, 归一化后的关系类型) 判重,已存在则跳过 |
| 置信度 | 抽取得到的关系固定 0.8;手工创建的关系为 1.0 |
| 按笔记清理 | CleanUpByNoteIdAsync 软删该笔记产生的关系,并删掉因此变成孤立的实体,同时顺带清理历史遗留孤立实体 |
| 按笔记恢复 | RestoreByNoteIdAsync 取消该笔记关系上的软删标记(笔记重新索引后可复原) |
| 实体合并 | POST /api/graph/merge:把被合并实体的关系改指向保留实体,出现自环(源 = 目标)则删除该边,最后删除被合并实体 |
渲染侧还有一层过滤:只返回至少有一条关系的实体,以及源和目标都还存在的关系。孤立节点被视为脏数据不展示,这也是「抽取成功但画布没变化」的常见原因——模型没产出任何边。
手工维护接口
POST /api/graph/entities·PUT /api/graph/entities/{id}·DELETE /api/graph/entities/{id}POST /api/graph/relations·DELETE /api/graph/relations/{id}POST /api/graph/merge· 合并重复实体
与压缩管道的关系
抽取前调用的是 CompressAlgorithmicAsync,也就是只跑算法节点、不调用 LLM 摘要。原因很直接:这是单轮任务,图谱抽取本身要花一次模型调用,再叠一层 LLM 压缩不划算,且摘要会丢实体名。压缩管道默认 Enabled = false、Mode = algorithmic、LlmThreshold = 500,节点集合与顺序见压缩管道。
所以「抽取结果变差」时要先看两件事:日志里压缩前后长度(压缩过度会砍掉长尾实体),以及模型是否被降级到能力较弱的会话模型。
失败与降级
| 情况 | 行为 |
|---|---|
| 没有可用模型 | 返回 graph.modelUnavailable,不写库 |
| 模型输出无法解析成 JSON | 返回 graph.parseFailed,不写库 |
| 模型只返回实体、没有关系 | 落库成功,但画布为空(孤立实体被过滤) |
| 后台任务被服务停止打断 | 任务重置为排队态并重新入队,不算失败 |
| 后台任务被主动取消 | 状态置为失败,错误信息「任务被取消」 |
观测
- 用量来源
Graph:输入 token、压缩后 token、延迟、内容预览(笔记标题)。 - SSE 流按
meta/entities/relations/done顺序推送,未识别的事件类型保留给前端扩展。 - 页面状态:
selectedNoteIds、isExtracting、extractResults、extractQueued,按笔记逐条给出成功 / 失败。 - 图谱服务本身没有统一日志前缀;压缩环节的日志来自
[Compression]。
实作要点
- 抽取是「按笔记覆盖」而不是「累加」:改完笔记重新抽取前先清理,能让实体名收敛(用合并接口处理同义实体)。
- 实体名归一化只做 trim + 小写,不做同义词归并;别名需要人用合并接口收口,这是图谱质量的长期活。
- 抽取的关系固定
0.8置信度、不随证据强弱变化,所以不要把置信度当作质量排序依据。 - 大批量一定走
batch-queue:同步批量会占住 HTTP 请求,且没有重排队保护。