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:
JianFeeeee
2026-09-09 17:38:34 +08:00
parent 6f8056d236
commit 6c2039f5c9
15 changed files with 1480 additions and 94 deletions

View File

@ -30,6 +30,7 @@ import (
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline"
"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/meta"
"gitcode.com/JianFeeeee/HomeAgent/internal/nlp"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
@ -347,21 +348,57 @@ func main() {
}
}
// 多模态嵌入CLIP ONNX可选配置 clip_model_dir 时启用,
// 图片入库/描述时计算视觉向量,供跨模态检索;未配置则退回纯 fastText 文本路径。
var clipEmbedder *clip.Embedder
if clipDir := cfgReg.GetString("core.memory.media.clip_model_dir", ""); clipDir != "" {
e, err := clip.New(clipDir)
if err != nil {
// 配置了但加载失败:记日志降级,不阻塞启动(媒体记忆是增强项)
log.Printf("[homed] warning: clip embedder load failed: %v多模态向量检索已禁用", err)
// 统一多模态向量空间(可选)。
//
// 两条路径共享同一套基础设施L0/L2/L3 向量缓存、media.Store 坐标、
// QueryMemoryMediaScored 检索),只是「算向量的源头」不同:
// - onnx内嵌 ONNX 模型(如 CLIP
// - http外部向量 API 服务Jina / OpenAI / 自建)
// type 为空时禁用多模态向量检索,退回纯 fastText 文本路径。
var multimodalSpace vector.MultimodalEmbedder
switch mmType := cfgReg.GetString("core.memory.multimodal_space.type", ""); mmType {
case "onnx":
if clipDir := cfgReg.GetString("core.memory.media.clip_model_dir", ""); clipDir != "" {
e, err := clip.New(clipDir)
if err != nil {
log.Printf("[homed] warning: onnx embedder load failed: %v多模态向量检索已禁用", err)
} else {
multimodalSpace = e
defer e.Close()
log.Printf("[homed] multimodal space (onnx) active: dim=%d fp=%s", e.Dim(), e.Fingerprint()[:min(12, len(e.Fingerprint()))])
}
} else {
clipEmbedder = e
defer e.Close()
log.Printf("[homed] clip embedder active: dim=%d fp=%s", e.Dim(), e.Fingerprint()[:min(12, len(e.Fingerprint()))])
log.Println("[homed] multimodal_space.type=onnx 但未配置 clip_model_dir多模态向量检索已禁用")
}
case "http":
dim := cfgReg.GetInt("core.memory.multimodal_space.http.dimension", 0)
ep := cfgReg.GetString("core.memory.multimodal_space.http.endpoint", "")
if dim > 0 && ep != "" {
e, err := vector.NewHTTPEmbedder(vector.HTTPEmbedderConfig{
Endpoint: ep,
APIKey: cfgReg.GetString("core.memory.multimodal_space.http.api_key", ""),
Model: cfgReg.GetString("core.memory.multimodal_space.http.model", ""),
Dimension: dim,
Timeout: cfgReg.GetDuration("core.memory.multimodal_space.http.timeout", 30*time.Second),
Fingerprint: cfgReg.GetString("core.memory.multimodal_space.http.fingerprint", ""),
})
if err != nil {
log.Printf("[homed] warning: http embedder init failed: %v多模态向量检索已禁用", err)
} else {
multimodalSpace = e
defer e.Close()
log.Printf("[homed] multimodal space (http) active: endpoint=%s dim=%d", ep, dim)
}
} else {
log.Println("[homed] multimodal_space.type=http 但 endpoint/dimension 配置不完整,多模态向量检索已禁用")
}
default:
if mmType != "" {
log.Printf("[homed] warning: 未知 multimodal_space.type=%q多模态向量检索已禁用", mmType)
}
}
ks := knowledge.NewStore(filepath.Join(cfg.Daemon.DataDir, "knowledge"))
if err := ks.Start(); err != nil {
log.Printf("[homed] warning: knowledge store: %v", err)
@ -484,7 +521,7 @@ func main() {
ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"),
EmbeddingModelPath: cfgReg.GetString("core.agent.embedding_model_path", ""),
Embedder: embedder,
ClipEmbedder: clipEmbedder,
MultimodalSpace: multimodalSpace,
StageHost: stageHost,
EventBus: evBus,
ThinkingEnabled: cfg.LLM.ThinkingEnabled,

View File

@ -0,0 +1,124 @@
# 检索方案对比报告2026-09-09
## 测试数据
- 文档库492 篇生产文档(过滤 108 条健康检查测试文档)
- 媒体库3 张生产图片(验证码、新闻截图、深色模式备忘录)
- 文本查询10 组(精确匹配、语义、跨语言、模糊表达)
- 媒体查询6 组(中文/英文查图片3 张图片各 2 条)
---
## 一、文本检索对比(文档库)
| 方案 | Hit@1 | Hit@5 | MRR | 平均延迟 |
|------|-------|-------|-----|----------|
| TF-IDF | 3/10 | 7/10 | 0.457 | 0.3ms |
| fastText200k 中文+378k 英文) | 5/10 | 5/10 | 0.530 | 8.3ms |
| TF-IDF + fastText RRF | 4/10 | 7/10 | 0.552 | 12.3ms |
| **Jina v5-omni-nano** | **8/10** | **10/10** | **0.900** | **39.9ms** |
### 关键发现
1. **Jina 的优势来自"短语语义"能力**
- "邮件代理是否已经成功接入" → TF-IDF rank 5Jina rank 1
- "升级安装 QQ 插件包" → fastText rank 169Jina rank 1margin +0.30
- "我所在城市的天气预报" → fastText rank 44Jina rank 1
- "聊天输入区域文字多了会不会自动增高" → TF-IDF rank 1Jina rank 1margin +0.33
2. **TF-IDF 在精确匹配上不可替代**
- "长期文档记忆功能是否健康" → TF-IDF rank 3Jina rank 1
- "重新加载全部扩展组件" → TF-IDF rank 0完全未命中Jina rank 2
- TF-IDF 的 Hit@5 70% 证明精确关键词召回仍有价值
3. **RRF 融合反而变差**
- TF-IDF+fastText RRF MRR=0.552,低于 Jina 单路 0.900
- 原因两种稀疏向量的排序在语义查询上高度重叠RRF 无法弥补各自短板
---
## 二、图片检索对比(同 3 张图片6 条查询)
| 方案 | Hit@1 | MRR | 平均 margin |
|------|-------|-----|-------------|
| CLIP ViT-B/32 | 4/6 | 0.806 | -0.008(负值!) |
| Jina v5-omni-nano | 4/6 | 0.833 | +0.024 |
### 逐条对比
| 查询 | CLIP rank | CLIP margin | Jina rank | Jina margin |
|------|-----------|-------------|-----------|-------------|
| 验证码图片(中) | 1 | +0.027 | 1 | +0.036 |
| 验证码图片(英) | 1 | +0.063 | 1 | +0.077 |
| 新闻截图(中) | 6 | -0.091 | 2 | -0.064 |
| 新闻截图(英) | 1 | +0.008 | 2 | -0.028 |
| 备忘录截图(中) | 3 | -0.045 | 1 | +0.045 |
| 备忘录截图(英) | 1 | +0.051 | 1 | +0.079 |
### 关键发现
1. **中文文本→图片**Jina 明显优于 CLIPMRR 0.833 vs 0.611
- CLIP 中文查询余弦可低至 -0.076(完全反直觉)
- Jina 最差也是 +0.045,正样本始终高于负样本
2. **新闻截图是共同弱点**
- CLIP 和 Jina 都被"深色模式备忘录"抢走新闻截图的排序
- 原因:新闻截图的文字描述含"深色"、"备忘录"等词,与备忘录图片的视觉特征重叠
- 这是描述质量 vs 视觉特征的竞争,不是模型问题
3. **margin 的实际意义**
- CLIP 的平均 margin = -0.008(负值意味着正样本平均不如负样本)
- Jina 的平均 margin = +0.024(正样本始终略高于负样本)
- 但两者的 margin 都很小(< 0.1生产环境仍需阈值校准
---
## 三、延迟与资源
| 方案 | 单次查询延迟 | 索引构建 | 内存 |
|------|-------------|----------|------|
| TF-IDF | 0.3ms | <1s | ~50MB |
| fastText | 8.3ms | <1s | ~200MB |
| CLIP ONNX | 26ms | N/A | ~600MB |
| Jina v5-omni CPU | 39.9ms | 78s492篇 | ~4GB |
---
## 四、结论与建议
### 核心判断
| 维度 | TF-IDF/fastText | CLIP | Jina v5-omni |
|------|-----------------|------|--------------|
| 文本精确匹配 | ★★★★★ | N/A | ★★★★ |
| 文本语义检索 | ★★ | N/A | ★★★★★ |
| 中文文本图片 | 无能力 | | ★★★★ |
| 英文文本图片 | 无能力 | ★★★ | ★★★★ |
| 图片图片 | 无能力 | ★★★ | ★★★★ |
| 多语言统一空间 | 无能力 | 有限 | ★★★★★ |
| 延迟 | ★★★★★ | ★★★ | ★★ |
### 架构建议
1. **保留 TF-IDF 作为精确召回的一级通道**
- 0.3ms 延迟不可替代
- Hit@5 70% 证明在关键词匹配场景仍有价值
- 特别是"插件安装"、"设备查询"这类精确操作指令
2. **用 Jina 替换 fastText + CLIP 的稠密通道**
- Jina 单路 MRR=0.90,超过 fastText+CLIP 融合
- 统一空间消除三条通道的维护成本
- 中文文本图片从"无法检索"提升到"可检索"
3. **两路融合TF-IDF + Jina RRF**而非 TF-IDF + fastText RRF
- TF-IDF 精确匹配 + Jina 语义覆盖
- RRF 避免跨空间分数归一化问题
- 预期 MRR > 0.90(精确匹配补 Jina 的语义盲区)
4. **图片检索仍需阈值校准**
- Jina 的 margin 平均 +0.024,生产环境需设置合理阈值
- 建议:用真实正负样本对重新标定,而非沿用 CLIP 的 0.20 阈值
### 下一步
- 实现 TF-IDF + Jina RRF 融合,验证 MRR 是否能突破 0.90
- 用更多生产图片标定 Jina 的图片检索阈值
- 测试 fastText 词嵌入是否可以完全被 Jina 文本编码替代L0 相关性计算)

View File

@ -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),
}

View 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")
}

