跳到主要内容

存储模型:画像槽位、事件与事件 gist

30 秒导读: 上一章(02-extract-merge-pipeline.md)讲了 LLM 怎么把对话 抽成、合并成结构化记忆。这一章讲这些记忆最终存成了什么——三张核心表:画像(profile)、 事件(event)、事件 gist。看懂它,你就知道 Memobase 的"记忆"在数据库里长什么样。

本章只讲存储:表结构、字段含义、落库入口。不讲这些数据怎么被检索、拼进 prompt——那是 04-retrieval-context.md 的事。


1. 先建立直觉:记忆不是一坨向量

很多"AI 记忆"系统的做法是:把每段对话切块 → 算 embedding → 全塞进向量库,用时再语义搜索。 Memobase 偏偏不是这样。它的核心记忆是结构化的、有限的槽位

一句话类比:

  • 画像(profile) = 一张用户档案卡,栏目固定(基本信息、兴趣、工作……),每栏一句话,改的是内容不是加行
  • 事件(event) = 一本日记,按时间一条条往下记,每条带时间戳,只增不改。
  • gist = 把日记里每条拆成的单句便签,每张便签配一个 embedding,专门用来"细粒度语义搜索"。

三者分工:

记忆类型数据形态增长方式主要用途
画像 UserProfile槽位(topic/sub_topic → 一句话)就地更新,数量有界稳定的"用户是谁"
事件 UserEvent时间线(带时间戳的一条条)只追加"发生了什么、何时"
事件 gist UserEventGist单句 + embedding随事件拆分追加时间线的细粒度语义搜索

关键直觉:画像是"少而精的槽",不是"多而杂的向量堆"。 这是 Memobase 和纯向量记忆最大的区别, 也是本章要讲透的第一件事。


2. 顶层全景:三张表的关系

所有表定义在 models/database.py,都用 SQLAlchemy 的 @REG.mapped_as_dataclass 声明, 共享一个 Base(database.py:102 class Base)提供 id(UUID)、created_atupdated_at 三个公共列。 created_at 就是事件时间线的时间戳来源。

怎么读下面这张图: 箭头是外键归属(谁属于谁);UserEvent 一条可以拆出多条 UserEventGist

┌──────────────┐
│ User │ 一个用户(user_id + project_id 复合主键)
└──────┬───────┘
┌────────────────┼─────────────────┬──────────────────┐
│ │ │ │
┌───────▼──────┐ ┌──────▼───────┐ ┌──────▼───────┐ ┌──────▼──────┐
│ UserProfile │ │ UserEvent │ │ UserStatus │ │ (其它:blob/ │
│ 画像·槽位 │ │ 事件·时间线 │ │ 用户状态 │ │ buffer …) │
└──────────────┘ └──────┬───────┘ └──────────────┘ └─────────────┘
content 文本 │ 1 条事件拆成 N 条 gist
attributes JSONB: │
topic/sub_topic ▼
update_hits ┌──────────────┐
│ UserEventGist│ 单句 + embedding(向量)
│ 可搜索单元 │
└──────────────┘

部件职责一览:

表 (class)文件:符号存什么
UserProfiledatabase.py:418 class UserProfile一个画像槽位:一句话内容 + topic/sub_topic 归类
UserEventdatabase.py:458 class UserEvent一条事件:提示语 + 标签 + 本次画像变更,带 embedding
UserEventGistdatabase.py:514 class UserEventGist事件拆出的单句,带 embedding,归属某条 UserEvent
UserStatusdatabase.py:589 class UserStatus用户的某类状态(type + attributes JSON)

复合主键注意: 这几张表的主键都是 (id, project_id),外键都按 (user_id, project_id) 关联到 users(见各表 __table_args__ 里的 ForeignKeyConstraint,如 database.py:449)。project_id 是贯穿全库的租户维度。本章后文为聚焦记忆语义,省略 project_id 不再逐次强调。


3. 画像:有限的 topic/sub_topic 槽位

3.1 它要解决的问题

