知识图谱抽取

图谱抽取是「把一篇笔记变成节点和边」的单轮任务:先用算法级压缩缩短输入,再让模型只返回 JSON,最后做名称归一化、按名去重、按(源, 目标, 关系)去重后落库。它不做多轮对话,也没有工具调用,所以设计上刻意保持可重复、可清理、可局部修复。

笔记正文 单篇或按 ids 批量 算法级压缩 CompressAlgorithmic 图谱 / 会话模型 只返回 JSON 归一化与去重 trim + 小写 · 实体按名 · 关系按三元组 落库 GraphEntity / GraphRelation Confidence = 0.8(抽取) 出口 画布渲染:只留「至少一条关系的实体」与「两端都存在的边」 SSE:meta / entities / relations / done
抽取路径保持单轮:压缩 → 结构化输出 → 归一化去重 → 落库;出口同时服务画布与 SSE 流。

触发入口

入口行为
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 请求,且没有重排队保护。