LearnNewsExamplesServices
Frontmatter
id16846
titleAn operator cannot shrink the embedding batch, so the smallest durable unit is 50 chunks
stateClosed
labels
bugai
assigneesneo-opus-grace
createdAtAug 10, 2026, 1:49 AM
updatedAtAug 10, 2026, 12:51 PM
githubUrlhttps://github.com/neomjs/neo/issues/16846
authorneo-opus-grace
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 10, 2026, 12:51 PM

An operator cannot shrink the embedding batch, so the smallest durable unit is 50 chunks

neo-opus-grace
neo-opus-grace commented on Aug 10, 2026, 1:49 AM

⚠️ Contract authority correction — read the re-cut ledger, not the superseded opening

The early “durable unit is 50 chunks, and it is all-or-nothing” and “three leaf bindings, nothing else changes” paragraphs below describe the pre-review prescription and are superseded. The delivered contract distinguishes normal-failure atomicity from cooperative-yield prefix persistence; enforces positive/non-negative integer domains through the parser and new type tokens; and carries all three leaves through both canonical standalone Compose profiles to both consumers. The Re-cut 2026-08-10 Contract Ledger and rewritten Acceptance Criteria are the current authority. The earlier prose remains visible as review history, not as the shipped contract.

Context

An external plane's Knowledge Base has held count: 0 for two months. The ingest path was traced end-to-end on dev and eliminated as the cause (#16706, Step 4): VectorService.embedChunks batches and upserts incrementally, tenant ingest writes to the canonical collection directly, and a batch failure rolls nothing back. A single successful batch would leave count > 0 permanently.

So the entire two-month deadlock reduces to one target: get one batch through. This ticket is about the fact that an operator on a starved plane cannot make that batch smaller.

The Problem

The durable unit is 50 chunks, and it is all-or-nothing.

embedChunks slices chunksToProcess by batchSize (default 50), calls TextEmbeddingService.embedTexts once with all 50 texts, and upserts only after that single call returns. Internally the provider work is split by batchEmbeddingChunkSize (default 5) — so one durable unit is ten provider calls that must all succeed. Any one of them exhausting its retries fails the batch, and nothing from those 50 chunks persists.

Now compare which of those dials an operator can actually turn, on a running deployment, without our source:

leaf default env override
openAiCompatible.batchEmbeddingChunkSize 5 NEO_OPENAI_COMPATIBLE_BATCH_EMBEDDING_CHUNK_SIZE
openAiCompatible.batchEmbeddingTimeoutMs 300000 NEO_OPENAI_COMPATIBLE_BATCH_EMBEDDING_TIMEOUT_MS
ollama.embeddingTimeoutMs 300000 NEO_OLLAMA_EMBEDDING_TIMEOUT_MS
batchSize 50 none
batchDelay 10000 none
maxRetries 5 none

Every dial that shapes an individual provider call is reachable. Every dial that shapes the durable unit is not. An operator can make each request smaller and more patient, and still cannot reduce the amount of work that has to succeed together before anything is written.

On a healthy plane this is invisible — 50 chunks land fine. On a starved one it is the difference between a corpus that starts and a corpus that stays at zero, because the smallest bet available is fifty times larger than the smallest bet that would prove the pipeline works.

The Architectural Reality

  • ai/mcp/server/knowledge-base/configBase.mjsbatchSize: leaf(50), batchDelay: leaf(10000), maxRetries: leaf(5). Each is leaf(default) with no env argument, so ConfigProvider.#applyEnvLayer has nothing to bind.
  • ai/services/knowledge-base/VectorService.mjsembedChunks reads const {batchSize, batchDelay, maxRetries} = aiConfig and slices the loop by batchSize; the collection.upsert sits after the single embedTexts call, which is what makes the slice the durable unit.
  • Sibling precedent for the fix is in the same file: kbFaqMinCount: leaf(3, 'NEO_KB_FAQ_MIN_COUNT', 'number'), askSynthesisTimeoutMs: leaf(300000, 'NEO_KB_ASK_SYNTHESIS_TIMEOUT_MS', 'number').

The Fix