如果每次对话都往库里加一条"用户喜欢咖啡",很快就会积累成千上万条重复、矛盾的碎片。Memobase 的 答案是:把"用户是谁"压进一组有限的槽位,同一个槽位反复更新,而不是无限追加。

3.2 表结构

UserProfile(database.py:418)只有两个语义字段:

字段类型含义
contentTEXT (database.py:423)这个槽位的内容,一句话,如"喜欢喝手冲咖啡"
attributesJSONB (database.py:431)归类与元数据:topic / sub_topic / update_hits

槽位由 attributes 里的 topic + sub_topic 定义。 画像不是靠向量区分,而是靠这对分类标签。 ProfileAttributes(models/response.py:66)明确要求二者必填:

# models/response.py:66 —— 画像归类的最小契约
class ProfileAttributes(BaseModel):
topic: str # 大类,如 "interests"
sub_topic: str # 子类,如 "drink"

落库前会用这个模型校验 attributes(controllers/profile.py:246,add_update_delete_user_profilesProfileAttributes.model_validate(attr))。所以每个画像槽位一定带着一对 topic::sub_topic 坐标。

3.3 update_hits:同一槽位被命中几次

attributes 里还有个 update_hits(常量名见 env.py:54 update_hits = "update_hits")。当合并管线判定 一条新信息应当更新或追加到某个已存在的槽位时,就把这个计数 +1:

# controllers/modal/chat/merge.py:167 —— 命中已有槽位则累加 update_hits
if ContanstTable.update_hits not in runtime_profile.attributes:
runtime_profile.attributes[ContanstTable.update_hits] = 1
else:
runtime_profile.attributes[ContanstTable.update_hits] += 1

它记录"这个槽位被反复提到了多少次",可作为该记忆置信度/重要度的粗略信号(merge.pymerge_yolo.py 里 UPDATE、APPEND 两个分支都会累加)。合并逻辑本身属于第 02 章;这里只需知道:它就是画像 attributes 里的一个整数字段。

3.4 落库入口:add/update/delete 三合一

画像的增改删,统一走一个函数 add_update_delete_user_profiles(controllers/profile.py:226)。它在一个 数据库事务里依次做三件事:

add_update_delete_user_profiles (controllers/profile.py:226)

├─ 1. 校验所有 attributes(ProfileAttributes) profile.py:246

└─ 一个 Session 事务:
├─ 新增:session.add_all(UserProfile(...)) profile.py:261
├─ 更新:改 db_profile.content / .attributes profile.py:291
└─ 删除:query(...).filter(id.in_(...)).delete profile.py:297
commit() 失败则 rollback() profile.py:303/310

谁调用它: 合并管线在 controllers/modal/chat/__init__.py:226handle_user_profile_db 把 LLM 算出的 中间结果 MergeAddResult(含 add / update / delete 三份列表)摊平成参数,再调这个函数:

# controllers/modal/chat/__init__.py:235 —— 合并结果 → 落库
p = await add_update_delete_user_profiles(
user_id, project_id,
[ap["content"] for ap in intermediate_profile["add"]], # 新增内容
[ap["attributes"] for ap in intermediate_profile["add"]], # 新增归类
[up["profile_id"] for up in intermediate_profile["update"]], # 要改哪些槽
[up["content"] for up in intermediate_profile["update"]],
[up["attributes"] for up in intermediate_profile["update"]],
intermediate_profile["delete"], # 要删的 id
)

要点:更新走的是"改已有行的 content/attributes"(profile.py:291),不是插新行。 这正是"槽位反复更新 而非无限追加"在代码层的体现,也是画像数量有界的根本原因。

落库成功后会清 Redis 缓存(refresh_user_profile_cache,profile.py:220)。读取路径与缓存留到 04 讲。


4. 事件:带时间戳的时间线

4.1 一条事件是什么

画像回答"用户是谁",事件回答"发生了什么、何时"。UserEvent(database.py:458)只有一个语义列 event_data(JSONB,database.py:463)外加一个 embedding 向量列(database.py:484)。 时间戳来自基类的 created_at(database.py:112),读取时正是按它倒序取(见 controllers/event.py:35 order_by(UserEvent.created_at.desc()))——这就是"时间线"的由来。

