拆开 AI 记忆引擎的完整老底:Hindsight 数据架构全解

预计阅读时间:155 分钟

一、全局:一张 document 树,四条管道

1.1 先看整体架构

┌────────────────────────────────────────────────────────────────────┐
│                    HINDSIGHT 0.8.0 数据架构总览                        │
├────────────────────────────────────────────────────────────────────┤
│                                                                    │
│  WRITE PATH (retain)            READ PATH (recall)                 │
│  ┌──────────────┐               ┌──────────────┐                   │
│  │ POST /memories│               │ POST /memories│                  │
│  │              │               │  /recall      │                  │
│  └──────┬───────┘               └──────┬───────┘                   │
│         ▼                             ▼                            │
│  ┌──────────────┐              ┌──────────────────┐                │
│  │ chunk 分块    │              │ query embedding   │               │
│  │ delta 增量    │              │ (bge-m3, 1024维)  │               │
│  └──────┬───────┘              └──────┬───────────┘                │
│         ▼                             ▼                            │
│  ┌──────────────┐        ┌──────────────────────────────┐          │
│  │ LLM 事实抽取  │        │ 四通道并行检索                  │         │
│  │ experience/  │        │ ① semantic (HNSW)            │         │
│  │ world 双语    │        │ ② bm25 (tsvector/4后端)      │         │
│  └──────┬───────┘        │ ③ graph (实体扩展)            │         │
│         ▼                │ ④ temporal (时间约束)         │         │
│  ┌──────────────┐        └──────┬───────────────────────┘          │
│  │ 实体解析       │               ▼                            │
│  │ trigram 匹配  │        ┌──────────────┐                       │
│  └──────┬───────┘        │ RRF 融合       │                       │
│         ▼                │ (k=60/轮转)    │                       │
│  ┌──────────────┐        └──────┬───────┘                        │
│  │ embedding    │               ▼                                 │
│  │ 增强后编码    │        ┌──────────────┐                         │
│  └──────┬───────┘        │ Cross-Encoder │                        │
│         ▼                │ 精排          │                         │
│  ┌──────────────┐        └──────┬───────┘                        │
│  │ 写入 memory_ │               ▼                                 │
│  │ units + links│        ┌──────────────┐                         │
│  └──────┬───────┘        │ Token 截断    │                         │
│         ▼                └──────────────┘                         │
│  ┌──────────────┐                                                 │
│  │ consolidation│      MAINTENANCE PATH (async)                   │
│  │ 异步合并      │      ┌─────────────────────────┐               │
│  │ observation  │      │ graph_maintenance       │               │
│  └──────────────┘      │ relink 图谱自愈          │               │
│                        └─────────────────────────┘               │
└────────────────────────────────────────────────────────────────────┘

Hindsight 的逻辑分四条管道:

管道 触发方式 实时性 核心文件
写路径(retain) POST /memories 同步或异步 retain/orchestrator.py
读路径(recall) POST /memories/recall 同步 search/retrieval.py + sql/
合并路径(consolidation) 后台定时 / 手动触发 异步 consolidation/consolidator.py
维护路径(graph maintenance) 删除/更新后触发 异步 graph_maintenance.py

图 1-1 Hindsight 0.8.0 四管道架构总览(写/读/合/维 + 模型调用链 + 实测数据)

Hindsight 数据架构总览

1.2 核心模型:一条 content 不是一条记录

这是理解 Hindsight 全部设计的钥匙。Hindsight 的「一条记录」不是数据库的一行,而是一棵以 document 为根的派生树

documents(根:原始文本 + retain 参数)
  └── chunks(分块:content_hash 去重)
        └── memory_units(事实:experience / world / observation)
              ├── unit_entities(事实 ↔ 实体 多对多)
              ├── memory_links(事实 ↔ 事实 图谱边:temporal/semantic/entity/causal)
              ├── entity_cooccurrences(实体共现计数)
              └── observation_history(observation 变更审计)

一次 retain("Hindsight CRUD 实验...") 实际发生:

一次 retain 调用(一条 content)
    │
    ├─→ chunk(按 retain_chunk_size 切块,可能多块)
    ├─→ LLM 抽取 → experience fact(中文事实)
    ├─→ LLM 翻译 → world fact(英文事实,双语召回)
    ├─→ 实体解析 → entities(trigram 模糊匹配挂实体表)
    ├─→ 建图谱边 → memory_links(4 种边类型)
    └─→ 异步合并 → observation(去重精炼后的派生事实,带 source_memory_ids 追溯)

为什么这个模型重要? 它直接决定了 CRUD 语义:

操作 数据库思维(错误) Hindsight 实际
写入 INSERT 一行 一棵树(多表写入)
删除 DELETE WHERE id=1 不提供(405)——删 document 级联
修改 UPDATE SET col=... 只能改 tags,内容走 reprocess
查询 SELECT ... WHERE ... 四通道混合检索 + 重排

理解了「记录 = 树」,后面所有设计(为什么没有单条删除、为什么 PATCH 只支持 tags、为什么 consolidation 要异步)就都顺理成章了。

一条 content 的完整旅程(浓缩版)

把全文的写/读/合/删串成一条时间线,这是「记住 Hindsight 架构」的最小图:

你调了一次 retain("Hindsight recall 延迟 201ms")
  │
  ├─ 写:chunk → DeepSeek 抽取 → pg_trgm 实体 → bge-m3 编码
  │      → INSERT experience + world → 建 3 种图谱边
  │      → 异步 consolidate → observation(合并后)
  │
  ├─ 读(后来某天):recall("上周的 Hindsight 性能")
  │      → bge-m3 编码查询 → dateparser 解析"上周"
  │      → semantic HNSW + bm25 + graph + temporal 四路并行
  │      → RRF 融合 → MiniLM 精排 → 返回 observation(135-270ms)
  │
  ├─ 改:PATCH document tags → 传播到全部关联 facts
  │      → 旧 observation 失效 → 重新合并
  │
  └─ 删:DELETE document → 捕获 unit_ids → relink 受害者入队
         → CASCADE 删树 → stale sweep → 图谱后台自愈

这张图覆盖了本文全部 21 章的核心。读到这里,你应该能讲清楚:写路径调了哪些模型、读路径为什么 0 次 LLM、为什么没有单条删除、图谱怎么自愈。

为什么记忆系统不能是「简单 KV 存储」?

有人会问:Agent 记忆不就是「存键值对 + 检索」吗?为什么 Hindsight 要搞 9 张表、四通道、合并引擎这么复杂?

因为记忆有三个 KV 存储解决不了的需求

  1. 时间维度:「上周部署了什么?」是 Agent 高频问题。KV 只存当前值,没有事件时间、发生区间、提及时间。Hindsight 用 4 套时间戳(event_date/occurred_start/occurred_end/mentioned_at)+ 时间索引解决。

  2. 关系维度:「Hindsight 依赖 vllm,vllm 挂了」——这条记忆和「Hindsight 部署在 Jetson」之间的关联,KV 存不了。Hindsight 用实体图 + 4 种边(temporal/semantic/entity/causal)解决。

  3. 共识维度:「用户说了 5 次喜欢咖啡」——KV 会存 5 条重复,检索时 5 条都返回。Hindsight 的 consolidation 把它合并成 1 条 observation(proof_count=5),检索时返回 1 条高质量共识而非 5 条噪音。

这就是「记忆系统」和「存储系统」的分界线:存储系统回答「存了什么」,记忆系统回答「发生了什么、意味着什么、什么时候的事」。后者需要结构化理解(LLM 抽取)、关系建模(图谱)、时间建模(多时间戳)、精炼(合并引擎)——四者叠加才构成「记忆」。

1.3 70 个 API 端点的 CRUD 地图

Hindsight 0.8.0 提供 70 个 REST 端点(35 GET / 17 POST / 10 DELETE / 6 PATCH / 2 PUT),按资源组织:

操作 端点 用途 实测耗时
POST /banks/{id}/memories retain 写入(核心) sync 17.9s / async 54ms
POST /banks/{id}/files/retain 文件写入
PUT /banks/{id} 创建/更新 bank
GET /banks/{id}/memories/list 列出记忆 56ms
GET /banks/{id}/memories/{mid} 单条详情 17ms
POST /banks/{id}/memories/recall 语义召回 135-270ms
GET /banks/{id}/documents 列出文档 58ms
GET /banks/{id}/operations/{op} 异步任务状态 秒级
PATCH /banks/{id}/documents/{doc} 更新 tags(传播) 75ms
POST /banks/{id}/documents/{doc}/reprocess 原参数重新提取
PATCH /banks/{id}/config 更新配置
DELETE /banks/{id}/documents/{doc} 删文档+级联事实 32-68ms
DELETE /banks/{id}/memories?type=world 按类型删 30ms
DELETE /banks/{id}/memories 清空 bank 记忆 53ms
DELETE /banks/{id}/memories/{mid}/observations 清派生 observation 11ms
DELETE /banks/{id} 删除整个 bank
🚫 DELETE /banks/{id}/memories/{mid} 不存在!405 6ms

完整 70 端点速查表见附录 A(含 entities / mental-models / directives / webhooks / audit-logs / llm-requests 全部)。


二、存储层:真实的表结构与索引设计

2.1 PostgreSQL 里躺着什么

从运行实例 pg_stat_user_tables 拉出真实状态(2026-08-21,生产 hermes bank):

行数 用途
memory_links 1,389 图谱边(temporal/semantic/entity/causal)
async_operations 412 异步任务跟踪
unit_entities 203 事实 ↔ 实体多对多
entity_cooccurrences 122 实体共现计数(图权重)
memory_units 103 核心:事实(experience/world/observation)
llm_requests 81 LLM 调用追踪(审计)
chunks 46 分块
entities 42 命名实体
documents 10 原始文档
observation_history 9 observation 变更审计
banks 1 bank 配置

注意 memory_linksmemory_units 的 13 倍——图谱边是事实的主体结构,不是附属品。每条事实平均挂着 ~13 条边。

生产 bank 的真实数据分布(2026-08-21 实测)

生产 hermes bank 的 fact_type 分布(PG 直查):

 fact_type  | count
------------+-------
 observation |  1148
 experience  |   755
 world       |   709
(3 rows)

解读: 1. observation(1148)比 experience(755)多——合并引擎在持续工作,一条 observation 平均聚合多条源事实 2. experience/world 几乎 1:1(755 vs 709)——双语展开稳定生效 3. memory_links 的边类型分布(生产库):

 link_type | count
-----------+-------
 temporal  | 19288
 semantic  |  3477
 caused_by |    96
(3 rows)

temporal 边占 84%(19288/22861)——时间邻近是记忆关联的最主要维度。语义边 3477(15%),因果边只有 96(<1%,LLM 提取成本高所以稀少)。图谱边结构反映记忆系统的关联本质:大部分关联来自「同一时间发生」

2.2 memory_units:24 列的核心表

真实 \d memory_units 输出(完整):

        Column          |           Type           | Nullable | Default
-------------------------+--------------------------+----------+-----------
 id                      | uuid                     | not null | gen_random_uuid()
 bank_id                 | text                     | not null |
 document_id             | text                     |          |
 text                    | text                     | not null |
 embedding               | vector(1024)             |          |
 context                 | text                     |          |
 event_date              | timestamp with time zone |          |
 occurred_start          | timestamp with time zone |          |
 occurred_end            | timestamp with time zone |          |
 mentioned_at            | timestamp with time zone |          |
 fact_type               | text                     | not null | 'world'
 access_count            | integer                  | not null | 0
 metadata                | jsonb                    | not null | '{}'
 created_at              | timestamp with time zone | not null | now()
 updated_at              | timestamp with time zone | not null | now()
 chunk_id                | text                     |          |
 tags                    | character varying[]      | not null | '{}'
 proof_count             | integer                  |          | 1
 source_memory_ids       | uuid[]                   |          | ARRAY[]::uuid[]
 consolidated_at         | timestamp with time zone |          |
 observation_scopes      | jsonb                    |          |
 text_signals            | text                     |          |
 search_vector           | tsvector                 |          |
 consolidation_failed_at | timestamp with time zone |          |

这 24 列按职责分组:

分组 作用
身份 id / bank_id / document_id / chunk_id 一棵树的定位:哪个 bank、哪个文档、哪个分块
内容 text / context / text_signals 事实文本 + 上下文 + 关键词补充信号
检索 embedding / search_vector 语义向量(pgvector)+ 全文索引(tsvector)
时间 event_date / occurred_start / occurred_end / mentioned_at 四套时间戳(事件发生、合并边界、提及时间)
类型 fact_type / tags / observation_scopes experience/world/observation + 标签体系
合并 source_memory_ids / proof_count / consolidated_at / consolidation_failed_at observation 的溯源、证据强度、合并状态
审计 created_at / updated_at / access_count / metadata 生命周期

关键设计source_memory_idsuuid[] 数组——一条 observation 可以引用 N 条源事实,这是合并引擎的溯源基础;proof_count 是数组长度,直接喂给召回评分做证据强度加成。

2.3 15+ 个索引:每个通道一个武器

memory_units 表上的真实索引(pg_indexes 输出节选):

-- 语义通道:全量 + 部分 HNSW(按 fact_type 拆三个)
CREATE INDEX idx_memory_units_embedding_hnsw ON memory_units USING hnsw (embedding vector_cosine_ops);
CREATE INDEX idx_mu_emb_obsv ON memory_units USING hnsw (embedding vector_cosine_ops)
    WHERE fact_type = 'observation' AND bank_id = 'hermes';
CREATE INDEX idx_mu_emb_worl ON memory_units USING hnsw (embedding vector_cosine_ops)
    WHERE fact_type = 'world' AND bank_id = 'hermes';
CREATE INDEX idx_mu_emb_expr ON memory_units USING hnsw (embedding vector_cosine_ops)
    WHERE fact_type = 'experience' AND bank_id = 'hermes';

-- 全文通道:GIN tsvector
CREATE INDEX idx_memory_units_text_search ON memory_units USING gin (search_vector);

-- 时间通道:3 个部分索引
CREATE INDEX idx_memory_units_bank_occurred_start ON memory_units
    (bank_id, fact_type, occurred_start) WHERE occurred_start IS NOT NULL;
CREATE INDEX idx_memory_units_bank_occurred_end ON memory_units
    (bank_id, fact_type, occurred_end) WHERE occurred_end IS NOT NULL;
CREATE INDEX idx_memory_units_bank_mentioned_at ON memory_units
    (bank_id, fact_type, mentioned_at) WHERE mentioned_at IS NOT NULL;

-- 类型/归属通道
CREATE INDEX idx_memory_units_bank_fact_type ON memory_units (bank_id, fact_type);
CREATE INDEX idx_memory_units_bank_date ON memory_units (bank_id, event_date DESC);
CREATE INDEX idx_memory_units_document_id ON memory_units (document_id);
CREATE INDEX idx_memory_units_chunk_id ON memory_units (chunk_id);

-- 合并通道
CREATE INDEX idx_memory_units_consolidation_failed ON memory_units
    (bank_id, consolidation_failed_at)
    WHERE consolidation_failed_at IS NOT NULL AND fact_type = ANY (ARRAY['experience','world']);

为什么部分 HNSW 索引? 这是源码注释里明确解释过的反模式修复:如果用一个全量 HNSW + ROW_NUMBER() OVER (PARTITION BY fact_type) 做 per-type LIMIT,PostgreSQL 规划器会放弃 HNSW 走全表扫描。拆成三个部分索引后,每个 ORDER BY embedding <=> $1 LIMIT N 直接命中自己的 HNSW。

为什么 4 套时间索引? 检索时时间约束可能落在 occurred_start(事件开始)、occurred_end(事件结束)、mentioned_at(提及时间)任一维度。三套部分索引覆盖三种过滤,部分索引(WHERE NOT NULL)避免空值浪费空间。

memory_links 是记忆系统的「关系网络」:

-- 双向索引(graph_maintenance 的 victim 查询依赖它)
CREATE INDEX idx_memory_links_from_type_weight ON memory_links (from_unit_id, link_type, weight DESC);
CREATE INDEX idx_memory_links_to_type_weight ON memory_links (to_unit_id, link_type, weight DESC);

边的 4 种类型(link_type):

类型 语义 生产者
temporal 时间邻近(事件发生时间接近) retain 时自动建
semantic 语义相似(embedding 距离近) retain 时自动建
entity 共享实体(提到同一实体) retain 时自动建
causal 因果关系(LLM 识别) LLM 提取

每条边带 weight——共现次数/相似度,是图谱检索排序的依据。

2.6 chunks 表:分块的真实结构

chunks 表(真实 \d chunks 输出):

    Column    |           Type           | Nullable | Default
--------------+--------------------------+----------+---------
 chunk_id     | text                     | not null |
 document_id  | text                     | not null |
 bank_id      | text                     | not null |
 chunk_index  | integer                  | not null |
 chunk_text   | text                     | not null |
 created_at   | timestamp with time zone | not null | now()
 content_hash | text                     |          |

Indexes:
    "pk_chunks" PRIMARY KEY, btree (chunk_id)
    "idx_chunks_bank_id" btree (bank_id)
    "idx_chunks_document_id" btree (document_id)

Foreign-key constraints:
    "chunks_document_fkey" FOREIGN KEY (document_id, bank_id)
        REFERENCES documents(id, bank_id) ON DELETE CASCADE
Referenced by:
    TABLE "memory_units" CONSTRAINT "memory_units_chunk_fkey"
        FOREIGN KEY (chunk_id) REFERENCES chunks(chunk_id) ON DELETE CASCADE

关键设计: 1. 双向 CASCADEdocuments → chunks → memory_units 外键链。删 document → CASCADE 删 chunks → CASCADE 删 memory_units——一棵树的级联删除是数据库外键保证的,不是应用层手写 2. content_hash(SHA256)是 delta retain 的增量检测依据 3. chunk_index 对应 chunk_id = {bank}_{doc}_{index} 的尾号,chunk_index_offset 防碰撞(见 3.4)

2.7 多种类型(fact_type)到底怎么存

这是理解 Hindsight 存储设计的核心问题。fact_type 字段只有三个值,但每个值的「生产者、消费场景、生命周期」完全不同:

┌────────────────────────────────────────────────────────────────┐
                    fact_type 三类事实分工                         
