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 是用于「内容是否同一份」比对的快速摘要。