Skip to content

Commit 98d2b6f

Browse files
Address review findings: from_stages() factory, chunking fix, observability, NER error handling
- Replace ExtractionConfig(stages=...) with ExtractionConfig.from_stages() factory method to prevent misuse of mutually exclusive parameters - Move chunking above custom stages early return so it applies to both paths - Add INFO log showing custom pipeline composition - Use Pydantic PrivateAttr for NER model caching instead of fragile hasattr - Add logging and error handling for NER model loading - Simplify from_stages() to call cls() for future-proof attribute inheritance - Add integration tests for custom pipeline path (chunking + schema injection) - Update docs and notebook to use from_stages() API
1 parent 4af8d4c commit 98d2b6f

6 files changed

Lines changed: 187 additions & 28 deletions

File tree

docs-site/src/content/docs/lexical-graph/composable-extraction-pipeline-usage.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,12 @@ builder.add(LLMTopicExtractionStage())
1818
transforms = builder.build()
1919
```
2020

21-
Or pass stages directly to `ExtractionConfig`:
21+
Or use `ExtractionConfig.from_stages()` for a custom composable pipeline:
2222

2323
```python
2424
from graphrag_toolkit.lexical_graph.lexical_graph_index import ExtractionConfig, LexicalGraphIndex
2525

26-
config = ExtractionConfig(
26+
config = ExtractionConfig.from_stages(
2727
stages=[
2828
LLMPropositionStage(),
2929
LLMTopicExtractionStage(),
@@ -94,7 +94,7 @@ schema = ExtractionSchema(
9494
Add the filter after topic extraction:
9595

9696
```python
97-
config = ExtractionConfig(
97+
config = ExtractionConfig.from_stages(
9898
stages=[
9999
LLMPropositionStage(),
100100
LLMTopicExtractionStage(),
@@ -105,7 +105,7 @@ config = ExtractionConfig(
105105

106106
When `strict=False` (the default), the filter stage passes everything through unchanged — useful for soft constraints where you want the schema for prompt guidance but not hard filtering.
107107

108-
When `schema` is provided to `ExtractionConfig`, it is automatically injected into the `LLMTopicExtractionStage` prompt — guiding the LLM to focus on your entity types and relationships during extraction, not just during filtering.
108+
When `schema` is provided to `ExtractionConfig.from_stages()`, it is automatically injected into the `LLMTopicExtractionStage` prompt — guiding the LLM to focus on your entity types and relationships during extraction, not just during filtering.
109109

110110
## Combining NER with LLM Extraction
111111

@@ -119,7 +119,7 @@ from graphrag_toolkit.lexical_graph.indexing.extract import (
119119
EntityMergeStage,
120120
)
121121

122-
config = ExtractionConfig(
122+
config = ExtractionConfig.from_stages(
123123
stages=[
124124
LLMPropositionStage(),
125125
NERExtractionStage(
@@ -164,7 +164,7 @@ from graphrag_toolkit.lexical_graph.indexing.extract import (
164164
LLMTopicExtractionStage,
165165
)
166166

167-
config = ExtractionConfig(
167+
config = ExtractionConfig.from_stages(
168168
stages=[
169169
LocalPropositionStage(), # Runs locally, no LLM cost
170170
LLMTopicExtractionStage(),
@@ -196,7 +196,7 @@ schema = ExtractionSchema(
196196
strict=True,
197197
)
198198

199-
config = ExtractionConfig(
199+
config = ExtractionConfig.from_stages(
200200
stages=[
201201
LLMPropositionStage(),
202202
NERExtractionStage(entity_labels=['Person', 'Organization', 'Technology']),
@@ -243,4 +243,4 @@ The `PipelineBuilder` validates that `input_keys()` are available (from initial
243243

244244
## Backward Compatibility
245245

246-
When `stages` is not provided to `ExtractionConfig`, the existing default pipeline (LLM propositions → topic extraction) is used unchanged. All existing code continues to work without modification.
246+
When `ExtractionConfig.from_stages()` is not used, the existing default pipeline (LLM propositions → topic extraction) is used unchanged. All existing code continues to work without modification.

docs-site/src/content/docs/lexical-graph/composable-extraction-pipeline-validation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ assert config.stages is None
151151
assert config.schema is None
152152

153153
# Custom stages
154-
config = ExtractionConfig(
154+
config = ExtractionConfig.from_stages(
155155
stages=[LLMPropositionStage(), LLMTopicExtractionStage()],
156156
schema=ExtractionSchema(
157157
entity_types={'Person': EntityTypeConfig()},

examples/lexical-graph-local-dev/notebooks/06-Composable-Extraction-Pipeline.ipynb

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,22 +75,22 @@
7575
"outputs": [],
7676
"source": [
7777
"# Pattern A: Default \u2014 same as built-in behavior, but explicit\n",
78-
"default_pipeline = ExtractionConfig(\n",
78+
"default_pipeline = ExtractionConfig.from_stages(\n",
7979
" stages=[\n",
8080
" LLMPropositionStage(),\n",
8181
" LLMTopicExtractionStage(),\n",
8282
" ]\n",
8383
")\n",
8484
"\n",
8585
"# Pattern B: Skip propositions \u2014 extract topics directly from text (faster, cheaper)\n",
86-
"direct_pipeline = ExtractionConfig(\n",
86+
"direct_pipeline = ExtractionConfig.from_stages(\n",
8787
" stages=[\n",
8888
" LLMTopicExtractionStage(use_propositions=False),\n",
8989
" ]\n",
9090
")\n",
9191
"\n",
9292
"# Pattern C: Local propositions \u2014 use a local model instead of LLM (free, no API calls)\n",
93-
"local_pipeline = ExtractionConfig(\n",
93+
"local_pipeline = ExtractionConfig.from_stages(\n",
9494
" stages=[\n",
9595
" LocalPropositionStage(),\n",
9696
" LLMTopicExtractionStage(),\n",
@@ -139,7 +139,7 @@
139139
"print(schema.format_as_prompt_constraint())\n",
140140
"\n",
141141
"# Use it in a pipeline\n",
142-
"extraction_config = ExtractionConfig(\n",
142+
"extraction_config = ExtractionConfig.from_stages(\n",
143143
" stages=[\n",
144144
" LLMPropositionStage(),\n",
145145
" LLMTopicExtractionStage(),\n",
@@ -208,7 +208,7 @@
208208
" strict=True,\n",
209209
")\n",
210210
"\n",
211-
"extraction_config = ExtractionConfig(\n",
211+
"extraction_config = ExtractionConfig.from_stages(\n",
212212
" stages=[\n",
213213
" LLMPropositionStage(), # 1. Break text into propositions\n",
214214
" NERExtractionStage( # 2. Find entities locally (free)\n",
@@ -319,7 +319,7 @@
319319
" strict=True,\n",
320320
")\n",
321321
"\n",
322-
"extraction_config = ExtractionConfig(\n",
322+
"extraction_config = ExtractionConfig.from_stages(\n",
323323
" stages=[\n",
324324
" LLMPropositionStage(),\n",
325325
" NERExtractionStage(\n",

lexical-graph/src/graphrag_toolkit/lexical_graph/indexing/extract/stages/ner_extraction_stage.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
# SPDX-License-Identifier: Apache-2.0
33

44
import logging
5-
from typing import List, Optional, Sequence
5+
from typing import Any, List, Optional, Sequence
66

77
from llama_index.core.schema import BaseNode, TransformComponent
88
from llama_index.core.bridge.pydantic import Field
9+
from pydantic import PrivateAttr
910

1011
from graphrag_toolkit.lexical_graph.indexing.extract.extraction_stage import ExtractionStage
1112

@@ -20,6 +21,7 @@ class NERTransform(TransformComponent):
2021
model_name: str = Field(default='urchade/gliner_base')
2122
entity_labels: List[str] = Field(default_factory=list)
2223
threshold: float = Field(default=0.5)
24+
_model: Any = PrivateAttr(default=None)
2325

2426
def __call__(self, nodes: Sequence[BaseNode], **kwargs) -> Sequence[BaseNode]:
2527
try:
@@ -30,8 +32,16 @@ def __call__(self, nodes: Sequence[BaseNode], **kwargs) -> Sequence[BaseNode]:
3032
'Install it with: pip install gliner'
3133
)
3234

33-
if not hasattr(self, '_model'):
34-
self._model = GLiNER.from_pretrained(self.model_name)
35+
if self._model is None:
36+
logger.info('Loading NER model: %s', self.model_name)
37+
try:
38+
self._model = GLiNER.from_pretrained(self.model_name)
39+
except Exception as e:
40+
raise RuntimeError(
41+
f'Failed to load NER model "{self.model_name}". '
42+
f'Ensure the model name is correct and you have internet access. '
43+
f'Error: {e}'
44+
) from e
3545

3646
for node in nodes:
3747
text = node.get_content()

lexical-graph/src/graphrag_toolkit/lexical_graph/lexical_graph_index.py

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ class ExtractionConfig():
5757
and topic or metadata filtering. It provides flexibility in customizing
5858
the extraction behavior and prompts to tailor it to specific use cases.
5959
60+
For custom composable pipelines, use the ExtractionConfig.from_stages()
61+
class method instead of the constructor.
62+
6063
Attributes:
6164
enable_proposition_extraction (bool): Determines whether proposition
6265
extraction is enabled. Defaults to True.
@@ -88,23 +91,40 @@ def __init__(self,
8891
extract_propositions_prompt_template: Optional[str] = None,
8992
extract_topics_prompt_template: Optional[str] = None,
9093
extraction_filters: Optional[MetadataFiltersType] = None,
91-
extraction_llm: Optional[ExtractionLLMType] = None,
92-
stages: Optional[List[ExtractionStage]] = None,
93-
schema: Optional[ExtractionSchema] = None):
94+
extraction_llm: Optional[ExtractionLLMType] = None):
9495
self.enable_proposition_extraction = enable_proposition_extraction
9596
self.preferred_entity_classifications = preferred_entity_classifications if preferred_entity_classifications is not None else []
9697
self.preferred_topics = preferred_topics if preferred_topics is not None else []
9798
self.infer_entity_classifications = infer_entity_classifications
9899
self.extract_propositions_prompt_template = extract_propositions_prompt_template
99100
self.extract_topics_prompt_template = extract_topics_prompt_template
100101
self.extraction_filters = FilterConfig(extraction_filters)
101-
self.stages = stages
102-
self.schema = schema
102+
self.stages = None
103+
self.schema = None
103104
if extraction_llm is not None:
104105
self.extraction_llm = extraction_llm if isinstance(extraction_llm, LLMCache) else GraphRAGConfig.to_llm(extraction_llm)
105106
else:
106107
self.extraction_llm = None
107108

109+
@classmethod
110+
def from_stages(cls, stages: List['ExtractionStage'], schema: Optional['ExtractionSchema'] = None):
111+
"""Create config with a custom composable extraction pipeline.
112+
113+
When using custom stages, configure extraction behavior (LLM, prompts,
114+
classifications) directly on the stage instances.
115+
116+
Args:
117+
stages: Ordered list of ExtractionStage instances defining the pipeline.
118+
schema: Optional ExtractionSchema for type constraints and filtering.
119+
120+
Returns:
121+
ExtractionConfig configured for custom pipeline execution.
122+
"""
123+
config = cls()
124+
config.stages = stages
125+
config.schema = schema
126+
return config
127+
108128

109129
class BuildConfig():
110130
"""
@@ -346,6 +366,10 @@ def _configure_extraction_pipeline(self, config: IndexingConfig):
346366
pre_processors = []
347367
components = []
348368

369+
if config.chunking:
370+
for c in config.chunking:
371+
components.append(c)
372+
349373
# If custom stages are provided, use PipelineBuilder
350374
if config.extraction.stages:
351375
schema = config.extraction.schema
@@ -355,13 +379,13 @@ def _configure_extraction_pipeline(self, config: IndexingConfig):
355379
if schema and isinstance(stage, LLMTopicExtractionStage) and stage._schema is None:
356380
stage._schema = schema
357381
builder.add(stage)
358-
components = builder.build()
382+
components.extend(builder.build())
383+
logger.info(
384+
'Custom extraction pipeline: %s',
385+
' → '.join(type(s).__name__ for s in config.extraction.stages)
386+
)
359387
return (pre_processors, components)
360388

361-
if config.chunking:
362-
for c in config.chunking:
363-
components.append(c)
364-
365389
if config.extraction.enable_proposition_extraction:
366390
if config.batch_config:
367391
components.append(BatchLLMPropositionExtractorSync(

lexical-graph/tests/unit/test_lexical_graph_index.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
from graphrag_toolkit.lexical_graph import ExtractionConfig
1313
from graphrag_toolkit.lexical_graph.lexical_graph_index import LexicalGraphIndex
1414
from graphrag_toolkit.lexical_graph.utils.llm_cache import LLMCache
15+
from graphrag_toolkit.lexical_graph.indexing.extract import (
16+
ExtractionStage, ExtractionSchema, EntityTypeConfig,
17+
LLMPropositionStage, LLMTopicExtractionStage
18+
)
1519

1620
class TestExtractionConfig:
1721

@@ -46,6 +50,127 @@ def test_extraction_lmm_configured_with_llm_cache_returns_llm_cache(self):
4650

4751
assert extraction_config.extraction_llm == llm_cache
4852

53+
def test_default_config_has_no_stages(self):
54+
config = ExtractionConfig()
55+
assert config.stages is None
56+
assert config.schema is None
57+
58+
def test_from_stages_creates_config_with_stages(self):
59+
stages = [LLMPropositionStage(), LLMTopicExtractionStage()]
60+
config = ExtractionConfig.from_stages(stages=stages)
61+
assert config.stages == stages
62+
assert config.schema is None
63+
assert config.extraction_llm is None
64+
65+
def test_from_stages_with_schema(self):
66+
stages = [LLMPropositionStage(), LLMTopicExtractionStage()]
67+
schema = ExtractionSchema(
68+
entity_types={'Person': EntityTypeConfig(description='A person')},
69+
strict=True,
70+
)
71+
config = ExtractionConfig.from_stages(stages=stages, schema=schema)
72+
assert config.stages == stages
73+
assert config.schema == schema
74+
assert config.schema.strict is True
75+
76+
class TestCustomPipelineIntegration:
77+
78+
def test_from_stages_produces_chunking_plus_stage_transforms(self):
79+
"""Verify custom stages path includes chunking and stage transforms."""
80+
from graphrag_toolkit.lexical_graph.indexing.extract import SchemaFilterStage
81+
from graphrag_toolkit.lexical_graph.lexical_graph_index import IndexingConfig
82+
from llama_index.core.node_parser import SentenceSplitter
83+
84+
schema = ExtractionSchema(
85+
entity_types={'Person': EntityTypeConfig(description='A person')},
86+
strict=True,
87+
)
88+
extraction_config = ExtractionConfig.from_stages(
89+
stages=[LLMPropositionStage(), LLMTopicExtractionStage(), SchemaFilterStage(schema=schema)],
90+
schema=schema,
91+
)
92+
indexing_config = IndexingConfig(extraction=extraction_config)
93+
94+
graph_store = Mock()
95+
vector_store = Mock()
96+
97+
with (
98+
patch(
99+
'graphrag_toolkit.lexical_graph.lexical_graph_index.GraphStoreFactory.for_graph_store',
100+
return_value=graph_store,
101+
),
102+
patch(
103+
'graphrag_toolkit.lexical_graph.lexical_graph_index.MultiTenantGraphStore.wrap',
104+
return_value=graph_store,
105+
),
106+
patch(
107+
'graphrag_toolkit.lexical_graph.lexical_graph_index.VectorStoreFactory.for_vector_store',
108+
return_value=vector_store,
109+
),
110+
patch(
111+
'graphrag_toolkit.lexical_graph.lexical_graph_index.MultiTenantVectorStore.wrap',
112+
return_value=vector_store,
113+
),
114+
patch.object(LexicalGraphIndex, '_configure_extraction_pipeline', return_value=([], [])),
115+
):
116+
index = LexicalGraphIndex(graph_store, vector_store)
117+
118+
# Call the real method directly
119+
(pre_processors, components) = LexicalGraphIndex._configure_extraction_pipeline(index, indexing_config)
120+
121+
# Chunking should be first (default SentenceSplitter)
122+
assert isinstance(components[0], SentenceSplitter)
123+
# 3 stage transforms after chunking
124+
assert len(components) == 4
125+
assert len(pre_processors) == 0
126+
127+
def test_from_stages_schema_injected_into_topic_stage(self):
128+
"""Verify schema is auto-injected into LLMTopicExtractionStage."""
129+
from graphrag_toolkit.lexical_graph.lexical_graph_index import IndexingConfig
130+
131+
schema = ExtractionSchema(
132+
entity_types={'Person': EntityTypeConfig(description='A human being')},
133+
)
134+
topic_stage = LLMTopicExtractionStage()
135+
assert topic_stage._schema is None
136+
137+
extraction_config = ExtractionConfig.from_stages(
138+
stages=[LLMPropositionStage(), topic_stage],
139+
schema=schema,
140+
)
141+
indexing_config = IndexingConfig(extraction=extraction_config)
142+
143+
graph_store = Mock()
144+
vector_store = Mock()
145+
146+
with (
147+
patch(
148+
'graphrag_toolkit.lexical_graph.lexical_graph_index.GraphStoreFactory.for_graph_store',
149+
return_value=graph_store,
150+
),
151+
patch(
152+
'graphrag_toolkit.lexical_graph.lexical_graph_index.MultiTenantGraphStore.wrap',
153+
return_value=graph_store,
154+
),
155+
patch(
156+
'graphrag_toolkit.lexical_graph.lexical_graph_index.VectorStoreFactory.for_vector_store',
157+
return_value=vector_store,
158+
),
159+
patch(
160+
'graphrag_toolkit.lexical_graph.lexical_graph_index.MultiTenantVectorStore.wrap',
161+
return_value=vector_store,
162+
),
163+
patch.object(LexicalGraphIndex, '_configure_extraction_pipeline', return_value=([], [])),
164+
):
165+
index = LexicalGraphIndex(graph_store, vector_store)
166+
167+
# Call the real method directly
168+
LexicalGraphIndex._configure_extraction_pipeline(index, indexing_config)
169+
170+
# Schema should be injected into the topic stage
171+
assert topic_stage._schema == schema
172+
173+
49174
class TestLexicalGraphIndex:
50175

51176
def test_init_invokes_graph_store_init_hook(self):

0 commit comments

Comments
 (0)