跳到主要内容

CORE 时序知识图谱模型 — 具体化三元组 + 双时间轴 + 失效

本章讲什么: CORE 记忆的「数据结构」。为什么一个事实在 CORE 里不是一条边,而是一个独立节点?为什么删除要靠「划掉」而不是真删?这是整个项目最值得借鉴的设计。

1. 先讲直觉:为什么普通三元组不够用

经典知识图谱里,一个事实「John 在 Google 工作」就是一条边:

(John) ──works_at──► (Google) ← 边上挂不下太多东西

问题来了:

  • 这条事实何时成立?何时失效?哪段对话说的?有多确信?——边上塞这些属性既别扭又难查。
  • 事实变了怎么办?——删边?那「以前在 Google」的历史就没了。

CORE 的答案:把「事实」本身提升为一等公民节点,叫 Statement(语句)节点。这叫具体化(reification,把关系变成节点)

2. 具体化三元组:事实是一个节点

在 CORE 里,「John 在 Google 工作」长这样:

(Episode: 「我在 Google 做后端」) ← 出处/证据
│ HAS_PROVENANCE

(Entity:John) ◄─HAS_SUBJECT─ (Statement) ─HAS_OBJECT─► (Entity:Google)

HAS_PREDICATE

(Entity: "works_at", type=Predicate)

怎么读这张图: 中间的 Statement 是事实本体,它伸出四只手——指向 subject / predicate / object 三个 Entity,外加一条 HAS_PROVENANCE 连回「说出这事的 Episode」。所有时间戳、aspect、确信度都挂在 Statement 节点上。

注意:连谓词(predicate)也是一个 Entity 节点(type="Predicate"),不是普通字符串。这样「works_at」这个关系本身也能被检索、被合并。

2.1 真实的 Cypher

落库的 Cypher 把这五个关系一次 MERGE 出来(packages/providers/src/graph/neo4j/domains/triple.ts:31-48):

MERGE (episode)-[:HAS_PROVENANCE]->(statement)
MERGE (statement)-[:HAS_SUBJECT]->(subject)
MERGE (statement)-[:HAS_PREDICATE]->(predicate)
MERGE (statement)-[:HAS_OBJECT]->(object)

saveTriple 先存 Statement 和三个 Entity 节点,再建这四条关系(triple.ts:12-63)。读回来时反向走:getTriplesForEpisode 从 Episode 沿 HAS_PROVENANCE 找 Statement,再各找 subject/predicate/object(triple.ts:65-96)。

2.2 Statement 节点上有什么

类型见 packages/types/src/graph/graph.entity.ts:255-270:

字段含义
fact人类可读的事实句子
validAt事实在现实中何时开始成立
invalidAt何时失效(null = 仍然有效)
invalidatedBy哪个 Episode 让它失效的(UUID)
aspect分类(Identity/Event/Relationship/…)
createdAt系统何时记录的(与 validAt 分开!)

3. 双时间轴(bi-temporal):两个「时间」不是一回事

CORE 严格区分两条时间线:

  • valid time(有效时间) —— 事实在现实世界里的生命周期:validAtinvalidAt
  • transaction time(记录时间) —— 系统何时知道这件事:createdAt

为什么要分开? 举例:你 6 月 30 日才告诉 AI「我去年 1 月就搬到纽约了」。

  • validAt = 2025-01(事实从去年起成立)
  • createdAt = 2026-06-30(系统今天才记录)

只有把两者分开,才能正确回答「我去年住哪」和「你什么时候知道的」这类问题。validAt 来自 episode 的 referenceTime,在织三元组时赋值(knowledgeGraph.server.ts:694)。

4. 失效而非删除:记忆怎么「演化」

这是 CORE 最重要的语义。当新事实和旧事实矛盾(「John 从 Google 跳槽去了 Meta」),CORE 不删旧语句,而是:

旧:(John works_at Google) validAt=2024 invalidAt=null ──┐
│ 新 episode 说「John 现在在 Meta」

旧:(John works_at Google) validAt=2024 invalidAt=2026 invalidatedBy=<新episode>
新:(John works_at Meta) validAt=2026 invalidAt=null

旧语句被打上 invalidAt 时间戳和 invalidatedBy(谁干的),留在图里。好处:

  • 回答「John 现在在哪」→ 只看 invalidAt IS NULL 的。
  • 回答「John 以前在哪」→ 历史还在。
  • 审计「这个记忆怎么变的」→ 顺着 invalidatedBy 能追溯到具体那段对话。

实现:invalidateStatement(apps/webapp/app/services/graphModels/statement.ts:157-177)调底层 provider 的 invalidateStatement,写入 invalidatedByinvalidAt。批量版 invalidateStatements(:179-195)。

谁来触发失效? 不是摄取主线——是异步图解析在做矛盾检测后调用,见 03-graph-resolution.md

4.1 「有效」语句的查询长这样

几乎所有读取都带 invalidAt IS NULL 这个过滤。例如取某 episode 当前有效的、属于特定 aspect 的语句(statement.ts:342-363):

MATCH (e:Episode {uuid: $episodeUuid})-[:HAS_PROVENANCE]->(s:Statement)
WHERE s.invalidAt IS NULL -- 只要仍然有效的
AND s.aspect IN $aspects
RETURN s

反过来,「被某 episode 失效掉的语句」也能查(getInvalidatedStatementsForEpisode,statement.ts:375-396,WHERE s.invalidatedBy = $episodeUuid)——persona 生成会用它当「墓碑(tombstone)」信号。

5. 两类知识、两个存储(再强调)

图谱只存世界事实(可分解成 SPO 的客观事实)。用户心声(整句指令/偏好)不进图谱,进 Aspects Store(Postgres VoiceAspect 表 + 向量),类型见 graph.entity.ts:238-249。它也有 validAt/invalidAt/invalidatedBy,同样走「失效而非删除」,但不分解——因为「永远在发 PR 前跑测试」拆成三元组就废了。

一段内容
├─ 世界事实「John 在 Google 工作」 ─► Neo4j:拆成 SPO 三元组
└─ 用户心声「永远先跑测试」 ─► Aspects Store:整句存,不拆

6. 巧妙之处

  • 谓词也是实体节点(triple.ts:42)→ 关系本身可被语义检索和合并,「works_at」和「employed_by」能在图解析时归并。
  • 出处即证据链(HAS_PROVENANCE)→ 每个事实都能追回「哪段对话说的」,删 episode 能级联清理(graphModels/episode.ts:92-159)。
  • 同一物理结构服务历史与现状 → 一个 invalidAt 字段,既支持「时间旅行查询」又支持「只看当前」,不用维护两套表。

7. 代码地图

主题文件符号
具体化三元组落库(Cypher)packages/providers/src/graph/neo4j/domains/triple.tssaveTriple
读回三元组packages/providers/src/graph/neo4j/domains/triple.tsgetTriplesForEpisode / getTriplesForStatementsBatch
Statement / Triple 类型packages/types/src/graph/graph.entity.tsStatementNode / Triple
失效(单/批)apps/webapp/app/services/graphModels/statement.tsinvalidateStatement / invalidateStatements
有效语句查询apps/webapp/app/services/graphModels/statement.tsgetStatementsForEpisodeByAspects
被失效语句查询(墓碑)apps/webapp/app/services/graphModels/statement.tsgetInvalidatedStatementsForEpisode
心声类型(整句存)packages/types/src/graph/graph.entity.tsVoiceAspectNode / VOICE_ASPECTS