fix(embeddings): fail loudly instead of storing zero vectors when embedding fails#32
Merged
Merged
Conversation
…eckpoint recoverable
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
An embedding provider that fails must not be allowed to substitute a zero vector. Chroma accepts a zero vector and then reports cosine distance 1.0 against every document, so
score = 1/(1+distance)collapses to a constant 0.5: every query returns the same passages, in arbitrary order, while the API reportssuccess: true.This makes the failure loud at both ends — the write path and the read path — and makes an already-poisoned knowledge base visible.
Operational note
Retrieval is currently broken at runtime regardless of this change. The configured embedder is OpenAI
text-embedding-3-largeand its key's quota is exhausted (429 insufficient_quota). The operator must restore the quota, or setknowledge_base.embedding_modelto a local model such asall-MiniLM-L6-v2, before live retrieval works at all. Before this change that condition was silent; after it, it is an error.What was actually wrong
The reported per-item zero-fill (
embedding_item_skipped) lives on the branchasbb/p0-kb-doc, introduced by77f0f331, and is not onmain. Onmain,LiteLLMEmbeddingProvider.embedalready re-raises. Whatmaindid have:add_documentsrejectedNoneembeddings but stored zero vectors without complaint. Nothing in the codebase checked a vector's norm on either the write or the read side.searchpassed a zero-norm query straight to Chroma, which returns distance 1.0 for every document rather than raising. Confirmed empirically againstchromadb1.5.9: a zero vector on either side is indistinguishable at the score layer.add_papersswallowed every per-paper exception, so even a raised provider error surfaced asadded_chunks: 0with a200.LiteLLMEmbeddingProvider.embedand the sentence-transformer provider drop empty texts, returning a list shorter than their input.screening.pyindexed that list positionally against paper boundaries, so one empty reference abstract silently scored candidates against the wrong paper.Changes
llm/embeddings.py: addEmbeddingFailedErrorandis_zero_vector. The provider protocol now states the contract: raise on failure, never substitute a zero vector; a zero vector is only valid for input that is itself empty.retrieval/chroma_store.py:add_documentswraps the provider call, rejects a vector count that does not match the text count, and rejects a zero-norm vector produced for a non-empty chunk.searchrejects a zero-norm query embedding. It is the only caller ofcoll.queryin the codebase, so this covers every retriever.rag/dynamic_kb.py:add_papersre-raisesEmbeddingFailedErrorinstead of swallowing it. Ordinary per-paper errors (an unparseable PDF) are still tolerated.search/screening.py: refuse to score when the reference vectors are misaligned with the reference texts.web/app.py: one exception handler turnsEmbeddingFailedErrorinto502 {"success": false, ...}for every route, rather than a bare 500.web/routers/kb.py:GET /api/kb/{name}/statsreturns anembedding_healthblock (probed_chunks,zero_vector_chunks,degraded) from a bounded sample, so a poisoned KB is visible without reading Chroma by hand.Empty input is still handled exactly as before:
embed(["", " "])returns zero vectors, and that remains legal.Verification
tests/unit/test_embedding_degeneracy.py(12 tests): a raising provider aborts the ingest and writes nothing; a zero vector for a non-empty chunk is rejected and nothing is persisted; a short vector list is rejected; a zero-norm query raises rather than returning uniform scores; a healthy query still searches; empty strings stay graceful;add_paperspropagates an embedding failure but still tolerates a bad PDF._probe_embedding_healthexercised against real Chroma collections: healthy →degraded: false; all-zero →2/2 degraded; mixed →1/2 degraded; empty →0.ruff: no new findings against themainbaseline for the touched files.mypy src/fails identically before and after this change (5 errors originating in numpy stubs, because[tool.mypy] python_version = "3.11"disagrees with the interpreter). Pre-existing, not addressed here.Follow-up: holes found by adversarially reviewing this PR
Raising an error only helps if nobody swallows it, and guarding one write path only helps if there is no second one. A review pass found four defects in the first version of this branch; all are fixed here, each with a test that fails without the fix.
The read-side guard was swallowed by every fan-out.
MultiKBRetriever.search,query_chunks_across_collections,AdvancedRAGMode._wrrf_retrievaland bothDeepResearchretrieval paths wrap each per-collectionsearchinexcept Exception: ... continue. The query embedding is computed once and reused for every collection, so a degenerate query made every collection fail and every failure was dropped — returning an empty result withsuccess: true. That is the same silent degradation this PR exists to remove, and the single-KB path of the same MCP tools already returnedsuccess: false. Each site now re-raisesEmbeddingFailedErrorahead of its broad handler; an ordinary missing-collection failure is still tolerated.The write-side guard was bypassed by callers that pre-embed.
add_documentsonly validated vectors it produced itself, butcapsule_builder,capsule_readerandlocal_docssetchunk.embeddingbefore calling it. Those vectors went straight tocoll.add. Every vector about to be written is now screened.An empty chunk could still store a zero vector. A chunk with no text and no title composes to an empty embedding text, and
CachedEmbeddingProvider— which is in the production factory — returns an aligned zero vector for it. The rule is now simply that no zero vector is ever stored: for a text with content it means the embedder failed, and for an empty text the chunk has nothing to retrieve.Re-raising broke a resumable ingest.
ingest_dois_into_kbmarks each DOI"added"the moment it is retrieved, before the batch is embedded, andremaining_ids()never re-offers an"added"id. Previously the swallow let the run finish and delete the checkpoint; the re-raise aborted and left those DOIs marked as ingested for ever. On an embedding failure the marks are now undone, so a resume retries them. Chroma ignores duplicate ids, so re-ingesting any paper that did land is harmless.Known consequence, not fixed here
add_paperswrites per paper, so an embedding failure part-way through a batch leaves the earlier papers in Chroma while the caller'spaper_count/chunk_countupdates are skipped (github_kb.py,zotero_ingest.py,search_to_kb.pyall update counts after the call returns). The stored vectors are valid, andGET /api/kb/{name}/statsrecomputeschunk_countfromcoll.count(), so the visible numbers stay honest.bibtex_kb.pyalready handles this by deleting the collection and re-raising. Making the other three consistent is a separate change.Suite after these fixes: 2418 passed, 0 failed.
Not addressed
mcp/server.pystill builds its embedding provider withuse_local_fallback=True, so a remote failure falls back to 384-dim MiniLM while the KB is stamped with the 3072-dim primary model name.web/state.pyalready disables this deliberately, with a comment explaining the hazard. Worth a separate change.asbb/p0-kb-docis fixed separately on that branch; merging it unfixed would reintroduce the silent write, which the newadd_documentsguard would then catch.