diff --git a/crates/kebab-eval/tests/loader.rs b/crates/kebab-eval/tests/loader.rs index 62f4033..704f173 100644 --- a/crates/kebab-eval/tests/loader.rs +++ b/crates/kebab-eval/tests/loader.rs @@ -38,7 +38,44 @@ fn loads_minimal_well_formed_yaml() { assert_eq!(qs[1].difficulty.as_deref(), Some("easy")); } -// ── 2. duplicate IDs error lists every offender (sorted, deduplicated) ─────── +// ── 2. fb-41 multi-hop golden fixture loads + sanity-checks ───────────────── + +/// fb-41 baseline + post-merge Δ measurement fixture. The shared +/// loader must accept `fixtures/multi_hop_golden.yaml` and the bucket +/// distribution must stay 5 cross-doc + 5 intra-doc + 5 single-fact +/// negative — curators dropping or re-id'ing a question hit a clear +/// failure mode here before it reaches the runner. +#[test] +fn loads_multi_hop_golden_fixture() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("fixtures") + .join("multi_hop_golden.yaml"); + let qs = load_golden_set(&path).expect("multi_hop_golden.yaml must parse"); + + assert_eq!(qs.len(), 15, "fb-41 fixture must have 15 questions"); + + let cross_doc = qs.iter().filter(|q| q.id.starts_with("mh-c-")).count(); + let intra_doc = qs.iter().filter(|q| q.id.starts_with("mh-i-")).count(); + let single = qs.iter().filter(|q| q.id.starts_with("mh-s-")).count(); + assert_eq!(cross_doc, 5, "expected 5 mh-c-* (cross-doc) questions"); + assert_eq!(intra_doc, 5, "expected 5 mh-i-* (intra-doc) questions"); + assert_eq!(single, 5, "expected 5 mh-s-* (single-fact negative) questions"); + + // Every question carries at least one `must_contain` so the + // rule-based answer-correctness metric (P5-2) has a signal even + // before `expected_chunk_ids` are filled in. + for q in &qs { + assert!( + !q.must_contain.is_empty(), + "{}: must_contain is empty — baseline measurement needs a signal", + q.id + ); + } +} + +// ── 3. duplicate IDs error lists every offender (sorted, deduplicated) ─────── #[test] fn rejects_duplicate_ids() { diff --git a/docs/superpowers/plans/2026-05-25-p9-fb-41-multi-hop-rag.md b/docs/superpowers/plans/2026-05-25-p9-fb-41-multi-hop-rag.md new file mode 100644 index 0000000..90dea0d --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-p9-fb-41-multi-hop-rag.md @@ -0,0 +1,239 @@ +--- +title: "p9-fb-41 multi-hop RAG implementation plan" +date: 2026-05-25 +task_id: p9-fb-41 +phase: P9 +status: open +target_version: 0.18.0 +design: ../specs/2026-05-25-p9-fb-41-multi-hop-rag-design.md +--- + +# p9-fb-41 implementation plan + +Design: `docs/superpowers/specs/2026-05-25-p9-fb-41-multi-hop-rag-design.md`. + +XL 작업 — 6 PR 분할 (각 머지 후 누적, 마지막 PR 후 v0.18.0 cut). + +## PR-1: Multi-hop eval golden set + baseline (PR #166) + +**Goal**: 구현 전 baseline 측정 anchor 확보. RAG pipeline 미변경 — fixture 만 (별 type / loader 변경 없이 기존 `GoldenQuery` 그대로). + +**실제 변경** (kebab-eval crate 구조 survey 후 plan 초안 대비 단순화): +- `fixtures/multi_hop_golden.yaml` 신규 — YAML 형식 (TOML 아님). fb-39 의 `fixtures/golden_queries.yaml` 와 sister naming. 15 question (5 cross-doc + 5 intra-doc + 5 single-fact negative; 한국어 12 + 영어 3 mix). +- 기존 `GoldenQuery` struct (id, query, lang, expected_doc_ids, expected_chunk_ids, must_contain, forbidden, difficulty) 재사용 — **별 type / loader / runner 변경 없음**. 기존 `kebab_eval::load_golden_set` 가 fixture path 인자 그대로 받아 parse. +- `crates/kebab-eval/tests/loader.rs::loads_multi_hop_golden_fixture` 신규 회귀 핀 — fixture parse OK + 15 question + 5/5/5 bucket 분포 + 모든 question 의 `must_contain` 최소 1 개. + +**plan 초안 대비 deviation (회차 1 리뷰 정정)**: +- ~~`tasks/eval/multi-hop-golden.toml`~~ → `fixtures/multi_hop_golden.yaml` (workspace root, fb-39 sister naming, YAML). +- ~~`crates/kebab-eval/src/golden.rs::MultiHopGoldenQuestion` 신규 struct + TOML deserialize~~ → 기존 `GoldenQuery` 재사용, 변경 없음. +- ~~runner 분기 추가~~ → PR-4 의 책임 (CLI flag 도입 시점). PR-1 은 fixture 만. +- ~~`crates/kebab-eval/tests/multi_hop_golden_smoke.rs` 신규~~ → 기존 `tests/loader.rs` 에 한 test 추가. + +원인: plan 초안 작성 시점에 kebab-eval crate 구조 (이미 generic loader 보유, fixture path 인자 받음) 미survey. 실제 작업 시 더 간단한 path 발견. + +**Test** (실제): +- `cargo test -p kebab-eval --test loader -j 1` — 3 test 모두 통과 (기존 2 + 신규 `loads_multi_hop_golden_fixture`). +- baseline run 은 별 run script — 사용자가 `kebab eval run --fixture fixtures/multi_hop_golden.yaml` 으로 실행 + 결과 캡처. fixture 의 `expected_chunk_ids` 가 비어 있어 `precision_at_k_chunk` skip; `must_contain` + `forbidden` 기반 rule metric 작동. + +**Wire 영향**: 없음. + +--- + +## PR-2: kebab-rag MultiHopPipeline (fixed depth=2) + AskOpts.multi_hop + +**Goal**: multi-hop dispatch + decompose + synthesize 두 단계 구현. dynamic iter (decide loop) 는 PR-3. + +**Files**: +- `crates/kebab-rag/src/pipeline.rs`: + - `AskOpts.multi_hop: bool` 필드 추가. + - `impl Default for AskOpts` 도입 (HOTFIXES 2026-05-07 의 known limitation 해소). + - `RagPipeline::ask_multi_hop(query, opts) -> Result` 신규. + - `RagPipeline::ask` 의 entry 에 dispatcher 한 줄: `if opts.multi_hop { return self.ask_multi_hop(query, opts); }`. + - depth=2 hard-coded: decompose 1 회 → 각 sub-query retrieve → synthesize 1 회. decide loop 없음. + - prompts: `MULTI_HOP_DECOMPOSE_PROMPT` + `MULTI_HOP_SYNTHESIZE_PROMPT` const. + - `PROMPT_TEMPLATE_VERSION_MULTI_HOP = "rag-multi-hop-v1"` const. +- `crates/kebab-core/src/lib.rs` (또는 traits): + - `RefusalReason::MultiHopDecomposeFailed` 신규 variant. +- All `AskOpts` 명시 초기화 site (kebab-cli + kebab-tui + kebab-mcp + integration test): + - `multi_hop: false` 명시 추가 — `Default` 도입으로 점진 정리 가능하지만 PR-2 는 명시. +- `crates/kebab-rag/tests/multi_hop.rs` 신규 — mock LLM (`MockLlm` trait impl) + mock Retriever 로 dispatch / decompose / synthesize 동작 핀. + +**Implementation order**: +1. `AskOpts.multi_hop` 필드 + `Default` impl. +2. 모든 caller 갱신 (`multi_hop: false` 또는 `..Default::default()` 사용). +3. `MULTI_HOP_DECOMPOSE_PROMPT` + `MULTI_HOP_SYNTHESIZE_PROMPT` const. +4. `ask_multi_hop` 의 mock-friendly skeleton: + - decompose LLM call → JSON array parse + - 각 sub-query 로 `retriever.search()` 호출 + - chunk pool 누적 + dedup + - synthesize LLM call → Answer 생성 +5. `RagPipeline::ask` 에 dispatcher. +6. `RefusalReason::MultiHopDecomposeFailed` variant. +7. Integration test: mock LLM 가 ["q1", "q2"] decompose 후 "Final answer" synthesize 반환 → Answer.answer 검증. + +**Test**: +- `ask_multi_hop_dispatches_when_flag_set` — `opts.multi_hop=true` 시 multi-hop path 호출 확인. +- `ask_multi_hop_decompose_parse_failure_returns_refusal` — decompose LLM 가 잘못된 JSON 반환 시 `MultiHopDecomposeFailed` refusal. +- `ask_multi_hop_empty_decompose_falls_back_to_single_query` — decompose 가 `[]` 또는 `[원본]` 반환 시 sub-query 1 개 (원본) 로 진행. +- `ask_with_multi_hop_false_keeps_legacy_path` — 회귀 핀. + +**Wire 영향**: `Answer.hops` 미노출 (internal only). PR-3 에서 채우기 시작. + +**Risks**: +- prompt JSON parse 견고성 — LLM 이 markdown code fence (`\`\`\`json ... \`\`\``) wrap 가능. parser 가 fence strip + array deserialize. +- 모든 `AskOpts` caller 갱신 누락 시 compile fail (긍정적 측면 — 자동 발견). + +--- + +## PR-3: Dynamic iteration (decide loop + caps) + +**Goal**: depth=2 fixed → dynamic N-hop. LLM 의 decide signal + max_depth / max_sub_queries / max_pool_chunks cap. + +**Files**: +- `crates/kebab-rag/src/pipeline.rs`: + - `ask_multi_hop` 의 decompose + synthesize 사이에 decide loop. + - `MULTI_HOP_DECIDE_PROMPT` const. + - hop trace 누적 (`Vec`) — `Answer.hops` field 의 internal staging. +- `crates/kebab-config/src/lib.rs`: + - `RagCfg` 에 `multi_hop_max_depth: u32` (default 3), `multi_hop_max_sub_queries_per_iter: u32` (default 5), `multi_hop_max_pool_chunks: u32` (default 30) 추가. 모두 additive serde default. env override 동반. +- `crates/kebab-core/src/lib.rs`: + - `Answer.hops: Option>` 필드 additive. + - `HopRecord { iter, kind, sub_queries, decision, new_sub_queries, context_chunks_added, total_context_chunks, forced_stop, llm_call_ms }` struct. + - `HopKind::Decompose | Decide | Synthesize` enum. + +**Implementation order**: +1. `HopRecord` + `HopKind` 도메인 타입. +2. `Answer.hops` field 추가 (additive). +3. RagCfg 새 3 노브 + config tests (default / env override / legacy parse — 기존 `legacy_config_without_*_uses_default` 패턴). +4. `MULTI_HOP_DECIDE_PROMPT` const. +5. `ask_multi_hop` 내부에 decide loop: + - iter 0: decompose → sub_queries (HopRecord 1). + - iter 1+: retrieve → pool 누적 → decide LLM call (HopRecord 추가) → continue 면 다음 iter 의 retrieve, stop 면 break. + - cap 도달 (max_depth / max_total_sub_queries / max_pool_chunks) 시 forced_stop=true 로 break. + - synthesize → Answer.hops 에 누적된 HopRecord array 첨부. +6. decide JSON parse failure → forced_stop synthesize (refusal 아님, 안전한 graceful degrade). + +**Test**: +- `multi_hop_decide_stop_triggers_synthesize` — decide 가 `[]` 반환 시 즉시 synthesize. +- `multi_hop_decide_continue_adds_more_chunks` — decide 가 ["q4"] 반환 시 추가 retrieve + iter 2 진행. +- `multi_hop_max_depth_force_stops` — depth=max_depth 도달 시 `forced_stop=true` + 정상 answer. +- `multi_hop_pool_chunks_dedup_by_chunk_id` — 같은 chunk 가 두 sub-query 에서 나와도 pool 에 1 회. +- `multi_hop_decide_parse_failure_falls_through_to_synthesize` — decide JSON 파싱 실패 시 forced synthesize. + +**Wire 영향**: `Answer.hops` 노출. `docs/wire-schema/v1/answer.schema.json` 갱신 (additive — `required` 변경 없음, `properties.hops.type = "array"` + `optional`). + +**Risks**: +- prompt token cost — depth 깊을수록 packed_context 가 매 decide call 마다 LLM 에 보내짐. `cfg.rag.max_context_tokens` 안에서 trim. +- LLM 이 영원히 continue 반환 — max_depth cap 으로 강제 break, dogfood 후 default 3 검증. + +--- + +## PR-4: CLI `--multi-hop` flag + wire JSON Schema + +**Goal**: 사용자가 `kebab ask --multi-hop "..."` 로 진입. `Answer.hops` JSON Schema additive. + +**Files**: +- `crates/kebab-cli/src/main.rs`: + - `Ask` subcommand 에 `--multi-hop` flag (default false). + - `AskOpts.multi_hop` 로 전달. + - `--show-citations` 와 동일 surface — `--hide-citations` 와 orthogonal. +- `docs/wire-schema/v1/answer.schema.json`: + - `hops` field 추가 (optional, array of HopRecord). HopRecord 의 JSON Schema 도 inline 또는 `$defs/HopRecord`. +- `docs/wire-schema/v1/error.schema.json`: + - `multi_hop_decompose_failed` code description 추가 (additive `enum` 확장 — strict validator 영향 있지만 single-producer 환경이라 patch minor 처리, HOTFIXES 2026-05-09 fb-32 패턴). +- `crates/kebab-app/src/error_wire.rs`: + - `RefusalReason::MultiHopDecomposeFailed` → `error.v1.code = "multi_hop_decompose_failed"` 매핑. +- `crates/kebab-cli/tests/cli_ask_multi_hop.rs` 신규 — spawn-based test (mock environment, real binary), `--multi-hop --json` 출력에 `hops` field 등장 확인. + +**Implementation order**: +1. CLI flag 정의 + AskOpts wiring. +2. wire schema JSON 갱신. +3. error_wire 매핑. +4. Integration test (spawn). + +**Test**: +- `cli_ask_multi_hop_json_includes_hops` — `--multi-hop --json` 출력 parse 후 `Answer.hops` non-empty. +- `cli_ask_without_multi_hop_omits_hops` — 회귀 핀, 기존 single-pass 가 `hops: null` 또는 absent. + +**Wire 영향**: `answer.v1` schema additive (description 갱신 + optional field). `schema_version` 그대로 `answer.v1` (additive minor, fb-32 패턴). + +--- + +## PR-5: MCP `multi_hop` argument + SKILL.md + +**Goal**: agent 가 `mcp__kebab__ask` 호출 시 `multi_hop: true` 옵션 사용 가능. + +**Files**: +- `crates/kebab-mcp/src/lib.rs`: + - `ask` tool 의 input schema 에 `multi_hop: bool` (default false) 추가. + - tools/list 의 ask description 에 multi-hop 한 줄. + - call_tool 의 ask arm 가 `AskOpts.multi_hop` 전달. +- `integrations/claude-code/kebab/SKILL.md`: + - ask 절 (line ~95-115) 에 multi-hop bullet 추가: + - 비용 trade-off (2-5× LLM call) + - `multi_hop: true` argument 사용 케이스 (X 와 Y 의 관계, prerequisite chain, cross-doc reasoning) + - `Answer.hops` 의 trace 정보 surface + - 비-multi-hop 인 경우 (단순 fact-finding 은 single-pass 가 더 빠름) +- `crates/kebab-mcp/tests/tools_call_ask_multi_hop.rs` 신규 — `multi_hop: true` argument 가 multi-hop pipeline 호출 확인. + +**Implementation order**: +1. MCP tool schema + dispatch. +2. SKILL.md 갱신. +3. Integration test. + +**Test**: +- MCP `tools/call` ask 가 `multi_hop: true` 받고 정상 처리. +- `capabilities.multi_hop_ask` schema.v1 noeve flag 도입 검토 (선택 — agent 가 binary 지원 여부 detect 가능). additive bool. + +--- + +## PR-6: TUI Ask Multi-hop toggle + hop trace render + +**Goal**: TUI Ask 패널에서 multi-hop 모드 켜고 답변 본문에 hop trace 시각화. + +**Files**: +- `crates/kebab-tui/src/ask.rs`: + - `AskState.multi_hop: bool` 필드. + - keybinding: `F2` 또는 `Ctrl-T` (spec 의 binding note 참조, implementation 단계 결정). PR commit 메시지에 결정 근거 명시. + - 답변 본문 위에 `[multi-hop: depth=3, sub_queries=8]` 같은 trace summary row. +- `crates/kebab-tui/src/inspect.rs` (또는 신규 hop_inspect 모듈): + - Inspect 패널에 `InspectTarget::Hop(turn_index)` variant — Ask 트랜스크립트의 한 turn 의 hop trace detail (각 sub-query + retrieved chunks + decide signal) 표시. +- `crates/kebab-tui/src/cheatsheet.rs`: + - cheatsheet popup 에 multi-hop toggle binding + Inspect hop detail navigation 추가. + +**Implementation order**: +1. `AskState.multi_hop` field + toggle binding (cheatsheet test 로 binding 확정). +2. trace summary row 렌더. +3. Inspect hop detail target. +4. cheatsheet 갱신. + +**Test**: +- TUI integration: toggle 시 `AskState.multi_hop` 가 flip. +- multi-hop 답변 후 trace summary row 표시. +- Inspect 진입 후 hop detail navigate. + +**Wire 영향**: 없음 (TUI 표면만). + +--- + +## v0.18.0 cut (PR-6 머지 후) + +**Trigger** (CLAUDE.md 의 release rule): +- frozen design contract 변경 (§3.8 RAG sub-section "Multi-hop" 추가) — PR-3 또는 PR-4 시점에 frozen design doc update. +- 사용자 도그푸딩 영향 (새 `--multi-hop` surface). +- `prompt_template_version` cascade (`rag-multi-hop-v1` 신규). + +**Cut steps**: +1. workspace Cargo.toml version 0.17.2 → 0.18.0 (minor bump — surface 확장). +2. HANDOFF.md 한 줄 요약 갱신 (v0.18.0 cut + fb-41 multi-hop). +3. HOTFIXES.md 의 PR-2~PR-6 entry 들 anchor 정리 (`post-fb-41` → `v0.18.0`). +4. `gitea-release v0.18.0 --auto-notes` + release notes. +5. INDEX.md 의 fb-41 status `open` → `completed`. + +## Self-review notes + +- PR-1 (eval) 가 PR-2 와 독립 가능. PR-2 는 PR-1 머지 없이도 ship 가능 (단 baseline 측정 안 됨). 즉 직렬 dependency 는 없으나 PR-1 부터 진행 권장. +- PR-3 의 RagCfg 새 3 노브가 legacy config 파싱과 호환 — `#[serde(default)]` 패턴, kebab-config tests 의 legacy fixture 갱신은 PR-3 의 책임. +- AskOpts.multi_hop 가 PR-2 에서 도입되지만 actual multi-hop path 는 PR-2 의 fixed depth=2 만 동작. PR-3 의 decide loop 가 도입돼야 진짜 dynamic. caller 가 PR-2 단계에서 `multi_hop: true` 설정하면 단순 decompose+synthesize (depth=2) 만 — 의도된 staging. +- 모든 PR 가 회귀 핀 (existing single-pass path 동작 무변경) 포함. fb-15 multi-turn 와 fb-33 streaming 와의 orthogonality 도 회귀 핀 후보 — PR-3 단계에서 추가 검토. +- frozen design 갱신 timing: PR-3 의 wire `Answer.hops` 노출 시점이 적당. PR-3 commit 에 design doc §3.8 의 "Multi-hop" sub-section 추가 (verbatim, 본 spec 의 §1-§5 요약). diff --git a/docs/superpowers/specs/2026-05-25-p9-fb-41-multi-hop-rag-design.md b/docs/superpowers/specs/2026-05-25-p9-fb-41-multi-hop-rag-design.md new file mode 100644 index 0000000..64d899e --- /dev/null +++ b/docs/superpowers/specs/2026-05-25-p9-fb-41-multi-hop-rag-design.md @@ -0,0 +1,311 @@ +--- +title: "p9-fb-41 multi-hop RAG (query decomposition + dynamic N-hop)" +date: 2026-05-25 +task_id: p9-fb-41 +phase: P9 +status: open +target_version: 0.18.0 +contract_source: ./2026-04-27-kebab-final-form-design.md +contract_sections: [§3.8 RAG, §7 RAG pipeline] +--- + +# p9-fb-41 — Multi-hop RAG design + +## 문제 / 동기 + +도그푸딩 2026-05-06 — Claude Code 가 kebab CLI 사용 후 "추론 약함" 지적. 다단계 질문 ("X 와 Y 의 공통 prerequisite 인 Z 는?", "A 가 사용하는 library 중 deprecation 된 게 있나?") 에서 single-pass retrieval 이 한 번에 모든 근거 모으지 못함 → LLM 이 context 없는 부분 추측 / hallucinate. + +근본 한계: chunk-level retrieval 이 chunk 간 관계 (cross-doc reference, prerequisite chain, entity coreference) 직접 따라가지 못함. semantic embedding 이 query↔chunk 1:1 비교라 "A 를 알아내야 B 를 검색" 같은 sequential dependency 미지원. + +## 사용자 결정 (2026-05-25 AskUserQuestion) + +| Axis | 결정 | 근거 | +|------|------|------| +| Approach | **Query decomposition** (LLM 서브-질문) | 가장 명확한 multi-hop RAG 패턴. graph-based 는 schema migration 큰 부담, query expansion 만으로는 진짜 multi-hop 아님 | +| Trigger | **Explicit `--multi-hop` flag** | LLM 호출 N 회 비용 명시. 기본 single-pass 유지 (예측 가능한 latency / cost). heuristic auto-detect 는 judge LLM 의 false positive 위험 | +| MVP scope | **Dynamic N-hop** (LLM 이 depth 결정) | ReAct/CoT 형태 — 첫 decompose seed 후 LLM 이 "충분?" 결정. 단순 depth=2 보다 더 자연스러운 reasoning, max_depth cap 으로 안전 | +| Eval | **Multi-hop golden set 먼저** | 구현 전 baseline 측정 → 머지 후 Δ 수치화. fb-39 의 P@k metric 인프라 그대로 활용, multi-hop fixture 만 신규 | + +(3 의 dynamic + 1 의 decomposition 은 hybrid 로 결합: 첫 iter 에 decompose seed, 이후 iter 마다 LLM 이 "추가 sub-question?" 결정 + max_depth cap.) + +## 동결된 설계 결정 + +### 1. Pipeline 구조 + +``` +ask_multi_hop(query, opts) → + iter 0: decompose(query) → [q1, q2, q3, ...] (LLM call 1) + iter 1: retrieve(q1), retrieve(q2), retrieve(q3) → context_pool_1 + decide(query, context_pool_1) → continue? + new sub-queries (LLM call 2) + iter 2: retrieve(new_q1), retrieve(new_q2) → context_pool_2 (이전 pool 누적) + decide(query, all_pools) → continue? + new sub-queries (LLM call 3) + ... + iter N: stop signal OR max_depth reached + synthesize(query, all_pools) → final answer + citations (LLM call N+1) +``` + +각 iter 의 sub-queries 개수 cap: `max_sub_queries_per_iter = 5`. 누적 LLM 호출 = (max_depth + 1) — decompose / decide / decide / ... / synthesize. + +### 2. Stop condition (dynamic depth) + +LLM 의 `decide` call 이 두 신호 중 하나 반환: +- **`continue`**: 새 sub-query JSON array (최대 `max_sub_queries_per_iter`), pipeline 이 retrieve loop 다음 iter. +- **`stop`**: empty array (`[]`), pipeline 이 synthesize 단계로. + +추가 안전 cap (LLM 이 영원히 `continue` 반환하는 케이스): +- `max_depth = 3` (default, config 노브). depth 도달 시 강제 synthesize. +- `max_total_sub_queries = 12` 누적. 도달 시 강제 synthesize. + +각 cap 도달 시 `Answer.refusal_reason` 가 아닌 정상 답변 — 단 `Answer.hops[].forced_stop = true` 로 trace 명시. + +### 3. AskOpts 확장 (additive) + +```rust +pub struct AskOpts { + // ... 기존 필드 ... + + /// p9-fb-41: multi-hop mode 활성화. 기본 false (single-pass). + /// `kebab ask --multi-hop` flag, MCP `ask` tool 의 `multi_hop: true`, + /// TUI Ctrl-M toggle 이 모두 이 한 필드로 routing. + pub multi_hop: bool, +} +``` + +`AskOpts::default()` (또는 builder) 가 `multi_hop: false` — 기존 caller 자동 backwards-compat. + +**메모**: HOTFIXES 2026-05-07 fb-30 entry 가 명시했듯 현재 `AskOpts` 는 `Default` 미구현이라 모든 호출 site (kebab-cli + kebab-tui + kebab-mcp + kebab-app integration test) 가 9 field 를 명시 초기화. fb-41 의 신규 `multi_hop` field 추가 시 모든 site 도 명시적 `multi_hop: false` 추가 필요. PR-2 의 부수 작업으로 `impl Default for AskOpts` 동시 도입 권장 — 향후 field 추가의 maintenance 비용 ↓. + +### 4. RagPipeline 신규 method + +```rust +impl RagPipeline { + /// p9-fb-41: multi-hop ask. `opts.multi_hop == true` 일 때만 호출. + /// 내부적으로 `ask` 와 별도 path (decompose → iterate → synthesize). + /// 일반 `ask` 와 동일 wire (`Answer`) 반환, `hops` 필드만 추가로 채움. + pub fn ask_multi_hop(&self, query: &str, opts: AskOpts) -> Result; +} +``` + +`ask` 의 entrypoint 에 dispatcher 한 줄: +```rust +pub fn ask(&self, query: &str, opts: AskOpts) -> Result { + if opts.multi_hop { + return self.ask_multi_hop(query, opts); + } + // ... 기존 single-pass path ... +} +``` + +CLI / MCP / TUI 모든 caller 가 `opts.multi_hop` 만 set 하고 `ask` 호출 — entry 한 곳에서 분기. multi-turn (`ask_with_history`) 와 multi-hop 은 orthogonal — combined 가능 (history + multi-hop). + +### 5. Wire schema additive (answer.v1) + +`answer.v1` 에 optional `hops` field 추가: + +```json +{ + "schema_version": "answer.v1", + "answer": "...", + "grounded": true, + "citations": [...], + "conversation_id": null, + "turn_index": null, + "hops": [ + { + "iter": 0, + "kind": "decompose", + "sub_queries": ["q1", "q2", "q3"], + "llm_call_ms": 1234 + }, + { + "iter": 1, + "kind": "decide", + "decision": "continue", + "new_sub_queries": ["q4"], + "context_chunks_added": 3, + "forced_stop": false, + "llm_call_ms": 890 + }, + { + "iter": 2, + "kind": "decide", + "decision": "stop", + "new_sub_queries": [], + "context_chunks_added": 0, + "forced_stop": false, + "llm_call_ms": 654 + }, + { + "iter": 3, + "kind": "synthesize", + "total_context_chunks": 8, + "llm_call_ms": 2103 + } + ] +} +``` + +`hops` 가 `None` 이면 single-pass (기존 동작). additive minor — schema_version 그대로 `answer.v1`, JSON Schema description 의 `hops` 필드를 `optional` 명시. + +### 6. Prompt templates (kebab-rag 내부) + +세 신규 prompt template: + +**decompose**: +``` +사용자 질문을 다단계 추론에 필요한 sub-question 들로 분해하라. + +원본 질문: {query} + +규칙: +- 최대 {max_sub_queries_per_iter} 개 +- 각 sub-question 은 독립적으로 검색 가능해야 함 +- 원본이 이미 단순하면 1 개만 반환 +- JSON array 만 출력 (no prose) + +출력 예: ["sub-question 1", "sub-question 2"] +``` + +**decide** (매 iter): +``` +원본 질문: {query} + +지금까지 모은 근거 (chunk N 개): +{packed_context_snippet} + +추가 retrieval 이 필요한가? 필요하면 새 sub-question 들 (최대 {max_sub_queries_per_iter} 개) 을 JSON array 로, +충분하면 빈 array `[]` 를 반환하라. + +남은 깊이: {max_depth - current_depth} +``` + +**synthesize**: +``` +원본 질문: {query} + +다음 chunk 들을 근거로 답하라: +{packed_context_with_citations} + +규칙: +- 모든 주장에 [N] citation marker +- 근거 없는 부분은 명시적으로 말하라 ("문서에서 확인되지 않음") +- 한국어로 답변 + +답변: +``` + +`prompt_template_version` cascade: 신규 `rag-multi-hop-v1` 상수. 기본 single-pass 의 `rag-v2` 와 별개로 추적 — `Answer.prompt_template_version` 가 단일 값이라 multi-hop 답변은 `rag-multi-hop-v1`. + +### 7. Retrieval 합성 (context pool dedup) + +각 iter 의 retrieve 결과를 누적 pool 에 합성. dedup 정책: +- `chunk_id` 동일하면 첫 occurrence 유지 (이후 추가된 sub-query 도 같은 chunk 가져오면 skip). +- 누적 pool 의 max size: `max_pool_chunks = 30` (cfg 노브). 초과 시 가장 마지막 추가된 sub-query 의 lowest-rank chunk 부터 drop. +- pool 의 token 한도: `cfg.rag.max_context_tokens` (single-pass 와 동일) — synthesize 단계에서 char budget 으로 cap. + +### 8. Cost / Latency 예측 + +LLM call 수 (default `max_depth=3`): +- 최소: 2 (decompose + stop + synthesize) — depth=1 +- 중간: 3-4 (depth 2-3) +- 최대: 5 (decompose + decide×3 + synthesize) — max_depth 도달 + +대비 single-pass 는 1 LLM call. 즉 multi-hop = **2-5× LLM 호출 + 2-12× retrieval** (sub-query 별). 사용자가 `--multi-hop` 명시 시만 발동 — cost surprise 회피. + +latency: CPU only 환경의 cold-start 8B+ 모델은 multi-hop 가 무의미 (총 5-10 분). 권장 모델 = ≤4B Q4 (v0.17.1 README 의 권장 그대로). `[models.llm] request_timeout_secs` (v0.17.1) cap 각 call 적용. + +### 9. Refusal / error handling + +- decompose 가 JSON parse 실패 / 빈 array → `RefusalReason::MultiHopDecomposeFailed` (신규). 새 enum variant. +- decide 가 JSON parse 실패 → 강제 synthesize (LLM 가 stop 결정한 것처럼 처리, `forced_stop=true`). +- 어떤 sub-query 가 retrieval 0 hit → skip (해당 hop 의 `context_chunks_added=0`), iter 계속. +- 모든 iter 누적 pool 이 비어 있으면 synthesize 가 `grounded=false` + `RefusalReason::NoChunksFound`. +- LLM stream 도중 cancel → 부분 `hops` array 까지 채워서 `Answer.refusal_reason = LlmStreamAborted` (fb-15 와 동일 패턴). + +### 10. Streaming (fb-33 와 통합) + +`stream_sink` 가 set 되어 있으면: +- 각 hop 의 LLM call 이 시작될 때 `StreamEvent::Token { delta: "[hop iter=N kind=decompose ...]\n" }` 같은 trace event (debug only) 또는 새 `StreamEvent::HopStarted` variant 신설. +- 최종 synthesize 의 token 만 user-visible delta (single-pass 와 동일). +- 결정: trace event 는 `StreamEvent::HopStarted { iter, kind }` 신규 variant — additive enum. + +`AskOpts.stream_sink` 가 `None` 이면 모든 hop blocking, `Final` event 한 번만 emit. + +## 호출 면 (Surface) — PR 분할 + +| PR | 범위 | 영향 | +|----|------|------| +| **PR-1: eval golden set + baseline** | `tasks/eval/multi-hop-golden.toml` 신규 (10-15 question), `kebab-eval` runner 확장 (multi-hop fixture 지원), baseline run | metric 인프라만, RAG pipeline 미변경 | +| **PR-2: kebab-rag MultiHopPipeline (fixed depth=2)** | `RagPipeline::ask_multi_hop` 신규, `AskOpts.multi_hop` 필드 추가, decompose + synthesize prompts, depth=2 fixed (decide skip) | wire `Answer.hops` 미노출 (internal only) | +| **PR-3: dynamic iteration** | `decide` prompt + LLM call loop, `max_depth` / `max_sub_queries_per_iter` / `max_pool_chunks` config 노브, refusal variant 추가 | wire `Answer.hops` 채우기 시작 | +| **PR-4: CLI `--multi-hop` flag + wire** | `kebab-cli` 에 flag, `Answer.hops` JSON Schema additive, `error.v1` code `multi_hop_decompose_failed` | wire breaking 아님 (additive) | +| **PR-5: MCP + SKILL.md** | `mcp__kebab__ask` tool 의 `multi_hop: bool` argument, SKILL.md 의 ask 절에 multi-hop 안내 + cost trade-off | agent 통합 표면 | +| **PR-6: TUI Multi-hop toggle + trace render** | Ask 패널의 multi-hop toggle (`AskState.multi_hop`), 답변 본문 위에 hop trace summary (sub-queries / depth 표시), Inspect 패널에 hop detail | UI 표면 | + +> **PR-6 binding note**: `Ctrl-M` 은 terminal protocol 상 `Enter` 와 동일 keycode (`\r`) — crossterm 일부 terminal 에서 두 binding ambiguous. 후보: +> - `F2` (cheatsheet 의 `F1` sibling, 새 functional area) +> - `:m` vim-style command (mode machine 위에 ex command 추가 — 부담 큼) +> - `Ctrl-T` (toggle, 다른 binding 과 충돌 없음 — Library `t` 가 tag filter 와 별개) +> +> PR-6 implementation 단계에서 cheatsheet 갱신 + crossterm test 한 후 최종 선택. spec 은 binding 미확정. + +각 PR 가 머지 후 누적, 마지막 PR 후 v0.18.0 cut (minor bump — 사용자-visible 새 surface 추가 + prompt_template_version cascade). + +## Eval golden set scope (PR-1) + +`tasks/eval/multi-hop-golden.toml` 형식 (fb-39 의 single-pass golden 와 sister): + +```toml +[[question]] +id = "mh-001" +query = "rust 의 async runtime 중 kebab 이 사용하는 것은? 그 runtime 의 default executor 는?" +expected_answer_contains = ["tokio", "thread pool"] +expected_sources = [ + "Cargo.toml", + "crates/kebab-app/src/lib.rs", +] +multi_hop_required = true # single-pass 로는 잘 안 됨, 검증용 flag + +[[question]] +id = "mh-002" +# ... 추가 ... +``` + +15 question 목표. 출처 분포: +- 5 question: 두 doc 가로질러 (cross-doc reasoning) +- 5 question: 같은 doc 안 두 section 간 (intra-doc multi-hop, single-pass 도 가능할 수 있음 — baseline 비교) +- 5 question: 단순 single-fact (negative — multi-hop 이 single-pass 대비 regression 안 일으키는지 검증) + +metric: +- **P@5, P@10**: 기존 fb-39 metric, multi-hop 결과의 citations 도 동일 평가. +- **answer correctness**: `expected_answer_contains` 의 substring 모두 등장 시 1, 아니면 0. 단순 metric — semantic match 아님 (eval LLM 도입은 별 작업). +- **citation coverage**: `expected_sources` 중 actual citations 에 등장하는 비율. + +baseline (현 single-pass) → 각 metric 측정 → PR-2/3 머지 후 재측정 → Δ 보고. + +## Out of scope (future PR) + +- LLM 기반 semantic eval (answer 의 의미 일치도 측정 — gpt-4 같은 strong eval-LLM 필요) +- Graph-based retrieval (chunk 간 link 추출 + 그래프 traversal) — fb-41 spec 의 alternative axis A3 였음, 사용자가 query decomposition 선택 +- ReAct-style tool calling (LLM 이 직접 `retrieve(query)` tool invocation) — 현재 decide loop 가 비슷한 동작이지만 tool calling protocol 자체는 도입 안 함 +- Heuristic auto-detect (`--multi-hop-auto`) — judge LLM 도입 비용 + 잘못된 분기 위험. 향후 사용자 도그푸딩 결과 기반 재검토 +- multi-turn (`history`) + multi-hop combined 의 prompt budget 최적화 — orthogonal 결합 자체는 PR-2 부터 지원, prompt token 한도 정밀 조절은 별 PR + +## 검증 (각 PR 별) + +| PR | Test | +|----|------| +| PR-1 | 15 question golden fixture parse OK + single-pass baseline metric 출력 | +| PR-2 | `ask_multi_hop` integration test (decompose mock + 2 retrieve mock + synthesize mock) + `AskOpts.multi_hop=false` 시 기존 path 호출 회귀 | +| PR-3 | dynamic iter (depth 2-3) 통합 test, `max_depth` cap 동작, `decide` JSON parse failure → forced synthesize | +| PR-4 | `kebab ask --multi-hop --json` 의 stdout 에 `Answer.hops` 등장 | +| PR-5 | `mcp__kebab__ask` 의 `multi_hop: true` argument tools/call 통과 | +| PR-6 | TUI test — Ctrl-M toggle, hop trace render | + +## Cross-link + +- Spec stub: `tasks/p9/p9-fb-41-multi-hop-reasoning.md` +- 의존: fb-39 eval foundation (P@k metric 인프라) — 이미 머지됨 +- Sister: fb-15 (multi-turn, history) — orthogonal, combined 가능 +- 관련 wire: `answer.v1` (additive `hops`), `error.v1` (신규 code `multi_hop_decompose_failed`) +- 관련 design 절: §3.8 RAG — 본 spec 가 sub-section "Multi-hop" 으로 갱신 예정 (PR-3 또는 PR-4 시점에 frozen design doc update) diff --git a/fixtures/multi_hop_golden.yaml b/fixtures/multi_hop_golden.yaml new file mode 100644 index 0000000..97ef8b9 --- /dev/null +++ b/fixtures/multi_hop_golden.yaml @@ -0,0 +1,122 @@ +# Multi-hop golden query suite for `kebab eval run` (fb-41 baseline + post-merge Δ). +# +# Sister to `fixtures/golden_queries.yaml` (single-pass). Same `GoldenQuery` +# shape (kebab_eval::types::GoldenQuery) so the existing runner can ingest +# both fixtures without code changes — the multi-hop pipeline (fb-41 PR-2+) +# will dispatch on AskOpts.multi_hop, NOT on the fixture file. +# +# Curators: `expected_chunk_ids` / `expected_doc_ids` MUST refer to real +# rows in the active workspace's SQLite store at run time. Leave empty +# until you have ingested the corpus (`kebab ingest` over the kebab repo +# itself). The runner skips metrics that need ground-truth refs. +# +# fb-41 measurement protocol: +# 1) Pre-PR-2 (current binary, single-pass): baseline run → capture +# P@5, P@10, must_contain pass rate, citation_coverage. +# 2) Post-PR-3 (multi-hop enabled): same fixture, AskOpts.multi_hop=true +# → re-run → Δ vs baseline. +# 3) Cross-doc + intra-doc questions are the ones we expect to improve. +# Single-fact negatives detect multi-hop regression (added LLM hops +# should not make simple lookups worse). +# +# Question buckets (5 / 5 / 5): +# - `mh-c-*` — cross-doc multi-hop (README + HANDOFF + design doc, two +# or more docs needed) +# - `mh-i-*` — intra-doc multi-hop (same doc, two sections joined) +# - `mh-s-*` — single-fact negative (multi-hop should not regress) + +# ── Cross-doc multi-hop ────────────────────────────────────────────── + +- id: mh-c-001 + query: "kebab 가 지원하는 모든 미디어 타입 (markdown / image / pdf / code) 과 각 타입의 chunker_version 은?" + lang: ko + must_contain: ["markdown", "image", "pdf", "chunker_version"] + difficulty: multi-hop + +- id: mh-c-002 + query: "v0.17.0 의 trigram tokenizer migration (V007) 가 한국어 lexical 검색에 미친 영향과, 그로 인해 발생한 heading_path_json 노이즈 문제는 어떤 후속 release 에서 해결됐나?" + lang: ko + must_contain: ["trigram", "heading_path", "v0.17.2", "column filter"] + difficulty: multi-hop + +- id: mh-c-003 + query: "kebab 의 RAG pipeline 에서 LLM endpoint timeout 노브 (`request_timeout_secs`) 가 LLM 과 OCR 두 곳에 별도로 존재하는데, 둘이 분리된 이유와 각각의 default 값은?" + lang: ko + must_contain: ["request_timeout_secs", "models.llm", "image.ocr", "300"] + difficulty: multi-hop + +- id: mh-c-004 + query: "kebab MCP server (fb-30) 가 노출하는 tool 의 개수와 각 tool 이 호출하는 kebab-app facade fn 은? mutation tool (fb-31) 도입 후 read-only 정책은 어떻게 변경됐나?" + lang: ko + must_contain: ["search", "ask", "schema", "doctor", "ingest_file", "ingest_stdin"] + difficulty: multi-hop + +- id: mh-c-005 + query: "Which wire schemas in kebab's v1 contract carry the `indexed_at` / `stale` staleness fields added by fb-32? List every schema id under wire schema v1." + lang: en + must_contain: ["schema_version", "indexed_at", "stale", "search_hit", "citation"] + difficulty: multi-hop + +# ── Intra-doc multi-hop ────────────────────────────────────────────── + +- id: mh-i-001 + query: "How do the boundary rules in design §3 chunking and the chunk_id recipe in §5 storage cascade together? What pieces of the chunk_id come from each section?" + lang: en + must_contain: ["chunker_version", "policy_hash", "chunk_id"] + difficulty: multi-hop + +- id: mh-i-002 + query: "HANDOFF.md 의 phase 로드맵 표에서 P10 의 현재 status 와, 같은 doc 의 다음 task 후보 절에 등장하는 미구현 fb-* 항목들?" + lang: ko + must_contain: ["P10", "fb-41"] + difficulty: multi-hop + +- id: mh-i-003 + query: "README 의 kebab ingest 명령이 지원한다고 명시한 모든 확장자와, 같은 README 의 Configuration 절에 등장하는 `workspace.exclude` default pattern 의 관계?" + lang: ko + must_contain: [".md", ".pdf", ".png", "workspace.exclude"] + difficulty: multi-hop + +- id: mh-i-004 + query: "CLAUDE.md 의 facade rule (kebab-app 만 UI binary 가 import 가능) 과, 같은 doc 의 Allowed / forbidden deps 절에서 kebab-core 의 의존성 제약 — 두 규약이 어떻게 일관성 있게 작동하는가?" + lang: ko + must_contain: ["kebab-app", "kebab-core", "facade"] + difficulty: multi-hop + +- id: mh-i-005 + query: "p10 의 tier 1 AST chunker 와 tier 3 paragraph fallback 의 차이, 그리고 둘이 같은 file 에 적용되는 dispatch 순서?" + lang: ko + must_contain: ["AST", "paragraph", "fallback"] + difficulty: multi-hop + +# ── Single-fact negative (regression detection) ────────────────────── + +- id: mh-s-001 + query: "kebab 의 default embedding model 은?" + lang: ko + must_contain: ["multilingual-e5-large"] + difficulty: easy + +- id: mh-s-002 + query: "What license does kebab ship under?" + lang: en + must_contain: ["MIT", "Apache"] + difficulty: easy + +- id: mh-s-003 + query: "kebab.sqlite 파일의 default 위치는?" + lang: ko + must_contain: ["~/.local/share/kebab", ".local/share"] + difficulty: easy + +- id: mh-s-004 + query: "kebab tui 의 mode machine 에서 NORMAL → INSERT 토글 키는?" + lang: ko + must_contain: ["INSERT", "i 입력모드"] + difficulty: easy + +- id: mh-s-005 + query: "kebab 의 RRF k 파라미터 default 값은?" + lang: ko + must_contain: ["60"] + difficulty: easy