Plan: KB retrieval enhancements — operator-tunable, caller-overridable¶
Motivation¶
Currently Retriever.search() runs all three enhancements (expand 3-way + HyDE + rerank)
when an LLM is wired. Each call costs 3 LLM round-trips PLUS expand's amplification
(4 query variants × (1 dense + 1 BM25) per variant = 8 ranked-list passes).
- RCA's
kb_searchalways runs full enhancement — no caller threadsquick=Truefor RCA. - The existing
quick: boolis FE-driven and all-or-nothing. - Operator has zero say in defaults.
User report: 「找超久」.
Goal:
1. Per-knob granularity (expand, hyde, rerank as independent dials)
2. Three-layer override: LLM tool args > Python caller > operator config
3. Operator-defined max ceiling that LLM-set tool args cannot exceed
4. Shipped default = light enhancement, not full enhancement
Final design (locked via grilling 2026-06-05)¶
Config shape¶
kb:
retrieval:
enhancements:
expand: { default: 1, max: 3 } # int: 0 = off
hyde: { default: 0, max: 1 } # int: 0 = off
rerank: { default: true, max: true } # bool: max=false forces off
Knob semantics¶
| Knob | Type | Meaning | Cost |
|---|---|---|---|
expand |
int | Number of alternative query phrasings to generate. 0 = only original query. |
1 LLM call + N × (dense + BM25) per extra variant |
hyde |
int | Number of hypothetical documents to embed + retrieve against. 0 = off. |
N LLM calls + N × dense per field |
rerank |
bool | LLM rerank over the merged candidate set. | 1 LLM call |
Resolution cascade¶
For each knob, effective value:
raw = tool_args[knob] if LLM set it
else caller[knob] if Python caller set it
else operator.default
clamped =
int → min(max(0, raw), operator.max)
bool → raw AND operator.max
Decisions table¶
| Decision | Choice |
|---|---|
| Granularity | Three independent knobs (not "level" preset) |
expand / hyde type |
int (count of generations, 0 = off) |
rerank type |
bool |
| Shipped default | {expand: 1, hyde: 0, rerank: true} — light, not full |
| Override layers | LLM tool args > Python caller > operator config |
| LLM ceiling | Operator max clamps tool-args input |
rerank max |
Same shape as int knobs ({default, max}) — max=false forces off |
Legacy quick: bool (FE composer + context field) |
Replace with structured enhancements payload |
Files touched¶
Schema (src/workspace_app/config/schema.py)¶
- New
EnhancementIntdataclass{default: int, max: int} - New
EnhancementBooldataclass{default: bool, max: bool} - New
EnhancementSettingsdataclass{expand: EnhancementInt, hyde: EnhancementInt, rerank: EnhancementBool} - New
RetrievalSettingsdataclass{enhancements: EnhancementSettings}(parallel toretrieval_llmref — one holds "which LLM", the other holds "how aggressive") KbSettings.retrieval: RetrievalSettings = default_factory(...)— bundled defaults from table above- Keep
KbSettings.retrieval_llmintact
Loader (src/workspace_app/config/loader.py)¶
- Extend
_TOP_SCHEMA["kb"]withretrieval: {enhancements: {expand|hyde|rerank: {default, max}}}nested allow-tree _settings_from_dict: constructRetrievalSettingsfrom merged dict
Retriever (src/workspace_app/kb/retriever.py)¶
- New
Enhancementsvalue object{expand: int | None, hyde: int | None, rerank: bool | None}—None= inherit Retriever.__init__(..., enhancement_defaults: EnhancementSettings | None = None)— holds operator configRetriever.search(query, collection_ids, on_progress, *, enhancements: Enhancements | None = None)— replacesquick: bool- Internal: resolution cascade + clamp before each enhancement branch
expand_queries(n=...)already acceptsn; just thread the resolved value (0 → skip, ≥1 → pass)hypothetical_documentneeds to grownparam OR loop n times (current returns 1 string; forn>1generate n docs)
Factories (src/workspace_app/factories.py)¶
get_retriever(settings, ...)readssettings.kb.retrieval.enhancementsand passes to Retriever asenhancement_defaults
Tool (src/workspace_app/agent/tools.py)¶
kb_search_impl(ctx, query, expand=None, hyde=None, rerank=None)— add 3 optional args- Build
Enhancements(expand=expand, hyde=hyde, rerank=rerank)and pass toretriever.search - Tool schema (FunctionTool JSON) gains the 3 optional args with brief descriptions
Context (src/workspace_app/agent/context.py)¶
- Drop
kb_quick: boolfield - (Operator default lives on the retriever, not the context, so no new context field)
API (src/workspace_app/api/kb_chat_routes.py)¶
- Replace
body.quick: boolwithbody.enhancements: EnhancementsInput | None - Pass-through to retriever via the existing retriever wiring (KB chat builds its own retriever)
- For now, mirror the FE input model in
kb_chat_routes.py; FE migration deferred (see Out of scope)
Tests¶
tests/config/test_schema.py— bundledRetrievalSettingsdefaults; nested types correcttests/config/test_loader.py— operator config overridesdefault/max; unknown nested key raisestests/test_factories.py—get_retrieverwires enhancement_defaultstests/kb/test_retriever.py— resolution cascade (caller > default), clamp (LLM > max → clamped),expand=0skips,hyde=0skips,rerank=falseskipstests/agent/test_tools.py—kb_searchtool args pass-through; clamping enforcedtests/api/test_kb_chat_api.py—body.enhancementswiring
Docs¶
CONTEXT.md— add Enhancements as domain term (resolution cascade + clamp)configs/config.example.yaml— newkb.retrievalsection with comments
TDD execution order¶
- Schema — bundled types (
EnhancementInt,EnhancementBool,EnhancementSettings,RetrievalSettings) +KbSettings.retrieval - Loader —
_TOP_SCHEMA+_settings_from_dict - Retriever —
Enhancementsvalue object +Retriever.searchnew signature + resolution cascade expand_queries/hypothetical_document— threadnthrough (HyDE may need new param)- Factories —
get_retrieverreads from settings - Tool —
kb_searchadds 3 optional args - Context — drop
kb_quick - KB chat routes —
body.enhancements - CONTEXT.md +
configs/config.example.yaml - Full BE + ruff + ty
Out of scope (future)¶
- FE picker — keep current "Quick" toggle as-is in the FE for now (or map it to
{expand: 0, hyde: 0, rerank: false}at the route layer); rich enhancement UI is a separate task - New enhancement types (semantic reranker, etc.)
- Per-collection enhancement overrides — global only
- Per-call cost telemetry (would help operators tune
max)