package core import ( "fmt" "log" "sort" "strings" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" ) // CrossModalHit 是跨模态检索融合后的一条候选。 // // 统一的检索单元是记忆块而非 CAS 全库:媒体在 L0/L2/L3 都和文本一样有 owner, // 只有仍被某层记忆引用的媒体才可召回。Doc 是 L2 文档块;Media 是该块携带的 // 原生媒体坐标。两路分数尺度不同,融合前各自归一化,见 fuseCrossModal。 type CrossModalHit struct { Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil Media *media.Item // 视觉路命中的媒体;文本路命中时也可能带关联媒体 MediaScore float64 // 视觉路原始 cosine(无则 0) DocScore float64 // 文本路原始 cosine(无则 0) Fused float64 // 归一化加权融合分,供最终排序 // 该媒体同时被两路命中(文本路经文档关联、视觉路直接命中)时, // DoubleHit=true —— 双信号确认,应排在只被一路命中的候选之前。 DoubleHit bool } // CrossModalFusionConfig 控制文本路与视觉路的融合行为。 // 默认各路权重 0.5,双命中加权 0.15;不同模型/场景可按实测调整。 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 整段文本用现有方法(fastText/TF-IDF 稀疏 cosine)查文档层, // 每个命中文档再反查其关联媒体(docMediaContext)——描述文本命中即媒体命中。 // 2. 视觉路:query 整段文本经多模态模型文本编码 → 与媒体库全部图像坐标比余弦 // (QueryMediaScored),覆盖描述文本没写到的视觉内容。 // 3. 融合:两条路候选各自 min-max 归一化到 [0,1],加权求和后降序,取 topK。 // 同一媒体被两路同时命中视为双信号确认,额外加权。 // // 多模态空间未配置时视觉路为空,退化为纯文本路(等价旧 docStore.Query)。 func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionConfig) []CrossModalHit { if topK <= 0 { topK = 5 } // 融合前各取 2× 余量,保证融合排序后 topK 仍有足够候选。 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} // 命中文档若关联着媒体(media_refs),把媒体作为文本路候选一并带上: // 描述文本命中 → 该媒体就是相关记忆,供后续展示/注入。 if a.mediaStore != nil { refs, err := a.mediaStore.Refs(media.OwnerDocument, dh.Doc.ID) if err == nil && len(refs) > 0 { if it, err := a.mediaStore.Stat(refs[0]); err == nil { hit.Media = it } } } textHits = append(textHits, hit) } } // ---- 视觉路(多模态文本编码 → 当前记忆层持有的媒体块)---- var visualHits []CrossModalHit if a.multimodalSpace != nil && a.multimodalSpace.Loaded() && a.mediaStore != nil { qv, err := a.multimodalSpace.VectorizeDense(query) if err != nil { log.Printf("[crossmodal] 多模态文本编码失败: %v", err) } else if mh, err := a.mediaStore.QueryMemoryMediaScored(qv, a.multimodalSpace.Fingerprint(), per); err != nil { log.Printf("[crossmodal] 媒体记忆检索失败: %v", err) } else { for _, h := range mh { visualHits = append(visualHits, CrossModalHit{ Media: h.Item, MediaScore: h.Score, }) } } } return fuseCrossModal(textHits, visualHits, topK, cfg) } // fuseCrossModal 把文本路与视觉路候选按各自归一化分融合排序。 // // 归一化模板:两路分数尺度不可直接相加,先各自在路内 min-max 到 [0,1]: // // norm(x) = (x - min) / (max - min),max==min 时置 1 // // 再加权求和:fused = wText·normText + wVisual·normVisual。同一媒体两路都命中 // (经文档关联 + 视觉直接)时 DoubleHit,在加权分上再加双信号确认分。 // 权重通过 CrossModalFusionConfig 按场景配置,不同模型/版本可按实测调整。 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.Digest } 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) } // 再并入文本路:命中的文档是独立实体;带媒体的文档若其媒体 digest // 已在视觉路(双命中),合并到同一候选并标记 DoubleHit。 for i, h := range textHits { if h.Doc == nil { continue } if h.Media != nil { if ex, ok := byKey["media:"+h.Media.Digest]; 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 把融合候选渲染成注入上下文的文本。 // 文档行对齐既有【相关记忆文档】格式;媒体行复用 mediaMarkerLine 的 // `[ <短digest>] <描述>` 格式(那是解析回媒体引用的唯一合法格式)。 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)} 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.Media != nil { if line := a.mediaMarkerLine(h.Media.Digest); line != "" { parts = append(parts, line) } } parts = append(parts, fmt.Sprintf("相关度:%.2f%s", h.Fused, marker)) lines = append(lines, strings.Join(parts, " ")) } return "【跨模态相关记忆】\n" + strings.Join(lines, "\n") }