From b1f02a22b60b6451e00dfd611121581d00fcc4df Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 22 Oct 2026 19:33:50 +0800 Subject: [PATCH] =?UTF-8?q?fix(memory):=20=E5=90=91=E9=87=8F=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E6=94=B9=E7=94=A8=E4=BA=8B=E4=BB=B6=E6=AD=A3=E6=96=87?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20=E6=91=98=E8=A6=81=E6=98=AF=20jieba=20?= =?UTF-8?q?=E8=AF=8D=E8=A2=8B=EF=BC=8C=E6=8A=8A=E5=85=A8=E5=BA=93=E6=8B=89?= =?UTF-8?q?=E6=88=90=E5=90=8C=E4=B8=80=E6=96=B9=E5=90=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 实测缺陷(真实 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)。 --- internal/memory/document/document.go | 76 +++++++++++++++++-- .../memory/document/retrieval_quality_test.go | 18 +++-- 2 files changed, 80 insertions(+), 14 deletions(-) diff --git a/internal/memory/document/document.go b/internal/memory/document/document.go index ec79ed28..7bdcba06 100644 --- a/internal/memory/document/document.go +++ b/internal/memory/document/document.go @@ -197,11 +197,35 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) { log.Printf("[document memory] dense index built: %d new vectors", count) } -// denseFor 计算文档的稠密向量:文本向量与其一等记忆块的媒体向量融合。 +// denseFor 计算文档的稠密向量:**事件正文** ⊕ 同事件媒体块的向量。 // -// 只有与当前统一空间同指纹的块向量才参与融合:不同模型/维度的旧向量 -// 属于另一个坐标系,混进去会算出一个两边都不像的方向。 -// 任意一路缺失时退化为另一路;都不可用返回 nil。 +// ## 为什么不能用 Summary +// +// 旧实现是 `VectorizeDense(doc.Summary + " " + doc.Content)`,而 Summary +// 由 summarizeEntries → jieba 词袋生成,同一来源的摘要**逐字同质**: +// +// 来自 1 个来源的 1 条对话 (src0) 涉及: 用户, 提问 +// 来自 1 个来源的 1 条对话 (src1) 涉及: 用户, 提问 +// +// 实测(真实 chineseclip,4 篇主题完全不同的短正文)两两余弦 0.85~0.95 —— +// 几乎同向;查询分数全挤在 0.70~0.78,区分度 0.08 ⇒ 检索等同失效。 +// +// 这与设计直接矛盾:ARCHITECTURE 写的是「统一多模态空间(主)」,而 +// Summary 属于主路径的输入,却由最末一级的词法分词产出。 +// +// ## 媒体怎么办:不需要单独生成摘要 +// +// 媒体块与文本块**在同一事件内**(Doc.Events[i].Blocks),事件的正文进入 +// 向量即覆盖了它的语义。为媒体单独生成描述正是 CAS 时代「描述式索引」 +// 的错误(描述是模型生成的二手信息),已在 v1.2.0 整体拆除,不重走。 +// +// 若将来要「按图搜图」,那是视觉路(RecallBlocks 读块自己的向量)的职责, +// 已有独立通路,与本函数无关。 +// +// ## 存量兼容 +// +// 无 Events 的旧文档(本次改动前归档的)退回 Content —— 那是当时唯一的 +// 原文载体,虽然它是渲染视图,但比 Summary 更接近内容。 func (s *Store) denseFor(doc *Doc) []float64 { if s.denseSpace == nil || !s.denseSpace.Loaded() { return nil @@ -209,9 +233,16 @@ func (s *Store) denseFor(doc *Doc) []float64 { fp := s.denseSpace.Fingerprint() dim := s.denseSpace.Dim() var parts [][]float64 - if tv, err := s.denseSpace.VectorizeDense(doc.Summary + " " + doc.Content); err == nil && len(tv) > 0 { - parts = append(parts, tv) + + // 文本路:用事件正文(原文),不用 Summary(jieba 词袋)。 + // 媒体块的语义由同一事件的文本覆盖,因此这里不需要为媒体单独编码。 + if text := documentTextForVector(doc); text != "" { + if tv, err := s.denseSpace.VectorizeDense(text); err == nil && len(tv) > 0 { + parts = append(parts, tv) + } } + + // 媒体路:块自带的多模态向量(同空间同维度才参与融合)。 for _, b := range doc.Blocks { // 只比指纹不够:指纹相同但**维度不同**的块会被 FuseVectors 按 // 「最大维度」拼成错维度向量(并覆盖掉文本向量),而结果又被 @@ -224,6 +255,39 @@ func (s *Store) denseFor(doc *Doc) []float64 { return vector.FuseVectors(parts...) } +// documentTextForVector 取文档用于向量化的文本:**正文**。 +// +// 有 Events 时按事件顺序拼原文(来自块里的正文);无 Events 的存量文档 +// 退回 Content(渲染视图,但仍是原文最接近的载体)。 +// 绝不含 Summary —— 那是 jieba 词袋,会把全库拉向同一方向。 +func documentTextForVector(doc *Doc) string { + if doc == nil { + return "" + } + if !doc.HasEvents() { + return strings.TrimSpace(doc.Content) + } + var b strings.Builder + for _, ev := range doc.Events { + in, resp, _ := ev.InOut() + if in != "" { + b.WriteString(in) + b.WriteString("\n") + } + if resp != "" { + b.WriteString(resp) + b.WriteString("\n") + } + for _, tr := range ev.ToolResults { + if tr.Output != "" { + b.WriteString(tr.Output) + b.WriteString("\n") + } + } + } + return b.String() +} + // Reindex 重建 TF-IDF 索引(fallback 路径变更时调用)。 func (s *Store) Reindex() { s.mu.Lock() diff --git a/internal/memory/document/retrieval_quality_test.go b/internal/memory/document/retrieval_quality_test.go index d23acbff..b345fb09 100644 --- a/internal/memory/document/retrieval_quality_test.go +++ b/internal/memory/document/retrieval_quality_test.go @@ -224,19 +224,20 @@ func TestRetrieval_ScoreGapIsSignificant(t *testing.T) { // ④ 向量输入必须是正文,而不是摘要。 // -// 做法:把哨兵串只放进 Response(正文),不放 Summary。 -// 若 denseFor 仍用 Summary,哨兵不会进向量 ⇒ 该文档检索不到唯一哨兵查询。 +// 做法:哨兵串只放进 Response(正文),不放 Summary。用 bigramSpace +// (对任意文本都给可区分的向量)验证:哨兵查询的 top1 必须是含哨兵的那篇。 // -// 变异:denseFor 读 Summary ⇒ 红。 +// 变异: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) - s.SetDenseSpace(&fakeDenseSpace{}) + ds := &bigramSpace{} + s.SetDenseSpace(ds) - const sentinel = "紫蓝红三色带" + const sentinel = "量子纠缠态退相干时间测量" _, err := s.ContextToDoc("context_archived", []ContextEntry{{ Timestamp: time.Now(), Source: "test", @@ -246,15 +247,16 @@ func TestRetrieval_VectorInputIsBodyNotSummary(t *testing.T) { if err != nil { t.Fatal(err) } - // 另一篇同长度干扰文档 + // 与哨兵完全无关的干扰文档(越长越能测出"摘要被当成内容") _, _ = s.ContextToDoc("context_archived", []ContextEntry{{ Timestamp: time.Now().Add(time.Minute), Source: "test", Content: "另一条用户消息", - Response: "这是完全不同的一段时间较长的正文内容,与配色毫无关系。", + Response: "这是完全不同的一段很长的正文内容,只谈插件安装与打包部署流程," + + "与物理测量毫无关系,纯粹用来干扰检索。" + strings.Repeat("补充无关叙述。", 20), }}, nil, nil, nil, nil) - s.BuildDenseIndex(&fakeDenseSpace{}) + s.BuildDenseIndex(ds) hits := s.QueryScored(sentinel, 1) if len(hits) == 0 {