一、全局:一张 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 四管道架构总览(写/读/合/维 + 模型调用链 + 实测数据)

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 存储解决不了的需求:
-
时间维度:「上周部署了什么?」是 Agent 高频问题。KV 只存当前值,没有事件时间、发生区间、提及时间。Hindsight 用 4 套时间戳(event_date/occurred_start/occurred_end/mentioned_at)+ 时间索引解决。
-
关系维度:「Hindsight 依赖 vllm,vllm 挂了」——这条记忆和「Hindsight 部署在 Jetson」之间的关联,KV 存不了。Hindsight 用实体图 + 4 种边(temporal/semantic/entity/causal)解决。
-
共识维度:「用户说了 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_links 是 memory_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_ids 是 uuid[] 数组——一条 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)避免空值浪费空间。
2.4 图谱边:memory_links 的结构
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. 双向 CASCADE:documents → 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.py 的 resolve_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.py 的 augment_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.py 的 retain_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.py 的 compute_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": "..."}
metadata 是 dict[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.py 的 insert_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 embedding(bge-m3, 84ms)
│ query → 1024 维向量
│
├─ Stage 1: 四通道并行检索(~60-350ms)
│ ① semantic(HNSW 语义)② 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.py 的 SQLDialect 抽象接口。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 臂的四后端 switch(build_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-fetch(LIMIT 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 的三个关键设计:
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") # 假调用触发懒加载
_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))
- 防御性容错: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(或四后端之一)
- graph:
LinkExpansionRetriever实体扩展(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-Encoder(Stage 1):query 和 doc 各自独立编码 → 余弦
Cross-Encoder(Stage 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 定义了完整流程:
- Parallel recalls — one per fact (read-only; safe to parallelise)
- Union of retrieved observations across the batch (deduped by id)
- Single LLM call with all N facts + unioned observations
- Sequential action execution (writes remain serial for consistency)
- Returns one result dict per memory, in the same order as
memories
| 步骤 | 操作 | 关键点 |
|---|---|---|
| ① | 每条 fact 并行召回相关 observation | 只读,可并行 |
| ② | 合并召回结果(按 id 去重) | 一个 batch 的观察集合 |
| ③ | 单次 LLM 调用裁决全部动作 | 一次调用处理 N 条 fact,省 token |
| ④ | 顺序执行动作(create/update/delete) | 写路径必须串行,保证一致性 |
| ⑤ | 返回与输入同序的结果 | 可追踪 |
「并行读、串行写」是核心架构:召回可以并发(只读安全),动作执行必须串行(写冲突)。这是数据库读写锁哲学的同源——读多写少时的最优解。
每个 batch 还有安全校验:
Per-fact security: action execution validates each learning_id against the observations that were recalled specifically for that fact, so cross-tag updates cannot occur.
每条 fact 的合并动作只能作用于为它召回的 observation——防止跨标签串改。
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:8000 → APIConnectionError
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.py 的 update_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.py 的 delete_document 是整个删除语义的精华,四步顺序有严格的架构逻辑:
async def delete_document(self, document_id, bank_id, *, request_context):
async with conn.transaction():
# ① 先捕获要删的 unit_ids(否则级联后就查不到了)
unit_rows = await conn.fetch(
"SELECT id FROM memory_units WHERE document_id = $1 ...")
unit_ids = [str(row["id"]) for row in unit_rows]
# ② 先入队 relink 受害者(必须在级联前!)
if unit_ids:
await enqueue_relink_victims(conn, bank_id, unit_ids, ops=backend.ops)
# ③ 删文档(CASCADE 删 memory_units + links)
deleted = await conn.fetchval(
"DELETE FROM documents WHERE id = $1 AND bank_id = $2 RETURNING id")
# ④ 删除后清理 stale observations(在删除之后跑!)
if unit_ids:
invalidated_obs = await self._delete_stale_observations_for_memories(
conn, bank_id, unit_ids)
# ⑤ 事务外:触发 consolidation + graph maintenance
| 步骤 | 时机 | 为什么 |
|---|---|---|
| 捕获 unit_ids | 删除前 | 级联删除后 join 找不到源行 |
| enqueue_relink_victims | 级联前 | 被删单元的入边受害者必须先入队,否则行没了 join 返回空 |
| DELETE documents | 级联 | CASCADE 一次删干净 |
| stale observation sweep | 删除后 | 能捕获删除期间并发插入的孤儿 observation |
图 10-1 删除五步时序图(T0 请求 → T1 事务内五步 → T2 提交 → T3 异步触发 → T4 图谱收敛)

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 刻意不暴露单条删除,但运维场景(清重复数据、修脏标签、紧急回滚)可能需要:
- 首选 API:document 级联删除覆盖 95% 的「删一批」需求
- 次选 API:
DELETE /memories?type=xxx按类型清理 - 最后手段 PG 直操作:
DELETE FROM memory_units WHERE ...单条级精度——必须同时清理memory_links、unit_entities、observation_sources关联行,否则图谱残留孤儿引用导致召回异常。这就是 API 不提供单条删除的原因——级联逻辑太容易漏。
操作规范:PG 直操作前先备份(pg_dump 相关表),操作后 POST /banks/{id}/consolidation/recover 修复维护状态。
十一、图谱自愈:relink 机制
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.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.
「受害者先入队,再执行删除」——删除一条事实不是删完就完事;要先把「引用它的其他记忆」找出来排队重连,否则它们的图谱边指向不存在的节点,后续召回静默失败。
受害者发现逻辑:
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 功能层面的限制
- 没有单条删除/编辑(0.8.0-slim):官方最新文档有 curate 端点,但当前实例没有。要改单条事实只能 PG 直操作(有孤儿风险)。
- consolidation 历史 bug:0.8.0 早期 schema 为 public 时
banks_needing_consolidation()函数缺失(Issue #2056),consolidation 完全停滞。需手动安装函数恢复。 - PATCH document 只支持 tags:不能改 content、metadata、context——改内容只能 reprocess(重跑 retain)。
- 无官方 re-embed API:切换 embedding 模型要自己写脚本批量 UPDATE。
- 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 敏感信息处理
记忆系统存的是「用户说了什么」——天然含敏感信息。实践建议:
- retain 前脱敏:在写入前对 content 做 PII 脱敏(姓名/手机号/地址替换)
- tags 分级:用 stage:reference 标记可清理的临时信息
- bank 隔离敏感数据:敏感话题单独 bank,权限独立
- 定期 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 七个可以直接复用的设计决策
-
「记录 = 树」不是「记录 = 行」:有派生数据、有图谱关系的系统,把「记录」建模成以文档为根的派生树(fact + 实体 + 边 + observation),比扁平表更能表达领域语义。代价是 CRUD 粒度变粗——但粗粒度换来的是可维护的一致性。
-
删除粒度要显式设计:不是「用户想删啥就提供啥」,而是「哪些粒度的一致性我能保证」。数据库的单行删除在记忆/图谱系统里是伪需求,安全的粒度(document/fact_type/bank)才是真需求。
-
模板化不是写死:SQL 模板要有方言抽象层、参数化、扩展点(tags_clause/extra_where)。你的业务 SQL 出现多处相似拼接时,值得抽一个薄方言层——不一定要支持 Oracle,但至少把「参数 vs 字面量」的边界划清楚。
-
冷启动成本显式管理:任何「首次调用慢、后续快」的依赖(正则表、时区数据、模型加载)都应该在启动期预热,而不是等用户请求触发。
load()+ 假调用预热是零成本方案。 -
规则优先 + 小模型兜底:90% 场景用正则(快、确定、可测试),10% 冷门场景用 80M 小模型(准、慢、可接受)。这是「不用 LLM 生成 SQL」的完整版答案——不是不用模型,是不用大模型做能确定化的事。
-
并行读、串行写:consolidation 五步流程里召回并发、动作串行——读多写少系统的标准答案。写冲突靠串行解决,比加锁简单可靠。
-
异步系统的竞态靠「执行前校验」:删除与合并并发时,合并前校验源事实是否还活着(
_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.py、sql/base.py、query_analyzer.py、search/retrieval.py、search/fusion.py、retain/orchestrator.py、retain/embedding_processing.py、retain/link_creation.py、retain/fact_extraction.py、entity_resolver.py、consolidation/consolidator.py、graph_maintenance.py、memory_engine.py - 表结构来自运行实例 PostgreSQL
\d memory_units与pg_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 升级注意事项
- 先备后升:
cp -a /app/hindsight/data /app/hindsight/data.before-0.8.0-$STAMP(NVMe 备份,0 风险) - docker inspect 快照:升级前导出容器 env,重建时原样带回
- PG password 真相:
pg_hba.conf对 127.0.0.1 是 trust 认证,DATABASE_URL 里 password 字段是装饰,不用找回旧密码 - stats lag 是常态:异步 retain 后 30-60s 才刷新,验真用 memories/list
- 回退:备份 + 镜像 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 25 → APIConnectionError(启动连不上 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 bank(test_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