├────────────────────────────────────────────────────────────────┤
                                                                
  experience原始经历                                         
  ├─ 生产者retain  LLM  content 抽取                        
  ├─ 内容中文原始事实 "Hindsight v0.8.0 recall 延迟 201ms"  
  ├─ 特点最接近原始输入不精炼可能重复                        
  ├─ 消费作为 consolidation 的输入源                            
  └─ 生命周期consolidated_at 标记后等待被合并                    
                                                                
  world世界知识 / 双语版                                      
  ├─ 生产者LLM 自动把 experience 翻译成英文                     
  ├─ 内容"Hindsight v0.8.0 recall latency is 201ms"           
  ├─ 特点 experience 一一对应实现双语召回                    
  ├─ 消费召回时英文查询也能命中中文记忆                          
  └─ 生命周期 experience 一起被 consolidation 处理            
                                                                
  observation观察 / 精炼知识                                  
  ├─ 生产者consolidation 引擎 LLM 合并多条源事实               
  ├─ 内容"Hindsight v0.8.0 召回性能 201ms,比 v0.7.1 快 3 倍"  
  ├─ 特点去重后的共识知识 source_memory_ids 溯源             
  ├─ 消费召回主通道召回优先 observation                     
  └─ 生命周期可被更新/删除LLM 裁决),可审计                   
                                                                
└────────────────────────────────────────────────────────────────┘

实际数据验证(写入 5 条相似事实后,consolidation 前后对比实测):

阶段 experience world observation 总量
写入后(consolidation 前) 34 7 2 43
触发 consolidate 后 60s 34 7 3 44
consolidation 完成后 34 7 3 44

observation 从 2 → 3 说明合并引擎把多条相似 experience 精炼成了新的 observation。注意 experience/world 数量不变——consolidation 不删源事实,只派生 observation(这也是为什么删除 observation 时「重置源事实的 consolidated_at 让它重新合并」,而不是直接删源)。

三种事实类型的使用场景决策

理解了 experience / world / observation 的存储差异后,实际操作中怎么选?

需求 用哪种类型 原因
存原始对话/文档 document(content 原文) 保留原始信息,可 reprocess
单条事实查询 experience(中文)+ world(英文) 双语召回
精炼知识查询 observation 去重后的共识,quality 最高
统计/审计 全部 fact_type 字段区分

关键实践召回时优先 observation,兜底 experience/world——这也是 Hindsight 的默认 recall 行为(recall_types: "observation,world,experience")。observation 是精炼知识,quality 最高;observation 没命中再降级到原始事实。

为什么同步/异步写入的 fact_type 分布不同?(前面 3.12 提过): - 同步:完整跑完 extraction + consolidation → observation 立即可见 - 异步:只完成 extraction → observation 等 consolidation 后台触发

如果你的应用立即需要 observation 级召回(比如写入后马上查询),用同步模式;如果写入后有几秒/几分钟延迟容忍,用异步(省 HTTP 等待)。

2.8 多年数据的时间维度怎么存

Hindsight 有 4 套时间戳,每套回答不同的问题:

时间戳 含义 谁写入 检索用途
event_date 用户提供的单一事件日期 retain 请求的 timestamp 字段 简单日期过滤
occurred_start 事件开始时间(合并边界 LEAST) LLM 提取 + consolidation 更新 时间范围查询起点
occurred_end 事件结束时间(合并边界 GREATEST) LLM 提取 + consolidation 更新 时间范围查询终点
mentioned_at 记忆被提及/写入的时间 retain 自动 新鲜度排序

为什么需要 start/end 两套? 因为一条 observation 是 N 条源事实的共识,它的时间跨度 = 所有源事实的并集。consolidation 更新 observation 时:

-- consolidation 更新 observation 的时间边界(真实 SQL)
UPDATE memory_units
SET 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

多年数据的存储策略:Hindsight 不按时间分区,所有时间戳都在 memory_units 行的 4 列里,靠 3 个部分索引(occurred_start / occurred_end / mentioned_at)加速。查询「2023 年发生过什么」时,dateparser 把查询解析成 [2023-01-01, 2023-12-31],然后走 occurred_start <= 2023-12-31 AND occurred_end >= 2023-01-01 的重叠区间过滤。跨年数据不需要特殊存储——就是普通行 + 时间戳索引,检索时用重叠区间语义(两个区间有交集即命中)。

2.9 每一步调用什么模型(全链路模型清单)

这是读者最关心的「哪一步用哪个模型干什么」:

阶段 模型/组件 类型 干什么 调用方式
① query 编码 bge-m3 embedding 模型(1024 维) 把查询/事实文本转成向量 vLLM 本地部署 localhost:8000
② 事实抽取 DeepSeek V4 Flash LLM(生成式) 从 chunk 提取 fact + entities + 时间 云端 API
③ 英译 DeepSeek V4 Flash LLM 把 experience 翻译成 world(双语召回) 同一次调用
④ 实体解析 pg_trgm PostgreSQL 扩展 trigram 相似度模糊匹配实体 本地 SQL
⑤ 时间解析 dateparser(规则优先) 规则引擎 把 "last week" 解析成日期范围 本地 CPU
⑤b 复杂时间 flan-t5-small(可选) 小模型 80M 规则失败时生成式解析 本地 CPU
⑥ BM25 tsvector / VectorChord / pgroonga / pg_search 全文索引 关键词检索 本地 SQL
⑦ 语义检索 pgvector HNSW 向量索引 近似近邻 本地 SQL
⑧ 图谱检索 memory_links 自连接 图遍历 实体扩展找关联事实 本地 SQL
⑨ 融合 RRF / interleave 算法 多通道排名融合 本地 Python
⑩ 精排 ms-marco-MiniLM-L-6-v2 Cross-Encoder(80MB) query+doc 联合打分 本地 CPU
⑪ 合并裁决 DeepSeek V4 Flash LLM consolidation 时判断 create/update/delete 云端 API

核心规律: - 能确定化的用规则/索引(实体匹配、时间解析、BM25、向量、图谱) - 需要语义的用最小模型(Cross-Encoder 80MB、T5 80M) - 需要理解的用大模型(事实抽取、翻译、合并裁决——DeepSeek) - LLM 不参与 SQL 生成——所有检索 SQL 是编译期写死的模板,运行时只注入参数(这是上篇《SQL 模板替代 Text2SQL》的核心结论,本篇从全链路视角再确认一次)


2.10 实体解析:trigram 模糊匹配的工程细节

实体解析是「把事实挂到实体图」的关键步骤。entity_resolver.py 实现了三种匹配策略,自动检测选择:

# entity_resolver.py 策略选择(源码)
# "trigram" 用 pg_trgm GIN 索引只拉相似候选;"full" 全量扫描
# Oracle 后端用 UTL_MATCH
# 自动检测 pg_trgm 是否可用:
has_trgm = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm')")
if not has_trgm:
    # fall back to 'full' entity lookup strategy

trigram 策略的细节_resolve_entities_batch_trigram):

# 拉低阈值避免漏匹配(10k+ 实体的 bank 上 LIKE 全表扫描会超时)
await conn.execute("SET pg_trgm.similarity_threshold = 0.15")
# 用 % 操作符走 GIN 索引
# ... 执行候选查询 ...
await conn.execute("RESET pg_trgm.similarity_threshold")

三个工程细节: 1. 阈值 0.15:pg_trgm 默认阈值 0.3 会漏掉「Hindsight」vs「hindsight」这类大小写/拼写变体,降到 0.15 提高召回 2. 会话级 SET/RESET:改了 similarity_threshold 用完必须 RESET,否则泄漏到连接池其他事务(注释明确写了这个坑) 3. 自动降级:没有 pg_trgm 扩展就 fallback 到全量扫描——不因缺扩展而失败

实体解析流程

LLM 抽取的实体名("Hindsight", "CRUD")
  │
  ├─ trigram 候选查询(% 相似度)
  │    ├─ 命中 → 复用已有 entity,mention_count++
  │    └─ 未命中 → 创建新 entity
  │
  └─ 写入 unit_entities(多对多)
       fact_id ↔ entity_id

实体解析的完整工作流(源码级)

entity_resolver.pyresolve_entities_batch 是整个实体系统的入口,完整流程:

resolve_entities_batch(entities, bank_id, conn)
  │
  ├─ 检查 pg_trgm 是否可用(自动检测)
  │    ├─ 可用 → trigram 策略(GIN 索引加速)
  │    └─ 不可用 → full 策略(全量扫描,Oracle 用 UTL_MATCH)
  │
  ├─ trigram 策略细节:
  │    SET pg_trgm.similarity_threshold = 0.15
  │    对每个实体名 → 候选查询(% 相似度,走 GIN 索引)
  │    命中 → 返回已有 entity_id
  │    未命中 → 返回 None(待创建)
  │    RESET pg_trgm.similarity_threshold   ← 防泄漏到连接池
  │
  ├─ 创建新实体(幂等)
  │    INSERT ... ON CONFLICT DO NOTHING
  │
  └─ 写入 unit_entities(多对多)
       fact_id ↔ entity_id

为什么阈值 0.15 这么低? pg_trgm 默认 0.3 会把「Hindsight」vs「hindsight」判为不相似(大小写变体),0.15 提高召回。代价是误匹配更多,但实体解析的「未命中新建」是幂等的——多建一个实体比漏挂一个实体损失小。

注意事务边界resolve_entities_only 注释明确「OUTSIDE the main write transaction」——实体解析在写事务外执行,减少长事务持有时间。这也是「写路径性能」的一个细节。

2.11 embedding 增强:为什么编码的不是原始文本

embedding_processing.pyaugment_texts_with_dates 是写路径最容易被忽略却至关重要的设计:

def augment_texts_with_dates(facts, format_date_fn) -> list[str]:
    """Augment fact texts with readable dates for better temporal matching.

    This allows queries like "camping in June" to match facts that happened in June.
    """
    for fact in facts:
        fact_date = fact.occurred_start or fact.mentioned_at
        if fact_date is not None:
            readable_date = format_date_fn(fact_date)
            if fact.occurred_end and fact.occurred_end != fact.occurred_start:
                augmented_text = f"{fact.fact_text} (happened from {readable_date} to {readable_end})"
            else:
                augmented_text = f"{fact.fact_text} (happened in {readable_date})"
        else:
            augmented_text = fact.fact_text
        if fact.entities:
            augmented_text = f"{augmented_text} [{', '.join(fact.entities)}]"
        augmented_texts.append(augmented_text)

为什么增强? 实际效果示例:

原始 fact:  "Hindsight v0.8.0 recall 延迟降低到 201ms"
增强后:      "Hindsight v0.8.0 recall 延迟降低到 201ms (happened in June 2026) [Hindsight, recall, v0.8.0]"

用户查「June 的 recall 性能」时,如果没有日期增强,原始 fact 只有时间戳没有「June」这个单词——语义匹配会漏掉。embedding 的输入 ≠ 存储的文本:DB 存原始 fact,embedding 编码增强文本。这就是「存储与索引分离」的经典实践。

2.12 图谱边创建:三种链接的工程实现

link_creation.py 实现了三种图谱边:

async def create_temporal_links_batch(conn, bank_id, unit_ids, ops=None):
    """Create temporal links between facts.
    Links facts that occurred close in time to each other."""

async def create_semantic_links_batch(conn, bank_id, unit_ids, embeddings, pre_computed_ann_links=None, ops=None):
    """Create semantic links between facts.
    Links facts that are semantically similar based on embeddings.
    When pre_computed_ann_links are provided (from Phase 1), they are used
    instead of running ANN queries inside the transaction."""

async def create_causal_links_batch(conn, bank_id, unit_ids, facts, ops=None):
    """Create causal links between facts.
    Links facts that have causal relationships (causes, enables, prevents)."""
边类型 判定逻辑 关键设计
temporal 发生时间接近 时间窗口内的事实互连
semantic embedding 距离近 Phase 1 预计算 ANN,事务内不再查
causal LLM 提取的因果关系 causes / enables / prevents
entity 共享实体 实体解析时自动挂

semantic 链接的预计算设计pre_computed_ann_links 参数——Phase 1(事务外)先跑 ANN 查询拿到候选,Phase 3(事务内)直接用结果建边。把慢查询挪出事务,缩短事务持有时间,减少锁竞争。

2.13 fact_extraction 的 prompt 结构:LLM 到底在干什么

fact_extraction.py 的 prompt 设计决定了「事实抽取」的质量。核心是 fact_type 的语义区分:

- "world": About other people, external events, general knowledge, objective facts
- "assistant": First-person actions, experiences, or observations by the speaker/author
  (e.g., "I changed X", "I discovered Y", "I debugged Z"). Also includes interactions
  with the user (requests, recommendations).

few-shot 示例(源码里的完整示例):

Input: "Alice has 5 years of Kubernetes experience and holds CKA certification.
        She's been leading the infrastructure team since March. By the way,
        she prefers dark roast coffee."

Output:
1. what="Alice has 5 years Kubernetes experience, CKA certified",
   who="Alice", entities=["Alice", "Kubernetes", "CKA"]
2. what="Alice leads the infrastructure team since March",
   who="Alice", entities=["Alice", "infrastructure team"]
3. what="Alice prefers dark roast coffee",
   who="Alice", entities=["Alice"]

注意:0.8.0 的 fact_type 是 world / experience(生产配置),源码里保留了 world / assistant 的变体(旧版本/不同模式)。生产 env 配置 HINDSIGHT_API_LLM_MODEL=deepseek-v4-flash,抽取、翻译、合并裁决都用 DeepSeek。

2.14 写路径完整时序(源码级)

把所有阶段拼起来,一次 retain 的完整时序:

T+0ms     POST /memories(HTTP 到达)
T+0ms     retain_batch 分组(按 document_id)
T+10ms    chunk 分块 + delta 比对(SHA256 content_hash)
T+50ms    LLM 抽取(DeepSeek:fact + entities + 时间 + causal)
T+2s      英译 world(DeepSeek)
T+2.5s    实体解析(pg_trgm,0.15 阈值)
T+3s      embedding 增强 + bge-m3 编码
T+3.5s    INSERT memory_units(experience + world)
T+4s      挂 unit_entities
T+5s      temporal/semantic/causal 链接创建
T+6s      提交事务(同步模式在此返回,~18s 含 LLM 全部往返)
T+25s     consolidation 后台触发(异步模式)

注:T+50ms 到 T+6s 之间的 LLM 调用是串行的多次往返(抽取→翻译→因果),这就是同步模式 17.9s 的真实构成。异步模式 HTTP 在 T+0ms 就返回 operation_id,后台走同一管线。

三、写路径:一条 content 如何变成一棵树

3.1 retain_batch:20+ 参数的编排入口

retain/orchestrator.pyretain_batch 是写路径的主入口。它把一次写入拆成四个阶段:

retain_batch(contents_dicts, ...)
  │
  ├─ Phase 0: 预处理(_pre_resolve_phase1)
  │    bank profile → narrator → contents 转换 → 按 document_id 分组
  │
  ├─ Phase 1: 分块 + 增量比对(_try_delta_retain)
  │    chunk → SHA256 content_hash → 与已有 chunk 比对 → 只处理变化的
  │
  ├─ Phase 2: 抽取 + 编码(_extract_and_embed)
  │    LLM 抽取 fact + entities + 时间 → bge-m3 编码增强文本
  │
  └─ Phase 3: 写入 + 建边(_insert_facts_and_links)
       INSERT memory_units → 实体解析挂 unit_entities → 建 memory_links

3.2 delta retain:更新不是删了重来,是最小变更

retain_batch 的 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.

重新写入同一 document_id 时,只有内容变化的 chunk 才重新走 LLM 抽取,没变的 chunk 保留原有 fact/实体/图谱边。chunk_storage.py 用 SHA256 content_hash 做增量检测:

# chunk_storage.py
chunk_id = f"{bank_id}_{document_id}_{chunk_index}"
# 例如: "hermes_7bdc5e7f-bdfc-4493-be75-1931257af76a_6"
# content_hash 用于增量更新:hash 变了才重新抽取

为什么重要? 记忆系统的写入成本大头是 LLM 抽取(一条 content 同步要 17.9s)。如果每次更新都全量重抽,成本随文档增长线性爆炸。delta retain 让「追加一段对话」只付「新增部分」的抽取成本。

delta retain 的完整实现链(从 API 到 chunk hash)

delta retain 不是单一函数,而是贯穿写路径的一条完整链路:

POST /memories(带 document_id)
  │
  ├─ _try_delta_retain(orchestrator.py)
  │    ├─ 读已有 document 的 chunks(按 document_id)
  │    ├─ 计算新内容的分块(chunk_text)
  │    ├─ 对每个 chunk 算 SHA256 content_hash
  │    ├─ 与已有 chunk 的 content_hash 比对
  │    ├─ 已存在 → 跳过(保留原 fact/实体/边)
  │    └─ 新增/变化 → 进入抽取管线
  │
  ├─ _delta_metadata_only(只更新元数据时)
  │    └─ 不重跑抽取,只更新 document 的 tags/metadata
  │
  └─ _streaming_retain_batch(大文档)
       └─ producer 内同样的 chunk_hash 比对(见 3.7)

关键:hash 比对发生在两个层面——_try_delta_retain 在「文档级」比对,_streaming_retain_batch 的 producer 在「chunk 级」二次比对。双保险确保不重复抽取。

性能意义:同一 document 重复 retain(如对话线程追加),未变化的 chunk 理论上零 LLM 成本。但实测(2026-08-21)delta 收益并不总是明显:大文本 ~5000 字首次写入后台 20s,追加新增段后第二次 25s——因为 append 拼接会改变 chunk 边界,实际只有部分 chunk 命中 content_hash 跳过。delta 的最佳收益场景是「逐条追加式」写入(如聊天消息逐条 retain),此时新增 chunk 占比小、跳过比例高。期望 delta 显著加速前先确认你的写入模式是「追加式」而非「整体重写式」。

3.4 图谱边的权重计算:时间邻近度的数学

link_utils.pycompute_temporal_links 定义了 temporal 边的权重公式:

def compute_temporal_links(new_units, candidates, time_window_hours=24):
    """Compute temporal links between new units and candidate neighbors."""
    for unit_id, unit_event_date in new_units.items():
        if unit_event_date is None:
            continue  # 无事件时间无法建 temporal 边
        # 24h 时间窗口(带 overflow 保护)
        time_lower = unit_event_date_norm - timedelta(hours=time_window_hours)
        time_upper = unit_event_date_norm + timedelta(hours=time_window_hours)
        # 窗口内候选,最多 10 个邻居
        matching_neighbors = [...][:10]
        for recent_id, recent_event_date in matching_neighbors:
            time_diff_hours = abs((unit - recent).total_seconds() / 3600)
            weight = max(0.3, 1.0 - (time_diff_hours / time_window_hours))
            links.append((unit_id, recent_id, "temporal", weight, None))
    return _cap_links_per_unit(links)

权重公式解读

weight = max(0.3, 1 - time_diff_hours / 24)

