数据截至 (上游 commit 358c16bbc6d6)
存储模型:画像槽位、事件与事件 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_at、updated_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) | 文件:符号 | 存什么 |
|---|---|---|
| UserProfile | database.py:418 class UserProfile | 一个画像槽位:一句话内容 + topic/sub_topic 归类 |
| UserEvent | database.py:458 class UserEvent | 一条事件:提示语 + 标签 + 本次画像变更,带 embedding |
| UserEventGist | database.py:514 class UserEventGist | 事件拆出的单句,带 embedding,归属某条 UserEvent |
| UserStatus | database.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)只有两个语义字段:
| 字段 | 类型 | 含义 |
|---|---|---|
content | TEXT (database.py:423) | 这个槽位的内容,一句话,如"喜欢喝手冲咖啡" |
attributes | JSONB (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_profiles
里 ProfileAttributes.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.py、merge_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:226 的 handle_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_tip | str | 本次会话的记忆提示语(memo),人类可读的一段/几行要点 |
event_tags | list[EventTag] | 结构化标签,每个是 {tag, value}(response.py:94) |
profile_delta | list[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
一条 UserEvent 的 event_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_data | JSONB (database.py:519) | {content: "单句要点"},形状见 EventGistData (response.py:99) |
event_id | UUID (database.py:522) | 归属的父事件,外键指向 user_events (database.py:575) |
embedding | Vector (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:991 - 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 仍会写入,只是embedding为None,语义搜索不可用。
8. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 画像表(槽位) | src/server/api/memobase_server/models/database.py | UserProfile |
| 事件表(时间线) | src/server/api/memobase_server/models/database.py | UserEvent |
| 事件 gist 表(可搜索单元) | src/server/api/memobase_server/models/database.py | UserEventGist |
| 用户状态表 | src/server/api/memobase_server/models/database.py | UserStatus |
| 公共列(id/created_at/updated_at) | src/server/api/memobase_server/models/database.py | Base |
| 画像归类契约(topic/sub_topic) | src/server/api/memobase_server/models/response.py | ProfileAttributes |
| 事件 payload 契约 | src/server/api/memobase_server/models/response.py | EventData / EventTag / ProfileDelta |
| gist 内容契约 | src/server/api/memobase_server/models/response.py | EventGistData |
| 画像增改删落库入口 | src/server/api/memobase_server/controllers/profile.py | add_update_delete_user_profiles |
| 合并结果 → 画像落库 | src/server/api/memobase_server/controllers/modal/chat/__init__.py | handle_user_profile_db |
| update_hits 累加 | src/server/api/memobase_server/controllers/modal/chat/merge.py | update_hits 分支 |
| 事件追加(含拆 gist) | src/server/api/memobase_server/controllers/event.py | append_user_event |
| 组装并追加一条事件 | src/server/api/memobase_server/controllers/modal/chat/__init__.py | handle_session_event |
| gist 读取/搜索 | src/server/api/memobase_server/controllers/event_gist.py | get_user_event_gists / search_user_event_gists |
| embedding 维度校验 | src/server/api/memobase_server/models/database.py | check_legal_embedding_dim |