Skip to content

perf(knowledge): overlap search candidate stages - #2312

Open
ohdearquant wants to merge 9 commits into
mainfrom
codex/knowledge-search-concurrency
Open

perf(knowledge): overlap search candidate stages#2312
ohdearquant wants to merge 9 commits into
mainfrom
codex/knowledge-search-concurrency

Conversation

@ohdearquant

Copy link
Copy Markdown
Owner

Summary

  • run the independent ANN query/hydration and lexical candidate stages concurrently for both knowledge.search and knowledge.suggest
  • keep both futures attached to the caller task so the existing absolute read deadline, cancellation, timeout fail-open behavior, eligibility filters, hydration diagnostics, fusion, and rerank guards remain unchanged
  • add controlled paused-clock regressions that prove lexical work starts while ANN is blocked and that a completed ANN result survives a lexical deadline expiry
  • keep query-vector reuse and request caching from perf(knowledge): one request embeds the same query up to seven times, against ADR-051 #2232 out of this change

Validation

  • cargo test -p khive-pack-knowledge — 402 passed, 1 ignored; doctests passed separately
  • cargo check -p khive-pack-knowledge --all-targets --all-features
  • cargo clippy -p khive-pack-knowledge --all-targets --all-features -- -D warnings
  • cargo check --workspace --all-targets
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo fmt --manifest-path crates/Cargo.toml --check --all

Closes #1996

@ohdearquant

Copy link
Copy Markdown
Owner Author

The dependency audit comes back clean, which is the thing that had to be true: the lexical leg never
read an ANN-produced value. Hits, availability and hydration counts are consumed only after the join
(crates/khive-pack-knowledge/src/knowledge/search.rs:2128-2141), candidate vectors and dedup and
score maps are local to their own futures, and the query embedding is a local inside the ANN future
rather than a slot both legs touch.

Two design choices deserve credit rather than silence. join_candidate_stages uses tokio::join!
and keeps both futures attached to the caller task (search.rs:1411-1419) — spawning them would
have detached them from the task-local read deadline and from cancellation, which is the usual way
this refactor goes wrong. And determinism holds: join! preserves source association, lexical
ranking has a slug tie-break, and RRF sorts by descending score then id, so completion order does not
reach the result. That is worth stating explicitly because "results may now vary run to run" is the
default outcome of overlapping two producers, and here it does not happen.

The one real behaviour change is contention that did not previously exist inside a single
request.
Both legs acquire SQL readers, and they now hold them at the same time. Before this diff
only one candidate stage was ever active per request, so the two could not compete with each other.
What makes it worth fixing rather than noting is that the two legs classify the resulting failure
differently:

fn is_read_timeout(e: &RuntimeError) -> bool {
    matches!(e, RuntimeError::Storage(khive_storage::StorageError::Timeout { .. }))
}

(search.rs:523-528). That matches storage read timeouts only, so a pool admission timeout is
not fail-open — on the lexical path it propagates as a hard error. On the ANN path the equivalent
loss converts to an empty AnnCandidateOutcome and, on the generic fallthrough, carries no
ann_unavailable marker.

So under max_readers=1 or a globally saturated pool, the same contention produces two different
caller experiences depending on which leg loses: a verb-level error, or a silently lexical-only
result that is indistinguishable from a query with no vector matches. The second is the worse one,
and this diff is what makes it reachable from within one request. Either keep sequential scheduling
when the reader budget cannot support two legs, or give the ANN-lost-to-admission case its own
caller-visible degradation classification so the quiet path stops being quiet.

Tests cannot distinguish the two completion orders. search_and_suggest_candidate_stages_overlap
(ann_degrade_tests.rs:274) blocks ANN and proves only that lexical starts before ANN is released;
controlled_lexical_timeout_keeps_concurrent_ann_result (:338) explicitly waits for ann_finished
before releasing lexical. Neither arranges lexical-first completion, and none compares the two orders.
The implementation is order-independent today, so this is not a live defect — but it means the suite
would stay green through a future change that accumulates in completion order or leaves score ties
unresolved, and pagination and cached ordering are what break when that happens. Running the same
query twice under deterministic gates forcing each order, with a tie case, and asserting identical
result JSON would close it.

