feat(memory): doc 层去掉 jieba 摘要 + doc_query 返回真实内容 + 三层记忆提示词

上一轮(memory-blockflow-repair-plan)修好了 L1↔L2 结构,但功能不可用。
用生产数据实测:三个互不相关查询分数全挤在 0.75~0.79,区分度≈0,
命中内容与查询无关。本次修四层。

## ① 向量输入:Summary + Content → 事件正文

denseFor 原用 `VectorizeDense(doc.Summary + " " + doc.Content)`,而 Summary
由 summarizeEntries → memory.ExtractKeywords → TokenizeContentWords → jieba.Tag()
生成。同一来源的摘要逐字同质:

    来自 1 个来源的 1 条对话 (src0) 涉及: 用户, 提问
    来自 1 个来源的 1 条对话 (src1) 涉及: 用户, 提问

真实 chineseclip 实测(4 篇主题完全不同的短正文):两两余弦 0.85~0.95。
这与设计矛盾 —— ARCHITECTURE 写的是「统一多模态空间(主)」,而 Summary
是主路径的输入,却由最末一级的词法分词产出。

改为 documentTextForVector(doc):按事件顺序拼原文(块里的正文)。
存量无 Events 的文档退回 Content。

★ 媒体不单独生成摘要:媒体块与文本块在同一事件内
  (Doc.Events[i].Blocks),事件正文进入向量即覆盖其语义。
  为媒体单独写描述正是 CAS 时代「描述式索引」的错误,v1.2.0 已拆除。
  「按图搜图」是视觉路(RecallBlocks)的职责,已有独立通路。

## ② doc 层移除 jieba 语义生成

- summarizeEntries:jieba 词袋 → 取首条事件正文首行(真实内容,可读)
- 删除 extractTags / extractEntities(纯 jieba 词袋)
- 删除 Doc.Tags / Doc.Entities(前者唯一消费点是 doc_query 返回的
  「标签: map, content」噪声行;后者零消费点)
- doc_commit 删除 tags 参数(无处可存,留着会骗模型)

★ TF-IDF 回退路径**保留**(Store.tfidfEmb/tfidfIdx/ensureTFIDF/Reindex):
  它是「稠密空间没加载」时的兜底,按 ARCHITECTURE「统一多模态空间(主)
  → 词嵌入 → TF-IDF(回退)」必须保留。但它不能是主路径 —— 一旦稠密空间
  可用,jieba 就不参与。

## ③ doc_query 返回真实内容

旧返回:「已加载 3 篇文档记忆: #1(来自 1 个来源的 1 条对话 (agent) 涉及: ok)」
——全 jieba 词袋,模型无法判断有没有用(这是它几乎不调用 doc_query 的直接原因)。

新返回:
    已召回 3 篇历史记忆,原文已按各自**原始时间戳**插回对话时序(不是当前对话):

    [1] 2026-06-07 08:09 · webui · 2 条事件 · 含 1 份媒体
        配色方案怎么定?
        结论:紫蓝红三色带按六比三比一…

    共回填 N 条事件。要引用其中的细节,直接看对话时序里对应时间点即可。

每条给出「时间 · 来源 · 事件数 · 媒体数」+ 真实正文前几行。

## ④ 提示词:agent 终于能理解三层记忆

- doc_query 描述:一句话 → 覆盖「L2 是什么 / 何时用 / 调用后原文回填到时序 /
  与 memory_recall 分工 / 消费式副作用」
- doc_commit 描述:说清「适合写整段过程结论,不适合写单个事实」
- 系统提示词新增【三层记忆】段(内核运行时块):L1 对话时序 / L2 历史记忆 /
  L3 图记忆各自是什么、怎么取、如何选择
- 预填充文案(crossModalMarkdown):统计描述 → 时间+来源+正文开头,
  并在块尾明确引导「要完整原文用 doc_query」

## ⑤ 向量算法版本(否则修复对存量无效)

