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:
@@ -0,0 +1,84 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user