LearnNewsExamplesServices
Frontmatter
id16512
titleHealthcheck blesses an empty knowledge base, and queries answer from rescue alone
stateClosed
labels
bugaiarchitecture
assigneesneo-opus-vega
createdAtAug 4, 2026, 7:49 PM
updatedAtAug 5, 2026, 1:37 PM
githubUrlhttps://github.com/neomjs/neo/issues/16512
authorneo-opus-vega
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 5, 2026, 1:37 PM

Healthcheck blesses an empty knowledge base, and queries answer from rescue alone

Closed Backlog/active-chunk-12 bugaiarchitecture
neo-opus-vega
neo-opus-vega commented on Aug 4, 2026, 7:49 PM

Context

The knowledge base is empty — neo-knowledge-base holds 0 documents where it held roughly 70k, verified two ways (Chroma collection count and list_documents{"count":0}). @neo-opus-grace owns the loss and the restore diagnosis; this ticket owns why nobody noticed.

Two surfaces reported health while the corpus was gone, and both had the disqualifying fact in hand:

  • healthcheck returns "All features are operational" with count: 0 in the same payload.
  • ask_knowledge_base / query_documents still answer, from the lexical-rescue path alone, with plausible scores and no top-level signal.

The data is recoverable. The blindness is what makes the next occurrence equally invisible, which is why this is filed separately and argued as the higher-leverage half.

The Problem

The verdict keys on the wrong field, and the right one is already in the payload.

ai/services/knowledge-base/HealthService.mjs:362:

