一、核心矛盾:数据库思维 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.py 的 retain_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_sizechunks. 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 定义了完整的合并批处理流程:
- Parallel recalls — one per fact (read-only; safe to parallelise)
- Union of retrieved observations across the batch (deduped by id)
- Single LLM call with all N facts + unioned observations
- Sequential action execution (writes remain serial for consistency)
- 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.py 的 delete_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.
「受害者先入队,再执行删除」——这是图谱一致性维护的关键模式。删除一条事实,不是删完就完事;要先把「引用它的其他记忆」找出来排队重连,否则它们的图谱边指向不存在的节点,后续召回会静默失败。
五、relink 机制:图谱如何自愈
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 补边)。这就是图谱的「自愈机制」:删除引发拓扑变化,后台任务补齐连接。
这个设计的哲学:图谱一致性是最终一致,不是事务一致。删除操作本身是事务性的(一个事务内完成),但「被删节点的邻居重连」是异步的——允许短暂的不一致,后台收敛。
六、架构结论:为什么「不可单删」是正确设计
把三块拼起来看:
- 写入是树:retain 建一棵 document 根树(fact + 实体 + 图谱边)
- 合并且行:consolidation 在树上派生 observation(LLM 裁决 create/update/delete)
- 删除是伐树:delete_document 递归清理整棵树,先救受害者再砍树
- 维护异步:图谱重连、consolidation 重建都是后台收敛
在这个模型里,「单条 memory」是树的中间节点——它的父(document)决定它为何存在,它的子(observation)依赖它派生,它的边(links)连接其他树。删一个中间节点 = 破坏三处一致性。API 不暴露它,不是偷懒,是把复杂度显式收敛到三个安全粒度:
| 删除粒度 | 语义 | 一致性成本 |
|---|---|---|
| document | 伐掉整棵树 | 一次级联 + 批量 relink |
| fact_type | 砍掉一类枝干(如全部英文 world) | 保留实体,observation 失效 |
| bank | 烧掉整个森林 | 一次 DELETE,无遗留 |
给你的架构启示:当你设计一个有派生数据、有图谱关系的系统时,考虑「删除粒度」的显式设计——不是「用户想删啥就提供啥」,而是「哪些粒度的一致性我能保证」。数据库的单行删除在记忆/图谱系统里是伪需求,安全的粒度才是真需求。观察 Hindsight 的取舍:它把「删」的复杂度收敛到 document(树根),把「改」的复杂度收敛到 tags(元数据),把「合」的复杂度交给异步引擎——三层各司其职,单点不背锅。
七、边界与声明
- 本文所有代码/注释来自 Hindsight 0.8.0-slim 容器内实际源码:
retain/orchestrator.py、consolidation/consolidator.py、graph_maintenance.py、memory_engine.py DELETE /memories/{id}返回 405 为 2026-08-21 真实 API 实测- 删除粒度、relink 时机、stale observation 顺序均来自源码注释与实现,非推断
- 与上一篇(实操篇)配合阅读:实操篇回答「怎么做」,本篇回答「为什么这么设计」
本文由 admin 原创,转载请注明出处。
评论
0