Give the three leaves their env bindings. Nothing else changes.

batchSize : leaf(50,    'NEO_KB_EMBEDDING_BATCH_SIZE',     'number'),
batchDelay: leaf(10000, 'NEO_KB_EMBEDDING_BATCH_DELAY_MS', 'number'),
maxRetries: leaf(5,     'NEO_KB_EMBEDDING_MAX_RETRIES',    'number'),

This is the sanctioned pattern, not a workaround. ADR-0019 §2.1: "leaf(default, env, type) declares one value … the leaf owns env-override-with-default." No helper, no cascade, no hasEnvValue, no formula, no re-derivation — the resolution machinery already exists and these three leaves simply were never wired into it.

Why all three rather than only batchSize: they are one group with one purpose — the recovery levers for a provider that cannot keep up. batchSize shrinks the bet; maxRetries bounds how long a doomed bet costs; batchDelay (10 s between batches) decides whether a shrunken batch size turns a recovery run into an overnight job. Shipping batchSize alone hands an operator a knob whose obvious setting makes their sync 50× slower with no way to compensate.

Contract Ledger Matrix

Re-cut 2026-08-10 after @neo-gpt-emmy's cycle-2. The original ledger listed three leaf bindings and nothing else, while the delivered change also carries the leaves into two deployment profiles, puts a domain on them, and adds two type surfaces. A ledger narrower than its diff guarantees a review finding, every cycle — that gap is the review cost this ticket has now paid twice.

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
aiConfig.batchSize configBase.mjs leaf env-overridable via NEO_KB_EMBEDDING_BATCH_SIZE, typed positiveInt unset or out-of-domain50 leaf JSDoc env set ⇒ resolved value changes; 0/-1/2.5/NaN ⇒ default
aiConfig.batchDelay same NEO_KB_EMBEDDING_BATCH_DELAY_MS, typed nonNegativeInt0 is legitimate unset ⇒ 10000 leaf JSDoc 0 resolves to 0; -1 falls back
aiConfig.maxRetries same NEO_KB_EMBEDDING_MAX_RETRIES, typed positiveInt. TOTAL attempts, not retries on top of one unset ⇒ 5 leaf JSDoc, corrected the loop is while (retries < maxRetries) from zero
Env.parseIntAtLeast src/util/Env.mjs new — integer-with-minimum env parser; out-of-domain returns undefined so the leaf default stands mirrors parsePort method JSDoc fractional/non-finite/non-numeric matrix, mutation-bound
positiveInt / nonNegativeInt ai/ConfigProvider.mjs new type tokens — parser enforces, validator is advisory unknown token ⇒ passes through inline rationale the parser is the enforcement; #validateLeafValue warns and keeps
kb-server + orchestrator env docker-compose.yml and docker-compose.dev.yml all three leaves pass through as ${VAR:-} in every standalone profile unset ⇒ leaf default inline rationale per-(profile, service, leaf) coordinates, each profile parsed alone

Decision Record impact: aligned-with ADR 0019 — uses the leaf's own env-binding and adds no resolution logic. The new parser/type tokens extend the sanctioned decode layer rather than introducing a second one.

Acceptance Criteria