两件事同时发生(diff=0h)  → weight = 1.0
间隔 12 小时                → weight = 0.5
间隔 24 小时(窗口边界)     → weight = 0.3(下限)
间隔超过 24 小时            → 不建边

三个工程细节: 1. 时间窗口 24h 默认:事件发生 24 小时内的算「时间邻近」 2. 权重下限 0.3:即使窗口边界附近也有最小权重,不会完全断裂 3. overflow 保护datetime.min/max 兜底极端时间值,不会崩 4. 每个 unit 最多 10 个邻居:防止高密度时段边爆炸

3.5 流式 mini-batch:17k chunk 文档防 OOM

async def _streaming_retain_batch(...):
    """
    Process a large document in streaming mini-batches to bound memory usage.

    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 抽取是慢路径,不能一次性加载全部内容

3.7 流式编排的 LLM producer:内存释放的艺术

_streaming_retain_batch 内部用双协程(producer/consumer)实现流式处理。producer 的细节:

async def _llm_producer() -> None:
    async def _extract_one(global_idx, chunk_text) -> None:
        # ... 构建 RetainContent,调 _extract_and_embed(LLM + embedding)...
        await chunk_queue.put((global_idx, content, extracted, processed, chunk_meta, usage))
        # Memory: release the chunk text from the shared list now that it's
        # been extracted and queued. The queued RetainContent holds its own copy.
        all_pre_chunks[global_idx] = ""   # ← 关键:提取完立即释放内存

    for i, chunk_text in enumerate(all_pre_chunks):
        chunk_hash = chunk_storage.compute_chunk_hash(chunk_text)
        if chunk_hash in existing_chunk_hashes:
            all_pre_chunks[i] = ""        # 已提交的 chunk 也不需要保留
            skipped_total += 1
            continue
        tasks.append(asyncio.create_task(_extract_one(i, chunk_text)))

两个内存释放点: 1. 提取完的 chunk 文本立即置空(all_pre_chunks[global_idx] = "")——队列里的 RetainContent 持有自己的副本 2. delta 跳过的 chunk 也置空——已提交的不需要保留

这是 17k chunk 文档不 OOM 的完整答案:流式 mini-batch(DB 分批提交)+ 双协程(LLM 并行提取)+ 内存即时释放(文本置空)

3.8 chunk_index_offset:issue #1888 事故修复

# chunk_index_offset 的注释:
# 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。这是真实事故驱动的设计——写路径在细节上全是防御。

3.9 同步 vs 异步:17.9s vs 54ms

写路径两种模式,实测数据(2026-08-21):

模式 HTTP 返回 总耗时 返回内容 适用场景
同步单条 17.9s 17.9s usage(token 消耗) 需要立即知道结果
异步单条 54ms ~5-10s operation_id 不阻塞主流程
异步批量 10 条 54ms 25s operation_id 批量写入(推荐)

同步模式为什么 17.9s?因为要等完整管线跑完:分块 → LLM 抽取(DeepSeek,3029 input tokens)→ 实体解析 → embedding → 写入。返回的 usage 字段:

{"input_tokens": 3029, "output_tokens": 2628, "total_tokens": 5657, "cached_tokens": 0}

异步模式 54ms 返回,后台 25s 完成——通过 operations/{id} 轮询:

t+5s  status=pending
t+10s status=pending
t+20s status=pending
t+25s status=completed

关键认知:async 不等于快,等于「不阻塞」

同步写入的完整请求与响应

一次真实同步写入的完整数据(2026-08-21 实测):

请求

POST /v1/default/banks/test_crud_20260821/memories
{
  "items": [{
    "content": "Hindsight CRUD 实验:单条写入测试,验证 retain 同步模式",
    "context": "test/crud",
    "tags": ["topic:code", "stage:process"],
    "metadata": {"experiment": "crud-20260821", "step": "1-create"}
  }],
  "async": false
}

响应(17,921ms 后):

{
  "success": true,
  "bank_id": "test_crud_20260821",
  "items_count": 1,
  "async": false,
  "operation_id": null,
  "usage": {
    "input_tokens": 3029,
    "output_tokens": 2628,
    "total_tokens": 5657,
    "cached_tokens": 0
  }
}

同步 vs 异步的响应差异: - 同步:async: false + usage(token 精确统计)+ operation_id: null - 异步:async: true + usage: null + operation_id(任务跟踪)

为什么同步返回 usage? 同步模式等完整管线跑完,自然知道 token 消耗;异步模式 HTTP 先返回,usage 记在 operation 的 result_metadata 里。

3.10 批量吞吐曲线(本次实验新数据)

为了验证「批量写入效率」,我做了 1/5/10/20 条异步批量的对照实验:

批量条数 HTTP 返回 后台完成 单位条均后台耗时
1 66ms 20,052ms 20,052ms/条
5 20ms 15,039ms 3,008ms/条
10 20ms 25,064ms 2,506ms/条
20 24ms 25,073ms 1,254ms/条

结论:HTTP 层恒定 ~20-60ms 与批量大小无关;后台完成时间 15-25s 基本恒定——瓶颈是 LLM 抽取串行(一条 content 的抽取约 1.2s,但受 DeepSeek 并发和 consolidation 干扰),不是数量。20 条与 1 条的后台耗时几乎一样,这是批量写入友好的硬证据。大批量导入(数百条)建议分片(每片 10-20 条)+ 每片一个 operation_id 跟踪。

3.11 踩坑:metadata 必须 dict[str,str]

HTTP 422: Input should be a valid string
{"type": "string_type", "loc": ["body", "items", 0, "metadata", "idx"], "msg": "..."}

metadatadict[str, str]——传 int 直接 422。必须 str(i) 字符串化。

3.12 一条 content 生成几条 fact?(双语展开)

实测:10 条 content 生成 12 条 memory(1.2x)。Hindsight 0.8.0 会自动把中文事实翻译成英文(fact_type=world),实现双语召回。不要试图去重——这是设计

另外注意:同步 vs 异步生成的事实类型分布不同(同步生成 observation + world,异步批量生成 experience + world)。原因:同步模式跑完了完整 extraction + consolidation,异步模式只完成 extraction,observation 等 consolidation 阶段才生成。如果你依赖 observation 做召回,同步模式更可预期


3.13 批量写入的核心 SQL:unnest 展开

fact_storage.pyinsert_facts_batch 是写路径的性能关键——批量插入用 PostgreSQL unnest 展开数组,一次 INSERT 写多行:

async def insert_facts_batch(conn, bank_id, facts, ...):
    # Convert tags to JSON string for proper batch insertion
    # (PostgreSQL unnest doesn't handle 2D arrays well)
    ...
    # unnest (PG) vs row-by-row (Oracle) transparently

为什么 unnest? 传统逐行 INSERT 每行一次网络往返;unnest($1::uuid[], $2::text[], ...) 把 N 个并行数组展开成 N 行,一次 INSERT 完成。批量写入 20 条后台 25s 的吞吐能力(见 3.6)部分来自这里。

Oracle 对比:Oracle 方言没有 unnest,ops_oracle.py 回退到逐行 INSERT——方言抽象层在这里体现价值:上层代码不知道底层是 unnest 还是逐行。

3.14 documents 表的 upsert 语义

fact_storage.py_upsert_document_row 实现了 documents 的 upsert:

INSERT INTO documents (id, bank_id, original_text, content_hash, retain_params, tags, created_at, updated_at)
VALUES ($1, $2, ...)
ON CONFLICT (id, bank_id) DO UPDATE
-- 重新摄取文档时 delete + insert,created_at 保持首次时间,updated_at 用 NOW()

设计ON CONFLICT DO UPDATE 保留首次 created_at(文档诞生时间),更新 updated_at(最近摄取时间)。配合 handle_document_tracking 的「先删旧 memory_units 再插新的」逻辑,实现 document_id upsert 语义(附录 C 有完整说明)。


四、读路径:从 472ms 到 135-270ms

4.1 读路径全景

recall("Hindsight recall architecture")
  
  ├─ Stage 0: query embeddingbge-m3, 84ms
      query  1024 维向量
  
  ├─ Stage 1: 四通道并行检索~60-350ms
       semanticHNSW 语义 bm25关键词 graph实体扩展 temporal时间
      3  fact_type × 通道 = 多路 UNION ALL
  
  ├─ Stage 2: RRF 融合2ms
      k=60 倒数排名融合 / interleave 轮转
  
  ├─ Stage 3: Cross-Encoder 精排3ms
      MiniLM-L-6-v2 query+doc 联合打分
  
  └─ Stage 4: Token 截断4ms
       贪心选取直到 max_tokens 上限

4.2 SQL 方言抽象层:一个模板,四种全文后端

检索 SQL 不是手写死字符串,而是来自 engine/sql/base.pySQLDialect 抽象接口。Hindsight 同时支持 PostgreSQL 和 Oracle,业务代码只依赖接口不直接拼接 SQL:

能力 PostgreSQL 实现 Oracle 实现
参数绑定 $1 :1
向量距离 embedding <=> $1::vector VECTOR_DISTANCE(embedding, :1)
全文检索 tsvector / VectorChord / pgroonga / pg_search Oracle Text
批量插入 unnest($1::uuid[], $2::text[]) 循环 INSERT
咨询锁 pg_try_advisory_lock($1) DBMS_LOCK

build_semantic_arm() 的方法签名就是模板灵活性的来源——fact_type 是内联字面量而不是参数(来自受控枚举,永不来自用户输入,SQL 注入面为零):

def build_semantic_arm(self, *, table, cols, fact_type, embedding_param,
                       bank_id_param, fetch_limit, min_similarity,
                       tags_clause="", groups_clause="", extra_where=""):
    return (
        f"(SELECT {cols},"
        f"        1 - (embedding <=> {embedding_param}::vector) AS similarity,"
        f"        NULL::float AS bm25_score,"
        f"        'semantic' AS source"
        f" FROM {table}"
        f" WHERE bank_id = {bank_id_param}"
        f"   AND fact_type = '{fact_type}'"
        f"   AND embedding IS NOT NULL"
        f"   AND (1 - (embedding <=> {embedding_param}::vector)) >= {min_similarity}"
        f"   {tags_clause}{groups_clause}{extra_where}"
        f" ORDER BY embedding <=> {embedding_param}::vector"
        f" LIMIT {fetch_limit})"
    )

BM25 臂的四后端 switchbuild_bm25_arm):

后端 扩展 打分表达式 使用场景
native 内置 tsvector ts_rank_cd(search_vector, to_tsquery('english', $4)) 默认,零依赖
vchord VectorChord -(search_vector <&> to_bm25query(...)) 高精度 BM25
pgroonga PGroonga pgroonga_score(tableoid, ctid) + &@~ 中文分词友好
pg_search ParadeDB paradedb.score(id) + @@@ 大规模 BM25

每个后端都有专属陷阱(源码注释): 1. VectorChord 没有布尔门槛@@ 是 tsvector 的布尔语义,VectorChord 对所有行打分——必须显式 > min_score 过滤,否则 LIMIT 填满不相关行 2. pgroonga 必须转义:记忆文本里的 > ( 会被解析成查询语法,必须 pgroonga_query_escape 3. pg_search 需要字段限定@@@ 要求 field-qualified query,把 query 扇出到 text/context/text_signals 三个字段

4.3 为什么 semantic + BM25 合并成一条 UNION ALL

早期实现用 ROW_NUMBER() OVER (PARTITION BY fact_type) 窗口函数按类型分组取 Top N——PostgreSQL 规划器碰到窗口函数就放弃 HNSW 索引,走全表扫描。改成「每个 fact_type 一个 UNION ALL 子查询,各自 ORDER BY ... LIMIT」后,每个子查询命中自己的部分 HNSW 索引。这是源码注释里明确记录的反模式修复。

同时语义臂做 5x over-fetchLIMIT 500 而非 LIMIT 100)+ Python 侧裁剪——HNSW 是近似索引,ef_search=200 召回率有损,多拉 5 倍再精确裁剪补回损失。

4.4 那 305ms 去哪了:查询分析器的双轨设计

上篇 472ms 拆解里,temporal extraction 占 305ms(86%)——dateparser 冷启动 + 无时间约束查询的纯浪费。Hindsight 的解法是双轨查询分析器

QueryAnalyzer (抽象基类)
├── DateparserQueryAnalyzer   → 规则优先,dateparser 兜底(200+ 语言)
└── TransformerQueryAnalyzer  → 规则优先,T5 小模型兜底(80M 参数)

DateparserQueryAnalyzer 的三个关键设计

  1. load() 预热:启动期调 search_dates("today"),把正则表、时区数据的初始化成本从「第一次 recall」挪到「服务启动」:
def load(self) -> None:
    """Triggers the real initialization cost (regex tables, timezone data) at
    load time so the first actual recall doesn't pay the cold-start penalty."""
    if self._search_dates is None:
        from dateparser.search import search_dates
        self._search_dates = search_dates
        self._search_dates("today")  # 假调用触发懒加载
  1. _extract_period() 规则优先yesterday / today / last week / last month / last year / June 2024 等高频表达用正则直接算日期范围,不进 dateparser。覆盖日常查询 90%+,微秒级:
# Last week patterns(英西意法德)
if re.search(
    r"\b(last\s+week|la\s+semana\s+pasada|la\s+settimana\s+scorsa|la\s+semaine\s+derni[eè]re|letzte\s+woche)\b",
    query, re.IGNORECASE):
    start = reference_date - timedelta(days=reference_date.weekday() + 7)
    return constraint(start, start + timedelta(days=6))
  1. 防御性容错:dateparser 会崩(IndexError from locale.translate_search),任何解析 bug 降级为「无时间约束」,不拖垮检索:
try:
    results = self._search_dates(query, settings=settings)
except Exception as e:
    logger.warning("dateparser raised %s on query (treating as no temporal constraint): %s", ...)
    return QueryAnalysis(temporal_constraint=None)

还有假阳性过滤:do / may / march / will / can / sat / sun / mon... 这些被误解析成日期的常见词直接过滤。

TransformerQueryAnalyzer(规则优先 + T5 兜底)——同样先 _extract_with_rules(),规则没命中才加载模型;T5 prompt 用 few-shot 对齐输出格式:

prompt = f"""Today is {reference_date}. Extract date range or "none".

June 2024 = 2024-06-01 to 2024-06-30
yesterday = {yesterday} to {yesterday}
last Saturday = {last_saturday} to {last_saturday}
what is the weather = none
{query} ="""

注意模型选择哲学:即使用模型,也是 80M 参数的 flan-t5-small,不是让通用 LLM 生成 SQL。需要模型的地方用最小的模型解决确定性问题,需要 SQL 的地方用模板解决——这是「模板替代 Text2SQL」的完整版答案。

EXPLAIN 实测:为什么参数化 + UNION ALL 是 HNSW 正确姿势

手工写「子查询式」向量排序(把查询向量嵌在 SQL 里),PostgreSQL planner 会退化:

EXPLAIN (ANALYZE) SELECT id FROM memory_units
WHERE bank_id='hermes' AND fact_type='experience'
  AND embedding IS NOT NULL
ORDER BY embedding <=> (SELECT embedding FROM memory_units WHERE fact_type='experience' LIMIT 1)::vector
LIMIT 10;

QUERY PLAN
────────────────────────────────────────────────────────────────
 Limit  (cost=432.17..432.19 rows=10) (actual rows=10.00 loops=1)
   Buffers: shared hit=5186
   InitPlan 1
     ->  Limit  (cost=0.00..0.55 rows=1) (actual rows=1.00 loops=1)
           ->  Seq Scan on memory_units memory_units_1
                 Filter: (fact_type = 'experience')
   ->  Sort  (cost=431.61..433.50 rows=755)    全量排序
         Sort Key: ((memory_units.embedding <=> ...))
         Sort Method: top-N heapsort  Memory: 26kB
         Buffers: shared hit=5186
         ->  Bitmap Heap Scan on memory_units
               Recheck Cond: (fact_type = 'experience')
               Buffers: shared hit=5183
               ->  Bitmap Index Scan on idx_memory_units_fact_type
Planning Time: 2.343 ms
Execution Time: 8.694 ms

问题:子查询形式的 InitPlan 让 planner 无法确定 $1::vector 是常量,放弃 HNSW 索引,走 Bitmap Scan + 全量 Sort(8.7ms)。

Hindsight 的做法(参数化 + UNION ALL):

-- 参数化:$1 是预计算的 query embedding,planner 知道是常量
ORDER BY embedding <=> $1::vector   -- 直接走 HNSW,1-3ms
LIMIT 500

实测对比: | 写法 | 执行计划 | 耗时 | |------|---------|------| | 子查询内嵌向量 | Bitmap Scan + 全量 Sort | 8.7ms(数据量 755 行) | | 参数化 $1 | HNSW 索引扫描 | 1-3ms(预期,数据量大时差距指数级) |

这就是「SQL 模板 + 参数注入」的底层原因:不只是防注入,更是让 planner 拿到「向量是常量」的信息从而选对索引。数据量到万级时,全量 Sort vs HNSW 的差距是秒级 vs 毫秒级。

4.5 四通道检索与融合

retrieval.py 并行执行四条通道,asyncio.gather 并行:

  • semantic:部分 HNSW 索引,5x over-fetch
  • bm25:tsvector(或四后端之一)
  • graphLinkExpansionRetriever 实体扩展(entity/semantic/causal 三路 CTE,asyncio.wait_for 超时保护)
  • temporal:dateparser 解析时间约束 + 重叠区间过滤

RRF 融合fusion.py,k=60):score(d) = Σ 1/(60 + rank_i(d))

cap_per_source 防挤占:semantic 臂 500 条、graph 臂可能 58 条——不 cap 的话语义结果占满 reranker 候选预算,图通道发现的高价值结果被挤掉。

interleave_fusion:RRF 的「去重修正」(源码注释原文):

RRF scores a doc by the sum of its reciprocal ranks across arms, so a result that is #1 in one arm but absent/low in the others gets averaged down. That is exactly the consolidation-dedup failure mode: the near-identical existing observation (the "twin" to merge into) is semantic rank #1, yet shares no source-fact graph link and little lexical overlap, so RRF drops it below the recall budget cutoff and the LLM never sees it → creates a duplicate.

翻译:consolidation 去重时,语义上最像的「孪生兄弟」只在 semantic 通道排第 1,BM25/graph 通道都不出现——RRF 把它平均掉了,LLM 看不到它,于是产生重复记忆。轮转融合(interleave)保证每个通道的 Top 命中都有槽位:semantic #1 → bm25 #1 → graph #1 → temporal #1 → semantic #2...

4.7 九路汇总的真实数据示例

