Skip to content

A failed embedding is reported to the agent as an empty knowledge base — the one failure RAG cannot afford to state as a fact #628

Closed

Description

@serge-ivo

search_knowledge answers a broken embedder with the sentence "the knowledge base may be empty". That sentence is a fabricated explanation of an infrastructure failure, and it is the one the model reads.

The path (all verified)

embed() swallows everything:

// workers/api/src/agent-storage/vectors.ts:304
protected async embed(text: string): Promise<number[] | null> {
    if (!this.ai) return null;
    try { ... } catch { return null; }   // :320-322
}

grep -in "log\|error\|console" workers/api/src/agent-storage/vectors.ts returns 0 lines inside embed — a Workers-AI outage, a rate-limit, a 500 from the model gateway all become null with nothing written to the error log, the trace, or the console.

vectorSearch turns that null into "no matches":

// vectors.ts:106-109
if (!this.vectorize || !this.ai) return [];
const embedding = await this.embed(query);
if (!embedding) return [];

Two callers consume that empty array, and both state it as a fact about the user's data:

  1. The tool. workers/api/src/lib/storage-tools.ts:262-267 —
    if (results.length === 0) return ok(call.name, "No relevant results found. The knowledge base may be empty or the query didn't match any stored content.")
    Note ok(...), not fail(...). The model is told the search succeeded and found nothing.

  2. The prompt. workers/api/src/agent-storage/context.ts:71-72 —
    const results = await this.vectorSearch(query, 8); if (results.length > 0) { ... }
    On an empty result the entire ## Relevant Knowledge block is omitted. The turn proceeds ungrounded, and nothing in the prompt, the trace or list_errors says retrieval ran at all.

Why this is the read path's version of a bug the write path already fixed

indexingEnabled exists precisely so this cannot happen on ingest:

