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

85 lines
2.7 KiB
Go

package memory
import (
"context"
"math"
"testing"
"time"
)
// 向量编解码往返:float32 打包进 bytea 再取回不失真。
func TestVecRoundTrip(t *testing.T) {
v := []float32{0.1, -0.5, 1.0, 0, 0.333}
got := decodeVec(encodeVec(v))
if len(got) != len(v) {
t.Fatalf("长度不符: %d vs %d", len(got), len(v))
}
for i := range v {
if got[i] != v[i] {
t.Errorf("第%d个失真: %v vs %v", i, got[i], v[i])
}
}
// 脏数据(长度非 4 倍数)返回 nil,不 panic。
if decodeVec([]byte{1, 2, 3}) != nil {
t.Error("非法字节应返回 nil")
}
if decodeVec(nil) != nil {
t.Error("空返回 nil")
}
}
// cosine01:同向=1,正交=0.5→截到实际(正交余弦0→0),反向截到 0。
func TestCosine01(t *testing.T) {
a := []float32{1, 0, 0}
if c := cosine01(a, a); math.Abs(c-1) > 1e-6 {
t.Errorf("同向应为 1,得 %v", c)
}
if c := cosine01(a, []float32{0, 1, 0}); c != 0 {
t.Errorf("正交余弦 0,得 %v", c)
}
if c := cosine01(a, []float32{-1, 0, 0}); c != 0 {
t.Errorf("反向应截到 0(不奖励相反语义),得 %v", c)
}
if c := cosine01(a, []float32{0, 0, 0}); c != 0 {
t.Errorf("零向量应为 0,得 %v", c)
}
}
// 三项打分模式(rel 非 nil):相关性高的排到前面,即使重要度/最近性略低。
func TestRankProfiles_RelevanceMode(t *testing.T) {
now := time.Now()
rows := []Profile{
{BaseModel: BaseModel{ID: "a"}, Key: "无关但重要", Importance: 10, LastSeenAt: now},
{BaseModel: BaseModel{ID: "b"}, Key: "高度相关", Importance: 3, LastSeenAt: now},
}
rel := map[string]float64{"a": 0.05, "b": 0.95} // b 语义强相关
// 两项模式(rel=nil):a(重要度10)应在前。
if got := rankProfiles(rows, now, 0, nil); got[0].ID != "a" {
t.Errorf("两项模式重要度高者应在前,得 %s", got[0].ID)
}
// 三项模式:b 相关性 0.95 主导,应翻到前面。
if got := rankProfiles(rows, now, 0, rel); got[0].ID != "b" {
t.Errorf("三项模式相关性高者应在前,得 %s", got[0].ID)
}
}
// relevance 优雅降级:无 embedder / query 空 → 返回 nil(不报错、不影响召回)。
func TestRelevance_GracefulFallback(t *testing.T) {
s := &Store{} // 无 embedder
if s.relevance(context.Background(), []Profile{{Key: "k"}}, "问题") != nil {
t.Error("无 embedder 应返回 nil")
}
s.emb = stubEmbedder{}
if s.relevance(context.Background(), []Profile{{Key: "k"}}, " ") != nil {
t.Error("query 空白应返回 nil(不白算嵌入)")
}
}
// stubEmbedder 返回固定维度向量,供降级测试(不打真网络)。
type stubEmbedder struct{}
func (stubEmbedder) Embed(_ context.Context, _ []string) ([][]float32, error) {
return [][]float32{{1, 0}}, nil
}