Files
HomeAgent/internal/tfidf/tfidf.go
JianFeeeee 5836c2ce5c 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 等)。
2026-09-11 11:45:24 +08:00

307 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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)
}