ENZH
和 AI 讨论这篇文章
ChatGPTClaude

关于人的记忆,得单独建一张表

📊 幻灯片

记忆碎片聚合为完整人物档案的概念插画记忆碎片聚合为完整人物档案的概念插画

先讲问题出在哪

第三篇里我说过,记忆系统是 v2 里最值钱的一块,从 v1 原封不动搬过来的。这个说法本身没错,但后来发现它有一个挺大的盲区,这篇具体讲讲这个盲区是怎么回事,以及我们是怎么修的。

先说旧系统的做法。所有东西都存在一张 memories 表里,每条记忆带一个 768 维的 embedding。用户说「我喜欢吃辣」,就存一条;下次要查「用户口味偏好」,余弦相似度一搜就搜到了。这套机制对偏好类的信息是够用的。

但关于「人」的信息不是这么回事。比如用户在不同对话里提过关于小红的五件事:在腾讯当工程师、养了一只猫、最近加班很多、上周跟男朋友吵架了、下个月来北京。这五条记忆散落在表里,彼此之间没有任何关联。你想查「关于小红的所有信息」,唯一的办法是拿 ILIKE '%小红%' 去全表扫一遍。

更麻烦的是语义层面的问题。「小红最近怎么样了」和「小红在腾讯当工程师」这两句话,在 embedding 空间里几乎没有相似度——一个是在问近况,一个是在描述职业,向量方向完全不一样,语义搜索根本搜不到。也就是说,模型明明存过关于小红的信息,用户问起来的时候它拿不出来,表现出来就跟忘了一样。对一个主打记忆的伴侣产品来说,这 bug 挺尴尬的。

方案具体是这样的

思路不复杂:给「人」单独建一张 contacts 表,把用户生活里的人变成有索引的实体,不再是散落在记忆表里的事实碎片。说白了就是把人当成一个实体来存,而不是当成一堆互相不认识的行。

关于一个人的记忆从散落无关联的行,聚合成一个有结构关联的联系人实体关于一个人的记忆从散落无关联的行,聚合成一个有结构关联的联系人实体

CREATE TABLE contacts (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id       UUID NOT NULL REFERENCES users(id),
  name          TEXT NOT NULL,
  relationship  TEXT,           -- "同事", "大学室友", "妈妈"
  description   TEXT,           -- LLM 生成的性格/近况描述
  attributes    JSONB DEFAULT '{}',  -- 结构化数据:职业、城市、生日...
  memory_ids    UUID[] DEFAULT '{}', -- 关联的记忆 ID
  created_at    TIMESTAMPTZ DEFAULT NOW(),
  updated_at    TIMESTAMPTZ DEFAULT NOW()
);

这张表里最关键的是 memory_ids 这个 UUID 数组,它把联系人和相关的记忆做了结构化关联,查的时候直接按 ID 拿,不用再靠 ILIKE 扫全表。attributes 是个 JSONB 字段,存 LLM 从对话里提取出来的结构化信息,职业、所在城市这些。

整条管道分三个阶段

用户说一句「小红最近工作压力很大」,后面会发生三件事:

记忆管道分三个阶段,各按不同频率触发:提取、合并、检索记忆管道分三个阶段,各按不同频率触发:提取、合并、检索

  1. 提取(大概每 10 条消息触发一次)。MemoryAccumulator 照常提取记忆,现在多了一步:检查子类型。检测到这条记忆跟人有关,就跑 syncContacts()——upsert 一条联系人记录,把新的 memory ID 关联上去。

  2. 合并(每 50 条消息触发一次)。PersonConsolidator 把每个联系人关联的所有记忆碎片拉出来,让 LLM 生成一份结构化档案:性格描述、关键属性、时间线。生成完之后,把源碎片的重要性降到 0.05,不让它们再跟合并后的档案抢搜索结果。

  3. 检索(每条消息都跑)。用户消息里提到联系人的名字,aggregator 检测到匹配,就构建一张「联系人卡片」直接注入 system prompt,完全绕过语义搜索。

