GlinerNer
Zero-shot named-entity recognition over a Document. The labels are the prompt, and every entity carries the boxes it came from.
- Import
import { GlinerNer } from '@stabrise/scaledp/ner'- Group
- Understand
- Reads
- document
- Writes
- ner
- Needs
@huggingface/transformers
import { Pipeline } from '@stabrise/scaledp'
import { PdfToImage } from '@stabrise/scaledp/pdf'
import { PaddleTextRecognizer } from '@stabrise/scaledp/ocr'
import { GlinerNer } from '@stabrise/scaledp/ner'
const rows = await new Pipeline([
new PdfToImage({ resolution: 300 }),
new PaddleTextRecognizer({ keepFormatting: true }),
new GlinerNer({
inputCol: 'text',
labels: ['person', 'organization', 'email', 'phone', 'address'],
threshold: 0.5,
}),
]).transform(file)
for (const entity of rows[0].ner.entities) {
console.log(entity.entity_group, entity.word, entity.score, entity.boxes)
}580 MB on first run
That is the default gliner-multi-pii model. It downloads once into IndexedDB.
See Models and caching for the alternatives
and their sizes.
Why every model here is fp16
These are DeBERTa-backed GLiNER models, and 8-bit quantization wrecks their score calibration rather than merely blunting it. On one sentence — "Dear John Smith, your account 4111 1111 1111 1111 is due on 3 March 2025." — the default model reads:
| Weights | person | account_number | date |
|---|---|---|---|
| fp32, 1157 MB | 1.00 | 0.99 | 1.00 |
| fp16, 580 MB | 1.00 | 0.99 | 1.00 |
| int8, 349 MB | 0.38 | 0.37 | 0.16 |
The spans are identical in all three; only the scores collapse. Every int8 score
sits under the default threshold of 0.5, so the stage finds the entities and
then filters every one of them out — an empty result rather than an error.
gliner_medium-v2.1 at int8 is worse: fourteen spurious spans, none above 0.05.
fp16 costs roughly 1.7x the download and matches fp32 exactly, so that is what
the catalogue serves. The exception is gliner-pii-edge, which is small enough
to ship at fp32 outright.
Two decoders
span_mode in a model's gliner_config.json decides how its logits are read,
and the stage picks the decoder from it:
markerV0/marker—[batch, words, maxWidth, labels], one score per enumerated (start word, width, label) triple. Every model here but one.token_level—[batch, words, labels, 3]: per word and label, a start score, an end score and an "inside" score. Nothing is enumerated; a span is a start paired with a later end whose whole interior also scores, and it takes the weakest of the three as its own score so a confident start cannot carry an unconfident end.gliner-pii-edgeis built this way.
The difference is visible in the raw scores. On "… on 3 March 2025." the token-level model spikes its start channel to 0.93 on "3", its end channel to 0.93 on "2025", and holds its inside channel above 0.92 across all three words.
Labels are the prompt
GLiNER scores a label by its text, so the wording is part of the query.
'phone' and 'phone_number' are different questions, and a model fine-tuned on
one will score the other lower. Ask for 'medical_condition' and it looks for
one, with no retraining.
import { DEFAULT_PII_LABELS, GLINER2_PII_LABELS } from '@stabrise/scaledp/ner'whiteList filters the results by group after the fact, which is different from
narrowing labels — narrowing the labels changes the inference.
Entities carry boxes
Entity.start/end are character offsets into Document.text, and boxes are
the page boxes those characters fall in. The mapping is built by locating each
box's text in the document text.
A deliberate divergence from Python
Python derives the map from len(box.text) + 1, assuming exactly one separator
per box. That drifts as soon as keepFormatting inserts several spaces or a
newline, shifting every entity's boxes after the first wide gap.
That is what makes redaction work: the boxes are where the entity actually is on the page.
Chunking
Long documents are windowed at chunkLength 500 characters with a chunkStride
of 480, so a 20-character overlap catches entities that straddle a seam.
Duplicates from that overlap are de-duplicated here; Python's sliding window
reports them twice.
normaliseCasing
On by default, and it matters for scans. GLiNER1 models are cased, and a document set entirely in capitals looks unlike anything in training. The rewrite title-cases all-caps runs before inference and is length-preserving, so character offsets stay valid and reported words keep their original casing.
Model registry
import { NER_MODELS, getNerModel, modelSizeBytes } from '@stabrise/scaledp/ner'An id not in the registry is rejected by the constructor. Two of the four models
are private StabRise repos and need configure({ auth }) — see
Private models and auth.
Parameters
| Parameter | Type | Default | Meaning |
|---|---|---|---|
model | 'gliner-multi-pii' | 'gliner-pii-edge' | 'gliner-small' | 'gliner-multi' | 'stabrise-pii-multi' | 'stabrise-pii-multi-g2' | 'gliner-multi-pii' | Registry id. Private repos need configure({ auth }). |
labels | string[] | ['person', 'organization', 'location', 'email', 'phone_number', 'url', 'id', 'account_number', 'zip_code', 'address', 'ip_address', 'date', 'ssn', 'driver_license', 'passport', 'age', 'credit_card', 'medical_condition'] | GLiNER scores a label by its prompt text, so other wording asks a different question. |
threshold | number 0–1 | 0.5 | Minimum score an entity must reach. |
whiteList | string[] | [] | Keep only these entity groups; empty keeps everything. |
normaliseCasing | boolean | true | Title-case runs of capitals first. GLiNER1 models are cased; scans are often all caps. |