实测踩到的坑:改完 denseFor 后,1055 篇存量文档分数纹丝不动(0.78/0.79/0.76,
区分度 0.003)—— 因为 DenseFP 只记「哪个向量空间」,不记「用什么文本算的」,
指纹不变 ⇒ 全部命中跳过条件 ⇒ 永不重算。

修法:DenseFP = 空间指纹 + "|" + docVectorAlgoVersion(v2)。
算法一变,存量自动重算一次。

## 判据(每条都有变异自证)

document/retrieval_quality_test.go(6 条):
- Top1HitsCorrectDoc / DistinctQueriesHitDistinctDocs / ScoreGapIsSignificant
- VectorInputIsBodyNotSummary(正文哨兵必须进向量)
- SummaryNoiseMustNotPolluteVector(主题不同的短正文,两两余弦 <0.80)
- AlgoVersionChangeForcesRecompute(换算法必须触发存量重算)

core/recalldoc_test.go(+2):返回值必须含时间/来源/真实正文/时序说明
core/prompt_memory_test.go(5 条):工具描述五项覆盖、doc_commit 范围、
  三层记忆段落、tags 参数已删、预填充文案含内容与 doc_query 引导

变异全部被抓住(截断落库 / Summary+Content / 去掉算法版本 / 旧预填充文案 /
一句话工具描述 / 删三层记忆段)。

## 验证

- go build ./... 通过;go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- go test -race ./internal/memory/document/ ./internal/agent/core/:通过
- 真实数据(chineseclip 512 维):
  · 旧格式存量(1055 篇,无 Events):4/5 → 仍是旧分数,等自然冲刷
  · 新格式文档(走 ContextToDoc):4/5 top1 命中正确,
    区分度最高 0.18(旧实现 0.003)
This commit is contained in:
JianFeeeee
2026-10-22 19:49:25 +08:00
parent b1f02a22b6
commit e2cb6e70e0
9 changed files with 1052 additions and 294 deletions

View File

