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

@ -3,321 +3,146 @@ package core
import (
"fmt"
"log"
"regexp"
"strconv"
"strings"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
)
// L3 图库的媒体绑定。
//
// 媒体在 L3 是一等记忆块(memory_blocks),通过 sentence --contains--> block
// 结构边与承载它的句子相连。不再用 media_refs / owner 账本保活。
// 媒体在 L3 是一等记忆块(memory_blocks),以结构边与承载它的节点相连:
// sentence --contains--> block(对话/三元组产生的记忆)
// document --contains--> block(L2 文档归档进 L3)
//
// 图库里的实体与关系仍来自描述文本的 NLP 提取;媒体块只是补上
// 「这条记忆当时带着哪份媒体」这一结构信息。
// 这里不再有任何 marker 文本、正则反解或"描述文本当索引"的路径:
// 媒体只按自己的统一空间向量被检索,图库/文档只记录它的结构归属。
// mediaDigestPattern 匹配事件摘要里的媒体标记 [<mime或kind> <短digest>]。
// migrateLegacyGraphMedia 把 marker 反解出来的旧媒体实体迁移成原生一等块。
//
// 与 mediaSummaryForEvent 的输出格式对应。短 digest 是 12 位十六进制
// (shortDigest 的截断长度),这里放宽到 8-64 位以容忍将来调整截断长度,
// 以及有人手写了完整 digest 的情况。
var mediaDigestPattern = regexp.MustCompile(`\[[^\[\]]*?\b([0-9a-f]{8,64})\]`)
// mediaMarkerPattern 完整拆解一条媒体标记及其后跟的描述,
// 捕获组依次为:标签(mime 或 kind)、短 digest、该行剩余的描述文本。
//
// 与 mediaSummaryForEvent 的输出格式严格对应:
//
// [image/png a1b2c3d4e5f6] 一张紫蓝红三色带图
//
// 描述取到行尾而非贪婪到底:一条事件可能挂多个媒体,各占一行。
var mediaMarkerPattern = regexp.MustCompile(`\[([^\[\]\s]+)\s+([0-9a-f]{8,64})\]([^\n]*)`)
// mediaMarker 是从文档正文里解析出的一条媒体标记。
type mediaMarker struct {
label string // mime 或 kind,如 image/png
shortDigest string
description string
raw string // 原始整段,用作三元组的 SentenceText
}
// parseMediaMarkers 从文本里解析全部媒体标记。
//
// 为何需要它而不只是 extractMediaDigests:媒体入 L3 曾完全依赖 NLP 提取器
// 碰巧从描述文本里提出合规三元组——实测 LLM 的 477 字图片描述只产出
// 「水平 -分割-> 成」这种语法碎片,obj 仅 1 字被 validEntityName 拒掉,
// 于是整条媒体记忆进不了图库。而媒体自身的信息(digest / mime / 描述)
// 是确定的,不该受提取器运气支配。
func parseMediaMarkers(text string) []mediaMarker {
if text == "" {
return nil
// 旧数据里媒体是 type=Media 的普通实体(「图片 a1b2c3d4e5f6」),
// 靠生成的描述文本当索引。迁移后它变成真正的记忆块,以
// sentence --contains--> block 结构边挂回原句子,旧实体与描述关系删除。
// 迁移幂等(实体处理完即删除),因此在每个 Agent 启动时跑一次是安全的。
func (a *Agent) migrateLegacyGraphMedia() {
if a.memory == nil || a.mediaStore == nil {
return
}
ms := mediaMarkerPattern.FindAllStringSubmatch(text, -1)
if len(ms) == 0 {
return nil
}
seen := make(map[string]bool, len(ms))
var out []mediaMarker
for _, m := range ms {
d := m[2]
if seen[d] {
continue
blocks, entities, err := a.memory.MigrateLegacyMediaEntities(func(short string) (memory.MemoryBlock, bool) {
full, err := a.mediaStore.ResolvePrefix(short)
if err != nil {
return memory.MemoryBlock{}, false
}
seen[d] = true
out = append(out, mediaMarker{
label: m[1],
shortDigest: d,
description: strings.TrimSpace(m[3]),
raw: strings.TrimSpace(m[0]),
})
return a.blockFromDigest(full)
})
if err != nil {
log.Printf("[media] 旧媒体实体迁移失败(下轮重试): %v", err)
return
}
if blocks > 0 || entities > 0 {
log.Printf("[media] 旧媒体实体迁移完成: 新建 %d 个原生块,删除 %d 个描述式实体", blocks, entities)
}
return out
}
// mediaEntityName 是媒体在图库里的实体名。
//
// 形如「图片 a1b2c3d4e5f6」。刻意用 digest 而非描述文本构成名字:
// 描述会被重新生成(换视觉模型、补描述),若名字取自描述,同一张图
// 就会在图谱上留下多个节点。digest 不变则名字不变。
// 长度也天然合规(validEntityName 要求 2–50 字符)。
func mediaEntityName(label, shortDigest string) string {
kind := "媒体"
switch {
case strings.HasPrefix(label, "image"):
kind = "图片"
case strings.HasPrefix(label, "audio"):
kind = "音频"
case strings.HasPrefix(label, "video"):
kind = "视频"
}
return kind + " " + shortDigest
}
// mediaTriplesFromText 为文本里的每条媒体标记产出确定的三元组。
//
// 这是媒体进 L3 的可靠路径:不经过 NLP 提取器,因此不受它对描述性文本
// 提取能力的影响。每条媒体至少产出一条「<媒体实体> -内容-> <描述摘要>」,
// 且 SentenceText 用原始标记段,保证 bindSentenceMedia 的正则必然能
// 反解到 digest——绑定从概率事件变成确定行为。
//
// 描述摘要截到 40 字:validEntityName 上限 50 字符,留出余量;
// 图谱节点名过长会让可视化和实体合并都难以处理,完整描述留在
// SentenceText 与 media 表里。
func mediaTriplesFromText(text string) []memory.Triple {
markers := parseMediaMarkers(text)
if len(markers) == 0 {
return nil
}
var out []memory.Triple
for _, m := range markers {
name := mediaEntityName(m.label, m.shortDigest)
// 类型三元组恒可产出,不依赖描述是否存在
out = append(out, memory.Triple{
Subject: name,
SubjectType: "Media",
Relation: "类型",
Object: m.label,
ObjectType: "MimeType",
Confidence: 1.0,
SentenceText: m.raw,
})
desc := summarizeForEntity(m.description, 40)
if desc == "" {
continue
}
out = append(out, memory.Triple{
Subject: name,
SubjectType: "Media",
Relation: "内容",
Object: desc,
ObjectType: "Description",
Confidence: 1.0,
SentenceText: m.raw,
})
}
return out
}
// summarizeForEntity 把描述压成可作实体名的短串。
//
// 取首个句子边界之前的内容,再按 rune 截断——直接按字节截会切坏 UTF-8,
// 图库里就会出现乱码实体名。空白与 Markdown 强调符号一并清掉,
// 否则「**整体构成**」这类标记会进实体名。
func summarizeForEntity(s string, maxRunes int) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
s = strings.NewReplacer("**", "", "*", "", "\n", " ", "\t", " ").Replace(s)
for _, sep := range []string{"。", ";", ",", ". ", "; "} {
if i := strings.Index(s, sep); i > 0 {
s = s[:i]
break
}
}
s = strings.TrimSpace(s)
r := []rune(s)
if len(r) > maxRunes {
r = r[:maxRunes]
}
out := strings.TrimSpace(string(r))
// 太短的残片(如单字)过不了 validEntityName,直接放弃比写进去更好
if len([]rune(out)) < 2 {
return ""
}
return out
}
// extractMediaDigests 从文本里找出所有媒体标记的 digest。
//
// 为何靠正则从文本反解,而不是让三元组结构携带 digest:三元组是 NLP
// 提取器从纯文本产出的(nlp.ToMemoryTriple 只填 Subject/Relation/Object/
// Confidence/SentenceText),提取链路上没有任何位置能塞进结构化的 digest。
// 若要贯通就得改 internal/nlp 的整条数据流——而媒体标记本身就是我们
// 自己按固定格式写进文本的,反解是这里最省的可靠做法。
func extractMediaDigests(text string) []string {
if text == "" {
return nil
}
matches := mediaDigestPattern.FindAllStringSubmatch(text, -1)
if len(matches) == 0 {
return nil
}
seen := make(map[string]bool, len(matches))
var out []string
for _, m := range matches {
d := m[1]
if seen[d] {
continue
}
seen[d] = true
out = append(out, d)
}
return out
}
// bindSentenceBlocks 把句子文本里提到的媒体变成 L3 的一等记忆块,
// 并建立 sentence --contains--> block 结构边。
//
// seed 是本批文档已持有的一等块:迁移时按 digest 复用它们的身份(ID 不变),
// 真正做到“同一个块从 L2 移到 L3”,而不是另建一个同内容的新块。
// 返回本次写入 L3 的块数。
func (a *Agent) bindSentenceBlocks(sentenceIDs map[string]int64, seed []memory.MemoryBlock) int {
if a.mediaStore == nil || a.memory == nil || len(sentenceIDs) == 0 {
// attachBlocksToSentence 把一组 digest 变成 L3 一等块并挂到句子上。
// seed 允许复用已持有块的 ID(L2→L3 迁移保持块身份不变)。
func (a *Agent) attachBlocksToSentence(sentenceID int64, digests []string, seed map[string]memory.MemoryBlock) int {
if a.mediaStore == nil || a.memory == nil || sentenceID == 0 {
return 0
}
bound := 0
for _, d := range digests {
full, err := a.mediaStore.ResolvePrefix(d)
if err != nil {
log.Printf("[media] digest %s 无法解析: %v", d, err)
continue
}
b, ok := seed[full]
if !ok {
if b, ok = a.blockFromDigest(full); !ok {
continue
}
}
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
log.Printf("[media] L3 块写入失败 (%s): %v", shortDigest(full), err)
continue
}
if err := a.memory.AddMemoryBlockEdge("sentence", strconv.FormatInt(sentenceID, 10), "block", b.ID, "contains"); err != nil {
log.Printf("[media] 句子→块边建立失败 (%s): %v", shortDigest(full), err)
continue
}
bound++
}
return bound
}
// linkBlocksToDocument 把文档持有的块写入 L3,并建立
// document --contains--> block 边。块的 ID 原样保留(迁移而非重建)。
func (a *Agent) linkBlocksToDocument(docID string, blocks []memory.MemoryBlock) int {
if a.memory == nil || docID == "" || len(blocks) == 0 {
return 0
}
if err := a.memory.PutDocumentNode(docID, ""); err != nil {
log.Printf("[media] 写入 L3 文档节点失败 (%s): %v", docID, err)
return 0
}
if err := a.memory.PutMemoryBlocks(blocks); err != nil {
log.Printf("[media] 写入 L3 记忆块失败 (doc %s): %v", docID, err)
return 0
}
bound := 0
for _, b := range blocks {
if err := a.memory.AddMemoryBlockEdge("document", docID, "block", b.ID, "contains"); err != nil {
log.Printf("[media] 文档→块边建立失败 (%s): %v", shortDigest(b.PayloadDigest), err)
continue
}
bound++
}
return bound
}
// commitTriplesWithMedia 提交三元组并把三元组显式携带的媒体变成 L3 一等块。
//
// seed 是调用方已持有的一等块(如 L2 文档的 Blocks),用于保持块身份;
// 普通对话路径传 nil。blocks 是本次写入 L3 的块数。
func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int, seed []memory.MemoryBlock) (entities, relations, blocks int, err error) {
if a.memory == nil {
return 0, 0, 0, fmt.Errorf("graph memory 未启用")
}
if a.mediaStore == nil {
ec, rc, cErr := a.memory.Commit(triples, sessionID, turnID)
return ec, rc, 0, cErr
}
sentenceIDs, ec, rc, err := a.memory.CommitWithMedia(triples, sessionID, turnID)
if err != nil {
return ec, rc, 0, err
}
byDigest := make(map[string]memory.MemoryBlock, len(seed))
for _, b := range seed {
if b.PayloadDigest != "" {
byDigest[b.PayloadDigest] = b
}
}
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 _, short := range extractMediaDigests(text) {
full, err := a.mediaStore.ResolvePrefix(short)
if err != nil {
continue
}
b, ok := byDigest[full]
if !ok {
if b, ok = a.blockFromDigest(full); !ok {
continue
}
}
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
log.Printf("[media] L3 记忆块写入失败 (%s): %v", shortDigest(full), err)
continue
}
if err := a.memory.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", b.ID, "contains"); err != nil {
log.Printf("[media] 句子→块边建立失败 (%s): %v", shortDigest(full), err)
continue
}
bound++
}
blocks += a.attachBlocksToSentence(sid, t.MediaDigests, byDigest)
}
if bound > 0 {
log.Printf("[media] L3 图库写入 %d 个一等记忆块", bound)
}
return bound
return ec, rc, blocks, nil
}
// sentenceWithMediaMarkers 保证句子文本里带上这些 digest 的媒体标记。
//
// 存在的理由:L3 的块边由句子正文里的短 digest 反解而来。模型只知道
// digest(从 memory_recall 的「关联媒体」或对话里的媒体标记读到),
// 不该要求它自己按内核格式拼标记——格式写错的后果是块边静默建不起来。
//
// 已出现过的 digest 不重复追加:模型可能既写了标记又填了 media_digests。
func (a *Agent) sentenceWithMediaMarkers(sentence string, digests []string) string {
if a.mediaStore == nil || len(digests) == 0 {
return sentence
// RecallBlocksForSentence 反查某条图库句子持有的一等记忆块。
func (a *Agent) RecallBlocksForSentence(sentenceID int64) ([]memory.MemoryBlock, error) {
if a.memory == nil {
return nil, nil
}
present := make(map[string]bool)
for _, d := range extractMediaDigests(sentence) {
present[d] = true
}
var add []string
for _, d := range digests {
if d == "" || present[shortDigest(d)] {
continue
}
// 模型给的多半是短 digest(它在上下文里看到的就是短的),补全成完整
// digest 才能定位内容。补不上就跳过:内容可能已被删除。
full, err := a.mediaStore.ResolvePrefix(d)
if err != nil {
log.Printf("[media] 模型提交的 digest %s 无法解析: %v", d, err)
continue
}
if line := a.mediaMarkerLine(full); line != "" {
add = append(add, line)
present[shortDigest(full)] = true
}
}
if len(add) == 0 {
return sentence
}
if sentence == "" {
return strings.Join(add, "\n")
}
return sentence + "\n" + strings.Join(add, "\n")
}
// docMediaContext 为一篇文档产出媒体说明,供 doc_query 拼进工具返回值。
//
// 文档的一等记忆块随文档 JSON 持久化;这里只有正文,因此从正文标记反解。
func (a *Agent) docMediaContext(docID, content string) string {
if a.mediaStore == nil {
return ""
}
// 文档的一等记忆块随文档 JSON 持久化;这里只有正文,退回解析标记。
var digests []string
for _, short := range extractMediaDigests(content) {
full, err := a.mediaStore.ResolvePrefix(short)
if err != nil {
continue
}
digests = append(digests, full)
}
var lines []string
for _, d := range digests {
if line := a.mediaMarkerLine(d); line != "" {
lines = append(lines, line)
}
}
if len(lines) == 0 {
return ""
}
return strings.Join(lines, ";")
return a.memory.BlocksForNode("sentence", strconv.FormatInt(sentenceID, 10))
}
// resolveMediaDigests 把模型给的(多为短)digest 补全成完整 digest。
@ -344,38 +169,6 @@ func (a *Agent) resolveMediaDigests(digests []string) []string {
return out
}
// bindDocMedia 把一组完整 digest 挂到文档 owner 上,返回成功条数。
//
// commitTriplesWithMedia 提交三元组并把句子里的媒体变成 L3 一等块。
//
// seed 是调用方已持有的一等块(如 L2 文档的 Blocks),用于保持块身份;
// 普通对话路径传 nil。blocks 是本次写入 L3 的块数。
func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int, seed []memory.MemoryBlock) (entities, relations, blocks int, err error) {
if a.memory == nil {
return 0, 0, 0, fmt.Errorf("graph memory 未启用")
}
// 媒体存储关闭时退回普通 Commit,省掉 sentenceIDs 的 map 分配。
if a.mediaStore == nil {
ec, rc, cErr := a.memory.Commit(triples, sessionID, turnID)
return ec, rc, 0, cErr
}
sentenceIDs, ec, rc, err := a.memory.CommitWithMedia(triples, sessionID, turnID)
if err != nil {
return ec, rc, 0, err
}
return ec, rc, a.bindSentenceBlocks(sentenceIDs, seed), nil
}
// RecallBlocksForSentence 反查某条图库句子持有的一等记忆块。
//
// 这是整层的目的:几个月后从图谱走到一条句子,要能取回当时那份媒体。
func (a *Agent) RecallBlocksForSentence(sentenceID int64) ([]memory.MemoryBlock, error) {
if a.memory == nil {
return nil, nil
}
return a.memory.BlocksForNode("sentence", strconv.FormatInt(sentenceID, 10))
}
// sentenceIDsFromRelations 收集一批关系引用的句子 id(去重、去零)。
//
// 关系行本身不持有媒体,媒体作为一等块以 sentence --contains--> block
@ -397,21 +190,14 @@ func sentenceIDsFromRelations(relations []memory.Relation) []int64 {
}
// mediaContextForRelations 是 mediaContextForSentences 的关系入口。
//
// 单独包一层是因为两个调用点(自动注入的 buildMemoryContext 与显式的
// memory_recall 工具)拿到的都是关系列表,不该各自重复"关系→句子"这步。
func (a *Agent) mediaContextForRelations(relations []memory.Relation) string {
return a.mediaContextForSentences(sentenceIDsFromRelations(relations))
}
// mediaContextForInjectedEntities 为自动注入路径产出媒体说明。
//
// 单独一条路径是因为 Indexer.BuildContext 刻意不返回关系
// (Relations 恒为 nil,只给实体索引以省 token,细节留给 memory_recall)。
// 于是自动注入拿不到 sentence_id,必须用命中的实体名再查一次关系。
//
// 这次额外查询只为取 sentence_id,深度固定 1:媒体是"这条记忆当时带的图",
// 不需要顺着关系network 扩散——扩散只会带出无关媒体并挤占 token。
// Indexer.BuildContext 刻意不返回关系(只给实体索引以省 token),
// 因此这里用命中的实体名再查一次关系,只为拿到 sentence_id。
func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext) string {
if a.mediaStore == nil || a.memory == nil || injected == nil || len(injected.Entities) == 0 {
return ""
@ -427,13 +213,30 @@ func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext
return a.mediaContextForRelations(res.Relations)
}
// mediaContextForSentences 给一组句子附上媒体说明,供召回时拼进提示词。
// blockLabelsForDoc 渲染文档持有块的标签(MIME + 短 digest),供 doc_query 展示。
func (a *Agent) blockLabelsForDoc(d *document.Doc) string {
if a.mediaStore == nil || d == nil || len(d.Blocks) == 0 {
return ""
}
var parts []string
for _, b := range d.Blocks {
it, err := a.mediaStore.Stat(b.PayloadDigest)
if err != nil || it == nil {
continue
}
if line := mediaLabel(it); line != "" {
parts = append(parts, line)
}
}
return strings.Join(parts, ";")
}
// mediaContextForSentences 给一组句子附上其持有的一等块标签。
//
// 输出形如「句子 #12 关联媒体:[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图」。
// 描述文本本就在句子里,这里补的是「内容是否还在、能否重新看图」这个信息——
// 描述永存而字节可能已被淘汰,两者状态不同。
// 标签只含 MIME 与短 digest:图片按向量检索,标签的作用是告诉模型
// "这条记忆当时带着哪份媒体、可用该 digest 取回字节"。
func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string {
if a.memory == nil || len(sentenceIDs) == 0 {
if a.mediaStore == nil || a.memory == nil || len(sentenceIDs) == 0 {
return ""
}
var lines []string
@ -444,7 +247,11 @@ func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string {
}
var parts []string
for _, b := range blocks {
if line := a.mediaMarkerLine(b.PayloadDigest); line != "" {
it, err := a.mediaStore.Stat(b.PayloadDigest)
if err != nil || it == nil {
continue
}
if line := mediaLabel(it); line != "" {
parts = append(parts, line)
}
}