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 fba85be396
commit e02a672772
15 changed files with 1480 additions and 94 deletions

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 计算并写入 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
}

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

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 匹配模型 → 非 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")

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
}