Files
HomeAgent/providers/chineseclip/tokenizer.go
2026-10-27 12:18:27 +08:00

363 lines
12 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"
"unicode/utf8"
"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)) {
// ★ 用 RuneCountInString 而不是 len([]rune(token)):
// 后者为了**数一下长度**就把整个 token 转成 rune 切片 ⇒ 每个 token
// 一次堆分配。而这个循环对每个词都跑,是分词器里最频繁的小动作。
// 两者语义等价(都按 rune 计数,非法 UTF-8 每字节算一个 rune)。
if utf8.RuneCountInString(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 码点区间)。
//
// ★ 性能改动:把「累积 rune slice + 每次 string(cur)」换成
//
// 一趟扫描,段以**字节区间**表示,最后一次 substring。
// pprof 实测(mid_zh)本函数占 alloc_objects 的 33.5%。
//
// ★★ 但**非法 UTF-8 必须与旧实现逐值一致**:旧实现走 `[]rune(text)`,
//
// 会把每个非法字节归一成 U+FFFD(`<60>`,3 字节);而纯字节切片会
// **原样保留坏字节**。差分测试当场抓到这一分歧:
// "\xbc\xef=..." → 旧 ["<22><>" ...] vs 新 ["\xbc\xef" ...]
// 这是**真实缺陷**而非测量噪声:下游把 piece 当分词输入、也可能进日志,
// 保留坏字节会让它进入本不该到达的地方(且 hash/去重会与旧行为不一致)。
//
// ⇒ 正确做法:**逐 rune 扫描**(utf8.DecodeRuneInString 对非法序列返回
//
// (RuneError, 1),与 []rune 同语义),但**不预先把整串转成 rune slice**;
// 对非 ASCII/非法字节的段,用 strings.Builder 写回 RuneError 的 UTF-8,
// 从而与旧实现完全一致,同时省掉「整串 rune slice」那一块分配。
func splitOnPunctuation(text string) []string {
var out []string
var b strings.Builder
b.Grow(len(text))
hasBuf := false
flush := func() {
if hasBuf {
out = append(out, b.String())
b.Reset()
hasBuf = false
}
}
for i := 0; i < len(text); {
r, size := utf8.DecodeRuneInString(text[i:])
if isBERTPunctuation(r) {
flush()
out = append(out, string(r))
i += size
continue
}
// 普通字符:直接写原字节(与原实现 string([]rune) 等价)。
// 非法序列:DecodeRuneInString 返回 RuneError,且 Go 的 []rune 也会
// 产出 RuneError ⇒ 两者一致。
if r == utf8.RuneError && size == 1 {
b.WriteRune(utf8.RuneError)
} else {
b.WriteString(text[i : i+size])
}
hasBuf = true
i += size
}
flush()
return out
}
// wordpiece 贪心最长匹配;整词任一段无法匹配则该词整体退化为 [UNK]。
//
// ★ 先定字节边界,再取一次 substring(而非每个候选都 string(runes[a:b])):
//
// pprof 实测(mid_zh)本函数占 alloc_objects 的 29.5%,是第二大分配源。
// 根因是内层循环**每轮候选都构造一个 string**:
// piece := string(runes[start:end]) // "##"+piece 又是第二次分配
// 而绝大多数候选都是未命中(要慢慢缩短 end),也就是**绝大多数
// 分配都是浪费的**。
// 改为:在原始字符串上按 rune 边界倒着推 end,只对**命中前最后一次**
// 候选做一次 substring。于是每次匹配尝试从「2 次分配」降为 0 次,
// 只有真正命中的那一段才分配。
// 语义严格不变:仍然是最长前缀匹配、仍然对未命中整体退 [UNK]。
func (t *Tokenizer) wordpiece(token string) []string {
// 先建立 rune 边界表(单次分配,比每轮 substring 便宜得多)
if utf8.RuneCountInString(token) > maxInputCharsPerWord {
return []string{tokenUNK}
}
bounds := runeBounds(token)
nr := len(bounds) - 1 // rune 个数
var out []string
start := 0 // rune 下标
for start < nr {
end := nr
found := false
var cur string
for end > start {
// 先查词表(用原串零拷贝切片构造 map key 仍需 string,
// 但 Go 对 map[string] 的短 key 查找有优化,且这里
// 只在**命中**时才真正保留;未命中的候选仍需构造 key)。
word := token[bounds[start]:bounds[end]]
piece := word
if start > 0 {
piece = "##" + word
}
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
}
// runeBounds 返回 token 的 rune 边界字节偏移(长度 = rune 数 + 1)。
//
// 单次分配存边界,避免 wordpiece 内层循环反复切分字符串。
func runeBounds(s string) []int {
b := make([]int, 0, utf8.RuneCountInString(s)+1)
for i := 0; i < len(s); {
b = append(b, i)
_, size := utf8.DecodeRuneInString(s[i:])
i += size
}
b = append(b, len(s))
return b
}
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
}