Cache embedder instances to fix slow embedder loading on query endpoints#1835
Conversation
Loading the configured embedder was slow because every embed call instantiated the embedder class fresh. PipelineComponentBase.__init__ reads the PipelineSettings DB singleton and, whenever a secret is configured, decrypts it with a deliberately expensive PBKDF2 KDF (480,000 HMAC iterations). Query-time endpoints paid that DB read + KDF on every request: Corpus.embed_text behind the public search API, the MCP search_corpus tool, and global vector search. Mirror the existing reranker instance cache for embedders: - pipeline/utils.py: add get_embedder_instance() / invalidate_embedder_cache() caching the constructed embedder keyed by (class_path, PipelineSettings.modified). The modified (auto_now) key element means any settings save() busts the cache process-wide on the next lookup, so config changes still take effect without a worker restart. Per-call kwarg overrides are unaffected; only DB-resolved defaults are pinned. - utils/embeddings.py and core_vector_stores.py: hot paths use the cache. - conftest.py: extend the autouse cache-isolation fixture to clear the embedder/reranker instance caches before each test, so tests that mock get_component_by_name to return different classes for the same path don't collide on a stale cached instance (PipelineSettings.modified is stable across TestCase tests). - test_pipeline_utils.py: TestEmbedderInstanceCache covering caching, isolation, invalidation, and settings-write busting. https://claude.ai/code/session_01SE9E5SDNBRrLVc57cX7B4i
Code ReviewOverview: This PR adds a process-local instance cache for embedders, mirroring the existing reranker cache pattern in What works well
Issues1. The reranker's # get_embedder_instance (this PR):
instance = embedder_class() # ← propagates on failure
_EMBEDDER_INSTANCE_CACHE[cache_key] = instance
return instance
# get_default_reranker_instance (existing):
try:
instance = reranker_class()
except Exception as e:
logger.warning(f"Failed to instantiate reranker '{class_path}': {e}. Skipping …")
return None # ← degrades gracefullyIn Recommended fix: wrap 2. Missing The reranker has default_embedder_class = get_default_embedder()
query_vector = get_embedder_instance(default_embedder_class).embed_text(query_text)A
3. Stale entries are never evicted from the cache When 4. Thread-safety of concrete implementations is assumed but undocumented The base Minor
SummaryThe core implementation is correct and well-tested. The main ask is addressing the error-handling divergence from the reranker pattern (issue 1), which is worth fixing before merge to keep the two caches consistent. Adding |
…int-loading-perf-3TLCS # Conflicts: # CHANGELOG.md
…ionale Document why get_embedder_instance propagates construction errors rather than degrading to None like the reranker cache (embedding is mandatory for vector search; None would crash the caller or silently drop the query vector), and state the thread-safety requirement for cache-eligible embedder implementations.
Code Review — PR #1835: Cache embedder instances on query endpointsOverviewThis PR fixes a real performance issue: every query-time embed call was paying the What's Good
IssuesMedium1. No test for construction-failure propagation. class _FailingEmbedder(BaseEmbedder):
def __init__(self):
raise RuntimeError("simulated init failure")
...
def test_construction_failure_propagates_and_is_not_cached(self):
from opencontractserver.pipeline.utils import get_embedder_instance
with self.assertRaises(RuntimeError):
get_embedder_instance(_FailingEmbedder, "tests.Failing")
# Cache must be empty — no stale broken entry
self.assertEqual(len(_EMBEDDER_INSTANCE_CACHE), 0)Without this test, a future refactor that accidentally silences the exception (e.g. by adding a 2. Thread-safety requirement is only in a module comment. Minor3. Stale cache entries accumulate on settings writes. 4. 5. Hot-path import inside # opencontractserver/utils/embeddings.py:244
from opencontractserver.pipeline.utils import get_embedder_instancePython module imports are cached after the first call, so there's no runtime cost after warm-up. Still, it's inconsistent with the rest of the file (most imports are at module level) and slightly obscures the dependency. Consider hoisting it to the top of SummaryThe core change is correct and well-tested for the happy path. The two medium items are worth addressing before merge: (1) a short test for construction-failure propagation to lock in the intentional divergence from the reranker cache, and (2) a docstring note on thread-safety in |
…int-loading-perf-3TLCS
…ent thread-safety contract - Add TestEmbedderInstanceCache.test_construction_failure_propagates_and_is_not_cached: a probe embedder whose __init__ raises must propagate the exception and leave _EMBEDDER_INSTANCE_CACHE unpopulated, locking in the intentional divergence from the reranker cache's None-degradation. - Document the thread-safety requirement and exception-propagation behaviour in get_embedder_instance()'s docstring (previously only in the module-level comment).
Code ReviewThe approach is sound — mirroring the established reranker cache pattern is the right call, and the motivation is clear. A few observations below. DRY violation: duplicate cache-key function
def _get_pipeline_cache_key(class_path: str) -> tuple[str, Any]:
from opencontractserver.documents.models import PipelineSettings
modified = PipelineSettings.get_instance().modified
return (class_path, modified)CLAUDE.md calls this out explicitly. A single shared helper would serve both caches. Not a blocker, but worth fixing in a follow-up (or now, since the two functions were added in the same PR). Subtle contract hazard:
|
| return [0.0] * self.vector_size | ||
|
|
||
|
|
||
| class _FailingProbeEmbedder(BaseEmbedder): |
…int-loading-perf-3TLCS
… cache contract - Consolidate the byte-for-byte identical _get_reranker_cache_key and _get_embedder_cache_key into a single shared _get_pipeline_cache_key, used by both the reranker and embedder instance caches (CLAUDE.md DRY rule). - Document the get_embedder_instance caller contract: a supplied embedder_path MUST identify the same class as embedder_class, since the cache keys on the path but stores a class instance — a mismatched pair would poison the cache. (Docstring note rather than an assert, which -O strips, or a raise, which would change behavior for the safe existing callers.)
Code ReviewOverviewThis PR fixes a real performance problem: every query-time embed call was paying a full DB read + PBKDF2 KDF (480k iterations) to construct a fresh embedder instance. The fix mirrors the existing reranker instance cache with appropriate adaptations for embedding semantics. The implementation is sound and the test coverage is thorough. Strengths
Issues / Suggestions1. Potential cache-key divergence between the two call sites (minor efficiency concern)In # core_vector_stores.py — key derived from class metadata
get_embedder_instance(default_embedder_class)
# embeddings.py — key is the lookup path, which may differ
get_embedder_instance(embedder_class, embedder_path)If 2. Pre-lock hot-path read relies on CPython GIL atomicity# No lock held here
cached = _EMBEDDER_INSTANCE_CACHE.get(cache_key)
if cached is not None:
return cached
3. Stale-timestamp cache entries accumulate indefinitelyWhen 4.
|
Problem
Loading the configured embedder was "super duper slow" on query-time endpoints. Every embed call instantiated the embedder class fresh via
embedder_class(). That constructor (PipelineComponentBase.__init__) reads thePipelineSettingsDB singleton and, whenever any secret is configured, decrypts it withPipelineSettings._derive_key— a PBKDF2 KDF running 480,000 HMAC iterations (intentionally expensive). Query-time endpoints paid this DB-read + KDF cost on every request:Corpus.embed_text→ public search API (opencontractserver/discovery/views.py)search_corpustool (opencontractserver/mcp/tools.py)opencontractserver/llms/vector_stores/core_vector_stores.py)The codebase already solved this exact problem for rerankers with a process-local instance cache. This PR mirrors that established, tested pattern for embedders.
Changes
pipeline/utils.py— newget_embedder_instance()/invalidate_embedder_cache(), caching the constructed embedder keyed by(class_path, PipelineSettings.modified). Themodified(auto_now) key element means any settings write through the GraphQL mutations — which all callsave()— busts the cache process-wide on the next lookup, so config changes still take effect without a worker restart.utils/embeddings.py+core_vector_stores.py— hot paths now use the cached instance. Per-call kwarg overrides still work (only DB-resolved defaults are pinned).conftest.py— extended the existing autouse cache-isolation fixture to also clear the embedder/reranker instance caches before each test, preventing cross-test collisions (the migration-seededPipelineSettings.modifiedis stable acrossTestCasetests, so two tests mocking different classes under the same path would otherwise share a cached instance).test_pipeline_utils.py— newTestEmbedderInstanceCachecovering per-class-path caching, distinct-path isolation, default class-path derivation, settings-write invalidation, and explicit cache invalidation.CHANGELOG.md— documented under Fixed.Notes
Corpus.embed_text→generate_embeddings_from_text.https://claude.ai/code/session_01SE9E5SDNBRrLVc57cX7B4i
Generated by Claude Code