Entity Extraction with LLMs: spaCy, GLiNER, and LLM Structured Output Comparison for 2026
TL;DR — Entity extraction with LLMs: spaCy, GLiNER, LLM structured output. KnodeGraph: "Three options: spaCy (~89 F1, 5K tok/s, CPU), GLiNER (~80 F1 zero-shot, 300MB, nested spans), LLM (full context, >95%, higher cost). Production: two-of-three voting — keep entity if any two of spaCy, GLiNER, LLM agree. Use zero-shot until 500 labeled examples, then fine-tune." LLMversus: "Fine-tuned: <$0.10/1K docs, 90-94% F1. LLM: >95%, custom types. Hybrid: $0.50-2/1K docs, 96%+. GLiNER: zero-shot like CLIP for text, 500-2000 docs/min, use for 10+ types or evolving schemas." DeepWiki: "GlinerGraphTransformer: GLiNER + GLiREL in spaCy pipeline, no LLM calls, deterministic, confidence thresholds." GLiNER-Relex: "Joint NER+RE, 18.1% CrossRE vs GPT-5-mini 12.4%, encoder-based faster than LLMs, promising GraphRAG backbone." Learn more with build from documents, knowledge graph basics, GraphRAG tutorial, and function calling.
KnodeGraph frames the landscape: "Named entity recognition is the foundation of every document-based knowledge graph. Get NER wrong and the entire downstream graph inherits the noise. Three production-grade options in 2026 — classical spaCy pipelines, the zero-shot GLiNER family, and prompt-driven LLM extraction."
LLMversus adds: "For standard entities at high volume, use a fine-tuned BERT-class model — under $0.10/1,000 documents and 90-94% F1. For custom domain entities or accuracy above 95%, use LLM with few-shot examples. A hybrid pipeline using the fast model first and escalating ambiguous spans to an LLM costs $0.50-2 per 1,000 documents with 96%+ accuracy."
Entity Extraction Architecture
articles, transcripts
emails, reports
domain-specific text"] end subgraph Methods["Extraction Methods"] spaCy["spaCy
Classical NER
en_core_web_trf
~89 F1 OntoNotes
~5000 tok/s CPU
10K+ docs/min
fixed entity types
needs fine-tuning"] GLiNER["GLiNER
Zero-shot NER
specify types at inference
~80 F1 out-of-domain
300MB model
500-2000 docs/min
nested spans v2.1
like CLIP for text"] LLM["LLM Structured Output
full context awareness
custom entity types
>95% accuracy
handles anaphora
implicit entities
role-based mentions
higher cost per doc"] end subgraph Relations["Relation Extraction"] GLiREL["GLiREL
neural relation extraction
zero-shot relations
no LLM calls
deterministic
depends on GLiNER entities"] LLMGraph["LLMGraphTransformer
LLM-based extraction
flexible prompting
per-token API cost
LangChain integration"] GLiNERRelex["GLiNER-Relex
joint NER + RE
18.1% CrossRE
vs GPT-5-mini 12.4%
encoder-based, fast
GraphRAG backbone"] Custom["Custom LLM Prompt
structured JSON output
schema-constrained
function calling
full control"] end subgraph Hybrid["Hybrid Pipeline (Production)"] Fast["Fast Model
spaCy or GLiNER
high-confidence predictions
<$0.10/1K docs"] Ambiguous["LLM Escalation
ambiguous spans only
few-shot examples
>95% accuracy"] Voting["Two-of-Three Voting
keep entity if any two
of spaCy, GLiNER, LLM agree
best precision-recall"] end subgraph Output["Output"] Entities["Entities
typed spans with offsets
confidence scores
normalized names"] Relations["Relations
typed triplets
Entity→Relation→Entity
confidence scores"] Graph["Knowledge Graph
nodes + edges
upsert to Neo4j/FalkorDB
provenance tracking"] end Text --> spaCy Text --> GLiNER Text --> LLM spaCy --> GLiREL GLiNER --> GLiREL GLiNER --> GLiNERRelex LLM --> LLMGraph LLM --> Custom spaCy --> Fast GLiNER --> Fast LLM --> Ambiguous Fast --> Voting Ambiguous --> Voting Voting --> Entities GLiREL --> Relations LLMGraph --> Relations GLiNERRelex --> Relations Custom --> Relations Entities --> Graph Relations --> Graph style Methods fill:#4169E1,color:#fff style Relations fill:#39FF14,color:#000 style Hybrid fill:#2D1B69,color:#fff style Output fill:#FF6B6B,color:#fff
Method Comparison
| Method | F1 Score | Speed | Cost /1K docs | Entity Types | Best For |
|---|---|---|---|---|---|
| spaCy | ~89 (OntoNotes) | 10K+ docs/min | <$0.10 | Fixed (fine-tuned) | High-volume, standard types |
| GLiNER | ~80 (out-of-domain) | 500-2000 docs/min | <$0.10 | Zero-shot (any) | Evolving schemas, 10+ types |
| LLM | >95% | API-dependent | $1-5 | Custom (prompted) | Custom types, >95% accuracy |
| Hybrid | 96%+ | Variable | $0.50-2 | All types | Production 10K-500K docs/day |
| GLiNER-Relex | 18.1% (CrossRE) | Fast (encoder) | Hardware only | Zero-shot relations | GraphRAG, latency-sensitive |
Implementation
from dataclasses import dataclass
from typing import Optional
from enum import Enum
class ExtractionMethod(Enum):
SPACY = "spacy"
GLINER = "gliner"
LLM = "llm"
HYBRID = "hybrid"
@dataclass
class EntityExtractionGuide:
"""Entity extraction with LLMs implementation guide."""
def get_spacy_pipeline(self) -> str:
"""spaCy NER pipeline."""
return (
"# === SPACY NER ===\n"
"import spacy\n"
"\n"
"# Load transformer model\n"
"nlp = spacy.load(\n"
" 'en_core_web_trf')\n"
"# ~89 F1 on OntoNotes\n"
"# ~5000 tokens/sec on CPU\n"
"# 10K+ docs/min\n"
"\n"
"# Extract entities\n"
"doc = nlp(\n"
" 'Dr. Smith works at '\n"
" 'AI Lab in San Francisco.')\n"
"\n"
"for ent in doc.ents:\n"
" print(\n"
" f'{ent.text} '\n"
" f'{ent.label_} '\n"
" f'{ent.start_char}-'\n"
" f'{ent.end_char}')\n"
"# Dr. Smith PERSON 0-9\n"
"# AI Lab ORG 19-25\n"
"# San Francisco GPE 29-42\n"
"\n"
"# Add custom entity ruler\n"
"ruler = nlp.add_pipe(\n"
" 'entity_ruler',\n"
" before='ner')\n"
"patterns = [\n"
" {'label': 'PRODUCT',\n"
" 'pattern': 'ClimateNet'},\n"
" {'label': 'EVENT',\n"
" 'pattern': [\n"
" {'LOWER': 'ai'}\n"
" {'LOWER': 'conference'}]},\n"
"]\n"
"ruler.add_patterns(patterns)\n"
"\n"
"# Fine-tune for custom types\n"
"# (needs 500+ labeled examples)\n"
"# Use spaCy train CLI\n"
"# or spacy-nlp pipeline"
)
def get_gliner_pipeline(self) -> str:
"""GLiNER zero-shot NER pipeline."""
return (
"# === GLINER ZERO-SHOT NER ===\n"
"from gliner import GLiNER\n"
"\n"
"# Load model (300MB)\n"
"model = GLiNER.from_pretrained(\n"
" 'urchade/gliner_mediumv2.1')\n"
"# ~80 F1 on out-of-domain\n"
"# Specify types at inference\n"
"# Nested spans v2.1\n"
"\n"
"# Define entity types\n"
"labels = [\n"
" 'Person',\n"
" 'Organization',\n"
" 'Location',\n"
" 'Date',\n"
" 'Product',\n"
" 'Event',\n"
" 'Technology',\n"
"]\n"
"\n"
"# Extract entities\n"
"text = (\n"
" 'Dr. Smith works at AI Lab.'\n"
" ' She published ClimateNet.')\n"
"entities = model.predict_entities(\n"
" text, labels,\n"
" threshold=0.5)\n"
"\n"
"for ent in entities:\n"
" print(\n"
" f'{ent[\"text\"]} '\n"
" f'{ent[\"label\"]} '\n"
" f'{ent[\"score\"]:.2f}')\n"
"# Dr. Smith Person 0.92\n"
"# AI Lab Organization 0.85\n"
"# ClimateNet Product 0.78\n"
"\n"
"# Multi-language\n"
"# gliner_multi-v2.1\n"
"# 11 languages, ~75 F1\n"
"\n"
# GLiNER + GLiREL for relations\n"
"# (GlinerGraphTransformer)\n"
"# No LLM API calls needed"
)
def get_llm_extraction(self) -> str:
"""LLM structured output entity extraction."""
return (
"# === LLM STRUCTURED OUTPUT ===\n"
"from openai import OpenAI\n"
"import json\n"
"\n"
"client = OpenAI(\n"
" base_url=\n"
" 'http://localhost:11434/v1',\n"
" api_key='ollama')\n"
"\n"
"schema = {\n"
" 'type': 'object',\n"
" 'properties': {\n"
" 'entities': {\n"
" 'type': 'array',\n"
" 'items': {\n"
" 'type': 'object',\n"
" 'properties': {\n"
" 'name': {\n"
" 'type':'string'},\n"
" 'type': {\n"
" 'type':'string'},\n"
" 'offset': {\n"
" 'type':'integer'},\n"
" },\n"
" 'required':[\n"
" 'name','type'],\n"
" },\n"
" },\n"
" 'relations': {\n"
" 'type': 'array',\n"
" 'items': {\n"
" 'type': 'object',\n"
" 'properties': {\n"
" 'source':{\n"
" 'type':'string'},\n"
" 'target':{\n"
" 'type':'string'},\n"
" 'relation':{\n"
" 'type':'string'},\n"
" },\n"
" 'required':[\n"
" 'source',\n"
" 'target',\n"
" 'relation'],\n"
" },\n"
" },\n"
" },\n"
" 'required': ['entities'],\n"
"}\n"
"\n"
"prompt = '''Extract entities\n"
"and relationships from text.\n"
"\n"
"Entity types: Person,\n"
" Organization, Product, Event\n"
"Relation types: WORKS_AT,\n"
" DEVELOPED, PUBLISHED\n"
"\n"
"Text: {text}\n"
"\n"
"Return JSON matching schema.\n"
"'''\n"
"\n"
"resp = client.chat.completions\n"
" .create(\n"
" model='llama3.2',\n"
" messages=[{\n"
" 'role': 'user',\n"
" 'content': prompt.format(\n"
" text='Dr. Smith works'\n"
" ' at AI Lab. She'\n"
" ' published ClimateNet.')\n"
" }],\n"
" format='json',\n"
" stream=False,\n"
")\n"
"result = json.loads(\n"
" resp.choices[0].message\n"
" .content)\n"
"print(result['entities'])\n"
"# [{'name': 'Dr. Smith',\n"
"# 'type': 'Person'}, ...]\n"
"print(result['relations'])\n"
"# [{'source': 'Dr. Smith',\n"
"# 'target': 'AI Lab',\n"
"# 'relation': 'WORKS_AT'}]"
)
def get_hybrid_pipeline(self) -> str:
"""Hybrid two-of-three voting pipeline."""
return (
"# === HYBRID PIPELINE ===\n"
"# Two-of-three voting:\n"
"# spaCy + GLiNER + LLM\n"
"\n"
"def extract_hybrid(text):\n"
" '''Hybrid entity\n"
" extraction with voting.''' \n"
" \n"
" # 1. Fast models first\n"
" spacy_ents = (\n"
" extract_spacy(text))\n"
" gliner_ents = (\n"
" extract_gliner(text))\n"
" \n"
" # 2. Check agreement\n"
" agreed = []\n"
" ambiguous = []\n"
" \n"
" for ent in spacy_ents:\n"
" in_gliner = any(\n"
" g['text'].lower() ==\n"
" ent.text.lower()\n"
" for g in gliner_ents)\n"
" if in_gliner:\n"
" agreed.append({\n"
" 'text': ent.text,\n"
" 'label': ent.label_,\n"
" 'source':\n"
" 'spacy+gliner',\n"
" 'confidence':\n"
" 'high'})\n"
" else:\n"
" ambiguous.append({\n"
" 'text': ent.text,\n"
" 'label': ent.label_,\n"
" 'source': 'spacy'})\n"
" \n"
" # 3. LLM for ambiguous\n"
" if ambiguous:\n"
" llm_ents = (\n"
" extract_llm(text))\n"
" for amb in ambiguous:\n"
" in_llm = any(\n"
" l['name'].lower() ==\n"
" amb['text'].lower()\n"
" for l in llm_ents)\n"
" if in_llm:\n"
" amb['confidence'] =\n"
" 'medium'\n"
" amb['source'] +=\n"
" '+llm'\n"
" agreed.append(amb)\n"
" \n"
" return agreed\n"
"\n"
"# Usage\n"
"result = extract_hybrid(\n"
" 'Dr. Smith works at AI Lab.')\n"
"# Cost: $0.50-2/1K docs\n"
"# Accuracy: 96%+"
)
def get_gliner_graph_transformer(self) -> str:
"""GlinerGraphTransformer for KG construction."""
return (
"# === GLINER GRAPH TRANSFORMER ===\n"
"# No LLM API calls needed\n"
"# Uses GLiNER + GLiREL\n"
"\n"
"from langchain_experimental.\n"
" graph_transformers import\n"
" GlinerGraphTransformer\n"
"\n"
"transformer = (\n"
" GlinerGraphTransformer(\n"
" allowed_nodes=[\n"
" 'Person',\n"
" 'Organization',\n"
" 'Topic',\n"
" ],\n"
" allowed_relationships=[\n"
" 'WORKS_AT',\n"
" 'MENTIONS',\n"
" 'COLLABORATED_WITH',\n"
" ],\n"
" gliner_model=\n"
" 'urchade/gliner_mediumv2.1',\n"
" glirel_model=\n"
" 'jackboyla/glirel_beta',\n"
" entity_confidence_threshold=0.1,\n"
" relationship_confidence_\n"
" threshold=0.1,\n"
" device='cpu',\n"
" ignore_self_loops=True,\n"
"))\n"
"\n"
"# Convert documents to graph\n"
"graph_docs = (\n"
" transformer\n"
" .convert_to_graph_documents(\n"
" documents))\n"
"# No LLM API calls\n"
"# Deterministic extraction\n"
"# Configurable thresholds\n"
"# spaCy pipeline architecture"
)
def get_pitfalls(self) -> dict:
"""Common pitfalls and solutions."""
return {
"uncalibrated_scores": {
"issue": "Confidence scores not calibrated across models — 0.95 from spaCy != 0.95 from GLiNER",
"fix": "Use relative thresholds per model, not absolute. Calibrate on held-out set.",
},
"tokenization_mismatch": {
"issue": "spaCy offsets are character-based, some tools assume word-based",
"fix": "Always validate offsets when chaining tools. Convert between offset types.",
},
"missing_normalization": {
"issue": "Forgetting to lowercase/strip punctuation before merging entities",
"fix": "Normalize entity text before deduplication and merging.",
},
"ontonotes_bias": {
"issue": "OntoNotes schema biased toward news text — not scientific or legal",
"fix": "Use domain-specific labeled data. Don't rely on OntoNotes F1 for your domain.",
},
"no_eval_set": {
"issue": "Cannot tell if prompt change improved without 100+ labeled examples",
"fix": "Always maintain held-out eval set. Use seqeval for span-level F1 with strict matching.",
},
"nested_entities": {
"issue": "Traditional sequence labeling cannot handle overlapping spans",
"fix": "Use GLiNER (nested spans v2.1), span-based models, or LLM with structured output returning array.",
},
"fine_tuning_threshold": {
"issue": "When to switch from zero-shot to fine-tuned model",
"fix": "Use zero-shot until 500 labeled examples per type. Past 500, fine-tuned spaCy/BERT beats zero-shot on speed-per-dollar.",
},
}
Entity Extraction Checklist
- [ ] Entity extraction is the foundation of knowledge graphs — get NER wrong and downstream graph inherits noise
- [ ] Three production-grade options: spaCy (classical), GLiNER (zero-shot), LLM (structured output)
- [ ] spaCy 3.7 en_core_web_trf: ~89 F1 on OntoNotes, ~5000 tokens/sec on CPU, 10K+ docs/min, no GPU needed
- [ ] spaCy: fixed entity types, needs fine-tuning for new types, fastest for high-volume stable types
- [ ] spaCy: transition-based parser, transformer encoder + CRF tagging layer
- [ ] GLiNER: zero-shot NER, specify entity types at inference without retraining
- [ ] GLiNER: ~80 F1 on out-of-domain types, 300MB model, like CLIP for text
- [ ] GLiNER: 500-2000 docs/min, slower than spaCy but more flexible
- [ ] GLiNER: supports nested/overlapping spans natively as of v2.1
- [ ] GLiNER: gliner_multi-v2.1 covers 11 languages zero-shot at ~75 F1
- [ ] GLiNER: use when 10+ entity types, types change frequently, zero-shot transfer, prototyping
- [ ] LLM structured output: full context awareness, handles anaphora and implicit entities
- [ ] LLM: custom entity types via prompting, >95% accuracy with few-shot examples
- [ ] LLM: sees full context — catches entities mentioned only by role that spaCy/GLiNER miss
- [ ] LLM: higher cost per document ($1-5/1K docs vs <$0.10 for spaCy/GLiNER)
- [ ] Hybrid pipeline: fast model first, LLM for ambiguous spans — $0.50-2/1K docs, 96%+ accuracy
- [ ] Hybrid: two-of-three voting — keep entity if any two of spaCy, GLiNER, LLM agree
- [ ] Hybrid: optimal for production systems processing 10K-500K documents/day
- [ ] Use zero-shot (GLiNER or LLM) until 500 labeled examples per entity type
- [ ] Past 500 examples: fine-tuned spaCy or BERT model usually beats zero-shot on speed-per-dollar
- [ ] Relation extraction: GLiREL (neural, zero-shot, no LLM calls, deterministic)
- [ ] Relation extraction: GLiREL depends on entities from GLiNER — must come after in pipeline
- [ ] Relation extraction: LLMGraphTransformer (LLM-based, flexible prompting, per-token cost)
- [ ] Relation extraction: GLiNER-Relex (joint NER+RE, 18.1% CrossRE vs GPT-5-mini 12.4%)
- [ ] GLiNER-Relex: encoder-based, substantially faster than autoregressive LLMs
- [ ] GLiNER-Relex: promising GraphRAG backbone for latency-sensitive, resource-constrained, privacy-sensitive scenarios
- [ ] GlinerGraphTransformer: GLiNER + GLiREL in spaCy pipeline, no LLM API calls, deterministic
- [ ] GlinerGraphTransformer: configurable confidence thresholds, supports GPU acceleration
- [ ] GlinerGraphTransformer: models urchade/gliner_mediumv2.1, jackboyla/glirel_beta
- [ ] Pitfall: confidence scores not calibrated across models — 0.95 from one != 0.95 from another
- [ ] Pitfall: tokenization mismatch — spaCy offsets character-based, some tools word-based
- [ ] Pitfall: forgetting to lowercase/strip punctuation before merging entities
- [ ] Pitfall: over-fitting to OntoNotes — schema biased toward news, not scientific or legal
- [ ] Pitfall: skipping held-out eval set — need 100+ labeled examples to evaluate changes
- [ ] Pitfall: flat spans for nested entities — use GLiNER, span-based models, or LLM array output
- [ ] Evaluation: span-level F1 with strict matching (boundaries + label must match)
- [ ] Evaluation: use seqeval library (same as CoNLL leaderboard)
- [ ] spaCy: add entity_ruler for custom patterns before NER
- [ ] spaCy: fine-tune with spaCy train CLI for custom entity types
- [ ] LLM: use structured output / JSON format / function calling for schema-constrained extraction
- [ ] LLM: few-shot examples in prompt improve accuracy for custom types
- [ ] Read build from documents for full pipeline
- [ ] Read knowledge graph basics for concepts
- [ ] Read GraphRAG tutorial for GraphRAG integration
- [ ] Read function calling for structured output
- [ ] Test: entity extraction F1 on your domain-specific held-out eval set
- [ ] Test: hybrid voting improves precision-recall over single model
- [ ] Test: confidence thresholds calibrated for your data
- [ ] Test: offset compatibility when chaining extraction tools
- [ ] Test: nested entity extraction with GLiNER v2.1
- [ ] Test: relation extraction quality with GLiREL vs LLM
- [ ] Test: throughput meets your production requirements
- [ ] Document method choice, F1 scores, thresholds, pipeline architecture, cost per 1K docs
FAQ
What are the main approaches to entity extraction with LLMs in 2026?
Three production-grade options: spaCy (classical), GLiNER (zero-shot), and LLM structured output. KnodeGraph: "Three production-grade options in 2026 — classical spaCy pipelines, zero-shot GLiNER family, and prompt-driven LLM extraction. spaCy 3.7 with en_core_web_trf: ~5000 tokens/sec on CPU, ~89 F1 on OntoNotes, no GPU needed. GLiNER: specify entity types at inference time without retraining, ~80 F1 on out-of-domain types, 300MB model. Classical NER and GLiNER both operate sentence-by-sentence — miss anaphora, implicit entities, entities mentioned only by role. LLMs handle these natively because they see full context." LLMversus: "Fine-tuned BERT-class model (spaCy, GLiNER): under $0.10/1,000 docs, 90-94% F1. LLM with few-shot: custom entity types, >95% accuracy. Hybrid: fast model first, LLM for ambiguous, $0.50-2/1,000 docs, 96%+ accuracy." Approaches: (1) spaCy: fast, stable types, fine-tuned, ~89 F1. (2) GLiNER: zero-shot, flexible types, ~80 F1, 300MB. (3) LLM: full context, custom types, >95%, higher cost. (4) Hybrid: two-of-three voting, best precision-recall.
How does GLiNER compare to spaCy for NER?
GLiNER is zero-shot with flexible entity types; spaCy is faster with higher throughput for stable types. KnodeGraph: "spaCy 3.7 en_core_web_trf: ~5000 tokens/sec on CPU, ~89 F1 on OntoNotes, no GPU. GLiNER: ~80 F1 on out-of-domain types, 300MB model, specify types at inference. GLiNER slower than spaCy: 500-2000 docs/min vs 10,000+ for spaCy on CPU. GLiNER supports nested spans natively as of v2.1." LLMversus: "GLiNER performs zero-shot NER by encoding both text and entity type labels, then finding matching spans — like CLIP for text. Use GLiNER when: many entity types (10+), types change frequently, zero-shot transfer to new domains, prototyping. spaCy when: >100K docs/day, latency matters, standard types (PER, ORG, LOC, DATE), budget under $200/month." GLiNER-spacy Comparison: "GLiNER superior flexibility for dynamic entities (ERROR_CODE, PRODUCT) without retraining. spaCy significantly higher throughput and lower latency for stable high-volume pipelines." Comparison: (1) spaCy: 10K+ docs/min, ~89 F1, fixed types, needs training for new types. (2) GLiNER: 500-2000 docs/min, ~80 F1, zero-shot types, 300MB, nested spans. (3) Use spaCy for stable high-volume; GLiNER for evolving schemas.
How do you extract relationships between entities with LLMs?
Use GLiREL for neural relation extraction, LLMGraphTransformer for LLM-based, or custom LLM prompts with structured output. DeepWiki: "GlinerGraphTransformer uses GLiNER for entity extraction and GLiREL for relationship extraction within spaCy pipeline. No LLM API calls, deterministic extraction, configurable confidence thresholds. GLiREL depends on entities from GLiNER — must come after in pipeline. Models: urchade/gliner_mediumv2.1, jackboyla/glirel_beta." GLiNER-Relex Paper: "GLiREL adapted GLiNER approach to relation classification, encoding relation labels alongside text in shared bidirectional transformer. GLiNER-Relex: joint NER and RE, best cross-domain transfer at 18.1% on CrossRE vs GPT-5-mini 12.4%, GLiNER2 4.9%, GLiREL 1.4%. Encoder-based model substantially faster than autoregressive LLMs — promising extraction backbone for GraphRAG in latency-sensitive, resource-constrained, privacy-sensitive scenarios." Neo4j: "Relik for entity linking and relationship extraction integrated with LlamaIndex." Methods: (1) GLiREL: neural, zero-shot relations, no LLM calls, deterministic. (2) LLMGraphTransformer: LLM-based, flexible prompting, per-token cost. (3) Custom LLM prompt: structured JSON output with schema. (4) GLiNER-Relex: joint NER+RE, fastest, cross-domain.
When should you use a hybrid entity extraction pipeline?
Hybrid two-of-three voting is the production standard for best precision-recall. KnodeGraph: "Production pipelines rarely use a single model. Standard pattern is two-of-three voting: keep an entity if any two of (spaCy, GLiNER, LLM) agree. Use zero-shot (GLiNER or LLM) until you have at least 500 manually labeled examples per entity type. Past 500 examples, fine-tuned spaCy or BERT model usually beats zero-shot on speed-per-dollar." LLMversus: "Hybrid approach — fast model for high-confidence predictions, LLM for ambiguous spans — optimal for production systems processing 10K-500K documents/day. Costs $0.50-2 per 1,000 documents with 96%+ accuracy. Use fine-tuned model when: >100K docs/day, latency matters, standard types, budget under $200/month. Use LLM when: custom types without labeled data, >95% accuracy required, zero-shot prototyping." Hybrid: (1) Fast model (spaCy/GLiNER) for high-confidence. (2) LLM for ambiguous spans. (3) Two-of-three voting: spaCy + GLiNER + LLM. (4) Optimal for 10K-500K docs/day. (5) $0.50-2/1K docs, 96%+ accuracy. (6) Switch to fine-tuned after 500 labeled examples per type.
What are common pitfalls in entity extraction and how to avoid them?
Confidence scores not calibrated, tokenization mismatches, OntoNotes bias, skipping eval sets. KnodeGraph: "Pitfalls: (1) Trusting confidence scores blindly — across spaCy, GLiNER, and LLMs, scores not calibrated, 0.95 from one model is not 0.95 from another. (2) Tokenization mismatch — spaCy offsets are character-based, some downstream tools assume word-based, always validate offsets when chaining. (3) Forgetting to lowercase/strip punctuation before merging. (4) Over-fitting to OntoNotes — schema biased toward news text, not scientific or legal. (5) Skipping held-out eval set — cannot tell if prompt change improved without 100+ labeled examples. Standard practice: span-level F1 with strict matching using seqeval." LLMversus: "Traditional sequence labeling cannot handle overlapping spans. For nested entities use GLiNER (designed for nested/overlapping), span-based models, or LLM with structured output schema returning overlapping spans as array." Pitfalls: (1) Uncalibrated confidence scores. (2) Tokenization offset mismatches. (3) Missing normalization before merging. (4) OntoNotes news bias. (5) No held-out eval set. (6) Flat spans for nested entities. (7) Use seqeval for span-level F1 with strict matching.
Want a self-hosted AI company brain that does all of this out of the box?
Book a demo →