Files
HomeAgent/pkg/embedding/embedding.go
JianFeeeee a37bc7333e refactor(memory): 核心不再适配具体模型——公共 embedding provider SPI + 注册表
问题:cmd/homed 里 `case "onnx": qwen.New(modelDir)` 把模型适配写进了核心,
`type=onnx` 名义上是格式、实际写死了一个模型家族;2117 行 Qwen 专属代码
(BPE、chat template、M-RoPE、Vision_gN 命名)住在内核树里,还带着一对
`//go:build onnxruntime` 的 stub。加任何新模型都要改内核。

现在核心只认一个模型无关的公共契约(pkg/embedding):
- 输入是不透明的 Data+MIME,解码/预处理/时序分组全归 provider
- 能力是数据(Info.Modalities),不是接口方法——新增模态无需改核心接口
- 不支持的模态返回 embedding.ErrUnsupportedModality(可 errors.Is 识别)
- 按名字注册,重复注册 panic;Options 是 provider 私有命名空间,核心不解释

改动:
- 新增 pkg/embedding:Modality/Purpose/Input/Info/Provider/Config + 注册表
  (Open 校验 Info,ValidateVector 在入库前拦下维度错与非有限值)
- providers/qwen3vl:Qwen 实现整体移出内核(git mv),实现公共 SPI 并自注册
- internal/memory/vector:新增 ProviderAdapter(公共 SPI → 内部小接口);
  ErrModalityUnsupported 改为公共哨兵别名;删除 VideoEmbedder 可选接口
  (那正是「核心为每个新模态长方法」的坏味道)
- http embedder 也变成普通 provider(注册名 http)
- cmd/homed:删除 qwen import 与 onnx/http 分支,改为按 provider 名打开 +
  透传 options.*;provider 打开失败只警告并禁用多模态检索,不影响启动
- config:multimodal_space.type/onnx./http.* → provider + options.*
- 删除 internal/memory/qwen(整体搬迁)

测试:
- pkg/embedding:注册表隔离/未知名字/非法 Info 自动关闭/ValidateVector
- vector:适配器原样透传字节与 MIME、维度错被拦、Close 幂等且停止使用、
  两个哨兵 errors.Is 互通
- providers/qwen3vl:新增公共 SPI 全链路集成测试(Open→Info→Embed→
  未知模态哨兵),并明确断言 Info 不声明 video

已知未完成(不得当作已验证):
- 视频冻结回归 TestEmbedderVideoMatchesONNXReference **显式跳过**:Go 侧
  video 模板缺少 processor 按时间组插入的字面时间戳文本
  (<0.0 seconds>/<1.0 seconds>),同一输入 Python seq=1190(1152+38)、
  Go 只有 22 个文本 token。时间戳也占 M-RoPE 位置,故现有 M-RoPE 自洽断言
  通过不能证明与官方实现一致。修复属 provider 内部工作。
- 视觉侧三档已导出并逐档校验通过(cos 1.000000119/1.000000119/1.000000000)

验证:go build ./... ;go vet -tags onnxruntime ./... ;
go test -short ./internal/memory/... ./internal/agent/core/... ./internal/sdk/... ./pkg/...
;onnxruntime 下 providers/qwen3vl 全绿(视频为显式 skip)
2026-09-11 18:26:19 +08:00

173 lines
5.1 KiB
Go

