Skip to content

Commit b8727ea

Browse files
feat: Add tokenizer-based text chunking for NER recognizers (#2041)
1 parent 6d60a29 commit b8727ea

14 files changed

Lines changed: 1008 additions & 51 deletions

docs/analyzer/recognizer_registry_provider.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,19 @@ The recognizer list comprises of both the predefined and custom recognizers, for
111111
- `deny_list`: A list of words to detect, in case the recognizer uses a predefined list of words.
112112
- `deny_list_score`: confidence score for a term identified using a deny-list.
113113
- `score_thresholds`: optional score thresholds for this recognizer. Use `default` as the recognizer-wide threshold and entity names for overrides. Note that supplying `analyzer_engine.analyze(score_threshold=...)` bypasses recognizer-level thresholds for that request. The precedence is: Presidio Analyzer analyzer.analyze(score_threshold=...) > an entity specific threshold > a recognizer default threshold (`default`) > the Presidio Analyzer `default_score_threshold`.
114+
- `text_chunker`: configures how long texts are split for NER recognizers (`GLiNERRecognizer`, `HuggingFaceNerRecognizer`). Accepts a dict with `chunker_type` and params. Available types: `character` (default) and `tokenizer` (uses the model's tokenizer for accurate token-based splitting). Example:
115+
116+
```yaml
117+
- name: GLiNERRecognizer
118+
type: predefined
119+
model_name: urchade/gliner_multi_pii-v1
120+
text_chunker:
121+
chunker_type: tokenizer
122+
# max_tokens omitted: auto-derived from the model's tokenizer and
123+
# reduced to reserve room for special tokens ([CLS]/[SEP]). Set it
124+
# explicitly only if you account for those special tokens yourself.
125+
overlap_tokens: 32
126+
```
114127

115128
!!! tip "Configuration Tip: Agglutinative languages (e.g., Korean)"
116129

docs/samples/python/gliner.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,37 @@ results = analyzer_engine.analyze(
7575
print(results)
7676
```
7777

78+
## Text Chunking
79+
80+
By default, GLiNERRecognizer splits long texts into character-based chunks (250 chars, 50 overlap). You can customize this via `text_chunker`:
81+
82+
**From Python:**
83+
84+
```python
85+
from presidio_analyzer.chunkers import CharacterBasedTextChunker
86+
87+
gliner_recognizer = GLiNERRecognizer(
88+
model_name="urchade/gliner_multi_pii-v1",
89+
entity_mapping=entity_mapping,
90+
text_chunker=CharacterBasedTextChunker(chunk_size=400, chunk_overlap=60),
91+
)
92+
```
93+
94+
**From YAML (using tokenizer-based chunking):**
95+
96+
```yaml
97+
- name: GLiNERRecognizer
98+
type: predefined
99+
model_name: urchade/gliner_multi_pii-v1
100+
text_chunker:
101+
chunker_type: tokenizer
102+
overlap_tokens: 32
103+
```
104+
105+
The `tokenizer` chunker uses the model's own tokenizer (resolved automatically at load time) to split text by token count, respecting the model's token limit instead of approximating with character counts.
106+
107+
`max_tokens` is omitted above so it is auto-derived from the model's tokenizer and reduced to reserve room for special tokens (e.g. `[CLS]`/`[SEP]`). Set it explicitly only if you account for those special tokens yourself, otherwise chunks may overflow the model's real input limit and be truncated.
108+
78109
## ONNX Runtime Support
79110

80111
GLiNERRecognizer supports using ONNX Runtime as a backend, which provides better CPU compatibility and can prevent crashes on older CPUs without AVX2 instruction set support (e.g., Intel Sandy Bridge).

presidio-analyzer/presidio_analyzer/chunkers/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@
55
CharacterBasedTextChunker,
66
)
77
from presidio_analyzer.chunkers.text_chunker_provider import TextChunkerProvider
8+
from presidio_analyzer.chunkers.tokenizer_based_text_chunker import (
9+
TokenizerBasedTextChunker,
10+
)
811

912
__all__ = [
1013
"BaseTextChunker",
1114
"TextChunk",
1215
"CharacterBasedTextChunker",
1316
"TextChunkerProvider",
17+
"TokenizerBasedTextChunker",
1418
]
15-

presidio-analyzer/presidio_analyzer/chunkers/text_chunker_provider.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Factory provider for creating text chunkers from configuration."""
22

33
import logging
4-
from typing import Any, Dict, Optional, Type
4+
from typing import Any, Dict, Optional
55

66
from presidio_analyzer.chunkers.base_chunker import BaseTextChunker
77
from presidio_analyzer.chunkers.character_based_text_chunker import (
@@ -10,11 +10,6 @@
1010

1111
logger = logging.getLogger("presidio-analyzer")
1212

13-
# Registry mapping chunker type names to classes
14-
_CHUNKER_REGISTRY: Dict[str, Type[BaseTextChunker]] = {
15-
"character": CharacterBasedTextChunker,
16-
}
17-
1813

1914
class TextChunkerProvider:
2015
"""Create text chunkers from configuration.
@@ -44,17 +39,23 @@ def create_chunker(self) -> BaseTextChunker:
4439
config = self.chunker_configuration.copy()
4540
chunker_type = config.pop("chunker_type", "character")
4641

47-
if chunker_type not in _CHUNKER_REGISTRY:
42+
if chunker_type == "character":
43+
chunker_class = CharacterBasedTextChunker
44+
elif chunker_type == "tokenizer":
45+
from presidio_analyzer.chunkers.tokenizer_based_text_chunker import (
46+
TokenizerBasedTextChunker,
47+
)
48+
49+
chunker_class = TokenizerBasedTextChunker
50+
else:
4851
raise ValueError(
4952
f"Unknown chunker_type '{chunker_type}'. "
50-
f"Available: {list(_CHUNKER_REGISTRY.keys())}"
53+
f"Available: ['character', 'tokenizer']"
5154
)
5255

53-
chunker_class = _CHUNKER_REGISTRY[chunker_type]
5456
try:
5557
return chunker_class(**config)
5658
except TypeError as exc:
5759
raise ValueError(
5860
f"Invalid configuration for chunker_type '{chunker_type}': {config}"
5961
) from exc
60-
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
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

Comments
 (0)