Repository navigation
fix: fail loudly on non-1536-dim local embeddings (#84) - #95
Conversation
EMBEDDINGS_PROVIDER=local was shipped, documented, and could never persist a vector. The stored column is Vector(1536), but LocalEmbeddingsProvider returned whatever the model emits — 384 for all-MiniLM-L6-v2, 768 for all-mpnet-base-v2 — and every vector was then dropped by the length checks in store.py (silently, returning 0 rows). Setting EMBEDDINGS_DIMENSIONS to match the model instead tripped the `!= 1536` guard, also a silent no-op. Either way semantic ask / similar quietly fell back to keyword mode with no signal that the configured provider was inert. Option 3 from the issue (fail loudly; no silent no-op): - LocalEmbeddingsProvider now reads the model's real output width and raises EmbeddingsConfigError with an actionable message when it isn't 1536. - New validate_embeddings_config() rejects any non-disabled provider whose EMBEDDINGS_DIMENSIONS != the stored width, and (for local) a model whose native width doesn't match or whose extra is missing. Wired into API startup (lifespan) and `ingest --with-embeddings` (exits with the message). - The factory is split: build_embeddings_provider() is strict (used by validation); get_embeddings_provider() stays fail-open on the hot path but now logs at error instead of swallowing the misconfig silently. - STORED_EMBEDDING_DIMS moves to provider.py as the canonical source; store.py imports it. - .env.example and README state the constraint (common local models don't emit 1536 dims and are rejected). Tests cover the model-dimension mismatch, the dimensions!=1536 rejection, the missing-extra path, and the hot-path fail-open. Closes #84 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
leo-aa88
left a comment
There was a problem hiding this comment.
COMMENT
(Would be REQUEST CHANGES, but GitHub blocks a formal changes-request on your own PR — treat the inline MAJOR as blocking.)
Strong PR overall — the strict/fail-open factory split, the validate_embeddings_config gate, and moving STORED_EMBEDDING_DIMS to a single canonical source are all right, and I verified the mechanics at head 0907627:
- CONFIRMED — column width is genuinely 1536. Checked migrations
0001/0009andsrc/db/models.py(Vector(1536)on bothlog_embeddingsandcluster_embeddings).STORED_EMBEDDING_DIMS = 1536is correct. - CONFIRMED — the constant move is safe.
store.pyre-imports it fromprovider.pyandembeddings/__init__.pyre-exports it, so every existing importer (retrieval/similar.py,retrieval/question_router.py, and the store/similar/router tests) still resolves. Import smoke + 70 targeted tests pass;ruffclean. - CONFIRMED — validation logic is correct.
validate_embeddings_configrejectsdimensions != 1536for any non-disabled provider, and forlocalconstructs the model so a native-width mismatch (384/768) raises;EmbeddingsConfigErrorpropagates whileImportErroris re-wrapped. Hot-pathget_embeddings_providerfails open to disabled and logs aterror. Tests cover mismatch / dimensions / missing-extra / hot-path fail-open.
BLOCKING (inline on src/api/app.py): the API-startup path is documented as failing loudly but actually just logs and continues, which reopens a near-silent no-op for the server — the exact scenario #84 targets. Detail and two fix options on the inline comment.
Minor (non-blocking) QUESTION: validate_embeddings_config for local loads the SentenceTransformer model (potential first-run download) synchronously inside the API lifespan and before ingest — fine, but worth a one-line note that startup does real model I/O when provider=local.
VERDICT
Core fix is correct and well-tested. One blocker before merge: reconcile the API-startup behavior with the README/.env.example claims — either abort on startup, or soften the docs to "logged at error, server continues." Everything else is CONFIRMED sound.
|
|
||
| try: | ||
| validate_embeddings_config(settings) | ||
| except EmbeddingsConfigError as exc: |
There was a problem hiding this comment.
MAJOR — declared behavior vs actual: API startup does not fail here, it logs and continues.
The README (Vectors are stored…API startup fail with an actionable message), .env.example (startup and \ingest --with-embeddings` fail with an actionable message), and this PR's own acceptance criterion (fails loudly at startup/ingest) all state that a non-persistable embeddings config makes **API startup fail**. But this handler catches EmbeddingsConfigErrorand only emits oneerrorlog, thenyield`s — the app boots and serves normally.
CONFIRMED asymmetry with the ingest path, which genuinely fails: src/cli/commands/ingest.py:74 does raise typer.Exit(1) on the same error. So "fails loudly at startup/ingest" is true for ingest, false for the API.
Operational consequence: an operator who deploys the API with EMBEDDINGS_PROVIDER=local + a 384/768-dim model (the exact case #84 is about) gets a green, healthy-looking server that will never persist a vector and silently keyword-falls-back on /v1/query/similar and semantic ask — with only a single startup log line, which is easy to miss. That's close to the silent no-op the issue set out to eliminate.
Two clean resolutions — pick one:
- Match the docs: re-raise after logging (e.g.
raise/raise RuntimeError(...)) so the process refuses to boot on a broken config. Note this couples the whole API's availability to an optional feature, which may be too aggressive during an incident. - Match the code (my preference): keep logging-and-continue, but reword the README +
.env.exampleto say the API logs the misconfig aterroron startup and continues serving non-embedding features, and onlyingest --with-embeddingshard-exits. That keeps explain/timeline/compare available while still being non-silent.
Either way, docs and code should agree. Right now they don't.
Review on #95: the README, .env.example, and acceptance criterion said API startup "fails loudly" on a non-persistable embeddings config, but the lifespan handler logs at error and continues — an asymmetry with the ingest path, which hard-exits. Keep the log-and-continue behavior (aborting the whole API over an optional feature is worse, especially during an incident) and make the docs match: API startup logs the misconfig at error and keeps serving non-embedding features; only `ingest --with-embeddings` hard-exits. Also note that provider=local loads (and may download) the model at startup/ingest, and add a code comment so the intentional non-abort isn't "fixed" later. Docs/comment only; no logic change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
leo-aa88
left a comment
There was a problem hiding this comment.
APPROVE
LGTM. The follow-up commit 3e25108 resolves the BLOCKING finding from my previous review (COMMENT at 0907627) by taking the reconcile-the-docs route — the better option, since coupling the whole API's availability to an optional feature would be worse during an incident.
CONFIRMED — the declared/actual mismatch is gone. The delta is docs + one code comment, no logic change (verified via compare 0907627...3e25108: only .env.example, README.md, and an explanatory comment in src/api/app.py). The docs now describe exactly what the code does:
README.md:594/:916and.env.example: "ingest --with-embeddingshard-exits (non-zero) with an actionable message … and API startup logs the misconfig aterrorand keeps serving the non-embedding features." That matchesingest.py:74(raise typer.Exit(1)) and the log-and-continuelifespanhandler.- My earlier minor QUESTION is also addressed: both files now note that
provider=localloads (and may download) the SentenceTransformer model at startup / before ingest. - The
app.pycomment records the intentional log-not-abort decision and flags keeping it in sync with the docs.
No contradicting "startup fails/aborts" phrasing remains; the settings-table cell reads "surfaced at startup / before ingest," which is accurate.
Everything CONFIRMED sound in my prior pass still holds (column width 1536 across migrations 0001/0009 + models; safe STORED_EMBEDDING_DIMS relocation; correct strict/fail-open factory split and validate_embeddings_config; tests covering mismatch / dimensions / missing-extra / hot-path fail-open — 70 targeted tests + lint green).
VERDICT
Core fix correct and well-tested, and the one blocker is fully resolved with docs that now match behavior. No BLOCKING/MAJOR/MINOR findings remain. Merge it. (Comment rather than formal Approve — GitHub blocks self-approval on your own PR.)
Summary
EMBEDDINGS_PROVIDER=localwas shipped as a packaging extra and documented, but could never persist a vector. The stored column isVector(1536), whileLocalEmbeddingsProviderreturned whatever the model emits — 384 forall-MiniLM-L6-v2, 768 forall-mpnet-base-v2. The mismatched vectors were then dropped by the length checks instore.py, silently returning 0 rows. SettingEMBEDDINGS_DIMENSIONSto match the model instead tripped the!= 1536guard — also a silent no-op. Either way, semanticaskand/v1/query/similarquietly fell back to keyword mode with no indication the configured provider was inert.This implements Option 3 from the issue: make it fail loudly, with no configuration left that produces a silent no-op.
Changes
LocalEmbeddingsProviderreads the model's real output width and raisesEmbeddingsConfigError(actionable message) when it isn't 1536.validate_embeddings_config()(new) rejects any non-disabled provider withEMBEDDINGS_DIMENSIONS != 1536, and forlocala model whose native width doesn't match or whose extra is missing. Wired into API startup (lifespan) andingest --with-embeddings(exits with the message before ingesting).build_embeddings_provider()is strict (used by validation);get_embeddings_provider()stays fail-open on the hot path so a hiccup never crashes ingest, but now logs aterrorinstead of swallowing the misconfig silently.STORED_EMBEDDING_DIMSmoves toprovider.pyas the canonical source;store.pyimports it..env.exampleandREADME.mdstate the real constraint (common local models don't emit 1536 dims and are rejected loudly).Acceptance criteria
localeither works end to end, or fails loudly at startup/ingest with an actionable message.error)..env.examplereflect the real constraint.dimensions != 1536, missing-extra, and hot-path fail-open).Testing
make lint→ All checks passedmake test-unit→ 911 passedCloses #84
🤖 Generated with Claude Code