feat(rag): fb-41 PR-3b-i dynamic decide loop + helpers #169
Reference in New Issue
Block a user
Delete Branch "feat/fb-41-pr-3b-decide-loop"
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-3b-i (PR-3b 의 분할 첫 PR, code 변경 중심).
ask_multi_hop의 fixed depth=2 → dynamic N-hop. ScriptedLm helper + 5+ integration tests 는 PR-3b-ii.설계: 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-3b 단락)
변경
multi_hop_decompose시그니처 변경 —Result<(Option<Vec<String>>, u32)>(parsed + latency_ms). caller 가 HopRecord 의llm_call_msstamp.multi_hop_decidehelper 신규 — decide LLM call →parse_decompose_response. None / empty array 가 stop signal (refusal 아닌 graceful degrade).MULTI_HOP_DECIDE_SYSTEM_PROMPTconst 신규.MULTI_HOP_DECOMPOSE_USER_TEMPLATEconst 제거 +format!named arg 사용 (PR-2 회차 1 carry-over fix). compile-time substitution check — 사용자 query 안에{max_sub_queries}literal 있어도 mis-replace 회피.ask_multi_hop의 §1-§2 영역 dynamic loop 으로 재작성:max_pool_chunks도달: pool_cap_hit=true → forced_stop=true + decide LLM call skip.max_depth도달: forced_stop=true (마지막 iter).synthesize_started별 stamp → §8 Build Answer 직전HopRecord { kind=Synthesize, llm_call_ms=synth_ms }추가. happy path 의 Answer literalhops: Some(hops)채움.검증
cargo build -p kebab-rag -j 1— clean.cargo test -p kebab-rag -j 1— 56 test 모두 통과. 기존 PR-2 회귀 핀 2 통과 (ask_multi_hop_dispatches_and_decompose_garbage_refuses+ask_with_multi_hop_false_keeps_single_pass_path).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.시험 항목 (Test Plan)
비범위 (PR-3b-ii)
ScriptedLmhelper (per-call 다른 response 반환) — kebab-rag tests/common/mod.rs 또는 별 modulemulti_hop_decide_stop_triggers_synthesize— decide[]→ 즉시 synthesizemulti_hop_decide_continue_adds_more_chunks— decide["q4"]→ iter 2 진행multi_hop_max_depth_force_stops— depth=max_depth → forced_stop=truemulti_hop_pool_chunks_dedup_by_chunk_id— 같은 chunk 가 두 sub-query 에서 나와도 pool 에 1 회multi_hop_decide_parse_failure_falls_through_to_synthesize— graceful degradeask+ask_multi_hop의 §4-§9 공통 helper)answer.schema.json의hops명시 추가 (additive minor)Wire 영향
Answer.hops가 multi-hop happy path 에서 emit. JSON SchemaadditionalPropertiesdefaulttrue(PR-3a review 확인) 라 wire breaking 아님 — additive. schema.json 명시 갱신은 PR-3b-ii 또는 PR-4 의 sweep.다음 단계
PR-3b-i 머지 후 PR-3b-ii (ScriptedLm + 5+ tests + refusal hops). 그 다음 PR-4 (CLI
--multi-hopflag + wire schema additive).Assisted-by: Claude Code
PR-3b 의 분할 첫 PR. ask_multi_hop 의 fixed depth=2 → dynamic N-hop. ScriptedLm helper + 5+ integration tests (happy-path 통합 검증) 는 PR-3b-ii 분리. 본 PR 의 회귀 핀 = 기존 PR-2 의 2 integration test 통과 (decompose garbage refusal + multi_hop=false single-pass keep). - `RagPipeline::multi_hop_decompose` 시그니처 변경 — `Result< (Option<Vec<String>>, u32)>` (parsed result + LLM call latency_ms). caller (`ask_multi_hop`) 가 hop trace 의 `llm_call_ms` stamp. - `RagPipeline::multi_hop_decide` helper 신규. decide LLM call → `parse_decompose_response` 으로 `Option<Vec<String>>` 반환. None 또는 empty array 가 stop signal (refusal 아닌 graceful degrade). - `MULTI_HOP_DECIDE_SYSTEM_PROMPT` const 신규. - `MULTI_HOP_DECOMPOSE_USER_TEMPLATE` const 제거 + `format!` named arg 사용 (PR-2 회차 1 carry-over fix). compile-time substitution check — 사용자 query 안에 `{max_sub_queries}` literal 있어도 mis-replace 회피. - `ask_multi_hop` 의 §1 (Decompose) + §2 (Retrieve) 영역을 dynamic loop 으로 재작성: - iter 0 = decompose, HopRecord 추가 (kind=Decompose). - iter 1..=max_depth = retrieve current_sub_queries → pool dedup → decide LLM call (forced_stop / pool_cap_hit 시 skip). HopRecord 추가 (kind=Decide, sub_queries=new_sub_queries, context_chunks_added, forced_stop, llm_call_ms). - `max_pool_chunks` 도달 시 `pool_cap_hit = true` → 그 iter 의 HopRecord 가 `forced_stop = true` + decide LLM call skip. - depth 도달 (`iter >= max_depth`) 시 동일하게 forced_stop. - decide parse failure 또는 empty array → loop break (early synthesize, NOT refusal — spec §9 graceful degrade). - §6 (Generate) 시작 시 `synthesize_started: Instant::now()` 별 stamp → §8 Build Answer 직전 `HopRecord { kind=Synthesize, llm_call_ms = synth_ms }` 추가. happy path 의 Answer literal `hops: Some(hops)` 채움 (`hops: None` → `Some(...)` 변경). - doc comment 갱신: "PR-2 scope (fixed depth=2)" → "PR-3b-i scope (dynamic N-hop)". refusal path 의 hops trace 손실 caveat 명시 (PR-3b-ii / follow-up 에서 helper signature 확장 시 해결). 기존 회귀 핀 (PR-2 의 2 integration test): - `ask_multi_hop_dispatches_and_decompose_garbage_refuses`: decompose garbage → RefusalReason::MultiHopDecomposeFailed + 정확히 1 LLM call. PR-3b-i 의 시그니처 변경 후도 통과. - `ask_with_multi_hop_false_keeps_single_pass_path`: 영향 없음. 56 unit + integration test 모두 통과 (kebab-rag). Wire 영향: `Answer.hops` 가 multi-hop happy path 에서 emit. JSON Schema additionalProperties default `true` 라 wire breaking 아님 (PR-3a 의 review 확인). schema.json 명시 갱신은 별 PR (PR-3b-ii 또는 PR-4 의 schema sweep). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>회차 1 — dynamic decide loop 의 step (decompose → retrieve+decide loop → synthesize) 가 명확. forced_stop signaling unified, decide parse failure 의 graceful degrade 정확. actionable 0 + suggestion 3 + 칭찬 2:
suggestion (모두 PR-3b-ii 또는 follow-up):
Some(qs) if !qs.is_empty()guard 의 의도 (defensive vs dead code) — doc 명시칭찬:
APPROVE — 본 PR scope 안에서 의도 분명, 모든 회귀 핀 통과. PR-3b-ii (ScriptedLm + 5+ happy-path integration tests) 진행 권장.
@@ -595,55 +596,143 @@ impl RagPipeline {/// sub-questions, retrieve each separately, then synthesize a single/// answer over the deduplicated pool.칭찬 — doc comment 의 "refusal path 의 hops trace 손실 caveat" 명시 ("Pool empty after retrieval →
RefusalReason::NoChunks(refusal path currently loses the partial hop trace — cleanup deferred to PR-3b-ii)"). 후속 reviewer 가 ask_multi_hop 의 refusal 케이스 trace 부재 즉시 발견. 의도된 staging vs forgotten work 구분 자연.또 "decide parse failure ≠ refusal" 별도 단락 으로 graceful degrade 정책 명시 — spec §9 의 의도가 doc 에 etched 됨.
@@ -647,0 +714,4 @@match decide_result {Some(qs) if !qs.is_empty() => (qs, ms),// Empty array OR parse failure → stop signal.// Parse failure is NOT a refusal — gracefulSuggestion — decide prompt 의 pool snippet concat 가 pack budget 미적용:
pool.iter()가 pool 의 모든 chunk 를 concat.multi_hop_max_pool_chunksdefault 30 + snippet ~220 char → ~6.6 KB.multi_hop_max_pool_chunks가 cfg 로 늘어나면 (예: 100) preview 가 ~22 KB → LLM context 의 큰 부분 차지.cfg.rag.max_context_tokens(default 8000 ≈ 32 KB) 와 별 cap 이라 향후 overflow 가능.pack_context의 budget 처리 (token estimate + budget trim) 와 다른 path.옵션 (PR-3b-ii 또는 follow-up):
multi_hop_decide가 pack budget 적용 — 신규 helperpack_decide_preview(pool, budget).pool.iter().take(N)으로 hard cap — N 도 cfg.max_pool_chunkscfg 가 implicit cap 으로 충분.PR-3b-i 의 scope 외 — default 30 에서는 acceptable.
@@ -647,0 +717,4 @@// Parse failure is NOT a refusal — graceful// degrade to early synthesize (per spec §9)._ => (Vec::new(), ms),}칭찬 —
forced_stop = depth_force_stop || pool_cap_hit의 unified signal. 두 cap (max_depth+max_pool_chunks) 가 동일forced_stop=true로 HopRecord 에 trace. 사용자 / agent 가 후속 archeology 시 forced_stop=true 만 보고 "cap 도달 = trace 분석 필요" 즉시 파악. 두 cap 의 별 signal 분리하지 않은 게 wire 의 단순성 ↑.또 doc comment 의 "both early exits flag
forced_stop = trueon the iter'sHopRecord" 명시 가 후속 reviewer 의 의도 추적 가능.Suggestion —
HopRecord.llm_call_ms = 0의미 ambiguity:HopRecord 의
llm_call_ms = 0가 두 의미 hold:실제 (2) 거의 안 일어남 (LLM call 은 항상 latency 있음). 단 후속 archeology 가
llm_call_ms = 0보면 두 케이스 구분 어려움.옵션:
forced_stop = true+llm_call_ms = 0= decide call skipped" 안내. PR-3b-ii 단계에서 추가.Option<u32>: shape 변경 —llm_call_ms: Option<u32>로 None 가 "no call". 단 wire schema 영향 + serde 다룸. PR-3b-ii 또는 future PR scope.PR-3b-i 의 scope 외 — 의미가 trace 의 fidelity 측면 약점.
Suggestion —
Some(qs) if !qs.is_empty()guard 의 의도 doc:현재
parse_decompose_response의 documented invariant: empty array → None (drop empty + take cap + None on all-empty). 따라서decide_result가Some(...)면 항상 non-empty array. 즉if !qs.is_empty()guard 가 실질적 dead code.Defensive 의도 (parse_decompose_response 가 미래에 invariant 변경 시 안전) 면 OK — doc comment 한 줄로 명시 권장:
또는 단순화:
decide_result.unwrap_or_default()+if qs.is_empty() { break }한 단계 더 위에서. Suggestion 만.