mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 09:28:14 +00:00
媒体此前是"文本块 + digest 引用 + owner 账本 + 独立 GC":ContextEvent.Media
记 digest,media_refs 表用 owner_kind/owner_id 保活,ref_count 决定 GC 能否清。
这与文本记忆块的管理方式不一致,也是本次一并纠正的核心偏差。
改为与文本块完全一致的生命周期:
1. 一等记忆块直接由所在层持有
- ContextEvent.Blocks / Doc.Blocks / GraphDB memory_blocks
- 块带 modality/digest/MIME/size/vector/fingerprint,文本、图片、视频同构
- Context→Document→Graph 迁移的是块本身(ID 不变),迁移后清空源容器,
同一块不同时存在于两层
2. 删除平行生命周期账本
- media.Store 去掉 media_refs 表、OwnerKind 常量、RefCount 字段、
AddRef/DropRef/DropOwner/Refs、ref_count 列与索引
- 删除 mediaGCLoop、GC(keep,minAge)、容量上限与 media.gc_* / media.max_mb 配置
- 媒体内容在块被永久删除时一并删除(media.Store.Delete + forgetPayloads),
与"删除文本块即删除内容"同一语义
3. L3 原生结构
- memory_blocks / memory_block_edges(contains/depicts/derived_from)
- 边端点必须是真实图节点,不再用 owner 字符串伪装关系
- BlocksForNode 支持 sentence --contains--> block 反查
4. SDK 与检索同步
- 插件附件/标记直接变成块,不再 AddRef
- 跨模态检索改用 QueryMediaScored(CAS 内不再有孤儿缓存需要过滤)
测试全部改写为块语义:删除 refcount/media_refs/GC 断言,新增块迁移、
单层不变量、Delete 语义与并发删除回归。
注:cmd/homed/main.go 同时携带工作区中既有的 CLIP→Qwen 模型目录接线改动。
265 lines
8.5 KiB
Go
265 lines
8.5 KiB
Go
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}
|
||
// 命中文档若持有一等记忆块,把首个媒体块一并带上:
|
||
// 描述文本命中 → 该媒体就是相关记忆,供后续展示/注入。
|
||
if a.mediaStore != nil && len(dh.Doc.Blocks) > 0 {
|
||
if it, err := a.mediaStore.Stat(dh.Doc.Blocks[0].PayloadDigest); 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.QueryMediaScored(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 的
|
||
// `[<mime> <短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")
|
||
}
|