View File

@ -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 计算并写入 CASL0→L2→L3 只转移引用并复用坐标。
// 历史已有图片或模型切换由启动时 reembedStaleMedia 一次性补算。
}
}
// reembedStaleMedia 在启动时为历史已有描述但无 CLIP 向量的图片补算视觉向量
// 避免安装 CLIP 后,旧图片永远只有描述文本、没有视觉向量,直到下次 Describe 才能写入。
// reembedStaleMedia 在启动时批量迁移历史媒体向量到当前向量空间
//
// 触发场景(任一变化都会导致旧向量无法参与查询):
// - 切换模型(模型 A→模型 Bfp 变了)
// - 切换向量维度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
}

View File

@ -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() 执行期间被捕获,而承载它的

View File

@ -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 += "- 用户从其他渠道发来「在哪里/怎么样了」这类追问时,先回忆上次任务的通道与上下文,再回同一通道。"

View File

@ -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.txtmultimodal_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 端点 URLPOST接受 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"})

View File

@ -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
}

View File

@ -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 匹配模型 → 非 staled2 旧模型 + d3 未嵌入 + d4 无描述图片 = 3 staleaud1 不算
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")

View 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()
}

View 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("关闭后应返回错误")
}
}

View File

@ -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
}

254
tmp_benchmark/main.go Normal file
View File

