mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 09:28:14 +00:00
核心暴露 MultimodalEmbedder 接口,两条路径共享同一套 L0/L2/L3 向量缓存、media.Store 坐标、QueryMemoryMediaScored 检索: - onnx:内嵌 ONNX 模型(CLIP 等),通过 build tag 编译 - http:外部向量 API 服务(Jina v5 / OpenAI / 自建) 跨模态融合权重改为 CrossModalFusionConfig 可配置结构体, 移除所有模型特定硬编码(CLIP/Jina),版本切换只需改配置。 模型切换自动迁移: - StaleVecDigestsAll 支持全模态(image+audio+video) - 启动时并发重算(ONNX 4 workers / API 8 workers) - 修复 SQL 运算符优先级导致 kind 过滤失效的 bug 实测对比(492 篇生产文档 + 3 张真实图片): - TF-IDF:MRR 0.457(精确匹配快,语义差) - fastText:MRR 0.530(语义中等,延迟 8ms) - Jina v5-omni:MRR 0.900(全面领先,延迟 40ms) - 中文文本→图片:Jina MRR 0.833 vs CLIP 0.611 See docs/embedding-comparison.md for full benchmark.
268 lines
8.6 KiB
Go
268 lines
8.6 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}
|
||
// 命中文档若关联着媒体(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 的
|
||
// `[<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")
|
||
}
|