Files
HomeAgent/providers/chineseclip/embedder_onnx_test.go
JianFeeeee bfdb395731 feat(memory): 新增 chineseclip provider —— text+image 的小体积可商用向量空间
## 为什么

用户决定「本轮不覆盖 video,先支持 text+image」。这一刀正好解锁了此前
「小 + 可商用 + 覆盖视频」三者不可兼得的僵局:不要求视频后,唯一同时满足
**小、可商用、中文原生** 的选项是 Chinese-CLIP ViT-B/16。

实测对比(同机、真实跑出来的数字):

| | Chinese-CLIP | jina-v5-omni-nano | Qwen3-VL-Emb-2B |
|---|---|---|---|
| 参数量 | 188M | 1.04B | 2B |
| 产物 / 常驻内存 | 754MB / **1.15GB** | ~2GB / 2.23GB | 8GB / 9.4GB |
| 维度 | 512 | 768 | 2048 |
| 许可 | **Apache-2.0** | CC BY-NC(不可商用) | Apache-2.0 |
| 视频 | 无 | 有 | 有 |

本机可用内存只有 5.3GB,Qwen 的 9.4GB 无法进程内使用;而 ORT format + mmap
那条路被证实当前不通(转换器对三段图段错误;走通还需同时升 ORT 运行时与
Go 绑定,v1.36 要求 API 29 而本机只有 28)。1.15GB 则可以直接进程内跑。

**代价已写进包注释与文档**:CLIP 是双塔对比学习,text↔image 是强项,但纯文本
语义明显弱于 MLLM 型嵌入器;文本检索仍由既有词向量/TF-IDF 路径兜底。
需要更强文本语义或视频时切回 qwen3vl。

## 内容

- `providers/chineseclip/`:按公共 SPI 实现的 provider(注册名 `chineseclip`),
  含 BERT WordPiece 分词器、图像预处理、ONNX 双塔推理、无标签 stub。
- `scripts/export_chineseclip_onnx.py`:从官方权重导出规范产物 + 冻结参考,
  自带逐用例 PyTorch 对比与覆盖度断言(计划集合≠执行集合即非零退出)。
- `cmd/homed/main.go`:空白导入两个 provider,由配置选其一。
- `go.mod`:`golang.org/x/text` 由间接依赖转为直接依赖(删音标需要 NFD)。

## 实现要点

- **分词器逐 token 对齐官方**。第一版探针自己拼 BertTokenizer(只给 vocab.txt、
  没删音标、中文没逐字切),中文被整体切成 [UNK],三个不同句子产出几乎相同的
  向量(余弦 0.98)——差点把「模型坏了」当成结论。官方配置是 do_lower_case=true
  + 删音标生效 + 中文逐字切分;`TestTokenizerMatchesOfficialReference` 钉住
  逐 token 一致。
- **图像缩放自写 bicubic**(复刻 PIL 的 precompute_coeffs + a=-0.5 核),不引
  golang.org/x/image:它未进本机模块缓存,且最新版要求把整个工具链升到 Go 1.26,
  为一个缩放函数动工具链不划算。
- **归一化在 provider 侧**(两个塔的图里都没归一化),检索按余弦。
- **指纹覆盖全部影响语义的产物**:两个 ONNX 图 + vocab.txt + embed_config.json,
  读不到就写 MISSING(跳过等于对缺件不敏感)。
- 会话 Run 用 runMu 串行化(ORT 会话不保证并发安全),创建/销毁用 mu。

## 模态范围

只声明 `text` 与 `image`;`audio`/`video` 明确返回 `ErrUnsupportedModality`,
绝不用别的模型向量冒充(这是「音频明确 unsupported」纪律的落地)。

## 验证(实测)

导出侧:10 个用例(5 文本 + 5 图像)ONNX vs 官方 PyTorch 全部
`cos = 1.000000000`,覆盖度断言 10/10 通过。

Go 侧(`CHINESECLIP_MODEL_DIR=... go test -tags onnxruntime ./providers/chineseclip/ -v`):
11/11 通过,其中
- 文本 5 用例 `cos = 1.000000000000`(逐位一致)
- 图像 4 纯色用例 `cos = 1.000000`(与官方预处理在 6 位小数内一致)
- 跨模态判别:红图对「红色」文本高于「蓝色」文本
- 模态拒绝 / 空输入 / 指纹稳定 / 产物缺失报错

顺带修掉测试自身的一个假通过:参考向量是**未归一化**的原始输出(模长 10~36),
原先「点积当余弦 + 单侧下界」会让 13.6 也判过,已改为真余弦 + 双侧容差。

构建矩阵:`go build/vet ./...` 与 `-tags onnxruntime` 两种都过;
`providers/... pkg/... internal/config/... internal/memory/vector/...` 回归通过
(qwen3vl 的 TestVideoModelInputMRope 需要 QWEN_ONNX_MODEL_DIR 指向含视频档的
v3 目录,缺该环境变量时用的是只有文本+图像的目录,与本改动无关)。

## 未做(明确记录)

- 发行版默认 provider 与构建标签变更:留下一提交(涉及打包与模型分发策略)。
- 模型产物(754MB)不进仓库,由导出脚本生成。
2026-09-11 23:58:53 +08:00

