mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 18:08:04 +00:00
## 为什么 用户决定「本轮不覆盖 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)不进仓库,由导出脚本生成。
166 lines
5.2 KiB
Go
166 lines
5.2 KiB
Go
package chineseclip
|
||
|
||
import (
|
||
"encoding/json"
|
||
"os"
|
||
"path/filepath"
|
||
"reflect"
|
||
"testing"
|
||
)
|
||
|
||
// referenceText 是官方导出时冻结的逐文本 token 参考。
|
||
type referenceText struct {
|
||
Text string `json:"text"`
|
||
InputIDs []int64 `json:"input_ids"`
|
||
Attention []int64 `json:"attention_mask"`
|
||
Vector []float64 `json:"vector"`
|
||
PixelsSHA256 string `json:"pixels_sha256"`
|
||
Name string `json:"name"`
|
||
}
|
||
|
||
type reference struct {
|
||
Texts []referenceText `json:"texts"`
|
||
Images []referenceText `json:"images"`
|
||
}
|
||
|
||
func loadReference(t *testing.T, dir string) reference {
|
||
t.Helper()
|
||
path := filepath.Join(dir, "reference.json")
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
t.Skipf("缺少冻结参考 %s(由 scripts/export_chineseclip_onnx.py 生成): %v", path, err)
|
||
}
|
||
var ref reference
|
||
if err := json.Unmarshal(data, &ref); err != nil {
|
||
t.Fatalf("解析参考 %s: %v", path, err)
|
||
}
|
||
return ref
|
||
}
|
||
|
||
func modelDir(t *testing.T) string {
|
||
t.Helper()
|
||
dir := os.Getenv("CHINESECLIP_MODEL_DIR")
|
||
if dir == "" {
|
||
t.Skip("未设置 CHINESECLIP_MODEL_DIR,跳过需要真实产物的用例")
|
||
}
|
||
if _, err := os.Stat(filepath.Join(dir, "embed_config.json")); err != nil {
|
||
t.Skipf("模型目录 %s 缺 embed_config.json: %v", dir, err)
|
||
}
|
||
return dir
|
||
}
|
||
|
||
// 分词器必须与官方 Chinese-CLIP 逐 token 一致。
|
||
//
|
||
// 这条测试是有来历的:第一版探针自己拼 BertTokenizer(只给 vocab.txt、没删音标、
|
||
// 中文没逐字切),中文全被切成 [UNK],三个不同句子产出几乎相同的向量
|
||
// (余弦 0.98)——差点把「模型坏了」当成结论。分词不一致会静默毁掉整个向量空间。
|
||
func TestTokenizerMatchesOfficialReference(t *testing.T) {
|
||
dir := modelDir(t)
|
||
cfg, err := loadConfig(dir)
|
||
if err != nil {
|
||
t.Fatalf("读取 embed_config.json: %v", err)
|
||
}
|
||
tok, err := LoadTokenizer(dir, cfg.MaxLength)
|
||
if err != nil {
|
||
t.Fatalf("加载分词器: %v", err)
|
||
}
|
||
ref := loadReference(t, dir)
|
||
if len(ref.Texts) == 0 {
|
||
t.Fatal("参考里没有文本用例")
|
||
}
|
||
for _, c := range ref.Texts {
|
||
ids, mask := tok.Encode(c.Text)
|
||
if !reflect.DeepEqual(ids, c.InputIDs) {
|
||
t.Errorf("input_ids 不一致 %q\n got %v\n want %v", c.Text, ids, c.InputIDs)
|
||
}
|
||
if !reflect.DeepEqual(mask, c.Attention) {
|
||
t.Errorf("attention_mask 不一致 %q\n got %v\n want %v", c.Text, mask, c.Attention)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 不依赖真实产物的纯逻辑用例:覆盖中文逐字、删音标、标点切分、UNK、补齐。
|
||
func TestTokenizerUnitCases(t *testing.T) {
|
||
dir := t.TempDir()
|
||
vocab := []string{
|
||
"[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]",
|
||
"红", "色", "一", "张", "方", "块", "的", "图", "片",
|
||
"cafe", "hello", "world", "##ive", "na", "a", "-", "b", "(", ")", "括", "号", "12345",
|
||
}
|
||
if err := os.WriteFile(filepath.Join(dir, "vocab.txt"),
|
||
[]byte(joinLines(vocab)), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
tok, err := LoadTokenizer(dir, 8)
|
||
if err != nil {
|
||
t.Fatalf("加载分词器: %v", err)
|
||
}
|
||
|
||
cases := []struct {
|
||
name string
|
||
text string
|
||
want []string // 期望的 token 文本(不含特殊 token,便于阅读)
|
||
}{
|
||
{"中文逐字", "红色", []string{"红", "色"}},
|
||
{"删音标+小写", "CAFÉ", []string{"cafe"}},
|
||
{"删音标词内组合", "naïve", []string{"na", "##ive"}},
|
||
{"标点切分", "a-b", []string{"a", "-", "b"}},
|
||
{"全角括号按标点处理", "(括号)", []string{"(", "括", "号", ")"}},
|
||
{"纯数字", "12345", []string{"12345"}},
|
||
}
|
||
for _, c := range cases {
|
||
got := tok.tokenize(c.text)
|
||
if !reflect.DeepEqual(got, c.want) {
|
||
t.Errorf("%s: %q\n got %v\n want %v", c.name, c.text, got, c.want)
|
||
}
|
||
}
|
||
|
||
// 未登录词整体退化为 [UNK](与 HF 一致)。
|
||
if got := tok.tokenize("zzz"); !reflect.DeepEqual(got, []string{"[UNK]"}) {
|
||
t.Errorf("未登录词应退化为 [UNK],得到 %v", got)
|
||
}
|
||
|
||
// 补齐:maxLength=8,"红色" 只占 2 个位置,其余补 [PAD]。
|
||
ids, mask := tok.Encode("红色")
|
||
if len(ids) != 8 || len(mask) != 8 {
|
||
t.Fatalf("补齐长度应为 8,得到 ids=%d mask=%d", len(ids), len(mask))
|
||
}
|
||
if ids[0] != 2 || ids[1] != 5 || ids[2] != 6 || ids[3] != 3 {
|
||
t.Errorf("应为 [CLS] 红 色 [SEP],得到 %v", ids[:4])
|
||
}
|
||
for i := 4; i < 8; i++ {
|
||
if ids[i] != 0 {
|
||
t.Errorf("位置 %d 应为 [PAD],得到 %v", i, ids)
|
||
}
|
||
}
|
||
wantMask := []int64{1, 1, 1, 1, 0, 0, 0, 0}
|
||
if !reflect.DeepEqual(mask, wantMask) {
|
||
t.Errorf("attention_mask 应为 %v,得到 %v", wantMask, mask)
|
||
}
|
||
|
||
// 截断:9 个汉字在 maxLength=8 下只保留 6 个,正好填满,不应出现 [PAD]。
|
||
truncIDs, truncMask := tok.Encode("红色一张方块的图片")
|
||
if len(truncIDs) != 8 {
|
||
t.Fatalf("截断后长度应为 8,得到 %d", len(truncIDs))
|
||
}
|
||
if truncIDs[0] != 2 || truncIDs[7] != 3 {
|
||
t.Errorf("截断后首尾应为 [CLS]/[SEP],得到 %v", truncIDs)
|
||
}
|
||
for i, id := range truncIDs {
|
||
if id == 0 {
|
||
t.Errorf("截断后位置 %d 不应是 [PAD]: %v", i, truncIDs)
|
||
}
|
||
}
|
||
if !reflect.DeepEqual(truncMask, []int64{1, 1, 1, 1, 1, 1, 1, 1}) {
|
||
t.Errorf("截断后 attention_mask 应全为 1,得到 %v", truncMask)
|
||
}
|
||
}
|
||
|
||
func joinLines(items []string) string {
|
||
out := ""
|
||
for _, item := range items {
|
||
out += item + "\n"
|
||
}
|
||
return out
|
||
}
|