Files
HomeAgent/internal/agent/core/medialoop.go
JianFeeeee 6c2039f5c9 feat(vector): pluggable multimodal vector space
核心暴露 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.
2026-09-09 17:38:34 +08:00

288 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
import (
"log"
"runtime/debug"
"sync"
"sync/atomic"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
)
// 媒体记忆的两条后台循环。
//
// mediaGCLoop 清理无人引用的 blob让容量上限真正生效。
// mediaDescribeLoop 给未描述的媒体生成文字描述(方案 C 的另一半)。
//
// 为何描述要走后台而不是入库时同步做:视觉模型一次调用在生产实测 9.6s
// see_video 6 帧批量 23s。放在对话路径上会让每张图都给回复加十几秒
// 而描述的价值是**几个月后还能检索到这张图**,不是这一轮对话——
// 这一轮模型本来就直接看着图。
const (
// mediaDescribeBatch 是单轮描述的媒体条数上限。
//
// 取 4既有回退链的 modalFallbackMaxBlocks 是 6一次请求最多带 6 个媒体),
// 这里留出余量,且每条单独请求以便逐条落库——批量描述拿回来一整段文字
// 无法可靠切分回各自的 digest。
mediaDescribeBatch = 4
// mediaDescribeMinInterval 是两轮描述之间的最小间隔。
//
// 描述是纯后台的锦上添花,不该跟对话抢视觉模型配额。取 30s 让它
// 慢慢消化积压,而不是一上线就把几百条历史媒体全打过去。
mediaDescribeMinInterval = 30 * time.Second
)
// mediaGCLoop 周期清理无引用的媒体内容。
//
// 不做这件事的后果容量上限形同虚设。CAS 的 GC 只在被显式调用时执行,
// 而 Put 路径不触发它——一次 see_video 抽 10 帧,帧本身没人引用(工具
// 结果被 Prune 掉之后),若无人清理就会一直堆在磁盘上。
func (a *Agent) mediaGCLoop() {
defer func() {
if r := recover(); r != nil {
log.Printf("[agent] mediaGCLoop panic recovered: %v\n%s", r, debug.Stack())
time.Sleep(time.Second)
go a.mediaGCLoop()
}
}()
if a.mediaStore == nil || a.mediaGCInterval <= 0 {
return
}
ticker := time.NewTicker(a.mediaGCInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
removed, freed, err := a.mediaStore.GC(a.mediaGCMinAge)
if err != nil {
log.Printf("[media] GC 失败: %v", err)
continue
}
if removed > 0 {
st := a.mediaStore.Stats()
log.Printf("[media] GC 清理 %d 条(释放 %d 字节),剩余 %v 条 / %v 字节",
removed, freed, st["count"], st["total_bytes"])
}
case <-a.ctx.Done():
return
}
}
}
// mediaDescribeLoop 给未描述的媒体补文字描述。
//
// 描述文本才是持久语义记忆blob 会被容量 GC 淘汰,而描述留在 media 表里,
// 并经 mediaSummaryForEvent 写进 L0 事件、随归档进 L2 文档、经蒸馏进 L3 图库。
// 于是「那张紫蓝红三色带图」在原始字节早已被清掉之后仍然可被检索到。
func (a *Agent) mediaDescribeLoop() {
defer func() {
if r := recover(); r != nil {
log.Printf("[agent] mediaDescribeLoop panic recovered: %v\n%s", r, debug.Stack())
time.Sleep(time.Second)
go a.mediaDescribeLoop()
}
}()
if a.mediaStore == nil || !a.mediaDescribe {
return
}
ticker := time.NewTicker(mediaDescribeMinInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
a.describePendingMedia()
case <-a.ctx.Done():
return
}
}
}
// describePendingMedia 取一批未描述的媒体逐条描述。
//
// 逐条而非批量:批量拿回来是一整段文字,无法可靠切分回各自的 digest
// (模型未必按序号输出,也可能把两张图合并成一句)。宁可多几次往返
// 也要保证「描述 ↔ digest」的对应关系是确定的。
func (a *Agent) describePendingMedia() {
pending, err := a.mediaStore.Pending(mediaDescribeBatch)
if err != nil {
log.Printf("[media] 取待描述项失败: %v", err)
return
}
if len(pending) == 0 {
return
}
for _, it := range pending {
select {
case <-a.ctx.Done():
return
default:
}
kind := "image"
if it.Kind == media.KindAudio {
kind = "audio"
} else if it.Kind != media.KindImage {
// 视频帧以 image 入库;其余大类没有可用的描述通道,
// 标记成"不可描述"以免每轮都被 Pending 取出来重试。
if err := a.mediaStore.Describe(it.Digest, "", "unsupported"); err != nil {
log.Printf("[media] 标记不可描述失败 %s: %v", shortDigest(it.Digest), err)
}
continue
}
p, srcName := a.resolveModalFallback(kind)
if p == nil {
// 没有声明该模态能力的源——这一轮整体跳过,不逐条重试。
// 配置好之后自然会被下一轮捡起来。
log.Printf("[media] 无可用的 %s 描述源,跳过本轮(%d 条待描述)", kind, len(pending))
return
}
data, err := a.mediaStore.Get(it.Digest)
if err != nil {
// blob 已被 GC 清掉但元数据还在GC 会同删,此处属异常路径):
// 标记一下避免死循环。
log.Printf("[media] 读内容失败 %s: %v", shortDigest(it.Digest), err)
if e := a.mediaStore.Describe(it.Digest, "", "content-missing"); e != nil {
log.Printf("[media] 标记内容缺失失败 %s: %v", shortDigest(it.Digest), e)
}
continue
}
mime := it.MIME
if mime == "" {
mime = "image/png"
}
url := media.DataURL(mime, data)
desc, err := a.chatModalFallbackBatch(p, kind, []string{url}, []string{"high"})
if err != nil {
// 失败不标记:可能是网络抖动或配额,下一轮该重试。
log.Printf("[media] 描述失败 %s (源=%s): %v", shortDigest(it.Digest), srcName, err)
continue
}
if desc == "" {
// 空回复通常意味着上游把媒体剥离了——与 modalfallback 里的判断
// 同一个道理,视作失败而非"没什么可说的"。
log.Printf("[media] 描述为空 %s (源=%s),视作失败", shortDigest(it.Digest), srcName)
continue
}
if err := a.mediaStore.Describe(it.Digest, desc, srcName); err != nil {
log.Printf("[media] 写描述失败 %s: %v", shortDigest(it.Digest), err)
continue
}
log.Printf("[media] 已描述 %s (%s, %d 字, 源=%s)", shortDigest(it.Digest), kind, len([]rune(desc)), srcName)
// 描述成功后无需再次做视觉嵌入:图片在进入 L0 记忆块时已由
// embedMediaOnIngest 计算并写入 CASL0→L2→L3 只转移引用并复用坐标。
// 历史已有图片或模型切换由启动时 reembedStaleMedia 一次性补算。
}
}
// reembedStaleMedia 在启动时批量迁移历史媒体向量到当前向量空间。
//
// 触发场景(任一变化都会导致旧向量无法参与查询):
// - 切换模型(模型 A→模型 Bfp 变了)
// - 切换向量维度ONNX→HTTP dim 512→1024
// - 首次部署嵌入服务(历史无向量的媒体补算)
// - 嵌入服务离线后重新上线(失败条目 vec_model 仍为空)
//
// 并发策略:启动时用 worker pool 并行迁移,避免上千张图片串行耗时过长。
// 并发数在 ONNX 内嵌路径下不超 CPU 核心数(避免 ONNX 并发限流),
// 外部 API 路径下不超 8避免打爆外部服务
func (a *Agent) reembedStaleMedia() {
if a.multimodalSpace == nil || a.mediaStore == nil {
return
}
fp := a.multimodalSpace.Fingerprint()
digests, err := a.mediaStore.StaleVecDigestsAll(fp)
if err != nil {
log.Printf("[media] 查询需重算向量的媒体失败: %v", err)
return
}
if len(digests) == 0 {
log.Printf("[media] 无需迁移向量(所有媒体已与当前空间对齐 fp=%s", shortFP(fp))
return
}
// 并发度ONNX 内嵌不超过 4外部 API 不超过 8由配置或实际环境动态定
workers := 4
if fp[:min(4, len(fp))] == "http:" {
workers = 8
}
log.Printf("[media] 启动向量迁移: %d 条 → 新空间 fp=%s dim=%d workers=%d",
len(digests), shortFP(fp), a.multimodalSpace.Dim(), workers)
jobs := make(chan string, workers*2)
var done, failed int64
var failedMu sync.Mutex
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for d := range jobs {
if err := a.reembedOne(d, fp); err != nil {
failedMu.Lock()
failed++
failedMu.Unlock()
continue
}
atomic.AddInt64(&done, 1)
}
}()
}
for i, d := range digests {
jobs <- d
// 每迁移 20 条输出进度日志,让用户看到迁移在推进
if (i+1)%20 == 0 {
log.Printf("[media] 向量迁移进度: %d/%d (done=%d failed=%d)", i+1, len(digests), atomic.LoadInt64(&done), failed)
}
}
close(jobs)
wg.Wait()
log.Printf("[media] 向量迁移完成: 成功=%d 失败=%d 总计=%d fp=%s",
done, failed, len(digests), shortFP(fp))
}
// reembedOne 为单条媒体重新计算向量并写入。stat 错误时跳过(可能已被 GC 清除)。
// Get 错误或 Embed 错误时静默跳过该条目(不影响迁移其他条目)。
func (a *Agent) reembedOne(digest, fp string) error {
it, err := a.mediaStore.Stat(digest)
if err != nil {
return err
}
data, err := a.mediaStore.Get(digest)
if err != nil {
return err
}
mime := it.MIME
if mime == "" {
mime = "image/png"
}
vec, err := a.multimodalSpace.EmbedImageDense(data, mime)
if err != nil {
return err
}
return a.mediaStore.SetVec(digest, vec, fp)
}
// shortFP 截断 fingerprint 为可读日志格式。
func shortFP(fp string) string {
if len(fp) > 12 {
return fp[:12]
}
return fp
}