// vectors.ts:26-31
 * ... so callers MUST check this before reporting a KB add / repo index as "indexed" —
 * otherwise ingest looks green while RAG is dead (#22).
get indexingEnabled(): boolean { return !!(this.vectorize && this.ai); }

grep -rn "indexingEnabled" workers/api/src — the read path never consults it. #22 stopped ingest lying about indexing; the symmetric lie on retrieval was never closed.

Blast radius (live, read over MCP)

  • Repo Chat (267286d5-6877-4f2a-8b5c-3c40b3e3be85) — vector_stats: 315 sources, 3,979 chunks, 1,823,670 chars. Its declared tools are exactly ["search_knowledge","list_knowledge","read_knowledge"], so search_knowledge is its only route to any of it.
  • Every RAG agent shares buildRAGContext: Doc Chat, Local Repo Chat, Google Drive Doc Chat, the apply agent's KB.

An agent told "the knowledge base may be empty" will report that to the user, and the user will believe their ingest failed.

What "fixed" looks like

Distinguish the search returned nothing from the search did not run. embed() should report its failure (a distinguishable return, plus one logError so it is visible in list_errors), vectorSearch should propagate it rather than flattening to [], search_knowledge should fail(...) with "retrieval is unavailable right now" instead of ok(...) with a claim about the corpus, and buildRAGContext should say retrieval was attempted and failed rather than silently omitting the block.

Verified vs inferred

  • Verified: every line and grep above; the Repo Chat numbers are a live vector_stats read on 2026-08-16.
  • Inferred: the frequency of embed failures in production. Not measurable today — that is the point of the issue: there is no record of one ever having happened.

Activity

  1. serge-ivo commented on Aug 15, 2026

    @serge-ivo
    ContributorAuthor

    Fixed in 50192bc (on main, CI green, API Worker deployed).

    What each surface says now

    Situation Before After
    Embedder fails (outage / rate-limit / subrequest cap) search_knowledge → ok: "No relevant results found. The knowledge base may be empty…" search_knowledge → fail: "Knowledge retrieval is unavailable right now — the search could NOT be run, so this is not a statement about what is stored. Do not tell the user their knowledge base is empty…"
    Embedder fails, chat turn ## Relevant Knowledge block silently omitted ## Knowledge Retrieval Unavailable block: "…its absence is not evidence that it does not exist"
    Corpus genuinely has no match ok: "…may be empty or the query didn't match" ok: "No relevant results found. The search ran successfully and matched nothing — the knowledge base may be empty, or the query didn't match any stored content."
    Failure record nothing, anywhere one logError per failure, source rag-retrieval → readable in list_errors and mirrored into the trace

    The empty-corpus case deliberately stays a plain ok. Collapsing both into a generic error would have traded one lie for another, and an empty corpus is a legitimate, common, correct result.

    How it was done

    Mirrored indexingEnabled rather than inventing a second vocabulary, as the issue asked. embed() returns null only when there is no AI binding — precisely what indexingEnabled already reports; every other outcome throws RetrievalUnavailableError. A 200 carrying no vector counts as a failure too: with AI configured, that is a provider failure wearing a success's clothes, which is the same call vectorizeStore has always made.

    vectorSearch propagates it, and now wraps a Vectorize outage the same way. That closes an asymmetry the issue did not name: an embedder outage was silent, while a Vectorize outage propagated raw out of buildRAGContext and 500'd the whole chat turn. Both are now the same honest answer.

    New vocabulary lives in workers/api/src/lib/retrieval.ts so the sentence and the failure report each have one definition.

    Deliberate boundary — please sanity-check this call

    indexingEnabled === false (no Vectorize/AI binding, e.g. PLATFORM_AI_ENABLED off) still returns [], not an error. Reasoning: that is a static property of a deployment on which nothing was ever embedded, so there is genuinely nothing to find — it is not an event that makes a populated corpus look empty, which is the failure this issue is about. #22 already requires the write side to report vectorized: false there. Raising it on every turn of an AI-disabled deployment would be noise. Say the word if you want that case to speak up too.

    Rejected

    Having embed() log its own failure (the issue suggests "plus one logError"). The storage engine has no Env, and its meter — the existing {DB} escape hatch — is null on the storage-route path because withEngine resolves the engine without a userId. So the report is raised by the two callers that do have an Env, which is the shape #22 already uses. The list_errors visibility the issue asked for is delivered; only its location moved.

    Also rejected: leaving the usage ledger inside the embed catch. It was folded in, so a D1 hiccup in recordPlatformUsage discarded a perfectly good vector. It now fails independently of the embedding it is accounting for.

    Verified

    19 tests in workers/api/src/agent-storage-retrieval.test.ts, each watched failing first against the restored old behaviour — 7 did, including expected '' to match /Knowledge Retrieval Unavailable/ for the silent prompt block. Both directions are pinned: a fix that answered every empty result with "retrieval failed" fails this suite too.

    Full bar green (typecheck ×11, worker test typecheck, biome, and all 9 script gates) at the commit, checked in a clean worktree to exclude other lanes' concurrent edits. Playwright e2e skipped — server-side API Worker only, no store/ or agents/coder/web.

    Not verified

    That an embed failure now renders correctly in production: I cannot force a Workers-AI outage without causing one. The success path is verified live instead. The frequency question the issue flags as unmeasurable is now measurable — rag-retrieval in list_errors is the counter that never existed.

  2. serge-ivo commented on Aug 15, 2026

    @serge-ivo
    ContributorAuthor

    Production check after the deploy — partial, and I want the limit on record rather than buried.

    Deploy of 947fe832 to the API Worker completed 22:47:51 UTC; read taken 22:55:54 UTC.

    No regression signal:

    • vector_stats on Repo Chat (267286d5-…) is byte-for-byte unchanged: 315 sources / 3,979 chunks / 1,823,670 chars, lastIndexed still 2026-08-04. The store was not touched by the deploy.
    • list_errors (124 rows, 2026-07-23 → 2026-08-15): occurrences of rag-retrieval = 0. No new error source in the window, and no error at all in the last hour.
    • @cf/baai/bge-base-en-v1.5 shows 243 calls / 8,106 input tokens over 7d with no associated errors — the embedder itself is healthy.

    What that zero does and does not mean. It rules out a deploy-time or startup burst of retrieval errors. It does not confirm the success path, because nothing exercised it: the latest lastActivityAt across all 40 instances is 2026-08-15 21:43 UTC — over an hour before the deploy — and Repo Chat's most recent search_knowledge call was 2026-07-07. Zero errors is currently consistent both with "the fix is fine" and with "no search has run", and the activity data says the second is why.

    So: closed on the strength of the unit tests (19, each watched failing against the restored old behaviour) plus the absence of any regression signal — but the first real search on a live agent is what actually confirms the success path. If anyone runs one, re-checking list_errors for source: "rag-retrieval" turns this into a genuine pass/fail. I'd rather say that than claim a green I did not measure.

    One incidental finding, unrelated to this issue and worth its own ticket if it recurs: list_errors has no source filter, and at 124 rows its response is ~69 KB — enough to overflow a 25k-token MCP host budget in a single call.

  3. serge-ivo commented on Aug 15, 2026

    @serge-ivo
    ContributorAuthor

    Success path verified live — the check 50192bca could not make

    The fix was deployed with the honest caveat that nothing had exercised the search path since (the newest activity across all 40 instances predated the deploy by an hour), so a zero rag-retrieval error count ruled out a deploy-time burst but did not confirm retrieval still worked.

    Run now against 267286d5-6877-4f2a-8b5c-3c40b3e3be85 (Repo Chat — 315 sources, 3,979 chunks, reachable only through this path):

    query: "how does authentication work"   top_k: 2
    
    0.67412  ProAgentStore/platform::docs/connector-auth.md
    0.67031  ProAgentStore/platform::SECURITY.md
    

    Both results are semantically on-target, so embed() → vectorSearch → results is intact end to end. The fix narrowed embed()'s null return to the no-AI-binding case and made everything else throw; this confirms the throw did not swallow the ordinary path.

    Still not exercised, and worth stating rather than implying otherwise:

    • The failure path. Confirming that an embedder outage now returns "the search could NOT be run" rather than "the knowledge base may be empty" requires the embedder to actually fail. It is pinned by the tests that were watched failing against restored old behaviour; it has not been observed in production, and manufacturing an outage is not worth the cost.
    • The genuinely-empty case. The new wording — "The search ran successfully and matched nothing" — needs an instance with no indexed content. Repo Chat is the opposite of that.

    So: one of three surfaces confirmed live, two pinned by test. The one confirmed is the one that would have been most costly to break, since it is the path every Repo Chat question takes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions