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)不进仓库,由导出脚本生成。
This commit is contained in:
JianFeeeee
2026-09-11 23:58:53 +08:00
parent d1959cbe80
commit bfdb395731
11 changed files with 1757 additions and 4 deletions

View File

@ -0,0 +1,273 @@
//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 侧自写 bicubic(PIL 系数)与官方预处理不会逐位相同,故用余弦阈值;
// 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("产物不完整时应打开失败")
}
}