跳到主要内容

CORE 图解析 — 异步去重、实体合并与矛盾失效

本章讲什么: 摄取把事实快速落库后,后台怎么把图「整理干净」——合并同一个人的不同写法、删掉重复事实、让过时事实失效。这是记忆保持一致与演化的关键一步。

1. 为什么要单独一步、还异步

摄取主线(§1)追求:抽完就落库,不做全局比对。但这会留下脏数据:

  • 同一个实体被建了两遍(「John」vs「John Smith」)。
  • 同一个事实换种说法又存了一遍(「在 Google 工作」vs「受雇于 Google」)。
  • 新事实和旧事实矛盾,但旧的还挂着 invalidAt=null

全局去重要查整张图、还要调 LLM 判断,昂贵。所以丢到后台队列异步做——写入延迟低,整理慢慢来。入口 processGraphResolution(apps/webapp/app/jobs/ingest/graph-resolution.logic.ts:84-538)。

2. 八步全景

Step 0 按名去重实体(deduplicateEntitiesByName)── 廉价预清理
Step 1 实体解析:向量召回相似实体 → LLM 判是否同一个 → 记录 merge
Step 2 语句解析:结构匹配 + 语义相似 → LLM 判 重复/矛盾
Step 3 应用实体合并(mergeEntities:改引用再删源)
Step 4 删重复语句(先 moveAllProvenance 搬出处,再删)
Step 5 失效矛盾语句(invalidateStatements,写 invalidatedBy)
Step 6 清理孤儿实体(没有任何关系的)
Step 6.5 联系人同步(Person 实体投影到 Contacts)
Step 7 心声 aspect 解析(processAspectResolution)
Step 8 更新队列 + 信用额度对账

下面只细讲最核心的两块:实体解析(Step 1)语句解析(Step 2)

3. 实体解析:两段式「召回 + 判定」

问题:这次抽出的实体「John」,跟图里已有的「John Smith」是同一个人吗?

CORE 的策略是两段式——先用便宜的向量搜索召回候选,只有「确实有像的候选」才花钱调 LLM 判定resolveExtractedNodesWithMerges(graph-resolution.logic.ts:543-741):

本次实体 ──► 拿它的嵌入 ──► 向量搜相似实体(阈值 0.7,top 5)
│ │
没有相似候选 ◄─────────────────────► 有相似候选
保持原样,不调 LLM 攒起来批量喂 LLM(dedupeNodes)

LLM 只返回「是重复」的那些 → 记录 merge

怎么读: 关键在「只对有歧义的实体调 LLM」(:619-630)——绝大多数实体没相似候选,直接跳过,省钱。

LLM 的输入(:631-647)对每个待判实体附上它的相似候选列表;输出只列「这个是那个的重复」的配对(:684-706),其余默认保留。处理时先假设全保留,再只处理 LLM 标出的重复(:678-683)——这是贯穿全项目的「稀疏输出」模式,省 token 也省解析。

合并动作 mergeEntities(graphModels/entity.ts:125-132):把指向 source 的所有关系改指向 target,再删 source,幂等(source 已不存在则 no-op)。

4. 语句解析:重复 vs 矛盾

这是最精巧的一段(resolveStatementsWithDuplicates,graph-resolution.logic.ts:746-1076)。对每条新语句,要同时回答两个问题:

  • 是重复吗?(同义事实已存在)→ 删新的,把出处搬到旧的。
  • 有矛盾吗?(让某条旧事实站不住)→ 保留新的,把旧的失效。

4.1 三路召回候选

LLM 不可能跟全图比。CORE 用三种召回先缩小候选集,再喂 LLM:

召回方式抓什么例子出处
同 主语+谓语潜在矛盾(同一属性不同值)「John lives_in NYC」vs「John lives_in LA」findContradictoryStatementsBatch :802-807
同 主语+宾语(异谓词)重复或矛盾「works_at Google」vs「employed_by Google」findStatementsWithSameSubjectObjectBatch :808-813
语义相似(向量)换种说法的同义事实阈值 0.7findSimilarStatements :870-892

