跳到主要内容

第 1 章:HSG —— 写入与召回主线

本章讲什么: OpenMemory 的名字叫 HSG(Hierarchical Semantic/Sector Graph,项目里称 Hierarchical Memory Decomposition,分层记忆分解)。这一章把"一条记忆怎么进库"和"一次查询怎么把它捞回来"两条主线拆开讲透。看完你就抓住了整个引擎的骨。


1. 写入主线:addingest_documentadd_hsg_memory

1.1 入口:大文档先拆,小文本直接进

Memory.add 只是薄封装,真正干活的是 ingest_document。它要解决的小问题:一条 3 万字的文档,不能当成一条记忆整存——否则检索时它总是"半相关",还会淹没细节。

思路:估算 token,超过阈值(LG = 8000)就走 root-child 策略——建一个"目录页"根记忆 + 若干"章节"子记忆,并用 waypoint 边把根连到每个子。

ingest_document(text)
│ 估 token = len/4
├─ ≤8000 ──▶ 单条:add_hsg_memory(text) 返回
└─ >8000 ──▶ split_text(按段落切 ~3000 字/段)
├─ mk_root:一条 reflective 摘要根记忆(salience=1.0)
└─ 逐段 mk_child ──▶ add_hsg_memory + link(root→child 边 weight=1.0)

真实实现:ops/ingest.py:85(ingest_document),阈值常量 LG/SECops/ingest.py:13,拆分函数 split_text(:16),根记忆构造 mk_root(:31),父子连边 link(:79)。

1.2 去重:simhash + 汉明距离

要解决的小问题: 同一件事换了句式又存一遍("用户喜欢暗色" vs "用户偏好 dark mode"),不该产生两条记忆。

思路: 给内容算一个 64-bit simhash(局部敏感哈希:相似文本哈希也相似),入库前查库里有没有 simhash 接近的老记忆;若汉明距离 ≤ 3 就判为重复——不新建,只给老记忆"加分"并刷新时间戳。

# 示意,非源码:simhash 去重的核心判断
simhash = compute_simhash(content) # 相似文本 → 相似 hash
existing = fetch_by_simhash(simhash) # 取最像的一条
if existing and hamming_dist(simhash, existing.simhash) <= 3:
boost_salience(existing, +0.15) # 重复:老记忆加重要度
return existing # 复用,不新建
# 否则:才走真正的新建流程

真实实现:memory/hsg.py:389(add_hsg_memory)开头,compute_simhash(:165)、hamming_dist(:200)。

坑: compute_simhash 里有一段把每个 token 的 hash 先算一遍又清零重算(:170-181 两次 h=0 循环),第一段是死代码;真正生效的是第二段带符号处理的哈希。功能正确,但可读性差。

1.3 分区:正则打分把内容归到五个脑区

要解决的小问题: "我今天很沮丧"和"Python 的 GIL 是全局解释器锁"是两类记忆,不该用同一套权重去检索。

思路: 每个脑区配一组正则(SECTOR_CONFIGS),内容命中越多、权重越高的脑区得分越高;最高分者当 primary,达到阈值的其余脑区当 additional。

五个脑区(core/constants.py:10,SECTOR_CONFIGS),关键差异在 decay_lambda(衰减快慢):

脑区装什么触发词举例decay_lambda(越大越易忘)
episodic 情景具体事件、时间today、went、saw、remember when0.015
semantic 语义事实、概念、定义is a、concept、fact、capital0.005
procedural 程序步骤、操作how to、install、click、first…then0.008
emotional 情绪感受happy、love、frustrated、!!0.02(最易忘)
reflective 反思洞察、总结realize、insight、lesson、pattern0.001(最持久)

注意设计意图:情绪最易忘、反思最持久——模拟"当下的情绪褪色快,悟出的道理留得久"。

真实实现:classify_content(memory/hsg.py:115)。若 metadata 显式给了 sector 就直接采用、confidence=1.0(:116-118);否则逐脑区正则计分、按 30% 阈值挑 additional(:120-142)。

1.4 多脑区嵌入 + 落库 + 建链

