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

@ -200,7 +200,6 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv {
Memory: graph,
DocStore: docStore,
MediaStore: ms,
MediaDescribe: true, // 描述循环由测试直接调 describePendingMedia
StageHost: NewStageHost(),
MaxContextSize: 3, // 故意压低:第二轮就能触发 Prune 归档
InputProcessing: types.InputProcessingConfig{},
@ -250,19 +249,28 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
a.handleInput(evt)
t.Logf("第一轮(含真实 LLM 往返)耗时 %.1fs", time.Since(t0).Seconds())
// 用 Pending 而非 Search 查刚落盘的项:Search 的 WHERE 里带
// `COALESCE(description,'') != ''`,只返回**已描述**的媒体,
// 此刻描述还没生成(阶段3 才做),Search 必然返回 0 条。
items, err := env.mediaSt.Pending(10)
// 媒体不再有文字描述:CAS 里只有字节、元数据与向量。
// 这里直接按 digest 定位刚落的图(不再有 Pending 队列)。
st := env.mediaSt.Stats()
if st["count"].(int) != 1 {
t.Fatalf("CAS 应自动收到 1 张图,实际 %v 张(captureBlockMedia 未被触发?)", st["count"])
}
var digest string
var found bool
for _, e := range a.context.Recent(0) {
for _, b := range e.Blocks {
digest, found = b.PayloadDigest, true
}
}
if !found {
t.Fatal("无法从上下文块定位刚落盘的图")
}
it0, err := env.mediaSt.Stat(digest)
if err != nil {
t.Fatalf("pending: %v", err)
t.Fatal(err)
}
if len(items) != 1 {
t.Fatalf("CAS 应自动收到 1 张图,实际 %d 张(captureBlockMedia 未被触发?)", len(items))
}
digest := items[0].Digest
t.Logf("✓ 阶段1 CAS 自动落盘: digest=%s size=%d tool=%s",
digest[:12], items[0].Size, items[0].Tool)
digest[:12], it0.Size, it0.Tool)
stored, err := env.mediaSt.Get(digest)
if err != nil || !bytes.Equal(stored, img) {
@ -271,13 +279,15 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
// ── 阶段 2:一等记忆块自动挂到 ContextEvent 上 ──
//
// 这一步验证 bindEventMedia:事件必须拿到 ID 并直接持有块。
// 这一步验证 bindEventMedia:事件必须拿到 ID 并直接持有块;
// 事件文本必须保持原样(不再往正文里贴媒体标记)。
var evtID string
var summaryOK bool
for _, e := range a.context.Recent(0) {
if len(e.Blocks) > 0 {
evtID = e.ID
summaryOK = strings.Contains(e.Input, digest[:12])
if strings.Contains(e.Input, digest[:12]) {
t.Error("事件 Input 里被写入了媒体标记——描述式索引链应该已经拆除")
}
if e.Blocks[0].PayloadDigest != digest {
t.Fatalf("事件持有的块 digest 不对: %+v", e.Blocks)
}
@ -287,51 +297,17 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
if evtID == "" {
t.Fatal("没有任何 ContextEvent 挂上媒体(bindEventMedia 未被触发)")
}
if !summaryOK {
t.Error("事件 Input 里没有媒体摘要标记(mediaSummaryForEvent 未生效)——" +
"L2/L3 靠正文里的短 digest 反查,缺了它整条召回链断掉")
}
t.Logf("✓ 阶段2 块自动绑定: event=%s 摘要内嵌=%v", evtID, summaryOK)
t.Logf("✓ 阶段2 块自动绑定: event=%s", evtID)
// ── 阶段 3:描述由后台循环自动生成(真实视觉模型)──
pending, err := env.mediaSt.Pending(5)
if err != nil {
// ── 阶段 3:媒体只按自己的向量被索引,不再生成任何描述 ──
if it, err := env.mediaSt.Stat(digest); err != nil {
t.Fatal(err)
}
if len(pending) != 1 {
t.Fatalf("应有 1 条待描述,实际 %d 条", len(pending))
}
t1 := time.Now()
a.describePendingMedia()
t.Logf("描述生成耗时 %.1fs", time.Since(t1).Seconds())
it, err := env.mediaSt.Stat(digest)
if err != nil {
t.Fatal(err)
}
if it.Description == "" {
t.Fatal("描述为空——describePendingMedia 未能通过视觉源生成描述")
}
sawColors := strings.Contains(it.Description, "紫") &&
strings.Contains(it.Description, "蓝") &&
strings.Contains(it.Description, "红")
t.Logf("✓ 阶段3 描述自动生成 (%d 字, 源=%s): %s",
len([]rune(it.Description)), it.DescribedBy, truncRunes(it.Description, 90))
if !sawColors {
t.Errorf("描述未含紫/蓝/红三色,视觉模型可能没真正看到图片: %s",
truncRunes(it.Description, 200))
}
if left, _ := env.mediaSt.Pending(5); len(left) != 0 {
t.Errorf("描述完成后仍在待描述队列(%d 条)——会被反复重描述", len(left))
}
// 有描述之后 Search 才应能命中(它按 description 做 LIKE)
if found, err := env.mediaSt.Search("紫", media.KindImage, 5); err != nil {
t.Errorf("search: %v", err)
} else if len(found) == 0 {
t.Error("描述已生成但 Search(\"紫\") 命中 0 条——媒体库关键词入口失效")
} else if len(it.Vec) == 0 {
// 未配置多模态空间时就没有向量——这是合法的降级状态,
// 但要明确报出来,而不是靠描述文本假装能检索。
t.Log("未配置多模态空间:本图无向量,之后只能靠块结构召回 digest")
} else {
t.Logf("✓ 阶段3 Search(\"紫\") 命中 %d 条", len(found))
t.Logf("✓ 阶段3 已写入原生向量: dim=%d", len(it.Vec))
}
// ── 阶段 4:Prune 自动把块从 L0 迁移到 L2 ──
@ -385,11 +361,11 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
t.Fatalf("迁移后内容不可读: %v", err)
}
// ── 阶段 5:archiveColdDocs 自动把媒体带进 L3 图库 ──
// ── 阶段 5:archiveColdDocs 自动把块连到 L3 文档节点 ──
//
// FindColdDocs(72h, 2) 要求文档足够"冷",测试里新建的文档不满足,
// 因此把 LastAccess 往前推——这是为了触发生产代码路径,
// 而不是替代它(Commit/bindSentenceBlocks 全部由它自己调)。
// 而不是替代它(commitTriplesWithMedia/linkBlocksToDocument 全由它自己调)。
for _, d := range env.docStore.RecentDocs(20) {
if d.ID == docRefsFound {
d.LastAccess = time.Now().Add(-100 * time.Hour)
@ -398,13 +374,21 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
}
a.archiveColdDocs()
// 块可能以 document --contains--> block(文档归档)或
// sentence --contains--> block(对话三元组)两种边存在。
sentRefs := 0
var boundSentence int64
docBound := 0
rows, err := env.graph.Recall(nil, nil, 1, "")
if err != nil {
t.Fatalf("graph recall: %v", err)
}
t.Logf("图库实体数 %d", len(rows.Entities))
docBlocks, err := env.graph.BlocksForNode("document", docRefsFound)
if err != nil {
t.Fatal(err)
}
docBound = len(docBlocks)
// 句子 id 是自增整数,扫前若干个足够覆盖本测试写入的量
for sid := int64(1); sid <= 40; sid++ {
blocks, err := env.graph.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
@ -415,10 +399,18 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
}
}
}
if sentRefs == 0 {
t.Error("L2→L3 未写入任何句子→块边——" +
"bindSentenceBlocks 未被 commitTriplesWithMedia 触发," +
"或句子正文里没有可反解的短 digest")
if sentRefs == 0 && docBound == 0 {
t.Error("L2→L3 未写入任何块边——linkBlocksToDocument 未被 archiveColdDocs 触发")
} else if docBound > 0 {
t.Logf("✓ 阶段5 L3 自动写入: 文档 %s 持有 %d 个块", docRefsFound, docBound)
got := docBlocks
if got[0].PayloadDigest != digest {
t.Errorf("文档节点持有的块 digest 不对: %+v", got)
} else if raw, err := env.mediaSt.Get(got[0].PayloadDigest); err != nil || !bytes.Equal(raw, img) {
t.Errorf("从文档块取回的字节与原图不一致 (err=%v)", err)
} else {
t.Logf("✓ 阶段5 反查取回 %d 字节,与原图逐字节一致", len(raw))
}
} else {
t.Logf("✓ 阶段5 L3 自动写入: %d 个句子块,首个 sentences.id=%d", sentRefs, boundSentence)
@ -448,15 +440,10 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
if err := a.indexer.Sync(); err != nil {
t.Fatalf("indexer sync: %v", err)
}
if mc := a.buildMemoryContext("图片 颜色", 0); mc != "" {
if mc := a.buildMemoryContext("测试图片", 0); mc != "" {
t.Logf("注入的记忆上下文: %s", truncRunes(mc, 200))
if strings.Contains(mc, "【关联媒体】") {
t.Logf("✓ 记忆上下文含媒体段")
} else {
t.Error("记忆上下文缺少媒体段——L3 媒体检索接线未生效")
}
} else {
t.Error("图库召回为空,agent 无从得知历史媒体")
t.Log("图库召回为空(本测试不再依赖文本描述,仅记录现状)")
}
ask := &agentIO.InputEvent{
@ -475,6 +462,9 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
a.handleInput(ask)
t.Logf("第二轮耗时 %.1fs", time.Since(t2).Seconds())
// 第二轮仍走真实 LLM:这里只验证链路不报错、有回复。
// 不再断言"答出紫/蓝/红":图片的颜色信息只在原生向量里,
// 未配置多模态空间时模型本来就无从得知——那不属于记忆接线缺陷。
var answer string
select {
case out := <-respCh:
@ -483,27 +473,20 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
t.Fatal("第二轮没有收到回复")
}
t.Logf("agent 回答: %s", truncRunes(answer, 220))
recalled := strings.Contains(answer, "紫") &&
strings.Contains(answer, "蓝") &&
strings.Contains(answer, "红")
if !recalled {
t.Errorf("agent 未能召回三色。这可能是记忆注入链路问题,"+
"也可能是本轮上下文里已无相关记忆(描述在 L2/L3 但未被检索命中)。回答: %s",
truncRunes(answer, 300))
} else {
t.Logf("✓ 阶段7 E2E 召回成功:不给图,agent 答出紫/蓝/红")
if strings.HasPrefix(answer, "处理错误:") {
t.Skipf("上游 LLM 调用失败,端到端召回无法判定: %s", truncRunes(answer, 160))
}
t.Logf("✓ 阶段7 E2E 链路贯通(召回能力取决于是否配置多模态向量空间)")
st := env.mediaSt.Stats()
t.Logf("收尾: %v 条 / %v 字节 / 已描述 %v",
st["count"], st["total_bytes"], st["described"])
st = env.mediaSt.Stats()
t.Logf("收尾: %v 条 / %v 字节 / 类型 %v",
st["count"], st["total_bytes"], st["by_kind"])
}
// TestMediaLive_NegativeControl 阴性对照:没有媒体记忆时不该"记得"。
//
// 没有这条对照,阶段7 的"答出紫蓝红"可能只是模型在猜常见配色,
// 无法区分真召回与先验偏好。
// 没有这条对照,任何"答出了具体内容"的结果都可能只是模型先验,
// 无法区分真召回与猜测。
func TestMediaLive_NegativeControl(t *testing.T) {
c := requireLiveCfg(t)
env := newLiveEnv(t, c)