The Big Picture
Step 1 — Entity NER
The first step identifies what things are mentioned in the text. It’s pluggable — you choose which NER backend to use.EntityExtractor ABC
All extractors implement the same interface:- name — the entity’s name as it appears in the text (e.g., “Professor Harmon”)
- type — one of the allowed entity types (e.g., “Person”)
- description — a brief description
- confidence — how confident the model is (0.0 to 1.0)
- spans — character offsets where the entity appears:
{chunk_id: [{start, end}]} - source_chunk_ids — which chunks mention this entity
GLiNERExtractor (Default)
A local transformer model that runs on your machine — no API calls needed.- The model is loaded lazily on first use (thread-safe via
threading.Lock) - Runs inference via
asyncio.to_thread()to avoid blocking the event loop - Text longer than the model’s input limit (
config.max_len, 384 words for the default model) is processed as overlapping windows and merged back into non-overlapping spans, so the tail of a long chunk is not truncated. Short text takes a single-pass fast path. - Returns predictions with character-level spans — more precise than LLM spans
- Entities in the band just below the confidence threshold (default: 25 % below it, e.g. 0.5625–0.75) are labeled
"Unknown"and handed to the step-2 LLM, which re-types or drops them against the text; anything below the band is discarded. Passcandidate_threshold=Noneto disable the band.
threshold=None (the default) selects the measured value for model_name (0.75 for the urchade/gliner_* models, 0.5 for the knowledgator/gliner-bi-* bi-encoders); unknown models fall back to 0.5 with a warning. Invalid configurations raise ValueError: threshold outside [0, 1], candidate_threshold outside [0, threshold], window_tokens not a positive integer, or window_overlap not a non-negative integer — all at construction. window_overlap must also be smaller than the window; that is checked at construction when window_tokens is given explicitly, otherwise on the first inference call, once the window has been derived from the model.
Best for: Production pipelines where you want fast, cheap NER without API costs.
LLMExtractor
Uses your LLM for entity extraction via a structured prompt.NER_PROMPT to the LLM asking it to extract entities with names, types, descriptions, confidence scores, and character offsets. The response is parsed as JSON.
Best for: When you need richer entity descriptions or when GLiNER doesn’t perform well on your domain.
Custom Extractors
SubclassEntityExtractor to plug in any NER backend:
Step 2 — LLM Verify + Relationship Extraction
The second step uses the LLM to do two things at once:- Verify entities — remove false positives from step 1, fix naming errors, and add any entities the NER model missed
- Extract relationships — identify all factual connections between the verified entities
What the LLM Receives
A structured prompt (VERIFY_EXTRACT_RELS_PROMPT) containing:
- The list of entity types
- The pre-extracted entities from step 1 (as JSON)
- The chunk text, with pronouns resolved when coreference is on
What the LLM Returns
A JSON object with two arrays:Metadata Merging
After step 2, GLiNER spans from step 1 are carried forward into the verified entities. GLiNER character offsets are more precise than LLM-generated offsets, so step 1 spans take priority. If the LLM found a new entity that GLiNER missed, the LLM’s own spans are kept.Fallback Behavior
If step 2 fails for a chunk (bad JSON, API error), the pipeline falls back to using step 1 entities without relationships — you still get entities, just no relationships for that chunk.The Ontology — Entity Types
Every extracted entity is mapped to one of the allowed entity types. The SDK ships with 11 default types:"Unknown".
Customizing the Ontology
There are three ways to define entity types, listed by priority: 1. GraphSchema entities (highest priority):entity_types parameter on GraphExtraction:
Relation Types
Relations resolve the same way, with one difference in strictness:Ontology.relations(highest priority). The prompt lists them under Allowed Relationships and says the type MUST be one of them, andIngestionPipelineprunes any edge whoserel_typeis not declared.relation_typesparameter on GraphExtraction. Guidance, not a filter: the prompt lists them under Preferred Relationships and asks the LLM to prefer one, falling back to a descriptiveUPPER_SNAKE_CASElabel when none fits. Off-list edges are kept. Blank labels are rejected.DEFAULT_RELATION_TYPES(lowest priority). A 31-label domain-neutral vocabulary (LOCATED_IN,PART_OF,CONTAINS,MEMBER_OF,EMPLOYED_AT,FOUNDED,CREATED,AUTHORED,BORN_IN,MARRIED_TO,USES, …) used when neither of the above is given. Without it the model invented a fresh label for almost every edge (447 distinct labels against 30 in the benchmark answer key); with it the vocabulary shrinks ~4x and exact triple F1 doubles.
Entity Name Validation
Not every string the NER model produces is a valid entity. The SDK filters names through quality gates:
The full stoplist includes ~50 pronouns and generic references. See
_ENTITY_STOPLIST in entity_extractors.py.
Entity Aggregation
After extraction runs on all chunks, entities are deduplicated across chunks by(normalized_name.lower(), type.lower()):
- What each chunk said about it is kept per chunk: every distinct description, two from the same chunk included. The node gets them joined (see below)
source_chunk_idsare merged (the entity knows every chunk it appeared in)spansare merged (character offsets from every chunk)- Capitalized names are preferred over lowercase
source_chunk_ids = ["chunk_3", "chunk_7", "chunk_12"] and spans from all three chunks. Written to the graph, that is three MENTIONED_IN edges, each holding that chunk’s spans and descriptions; the node holds neither the chunk list nor the spans.
Relationship Aggregation
Relationships are similarly deduplicated by(source.lower(), type.lower(), target.lower()):
- Longer descriptions win
source_chunk_idsare merged- Spans are merged across chunks
How Entities Become Graph Nodes
After aggregation, each entity becomes aGraphNode:
- ID:
compute_entity_id(name, type)— deterministic:"alice__person"(lowercase, spaces replaced with underscores, type-qualified to prevent collisions) - Label: The entity type (e.g.,
Person,Organization) - Properties:
name,typeanddescription— what the document’s chunks that named it said, joined with" | "(withdescriptions, the list, when there is more than one). The store adds these to what the entity already holds from other documents, andfinalize()summarises an entity holding more than 5. Which chunks named it, where in each (spans), and what each said about it go on itsMENTIONED_INedges, one per chunk, not on the node
How Relationships Become Graph Edges
All relationships becomeGraphRelationship objects with type "RELATES":
- Type: Always
"RELATES"— a single unified edge type - Properties:
rel_type— the original relationship type (e.g.,"WORKS_AT")fact— a human-readable fact string:"(Alice, WORKS_AT, Acme Corp): Alice is a senior engineer at Acme Corp"description— the relationship descriptionkeywords— comma-separated terms for fulltext searchweight— confidence (1.0 = explicitly stated, 0.5 = implied)src_name,tgt_name— endpoint entity namessource_chunk_ids— provenancespans— character offsets of the evidence
RELATES type with a rel_type property avoids creating dozens of relationship types in the graph (each needing its own index). The original type is preserved in the rel_type property and is used for display and filtering.
Optional: Coreference Resolution
Coreference resolution replaces pronouns with the entities they refer to, before extraction runs:FastCorefResolver
"biu-nlp/lingmess-coref" (LingMessCoref)
How it works:
- Predict coreference clusters (groups of spans referring to the same entity)
- Find the canonical mention for each cluster (the longest non-pronoun span)
- Replace pronouns with canonical mentions, right-to-left to preserve offsets
- Handles possessives: “her” becomes “Voss’s”
MENTIONED_IN edges are found in the original. RELATES evidence offsets are omitted when coreference rewrites a chunk, because the model gave them for the resolved text.
When to use: When your documents have heavy pronoun usage and extraction is missing entities because of it. Adds ~1-2 seconds per chunk.
Concurrency
- GLiNER/custom extractors: Use
asyncio.Semaphore(max_concurrency or 12)for parallel chunk processing - LLM extractors: Use
llm.abatch_invoke(max_concurrency=...)for batched LLM calls - Step 2 (verify + rels): Always uses
llm.abatch_invoke()regardless of step 1 backend