联系人卡片长这样:

[联系人: 小红]
关系: 大学室友
描述: 在腾讯当前端工程师,养了一只橘猫叫豆豆。
最近加班多,上周跟男朋友吵了一架。下个月来北京出差。
属性: {职业: "前端工程师", 公司: "腾讯", 城市: "深圳"}

直接塞进 system prompt,模型立刻知道小红是谁,也就不存在「语义搜索搜不搜得到」这个问题了。反正信息直接在上下文里,不用再搜。

检索分两步

实际查询的时候,策略分两步:

// 第一步:通过 contacts 表拿关联的 memory ID(O(1) 查找)
const contact = await db.contacts.findByName(userId, mentionedName)
const linkedMemories = await db.memories.findByIds(contact.memory_ids)

// 第二步:ILIKE 兜底,抓未关联的记忆
const unlinkedMemories = await db.memories.findByContent(
  userId, mentionedName,
  { exclude: contact.memory_ids }
)

第一步是精确的:通过联系人表直接拿到关联的 memory ID,O(1)。第二步是兜底:ILIKE 扫一遍,把那些还没被关联上的记忆也抓回来。为什么要留这个兜底?因为有些记忆是在 syncContacts() 上线之前创建的,还有一些是同步失败漏掉的。反正随着越来越多的记忆被正确关联,这个兜底被触发的次数会越来越少。

踩过的坑

这块上线过程里踩了不少坑,都是真实发生过的 bug,挨个记一下。

数组下标错位,这个最严重。storedIds[i]personMemories[i] 对不上——storedIds 是混合 ADD/UPDATE 操作的返回结果,顺序跟输入数组不一致。结果就是错误的记忆被关联到了错误的联系人身上。后来改成从数据库查 content 到 ID 的映射,不再依赖数组下标。

UUID 空数组崩溃。空的 UUID 数组传给 Postgres 的 ANY() 操作符会报类型推断错误。拆成两个独立查询,数组为空就直接跳过第一步。

重要性地板设太高GREATEST(0.1, importance * 0.5) 的本意是给被合并的记忆降权,但 0.1 这个下限还是太高,碎片在搜索结果里一直冒出来,跟合并后的档案抢位置。直接改成 0.05。

CJK 截断.slice(0, 100) 会在多字节中文的中间切一刀,切出乱码。改成 [...text].slice(0, 100).join(''),按字符截,不按字节截。

重复合并。合并器会反复处理已经合并过的记忆碎片,每次都生成一份新档案。加了个 (metadata->>'consolidated_into') IS NULL 的过滤条件,只处理还没被合并过的。

还没解决的问题

也有一堆问题现在还没解决,先列在这里:

  • 联系人匹配是精确字符串匹配,没有模糊匹配,「小红」和「红红」不会被识别成同一个人
  • 代词解析没做——「她最近怎么样了」里的「她」不会被解析成「小红」。联系人卡片注入 system prompt 之后模型自己能猜,但前提是用户在同一轮对话里显式提过名字
  • 合并器是 O(N²) 的,要比较每一对联系人的记忆集合,500+ 条记忆之后开始变慢
  • PostgreSQL 内置的分词器对中文基本没用,全文索引在中文场景形同虚设
  • 并发提取有竞态条件:两个并行的提取任务可能同时给同一个人创建重复的联系人记录

可扩展性大概算一下:假设用户每天 20 条消息,一年积累 600 条左右的记忆,三年就是 1800 条。O(N²) 的合并器在到那个量级之前就会吃力。这个还没解决,先记着。


本文也有 English version

和 AI 讨论这篇文章
ChatGPTClaude

订阅更新

新文章发布时发到你的邮箱,不发别的。


© Xingfan Xia 2024 - 2026 · CC BY-NC 4.0