7ae7f7be67
审计三真桩之一。记忆召回此前打分只有 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>
90 lines
2.6 KiB
Go
90 lines
2.6 KiB
Go
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)
|
|
}
|
|
}
|