三个 fact_type(observation/world/experience)× 三种方法(semantic/bm25/graph)= 9 路并行。一次真实查询「Hindsight recall architecture」的九路结果:

semantic/observation: 198 hits, top cosine=0.67
bm25/observation:      0 hits   (纯语义查询无关键词命中)
graph/observation:    58 hits, entity score=3
semantic/world:      131 hits, top cosine=0.62
bm25/world:           33 hits
graph/world:          16 hits
semantic/experience: 164 hits, top cosine=0.59
bm25/experience:      35 hits
graph/experience:     59 hits, entity score=3
────────────────────
合计 694 raw hits

读这条数据的关键: 1. bm25/observation: 0 hits——纯语义查询没有关键词命中,但 observation 通道仍靠 semantic 找到 198 条——多通道的意义就在这里:语义查询也覆盖 2. graph 通道的 entity score=3——通过共享实体找到的关联,语义/关键词都找不到的「关系记忆」 3. 694 raw → 去重(按 node_id)→ 508 unique candidates → RRF → CE 精排 → Token 截断

为什么 graph 通道不可替代:语义查「Hindsight recall」能命中「recall 延迟 201ms」;但「Hindsight 依赖的 vllm 挂了」这种共享实体但词面完全不同的记忆,只有 graph 通道能通过「Hindsight 实体」扩展找到。这就是图谱检索的独特价值——关系不是语义,是结构

4.8 Cross-Encoder 精排 + Token 截断

Stage 3 用 ms-marco-MiniLM-L-6-v2(80MB,纯 CPU,3ms 处理 300 条候选)做 query+doc 联合打分:

Bi-EncoderStage 1):query  doc 各自独立编码  余弦
Cross-EncoderStage 3):[CLS] query [SEP] doc [SEP]  联合打分

综合评分公式(源码 reranking.py):

recency_boost      = 1 + 0.2 × (recency - 0.5)       # 新鲜度:±10%
temporal_boost     = 1 + 0.2 × (temporal - 0.5)      # 时间约束:±10%
proof_count_boost  = 1 + 0.1 × (proof_norm - 0.5)    # 证据强度:±5%
combined_score = CE_normalized × recency_boost × temporal_boost × proof_count_boost

Stage 4 按 combined_score 降序贪心选取,累计 token 触达 max_tokens(2048/4096)停止。

4.10 Token 截断的贪心算法

Stage 4 的 Token 截断是「候选 → 上下文」的最后一道闸门。算法(源码逻辑):

输入:按 combined_score 降序的候选列表
预算:max_tokens(2048/4096)

算法(贪心):
  selected = []
  used_tokens = 0
  for candidate in sorted_candidates:      # 按 combined_score 降序
      t = count_tokens(candidate.text)
      if used_tokens + t > max_tokens:
          break                             # 触顶停止
      selected.append(candidate)
      used_tokens += t
  return selected

实测:24 条选中,1974/2048 tokens 使用

为什么贪心而不是全局最优? 0-1 背包问题(最大化选中价值且不超预算)的全局最优是 NP-hard——但记忆召回场景下,按分数降序贪心已经足够好(分数单调递减,跳过一个低分选一个更低分没有意义)。贪心 + 排序 = 工程上 99% 场景的最优解

4.12 真实召回返回示例

一次 POST /memories/recall 的完整返回结构(测试数据):

{
  "id": "7a376883-50ba-409b-8db1-88df2bb290a2",
  "text": "Hindsight CRUD 实验:批量写入第 2 条,测试 async 模式吞吐与 operation 跟踪",
  "type": "experience",
  "entities": ["Hindsight", "CRUD"],
  "context": "test/crud-batch",
  "occurred_start": "2026-08-21T00:00:00+00:00",
  "occurred_end": "2026-08-21T23:59:59+00:00",
  "mentioned_at": "2026-08-21T14:32:48.838700+00:00",
  "document_id": "90bd443d-1e1d-4e90-8231-cd648da061a2",
  "metadata": {"idx": "2", "step": "4-batch", "experiment": "crud-20260821"},
  "chunk_id": "test_crud_20260821_90bd443d-1e1d-4e90-8231-cd648da061a2_2",
  "tags": ["stage:process", "topic:code"],
  "source_fact_ids": []
}

注意:返回里没有分数字段——召回结果在服务端已经 rerank 完,返回的是最终排序。source_fact_ids 对 observation 有效(溯源),对 experience/world 为空数组。

4.13 真实性能数据:从 472ms 到 135-270ms

2026-08-21 真实 API 实测(多组查询 × 3 次):

查询 第 1 次 第 2 次 第 3 次 平均
Hindsight CRUD 实验 206ms 196ms 194ms 199ms
批量写入 async 模式 150ms 142ms 143ms 145ms
test bank 增删改测试 228ms 230ms 231ms 230ms
任意不相关查询 xyzzy 237ms 237ms 271ms 248ms
无时间约束 "Hindsight 记忆引擎 存储与检索" 222ms 216ms 211ms 216ms
带约束 "last week" 138ms 134ms 133ms 135ms
带约束 "2026 年 8 月" 254ms 247ms 250ms 250ms

两个反直觉发现: 1. "last week" 约束反而更快(135ms vs 216ms)——时间过滤缩小了候选集,检索量减少带来的收益大于解析成本 2. "2026 年 8 月" 约束更慢(250ms)——中文月份解析走了 dateparser 的复杂路径,解析成本高于过滤收益

对比上篇 472ms 的分解:

上篇(首次部署快照)              本篇(当前实例)
generate_query_embedding  84ms  ┐
parallel_retrieval       352ms  ├─ 预热后 dateparser ≈ 0
  └─ temporal extraction  305ms │   规则优先拦截高频表达
rrf_merge                  2ms  ┘   语义+BM25 合并 UNION ALL
reranking                  3ms
token_filtering            4ms
─────────────────────────────────────────────
总计                     472ms      总计 135-270ms

速度提升三来源:① load() 预热把 dateparser 初始化成本挪到启动期;② _extract_period() 规则优先拦截高频表达;③ semantic+BM25 合并为单一 UNION ALL 减少连接往返。


4.14 RRF 融合的数学与工程细节

RRF(Reciprocal Rank Fusion)的完整公式:

score(d) = Σ_i 1 / (k + rank_i(d))

k = 60(源码默认值)

为什么 k=60? k 控制「排名差异带来的分数差异」: - k 越大 → 各通道排名对分数影响越小 → 更「平均」 - k 越小 → 第一名优势越明显 → 更「集中」 - 60 是实践中的常用值——给每个通道的 Top 排名足够权重,又不让第一名垄断

示例计算:一条 fact 在 semantic 排第 12、bm25 排第 18、graph 排第 70:

RRF_score = 1/(60+12) + 1/(60+18) + 1/(60+70)
          = 0.0139 + 0.0128 + 0.0077
          = 0.0344

cap_per_source 防挤占:融合前每个通道先截断——semantic 臂 500 条、graph 臂可能只有 58 条。不 cap 的话,语义结果会占满 reranker 的候选预算,图通道发现的「共享实体但低词面重叠」的记忆被挤掉。cap 是「通道公平」的保证。

interleave_fusion(轮转融合)的完整语义

取 semantic #1 → bm25 #1 → graph #1 → temporal #1
→ semantic #2 → bm25 #2 → graph #2 → temporal #2 → ...
(去重,直到全部排完)

它给 rrf_score 赋严格递减的位置值,下游按 score 排序的代码无需改动。RRF 不是银弹——当多通道检索结果里某个通道独有的高价值结果总是被平均掉时,轮转融合牺牲一点全局最优,换来了通道公平性。

4.15 Cross-Encoder 精排的实测打分过程

reranking.py 的综合评分公式(源码):

recency_boost      = 1 + 0.2 × (recency - 0.5)       # 新鲜度:±10%
temporal_boost     = 1 + 0.2 × (temporal - 0.5)      # 时间约束:±10%
proof_count_boost  = 1 + 0.1 × (proof_norm - 0.5)    # 证据强度:±5%

combined_score = CE_normalized × recency_boost × temporal_boost × proof_count_boost

实测一条 fact 的打分过程(源码注释里的完整示例):

{
  'cross_encoder_score_normalized': 0.997,  # 联合语义得分,主导权重
  'recency': 0.983,                         # 近期事实,新鲜度加成
  'temporal': 0.500,                        # 无时间约束,中性
  'rrf_score': 0.033,                       # RRF 得分已被 CE 压制
  'combined_score': 1.093                   # 最终得分
}

关键观察:RRF 得分 0.033 与 CE 得分 0.997 相差 30 倍——精排阶段 CE 是主导,RRF 只决定候选池。RRF 负责「把谁带进来」,CE 负责「进来后谁排第一」。

4.16 更多真实查询实验:标签过滤与召回质量

本次实验补充验证 tags 过滤的硬性:

实验 查询 tags 命中数 延迟
A "Hindsight 存储 检索 记忆 引擎" 44 236ms
B 同上 ["topic:code"] 44 ~240ms
C 同上 ["topic:business"](无关) 0 ~240ms

结论:tags 是硬过滤——所有数据都打 topic:code 时,B 与 A 命中相同;换成无关的 topic:business 直接 0 命中。tags 过滤发生在检索阶段(SQL WHERE),不是召回后过滤——这是高效的关键。

召回质量分析:多通道互补性验证

从九路汇总数据(4.5b)能验证多通道的互补性:

通道 独有贡献 说明
semantic 语义相似但词面不同 「recall 延迟」vs「召回性能」
bm25 精确关键词匹配 「Hindsight」字面命中
graph 共享实体但无词面重叠 「Hindsight 依赖的 vllm」
temporal 时间范围过滤 「上周的事」

关键认知:四通道不是「冗余备份」,是四种不同维度的相关性: - semantic 回答「意思像不像」 - bm25 回答「词像不像」 - graph 回答「关系近不近」 - temporal 回答「时间对不对」

单一通道的系统(如 Mem0 纯语义)会在 graph 通道独有场景漏掉——这正是 Hindsight 94.6% vs Mem0 49.0% 在组合查询上差距的来源(见 15.2 基准对比)。

验证方法:对同一个查询,分别用 tags 强制过滤各通道结果对比命中集——例如查「Hindsight 部署」: - semantic 命中「Hindsight 配置了 DeepSeek」 - bm25 命中「Hindsight 部署指南」 - graph 命中「部署 Hindsight 的 Jetson 挂了」

三个命中互不重叠,合并后才完整。

4.17 读路径的 8 个可调参数

从源码里挖出的读路径调参点:

参数 默认 影响
min_similarity 0.3 语义臂最低相似度阈值
fetch_limit 500(5x over-fetch) HNSW 拉取上限
ef_search 200 HNSW 召回质量(池连接全局设置)
k(RRF) 60 融合平滑度
bm25_min_score 0.0 VectorChord 后端过滤
max_tokens 2048/4096 Token 截断上限
recall_max_concurrent 32 并发限流
link_expansion_timeout 10s 图谱扩展超时(超时 fallback 语义+因果)

五、合并引擎:consolidation 如何把事实变成知识

5.1 三种动作:create / update / delete 全由 LLM 裁决

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 = ""

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

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

5.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——防止跨标签串改。

5.4 consolidation 的八条合并规则(prompts.py 源码)

合并引擎的行为由 consolidation/prompts.py 里的规则驱动,这些规则是理解「LLM 怎么裁决」的关键。原文翻译整理:

# 规则 原文要点
1 优先 UPDATE 而非 CREATE 新事实描述已有 observation 覆盖的同一事件/决策/模式时,UPDATE 并挂载新证据。一个 canonical observation + 多个源事实,永远好于多个各带一个源事实的兄弟
2 CREATE 是正确默认 已有列表为空、或没有覆盖同一 facet 时,CREATE。此规则防重复,不拒绝记录新知识。
3 按实体/facet 匹配,不按主题 "Sold item X" 只更新 X 的 observation;不因共享主题而更新其他实体的 observation。
4 级联到所有受影响 observation 实体 C 从分组移除时,同时更新 C 的个体 observation 和包含 C 的分组 observation。
5 保留历史 记录重大事件(sold/died/moved/changed)的 observation 永不 DELETE。只有完全重复或无意义才删。
6 禁止计算 用户说"我有 2 只狗"再说"有只狗叫 Rex",不要把数量改 3——你不知道 Rex 是否在这 2 只里。只合并陈述,不做算术/逻辑推理。

「禁止计算」是最反直觉也最正确的规则——LLM 合并事实时最容易犯的错误就是自作主张做算术。记忆系统只记录「用户说了什么」,不做「用户应该是什么」。

consolidation 系统提示词的关键段落(原文节选)

consolidation/prompts.py 的系统提示词里,除了八条规则还有这些关键指导:

① 为什么 PREFER UPDATE 是核心(原文)

One canonical observation with many source facts is always better than many siblings with one source fact each. Merge aggressively on: same named event, same diagnostic finding, same architectural decision, same recurring claim.

② MATCH BY ENTITY/FACET 的示例

"Sold item X" updates only the X observation. "Now has 5 items" updates only the count observation. Do not update observations about different entities just because they share a general topic.

③ 输出格式约束:consolidation 的 LLM 输出被严格 schema 约束(_ConsolidationBatchResponse),只允许 creates/updates/deletes 三组结构化动作——LLM 的「自由发挥」被收敛到三类动作,这是「LLM 裁决 + 确定性执行」的边界。

设计哲学:LLM 只做「判断」(哪个 observation 该合并/更新/删除),不做「操作」(SQL 由模板执行)。判断可以模糊,操作必须确定——这是所有「LLM + 数据库」系统的正确分工。

5.6 合并裁决的完整决策逻辑

新事实(experience/world)
  │
  ├─ 召回相关 observation(并行,只读)
  │
  ├─ 有匹配的已有 observation?
  │    ├─ 是 → 是同一事件/实体/facet?
  │    │     ├─ 是 → UPDATE(挂载新证据,LEAST/GREATEST 合并时间)
  │    │     └─ 否 → CREATE(新 observation)
  │    └─ 否 → CREATE
  │
  ├─ 有被新事实否定的 observation?
  │    └─ 是 → DELETE(仅当 restated identically 或 truly meaningless)
  │
  └─ 输出:_ConsolidationBatchResponse(creates/updates/deletes)

5.7 去重裁决:孪生兄弟问题

class _DedupDecision(BaseModel): ...      # 去重决策模型
async def _dedup_adjudicate(...): ...     # 裁决:新 fact 是否与已有 observation 重复
async def _dedup_reconcile_create(...): ... # 重复 → 合并进已有
async def _dedup_reconcile_update(...): ... # 重复 → 更新已有

consolidation 先做去重裁决再决定 create/update——语义上最像的已有 observation 必须被找到,否则产生重复。RRF 的 interleave_fusion 正是为此服务(见 4.5)。

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

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

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 合并(所有贡献者的标签并集)

这是「观察的时间跨度 = 所有源事实的并集」的领域语义。proof_count 直接喂给召回评分(±5%),证据越足越靠前

5.9 观察历史:从 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 列还是日志文件。

5.10 并发删除保护

_execute_update_action 执行前校验源是否还活着:

live_source_memory_ids = await _filter_live_source_memories(conn, bank_id, source_memory_ids)
if not live_source_memory_ids:
    # Update skipped: all source memories were deleted concurrently
    return

如果 observation 的全部源事实都已被并发删除,更新直接跳过——不创建孤儿 observation。异步系统的删除/合并竞态,靠「执行前校验源存活」解决——不是加锁,是校验。

5.11 实测:5 条相似事实的合并过程

本次实验(独立 test bank,写完即删):

写入 5 条高度相似事实async 批量
   等待写入完成operation completed
   记录 fact_type 分布
   POST /banks/{id}/consolidate 手动触发
   轮询 operation 状态
   60s 后复查 fact_type 分布

实测数据

阶段 experience world observation 总量 说明
写入后(consolidation 前) 34 7 2 43 observation 2 来自此前小批量
触发 consolidate 后 60s 34 7 3 44 +1 observation
consolidation 完成后 34 7 3 44 稳定

consolidate API 返回 {"operation_id": "...", "deduplicated": true}——去重确实发生了。5 条相似事实被 LLM 裁决合并,最终派生 1 条新的 observation。

关键观察:experience/world 数量不变——consolidation 不删源事实,只派生 observation。这也解释了为什么「删 observation 要重置源事实的 consolidated_at」——源还在,只是需要重新合并。


consolidation 触发与状态跟踪

手动触发合并的完整流程(真实输出):

POST /v1/default/banks/test_arch_20260821/consolidate
 200 {"operation_id": "67b7e48f-6994-4823-bae8-d93dcaceb2b0", "deduplicated": true}

operation 状态轮询(consolidation 是后台任务):

GET /v1/default/banks/test_arch_20260821/operations/67b7e48f...
{
  "status": "pending",          // pending → completed
  "operation_type": "consolidation",
  "created_at": "2026-08-21T15:00:04+00:00",
  "completed_at": null,
  "error_message": null,
  "retry_count": 0,
  "result_metadata": {}
}

判定 consolidation 是否真的工作了:对比 fact_type 分布中的 observation 数量(见 5.11 实测表:2 → 3)。如果 observation 不变,可能源事实已合并过(consolidated_at 已标记),或 consolidation 函数缺失(历史 bug)。

5.12 consolidation 的 LLM 批量细节:省 token 的艺术

_process_memory_batch 的「单次 LLM 调用处理 N 条 fact」是 token 优化的核心。源码里 consolidation 相关的可调参数:

参数 作用 说明
consolidation_llm_batch_size 每批多少条 fact 一批一次 LLM 调用
consolidation_llm_parallelism 并行批数 控制 DeepSeek 并发
consolidation_source_facts_max_tokens 源事实 token 上限 防止超长
consolidation_max_memories_per_round 每轮最多处理 防单轮过久
max_observations_per_scope 每标签组 observation 上限 到限只允许 update/delete

_process_memory_batch 里还有一个「observation 配额」逻辑:

max_obs = config.max_observations_per_scope
if max_obs > 0 and fact_tags:
    current_count = await _count_observations_for_scope(conn, bank_id, fact_tags)
    remaining = max(max_obs - current_count, 0)
    if remaining == 0:
        # 到 observation 上限,只允许 updates/deletes(不允许 creates)

设计意图:每个标签组的 observation 数量有上限——防止合并引擎无限膨胀。到限后只允许更新/删除已有 observation,不允许新建。这是「记忆精炼的预算控制」。

5.13 合并引擎的完整生命周期图

consolidation 引擎完整流程
───────────────────────────

  后台定时任务(每 5 分钟)
  │   banks_needing_consolidation() → 找有待处理 fact 的 bank
  ▼
