feat(rag): fb-41 PR-2 ask_multi_hop skeleton (fixed depth=2) #167
Reference in New Issue
Block a user
Delete Branch "feat/fb-41-pr-2-ask-multi-hop-skeleton"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
요약
fb-41 multi-hop RAG 의 PR-2.
RagPipeline::ask_multi_hopskeleton — decompose LLM call → 각 sub-query retrieve + chunk_id dedup pool → synthesize LLM call. Dynamic decide loop (LLM 의 "추가 필요?" signal + max_depth cap) 는 PR-3 에서.설계: docs/superpowers/specs/2026-05-25-p9-fb-41-multi-hop-rag-design.md
계획: docs/superpowers/plans/2026-05-25-p9-fb-41-multi-hop-rag.md (PR-2 단락)
변경
AskOpts.multi_hop: bool신규 +impl Default for AskOpts— HOTFIXES 2026-05-07 의 known limitation (모든 caller 가 9 field 명시 init) 해소. 9 explicit init site 갱신 (kebab-rag tests 3 + kebab-cli 2 + kebab-mcp + kebab-tui + kebab-eval + kebab-app/tests).RagPipeline::askentry 에 dispatcher 한 줄.opts.multi_hop=true→ask_multi_hoproute.RagPipeline::ask_multi_hopskeleton — decompose + retrieve + synthesize 3 stage. PR-2 는 fixed depth=2 (decide loop 없음).prompt_template_version = "rag-multi-hop-v1"stamp.RefusalReason::MultiHopDecomposeFailedvariant 신규. Cascade:kebab-store-sqlite::refusal_reason_label+kebab-tui::ask refusal renderexhaustive match 갱신.MULTI_HOP_DECOMPOSE_SYSTEM_PROMPT+MULTI_HOP_DECOMPOSE_USER_TEMPLATE+MULTI_HOP_SYNTHESIZE_SYSTEM_PROMPT+PROMPT_TEMPLATE_VERSION_MULTI_HOP+MULTI_HOP_MAX_SUB_QUERIES_DEFAULT = 5.parse_decompose_response+strip_markdown_json_fencehelper — markdown code fence (``` json) strip + JSON array of strings parse + trim + drop empty + cap.회귀 핀 (55 test 통과, 8 신규)
parse_decompose_response_*): bare array / json fence / bare fence / garbage / cap / trim.ask_multi_hop_dispatches_and_decompose_garbage_refuses—multi_hop=true시 garbage decompose 응답이RefusalReason::MultiHopDecomposeFailed+ 정확히 1 LLM call (synthesize 까지 진입 안 함) +prompt_template_version = rag-multi-hop-v1.ask_with_multi_hop_false_keeps_single_pass_path— 회귀 핀, 기존 single-pass caller 자동 backwards-compat. Default route + config 의prompt_template_version그대로 stamp.의도된 비범위 (PR-3+ 책임)
continue/stopsignal +max_depth/max_sub_queries_per_iter/max_pool_chunkscap) — PR-3.Answer.hopswire field 노출 — PR-3 가 trace 채우기 시작.ScriptedLmhelper (per-call 다른 response 반환) 가 PR-3 와 함께 도입.--multi-hopflag — PR-4.multi_hopargument + SKILL.md — PR-5.검증
cargo test -p kebab-rag -j 1— 55 test 모두 녹색 (30 unit + 19 pipeline + 3 prompt + 3 streaming + 0 doc).cargo clippy -p kebab-rag -p kebab-core -p kebab-store-sqlite -p kebab-tui -p kebab-app --all-targets -j 1 -- -D warnings— clean.-j 1).시험 항목 (Test Plan)
parse_decompose_response_parses_bare_json_arrayparse_decompose_response_strips_markdown_json_fenceparse_decompose_response_strips_bare_markdown_fenceparse_decompose_response_returns_none_for_garbageparse_decompose_response_caps_at_max_sub_queriesparse_decompose_response_trims_each_entryask_multi_hop_dispatches_and_decompose_garbage_refusesask_with_multi_hop_false_keeps_single_pass_path다음 단계
PR-3 — dynamic decide loop +
Answer.hopswire additive +max_depth등 cfg 노브. ScriptedLm helper 도입으로 happy-path integration test.Assisted-by: Claude Code
PR-2 of fb-41 multi-hop RAG. Decompose + retrieve + synthesize 3-stage pipeline가 `opts.multi_hop=true` 일 때 dispatch. Dynamic decide loop 는 PR-3. - `AskOpts.multi_hop: bool` 필드 추가 + `impl Default for AskOpts` 도입 (HOTFIXES 2026-05-07 의 known limitation 해소). 9 explicit init site 모두 `multi_hop: false` 추가 — Default 도입으로 향후 `..Default::default()` 점진 migrate 가능. - `RagPipeline::ask` 의 entry 에 dispatcher 한 줄 (`if opts.multi_hop { return self.ask_multi_hop(...) }`). - `RagPipeline::ask_multi_hop` 신규 method. 1) decompose LLM call → JSON array of strings parse, 2) 각 sub-query 로 retrieve + chunk_id dedup pool, 3) score gate / no-chunks 가드, 4) pack_context (single-pass 와 helper 공유), 5) synthesize LLM call w/ MULTI_HOP_SYNTHESIZE_SYSTEM_PROMPT, 6) citation extract + Answer build. `prompt_template_version` = "rag-multi-hop-v1" 로 stamp — eval `compare` 가 single-pass vs multi-hop 분리. - Prompt const 신규: MULTI_HOP_DECOMPOSE_SYSTEM_PROMPT + MULTI_HOP_DECOMPOSE_USER_TEMPLATE + MULTI_HOP_SYNTHESIZE_SYSTEM_PROMPT + PROMPT_TEMPLATE_VERSION_MULTI_HOP + MULTI_HOP_MAX_SUB_QUERIES_DEFAULT. - `kebab_core::RefusalReason::MultiHopDecomposeFailed` variant 신규. Cascade: kebab-store-sqlite `refusal_reason_label` + kebab-tui `ask refusal render` exhaustive match 갱신. - `parse_decompose_response` + `strip_markdown_json_fence` helper — markdown code fence (```json / ```) strip + JSON array of strings parse + trim + drop empty + cap at MULTI_HOP_MAX_SUB_QUERIES_DEFAULT. None 반환 시 caller 가 `MultiHopDecomposeFailed` refusal. Tests (55 passing total, 8 신규): - 6 unit (parse_decompose_response 의 bare array / fence variants / garbage / cap / trim 회귀 핀). - 2 integration: `ask_multi_hop_dispatches_and_decompose_garbage_refuses` (decompose garbage → MultiHopDecomposeFailed + 정확히 1 LLM call) + `ask_with_multi_hop_false_keeps_single_pass_path` (회귀 핀, 기존 caller 자동 backwards-compat). Happy-path multi-hop (decompose 성공 → synthesize) 의 integration test 는 ScriptedLm helper 가 PR-3 의 decide loop 와 함께 도입될 때 같이 추가. 현 `MockLanguageModel` 는 canned single response 라 2-LLM-call sequence 핀 불가. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>회차 1 — PR-2 scope 정확히 ship (Default + dispatch + decompose-failure refusal + cascade). RefusalReason cascade build/clippy clean. actionable 1 + suggestion 3 + question 1 + 칭찬 1:
actionable:
parse_decompose_response_drops_partial_empty_keeps_valid회귀 핀 추가 — 현재 모두 empty 만 핀, partial-empty 케이스 가드 부족suggestion:
{max_sub_queries}substitution corner case (사용자 query 안에 literal 가능)question:
stop: Vec::new()의 의도 — instruction-following 약한 모델 prose 추가 대응 policy 확인칭찬: exhaustive match cascade 가 Rust 자동 guard 로 missing site 0.
@@ -69,3 +74,4 @@MultiHopDecomposeFailed,}#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]칭찬 —
RefusalReason::MultiHopDecomposeFailedvariant 추가 후 모든 exhaustive match cascade (kebab-store-sqlite::refusal_reason_label + kebab-tui::ask refusal render) 가 build + clippy clean — Rust 의 exhaustive match 가 자동 guard. cascade missing 한 곳 없음. 또 wire code 의 snake_case 매핑 (multi_hop_decompose_failed) 이 design §10.1 error.v1 의 future-extensions 절 패턴과 일관.@@ -546,2 +587,4 @@}/// p9-fb-41: multi-hop ask. Decompose the user query into independent/// sub-questions, retrieve each separately, then synthesize a singleRefactor opportunity (PR-3 합리적, 본 PR scope 외) —
ask_multi_hop의 §4-§9 (pack_context → synthesize prompt → generate stream → citation extract → Answer build → persist) 가ask의 동일 단계 mirror. ~150 lines 중복.PR-3 가 dynamic iter 도입하면 자연스러운 refactor 지점 — 공통 helper (예:
synthesize_with_packed_context(query, system, packed_text, packed_entries, opts, started, top_score, prompt_template_version) -> Result<Answer>) 추출. 본 PR 의 working state 가 우선이라 즉시 refactor 안 하는 게 의도된 staging.history block 처리 (line 850+
history_block = serialize_history(...)) 도 양쪽 path 에서 같은 코드 — 같은 PR-3 refactor 에 묶임. drift 위험 (한쪽만 patch 시 단발 검색 결과 다름) → 명시적 helper 화 가치 ↑.Suggestion 만 — 본 PR 머지 후 PR-3 plan 단락에 "helper 추출" 의 명시 추가 권장.
@@ -548,0 +890,4 @@Ok(answer)}/// Run a single decompose LLM call and parse the response into aQuestion — decompose stop vec 비어 있는 의도 확인:
synthesize 는
stop: vec!["\n\n[원본 질문]"]로 stop sequence 있음. decompose 는 비어 있음. instruction-following 약한 모델 (gemma3:4b CPU 환경, README 권장) 이 JSON array]다음에 prose 추가 ("..."]\n\n참고: ...) 시parse_decompose_response가 fence strip 만으로 처리 못 함 → refusal.2 옵션:
["]\n\n", "```\n"]— array 끝 + fence 끝 기점 stop. parse 강인성 ↑, 단 LLM 이 마지막]직전 stop 가능성 (truncated array → parse fail).현재 채택은 (1) 로 보이는데 명시적 의도라면 OK, 미고려라면 (2) 검토. 답변에 따라 회차 2 에서 doc comment 한 줄 추가 또는 stop vec 보강.
@@ -548,0 +898,4 @@let user = MULTI_HOP_DECOMPOSE_USER_TEMPLATE.replace("{query}", query).replace("{max_sub_queries}",Corner case (nit) —
MULTI_HOP_DECOMPOSE_USER_TEMPLATEsubstitution order:사용자 query 안에
"{max_sub_queries}"literal 이 들어 있으면 두 번째 replace 가 의도치 않게 fire (예:"여기 {max_sub_queries} 의 의미는?"같은 메타 질문). 실용적 corner case 빈도 ↓ — kebab 사용자가 LLM template variable 을 query 에 일부러 넣을 가능성 매우 낮음.Fix 옵션 (회차 2 또는 PR-3):
{max_sub_queries}먼저 →{query}두 번째. 사용자 query 안의{query}literal 도 동일 문제 — 완전 해결은 안 됨.format!매크로의{query}named arg 사용 — compile-time check, mis-replace 없음.Fb-41 의 multi-hop 만 영향이라 PR-2 의 명시 nit, fix 시점은 PR-3 합리적.
@@ -912,0 +1379,4 @@/// Returns `None` when:/// - parse fails outright (not a JSON array of strings),/// - the array deserializes but is empty after trim/drop,///회귀 핀 보강 권장: partial-empty 케이스. 현재
parse_decompose_response_returns_none_for_garbage는 모두 empty 인[" ", ""]→ None 검증.["", "valid"]→Some(vec!["valid"])(empty drop, 나머지 유지) 케이스의 회귀 핀 없음.현재 동작이 그렇게 작동 (filter + trim + take chain) — pin 해두면 향후 fence strip / order 변경 시 회귀 자동 ���견.
- `parse_decompose_response_drops_partial_empty_keeps_valid` 신규 회귀 핀 — `["", "valid q", " "]` → `["valid q"]` (trim+filter chain 동작 pin). - `multi_hop_decompose` 의 `stop: Vec::new()` 옆 doc comment 추가 — 의도 명시 (instruction-following 모델 기대 + prose 추가 시 MultiHopDecomposeFailed refusal 가 policy). 회차 1 question 의 답변. - plan 의 PR-3 implementation order 에 회차 1 carry-over 추가: 1) ask + ask_multi_hop 의 §4-§9 mirror → 공통 helper 추출, 2) decompose template 의 substitution corner case → format! named arg 으로 교체. 회차 1 의 다른 suggestion (mirror refactor, substitution corner case, history block helper) 는 PR-3 합리적 timing 으로 plan 에 명시 — 회차 2 reply 에 정리. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>회차 2 — 회차 1 의 actionable 1 (partial-empty pin) + question 답변 (stop intent doc) 잘 반영. 잔여 actionable 0건. APPROVE.
회차 1 의 다른 3 suggestion 의 PR-3 carry-over 명시:
MULTI_HOP_DECOMPOSE_USER_TEMPLATEsubstitution corner case →format!named arg 으로 교체plan 의 PR-3 implementation order 에 7번 항목으로 추가됨 — PR-3 가 자연스러운 timing (decide loop 도입하면서 본격 refactor). 본 PR scope 외 잘 분리.
cadence 자연 (5 → 0) 수렴. PR-3 (dynamic iter + decide loop + Answer.hops wire + cfg 노브 + ScriptedLm helper) 진행 권장.
@@ -548,0 +899,4 @@.replace("{query}", query).replace("{max_sub_queries}",&MULTI_HOP_MAX_SUB_QUERIES_DEFAULT.to_string(),칭찬 —
stop: Vec::new()옆 doc comment 가 (1) instruction-following 모델 가정 + (2) prose-after-array 시 의도된 refusal policy + (3) 잘못된 stop sequence (]) 의 truncation 위험까지 명시. 후속 reviewer / archeology 가Vec::new()의도 즉시 파악 + alternative trade-off 까지 추적 가능. 회차 1 question 의 답변이 코드 + doc 양쪽에 etched 됨.@@ -912,0 +1392,4 @@////// in which case the caller surfaces `RefusalReason::MultiHopDecomposeFailed`.fn parse_decompose_response(raw: &str) -> Option<Vec<String>> {let stripped = strip_markdown_json_fence(raw.trim());칭찬 —
parse_decompose_response_drops_partial_empty_keeps_valid핀이 정확히 trim+filter chain 의 invariant ("["", "valid q", " "]→["valid q"]") 를 표현. 향후 refactor (예: PR-3 의 helper 추출 시 step 재정렬) 가 partial-empty 케이스를 silently 깨트리면 즉시 fail.