Files
HomeAgent/internal/agent/core/crossmodal.go
JianFeeeee e2cb6e70e0 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)
2026-10-22 19:49:25 +08:00

377 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
import (
"fmt"
"log"
"sort"
"strings"
"unicode"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
)
// CrossModalHit 是跨模态检索融合后的一条候选。
//
// 统一的检索单元是**记忆块**(文本块与媒体块同构)。文本路命中文档;
// 视觉路命中媒体块——两者都是块,向量在同一统一空间里可直接比较。
//
// ★ 2026-10-11:不再有「CAS 全库」这个检索面。视觉路直接走
// memory.RecallBlocks(读块自己的向量),因此不存在「召回已无处可归的
// 内容」这回事——能召回的必然是被某层持有的块。
type CrossModalHit struct {
Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil
Media *memory.MemoryBlock // 视觉路命中的媒体块;文本路命中时也可能带关联块
MediaScore float64 // 视觉路原始 cosine(无则 0)
DocScore float64 // 文本路原始 cosine(无则 0)
Fused float64 // 归一化加权融合分,供最终排序
// 该媒体同时被两路命中时 DoubleHit=true —— 双信号确认,应排在只被
// 一路命中的候选之前。
DoubleHit bool
}
// CrossModalFusionConfig 控制文本路与视觉路的融合行为。
type CrossModalFusionConfig struct {
WeightText float64 // 文本路融合权重(默认 0.5)
WeightVisual float64 // 视觉路融合权重(默认 0.5)
DoubleHitBonus float64 // 双命中额外加分(默认 0.15)
MinMaxEps float64 // min-max 归一化除零保护(默认 1e-12)
}
var defaultFusionConfig = CrossModalFusionConfig{
WeightText: 0.5,
WeightVisual: 0.5,
DoubleHitBonus: 0.15,
MinMaxEps: 1e-12,
}
func (c CrossModalFusionConfig) textWeight() float64 {
if c.WeightText <= 0 {
return defaultFusionConfig.WeightText
}
return c.WeightText
}
func (c CrossModalFusionConfig) visualWeight() float64 {
if c.WeightVisual <= 0 {
return defaultFusionConfig.WeightVisual
}
return c.WeightVisual
}
func (c CrossModalFusionConfig) doubleHitBonus() float64 { return c.DoubleHitBonus }
func (c CrossModalFusionConfig) minMaxEps() float64 {
if c.MinMaxEps <= 0 {
return defaultFusionConfig.MinMaxEps
}
return c.MinMaxEps
}
// retrieveCrossModal 是跨模态并行检索的统一入口。
//
// 策略(两路并行,召回真正最相似的):
// 1. 文本路:query 整段文本编码后查文档层(Doc.DenseVec 已融合其块的媒体向量),
// 命中文档若持有媒体块,直接带上该块。
// 2. 视觉路:query 经多模态模型文本编码 → 与**媒体块自己的向量**比余弦
// (memory.RecallBlocks),覆盖文本向量没写到的视觉内容。
// 用的就是块向量——没有第二套向量、没有第二个检索面。
// 3. 融合:两条路候选各自 min-max 归一化到 [0,1],加权求和后降序,取 topK。
//
// 多模态空间未配置时视觉路为空,退化为纯文本路。
func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionConfig) []CrossModalHit {
if topK <= 0 {
topK = 5
}
per := topK * 2
if per < 8 {
per = 8
}
// ---- 文本路 ----
var textHits []CrossModalHit
if a.docStore != nil {
for _, dh := range a.docStore.QueryScored(query, per) {
hit := CrossModalHit{Doc: dh.Doc, DocScore: dh.Score}
// 命中文档若持有一等记忆块,把首个媒体块一并带上(用于双命中标记)。
for i := range dh.Doc.Blocks {
if dh.Doc.Blocks[i].Modality != memory.BlockText {
b := dh.Doc.Blocks[i]
hit.Media = &b
break
}
}
textHits = append(textHits, hit)
}
}
// ---- 视觉路(多模态文本编码 → 媒体块自己的向量)----
var visualHits []CrossModalHit
if a.multimodalSpace != nil && a.multimodalSpace.Loaded() && a.memory != nil {
qv, err := a.multimodalSpace.VectorizeDense(query)
if err != nil {
log.Printf("[crossmodal] 多模态文本编码失败: %v", err)
} else if hits, err := a.memory.RecallBlocks(memory.BlockRecallQuery{
Vector: qv,
Fingerprint: a.multimodalSpace.Fingerprint(),
TopK: per,
// ★ ExcludeText 必须传:视觉路要的是**媒体块**。
//
// 不传的后果(实测回归,块 union 化引入):CAS 时代这里查的是
// `FROM media`(只含媒体),结构上不可能被文本块挤掉。
// 换成全局 RecallBlocks 后,同一个 chineseclip 空间里图像块的
// 绝对 cosine(0.36~0.41)天然低于文本块(0.65~1.00),
// per=200 时返回 1000 条里图像块 **0 条** —— 视觉路被饿死。
// 下面的 `continue` 是防御,不是过滤手段:筛选必须发生在
// 截断**之前**,否则名额已被文本块占满。
ExcludeText: true,
}); err != nil {
log.Printf("[crossmodal] 媒体块检索失败: %v", err)
} else {
for _, h := range hits {
if h.Block.Modality == memory.BlockText {
continue // 文本块由文本路负责,避免同一内容两路重复
}
b := h.Block
visualHits = append(visualHits, CrossModalHit{
Media: &b, MediaScore: h.Score,
})
}
}
}
return fuseCrossModal(textHits, visualHits, topK, cfg)
}
// fuseCrossModal 把文本路与视觉路候选按各自归一化分融合排序。
func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossModalFusionConfig) []CrossModalHit {
norm := func(hits []CrossModalHit, pick func(CrossModalHit) float64) []float64 {
out := make([]float64, len(hits))
if len(hits) == 0 {
return out
}
maxV, minV := pick(hits[0]), pick(hits[0])
for _, h := range hits[1:] {
v := pick(h)
if v > maxV {
maxV = v
}
if v < minV {
minV = v
}
}
for i, h := range hits {
v := pick(h)
if maxV-minV < cfg.minMaxEps() {
out[i] = 1
continue
}
out[i] = (v - minV) / (maxV - minV)
}
return out
}
textN := norm(textHits, func(h CrossModalHit) float64 { return h.DocScore })
visualN := norm(visualHits, func(h CrossModalHit) float64 { return h.MediaScore })
byKey := make(map[string]*CrossModalHit)
var keys []string
key := func(h CrossModalHit) string {
if h.Doc != nil {
return "doc:" + h.Doc.ID
}
if h.Media != nil {
return "media:" + h.Media.ID
}
return ""
}
for i, h := range visualHits {
k := key(h)
if k == "" {
continue
}
clone := h
clone.Fused = cfg.visualWeight() * visualN[i]
byKey[k] = &clone
keys = append(keys, k)
}
for i, h := range textHits {
if h.Doc == nil {
continue
}
if h.Media != nil {
if ex, ok := byKey["media:"+h.Media.ID]; ok {
ex.DoubleHit = true
ex.Doc = h.Doc
ex.Fused += cfg.textWeight()*textN[i] + cfg.doubleHitBonus()
continue
}
}
k := "doc:" + h.Doc.ID
if ex, ok := byKey[k]; ok {
ex.Doc = h.Doc
ex.DoubleHit = false
ex.Fused += cfg.textWeight() * textN[i]
continue
}
clone := h
clone.Fused = cfg.textWeight() * textN[i]
byKey[k] = &clone
keys = append(keys, k)
}
var merged []CrossModalHit
for _, k := range keys {
if c := byKey[k]; c != nil {
merged = append(merged, *c)
}
}
sort.SliceStable(merged, func(i, j int) bool {
if merged[i].DoubleHit != merged[j].DoubleHit {
return merged[i].DoubleHit
}
return merged[i].Fused > merged[j].Fused
})
if len(merged) > topK {
merged = merged[:topK]
}
return merged
}
// 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 {
var parts []string
parts = append(parts, fmt.Sprintf("[%d]", i+1))
// 时间 + 来源:让模型知道"这是什么时候、谁的事"
if h.Doc != nil {
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 {
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)
}
}
}
return ""
}
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]) + "…"
}