Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

272 lines
7.0 KiB
Go

package embedding
import (
"encoding/json"
"fmt"
"os"
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
)
// wordPieceTokenizer is a minimal, dependency-free implementation of the
// HuggingFace fast-tokenizer trio used by BERT-style vocabularies:
// BertNormalizer → BertPreTokenizer → WordPiece. It loads the standard
// tokenizer.json and reproduces the exact token-ID sequences of the
// reference implementation for that configuration (verified by golden
// tests against the upstream tokenizer).
//
// Scope: only the configuration the bundled static code-embedding model
// uses — BertNormalizer{clean_text, handle_chinese_chars, lowercase,
// strip_accents:null} + BertPreTokenizer + WordPiece{"##"} — is
// implemented. Loading a tokenizer.json with a different model type
// fails loudly rather than mis-tokenizing quietly.
type wordPieceTokenizer struct {
vocab map[string]int
unkID int
contPrefix string
maxWordChars int
lowercase bool
stripAccents bool
cleanText bool
handleCJK bool
}
// tokenizerJSON mirrors just the fields of tokenizer.json this
// implementation consumes.
type tokenizerJSON struct {
Normalizer *struct {
Type string `json:"type"`
CleanText *bool `json:"clean_text"`
HandleCJK *bool `json:"handle_chinese_chars"`
StripAccents *bool `json:"strip_accents"`
Lowercase *bool `json:"lowercase"`
} `json:"normalizer"`
PreTokenizer *struct {
Type string `json:"type"`
} `json:"pre_tokenizer"`
Model struct {
Type string `json:"type"`
UnkToken string `json:"unk_token"`
ContinuingSubwordPrefix string `json:"continuing_subword_prefix"`
MaxInputCharsPerWord *int `json:"max_input_chars_per_word"`
Vocab map[string]int `json:"vocab"`
} `json:"model"`
}
// loadWordPieceTokenizer parses a tokenizer.json from disk.
func loadWordPieceTokenizer(path string) (*wordPieceTokenizer, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read tokenizer: %w", err)
}
var tj tokenizerJSON
if err := json.Unmarshal(raw, &tj); err != nil {
return nil, fmt.Errorf("parse tokenizer: %w", err)
}
if tj.Model.Type != "WordPiece" {
return nil, fmt.Errorf("unsupported tokenizer model %q (want WordPiece)", tj.Model.Type)
}
if len(tj.Model.Vocab) == 0 {
return nil, fmt.Errorf("tokenizer vocab is empty")
}
t := &wordPieceTokenizer{
vocab: tj.Model.Vocab,
contPrefix: tj.Model.ContinuingSubwordPrefix,
maxWordChars: 100,
// BertNormalizer defaults per the reference implementation.
cleanText: true,
handleCJK: true,
}
if t.contPrefix == "" {
t.contPrefix = "##"
}
if tj.Model.MaxInputCharsPerWord != nil && *tj.Model.MaxInputCharsPerWord > 0 {
t.maxWordChars = *tj.Model.MaxInputCharsPerWord
}
unk := tj.Model.UnkToken
if unk == "" {
unk = "[UNK]"
}
id, ok := t.vocab[unk]
if !ok {
return nil, fmt.Errorf("unk token %q not in vocab", unk)
}
t.unkID = id
if n := tj.Normalizer; n != nil {
if n.CleanText != nil {
t.cleanText = *n.CleanText
}
if n.HandleCJK != nil {
t.handleCJK = *n.HandleCJK
}
if n.Lowercase != nil {
t.lowercase = *n.Lowercase
}
// strip_accents:null means "follow lowercase" in the reference
// implementation; an explicit value wins.
if n.StripAccents != nil {
t.stripAccents = *n.StripAccents
} else {
t.stripAccents = t.lowercase
}
}
return t, nil
}
// Encode returns the WordPiece token IDs for text. No special tokens
// are added (the model's post_processor is null).
func (t *wordPieceTokenizer) Encode(text string) []int {
var ids []int
for _, word := range t.preTokenize(t.normalize(text)) {
ids = t.wordPiece(word, ids)
}
return ids
}
// normalize applies BertNormalizer: control-char cleanup, CJK padding,
// accent stripping (NFD, drop Mn), lowercasing.
func (t *wordPieceTokenizer) normalize(s string) string {
var b strings.Builder
b.Grow(len(s) + 8)
for _, r := range s {
if t.cleanText {
if r == 0 || r == 0xFFFD || isBertControl(r) {
continue
}
if isBertWhitespace(r) {
b.WriteByte(' ')
continue
}
}
if t.handleCJK && isCJK(r) {
b.WriteByte(' ')
b.WriteRune(r)
b.WriteByte(' ')
continue
}
b.WriteRune(r)
}
out := b.String()
if t.stripAccents {
decomposed := norm.NFD.String(out)
var sb strings.Builder
sb.Grow(len(decomposed))
for _, r := range decomposed {
if unicode.Is(unicode.Mn, r) {
continue
}
sb.WriteRune(r)
}
out = sb.String()
}
if t.lowercase {
out = strings.ToLower(out)
}
return out
}
// preTokenize applies BertPreTokenizer: split on whitespace, then
// isolate each punctuation rune as its own token.
func (t *wordPieceTokenizer) preTokenize(s string) []string {
var words []string
var cur strings.Builder
flush := func() {
if cur.Len() > 0 {
words = append(words, cur.String())
cur.Reset()
}
}
for _, r := range s {
switch {
case isBertWhitespace(r):
flush()
case isBertPunct(r):
flush()
words = append(words, string(r))
default:
cur.WriteRune(r)
}
}
flush()
return words
}
// wordPiece runs the greedy longest-match-first sub-word split for one
// word, appending IDs to dst.
func (t *wordPieceTokenizer) wordPiece(word string, dst []int) []int {
runes := []rune(word)
if len(runes) > t.maxWordChars {
return append(dst, t.unkID)
}
start := 0
var pieces []int
for start < len(runes) {
end := len(runes)
id := -1
for end > start {
sub := string(runes[start:end])
if start > 0 {
sub = t.contPrefix + sub
}
if v, ok := t.vocab[sub]; ok {
id = v
break
}
end--
}
if id < 0 {
// No piece matched: the whole word becomes UNK, per the
// reference implementation.
return append(dst, t.unkID)
}
pieces = append(pieces, id)
start = end
}
return append(dst, pieces...)
}
// isBertControl mirrors the reference _is_control: category Cc/Cf,
// except tab / newline / carriage-return which count as whitespace.
func isBertControl(r rune) bool {
if r == '\t' || r == '\n' || r == '\r' {
return false
}
return unicode.Is(unicode.Cc, r) || unicode.Is(unicode.Cf, r)
}
// isBertWhitespace mirrors the reference _is_whitespace.
func isBertWhitespace(r rune) bool {
switch r {
case ' ', '\t', '\n', '\r':
return true
}
return unicode.Is(unicode.Zs, r)
}
// isBertPunct mirrors the reference _is_punctuation: the four ASCII
// symbol ranges plus every Unicode P* category rune.
func isBertPunct(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 mirrors the reference _is_chinese_char CJK block test.
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
}