Skip to content

fix: fail loudly on non-1536-dim local embeddings (#84) - #95

Merged
leo-aa88 merged 2 commits into
mainfrom
fix/local-embeddings-dim-guard-84
Sep 7, 2026
Merged

leo-aa88 merged 2 commits into
mainfrom
fix/local-embeddings-dim-guard-84

Conversation

@leo-aa88

@leo-aa88 leo-aa88 commented Sep 7, 2026

Copy link
Copy Markdown
Member

Summary

EMBEDDINGS_PROVIDER=local was shipped as a packaging extra and documented, but could never persist a vector. The stored column is Vector(1536), while LocalEmbeddingsProvider returned whatever the model emits — 384 for all-MiniLM-L6-v2, 768 for all-mpnet-base-v2. The mismatched vectors were 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 and /v1/query/similar quietly 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

  • LocalEmbeddingsProvider reads the model's real output width and raises EmbeddingsConfigError (actionable message) when it isn't 1536.
  • validate_embeddings_config() (new) rejects any non-disabled provider with EMBEDDINGS_DIMENSIONS != 1536, 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 before ingesting).
  • Factory split: 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 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.md state the real constraint (common local models don't emit 1536 dims and are rejected loudly).

Acceptance criteria

  • local either works end to end, or fails loudly at startup/ingest with an actionable message.
  • No configuration produces the current silent no-op (dimension mismatch → hard error at startup/ingest; hot path logs at error).
  • README and .env.example reflect the real constraint.
  • Test covering the dimension-mismatch path (model-dim mismatch, dimensions != 1536, missing-extra, and hot-path fail-open).

Testing

  • make lint → All checks passed
  • make test-unit → 911 passed

Closes #84

🤖 Generated with Claude Code

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 leo-aa88 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/0009 and src/db/models.py (Vector(1536) on both log_embeddings and cluster_embeddings). STORED_EMBEDDING_DIMS = 1536 is correct.
  • CONFIRMED — the constant move is safe. store.py re-imports it from provider.py and embeddings/__init__.py re-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; ruff clean.
  • CONFIRMED — validation logic is correct. validate_embeddings_config rejects dimensions != 1536 for any non-disabled provider, and for local constructs the model so a native-width mismatch (384/768) raises; EmbeddingsConfigError propagates while ImportError is re-wrapped. Hot-path get_embeddings_provider fails open to disabled and logs at error. 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.

Comment thread src/api/app.py

try:
validate_embeddings_config(settings)
except EmbeddingsConfigError as exc:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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.
  2. Match the code (my preference): keep logging-and-continue, but reword the README + .env.example to say the API logs the misconfig at error on startup and continues serving non-embedding features, and only ingest --with-embeddings hard-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 leo-aa88 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 / :916 and .env.example: "ingest --with-embeddings hard-exits (non-zero) with an actionable message … and API startup logs the misconfig at error and keeps serving the non-embedding features." That matches ingest.py:74 (raise typer.Exit(1)) and the log-and-continue lifespan handler.
  • My earlier minor QUESTION is also addressed: both files now note that provider=local loads (and may download) the SentenceTransformer model at startup / before ingest.
  • The app.py comment 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.)

@leo-aa88
leo-aa88 merged commit 00d5090 into main Sep 7, 2026
2 checks passed
@leo-aa88
leo-aa88 deleted the fix/local-embeddings-dim-guard-84 branch September 7, 2026 06:28
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.

EMBEDDINGS_PROVIDER=local is non-functional: Vector(1536) column vs 384/768-dim local models

1 participant