event_data 的形状由 EventData(models/response.py:103)定义,三个可选字段:

字段类型含义
event_tipstr本次会话的记忆提示语(memo),人类可读的一段/几行要点
event_tagslist[EventTag]结构化标签,每个是 {tag, value}(response.py:94)
profile_deltalist[ProfileDelta]本次事件带来的画像变更:每项含 content + attributes(response.py:86)

profile_delta 是个巧妙设计:事件不仅记"发生了什么",还内嵌一份"这次让画像变了哪些槽"的快照。 于是事件既是时间线,又留下了画像演化的痕迹。

4.2 落库入口:append_user_event

事件只追加、不就地改写,入口是 append_user_event(controllers/event.py:68)。它做三步:

append_user_event (controllers/event.py:68)

├─ 1. 用 EventData 校验入参 event.py:72

├─ 2. 若开启 event embedding:
│ event_embedding_str(event) → get_embedding event.py:84
│ (把 event_tip/profile_delta/tags 拼成一段文本再算向量)

├─ 3. 拆 gist:把 event_tip 按行拆成以 "-" 开头的单句 event.py:113-118
│ 每句各算一个 embedding event.py:119

└─ 一个 Session 事务:
├─ session.add(UserEvent(event_data=..., embedding=...)) event.py:145
└─ 逐条 session.add(UserEventGist(event_id=user_event.id, event.py:152
gist_data=..., embedding=...))
commit()

上游怎么喂它:合并管线在 handle_session_event(controllers/modal/chat/__init__.py:213)把三样东西 打包成 dict 传进来:

# controllers/modal/chat/__init__.py:213 —— 组装一条事件并追加
eid = await append_user_event(
user_id, project_id,
{
"event_tip": memo_str, # 本轮记忆提示语
"event_tags": event_tags, # 结构化标签
"profile_delta": delta_profile_data, # 这次的画像变更
},
)

注意第 3 步:一次 append_user_event 同时写了一条 UserEvent 和它派生的多条 UserEventGist,在同一个 事务里 commit(event.py:162)。 event 和 gist 是"整条 vs 拆句"的父子关系。


5. 事件 gist:可搜索的最小单元

5.1 为什么还要 gist

一条 UserEventevent_tip 往往是多行要点(如"- 用户换了工作\n- 现在住在上海")。如果只给整条事件 算一个 embedding,语义搜索的粒度太粗。Memobase 的做法是把 event_tip 按行拆成单句,每句单独存一条 UserEventGist 并各配一个 embedding——gist 才是时间线上真正"可被细粒度语义搜索"的单元。

5.2 拆分规则

拆分逻辑在 append_user_event 里(controllers/event.py:113):

# controllers/event.py:113 —— 把 event_tip 按行拆成 gist
if validated_event.event_tip is not None:
event_gists = validated_event.event_tip.split("\n")
# 只保留以 "-" 开头的行(即一条条要点)
event_gists = [l.strip() for l in event_gists if l.strip().startswith("-")]

每条保留下来的要点,连同它的 embedding,存成一条 gist:

# controllers/event.py:137 —— 每句一条 gist(内容放进 gist_data.content)
event_gist_dbs.append({
"gist_data": {"content": event_gist},
"embedding": event_gist_embedding,
})

5.3 表结构

UserEventGist(database.py:514)的字段:

字段类型含义
gist_dataJSONB (database.py:519){content: "单句要点"},形状见 EventGistData (response.py:99)
event_idUUID (database.py:522)归属的父事件,外键指向 user_events (database.py:575)
embeddingVector (database.py:553)这句的向量,维度 = CONFIG.embedding_dim

embedding 用的是 pgvector 的 Vector 类型(from pgvector.sqlalchemy import Vector,database.py:40), 维度在启动时会和数据库实际列做一致性校验(UserEventGist.check_legal_embedding_dim,database.py:583)。

5.4 gist 的存储与生成在哪

