|
| 1 | +"""Tokenizer-based text chunker using HuggingFace tokenizers.""" |
| 2 | + |
| 3 | +import logging |
| 4 | +from typing import TYPE_CHECKING, List, Optional, Union |
| 5 | + |
| 6 | +from presidio_analyzer.chunkers.base_chunker import BaseTextChunker, TextChunk |
| 7 | + |
| 8 | +if TYPE_CHECKING: |
| 9 | + from transformers import PreTrainedTokenizerBase |
| 10 | + |
| 11 | +logger = logging.getLogger("presidio-analyzer") |
| 12 | + |
| 13 | +# Fallback when the tokenizer does not expose a finite model_max_length |
| 14 | +_DEFAULT_MAX_TOKENS = 512 |
| 15 | + |
| 16 | + |
| 17 | +class TokenizerBasedTextChunker(BaseTextChunker): |
| 18 | + """Text chunker that splits text based on tokenizer token counts. |
| 19 | +
|
| 20 | + Unlike character-based chunking, this respects the model's actual token |
| 21 | + limit and avoids splitting mid-subword. Chunks are defined by token |
| 22 | + boundaries and mapped back to character offsets. |
| 23 | +
|
| 24 | + Can be configured from YAML via the ``text_chunker`` field:: |
| 25 | +
|
| 26 | + text_chunker: |
| 27 | + chunker_type: tokenizer |
| 28 | + # max_tokens omitted: auto-derived from the model's tokenizer and |
| 29 | + # reduced to reserve room for special tokens ([CLS]/[SEP]). Set it |
| 30 | + # explicitly only if you account for those special tokens yourself. |
| 31 | + overlap_tokens: 32 |
| 32 | +
|
| 33 | + When ``tokenizer`` is omitted, the chunker starts in deferred mode and |
| 34 | + the recognizer resolves it at model-load time using the model's own |
| 35 | + tokenizer (via :meth:`resolve`). |
| 36 | +
|
| 37 | + :param tokenizer: A HuggingFace tokenizer name (str), a loaded |
| 38 | + PreTrainedTokenizer instance, or None for deferred mode. |
| 39 | + :param max_tokens: Maximum number of tokens per chunk. Defaults to the |
| 40 | + tokenizer's model_max_length (falls back to 512 if not set or |
| 41 | + unreasonably large). |
| 42 | + :param overlap_tokens: Number of tokens to overlap between consecutive |
| 43 | + chunks (must be >= 0 and < max_tokens). Defaults to 32. |
| 44 | + """ |
| 45 | + |
| 46 | + def __init__( |
| 47 | + self, |
| 48 | + tokenizer: Optional[Union[str, "PreTrainedTokenizerBase"]] = None, |
| 49 | + max_tokens: Optional[int] = None, |
| 50 | + overlap_tokens: int = 32, |
| 51 | + ): |
| 52 | + if tokenizer is None: |
| 53 | + # Deferred mode: tokenizer will be provided later via resolve(). |
| 54 | + # Store config for now; validation happens in resolve(). |
| 55 | + self.tokenizer = None |
| 56 | + self.max_tokens = max_tokens |
| 57 | + self.overlap_tokens = overlap_tokens |
| 58 | + return |
| 59 | + |
| 60 | + if isinstance(tokenizer, str): |
| 61 | + try: |
| 62 | + from transformers import AutoTokenizer |
| 63 | + except ImportError as e: |
| 64 | + raise ImportError( |
| 65 | + "transformers is required to load a tokenizer by name. " |
| 66 | + "Install it with: pip install transformers" |
| 67 | + ) from e |
| 68 | + tokenizer = AutoTokenizer.from_pretrained(tokenizer) |
| 69 | + |
| 70 | + self._init_with_tokenizer(tokenizer, max_tokens, overlap_tokens) |
| 71 | + |
| 72 | + def _init_with_tokenizer( |
| 73 | + self, |
| 74 | + tokenizer: "PreTrainedTokenizerBase", |
| 75 | + max_tokens: Optional[int], |
| 76 | + overlap_tokens: int, |
| 77 | + ) -> None: |
| 78 | + """Initialize with a loaded tokenizer instance.""" |
| 79 | + self.tokenizer = tokenizer |
| 80 | + |
| 81 | + if not getattr(tokenizer, "is_fast", True): |
| 82 | + raise ValueError( |
| 83 | + "TokenizerBasedTextChunker requires a fast tokenizer " |
| 84 | + "(one that supports return_offsets_mapping). " |
| 85 | + "Use AutoTokenizer.from_pretrained(name, use_fast=True)." |
| 86 | + ) |
| 87 | + |
| 88 | + if max_tokens is None: |
| 89 | + raw = getattr(tokenizer, "model_max_length", _DEFAULT_MAX_TOKENS) |
| 90 | + # Some tokenizers report absurdly large values (e.g. 1e30) |
| 91 | + if raw is None or raw > 1_000_000: |
| 92 | + max_tokens = _DEFAULT_MAX_TOKENS |
| 93 | + else: |
| 94 | + max_tokens = raw |
| 95 | + |
| 96 | + # Reserve space for special tokens ([CLS], [SEP], etc.) that the |
| 97 | + # NER pipeline adds automatically, so chunks don't exceed the |
| 98 | + # model's actual input limit. |
| 99 | + num_special = getattr( |
| 100 | + tokenizer, "num_special_tokens_to_add", lambda pair=False: 0 |
| 101 | + )(pair=False) |
| 102 | + max_tokens = max(1, max_tokens - num_special) |
| 103 | + |
| 104 | + # Clamp overlap if auto-derived max_tokens is smaller than default overlap |
| 105 | + if overlap_tokens >= max_tokens: |
| 106 | + overlap_tokens = max(0, max_tokens - 1) |
| 107 | + logger.warning( |
| 108 | + "overlap_tokens clamped to %d (max_tokens=%d)", |
| 109 | + overlap_tokens, |
| 110 | + max_tokens, |
| 111 | + ) |
| 112 | + |
| 113 | + if max_tokens <= 0: |
| 114 | + raise ValueError("max_tokens must be greater than 0") |
| 115 | + if overlap_tokens < 0 or overlap_tokens >= max_tokens: |
| 116 | + raise ValueError( |
| 117 | + "overlap_tokens must be non-negative and less than max_tokens" |
| 118 | + ) |
| 119 | + |
| 120 | + self.max_tokens = max_tokens |
| 121 | + self.overlap_tokens = overlap_tokens |
| 122 | + |
| 123 | + def resolve( |
| 124 | + self, tokenizer: "PreTrainedTokenizerBase" |
| 125 | + ) -> "TokenizerBasedTextChunker": |
| 126 | + """Resolve a deferred chunker with the model's own tokenizer. |
| 127 | +
|
| 128 | + :param tokenizer: A loaded HuggingFace fast tokenizer. |
| 129 | + :return: self, for convenience. |
| 130 | + """ |
| 131 | + self._init_with_tokenizer(tokenizer, self.max_tokens, self.overlap_tokens) |
| 132 | + return self |
| 133 | + |
| 134 | + @property |
| 135 | + def is_deferred(self) -> bool: |
| 136 | + """Whether this chunker is waiting for a tokenizer.""" |
| 137 | + return self.tokenizer is None |
| 138 | + |
| 139 | + def chunk(self, text: str) -> List[TextChunk]: |
| 140 | + """Split text into token-aligned chunks with character offset tracking. |
| 141 | +
|
| 142 | + :param text: The input text to chunk. |
| 143 | + :return: List of TextChunk objects with text and position information. |
| 144 | + :raises RuntimeError: If tokenizer has not been resolved yet. |
| 145 | + """ |
| 146 | + if self.tokenizer is None: |
| 147 | + raise RuntimeError( |
| 148 | + "TokenizerBasedTextChunker has no tokenizer. " |
| 149 | + "Either pass one at init or call resolve(tokenizer) first." |
| 150 | + ) |
| 151 | + if not text: |
| 152 | + return [] |
| 153 | + |
| 154 | + encoding = self.tokenizer( |
| 155 | + text, |
| 156 | + return_offsets_mapping=True, |
| 157 | + add_special_tokens=False, |
| 158 | + truncation=False, |
| 159 | + ) |
| 160 | + |
| 161 | + offsets = encoding.get("offset_mapping") |
| 162 | + if offsets is None: |
| 163 | + raise ValueError( |
| 164 | + "Tokenizer did not return offset_mapping. " |
| 165 | + "TokenizerBasedTextChunker requires a fast tokenizer " |
| 166 | + "(one that supports return_offsets_mapping)." |
| 167 | + ) |
| 168 | + num_tokens = len(offsets) |
| 169 | + |
| 170 | + logger.debug( |
| 171 | + "Chunking text: length=%d chars, %d tokens, max_tokens=%d, overlap=%d", |
| 172 | + len(text), |
| 173 | + num_tokens, |
| 174 | + self.max_tokens, |
| 175 | + self.overlap_tokens, |
| 176 | + ) |
| 177 | + |
| 178 | + if num_tokens <= self.max_tokens: |
| 179 | + return [TextChunk(text=text, start=0, end=len(text))] |
| 180 | + |
| 181 | + chunks = [] |
| 182 | + step = self.max_tokens - self.overlap_tokens |
| 183 | + start_token = 0 |
| 184 | + |
| 185 | + while start_token < num_tokens: |
| 186 | + end_token = min(start_token + self.max_tokens, num_tokens) |
| 187 | + |
| 188 | + char_start = offsets[start_token][0] |
| 189 | + char_end = offsets[end_token - 1][1] |
| 190 | + |
| 191 | + chunks.append( |
| 192 | + TextChunk( |
| 193 | + text=text[char_start:char_end], start=char_start, end=char_end |
| 194 | + ) |
| 195 | + ) |
| 196 | + |
| 197 | + if end_token >= num_tokens: |
| 198 | + break |
| 199 | + start_token += step |
| 200 | + |
| 201 | + logger.debug("Created %d chunks from text", len(chunks)) |
| 202 | + return chunks |
0 commit comments