mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 17:27:31 +00:00
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:
@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
@ -234,37 +235,142 @@ func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossMod
|
||||
return merged
|
||||
}
|
||||
|
||||
// crossModalMarkdown 把融合候选渲染成注入上下文的文本。
|
||||
// 文档行给出摘要;媒体行只给 MIME + 短块 ID(不再有生成的描述)。
|
||||
// crossModalMarkdown 把融合候选渲染成注入提示词的文本。
|
||||
//
|
||||
// ## 为什么改
|
||||
//
|
||||
// 旧输出是这样的:
|
||||
//
|
||||
// 【跨模态相关记忆】
|
||||
// [1] 来自 4 个来源的 61 条对话 (cold_storage, agentcli...) (来源:context_archived) 相关度:0.78(文本命中)
|
||||
//
|
||||
// 三个问题,都被生产日志证实:
|
||||
//
|
||||
// 1. 只有**统计描述**(几个来源几条对话),不是内容 —— 模型无法判断相关性;
|
||||
// 2. **没有一个字**说"你可以把这些原文取回来" —— 模型看完只能忽略;
|
||||
// 3. 来源写了 `context_archived` 这种内部标记,对模型无意义。
|
||||
//
|
||||
// 现在每条给出「时间 · 来源 · 正文开头」,并在块尾**明确告知 doc_query 的作用**:
|
||||
// 这才是让预填充真正被用起来的必要条件。
|
||||
func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
|
||||
if len(hits) == 0 {
|
||||
return ""
|
||||
}
|
||||
var lines []string
|
||||
for i, h := range hits {
|
||||
marker := ""
|
||||
switch {
|
||||
case h.DoubleHit:
|
||||
marker = "(图文双命中)"
|
||||
case h.Doc != nil:
|
||||
marker = "(文本命中)"
|
||||
case h.Media != nil:
|
||||
marker = "(视觉命中)"
|
||||
}
|
||||
parts := []string{fmt.Sprintf("[%d]", i+1)}
|
||||
var parts []string
|
||||
parts = append(parts, fmt.Sprintf("[%d]", i+1))
|
||||
|
||||
// 时间 + 来源:让模型知道"这是什么时候、谁的事"
|
||||
if h.Doc != nil {
|
||||
parts = append(parts, h.Doc.Summary)
|
||||
if h.Doc.Source != "" {
|
||||
parts = append(parts, fmt.Sprintf("(来源:%s)", h.Doc.Source))
|
||||
if h.Doc.HasEvents() {
|
||||
first := h.Doc.Events[0].Timestamp
|
||||
last := h.Doc.Events[len(h.Doc.Events)-1].Timestamp
|
||||
if first.Equal(last) {
|
||||
parts = append(parts, first.Format("2006-01-02 15:04"))
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf("%s→%s",
|
||||
first.Format("01-02 15:04"), last.Format("01-02 15:04")))
|
||||
}
|
||||
// 真实来源(跳过内部归档标记,它对模型无意义)
|
||||
if src := firstRealSource(h.Doc.Events); src != "" {
|
||||
parts = append(parts, src)
|
||||
}
|
||||
} else {
|
||||
parts = append(parts, h.Doc.CreatedAt.Format("2006-01-02 15:04"))
|
||||
}
|
||||
}
|
||||
|
||||
// 正文开头:真实内容,模型可据此判断相关性
|
||||
if h.Doc != nil {
|
||||
if head := docHeadlineForPrompt(h.Doc); head != "" {
|
||||
parts = append(parts, head)
|
||||
}
|
||||
}
|
||||
// 媒体只报类型与数量,不编描述(描述式索引已在 v1.2.0 拆除)
|
||||
if h.Media != nil {
|
||||
if line := blockLabel(h.Media); line != "" {
|
||||
parts = append(parts, line)
|
||||
label := string(h.Media.Modality)
|
||||
if h.Media.MIME != "" {
|
||||
label = h.Media.MIME
|
||||
}
|
||||
parts = append(parts, "["+label+"]")
|
||||
}
|
||||
if h.DoubleHit {
|
||||
parts = append(parts, "(图文双命中)")
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("相关度:%.2f", h.Fused))
|
||||
lines = append(lines, strings.Join(parts, " · "))
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("【历史记忆(L2,自动预填充)】\n")
|
||||
b.WriteString("下面是按语义相似度从历史层挑出的相关片段 —— **不是当前对话**。\n")
|
||||
b.WriteString(strings.Join(lines, "\n"))
|
||||
b.WriteString("\n要其中某条的完整原文与当时过程:用 doc_query,它会把那些事件" +
|
||||
"按原始时间戳插回你的对话时序。")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// firstRealSource 取事件里第一个非内部标记的来源。
|
||||
//
|
||||
// `context_archived` 是内核归档动作的标记,不是"谁说的",对模型无意义。
|
||||
func firstRealSource(events []document.DocEvent) string {
|
||||
for _, ev := range events {
|
||||
if ev.Source != "" && ev.Source != "context_archived" && ev.Source != "cold_storage" {
|
||||
return ev.Source
|
||||
}
|
||||
}
|
||||
if len(events) > 0 {
|
||||
return events[0].Source
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// docHeadlineForPrompt 取文档正文的第一句,作为注入行里的内容提示。
|
||||
//
|
||||
// 不截断成"摘要":这里只是一行提示,完整内容在 L2 里、需要时用 doc_query 取。
|
||||
func docHeadlineForPrompt(d *document.Doc) string {
|
||||
if d == nil {
|
||||
return ""
|
||||
}
|
||||
if d.HasEvents() {
|
||||
for _, ev := range d.Events {
|
||||
in, resp, _ := ev.InOut()
|
||||
for _, cand := range []string{in, resp} {
|
||||
if line := firstMeaningfulLineForPrompt(cand); line != "" {
|
||||
return truncateRunesForPrompt(line, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("相关度:%.2f%s", h.Fused, marker))
|
||||
lines = append(lines, strings.Join(parts, " "))
|
||||
return ""
|
||||
}
|
||||
return "【跨模态相关记忆】\n" + strings.Join(lines, "\n")
|
||||
return truncateRunesForPrompt(firstMeaningfulLineForPrompt(d.Content), 60)
|
||||
}
|
||||
|
||||
func firstMeaningfulLineForPrompt(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
|
||||
}
|
||||
}
|
||||
if hasWord {
|
||||
return strings.Join(strings.Fields(line), " ")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func truncateRunesForPrompt(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max]) + "…"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user