mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
refactor(memory)!: 块 union 化,彻底淘汰 CAS 第一阶段(生产代码)
块自带负载(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 空实现,待⑥删包)
This commit is contained in:
@ -6,28 +6,30 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
)
|
||||
|
||||
// CrossModalHit 是跨模态检索融合后的一条候选。
|
||||
//
|
||||
// 统一的检索单元是记忆块而非 CAS 全库:媒体在 L0/L2/L3 都由层容器持有,
|
||||
// 只有仍被某层记忆块持有的媒体才可召回。Doc 是 L2 文档;Media 是该块携带的
|
||||
// 原生媒体坐标。两路分数尺度不同,融合前各自归一化,见 fuseCrossModal。
|
||||
// 统一的检索单元是**记忆块**(文本块与媒体块同构)。文本路命中文档;
|
||||
// 视觉路命中媒体块——两者都是块,向量在同一统一空间里可直接比较。
|
||||
//
|
||||
// ★ 2026-10-11:不再有「CAS 全库」这个检索面。视觉路直接走
|
||||
// memory.RecallBlocks(读块自己的向量),因此不存在「召回已无处可归的
|
||||
// 内容」这回事——能召回的必然是被某层持有的块。
|
||||
type CrossModalHit struct {
|
||||
Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil
|
||||
Media *media.Item // 视觉路命中的媒体;文本路命中时也可能带关联媒体
|
||||
MediaScore float64 // 视觉路原始 cosine(无则 0)
|
||||
DocScore float64 // 文本路原始 cosine(无则 0)
|
||||
Fused float64 // 归一化加权融合分,供最终排序
|
||||
// 该媒体同时被两路命中(文本路经文档关联、视觉路直接命中)时,
|
||||
// DoubleHit=true —— 双信号确认,应排在只被一路命中的候选之前。
|
||||
Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil
|
||||
Media *memory.MemoryBlock // 视觉路命中的媒体块;文本路命中时也可能带关联块
|
||||
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)
|
||||
@ -54,9 +56,7 @@ func (c CrossModalFusionConfig) visualWeight() float64 {
|
||||
}
|
||||
return c.WeightVisual
|
||||
}
|
||||
func (c CrossModalFusionConfig) doubleHitBonus() float64 {
|
||||
return c.DoubleHitBonus
|
||||
}
|
||||
func (c CrossModalFusionConfig) doubleHitBonus() float64 { return c.DoubleHitBonus }
|
||||
func (c CrossModalFusionConfig) minMaxEps() float64 {
|
||||
if c.MinMaxEps <= 0 {
|
||||
return defaultFusionConfig.MinMaxEps
|
||||
@ -69,17 +69,16 @@ func (c CrossModalFusionConfig) minMaxEps() float64 {
|
||||
// 策略(两路并行,召回真正最相似的):
|
||||
// 1. 文本路:query 整段文本编码后查文档层(Doc.DenseVec 已融合其块的媒体向量),
|
||||
// 命中文档若持有媒体块,直接带上该块。
|
||||
// 2. 视觉路:query 经多模态模型文本编码 → 与媒体块向量比余弦
|
||||
// (QueryMediaScored),覆盖文本向量没写到的视觉内容。
|
||||
// 2. 视觉路:query 经多模态模型文本编码 → 与**媒体块自己的向量**比余弦
|
||||
// (memory.RecallBlocks),覆盖文本向量没写到的视觉内容。
|
||||
// 用的就是块向量——没有第二套向量、没有第二个检索面。
|
||||
// 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
|
||||
@ -90,34 +89,38 @@ func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionC
|
||||
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
|
||||
// 命中文档若持有一等记忆块,把首个媒体块一并带上(用于双命中标记)。
|
||||
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.mediaStore != nil {
|
||||
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 mh, err := a.mediaStore.QueryMediaScored(qv, a.multimodalSpace.Fingerprint(), per); 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 {
|
||||
// 只有仍被某层记忆块持有的媒体才可召回:CAS 是全库字节存储,
|
||||
// 直接拿它的检索结果会把已无处可归的内容也从记忆里翻出来。
|
||||
held := a.heldMediaDigests()
|
||||
for _, h := range mh {
|
||||
if h.Item == nil || !held[h.Item.Digest] {
|
||||
continue
|
||||
for _, h := range hits {
|
||||
if h.Block.Modality == memory.BlockText {
|
||||
continue // 文本块由文本路负责,避免同一内容两路重复
|
||||
}
|
||||
b := h.Block
|
||||
visualHits = append(visualHits, CrossModalHit{
|
||||
Media: h.Item, MediaScore: h.Score,
|
||||
Media: &b, MediaScore: h.Score,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -127,14 +130,6 @@ func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionC
|
||||
}
|
||||
|
||||
// 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))
|
||||
@ -171,12 +166,11 @@ func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossMod
|
||||
return "doc:" + h.Doc.ID
|
||||
}
|
||||
if h.Media != nil {
|
||||
return "media:" + h.Media.Digest
|
||||
return "media:" + h.Media.ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 先并入视觉路(视觉媒体是独立实体)
|
||||
for i, h := range visualHits {
|
||||
k := key(h)
|
||||
if k == "" {
|
||||
@ -187,14 +181,12 @@ func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossMod
|
||||
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 {
|
||||
if ex, ok := byKey["media:"+h.Media.ID]; ok {
|
||||
ex.DoubleHit = true
|
||||
ex.Doc = h.Doc
|
||||
ex.Fused += cfg.textWeight()*textN[i] + cfg.doubleHitBonus()
|
||||
@ -233,7 +225,7 @@ func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossMod
|
||||
}
|
||||
|
||||
// crossModalMarkdown 把融合候选渲染成注入上下文的文本。
|
||||
// 文档行给出摘要;媒体行只给 MIME + 短 digest(不再有生成的描述)。
|
||||
// 文档行给出摘要;媒体行只给 MIME + 短块 ID(不再有生成的描述)。
|
||||
func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
|
||||
if len(hits) == 0 {
|
||||
return ""
|
||||
@ -257,7 +249,7 @@ func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
|
||||
}
|
||||
}
|
||||
if h.Media != nil {
|
||||
if line := mediaLabel(h.Media); line != "" {
|
||||
if line := blockLabel(h.Media); line != "" {
|
||||
parts = append(parts, line)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user