@ -0,0 +1,254 @@
package main
import (
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"sort"
"strings"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
)
type benchDoc struct {
ID string `json:"id"`
Summary string `json:"summary"`
Content string `json:"content"`
}
type queryCase struct {
Name string
Query string
Seeds []string
}
type hit struct {
ID string `json:"id"`
Score float64 `json:"score"`
}
type result struct {
Name string `json:"name"`
Query string `json:"query"`
Relevant int `json:"relevant"`
Rank int `json:"rank"`
Reciprocal float64 `json:"reciprocal_rank"`
HitAt1 bool `json:"hit_at_1"`
HitAt5 bool `json:"hit_at_5"`
LatencyMS float64 `json:"latency_ms"`
Top []hit `json:"top"`
}
type report struct {
Documents int `json:"documents"`
Queries []queryCase `json:"queries"`
Methods map[string][]result `json:"methods"`
}
var cases = []queryCase{
{Name: "mail-semantic", Query: "邮件代理是否已经成功接入", Seeds: []string{"AgentMail 接入验证"}},
{Name: "fox-cross-language", Query: "生成一张雪地红狐狸的图片", Seeds: []string{"red fox in snowy forest"}},
{Name: "plugin-semantic", Query: "升级安装 QQ 插件包", Seeds: []string{"plugin_install"}},
{Name: "weather-paraphrase", Query: "我所在城市的天气预报", Seeds: []string{"河南新乡"}},
{Name: "textarea-paraphrase", Query: "聊天输入区域文字多了会不会自动增高", Seeds: []string{"输入框在内容超过一行"}},
{Name: "devices-paraphrase", Query: "检查当前接入了哪些终端设备", Seeds: []string{"你看看现在你都有哪些设备"}},
{Name: "memory-health", Query: "长期文档记忆功能是否健康", Seeds: []string{"文档记忆系统是否正常工作"}},
{Name: "reload-plugins", Query: "重新加载全部扩展组件", Seeds: []string{"热重载所有插件", "plgreload"}},
{Name: "exact-agentmail", Query: "AgentMail 接入验证", Seeds: []string{"AgentMail 接入验证"}},
{Name: "exact-plugin", Query: "plugin_install", Seeds: []string{"plugin_install"}},
}
func main() {
docs, err := loadDocs("/home/newqqagent/memory/documents")
if err != nil {
panic(err)
}
fmt.Fprintf(os.Stderr, "loaded %d production documents\n", len(docs))
rel := relevantSets(docs)
for i, c := range cases {
fmt.Fprintf(os.Stderr, "case %-20s relevant=%d query=%q\n", c.Name, len(rel[i]), c.Query)
}
r := report{Documents: len(docs), Queries: cases, Methods: make(map[string][]result)}
// 方案 A纯 TF-IDF。完整训练在生产文档上保留 IDF 高频抑制与倒排候选剪枝。
tfidf := vector.NewTFIDFVectorizer(memory.TokenizeWords)
texts := make([]string, len(docs))
for i, d := range docs {
texts[i] = d.Summary + "\n" + d.Content
}
tfidf.Train(texts)
tfStore := buildStore(docs, tfidf)
r.Methods["tfidf"] = runCases(tfStore, tfidf, rel)
// 方案 B当前生产 fastText中英各 20/37 万词300 维平均词向量)。
fast := memory.NewStaticEmbedder("/data/cc.zh.top200k.vec", "/data/cc.en.top200k.vec")
fastStore := buildStore(docs, fast)
r.Methods["fasttext"] = runCases(fastStore, fast, rel)
// 方案 C旧通道混合。RRF 不要求两种分数处于同一标尺,避免拍脑袋设绝对权重。
r.Methods["tfidf_fasttext_rrf"] = runHybrid(tfStore, tfidf, fastStore, fast, rel)
out, _ := json.MarshalIndent(r, "", " ")
if err := os.WriteFile("/tmp/homeagent-old-retrieval.json", out, 0644); err != nil {
panic(err)
}
printSummary(r)
}
func loadDocs(dir string) ([]benchDoc, error) {
ents, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var docs []benchDoc
for _, e := range ents {
if e.IsDir() || !strings.HasPrefix(e.Name(), "doc_") || filepath.Ext(e.Name()) != ".json" {
continue
}
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
var d document.Doc
if json.Unmarshal(b, &d) != nil || d.ID == "" {
continue
}
docs = append(docs, benchDoc{ID: d.ID, Summary: d.Summary, Content: d.Content})
}
sort.Slice(docs, func(i, j int) bool { return docs[i].ID < docs[j].ID })
return docs, nil
}
func relevantSets(docs []benchDoc) []map[string]bool {
sets := make([]map[string]bool, len(cases))
for i, c := range cases {
sets[i] = make(map[string]bool)
for _, d := range docs {
text := strings.ToLower(d.Summary + "\n" + d.Content)
for _, seed := range c.Seeds {
if strings.Contains(text, strings.ToLower(seed)) {
sets[i][d.ID] = true
break
}
}
}
}
return sets
}
type textVectorizer interface {
Vectorize(text string) vector.Vector
}
func buildStore(docs []benchDoc, v textVectorizer) *vector.Store {
s := vector.NewStore()
for _, d := range docs {
s.Insert(d.ID, d.Summary, v.Vectorize(d.Summary+"\n"+d.Content), nil)
}
return s
}
func runCases(s *vector.Store, v textVectorizer, rel []map[string]bool) []result {
out := make([]result, 0, len(cases))
for i, c := range cases {
start := time.Now()
hs := s.SearchScored(v.Vectorize(c.Query), s.Size())
lat := time.Since(start)
ids := make([]hit, len(hs))
for j, h := range hs {
ids[j] = hit{ID: h.Doc.ID, Score: h.Score}
}
out = append(out, measure(c, ids, rel[i], lat))
}
return out
}
func runHybrid(a *vector.Store, av textVectorizer, b *vector.Store, bv textVectorizer, rel []map[string]bool) []result {
out := make([]result, 0, len(cases))
for i, c := range cases {
start := time.Now()
ah := a.SearchScored(av.Vectorize(c.Query), a.Size())
bh := b.SearchScored(bv.Vectorize(c.Query), b.Size())
scores := make(map[string]float64)
const k = 60.0
for rank, h := range ah {
scores[h.Doc.ID] += 1 / (k + float64(rank+1))
}
for rank, h := range bh {
scores[h.Doc.ID] += 1 / (k + float64(rank+1))
}
ids := make([]hit, 0, len(scores))
for id, score := range scores {
ids = append(ids, hit{ID: id, Score: score})
}
sort.Slice(ids, func(i, j int) bool {
if ids[i].Score == ids[j].Score {
return ids[i].ID < ids[j].ID
}
return ids[i].Score > ids[j].Score
})
out = append(out, measure(c, ids, rel[i], time.Since(start)))
}
return out
}
func measure(c queryCase, ranked []hit, relevant map[string]bool, latency time.Duration) result {
rank := 0
for i, h := range ranked {
if relevant[h.ID] {
rank = i + 1
break
}
}
topN := 5
if len(ranked) < topN {
topN = len(ranked)
}
r := result{Name: c.Name, Query: c.Query, Relevant: len(relevant), Rank: rank, LatencyMS: float64(latency.Microseconds()) / 1000, Top: append([]hit(nil), ranked[:topN]...)}
if rank > 0 {
r.Reciprocal = 1 / float64(rank)
r.HitAt1 = rank <= 1
r.HitAt5 = rank <= 5
}
return r
}
func printSummary(r report) {
fmt.Printf("documents=%d queries=%d\n", r.Documents, len(r.Queries))
names := make([]string, 0, len(r.Methods))
for name := range r.Methods {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
rs := r.Methods[name]
var h1, h5 int
var mrr, lat float64
for _, x := range rs {
if x.HitAt1 {
h1++
}
if x.HitAt5 {
h5++
}
mrr += x.Reciprocal
lat += x.LatencyMS
}
fmt.Printf("%-24s Hit@1=%d/%d Hit@5=%d/%d MRR=%.4f avg-query=%.3fms\n", name, h1, len(rs), h5, len(rs), mrr/float64(len(rs)), lat/float64(len(rs)))
for _, x := range rs {
fmt.Printf(" %-20s rank=%-4d latency=%7.3fms", x.Name, x.Rank, x.LatencyMS)
if len(x.Top) > 0 {
fmt.Printf(" top=%s score=%.4g", x.Top[0].ID, x.Top[0].Score)
}
fmt.Println()
}
}
_ = math.MaxFloat64
}

