Skip to content

Make local embeddings reliable: truncate over-budget inputs and surface silent fallbacks#236

Merged
zzet merged 13 commits into
mainfrom
fix/local-embedding-reliability
Jul 3, 2026
Merged

Make local embeddings reliable: truncate over-budget inputs and surface silent fallbacks#236
zzet merged 13 commits into
mainfrom
fix/local-embedding-reliability

Conversation

@zzet

@zzet zzet commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Summary

Makes provider: local survive 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 an embedding: block in the global config file disappears without a warning. The net effect (per #232) is that only static (GloVe-50d) and api work, 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)

  • Token-exact truncation (internal/embedding/truncate.go) — reads the model's tokenizer.json/config.json, derives the budget from max_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.
  • Provider output validation — every EmbedBatch now 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.
  • Indexer vector-ingest hardening — malformed vectors are dropped and counted (with a dropped= field and a sample of node IDs); an all-invalid build aborts to text-only rather than shipping an empty vector index.
  • Truthful selection + logging — a SelectionReport records which local backend was constructed and which were skipped; the "embeddings enabled" line now reads local (hugot/fp32), dim: 384 or local → static fallback, dim: 50 instead 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.
  • Global embedding: configGlobalConfig learns an embedding: block (merged under the repo-local one, repo values win), and unrecognised top-level keys in ~/.gortex/config.yaml now warn at startup instead of vanishing.
  • GoMLX build tags — the gomlx entry points move to -tags "embeddings_gomlx XLA" so the real XLA session is actually compiled (it was dead code before); CI compiles both the pair and embeddings_gomlx alone. Docs are honest that XLA runtime is experimental and the pure-Go backend is the reliable default.
  • eval embedders observability — 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.
  • Docs — ONNX manual-setup honesty, truncation semantics, config placement/precedence, a per-build-tag backend matrix, and a troubleshooting section keyed on the actual log lines.

Verification

  • Unit tests for every fix (truncation span-cutting, budget derivation, output validation, the drop/all-invalid indexer paths, selection descriptions, the config merge + unknown-key detection, the eval note selection).
  • Live regression (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 -race green on the touched packages; golangci-lint clean; the default, embeddings_gomlx XLA, and embeddings_onnx builds 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 local config.

zzet added 13 commits July 3, 2026 22:49
…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.
@zzet zzet merged commit dde55f1 into main Jul 3, 2026
10 checks passed
@zzet zzet deleted the fix/local-embedding-reliability branch July 3, 2026 22:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Local embedding backends broken on darwin/arm64 — pure-Go Hugot emits empty vectors, GoMLX panics

1 participant