为什么 Hindsight 没有「单条删除」?——记忆系统的数据架构设计

预计阅读时间:19 分钟

一、核心矛盾:数据库思维 vs 记忆系统思维

上一篇文章的实操结论是「没有单条 memory 删除」。如果你带着数据库思维看,这是缺陷;但带着记忆系统思维看,这是架构收敛

数据库的「行」是原子的:一行就是一条记录,删一行只影响外键约束,事务保证一致性。

Hindsight 的「事实」不是原子的。一次 retain("Hindsight CRUD 实验...") 实际发生了什么?

一次 retain 调用(一条 content)
    │
    ├─→ chunk(可能切成多块)
    ├─→ LLM 抽取 → experience fact(中文事实)
    ├─→ LLM 翻译 → world fact(英文事实,双语召回)
    ├─→ 实体解析 → entities(挂到实体表)
    ├─→ 建图谱边 → memory_links(temporal/semantic/entity/causal)
    └─→ 异步合并 → observation(去重精炼后的派生事实)

一条 content 最终变成 多条 fact + 多个实体 + 多条图谱边。这里没有「一行」,只有一棵树:

documents(根)
  └── chunks(分块)
        └── memory_units(fact:experience / world)
              ├── unit_entities(fact ↔ 实体 多对多)
              ├── memory_links(fact ↔ fact 图谱边)
              └── observation(派生,source_memory_ids 追溯)

删除「单条 fact」意味着:删它挂的实体关系、删它连的图谱边、找引用它的 observation 并失效——这是三个表的联动清理,不是一条 DELETE。API 层不暴露这个粒度,正是因为实现者知道单条删除的复杂度不值得暴露。


二、写路径架构:orchestrator.py 的四个关键设计

2.1 批量入口:retain_batch 的 delta retain

orchestrator.pyretain_batch 签名有 20+ 个参数,但核心设计在 docstring 里:

Supports delta retain: when upserting a document that already has chunks, only re-processes chunks whose content has changed. Unchanged chunks keep their existing facts, entities, and links.

Delta retain(增量保留):重新写入同一 document_id 时,只有内容变化的 chunk 才重新走 LLM 抽取,没变的 chunk 保留原有 fact/实体/图谱边。这是「更新」的正确姿势——不是删了重来,是最小变更

2.2 流式 mini-batch:防 OOM 的内存边界

Instead of extracting facts from ALL chunks at once (which can OOM for 17k+ chunk documents), this splits the pre-chunked content into batches of chunk_batch_size chunks. Each mini-batch goes through the full extract → embed → Phase 1/2/3 pipeline and commits to the DB before the next batch starts, so memory is released between batches.

注释里直接写了「17k+ chunk 文档会 OOM」——所以设计成流式 mini-batch:每批 N 个 chunk 走完整管线(extract → embed → 落库),内存随批次释放。这是记忆系统写入路径的工程真相:LLM 抽取是慢路径,不能一次性加载全部内容

2.3 多 document_id 自动分组

# When contents have multiple distinct per-content document_ids and no
# batch-level document_id, group by doc_id and process each group
# independently so each document is tracked separately.
if not document_id:
    per_content_doc_ids = [item.get("document_id") for item in contents_dicts]
    unique_doc_ids = {d for d in per_content_doc_ids if d}
    if len(unique_doc_ids) > 1:
        # Group contents by document_id, preserving original order
        groups: dict[str, ...] = {}

一次批量 retain 如果带了多个 document_id,会自动按 doc 分组独立处理——每个文档的派生树互不干扰。这就是「document 是写入原子单位」的体现:树根不同,子树天然隔离。