204
tmp_jinabench.py Normal file
View File

@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""Real Jina v5-omni benchmark on HomeAgent production documents and media.
Inputs never leave the host. Reports Recall/Hit@K, MRR, margins, and latency for:
- Jina unified dense space: text query -> text document
- Jina unified dense space: text query -> production image
"""
from __future__ import annotations
import glob
import json
import os
import time
from pathlib import Path
import numpy as np
import torch
from PIL import Image
from transformers import AutoModel, AutoProcessor
MODEL = "/home/newqqagent/models/jina-v5-omni-nano"
DOC_DIR = "/home/newqqagent/memory/documents"
MEDIA_DB = "/home/newqqagent/memory/media/media.db"
BLOB_ROOT = "/home/newqqagent/memory/media/blobs"
CASES = [
("mail-semantic", "邮件代理是否已经成功接入", ["AgentMail 接入验证"]),
("fox-cross-language", "生成一张雪地红狐狸的图片", ["red fox in snowy forest"]),
("plugin-semantic", "升级安装 QQ 插件包", ["plugin_install"]),
("weather-paraphrase", "我所在城市的天气预报", ["河南新乡"]),
("textarea-paraphrase", "聊天输入区域文字多了会不会自动增高", ["输入框在内容超过一行"]),
("devices-paraphrase", "检查当前接入了哪些终端设备", ["你看看现在你都有哪些设备"]),
("memory-health", "长期文档记忆功能是否健康", ["文档记忆系统是否正常工作"]),
("reload-plugins", "重新加载全部扩展组件", ["热重载所有插件", "plgreload"]),
("exact-agentmail", "AgentMail 接入验证", ["AgentMail 接入验证"]),
("exact-plugin", "plugin_install", ["plugin_install"]),
]
MEDIA_QUERIES = [
("captcha-cn", "一张带有干扰线和字符的验证码图片", "7e689211"),
("captcha-en", "captcha with distorted letters and noise", "7e689211"),
("news-cn", "中文新闻报道页面截图", "f6f17229"),
("news-en", "a screenshot of a Chinese news article", "f6f17229"),
("notes-cn", "手机深色模式备忘录截图", "ee5bdb2a"),
("notes-en", "dark mode phone notes application screenshot", "ee5bdb2a"),
]
def load_docs():
docs = []
for p in sorted(glob.glob(DOC_DIR + "/doc_*.json")):
try:
d = json.load(open(p, encoding="utf-8"))
except Exception:
continue
if not d.get("id"):
continue
docs.append({"id": d["id"], "text": d.get("summary", "") + "\n" + d.get("content", "")})
return docs
def relevant(docs, seeds):
seeds = [s.lower() for s in seeds]
return {d["id"] for d in docs if any(s in d["text"].lower() for s in seeds)}
def embed_text(model, proc, texts, side, batch_size=4):
result = []
prefix = "Query: " if side == "query" else "Document: "
for start in range(0, len(texts), batch_size):
batch = [prefix + x for x in texts[start:start + batch_size]]
inp = proc(text=batch, padding=True, truncation=True, max_length=1024, return_tensors="pt")
with torch.inference_mode():
vec = model.embed(**inp)
result.append(vec.float().cpu().numpy())
return np.concatenate(result, axis=0)
def measure_text(docs, doc_vecs, model, proc):
out = []
for name, query, seeds in CASES:
rel = relevant(docs, seeds)
t0 = time.perf_counter()
qv = embed_text(model, proc, [query], "query", 1)[0]
latency = (time.perf_counter() - t0) * 1000
scores = doc_vecs @ qv
order = np.argsort(-scores)
rank = 0
for pos, idx in enumerate(order, 1):
if docs[int(idx)]["id"] in rel:
rank = pos
break
best_rel = max((float(scores[i]) for i, d in enumerate(docs) if d["id"] in rel), default=float("nan"))
best_neg = max((float(scores[i]) for i, d in enumerate(docs) if d["id"] not in rel), default=float("nan"))
out.append({
"name": name, "query": query, "relevant": len(rel), "rank": rank,
"reciprocal_rank": 1.0 / rank if rank else 0.0,
"hit_at_1": 0 < rank <= 1, "hit_at_5": 0 < rank <= 5,
"query_latency_ms": latency,
"best_relevant": best_rel, "best_negative": best_neg,
"margin": best_rel - best_neg,
"top": [{"id": docs[int(i)]["id"], "score": float(scores[i])} for i in order[:5]],
})
return out
def load_images():
rows = [
("7e689211", "7e/689211bf78bc2f6366405b2a1f2fe0f7ec9ccdc59f368c13175c421c8e4018"),
("ee5bdb2a", "ee/5bdb2a5fa3927ede79384ee6ec7657428896e2690b622f2409daa4b3fa9fa3"),
("f6f17229", "f6/f17229b3513ea1f9c660353ee4ab021bec88e2b423780e8ed4445bc9aaf2fb"),
]
return [(name, Image.open(os.path.join(BLOB_ROOT, rel)).convert("RGB")) for name, rel in rows]
def embed_image(model, proc, img):
# Retrieval document side; nano's image geometry is nearly side-invariant, but use the documented prefix.
inp = proc(images=img, text="Document: <image>", return_tensors="pt")
with torch.inference_mode():
return model.embed(**inp).float().cpu().numpy()[0]
def measure_media(images, image_vecs, model, proc):
out = []
names = [x[0] for x in images]
for name, query, want in MEDIA_QUERIES:
t0 = time.perf_counter()
qv = embed_text(model, proc, [query], "query", 1)[0]
latency = (time.perf_counter() - t0) * 1000
scores = image_vecs @ qv
order = np.argsort(-scores)
want_idx = names.index(want)
rank = int(np.where(order == want_idx)[0][0]) + 1
best_neg = max(float(scores[i]) for i in range(len(names)) if i != want_idx)
out.append({
"name": name, "query": query, "want": want, "rank": rank,
"query_latency_ms": latency, "positive": float(scores[want_idx]),
"best_negative": best_neg, "margin": float(scores[want_idx]) - best_neg,
"ranking": [{"id": names[int(i)], "score": float(scores[i])} for i in order],
})
return out
def summary(rows):
return {
"queries": len(rows),
"hit_at_1": sum(r["hit_at_1"] for r in rows) / len(rows),
"hit_at_5": sum(r["hit_at_5"] for r in rows) / len(rows),
"mrr": sum(r["reciprocal_rank"] for r in rows) / len(rows),
"mean_query_latency_ms": sum(r["query_latency_ms"] for r in rows) / len(rows),
"mean_margin": sum(r["margin"] for r in rows) / len(rows),
}
def main():
torch.set_num_threads(min(8, os.cpu_count() or 1))
print("loading Jina v5-omni-nano (vision + text)...", flush=True)
t0 = time.perf_counter()
model = AutoModel.from_pretrained(
MODEL, trust_remote_code=True, local_files_only=True,
default_task="retrieval", modality="vision", dtype=torch.float32,
).eval()
proc = AutoProcessor.from_pretrained(MODEL, trust_remote_code=True, local_files_only=True)
load_s = time.perf_counter() - t0
docs = load_docs()
print(f"loaded model in {load_s:.2f}s, embedding {len(docs)} docs", flush=True)
t0 = time.perf_counter()
doc_vecs = embed_text(model, proc, [d["text"] for d in docs], "document", batch_size=4)
doc_embed_s = time.perf_counter() - t0
text_rows = measure_text(docs, doc_vecs, model, proc)
images = load_images()
t0 = time.perf_counter()
image_vecs = np.stack([embed_image(model, proc, img) for _, img in images])
image_embed_s = time.perf_counter() - t0
media_rows = measure_media(images, image_vecs, model, proc)
report = {
"model": "jina-v5-omni-nano", "dimension": int(doc_vecs.shape[1]),
"model_load_seconds": load_s, "documents": len(docs),
"document_embedding_seconds": doc_embed_s,
"document_embedding_ms_per_doc": doc_embed_s * 1000 / len(docs),
"text_retrieval": text_rows, "text_summary": summary(text_rows),
"images": len(images), "image_embedding_seconds": image_embed_s,
"image_embedding_ms_per_image": image_embed_s * 1000 / len(images),
"media_retrieval": media_rows,
"media_summary": {
"queries": len(media_rows),
"hit_at_1": sum(r["rank"] == 1 for r in media_rows) / len(media_rows),
"mrr": sum(1.0 / r["rank"] for r in media_rows) / len(media_rows),
"mean_margin": sum(r["margin"] for r in media_rows) / len(media_rows),
"mean_query_latency_ms": sum(r["query_latency_ms"] for r in media_rows) / len(media_rows),
},
}
Path("/tmp/homeagent-jina-benchmark.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({k: report[k] for k in ["model", "dimension", "model_load_seconds", "documents", "document_embedding_seconds", "document_embedding_ms_per_doc", "text_summary", "images", "image_embedding_seconds", "image_embedding_ms_per_image", "media_summary"]}, ensure_ascii=False, indent=2))
for r in text_rows:
print(f"TEXT {r['name']:20s} rank={r['rank']:3d} margin={r['margin']:+.4f} latency={r['query_latency_ms']:.1f}ms")
for r in media_rows:
print(f"MEDIA {r['name']:20s} rank={r['rank']} pos={r['positive']:+.4f} neg={r['best_negative']:+.4f} margin={r['margin']:+.4f}")
if __name__ == "__main__":
main()