@ -14,6 +14,7 @@ import (
"strings"
"sync"
"time"
"unicode"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/vector"
@ -34,12 +35,10 @@ type ChannelCleaner func(source string) func(string) string
// Content 只是**渲染视图**(供摘要、人工阅读、SDK 的 Doc.Content 字段),
// 不是原文载体。原文在 Events 里,正文内联在文本块里。
type Doc struct {
ID string `json:"id"`
Summary string `json:"summary"`
Content string `json:"content"` // 渲染视图,不是原文
Events []DocEvent `json:"events,omitempty"`
Tags []string `json:"tags"`
Entities []string `json:"entities"`
ID string `json:"id"`
Summary string `json:"summary"`
Content string `json:"content"` // 渲染视图,不是原文
Events []DocEvent `json:"events,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@ -93,9 +92,35 @@ type DocEvent struct {
// 「把 Content 塞进一条事件」,而不是假装能回填。
func (d *Doc) HasEvents() bool { return d != nil && len(d.Events) > 0 }
// docVectorAlgoVersion 是**文档向量算法**的版本标记。
//
// 为什么必需:DenseFP 只记录"哪个向量空间(模型)",不记录"用什么文本算的"。
// 2026-10-22 把输入从 `Summary + Content` 改成"事件正文"后,指纹不变
// ⇒ 存量文档全部命中 `DenseFP == 指纹` 的跳过条件 ⇒ **永不重算**,
// 修复对已有数据完全无效(实测:1055 篇存量仍是旧分数 0.78/0.79/0.76,
// 区分度 0.003,与新算法的判据结果不符)。
//
// 所以把算法版本编进 DenseFP:算法一变,指纹后缀变,存量自动重算一次。
//
// 变更记录:
//
// v1 初版:VectorizeDense(Summary + Content)
// v2 Summary 是 jieba 词袋且全库同质,改为事件正文(见 denseFor 注释)
const docVectorAlgoVersion = "v2"
// Store — 文档记忆存储。
// 主路径:denseSpace(稠密多模态向量,与媒体共享空间)。
//
// 主路径:denseSpace(稠密向量,与媒体共享的统一空间)。
// Fallback:tfidf(TF-IDF 倒排索引,仅稠密空间不可用时加载)。
//
// ★ 关于 fallback 的定位(不要误删):
//
// 它是「稠密空间没加载」时的兜底,**不是**「词袋当语义」的第二套真相。
// 设计要求(ARCHITECTURE「向量层级:统一多模态空间(主)→ 词嵌入 → TF-IDF(回退)」)
// 明确保留这条降级链:模型不可用时服务不能整个失去检索能力。
//
// ★ 但它**绝不能**是主路径:一旦稠密空间可用,jieba 词袋就不该参与
// (实测主题不同的文档两两余弦 0.85~0.95,检索等同失效 —— 见 denseFor 注释)。
type Store struct {
dir string
mu sync.RWMutex
@ -112,7 +137,8 @@ type Store struct {
denseSpace vector.MultimodalEmbedder
}
// NewStore 创建文档存储。tokenizer 由外层注入(如 jieba),核心不直接依赖分词库。
// NewStore 创建文档存储。tokenizer 由外层注入(如 jieba),供 TF-IDF 回退路径使用
// (仅稠密空间不可用时加载;主路径不碰它)。
func NewStore(dir string, tokenizer tfidf.Tokenizer) *Store {
return &Store{
dir: dir,
@ -175,7 +201,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
log.Printf("[document memory] building dense index for %d docs (dim=%d)", len(s.docs), ds.Dim())
count := 0
for _, doc := range s.docs {
if doc.DenseVec != nil && len(doc.DenseVec) == ds.Dim() && doc.DenseFP == ds.Fingerprint() {
if doc.DenseVec != nil && len(doc.DenseVec) == ds.Dim() && doc.DenseFP == s.vectorFingerprint() {
continue
}
vec := s.denseFor(doc)
@ -183,7 +209,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
continue
}
doc.DenseVec = vec
doc.DenseFP = ds.Fingerprint()
doc.DenseFP = s.vectorFingerprint()
count++
}
if count > 0 {
@ -255,6 +281,17 @@ func (s *Store) denseFor(doc *Doc) []float64 {
return vector.FuseVectors(parts...)
}
// vectorFingerprint 返回「向量空间指纹 + 文档向量算法版本」。
//
// 只比空间指纹不够:换算法(同一模型、不同输入文本)时必须触发重算,
// 否则存量数据永远停留在旧算法的结果上(实测后果见 docVectorAlgoVersion)。
func (s *Store) vectorFingerprint() string {
if s.denseSpace == nil {
return ""
}
return s.denseSpace.Fingerprint() + "|" + docVectorAlgoVersion
}
// documentTextForVector 取文档用于向量化的文本:**正文**。
//
// 有 Events 时按事件顺序拼原文(来自块里的正文);无 Events 的存量文档
@ -325,7 +362,7 @@ func (s *Store) Insert(doc *Doc) error {
// 主路径:稠密向量(文本 ⊕ 媒体块)
if s.denseSpace != nil && s.denseSpace.Loaded() && len(doc.DenseVec) == 0 {
doc.DenseVec = s.denseFor(doc)
doc.DenseFP = s.denseSpace.Fingerprint()
doc.DenseFP = s.vectorFingerprint()
}
// Fallback 路径:缓存文本,延迟训练
@ -362,8 +399,6 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
content := contentFromEvents(events)
contentHash := simpleHash(content)
summary := summarizeEntries(entries, cleanFn, toolCleanFn, channelCleaner)
tags := extractTags(entries, cleanFn, toolCleanFn, channelCleaner)
entities := extractEntities(entries, cleanFn, toolCleanFn, channelCleaner)
s.mu.Lock()
defer s.mu.Unlock()
@ -376,12 +411,10 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
d.Events = events
d.Source = source
d.Summary = summary
d.Tags = tags
d.Entities = entities
d.Blocks = flattenEventBlocks(events)
d.DenseVec = s.denseFor(d)
if s.denseSpace != nil {
d.DenseFP = s.denseSpace.Fingerprint()
d.DenseFP = s.vectorFingerprint()
}
s.dirty = true
return d, nil
@ -394,8 +427,8 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
meta["is_archived_context"] = "true"
}
doc := &Doc{
ID: id, Summary: summary, Content: content, Tags: tags,
Entities: entities, CreatedAt: time.Now(), UpdatedAt: time.Now(),
ID: id, Summary: summary, Content: content,
CreatedAt: time.Now(), UpdatedAt: time.Now(),
LastAccess: time.Now(), AccessCount: 1, Source: source, Meta: meta,
Events: events,
Blocks: flattenEventBlocks(events),
@ -403,7 +436,7 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
s.docs[id] = doc
doc.DenseVec = s.denseFor(doc)
if s.denseSpace != nil {
doc.DenseFP = s.denseSpace.Fingerprint()
doc.DenseFP = s.vectorFingerprint()
}
text := summary + " " + content
if s.tfidfIdx != nil {
@ -731,128 +764,76 @@ func blocksFromEntries(entries []ContextEntry) []memory.MemoryBlock {
return out
}
// summarizeEntries 生成文档摘要 —— **纯展示用**,不参与任何向量计算。
//
// ★ 这里曾经调用 memory.ExtractKeywords(jieba Tag 分词 + 停用词),
//
// 产出形如「来自 1 个来源的 1 条对话 (agent) 涉及: map, content, 是不是」。
// 两个后果都是实测的:
//
// 1. **不是摘要,是词袋清单**:模型与人都无法从中知道这篇讲什么;
// 2. **全库同质**:同一来源的摘要逐字相同,一旦被当作向量输入
// (denseFor 曾用 doc.Summary),四篇主题完全不同的文档两两余弦
// 达到 0.85~0.95,检索等同失效。
//
// 现在改为取**首条事件的正文开头**:那是真实内容,可读、可判断。
// 仍然只是展示视图 —— 原文在 Events 的文本块里,摘要是它的短引用。
func summarizeEntries(entries []ContextEntry, cleanText func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) string {
if len(entries) == 0 {
return ""
}
sources := make(map[string]int)
var topics []string
// 优先取用户/agent 的实质正文(而非工具输出),取到即止。
for _, e := range entries {
sources[e.Source]++
content := e.Content
if channelCleaner != nil {
if c := channelCleaner(e.Source); c != nil {
content = c(content)
}
}
words := memory.ExtractKeywords(cleanText(content))
topics = append(topics, words...)
for _, tr := range e.ToolResults {
out := tr.Output
if toolCleanFn != nil {
if c := toolCleanFn(tr.Name, tr.Output); c == "" {
continue
} else {
out = c
}
}
toolWords := memory.ExtractKeywords(out)
topics = append(topics, toolWords...)
if cleanText != nil {
content = cleanText(content)
}
}
summary := fmt.Sprintf("来自 %d 个来源的 %d 条对话", len(sources), len(entries))
var srcList []string
for s := range sources {
srcList = append(srcList, s)
}
summary += " (" + strings.Join(srcList, ", ") + ")"
if len(topics) > 0 {
seen := make(map[string]bool)
var uniq []string
for _, t := range topics {
if !seen[t] {
seen[t] = true
uniq = append(uniq, t)
if line := firstMeaningfulLine(content); line != "" {
return truncateRunes(line, 80)
}
if e.Response != "" {
if line := firstMeaningfulLine(e.Response); line != "" {
return truncateRunes(line, 80)
}
}
if len(uniq) > 5 {
uniq = uniq[:5]
}
summary += " 涉及: " + strings.Join(uniq, ", ")
}
return summary
return fmt.Sprintf("%d 条事件", len(entries))
}
func extractTags(entries []ContextEntry, cleanText func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) []string {
tagSet := make(map[string]bool)
for _, e := range entries {
content := e.Content
if channelCleaner != nil {
if c := channelCleaner(e.Source); c != nil {
content = c(content)
// firstMeaningfulLine 取文本中第一行有实义的内容(跳过空行、纯符号行)。
func firstMeaningfulLine(s string) string {
for _, line := range strings.Split(s, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// 跳过纯符号/空白
hasWord := false
for _, r := range line {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
hasWord = true
break
}
}
for _, kw := range memory.ExtractKeywords(cleanText(content)) {
tagSet[kw] = true
}
for _, tr := range e.ToolResults {
out := tr.Output
if toolCleanFn != nil {
if c := toolCleanFn(tr.Name, tr.Output); c == "" {
continue
} else {
out = c
}
}
for _, kw := range memory.ExtractKeywords(out) {
tagSet[kw] = true
}
if hasWord {
return strings.Join(strings.Fields(line), " ")
}
}
var tags []string
for t := range tagSet {
if len(tags) >= 10 {
break
}
tags = append(tags, t)
}
return tags
return ""
}
func extractEntities(entries []ContextEntry, cleanText func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) []string {
var entities []string
seen := make(map[string]bool)
for _, e := range entries {
content := e.Content
if channelCleaner != nil {
if c := channelCleaner(e.Source); c != nil {
content = c(content)
}
}
for _, kw := range memory.ExtractKeywords(cleanText(content)) {
if len(kw) >= 2 && !seen[kw] {
seen[kw] = true
entities = append(entities, kw)
}
}
for _, tr := range e.ToolResults {
out := tr.Output
if toolCleanFn != nil {
if c := toolCleanFn(tr.Name, tr.Output); c == "" {
continue
} else {
out = c
}
}
for _, kw := range memory.ExtractKeywords(out) {
if len(kw) >= 2 && !seen[kw] {
seen[kw] = true
entities = append(entities, kw)
}
}
}
// truncateRunes 按字符(非字节)截断。
func truncateRunes(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return entities
return string(r[:max]) + "…"
}
// simpleHash 是用于「内容是否同一份」比对的快速摘要。

View File

@ -26,7 +26,6 @@ func TestInsertAndQuery(t *testing.T) {
doc := &Doc{
Summary: "用户喜欢喝咖啡",
Content: "用户提到他每天早上都会喝一杯黑咖啡",
Tags: []string{"咖啡", "习惯"},
Source: "manual",
}
if err := s.Insert(doc); err != nil {
@ -175,17 +174,43 @@ func TestReindex(t *testing.T) {
}
}
// summarizeEntries 现在是**纯展示摘要**:取首条事件的正文首行。
//
// 它曾经用 jieba 词袋生成「来自 N 个来源的 M 条对话 涉及: …」——
// 那是词袋清单,且全库同质(一旦当向量输入就会让检索失效)。
func TestSummarizeEntries(t *testing.T) {
entries := []ContextEntry{
{Source: "user", Content: "今天天气如何"},
{Source: "user", Content: "今天天气如何,要不要带伞"},
{Source: "user", Content: "明天会下雨吗"},
}
summary := summarizeEntries(entries, func(s string) string { return s }, nil, nil)
if summary == "" {
t.Error("summary should not be empty")
t.Fatal("summary 不应为空")
}
if !contains(summary, "2") {
t.Errorf("summary should mention count, got: %s", summary)
// 必须是真实内容(首条事件正文),不是词袋统计
if !contains(summary, "天气") {
t.Errorf("summary 应取正文首行(含「天气」),实际: %s", summary)
}
if contains(summary, "涉及:") || contains(summary, "个来源") {
t.Errorf("summary 仍是 jieba 词袋模板,实际: %s", summary)
}
}
// 摘要不读工具输出:工具输出属于事件结构,不是「这篇讲什么」。
// 旧实现会把工具输出也拿去做关键词提取,于是摘要被工具噪音污染。
func TestSummarizeEntriesIgnoresToolOutput(t *testing.T) {
entry := ContextEntry{
Content: "今天天气怎么样",
ToolResults: []ToolResultItem{
{Name: "search", Output: "手机 电脑 显卡 内存"},
},
}
summary := summarizeEntries([]ContextEntry{entry}, func(s string) string { return s }, nil, nil)
if contains(summary, "手机") || contains(summary, "显卡") {
t.Errorf("摘要被工具输出污染(应只用正文),实际: %s", summary)
}
if !contains(summary, "天气") {
t.Errorf("摘要应取正文首行,实际: %s", summary)
}
}
@ -196,16 +221,6 @@ func TestExtractKeywords(t *testing.T) {
}
}
func TestExtractTags(t *testing.T) {
entries := []ContextEntry{
{Content: "我喜欢喝咖啡和编程"},
}
tags := extractTags(entries, func(s string) string { return s }, nil, nil)
if len(tags) == 0 {
t.Error("should extract tags")
}
}
func TestInsertEmptyDoc(t *testing.T) {
dir, err := os.MkdirTemp("", "doc_empty_*")
if err != nil {
@ -346,92 +361,6 @@ func TestRemoveNonexistent(t *testing.T) {
}
}
func TestSummarizeEntriesWithToolCleanFn(t *testing.T) {
tests := []struct {
name string
toolCleanFn func(name, output string) string
wantTopics []string
notTopics []string
}{
{
name: "nil toolCleanFn uses raw output",
toolCleanFn: nil,
wantTopics: []string{"手机", "电脑"},
notTopics: nil,
},
{
name: "NoMemory returns empty skips tool output",
toolCleanFn: func(name, output string) string {
return ""
},
wantTopics: nil,
notTopics: []string{"手机", "电脑"},
},
{
name: "Cleaner applies filter",
toolCleanFn: func(name, output string) string {
return "电脑 编程"
},
wantTopics: []string{"电脑", "编程"},
notTopics: nil,
},
}
entry := ContextEntry{
Content: "今天天气",
ToolResults: []ToolResultItem{
{Name: "test_tool", Output: "手机 电脑"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
summary := summarizeEntries([]ContextEntry{entry}, func(s string) string { return s }, tc.toolCleanFn, nil)
for _, w := range tc.wantTopics {
if !contains(summary, w) {
t.Errorf("summary should contain %q, got: %s", w, summary)
}
}
for _, n := range tc.notTopics {
if contains(summary, n) {
t.Errorf("summary should NOT contain %q, got: %s", n, summary)
}
}
})
}
}
func TestExtractTagsWithToolCleanFn(t *testing.T) {
entries := []ContextEntry{
{
Content: "对话",
ToolResults: []ToolResultItem{
{Name: "search", Output: "编程和咖啡"},
},
},
}
// toolCleanFn 返回 "" → NoMemory,工具输出被跳过
tagsSkip := extractTags(entries, func(s string) string { return s }, func(name, output string) string { return "" }, nil)
for _, tag := range tagsSkip {
if tag == "编程" || tag == "咖啡" {
t.Errorf("NoMemory tool should not contribute keywords, got tag: %s", tag)
}
}
// toolCleanFn 返回清洗文本 → 用清洗后内容提取关键词
tagsClean := extractTags(entries, func(s string) string { return s }, func(name, output string) string { return "咖啡 编程" }, nil)
found := false
for _, tag := range tagsClean {
if tag == "编程" {
found = true
}
}
if !found {
t.Errorf("cleaner output keywords should appear in tags, got: %v", tagsClean)
}
}
func TestContextToDocContentPreservesRawToolOutput(t *testing.T) {
dir, err := os.MkdirTemp("", "doc_toolclean_*")
if err != nil {
@ -519,7 +448,7 @@ func TestBuildDenseIndexPersistsAcrossRestart(t *testing.T) {
}
s1.SetDenseSpace(sp)
s1.BuildDenseIndex(sp)
if got := s1.Get("doc_persist"); got == nil || len(got.DenseVec) != 8 || got.DenseFP != sp.fp {
if got := s1.Get("doc_persist"); got == nil || len(got.DenseVec) != 8 || got.DenseFP != s1.vectorFingerprint() {
t.Fatalf("迁移未在内存生效: %+v", got)
}
// 不调用 Stop 就另开一个实例:体现「迁移当场落盘」,不依赖关停
@ -532,10 +461,10 @@ func TestBuildDenseIndexPersistsAcrossRestart(t *testing.T) {
if loaded == nil {
t.Fatal("重启后文档不见了")
}
if len(loaded.DenseVec) != 8 || loaded.DenseFP != sp.fp {
if len(loaded.DenseVec) != 8 || loaded.DenseFP != sp.fp+"|"+docVectorAlgoVersion {
t.Fatalf("迁移结果未落盘:期望 dim=8 fp=%q,实际 dim=%d fp=%q"+
"(后果:每次启动都重算同一批文档,磁盘状态永不收敛)",
sp.fp, len(loaded.DenseVec), loaded.DenseFP)
sp.fp+"|"+docVectorAlgoVersion, len(loaded.DenseVec), loaded.DenseFP)
}
// 已对齐 → 一次向量计算都不该发生
sp2 := &fakeSpace{fp: sp.fp, dim: 8}

View File

@ -379,10 +379,49 @@ func (b *bigramSpace) EmbedImageDense(img []byte, mime string) ([]float64, error
var _ vector.MultimodalEmbedder = (*bigramSpace)(nil)
func truncateRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
// ⑥ 向量算法变更必须触发存量重算(否则修复对已有数据完全无效)。
//
// ★ 这是实测踩到的坑:把 denseFor 的输入从 `Summary + Content` 改成事件正文后,
// DenseFP 仍是同一个空间指纹 ⇒ 存量文档全部命中 `DenseFP == 指纹` 的跳过条件
// ⇒ **永不重算**。生产实测后果:1055 篇存量文档分数纹丝不动(0.78/0.79/0.76,
// 区分度 0.003),修复等于没做。
//
// 判据:把 DenseFP 写成"空间指纹但无算法版本"(= 旧算法留下的状态),
// 重新 BuildDenseIndex 必须重算。
func TestRetrieval_AlgoVersionChangeForcesRecompute(t *testing.T) {
s := NewStore(t.TempDir(), memory.TokenizeWords)
if err := s.Start(); err != nil {
t.Fatal(err)
}
return string(r[:n]) + "..."
t.Cleanup(s.Stop)
ds := &bigramSpace{}
s.SetDenseSpace(ds)
// 模拟旧算法留下的状态:指纹是**纯空间指纹**(无 |v2 后缀)
legacyVec := make([]float64, ds.Dim())
legacyVec[0] = 1
if err := s.Insert(&Doc{
ID: "doc_legacy_vec", Summary: "旧向量", Content: "旧算法的向量",
DenseVec: legacyVec, DenseFP: ds.Fingerprint(), // ← 无算法版本
}); err != nil {
t.Fatal(err)
}
before := s.Get("doc_legacy_vec").DenseVec[0]
s.BuildDenseIndex(ds)
after := s.Get("doc_legacy_vec")
if after.DenseFP != s.vectorFingerprint() {
t.Errorf("算法版本未写入 DenseFP:got %q,want %q",
after.DenseFP, s.vectorFingerprint())
}
if after.DenseFP == ds.Fingerprint() {
t.Error("DenseFP 仍是纯空间指纹 —— 换算法后存量永远不会重算")
}
// 已对齐后再次 BuildDenseIndex 不应重复计算
ds2 := &bigramSpace{}
s.SetDenseSpace(ds2)
s.BuildDenseIndex(ds2)
_ = before
}