run_consolidation_job
  │   _count_unconsolidated() → 统计未合并的 experience/world
  │
  ├─ 按 tag group 分组(_process_tag_group)
  │    同标签的事实一起处理
  │
  ├─ 分批(_process_memory_batch)
  │    ① 每条 fact 并行召回相关 observation(只读)
  │    ② union 去重
  │    ③ 单次 LLM 调用裁决 create/update/delete
  │    ④ 顺序执行动作(写串行)
  │
  ├─ 执行动作
  │    _execute_create_action → INSERT observation
  │    _execute_update_action → UPDATE + LEAST/GREATEST + 历史
  │    _execute_delete_action → DELETE observation
  │
  ├─ _append_observation_history(审计)
  │
  └─ _trigger_mental_model_refreshes(触发心智模型刷新)

5.14 consolidation 相关的运维端点

端点 用途
POST /banks/{id}/consolidate 手动触发合并(返回 operation_id)
POST /banks/{id}/consolidation/recover 修复合并状态(清 failed 标记)
GET /banks/{id}/mental-models 列出心智模型(合并产物)
GET /banks/{id}/memories/{mid}/history observation 变更历史

六、多 bank 隔离与 LLM 追踪:租户架构

6.1 bank 是什么:租户级隔离单元

Hindsight 的 bank 是租户级隔离单元——多个 AI agent 可以共用一个 PostgreSQL 实例,数据通过 bank_id 隔离。实测:一个 bank 的 test 数据不会出现在另一个 bank 的检索里。

# 所有表都有 bank_id 列,检索 SQL 强制过滤
WHERE bank_id = $2  # 每个查询都带

bank 配置项GET /banks/{id}/config 返回 20+ 配置):

配置 作用
retain_mission 指导 retain 抽取什么(英文版过滤省 token)
observations_mission 指导 consolidation 怎么合并
entity_labels 标签体系(topic/stage 等)
enable_observations observation 总开关
enable_auto_consolidation 自动合并开关
retain_chunk_size 分块大小
max_observations_per_scope 每标签组 observation 上限

6.2 LLM 调用追踪:每一次 DeepSeek 调用都可审计

llm_requests 表记录了每一次 LLM 调用(81 行,生产 bank 实测):

# memory_engine.py 的 _LLM_REQUEST_COLUMNS
"id, bank_id, operation, scope, trace_id, span_id, parent_span_id, "
"provider, model, status, started_at, ended_at, duration_ms, "
"input_tokens, output_tokens, cached_tokens, total_tokens, "
"input, output, error, llm_info, metadata"

每个 LLM 调用:provider/model/status/耗时/token 数/输入/输出/错误——完整审计。相关端点:

端点 用途
GET /banks/{id}/llm-requests LLM 调用列表
GET /banks/{id}/llm-requests/stats 调用统计
GET /banks/{id}/audit-logs 操作审计日志
GET /banks/{id}/audit-logs/stats 审计统计

运维价值:DeepSeek token 消耗可以精确核算(sync retain 实测 5657 tokens/条),LLM 失败可以按 operation/scope 排查——这是「记忆系统可观测性」的基础设施。

6.3 观察:LLM 在记忆系统的三个位置

综合全链路,DeepSeek(生成式 LLM)只在三处出现:

位置 调用 频率
写路径 事实抽取 + 英译 + 因果 每次 retain
合并路径 consolidation 裁决 create/update/delete 每批合并
反思路径 reflect 综合推理 显式调用

检索路径 0 次 LLM——所有检索 SQL 是编译期写死的模板,运行时只注入参数(query embedding、时间范围、tags)。这就是「SQL 模板替代 Text2SQL」的完整含义:LLM 负责理解与生成(写/合并),确定性算法负责检索(读)


七、反思路径:reflect 如何综合记忆

写、读、合三条管道之外,Hindsight 还有第四条核心管道:reflect(反思)。这是「从记忆中推理出结论」的能力,不是检索,是综合。

7.1 reflect 是什么

reflect 输入:
  ├─ 记忆库(bank)
  ├─ 查询(query)
  └─ 行为档案(disposition:skepticism / literalism / empathy,1-5 分)

reflect 流程:
  ├─ ① recall 检索相关记忆
  ├─ ② LLM 基于记忆 + 行为档案生成响应
  ├─ ③ 形成/强化新意见(opinion)
  └─ ④ 更新意见网络

与 recall 的区别:recall 是「找出相关记忆给你看」,reflect 是「基于记忆推理出结论」。比如 recall 返回「用户喜欢喝咖啡」,reflect 能基于多条记忆推理「用户偏好咖啡因摄入,推荐新咖啡产品」。

7.2 行为档案(disposition)

每个 bank 有 disposition 配置(GET /banks/{id}/profile):

{
  "skepticism": 3,   // 怀疑度 1-5:越高越质疑新信息
  "literalism": 3,   // 字面理解度 1-5:越高越按字面理解
  "empathy": 3       // 同理心 1-5:越高越考虑情感
}

架构意义:这是记忆系统的「人格参数」——同一个 bank 的记忆,在不同 disposition 下 reflect 出不同风格的结论。这是普通 RAG 完全没有的维度。

7.3 心智模型(mental models)

reflect 的产出之一是 mental models(心智模型)——对常见查询的结构化知识模型:

端点 用途
GET /banks/{id}/mental-models 列出心智模型
GET /banks/{id}/mental-models/{mid} 详情
GET /banks/{id}/mental-models/{mid}/history 变更历史
POST /banks/{id}/mental-models 创建
POST /banks/{id}/mental-models/{mid}/refresh 刷新
POST /banks/{id}/mental-models/{mid}/clear 清空
PATCH /banks/{id}/mental-models/{mid} 更新
DELETE /banks/{id}/mental-models/{mid} 删除

与 observation 的区别:observation 是「事实的合并」,mental model 是「对某一主题的综合理解」。observation 由 consolidation 自动生成,mental model 由 reflect 显式生成/刷新。

7.4 指令系统(directives)

directives 是银行级的可编程指令:

端点 用途
GET /banks/{id}/directives 列出
GET /banks/{id}/directives/{did} 详情
POST /banks/{id}/directives 创建
PATCH /banks/{id}/directives/{did} 更新
DELETE /banks/{id}/directives/{did} 删除

架构意义:directives 是「记忆系统的业务规则注入点」——告诉记忆系统在 retain/recall/consolidate 时遵守什么规则,不需要改代码。

7.5 webhooks:事件驱动

端点 用途
POST /banks/{id}/webhooks 创建 webhook
GET /banks/{id}/webhooks 列出
DELETE /banks/{id}/webhooks/{wid} 删除
PATCH /banks/{id}/webhooks/{wid} 更新
GET /banks/{id}/webhooks/{wid}/deliveries 投递记录

架构意义:记忆变化(新 observation、consolidation 完成)可以推送给外部系统——记忆系统从「查询式」进化为「事件驱动式」。


八、部署与运维实战:今天刚踩过的坑

8.1 vllm 崩溃风暴:RestartCount 10384 的真实事故

2026-08-21 实测现场:Hindsight 服务不可用(8888 端口拒绝连接),排查发现:

docker ps -a
NAMES         STATUS
hindsight     Up 9 seconds                ← 刚被拉起来
vllm          Exited (255) 44 hours ago   ← vllm 44 小时前挂了!

docker inspect hindsight --format 'RestartCount={{.RestartCount}}'
RestartCount=10384   ← 崩溃重启了 10384 次!

docker logs hindsight --tail 25
openai.APIConnectionError: Connection error.
ERROR:    Application startup failed. Exiting.

根因链: 1. vllm(bge-m3 embedding)44 小时前退出(ExitCode=0,被显式 stop) 2. Hindsight 启动时 init_embeddings() 连不上 localhost:8000APIConnectionError 3. 启动失败 → Docker restart=always 每 15s 重试 → 10384 次崩溃循环

关键教训:vllm 的 restart=always 不覆盖「显式 docker stop」——用户 stop 的容器不会被自动拉起。vllm 挂 → hindsight 启动依赖 → 重启风暴。

修复(正确顺序):

# ① 先启 vllm,等 health 200(约 80s)
docker start vllm
curl -s http://localhost:8000/health   # 等 200

# ② vllm 就绪后 hindsight 自动恢复(restart=always 拉起)
curl -s http://localhost:8888/health   # 等 200

# ③ 真实 recall 验证(不只是 health 200)
POST /v1/default/banks/hermes/memories/recall
# 命中 40+ 条 → 服务真正可用

8.2 资源占用全景

组件 内存 说明
vLLM(bge-m3) ~3.3GB 统一内存
Hindsight Docker ~730MB API 服务 + 内嵌 PG
合计新增 ~4GB Jetson 29GB 总内存下安全

8.3 运维检查清单

# 服务健康(health 200 ≠ 检索可用,必须 recall 验证)
curl -s http://localhost:8888/health
curl -s -X POST http://localhost:8888/v1/default/banks/hermes/memories/recall \
  -H 'Content-Type: application/json' -d '{"query":"test","limit":1}'

# 容器状态(看 RestartCount 是否暴涨)
docker inspect hindsight --format 'RestartCount={{.RestartCount}}'
docker inspect vllm --format 'RestartCount={{.RestartCount}}'

# LLM key 有效性(fact extraction 失败的常见根因)
docker inspect hindsight --format '{{json .Config.Env}}' | grep -o 'LLM_API_KEY=...'

# 磁盘(bge-m3 模型 4.3G 在 NVMe,根分区别放模型)
df -h / /app

九、改的架构:PATCH document 的传播语义

9.1 只能改 tags:一处改、全链传播

update_document 是「改」的唯一入口,只支持改 tags——不支持改 content(内容走 reprocess)。为什么?因为 tags 是元数据,改它只需要传播;content 是事实源,改它要重新抽取。

源码 memory_engine.pyupdate_document 完整语义:

async def update_document(self, document_id, bank_id, *, tags=None, request_context):
    """Update mutable fields on a document without re-processing its content.

    Tag changes propagate to all associated memory units and trigger observation
    invalidation + re-consolidation (same semantics as delete_document):
    - Observations referencing the document's memory units are deleted.
    - The document's own units and any co-source memories from other documents
      have consolidated_at reset so they are re-consolidated under the new tags.
    """
    # ① UPDATE documents SET tags(75ms)
    # ② UPDATE memory_units SET tags WHERE document_id(级联传播)
    # ③ 找到引用这些事实的 observation,删除(invalidation)
    # ④ 重置 consolidated_at → 触发重新 consolidation

实测(2026-08-21,test bank):

# PATCH document tags
r = api("PATCH", f"/banks/{BANK}/documents/{doc_id}",
        {"tags": ["topic:code", "stage:decision", "tag:patched"]})
# {'success': True}  75ms

# 验证:document 的 tags 已更新
doc = api("GET", f"/banks/{BANK}/documents/{doc_id}")
# tags=['topic:code', 'stage:decision', 'tag:patched']

# 验证:关联的所有 memory units 的 tags 也被传播
items = api("GET", f"/banks/{BANK}/memories/list?limit=50")
patched = [m for m in items["items"] if "tag:patched" in m.get("tags", [])]
# 10 条全部带上新 tag!

75ms 完成一次级联传播——document + 全部关联 memory units(10 条)同步更新。

9.2 reprocess:用原参数重新提取

r = api("POST", f"/banks/{BANK}/documents/{doc_id}/reprocess")
# 返回 {'success': True, 'operation_id': ..., 'items_count': ...}

源码逻辑:get_document() 取回 original_text + retain_params,用 update_mode=replace 重新跑 retain。适合「提取规则升级后重灌」或「当时提取有误想重跑」。

9.3 改的粒度对比

想改什么 用什么 成本
文档 tags PATCH /documents/{id} 75ms + 异步 re-consolidation
事实内容 POST /documents/{id}/reprocess 重跑 retain(秒级)
单条事实文本 ❌ 无 API(0.8.0-slim) 官方最新版有 curate,本实例无
bank 配置 PATCH /banks/{id}/config 即时

十、删的架构:为什么没有「单条删除」

10.1 边界确认:DELETE 单条是 405

r = api("DELETE", f"/banks/{BANK}/memories/{some_memory_id}")
# HTTP 405: {"detail": "Method Not Allowed"}  6ms

Hindsight 0.8.0 没有 DELETE /memories/{id} 端点。删除粒度只有三种:document(级联)、fact_type(类型)、bank(整体)。

10.2 删除为什么必须级联:五步时序

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 级联前 被删单元的入边受害者必须先入队,否则行没了 join 返回空
DELETE documents 级联 CASCADE 一次删干净
stale observation sweep 删除后 能捕获删除期间并发插入的孤儿 observation

图 10-1 删除五步时序图(T0 请求 → T1 事务内五步 → T2 提交 → T3 异步触发 → T4 图谱收敛)

Hindsight 删除时序

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

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 再删,中间 consolidation 可能插一条引用被删源的新 observation——孤儿。先删后 sweep,任何删除前提交的 observation 都会被扫到。主删除是事务性的,孤儿清理是兜底的。

10.4 事务外的两个异步触发

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

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

# ② 有任何 unit 被删 → 触发图谱维护
if unit_ids:
    try:
        await self.submit_async_graph_maintenance(bank_id=bank_id, ...)
    except Exception as e:
        logger.warning(f"Failed to submit graph maintenance ...: {e}")

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

10.5 删除粒度实测

实验 A:删 document 级联(test bank):

# 删除前 graph edges
GET /banks/{id}/graph  2038 条边

# DELETE document(5 条 memory_units)
DELETE /banks/{id}/documents/{doc_id}
# {'success': True, 'message': "Document '...' and 5 associated memory units deleted successfully"}  32ms

# 删除后 graph edges
GET /banks/{id}/graph  1809 条边-229 !)

32ms 删 5 条事实,连带清理 229 条图谱边——级联删除不是「删几行」,是「砍掉一棵树 + 修剪整片森林」。

实验 B:按类型删 world

DELETE /banks/{id}/memories?type=world
# {'success': True}  30ms
# 之后 list 中 world 全部消失,experience/observation 保留

实验 C:单条 observation 清理

DELETE /banks/{id}/memories/{mid}/observations
# {'success': True, 'deleted_count': 0}  11ms

10.6 删除粒度决策表

想删什么 用什么 连带影响
单条事实 ❌ 无 API 需删 document 或 PG 直操作
单条事实的 observation DELETE /memories/{id}/observations 保留事实,重置 consolidated_at
整个文档的所有事实 DELETE /documents/{id} 级联删 units + links + observations
某种类型的事实 DELETE /memories?type=world 只删该类型,实体保留
整个 bank DELETE /banks/{id} 全删(文档/实体/图谱/配置)

删除文档的完整执行输出

一次真实 DELETE /documents/{id} 的完整输出(test bank):

{
  "success": true,
  "message": "Document '90bd443d-1e1d-4e90-8231-cd648da061a2' and 10 associated memory units deleted successfully",
  "document_id": "90bd443d-1e1d-4e90-8231-cd648da061a2",
  "memory_units_deleted": 10
}

10 条 memory_units 被级联删除——这就是「树」的删除:一个 document 根,10 个 fact 节点,加上它们挂的实体关联、图谱边,全部在一个事务里清掉。

删除的幂等性:删不存在的 document 返回 404 Document not found——幂等靠 404 而非「删除 0 行」:

DELETE /v1/default/banks/test_bank/documents/nonexistent-id
 404 {"detail": "Document not found"}10ms)

清理的完整链路(源码级): 1. 捕获 unit_ids(10 条) 2. enqueue_relink_victims(找引用这 10 条的 temporal/semantic 边受害者) 3. DELETE FROM documents WHERE id=$1(CASCADE 删 chunks → memory_units → links) 4. _delete_stale_observations_for_memories(清引用这些事实的 observation) 5. 事务外:触发 consolidation(如有 observation 失效)+ graph maintenance(图谱自愈)

10.7 什么时候该绕过 API 直操 PostgreSQL

API 刻意不暴露单条删除,但运维场景(清重复数据、修脏标签、紧急回滚)可能需要:

  1. 首选 API:document 级联删除覆盖 95% 的「删一批」需求
  2. 次选 APIDELETE /memories?type=xxx 按类型清理
  3. 最后手段 PG 直操作DELETE FROM memory_units WHERE ... 单条级精度——必须同时清理 memory_linksunit_entitiesobservation_sources 关联行,否则图谱残留孤儿引用导致召回异常。这就是 API 不提供单条删除的原因——级联逻辑太容易漏。

操作规范:PG 直操作前先备份(pg_dump 相关表),操作后 POST /banks/{id}/consolidation/recover 修复维护状态。


11.1 图谱检索的核心 SQL:LinkExpansionRetriever

search/link_expansion_retrieval.py 是图通道的实现。它的核心是通过共享实体找到候选:

-- Entity 扩展:通过共享实体找到候选(简化版)
SELECT DISTINCT mu.*, COUNT(DISTINCT ue.entity_id) AS graph_score
FROM unit_entities ue
JOIN unit_entities seed_ue ON ue.entity_id = seed_ue.entity_id
JOIN memory_units mu ON ue.unit_id = mu.id
WHERE seed_ue.unit_id = ANY($1::uuid[])   -- 语义种子
GROUP BY mu.id
ORDER BY graph_score DESC;

三条边类型分别扩展: - entity 边:共享实体(上面的 SQL) - semantic 边:embedding 相似的邻居 - causal 边:因果链上的前后节点

每个 fact_type 一次 LinkExpansion retriever(entity/semantic/causal 3-way CTE),asyncio.wait_for 包裹 conn.fetch——超时(默认 10s)fallback 到 semantic+causal。图谱扩展有超时保护,不会因图遍历过深拖垮整个检索。

11.2 受害者先入队,再执行删除

graph_maintenance.pyenqueue_relink_victims 注释把核心设计讲透:

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.

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

受害者发现逻辑:

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 边——注释解释:实体边本来就要被清理,纳入重算只会加噪声。

11.3 图谱维护任务

受害者入队后,run_graph_maintenance_job + _relink_batch 在后台重新计算出边(top-up 补边):

graph_maintenance_queue
  └── run_graph_maintenance_job(后台)
        └── _relink_batch(分批重连)
              ├─ 重新计算受害者的出边
              ├─ 找新邻居(语义相似/时间邻近/共享实体)
              └─ UPDATE memory_links

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

11.4 删除的完整生命周期(时序图)

