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

189 lines
7.4 KiB
Go
Raw Permalink 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 embedding
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"unicode/utf8"
"github.com/gomlx/go-huggingface/tokenizers/api"
"github.com/gomlx/go-huggingface/tokenizers/hftokenizer"
)
const (
// fallbackTokenBudget is the token budget used when a model directory has no
// parseable config.json to read max_position_embeddings from. 512 - 2 = 510,
// matching the classic BERT/MiniLM context window minus the two special tokens.
fallbackTokenBudget = 510
// specialTokenReserve is the number of positions the inference pipeline consumes
// for special tokens ([CLS]/[SEP]) appended after client-side truncation, so the
// truncation budget is max_position_embeddings minus this.
specialTokenReserve = 2
// runeClampBudgetFactor caps the rune-clamp fallback at budget runes. A
// WordPiece token spans at least one rune, so token_count <= rune_count;
// clamping to budget runes therefore guarantees token_count <= budget. A
// looser cap (e.g. 4*budget) would NOT bound the token count and could let a
// token-dense input (CJK, single-char words) overflow the window it is meant
// to protect. Recall loss in this rare fallback is an acceptable price for a
// hard safety bound.
runeClampBudgetFactor = 1
)
// tokenTruncator caps input texts at a model's positional budget before they reach
// the inference pipeline.
//
// The pure-Go Hugot tokenizer path does not honour max_position_embeddings, so a
// text longer than the model's context window reaches inference at full length and
// produces a tensor shape mismatch that aborts the entire vector index. Truncating
// client-side keeps that failure from ever occurring; because a transformer cannot
// attend past its positional budget, cutting the tail is lossless by construction.
//
// A tokenTruncator returned by newTokenTruncator is always safe to use: if the real
// tokenizer could not be loaded, tk is nil and Truncate degrades to a rune clamp
// rather than disabling truncation (and the caller's local backend) entirely.
type tokenTruncator struct {
tk *hftokenizer.Tokenizer // nil ⇒ rune-clamp fallback
budget int // max token count before truncation (max_position_embeddings - 2)
clamp int // rune cap used when tk == nil
}
// newTokenTruncator builds a truncator for the model cached under modelDir. It reads
// <modelDir>/tokenizer.json (the same file hugot loads) and derives the token budget
// from <modelDir>/config.json's max_position_embeddings.
//
// The returned truncator is always non-nil and usable. A non-nil error is
// informational: it reports a degraded state (a fallback budget because config.json
// was missing/unparseable, or rune-clamp-only mode because tokenizer.json could not
// be loaded) so the caller can warn without disabling the provider.
func newTokenTruncator(modelDir string) (*tokenTruncator, error) {
budget, budgetErr := readTokenBudget(modelDir)
t := &tokenTruncator{budget: budget, clamp: budget * runeClampBudgetFactor}
tkBytes, err := os.ReadFile(filepath.Join(modelDir, "tokenizer.json"))
if err != nil {
return t, fmt.Errorf("token truncation degraded to rune clamp: read tokenizer.json: %w", err)
}
tk, err := hftokenizer.NewFromContent(nil, tkBytes)
if err != nil {
return t, fmt.Errorf("token truncation degraded to rune clamp: parse tokenizer.json: %w", err)
}
// Encode without special tokens so every returned span is a real byte span into
// the original text; the two reserved positions cover the [CLS]/[SEP] the pipeline
// adds later. IncludeSpans is required for EncodeWithAnnotations to populate Spans.
if err := tk.With(api.EncodeOptions{IncludeSpans: true, AddSpecialTokens: false}); err != nil {
return t, fmt.Errorf("token truncation degraded to rune clamp: configure tokenizer: %w", err)
}
t.tk = tk
if budgetErr != nil {
return t, fmt.Errorf("using fallback token budget %d: %w", budget, budgetErr)
}
return t, nil
}
// Truncate returns text unchanged when it fits the token budget, otherwise the
// longest prefix that stays within budget, cut on a token (and rune) boundary.
func (t *tokenTruncator) Truncate(text string) string {
if t == nil || t.budget <= 0 || text == "" {
return text
}
// Fast path: a WordPiece/subword token spans at least one rune (true for every
// registered variant — MiniLM/BGE/Jina are all WordPiece), so a rune count
// within budget guarantees the token count is too. Only longer texts pay for the
// extra tokenizer pass; tokenization is µsms while inference dominates. (A
// byte-level-BPE variant, where one rune can yield several tokens, would need
// this invariant revisited — but the exact-tokenize path below is always correct.)
if utf8.RuneCountInString(text) <= t.budget {
return text
}
if t.tk == nil {
return clampRunes(text, t.clamp)
}
enc := t.tk.EncodeWithAnnotations(text)
if len(enc.IDs) <= t.budget {
return text
}
if len(enc.Spans) < t.budget {
// Spans unexpectedly short (tokenizer without span support) — degrade safely.
return clampRunes(text, t.clamp)
}
cut := enc.Spans[t.budget-1].End
// cut must land strictly inside the text: we only reach here with more than
// budget tokens, so the budget-th token ends before the end. cut == len(text)
// means a degenerate (zero-width) later span — fall back to the rune clamp
// rather than returning the full over-budget text.
if cut <= 0 || cut >= len(text) {
return clampRunes(text, t.clamp)
}
// Defensive: token spans already align to rune boundaries, but never hand back a
// string split mid-rune.
for cut < len(text) && !utf8.RuneStart(text[cut]) {
cut--
}
return text[:cut]
}
// TruncateAll applies Truncate to every text. It returns the input slice
// unchanged (no allocation) when nothing needed truncating — the common case on
// this hot path — and otherwise a copy with the over-budget entries shortened.
func (t *tokenTruncator) TruncateAll(texts []string) []string {
if t == nil || t.budget <= 0 {
return texts
}
var out []string
for i, s := range texts {
cut := t.Truncate(s)
if len(cut) == len(s) { // Truncate only ever returns s or a strict prefix.
continue
}
if out == nil {
out = make([]string, len(texts))
copy(out, texts)
}
out[i] = cut
}
if out == nil {
return texts
}
return out
}
// readTokenBudget derives the truncation budget from <modelDir>/config.json's
// max_position_embeddings. It returns the fallback budget with a non-nil error when
// the file is missing, unparseable, or carries an implausible value — never failing.
func readTokenBudget(modelDir string) (int, error) {
raw, err := os.ReadFile(filepath.Join(modelDir, "config.json"))
if err != nil {
return fallbackTokenBudget, fmt.Errorf("read config.json: %w", err)
}
var cfg struct {
MaxPositionEmbeddings int `json:"max_position_embeddings"`
}
if err := json.Unmarshal(raw, &cfg); err != nil {
return fallbackTokenBudget, fmt.Errorf("parse config.json: %w", err)
}
if cfg.MaxPositionEmbeddings <= specialTokenReserve {
return fallbackTokenBudget, fmt.Errorf("config.json max_position_embeddings=%d is implausible", cfg.MaxPositionEmbeddings)
}
return cfg.MaxPositionEmbeddings - specialTokenReserve, nil
}
// clampRunes returns the longest prefix of text with at most maxRunes runes, always
// cutting on a rune boundary.
func clampRunes(text string, maxRunes int) string {
if maxRunes <= 0 {
return ""
}
count := 0
for i := range text {
if count == maxRunes {
return text[:i]
}
count++
}
return text
}