Files
HomeAgent/internal/tfidf/tfidf.go
JianFeeeee f1091676f8 style: gofmt 两处对齐(graphmedia_test.go 注释、tfidf.go 结构体字段)
这两处是本次特性分支带入的格式回归(origin/main 上不脏)。
纯空白/对齐调整,无语义变化;不整体重排文件。
2026-09-12 08:23:30 +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)
}