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

306
internal/tfidf/tfidf.go Normal file
View File

@ -0,0 +1,306 @@
// Package tfidf 提供 TF-IDF 向量化作为 MultimodalEmbedder 的一个实现。
//
// 设计:核心(内侧)只认 vector.MultimodalEmbedder 接口;本包是外层可插拔
// 的一个具体实现,与 Jina HTTP / ONNX CLIP 并列。启动时由 cmd/homed 按配置
// 选择注入,核心代码零改动。
//
// 稀疏向量 → 稠密桥接:TF-IDF 产出的是稀疏 map[feature]weight,通过特征哈希
// 投射到固定维度(默认 4096)的 []float64,供统一的 cosine 检索使用。
// 哈希碰撞在 4096 维下可接受(英文单词 ~50k,碰撞率 ~3%)。
package tfidf
import (
"fmt"
"hash/fnv"
"math"
"sort"
"strings"
"sync"
)
// Tokenizer 将文本拆分为词级 token。
type Tokenizer func(text string) []string
// Vector 是带权特征映射:feature → weight(稀疏表示)。
type Vector map[string]float64
// Embedder 实现 vector.MultimodalEmbedder,将 TF-IDF 稀疏向量投射为固定维度稠密向量。
type Embedder struct {
mu sync.RWMutex
tokenizer Tokenizer
docFreq map[string]int
totalDocs int
dim int
loaded bool
fingerprint string
}
// NewEmbedder 创建 TF-IDF 多模态嵌入器。
// dim 是投射后的稠密维度(默认 4096,哈希空间大小)。
func NewEmbedder(tokenizer Tokenizer, dim int) *Embedder {
if dim <= 0 {
dim = 4096
}
return &Embedder{
tokenizer: tokenizer,
docFreq: make(map[string]int),
dim: dim,
}
}
// Train 用文档集训练 IDF 统计。启动时调用一次。
func (e *Embedder) Train(docs []string) {
e.mu.Lock()
defer e.mu.Unlock()
e.totalDocs = len(docs)
e.docFreq = make(map[string]int)
for _, doc := range docs {
features := e.tokenizer(doc)
seen := make(map[string]bool)
for _, f := range features {
if !seen[f] {
e.docFreq[f]++
seen[f] = true
}
}
}
e.fingerprint = fmt.Sprintf("tfidf:d%d:f%d", e.totalDocs, len(e.docFreq))
e.loaded = true
}
// vectorize 将文本转为 TF-IDF 稀疏向量(内部方法,调用方已持锁)。
func (e *Embedder) vectorize(text string) Vector {
features := e.tokenizer(text)
tf := make(map[string]float64)
for _, f := range features {
tf[f]++
}
maxTF := 0.0
for _, c := range tf {
if c > maxTF {
maxTF = c
}
}
vec := make(Vector)
for f, count := range tf {
tfNorm := count / maxTF
if e.totalDocs < 3 {
vec[f] = tfNorm
continue
}
df := e.docFreq[f]
if df <= 0 {
continue
}
idf := math.Log(float64(e.totalDocs+1) / float64(df+1))
if idf < 0.1 {
continue
}
vec[f] = tfNorm * idf
}
return vec
}
// sparseToDense 将稀疏向量投射为固定维度稠密向量(FNV-1a 哈希映射)。
func (e *Embedder) sparseToDense(sparse Vector) []float64 {
dense := make([]float64, e.dim)
var norm float64
for feature, weight := range sparse {
idx := fnvHash(feature) % uint32(e.dim)
dense[idx] += weight
norm += weight * weight
}
// L2 归一化
if norm > 0 {
norm = math.Sqrt(norm)
for i := range dense {
dense[i] /= norm
}
}
return dense
}
func fnvHash(s string) uint32 {
h := fnv.New32a()
h.Write([]byte(s))
return h.Sum32()
}
// --- vector.MultimodalEmbedder 接口实现 ---
func (e *Embedder) VectorizeDense(text string) ([]float64, error) {
e.mu.RLock()
defer e.mu.RUnlock()
if !e.loaded {
return nil, fmt.Errorf("tfidf: not trained")
}
sparse := e.vectorize(text)
return e.sparseToDense(sparse), nil
}
func (e *Embedder) EmbedImageDense(img []byte, mime string) ([]float64, error) {
return nil, fmt.Errorf("tfidf: image embedding not supported")
}
func (e *Embedder) Fingerprint() string {
e.mu.RLock()
defer e.mu.RUnlock()
return e.fingerprint
}
func (e *Embedder) Dim() int { return e.dim }
func (e *Embedder) Loaded() bool {
e.mu.RLock()
defer e.mu.RUnlock()
return e.loaded
}
func (e *Embedder) Close() {}
// --- 检索(供 document.Store 使用,非接口方法)---
// DocHit 是一条检索命中。
type DocHit struct {
ID string
Score float64
}
// SearchableIndex 是支持 TF-IDF 倒排检索的索引。
// document.Store 在 TF-IDF 模式下使用此索引替代 brute-force。
type SearchableIndex struct {
mu sync.RWMutex
docs map[string]Vector // id → tfidf sparse vector
texts map[string]string // id → 原文
emb *Embedder
}
// NewSearchableIndex 创建可检索索引。
func NewSearchableIndex(emb *Embedder) *SearchableIndex {
return &SearchableIndex{
docs: make(map[string]Vector),
texts: make(map[string]string),
emb: emb,
}
}
// Add 添加或更新一条文档。
func (idx *SearchableIndex) Add(id, text string) {
idx.mu.Lock()
defer idx.mu.Unlock()
idx.emb.mu.RLock()
vec := idx.emb.vectorize(text)
idx.emb.mu.RUnlock()
idx.docs[id] = vec
idx.texts[id] = text
}
// Remove 移除一条文档。
func (idx *SearchableIndex) Remove(id string) {
idx.mu.Lock()
defer idx.mu.Unlock()
delete(idx.docs, id)
delete(idx.texts, id)
}
// Search 用查询文本检索 topK 个最相似的文档。
func (idx *SearchableIndex) Search(query string, topK int) []DocHit {
idx.mu.RLock()
defer idx.mu.RUnlock()
if len(idx.docs) == 0 {
return nil
}
idx.emb.mu.RLock()
qVec := idx.emb.vectorize(query)
idx.emb.mu.RUnlock()
type scored struct {
id string
score float64
}
var results []scored
for id, docVec := range idx.docs {
score := sparseCosine(qVec, docVec)
if score > 0.01 {
results = append(results, scored{id, score})
}
}
sort.Slice(results, func(i, j int) bool { return results[i].score > results[j].score })
if len(results) > topK {
results = results[:topK]
}
out := make([]DocHit, len(results))
for i, r := range results {
out[i] = DocHit{ID: r.id, Score: r.score}
}
return out
}
// sparseCosine 计算两个稀疏向量的余弦相似度。
func sparseCosine(a, b Vector) float64 {
var dot, na, nb float64
for f, va := range a {
dot += va * b[f]
na += va * va
}
for _, vb := range b {
nb += vb * vb
}
if na == 0 || nb == 0 {
return 0
}
return dot / math.Sqrt(na*nb)
}
// Text 返回文档原文(供调试/展示)。
func (idx *SearchableIndex) Text(id string) string {
idx.mu.RLock()
defer idx.mu.RUnlock()
return idx.texts[id]
}
// IDs 返回所有文档 ID(供重建索引)。
func (idx *SearchableIndex) IDs() []string {
idx.mu.RLock()
defer idx.mu.RUnlock()
ids := make([]string, 0, len(idx.docs))
for id := range idx.docs {
ids = append(ids, id)
}
sort.Strings(ids)
return ids
}
// Size 返回索引中的文档数。
func (idx *SearchableIndex) Size() int {
idx.mu.RLock()
defer idx.mu.RUnlock()
return len(idx.docs)
}
// Train 用文档集训练并建立索引。
func (idx *SearchableIndex) Train(docs map[string]string) {
idx.mu.Lock()
defer idx.mu.Unlock()
for id, text := range docs {
idx.emb.mu.RLock()
vec := idx.emb.vectorize(text)
idx.emb.mu.RUnlock()
idx.docs[id] = vec
idx.texts[id] = text
}
}
// TokenizeWords 是默认的中英文分词器(需外部注入 jieba 分词函数)。
// 外层 cmd/homed 负责注入,核心不直接依赖 jieba。
var TokenizeWords Tokenizer = func(text string) []string {
// 简单 fallback:按空白和标点拆分
return strings.Fields(text)
}