Merge pull request #1042 from Accute9/spacy-cache-split-chunking

perf(split): avoid repeated spaCy model loading in split/chunking paths
This commit is contained in:
Mohd Kaif
2026-08-17 14:44:44 +05:30
committed by GitHub
7 changed files with 377 additions and 22 deletions
+8
View File
@@ -73,6 +73,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **`split`/chunking paths bypassed the centralized spaCy model cache, reloading the model on every call** (#1042, closes #998) by @Accute9, reviewed by @Sameer6305
- `semantica/split/methods.py`'s `split_by_sentences()` and `semantica/split/semantic_chunker.py`'s `SemanticChunker.__init__` each called `spacy.load()` directly instead of reusing the process-level cache added in #889/`semantic_extract/methods.py`'s `load_spacy_model()` — every call/construction re-paid the ~120ms model-load cost independently of `NERExtractor`, which already used the cache
- Both now route through `load_spacy_model()`, sharing one cached `Language` instance per model name across `split_by_sentences()`, `SemanticChunker`, and `NERExtractor`; a missing model still falls back to regex/paragraph chunking without poisoning the cache for a later successful load
- **Fixed during review** (@Sameer6305): `NERExtractor.__init__()` still had a direct `spacy.load()` call site with the same cache-bypass issue, outside the two files named in #998 but sharing the same root cause; routed through the cache alongside stale test patch targets and a strengthened cache-configuration assertion
- **Fixed during review** (@KaifAhmad1): `SemanticChunker.__init__` only caught `OSError` around `load_spacy_model()`, while the sibling fix to `NERExtractor` in this same PR added a broader `except Exception` for a model that is installed but fails at runtime (e.g. a config incompatible with the installed spaCy version). A broken-but-present model crashed `SemanticChunker()` outright instead of degrading to fallback chunking like every other path in this PR. Added the matching `except Exception` branch, leaving `self.nlp` as `None`; new `test_semantic_chunker_falls_back_when_spacy_runtime_is_broken` mirrors the existing `NERExtractor` regression test for the same scenario
- New `tests/split/test_spacy_model_cache.py`: cache reuse across repeated calls/instances, shared cache between `split_by_sentences()`/`SemanticChunker`/`NERExtractor`, distinct model names loading separately, missing-model fallback without poisoning the cache, and the broken-runtime fallback added above
- `pytest tests/split/test_spacy_model_cache.py tests/split/test_splitter.py tests/split/test_chunkers.py`: all passing (3 pre-existing, unrelated `tests/test_ner_configurations.py` failures confirmed present on `main` before this PR)
- **`export_yaml` raised a raw `AttributeError` on list input, silently wrote empty exports for unrecognized dict keys, and graph payloads were reconciled differently by every exporter** (#958, closes #956, #952, #953) by @pravit-amp, reviewed by @Sameer6305
- Graph payloads circulate under two vocabularies, `entities`/`relationships` and `nodes`/`edges`, and each exporter reconciled them locally with a different idiom — `LPGExporter` in particular dropped every entity whenever `nodes` was present but empty, the exact shape `JSONExporter` emits. A new `normalize_graph_payload()` in `utils/helpers.py` centralizes that decision once, adopted by `LPGExporter`, `ArangoAQLExporter`, `Neo4jCSVExporter`, and both YAML exporters; `ContextGraph.to_dict()` now round-trips through YAML correctly as a result
- `export_yaml(records, path)` on a bare list previously failed with `AttributeError` from inside the exporter; it and the other YAML methods now reject non-mapping input with an actionable `ProcessingError` naming the expected keys, since these formats distinguish entities/relationships/triplets and guessing which one a list represents would mislabel the records
+6 -1
View File
@@ -144,7 +144,12 @@ class NERExtractor:
self._ml_runtime_usable = True
if "ml" in self.method and SPACY_AVAILABLE:
try:
self.nlp = spacy.load(self.model_name)
# Deferred import: keeps semantic_extract.methods out of the
# module-level import graph and routes loading through the
# process-level cache so repeated NERExtractor constructions
# never pay the ~120 ms spacy.load() cost more than once.
from .methods import load_spacy_model
self.nlp = load_spacy_model(self.model_name)
except OSError:
self.logger.warning(
f"spaCy model {self.model_name} not found. ML method will fallback."
+3 -2
View File
@@ -97,7 +97,7 @@ from .semantic_chunker import Chunk
logger = get_logger("split_methods")
# Try to import optional dependencies
spacy, SPACY_AVAILABLE = safe_import("spacy")
_, SPACY_AVAILABLE = safe_import("spacy")
nltk, NLTK_AVAILABLE = safe_import("nltk")
tiktoken, TIKTOKEN_AVAILABLE = safe_import("tiktoken")
@@ -336,7 +336,8 @@ def split_by_sentences(
# Try spaCy first
if SPACY_AVAILABLE and kwargs.get("use_spacy", True):
try:
nlp = spacy.load("en_core_web_sm")
from ..semantic_extract.methods import load_spacy_model
nlp = load_spacy_model("en_core_web_sm")
doc = nlp(text)
sentences = [sent.text for sent in doc.sents]
except Exception:
+11 -2
View File
@@ -36,7 +36,8 @@ from ..utils.helpers import safe_import
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
spacy, SPACY_AVAILABLE = safe_import("spacy")
_, SPACY_AVAILABLE = safe_import("spacy")
@dataclass
@@ -79,11 +80,19 @@ class SemanticChunker:
if SPACY_AVAILABLE:
model_name = config.get("model", "en_core_web_sm")
try:
self.nlp = spacy.load(model_name)
from ..semantic_extract.methods import load_spacy_model
self.nlp = load_spacy_model(model_name)
except OSError:
self.logger.warning(
f"spaCy model {model_name} not found. Using fallback chunking."
)
except Exception:
self.logger.warning(
"spaCy model %s failed to initialize and will be disabled "
"for this chunker instance. Using fallback chunking.",
model_name,
exc_info=True,
)
def chunk(self, text: str, **options) -> List[Chunk]:
"""
+321
View File
@@ -0,0 +1,321 @@
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from semantica.semantic_extract import methods as se_methods
from semantica.split import methods as split_methods
from semantica.split import semantic_chunker
from semantica.semantic_extract import ner_extractor as ner_extractor_module
from semantica.semantic_extract.ner_extractor import NERExtractor
@pytest.fixture(autouse=True)
def clear_cache():
se_methods.clear_spacy_model_cache()
yield
se_methods.clear_spacy_model_cache()
@pytest.fixture(autouse=True)
def force_spacy_available(monkeypatch):
# split.methods, split.semantic_chunker, and ner_extractor each compute
# their own SPACY_AVAILABLE flag from the real environment at import time;
# force all true so these tests exercise the spaCy branch regardless of
# whether spaCy is actually installed where they run.
monkeypatch.setattr(split_methods, "SPACY_AVAILABLE", True)
monkeypatch.setattr(semantic_chunker, "SPACY_AVAILABLE", True)
monkeypatch.setattr(ner_extractor_module, "SPACY_AVAILABLE", True)
def _fake_spacy(load):
return SimpleNamespace(
load=load,
util=SimpleNamespace(is_package=lambda _name: True),
)
def _nlp_mock(sentences=("Hello world.",)):
"""A stand-in spaCy Language object: callable, returns a doc with .sents."""
nlp = MagicMock()
nlp.return_value = SimpleNamespace(
sents=[SimpleNamespace(text=s) for s in sentences]
)
return nlp
class TestSpacyModelCache:
"""split.methods and split.semantic_chunker must share the cached model
defined in semantic_extract.methods instead of each calling spacy.load()
independently.
"""
def test_split_by_sentences_reuses_cached_model(self, monkeypatch):
calls = []
def fake_load(name, **kwargs):
calls.append((name, kwargs))
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
split_methods.split_by_sentences("Hello world. Bye world.")
split_methods.split_by_sentences("Another sentence here.")
split_methods.split_by_sentences("A third call.")
assert len(calls) == 1, "spacy.load should run once, not once per call"
assert calls[0][0] == "en_core_web_sm"
def test_semantic_chunker_reuses_cached_model_across_instances(self, monkeypatch):
calls = []
def fake_load(name, **kwargs):
calls.append((name, kwargs))
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
chunker1 = semantic_chunker.SemanticChunker()
chunker2 = semantic_chunker.SemanticChunker()
assert len(calls) == 1, "each new SemanticChunker should not reload the model"
assert chunker1.nlp is chunker2.nlp
def test_split_methods_and_semantic_chunker_share_the_cache(self, monkeypatch):
calls = []
def fake_load(name, **kwargs):
calls.append((name, kwargs))
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
split_methods.split_by_sentences("Test sentence for split.methods.")
semantic_chunker.SemanticChunker()
assert len(calls) == 1, (
"split.methods and split.semantic_chunker must share one cached "
"model instead of each loading their own"
)
def test_distinct_model_names_load_separately(self, monkeypatch):
calls = []
def fake_load(name, **kwargs):
calls.append((name, kwargs))
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
sm_chunker = semantic_chunker.SemanticChunker(model="en_core_web_sm")
lg_chunker = semantic_chunker.SemanticChunker(model="en_core_web_lg")
sm_chunker_again = semantic_chunker.SemanticChunker(model="en_core_web_sm")
assert [name for name, _ in calls] == ["en_core_web_sm", "en_core_web_lg"]
assert sm_chunker.nlp is sm_chunker_again.nlp
assert sm_chunker.nlp is not lg_chunker.nlp
def test_no_disable_kwarg_requested(self, monkeypatch):
"""split.methods and split.semantic_chunker both want the full
pipeline (they need .sents, which requires the parser/senter). If
either one later starts requesting a trimmed pipeline (e.g.
disable=["ner"]), the name-only cache key in load_spacy_model would
silently hand back a cached model built for a different config --
this test should catch that the moment it happens.
"""
calls = []
def fake_load(_name, **kwargs):
calls.append(kwargs)
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
split_methods.split_by_sentences("Hello world.")
se_methods.clear_spacy_model_cache()
semantic_chunker.SemanticChunker()
assert len(calls) == 2
assert all(kwargs == {} for kwargs in calls), (
"neither caller should pass any pipeline-configuration kwargs; "
"the name-only cache key in load_spacy_model cannot distinguish "
"models loaded with different component configs"
)
def test_missing_model_falls_back_without_poisoning_cache(self, monkeypatch):
attempts = []
def failing_load(name, **_kwargs):
attempts.append(name)
raise OSError(f"Can't find model '{name}'")
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(failing_load))
# split_by_sentences should fall back to regex splitting, not raise
chunks = split_methods.split_by_sentences("Hello world. Bye world.")
assert chunks, "fallback splitting should still produce chunks"
# SemanticChunker should leave .nlp as None rather than propagate
chunker = semantic_chunker.SemanticChunker()
assert chunker.nlp is None
assert len(attempts) == 2, "a failed load must not be cached"
# Once the model is available, both callers should now get it, and
# share a single successful load.
def working_load(name, **_kwargs):
attempts.append(name)
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(working_load))
chunker2 = semantic_chunker.SemanticChunker()
split_methods.split_by_sentences("One more sentence.")
assert len(attempts) == 3, (
"the model should load once after it becomes available"
)
assert chunker2.nlp is not None
def test_semantic_chunker_falls_back_when_spacy_runtime_is_broken(
self, monkeypatch
):
"""A spaCy model that is installed but unusable at runtime (e.g. a
config incompatible with the installed spaCy version) must degrade
SemanticChunker to fallback chunking, not crash __init__ -- mirrors
TestNERExtractorSpacyModelCache's equivalent broken-runtime test.
"""
def broken_load(name, **_kwargs):
raise RuntimeError("ConfigSchemaNlp is not fully defined")
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(broken_load))
chunker = semantic_chunker.SemanticChunker()
assert chunker.nlp is None
class TestNERExtractorSpacyModelCache:
"""NERExtractor(method="ml") must reuse the centralized cache in
semantic_extract.methods, not call spacy.load() on every construction.
These tests mirror TestSpacyModelCache but focus on the NERExtractor path,
confirming that all three callers (split_by_sentences, SemanticChunker, and
NERExtractor) draw from the same process-level cache.
"""
def test_ner_extractor_reuses_cached_model_across_instances(self, monkeypatch):
"""Two NERExtractor(method='ml') constructions with the same model name
must cause exactly one underlying spacy.load() call."""
calls = []
def fake_load(name, **kwargs):
calls.append(name)
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
e1 = NERExtractor(method="ml")
e2 = NERExtractor(method="ml")
e3 = NERExtractor(method="ml", model="en_core_web_sm")
assert len(calls) == 1, (
"repeated NERExtractor constructions should not reload the model"
)
assert e1.nlp is e2.nlp is e3.nlp
def test_ner_extractor_and_split_callers_share_one_cached_model(self, monkeypatch):
"""NERExtractor, SemanticChunker, and split_by_sentences must all use
the same cached Language object for the same model name."""
calls = []
def fake_load(name, **kwargs):
calls.append(name)
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
split_methods.split_by_sentences("First sentence.")
semantic_chunker.SemanticChunker()
NERExtractor(method="ml")
assert len(calls) == 1, (
"split_by_sentences, SemanticChunker, and NERExtractor must share "
"one cached model instead of each loading their own"
)
def test_ner_extractor_distinct_model_names_load_separately(self, monkeypatch):
"""Different model names must produce separate cache entries."""
calls = []
def fake_load(name, **kwargs):
calls.append(name)
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
sm = NERExtractor(method="ml", model="en_core_web_sm")
lg = NERExtractor(method="ml", model="en_core_web_lg")
sm_again = NERExtractor(method="ml", model="en_core_web_sm")
assert calls == ["en_core_web_sm", "en_core_web_lg"]
assert sm.nlp is sm_again.nlp
assert sm.nlp is not lg.nlp
def test_ner_extractor_failed_load_not_cached_and_retried(self, monkeypatch):
"""A missing model must not poison the cache. A subsequent construction
after the model becomes available must succeed and share the loaded model."""
attempts = []
def failing_load(name, **_kwargs):
attempts.append(name)
raise OSError(f"Can't find model '{name}'")
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(failing_load))
# Construction with missing model: nlp must remain None, no crash
extractor1 = NERExtractor(method="ml")
assert extractor1.nlp is None
assert len(attempts) == 1, "one load attempt expected for the missing model"
# Second construction: must retry (cache must not hold the failure)
extractor2 = NERExtractor(method="ml")
assert extractor2.nlp is None
assert len(attempts) == 2, "a failed load must not be cached"
# Now install a working model and verify recovery
def working_load(name, **_kwargs):
attempts.append(name)
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(working_load))
extractor3 = NERExtractor(method="ml")
extractor4 = NERExtractor(method="ml")
assert extractor3.nlp is not None
assert extractor3.nlp is extractor4.nlp
assert len(attempts) == 3, (
"exactly one successful load expected after the model becomes available"
)
def test_ner_extractor_non_ml_method_does_not_load_model(self, monkeypatch):
"""NERExtractor with a non-ml method must not touch the spaCy cache."""
calls = []
def fake_load(name, **kwargs):
calls.append(name)
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
NERExtractor(method="pattern")
NERExtractor(method="llm")
NERExtractor(method="regex")
assert calls == [], "non-ml methods must not trigger any spacy.load()"
if __name__ == "__main__":
pytest.main([__file__])
+7 -9
View File
@@ -30,18 +30,16 @@ class TestSplitter(unittest.TestCase):
splitter = TextSplitter(method=["recursive", "token"])
self.assertEqual(splitter.methods, ["recursive", "token"])
@patch('semantica.split.semantic_chunker.spacy')
@patch('semantica.semantic_extract.methods.spacy')
def test_semantic_chunker_initialization(self, mock_spacy):
# Mock spacy.load to return a mock nlp object
# SemanticChunker now loads spaCy through the centralized
# load_spacy_model() in semantic_extract.methods, so we patch
# methods.spacy rather than the removed semantic_chunker.spacy binding.
mock_nlp = MagicMock()
mock_spacy.load.return_value = mock_nlp
# We need to ensure SPACY_AVAILABLE is True for this test context if possible,
# but it is imported at module level.
# If spacy is not installed, it sets SPACY_AVAILABLE = False.
# We might need to patch the module attribute or just test fallback if spacy missing.
chunker = SemanticChunker(chunk_size=100)
with patch('semantica.split.semantic_chunker.SPACY_AVAILABLE', True):
chunker = SemanticChunker(chunk_size=100)
self.assertEqual(chunker.chunk_size, 100)
def test_chunk_dataclass(self):
+21 -8
View File
@@ -101,9 +101,15 @@ class TestNERConfigurations(unittest.TestCase):
self.assertEqual(entities[0].metadata["extraction_method"], "ml")
self.assertEqual(entities[0].metadata["model"], "en_core_web_trf")
@patch('semantica.semantic_extract.ner_extractor.spacy')
@patch('semantica.semantic_extract.methods.spacy')
def test_ner_ml_init_falls_back_when_spacy_runtime_is_broken(self, mock_spacy):
"""Test NER init does not crash when spaCy is installed but unusable at runtime."""
"""Test NER init does not crash when spaCy is installed but unusable at runtime.
The model load now goes through load_spacy_model() in semantic_extract.methods,
so we patch methods.spacy (not ner_extractor.spacy) to inject the failure.
"""
from semantica.semantic_extract.methods import clear_spacy_model_cache
clear_spacy_model_cache()
mock_spacy.load.side_effect = RuntimeError("ConfigSchemaNlp is not fully defined")
with patch('semantica.semantic_extract.ner_extractor.SPACY_AVAILABLE', True):
@@ -112,17 +118,23 @@ class TestNERConfigurations(unittest.TestCase):
self.assertIsNone(extractor.nlp)
self.assertFalse(extractor._ml_runtime_usable)
@patch('semantica.semantic_extract.ner_extractor.spacy')
@patch('semantica.semantic_extract.methods.get_entity_method')
@patch('semantica.semantic_extract.methods.spacy')
def test_ner_ml_runtime_failure_disables_repeated_ml_load_attempts(
self,
mock_methods_spacy,
mock_get_method,
mock_init_spacy,
):
"""Test degraded ML mode skips repeated spaCy load attempts after init failure."""
mock_init_spacy.load.side_effect = RuntimeError("ConfigSchemaNlp is not fully defined")
"""Test degraded ML mode skips repeated spaCy load attempts after init failure.
The model load at construction time now goes through load_spacy_model() in
semantic_extract.methods, so methods.spacy is the single mock target for the
init-time failure. After the RuntimeError is raised, _ml_runtime_usable is
False and no further spacy.load (or extract_entities_ml) calls are made.
"""
from semantica.semantic_extract.methods import clear_spacy_model_cache
clear_spacy_model_cache()
mock_methods_spacy.load.side_effect = RuntimeError("ConfigSchemaNlp is not fully defined")
mock_ml_method = MagicMock(return_value=[])
mock_get_method.side_effect = lambda name: mock_ml_method if name == "ml" else (lambda *_args, **_kwargs: [])
@@ -132,8 +144,9 @@ class TestNERConfigurations(unittest.TestCase):
entities = extractor.extract_entities(self.text)
self.assertFalse(extractor._ml_runtime_usable)
self.assertEqual(mock_init_spacy.load.call_count, 1)
self.assertEqual(mock_methods_spacy.load.call_count, 0)
# methods.spacy.load called once during __init__ (the RuntimeError); not again
# during extract_entities because _filter_unusable_methods removes "ml".
self.assertEqual(mock_methods_spacy.load.call_count, 1)
self.assertEqual(mock_ml_method.call_count, 0)
self.assertIsInstance(entities, list)