长期记忆与知识图谱:实体关系抽取、偏好沉淀与按需召回

本节目标

为什么向量检索搞不定「结构化关系与状态更新」?

假设用户在第 1 周告诉 Agent:「我们的主数据库用的是 MySQL 8.0」,到了第 3 周又说:「我们已经把主数据库从 MySQL 迁移到了 PostgreSQL 16」。
如果只用第 11 课的向量库把两句话都存进去,当第 4 周检索「主数据库是什么」时,向量库会把「MySQL 8.0」和「PostgreSQL 16」两条冲突的记录同时召回,导致模型产生精神分裂。

而基于实体-关系三元组(Knowledge Graph Triples)的语义记忆天然支持按主谓键(Subject + Predicate)精准更新状态与多跳关联推理(Multi-hop Reasoning):

[User: Wen] ──(维护项目)──► [Project: Study] ──(部署平台)──► [Cloudflare Pages]
                                  │
                             (存储绑定)
                                  ▼
                           [KV: GUESTBOOK]

当询问「Wen 维护的项目使用了什么存储?」时,哪怕对话里从未在同一句话里同时出现过 Wen 和 KV: GUESTBOOK,通过从实体 Wen 出发进行 2 跳广度优先遍历(2-Hop BFS),也能精准召回整条关系链。

手写支持状态覆盖与多跳 BFS 检索的知识图谱记忆引擎

export class GraphMemoryStore {
  constructor() {
    // 复合主键 "${subject}::${predicate}" -> { subject, predicate, object, updatedAt, confidence }
    this.triples = new Map()
  }

  // 写入或更新事实三元组(自动覆盖同一主语+谓语的过期事实)
  upsertTriple({ subject, predicate, object, confidence = 1.0 }) {
    const s = String(subject).trim()
    const p = String(predicate).trim()
    const o = String(object).trim()
    const key = `${s.toLowerCase()}::${p.toLowerCase()}`

    const record = {
      subject: s,
      predicate: p,
      object: o,
      confidence,
      updatedAt: new Date().toISOString()
    }
    this.triples.set(key, record)
    return record
  }

  // 批量从 LLM 抽取出的结构化事实更新图谱
  ingestFacts(facts = []) {
    return facts.map((f) => this.upsertTriple(f))
  }

  // 多跳子图检索(Multi-hop BFS):从种子实体出发,沿出边与入边向外探索 maxHops 层关联事实
  querySubgraph(seedEntities = [], { maxHops = 2, maxFacts = 20 } = {}) {
    const queue = seedEntities.map((e) => ({ entity: String(e).toLowerCase(), hop: 0 }))
    const visitedEntities = new Set(queue.map((q) => q.entity))
    const matchedTriples = new Map()

    while (queue.length > 0 && matchedTriples.size < maxFacts) {
      const { entity, hop } = queue.shift()
      if (hop >= maxHops) continue

      for (const [key, t] of this.triples.entries()) {
        const sLow = t.subject.toLowerCase()
        const oLow = t.object.toLowerCase()

        if (sLow.includes(entity) || oLow.includes(entity)) {
          matchedTriples.set(key, t)
          const neighbor = sLow.includes(entity) ? oLow : sLow
          if (!visitedEntities.has(neighbor)) {
            visitedEntities.add(neighbor)
            queue.push({ entity: neighbor, hop: hop + 1 })
          }
        }
      }
    }

    return [...matchedTriples.values()]
  }

  // 将召回的子图格式化为紧凑的自然语言上下文块注入 System Prompt
  formatForPrompt(seedEntities, opts) {
    const facts = this.querySubgraph(seedEntities, opts)
    if (facts.length === 0) return ''
    const lines = facts.map((f) => `- (${f.subject}) --[${f.predicate}]--> (${f.object})`)
    return `【长期知识图谱召回事实】\n${lines.join('\n')}`
  }
}

课后练习

练习 1:为什么在 GraphMemoryStore 中,我们将 Map 的主键设计为 ${subject}::${predicate}(主语 + 谓语),而不是 ${subject}::${predicate}::${object}?

答案:对于具有唯一状态的事实属性(例如 (项目 Study, 部署分支, main) 或 (用户, 偏好的包管理器, pnpm)),当状态发生变更时,以 subject::predicate 作为主键能自动用新宾语(object)原地覆盖旧值,从根源上消除新旧记忆并存导致的上下文矛盾。

练习 2:相比把整个知识图谱的几千条三元组全量塞进 System Prompt,为什么要用 querySubgraph(seedEntities, { maxHops: 2 }) 做局部子图召回?

答案:全量塞入不仅浪费大量 Token,还会引入与当前问题毫不相干的噪声实体干扰模型注意力;以当前用户问题中的核心实体为种子做 2 跳 BFS 扩展,能以几十个 Token 的极小代价精准提取出直接相关与间接关联的事实链。