StagesUnderstand

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)
}
Open in builder

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:

Weightspersonaccount_numberdate
fp32, 1157 MB1.000.991.00
fp16, 580 MB1.000.991.00
int8, 349 MB0.380.370.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-edge is 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

ParameterTypeDefaultMeaning
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 }).
labelsstring[]['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.
thresholdnumber 0–10.5Minimum score an entity must reach.
whiteListstring[][]Keep only these entity groups; empty keeps everything.
normaliseCasingbooleantrueTitle-case runs of capitals first. GLiNER1 models are cased; scans are often all caps.

On this page