One visibility note. The rename to wall_clock_lexical_timeout_degrades_suggest_without_verb_error
also deletes two assertions from that test — result["total"] > 0 ("ANN candidates fetched before
the lexical stage must still produce results") and the check that the top result is the seeded
vector-backed domain — leaving only degraded.lexical_timeout. In fairness the coverage is not lost:
controlled_lexical_timeout_keeps_concurrent_ann_result asserts ANN survival on the real handler
legs under paused time with rebuild_ann: false, which is deterministic where the old test was
wall-clock. So this is fine on the merits. It is worth calling out only because a rename is the
easiest place for an assertion deletion to pass unread, and the assertions removed were the ones
covering the exact property this diff puts at risk.

@ohdearquant
ohdearquant marked this pull request as ready for review September 1, 2026 16:39

@ohdearquant ohdearquant left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Automated review. Posted by this repository's automated pull-request review pipeline; this is not a human read and does not gate the merge by itself.

Verdict on head 8306c79: REQUEST-CHANGES, 1 blocking finding. Finding details are delivered to the review's recipients rather than posted here. Do not merge this head while blocking findings are outstanding; a pipeline comment on a newer head supersedes this one.

knowledge.search and knowledge.suggest run the ANN and lexical candidate
stages concurrently under one shared request deadline. When the ANN leg
was the one to exhaust that deadline, the joined result carried no record
of it: the outcome defaulted to an empty, "healthy" state indistinguishable
from an ANN leg that legitimately found nothing. If the lexical leg had
already produced hits, the post-processing that follows treated the
request as undegraded and re-touched the now-expired deadline anyway (a
fresh embedding rerank in both verbs, plus a final deadline check in
search), turning a successful lexical search into a verb-level timeout
instead of returning the hits already in hand.

The ANN candidate outcome now carries its own timeout flag, set only on
the timeout arms of the ANN helper. Both verbs combine it with the
lexical leg's timeout flag into one degraded condition that gates the
rerank, the read-heavy post-processing, and the final deadline check —
mirroring the fail-open path the lexical timeout already used — and
report the ANN timeout alongside the existing lexical one in the
response's degraded metadata.

Also replaces a regression test that asserted on this file's literal
source text (call-site spellings and occurrence counts) with a
behavioural test: a fake embedder that returns different vectors for the
query role versus the generic role, asserting on which candidate wins
the fused ranking.

@ohdearquant ohdearquant left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Automated review. Posted by this repository's automated pull-request review pipeline; this is not a human read and does not gate the merge.

Verdict on current head: APPROVE, zero blocking findings. This is a comment, not an approval — a human reviewer decides whether to approve and merge.

@ohdearquant ohdearquant closed this Sep 3, 2026
@ohdearquant ohdearquant reopened this Sep 3, 2026
Overlapping the vector and lexical candidate stages moved the vector
search's read-deadline timeout onto a new arm that discarded any
candidates it had already found, so a request whose deadline expired
mid-search could return zero results even after real work completed.
The vector candidate leg now carries forward whatever its most recently
finished round produced when the deadline expires, matching the lexical
leg's existing partial-result behavior, so a degraded response reports
whatever either side actually found instead of failing open to nothing.

Also tightens the stage-overlap regression test, which could pass for a
lexical-first sequential handler due to a notification signal fired
before anyone was listening for it, and extends the query-role coverage
added for the overlap work to the third call site in section-scoring.

@ohdearquant ohdearquant left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Automated review. Posted by this repository's automated pull-request review pipeline; this is not a human read and does not gate the merge by itself.

Verdict on head 6c9e66d: REQUEST-CHANGES, 1 blocking finding. Finding details are delivered to the review's recipients rather than posted here. Do not merge this head while blocking findings are outstanding; a pipeline comment on a newer head supersedes this one.

…times out

search_eligible_ann_with_refill widens the ANN candidate pool across rounds
until enough eligible hits are hydrated or the request deadline expires. When
the deadline expired right after a round's hydration call returned, the
timeout branch returned that round's own hit set, even when its hydration had
just failed (a missing row, or a reader-acquisition failure that strips every
shell in the round) and an earlier round had already produced real, hydrated,
eligible hits. That could hand back fewer hits than a prior round, or none at
all, discarding completed work just because the deadline was noticed late.

The deadline branch now unions the current round's hits with the previous
round's, keeping the previous round's copy for any duplicate id, sorts by
score, and truncates to the eligible target, so a hydration timeout on a
later round can never erase a round that already completed.

Adds a regression test that forces a first round to complete with two real,
hydrated hits, fails the second round's hydration outright via a new
test-only fault hook, and expires the deadline immediately after — asserting
the two first-round hits survive with timed_out set.

@ohdearquant ohdearquant left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Automated review. Posted by this repository's automated pull-request review pipeline; this is not a human read and does not gate the merge by itself.

Verdict on head e3db0d3: REQUEST-CHANGES, 1 blocking finding. Finding details are delivered to the review's recipients rather than posted here. Do not merge this head while blocking findings are outstanding; a pipeline comment on a newer head supersedes this one.

… the hydrator at its seam

The ANN refill loop's deadline branch only carried the immediately
preceding round's hits forward. If an earlier round produced valid
hydrated hits, a later round's hydration then failed for a reason
unrelated to the deadline (clearing that round's hits), and a
subsequent round hit the request read deadline, the earlier round's
hits were lost even though they had already been safely hydrated. The
loop now accumulates every hit that has hydrated successfully in any
completed round, keyed by id, and the deadline branch returns from
that accumulator instead of just the last round. A round that fails
hydration for a non-deadline reason contributes no hits and therefore
cannot evict an id an earlier round already placed in the accumulator.
On a duplicate id the most recently hydrated round's entry wins, since
ids are re-read from the canonical store every round and ANN scores
are deterministic per id.

The test-only hydration fault used to model this failure bypassed the
hydrator entirely, clearing hit shells directly in the refill loop.
It now lives inside the hydrator's own reader-acquisition seam,
substituting a synthetic storage timeout so the failure count and the
shell-stripping come from the hydrator's real error-handling branch
rather than a duplicate of it.

The deadline branch's fallback also sorted its full merged batch before
truncating to the eligible target. It now selects the top entries with
a bounded partition and sorts only the kept prefix, so the discarded
tail no longer pays for a full sort.

@ohdearquant ohdearquant left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Automated review. Posted by this repository's automated pull-request review pipeline; this is not a human read and does not gate the merge by itself.

Verdict on head 979b745: REQUEST-CHANGES, 1 blocking finding. Finding details are delivered to the review's recipients rather than posted here. Do not merge this head while blocking findings are outstanding; a pipeline comment on a newer head supersedes this one.

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.

knowledge: run ANN and lexical candidate stages concurrently to stop serializing the read deadline

1 participant