2.4 chunk_index_offset:修复 chunk_id 碰撞(issue #1888)

without a per-document offset each sub-batch would restart chunk_index at 0, so their chunk_ids collide and later sub-batches overwrite earlier chunks — leaving only one sub-batch's worth of chunks/memories behind (issue #1888)

大文档被切成多个 sub-batch 时,如果每个 sub-batch 都从 chunk_index=0 开始,chunk_id 会碰撞、后面的覆盖前面的,最后只剩一批数据。修复是每批一个 offset。这是真实事故驱动的设计——记忆系统的写入路径在细节上全是这种防御。


三、合并引擎:consolidation 的三种动作

consolidation 是 Hindsight 架构的灵魂——它把原始事实(experience/world)合并成精炼知识(observation)。consolidator.py 的三种动作定义了合并语义:

class _CreateAction(BaseModel):
    """创建新 observation"""
    text: str
    source_fact_ids: list[str]   # 引用的 NEW FACTS 的 UUID
    reason: str = ""             # LLM 的一句话理由(诊断用)

class _UpdateAction(BaseModel):
    """更新已有 observation"""
    text: str
    observation_id: str          # 已有 observation 的 UUID
    source_fact_ids: list[str]
    reason: str = ""

class _DeleteAction(BaseModel):
    """删除已有 observation(被新事实否定/覆盖)"""
    observation_id: str
    reason: str = ""

class _ConsolidationBatchResponse(BaseModel):
    creates: list[_CreateAction] = []
    updates: list[_UpdateAction] = []
    deletes: list[_DeleteAction] = []

关键设计:observation 的增删改是 LLM 裁决的。consolidation 跑批时,把一批新事实丢给 LLM,LLM 返回三类动作——哪些该合并成新 observation(create)、哪些该更新已有 observation(update)、哪些该作废(delete)。

这带来一个重要的架构推论:observation 的生命周期不归用户管,归 LLM 管。这就是为什么 DELETE /memories/{id}/observations 是「清空派生的 observation」而不是「删 observation 本身」——observation 是派生数据,源头在源事实,用户能做的就是「让它重新合并」(重置 consolidated_at)。

3.1 去重裁决:_dedup_adjudicate

class _DedupDecision(BaseModel):
    ...  # 去重决策模型

async def _dedup_adjudicate(...):
    ...  # 裁决:这条新 fact 是否与已有 observation 重复

async def _dedup_reconcile_create(...):
    ...  # 重复 → 合并进已有

async def _dedup_reconcile_update(...):
    ...  # 重复 → 更新已有

consolidation 先做去重裁决(_dedup_adjudicate),再决定 create 还是 update(_dedup_reconcile_*)。这就是上一篇提到的「孪生兄弟」问题——语义上最像的已有 observation 必须被找出来,否则产生重复。RRF 的 interleave_fusion 正是为此服务的。

3.2 批量处理的五步流程:为什么 LLM 只有一次调用

_process_memory_batch 的 docstring 定义了完整的合并批处理流程:

  1. Parallel recalls — one per fact (read-only; safe to parallelise)
  2. Union of retrieved observations across the batch (deduped by id)
  3. Single LLM call with all N facts + unioned observations
  4. Sequential action execution (writes remain serial for consistency)
  5. Returns one result dict per memory, in the same order as memories

翻译成架构语言:

步骤 操作 关键点
每条 fact 并行召回相关 observation 只读,可并行
合并召回结果(按 id 去重) 一个 batch 的观察集合
单次 LLM 调用裁决全部动作 一次调用处理 N 条 fact,省 token
顺序执行动作(create/update/delete) 写路径必须串行,保证一致性
返回与输入同序的结果 可追踪

「并行读、串行写」是这里的核心架构:召回可以并发(只读安全),但动作执行必须串行(写冲突)。这跟数据库的读写锁哲学同源——读多写少时的最优解

每个 batch 的动作执行前还有一层安全校验:

Per-fact security: action execution validates each learning_id against the observations that were recalled specifically for that fact, so cross-tag updates cannot occur.

每条 fact 的合并动作只能作用于为它召回的 observation——防止跨标签串改。这是记忆系统的权限边界:合并不能越界。

3.3 更新动作的合并语义:LEAST/GREATEST

_execute_update_action 展示了 observation 被多来源更新时的字段合并规则:

await conn.execute(
    f"""
    UPDATE memory_units
    SET text = $1,
        embedding = $2::vector,
        source_memory_ids = $3,
        proof_count = $4,
        tags = $9,
        updated_at = now(),
        occurred_start = LEAST(occurred_start, COALESCE($6, occurred_start)),
        occurred_end = GREATEST(occurred_end, COALESCE($7, occurred_end)),
        mentioned_at = GREATEST(mentioned_at, COALESCE($8, mentioned_at))
    WHERE id = $5
    """, ...)

合并语义一目了然: - occurred_start 取最小(最早开始) - occurred_end 取最大(最晚结束) - mentioned_at 取最大(最近提及) - source_memory_ids 累加(贡献者全部记录) - proof_count = 贡献者数量(证据强度) - tags 合并(所有贡献者的标签并集)

这背后是「观察的时间跨度 = 所有源事实的并集」的领域语义——一条 observation 是多个事实的共识,它的时间边界必须覆盖全部来源。proof_count 直接喂给召回评分(上一篇的 proof_count_boost ±5%),证据越足越靠前

3.4 观察历史:从 256MB jsonb 事故到独立表

观察历史记录里藏着一个真实事故:

History lived in a single unbounded JSONB column before; an often-reinforced observation grew it until it crossed Postgres's 256MB jsonb limit and got stuck.

早期版本把 observation 的历史存在一个无上限的 JSONB 列里,经常被更新的 observation 历史越积越大,最终超过 PostgreSQL 的 256MB jsonb 上限卡死。修复:独立 observation_history 表 + 每行一条变更 + 按配置上限裁剪(observation_history_max_entries)。

这是「派生数据审计」的工程代价:要可追溯,就要存历史;历史会膨胀,就要设计存储边界。任何「无限增长的单值」都是架构债——不管是 JSONB 列还是日志文件。

3.5 并发删除保护

_execute_update_action 执行前有一道关键防线:

live_source_memory_ids = await _filter_live_source_memories(conn, bank_id, source_memory_ids)
if not live_source_memory_ids:
    logger.debug(
        f"Update skipped: all {len(source_memory_ids)} source memories for observation "
        f"{observation_id} were deleted concurrently")
    return

如果该 observation 的全部源事实都已被并发删除,更新直接跳过——不创建孤儿 observation。这是 delete 与 consolidate 并发时的安全网:删除可能先于合并完成,合并必须检查源是否还活着。异步系统的删除/合并竞态,靠「执行前校验源存活」解决——不是加锁,是校验。


四、删除架构:为什么删除必须级联

memory_engine.pydelete_document 是整个删除语义的精华。它的执行顺序有严格的架构逻辑:

async def delete_document(self, document_id, bank_id, *, request_context):
    async with conn.transaction():
        # ① 先捕获要删的 unit_ids(否则级联后就查不到了)
        unit_rows = await conn.fetch(
            "SELECT id FROM memory_units WHERE document_id = $1 ...")
        unit_ids = [str(row["id"]) for row in unit_rows]

        # ② 先入队 relink 受害者(必须在级联前!)
        if unit_ids:
            await enqueue_relink_victims(conn, bank_id, unit_ids, ops=backend.ops)

        # ③ 删文档(CASCADE 删 memory_units + links)
        deleted = await conn.fetchval(
            "DELETE FROM documents WHERE id = $1 AND bank_id = $2 RETURNING id")

        # ④ 删除后清理 stale observations(在删除之后跑!)
        if unit_ids:
            invalidated_obs = await self._delete_stale_observations_for_memories(
                conn, bank_id, unit_ids)
    ...
    # ⑤ 事务外:触发 consolidation + graph maintenance

四个步骤的顺序是架构设计的核心:

步骤 时机 为什么
捕获 unit_ids 删除前 级联删除后 join 找不到源行
enqueue_relink_victims 级联前 被删单元的「入边」(temporal/semantic)受害者必须先入队,否则行没了 join 返回空
DELETE documents 级联 CASCADE 一次删干净
stale observation sweep 删除后 能捕获删除期间并发插入的孤儿 observation

4.1 stale observation sweep 的「延迟清理」设计

_delete_stale_observations_for_memories 是个委托方法(fact_storage.delete_stale_observations_for_memories),retain 管线和 delete 管线共用同一套 SQL——同一份清理逻辑,两条路径复用。这是「单一实现」的架构约束:retain 时发现源事实更新了要清旧 observation,delete 时源事实没了也要清,逻辑必须一致,否则两处行为分叉。

为什么 sweep 必须在删除之后跑?delete_document 的注释解释:

Running the stale-observation sweep AFTER the delete ensures we also catch observations inserted concurrently by consolidation — otherwise an insert that commits between the sweep and the delete would leave an orphan referencing the just-deleted source memory.

删除是个事务,consolidation 在后台并发跑。如果先 sweep 再删,sweep 和 delete 之间 consolidation 可能插一条引用被删源的新 observation——这条就成了孤儿。先删后 sweep,任何在删除前提交的 observation 都会被扫到。这就是「删除的最终一致性」:主删除是事务性的,孤儿清理是兜底的。

4.2 事务外的两个异步触发

删除事务提交后,还有两个后台任务接力:

# ① 有 observation 被失效 → 触发重新合并
if invalidated_obs > 0:
    config = await self._config_resolver.resolve_full_config(bank_id, request_context)
    if config.enable_auto_consolidation:
        await self.submit_async_consolidation(bank_id=bank_id, request_context=request_context)

# ② 有任何 unit 被删 → 触发图谱维护(即使没有 relink 受害者,
#    被删单元的实体可能成了孤儿,需要 bank 级 sweep)
if unit_ids:
    try:
        await self.submit_async_graph_maintenance(bank_id=bank_id, request_context=request_context)
    except Exception as e:
        logger.warning(f"Failed to submit graph maintenance after document deletion for bank {bank_id}: {e}")

注意 try/except + logger.warning——删除成功不因后台任务失败而回滚。触发失败只是少了一次异步收敛,主删除已完成。这是「删除的原子范围」设计:事务内保证数据一致,事务外允许后台任务失败重试

graph_maintenance.py 的注释把这个「为什么先于级联」讲透了:

Must run inside the same transaction that deletes the units, before the cascade fires — once the rows are gone, the join that finds the victims returns nothing.

「受害者先入队,再执行删除」——这是图谱一致性维护的关键模式。删除一条事实,不是删完就完事;要先把「引用它的其他记忆」找出来排队重连,否则它们的图谱边指向不存在的节点,后续召回会静默失败。


enqueue_relink_victims 的受害者发现逻辑:

victim_rows = await conn.fetch(
    f"""
    SELECT DISTINCT from_unit_id
    FROM memory_links
    WHERE to_unit_id = ANY($1::uuid[])
      AND bank_id = $2
      AND link_type IN ('temporal', 'semantic')
    """,
    deleted_uuids, bank_id,
)

注意:只处理 temporal(时间)和 semantic(语义)边,排除 entity(实体)边——注释解释:实体边本来就要被清理,纳入重算只会加噪声。

受害者入队后,run_graph_maintenance_job + _relink_batch 在后台重新计算它们的出边(top-up 补边)。这就是图谱的「自愈机制」:删除引发拓扑变化,后台任务补齐连接

这个设计的哲学:图谱一致性是最终一致,不是事务一致。删除操作本身是事务性的(一个事务内完成),但「被删节点的邻居重连」是异步的——允许短暂的不一致,后台收敛。


六、架构结论:为什么「不可单删」是正确设计

把三块拼起来看:

  1. 写入是树:retain 建一棵 document 根树(fact + 实体 + 图谱边)
  2. 合并且行:consolidation 在树上派生 observation(LLM 裁决 create/update/delete)
  3. 删除是伐树:delete_document 递归清理整棵树,先救受害者再砍树
  4. 维护异步:图谱重连、consolidation 重建都是后台收敛

在这个模型里,「单条 memory」是树的中间节点——它的父(document)决定它为何存在,它的子(observation)依赖它派生,它的边(links)连接其他树。删一个中间节点 = 破坏三处一致性。API 不暴露它,不是偷懒,是把复杂度显式收敛到三个安全粒度:

删除粒度 语义 一致性成本
document 伐掉整棵树 一次级联 + 批量 relink
fact_type 砍掉一类枝干(如全部英文 world) 保留实体,observation 失效
bank 烧掉整个森林 一次 DELETE,无遗留

给你的架构启示:当你设计一个有派生数据、有图谱关系的系统时,考虑「删除粒度」的显式设计——不是「用户想删啥就提供啥」,而是「哪些粒度的一致性我能保证」。数据库的单行删除在记忆/图谱系统里是伪需求,安全的粒度才是真需求。观察 Hindsight 的取舍:它把「删」的复杂度收敛到 document(树根),把「改」的复杂度收敛到 tags(元数据),把「合」的复杂度交给异步引擎——三层各司其职,单点不背锅。


七、边界与声明

  • 本文所有代码/注释来自 Hindsight 0.8.0-slim 容器内实际源码:retain/orchestrator.pyconsolidation/consolidator.pygraph_maintenance.pymemory_engine.py
  • DELETE /memories/{id} 返回 405 为 2026-08-21 真实 API 实测
  • 删除粒度、relink 时机、stale observation 顺序均来自源码注释与实现,非推断
  • 与上一篇(实操篇)配合阅读:实操篇回答「怎么做」,本篇回答「为什么这么设计」

本文由 admin 原创,转载请注明出处。

相关推荐

评论

0
暂无评论,来发表第一条评论吧

发表评论

登录 后发表评论

发现更多