274 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//go:build onnxruntime
package chineseclip
import (
"bytes"
"context"
"errors"
"image"
"image/color"
"image/png"
"math"
"os"
"testing"
"gitcode.com/JianFeeeee/HomeAgent/pkg/embedding"
)
// cosine 计算两个向量的**真余弦**:两边都先归一化。
//
// 参考向量存的是 ONNX 的原始输出(未归一化,模长 10~36而 provider 的输出是
// L2 归一化后的。直接点积会得到参考向量的模长(例如 13.6),既不是余弦,
// 也会让单侧阈值判定变成假通过。
func cosine(a, b []float64) float64 {
if len(a) != len(b) || len(a) == 0 {
return math.NaN()
}
var dot, na, nb float64
for i := range a {
dot += a[i] * b[i]
na += a[i] * a[i]
nb += b[i] * b[i]
}
if na == 0 || nb == 0 {
return math.NaN()
}
return dot / (math.Sqrt(na) * math.Sqrt(nb))
}
// solidPNG 生成与导出脚本 FIXTURE_IMAGES 一致的纯色图320×320
func solidPNG(t *testing.T, c color.RGBA) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 320, 320))
for y := 0; y < 320; y++ {
for x := 0; x < 320; x++ {
img.Set(x, y, c)
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("生成测试图: %v", err)
}
return buf.Bytes()
}
func openTestProvider(t *testing.T) embedding.Provider {
t.Helper()
dir := modelDir(t)
p, err := embedding.Open("chineseclip", embedding.Config{
Options: map[string]string{"model_dir": dir},
})
if err != nil {
t.Fatalf("embedding.Open(chineseclip): %v", err)
}
t.Cleanup(p.Close)
return p
}
// provider 必须通过公共 SPI 可见,并声明正确的向量空间身份。
func TestProviderInfo(t *testing.T) {
p := openTestProvider(t)
info := p.Info()
if info.Dimension != 512 {
t.Errorf("维度应为 512得到 %d", info.Dimension)
}
if len(info.Fingerprint) != 64 {
t.Errorf("指纹应为 64 位十六进制,得到 %q", info.Fingerprint)
}
want := map[embedding.Modality]bool{embedding.ModalityText: true, embedding.ModalityImage: true}
if len(info.Modalities) != len(want) {
t.Fatalf("模态应为 %v得到 %v", want, info.Modalities)
}
for _, m := range info.Modalities {
if !want[m] {
t.Errorf("声明了未支持的模态 %q", m)
}
}
}
// 文本向量必须与官方 PyTorch 参考一致。
//
// 文本侧没有预处理歧义(分词器已逐 token 对齐),所以要求非常严:
// 余弦与参考的偏差应小于 1e-9。
func TestEmbedTextMatchesReference(t *testing.T) {
dir := modelDir(t)
p := openTestProvider(t)
ref := loadReference(t, dir)
if len(ref.Texts) == 0 {
t.Fatal("参考里没有文本用例")
}
ctx := context.Background()
for _, c := range ref.Texts {
got, err := p.Embed(ctx, embedding.Input{
Modality: embedding.ModalityText,
Purpose: embedding.PurposeQuery,
Text: c.Text,
})
if err != nil {
t.Fatalf("Embed(text=%q): %v", c.Text, err)
}
if err := embedding.ValidateVector(got, 512); err != nil {
t.Fatalf("向量不合法 %q: %v", c.Text, err)
}
cos := cosine(got, c.Vector)
if math.Abs(cos-1.0) > 1e-9 {
t.Errorf("文本向量与参考不一致 %q: cos=%.12f", c.Text, cos)
}
t.Logf("文本 cos=%.12f %s", cos, c.Text[:min(len(c.Text), 24)])
}
}
// 图像向量与官方参考一致(容忍缩放实现差异)。
//
// Go 侧自写 bicubicPIL 系数)与官方预处理不会逐位相同,故用余弦阈值;
// 0.999 足以证明「同一条管线」,同时不会掩盖把像素顺序或归一化写错这类错误
// (那类错误会直接掉到 0.9 以下)。
func TestEmbedImageMatchesReference(t *testing.T) {
dir := modelDir(t)
p := openTestProvider(t)
ref := loadReference(t, dir)
ctx := context.Background()
colors := map[string]color.RGBA{
"red": {R: 220, G: 30, B: 30, A: 255},
"green": {R: 60, G: 120, B: 60, A: 255},
"blue": {R: 30, G: 30, B: 220, A: 255},
"gray": {R: 128, G: 128, B: 128, A: 255},
}
checked := 0
for _, c := range ref.Images {
rgba, ok := colors[c.Name]
if !ok {
continue // gradient 在 Go 侧不便逐位复刻,跳过(仍由导出脚本覆盖)
}
got, err := p.Embed(ctx, embedding.Input{
Modality: embedding.ModalityImage,
Purpose: embedding.PurposeDocument,
Data: solidPNG(t, rgba),
MIME: "image/png",
})
if err != nil {
t.Fatalf("Embed(image=%s): %v", c.Name, err)
}
cos := cosine(got, c.Vector)
// 双侧判定:单侧下界挡不住「模长缩放」这类错误(未归一化的参考向量
// 会让点积恰好远大于 1 而“通过”)。
if math.Abs(cos-1.0) > 1e-3 {
t.Errorf("图像向量与参考不一致 %s: cos=%.6f", c.Name, cos)
}
t.Logf("图像 cos=%.6f %s", cos, c.Name)
checked++
}
if checked == 0 {
t.Fatal("没有比对任何图像用例(参考里缺少纯色样例)")
}
}
// 跨模态必须真的有区分度:红图对"红色"文本应高于"蓝色"文本。
// 这条防的是「向量塌缩但 cos 检查全过」那类假通过。
func TestCrossModalDiscrimination(t *testing.T) {
dir := modelDir(t)
p := openTestProvider(t)
ref := loadReference(t, dir)
ctx := context.Background()
var redText, blueText string
for _, c := range ref.Texts {
switch c.Text {
case "一张红色方块的图片":
redText = c.Text
case "蓝色的天空":
blueText = c.Text
}
}
if redText == "" || blueText == "" {
t.Skip("参考里缺少用于跨模态判别的文本")
}
imgVec, err := p.Embed(ctx, embedding.Input{
Modality: embedding.ModalityImage, Data: solidPNG(t, color.RGBA{R: 220, G: 30, B: 30, A: 255}),
MIME: "image/png",
})
if err != nil {
t.Fatalf("Embed(image): %v", err)
}
redVec, err := p.Embed(ctx, embedding.Input{Modality: embedding.ModalityText, Text: redText})
if err != nil {
t.Fatalf("Embed(red text): %v", err)
}
blueVec, err := p.Embed(ctx, embedding.Input{Modality: embedding.ModalityText, Text: blueText})
if err != nil {
t.Fatalf("Embed(blue text): %v", err)
}
if cosine(imgVec, redVec) <= cosine(imgVec, blueVec) {
t.Errorf("跨模态判别失败:红图-红文本 %.4f 应高于 红图-蓝文本 %.4f",
cosine(imgVec, redVec), cosine(imgVec, blueVec))
}
}
// 音频/视频必须明确拒绝,绝不用别的模型向量冒充。
func TestEmbedRejectsUnsupportedModalities(t *testing.T) {
p := openTestProvider(t)
ctx := context.Background()
for _, m := range []embedding.Modality{embedding.ModalityAudio, embedding.ModalityVideo} {
_, err := p.Embed(ctx, embedding.Input{Modality: m, Data: []byte("x"), MIME: "application/octet-stream"})
if err == nil {
t.Fatalf("模态 %s 应被拒绝", m)
}
if !errors.Is(err, embedding.ErrUnsupportedModality) {
t.Errorf("模态 %s 的错误应可判定为 ErrUnsupportedModality得到: %v", m, err)
}
}
}
// 空输入必须报错而不是产出垃圾向量。
func TestEmbedRejectsEmptyInput(t *testing.T) {
p := openTestProvider(t)
ctx := context.Background()
if _, err := p.Embed(ctx, embedding.Input{Modality: embedding.ModalityText, Text: " "}); err == nil {
t.Error("空文本应报错")
}
if _, err := p.Embed(ctx, embedding.Input{Modality: embedding.ModalityImage}); err == nil {
t.Error("空图像应报错")
}
}
// 指纹必须稳定且对产物内容敏感(同目录两次打开一致)。
func TestFingerprintStable(t *testing.T) {
dir := modelDir(t)
first, err := embedding.Open("chineseclip", embedding.Config{Options: map[string]string{"model_dir": dir}})
if err != nil {
t.Fatalf("首次打开: %v", err)
}
defer first.Close()
second, err := embedding.Open("chineseclip", embedding.Config{Options: map[string]string{"model_dir": dir}})
if err != nil {
t.Fatalf("二次打开: %v", err)
}
defer second.Close()
if first.Info().Fingerprint != second.Info().Fingerprint {
t.Errorf("同一产物两次打开指纹不一致: %s vs %s",
first.Info().Fingerprint, second.Info().Fingerprint)
}
}
// 缺 model_dir 必须明确报错(便于区分「没配置」与「模型坏了」)。
func TestOpenRejectsMissingModelDir(t *testing.T) {
if _, err := embedding.Open("chineseclip", embedding.Config{}); err == nil {
t.Fatal("缺 model_dir 时应打开失败")
}
}
// 目录存在但不是本模型产物时,必须报出缺哪个文件,而不是静默用默认值。
func TestOpenRejectsIncompleteArtifacts(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(dir+"/embed_config.json", []byte(`{"dim":512,"max_length":52,"image_size":224,"image_mean":[0.5,0.5,0.5],"image_std":[0.5,0.5,0.5],"text_onnx":"TextEncoder.onnx","vision_onnx":"VisionEncoder.onnx"}`), 0o644); err != nil {
t.Fatal(err)
}
_, err := embedding.Open("chineseclip", embedding.Config{Options: map[string]string{"model_dir": dir}})
if err == nil {
t.Fatal("产物不完整时应打开失败")
}
}