分完区,add_hsg_memory 依次做四件事(memory/hsg.py:414-466):

  1. 多脑区嵌入。 对 primary + additional 每个脑区各嵌一个向量(embed_multi_sector,memory/embed.py:45)。默认用 synthetic 适配器——无需任何 API key,靠 token 哈希 + n-gram + 位置编码在本地造向量(ai/synthetic.py:55,_gen_syn_emb)。也可切 OpenAI/Gemini/Ollama/AWS/MiniMax(emb_dispatch,embed.py:24)。
  2. 算 mean 向量。 把各脑区向量取均值当"整体指纹"(calc_mean_vec,embed.py:59),额外以 _mean 伪脑区存进向量库,给"跨脑区找最相似邻居"用(见 Issue #141 注释,hsg.py:459)。
  3. 落库。 记忆行进 memories,每个脑区向量进 vectors(SQLiteVectorStore.storeVector,vector_store.py:39,向量用 struct.pack 存 BLOB)。初始 salience = 0.4 + 0.1*len(additional)(hsg.py:430)——命中脑区越多、越重要。
  4. 建 waypoint 边。 create_single_waypoint(hsg.py:268):在向量库里搜 mean 向量最相似的一条老记忆,连一条权重=相似度的有向边;找不到就自连一条 weight=1.0 的边(保证图不空)。

2. 召回主线:searchhsg_query

hsg_query(memory/hsg.py:523)是全项目最重的函数。一步步拆:

2.1 五步流水

hsg_query(qt, k, filters)
① 缓存命中?(60s TTL)──▶ 直接返回
② 对 query 分区 + 逐脑区嵌入(embed_query_for_all_sectors)
③ 逐脑区向量检索,每区取 k*3 候选;算平均相似度 avg_top
④ 置信度低(avg_top<0.55)?──▶ expand_via_waypoints 沿联想边扩散拉更多候选
⑤ 对每个候选算 compute_hybrid_score → 排序取 top-k → 命中者强化

2.2 自适应扩散:不自信才去联想

巧妙点: 向量检索平均相似度高(avg_top >= 0.55,hsg.py:563)说明"直接命中够好",就不折腾;只有不自信时expand_via_waypoints 沿 waypoint 图 BFS 拉入联想到的记忆(:565-568)。扩散是带权重衰减的 BFS:每跳权重乘 0.8,低于 0.1 剪掉(expand_via_waypoints,hsg.py:500)。

扩张量也自适应:adapt_exp = ceil(0.3*k*(1-avg_top))(:561)——越不自信,拉越多候选。

2.3 复合打分:五个因子加权过 sigmoid

要解决的小问题: 只按余弦相似度排序,会漏掉"不那么像但很相关/很新/很重要"的记忆。

思路: 把五个信号加权求和,再过 sigmoid 压到 (0,1)。权重是硬编码常量 SCORING_WEIGHTS(hsg.py:28):

因子权重含义计算
similarity0.35向量相似(经 boosted_sim 非线性放大)boosted_sim(:162)
overlap0.20query/记忆的 canonical token 重叠比compute_token_overlap(:253)
waypoint0.15该候选在联想扩散里的边权expand_via_waypoints
recency0.10新近度(指数衰减)calc_recency_score(decay.py:104)
tag_match0.20标签与 query token 的匹配compute_tag_match_score(:80)

真实实现:compute_hybrid_score(hsg.py:258)。

打分前相似度还做了两层调整(hsg.py:584-597):

  • 跨脑区共振 calculateCrossSectorResonanceScore(ops/dynamics.py:33):用一个 5×5 脑区互依矩阵,把"记忆脑区 × query 脑区"的耦合系数乘到相似度上。
  • 跨脑区惩罚:若记忆脑区 ≠ query 脑区,再乘一个 SECTOR_RELATIONSHIPS(hsg.py:56)里的折扣(默认 0.3)。
# 示意,非源码:候选的最终分怎么来的
sim = cross_sector_resonance(mem.sector, q.sector, multi_vec_sim) # 先按脑区耦合调
if mem.sector != q.sector:
sim *= sector_penalty(q.sector, mem.sector) # 跨区再打折
score = sigmoid( # 五因子加权
0.35*boosted_sim(sim) + 0.20*token_overlap + 0.15*waypoint_w
+ 0.10*recency + 0.20*tag_match + keyword_bonus)

2.4 召回即强化(闭环)

top-k 选出后,hsg_query 立刻回写(hsg.py:638-656):

  • 命中记忆 salience 用 applyRetrievalTraceReinforcementToMemory 回升、刷新 last_seen_at
  • 若命中来自联想路径,顺着 waypoint 用 propagateAssociativeReinforcementToLinkedNodes 给邻居也加分(带时间衰减 gamma)。
  • on_query_hit(decay.py:188):进一步强化 + 必要时重嵌入被压缩过的向量。

这就是它区别于 RAG 的核心:查询不是只读的,它会改变记忆状态——用得多的记忆更容易被再召回。这条"记忆动力学"下一章细讲。


3. 关键细节 / 坑

  • synthetic 嵌入是"够用"而非"高质量"。 默认不接任何模型,靠哈希造向量(ai/synthetic.py),适合离线/演示;要真语义质量得配 EMB_KIND=openai 等。
  • 向量检索是暴力全扫。 SQLiteVectorStore.search(vector_store.py:67)把该脑区所有向量拉出来逐个算余弦,没有 ANN 索引——数据量大时是瓶颈(Postgres/Valkey 后端可缓解)。
  • hsg_query 里有重复代码。 max_seg_res 查两遍(hsg.py:419/421)、item["salience"] 赋两遍(:617/618),不影响结果但是明显冗余。
  • 缓存粒度粗。 cache_key 用 query:k:filters 拼(:527),任何写入都不主动失效对应缓存,只靠 60s TTL 过期。