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 dae01f9c06
commit 5836c2ce5c
56 changed files with 2686 additions and 2834 deletions

View File

@ -3,9 +3,7 @@ package sdk
import (
"fmt"
"log"
"regexp"
"strconv"
"strings"
"sync/atomic"
"time"
@ -20,34 +18,15 @@ import (
// 这一层原先的缺陷是**静默裁字段**:插件把 Triple / Doc 交进来,包装层只挑
// 自己认识的几个字段转成内部结构,其余丢弃且不报错。两侧都中招:
// - 图记忆:丢 Confidence/SubjectType/ObjectType/SentenceText,又走 Commit
// 而非 CommitWithMedia,于是 sentences 表没有落点,媒体引用无从挂起;
// 而非 CommitWithMedia,于是 sentences 表没有落点,媒体块无从挂接;
// - 知识库:Query 只回 ID/Title/Content,Insert 只写这三个,读写两个方向
// 都把媒体元数据裁掉;Remove 不解引用,媒体永久算「被引用」,GC 收不掉。
// 都把媒体元数据裁掉。
//
// 现在的规则:内部结构有的字段一律透传;媒体一律走标记格式并挂到对应 owner。
// 现在的规则:内部结构有的字段一律透传;媒体一律变成一等记忆块。
// 媒体存储为 nil 时整条链路静默降级为纯文本行为(媒体是记忆增强,不是必需品)。
// ---------- 媒体标记(本层内部) ----------
//
// 标记是媒体在**纯文本记忆**里的表示形式:
//
// [image/png a1b2c3d4e5f6] 一张紫蓝红三色带图
// └ label └ 短 digest └ 描述
//
// 之所以必须借文本承载:Doc.Content、sentences.text、文本记忆的 Input 全是
// 字符串,没有字段能挂结构化数据。描述文本是持久的语义记忆(检索靠它),
// digest 是回到字节的钥匙(反查靠它)。
//
// 格式与内核侧 graphmedia.go 的 mediaSummaryForEvent 一致——两边必须能互读
// 对方写下的标记,否则插件写入的媒体在内核归档时挂不上引用,且不报错。
const sdkShortDigestLen = 12
// sdkMarkerPattern 拆解一条标记,捕获组依次为 label、短 digest、该行剩余描述。
// digest 放宽到 8-64 位以容忍完整 digest 手写的情况;描述取到行尾而非贪婪到底,
// 因为一条记忆可能挂多份媒体、各占一行。
var sdkMarkerPattern = regexp.MustCompile(`\[([^\[\]\s]+)\s+([0-9a-f]{8,64})\]([^\n]*)`)
func sdkShortDigest(d string) string {
if len(d) > sdkShortDigestLen {
return d[:sdkShortDigestLen]
@ -55,34 +34,6 @@ func sdkShortDigest(d string) string {
return d
}
// sdkMarkerFor 为一份已入库的媒体生成标记行。查不到就返回空串——
// 媒体可能已被 GC 清掉,此时不该凭空造出一条指向虚无的标记。
func sdkMarkerFor(ms *media.Store, digest string) string {
it, err := ms.Stat(digest)
if err != nil || it == nil {
return ""
}
label := string(it.Kind)
if it.MIME != "" {
label = it.MIME
}
if it.Description == "" {
// 「已入库但还没描述」与「压根没有媒体」必须可区分:
// 描述由后台循环异步补齐,占位符保证补齐前这份媒体也不会从文本里消失。
return fmt.Sprintf("[%s %s] (未描述)", label, sdkShortDigest(digest))
}
return fmt.Sprintf("[%s %s] %s", label, sdkShortDigest(digest), it.Description)
}
// sdkDigestsIn 返回文本里出现过的短 digest 集合,用于避免重复追加标记。
func sdkDigestsIn(s string) map[string]bool {
out := map[string]bool{}
for _, m := range sdkMarkerPattern.FindAllStringSubmatch(s, -1) {
out[m[2]] = true
}
return out
}
// sdkBlockSeq 保证块 ID 全局唯一:Graph 的 memory_blocks 以 id 为主键,
// 不同文档里序号相同的块会在 L2→L3 迁移时相互覆盖。
var sdkBlockSeq int64
@ -127,27 +78,8 @@ func sdkBlockForDigest(ms *media.Store, digest string) (memory.MemoryBlock, bool
}, true
}
// sdkBlocksFromText 把文本标记里的媒体变成一等块(去重)。
func sdkBlocksFromText(ms *media.Store, text string) []memory.MemoryBlock {
if ms == nil || text == "" {
return nil
}
seen := map[string]bool{}
var blocks []memory.MemoryBlock
for _, m := range sdkMarkerPattern.FindAllStringSubmatch(text, -1) {
full, err := ms.ResolvePrefix(m[2])
if err != nil || seen[full] {
continue
}
seen[full] = true
if b, ok := sdkBlockForDigest(ms, full); ok {
blocks = append(blocks, b)
}
}
return blocks
}
// sdkBlocksFromDigests 为显式 digest 列表构造一等块(去重)。
// digest 可以是短前缀,内部会先补全。
func sdkBlocksFromDigests(ms *media.Store, digests []string) []memory.MemoryBlock {
if ms == nil || len(digests) == 0 {
return nil
@ -155,11 +87,15 @@ func sdkBlocksFromDigests(ms *media.Store, digests []string) []memory.MemoryBloc
seen := map[string]bool{}
var blocks []memory.MemoryBlock
for _, d := range digests {
if d == "" || seen[d] {
if d == "" {
continue
}
seen[d] = true
if b, ok := sdkBlockForDigest(ms, d); ok {
full, err := ms.ResolvePrefix(d)
if err != nil || seen[full] {
continue
}
seen[full] = true
if b, ok := sdkBlockForDigest(ms, full); ok {
blocks = append(blocks, b)
}
}
@ -177,10 +113,9 @@ func sdkPutAttachment(ms *media.Store, a MediaAttachment, tool string) (string,
mime = "application/octet-stream"
}
return ms.Put(a.Data, media.Item{
MIME: mime,
Tool: tool,
OriginPath: a.Name,
Description: a.Description,
MIME: mime,
Tool: tool,
OriginPath: a.Name,
})
}
if a.Digest == "" {
@ -193,32 +128,6 @@ func sdkPutAttachment(ms *media.Store, a MediaAttachment, tool string) (string,
return full, nil
}
// sdkAttachmentsFromText 从文本标记反解出附件元数据(不含字节),
// 让插件不必自己写正则去认标记。
func sdkAttachmentsFromText(ms *media.Store, s string) []MediaAttachment {
if ms == nil || s == "" {
return nil
}
var out []MediaAttachment
seen := map[string]bool{}
for _, m := range sdkMarkerPattern.FindAllStringSubmatch(s, -1) {
full, err := ms.ResolvePrefix(m[2])
if err != nil || seen[full] {
continue
}
seen[full] = true
att := MediaAttachment{Digest: full, MIME: m[1], Description: strings.TrimSpace(m[3])}
if it, err := ms.Stat(full); err == nil && it != nil {
att.MIME = it.MIME
if it.Description != "" {
att.Description = it.Description
}
}
out = append(out, att)
}
return out
}
// ---------- 图记忆 ----------
type graphMemory struct {
@ -260,10 +169,8 @@ func (m *graphMemory) Recall(query []string, depth int) ([]Entity, []Relation, e
return entities, relations, nil
}
// Commit 把插件的三元组写入图库,并把三元组句子里的媒体变成 L3 一等块。
//
// 媒体的落点链是 SentenceText → sentences 表 → sentence_id → 块边。
// 旧实现丢掉 SentenceText 又走 Commit(不回 sentenceIDs),这条链一步都走不通。
// Commit 把插件的三元组写入图库,并把结构化 MediaDigests 变成 L3 一等块。
// 媒体通过 sentence --contains--> block 结构边挂接,不读写任何正文 marker。
func (m *graphMemory) Commit(triples []Triple) error {
if m.db == nil {
return nil
@ -278,9 +185,7 @@ func (m *graphMemory) Commit(triples []Triple) error {
SubjectType: t.SubjectType,
ObjectType: t.ObjectType,
SentenceText: t.SentenceText,
}
if len(t.MediaDigests) > 0 {
mt.SentenceText = m.sentenceWithMedia(mt.SentenceText, t.MediaDigests)
MediaDigests: t.MediaDigests,
}
ts = append(ts, mt)
}
@ -289,61 +194,28 @@ func (m *graphMemory) Commit(triples []Triple) error {
if err != nil {
return err
}
m.bindSentences(sentenceIDs)
m.bindSentences(sentenceIDs, ts)
return nil
}
// sentenceWithMedia 保证句子文本里带有这些 digest 的媒体标记。
// bindSentences 把每个三元组显式携带的媒体变成 L3 一等记忆块,
// 并以 sentence --contains--> block 结构边关联。
//
// 让插件填 MediaDigests 就够,不必知道标记格式——否则格式写错的后果是
// 引用静默挂不上。已出现过的 digest 不重复追加:插件可能既手写了标记又填了
// MediaDigests,重复标记会让同一份媒体产生两条一样的句子引用。
func (m *graphMemory) sentenceWithMedia(sentence string, digests []string) string {
present := sdkDigestsIn(sentence)
var add []string
for _, d := range digests {
if d == "" || present[sdkShortDigest(d)] {
continue
}
if m.ms == nil {
// 没有媒体存储时也把 digest 留在文本里:拿不到描述,
// 但将来存储可用时这条记忆仍能反查回字节。
add = append(add, fmt.Sprintf("[media %s] (未描述)", sdkShortDigest(d)))
present[sdkShortDigest(d)] = true
continue
}
full, err := m.ms.ResolvePrefix(d)
if err != nil {
log.Printf("[sdk media] 插件 %s 提交的 digest %s 无法解析: %v", m.plugin, d, err)
continue
}
if line := sdkMarkerFor(m.ms, full); line != "" {
add = append(add, line)
present[sdkShortDigest(full)] = true
}
}
if len(add) == 0 {
return sentence
}
if sentence == "" {
return strings.Join(add, "\n")
}
return sentence + "\n" + strings.Join(add, "\n")
}
// bindSentences 把每条句子里引用的媒体变成 L3 的一等记忆块,
// 并建立 sentence --contains--> block 的结构边。
// 不再写 media_refs:块本身就是图的一部分,不需要 owner 账本保活。
func (m *graphMemory) bindSentences(sentenceIDs map[string]int64) {
// 不再往句子文本里写 marker、也不再从文本反解 digest:归属由结构化字段直接给出。
func (m *graphMemory) bindSentences(sentenceIDs map[string]int64, triples []memory.Triple) {
if m.ms == nil || m.db == nil || len(sentenceIDs) == 0 {
return
}
bound := 0
for text, sid := range sentenceIDs {
for _, t := range triples {
if len(t.MediaDigests) == 0 {
continue
}
sid := sentenceIDs[t.SentenceText]
if sid == 0 {
continue
}
for _, b := range sdkBlocksFromText(m.ms, text) {
for _, b := range sdkBlocksFromDigests(m.ms, t.MediaDigests) {
if err := m.db.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
log.Printf("[sdk media] 插件 %s 写入 L3 记忆块失败: %v", m.plugin, err)
continue
@ -403,37 +275,21 @@ func NewTextMemoryWithMedia(plugin string, tm *text.Memory, ms *media.Store) Tex
return &textMemoryImpl{tm: tm, ms: ms, plugin: plugin}
}
// Append 追加一条文本事件;带附件时把媒体标记并进正文。
// Append 追加一条文本事件。
//
// 文本记忆是追加写 JSONL,没有结构化块存储,所以媒体在这一层
// 只能以标记形式存在。这不是妥协——描述文本才是持久的语义记忆,blob 只是缓存。
// 文本记忆是追加写 JSONL 的原始日志,只有字符串字段,没有块容器;
// 因此附件在这里无法结构化存下。不假装用文本标记承载媒体——
// 需要保存媒体请用文档记忆或图记忆(它们持有一等记忆块)。
func (m *textMemoryImpl) Append(evt TextEvent) error {
if m.tm == nil {
return nil
}
content := evt.Content
if len(evt.Attachments) > 0 && m.ms != nil {
var lines []string
for _, a := range evt.Attachments {
d, err := sdkPutAttachment(m.ms, a, "plugin_text:"+m.plugin)
if err != nil {
log.Printf("[sdk media] 插件 %s 文本附件入库失败: %v", m.plugin, err)
continue
}
if line := sdkMarkerFor(m.ms, d); line != "" {
lines = append(lines, line)
}
}
if len(lines) > 0 {
if content == "" {
content = strings.Join(lines, "\n")
} else {
content += "\n" + strings.Join(lines, "\n")
}
}
if len(evt.Attachments) > 0 {
log.Printf("[sdk media] 插件 %s 向文本记忆追加了 %d 份附件,已忽略:"+
"文本层是字符串日志,不具备块存储;请改用文档/图记忆保存媒体", m.plugin, len(evt.Attachments))
}
return m.tm.Append(text.Event{
Timestamp: evt.Timestamp, Source: evt.Role, Input: content, AgentID: evt.Channel,
Timestamp: evt.Timestamp, Source: evt.Role, Input: evt.Content, AgentID: evt.Channel,
})
}
@ -449,7 +305,6 @@ func (m *textMemoryImpl) RecentEvents(n int) ([]TextEvent, error) {
for i, e := range got {
out[i] = TextEvent{
Role: e.Source, Content: e.Input, Timestamp: e.Timestamp, Channel: e.AgentID,
Attachments: sdkAttachmentsFromText(m.ms, e.Input),
}
}
return out, nil
@ -477,12 +332,8 @@ func NewDocMemoryWithMedia(plugin string, ds *doc.Store, ms *media.Store) DocMem
return &docMemoryImpl{ds: ds, ms: ms, plugin: plugin}
}
// Query 检索文档,并补齐媒体元数据。
//
// 旧实现只回 ID/Title/Content,插件即便拿到一篇带媒体的文档也看不出这里有
// 几份媒体、分别是什么。现在同时给出完整 digest 列表与 mime+描述,
// 但**不回字节**:一次检索可能命中几十份媒体,全塞回去会把跨进程消息撑爆,
// 需要字节时按 digest 单取。
// Query 检索文档,并从文档持有的一等块补齐媒体元数据。
// 只返回 digest/MIME,不返回字节或生成式描述;需要字节时按 digest 单取。
func (m *docMemoryImpl) Query(text string, topK int) []*Doc {
if m.ds == nil {
return nil
@ -498,30 +349,22 @@ func (m *docMemoryImpl) Query(text string, topK int) []*Doc {
// fillMedia 填充文档的媒体字段。
//
// 优先读一等记忆块(文档直接持有),为空时退回解析正文标记——
// 历史文档与经旧版插件写入的文档只有标记、没有块。
// 唯一的来源是文档直接持有的一等记忆块:媒体不靠正文标记、
// 也不靠任何生成的描述文本。CAS 只提供 MIME 等元数据。
func (m *docMemoryImpl) fillMedia(out *Doc, d *doc.Doc) {
if m.ms == nil {
if m.ms == nil || d == nil {
return
}
if d != nil && len(d.Blocks) > 0 {
for _, b := range d.Blocks {
if b.PayloadDigest == "" {
continue
}
out.MediaDigests = append(out.MediaDigests, b.PayloadDigest)
att := MediaAttachment{Digest: b.PayloadDigest, MIME: b.MIME, Description: ""}
if it, err := m.ms.Stat(b.PayloadDigest); err == nil && it != nil {
att.MIME = it.MIME
att.Description = it.Description
}
out.Attachments = append(out.Attachments, att)
for _, b := range d.Blocks {
if b.PayloadDigest == "" {
continue
}
return
}
out.Attachments = sdkAttachmentsFromText(m.ms, out.Content)
for _, a := range out.Attachments {
out.MediaDigests = append(out.MediaDigests, a.Digest)
out.MediaDigests = append(out.MediaDigests, b.PayloadDigest)
att := MediaAttachment{Digest: b.PayloadDigest, MIME: b.MIME}
if it, err := m.ms.Stat(b.PayloadDigest); err == nil && it != nil {
att.MIME = it.MIME
}
out.Attachments = append(out.Attachments, att)
}
}
@ -543,15 +386,14 @@ func appendBlocks(existing []memory.MemoryBlock, add []memory.MemoryBlock) []mem
return existing
}
// Insert 写入文档。正文里已有的媒体标记会被挂成文档级引用,
// 避免插件写进来的媒体在下一次 GC 时被当作无主内容清掉。
// Insert 写入文档。
func (m *docMemoryImpl) Insert(d *Doc) error { return m.InsertWithMedia(d, nil) }
// InsertWithMedia 写入文档并关联媒体。
//
// 标记由内核补进 Content——插件不必知道标记格式,也就不会因为格式写错导致
// 引用挂不上。补标记必须在 ds.Insert 之前完成:向量索引用 Summary+Content
// 计算,标记进不去正文就检索不到这份媒体。
// 媒体直接成为文档持有的一等记忆块:落进 CAS 拿到 digest,
// 再变成块挂到文档上。不往正文写 marker——文档向量会融合这些块的
// 媒体向量(同一统一空间),图片按自己的向量被召回。
func (m *docMemoryImpl) InsertWithMedia(d *Doc, attachments []MediaAttachment) error {
if m.ds == nil || d == nil {
return nil
@ -561,29 +403,26 @@ func (m *docMemoryImpl) InsertWithMedia(d *Doc, attachments []MediaAttachment) e
target.Source = "plugin:" + m.plugin
}
digests := m.storeAttachments(attachments, &target.Content)
digests := m.storeAttachments(attachments)
// 一等记忆块:文档直接持有块本身,CAS 只提供字节与向量。
// 不再写 media_refs——块随文档一同存活或被删除,无需 owner 账本。
target.Blocks = appendBlocks(target.Blocks,
append(sdkBlocksFromDigests(m.ms, digests), sdkBlocksFromText(m.ms, target.Content)...))
target.Blocks = appendBlocks(target.Blocks, sdkBlocksFromDigests(m.ms, digests))
if err := m.ds.Insert(target); err != nil {
return err
}
// 回填给调用方:ID 是新建时内核生成的,Content 含内核补的标记。
// 回填给调用方:ID 是新建时内核生成的。
d.ID = target.ID
d.Content = target.Content
return nil
}
// storeAttachments 把附件落库并把标记追加进 content,返回全部完整 digest。
func (m *docMemoryImpl) storeAttachments(atts []MediaAttachment, content *string) []string {
// storeAttachments 把附件落库,返回全部完整 digest。
func (m *docMemoryImpl) storeAttachments(atts []MediaAttachment) []string {
if m.ms == nil || len(atts) == 0 {
return nil
}
present := sdkDigestsIn(*content)
var digests, lines []string
seen := map[string]bool{}
var digests []string
for _, a := range atts {
full, err := sdkPutAttachment(m.ms, a, "plugin_doc:"+m.plugin)
if err != nil {
@ -591,21 +430,11 @@ func (m *docMemoryImpl) storeAttachments(atts []MediaAttachment, content *string
log.Printf("[sdk media] 插件 %s 文档附件入库失败: %v", m.plugin, err)
continue
}
if seen[full] {
continue
}
seen[full] = true
digests = append(digests, full)
if present[sdkShortDigest(full)] {
continue // 插件自己写了标记,不重复追加
}
present[sdkShortDigest(full)] = true
if line := sdkMarkerFor(m.ms, full); line != "" {
lines = append(lines, line)
}
}
if len(lines) > 0 {
if *content == "" {
*content = strings.Join(lines, "\n")
} else {
*content += "\n" + strings.Join(lines, "\n")
}
}
return digests
}