refactor(memory): 拆除描述式媒体索引,媒体成为一等块并按原生向量融合

背景:此前媒体是靠「生成的描述文本」将就进记忆的——写 marker 进正文、
再由正则反解成 media_refs 与图库里的 type=Media 实体。这条链路有三个
致命缺陷:描述由异步模型生成(未生成前媒体等于不存在)、语义检索实质上
只搜描述文字、图库里的「媒体节点」是描述文本的投影而不是媒体本身。

本提交把这条链路整体拆除,媒体改为按自己的原生向量参与记忆:

一、描述链彻底删除(无残留、无兼容分支)
- media.Item 去掉 Description/DescribedBy 与对应列;
- 删除 Store.Describe / Store.Search / Store.Pending;
- 删除 Agent.mediaDescribeLoop / describePendingMedia 与配置项
  core.memory.media.describe_on_ingest;
- SDK 侧 MediaAttachment 去掉 Description(见 SDK 仓独立提交)。

二、marker 机制删除,媒体归属改为结构化块边
- 删除 mediaMarkerLine/parseMediaMarkers/mediaEntityName/mediaTriplesFromText/
  extractMediaDigests/sentenceWithMediaMarkers/docMediaContext;
- memory.Triple 新增 MediaDigests 结构化字段;句子文本保持原样,
  不再被 marker 污染;
- 块以 sentence --contains--> block / document --contains--> block 结构边
  挂到承载节点(新增 documents 表与 document 节点种类);
- 模型未给原句时用「主谓宾。」拼一句自然语言作落点,不造 marker 文本。

三、旧数据迁移(幂等)
- 新增 GraphDB.MigrateLegacyMediaEntities:把 type=Media 的旧实体按短 digest
  还原成原生块、挂回原句子、删除旧实体与描述关系;Agent 启动时执行;
- CleanupOrphanedSentences 同时看关系引用与块边,避免把只靠块存活的句子
  连同块边一起删掉。

四、向量融合:媒体按图本身被召回
- 新增 vector.FuseVectors(逐维求和 + L2 归一化);
- Doc.DenseVec = 文本向量 ⊕ 文档块的媒体向量(同 fingerprint 才融合),
  新增 Doc.DenseFP,指纹变化触发重算;
- ContextEvent.DenseVec 同理融合事件块;事件新增 DenseFP,Prune 只在
  同一统一空间内比稠密余弦;
- 跨模态视觉路只召回「仍被某层记忆块持有」的媒体,CAS 全库字节不再
  直接充当记忆检索结果。

五、同时纳入本分支既有的嵌入基础改造(此前工作区未提交,缺它 HEAD 不可构建)
- internal/tfidf 懒回退包、千问三段式多模态 ONNX 空间的 Go 侧
  (qwen/embedder.go、image.go、model_input.go)、CLIP 移除、
  sdk.NewStore 分词器签名与调用点、embed 侧车 systemd 单元。

验证:go build ./... 、go vet ./...(含 -tags medialive)均通过;
在 HEAD 的独立 worktree 上重放本次暂存集后 go test -short ./internal/...
全部通过(端口冲突类用例在隔离环境中亦通过)。未提交工作区中与本改造
无关的改动(HarmonyOS、waiter、devicebridge、plan.md 等)。
This commit is contained in:
JianFeeeee
2026-09-11 11:45:24 +08:00
parent 2330db8a2b
commit e48b4bb006
56 changed files with 2686 additions and 2834 deletions

View File

@ -2,26 +2,44 @@ package core
import (
"log"
"runtime/debug"
"sync"
"sync/atomic"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
)
// 媒体记忆的后台循环。
//
// mediaDescribeLoop 给未描述的媒体生成文字描述。
// 媒体与记忆块的生命周期辅助。
//
// 媒体不单独做生命周期管理(没有 GC、没有引用计数):blob 是记忆块的内容,
// 块的创建/迁移/删除由记忆系统本身决定,块被永久删除时内容随之删除
// (见 forgetPayloads)。
// 块的创建/迁移/删除由记忆系统本身决定,块被永久删除时内容随之删除。
// 图片不靠文本描述索引——它只按自己的统一空间向量被检索。
// heldMediaDigests 汇总三层记忆当前持有的媒体 digest 集合。
//
// 为何描述要走后台而不是入库时同步做:视觉模型一次调用在生产实测 9.6s
// (see_video 6 帧批量 23s)。放在对话路径上会让每张图都给回复加十几秒,
// 而描述的价值是**几个月后还能检索到这张图**,不是这一轮对话——
// 这一轮模型本来就直接看着图。
// CAS 是全库字节存储,它的检索结果不等于「记忆里的媒体」——
// 召回前用它把已无处可归的内容过滤掉。
func (a *Agent) heldMediaDigests() map[string]bool {
held := map[string]bool{}
collect := func(blocks []memory.MemoryBlock) {
for _, b := range blocks {
if b.PayloadDigest != "" {
held[b.PayloadDigest] = true
}
}
}
if a.context != nil {
collect(a.context.Blocks())
}
if a.docStore != nil {
collect(a.docStore.Blocks())
}
if a.memory != nil {
if blocks, err := a.memory.MemoryBlocks(); err == nil {
collect(blocks)
}
}
return held
}
// payloadHeld 报告某个 digest 是否仍被三层记忆中的一等块持有。
// 这是删除前的一次活查询(不是持久化账本):同一份字节可能同时被多个块共享。
@ -73,135 +91,6 @@ func (a *Agent) forgetPayloads(digests []string) {
}
}
const (
// mediaDescribeBatch 是单轮描述的媒体条数上限。
//
// 取 4:既有回退链的 modalFallbackMaxBlocks 是 6(一次请求最多带 6 个媒体),
// 这里留出余量,且每条单独请求以便逐条落库——批量描述拿回来一整段文字
// 无法可靠切分回各自的 digest。
mediaDescribeBatch = 4
// mediaDescribeMinInterval 是两轮描述之间的最小间隔。
//
// 描述是纯后台的锦上添花,不该跟对话抢视觉模型配额。取 30s 让它
// 慢慢消化积压,而不是一上线就把几百条历史媒体全打过去。
mediaDescribeMinInterval = 30 * time.Second
)
// mediaDescribeLoop 给未描述的媒体补文字描述。
//
// 描述文本才是持久语义记忆:它留在 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 计算并写入 CAS,L0→L2→L3 只转移引用并复用坐标。
// 历史已有图片或模型切换由启动时 reembedStaleMedia 一次性补算。
}
}
// reembedStaleMedia 在启动时批量迁移历史媒体向量到当前向量空间。
//
// 触发场景(任一变化都会导致旧向量无法参与查询):
@ -270,8 +159,7 @@ func (a *Agent) reembedStaleMedia() {
done, failed, len(digests), shortFP(fp))
}
// reembedOne 为单条媒体重新计算向量并写入。stat 错误时跳过(可能已被 GC 清除)。
// Get 错误或 Embed 错误时静默跳过该条目(不影响迁移其他条目)。
// reembedOne 为单条媒体重新计算向量并写入(stat/get 失败时跳过该条目)。
func (a *Agent) reembedOne(digest, fp string) error {
it, err := a.mediaStore.Stat(digest)
if err != nil {