mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
块自带负载(MemoryBlock.Data),删 PayloadDigest;检索纯向量,媒体随块 同生灭。生产代码全绿;CAS 专属测试将在下一提交随判据重写。 - memory: Data BLOB + migration9;blockColumns 不含 data(召回只比向量); MediaBlockID 内容派生 ID;BlockByIDPrefix/BlockData 按需加载 - sdk: memory_impl 去 media.Store;MemoryBlockView PayloadDigest→HasData - core: mediaref/graphmedia/crossmodal/medialoop 全部改块语义; captureBlockMedia 直接产块;resolveMediaRefs 按块 ID 解析 - knowledge: 媒体字节随条目目录自带(File+Data),删 mediaGet/mediaPut - webui: graphBlockView/pulse payload_digest→has_data - bootstrap/registry: 去 MediaStore 接线(SetMediaStore 空实现,待⑥删包)
261 lines
7.7 KiB
Go
261 lines
7.7 KiB
Go
package core
|
||
|
||
import (
|
||
"fmt"
|
||
"log"
|
||
"sort"
|
||
"strings"
|
||
|
||
"github.com/JianFeeeee/HomeAgent/internal/memory"
|
||
"github.com/JianFeeeee/HomeAgent/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,
|
||
}); 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 把融合候选渲染成注入上下文的文本。
|
||
// 文档行给出摘要;媒体行只给 MIME + 短块 ID(不再有生成的描述)。
|
||
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 := blockLabel(h.Media); 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")
|
||
}
|