Files
Blizzard 7ae7f7be67 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>
2026-07-18 12:47:55 +08:00

51 lines
1.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package memory
import (
"testing"
"time"
)
func TestRankProfiles(t *testing.T) {
now := time.Date(2026, 6, 22, 12, 0, 0, 0, time.UTC)
rows := []Profile{
{Key: "高重要近期", Value: "a", Importance: 9, LastSeenAt: now.AddDate(0, 0, -1)},
{Key: "低重要久远", Value: "b", Importance: 2, LastSeenAt: now.AddDate(0, 0, -60)},
{Key: "中等", Value: "c", Importance: 5, LastSeenAt: now.AddDate(0, 0, -10)},
}
ranked := rankProfiles(rows, now, 0, nil)
if ranked[0].Key != "高重要近期" || ranked[2].Key != "低重要久远" {
t.Errorf("应按 Score 降序:高重要近期 > 中等 > 低重要久远,得 %s/%s/%s", ranked[0].Key, ranked[1].Key, ranked[2].Key)
}
// 截断 top-N
if got := rankProfiles(rows, now, 2, nil); len(got) != 2 || got[0].Key != "高重要近期" {
t.Errorf("top-2 截断错: %d 条 首=%s", len(got), got[0].Key)
}
// 原切片不被改动(rankProfiles 应 copy
if rows[0].Key != "高重要近期" {
t.Error("rankProfiles 不应修改入参顺序")
}
}
func TestRecencyDecay(t *testing.T) {
now := time.Date(2026, 6, 22, 12, 0, 0, 0, time.UTC)
if recencyScore(now, time.Time{}) != 1.0 {
t.Error("无 last_seen 应视为新鲜=1")
}
fresh := recencyScore(now, now.AddDate(0, 0, -1))
old := recencyScore(now, now.AddDate(0, 0, -30))
if !(fresh > old && old > 0) {
t.Errorf("越久越低且 >0: fresh=%v old=%v", fresh, old)
}
}
func TestProfileScore_DefaultImportance(t *testing.T) {
now := time.Now()
// importance=0(旧/未评分)应按兜底 5 计,而不是 0(否则被不公平遗忘)。
p := Profile{Importance: 0, LastSeenAt: now}
got := profileScore(p, now, nil)
want := wRecency*1.0 + wImportance*(defaultImportance/10)
if got != want {
t.Errorf("未评分行应用兜底 importance: got %v want %v", got, want)
}
}