fix(memory): 向量输入改用事件正文 —— 摘要是 jieba 词袋,把全库拉成同一方向

## 实测缺陷(真实 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)。
This commit is contained in:
JianFeeeee
2026-10-22 19:33:50 +08:00
parent 1dcd3ced03
commit b1f02a22b6
2 changed files with 80 additions and 14 deletions

View File

@ -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 {