Make embedding server optional for vMCP optimizer - #6106
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6106 +/- ##
==========================================
+ Coverage 72.41% 72.42% +0.01%
==========================================
Files 734 734
Lines 75822 75817 -5
==========================================
+ Hits 54903 54908 +5
+ Misses 17019 16994 -25
- Partials 3900 3915 +15 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Verified the underlying premise end to end and it holds up: the runtime really does degrade to FTS5 keyword-only when there's no embedding source. similarity/embedding_client.go:17-19 returns (nil, nil) on an empty EmbeddingService, toolstore/sqlite_store.go:192-203 has an explicit FTS5-only branch that returns before any hybrid code runs, and the CLI already treats this as a first-class Tier 1 mode (pkg/vmcp/cli/serve.go:486-511). Nothing nil-panics, and the reconcile path is safe too: populateOptimizerEmbeddingService falls through to a no-op and readiness isn't blocked, since the requeue is gated on EmbeddingServerRef != nil. So aligning the operator with behavior that already shipped is the right call.
Signaling the degraded mode
Anchoring this here since the file isn't in the diff: cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go:160, the no-ref branch.
Now that this config is legal, that branch is the only place that knows the user is about to run in keyword-only mode, and it says nothing.
Everything that exists today is slog.Debug: optimizer.go:256 (semantic_search_enabled), sqlite_store.go:113, and sqlite_store.go:202. There's no Event, and no Condition either, because SetEmbeddingServerReadyCondition is only reached when EmbeddingServerRef != nil (virtualmcpserver_controller.go:1057-1074). At default log level a user gets no indication anywhere that semantic ranking is off: not in pod logs, not in kubectl describe, not in .status. "Optimizer running keyword-only" and "optimizer not enabled" are indistinguishable.
That's a rough failure mode, because the only symptom is worse find_tool results, which people tend to misdiagnose for a while before suspecting config.
Two suggestions, following the idiom already in that function:
An Info log plus a Normal event, shaped like the EmbeddingServiceManual warning just below it:
if config.Optimizer != nil && config.Optimizer.EmbeddingService == "" {
ctxLogger.Info("optimizer configured without an embedding source; " +
"find_tool will use FTS5 keyword-only search with no semantic ranking")
if r.Recorder != nil {
r.Recorder.Eventf(vmcp, nil, corev1.EventTypeNormal,
"OptimizerKeywordOnly", "ValidateEmbeddingService",
"No embeddingServerRef or optimizer.embeddingService set; find_tool will use "+
"keyword-only search. Set embeddingServerRef to enable semantic ranking.")
}
}And promoting the runtime line at optimizer.go:256 from Debug to Info when embClient == nil, since the operator event does nothing for anyone running vMCP through the CLI.
On the Condition: I'd deliberately skip it. Setting EmbeddingServerReady=False would report an intentional configuration as a failure, and the existing False path is paired with Phase=Pending and a requeue, which isn't right for a mode that's working as designed. Event plus log feels like the better fit, but happy to be argued out of it if you'd rather it show up in .status.
The find_tool description should reflect the mode
Also not in the diff: pkg/vmcp/session/optimizerdec/decorator.go:73.
That description is unconditional: "This searches available MCP server tools using semantic and keyword-based matching." Once keyword-only becomes a reachable default, that's promising the model semantic ranking that may not be there. The description is part of the contract the LLM plans against, so it should reflect what the deployment can actually do.
Rather than watering the wording down for everyone, I think it's worth teaching the decorator which mode it's in and picking between two strings. Semantic ranking is a real capability difference, and a model that knows it's doing lexical matching can compensate by phrasing queries with terms likely to appear in tool metadata.
The constraint is that OptimizerTools() is shared: its doc comment says this decorator and the Serve path (serve_optimizer.go:81) have to advertise an identical pair, so whatever signal we add needs to reach both or the two paths will disagree about what find_tool claims.
Adding the bit to the Optimizer interface handles that cleanly. Both call sites already hold an opt at the point they build the definitions, so it needs no constructor threading:
type Optimizer interface {
FindTool(...)
CallTool(...)
// SemanticSearchEnabled reports whether an embedding client was configured.
// When false, find_tool ranks by FTS5 keyword match only.
SemanticSearchEnabled() bool
}
func OptimizerTools(semanticEnabled bool) []vmcp.Tool { ... }The predicate should be the same one NewEmbeddingClient uses (embedding_client.go:17), so the advertised description can't claim semantic matching in a build where the client came back nil.
| // - embeddingServerRef.name must be non-empty when ref is provided | ||
| // - optimizer requires either embeddingServerRef or a manually set embeddingService | ||
| // - optimizer does not require an embedding source: with neither embeddingServerRef | ||
| // nor optimizer.embeddingService set, find_tool runs FTS5 keyword-only search |
There was a problem hiding this comment.
gofmt rejects this. Go 1.19+ gofmt reformats doc comments, and a column-0 // - bullet with an indented continuation line isn't recognized as a list, so it gets rewritten to the canonical // - form:
// - embeddingServerRef.name must be non-empty when ref is provided
// - optimizer does not require an embedding source: with neither embeddingServerRef
// nor optimizer.embeddingService set, find_tool runs FTS5 keyword-only search
// - if embeddingServerRef is set without optimizer, auto-populate optimizer with defaults
gofmt -l flags the file as it stands, so linting will fail. task lint-fix sorts it.
| @@ -611,15 +614,6 @@ func (r *VirtualMCPServer) validateEmbeddingServer() error { | |||
|
|
|||
| hasOptimizer := r.Spec.Config.Optimizer != nil | |||
| hasRef := r.Spec.EmbeddingServerRef != nil | |||
There was a problem hiding this comment.
Worth thinking about what happens to embeddingProvider and embeddingModel once this check is gone.
With no embedding source those two fields stop meaning anything, so I agree there's no reason to reject the spec over them. What we should do is tell the user they're inert, so nobody sets embeddingProvider: openai, sees keyword-only results, and spends an afternoon wondering why their gateway is never called.
One wrinkle to sort out first: today they aren't actually ignored. resolveEmbeddingProvider (pkg/vmcp/optimizer/optimizer.go:113-117) hard-errors for the openai provider when embeddingService is empty, so this spec doesn't degrade, it CrashLoopBackOffs:
config:
optimizer:
embeddingProvider: openai
embeddingModel: text-embedding-3-small
# no embeddingService, no embeddingServerRefThe CEL rule at line 22 of this file doesn't catch it, since that one only blocks embeddingServerRef combined with openai and there's no ref here. Before this PR Validate() caught it with a clear message. Now it reaches the pod and dies at startup with the reason only in pod logs, no condition or event pointing at why.
So "these fields are ignored" needs to become true before we can warn that it's true. That means short-circuiting on the absence of an embedding source before the provider switch:
func resolveEmbeddingProvider(optCfg *Config) error {
if optCfg.EmbeddingService == "" {
// No embedding source: semantic search is disabled entirely, so the
// provider and model are inert. Skip provider validation rather than
// failing startup over fields that will never be read.
return nil
}
...
}With that in place the behavior matches the mental model (no embedding source means keyword-only, full stop, regardless of provider) and the warning is honest. Pair it with a line alongside the keyword-only event in the controller:
if config.Optimizer.EmbeddingProvider != "" || config.Optimizer.EmbeddingModel != "" {
ctxLogger.Info("optimizer.embeddingProvider and optimizer.embeddingModel are ignored "+
"when no embedding source is configured; set embeddingServerRef or "+
"optimizer.embeddingService to enable semantic ranking",
"embeddingProvider", config.Optimizer.EmbeddingProvider,
"embeddingModel", config.Optimizer.EmbeddingModel)
}Note embeddingProvider defaults to tei via kubebuilder, so on the operator path that check fires for essentially everyone in keyword-only mode. Probably worth folding the "provider and model ignored" sentence into the single keyword-only event rather than emitting a second one.
| }, | ||
| expectError: true, | ||
| errContains: "spec.config.optimizer requires an embedding service", | ||
| expectOptimizer: true, |
There was a problem hiding this comment.
This asserts the check is gone, which is right, but nothing yet asserts the newly-legal config produces a working deployment.
The keyword-only search path is well covered at the store layer (TestSQLiteToolStore_Search has 9 FTS5-only cases, plus _ResultsCapped, _Close, _Concurrent), but all of that predates this PR. The gaps are at the seams this PR opens:
-
NewOptimizerFactoryhas no tests at all. Zero references to it in any test file. Coverage stops atNewSQLiteToolStoreandNewEmbeddingClientseparately, so nothing asserts thatNewOptimizerFactory(&Config{})with no service succeeds and yields a store that returns FTS5 results. That's precisely the composition this PR makes reachable from the operator. -
No operator-side test for
populateOptimizerEmbeddingServicein the ref-nil, svc-empty case. Worth asserting the ConfigMap still carries a non-nilconfig.Optimizerwith an emptyembeddingService, so a later refactor can't quietly drop the optimizer block. One trap:TestOptimizerEmbeddingServiceURL(controllers/virtualmcpserver_vmcpconfig_test.go:2149) looks like the natural home and already acceptsesName == "", but its assertion block is wrapped inif tt.expectedURL != ""at line 2262, so a new case with an emptyexpectedURLwould pass while asserting nothing. -
No envtest case for
spec.config.optimizer: {}with no ref being admitted.cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_embedding_cel_test.gois the place, and it's cheap to add next to the existing provider cases. -
If
resolveEmbeddingProvidergains the empty-service short-circuit, it wants a case assertingembeddingProvider: openaiwith no service returns cleanly instead of erroring. That's the regression pin for the whole "provider is inert without a source" contract.
Items 1 and 2 feel like they belong in this PR. 3 and 4 could be follow-ups, though 4 should land with whatever change introduces it.
Summary
The vMCP optimizer's
find_toolsearch only needs an embedding server forsemantic ranking — the underlying implementation already supports FTS5
keyword-only search with no embedder configured (see
pkg/vmcp/optimizer/internal/toolstore/sqlite_store.goandpkg/vmcp/optimizer/internal/similarity/embedding_client.go, which alreadytreat an empty
EmbeddingServiceas "semantic search disabled, keyword-onlystill works"). However, the operator's
VirtualMCPServeradmission-timevalidation contradicted this:
validateEmbeddingServer()unconditionallyrejected any config with
spec.config.optimizerset unlessspec.embeddingServerReforspec.config.optimizer.embeddingServicewasalso set, even when the user only wanted keyword search. This made the
embedding server (and the extra TEI/OpenAI infrastructure it requires)
mandatory for a feature that was designed to work without it.
hasOptimizer && !hasRef && !hasManualServiceerror branch invalidateEmbeddingServer()sospec.config.optimizerno longer requires anembedding source.
embeddingServerRef.namemust benon-empty when the ref is set, and setting
embeddingServerRefwithoutoptimizerstill auto-populates a defaultOptimizerConfig.EmbeddingServerRefandvalidateEmbeddingServerto state that omitting both fields now falls back to FTS5 keyword-only
search instead of erroring.
TestValidateEmbeddingServerto assert success (withOptimizerpopulated) instead of an error for the no-embedding-source case.
Type of change
Test plan
task test)task test-e2e)task lint-fix)Ran
TestValidateEmbeddingServerdirectly — all 5 subtests pass, includingthe updated
optimizer_without_ref_or_service_succeeds_keyword_onlycase.Full
task operator-testsuite run in progress at time of writing;confirm it's green before merging.
API Compatibility
v1beta1API, OR theapi-break-allowedlabel is applied and the migration guidance is described above.This only relaxes a Go-level reconcile-time validation rule (not a CEL rule
or schema marker) — no CRD field types, names, or schemas changed. Configs
that were previously rejected are now accepted; no previously-accepted
config is now rejected.
Changes
cmd/thv-operator/api/v1beta1/virtualmcpserver_types.govalidateEmbeddingServer(); update doc commentscmd/thv-operator/api/v1beta1/virtualmcpserver_types_test.goDoes this introduce a user-facing change?
Yes.
VirtualMCPServerresources can now setspec.config.optimizerwithoutalso setting
spec.embeddingServerReforspec.config.optimizer.embeddingService. In that case,find_toolperformsFTS5 keyword-only search with no semantic ranking, instead of the operator
rejecting the resource at admission.
Special notes for reviewers
The
openaiembedding provider still requiresembeddingServiceandembeddingModel— that constraint is enforced separately, at optimizerstartup, in
pkg/vmcp/optimizer/optimizer.go'sresolveEmbeddingProvider,and is unrelated to this fix (there's no default OpenAI-compatible endpoint
to fall back to). No CRD manifest regeneration (
task operator-generate/operator-manifests) was needed since this is a Go validation change, not akubebuilder marker or CEL rule change.