mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
## 实测缺陷(真实 chineseclip,4 篇主题完全不同的短正文)
[1][3] 0.9478 [2][3] 0.9427
[1][2] 0.9316 [0][3] 0.8614
两两余弦 0.85~0.95,几乎同向。查询分数 0.70~0.78 挤在一起,区分度 0.08。
根因:denseFor 用 `doc.Summary + " " + doc.Content`,而 Summary 由
summarizeEntries → memory.ExtractKeywords → TokenizeContentWords → jieba.Tag()
生成,同一来源的摘要**逐字同质**:
来自 1 个来源的 1 条对话 (src0) 涉及: 用户, 提问
来自 1 个来源的 1 条对话 (src1) 涉及: 用户, 提问
这与设计直接矛盾:ARCHITECTURE L131/L174 写的向量层级是
「统一多模态空间(主)→ StaticEmbedder → TF-IDF(回退)」,
而 Summary 是**主路径的输入**,却由**最末一级的词法分词**产出。
## 修法
denseFor 改用 documentTextForVector(doc):按事件顺序拼原文(来自块里的正文)。
存量无 Events 的文档退回 Content(渲染视图,但比 Summary 更接近内容)。
★ 绝不含 Summary —— 它是词袋,会把全库拉向同一方向。
## 媒体为什么不单独生成摘要
媒体块与文本块**在同一事件内**(Doc.Events[i].Blocks),事件正文进入向量
即覆盖其语义。为媒体单独生成描述正是 CAS 时代「描述式索引」的错误
(描述是二手信息),v1.2.0 已整体拆除,不重走。
「按图搜图」是视觉路(RecallBlocks 读块自己的向量)的职责,已有独立通路。
## 判据
- TestRetrieval_SummaryNoiseMustNotPolluteVector:主题不同的短正文文档,
两两余弦必须 <0.80。修复前 0.82~0.86。
- TestRetrieval_VectorInputIsBodyNotSummary:正文里的唯一哨兵必须能检索到。
变异:denseFor 改回 Summary+Content ⇒ 前者立刻红(实测 0.8225~0.8573)。
389 lines
14 KiB
Go
389 lines
14 KiB
Go
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。用 bigramSpace
|
||
// (对任意文本都给可区分的向量)验证:哨兵查询的 top1 必须是含哨兵的那篇。
|
||
//
|
||
// 变异:denseFor 读 Summary ⇒ 哨兵不在向量里 ⇒ top1 是别的文档 ⇒ 红。
|
||
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)
|
||
ds := &bigramSpace{}
|
||
s.SetDenseSpace(ds)
|
||
|
||
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: "这是完全不同的一段很长的正文内容,只谈插件安装与打包部署流程," +
|
||
"与物理测量毫无关系,纯粹用来干扰检索。" + strings.Repeat("补充无关叙述。", 20),
|
||
}}, nil, nil, nil, nil)
|
||
|
||
s.BuildDenseIndex(ds)
|
||
|
||
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]) + "..."
|
||
}
|