@@ -21,6 +21,7 @@ type TokenClassificationPipeline struct {
2121 IDLabelMap map [int ]string
2222 AggregationStrategy string
2323 IgnoreLabels []string
24+ SplitWords bool
2425}
2526type Entity struct {
2627 Entity string
@@ -98,6 +99,14 @@ func WithIgnoreLabels(ignoreLabels []string) backends.PipelineOption[*TokenClass
9899 }
99100}
100101
102+ // WithSplitWords enables word-level alignment like Hugging Face's is_split_into_words.
103+ func WithSplitWords () backends.PipelineOption [* TokenClassificationPipeline ] {
104+ return func (pipeline * TokenClassificationPipeline ) error {
105+ pipeline .SplitWords = true
106+ return nil
107+ }
108+ }
109+
101110// NewTokenClassificationPipeline Initializes a feature extraction pipeline.
102111func NewTokenClassificationPipeline (config backends.PipelineConfig [* TokenClassificationPipeline ], s * options.Options , model * backends.Model ) (* TokenClassificationPipeline , error ) {
103112 defaultPipeline , err := backends .NewBasePipeline (config , s , model )
@@ -179,6 +188,9 @@ func (p *TokenClassificationPipeline) Validate() error {
179188
180189// Preprocess tokenizes the input strings.
181190func (p * TokenClassificationPipeline ) Preprocess (batch * backends.PipelineBatch , inputs []string ) error {
191+ if p .SplitWords {
192+ return fmt .Errorf ("split-words enabled: use RunWords/PreprocessWords for [][]string inputs" )
193+ }
182194 start := time .Now ()
183195 backends .TokenizeInputs (batch , p .Model .Tokenizer , inputs )
184196 atomic .AddUint64 (& p .Model .Tokenizer .TokenizerTimings .NumCalls , 1 )
@@ -187,6 +199,79 @@ func (p *TokenClassificationPipeline) Preprocess(batch *backends.PipelineBatch,
187199 return err
188200}
189201
202+ // PreprocessWords tokenizes pre-split words and maps tokens to word IDs via offsets.
203+ func (p * TokenClassificationPipeline ) PreprocessWords (batch * backends.PipelineBatch , inputs [][]string ) error {
204+ start := time .Now ()
205+ // Join words with single spaces to simulate pretokenized behavior
206+ joined := make ([]string , len (inputs ))
207+ wordBoundaries := make ([][][2 ]uint , len (inputs ))
208+ // local helper to convert non-negative int to uint safely
209+ toUintNonNeg := func (i int ) uint {
210+ if i < 0 {
211+ return 0
212+ }
213+ return uint (i )
214+ }
215+ for i , words := range inputs {
216+ joined [i ] = strings .Join (words , " " )
217+ // compute boundaries in joined string
218+ var boundaries [][2 ]uint
219+ pos := 0
220+ for wIdx , w := range words {
221+ startPos := pos
222+ endPos := pos + len (w )
223+ // clamp to non-negative and convert safely to uint
224+ // ensure non-negative before converting to uint
225+ if startPos < 0 {
226+ startPos = 0
227+ }
228+ if endPos < 0 {
229+ endPos = 0
230+ }
231+ boundaries = append (boundaries , [2 ]uint {toUintNonNeg (startPos ), toUintNonNeg (endPos )})
232+ // add one space after every word except last
233+ if wIdx < len (words )- 1 {
234+ pos = endPos + 1
235+ } else {
236+ pos = endPos
237+ }
238+ }
239+ wordBoundaries [i ] = boundaries
240+ }
241+ backends .TokenizeInputs (batch , p .Model .Tokenizer , joined )
242+ atomic .AddUint64 (& p .Model .Tokenizer .TokenizerTimings .NumCalls , 1 )
243+ atomic .AddUint64 (& p .Model .Tokenizer .TokenizerTimings .TotalNS , safeconv .DurationToU64 (time .Since (start )))
244+
245+ // Map token offsets to word indices
246+ for i := range batch .Input {
247+ input := batch .Input [i ]
248+ boundaries := wordBoundaries [i ]
249+ wordIDs := make ([]int , len (input .Offsets ))
250+ for t := range input .Offsets {
251+ if input .SpecialTokensMask [t ] > 0 {
252+ wordIDs [t ] = - 1
253+ continue
254+ }
255+ tokStart := input .Offsets [t ][0 ]
256+ tokEnd := input .Offsets [t ][1 ]
257+ id := - 1
258+ for w := range boundaries {
259+ b := boundaries [w ]
260+ // assign if token lies within the word span
261+ if tokStart >= b [0 ] && tokEnd <= b [1 ] {
262+ id = w
263+ break
264+ }
265+ }
266+ wordIDs [t ] = id
267+ }
268+ batch .Input [i ].WordIDs = wordIDs
269+ // also set raw to joined string for offsets consistency
270+ batch .Input [i ].Raw = joined [i ]
271+ }
272+ return backends .CreateInputTensors (batch , p .Model , p .Runtime )
273+ }
274+
190275// Forward performs the forward inference of the pipeline.
191276func (p * TokenClassificationPipeline ) Forward (batch * backends.PipelineBatch ) error {
192277 start := time .Now ()
@@ -257,6 +342,7 @@ func (p *TokenClassificationPipeline) GatherPreEntities(input backends.Tokenized
257342 endInd := input .Offsets [j ][1 ]
258343 wordRef := sentence [startInd :endInd ]
259344 isSubword := len (word ) != len (wordRef )
345+ // In split-words mode, grouping will use offsets between tokens rather than IsSubword.
260346 // TODO: check for unknown token here, it's in the config and can be loaded and compared with the token
261347 // in that case set the subword as in the python code
262348 preEntities = append (preEntities , Entity {
@@ -355,15 +441,29 @@ func (p *TokenClassificationPipeline) aggregateWords(entities []Entity) ([]Entit
355441 for _ , entity := range entities {
356442 if len (wordGroup ) == 0 {
357443 wordGroup = []Entity {entity }
358- } else if entity .IsSubword {
359- wordGroup = append (wordGroup , entity )
360- } else {
444+ continue
445+ }
446+ // Default behavior: group by IsSubword boundaries
447+ groupBreak := ! entity .IsSubword
448+ if p .SplitWords {
449+ // In split-words mode, we group by contiguous tokens of the same word boundary since we pretokenized.
450+ // Since preEntities don’t carry word IDs directly, simulate grouping by contiguous offsets:
451+ // break group if there is a gap between previous End and current Start (space) or heuristic non-subword.
452+ // TODO: eventually we should export word IDs from the tokenizer to avoid this heuristic but the rust tokenizer bindings don't expose this yet
453+ // and we also use other tokenizers in go backend.
454+ prev := wordGroup [len (wordGroup )- 1 ]
455+ // if there is a gap in offsets consider it a new word
456+ groupBreak = entity .Start > prev .End
457+ }
458+ if groupBreak {
361459 aggregated , err := p .aggregateWord (wordGroup )
362460 if err != nil {
363461 return nil , err
364462 }
365463 wordEntities = append (wordEntities , aggregated )
366464 wordGroup = []Entity {entity }
465+ } else {
466+ wordGroup = append (wordGroup , entity )
367467 }
368468 }
369469 if len (wordGroup ) > 0 {
@@ -516,3 +616,26 @@ func (p *TokenClassificationPipeline) RunPipeline(inputs []string) (*TokenClassi
516616 runErrors = append (runErrors , postErr )
517617 return result , errors .Join (runErrors ... )
518618}
619+
620+ // RunWords runs the pipeline for pre-split word inputs.
621+ // Each input is a slice of words representing a pretokenized sentence.
622+ // This is particularly useful when the user wants to control tokenization because of special tokens,
623+ // hashtags, or other domain-specific tokenization needs.
624+ func (p * TokenClassificationPipeline ) RunWords (inputs [][]string ) (* TokenClassificationOutput , error ) {
625+ var runErrors []error
626+ batch := backends .NewBatch (len (inputs ))
627+ defer func (* backends.PipelineBatch ) {
628+ runErrors = append (runErrors , batch .Destroy ())
629+ }(batch )
630+ runErrors = append (runErrors , p .PreprocessWords (batch , inputs ))
631+ if e := errors .Join (runErrors ... ); e != nil {
632+ return nil , e
633+ }
634+ runErrors = append (runErrors , p .Forward (batch ))
635+ if e := errors .Join (runErrors ... ); e != nil {
636+ return nil , e
637+ }
638+ result , postErr := p .Postprocess (batch )
639+ runErrors = append (runErrors , postErr )
640+ return result , errors .Join (runErrors ... )
641+ }
0 commit comments