Skip to main content
Extraction is the heart of the ingestion pipeline. It takes raw text chunks and turns them into structured knowledge — named entities (people, places, concepts) and the relationships between them (works at, located in, married to). GraphRAG SDK uses a two-step hybrid approach: a fast local NER model finds the entities first, then an LLM verifies them and extracts relationships. This gives you the speed of local models with the reasoning power of LLMs.

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:
Each extracted entity has:
  • 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.
How it works:
  1. The model is loaded lazily on first use (thread-safe via threading.Lock)
  2. Runs inference via asyncio.to_thread() to avoid blocking the event loop
  3. 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.
  4. Returns predictions with character-level spans — more precise than LLM spans
  5. 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. Pass candidate_threshold=None to disable the band.
Thresholds are model-specific: 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.
How it works: Sends a 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

Subclass EntityExtractor to plug in any NER backend:

Step 2 — LLM Verify + Relationship Extraction

The second step uses the LLM to do two things at once:
  1. Verify entities — remove false positives from step 1, fix naming errors, and add any entities the NER model missed
  2. 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 original chunk text

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:
Entities that don’t match any type (or score in the band just below the confidence threshold) are labeled "Unknown".

Customizing the Ontology

There are three ways to define entity types, listed by priority: 1. GraphSchema entities (highest priority):
2. entity_types parameter on GraphExtraction:
3. Defaults (lowest priority):

Relation Types

Relations resolve the same way, with one difference in strictness:
  1. Ontology.relations (highest priority). The prompt lists them under Allowed Relationships and says the type MUST be one of them, and IngestionPipeline prunes any edge whose rel_type is not declared.
  2. relation_types parameter on GraphExtraction. Guidance, not a filter: the prompt lists them under Preferred Relationships and asks the LLM to prefer one, falling back to a descriptive UPPER_SNAKE_CASE label when none fits. Off-list edges are kept. Blank labels are rejected.
  3. 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()):
  • If the same entity appears in multiple chunks, the one with the longer description wins
  • source_chunk_ids are merged (the entity knows every chunk it appeared in)
  • spans are merged (character offsets from every chunk)
  • Capitalized names are preferred over lowercase
Example: “Alice” appears in chunks 3, 7, and 12. After aggregation, there’s one entity with source_chunk_ids = ["chunk_3", "chunk_7", "chunk_12"] and spans from all three chunks.

Relationship Aggregation

Relationships are similarly deduplicated by (source.lower(), type.lower(), target.lower()):
  • Longer descriptions win
  • source_chunk_ids are merged
  • Spans are merged across chunks

How Entities Become Graph Nodes

After aggregation, each entity becomes a GraphNode:
  • 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, description, source_chunk_ids, and optionally spans

How Relationships Become Graph Edges

All relationships become GraphRelationship 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 description
    • keywords — comma-separated terms for fulltext search
    • weight — confidence (1.0 = explicitly stated, 0.5 = implied)
    • src_name, tgt_name — endpoint entity names
    • source_chunk_ids — provenance
    • spans — character offsets of the evidence
Why a single edge type? Using one 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

Model: "biu-nlp/lingmess-coref" (LingMessCoref) How it works:
  1. Predict coreference clusters (groups of spans referring to the same entity)
  2. Find the canonical mention for each cluster (the longest non-pronoun span)
  3. Replace pronouns with canonical mentions, right-to-left to preserve offsets
  4. Handles possessives: “her” becomes “Voss’s”
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

File Reference