Files
HomeAgent/internal/memory/scene_relevance_test.go
JianFeeeee b2938ed4a7 fix(memory): 场景召回按块向量余弦排序 + 召回 query 可见性 + 对话回执放宽
① 场景召回是每轮常驻注入,此前按「场景权重→时间倒序」排,与 query 无关:
   老图库的 rt/rtchild/inputch 等历史事实常年霸榜,实测 1833 字符里九成是它们,
   刚聊的群聊内容一句没有 —— 这就是"忘事"的机制。
   块里本来就存着向量,相关性就是一次余弦比较:RecallByScene 带出端点/原句块
   向量,Indexer 按 max(cos) 排序并设 0.20 下限,取前 8。
   无稠密空间时保持原顺序(不能用字面匹配冒充语义 —— 既有判据要求
   「措辞与记忆零字面重合时场景路必须兜住」)。

② recall query 可见性:cleaner 缺失/生效/回空三种情况都打日志。此前静默回退
   原文,"召回 query 其实是工具原始回执"这件事在日志里完全看不到。
   实测 qq_get_message: 199 -> 27 bytes(cleaner 生效)。

③ 对话正文类工具(get_message/get_history/list_chats)时间线渲染放宽到 400 字
   (160 字会把群聊上下文切成碎片)。

判据:internal/memory/scene_relevance_test.go
2026-10-27 13:25:25 +08:00

73 lines
2.8 KiB
Go
Raw 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
// 场景召回必须按**块向量余弦**与当前 query 相关,而不是按历史热度/时间。
//
// 现场(2026-10-27):群里聊平板/机型,注入的 1833 字记忆里九成是
// `rt 标签 / rtchild / inputch` 这类老图库高频三元组 —— 模型"忘事"就是
// 这么来的。块里本来就存着向量,所以相关性只是一次余弦比较。
import (
"math"
"strings"
"testing"
)
func TestDenseCosineBasics(t *testing.T) {
if got := denseCosine([]float64{1, 0}, []float64{1, 0}); math.Abs(got-1) > 1e-9 {
t.Fatalf("同向余弦应为 1,实际 %v", got)
}
if got := denseCosine([]float64{1, 0}, []float64{0, 1}); math.Abs(got) > 1e-9 {
t.Fatalf("正交余弦应为 0,实际 %v", got)
}
if got := denseCosine([]float64{0, 0}, []float64{1, 0}); got != 0 {
t.Fatalf("零向量余弦应为 0,实际 %v", got)
}
}
// fakeEmbedder 把文本映射成固定向量:包含 "平板" ⇒ 与 query 同向;
// "rt" ⇒ 正交。用来验证"闸门按语义而不是字面"。
type fakeEmbedder struct{}
func (fakeEmbedder) Loaded() bool { return true }
func (fakeEmbedder) Dim() int { return 2 }
func (fakeEmbedder) Fingerprint() string { return "fake" }
func (fakeEmbedder) VectorizeDense(text string) ([]float64, error) {
if hasSub(text, "平板") || hasSub(text, "机型") {
return []float64{1, 0}, nil
}
return []float64{0, 1}, nil
}
func (fakeEmbedder) Vectorize([]string) [][]float64 { return nil }
// EmbedImageDense / Close 满足 vector.MultimodalEmbedder 的完整接口
// (测试只用文本路;图片与生命周期在真空间里才有意义)。
func (fakeEmbedder) EmbedImageDense([]byte, string) ([]float64, error) { return nil, nil }
func (fakeEmbedder) Close() {}
func hasSub(s, sub string) bool {
return strings.Contains(s, sub)
}
func TestRankSceneRelationsByVectorDropsUnrelated(t *testing.T) {
idx := NewIndexer(nil)
idx.SetDenseSpace(fakeEmbedder{})
rels := []Relation{
{SourceName: "rt标签", TargetName: "内核", SourceVec: []float64{0, 1}, TargetVec: []float64{0, 1}},
{SourceName: "平板", TargetName: "promax", SourceVec: []float64{1, 0}, TargetVec: []float64{0.9, 0.1}},
}
kept := idx.rankSceneRelationsByVector("群友在聊平板机型", rels)
if len(kept) != 1 || kept[0].SourceName != "平板" {
t.Fatalf("只应保留与 query 语义相关的三元组,实际 %+v", kept)
}
}
func TestRankSceneRelationsByVectorNoEmbedderKeepsOrder(t *testing.T) {
idx := NewIndexer(nil) // 未注入稠密空间
rels := []Relation{{SourceName: "a"}, {SourceName: "b"}}
kept := idx.rankSceneRelationsByVector("随便问问", rels)
if len(kept) != 2 || kept[0].SourceName != "a" {
t.Fatalf("无稠密空间时应保留原顺序(不能用字面匹配冒充语义),实际 %+v", kept)
}
}