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
@@ -0,0 +1,89 @@
package memory
import (
"context"
"hash/fnv"
"math"
"os"
"strings"
"testing"
)
// detEmbedder:确定性伪嵌入(同文本同向量;含指定关键词的文本在对应维度更高)。
// 只为端到端验证「本包代码路径」——Upsert 存向量 → Get 算余弦重排 —— 对真 PG,
// 不打真 embedding 网络(那是 rag 包已验证的复用基建)。
type detEmbedder struct{}
func (detEmbedder) Embed(_ context.Context, texts []string) ([][]float32, error) {
const dim = 16
kw := []string{"咖啡", "coffee", "运动", "健身", "音乐"}
out := make([][]float32, len(texts))
for i, t := range texts {
v := make([]float32, dim)
for _, w := range kw {
if strings.Contains(t, w) {
h := fnv.New32a()
_, _ = h.Write([]byte(w))
v[h.Sum32()%dim] += 1
}
}
var n float64
for _, x := range v {
n += float64(x) * float64(x)
}
if n > 0 {
for j := range v {
v[j] = float32(float64(v[j]) / math.Sqrt(n))
}
}
out[i] = v
}
return out, nil
}
// 端到端(真 PG):写两条记忆(咖啡 importance=3 / 运动 importance=8)→
// 无 query 时运动(重要度高)在前;query="咖啡"时咖啡(语义相关)翻到前面。
func TestRelevance_EndToEnd(t *testing.T) {
dsn := os.Getenv("MEMORY_TEST_DSN")
if dsn == "" {
t.Skip("设 MEMORY_TEST_DSN 启用 Postgres 端到端测试")
}
s := Open(dsn)
if s.db == nil {
t.Fatal("Store 不应降级")
}
s.SetEmbedder(detEmbedder{})
ctx := context.Background()
uid := "memtest-relevance-e2e"
defer func() {
_ = s.Delete(ctx, uid, "饮品偏好")
_ = s.Delete(ctx, uid, "运动习惯")
}()
if err := s.Upsert(ctx, uid, "饮品偏好", "喜欢手冲咖啡 coffee 不加糖", 3); err != nil {
t.Fatalf("upsert 咖啡: %v", err)
}
if err := s.Upsert(ctx, uid, "运动习惯", "每天健身运动一小时", 8); err != nil {
t.Fatalf("upsert 运动: %v", err)
}
// 确认写入即向量化:embedding 列非空。
var rows []Profile
s.db.WithContext(ctx).Where("user_id = ?", uid).Find(&rows)
for _, r := range rows {
if len(r.Embedding) == 0 {
t.Errorf("%s 应已向量化(embedding 非空)", r.Key)
}
}
// 无 query:两项打分,运动(importance 8)在前。
noQ, _ := s.Get(ctx, uid, "")
if !strings.HasPrefix(noQ, "- 运动习惯") {
t.Errorf("无 query 应重要度优先(运动在前),得:\n%s", noQ)
}
// query 咖啡:三项打分,咖啡语义相关翻到前面(尽管重要度更低)。
withQ, _ := s.Get(ctx, uid, "推荐一款好喝的咖啡 coffee")
if !strings.HasPrefix(withQ, "- 饮品偏好") {
t.Errorf("query=咖啡 应相关性优先(咖啡在前),得:\n%s", withQ)
}
}