时间线
  │
  ├─ T0  用户 DELETE /documents/{id}
  │
  ├─ T1  事务开始
  │       ① 捕获 unit_ids
  │       ② enqueue_relink_victims(受害者入队)
  │       ③ DELETE documents(CASCADE)
  │       ④ stale observation sweep
  ├─ T2  事务提交(主删除完成,API 返回 32ms)
  │
  ├─ T3  后台:submit_async_consolidation(如果有 observation 失效)
  ├─ T4  后台:submit_async_graph_maintenance(图谱维护)
  │        └─ _relink_batch 重连受害者
  │
  └─ T5  图谱最终一致(后台收敛完成)

11.5 实测:删除后图谱确实自愈

从实验 A(7.5)的数据看: - 删除前 graph edges: 2,038 - 删除后立即查: 1,809(-229,级联清理了边) - 后台 graph maintenance 异步收敛剩余拓扑

229 条边的级联清理 + 受害者重连的异步补偿——这就是「删一棵树,修整一片森林」的完整图景。


十二、端到端代码旅程:一个脚本走完四条管道

这是全文的「可执行精华」——一个 Python 脚本,把写路径、读路径、合并路径、删除路径全部跑一遍。每一步注释标明对应的架构环节。直接复制可用(<your-server> 替换为你的 Hindsight 地址)。

"""
Hindsight 四条管道端到端演示
用法: python3 hindsight_journey.py
前置: Hindsight 0.8.0 已部署(见 11.1 环境搭建)
"""
import urllib.request, json, time
from collections import Counter

BASE = "http://<your-server>:8888"
BANK = "journey_demo"

def api(method, path, body=None, timeout=180):
    """统一 API 调用(写路径/读路径/删路径共用)"""
    url = BASE + path
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(url, data=data, method=method,
                                 headers={"Content-Type": "application/json"})
    t0 = time.time()
    with urllib.request.urlopen(req, timeout=timeout) as r:
        raw = r.read().decode()
        return json.loads(raw), (time.time() - t0) * 1000

def wait_operation(op_id, timeout_s=120):
    """合并路径/异步写路径的任务轮询"""
    for _ in range(int(timeout_s / 5)):
        time.sleep(5)
        st, _ = api("GET", f"/v1/default/banks/{BANK}/operations/{op_id}")
        if st["status"] == "completed":
            return True
        if st["status"] in ("failed", "error"):
            return False
    return False

def fact_distribution():
    """查 fact_type 分布(观察 experience/world/observation 变化)"""
    r, _ = api("GET", f"/v1/default/banks/{BANK}/memories/list?limit=500")
    items = r.get("items", [])
    return dict(Counter(m.get("fact_type", "?") for m in items)), len(items)

print("=" * 60)
print("PHASE 1: 写路径(异步批量 → operation 跟踪)")
print("=" * 60)
items = [
    {"content": f"用户在第 {i} 天部署了 Hindsight 记忆系统,配置了 DeepSeek 和 bge-m3,"
                 f"记录了召回延迟约 200ms,比旧系统快 3 倍",
     "tags": ["topic:code", "stage:process"],
     "metadata": {"journey": "write", "day": str(i)}}
    for i in range(5)
]
resp, dt = api("POST", f"/v1/default/banks/{BANK}/memories", {"items": items, "async": True})
op_id = resp.get("operation_id")
print(f"[写] HTTP {dt:.0f}ms 返回, operation={op_id[:8]}")
wait_operation(op_id)
dist_before, total_before = fact_distribution()
print(f"[写] 完成后: total={total_before}, types={dist_before}")

print()
print("=" * 60)
print("PHASE 2: 读路径(四通道检索 → RRF → CE)")
print("=" * 60)
queries = [
    ("语义", "Hindsight 部署 召回延迟"),
    ("时间", "Hindsight 部署 last week"),
    ("标签", "Hindsight 部署", ["topic:code"]),
]
for label, q, *tags in queries:
    body = {"query": q, "limit": 5}
    if tags:
        body["tags"] = tags[0]
    r, dt = api("POST", f"/v1/default/banks/{BANK}/memories/recall", body)
    hits = len(r.get("results", []))
    print(f"[读] {label}: {dt:.0f}ms, hits={hits}")

print()
print("=" * 60)
print("PHASE 3: 合并路径(手动触发 consolidation → observation 派生)")
print("=" * 60)
resp, _ = api("POST", f"/v1/default/banks/{BANK}/consolidate")
print(f"[合] 触发: {json.dumps(resp, ensure_ascii=False)[:100]}")
# consolidation 是后台任务,等它收敛
time.sleep(60)
dist_after, total_after = fact_distribution()
print(f"[合] 60s 后: total={total_after}, types={dist_after}")
print(f"[合] observation 变化: {dist_before.get('observation', 0)}{dist_after.get('observation', 0)}")

print()
print("=" * 60)
print("PHASE 4: 删路径(document 级联 → 图谱收缩)")
print("=" * 60)
graph, _ = api("GET", f"/v1/default/banks/{BANK}/graph")
edges_before = len(graph.get("edges", []))
docs, _ = api("GET", f"/v1/default/banks/{BANK}/documents")
if docs.get("documents"):
    doc_id = docs["documents"][0]["id"]
    del_resp, dt = api("DELETE", f"/v1/default/banks/{BANK}/documents/{doc_id}")
    print(f"[删] DELETE doc: {dt:.0f}ms, 级联删 {del_resp.get('memory_units_deleted')} 条事实")
    graph2, _ = api("GET", f"/v1/default/banks/{BANK}/graph")
    edges_after = len(graph2.get("edges", []))
    print(f"[删] 图谱边: {edges_before}{edges_after} ({- (edges_before - edges_after)} 条)")

print()
print("=" * 60)
print("PHASE 5: 清理(删整个 test bank)")
print("=" * 60)
clean, dt = api("DELETE", f"/v1/default/banks/{BANK}")
print(f"[清] {json.dumps(clean, ensure_ascii=False)[:120]} ({dt:.0f}ms)")

print()
print("全部四条管道执行完毕 ✅")

这个脚本跑完你会看到(与本文实测一致的模式): - 写:HTTP 20-60ms 返回,后台 25s 完成,fact_type 出现 experience + world(双语展开) - 读:recall 150-270ms,带时间约束可能更快,tags 过滤命中数变化 - 合:consolidation 后 observation 数量增加(相似事实被合并) - 删:DELETE document 毫秒级返回,级联删多条事实 + 图谱边大降 - 清:整个 test bank 一键删除,零残留


十三、完整复现指南:从零搭建到跑通全部实验

本节是全文的可执行汇总——按步骤操作,你可以在自己的环境复现所有数据。

13.1 环境搭建(Docker 两步)

前置:Linux + Docker + NVIDIA GPU(Jetson AGX Orin 已验证;x86 + CUDA 亦可)。

# ① 启动 vLLM(bge-m3 embedding,端口 8000)
docker run -d --name vllm --network host --device nvidia.com/gpu=all --restart=always \
  -e HF_ENDPOINT=https://hf-mirror.com \
  ghcr.1ms.run/yuyirobotlab/vllm-orin:0.19.0 \
  --model /app/hf-cache/bge-m3-models --runner pooling --port 8000 \
  --gpu-memory-utilization 0.3 --enforce-eager --max-model-len 8192 \
  --served-model-name BAAI/bge-m3
# 等 /health 200(约 60-80s)

# ② 启动 Hindsight 0.8.0-slim(端口 8888)
docker run -d --name hindsight --network host --restart=always \
  -v DATA_DIR:容器内pg数据目录 \
  --env-file /app/hindsight/env.list \
  ghcr.1ms.run/vectorize-io/hindsight-api:0.8.0-slim
# 等 /health 200(约 38s)

env.list 关键配置(模型调用链):

HINDSIGHT_API_LLM_PROVIDER=deepseek
HINDSIGHT_API_LLM_MODEL=deepseek-v4-flash
HINDSIGHT_API_LLM_BASE_URL=https://api.deepseek.com/v1
HINDSIGHT_API_EMBEDDINGS_PROVIDER=openai
HINDSIGHT_API_EMBEDDINGS_OPENAI_BASE_URL=http://localhost:8000/v1
HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL=BAAI/bge-m3
HINDSIGHT_API_RERANKER_PROVIDER=rrf
HINDSIGHT_API_RETAIN_EVERY_N_TURNS=20
HINDSIGHT_API_HOST=0.0.0.0
HINDSIGHT_API_PORT=8888

⚠️ 顺序铁律:先 vLLM 健康再启 Hindsight——Hindsight 启动时 init_embeddings() 同步阻塞,连不上 localhost:8000 会启动失败循环。

13.2 实验一:写路径(同步 vs 异步 vs 批量)

import urllib.request, json, time

BASE = "http://<your-server>:8888"
BANK = "test_repro"

def api(method, path, body=None, timeout=180):
    url = BASE + path
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(url, data=data, method=method,
                                 headers={"Content-Type": "application/json"})
    t0 = time.time()
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.loads(r.read().decode()), (time.time()-t0)*1000

# 同步单条(预期 ~18s,返回 usage)
resp, dt = api("POST", f"/v1/default/banks/{BANK}/memories", {
    "items": [{"content": "Hindsight 复现实验:同步写入测试",
               "tags": ["topic:code", "stage:process"],
               "metadata": {"repro": "sync"}}],
    "async": False,
})
print(f"sync: {dt:.0f}ms usage={resp.get('usage')}")

# 异步批量 10 条(预期 HTTP ~20-60ms,后台 ~25s)
resp, dt = api("POST", f"/v1/default/banks/{BANK}/memories", {
    "items": [{"content": f"批量写入第 {i} 条", "tags": ["topic:code", "stage:process"],
               "metadata": {"repro": "batch", "i": str(i)}} for i in range(10)],
    "async": True,
})
op_id = resp["operation_id"]
print(f"async batch: {dt:.0f}ms op={op_id[:8]}")
for _ in range(10):
    time.sleep(5)
    st, _ = api("GET", f"/v1/default/banks/{BANK}/operations/{op_id}")
    if st["status"] == "completed":
        print("后台完成")
        break

13.3 实验二:读路径(延迟 + 时间约束 + tags 过滤)

# 语义召回(预期 150-270ms)
r, dt = api("POST", f"/v1/default/banks/{BANK}/memories/recall",
            {"query": "Hindsight 记忆引擎 存储与检索设计", "limit": 5})
print(f"recall: {dt:.0f}ms hits={len(r.get('results', []))}")

# 时间约束(预期比无约束快或相近)
r, dt = api("POST", f"/v1/default/banks/{BANK}/memories/recall",
            {"query": "Hindsight 记忆引擎 last week", "limit": 5})
print(f"recall+last week: {dt:.0f}ms")

# tags 硬过滤(预期无关 tag 命中 0)
r, dt = api("POST", f"/v1/default/banks/{BANK}/memories/recall",
            {"query": "Hindsight 记忆引擎", "limit": 50, "tags": ["topic:business"]})
print(f"recall+无关tag: hits={len(r.get('results', []))}(预期 0)")

13.4 实验三:consolidation(写入相似事实 → 触发合并)

# 写入 5 条相似事实
items = [{"content": f"Hindsight 的 consolidation 引擎会合并相似事实,第 {i} 次观察...",
          "tags": ["topic:code", "stage:process"], "metadata": {"repro": "consol", "i": str(i)}}
         for i in range(5)]
resp, _ = api("POST", f"/v1/default/banks/{BANK}/memories", {"items": items, "async": True})
# 等 operation completed

# 记录合并前分布
before, _ = api("GET", f"/v1/default/banks/{BANK}/memories/list?limit=500")
# 手动触发
resp, _ = api("POST", f"/v1/default/banks/{BANK}/consolidate")
print("consolidate:", resp)  # 预期 {"operation_id": ..., "deduplicated": true}
time.sleep(60)
after, _ = api("GET", f"/v1/default/banks/{BANK}/memories/list?limit=500")
# 对比 observation 数量:应该增加

13.5 实验四:删除(级联 + 图谱)

# 删除前查图谱边数
graph, _ = api("GET", f"/v1/default/banks/{BANK}/graph")
print("删除前 edges:", len(graph.get("edges", [])))

# DELETE document(级联)
docs, _ = api("GET", f"/v1/default/banks/{BANK}/documents")
del_resp, dt = api("DELETE", f"/v1/default/banks/{BANK}/documents/{docs['documents'][0]['id']}")
print(f"删除: {dt:.0f}ms 级联删 {del_resp.get('memory_units_deleted')} 条")

# 删除后查图谱(预期 edges 大降)
graph2, _ = api("GET", f"/v1/default/banks/{BANK}/graph")
print("删除后 edges:", len(graph2.get("edges", [])))

# 清理 test bank
api("DELETE", f"/v1/default/banks/{BANK}")

13.6 源码获取(复现源码级拆解)

# Hindsight 容器内源码路径
docker exec hindsight bash -lc 'ls /app/api/hindsight_api/engine/'
# sql/postgresql.py   → SQL 方言层(4 后端)
# query_analyzer.py   → 查询分析器(dateparser/T5 双轨)
# search/retrieval.py → 四通道检索
# search/fusion.py    → RRF / interleave
# retain/orchestrator.py → 写路径编排
# consolidation/consolidator.py → 合并引擎
# graph_maintenance.py → relink 图谱自愈
# memory_engine.py    → delete_document / update_document
# PostgreSQL 直查表结构
docker exec hindsight bash -lc \
  '<pg-psql-path> -h 127.0.0.1 -U hindsight -d hindsight -c "\\d memory_units"'

十四、Hindsight 的局限与不足(诚实评估)

前面全是架构亮点,这篇也讲清楚它的坑。基于实际部署 + 源码阅读 + 官方 issue 的综合判断。

14.1 部署与运维的坑

症状 严重度
vllm 挂 → 重启风暴 RestartCount 上万,服务不可用 🔴 高(实测 10384 次)
初始化同步阻塞 vllm 不可用 = Hindsight 整体不可用 🔴 高
Docker 桥接不可用 Jetson 需 --network host 🟡 中(平台特定)
外网被墙 GitHub/DockerHub/HF 拉不动 🟡 中(国内环境)
stats 异步 lag 验真误导 🟢 低(习惯即可)

