refactor(search): 죽은 search-cache + search --explain scaffold 제거

in-process LRU search cache 는 spine 재작성(#214)에서 이미 삭제됐는데
그 비계(scaffold)만 남아 있었다. 잔존물 제거:

- App::search/search_uncached 붕괴 (search() 는 1줄 위임자였음)
- search_uncached_with_config facade + 그 테스트 삭제
- 죽은 `search --no-cache` / `search --explain` CLI 플래그 제거
  (ask --explain 은 live — 건드리지 않음)
- 안 읽히던 RagCfg.explain_default config 필드 + fixture 제거
- 제거된 표면을 가리키던 stale 주석/문서(citation_helper/hybrid/
  search·app-facade README/DOGFOOD/HANDOFF) 정정

코어 검색 출력은 byte-identical (search_uncached 본문이 search() 로
verbatim 이동). `search_cache: false` wire capability 는 유지 — 제거 시
schema.v1 breaking bump 이라 손해. 적대적 검증 2렌즈(correctness-preserved
+ wire/config-safe) 통과, dead-symbol-complete 가 잡은 5건 주석/문서 정정 완료.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Mc6W1fgsrbFKTsqA6P8La
This commit is contained in:
2026-06-26 17:12:55 +00:00
parent fd3b4ae3d2
commit db9090c87b
17 changed files with 22 additions and 92 deletions

View File

@@ -80,7 +80,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-07)** — Markdown title fallback chain. `kebab-normalize::derive_title(frontmatter_title, &[Block], file_stem)` — 1) frontmatter title → 2) 첫 H1 → 3) 첫 H2 → 4) 첫 paragraph 80 chars → 5) 파일 stem (모든 단계 NFC 정규화, 빈 문자열 절대 반환 안 함, 마지막 sentinel `"untitled"`). `build_canonical_document` 가 lift 후 helper 호출. parser_version 상수 `pulldown-cmark-0.x``md-frontmatter-v2` bump — 기존 doc 은 `doc_id` 가 갱신되므로 다음 ingest 가 자동 재처리 (idempotent upsert, design §9 cascade). spec: `tasks/p9/p9-fb-07-md-title-fallback.md`.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-08)** — TUI search async worker + generation counter. 기존 200ms debounce 후 `kebab_app::search_with_config` 동기 호출이 vector/hybrid 모드 50-200ms 동안 UI freeze 시키던 문제 해소. `SearchState``generation: u64` + `worker_thread: Option<JoinHandle>` + `worker_rx: Option<Receiver<SearchWorkerMessage>>` 신규. `fire_search` 가 spawn 만 하고 즉시 return — worker 가 별 thread 에서 검색 후 `(generation, Result)` 를 channel 로 post. run loop 가 매 tick `poll_worker` 로 try_recv, generation 일치 시 hits 적용 / 불일치 시 silently 폐기 (사용자가 더 빠르게 타이핑하면 stale 결과 자동 drop). debounce_due 가 `searching && last_query == 현 input` 케이스 추가 skip — in-flight worker 의 결과 기다리는 동안 동일 query 재 spawn 안 함. spec: `tasks/p9/p9-fb-08-search-debounce.md`.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-05)** — `workspace.root` path policy 명확화. `kebab_config::expand_path_with_base(raw, data_dir, base_dir) -> PathBuf` 신규 — 기존 `expand_path` (tilde + env 만) 위에 relative path resolution 추가, 절대/`~`/`${VAR}` 입력은 base_dir 무시. `Config.source_dir: Option<PathBuf>` 필드 (`#[serde(skip)]`) 신규 — `from_file` / `load``path.parent()` 로 stamp. `Config::resolve_workspace_root()` helper 가 `expand_path_with_base(&workspace.root, "", source_dir.unwrap_or(cwd))` 호출. kebab-app + kebab-source-fs 의 모든 `workspace.root` 사용 사이트가 `cfg.resolve_workspace_root()` 로 통일 — kebab-source-fs 의 fork 된 `expand_tilde` 헬퍼는 제거 (kebab-app 의 `storage.data_dir` 한 곳만 남음, P+ 통일 caveat). `kebab init` 가 생성하는 `config.toml` 위에 path policy 안내 헤더 코멘트 자동 prepend (절대/tilde/env/상대 + 상대 base = config dir). spec: `tasks/p9/p9-fb-05-config-path-policy.md`.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-19)** — In-process LRU search cache + `corpus_revision` 카운터. SQLite V004 migration 으로 `kv (key TEXT PK, value TEXT)` 테이블 + `corpus_revision = '0'` seed. `SqliteStore::corpus_revision()` / `bump_corpus_revision()` 메서드 (`UPDATE ... CAST AS INTEGER + 1` 으로 atomic). `kebab-app::ingest_with_config_cancellable``new + updated > 0` 시 bump — no-op reingest 는 cache 보존. `App.search_cache: Option<Mutex<LruCache<SearchCacheKey, Vec<SearchHit>>>>` (capacity from `config.search.cache_capacity`, default 256, 0 = 비활성). `SearchCacheKey` = `query_norm` (NFKC + trim + lowercase) + `mode` + `k` + `snippet_chars` + `embedding_version` + `chunker_version` + `corpus_revision` snapshot. `App::search` 가 lookup → miss 시 `search_uncached` → put. `search_uncached_with_config` facade 추가, CLI `kebab search --no-cache` 로 bypass (디버깅용). frozen design §9 versioning 표에 `corpus_revision` row 추가. spec: `tasks/p9/p9-fb-19-search-cache.md`.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-19)** — In-process LRU search cache + `corpus_revision` 카운터. SQLite V004 migration 으로 `kv (key TEXT PK, value TEXT)` 테이블 + `corpus_revision = '0'` seed. `SqliteStore::corpus_revision()` / `bump_corpus_revision()` 메서드 (`UPDATE ... CAST AS INTEGER + 1` 으로 atomic). `kebab-app::ingest_with_config_cancellable``new + updated > 0` 시 bump — no-op reingest 는 cache 보존. `App.search_cache: Option<Mutex<LruCache<SearchCacheKey, Vec<SearchHit>>>>` (capacity from `config.search.cache_capacity`, default 256, 0 = 비활성). `SearchCacheKey` = `query_norm` (NFKC + trim + lowercase) + `mode` + `k` + `snippet_chars` + `embedding_version` + `chunker_version` + `corpus_revision` snapshot. `App::search` 가 lookup → miss 시 `search_uncached` → put. `search_uncached_with_config` facade 추가, CLI `kebab search --no-cache` 로 bypass (디버깅용). frozen design §9 versioning 표에 `corpus_revision` row 추가. spec: `tasks/p9/p9-fb-19-search-cache.md`. **→ 후속(spine #214 + cleanup): LRU search cache 자체가 제거됨 — `App.search_cache`/`SearchCacheKey`/`App::search_uncached`/`search_uncached_with_config` facade/`search --no-cache` 플래그 모두 삭제. 이 항목은 이력이며 캐시·플래그는 더 이상 존재하지 않음 (`search_cache: false` wire capability 만 정직하게 유지). `corpus_revision` 카운터는 incremental ingest 용도로 잔존.**
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-17)** — Multi-turn chat session 영속화 (storage 만 — UI 는 p9-fb-18). SQLite V005 migration (spec 의 V004 가 p9-fb-19 의 kv 와 충돌해서 V005 로 시프트, HOTFIXES) 으로 `chat_sessions` (session_id PK + created_at + updated_at + title + config_snapshot_json) + `chat_turns` (turn_id PK + session_id FK ON DELETE CASCADE + turn_index + question + answer + citations_json + created_at, UNIQUE(session_id, turn_index)) + `idx_chat_turns_session` 추가. `kebab_core::ChatSessionRepo` trait 6 메서드 (create_session / get_session / list_sessions / delete_session / append_turn / list_turns) + `kebab_core::{ChatSessionRow, ChatTurnRow}` 신규 export. `kebab-store-sqlite::SqliteStore` impl (별 `chat_sessions.rs` 모듈) — append_turn 이 insert + parent updated_at bump 을 같은 conn 에서 처리. frozen design §5 storage 에 §5.7a chat_sessions/turns 절 신설. spec: `tasks/p9/p9-fb-17-chat-session-storage.md`. unblocks p9-fb-18 (CLI session/repl).
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-18)** — CLI `kebab ask --session <id>` (multi-turn). p9-fb-17 의 ChatSessionRepo 위에 `kebab-app::App::ask_with_session(session_id, query, opts) -> Answer` 메서드. 첫 호출 시 자동으로 `chat_sessions` row 생성 (title = 첫 question NFC trim 40 chars), 이후 호출은 `list_turns` 로 prior history 받아 `RagPipeline::ask_with_history` 호출 + 새 turn append. `App` 의 helper: `first_question_title(question)` (NFC + trim + 40 char cap, fallback `"untitled"`) + `blake3_truncate(input)` (32-hex `turn_id` 생성). facade `kebab_app::ask_with_session_with_config` + CLI `--session <id>` flag 추가. `--repl` 은 spec 명시 사항이지만 stdin loop fixture 부담 으로 후속 task 로 deferral (out of scope per HANDOFF). spec: `tasks/p9/p9-fb-18-cli-ask-session-repl.md`.
- **2026-05-04 P9 post-도그푸딩 (p9-fb-23)** — Incremental ingest. 사용자 도그푸딩 피드백: 변하지 않은 문서는 다시 ingest 하지 않기. blake3 checksum + parser_version + chunker_version + embedding_version 4개 input 이 모두 일치할 때 parse/chunk/embed/vector upsert 모두 회피. SQLite V006 마이그레이션 — `documents``last_chunker_version` + `last_embedding_version` 컬럼 추가. 신규 `IngestItemKind::Unchanged` variant + `IngestReport.unchanged` + `AggregateCounts.unchanged` (wire schema additive). `IngestOpts { progress, cancel, force_reingest }` struct 도입 — `AskOpts` 패턴. `--force-reingest` CLI flag 로 skip 우회. 비용 dominator (fastembed) 가 변경된 / 새 doc 에만 발생. spec: `tasks/p9/p9-fb-23-incremental-ingest.md`. HOTFIXES `2026-05-04 — p9-fb-23` 항목이 version cascade 명시 동작의 source of truth.

