Consolidate Specialist Draft Retrieval onto the Hybrid Engine — Design
Date: 2026-06-25 Status: Approved design (Option A′), pre-implementation Issue: #3166 (P1) · Related: eval harness #3091, event collection #3189, KB-role hardening #3190
Problem
When a Specialist reply is drafted, two KB retrievals run and the weaker one reaches the model with system privilege:
- NestJS runs the hybrid + Cohere rerank retriever (
HaystackRetrievalService.search(),conversations.service.ts:2300) — MRR 0.99 (rag/evals/retrieval/BASELINE.md) — and (since #3190) injects it into the prompt as auser-role history turn (prompt-injection hardening). - The agent then also self-fetches keyword-only (
agent/main.py:_load_kb_context→agent/kb_client.py→GET /kb/retrieval,KbRetrievalService) — MRR 0.47 — and renders it viaformat_kb_contextinto the system prompt (agent/chat_helpers.py:232).
Net: redundant double-retrieval per draft, and a weaker-engine KB block sitting in the system prompt.
What #3190 already fixed (changes the scope)
#3190 (merged after #3166 was filed) moved the NestJS hybrid result into a security-hardened user-role turn. So the hybrid result is already delivered to the agent in a good slot. The remaining bug is narrower than the original framing: the agent additionally self-fetches keyword and puts it into the system prompt.
Goal (Option A′ — honor #3190)
Make NestJS's existing hybrid retrieval the single source of the draft's KB context (it already is, in the user-role history turn), and stop the agent from self-fetching keyword. Keep the keyword path as an outage-only fallback. Do not move KB content into the system prompt (that would regress #3190).
This is the minimal change that satisfies the evidence-backed ACs:
- hybrid is the only KB content the model sees (keyword no longer fetched/injected),
- redundant retrieval removed,
- keyword retained as outage fallback,
- #3190's user-role privilege treatment preserved.
The prompt-slot/answer-quality question (#3166 hypothesis 3) remains a deferred follow-up needing an end-to-end eval that does not exist yet.
Approach chosen: A′ (vs original A / B / C)
- A′ (chosen): keep NestJS's hybrid→user-turn injection; agent skips its keyword self-fetch when NestJS signals KB was provided. Smallest change; preserves #3190; removes redundancy and the weak engine.
- A (original): pass hybrid content in a
kb_contextfield that the agent renders as the labeled system-prompt block — rejected: reintroduces KB into system privilege, regressing #3190. - B: agent calls the hybrid
/v1/agent-api/contextendpoint — rejected: leaves NestJS's fetch in place (redundancy) and adds an embed+rerank call on the agent's critical path. - C: agent sniffs KB out of history — rejected: brittle string detection.
Wire contract — kb_context_provided flag
A new boolean on the agent chat request: kb_context_provided: bool = false.
| Value | Meaning | Agent behavior |
|---|---|---|
true | Hybrid retrieval genuinely completed — incl. a real 0-results hit | Skip _load_kb_context (trust hybrid; no keyword self-fetch) |
false / absent | Outage (paused, or circuit-open / failOpen which search() tags degraded) — or a direct non-NestJS agent call | Fall back: self-fetch keyword via _load_kb_context |
Gate on ctx.paused !== true && ctx.degraded !== true — i.e. genuine completion, not results.length. The subtlety: HaystackRetrievalService.search() fails open by returning { results: [], total: 0 } on circuit-open / caught errors, which is otherwise indistinguishable from a genuine 0-results hit. So search() now tags those fail-open returns with degraded: true (and the pause path keeps paused: true); a genuine empty success has neither. This lets us honor both halves of the contract: a real empty hybrid → true (trust it; don't resurrect the weak keyword engine), an outage → false (fall back to keyword). Gating on results.length > 0 would wrongly fall back on real empties; gating on !paused alone would wrongly not fall back on degraded outages.
Data flow (after)
inbound → conversations.service.ts
ctx = haystackRetrieval.search(orgId, query, { specialistId: conv.specialistId, source:"kb", failOpen:true })
completed (not paused, not degraded) → kbContextProvided = true
(results>0 also injected into history as a user turn, #3190)
paused / degraded outage → kbContextProvided = false
POST /v1/chat { ..., kb_context_provided: kbContextProvided }
▼
agent /chat | /chat/stream:
req.kb_context_provided == true → SKIP _load_kb_context (hybrid already in history)
req.kb_context_provided == false → _load_kb_context (keyword self-fetch) // fallback
Components & changes
NestJS — api/src/conversations/conversations.service.ts
- Add
specialistId: conv.specialistIdto thehaystackRetrieval.search()call (~:2300) so the hybrid result is K1/K2-scoped (ADR-008), matching the keyword path's scoping. (Today the call omits it → org-wide; consolidating without this would widen what the Specialist sees.) - Compute
kbContextProvided:truewhen the search was attempted and not paused (!ctx.paused);falseon pause/failure. Keep the existinghistory.unshift({ role:"user", ... })injection unchanged (#3190). - Pass
kbContextProvidedto theorgAgentClient.chat(...)/chatStream(...)call(s) on the draft path. Apply at each draft-producing call site that performs this retrieval (inventory in the plan). - No change to the
kb_retrieval_eventcapture (#3189) —engine:"hybrid"is now truly the served engine; drop the pre-#3166 caveat comment if present.
NestJS — api/src/common/agent.client.ts
- Add
kbContextProvided?: booleantoAgentChatRequest,kb_context_provided?: booleantoAgentV1ChatPayload, and set it inbuildV1ChatPayloadfromreq.kbContextProvided(omit/false-default when unset, so non-draft callers are unaffected).
Agent — agent/models/chat.py, agent/routers/chat.py
- Add
kb_context_provided: bool = Field(default=False, validation_alias=AliasChoices("kb_context_provided","kbContextProvided"))to the v1ChatRequest; map it through_to_legacy_requestonto the legacy request.
Agent — agent/main.py
- Add
kb_context_provided: bool = Falseto the legacyChatRequest. At the two_load_kb_contextcall sites (/chat~:521 and/chat/stream~:847): call it only whennot req.kb_context_provided. When skipped,req.kb_contextstays empty →format_kb_contextrenders nothing into the system prompt → no keyword content; the hybrid content already lives in the user-role history turn.chat_helpers.format_kb_contextis unchanged (already returns "" for empty/None).
Error handling
- NestJS retrieval is already
failOpen:true; on pause/failure it sendskb_context_provided:false→ agent falls back to keyword (degraded but functional). Never surfaces a 500. - Agent fallback path is the existing fail-open
_load_kb_context.
Testing
- NestJS unit:
specialistIdis passed tosearch(); the chat payload carrieskb_context_provided:trueon a normal retrieval andfalsewhen the search reportspaused. (History injection behavior is already covered by existing tests / #3190.) - Agent unit:
_load_kb_contextis not called whenkb_context_providedistrue, and is called whenfalse;format_kb_contextstill renders nothing extra into the system prompt in the provided case. - Eval (AC#4): re-run the
rag-eval-baselineworkflow after the change to confirm the served engine. - Answer-quality eval (AC#5) is an explicit follow-up, not built here.
Out of scope / deferred
- End-to-end answer-quality eval (faithfulness/answer-relevancy) — #3166 hypothesis 3.
- Removing
KbRetrievalService//kb/retrieval— retained as the fallback path. - The unused
/v1/agent-api/contextendpoint — left as-is. - Moving KB content between prompt slots — explicitly not done (preserves #3190).
Acceptance criteria (from #3166)
- The KB content the model uses is produced by the hybrid retriever — keyword self-fetch removed; hybrid (already in the user turn) is the only KB content.
- No redundant second retrieval — agent skips self-fetch when
kb_context_provided. - Keyword retrieval retained as documented outage fallback —
kb_context_provided:falsepath. - Re-run
rag-eval-baselineafter the change. - Answer-quality impact tracked as follow-up (not a blocker).
Note on AC#1 wording: the original AC said "labeled block". Under A′ the hybrid content stays in the #3190-hardened user-role turn (still produced by
formatKbContext, still labeled "Relevant knowledge base context:"), rather than the system-prompt block — a deliberate, more-secure placement. The substance (hybrid is the KB the model sees) is met.