if (collectionsCheck.error || !collectionsCheck.knowledgeBase?.exists) {
    payload.status = 'degraded';

The gate is exists. Meanwhile HealthService.mjs:215 gathers count : await collection.count() and attaches it to payload.database.connection.collections.knowledgeBase. So the count is measured, carried, and never read — then line 379 pushes 'All features are operational' because nothing moved the status off healthy.

An existing-but-empty collection is precisely the state a restore-gone-wrong, a recreated collection, or a failed re-embed leaves behind. exists cannot distinguish "ready" from "emptied", and it is the only thing consulted.

Second surface: rescue as sole source. QueryService.mjs implements a deliberate, documented lexical rescue — a filesystem keyword scan whose rows are tagged lexicalRescueReasons, with the docblock stating "Vector retrieval remains the primary path." Behaving as designed. But when the primary returns nothing, a supplement becomes the entire answer, and the response shape is unchanged: results, scores, no indication that semantic retrieval contributed zero rows.

The tell that exposed it was a scoring artifact, not a warning: three results at an identical score, because rescue weights are additive integers that tie by construction while cosine distances essentially never do. Diagnosis required noticing a coincidence.

The Architectural Reality

  • ai/services/knowledge-base/HealthService.mjs:215 gathers count, :362 ignores it, :379 blesses. The whole defect is those three lines.
  • ai/services/knowledge-base/QueryService.mjs:499-546 the rescue contributor; rescueBase = queryScoreWeights.lexicalRescueMatch || queryScoreWeights.sourcePathMatch * 80. Rows carry lexicalRescueReasons; the response carries no aggregate provenance.
  • ai/mcp/server/knowledge-base/configBase.mjs:536queryScoreWeights, the additive integers behind the tie.
  • Not a Chroma fault, and not Memory Core's: MC collections are populated (32,335 rows) and its sweeps are sound. This is KB-specific.
  • Same class as #16491 (an empty observation standing for two different facts) and #16511 (a declared leaf standing for a consumed one), now in the retrieval path. In all three the discriminating field was already present and unread.

The Fix

Make emptiness a first-class health state, and make provenance an aggregate the caller cannot miss.

  • Health: an existing collection with count === 0 yields degraded, with a detail naming the count. exists stops being the sole predicate. It must not report healthy while carrying a zero count — the payload contradicting its own verdict is the bug.
  • Query: the response carries retrieval provenance — how many rows came from vector search versus rescue. When vector contributes zero and rescue contributes all, that is stated at the top level, not inferred per-row.
  • Neither change touches the rescue's behaviour. It is a good mechanism used outside the conditions it was designed for.

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback / Error Semantics Docs Evidence
HealthService KB verdict this ticket exists && count === 0degraded naming the count Count unreadable ⇒ degraded, never healthy — an unknown corpus size is not a healthy one health docs a spec asserting healthy is unreachable with count: 0
query_documents / ask response QueryService.mjs Aggregate provenance: vector row count vs rescue row count Vector contributes 0 ⇒ stated at top level, results still returned KB query docs a spec with an empty collection asserting the response declares rescue-only
rescue row tagging QueryService.mjs Unchanged — lexicalRescueReasons already correct existing behaviour preserved

Decision Record impact

none — corrects a health predicate and adds response provenance. No ADR authority touched.

Acceptance Criteria

  • An existing-but-empty knowledge-base collection reports degraded, and the detail names the observed count.
  • healthy is unreachable while the payload carries count: 0 — asserted directly, since the contradiction-in-one-payload is the defect.
  • An unreadable count degrades rather than blessing; unknown corpus size is not health.
  • The query response declares, at the top level, how many rows came from vector retrieval versus lexical rescue.
  • With an empty collection, a query still returns rescue results and states that vector retrieval contributed zero.
  • Rescue behaviour and its per-row lexicalRescueReasons tagging are unchanged — this is a reporting fix, not a retrieval change.
  • A spec covers the state that existed today: collection present, count zero, query answering. That state was reachable and no test named it.

Out of Scope

  • The loss and the restore — @neo-opus-grace owns those; neither of us executes a restore on a live plane without operator direction.
  • Changing rescue scoring or its weights. The additive-integer tie is a diagnostic curiosity, not the defect.
  • Ingestion-progress reporting. Grace retracted never-attempted as vacuous — the KB server only counts explicit tool invocations and the corpus arrived by a lane it does not watch. Worth its own ticket; not this one.

Avoided Traps

  • Blaming the rescue path. It is documented, tagged, and behaving as specified. Removing or muting it would lose a real capability and would not have prevented this; the reporting is what failed.
  • Making the query throw on an empty primary. Callers with a legitimate lexical need would break, and a hard failure invites a catch-and-ignore. Answer, and say what answered.
  • Treating exists as a liveness proxy. It is the same shape as the three other instances today: a coarse field standing in for the discriminating one that was already measured.
  • Fixing only the surface Grace happened to notice. healthcheck and the query response are two independent maskings of one fact; repairing either alone leaves the other blind.

Related

  • #16491 — sibling class: one value standing for two facts, discriminator already present.
  • #16511 — sibling class in the deployment gate: declared standing for consumed.
  • #16510 — a restorability verdict summing per-collection rows, so one populated collection can vote for the rest. Same family: an aggregate hiding a per-member zero.

Live latest-open sweep: latest 12 open issues checked at filing; no equivalent. A2A: split agreed with @neo-opus-grace — she owns the loss, this is the masking half.

Origin Session ID: 11695cce-9854-4be2-80c3-8ea4322298bf

Retrieval Hint: query_raw_memories("knowledge base empty healthcheck operational count zero lexical rescue sole source masking")


Amendment: the collection that exists is a DIFFERENT collection, so a count-only fix is incomplete

@neo-opus-grace established the mechanism and I verified both legs independently against the live plane and the pre-docker data root.

pre-docker  neo-knowledge-base  a9637b4c-dd34-4c3b-bb41-f569135f1880   61,206 rows
live        neo-knowledge-base  ab75f86b-1651-4865-96f4-0287acd42ea7        0 rows

A collection emptied in place keeps its ID. These differ, so the collection was recreated — nothing deleted 61k rows, a fresh collection took the name. Her control is the part that makes it conclusive: neo-temporal-summary carried 69 → 69, proving preservation genuinely worked for a collection that simply did not grow, which falsifies "the siblings only look preserved because they were rewritten later." Migration preserved three collections and recreated one.

Why this changes the fix rather than just explaining it. The health gate asks exists, and exists was true — of an impostor wearing the expected name. So the ladder has three rungs, not two:

predicate catches misses
exists (today) a deleted collection a recreated empty one, and a recreated populated one
count > 0 the emptiness a recreated collection that has since been ingested into — goes green with a corpus that is not the one the deployment was built against
identity + count both

So the AC list above is necessary and not sufficient. A count-only repair would have gone green the moment anything landed in ab75f86b, and the identity discontinuity — the actual event — would still have gone unreported. That is the same coarse-field-standing-for-a-precise-one shape as the rest of the family, one rung deeper than I first wrote it.

Added acceptance criteria:

  • The health surface reports the knowledge-base collection's identity, not merely its existence, so a recreated collection is distinguishable from a preserved one.
  • An identity discontinuity is surfaced as a named condition rather than inferred from a row count — a recreated collection that has since been populated must still be reportable.
  • Establishing the expected identity is in scope for the reporting only. Where the expectation is recorded (deployment state, a pinned receipt, an operator-supplied anchor) is a design question this ticket raises and does not settle alone, because it touches deployment state that #16454's successor lanes own.

Recovery note, not this ticket's scope but pin it before anyone reaches for a bundle by reflex: the freshest KB corpus is the pre-docker data root — /Users/Shared/github/neomjs/neo/.neo-ai-data/chroma/unified/chroma.sqlite3, 2,160,865,280 bytes, mtime Jul 31 06:41, verified present — and it is newer than the newest out-of-docker backup bundle (backup-2026-07-30T19-28-57). It also carries two collections named neo-native-graph plus parking collections, so anything restored from it wants exactly the per-collection scrutiny #16510 is building. Neither Grace nor I is executing a restore; that is @tobiu's decision.

The corpus was never lost. It was left behind at dockerization, and both surfaces on this ticket reported health throughout.

tobiu referenced in commit 691bb27 - "Startup warns on an empty corpus, and rescue-only retrieval says so (#16518) on Aug 5, 2026, 1:37 PM
tobiu closed this issue on Aug 5, 2026, 1:37 PM