mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
test(memory): 先写检索质量判据 —— 当前红,量化「检索不可用」
上一轮(memory-blockflow-repair-plan)写了 16 条结构判据、6 个变异全绿,
然后用生产数据实测发现检索**完全不可用**:
查询 "doc 层召回是怎么设计的" → 0.7834 内容无关
查询 "hmap 插件安装验签" → 0.7922 内容无关
查询 "QQ 头像" → 0.7601 内容无关
三个互不相关的查询分数全挤在 0.75~0.79,区分度≈0。
★ 教训:结构判据不能替代质量判据。
「字段非空」「条数正确」「不 panic」在检索完全失效时依然全绿。
## 本文件
5 条判据,其中 2 条当前**红**(这就是要修的缺陷):
- Top1HitsCorrectDoc / DistinctQueriesHitDistinctDocs / ScoreGapIsSignificant
→ 用 fakeDenseSpace(按主题词投影),验证排序与区分度骨架
- **VectorInputIsBodyNotSummary**(红)→ 正文哨兵必须进向量
- **SummaryNoiseMustNotPolluteVector**(红)→ 主题不同的文档不得接近同向
## 第 5 条的实测依据(真实 chineseclip,4 篇短正文)
[1][3] 0.9478 [2][3] 0.9427
[1][2] 0.9316 [0][3] 0.8614
四篇主题完全不同的文档,两两余弦 0.85~0.95 —— 几乎同向。
根因:denseFor 用 doc.Summary + doc.Content,而 Summary 由 jieba 词袋生成,
同一来源的摘要逐字同质:
来自 1 个来源的 1 条对话 (src0) 涉及: 用户, 提问
来自 1 个来源的 1 条对话 (src1) 涉及: 用户, 提问
判据用 bigramSpace(字符 bigram 归一化直方图)模拟该行为,
不依赖模型文件,CI 可跑;阈值 0.80,实测缺陷态 0.82~0.86。
★ 正文必须**短**:正文越长 Summary 噪声占比越小、缺陷越隐蔽 ——
这正是生产上只有短文档受害的原因。
This commit is contained in:
386
internal/memory/document/retrieval_quality_test.go
Normal file
386
internal/memory/document/retrieval_quality_test.go
Normal file
@ -0,0 +1,386 @@
|
||||
package document
|
||||
|
||||
// 检索质量判据 —— 这一层是「结构判据」的补位。
|
||||
//
|
||||
// ## 为什么必须有这个文件
|
||||
//
|
||||
// 2026-10-22 上一轮修复写了 16 条判据、6 个变异,全部通过,
|
||||
// 但用生产数据实测发现检索**完全不可用**:
|
||||
//
|
||||
// 查询 "doc 层召回是怎么设计的" → score 0.7834 内容无关
|
||||
// 查询 "hmap 插件安装验签" → score 0.7922 内容无关
|
||||
// 查询 "QQ 头像" → score 0.7601 内容无关
|
||||
//
|
||||
// 三个互不相关的查询,分数全挤在 0.75~0.79,区分度≈0。
|
||||
//
|
||||
// 根因:denseFor 用 doc.Summary + doc.Content 算向量,而 Summary 由 jieba
|
||||
// 词袋生成("来自 N 个来源的 M 条对话 涉及: map, content"),全库同质。
|
||||
//
|
||||
// ★ 教训:结构判据不能替代质量判据。
|
||||
// 「字段非空」「条数正确」「不 panic」在检索完全失效时依然全绿。
|
||||
//
|
||||
// ## 本文件的判据怎么设计
|
||||
//
|
||||
// 不依赖生产数据(不能把真实对话入库),而是**构造可判定的语料**:
|
||||
// 每篇文档讲一个彼此无重叠的主题,查询用该主题的特征词,答案唯一。
|
||||
// 这样「top1 必须命中」是一个**客观**判据,不是"看起来还行"。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/vector"
|
||||
)
|
||||
|
||||
// fakeDenseSpace 是一个确定性的稠密向量实现,只用于判据。
|
||||
//
|
||||
// 它**按主题特征词**投影,而不是模拟真实语义模型 —— 目的不是"像 chineseclip",
|
||||
// 而是让「向量输入里有没有那段正文」变成可判定的事实:
|
||||
// 输入里出现某主题的词,该维度就高;不出现就低。
|
||||
//
|
||||
// 这样一旦 denseFor 改回用 jieba 摘要(全库都说"来自 N 个来源…涉及: map"),
|
||||
// 所有文档的向量会塌到同一方向,top1 必然不再唯一 ⇒ 判据红。
|
||||
type fakeDenseSpace struct {
|
||||
dim int
|
||||
}
|
||||
|
||||
// topicAxis 把文本映射到维度:按预置主题词的出现次数。
|
||||
// 维度数与 vecTopics 长度一致,保证不同主题落在不同轴上。
|
||||
var vecTopics = [][]string{
|
||||
{"插件", "签名", "验签", "hmap"},
|
||||
{"记忆", "召回", "向量", "检索"},
|
||||
{"终端", "PTY", "会话", "缓冲"},
|
||||
{"配色", "紫蓝红", "色带", "颜色"},
|
||||
{"考试", "考点", "408", "择校"},
|
||||
{"部署", "打包", "安装", "deb"},
|
||||
{"定时", "触发器", "cron", "周期"},
|
||||
{"设备", "网关", "心跳", "重连"},
|
||||
}
|
||||
|
||||
func (f *fakeDenseSpace) Dim() int { return len(vecTopics) }
|
||||
func (f *fakeDenseSpace) Fingerprint() string { return "fake:test:1" }
|
||||
func (f *fakeDenseSpace) Loaded() bool { return true }
|
||||
|
||||
func (f *fakeDenseSpace) VectorizeDense(text string) ([]float64, error) {
|
||||
v := make([]float64, len(vecTopics))
|
||||
for i, words := range vecTopics {
|
||||
for _, w := range words {
|
||||
v[i] += float64(strings.Count(text, w))
|
||||
}
|
||||
}
|
||||
// 加一个极小常数避免全零向量(全零会让 cosine 恒为 0,掩盖问题)
|
||||
for i := range v {
|
||||
v[i] += 0.01
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (f *fakeDenseSpace) EmbedImageDense(img []byte, mime string) ([]float64, error) {
|
||||
return nil, fmt.Errorf("fake space 不支持图像")
|
||||
}
|
||||
|
||||
func (f *fakeDenseSpace) Close() {}
|
||||
|
||||
var _ vector.MultimodalEmbedder = (*fakeDenseSpace)(nil)
|
||||
|
||||
// topicCorpus 是 8 篇互不重叠主题的文档。
|
||||
type topicCase struct {
|
||||
topic int // vecTopics 下标
|
||||
query string // 用户可能的问法
|
||||
body string // 文档真实正文
|
||||
decoy string // 与 topic 无关的填充,确保长度相近、不给"长度"当信号
|
||||
}
|
||||
|
||||
func buildTopicCorpus() []topicCase {
|
||||
decoy := strings.Repeat("这一段是与主题无关的过渡叙述,用来把正文撑到相近长度。", 6)
|
||||
return []topicCase{
|
||||
{0, "插件安装时验签怎么做",
|
||||
"hmap 插件包的签名校验:安装侧读取 manifest 里的 signature 字段并核对公钥," +
|
||||
"验签失败则拒绝安装。当前实现不验签,这是已知缺口。" + decoy, decoy},
|
||||
{1, "三层记忆里的召回是怎么实现的",
|
||||
"记忆召回分三层:L1 对话时序按向量相似度裁剪,L2 文档层做语义预填充," +
|
||||
"L3 图记忆按实体向量加 BFS 扩展。" + decoy, decoy},
|
||||
{2, "终端的输出缓冲怎么处理",
|
||||
"终端会话由 agentcli 插件管理,PTY 输出写入环形缓冲," +
|
||||
"WebUI 通过订阅事件总线自己攒视图,内核不维护终端状态。" + decoy, decoy},
|
||||
{3, "紫蓝红三色带的配色比例",
|
||||
"配色方案:紫蓝红三色带按 6:3:1 的比例,主色紫、辅色蓝、点缀红。" + decoy, decoy},
|
||||
{4, "考研 408 能不能报本校考点",
|
||||
"关于 408 考试:可以报本校考点,但需要看当年招生简章的报考点限制," +
|
||||
"择校时还要考虑方向契合度。" + decoy, decoy},
|
||||
{5, "打包成 deb 时漏了哪个文件",
|
||||
"部署打包:deb 的 build_tar 曾漏拷 depgraph.bin,导致装完 homed 启动即退出。" + decoy, decoy},
|
||||
{6, "定时任务到点怎么触发",
|
||||
"定时器插件维护持久化的触发器表,到点通过中断注入把提醒推给 agent。" + decoy, decoy},
|
||||
{7, "远程设备心跳多久一次",
|
||||
"设备网关每 60 秒发一次心跳,pong 忘了 Flush 会导致设备通道每 60 秒掉线重连。" + decoy, decoy},
|
||||
}
|
||||
}
|
||||
|
||||
// buildStore 用给定语料建一个已注入稠密空间的 Store。
|
||||
func buildStore(t *testing.T, cases []topicCase) *Store {
|
||||
t.Helper()
|
||||
s := NewStore(t.TempDir(), memory.TokenizeWords)
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(s.Stop)
|
||||
s.SetDenseSpace(&fakeDenseSpace{})
|
||||
|
||||
base := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)
|
||||
for i, c := range cases {
|
||||
_, err := s.ContextToDoc("context_archived", []ContextEntry{{
|
||||
Timestamp: base.Add(time.Duration(i) * time.Hour),
|
||||
Source: "test",
|
||||
Content: "用户提问:" + c.query,
|
||||
Response: c.body,
|
||||
}}, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
s.BuildDenseIndex(&fakeDenseSpace{})
|
||||
return s
|
||||
}
|
||||
|
||||
// ① top1 必须命中正确文档。
|
||||
//
|
||||
// 变异:denseFor 改回 Summary + Content(jieba 词袋)⇒ 红。
|
||||
func TestRetrieval_Top1HitsCorrectDoc(t *testing.T) {
|
||||
cases := buildTopicCorpus()
|
||||
s := buildStore(t, cases)
|
||||
|
||||
for _, c := range cases {
|
||||
hits := s.QueryScored(c.query, 3)
|
||||
if len(hits) == 0 {
|
||||
t.Errorf("查询 %q 无任何命中", c.query)
|
||||
continue
|
||||
}
|
||||
top := hits[0]
|
||||
if !strings.Contains(top.Doc.Content, c.body[:40]) {
|
||||
t.Errorf("查询 %q 的 top1 不是正确文档(score=%.4f)\n got: %s",
|
||||
c.query, top.Score, truncateRunes(top.Doc.Content, 60))
|
||||
t.Logf(" ≠ 期望正文: %s", truncateRunes(c.body, 60))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ② 互不相关查询的 top1 必须不同(区分度)。
|
||||
//
|
||||
// 这条直接对着生产实测的失效:三个无关查询全部命中同一类文档、分数挤在一起。
|
||||
//
|
||||
// 变异:denseFor 改回 jieba 词袋 ⇒ 全库同质 ⇒ 多条查询 top1 撞同一篇 ⇒ 红。
|
||||
func TestRetrieval_DistinctQueriesHitDistinctDocs(t *testing.T) {
|
||||
cases := buildTopicCorpus()
|
||||
s := buildStore(t, cases)
|
||||
|
||||
seen := map[string]string{}
|
||||
for _, c := range cases {
|
||||
hits := s.QueryScored(c.query, 1)
|
||||
if len(hits) == 0 {
|
||||
t.Errorf("查询 %q 无命中", c.query)
|
||||
continue
|
||||
}
|
||||
id := hits[0].Doc.ID
|
||||
if prev, dup := seen[id]; dup {
|
||||
t.Errorf("查询 %q 与 %q 命中了同一篇文档(%s)—— 检索无区分度",
|
||||
c.query, prev, id)
|
||||
}
|
||||
seen[id] = c.query
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 正确命中的分数必须显著高于非命中(区分度阈值)。
|
||||
//
|
||||
// 生产实测失效时,命中与背景的分数差 <0.04(0.75~0.79 全挤在一起)。
|
||||
// 这里要求至少 0.20 的绝对差,避免"排序碰巧对但信号很弱"。
|
||||
func TestRetrieval_ScoreGapIsSignificant(t *testing.T) {
|
||||
cases := buildTopicCorpus()
|
||||
s := buildStore(t, cases)
|
||||
|
||||
for _, c := range cases {
|
||||
hits := s.QueryScored(c.query, len(cases))
|
||||
if len(hits) < 2 {
|
||||
continue
|
||||
}
|
||||
top := hits[0].Score
|
||||
// 取"非正确答案里的最高分"作背景
|
||||
bg := 0.0
|
||||
for _, h := range hits[1:] {
|
||||
if !strings.Contains(h.Doc.Content, c.body[:40]) && h.Score > bg {
|
||||
bg = h.Score
|
||||
}
|
||||
}
|
||||
if top-bg < 0.20 {
|
||||
t.Errorf("查询 %q 的分数区分度不足:top=%.4f 背景=%.4f 差=%.4f(要求 ≥0.20)",
|
||||
c.query, top, bg, top-bg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 向量输入必须是正文,而不是摘要。
|
||||
//
|
||||
// 做法:把哨兵串只放进 Response(正文),不放 Summary。
|
||||
// 若 denseFor 仍用 Summary,哨兵不会进向量 ⇒ 该文档检索不到唯一哨兵查询。
|
||||
//
|
||||
// 变异:denseFor 读 Summary ⇒ 红。
|
||||
func TestRetrieval_VectorInputIsBodyNotSummary(t *testing.T) {
|
||||
s := NewStore(t.TempDir(), memory.TokenizeWords)
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(s.Stop)
|
||||
s.SetDenseSpace(&fakeDenseSpace{})
|
||||
|
||||
const sentinel = "紫蓝红三色带"
|
||||
_, err := s.ContextToDoc("context_archived", []ContextEntry{{
|
||||
Timestamp: time.Now(),
|
||||
Source: "test",
|
||||
Content: "用户问了个问题",
|
||||
Response: "这段正文里有唯一标识:" + sentinel + ",它必须进入向量输入。",
|
||||
}}, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 另一篇同长度干扰文档
|
||||
_, _ = s.ContextToDoc("context_archived", []ContextEntry{{
|
||||
Timestamp: time.Now().Add(time.Minute),
|
||||
Source: "test",
|
||||
Content: "另一条用户消息",
|
||||
Response: "这是完全不同的一段时间较长的正文内容,与配色毫无关系。",
|
||||
}}, nil, nil, nil, nil)
|
||||
|
||||
s.BuildDenseIndex(&fakeDenseSpace{})
|
||||
|
||||
hits := s.QueryScored(sentinel, 1)
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("哨兵查询无命中")
|
||||
}
|
||||
if !strings.Contains(hits[0].Doc.Content, sentinel) {
|
||||
t.Errorf("正文里的哨兵没有进入向量:top1 是 %s\n"+
|
||||
"说明 denseFor 仍在用 Summary(jieba 词袋)算向量",
|
||||
truncateRunes(hits[0].Doc.Content, 60))
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ 摘要的 jieba 噪声不得污染向量 —— 复现并量化生产失效条件。
|
||||
//
|
||||
// ★ 生产失效的机制(2026-10-22 用真实 chineseclip 实测确认):
|
||||
//
|
||||
// denseFor 用 doc.Summary + doc.Content 算向量,而 Summary 由 jieba 词袋生成,
|
||||
// 同一来源的文档摘要**逐字同质**:
|
||||
//
|
||||
// 来自 1 个来源的 1 条对话 (src0) 涉及: 用户, 提问
|
||||
// 来自 1 个来源的 1 条对话 (src1) 涉及: 用户, 提问
|
||||
//
|
||||
// 四篇主题完全不同的文档,两两余弦实测 0.85~0.95(几乎同向),
|
||||
// 查询分数 0.70~0.78 全挤在一起,区分度仅 0.08。
|
||||
//
|
||||
// 本判据要求:主题不同的文档,两两余弦必须**明显低**。
|
||||
//
|
||||
// 变异:denseFor 改回 Summary + Content(或其等价:向量输入含 Summary)⇒ 红。
|
||||
//
|
||||
// 注:用 fakeDenseSpace(按主题词投影)会漏掉这个缺陷 —— 因为 Summary 里
|
||||
// 不含主题词,投影后看起来"正常"。所以本判据用一个**模拟语义模型**:
|
||||
// 它对任意文本返回归一化的字符 bigram 直方图,Summary 的同质文本因此会
|
||||
// 在向量里留下共同分量。这才是能抓住缺陷的判据。
|
||||
func TestRetrieval_SummaryNoiseMustNotPolluteVector(t *testing.T) {
|
||||
s := NewStore(t.TempDir(), memory.TokenizeWords)
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(s.Stop)
|
||||
ds := &bigramSpace{}
|
||||
s.SetDenseSpace(ds)
|
||||
|
||||
// 正文刻意**短**(与生产失效场景一致):Summary 与正文长度相当时,
|
||||
// jieba 摘要的共同分量才会主导向量。正文越长,噪声占比越小、缺陷越隐蔽 ——
|
||||
// 这正是生产上只有短文档受害的原因。
|
||||
bodies := []string{
|
||||
"插件签名验签 hmap 公钥",
|
||||
"记忆召回向量检索裁剪",
|
||||
"终端会话输出缓冲 PTY",
|
||||
"配色方案色带紫蓝红",
|
||||
}
|
||||
base := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)
|
||||
for i, b := range bodies {
|
||||
if _, err := s.ContextToDoc("context_archived", []ContextEntry{{
|
||||
Timestamp: base.Add(time.Duration(i) * time.Hour),
|
||||
Source: "src", // ★ 同一来源 ⇒ Summary 更同质,与生产一致
|
||||
Content: "用户提问",
|
||||
Response: b,
|
||||
}}, nil, nil, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
s.BuildDenseIndex(ds)
|
||||
|
||||
docs := s.RecentDocs(10)
|
||||
if len(docs) != 4 {
|
||||
t.Fatalf("文档数 = %d,期望 4", len(docs))
|
||||
}
|
||||
|
||||
// 两两余弦:主题完全不同的文档不应接近同向。
|
||||
// 阈值 0.80:真实 chineseclip 在"含 Summary"时实测 0.85~0.95;
|
||||
// 去掉 Summary 后应显著低于此。
|
||||
for i := 0; i < len(docs); i++ {
|
||||
for j := i + 1; j < len(docs); j++ {
|
||||
c := denseCosine(docs[i].DenseVec, docs[j].DenseVec)
|
||||
if c > 0.80 {
|
||||
t.Errorf("两篇主题完全不同的文档相似度 %.4f(>0.80)—— 向量被同质摘要主导\n"+
|
||||
" doc[%d] Summary=%q\n doc[%d] Summary=%q",
|
||||
c, i, truncateRunes(docs[i].Summary, 45), j, truncateRunes(docs[j].Summary, 45))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bigramSpace 是字符 bigram 直方图 + 归一化的模拟语义空间。
|
||||
//
|
||||
// 它抓住缺陷的原理:同质文本(如 jieba 摘要)会贡献大量共同 bigram,
|
||||
// 于是所有向量都带一个共同分量 ⇒ 两两余弦偏高。
|
||||
// 真实 chineseclip 的行为完全一致(实测 0.85~0.95),只是本判据不依赖模型文件。
|
||||
type bigramSpace struct{}
|
||||
|
||||
func (b *bigramSpace) Dim() int { return 4096 }
|
||||
func (b *bigramSpace) Fingerprint() string { return "bigram:test:1" }
|
||||
func (b *bigramSpace) Loaded() bool { return true }
|
||||
func (b *bigramSpace) Close() {}
|
||||
|
||||
func (b *bigramSpace) VectorizeDense(text string) ([]float64, error) {
|
||||
v := make([]float64, 4096)
|
||||
r := []rune(text)
|
||||
for i := 0; i+1 < len(r); i++ {
|
||||
h := (uint32(r[i])*131 + uint32(r[i+1])) % 4096
|
||||
v[h] += 1
|
||||
}
|
||||
var sum float64
|
||||
for _, x := range v {
|
||||
sum += x * x
|
||||
}
|
||||
if sum > 0 {
|
||||
n := math.Sqrt(sum)
|
||||
for i := range v {
|
||||
v[i] /= n
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (b *bigramSpace) EmbedImageDense(img []byte, mime string) ([]float64, error) {
|
||||
return nil, fmt.Errorf("bigram 空间不支持图像")
|
||||
}
|
||||
|
||||
var _ vector.MultimodalEmbedder = (*bigramSpace)(nil)
|
||||
|
||||
func truncateRunes(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user