gist 的生成+写入append_user_event 里随事件一起完成(上一节)。controllers/event_gist.py 里则是 gist 的读取/搜索面,和存储对照着看能确认 gist 就是"可搜索单元":

  • get_user_event_gists(event_gist.py:15):按 created_at 倒序取近 N 天的 gist——把 gist 当时间线读。
  • search_user_event_gists(event_gist.py:65):对 UserEventGist.embedding 做余弦相似度检索 (event_gist.py:99 1 - UserEventGist.embedding.cosine_distance(query_embedding))——gist 是向量搜索 真正命中的对象。

搜索/拼装的完整用法留给 04-retrieval-context.md;这里只确认一件事:embedding 挂在 gist 上,gist 是细粒度语义搜索的落点。


6. 三者关系一句话收束

一次对话处理完(见 02 章的抽取-合并管线)会同时:

① 更新画像槽位 ──► add_update_delete_user_profiles ──► UserProfile 表
(改已有 topic/sub_topic 槽的 content;update_hits +1) [就地更新,有界]

② 追加一条事件 ──► append_user_event ──► UserEvent 表
(event_tip + event_tags + profile_delta,带时间戳) [只追加,时间线]

③ 同时把该事件的 event_tip 拆成单句 ──► UserEventGist 表
(每句 + embedding) [可向量搜索的最小单元]

画像 = 你是谁(少而精的槽) · 事件 = 发生了什么(带时间戳) · gist = 事件的可搜索碎片
  • 画像回答"用户是谁",是有限槽位,靠 topic/sub_topic 归类、靠就地更新保持有界——不是向量堆
  • 事件回答"何时发生了什么",是只追加的时间线,还内嵌 profile_delta 记下这次画像怎么变的。
  • gist是事件拆出的单句 + embedding,是细粒度语义搜索真正命中的单元。

7. 边界与本章不覆盖的

  • 不讲检索/拼装: 画像的截断排序(truncate_profiles,profile.py:10)、事件与 gist 的语义搜索 (search_user_events / search_user_event_gists)、以及如何拼成低延迟 context——全在 04-retrieval-context.md
  • 不讲抽取/合并: 画像的 add/update/delete 决策、event_tip/profile_delta 怎么被 LLM 算出来——在 02-extract-merge-pipeline.md
  • UserStatus 只作背景提及: type + attributes(database.py:589),不是本章三主角,未展开。
  • embedding 可选: 上面所有 embedding 步骤都以 CONFIG.enable_event_embedding 为开关 (event.py:84);关闭时 gist 仍会写入,只是 embeddingNone,语义搜索不可用。

8. 代码地图(导航索引)

主题文件路径符号名
画像表(槽位)src/server/api/memobase_server/models/database.pyUserProfile
事件表(时间线)src/server/api/memobase_server/models/database.pyUserEvent
事件 gist 表(可搜索单元)src/server/api/memobase_server/models/database.pyUserEventGist
用户状态表src/server/api/memobase_server/models/database.pyUserStatus
公共列(id/created_at/updated_at)src/server/api/memobase_server/models/database.pyBase
画像归类契约(topic/sub_topic)src/server/api/memobase_server/models/response.pyProfileAttributes
事件 payload 契约src/server/api/memobase_server/models/response.pyEventData / EventTag / ProfileDelta
gist 内容契约src/server/api/memobase_server/models/response.pyEventGistData
画像增改删落库入口src/server/api/memobase_server/controllers/profile.pyadd_update_delete_user_profiles
合并结果 → 画像落库src/server/api/memobase_server/controllers/modal/chat/__init__.pyhandle_user_profile_db
update_hits 累加src/server/api/memobase_server/controllers/modal/chat/merge.pyupdate_hits 分支
事件追加(含拆 gist)src/server/api/memobase_server/controllers/event.pyappend_user_event
组装并追加一条事件src/server/api/memobase_server/controllers/modal/chat/__init__.pyhandle_session_event
gist 读取/搜索src/server/api/memobase_server/controllers/event_gist.pyget_user_event_gists / search_user_event_gists
embedding 维度校验src/server/api/memobase_server/models/database.pycheck_legal_embedding_dim