14.2 功能层面的限制

  1. 没有单条删除/编辑(0.8.0-slim):官方最新文档有 curate 端点,但当前实例没有。要改单条事实只能 PG 直操作(有孤儿风险)。
  2. consolidation 历史 bug:0.8.0 早期 schema 为 public 时 banks_needing_consolidation() 函数缺失(Issue #2056),consolidation 完全停滞。需手动安装函数恢复。
  3. PATCH document 只支持 tags:不能改 content、metadata、context——改内容只能 reprocess(重跑 retain)。
  4. 无官方 re-embed API:切换 embedding 模型要自己写脚本批量 UPDATE。
  5. HNSW 是近似索引:召回率有损,靠 5x over-fetch + ef_search=200 补偿——参数调不好会漏。

14.3 架构层面的权衡

  • 单 worker 限制HINDSIGHT_API_WORKERS=N 与 embedded PG 不兼容(4 workers 各起一个 PG 实例冲突)。短期接受 1 worker + recall_max_concurrent=32 限流。
  • 内存占用:vLLM 3.3GB + Hindsight 730MB,Jetson 29GB 下安全,小机器要考虑。
  • LLM 依赖:写路径和合并路径强依赖 DeepSeek API——key 失效 = fact extraction 失败(HTTP 500 但 /health 200,探活正常但业务不可用)。
  • 图谱边膨胀:memory_links 是 memory_units 的 13 倍(实测 1389 vs 103 条)——图谱是主体结构,数据量增长时要注意边数量。

14.4 什么时候不该用 Hindsight

  • 只要简单的用户偏好存储 → Mem0 更轻
  • 需要双时态图谱推理("这个事实什么时候开始/停止为真")→ Zep/Graphiti
  • 记忆是 agent 运行时的一部分 → Letta/MemGPT
  • 纯向量检索 → Pinecone/FAISS 就够了
  • 没有 GPU 且不能忍受 3.3GB embedding 服务 → 考虑 CPU embedding 模式(BGE-small,慢一些但零 GPU)

十五、与主流记忆系统的架构对比

本节数据来源:Vectorize 官方对比文章(vectorize.io/articles/mem0-vs-zep)、2026 年记忆系统调研(devgenius/graphlit/mnemoverse),均实际检索核实。

15.1 三种架构流派

2026 年主流 AI Agent 记忆系统分三个流派:

流派 代表 检索策略 存储
单策略向量 Mem0 语义搜索(Pro 加图谱) 向量库
图谱优先 Zep / Graphiti 图遍历 + 语义增强 Neo4j 双时态图谱
多策略并行 Hindsight 语义 + BM25 + 图谱 + 时间 四路并行 嵌入式 PostgreSQL

架构差异的核心:Mem0 和 Zep 把检索限制在一两种策略——语义为主或图谱为主。Hindsight 是每次查询四路并行 + Cross-Encoder 重排,不猜哪条路最可能命中,全部跑完再融合。

15.2 基准数据对比(LongMemEval)

系统 准确率 说明
Hindsight 94.6% 四路并行,差异最大在 temporal+semantic、entity+keyword 组合查询
Zep 63.8% 图谱结构在时间/多跳关系查询有优势
Mem0 49.0% 单策略语义,复杂组合查询掉分

为什么差距大? 组合查询(「上周关于 Hindsight 的」= temporal + semantic)要求系统同时走多条路径。单策略系统只能猜一条路,猜错就漏;Hindsight 四路全跑,融合取最优。

15.3 成本与足迹对比

维度 Zep Mem0 Hindsight
记忆足迹 >600k tokens/会话 1,764 tokens/会话 未公开,但 consolidation 去重设计明显压缩
基础设施 需自管 Neo4j 托管 API 嵌入式 PostgreSQL,零外部依赖
检索实时性 图谱后台处理,即时检索常失败 即时 即时(135-270ms 实测)

Zep 的坑(Mem0 论文实测):图谱构建成本高,即时检索经常失败——正确答案要等图谱后台处理完成后(数小时)才出现。对实时应用这是硬伤。Hindsight 的嵌入式 PG + 即时向量检索没有这个问题。

15.4 架构选型决策树

你的 Agent 需要什么类型的记忆?
  │
  ├─ 只要用户偏好/事实快速存取 → Mem0(单策略够用,生态最成熟)
  │
  ├─ 需要时间维度推理("Q1 目标 vs 现在")→ Zep/Graphiti(双时态图谱)
  │
  ├─ 需要组合查询(时间+语义+实体+关键词混合)→ Hindsight(四路并行)
  │
  ├─ 记忆是运行时的一部分 → Letta/MemGPT(tiered memory blocks)
  │
  └─ 自托管 + 不想管外部图数据库 → Hindsight(嵌入式 PG)

Hindsight 的定位:组合查询场景 + 自托管简单性。代价是 0.8.0 相对较新、生态不如 Mem0 成熟。


十六、与 Agent 框架的集成:Hindsight 作为记忆提供者

16.1 Hermes Agent 官方集成

Hindsight 是 Hermes Agent 的官方内置记忆提供者之一(plugins/memory/hindsight/),配置即用:

# 安装客户端
pip install hindsight-client==0.6.1

# 配置(指向 Hindsight 服务)
hermes config set memory.provider hindsight

# config.json
{
  "mode": "local_external",
  "api_url": "http://<host>:8888",
  "memory_mode": "hybrid",
  "auto_retain": true,
  "auto_recall": true,
  "recall_budget": "mid",
  "banks": {"hermes": {"bankId": "hermes", "budget": "mid", "enabled": true}}
}

集成后 Agent 获得的能力

能力 机制 对应 Hindsight 架构
自动记忆 每 N 轮对话 auto_retain 写入 写路径(retain)
自动召回 每轮 prefetch 相关记忆注入上下文 读路径(recall)
工具调用 hindsight_recall / hindsight_reflect 读路径 + 反思
跨会话 bank 长期存储 存储层

auto_retain 的触发链路(源码 plugins/memory/hindsight/__init__.py):

# sync_turn() 每 turn 调一次
if self._turn_counter % self._retain_every_n_turns != 0:
    return  # 没到 N 倍数 → 只 buffer
# 到 N 倍数 → 整个 session 累计的全发(不是单 turn)
content = "[" + ",".join(self._session_turns) + "]"
self._retain_queue.put(_do_retain)  # writer 线程异步执行

注意retain_every_n_turns 配置在 Hindsight 服务端 env(HINDSIGHT_API_RETAIN_EVERY_N_TURNS=20)和 Hermes 插件 config(retain_every_n_turns)两处都有,改哪边都要重启才生效(插件启动时读 config)。

16.2 集成层的关键权衡

设计点 选择 原因
异步写入 auto_retain 走 writer 线程 不阻塞 Agent 响应
双语展开 experience + world 中英文查询都能命中
observation 优先 召回优先 observation 精炼知识比原始事实更准
hybrid 模式 内置记忆 + 外部 Hindsight 高频关键事实快速通道 + 长期深度记忆

16.3 多 Agent 场景

bank 隔离支持多 Agent 共存:每个 Agent 一个 bank,数据互不可见。实测:test bank 的数据不会出现在 hermes bank 的检索里。租户隔离是查询级强制(WHERE bank_id),不是应用层过滤。


十七、记忆系统的安全与隐私考量

17.1 凭据管理:LLM key 是命脉

Hindsight 依赖 DeepSeek API(写/合/思三处)。key 失效 = fact extraction 失败——实测症状:

hindsight_retain 返回 HTTP 500(Fact extraction failed: AuthenticationError)
但 /health 返回 200(服务在线)

探活正常 ≠ 业务可用——这是记忆系统最常见的「假健康」。诊断:

# 看容器 env 里 LLM key 的 suffix
docker inspect hindsight --format '{{json .Config.Env}}' | grep -o 'LLM_API_KEY=...'
# 与有效 key 对比(前缀不同即确认失效)

修复:重建容器换 key(Hindsight 无内部 .env,env 由 docker run -e 注入,必须重建)。重建前先 curl DeepSeek API 测新 key 可用性,避免白重建。

17.2 多租户隔离

bank 级隔离是查询级强制(每个 SQL 都带 WHERE bank_id = $N),不是应用层过滤:

-- 所有检索 SQL 强制带 bank 过滤
WHERE bank_id = $2
  AND fact_type = 'observation'
  AND embedding IS NOT NULL

测试验证:test bank 的数据不会出现在生产 bank 的检索结果里。API 层有 tenant 鉴权(_authenticate_tenant),所有写操作过 OperationValidator

17.3 数据生命周期

数据 保留策略 清理方式
memory_units 长期 DELETE document / bank
llm_requests 长期(审计) 无 API,PG 直操作
observation_history 按配置上限裁剪 observation_history_max_entries
async_operations 长期 无自动清理

17.4 敏感信息处理

记忆系统存的是「用户说了什么」——天然含敏感信息。实践建议:

  1. retain 前脱敏:在写入前对 content 做 PII 脱敏(姓名/手机号/地址替换)
  2. tags 分级:用 stage:reference 标记可清理的临时信息
  3. bank 隔离敏感数据:敏感话题单独 bank,权限独立
  4. 定期 consolidation 清理:合并引擎天然去重,减少冗余存储

十八、记忆系统构建的反模式(从 Hindsight 反面学到的)

Hindsight 源码注释里明确记录了几个「曾经踩过的错」,这些就是记忆系统构建的反模式。

18.1 窗口函数破坏索引(已修复)

# 反模式:ROW_NUMBER() OVER (PARTITION BY fact_type)
# 症状:PostgreSQL 规划器放弃 HNSW,走全表扫描,秒级 vs 毫秒级
# 修复:UNION ALL 子查询,每个有自己的 ORDER BY ... LIMIT

18.2 无界历史单值(已修复)

# 反模式:observation 历史存在单个无上限 JSONB 列
# 症状:经常更新的 observation 历史涨到 256MB,Postgres jsonb 上限卡死
# 修复:独立 observation_history 表 + 每行一条变更 + 上限裁剪

18.3 chunk_id 碰撞(已修复)

# 反模式:大文档切成多个 sub-batch 后 chunk_index 从 0 重新计数
# 症状:chunk_id 碰撞,后面的覆盖前面的,只剩一批数据(issue #1888)
# 修复:chunk_index_offset 每批递增

18.4 同步初始化依赖(未彻底解决)

# 反模式:Hindsight 启动时 init_embeddings() 同步阻塞连 vLLM
# 症状:vLLM 挂 = Hindsight 整体不可用(RestartCount 10384 实测)
# 未彻底解决:只靠 --restart=always 兜底,无健康依赖管理

18.5 stats 异步 lag 误导(设计权衡)

# 反模式(使用层面):用 /stats 验证写入
# 症状:异步 retain 后 stats 30-60s 不刷新,误判失败
# 正确:用 /memories/list 验真

18.6 记忆系统反模式速查表

反模式 症状 正确做法
单策略检索 组合查询漏 多通道并行 + 融合
存原文不抽取 无法答「上周的 X」 LLM 抽取事实 + 时间戳
无去重引擎 重复记忆堆积 consolidation 异步合并
删除单条事实 图谱孤儿 级联删树 + 图谱自愈
全量重索引 写入成本爆炸 delta retain 增量
一次性加载大文档 OOM 流式 mini-batch
无审计 无法追溯合并/删除 observation_history + llm_requests

十九、工程启示:从 Hindsight 架构能抄到什么

19.1 七个可以直接复用的设计决策

  1. 「记录 = 树」不是「记录 = 行」:有派生数据、有图谱关系的系统,把「记录」建模成以文档为根的派生树(fact + 实体 + 边 + observation),比扁平表更能表达领域语义。代价是 CRUD 粒度变粗——但粗粒度换来的是可维护的一致性。

  2. 删除粒度要显式设计:不是「用户想删啥就提供啥」,而是「哪些粒度的一致性我能保证」。数据库的单行删除在记忆/图谱系统里是伪需求,安全的粒度(document/fact_type/bank)才是真需求。

  3. 模板化不是写死:SQL 模板要有方言抽象层、参数化、扩展点(tags_clause/extra_where)。你的业务 SQL 出现多处相似拼接时,值得抽一个薄方言层——不一定要支持 Oracle,但至少把「参数 vs 字面量」的边界划清楚。

  4. 冷启动成本显式管理:任何「首次调用慢、后续快」的依赖(正则表、时区数据、模型加载)都应该在启动期预热,而不是等用户请求触发。load() + 假调用预热是零成本方案。

  5. 规则优先 + 小模型兜底:90% 场景用正则(快、确定、可测试),10% 冷门场景用 80M 小模型(准、慢、可接受)。这是「不用 LLM 生成 SQL」的完整版答案——不是不用模型,是不用大模型做能确定化的事。

  6. 并行读、串行写:consolidation 五步流程里召回并发、动作串行——读多写少系统的标准答案。写冲突靠串行解决,比加锁简单可靠。

  7. 异步系统的竞态靠「执行前校验」:删除与合并并发时,合并前校验源事实是否还活着(_filter_live_source_memories),而不是加锁。校验比锁便宜

19.2 五个避坑清单

症状 规避
metadata 传 int 422 string_type 值必须 str
窗口函数取 Top N 全表扫描(秒级 vs 毫秒级) UNION ALL 子查询
无限增长的单值(JSONB) 256MB 上限卡死 独立表 + 上限裁剪
stats 当验真 异步 lag 误判失败 用 memories/list
删 document 不看级联 误删一批事实 删前 GET documents 数事实

19.3 性能数字速查

指标 数值
同步写入单条 17.9s
异步写入 HTTP 返回 20-66ms
异步批量 20 条后台完成 ~25s
批量 20 条 vs 1 条后台耗时比 25s vs 20s(几乎不变)
recall 端到端 135-270ms
recall + last week 约束 135ms(反而更快)
list 记忆 56ms
单条详情 17ms
PATCH document tags(传播 10 条) 75ms
DELETE document 级联(5 条 fact + 229 条边) 32ms
按类型删 world 30ms
清空 bank 记忆 53ms
单条 DELETE 405(不存在)

二十、常见错误场景速查(全部实测)

场景 返回 含义 规避
DELETE /memories/{id} 405 Method Not Allowed 删除粒度不支持单条 删 document 或 PG 直操作
metadata 含 int 422 string_type 值必须 str str(i) 字符串化
DELETE /documents/{不存在} 404 Document not found 幂等靠 404
async retain 后立即查 stats 数字没变 stats lag 30-60s 用 memories/list 验真
PATCH /documents/{id} body 无 tags 422 At least one field 目前只支持改 tags 必须带 tags
async=False + 服务端开 batch API 400 大批次必须异步 用 async=True
vllm 挂 → hindsight 启动 APIConnectionError 崩溃循环 embedding 依赖 先启 vllm 等健康
同步 vs 异步 fact_type 分布 observation 数量不同 同步跑完 consolidation 按需选模式
stats 数值异常 total_nodes 不涨 异步刷新 lag 30-60s 后重查
consolidate 无变化 observation 不增 源事实可能已合并过 查 operation 状态

二十一、总结:一张图记住 Hindsight 架构

                    HINDSIGHT 架构一句话版
┌─────────────────────────────────────────────────────────┐
│  写:content → chunk → LLM抽取(DeepSeek) → 实体(pg_trgm) │
│      → embedding(bge-m3增强) → 事实(experience/world)   │
│      → 图谱边(temporal/semantic/entity/causal)          │
│                                                         │
│  读:query → bge-m3 → 四通道(semantic/bm25/graph/time)   │
│      → RRF/轮转 → Cross-Encoder → Token截断             │
│      → 135-270ms 返回                                   │
│                                                         │
│  合:consolidation 后台 → LLM裁决 create/update/delete   │
│      → observation(source_memory_ids 溯源)             │
│                                                         │
│  删:document 级联(先救 relink 受害者 → CASCADE → sweep)│
│      → 图谱后台自愈 → 无单条删除(405)                   │
└─────────────────────────────────────────────────────────┘

三条核心认知: 1. 记录是树不是行——document 根 + fact 节点 + 边 + observation,CRUD 粒度由树结构决定 2. LLM 管理解,算法管检索——DeepSeek 在写/合/反思三处,检索路径 0 次 LLM 3. 最终一致是设计不是缺陷——删除事务性、图谱自愈异步,短暂不一致后台收敛


二十二、结语:记忆系统是 Agent 的下一个基础设施

回看 Hindsight 的架构,最值得记住的不是任何单一技术——不是 SQL 模板、不是四通道检索、不是 relink 自愈——而是它把「记忆」当作一个有结构的领域来建模的认真态度:

  • 一条记录不是一行,是一棵树(document → chunk → fact → observation)
  • 一次写入不是存储,是理解(LLM 抽取 + 实体 + 图谱 + 时间)
  • 一次检索不是查询,是综合(四通道 + 融合 + 精排)
  • 一次删除不是清理,是修剪(先救受害者,再砍树,后台自愈)

这个态度带来的工程深度,从每一处源码注释都能感受到:「必须 before the cascade」「AFTER the delete」「otherwise an insert ... would leave an orphan」——每一条都是踩过坑之后留下的教训。

如果你的 Agent 正在被「上下文窗口不够」「记忆混乱」「重复回答」困扰,值得认真评估记忆系统——不是加一个向量库,而是建一个像 Hindsight 这样把「发生了什么、意味着什么、什么时候的事」都建模的结构化记忆层。

记住三个数字:写 17.9s(LLM 理解的成本)、读 200ms(四通道检索的速度)、删 32ms(级联树的效率)。记住一句话:LLM 管理解,算法管检索,图谱管关系,时间管维度——这就是记忆系统的架构答案。


二十三、边界与声明

  • 本文所有 SQL/代码/注释来自 Hindsight 0.8.0-slim 容器内实际源码:sql/postgresql.pysql/base.pyquery_analyzer.pysearch/retrieval.pysearch/fusion.pyretain/orchestrator.pyretain/embedding_processing.pyretain/link_creation.pyretain/fact_extraction.pyentity_resolver.pyconsolidation/consolidator.pygraph_maintenance.pymemory_engine.py
  • 表结构来自运行实例 PostgreSQL \d memory_unitspg_stat_user_tables 真实输出(2026-08-21)
  • 所有性能数据为 2026-08-21 真实 API 实测(内网部署,地址脱敏),使用独立测试 bank,实验后已删除(deleted_count=57),未污染生产数据
  • DELETE /memories/{id} 返回 405 为实测行为,属设计约束而非 bug
  • 官方最新文档提到的「Curate memory unit」(编辑单条 memory)端点,在当前 0.8.0-slim 实例中不存在——版本差异以实际部署为准
  • 竞品对比数据来自 Vectorize 官方文章与 2026 年公开调研,均已实际检索核实;Hindsight 与 Zep/Mem0 的准确率差异包含测试集与方法论差异,非单点可比
  • 全文可复现:环境搭建、实验脚本、源码路径见第十一章

附录 A:70 个 API 端点完整速查表

记忆与检索(核心)

方法 路径 用途
POST /banks/{id}/memories 写入(retain)
POST /banks/{id}/memories/recall 语义召回
GET /banks/{id}/memories/list 列出
GET /banks/{id}/memories/{memory_id} 单条详情
GET /banks/{id}/memories/{memory_id}/history observation 变更历史
DELETE /banks/{id}/memories 清空(可按 type)
DELETE /banks/{id}/memories/{id}/observations 清某条派生 observation
GET /banks/{id}/graph 记忆图谱

文档(写入的原始载体)

方法 路径 用途
GET /banks/{id}/documents 列文档
GET /banks/{id}/documents/{doc_id} 文档详情
GET /banks/{id}/documents/{doc_id}/chunks 分块列表
PATCH /banks/{id}/documents/{doc_id} 改 tags(传播)
DELETE /banks/{id}/documents/{doc_id} 删文档(级联)
POST /banks/{id}/documents/{doc_id}/reprocess 重新提取
GET /banks/{id}/tags 标签列表

实体与图谱

方法 路径 用途
GET /banks/{id}/entities 列实体
GET /banks/{id}/entities/graph 实体图
GET /banks/{id}/entities/{entity_id} 实体详情
POST /banks/{id}/entities/{entity_id}/regenerate 重生成实体 observation

异步任务

方法 路径 用途
GET /banks/{id}/operations 任务列表
GET /banks/{id}/operations/{op_id} 任务状态
DELETE /banks/{id}/operations/{op_id} 取消任务
POST /banks/{id}/operations/{op_id}/retry 重试失败任务

Bank 与配置

方法 路径 用途
GET /banks bank 列表
PUT /banks/{id} 创建/更新 bank
PATCH /banks/{id} 更新 bank
DELETE /banks/{id} 删除 bank
GET /banks/{id}/stats 统计(有 lag)
GET /banks/{id}/config 读配置
PATCH /banks/{id}/config 改配置
DELETE /banks/{id}/config 重置配置
POST /banks/{id}/consolidate 手动触发合并
POST /banks/{id}/consolidation/recover 修复合并状态
GET /banks/{id}/profile 人格档案
POST /banks/{id}/background 添加背景
POST /banks/{id}/import 导入模板
GET /banks/{id}/export 导出模板
POST /banks/{id}/document-transfer 批量导文档
POST /banks/{id}/reflect 反思
GET /banks/{id}/mental-models 心智模型
GET /banks/{id}/directives 指令列表
GET /banks/{id}/audit-logs 审计日志
GET /banks/{id}/llm-requests LLM 调用追踪

完整 70 端点含 webhooks 管理(增删改查 5 个)、audit-logs/stats、llm-requests/stats 等,均为同构 REST。核心链路就是上表的记忆/文档/任务三块。


附录 B:0.7.1 → 0.8.0 版本迁移指南

B.1 破坏性路径变更(照旧文档调会 404)

操作 0.7.1 路径 0.8.0 路径
召回 POST /banks/{id}/recall POST /banks/{id}/memories/recall
列记忆 GET /banks/{id}/memories GET /banks/{id}/memories/list
配置 PATCH 平铺 body 必须 {"updates": {...}} 包装
写入 POST /banks/{id}/retain(部分版本) POST /banks/{id}/memories
统计 GET /banks/{id}/stats 不变(异步刷新有 lag)

B.2 0.8.0 新增的 schema 变化

  • memory_units 增加 consolidation_failed_at(合并失败标记)与 observation_scopes(合并范围)
  • 新增 observation_history 表(observation 变更审计,替代旧的 JSONB 列)
  • 新增 llm_requests 表(LLM 调用追踪)
  • check_pg0_writable 启动检查:数据目录必须 chown -R 1000:1000

B.3 升级注意事项

  1. 先备后升cp -a /app/hindsight/data /app/hindsight/data.before-0.8.0-$STAMP(NVMe 备份,0 风险)
  2. docker inspect 快照:升级前导出容器 env,重建时原样带回
  3. PG password 真相pg_hba.conf 对 127.0.0.1 是 trust 认证,DATABASE_URL 里 password 字段是装饰,不用找回旧密码
  4. stats lag 是常态:异步 retain 后 30-60s 才刷新,验真用 memories/list
  5. 回退:备份 + 镜像 0.7.1 可 5 分钟回到旧版

B.4 切换 embedding 模型的坑(bge-large-zh → bge-m3)

两个模型虽然都是 1024 维(维度兼容),但向量分布完全不互通——维度对得上不代表能查。bge-large-zh 生成的旧数据,bge-m3 查出来全是 0.92+ 高距离(几乎正交)。切换模型必须 re-embed 全部历史数据,否则 recall 静默失败(HTTP 200 但返回不相关结果)。

实测:9,419 条 174 秒完成 re-embed,0 失败(54 rows/s)
完成后必须重建向量索引(旧聚类中心基于旧模型分布)

附录 C:retain 请求体参数详解(items 结构)

retain 请求体的每个 item 支持这些字段,用途和坑一次说清:

字段 类型 用途
content str 原始文本(必填) 会被 LLM 抽取成多条 fact
context str 上下文标签 帮助检索
timestamp str 事件时间 省略 = 无时间约束
metadata dict[str,str] 业务元数据 值必须 str,int 会 422
document_id str 归组标识 相同 id 会被当作同一文档处理
entities list 显式实体 省略 = LLM 自动提取
tags list[str] 标签 前缀格式 topic:xxx / stage:xxx
observation_scopes str 合并范围 per_tag 按标签分组合并
update_mode str 更新模式 append / replace
strategy str 写入策略 分组按策略处理

document_id 的 upsert 语义(官方文档核实):带 document_id 时 Hindsight 执行 upsert——同 ID 已存在则删除旧文档及其全部记忆,从新内容重新处理。这是「更新对话线程」的标准姿势:

同一 document_id 重新 retain:
  → 删除旧版本(文档 + 关联记忆)
  → 用新内容从零处理
  → 记忆永远反映最新状态,不累积重复

update_mode=replace(默认)与 update_mode=append 的区别: - replace:删除旧文档重处理(标准 upsert) - append:新内容拼接到旧文档再处理;delta retain 自动跳过未变 chunk,只对新部分触发 LLM 抽取


附录 D:FAQ——读者最可能问的 10 个问题

D.1 Hindsight 和普通向量数据库(Pinecone/FAISS)什么区别?

向量数据库只解决「语义检索」一条路。Hindsight 是完整记忆系统:写路径有 LLM 抽取(不是存原文),读路径有四通道(不是只有向量),后台有合并引擎(去重/精炼),存储是嵌入式 PG(不用管向量库 + 图数据库 + 关系库三套基础设施)。

D.2 为什么要 PostgreSQL 而不是专门的向量库?

Hindsight 官方文档明确回答:PostgreSQL 一个数据库提供了记忆系统需要的全部能力——pgvector(向量)、tsvector(全文)、JSONB(文档)、递归 CTE(图谱)、事务(一致性)。少一套基础设施 = 少一套运维。而且 PG 生态在扩展(VectorChord/pgroonga/ParadeDB 都能接)。

D.3 LLM 在检索路径真的完全不出现吗?

真的 0 次。所有检索 SQL 是编译期写死的模板,运行时只注入参数(query embedding、时间范围、tags)。LLM 只在写(抽取/翻译)、合(consolidation 裁决)、思(reflect)三处出现。这保证了检索的确定性、低延迟、零幻觉。

D.4 为什么没有单条删除?是设计缺陷吗?

设计决策,不是缺陷。一条 fact 是一棵 document 树的节点:删它要处理实体关系、图谱边、observation 溯源三处一致性。API 层把删除粒度收敛到 document(级联)/fact_type(类型)/bank(整体)三个能保证一致性的粒度。单条删除留给 PG 直操作(有孤儿风险,需手动清理关联表)。

D.5 同步写入为什么 18 秒?正常吗?

正常。写路径的核心成本是 LLM 抽取(DeepSeek 一次往返 ~2-5s,含抽取+翻译+因果多次调用)。这不是 Hindsight 的问题,是「LLM 理解」的成本。生产场景用异步模式(54ms 返回),只有需要精确 token 统计才用同步。

D.6 如何判断写入成功了?

GET /memories/list 搜刚写入的内容,不要信 stats。stats 端点异步刷新(30-60s lag),刚写入立即查 stats 会看到数字没动,误判失败。异步模式先等 operation 状态变 completed,再 list 验真。

D.7 consolidation 多久跑一次?

后台维护引擎每 5 分钟跑一次(maintenance.py),调 banks_needing_consolidation() 找有待处理 bank。也可以 POST /banks/{id}/consolidate 手动触发。注意:历史版本有 consolidation 函数缺失 bug(banks_needing_consolidation 没装上导致完全停滞),0.8.0 需确认函数存在。

D.8 Hindsight 支持中文吗?

支持且是强项。bge-m3 是多语言 embedding(1024 维);事实抽取/翻译用 DeepSeek(中文原生强);日期解析支持 200+ 语言(dateparser);BM25 有 pgroonga 后端(中文分词友好)。中文事实自动生成英文 world 版,双语召回。

D.9 数据量大会不会慢?

本文实测:103 条事实 + 1389 条边时 recall 135-270ms。设计上有三个扩展保障:部分 HNSW 索引(按 fact_type 拆,避免全表扫)、5x over-fetch + ef_search=200(召回率补偿)、流式 mini-batch(17k chunk 防 OOM)。瓶颈在 LLM 抽取(写),不在检索(读)

D.10 能不能只用一个 API 跑起来?

能。POST /memories(写入)+ POST /memories/recall(召回)两个端点即可完成「写入-检索」闭环。其余 68 个端点是运维/审计/扩展能力(documents/entities/operations/config/webhooks)。


附录 E:记忆系统设计模式(从 Hindsight 提炼的通用模式)

模式 描述 Hindsight 实现 适用场景
记录 = 树 一行不是原子记录,是根+节点+边 document → fact → links 有派生数据/关系的系统
存储索引分离 存原文,索引编码增强文本 DB 存 fact,embedding 编码增强文本 需要语义检索的系统
部分索引 按类型拆索引避免规划器放弃索引 3 个部分 HNSW 多类型数据的 Top-N 查询
增量处理 只处理变化的部分 delta retain(SHA256 chunk hash) 追加式文档
流式边界 大批量分批 + 内存即时释放 mini-batch + 双协程 慢路径(LLM/推理)
规则优先 + 小模型兜底 90% 正则,10% 小模型 dateparser + T5 可确定化的 NLP 任务
多策略并行 不猜哪条路,全跑融合 四通道 + RRF/CE 组合查询
派生数据审计 变更历史可追溯 observation_history 需要回滚/审计
删除粒度显式化 只暴露能保证一致性的粒度 document/fact_type/bank 图谱/派生系统
最终一致后台收敛 事务保证主操作,异步修拓扑 relink/graph maintenance 图谱一致性


附录 L:三个真实故障场景的完整排障记录

L.1 服务不可用(8888 拒绝连接)

现象:Hindsight API 连不上。 排障(按顺序): 1. ping <server-ip> → 通(机器活着) 2. nc -zv <server-ip> 8888 → 拒绝(服务没起) 3. docker ps -a → hindsight Up 9 seconds / vllm Exited 44h(根因!) 4. docker logs hindsight --tail 25APIConnectionError(启动连不上 vllm) 5. docker start vllm → 等 health 200 → hindsight 自动恢复

教训:health 200 ≠ 可用(vllm 挂时 hindsight 启动失败循环);RestartCount 上万是「依赖服务挂了」的信号,不是 hindsight 自身问题。

L.2 fact extraction 报 AuthenticationError(HTTP 500 但 health 200)

现象hindsight_retain 返回 500 Fact extraction failed: AuthenticationError排障: 1. /health → 200(服务在线,误导!) 2. docker inspect hindsight --format '{{json .Config.Env}}' → 看 LLM_API_KEY 3. 与有效 key 对比 → 发现 key 过期 4. 重建容器换新 key(保留 bind mount)

教训:记忆系统的「探活」必须包含真实业务调用(recall/retain),不能只看 health 端点——LLM key 失效是记忆系统最常见的「假健康」。

L.3 consolidation 完全停滞(observation 不增长)

现象:写入大量事实后 observation 数量不变。 排障: 1. POST /banks/{id}/consolidate → 返回 operation_id 2. 轮询 operation → completed 但 observation 没变 3. 查 PG:SELECT proname FROM pg_proc WHERE proname='banks_needing_consolidation' → 不存在! 4. 手动创建缺失函数(banks_needing_consolidation + schemas_with_expired_rows)

教训:0.8.0 早期版本 schema 为 public 时 Alembic 迁移会跳过维护函数创建(Issue #2056),consolidation 静默失效。升级后要验证维护函数存在。


附录 M:本文的写作方法论(为什么这些数据可信)

写这篇长文的完整过程,读者可以按同样的方法复现任何一条结论:

数据来源分级

数据级别 来源 可信度
源码引用 容器内 /app/api/hindsight_api/engine/*.py 原文 ★★★ 最高
表结构 运行实例 PostgreSQL \d / pg_stat_user_tables ★★★
性能数据 2026-08-21 真实 API 实测(独立 test bank) ★★★
执行计划 EXPLAIN (ANALYZE) 真实输出 ★★★
竞品对比 Vectorize 官方文章 + 2026 公开调研 ★★(需交叉验证)
经验判断 部署运维中的观察 ★(标注为经验)

实验隔离原则

所有性能实验跑在独立 test banktest_crud_* / test_arch_*),实验后 DELETE /banks/{id} 清理(实测 deleted_count=57),生产 hermes bank 零污染。读者复现时务必遵循同样的隔离——不要在 production bank 上跑实验

可复现性承诺

  • 每条性能数据都有对应的 API 调用(见第十三章复现指南 + 附录 I curl 速查)
  • 每条源码结论都有容器内文件路径(见 13.6 源码获取)
  • 每条表结构都有 PG 直查命令(\d memory_units
  • 每张架构图都有 HTML 源文件可重新渲染

版本声明

本文基于 Hindsight 0.8.0-slim(镜像 ghcr.1ms.run/vectorize-io/hindsight-api:0.8.0-slim)实测。新版本 API 可能有变化(官方最新文档已出现 curate 端点),引用时注意版本。

附录 F:RAG 与记忆系统的本质区别

很多人把「AI 记忆系统」和「RAG」混为一谈。从架构角度它们完全不同:

维度 RAG 记忆系统(Hindsight)
存储内容 原文分块 LLM 抽取的结构化事实
写入成本 低(embedding 即可) 高(LLM 抽取 + 实体 + 图谱)
检索 单路向量相似 四通道 + 融合 + 精排
去重 consolidation 合并引擎
更新 重新索引 delta retain 增量
删除 删 chunk 级联删树 + 图谱自愈
关系 实体图 + 4 种边
时间理解 4 套时间戳 + dateparser

一句话:RAG 是「检索原文片段」,记忆系统是「理解后存储知识」。Hindsight 的写路径把原始文本「消化」成事实和关系,读路径在消化后的知识上多通道检索。这就是为什么它能答「上周关于 Hindsight 的事」(时间+语义组合)而纯 RAG 做不到。


附录 G:全实验数据汇总(本文所有真实数据一览)

全部为 2026-08-21 真实 API 实测,测试 bank 使用后已删除(deleted_count=57)。

写路径

实验 数据
同步单条 HTTP 17,921ms,usage 3029/2628/5657 tokens
异步单条 HTTP 66ms,后台 20,052ms
异步 5 条 HTTP 20ms,后台 15,039ms
异步 10 条 HTTP 20ms,后台 25,064ms
异步 20 条 HTTP 24ms,后台 25,073ms
双语展开 10 条 content → 12 条 memory(1.2x)

读路径

实验 数据
recall(4 组 × 3 次) 135-271ms
无时间约束 216ms 平均
+last week 135ms 平均(反而快)
+2026年8月 250ms 平均(中文解析慢)
tags=topic:code 过滤 44 hits(全量)
tags=topic:business 过滤 0 hits(硬过滤)
list 56ms
get single 17ms

合并路径

实验 数据
5 条相似事实写入 experience 34 / world 7 / observation 2
触发 consolidate 后 60s observation 2 → 3
consolidate 返回 {"deduplicated": true}

删路径

实验 数据
DELETE 单条 405 Method Not Allowed(6ms)
DELETE document 32ms,级联删 5 条 fact
图谱边变化 2,038 → 1,809(-229 条)
DELETE memories?type=world 30ms
DELETE memories(清空) 53ms
删整个 bank deleted_count=57

生产实例状态

指标 数据
memory_links 1,389 行
memory_units 103 行
边/事实比 13.5x
recall 延迟(生产 hermes bank) 150-270ms

附录 H:源码注释金句(直接抄进你代码里的智慧)

源码注释里藏着大量工程智慧,原样摘录:

1. 删除的顺序(memory_engine.py 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.

2. relink 时机(graph_maintenance.py enqueue_relink_victims)

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.

3. RRF 的失败模式(fusion.py interleave_fusion)

RRF scores a doc by the sum of its reciprocal ranks across arms, so a result that is #1 in one arm but absent/low in the others gets averaged down. That is exactly the consolidation-dedup failure mode...

4. 冷启动(query_analyzer.py DateparserQueryAnalyzer.load)

Triggers the real initialization cost (regex tables, timezone data) at load time so the first actual recall doesn't pay the cold-start penalty.

5. 反模式修复(retrieval.py UNION ALL)

the previous window-function approach caused by using PARTITION BY inside ROW_NUMBER() [forcing] a full sequential scan

6. 内存管理(orchestrator.py 流式 producer)

Memory: release the chunk text from the shared list now that it's been extracted and queued.

7. 无界历史事故(consolidator.py)

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.

8. 写串行读并行(consolidator.py _process_memory_batch)

Sequential action execution (writes remain serial for consistency)

9. 删除并发保护(consolidator.py _execute_update_action)

Update skipped: all N source memories for observation X were deleted concurrently

10. chunk 碰撞事故(orchestrator.py chunk_index_offset)

their chunk_ids collide and later sub-batches overwrite earlier chunks — leaving only one sub-batch's worth of chunks/memories behind (issue #1888)

这些注释的共同点:每条都解释了「为什么」而不是「是什么」。工程代码最容易腐烂的地方就是「知道怎么改,不知道为什么这么写」——Hindsight 的注释风格值得学习。


附录 I:完整 curl 命令速查(复现最小集)

不需要 Python,纯 curl 就能跑通核心链路。以下命令全部实测可用(<host> 替换为你的 Hindsight 地址,<bank> 替换为 bank ID)。

写:异步批量

curl -s -X POST "http://<host>:8888/v1/default/banks/<bank>/memories" \
  -H 'Content-Type: application/json' \
  -d '{"items":[{"content":"测试记忆:Hindsight 架构复现","tags":["topic:code","stage:process"],"metadata":{"repro":"curl"}}],"async":true}'
# 返回 operation_id

查:任务状态

curl -s "http://<host>:8888/v1/default/banks/<bank>/operations/<operation_id>"
# status=pending → completed

查:召回

curl -s -X POST "http://<host>:8888/v1/default/banks/<bank>/memories/recall" \
  -H 'Content-Type: application/json' \
  -d '{"query":"测试记忆 Hindsight 架构","limit":5}'

查:list / get

curl -s "http://<host>:8888/v1/default/banks/<bank>/memories/list?limit=20"
curl -s "http://<host>:8888/v1/default/banks/<bank>/memories/<memory_id>"

改:PATCH document tags

# 先拿 document_id
curl -s "http://<host>:8888/v1/default/banks/<bank>/documents"
# 再改 tags(75ms,传播到全部关联 memory_units)
curl -s -X PATCH "http://<host>:8888/v1/default/banks/<bank>/documents/<doc_id>" \
  -H 'Content-Type: application/json' \
  -d '{"tags":["topic:code","stage:decision"]}'

删:document 级联

curl -s -X DELETE "http://<host>:8888/v1/default/banks/<bank>/documents/<doc_id>"
# 返回 {"memory_units_deleted": N}

删:按类型 / 清空 / 删 bank

curl -s -X DELETE "http://<host>:8888/v1/default/banks/<bank>/memories?type=world"
curl -s -X DELETE "http://<host>:8888/v1/default/banks/<bank>/memories"
curl -s -X DELETE "http://<host>:8888/v1/default/banks/<bank>"

边界确认:单条 DELETE

curl -s -X DELETE "http://<host>:8888/v1/default/banks/<bank>/memories/<memory_id>"
# 405 Method Not Allowed(设计如此)

合并:手动触发

curl -s -X POST "http://<host>:8888/v1/default/banks/<bank>/consolidate"
# 返回 {"operation_id": "...", "deduplicated": true}

表结构:PG 直查

# 容器内
docker exec <hindsight_container> bash -lc \
  '<pg-psql-path> -h 127.0.0.1 -U hindsight -d hindsight -c "\\d memory_units"'

附录 J:术语表

术语 含义
bank 租户级隔离单元(一个 AI agent 一个 bank)
document 写入的原始载体(一棵树的根)
chunk 分块(SHA256 content_hash 去重)
memory_unit 事实(experience/world/observation)
experience 中文原始事实(LLM 抽取)
world 英文翻译版(双语召回)
observation 合并后的精炼知识(带溯源)
entity 命名实体(trigram 匹配)
memory_link 图谱边(temporal/semantic/entity/causal)
retention 写入路径(retain)
recall 读路径(检索)
consolidation 合并路径(去重/精炼)
reflect 反思路径(综合推理)
RRF Reciprocal Rank Fusion(倒数排名融合)
CE Cross-Encoder(交叉编码器重排)
delta retain 增量写入(只处理变化的 chunk)
relink 图谱自愈(受害者重连)
operation 异步任务(后台跟踪)
disposition 行为档案(怀疑度/字面度/同理心)
mental model 心智模型(reflect 综合产物)
directive 银行级可编程指令
webhook 事件推送(记忆变化通知外部)

附录 K:读完本文后的学习路径(按需取用)

你的目标 读什么
快速上手 Hindsight 第一章 + 第九章(环境搭建)+ 附录 I(curl 速查)
理解写路径 第三章 + 第二章 2.8-2.12
理解读路径性能 第四章 + 附录 G(性能数据)
搭建自己的记忆系统 第五章(合并引擎)+ 附录 E(设计模式)
生产运维 第八章(运维实战)+ 第十七章(安全)+ 附录 B(迁移)
源码级深入 按第九章 13.6 的源码路径逐个文件读

三小时精读路线(从零到能跑): 1. 第 0-1 小时:第一章(全局)+ 第九章 9.1(环境搭建)+ 附录 I(curl 跑通写读) 2. 第 1-2 小时:第二章(存储)+ 第三章(写路径)+ 第四章(读路径) 3. 第 2-3 小时:第五章(合并)+ 第十章(删除)+ 第十一章(端到端脚本跑全流程)


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

相关推荐

评论

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

发表评论

登录 后发表评论

发现更多