mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +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)不进仓库,由导出脚本生成。
292 lines
8.1 KiB
Go
292 lines
8.1 KiB
Go
// Package chineseclip 提供 Chinese-CLIP ViT-B/16 的 text+image 向量空间 provider。
|
||
//
|
||
// 为什么是它(而不是 Qwen3-VL-Embedding-2B / jina-v5-omni-nano):
|
||
// - 体积:721MB ONNX、实测常驻 1.15GB;Qwen 2B 需要 9.4GB,本机可用内存只有 5.3GB。
|
||
// - 许可:Apache-2.0,可随发行版分发;jina-v5-omni-nano 是 CC BY-NC(不可商用)。
|
||
// - 中文:原生在 ~2 亿中文图文对上训练。
|
||
//
|
||
// 代价(明确记录):CLIP 是双塔对比学习,text↔image 是强项,但纯文本语义
|
||
// (text↔text)明显弱于 MLLM 型嵌入器。文本检索仍由既有词向量/TF-IDF 路径兜底,
|
||
// 本空间主要用于跨模态召回与相关性裁剪。需要视频或更强文本语义时应切回
|
||
// providers/qwen3vl(内存允许时)。
|
||
//
|
||
// 模态范围:仅 text 与 image。audio / video 返回 embedding.ErrUnsupportedModality,
|
||
// 绝不用别的模型向量冒充。
|
||
package chineseclip
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"unicode"
|
||
|
||
"golang.org/x/text/unicode/norm"
|
||
)
|
||
|
||
// BERT 的固定特殊 token(与官方 Chinese-CLIP 的 vocab.txt 一致)。
|
||
const (
|
||
tokenCLS = "[CLS]"
|
||
tokenSEP = "[SEP]"
|
||
tokenPAD = "[PAD]"
|
||
tokenUNK = "[UNK]"
|
||
|
||
// maxInputCharsPerWord 与 HF BertTokenizer 一致:超过就整词判 UNK。
|
||
maxInputCharsPerWord = 100
|
||
)
|
||
|
||
// Tokenizer 是 BERT WordPiece 分词器(Chinese-CLIP 官方配置:do_lower_case=true、
|
||
// strip_accents 生效、tokenize_chinese_chars=true)。
|
||
type Tokenizer struct {
|
||
vocab map[string]int32
|
||
maxLength int
|
||
}
|
||
|
||
// LoadTokenizer 从模型目录读取 vocab.txt。目录里那份词表是产物的组成部分,
|
||
// provider 只依赖这个目录,不去猜任何外部路径。
|
||
func LoadTokenizer(dir string, maxLength int) (*Tokenizer, error) {
|
||
path := filepath.Join(dir, "vocab.txt")
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("chineseclip: 读取词表 %s: %w", path, err)
|
||
}
|
||
if maxLength <= 0 {
|
||
return nil, fmt.Errorf("chineseclip: max_length 必须为正,得到 %d", maxLength)
|
||
}
|
||
vocab := make(map[string]int32, 32768)
|
||
for i, line := range strings.Split(string(data), "\n") {
|
||
piece := strings.TrimRight(line, "\r")
|
||
if piece == "" {
|
||
continue
|
||
}
|
||
if _, dup := vocab[piece]; dup {
|
||
// 词表出现重复行说明文件被写坏;不静默用后者覆盖前者。
|
||
return nil, fmt.Errorf("chineseclip: 词表第 %d 行重复: %q", i+1, piece)
|
||
}
|
||
vocab[piece] = int32(len(vocab))
|
||
}
|
||
for _, special := range []string{tokenCLS, tokenSEP, tokenPAD, tokenUNK} {
|
||
if _, ok := vocab[special]; !ok {
|
||
return nil, fmt.Errorf("chineseclip: 词表缺少特殊 token %s", special)
|
||
}
|
||
}
|
||
return &Tokenizer{vocab: vocab, maxLength: maxLength}, nil
|
||
}
|
||
|
||
// MaxLength 返回文本侧的最大 token 数(含特殊 token)。
|
||
func (t *Tokenizer) MaxLength() int { return t.maxLength }
|
||
|
||
// Encode 返回补齐到 maxLength 的 input_ids 与 attention_mask。
|
||
// attention_mask 与官方 tokenizer 的 padding='max_length' 行为一致:真实 token 为 1,
|
||
// padding 为 0。
|
||
func (t *Tokenizer) Encode(text string) ([]int64, []int64) {
|
||
pieces := t.tokenize(text)
|
||
|
||
// 预留 [CLS] 与 [SEP];超长直接截断尾部(官方 truncation=True 的默认方向)。
|
||
if limit := t.maxLength - 2; len(pieces) > limit {
|
||
pieces = pieces[:limit]
|
||
}
|
||
|
||
ids := make([]int64, 0, t.maxLength)
|
||
mask := make([]int64, 0, t.maxLength)
|
||
ids = append(ids, int64(t.vocab[tokenCLS]))
|
||
mask = append(mask, 1)
|
||
for _, p := range pieces {
|
||
ids = append(ids, int64(t.vocab[p]))
|
||
mask = append(mask, 1)
|
||
}
|
||
ids = append(ids, int64(t.vocab[tokenSEP]))
|
||
mask = append(mask, 1)
|
||
|
||
for len(ids) < t.maxLength {
|
||
ids = append(ids, int64(t.vocab[tokenPAD]))
|
||
mask = append(mask, 0)
|
||
}
|
||
return ids, mask
|
||
}
|
||
|
||
// tokenize 复刻 HF BasicTokenizer + WordPieceTokenizer 的完整流水线。
|
||
func (t *Tokenizer) tokenize(text string) []string {
|
||
var pieces []string
|
||
for _, basic := range basicTokenize(text) {
|
||
pieces = append(pieces, t.wordpiece(basic)...)
|
||
}
|
||
return pieces
|
||
}
|
||
|
||
// basicTokenize 实现 BasicTokenizer(空模型版):清洗 → 中文逐字加空格 →
|
||
// 按空白切分 → 删音标 + 转小写 → 按标点再次切分。
|
||
func basicTokenize(text string) []string {
|
||
cleaned := cleanText(text)
|
||
var out []string
|
||
for _, token := range strings.Fields(tokenizeChineseChars(cleaned)) {
|
||
if len([]rune(token)) > maxInputCharsPerWord {
|
||
// 与 HF 一致:超长基本 token 直接丢弃(后续不会产出 UNK)。
|
||
continue
|
||
}
|
||
stripped := stripAccents(strings.ToLower(token))
|
||
out = append(out, splitOnPunctuation(stripped)...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// cleanText 与 HF _clean_text 一致:丢弃 NUL/替换符与控制符,空白统一为空格。
|
||
func cleanText(text string) string {
|
||
var b strings.Builder
|
||
b.Grow(len(text))
|
||
for _, r := range text {
|
||
switch {
|
||
case r == 0 || r == 0xFFFD:
|
||
continue
|
||
case isControl(r):
|
||
continue
|
||
case isBERTWhitespace(r):
|
||
b.WriteRune(' ')
|
||
default:
|
||
b.WriteRune(r)
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// tokenizeChineseChars 在 CJK 字符两侧插入空格,使每个汉字成为独立基本 token。
|
||
func tokenizeChineseChars(text string) string {
|
||
var b strings.Builder
|
||
b.Grow(len(text) + 16)
|
||
for _, r := range text {
|
||
if isCJK(r) {
|
||
b.WriteRune(' ')
|
||
b.WriteRune(r)
|
||
b.WriteRune(' ')
|
||
continue
|
||
}
|
||
b.WriteRune(r)
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// stripAccents 与 HF _run_strip_accents 一致:NFD 分解后丢弃 Mn 组合记号
|
||
// ("café" → "cafe")。
|
||
func stripAccents(text string) string {
|
||
if isASCII(text) {
|
||
return text
|
||
}
|
||
var b strings.Builder
|
||
b.Grow(len(text))
|
||
for _, r := range norm.NFD.String(text) {
|
||
if unicode.Is(unicode.Mn, r) {
|
||
continue
|
||
}
|
||
b.WriteRune(r)
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// splitOnPunctuation 与 HF _run_split_on_punc 一致:标点自成一段。
|
||
//
|
||
// 注意 ASCII 段必须显式列出:'$' '+' '=' '^' '`' '|' '~' 属于 Sc/Sm/Sk,
|
||
// 不是 Unicode P*,但它们也是标点(HF 用的是 ASCII 码点区间)。
|
||
func splitOnPunctuation(text string) []string {
|
||
runes := []rune(text)
|
||
var out []string
|
||
var cur []rune
|
||
flush := func() {
|
||
if len(cur) > 0 {
|
||
out = append(out, string(cur))
|
||
cur = cur[:0]
|
||
}
|
||
}
|
||
for _, r := range runes {
|
||
if isBERTPunctuation(r) {
|
||
flush()
|
||
out = append(out, string(r))
|
||
continue
|
||
}
|
||
cur = append(cur, r)
|
||
}
|
||
flush()
|
||
return out
|
||
}
|
||
|
||
// wordpiece 贪心最长匹配;整词任一段无法匹配则该词整体退化为 [UNK]。
|
||
func (t *Tokenizer) wordpiece(token string) []string {
|
||
runes := []rune(token)
|
||
if len(runes) > maxInputCharsPerWord {
|
||
return []string{tokenUNK}
|
||
}
|
||
var out []string
|
||
start := 0
|
||
for start < len(runes) {
|
||
end := len(runes)
|
||
var cur string
|
||
found := false
|
||
for end > start {
|
||
piece := string(runes[start:end])
|
||
if start > 0 {
|
||
piece = "##" + piece
|
||
}
|
||
if _, ok := t.vocab[piece]; ok {
|
||
cur = piece
|
||
found = true
|
||
break
|
||
}
|
||
end--
|
||
}
|
||
if !found {
|
||
return []string{tokenUNK}
|
||
}
|
||
out = append(out, cur)
|
||
start = end
|
||
}
|
||
return out
|
||
}
|
||
|
||
func isASCII(s string) bool {
|
||
for i := 0; i < len(s); i++ {
|
||
if s[i] >= 0x80 {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// isBERTWhitespace:HF _is_whitespace = 空格/制表/换行/回车 或 Unicode Zs。
|
||
func isBERTWhitespace(r rune) bool {
|
||
if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
|
||
return true
|
||
}
|
||
return unicode.Is(unicode.Zs, r)
|
||
}
|
||
|
||
// isControl:HF _is_control = Cc/Cf,但制表/换行/回车不算。
|
||
func isControl(r rune) bool {
|
||
if r == '\t' || r == '\n' || r == '\r' {
|
||
return false
|
||
}
|
||
return unicode.Is(unicode.Cc, r) || unicode.Is(unicode.Cf, r)
|
||
}
|
||
|
||
// isBERTPunctuation:ASCII 标点区间 或 Unicode P*。
|
||
func isBERTPunctuation(r rune) bool {
|
||
if (r >= 33 && r <= 47) || (r >= 58 && r <= 64) || (r >= 91 && r <= 96) || (r >= 123 && r <= 126) {
|
||
return true
|
||
}
|
||
return unicode.IsPunct(r)
|
||
}
|
||
|
||
// isCJK:HF _tokenize_chinese_chars 使用的区间表。
|
||
func isCJK(r rune) bool {
|
||
switch {
|
||
case r >= 0x4E00 && r <= 0x9FFF,
|
||
r >= 0x3400 && r <= 0x4DBF,
|
||
r >= 0x20000 && r <= 0x2A6DF,
|
||
r >= 0x2A700 && r <= 0x2B73F,
|
||
r >= 0x2B740 && r <= 0x2B81F,
|
||
r >= 0x2B820 && r <= 0x2CEAF,
|
||
r >= 0xF900 && r <= 0xFAFF,
|
||
r >= 0x2F800 && r <= 0x2FA1F:
|
||
return true
|
||
}
|
||
return false
|
||
}
|