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,371 @@
#!/usr/bin/env python3
"""把 Chinese-CLIP ViT-B/16 导出成 HomeAgent 的规范 ONNX 产物。
为什么是 Chinese-CLIP
text+image 的默认向量空间要同时满足「小、可商用、中文原生」。
Chinese-CLIP ViT-B/16 = 188M 参数 / 721MB ONNX / 实测常驻 1.15GB
许可是 Apache-2.0(可随发行版分发),且原生在 2 亿中文图文对上训练。
对比jina-v5-omni-nano 2.23GB 但 CC BY-NC不可商用Qwen3-VL-Emb-2B
9.4GB(质量最好,保留为可选 provider
产物(--out 目录,会被清空重建):
TextEncoder.onnx input_ids[·,52] + attention_mask[·,52] → text_features[·,512]
VisionEncoder.onnx pixel_values[·,3,224,224] → image_features[·,512]
embed_config.json 维度/预处理/分词超参/文件名provider 侧的唯一权威)
vocab.txt 分词器词表(来自官方模型目录)
reference.json 冻结参考:逐文本 token id + 逐样本参考向量Go 侧回归用)
SHA256SUMS
自检纪律(对齐 export_qwen3vl_embedding_onnx.py
1. 每个用例都真跑一次 ONNX 并与 PyTorch 对比,打印逐用例 cos
2. 计划用例集合与实际执行集合必须相等,否则非零退出(防「先跳过再校验」的假通过);
3. 不采信退出码,失败一律非零退出并说明原因。
用法:
scripts/export_chineseclip_onnx.py --out DIR [--model-dir DIR|--model-id REPO]
[--verify-only] [--no-reference] [--skip-verify]
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
import sys
import time
import numpy as np
DEFAULT_MODEL_ID = "OFA-Sys/chinese-clip-vit-base-patch16"
TEXT_FILE = "TextEncoder.onnx"
VISION_FILE = "VisionEncoder.onnx"
CONFIG_FILE = "embed_config.json"
REFERENCE_FILE = "reference.json"
OPSET = 17
MAX_LENGTH = 52
IMAGE_SIZE = 224
# 冻结用例:文本覆盖纯中文/中英混/长文本/标点,图像覆盖纯色与渐变。
FIXTURE_TEXTS = [
"一张红色方块的图片",
"一只猫在草地上",
"蓝色的天空",
"HomeAgent 是一个本地 AI 管家",
"这是一段比较长的中文文本,用来验证分词器在超过五十个 token 时的截断行为是否正确,"
"同时检查标点符号、数字 12345 和英文单词 embedding 的处理。",
]
FIXTURE_IMAGES = [
("red", (220, 30, 30)),
("green", (60, 120, 60)),
("blue", (30, 30, 220)),
("gray", (128, 128, 128)),
]
def solid(color: tuple[int, int, int]) -> np.ndarray:
from PIL import Image
img = Image.new("RGB", (320, 320), color)
return np.asarray(img, dtype=np.uint8)
def gradient() -> np.ndarray:
"""确定性的横向渐变,避免只有纯色导致区分度不足。"""
row = np.linspace(0, 255, 320, dtype=np.uint8)
img = np.zeros((320, 320, 3), dtype=np.uint8)
img[:, :, 0] = row[None, :]
img[:, :, 1] = row[:, None]
img[:, :, 2] = 64
return img
def norm_cos(a: np.ndarray, b: np.ndarray) -> float:
a = a.reshape(-1).astype(np.float64)
b = b.reshape(-1).astype(np.float64)
na, nb = np.linalg.norm(a), np.linalg.norm(b)
if na == 0 or nb == 0:
return 0.0
return float(np.dot(a, b) / (na * nb))
def sha256_file(path: str) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def load_model(model_dir: str | None, model_id: str, local_only: bool):
import torch
from transformers import ChineseCLIPModel, ChineseCLIPProcessor
src = model_dir or model_id
kw = {"local_files_only": True} if local_only else {}
print(f"[load] {src}")
t0 = time.time()
processor = ChineseCLIPProcessor.from_pretrained(src, **kw)
model = ChineseCLIPModel.from_pretrained(src, **kw).eval()
print(f"[load] 用时 {time.time() - t0:.1f}s")
return model, processor
def tower_forward_text(model, input_ids, attention_mask):
import torch
with torch.inference_mode():
out = model.text_model(input_ids=input_ids, attention_mask=attention_mask)
pooled = out.pooler_output
if pooled is None:
pooled = out.last_hidden_state[:, 0]
return model.text_projection(pooled)
def tower_forward_vision(model, pixel_values):
import torch
with torch.inference_mode():
out = model.vision_model(pixel_values=pixel_values)
pooled = out.pooler_output
if pooled is None:
pooled = out.last_hidden_state[:, 0]
return model.visual_projection(pooled)
class TextTowerWrapper:
"""torch.onnx.export 需要 nn.Module这里在函数内构造以避免顶层 import torch。"""
def make_wrappers(model):
import torch
class TextTower(torch.nn.Module):
def __init__(self, m):
super().__init__()
self.m = m
def forward(self, input_ids, attention_mask):
out = self.m.text_model(input_ids=input_ids, attention_mask=attention_mask)
pooled = out.pooler_output
if pooled is None:
pooled = out.last_hidden_state[:, 0]
return self.m.text_projection(pooled)
class VisionTower(torch.nn.Module):
def __init__(self, m):
super().__init__()
self.m = m
def forward(self, pixel_values):
out = self.m.vision_model(pixel_values=pixel_values)
pooled = out.pooler_output
if pooled is None:
pooled = out.last_hidden_state[:, 0]
return self.m.visual_projection(pooled)
return TextTower(model).eval(), VisionTower(model).eval()
def export_onnx(model, processor, out_dir: str) -> None:
import torch
text_tower, vision_tower = make_wrappers(model)
tok = processor.tokenizer
enc = tok(["占位"], padding="max_length", truncation=True,
max_length=MAX_LENGTH, return_tensors="pt")
pixel = torch.zeros(1, 3, IMAGE_SIZE, IMAGE_SIZE, dtype=torch.float32)
print(f"[export] {TEXT_FILE}")
torch.onnx.export(
text_tower,
(enc["input_ids"], enc["attention_mask"]),
os.path.join(out_dir, TEXT_FILE),
input_names=["input_ids", "attention_mask"],
output_names=["text_features"],
dynamic_axes={"input_ids": {0: "batch"}, "attention_mask": {0: "batch"},
"text_features": {0: "batch"}},
opset_version=OPSET,
do_constant_folding=True,
dynamo=False,
)
print(f"[export] {VISION_FILE}")
torch.onnx.export(
vision_tower,
(pixel,),
os.path.join(out_dir, VISION_FILE),
input_names=["pixel_values"],
output_names=["image_features"],
dynamic_axes={"pixel_values": {0: "batch"}, "image_features": {0: "batch"}},
opset_version=OPSET,
do_constant_folding=True,
dynamo=False,
)
def preprocess_images(processor, images: list[np.ndarray]):
"""用官方 processor 做图像预处理,得到与 PyTorch 完全一致的像素张量。"""
from PIL import Image
pil = [Image.fromarray(a) for a in images]
enc = processor(images=pil, return_tensors="pt")
return enc["pixel_values"]
def run_verification(model, processor, out_dir: str, plan: list[str]) -> dict:
"""逐个用例真跑 ONNX 并与 PyTorch 比对;返回参考数据。"""
import onnxruntime as ort
tok = processor.tokenizer
text_sess = ort.InferenceSession(os.path.join(out_dir, TEXT_FILE),
providers=["CPUExecutionProvider"])
vision_sess = ort.InferenceSession(os.path.join(out_dir, VISION_FILE),
providers=["CPUExecutionProvider"])
executed: list[str] = []
reference: dict = {"texts": [], "images": []}
print("\n[verify] 文本塔")
for text in FIXTURE_TEXTS:
enc = tok([text], padding="max_length", truncation=True,
max_length=MAX_LENGTH, return_tensors="pt")
ids = enc["input_ids"].numpy().astype(np.int64)
mask = enc["attention_mask"].numpy().astype(np.int64)
pt = tower_forward_text(model, enc["input_ids"], enc["attention_mask"]).numpy()
ox = text_sess.run(["text_features"], {"input_ids": ids, "attention_mask": mask})[0]
cos = norm_cos(pt, ox)
name = f"text:{text[:24]}"
executed.append(name)
print(f" cos={cos:.9f} ids[:8]={ids[0][:8].tolist()} {text[:28]}")
if cos < 0.9999:
raise SystemExit(f"文本塔导出不一致: {name} cos={cos}")
reference["texts"].append({"text": text, "input_ids": ids[0].tolist(),
"attention_mask": mask[0].tolist(),
"vector": [float(v) for v in ox.reshape(-1)]})
print("\n[verify] 视觉塔")
images = [solid(c) for _, c in FIXTURE_IMAGES] + [gradient()]
names = [n for n, _ in FIXTURE_IMAGES] + ["gradient"]
pixel = preprocess_images(processor, images)
import torch
for i, (nm, _) in enumerate(zip(names, images)):
px = pixel[i:i + 1]
pt = tower_forward_vision(model, px).numpy()
ox = vision_sess.run(["image_features"],
{"pixel_values": px.numpy().astype(np.float32)})[0]
cos = norm_cos(pt, ox)
executed.append(f"image:{nm}")
print(f" cos={cos:.9f} {nm}")
if cos < 0.9999:
raise SystemExit(f"视觉塔导出不一致: {nm} cos={cos}")
# 参考向量直接存像素张量的 sha256Go 侧用同一预处理即可复算
reference["images"].append({
"name": nm,
"pixels_sha256": hashlib.sha256(px.numpy().astype(np.float32).tobytes()).hexdigest(),
"vector": [float(v) for v in ox.reshape(-1)],
})
missing = [c for c in plan if c not in executed]
extra = [c for c in executed if c not in plan]
if missing or extra:
raise SystemExit(f"用例覆盖不一致: 缺 {missing}{extra}")
print(f"\n[verify] 覆盖度 OK{len(executed)} 个用例,计划 {len(plan)}")
return reference
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--out", required=True)
ap.add_argument("--model-dir", default="")
ap.add_argument("--model-id", default=DEFAULT_MODEL_ID)
ap.add_argument("--verify-only", action="store_true")
ap.add_argument("--no-reference", action="store_true")
ap.add_argument("--skip-verify", action="store_true")
args = ap.parse_args()
plan = [f"text:{t[:24]}" for t in FIXTURE_TEXTS] + \
[f"image:{n}" for n, _ in FIXTURE_IMAGES] + ["image:gradient"]
if not args.verify_only:
# 清空重建,避免旧产物被当成这次的成果
if os.path.isdir(args.out):
shutil.rmtree(args.out)
os.makedirs(args.out, exist_ok=True)
model, processor = load_model(args.model_dir or None, args.model_id,
local_only=bool(args.model_dir))
if not os.path.isdir(args.out):
os.makedirs(args.out, exist_ok=True)
if not args.verify_only:
export_onnx(model, processor, args.out)
# 词表随产物一起放provider 只依赖这个目录
src_vocab = os.path.join(args.model_dir, "vocab.txt") if args.model_dir else None
if src_vocab and os.path.exists(src_vocab):
shutil.copy2(src_vocab, os.path.join(args.out, "vocab.txt"))
reference = None
if not args.skip_verify:
reference = run_verification(model, processor, args.out, plan)
else:
print("[verify] 已按 --skip-verify 跳过(不据此宣布成功)")
if not args.verify_only:
cfg = {
"arch": "chinese-clip-vit-base-patch16",
"dim": 512,
"text_onnx": TEXT_FILE,
"vision_onnx": VISION_FILE,
"max_length": MAX_LENGTH,
"image_size": IMAGE_SIZE,
"resample": "bicubic",
"rescale": 1.0 / 255.0,
"image_mean": [0.48145466, 0.4578275, 0.40821073],
"image_std": [0.26862954, 0.26130258, 0.27577711],
"normalize_vector": True, # provider 必须 L2 归一化后再入库
"tokenizer": {
"type": "bert-wordpiece",
"vocab": "vocab.txt",
"do_lower_case": True,
"tokenize_chinese_chars": True,
"cls_id": 101, "sep_id": 102, "pad_id": 0, "unk_id": 100,
},
"modalities": ["text", "image"],
"unsupported_modalities": ["audio", "video"],
"notes": "Chinese-CLIP ViT-B/16视觉 ViT-B/16 + 文本 RoBERTa-wwm-base"
"输出 512 维共享空间。文本塔取 CLSpooler后过 text_projection"
"视觉塔取 CLS 后过 visual_projection两者均未在图中归一化"
"归一化由 provider 负责。",
}
with open(os.path.join(args.out, CONFIG_FILE), "w") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
if reference is not None and not args.no_reference:
reference["source"] = {"model_id": args.model_id,
"model_dir": args.model_dir or "(hub)"}
reference["artifacts"] = {n: sha256_file(os.path.join(args.out, n))
for n in (TEXT_FILE, VISION_FILE)}
with open(os.path.join(args.out, REFERENCE_FILE), "w") as f:
json.dump(reference, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "SHA256SUMS"), "w") as f:
for n in sorted(os.listdir(args.out)):
if n == "SHA256SUMS":
continue
p = os.path.join(args.out, n)
if os.path.isfile(p):
f.write(f"{sha256_file(p)} {n}\n")
print(f"\n[out] {args.out}")
for n in sorted(os.listdir(args.out)):
p = os.path.join(args.out, n)
if os.path.isfile(p):
print(f" {n:<20} {os.path.getsize(p) / 1e6:9.1f} MB")
return 0
if __name__ == "__main__":
sys.exit(main())