mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-12 10:38:36 +00:00
feat(vector): pluggable multimodal vector space
核心暴露 MultimodalEmbedder 接口,两条路径共享同一套 L0/L2/L3 向量缓存、media.Store 坐标、QueryMemoryMediaScored 检索: - onnx:内嵌 ONNX 模型(CLIP 等),通过 build tag 编译 - http:外部向量 API 服务(Jina v5 / OpenAI / 自建) 跨模态融合权重改为 CrossModalFusionConfig 可配置结构体, 移除所有模型特定硬编码(CLIP/Jina),版本切换只需改配置。 模型切换自动迁移: - StaleVecDigestsAll 支持全模态(image+audio+video) - 启动时并发重算(ONNX 4 workers / API 8 workers) - 修复 SQL 运算符优先级导致 kind 过滤失效的 bug 实测对比(492 篇生产文档 + 3 张真实图片): - TF-IDF:MRR 0.457(精确匹配快,语义差) - fastText:MRR 0.530(语义中等,延迟 8ms) - Jina v5-omni:MRR 0.900(全面领先,延迟 40ms) - 中文文本→图片:Jina MRR 0.833 vs CLIP 0.611 See docs/embedding-comparison.md for full benchmark.
This commit is contained in:
@ -13,11 +13,11 @@ import (
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/clip"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
@ -145,8 +145,12 @@ type Agent struct {
|
||||
// 词嵌入模型,用于实体语义相似度计算
|
||||
embedder *memory.StaticEmbedder
|
||||
|
||||
// clipEmb 是 CLIP 多模态嵌入(可选),nil 时跳过视觉向量计算。
|
||||
clipEmb *clip.Embedder
|
||||
// multimodalSpace 是统一多模态向量空间(可选)。实现可以是内嵌 ONNX,
|
||||
// 也可以是外部 API 客户端;两者共享同一套 L0/L2/L3 向量缓存与检索基础设施。
|
||||
multimodalSpace vector.MultimodalEmbedder
|
||||
|
||||
// fusionCfg 控制文本路与视觉路的跨模态融合权重,可按模型实测结果配置。
|
||||
fusionCfg CrossModalFusionConfig
|
||||
|
||||
// 技能索引提供者:由 skillmgr 插件实现,向 system prompt 注入轻量技能索引
|
||||
skillIndex SkillIndexProvider
|
||||
@ -178,7 +182,8 @@ type AgentConfig struct {
|
||||
MediaGCInterval time.Duration
|
||||
MediaGCMinAge time.Duration
|
||||
MediaDescribe bool
|
||||
ClipEmbedder *clip.Embedder
|
||||
MultimodalSpace vector.MultimodalEmbedder
|
||||
FusionCfg CrossModalFusionConfig // 跨模态融合权重;零值用默认
|
||||
Personality *agentPkg.Personality
|
||||
PluginReg *plugin.Registry
|
||||
PluginDir string
|
||||
@ -284,7 +289,8 @@ func New(cfg AgentConfig) *Agent {
|
||||
thinkingEnabled: cfg.ThinkingEnabled,
|
||||
inputCfg: cfg.InputProcessing,
|
||||
embedder: embedder,
|
||||
clipEmb: cfg.ClipEmbedder,
|
||||
multimodalSpace: cfg.MultimodalSpace,
|
||||
fusionCfg: cfg.FusionCfg,
|
||||
noMergeMarkers: make(map[string]int),
|
||||
lastInput: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
267
internal/agent/core/crossmodal.go
Normal file
267
internal/agent/core/crossmodal.go
Normal file
@ -0,0 +1,267 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
)
|
||||
|
||||
// CrossModalHit 是跨模态检索融合后的一条候选。
|
||||
//
|
||||
// 统一的检索单元是记忆块而非 CAS 全库:媒体在 L0/L2/L3 都和文本一样有 owner,
|
||||
// 只有仍被某层记忆引用的媒体才可召回。Doc 是 L2 文档块;Media 是该块携带的
|
||||
// 原生媒体坐标。两路分数尺度不同,融合前各自归一化,见 fuseCrossModal。
|
||||
type CrossModalHit struct {
|
||||
Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil
|
||||
Media *media.Item // 视觉路命中的媒体;文本路命中时也可能带关联媒体
|
||||
MediaScore float64 // 视觉路原始 cosine(无则 0)
|
||||
DocScore float64 // 文本路原始 cosine(无则 0)
|
||||
Fused float64 // 归一化加权融合分,供最终排序
|
||||
// 该媒体同时被两路命中(文本路经文档关联、视觉路直接命中)时,
|
||||
// DoubleHit=true —— 双信号确认,应排在只被一路命中的候选之前。
|
||||
DoubleHit bool
|
||||
}
|
||||
|
||||
// CrossModalFusionConfig 控制文本路与视觉路的融合行为。
|
||||
// 默认各路权重 0.5,双命中加权 0.15;不同模型/场景可按实测调整。
|
||||
type CrossModalFusionConfig struct {
|
||||
WeightText float64 // 文本路融合权重(默认 0.5)
|
||||
WeightVisual float64 // 视觉路融合权重(默认 0.5)
|
||||
DoubleHitBonus float64 // 双命中额外加分(默认 0.15)
|
||||
MinMaxEps float64 // min-max 归一化除零保护(默认 1e-12)
|
||||
}
|
||||
|
||||
var defaultFusionConfig = CrossModalFusionConfig{
|
||||
WeightText: 0.5,
|
||||
WeightVisual: 0.5,
|
||||
DoubleHitBonus: 0.15,
|
||||
MinMaxEps: 1e-12,
|
||||
}
|
||||
|
||||
func (c CrossModalFusionConfig) textWeight() float64 {
|
||||
if c.WeightText <= 0 {
|
||||
return defaultFusionConfig.WeightText
|
||||
}
|
||||
return c.WeightText
|
||||
}
|
||||
func (c CrossModalFusionConfig) visualWeight() float64 {
|
||||
if c.WeightVisual <= 0 {
|
||||
return defaultFusionConfig.WeightVisual
|
||||
}
|
||||
return c.WeightVisual
|
||||
}
|
||||
func (c CrossModalFusionConfig) doubleHitBonus() float64 {
|
||||
return c.DoubleHitBonus
|
||||
}
|
||||
func (c CrossModalFusionConfig) minMaxEps() float64 {
|
||||
if c.MinMaxEps <= 0 {
|
||||
return defaultFusionConfig.MinMaxEps
|
||||
}
|
||||
return c.MinMaxEps
|
||||
}
|
||||
|
||||
// retrieveCrossModal 是跨模态并行检索的统一入口。
|
||||
//
|
||||
// 策略(两路并行,召回真正最相似的):
|
||||
// 1. 文本路:query 整段文本用现有方法(fastText/TF-IDF 稀疏 cosine)查文档层,
|
||||
// 每个命中文档再反查其关联媒体(docMediaContext)——描述文本命中即媒体命中。
|
||||
// 2. 视觉路:query 整段文本经多模态模型文本编码 → 与媒体库全部图像坐标比余弦
|
||||
// (QueryMediaScored),覆盖描述文本没写到的视觉内容。
|
||||
// 3. 融合:两条路候选各自 min-max 归一化到 [0,1],加权求和后降序,取 topK。
|
||||
// 同一媒体被两路同时命中视为双信号确认,额外加权。
|
||||
//
|
||||
// 多模态空间未配置时视觉路为空,退化为纯文本路(等价旧 docStore.Query)。
|
||||
func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionConfig) []CrossModalHit {
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
// 融合前各取 2× 余量,保证融合排序后 topK 仍有足够候选。
|
||||
per := topK * 2
|
||||
if per < 8 {
|
||||
per = 8
|
||||
}
|
||||
|
||||
// ---- 文本路 ----
|
||||
var textHits []CrossModalHit
|
||||
if a.docStore != nil {
|
||||
for _, dh := range a.docStore.QueryScored(query, per) {
|
||||
hit := CrossModalHit{Doc: dh.Doc, DocScore: dh.Score}
|
||||
// 命中文档若关联着媒体(media_refs),把媒体作为文本路候选一并带上:
|
||||
// 描述文本命中 → 该媒体就是相关记忆,供后续展示/注入。
|
||||
if a.mediaStore != nil {
|
||||
refs, err := a.mediaStore.Refs(media.OwnerDocument, dh.Doc.ID)
|
||||
if err == nil && len(refs) > 0 {
|
||||
if it, err := a.mediaStore.Stat(refs[0]); err == nil {
|
||||
hit.Media = it
|
||||
}
|
||||
}
|
||||
}
|
||||
textHits = append(textHits, hit)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 视觉路(多模态文本编码 → 当前记忆层持有的媒体块)----
|
||||
var visualHits []CrossModalHit
|
||||
if a.multimodalSpace != nil && a.multimodalSpace.Loaded() && a.mediaStore != nil {
|
||||
qv, err := a.multimodalSpace.VectorizeDense(query)
|
||||
if err != nil {
|
||||
log.Printf("[crossmodal] 多模态文本编码失败: %v", err)
|
||||
} else if mh, err := a.mediaStore.QueryMemoryMediaScored(qv, a.multimodalSpace.Fingerprint(), per); err != nil {
|
||||
log.Printf("[crossmodal] 媒体记忆检索失败: %v", err)
|
||||
} else {
|
||||
for _, h := range mh {
|
||||
visualHits = append(visualHits, CrossModalHit{
|
||||
Media: h.Item, MediaScore: h.Score,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fuseCrossModal(textHits, visualHits, topK, cfg)
|
||||
}
|
||||
|
||||
// fuseCrossModal 把文本路与视觉路候选按各自归一化分融合排序。
|
||||
//
|
||||
// 归一化模板:两路分数尺度不可直接相加,先各自在路内 min-max 到 [0,1]:
|
||||
//
|
||||
// norm(x) = (x - min) / (max - min),max==min 时置 1
|
||||
//
|
||||
// 再加权求和:fused = wText·normText + wVisual·normVisual。同一媒体两路都命中
|
||||
// (经文档关联 + 视觉直接)时 DoubleHit,在加权分上再加双信号确认分。
|
||||
// 权重通过 CrossModalFusionConfig 按场景配置,不同模型/版本可按实测调整。
|
||||
func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossModalFusionConfig) []CrossModalHit {
|
||||
norm := func(hits []CrossModalHit, pick func(CrossModalHit) float64) []float64 {
|
||||
out := make([]float64, len(hits))
|
||||
if len(hits) == 0 {
|
||||
return out
|
||||
}
|
||||
maxV, minV := pick(hits[0]), pick(hits[0])
|
||||
for _, h := range hits[1:] {
|
||||
v := pick(h)
|
||||
if v > maxV {
|
||||
maxV = v
|
||||
}
|
||||
if v < minV {
|
||||
minV = v
|
||||
}
|
||||
}
|
||||
for i, h := range hits {
|
||||
v := pick(h)
|
||||
if maxV-minV < cfg.minMaxEps() {
|
||||
out[i] = 1
|
||||
continue
|
||||
}
|
||||
out[i] = (v - minV) / (maxV - minV)
|
||||
}
|
||||
return out
|
||||
}
|
||||
textN := norm(textHits, func(h CrossModalHit) float64 { return h.DocScore })
|
||||
visualN := norm(visualHits, func(h CrossModalHit) float64 { return h.MediaScore })
|
||||
|
||||
byKey := make(map[string]*CrossModalHit)
|
||||
var keys []string
|
||||
key := func(h CrossModalHit) string {
|
||||
if h.Doc != nil {
|
||||
return "doc:" + h.Doc.ID
|
||||
}
|
||||
if h.Media != nil {
|
||||
return "media:" + h.Media.Digest
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 先并入视觉路(视觉媒体是独立实体)
|
||||
for i, h := range visualHits {
|
||||
k := key(h)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
clone := h
|
||||
clone.Fused = cfg.visualWeight() * visualN[i]
|
||||
byKey[k] = &clone
|
||||
keys = append(keys, k)
|
||||
}
|
||||
// 再并入文本路:命中的文档是独立实体;带媒体的文档若其媒体 digest
|
||||
// 已在视觉路(双命中),合并到同一候选并标记 DoubleHit。
|
||||
for i, h := range textHits {
|
||||
if h.Doc == nil {
|
||||
continue
|
||||
}
|
||||
if h.Media != nil {
|
||||
if ex, ok := byKey["media:"+h.Media.Digest]; ok {
|
||||
ex.DoubleHit = true
|
||||
ex.Doc = h.Doc
|
||||
ex.Fused += cfg.textWeight()*textN[i] + cfg.doubleHitBonus()
|
||||
continue
|
||||
}
|
||||
}
|
||||
k := "doc:" + h.Doc.ID
|
||||
if ex, ok := byKey[k]; ok {
|
||||
ex.Doc = h.Doc
|
||||
ex.DoubleHit = false
|
||||
ex.Fused += cfg.textWeight() * textN[i]
|
||||
continue
|
||||
}
|
||||
clone := h
|
||||
clone.Fused = cfg.textWeight() * textN[i]
|
||||
byKey[k] = &clone
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
||||
var merged []CrossModalHit
|
||||
for _, k := range keys {
|
||||
if c := byKey[k]; c != nil {
|
||||
merged = append(merged, *c)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(merged, func(i, j int) bool {
|
||||
if merged[i].DoubleHit != merged[j].DoubleHit {
|
||||
return merged[i].DoubleHit
|
||||
}
|
||||
return merged[i].Fused > merged[j].Fused
|
||||
})
|
||||
if len(merged) > topK {
|
||||
merged = merged[:topK]
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// crossModalMarkdown 把融合候选渲染成注入上下文的文本。
|
||||
// 文档行对齐既有【相关记忆文档】格式;媒体行复用 mediaMarkerLine 的
|
||||
// `[<mime> <短digest>] <描述>` 格式(那是解析回媒体引用的唯一合法格式)。
|
||||
func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
|
||||
if len(hits) == 0 {
|
||||
return ""
|
||||
}
|
||||
var lines []string
|
||||
for i, h := range hits {
|
||||
marker := ""
|
||||
switch {
|
||||
case h.DoubleHit:
|
||||
marker = "(图文双命中)"
|
||||
case h.Doc != nil:
|
||||
marker = "(文本命中)"
|
||||
case h.Media != nil:
|
||||
marker = "(视觉命中)"
|
||||
}
|
||||
parts := []string{fmt.Sprintf("[%d]", i+1)}
|
||||
if h.Doc != nil {
|
||||
parts = append(parts, h.Doc.Summary)
|
||||
if h.Doc.Source != "" {
|
||||
parts = append(parts, fmt.Sprintf("(来源:%s)", h.Doc.Source))
|
||||
}
|
||||
}
|
||||
if h.Media != nil {
|
||||
if line := a.mediaMarkerLine(h.Media.Digest); line != "" {
|
||||
parts = append(parts, line)
|
||||
}
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("相关度:%.2f%s", h.Fused, marker))
|
||||
lines = append(lines, strings.Join(parts, " "))
|
||||
}
|
||||
return "【跨模态相关记忆】\n" + strings.Join(lines, "\n")
|
||||
}
|
||||
@ -3,6 +3,8 @@ package core
|
||||
import (
|
||||
"log"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||||
@ -180,63 +182,106 @@ func (a *Agent) describePendingMedia() {
|
||||
}
|
||||
log.Printf("[media] 已描述 %s (%s, %d 字, 源=%s)", shortDigest(it.Digest), kind, len([]rune(desc)), srcName)
|
||||
|
||||
// 描述成功后,若 CLIP 嵌入器可用且是图片,计算视觉向量。
|
||||
// 这是"描述 + 向量"两步同步完成的路径;对于历史已有描述但无向量的媒体,
|
||||
// 由启动时的 reembedStaleMedia 补算。
|
||||
if a.clipEmb != nil && kind == "image" && it.Kind == media.KindImage {
|
||||
vec, err := a.clipEmb.EmbedImageDense(data, mime)
|
||||
if err != nil {
|
||||
log.Printf("[media] 视觉嵌入失败 %s: %v", shortDigest(it.Digest), err)
|
||||
} else if err := a.mediaStore.SetVec(it.Digest, vec, a.clipEmb.Fingerprint()); err != nil {
|
||||
log.Printf("[media] 写向量失败 %s: %v", shortDigest(it.Digest), err)
|
||||
} else {
|
||||
log.Printf("[media] 已嵌入 %s (dim=%d)", shortDigest(it.Digest), len(vec))
|
||||
}
|
||||
}
|
||||
// 描述成功后无需再次做视觉嵌入:图片在进入 L0 记忆块时已由
|
||||
// embedMediaOnIngest 计算并写入 CAS,L0→L2→L3 只转移引用并复用坐标。
|
||||
// 历史已有图片或模型切换由启动时 reembedStaleMedia 一次性补算。
|
||||
}
|
||||
}
|
||||
|
||||
// reembedStaleMedia 在启动时为历史已有描述但无 CLIP 向量的图片补算视觉向量。
|
||||
// 避免安装 CLIP 后,旧图片永远只有描述文本、没有视觉向量,直到下次 Describe 才能写入。
|
||||
// reembedStaleMedia 在启动时批量迁移历史媒体向量到当前向量空间。
|
||||
//
|
||||
// 触发场景(任一变化都会导致旧向量无法参与查询):
|
||||
// - 切换模型(模型 A→模型 B,fp 变了)
|
||||
// - 切换向量维度(ONNX→HTTP dim 512→1024)
|
||||
// - 首次部署嵌入服务(历史无向量的媒体补算)
|
||||
// - 嵌入服务离线后重新上线(失败条目 vec_model 仍为空)
|
||||
//
|
||||
// 并发策略:启动时用 worker pool 并行迁移,避免上千张图片串行耗时过长。
|
||||
// 并发数在 ONNX 内嵌路径下不超 CPU 核心数(避免 ONNX 并发限流),
|
||||
// 外部 API 路径下不超 8(避免打爆外部服务)。
|
||||
func (a *Agent) reembedStaleMedia() {
|
||||
if a.clipEmb == nil || a.mediaStore == nil {
|
||||
if a.multimodalSpace == nil || a.mediaStore == nil {
|
||||
return
|
||||
}
|
||||
fp := a.clipEmb.Fingerprint()
|
||||
digests, err := a.mediaStore.StaleVecDigests(fp)
|
||||
fp := a.multimodalSpace.Fingerprint()
|
||||
digests, err := a.mediaStore.StaleVecDigestsAll(fp)
|
||||
if err != nil {
|
||||
log.Printf("[media] 查询需重算向量的媒体失败: %v", err)
|
||||
return
|
||||
}
|
||||
if len(digests) == 0 {
|
||||
log.Printf("[media] 无历史媒体需要补算视觉向量")
|
||||
log.Printf("[media] 无需迁移向量(所有媒体已与当前空间对齐 fp=%s)", shortFP(fp))
|
||||
return
|
||||
}
|
||||
log.Printf("[media] 启动补算视觉向量: %d 条 (fp=%s...)", len(digests), fp[:min(12, len(fp))])
|
||||
done := 0
|
||||
for _, d := range digests {
|
||||
it, err := a.mediaStore.Stat(d)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
data, err := a.mediaStore.Get(d)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
mime := it.MIME
|
||||
if mime == "" {
|
||||
mime = "image/png"
|
||||
}
|
||||
vec, err := a.clipEmb.EmbedImageDense(data, mime)
|
||||
if err != nil {
|
||||
log.Printf("[media] 启动补算失败 %s: %v", shortDigest(d), err)
|
||||
continue
|
||||
}
|
||||
if err := a.mediaStore.SetVec(d, vec, fp); err != nil {
|
||||
log.Printf("[media] 启动写入向量失败 %s: %v", shortDigest(d), err)
|
||||
continue
|
||||
}
|
||||
done++
|
||||
|
||||
// 并发度:ONNX 内嵌不超过 4,外部 API 不超过 8(由配置或实际环境动态定)
|
||||
workers := 4
|
||||
if fp[:min(4, len(fp))] == "http:" {
|
||||
workers = 8
|
||||
}
|
||||
log.Printf("[media] 启动补算完成: %d/%d", done, len(digests))
|
||||
log.Printf("[media] 启动向量迁移: %d 条 → 新空间 fp=%s dim=%d workers=%d",
|
||||
len(digests), shortFP(fp), a.multimodalSpace.Dim(), workers)
|
||||
|
||||
jobs := make(chan string, workers*2)
|
||||
var done, failed int64
|
||||
var failedMu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for d := range jobs {
|
||||
if err := a.reembedOne(d, fp); err != nil {
|
||||
failedMu.Lock()
|
||||
failed++
|
||||
failedMu.Unlock()
|
||||
continue
|
||||
}
|
||||
atomic.AddInt64(&done, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i, d := range digests {
|
||||
jobs <- d
|
||||
// 每迁移 20 条输出进度日志,让用户看到迁移在推进
|
||||
if (i+1)%20 == 0 {
|
||||
log.Printf("[media] 向量迁移进度: %d/%d (done=%d failed=%d)", i+1, len(digests), atomic.LoadInt64(&done), failed)
|
||||
}
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
log.Printf("[media] 向量迁移完成: 成功=%d 失败=%d 总计=%d fp=%s",
|
||||
done, failed, len(digests), shortFP(fp))
|
||||
}
|
||||
|
||||
// reembedOne 为单条媒体重新计算向量并写入。stat 错误时跳过(可能已被 GC 清除)。
|
||||
// Get 错误或 Embed 错误时静默跳过该条目(不影响迁移其他条目)。
|
||||
func (a *Agent) reembedOne(digest, fp string) error {
|
||||
it, err := a.mediaStore.Stat(digest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := a.mediaStore.Get(digest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mime := it.MIME
|
||||
if mime == "" {
|
||||
mime = "image/png"
|
||||
}
|
||||
vec, err := a.multimodalSpace.EmbedImageDense(data, mime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.mediaStore.SetVec(digest, vec, fp)
|
||||
}
|
||||
|
||||
// shortFP 截断 fingerprint 为可读日志格式。
|
||||
func shortFP(fp string) string {
|
||||
if len(fp) > 12 {
|
||||
return fp[:12]
|
||||
}
|
||||
return fp
|
||||
}
|
||||
|
||||
@ -63,11 +63,35 @@ func (a *Agent) captureBlockMedia(blocks []agentAPI.ContentBlock, tool string) [
|
||||
log.Printf("[media] 落盘失败 (tool=%s mime=%s): %v", tool, mime, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 入库即算一次多模态坐标并缓存(多模态空间可用时)。
|
||||
// 之后 doc_query / memory_recall / 内部召回直接复用 SetVec 的缓存坐标,
|
||||
// 不重复跑 ONNX;模型切换由启动时的 reembedStaleMedia 补算。
|
||||
a.embedMediaOnIngest(d, mime, data)
|
||||
digests = append(digests, d)
|
||||
}
|
||||
return digests
|
||||
}
|
||||
|
||||
// embedMediaOnIngest 给刚入库的图片立即计算多模态坐标并缓存。
|
||||
// 只在 多模态空间可用且为图像时执行;音频/未配置时静默跳过(保持既有行为)。
|
||||
func (a *Agent) embedMediaOnIngest(digest, mime string, data []byte) {
|
||||
if a.multimodalSpace == nil || !a.multimodalSpace.Loaded() {
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(mime, "image/") {
|
||||
return
|
||||
}
|
||||
vec, err := a.multimodalSpace.EmbedImageDense(data, mime)
|
||||
if err != nil {
|
||||
log.Printf("[media] 入库嵌入失败 %s: %v", shortDigest(digest), err)
|
||||
return
|
||||
}
|
||||
if err := a.mediaStore.SetVec(digest, vec, a.multimodalSpace.Fingerprint()); err != nil {
|
||||
log.Printf("[media] 入库写向量失败 %s: %v", shortDigest(digest), err)
|
||||
}
|
||||
}
|
||||
|
||||
// stageMediaDigests 累积本轮捕获的 digest,等 ContextEvent 建好后一起挂上。
|
||||
//
|
||||
// 为何要缓存而不是当场 AddRef:媒体在 process() 执行期间被捕获,而承载它的
|
||||
|
||||
@ -55,15 +55,13 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
|
||||
prompt += "\n\n【记忆清理指令】当用户要求整理或清理记忆时,你必须实际调用 memory_ 工具执行操作,不能只回复文本。先用 memory_introspect 查看概况,再用 memory_recall 获取详情。有同义实体则用 memory_merge 合并(source 会被彻底删除),有无用噪音实体则用 memory_delete_entity 直接删除,也可用 memory_purge 批量清理,用 memory_edit 修正错误,用 memory_block_merge 标记不合并。如果工具执行成功,把结果告知用户;不要只描述计划而不执行。"
|
||||
|
||||
// 跨模态召回:文本路(fastText/TF-IDF 文档层,媒体描述文本已随记忆进入)
|
||||
// + 视觉路(多模态文本编码 → 媒体库坐标)两路归一化融合。
|
||||
// 未配置多模态空间时视觉路为空,等价旧的 docStore.Query。
|
||||
if a.docStore != nil {
|
||||
docs := a.docStore.Query(userInput, 3)
|
||||
if len(docs) > 0 {
|
||||
var parts []string
|
||||
parts = append(parts, "【相关记忆文档】")
|
||||
for i, d := range docs {
|
||||
parts = append(parts, fmt.Sprintf(" [%d] %s", i+1, d.Summary))
|
||||
}
|
||||
prompt += "\n\n" + strings.Join(parts, "\n")
|
||||
hits := a.retrieveCrossModal(userInput, 3, a.fusionCfg)
|
||||
if md := a.crossModalMarkdown(hits); md != "" {
|
||||
prompt += "\n\n" + md
|
||||
}
|
||||
}
|
||||
|
||||
@ -75,7 +73,7 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
prompt += "- 同步通道(webui / cli / 终端):直接返回纯文本,内核会把文本交给等待方显示,无需调用工具。\n"
|
||||
prompt += "- 异步通道(qq / wechat / 群聊等):返回纯文本**【不会】**自动送达用户,必须调用 output_send__{通道名} 工具(注意 meta 里带上正确的 user_id 或 group_id)才能真正把消息发出去。\n"
|
||||
prompt += "- 不确定当前通道的发送方式时,先用 output_send__{通道名}_help 查看该通道的 meta 格式和 type 枚举,再决定。\n"
|
||||
prompt += "- 同一轮对话中可多次调用输出门工具。长消息应当分多次发出,而不是一口气发完。\n"
|
||||
prompt += "- 每轮对话**通常只需调用一次** output_send__{通道名} 即可完成回复。仅在内容确实超过单条消息长度上限(如 >4000 字)时才拆分为多条;拆分时每条应是完整段落,不要碎片化。\n"
|
||||
prompt += "- 需要多步执行的长任务:**必须先**向当前对话通道发一条确认消息告诉用户已收到(异步通道用输出门工具,同步通道直接返回文本),**然后再**执行具体排查工具。确认消息不代表任务完成,发出后仍需继续执行实际工具并最终汇报结果。\n"
|
||||
prompt += "- 用户从其他渠道发来「在哪里/怎么样了」这类追问时,先回忆上次任务的通道与上下文,再回同一通道。"
|
||||
|
||||
|
||||
@ -655,7 +655,14 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
|
||||
reg(ConfigDef{Key: "core.memory.media.gc_interval", Default: "6h", Type: "duration", DisplayName: "媒体 GC 间隔", Description: "清理无引用媒体的周期;0 表示不自动清理", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.media.gc_min_age", Default: "1h", Type: "duration", DisplayName: "媒体 GC 保护期", Description: "新入库媒体在此时长内不被清理。刚落盘还没来得及挂到记忆上的项引用计数也是 0,靠这个保护期避免被误删", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.media.describe_on_ingest", Default: "false", Type: "bool", DisplayName: "自动描述媒体", Description: "后台用视觉/音频模型给未描述的媒体生成文字描述。**描述文本才是持久语义记忆**——blob 会被容量 GC 淘汰,描述会随记忆各层一直留存并可检索。代价是消耗视觉模型配额(单张图实测约 10s),故默认关闭;开启后每 30s 最多处理 4 条,不跟对话抢额度", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.media.clip_model_dir", Default: "", Type: "string", DisplayName: "CLIP 模型目录", Description: "多模态嵌入的 CLIP ONNX 模型目录(含 text.onnx、vision.onnx、clip_config.json、tokenizer.json、merges.txt)。留空禁用多模态向量检索,只保留 fastText 文本路径;配置后图片入库时自动计算视觉向量并与文档混合检索。修改后需重启生效。", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.media.clip_model_dir", Default: "", Type: "string", DisplayName: "CLIP ONNX 模型目录", Description: "多模态嵌入的 CLIP ONNX 模型目录(含 text.onnx、vision.onnx、clip_config.json、tokenizer.json、merges.txt)。multimodal_space.type=onnx 时从此目录加载内嵌模型;留空且 type=onnx 时禁用多模态向量检索。修改后需重启生效。", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.multimodal_space.type", Default: "", Type: "string", DisplayName: "多模态向量空间类型", Description: "可插拔多模态向量空间的实现类型。onnx = 内嵌 ONNX 模型(CLIP 等);http = 外部向量 API 服务。留空禁用多模态向量检索,只保留 fastText 文本路径。两条路径共享同一套 L0/L2/L3 向量缓存与检索基础设施。", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.multimodal_space.http.endpoint", Default: "", Type: "string", DisplayName: "外部向量 API 端点", Description: "外部多模态向量服务的 HTTP 端点 URL(POST,接受 modality/side/text/data/mime,返回 embedding)。type=http 时必填。", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.multimodal_space.http.api_key", Default: "", Type: "string", DisplayName: "外部向量 API 密钥", Description: "外部多模态向量服务的 API 密钥(作为 Bearer token 发送)。可选。", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.multimodal_space.http.model", Default: "", Type: "string", DisplayName: "外部向量模型标识", Description: "外部向量服务使用的模型名称,作为 vec_model 持久化。模型切换后历史向量会自动重算。", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.multimodal_space.http.dimension", Default: "0", Type: "int", DisplayName: "外部向量维度", Description: "外部向量服务返回的特征向量维度。必须与实际 API 返回值一致,否则运行时报错。", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.multimodal_space.http.timeout", Default: "30s", Type: "duration", DisplayName: "外部向量 API 超时", Description: "单次向量请求的超时时间。", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.memory.multimodal_space.http.fingerprint", Default: "", Type: "string", DisplayName: "外部向量空间指纹", Description: "用于标识外部向量空间版本的字符串(留空时自动根据 model+dim 生成)。模型切换后若 fingerprint 变化,历史向量会被重算。", Category: "memory"})
|
||||
reg(ConfigDef{Key: "core.knowledge.path", Default: filepath.Join(dataDir, "knowledge"), Type: "string", DisplayName: "知识库路径", Description: "知识库存储目录", Category: "paths"})
|
||||
reg(ConfigDef{Key: "core.log.path", Default: filepath.Join(dataDir, "log"), Type: "string", DisplayName: "日志目录", Description: "日志文件输出目录", Category: "paths"})
|
||||
|
||||
|
||||
@ -427,7 +427,8 @@ func (s *Store) Search(query string, kind Kind, limit int) ([]*Item, error) {
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
q := `SELECT digest, kind, mime, size, width, height, origin_path, tool,
|
||||
description, described_by, ref_count, first_seen, last_seen
|
||||
description, described_by, ref_count, first_seen, last_seen,
|
||||
vec, vec_model
|
||||
FROM media WHERE COALESCE(description,'') != ''`
|
||||
args := []interface{}{}
|
||||
if strings.TrimSpace(query) != "" {
|
||||
@ -473,7 +474,8 @@ func (s *Store) Pending(limit int) ([]*Item, error) {
|
||||
defer s.mu.RUnlock()
|
||||
rows, err := s.db.Query(`
|
||||
SELECT digest, kind, mime, size, width, height, origin_path, tool,
|
||||
description, described_by, ref_count, first_seen, last_seen
|
||||
description, described_by, ref_count, first_seen, last_seen,
|
||||
vec, vec_model
|
||||
FROM media
|
||||
WHERE COALESCE(description,'') = '' AND COALESCE(described_by,'') = ''
|
||||
ORDER BY last_seen DESC LIMIT ?`, limit)
|
||||
@ -650,15 +652,36 @@ func (s *Store) SetVec(digest string, vec []float64, model string) error {
|
||||
// vec_model 不等于 currentModel(模型切换)或 vec_model 为空(从未嵌入)。
|
||||
// 调用方使用返回的 digest 列表调用 Get/EmbedImage/SetVec 完成重算。
|
||||
func (s *Store) StaleVecDigests(currentModel string) ([]string, error) {
|
||||
return s.staleVecDigests(currentModel, "image")
|
||||
}
|
||||
|
||||
// StaleVecDigestsAll 返回所有需要重新嵌入的媒体 digest(不限 kind),
|
||||
// 供模型切换后全量迁移向量空间(image + audio + video 等)。
|
||||
func (s *Store) StaleVecDigestsAll(currentModel string) ([]string, error) {
|
||||
return s.staleVecDigests(currentModel, "")
|
||||
}
|
||||
|
||||
// staleVecDigests 是 StaleVecDigests 的核心实现,kind=” 时不按 kind 过滤。
|
||||
// 废弃了"只迁移图片"的限定:模型切换后所有模态都应迁移到新向量空间。
|
||||
func (s *Store) staleVecDigests(currentModel string, kind string) ([]string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
rows, err := s.db.Query(`
|
||||
query := `
|
||||
SELECT digest FROM media
|
||||
WHERE kind = 'image'
|
||||
AND COALESCE(description,'') != ''
|
||||
AND (COALESCE(vec_model,'') = '' OR vec_model != ?)
|
||||
ORDER BY last_seen`, currentModel)
|
||||
WHERE (COALESCE(vec_model,'') = '' OR vec_model != ?)`
|
||||
if kind != "" {
|
||||
query += ` AND kind = ?`
|
||||
}
|
||||
query += ` ORDER BY last_seen`
|
||||
|
||||
var args []interface{}
|
||||
args = append(args, currentModel)
|
||||
if kind != "" {
|
||||
args = append(args, kind)
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -681,6 +704,46 @@ func (s *Store) StaleVecDigests(currentModel string) ([]string, error) {
|
||||
// 谁的相似度更高就召回谁——不再区分「这是一张图的查询」还是「这是一段文字的查询」,
|
||||
// 由向量空间的相似度自动判断。
|
||||
func (s *Store) QueryMedia(queryVec []float64, model string, topK int) ([]*Item, error) {
|
||||
hits, err := s.QueryMediaScored(queryVec, model, topK)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hits == nil {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]*Item, len(hits))
|
||||
for i, h := range hits {
|
||||
out[i] = h.Item
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MediaHit 是一条媒体相似度候选及其分数。
|
||||
// 跨模态融合需要原始分数做归一化,仅返回 Item 会丢掉尺度信息。
|
||||
type MediaHit struct {
|
||||
Item *Item
|
||||
Score float64
|
||||
}
|
||||
|
||||
// QueryMemoryMediaScored 只检索当前仍被 L0/L2/L3 记忆块引用的媒体。
|
||||
// CAS 中 ref_count=0 的项是等待 GC 的孤儿缓存,不是可召回记忆;若把它们也查出,
|
||||
// 已从三层记忆淘汰的图片会被视觉路“复活”,破坏与文本块一致的生命周期。
|
||||
//
|
||||
// 分数只做排序,不在存储层设绝对阈值:多模态文本→图像的绝对 cosine 随模型、
|
||||
// 语言与数据域漂移,真实标定中有效命中可以低至 0.015。相关性门控在融合器中
|
||||
// 使用当前候选集合的相对分布完成。
|
||||
func (s *Store) QueryMemoryMediaScored(queryVec []float64, model string, topK int) ([]MediaHit, error) {
|
||||
return s.queryMediaScored(queryVec, model, topK, true)
|
||||
}
|
||||
|
||||
// QueryMediaScored 用查询向量对所有已嵌入媒体做余弦相似度检索,
|
||||
// 返回 topK 个最相似的候选及其原始 cosine 分数(供跨模态归一化)。
|
||||
// 这是媒体存储层的诊断/显式全库入口;记忆召回应调用 QueryMemoryMediaScored。
|
||||
func (s *Store) QueryMediaScored(queryVec []float64, model string, topK int) ([]MediaHit, error) {
|
||||
return s.queryMediaScored(queryVec, model, topK, false)
|
||||
}
|
||||
|
||||
func (s *Store) queryMediaScored(queryVec []float64, model string, topK int, referencedOnly bool) ([]MediaHit, error) {
|
||||
if topK <= 0 {
|
||||
topK = 20
|
||||
}
|
||||
@ -690,10 +753,21 @@ func (s *Store) QueryMedia(queryVec []float64, model string, topK int) ([]*Item,
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
rows, err := s.db.Query(`SELECT digest, kind, mime, size, width, height,
|
||||
query := `SELECT digest, kind, mime, size, width, height,
|
||||
origin_path, tool, description, described_by, ref_count, first_seen, last_seen,
|
||||
vec, vec_model
|
||||
FROM media WHERE vec IS NOT NULL AND vec != ''`)
|
||||
FROM media WHERE vec IS NOT NULL AND vec != ''`
|
||||
var args []interface{}
|
||||
if model != "" {
|
||||
query += ` AND vec_model = ?`
|
||||
args = append(args, model)
|
||||
}
|
||||
if referencedOnly {
|
||||
query += ` AND ref_count > 0 AND EXISTS (
|
||||
SELECT 1 FROM media_refs r WHERE r.digest = media.digest
|
||||
)`
|
||||
}
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -744,9 +818,9 @@ func (s *Store) QueryMedia(queryVec []float64, model string, topK int) ([]*Item,
|
||||
if len(candidates) > topK {
|
||||
candidates = candidates[:topK]
|
||||
}
|
||||
out := make([]*Item, len(candidates))
|
||||
out := make([]MediaHit, len(candidates))
|
||||
for i, c := range candidates {
|
||||
out[i] = c.item
|
||||
out[i] = MediaHit{Item: c.item, Score: c.score}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@ -149,25 +149,26 @@ func TestStaleVecDigests(t *testing.T) {
|
||||
// 有描述但从未嵌入(vec_model 空)→ stale
|
||||
d3, _ := s.Put([]byte("img3"), Item{MIME: "image/png", Description: "图三"})
|
||||
|
||||
// 无描述 → 不参与(描述流程外)
|
||||
s.Put([]byte("img4"), Item{MIME: "image/png"})
|
||||
// 无描述但有图片 → 也应被迁移(描述是可选语义通道,图片应独立于描述参与向量空间)
|
||||
d4, _ := s.Put([]byte("img4"), Item{MIME: "image/png"})
|
||||
|
||||
// 音频不属于图片 → 不算 stale
|
||||
// 音频不参与图片迁移(StaleVecDigests 只查 kind='image')
|
||||
s.Put([]byte("aud1"), Item{MIME: "audio/wav", Description: "语音"})
|
||||
|
||||
stale, err := s.StaleVecDigests("clip-vit-b32")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(stale) != 2 {
|
||||
t.Fatalf("expected 2 stale digests (d2 旧模型 + d3 未嵌入), got %d: %v", len(stale), stale)
|
||||
// d1 匹配模型 → 非 stale;d2 旧模型 + d3 未嵌入 + d4 无描述图片 = 3 stale;aud1 不算
|
||||
if len(stale) != 3 {
|
||||
t.Fatalf("expected 3 stale digests (d2 旧模型 + d3 未嵌入 + d4 无描述), got %d: %v", len(stale), stale)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, d := range stale {
|
||||
got[d] = true
|
||||
}
|
||||
if !got[d2] || !got[d3] {
|
||||
t.Errorf("expected d2 and d3 stale, got %v", stale)
|
||||
if !got[d2] || !got[d3] || !got[d4] {
|
||||
t.Errorf("expected d2, d3, d4 stale, got %v", stale)
|
||||
}
|
||||
if got[d1] {
|
||||
t.Errorf("d1 (匹配模型) 不应 stale")
|
||||
|
||||
131
internal/memory/vector/http_embedder.go
Normal file
131
internal/memory/vector/http_embedder.go
Normal file
@ -0,0 +1,131 @@
|
||||
package vector
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HTTPEmbedderConfig 配置一个外部多模态向量服务。
|
||||
// 服务契约刻意很小:POST Endpoint,输入 modality/data/mime/side,返回 embedding。
|
||||
// 任何云 API 或自建服务只需适配这一个协议,即可复用内核全部向量存储与检索链路。
|
||||
type HTTPEmbedderConfig struct {
|
||||
Endpoint string
|
||||
APIKey string
|
||||
Model string
|
||||
Dimension int
|
||||
Timeout time.Duration
|
||||
Fingerprint string
|
||||
}
|
||||
|
||||
// HTTPEmbedder 是 MultimodalEmbedder 的外部 API 实现。
|
||||
type HTTPEmbedder struct {
|
||||
cfg HTTPEmbedderConfig
|
||||
client *http.Client
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
type httpEmbedRequest struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
Modality string `json:"modality"`
|
||||
Side string `json:"side"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
MIME string `json:"mime,omitempty"`
|
||||
}
|
||||
|
||||
type httpEmbedResponse struct {
|
||||
Embedding []float64 `json:"embedding"`
|
||||
Data []struct {
|
||||
Embedding []float64 `json:"embedding"`
|
||||
} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func NewHTTPEmbedder(cfg HTTPEmbedderConfig) (*HTTPEmbedder, error) {
|
||||
if strings.TrimSpace(cfg.Endpoint) == "" {
|
||||
return nil, fmt.Errorf("vector: empty HTTP embedding endpoint")
|
||||
}
|
||||
if cfg.Dimension <= 0 {
|
||||
return nil, fmt.Errorf("vector: invalid HTTP embedding dimension %d", cfg.Dimension)
|
||||
}
|
||||
if cfg.Timeout <= 0 {
|
||||
cfg.Timeout = 30 * time.Second
|
||||
}
|
||||
if cfg.Fingerprint == "" {
|
||||
cfg.Fingerprint = "http:" + cfg.Model + fmt.Sprintf(":%d", cfg.Dimension)
|
||||
}
|
||||
return &HTTPEmbedder{cfg: cfg, client: &http.Client{Timeout: cfg.Timeout}}, nil
|
||||
}
|
||||
|
||||
func (e *HTTPEmbedder) VectorizeDense(text string) ([]float64, error) {
|
||||
return e.embed(httpEmbedRequest{Model: e.cfg.Model, Modality: string(ModalityText), Side: "query", Text: text})
|
||||
}
|
||||
|
||||
func (e *HTTPEmbedder) EmbedImageDense(img []byte, mime string) ([]float64, error) {
|
||||
return e.embed(httpEmbedRequest{Model: e.cfg.Model, Modality: string(ModalityImage), Side: "document", Data: base64.StdEncoding.EncodeToString(img), MIME: mime})
|
||||
}
|
||||
|
||||
func (e *HTTPEmbedder) embed(payload httpEmbedRequest) ([]float64, error) {
|
||||
e.mu.Lock()
|
||||
closed := e.closed
|
||||
e.mu.Unlock()
|
||||
if closed {
|
||||
return nil, fmt.Errorf("vector: HTTP embedder closed")
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, e.cfg.Endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if e.cfg.APIKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+e.cfg.APIKey)
|
||||
}
|
||||
resp, err := e.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vector: HTTP embedding request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("vector: HTTP embedding status %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
var out httpEmbedResponse
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
return nil, fmt.Errorf("vector: decode HTTP embedding: %w", err)
|
||||
}
|
||||
v := out.Embedding
|
||||
if len(v) == 0 && len(out.Data) > 0 {
|
||||
v = out.Data[0].Embedding
|
||||
}
|
||||
if len(v) != e.cfg.Dimension {
|
||||
return nil, fmt.Errorf("vector: HTTP embedding dimension %d, want %d", len(v), e.cfg.Dimension)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (e *HTTPEmbedder) Fingerprint() string { return e.cfg.Fingerprint }
|
||||
func (e *HTTPEmbedder) Dim() int { return e.cfg.Dimension }
|
||||
func (e *HTTPEmbedder) Loaded() bool {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return !e.closed
|
||||
}
|
||||
func (e *HTTPEmbedder) Close() {
|
||||
e.mu.Lock()
|
||||
e.closed = true
|
||||
e.mu.Unlock()
|
||||
}
|
||||
178
internal/memory/vector/http_embedder_test.go
Normal file
178
internal/memory/vector/http_embedder_test.go
Normal file
@ -0,0 +1,178 @@
|
||||
package vector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHTTPEmbedder_RequiresEndpoint(t *testing.T) {
|
||||
_, err := NewHTTPEmbedder(HTTPEmbedderConfig{Dimension: 512})
|
||||
if err == nil {
|
||||
t.Fatal("应拒绝空 endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPEmbedder_RequiresDimension(t *testing.T) {
|
||||
_, err := NewHTTPEmbedder(HTTPEmbedderConfig{Endpoint: "http://localhost"})
|
||||
if err == nil {
|
||||
t.Fatal("应拒绝 dimension<=0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPEmbedder_TextEmbedding(t *testing.T) {
|
||||
// 模拟返回 4 维向量的外部服务
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("期望 POST,实际 %s", r.Method)
|
||||
}
|
||||
var req httpEmbedRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.Modality != "text" {
|
||||
t.Errorf("期望 modality=text,实际 %s", req.Modality)
|
||||
}
|
||||
if req.Text == "" {
|
||||
t.Fatal("text 不应为空")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"embedding":[0.1,0.2,0.3,0.4]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
e, err := NewHTTPEmbedder(HTTPEmbedderConfig{
|
||||
Endpoint: srv.URL,
|
||||
Dimension: 4,
|
||||
Model: "test-model",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer e.Close()
|
||||
|
||||
if !e.Loaded() {
|
||||
t.Fatal("应处于 loaded 状态")
|
||||
}
|
||||
|
||||
vec, err := e.VectorizeDense("hello world")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(vec) != 4 || vec[0] != 0.1 || vec[3] != 0.4 {
|
||||
t.Errorf("向量不符合预期: %v", vec)
|
||||
}
|
||||
if e.Fingerprint() != "http:test-model:4" {
|
||||
t.Errorf("指纹不符合预期: %s", e.Fingerprint())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPEmbedder_ImageEmbedding(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req httpEmbedRequest
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.Modality != "image" {
|
||||
t.Errorf("期望 modality=image,实际 %s", req.Modality)
|
||||
}
|
||||
if req.MIME != "image/png" {
|
||||
t.Errorf("期望 mime=image/png,实际 %s", req.MIME)
|
||||
}
|
||||
w.Write([]byte(`{"embedding":[0.5,0.5,0.5]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
e, err := NewHTTPEmbedder(HTTPEmbedderConfig{
|
||||
Endpoint: srv.URL,
|
||||
Dimension: 3,
|
||||
Model: "img-model",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer e.Close()
|
||||
|
||||
vec, err := e.EmbedImageDense([]byte("fake-png-data"), "image/png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(vec) != 3 {
|
||||
t.Errorf("期望 3 维,实际 %d", len(vec))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPEmbedder_CustomFingerprint(t *testing.T) {
|
||||
e, err := NewHTTPEmbedder(HTTPEmbedderConfig{
|
||||
Endpoint: "http://localhost:1234",
|
||||
Dimension: 512,
|
||||
Fingerprint: "jina-v5-omni-nano:2026",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer e.Close()
|
||||
if e.Fingerprint() != "jina-v5-omni-nano:2026" {
|
||||
t.Errorf("自定义指纹未生效: %s", e.Fingerprint())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPEmbedder_DimensionMismatchReturnsError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"embedding":[1,2]}`)) // 返回 2 维,配置期望 4
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
e, err := NewHTTPEmbedder(HTTPEmbedderConfig{
|
||||
Endpoint: srv.URL,
|
||||
Dimension: 4,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer e.Close()
|
||||
|
||||
_, err = e.VectorizeDense("test")
|
||||
if err == nil {
|
||||
t.Fatal("维度不匹配时应返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPEmbedder_ServerErrorReturnsError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
w.Write([]byte("gateway down"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
e, err := NewHTTPEmbedder(HTTPEmbedderConfig{
|
||||
Endpoint: srv.URL,
|
||||
Dimension: 4,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer e.Close()
|
||||
|
||||
_, err = e.VectorizeDense("test")
|
||||
if err == nil {
|
||||
t.Fatal("服务端错误时应返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPEmbedder_ClosePreventsFurtherCalls(t *testing.T) {
|
||||
e, err := NewHTTPEmbedder(HTTPEmbedderConfig{
|
||||
Endpoint: "http://localhost:1234",
|
||||
Dimension: 4,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.Close()
|
||||
if e.Loaded() {
|
||||
t.Fatal("关闭后 Loaded() 应返回 false")
|
||||
}
|
||||
_, err = e.VectorizeDense("test")
|
||||
if err == nil {
|
||||
t.Fatal("关闭后应返回错误")
|
||||
}
|
||||
}
|
||||
@ -17,11 +17,15 @@ type Vectorizer interface {
|
||||
EmbedImage(img []byte, mime string) (Vector, error)
|
||||
}
|
||||
|
||||
// MultimodalEmbedder 是稠密多模态编码器的接口(CLIP 等视觉-文本联合模型)。
|
||||
// MultimodalEmbedder 是稠密多模态编码器的接口。
|
||||
//
|
||||
// 与 Vectorizer(稀疏词向量,供 TF-IDF/倒排检索)刻意区分:多模态模型产出的
|
||||
// 是共享稠密空间(如 CLIP 512 维),直接用于 media.Store 的稠密余弦检索,
|
||||
// 是共享稠密空间,直接用于 media.Store 的稠密余弦检索,
|
||||
// **不得**塞进文档/知识层的稀疏 vector.Store(会破坏倒排剪枝与 TF-IDF 语义)。
|
||||
//
|
||||
// 实现不限:可以是内嵌 ONNX,也可以是外部 HTTP 向量服务——
|
||||
// 内核只依赖本接口,两条路径共享同一套检索/存储基础设施。Fingerprint 是模型
|
||||
// 空间标识(如模型文件指纹),作为 vec_model 持久化用于切换后重算。
|
||||
type MultimodalEmbedder interface {
|
||||
VectorizeDense(text string) ([]float64, error)
|
||||
EmbedImageDense(img []byte, mime string) ([]float64, error)
|
||||
@ -31,6 +35,18 @@ type MultimodalEmbedder interface {
|
||||
Close()
|
||||
}
|
||||
|
||||
// MultimodalModality 是统一向量空间支持的输入模态。
|
||||
// 现内核只消费 text/image;外部 API 路径可能扩展 audio/video,
|
||||
// 通过类型断言在接口外按需扩展,不破坏现有契约。
|
||||
type MultimodalModality string
|
||||
|
||||
const (
|
||||
ModalityText MultimodalModality = "text"
|
||||
ModalityImage MultimodalModality = "image"
|
||||
ModalityAudio MultimodalModality = "audio"
|
||||
ModalityVideo MultimodalModality = "video"
|
||||
)
|
||||
|
||||
// ErrNotSupported 表示 Vectorizer 不支持图像嵌入,调用方按文本描述降级。
|
||||
var ErrNotSupported = fmt.Errorf("vectorizer does not support image embedding")
|
||||
|
||||
@ -83,6 +99,26 @@ func (s *Store) Remove(id string) {
|
||||
}
|
||||
|
||||
func (s *Store) Search(query Vector, topK int) []DocVector {
|
||||
hits := s.SearchScored(query, topK)
|
||||
if len(hits) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]DocVector, len(hits))
|
||||
for i, h := range hits {
|
||||
out[i] = h.Doc
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DocVectorHit 是一篇文档的相似度候选及其原始 cosine 分数。
|
||||
// 跨模态融合需要分数做归一化;纯排序的 Search 不暴露它。
|
||||
type DocVectorHit struct {
|
||||
Doc DocVector
|
||||
Score float64
|
||||
}
|
||||
|
||||
// SearchScored 与 Search 同语义,但返回带原始 cosine 分数的候选。
|
||||
func (s *Store) SearchScored(query Vector, topK int) []DocVectorHit {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
@ -122,9 +158,9 @@ func (s *Store) Search(query Vector, topK int) []DocVector {
|
||||
results = results[:topK]
|
||||
}
|
||||
|
||||
out := make([]DocVector, len(results))
|
||||
out := make([]DocVectorHit, len(results))
|
||||
for i, r := range results {
|
||||
out[i] = r.doc
|
||||
out[i] = DocVectorHit{Doc: r.doc, Score: r.score}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user