Files
kebab/docs/superpowers/plans/2026-05-25-p9-fb-41-multi-hop-rag.md
altair823 7150c376bb feat(rag): fb-41 PR-3a — HopRecord wire + RagCfg multi-hop knobs
PR-3 의 분할 첫 PR. wire additive (HopRecord + HopKind + Answer.hops
field) + RagCfg 의 multi_hop_* 3 노브. RAG pipeline 동작 미변경 —
모든 Answer literal 의 `hops = None`. PR-3b (후속) 가 ask_multi_hop
의 happy path 에서 dynamic decide loop 구현 + hops trace 채움.

분할 이유: 원래 PR-3 가 wire + cfg + decide loop + ScriptedLm +
helper refactor + 5+ tests 단일 PR 였는데 ~1500 줄 단일 patch 가
review 부담 + 회기 위험 ↑. additive foundation 부터 ship 후 decide
loop 별 PR — 사용자 결정 (2026-05-25).

- `kebab_core::HopRecord` (iter, kind, sub_queries,
  context_chunks_added, forced_stop, llm_call_ms) + `HopKind`
  (Decompose / Decide / Synthesize) — wire-additive shape.
- `kebab_core::Answer.hops: Option<Vec<HopRecord>>` —
  `#[serde(default, skip_serializing_if = "Option::is_none")]`,
  single-pass / refusal path 는 None, PR-3b 의 multi-hop happy
  path 가 Some.
- `kebab_config::RagCfg` 에 3 신규 노브:
  - `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)
  3 모두 `#[serde(default)]` + env override
  (`KEBAB_RAG_MULTI_HOP_MAX_*`) + legacy parse 핀
  (`LEGACY_PRE_TIMEOUT_TOML` 공유).
- 9 Answer literal site (pipeline.rs ×6 + kebab-cli + kebab-tui
  tests + kebab-eval test) 에 `hops: None` 명시 추가. exhaustive
  field check 가 자동 guard — 빠진 site 시 compile fail.
- plan 의 PR-3 단락 → PR-3a / PR-3b 분할 명시 + scope 정정.

Tests (163 passing across kebab-config + kebab-core + kebab-rag):
- 5 신규 multi-hop knob test (default / env override / legacy parse).
- 기존 50+57+31+19+3+3 test 모두 hops:None 추가 후도 통과.

Wire 영향: `answer.v1` 의 optional `hops` 필드 — `skip_serializing_
if = None` 이라 single-pass response 에 emit 안 됨. wire breaking
아님, JSON Schema 갱신은 PR-3b 또는 PR-4 (실제 emit 시점).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 07:15:01 +00:00

16 KiB
Raw Blame History

title, date, task_id, phase, status, target_version, design
title date task_id phase status target_version design
p9-fb-41 multi-hop RAG implementation plan 2026-05-25 p9-fb-41 P9 open 0.18.0 ../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.tomlfixtures/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<Answer> 신규.
    • 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_setopts.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 분할 (작업 양 측면, 2026-05-25 사용자 결정)

원래 plan: PR-3 가 wire additive (Answer.hops) + RagCfg 노브 + decide loop + ScriptedLm + helper refactor + 5+ tests 단일 PR.

실제 분할 (~1500+ 줄 단일 PR → review 부담 + 회기 위험 ↓):

  • PR-3a (본 PR): wire additive (HopRecord + HopKind + Answer.hops) + RagCfg 3 노브 + 모든 Answer literal 갱신 (hops:None). RAG pipeline 동작 미변경 — additive only.
  • PR-3b (후속): dynamic decide loop + ScriptedLm helper + 5+ integration tests + format! named arg + 회차 1 carry-over (mirror refactor / history block helper).

PR-3a: Wire additive + RagCfg 노브 (HopRecord type + Answer.hops field)

Goal: 후속 PR (PR-3b decide loop) 의 wire / config foundation. RAG pipeline 동작 변경 없음 — Answer.hops 가 모든 path 에서 None, RagCfg 새 3 노브가 default 만 적용. PR-3b 가 이 위에서 decide loop 구현.

Files:

  • crates/kebab-core/src/answer.rs:
    • HopRecord struct (iter, kind, sub_queries, context_chunks_added, forced_stop, llm_call_ms).
    • HopKind enum (Decompose / Decide / Synthesize).
    • Answer.hops: Option<Vec<HopRecord>> field — #[serde(default, skip_serializing_if = "Option::is_none")].
  • crates/kebab-core/src/lib.rs: pub use answer::{HopKind, HopRecord, ...}.
  • crates/kebab-config/src/lib.rs:
    • RagCfgmulti_hop_max_depth: u32 (default 3), multi_hop_max_sub_queries_per_iter: u32 (default 5), multi_hop_max_pool_chunks: u32 (default 30). #[serde(default)] + env override + legacy parse.
  • 모든 Answer literal site 갱신 (9 sites: kebab-rag/src/pipeline.rs ×6 + kebab-cli/src/main.rs + kebab-tui/tests/ask.rs + kebab-eval/src/metrics.rs): hops: None 명시. 향후 PR-3b 의 ask_multi_hop happy path 만 Some(hops_trace) 채움.

Tests:

  • default_multi_hop_max_depth_is_3, default_multi_hop_max_sub_queries_per_iter_is_5, default_multi_hop_max_pool_chunks_is_30.
  • env_overrides_multi_hop_knobs.
  • legacy_config_without_multi_hop_knobs_uses_defaults (LEGACY_PRE_TIMEOUT_TOML 공유).
  • 모든 기존 RAG / TUI / CLI / eval test 가 hops:None 추가 후도 통과 (회귀 핀).

Wire 영향: answer.v1 JSON Schema 의 hops optional 필드 — skip_serializing_ifNone 일 때 emit 안 함이라 옛 single-pass response 에 변동 없음. wire breaking 아님. JSON Schema 갱신은 PR-3b 또는 PR-4 (실제 emit 시점).

PR-3b: Dynamic iteration (decide loop + caps) — 후속 PR

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<HopRecord>) — Answer.hops field 의 internal staging.
  • crates/kebab-config/src/lib.rs:
    • RagCfgmulti_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<Vec<HopRecord>> 필드 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).
  7. PR-2 회차 1 carry-over — 같은 PR 에서 함께 해소:
    • ask + ask_multi_hop 의 §4-§9 mirror (~150 줄 중복) → 공통 helper synthesize_with_packed_context 추출. history block 처리도 helper 화. drift 위험 차단.
    • MULTI_HOP_DECOMPOSE_USER_TEMPLATE.replace("{query}", ...).replace("{max_sub_queries}", ...) corner case → format! named arg 또는 strict substitution helper 로 교체. 사용자 query 에 literal {max_sub_queries} 포함 시 mis-replace 회피.

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::MultiHopDecomposeFailederror.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-41v0.18.0).
  4. gitea-release v0.18.0 --auto-notes + release notes.
  5. INDEX.md 의 fb-41 status opencompleted.

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 요약).