// Package embedding defines the public SPI for dense multimodal embedding providers.
//
// The HomeAgent core depends only on this package. Model runtimes, tokenizers,
// preprocessing, media decoding, and model-specific configuration belong in
// provider packages registered with Register.
package embedding
import (
"context"
"errors"
"fmt"
"math"
"sort"
"strings"
"sync"
)
// Modality identifies the semantic kind of an embedding input. Providers may
// support additional modality strings; the constants are the common values.
type Modality string
const (
ModalityText Modality = "text"
ModalityImage Modality = "image"
ModalityAudio Modality = "audio"
ModalityVideo Modality = "video"
)
// Purpose tells a provider how the vector will be used. Providers whose model
// distinguishes query and document prompts can map this value accordingly.
type Purpose string
const (
PurposeQuery Purpose = "query"
PurposeDocument Purpose = "document"
)
// Input is the model-neutral request passed to a provider.
//
// Data is deliberately opaque to the core. MIME describes the encoding; the
// selected provider owns decoding, frame sampling, preprocessing, and all
// other model-specific interpretation. Text is used for textual inputs.
type Input struct {
Modality Modality
Purpose Purpose
Text string
Data []byte
MIME string
Metadata map[string]string
}
// Info describes one vector space. Fingerprint must change whenever vectors
// cease to be comparable with vectors produced by a previous provider build.
type Info struct {
Dimension int
Fingerprint string
Modalities []Modality
}
// Provider is the public Go extension point for a dense multimodal vector
// space. Implementations must be safe for concurrent Embed calls unless their
// factory documents otherwise and serializes internally.
type Provider interface {
Embed(context.Context, Input) ([]float64, error)
Info() Info
Close()
}
// Config contains provider-owned options. The core does not interpret option
// names or values; it only passes core.memory.multimodal_space.options.*
// through after stripping the prefix.
type Config struct {
Options map[string]string
}
// Factory constructs a provider instance.
type Factory func(Config) (Provider, error)
var (
// ErrUnsupportedModality means this vector space has no native encoder for
// the requested modality. Callers must not substitute another model's vector.
ErrUnsupportedModality = errors.New("embedding: unsupported modality")
registryMu sync.RWMutex
registry = make(map[string]Factory)
)
// Register makes a provider factory available under name. It is normally
// called from a provider package's init function. Duplicate names panic so a
// build cannot silently select whichever package initialized last.
func Register(name string, factory Factory) {
name = strings.TrimSpace(name)
if name == "" {
panic("embedding: register empty provider name")
}
if factory == nil {
panic("embedding: register nil factory for " + name)
}
registryMu.Lock()
defer registryMu.Unlock()
if _, exists := registry[name]; exists {
panic("embedding: provider already registered: " + name)
}
registry[name] = factory
}
// Open constructs a registered provider and validates its vector-space identity.
func Open(name string, cfg Config) (Provider, error) {
name = strings.TrimSpace(name)
registryMu.RLock()
factory := registry[name]
registryMu.RUnlock()
if factory == nil {
return nil, fmt.Errorf("embedding: unknown provider %q (available: %s)", name, strings.Join(Names(), ", "))
}
provider, err := factory(cloneConfig(cfg))
if err != nil {
return nil, fmt.Errorf("embedding: open provider %q: %w", name, err)
}
if provider == nil {
return nil, fmt.Errorf("embedding: provider %q returned nil", name)
}
if err := ValidateInfo(provider.Info()); err != nil {
provider.Close()
return nil, fmt.Errorf("embedding: provider %q: %w", name, err)
}
return provider, nil
}
// Names returns registered provider names in deterministic order.
func Names() []string {
registryMu.RLock()
defer registryMu.RUnlock()
names := make([]string, 0, len(registry))
for name := range registry {
names = append(names, name)
}
sort.Strings(names)
return names
}
// ValidateInfo checks the stable identity required by vector persistence.
func ValidateInfo(info Info) error {
if info.Dimension <= 0 {
return fmt.Errorf("invalid dimension %d", info.Dimension)
}
if strings.TrimSpace(info.Fingerprint) == "" {
return errors.New("empty fingerprint")
}
return nil
}
// ValidateVector rejects malformed provider output before it reaches storage.
func ValidateVector(vec []float64, dimension int) error {
if len(vec) != dimension {
return fmt.Errorf("embedding: vector dimension %d, want %d", len(vec), dimension)
}
for i, value := range vec {
if math.IsNaN(value) || math.IsInf(value, 0) {
return fmt.Errorf("embedding: vector value %d is not finite", i)
}
}
return nil
}
func cloneConfig(cfg Config) Config {
out := Config{Options: make(map[string]string, len(cfg.Options))}
for key, value := range cfg.Options {
out.Options[key] = value
}
return out
}