View File

@@ -250,13 +250,6 @@ impl App {
/// — long-lived callers (kb-eval, future TUI) get amortized cost
/// across calls.
pub fn search(&self, query: SearchQuery) -> Result<Vec<SearchHit>> {
self.search_uncached(query)
}
/// p9-fb-19: bypass the LRU cache and run the search directly.
/// Used by `--no-cache` CLI invocations and by `search` itself
/// on cache miss. Identical behavior to the pre-fb-19 `search`.
pub fn search_uncached(&self, query: SearchQuery) -> Result<Vec<SearchHit>> {
let mut hits = match query.mode {
SearchMode::Lexical => {
let lex = LexicalRetriever::with_settings(
@@ -363,8 +356,8 @@ impl App {
..query.clone()
};
// p9-fb-37: when --trace is requested, bypass the LRU cache and
// run through `HybridRetriever::search_with_trace`, which
// p9-fb-37: when --trace is requested, run through
// `HybridRetriever::search_with_trace`, which
// dispatches by mode internally. Vector / hybrid modes require
// embeddings (same as `--mode hybrid`); lexical mode skips
// embedder construction via `NoopRetriever` so lexical-only
@@ -398,16 +391,16 @@ impl App {
let hybrid = HybridRetriever::new(&self.config.search, lex, vec_retr);
let (mut traced_hits, trace) = hybrid.search_with_trace(&fetch_query)?;
// Stamp staleness — same as search_uncached.
// Stamp staleness — same as `search`.
let now = time::OffsetDateTime::now_utc();
crate::staleness::mark_stale_in_place(
&mut traced_hits,
now,
self.config.search.stale_threshold_days,
);
// p10-1A-2: backfill code_lang — same as search_uncached.
// p10-1A-2: backfill code_lang — same as `search`.
backfill_code_lang(&mut traced_hits);
// p10-1A-2 Task 8b: backfill repo — same as search_uncached.
// p10-1A-2 Task 8b: backfill repo — same as `search`.
self.backfill_repo(&mut traced_hits);
// Apply offset + k_effective truncation (mirrors non-trace path).
@@ -437,8 +430,8 @@ impl App {
}
// backfill_code_lang + backfill_repo are applied inside `search`
// via `search_uncached` — no explicit call needed here. Trace
// branch above calls them directly because it bypasses `search`.
// — no explicit call needed here. The trace branch above calls
// them directly because it bypasses `search`.
let mut all_hits = self.search(fetch_query)?;
// Skip offset.

View File

@@ -244,17 +244,6 @@ pub fn search_with_config(
App::open_with_config(config)?.search(query)
}
/// p9-fb-19: bypass the LRU search cache for one call. Same shape as
/// [`search_with_config`] but routes through [`App::search_uncached`]
/// — used by `kebab search --no-cache`.
#[doc(hidden)]
pub fn search_uncached_with_config(
config: kebab_config::Config,
query: SearchQuery,
) -> anyhow::Result<Vec<SearchHit>> {
App::open_with_config(config)?.search_uncached(query)
}
/// p9-fb-34: budget-aware search free function. Mirrors
/// [`search_with_config`] but threads `SearchOpts` (max_tokens,
/// snippet_chars, cursor) and returns the [`SearchResponse`]

View File

@@ -81,26 +81,6 @@ fn cache_key_normalization_treats_case_and_whitespace_as_equivalent() {
assert_eq!(plain_ids, upper_ids);
}
/// p9-fb-19 — `--no-cache` (`search_uncached_with_config`) bypasses
/// the cache. Result correctness is identical to `search_with_config`.
#[test]
fn search_uncached_returns_same_hits_as_cached() {
let env = TestEnv::lexical_only();
kebab_app::ingest_with_config(env.config.clone(), env.scope(), kebab_app::IngestOpts { summary_only: true, ..Default::default() }).unwrap();
let cached =
kebab_app::search_with_config(env.config.clone(), common::lexical_query("ownership"))
.unwrap();
let uncached = kebab_app::search_uncached_with_config(
env.config.clone(),
common::lexical_query("ownership"),
)
.unwrap();
assert_eq!(cached.len(), uncached.len());
for (a, b) in cached.iter().zip(uncached.iter()) {
assert_eq!(a.chunk_id, b.chunk_id);
}
}
/// p9-fb-19 — first ingest with commits bumps `corpus_revision` from
/// 0 to ≥1. Verified by reading the persisted kv via a fresh
/// SqliteStore handle (the field on `App` is `pub(crate)`).

View File

@@ -114,19 +114,6 @@ enum Cmd {
#[arg(long, value_enum, default_value_t = ModeFlag::Hybrid)]
mode: ModeFlag,
#[arg(long)]
explain: bool,
/// p9-fb-19: bypass the in-process LRU search cache for
/// this invocation. Forces a fresh retriever run even when
/// the same query was just served from cache. Useful when
/// debugging retriever behavior — and a no-op for the CLI
/// (each invocation is a new process anyway, so the cache
/// starts empty), but the flag stays for parity with the
/// future TUI cache-aware search and for explicit intent.
#[arg(long)]
no_cache: bool,
/// p9-fb-34: cap result wire JSON size at approximately N tokens
/// (chars/4 estimate). When set, smaller snippets and fewer hits
/// may be returned; check `truncated` in the JSON wire.
@@ -825,8 +812,6 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
query,
k,
mode,
explain: _,
no_cache: _,
max_tokens,
snippet_chars,
cursor,

View File

@@ -66,7 +66,6 @@ snippet_chars = 220
[rag]
prompt_template_version = "rag-v4"
score_gate = 0.30
explain_default = false
max_context_tokens = 8000
"#,
workspace = workspace.display(),

View File

@@ -67,7 +67,6 @@ snippet_chars = 220
[rag]
prompt_template_version = "rag-v4"
score_gate = 0.30
explain_default = false
max_context_tokens = 8000
"#,
workspace = workspace.display(),

View File

@@ -92,7 +92,6 @@ stale_threshold_days = {stale_threshold_days}
[rag]
prompt_template_version = "rag-v4"
score_gate = 0.30
explain_default = false
max_context_tokens = 8000
"#,
workspace = workspace.display(),

View File

@@ -335,7 +335,6 @@ pub struct RagCfg {
pub prompt_template_version: String,
#[serde(serialize_with = "ser_f32_clean")]
pub score_gate: f32,
pub explain_default: bool,
pub max_context_tokens: usize,
/// p9-fb-41: hard ceiling on the number of multi-hop iterations
/// (decompose iter + decide iters). When the LLM keeps returning
@@ -1057,7 +1056,6 @@ impl Config {
rag: RagCfg {
prompt_template_version: "rag-v4".to_string(),
score_gate: 0.30,
explain_default: false,
max_context_tokens: 8000,
multi_hop_max_depth: default_multi_hop_max_depth(),
multi_hop_max_sub_queries_per_iter: default_multi_hop_max_sub_queries_per_iter(),
@@ -1662,7 +1660,7 @@ impl Config {
}
// rag — prompt template version is a release-level toggle;
// the per-call tuning knobs (score_gate, explain_default,
// the per-call tuning knobs (score_gate,
// max_context_tokens, multi_hop_*, nli_threshold) stay
// config-only.
"KEBAB_RAG_PROMPT_TEMPLATE_VERSION" => {
@@ -1871,7 +1869,6 @@ snippet_chars = 220
[rag]
prompt_template_version = "rag-v3"
score_gate = 0.3
explain_default = false
max_context_tokens = 8000
[image.ocr]
@@ -2423,7 +2420,6 @@ stale_threshold_days = 30
[rag]
prompt_template_version = "rag-v2"
score_gate = 0.30
explain_default = false
max_context_tokens = 8000
"#;
let c: Config = toml::from_str(toml_text).expect("pre-P6 TOML must still parse");

View File

@@ -86,7 +86,6 @@ stale_threshold_days = 30
[rag]
prompt_template_version = "rag-v3"
score_gate = 0.30000001192092896
explain_default = false
max_context_tokens = 8000
multi_hop_max_depth = 3
multi_hop_max_sub_queries_per_iter = 5

View File

@@ -5,8 +5,8 @@
//! `chunks.source_spans_json` column and need identical mapping logic
//! so cross-mode citation strings round-trip byte-identically (a
//! requirement for the hybrid retriever's tie-break on chunk_id and
//! for the `search --explain` output documented in design §0 Q3 and
//! §1.6). Living here means a future PDF / image / audio extractor can
//! for citation provenance in `search --trace` output, design §0 Q3).
//! Living here means a future PDF / image / audio extractor can
//! enrich the mapping in one place rather than two.
use kebab_core::{Citation, SourceSpan, WorkspacePath};

View File

@@ -12,9 +12,8 @@
//! `m`'s output (chunks not appearing in `m` contribute 0).
//!
//! Each `SearchHit.retrieval` is rebuilt with the per-mode scores /
//! ranks the fusion observed, so `kb search --explain` (§1.6) can
//! show users exactly which retriever contributed what to the final
//! ordering.
//! ranks the fusion observed, so `kebab search --trace` can show
//! exactly which retriever contributed what to the final ordering.
use std::collections::HashMap;
use std::sync::Arc;
@@ -62,8 +61,8 @@ pub enum FusionPolicy {
/// and `embedding_model` — lexical search has FTS5 highlighting that's
/// more user-relevant than the vector retriever's truncated text.
/// Vector-only chunks fall through to the vector hit's data verbatim.
/// This matches `kb search --explain` (§1.6) expectations for snippet
/// provenance.
/// This keeps snippet provenance stable in the fused result's
/// `search --trace` output.
pub struct HybridRetriever {
lexical: Arc<dyn Retriever>,
vector: Arc<dyn Retriever>,

View File

@@ -585,7 +585,7 @@ fn build_hit(
chunker_version: ChunkerVersion(raw.chunker_version),
indexed_at,
// Placeholder — overwritten by `kebab_app::staleness::mark_stale_in_place`
// (called from `App::search` / `App::search_uncached`) and the equivalent
// (called from `App::search`) and the equivalent
// in `RagPipeline::ask` against the configured threshold.
stale: false,
score_kind: ScoreKind::Bm25,

View File

@@ -348,7 +348,7 @@ fn build_hit(
chunker_version: ChunkerVersion(meta.chunker_version.clone()),
indexed_at,
// Placeholder — overwritten by `kebab_app::staleness::mark_stale_in_place`
// (called from `App::search` / `App::search_uncached`) and the equivalent
// (called from `App::search`) and the equivalent
// in `RagPipeline::ask` against the configured threshold.
stale: false,
score_kind: ScoreKind::Cosine,

View File

@@ -424,15 +424,7 @@ $KB search 'tokenizer' --mode lexical --json | jq '.hits | length' # ≥ 1 if co
- `next_cursor` opaque token.
- `corpus_revision` mismatch → `stale_cursor` error.
### §2.6 Search cache (p9-fb-19)
```bash
"$RELEASE_BIN" search "query" --json # first call
"$RELEASE_BIN" search "query" --json # cached (in-process LRU, no-op in CLI)
"$RELEASE_BIN" search "query" --no-cache --json # force fresh
```
### §2.7 Bulk search
### §2.6 Bulk search
stdin ndjson — 줄당 하나의 query object (`{"query":"<text>"}` 필수, 나머지 optional):
```bash

View File

@@ -42,7 +42,7 @@ classDiagram
ingest_with_config_progress
ingest_with_config_cancellable
list_docs_with_config / inspect_*_with_config
search_with_config / search_uncached_with_config
search_with_config
ask_with_config / ask_with_session_with_config
doctor_with_config_path
}
@@ -117,7 +117,7 @@ flowchart LR
- `ingest_with_config(cfg, scope, summary_only)` — 가장 단순.
- `ingest_with_config_progress(cfg, scope, progress: Option<Sender<IngestEvent>>)` — TTY 진행 표시 / `--json` line-delimited 용.
- `ingest_with_config_cancellable(cfg, scope, progress, cancel: Option<Arc<AtomicBool>>)` — 위 + cooperative cancel. asset loop iter 시작에서 poll → true 면 break + `Aborted{partial_counts}` + `Ok(IngestReport)` 반환 (Err 아님). 부분 commit 보존.
- `search_with_config / search_uncached_with_config` — 후자는 LRU cache bypass (debug).
- `search_with_config` — one-shot `App` 생성 후 `App::search` 위임.
- `ask_with_session_with_config(cfg, session_id, query, opts)` — multi-turn (p9-fb-18).
- `doctor_with_config_path(Option<&Path>)` — config 경로 explicit.
@@ -125,7 +125,7 @@ flowchart LR
- `App::open_with_config(cfg) -> Result<Self>` — SQLite open + migration. embedder/vector/llm 은 lazy `OnceLock` 으로 첫 호출에서 build.
- `App::embedder() -> Option<...>``provider == "none"` 또는 `dimensions == 0` 이면 `None` (lexical-only fallback).
- `App::ask_with_session(session_id, query, opts)` — repo + RAG ask + 새 turn append 한 묶음 (p9-fb-18).
- `App::search(query)`LRU cache lookup → miss 시 `search_uncached` → put. cache key = `(query_norm, mode, k, snippet_chars, embedding_version, chunker_version, corpus_revision)`.
- `App::search(query)`설정된 retriever stack 실행 후 top-k hits 반환. staleness stamp + `code_lang` / `repo` backfill 을 hit 에 적용.
**`IngestEvent`** (`kebab-app::ingest_progress`):
- `Started { total }` / `AssetStarted { workspace_path }` / `AssetCompleted { counts }` / `Completed { counts }` / `Aborted { partial_counts }`. terminal frame 후 sender drop.

View File

@@ -130,7 +130,7 @@ flowchart LR
## 관련 spec / HOTFIXES
- frozen 설계 §3.7 (SearchHit), §6.4 (`search.hybrid_fusion`/`rrf_k`/`default_k`/`snippet_chars`), §0 Q3 (citation), §1.6 (`--explain`), §7.2 (`Retriever` trait): [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../../superpowers/specs/2026-04-27-kebab-final-form-design.md)
- frozen 설계 §3.7 (SearchHit), §6.4 (`search.hybrid_fusion`/`rrf_k`/`default_k`/`snippet_chars`), §0 Q3 (citation), §7.2 (`Retriever` trait): [`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`](../../superpowers/specs/2026-04-27-kebab-final-form-design.md)
- task spec:
- lexical: [`tasks/p2/p2-2-search-lexical.md`](../../../tasks/p2/p2-2-search-lexical.md)
- vector + hybrid: [`tasks/p3/p3-4-hybrid-fusion.md`](../../../tasks/p3/p3-4-hybrid-fusion.md)