用双时间轴防止知识图谱旧事实覆盖新状态
文章讨论 LLM 提取事实时,历史回填和乱序批次导致旧状态覆盖新状态的问题。通过区分事实的现实有效时间与系统观测时间,分析仅按到达顺序更新当前状态的缺陷。
AI 深度解读
LLM 从消息里提取事实时,先收到的未必更早发生。历史补录、重新提取、批次乱序,都可能让旧信息覆盖新状态。作者在一个个人知识图谱中采用的办法是:先比较事实生效的时间,迟到的旧事实归入历史,不去替换当前状态。
这篇值得读的地方,是它没有停在一次时间比较上。历史记录怎么存、截止日期为什么要例外、重复事实为什么仍要参与处理,以及修复工具如何把错误带回来,才是这套设计真正需要守住的边界。
当前状态该看发生时间,还是入库时间
作者把事实表示为带类型的三元组,例如「Alex works_at Acme」,再分成三类:持续成立的 state、某个时点发生的 event,以及偏好或习惯 preference。
每条记录同时携带两套时钟:
- 世界时间
valid_at / invalid_at,表示事实在现实中何时成立、何时结束。 - 系统时间
observed_at / expired_at,表示系统何时获知,以及何时停止将其视为当前记录。
作者还规定:同一主体和谓词,最多只能有一条开放状态记录。所谓开放,就是没有 invalid_at、expired_at 和 superseded_by。
问题出在按到达顺序更新。系统已经知道 Alex 从 3 月起在 Acme 工作,随后补录到他从 1 月起在 Initech 工作。若直接关闭旧记录、插入新记录,Initech 就会被误当成当前雇主,Acme 的 invalid_at 还会被写成 1 月,出现结束早于开始的错误。
事实晚到,不代表它描述的状态更新。
迟到事实归档后,还得退出后续处理
作者在关闭当前状态前增加了判断:如果新事实描述的时间更早,就将它存成历史,结束时间设为当前状态的开始时间。
下面这段代码解决的,就是旧事实覆盖新状态的问题:
if (
!hasDeadlineValidAt(predicate) && // see below
isClosableCurrentState(openFact) &&
incoming.validAt < openFact.validAt // the incoming fact describes an earlier world
) {
insert({
...incoming,
invalid_at: openFact.valid_at, // true until the newer state began
superseded_by: openFact.id,
// expired_at stays null: nothing was retracted, it just isn't the latest
})
return // and it must NOT be used to close anything else
}
要看紧最后一行 return:归档完成后必须退出,不能继续让这条历史事实关闭其他状态。
作者的系统还有第二轮处理,例如收到「入职流程完成」事件后,关闭「入职待完成」状态。迟到的历史事实并不描述当下,不能进入这轮处理。
另外两处也要配合。历史行必须带上 invalid_at,不能占据开放记录的位置,否则下一次查询当前状态时可能拿错记录。它也要在矛盾审查工具中标为已处理,因为他们的恢复操作会清空 invalid_at 和 superseded_by,可能重新制造第二条开放状态。
这里保留 expired_at 为空也有明确含义:这条历史事实没有被撤回,只是已经不是最新状态。
截止日期和重复事实,为什么不能一刀切
时间字段同名,语义未必相同。
某些谓词里的 valid_at 表示截止日期,例如 has_pending_reminder 对应周五到期的提醒。把截止日期提前,是一次正常更新。如果机械地把较早时间当成旧事实,原来的截止日期反而改不动。
所以作者先用 hasDeadlineValidAt(predicate) 排除这类谓词,再执行世界时间比较。这个例外提醒我们:比较时间之前,要先确定字段到底在表达什么。
重复事实则是另一条路径。完全相同的三元组不再插入,但仍然进入关闭状态的处理。重复观察到「入职流程完成」,应该有机会补上第一次遗漏的「关闭入职待完成」。
这和历史事实退出处理并不冲突:历史分支处理的是时间上更早的状态,去重分支处理的是已经存过的观察。
不重复写入,不等于不再执行后续处理。
我的判断
这套方案最有价值的地方,是把入库、查询、状态关闭和人工修复放在同一个约束下考虑。只修写入逻辑,恢复工具仍可能把数据改坏。
但它有两个明显前提。第一,同一主体和谓词最多存在一个当前值,适合原文中的状态建模;如果业务允许多个值同时成立,就不能直接照搬。第二,提取出来的 valid_at 足够可信。原文没有说明日期缺失、时间相等或提取错误时怎么处理。
作者描述了他们团队实现中的几次弯路,代码也明确是实现的简化版,但没有给出测试结果、错误率或性能对照。因此,这是有具体实现背景的工程经验,不能据此断言它优于所有方案。
与直接覆盖相比,保留历史更容易解释当前状态从何而来;若采用保留全部事实、查询时再推导当前值的做法,则需要另行设计查询规则。原文没有比较这两种方案,也没有交代并发写入如何保证最多一条开放记录。
能拿来做什么
- 在 Node 服务接入 LLM 事实提取时,分别保存事实生效时间和系统观察时间,避免用消息到达顺序决定当前状态。
- 给时间字段列出业务语义,尤其检查提醒、任务等截止日期场景,明确哪些谓词不能套用旧事实归档规则。
- 将「是否插入记录」和「是否执行状态关闭」分开判断,让重复观察仍有机会修复遗漏的状态变化。
- 检查后台的恢复、撤回和矛盾处理入口,并用原文的「3 月 Acme、后补 1 月 Initech」案例验证它们不会制造第二条开放状态。