feat(memory): 记忆召回加 Relevance —— Generative Agents 打分补齐第三项 (P1)

审计三真桩之一。记忆召回此前打分只有 Recency+Importance,缺 Relevance(对当前
任务的语义相关性)——注释写"待接 Milvus",但召回时甚至不知道当前问什么。

关键发现:dispatcher 注入点 fetchMemory(ctx,uid,_) 手上已有当前任务文本(b.query),
只是被 `_` 丢弃了。所以不是"接 Milvus"那么重,把 query 一路传下去 + 缓存嵌入即可。

设计(偏离注释的"接 Milvus"——用户偏好量小,不值当上向量库):
- Profile 加 embedding 列(float32 小端打包存 bytea);Upsert 时对 value 向量化缓存
  (value 没变不重算,失败留空不阻断)。
- memory 包定义 Embedder 小接口,gateway 注入 rag.Engine(复用同一控制面下发的
  embedding 模型),不硬依赖 rag 内部;rag.Engine 加导出 Embed 方法。
- memory_get 工具加可选 query 入参;fetchMemory 停止丢弃 b.query 传下去。
- Get(ctx,uid,query):query 非空且 embedder 就绪 → embed(query) 对每条缓存向量
  内存算余弦 → 三项打分 0.25R+0.35I+0.4Rel;否则回落两项(升级前行为)。
- 优雅降级贯穿:无 query/无 embedder/query 嵌入失败/行无向量 → 静默回落,绝不报错。
  零 Milvus 依赖、零向量库同步问题、保住"没 embedding 也能跑"。

验证:单测(编解码往返/cosine 截0/三项模式相关性翻转顺序/降级返 nil)+ 端到端
(真 PG:写入即向量化、query=咖啡把低重要度的咖啡记忆翻到运动前面)。migration
加列已 live;embedding 复用 RAG 已验证基建。三模块 build/vet/test 全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-18 12:47:55 +08:00
parent a742d118ad
commit 7ae7f7be67
8 changed files with 314 additions and 22 deletions
+11 -2
View File
@@ -60,6 +60,11 @@ type toolDef struct {
func NewGateway(b *sharedbus.Bus, m *memory.Store, h *history.Store, r *rag.Engine, pgDSN string) *Gateway {
g := &Gateway{bus: b, memory: m, history: h, rag: r, pgDSN: pgDSN}
// 记忆 Relevance 复用 RAG 的 embedding(同一控制面下发的模型):召回时对 query 算语义相关性。
// rag.Engine 满足 memory.Embedder;未配置 embedding 时 memory 优雅回落两项打分。
if m != nil && r != nil {
m.SetEmbedder(r)
}
g.tools = g.buildRegistry()
return g
}
@@ -98,7 +103,10 @@ func (g *Gateway) buildRegistry() map[string]toolDef {
},
"memory_get": {
cn: "记忆召回", desc: "召回当前用户的长期画像与偏好(称呼/职业/回答偏好等)。需要个性化、了解“我是谁”时调用。",
agent: true, agentName: "recall_user_memory", inject: []string{"user_id"}, handler: g.memoryGet,
agent: true, agentName: "recall_user_memory",
// query 可选:给了则按对当前任务的语义相关性优先召回(Relevance),不给则按最近性+重要度。
params: []paramSpec{{Name: "query", Type: "string", Desc: "当前任务/问题文本,用于按相关性优先召回(可选)", Required: false}},
inject: []string{"user_id"}, handler: g.memoryGet,
},
"memory_upsert": {
cn: "记忆写入", desc: "把关于用户的一条事实/偏好长期记住(如称呼、职业、回答偏好)。",
@@ -223,7 +231,8 @@ func (g *Gateway) listTools() *contract.ToolResult {
// memoryGet 召回某用户的常驻画像(已渲染为可注入 prompt 的多行文本)。
func (g *Gateway) memoryGet(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
profile, err := g.memory.Get(ctx, uid)
query, _ := call.Args["query"].(string) // 可选:按对当前任务的语义相关性优先召回
profile, err := g.memory.Get(ctx, uid, query)
if err != nil {
return &contract.ToolResult{OK: false, Error: "memory_get: " + err.Error()}
}