再加上「同会话前几条 episode 的语句」(:814-823)。三路结果合并、去重、批量取回完整数据(:896-946)。

早退优化: 若所有新语句都没召回到任何候选,直接跳过 LLM(:931-938)。

4.2 LLM 决断(稀疏输出)

候选攒齐后,resolveStatementPrompt 把「新语句们 + 它们的相似候选们」喂给 LLM,要求只返回有问题的(:1021-1022):

// 示意,非源码:LLM 返回的稀疏结果怎么被消费
for (const r of analysisResult) { // 只含「有问题」的语句
if (r.isDuplicate && r.duplicateId) {
duplicateStatements.push({ newStatementUuid: r.statementId,
existingStatementUuid: r.duplicateId }); // 删新、链旧
} else {
resolvedStatements.push(triple); // 不是重复就保留
invalidatedStatements.push(...r.contradictions); // 但它矛盾的旧语句要失效
}
}
// 没被 LLM 点名的语句 → 默认全部保留(:1027-1032)

真实实现 :1034-1059

4.3 应用:删重复要先搬出处

删重复语句不能直接删——别的 episode 可能也链到了这条「重复语句」。所以先把所有出处关系搬到要保留的那条,再批量删(:221-256):

重复语句 X(将删)◄─HAS_PROVENANCE─ Episode B, Episode C ← 别人也链着
│ moveAllProvenanceToStatement(X → 保留语句 Y)

保留语句 Y ◄─HAS_PROVENANCE─ Episode A, B, C ← 出处全搬过来
然后才 deleteStatements([X])

moveAllProvenanceToStatement(graphModels/episode.ts:334-346)。注释明确说这是为了处理「A 的解析还没跑完时,B、C 已经链到了重复语句」的竞态(graph-resolution.logic.ts:222-224)。顺序执行避免 Neo4j 死锁(:226)。

失效矛盾语句则调 invalidateStatements,带上 invalidatedBy: episodeUuid(:258-266)——这就是 §2 里「失效而非删除」的实际触发点。

5. 收尾:孤儿清理与对账

  • 孤儿实体清理(deleteOrphanedEntities,:268-279):合并后某些实体可能没有任何关系了,删掉并清其嵌入。
  • 联系人同步(:281-329):这次碰到的 Person 实体投影进 Contacts 表,非阻塞(失败不影响摄取)。
  • 信用对账(:481-513):所有 chunk 都完成后,按实际产生的语句数对账预留 credits。

6. 巧妙之处

  • 召回用便宜手段、判定才上 LLM:向量 + 结构化 Cypher 缩小候选,LLM 只做最后裁决,且只对有候选的调用。
  • 稀疏输出贯穿全程:实体去重、语句解析都让 LLM「只报异常」,默认保留——省 token、抗解析错误。
  • 删除前搬出处:保证并发摄取下不丢证据链。
  • 全程非阻塞容错:联系人同步、心声解析、webhook 失败都只 warn 不抛(:325-329:341-345)——摄取的正确性优先于附属功能。

7. 代码地图

主题文件符号
图解析编排(8 步)apps/webapp/app/jobs/ingest/graph-resolution.logic.tsprocessGraphResolution
实体解析(召回+LLM)apps/webapp/app/jobs/ingest/graph-resolution.logic.tsresolveExtractedNodesWithMerges
语句解析(重复/矛盾)apps/webapp/app/jobs/ingest/graph-resolution.logic.tsresolveStatementsWithDuplicates
矛盾召回(批量)apps/webapp/app/services/graphModels/statement.tsfindContradictoryStatementsBatch / findStatementsWithSameSubjectObjectBatch
语义相似召回apps/webapp/app/services/graphModels/statement.tsfindSimilarStatements
实体合并apps/webapp/app/services/graphModels/entity.tsmergeEntities / deduplicateEntitiesByName
搬移出处apps/webapp/app/services/graphModels/episode.tsmoveAllProvenanceToStatement