Files
HomeAgent/providers/chineseclip/tokenizer.go
JianFeeeee 3374e7dbd9 docs: 更正三处无实测支撑的性能断言
用户指出现有文档里的性能数字可疑。逐个实测后发现三类问题,都不加改原文地
标注更正(历史条目保留原文,仓内已有此惯例)。

## ① 「崩溃到恢复 <1s」——从未成立

写于 v1.0.0 发版说明。但**当时的退避代码就已是 1s**(查 v1.0.0 tag 的
`procRestartBackoff = time.Second`),首次重启就要等 1s。

实测(新增临时测试测量 scheduleProcRestart 延迟):

    第 1 次崩溃 → 1s      第 2 次 → 2.001s      第 3 次 → 3.002s

顺带纠正我自己刚在站点写错的阈值:并非「崩 3 次停下」。实测第 **4** 次
才停(`procMaxRestarts=3`,判定为 `n > 3`),前 3 次都会重启。

## ② 「RPC 往返 p50 24.1µs」——量级对、数字不符

实测 `BenchmarkToolInvoke`:inline/small **30.4µs**、frame/small 51.5µs、
inline/large 767µs、frame/large 398µs。原文与实测同为几十微秒量级,
但具体值对不上,且未注明测的是哪种 payload。

## ③ 「CLIP 实测常驻 1.15GB」——采样点不对(6 处)

实测加载 chineseclip 两塔,RSS 会**自己降下来**:

    加载前      0.00 GB
    两塔加载后  1.59 GB   ← 峰值
    GC + 静置     0.89 GB   ← 稳态(内核回收未用页)

1.15GB 落在两者之间,既不代表峰值也不代表稳态。线上稳态实测 0.39~0.58GB
(更长时间静置后更低)。同源问题:qwen3vl 的「常驻 9.4GB」实为**峰值**,
其视觉塔本就是按需加载(源码注释:每张图约 1.6GB,故按需)。

6 处全部改为「稳态 X(峰值 Y)」双值,消除口径歧义:README 中英、
docs/zh/multimodal-space.md、config/registry.go(2 处 + 1 处注释)、
providers/chineseclip/tokenizer.go。

## 验证

- `go build`(含 `-tags onnxruntime` 与不带)与 `go vet` 均通过
- 全仓 `grep 1.15GB` 已清零
- 测量用的临时测试文件已删除,无残留
2026-09-20 19:56:49 +08:00

292 lines
8.1 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.

// Package chineseclip 提供 Chinese-CLIP ViT-B/16 的 text+image 向量空间 provider。
//
// 为什么是它(而不是 Qwen3-VL-Embedding-2B / jina-v5-omni-nano):
// - 体积:721MB ONNX、实测稳态约 0.89GB(加载峰值 1.59GB);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
}