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) 按 UTF-16 码元截,碰上 emoji、生僻字这种占两个码元的字符,会从中间切开,切出乱码。改成 [...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