Make local embeddings reliable: truncate over-budget inputs and surface silent fallbacks#236
Merged
Conversation
…rence The pure-Go Hugot tokenizer path never applies max_position_embeddings, so any input longer than the model's context window (512 tokens for MiniLM) reaches inference at full length and produces a tensor shape mismatch that aborts the entire vector index, silently dropping the daemon to text-only search. Every real repository has symbols past that budget. Add a token-exact client-side truncator that reads the model's tokenizer.json and config.json, derives the budget from max_position_embeddings, and cuts over-long texts on a token (and rune) boundary before RunPipeline. A fast rune-count path skips the extra tokenizer pass for texts that already fit, and a degraded truncator (missing config.json or corrupt tokenizer.json) falls back to a rune clamp with a warning rather than disabling the local backend. Wired into both the Hugot and GoMLX providers.
A provider that returns nil, empty, short, or mis-counted vectors used to ship a quietly empty or mis-dimensioned index with no signal — surfacing only as an unexplained MRR of 0. Add a shared validateBatch check to every provider's EmbedBatch: one vector per input, none empty, and each of the expected width (the model's known dimension for the local backends; internal batch consistency for the API backend, which learns its width lazily). Any violation now returns a descriptive, index-naming error instead of degrading silently.
…y index The vector-ingest loop admitted any non-nil vector, so a wrong-width vector could poison the index, and a build where every vector came back invalid produced a silently empty vector index that mis-scores every query. Validate each vector against the detected dimension, dropping and counting the bad ones (with a sample of node IDs in a warning and a dropped= field on the build log), and when nothing valid remains abort to text-only search — the same all-or-nothing contract the chunk-failure path already uses.
…red one
A local config that silently fell back to static GloVe still logged
'provider: local ... dim: 50' because the startup line echoed the configured
name, and every backend that failed along the way was swallowed with no signal.
Add a SelectionReport that records each backend the local chain tried and the
one it chose, thread it through ResolveEmbedder, and log the truth: the startup
line now reads the constructed backend ('local (hugot)', or 'local → static
fallback' when it degraded) with its real dimension. A genuine degradation to
static warns per failed backend; the benign misses behind a successful choice
(the onnx/gomlx stubs in a default build) stay at debug level.
…on unknown keys An 'embedding:' block in ~/.gortex/config.yaml was silently ignored: GlobalConfig had no Embedding field, and an unknown top-level key vanished with no warning — so a user who put their embedding config in the global file (rather than a repo-local .gortex.yaml) got the static default with no explanation. Add an Embedding field to GlobalConfig and MergeEmbeddingInto (repo-local non-zero fields win, the global block fills the rest; the tri-state Enabled pointer inherits when unset), merged just before the embedder is resolved so flag/env precedence is untouched. Add UnknownGlobalKeys plus a daemon-startup warning so a misplaced or misspelled top-level key is surfaced rather than dropped — never failing the load, to keep forward compatibility.
gortex eval embedders built its indexer with a discarding logger and reported every empty vector index as the same 'no vector data after indexing' — which is exactly what led the reliability report to the wrong 'empty vectors' conclusion when the true cause was a chunk-embed abort. Record why a build shipped text-only on the indexer (LastVectorBuildError, set at the chunk-failure, all-invalid, and over-threshold paths) and read it in benchVariant to report the concrete cause. Also give benchVariant a WARN-level stderr logger so the indexer's own vector-build warnings are visible during the run instead of swallowed.
…licit The embeddings_onnx (GTE-small) backend never auto-downloads its model, but neither the error nor the docs said so — a fresh machine just saw 'ONNX model not found' with no path forward. Extend the error with the doc pointer and the fact that it never fetches the model, and state the manual model placement plus onnxruntime install requirement in the docs.
…XLA tag pair The gomlx build entry points passed only -tags embeddings_gomlx, but hugot gates its real XLA session behind its own XLA build tag — so every gomlx build linked the disabled stub, the GoMLX provider always errored at runtime, and the local chain silently fell through to the pure-Go backend. The GoMLX backend was dead code in every shipped binary. Move the Makefile target and the docs build command to -tags "embeddings_gomlx XLA" so the XLA session is actually compiled (verified to build with no system XLA libraries — PJRT is a runtime download). CI now compiles both the tag pair and embeddings_gomlx alone (a regression guard for scripts that still pass it by itself). The docs are honest that XLA runtime viability is platform-dependent and experimental, with the pure-Go backend as the reliable default.
…x, and troubleshooting Refresh the semantic-search guide around the reliability work: explain input truncation (why inputs are capped at the model's positional budget and what that means for long symbols), the config placement/precedence matrix now that the global config merges an embedding block, a per-build-tag backend matrix with a status column, and a troubleshooting section keyed on the actual log lines so each degradation-to-text-only mode maps to a documented cause and fix.
End-to-end guard against the shape-mismatch crash: with GORTEX_TEST_LIVE_MODELS=1 it embeds a ~1000-token input (and a mixed over-budget/short batch) through the real cached MiniLM model and asserts a clean 384-dim vector — the exact case that used to abort the vector index.
The degraded rune-clamp fallback capped inputs at 4x the token budget in runes, which does not bound the token count: a WordPiece token spans at least one rune, so N runes yields at most N tokens — only clamping to budget runes guarantees token_count <= budget. On token-dense input (CJK, single-char words) the 4x cap could still overflow the window and re-introduce the very shape-mismatch crash the truncator exists to prevent. Clamp to budget runes instead, treat a span-end that reaches the end of the text as degenerate (fall back to the clamp), and add a direct test for TruncateAll (the batch API every provider actually calls).
…diagnostics Refinements from a self-review of the reliability work: - A backend that is simply not compiled into the build (the onnx/gomlx stubs) now wraps a sentinel error, so the degradation-to-static warning fires only for backends that could really have worked — no more warning that onnx/gomlx are 'not compiled in' on every default build. The warning also covers the case where even the static fallback fails to construct. - The unknown-global-key diagnostic now inspects the config file that was actually loaded (which may be an overridden path), and a malformed global config warns instead of being silently ignored — exactly when its embedding block would have taken effect. - The indexer's last-vector-build-error is reset at the top of the build so a benign skip (no embedder / snapshot restore) can't report a stale failure.
The embeddings_gomlx XLA build failed to link on a clean Linux runner with 'cannot find -ltokenizers': hugot's XLA session uses the rust tokenizer, which is statically linked as libtokenizers.a — a native dependency the earlier change did not account for (it happened to be present on the dev machine). The pure-Go default and gortex's own ONNX path do not need it. Install the matching daulet/tokenizers v1.27.0 archive in the CI gomlx step (mirroring the onnxruntime install), teach 'make build-gomlx' to fetch it cross-platform, and document the requirement so a user building the XLA backend knows to provide libtokenizers.a.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Makes
provider: localsurvive a real repository. Today the zero-dependency local embedding path (pure-Go Hugot, MiniLM-L6-v2) dies on the first code chunk longer than 512 tokens — which every real repo has — and every failure along the chain is silent: the vector index is thrown away wholesale, the daemon logs a provider name that isn't what's running, and anembedding:block in the global config file disappears without a warning. The net effect (per #232) is that onlystatic(GloVe-50d) andapiwork, with no way to see why.This fixes the crash, isolates the remaining failure modes, and makes every layer of the chain tell the truth about what got constructed, what fell back, what was dropped, and why.
Fixes #232.
Root cause
The pure-Go Hugot tokenizer path never applies
max_position_embeddings, so any input past the model's context window reaches inference at full length and produces a tensor shape mismatch. The indexer's abort-on-any-error contract then throws away the entire vector index, dropping the daemon to text-only search — silently. ("Emits empty vectors" in the issue was a misdiagnosis; short texts embed fine.)What changed (one commit per fix)
internal/embedding/truncate.go) — reads the model'stokenizer.json/config.json, derives the budget frommax_position_embeddings − 2, and cuts over-long inputs on a token (and rune) boundary before inference. A rune-count fast path skips the extra tokenizer pass for inputs that already fit; a degraded tokenizer falls back to a rune clamp with a warning instead of disabling the backend. Wired into both the Hugot and GoMLX providers.EmbedBatchnow checks it returned one vector per input, none empty, each of the expected width; a violation is a loud, index-naming error instead of a silently empty/mis-dimensioned index.dropped=field and a sample of node IDs); an all-invalid build aborts to text-only rather than shipping an empty vector index.SelectionReportrecords which local backend was constructed and which were skipped; the "embeddings enabled" line now readslocal (hugot/fp32), dim: 384orlocal → static fallback, dim: 50instead of echoing the configured name. A genuine degradation to static warns per backend; benign misses (the onnx/gomlx stubs in a default build) stay at debug.embedding:config —GlobalConfiglearns anembedding:block (merged under the repo-local one, repo values win), and unrecognised top-level keys in~/.gortex/config.yamlnow warn at startup instead of vanishing.-tags "embeddings_gomlx XLA"so the real XLA session is actually compiled (it was dead code before); CI compiles both the pair andembeddings_gomlxalone. Docs are honest that XLA runtime is experimental and the pure-Go backend is the reliable default.eval embeddersobservability — records the concrete vector-build failure (LastVectorBuildError) and reports it instead of a bare "no vector data", with a WARN-level logger so the indexer's warnings are visible.Verification
GORTEX_TEST_LIVE_MODELS=1): a ~1000-token input — the exact case that used to crash — embeds to a clean 384-dim vector through the real cached MiniLM model, as does a mixed over-budget/short batch.go test -racegreen on the touched packages;golangci-lintclean; the default,embeddings_gomlx XLA, andembeddings_onnxbuilds all compile with no system XLA/ONNX libraries (both are runtime dlopen).The issue's three failure modes now each produce either working 384-dim vectors or a loud, documented, correctly-attributed log line — no silent static fallback remains reachable from a
localconfig.