Rewritten to match what is delivered. The previous five were all ticked while describing a superseded implementation — AC-1's receipt still claimed the leaves proved "the 'number' type argument is present" after the types had become positiveInt/nonNegativeInt, and nothing covered the deployment carry, the domain, or the parser boundary. Ticked criteria that describe an earlier version of the diff are worse than open ones: they read as verified.

  • The three leaves resolve from their env var when set and to their current default when unset. Receipt: config.template.spec.mjsembedding-batch recovery levers are env-overridable.
  • Defaults unchanged — a deployment setting none of the three behaves exactly as today. Receipt: …keep their defaults when no env is set asserts 50 / 10000 / 5 with the vars deleted; 449 passed across ai/deploy/, ai/mcp/server/knowledge-base/ and ai/scripts/lint/.
  • The operational domain is enforced, not merely described. 0 and negatives fall back rather than being accepted as "smaller": a batchSize of 0 is the loop stride, so i += 0 never advances; maxRetries: 0 skips the loop and returns a clean zero-embedded result with no provider call. 0 remains valid for batchDelay, which the runbook's recovery step tells operators to set.
  • The domain check is mutation-bound. Replacing Number.isInteger with Number.isFinite must redden a test. Receipt: it does — the fractional case, 1 failed / 12 passed, verified by running exactly that swap. The previous matrix (0, 0, -1) did not bind it: every value was rejected by both predicates on the < min branch, so the integer check was never why anything failed. Non-finite and non-numeric are covered too, because NaN < min is false and a bounds-only predicate would admit it.
  • The leaves reach every profile that BOOTS, per consuming service. Receipt: EmbeddingBatchLeverReachability.spec.mjs. docker-compose.dev.yml is a standalone parity stack, not an overlay — it previously rendered null/null/null while base and local rendered correctly, and a base+dev render resolved them from base and hid it. Each profile is now parsed alone. Mutation controls: a profile carrying none, a hardcoded value, and an interpolation of a different variable.
  • Values stay operator-overridable, not merely present. Each must interpolate its own variable; a literal is a decision taken away from the operator. Both compose forms are read — base is list form, parity is mapping form, and a reader handling one would report the other as empty.
  • Config-template parity holds. Receipt: ai:lint-config-template-ssot green.
  • No new resolution logic. Receipt: the change is leaf argument lists, one env parser mirroring parsePort, two type tokens, and deployment pass-through. No hasEnvValue, no formula, no consumer-side re-derivation.
  • Prose matches behaviour on the two axes review caught. maxRetries is documented as the TOTAL attempt budget (while (retries < maxRetries) from zero, so 5 buys five calls). The parser/validator boundary is stated truthfully: the parser enforces by returning undefined; #validateLeafValue warns and keeps, as its own JSDoc says. An earlier comment claimed the write choke point rejected — it does not, and a reader who believed it would skip the half that actually protects the consumer.
  • Post-merge: on a plane whose corpus will not start, shrinking NEO_KB_EMBEDDING_BATCH_SIZE is observed to land a batch that 50 could not. [L3-deferred — needs a running plane]

Out of Scope

  • Changing any default. A plane that sets nothing must be unaffected; this ticket adds reachability, not new behaviour.
  • Making the 50-chunk unit itself smaller by construction, or checkpointing inside a batch. That is a real design question and it is not this.
  • Why a provider fails to return embeddings (#16830, #14154) — this cannot fill a corpus, it only lets an operator shrink the bet until one lands.
  • The batch-failure isolation in #16843, which is the sibling concern on the same loop.

Avoided Traps

  • Adding a resolution helper. ADR-0019 §3 A3/A5: the leaf already owns env-override-with-default; a helper is the fingerprint of not understanding leaf().
  • Shipping batchSize alone. Its obvious remedial setting (1) multiplies the number of batchDelay waits by fifty. A knob that creates the need for a second knob is half a fix.
  • Changing the defaults "while we are here". The defaults are right for a healthy plane; the defect is reachability, not the values.

Related

  • #16706 — the epic whose empty-corpus elimination makes "get one batch through" the operative target.
  • #16843 — one failing batch strands the remainder; same loop, adjacent concern.
  • #16830, #14154 — why a provider returns nothing.
  • #16765 — declared-leaf/env diffing across revisions; this adds three leaves it will see.

Live latest-open sweep: latest 20 open issues checked at 2026-08-09T23:50Z plus a scoped title sweep for config/env-knob duplicates; no equivalent found.

Structure map: N/A — modifies ai/mcp/server/knowledge-base/configBase.mjs in place; no file created or relocated.

Origin Session ID: d8332b13-5d97-4839-ac11-d2de4602a989

Retrieval Hint: query_raw_memories("embedding batchSize no env override durable unit fifty chunks operator cannot shrink")

tobiu referenced in commit 3af1b8f - "fix(ai): an operator can shrink the embedding batch (#16846) (#16847) on Aug 10, 2026, 12:51 PM
tobiu closed this issue on Aug 10, 2026, 12:51 PM