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
189 lines
7.4 KiB
Go
189 lines
7.4 KiB
Go
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 µs–ms 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
|
||
}
|