리뷰가 이 PR 의 핵심을 무너뜨리는 결함을 잡았다.
1) render_dpi 가 아무 일도 안 하고 있었다 (HIGH)
`set_maximum_*` 만 걸었는데, pdfium-render 에서 maximum 은 초과할 때만
줄이는 클램프이고 스케일이 아니다. 타깃도 배율도 없으면 스케일 1.0 —
1 pt → 1 px, 즉 **72 DPI** 로 렌더된다. 300 을 주든 1200 을 주든 산출물이
같았다. 렌더가 실패하지 않으니 도그푸딩도 통과해 버렸다.
실측 (govdocs1-000157-ccitt.pdf 5쪽):
maximum_* 만 (초안) 621×801 px 72 DPI
target + maximum_* (수정) 1588×2048 px 184 DPI
같은 뿌리로 종횡비도 깨져 있었다. 클램프만 걸리는 경로는
`do_maintain_aspect_ratio = false` 라 가로·세로가 독립적으로 잘린다.
600×800pt 페이지를 600px 예산으로 렌더하면 600×600 으로 세로가 25%
눌린 채 나왔고, 긴 변만 보던 테스트는 초록불이었다.
`render_dpi_changes_the_rendered_size` 와
`the_pixel_budget_is_respected_without_distorting_the_page` 로 고정했다.
`set_target_width` 한 줄을 되돌리면 둘 다 실패하는 것을 확인했다.
덧붙여 render_dpi 는 **요청**이고 max_pixels 가 이긴다. PDF 기본값
2048 이면 A4 는 175 DPI 언저리에서 잘린다. 기본값 300 이 그대로 나오지
않는다는 뜻이라 config·README·SMOKE 문구를 실제와 맞췄다.
2) /MediaBox 를 직접 파싱하고 있었다 (MEDIUM)
`/MediaBox` 는 상속 속성이고 대부분의 생산자가 `/Pages` 노드에 한 번만
쓴다. lopdf 0.32 에는 상속 해석 헬퍼가 없어서 그런 PDF 는 전부 조용히
A4 폴백을 탔다. `/UserUnit` 도 미반영이었다.
pdfium 이 이미 페이지 크기를 안다. 거기서 받으니 40여 줄이 사라지고
상속·UserUnit 문제가 함께 없어졌으며, kebab-app 이 lopdf 딕셔너리를
뒤지던 레이어링도 정리됐다.
3) 렌더러가 있으면 오히려 손해 보는 경우가 있었다 (MEDIUM)
페이지 하나만 렌더에 실패하면 곧장 skip 이었고 DCTDecode 경로를 시도하지
않았다. "렌더러 우선 + 폴백" 이 렌더러 유무 수준에서만 성립했던 것이다.
페이지 단위 폴백을 넣었다.
4) 렌더러를 설정한 사용자에게 틀린 지시가 나갔다 (MEDIUM)
pdfium 이 PDF 자체를 못 열면 모든 페이지가 no_renderer 로 보고되면서
"render_library 를 지정하라" 고 안내했다. `unopenable_pdf` 로 갈랐다.
5) ⊘ 줄 수와 ocr-skipped 카운트가 안 맞았다 (MEDIUM)
카운트는 래스터 실패만 세는데 OCR 엔진 실패도 화면에는 똑같이 ⊘ 로
찍혔다. 사유를 라벨에 적어 둘을 구분한다 — 이 구분이 바로 아래 도그푸딩
에서 실제로 값을 했다.
6) 잔가지 (LOW)
docs 의 pdf-text-v1 잔재 3곳, doctor hint 의 줄 이음이 무너져 생긴 여백.
정답 있는 한국어 스캔으로 인식률을 쟀다 (CCITT 3건, qwen2.5vl:3b):
namu-beulenda… 8쪽 CER 15.65%
namu-bihaengdae 8쪽 CER 12.55%
namu-gu-anoli 6쪽 CER 15.08%
전 페이지 OCR 성공, 건너뜀 0. 수정 전에는 세 문서 모두 본문 0 자였다.
엔진 선택이 결과를 가른다는 것도 알게 됐다. 처음에는 이 머신에 있던
gemma3:4b 로 쟀는데 래스터는 정상인데 출력이 원문과 무관한 환각이었고,
해상도가 올라가자 밀집 한국어 페이지에서 180초 타임아웃이 났다. 범용
멀티모달 모델은 OCR 엔진이 아니다 — 이때 5번의 새 라벨이 "래스터 없음"이
아니라 "OCR 엔진 실패"로 찍어 줘서 원인이 바로 갈렸다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
`extract_dctdecode_page_image` 는 페이지의 image XObject 중 `/Filter` 가
정확히 DCTDecode 인 것 하나만 받는다. 실제 스캔본에서 흔한 CCITTFaxDecode·
JBIG2Decode·FlateDecode·JPXDecode, `[FlateDecode, DCTDecode]` 같은 체인,
Internet Archive 계열의 "배경 + /ImageMask" 분리 구조가 전부 걸러진다.
텍스트 게이트는 정상 동작했다. `needs_ocr` 판정을 통과했다는 건 "이 페이지는
스캔본이라 OCR 이 필요하다" 고 올바르게 본 것이다. 판정은 맞았고 래스터를 못
꺼냈을 뿐인데, 결과가 조용한 내용 손실이었다 — 색인은 성공으로 끝나고,
검색이 안 되는 시점에야 알게 되며, 그때 원인이 PDF 인코더라는 걸 역추적할
방법이 없다.
페이지를 렌더링한다 (`page_render::PageRenderer`, pdfium). 지원할 필터도,
고를 XObject 도 없고, 벡터와 이미지가 섞인 페이지도 리더가 보는 대로 나온다.
이슈가 지적한 "image XObject 선택이 비결정적" 문제도 이 경로에서는 성립하지
않는다.
이슈는 교체를 권했지만 렌더러 우선 + DCTDecode 폴백으로 갔다. 배포 형태
때문이다 — pdfium 은 공유 라이브러리로만 배포되고 정적 빌드가 없어서,
링크하면 CLAUDE.md 가 규정한 단일 바이너리가 깨진다. 사용자와 상의해 정했다.
- 런타임 바인딩. 있으면 전 인코딩 커버, 없으면 오늘 동작 + 왜 건너뛰었는지.
- `[ingest.pdf.ocr] render_library` 로 경로 지정, 비우면 로더 경로 탐색.
- `kebab doctor` 의 `pdf_render` 가 어느 쪽인지 보고.
- 바이너리 392.9 → 399.3 MB (+6.4 MB 글루). ldd 에 pdfium 없음.
조용한 손실을 시끄럽게 (이슈 부수 제안 2·3):
`failure_reason` 이 CLI 에서 `..` 로 버려지고 있었다. wire 이벤트는 원인을
구분해 싣는데 사람이 보는 출력이 "no DCTDecode or engine fail" 로 뭉갰다.
이제 no_renderer / render_error / ocr_error 를 구분해 찍는다.
`IngestReport.ocr_skipped_pages` 를 추가하고(additive) 사람용 요약에도
`ocr-skipped N` 으로 낸다 — stderr 한 줄로 흘리면 대량 ingest 에서 지나간다.
parser_version cascade: pdf-text-v1 → pdf-text-v2. 안 올리면 이미 색인된
스캔본에 적용되지 않는다 (파일이 안 바뀌었으니 해시가 같고 Unchanged 로
건너뛴다). 사용자가 --force-reingest 를 떠올려야만 고쳐지는 수정은 고쳐진 게
아니다. 스냅샷 둘이 따라 움직였고 바뀐 것이 파생 식별자뿐임을 확인했다 —
본문 텍스트·inlines·source_span·metadata 는 동일.
구현 중 발견: pdfium 은 동시 사용이 안전하지 않다. 테스트를 병렬로 돌리자
`double free or corruption` 으로 프로세스가 죽었고, `thread_safe` 기능만으로는
부족했다. ingest 는 PDF 를 하나씩 처리하니 오늘은 문제가 없지만 `Arc` 는
공유해도 된다고 광고하는 타입이라, `PageRenderer` 안에 뮤텍스를 두고
`RenderedPdf` 가 문서 수명 동안 잡게 했다 (필드 선언 순서가 load-bearing —
doc 이 guard 보다 먼저 드롭돼야 한다). 지금 비용 0, 병렬화되는 날 메모리
손상 대신 대기가 된다. `set_target_width` 만 주면 긴 스캔에서 pdfium 이 C++
length_error 로 프로세스를 죽여서(exceptions 비활성 빌드라 Err 로 못 받는다)
양변을 set_maximum_* 으로 묶었다. 바인딩도 run 당 1회여야 한다.
실측 (govdocs1-000157-ccitt.pdf, 22쪽 중 1쪽이 CCITT 스캔, gemma3:4b):
렌더러 없음 렌더러 있음
OCR ⊘ 건너뜀 — 인코딩을 읽을 수 없다 ✓ 101 chars, 6489ms
chunk 35 36
글자 수 35,994 36,095
요약 ocr-skipped 1 (없음)
렌더링 자체는 여섯 필터 계열 전부 확인 — CCITT / JBIG2 / Flate / JPX /
혼합(DCT+CCITT+JBIG2+Flate) / DCT, 300dpi 페이지당 40~145 ms.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
#231 은 "캐시가 히트하는데도 우회하고 전량 재임베딩하는 편이 더 빠르다" 고
보고하면서 본문에 "⚠️ 정량 실측 보완 필요 … 수치 미기록" 이라고 표를 비워
뒀다. 그 표를 채우는 것이 이 커밋의 핵심이다.
측정 (나무위키 792문서 / 16,379 chunk, ollama arctic-embed2 1024-dim,
--force-reingest 로 전 문서 full re-process):
캐시 히트 경로 139.1초 236 chunk/초
캐시 우회(전량 재임베딩) 1179.6초 28 chunk/초
캐시 히트가 8.5배 빠르다. 가설은 이 환경에서 재현되지 않는다.
새로 넣은 계측으로 139초의 내역을 보면 더 분명하다 (히트 32,758 / 미스 0):
Lance upsert + 레코드 구성 74.6초 53%
SQLite 문서·청크 기록 56.7초 41%
chunk 4.4초 3%
캐시 경로 전체(조회+삽입+touch) 0.6초 0.4%
이슈가 지목한 여섯 원인이 전부 합쳐 run 의 0.4% 다.
다만 "보고가 틀렸다" 로 읽으면 안 된다. 보고 이후 #229 와 #230 이 머지됐고,
#231 본문 스스로 #229 를 "같은 Mutex<Connection> 을 공유하므로 상호 증폭"
이라고 적었다. 원 보고 환경에서는 캐시 조회 52,000회가 그 뮤텍스를 잡았다
놓는데 같은 뮤텍스 위에서 chunk 삭제가 FTS5 전체 스캔을 돌리고 있었다.
"#229 를 먼저 고치면 체감이 줄어든다" 도 이슈의 예측이다. 이 머신이 61 GB
RAM 이라 DB 가 통째로 페이지 캐시에 올라간다는 점도 함께 적어 둔다.
반영한 것:
제안 1·2·4 는 실측과 무관하게 왕복이 줄 뿐 잃는 게 없어 넣었다. 다만 A/B
벽시계는 139.1초 → 139.3초로 측정 오차 안이다. 이 코퍼스에서는 체감이 없다.
- derivation_cache_get_many — `WHERE cache_key IN (…)` 배치 조회.
문서 하나가 평균 21 chunk 이라 왕복이 21회에서 1회가 된다.
- derivation_cache_put_many — 미스 벡터를 한 트랜잭션에. 기존 단건 put 은
명시 트랜잭션 밖이라 행마다 암묵 커밋이었다.
- prepare_cached — get/put/touch 셋 다. query_row 는 호출마다 SQL 을
다시 파싱한다.
제안 5(계측 노출)가 실질 산출물이다. `asset_timings` 에 cache_hit /
cache_miss / cache_ms 를 additive 로 실었다. 이전에는 hit/miss 가
tracing::info! 로 stderr 에만 나가 run 이 끝나면 사라졌고, "내 코퍼스에서
캐시가 이득인가" 를 확인할 방법이 없었다. cache_ms 에는 touch 도 포함한다 —
이슈의 가장 날카로운 지적이 "읽기 전용이어야 할 히트 경로가 쓰기를 만든다"
인데, touch 를 빼고 재는 지표로는 그 주장을 검증할 수 없다.
네 out-param 은 CacheStats 구조체로 묶었다. 함께 읽히고 함께 보고되는
값들이고, 셋만 갱신하고 하나를 빠뜨리면 캐시가 공짜인 것처럼 보고된다.
반영하지 않은 것:
- 제안 3(touch 를 히트 경로에서 분리). 캐시 경로 전체가 0.6초라 touch 만
떼어낼 이유가 없고, 권한 (c)안은 LRU 를 age 기반 축출로 바꾸는 의미
변경이다. 근거 없이 할 변경이 아니다.
- 제안 6(캐시 우회 스위치). 이슈 스스로 "1~4 로 해결되면 불필요 —
플래그부터 만들지 말 것" 이라고 적었다.
- 원인 6(4 KB BLOB overflow). page_size 변경은 기존 DB 에서 VACUUM 을
요구하는데 kebab 은 VACUUM 을 실행하지 않는다. 0.4% 에 낼 비용이 아니다.
곁다리로 #228 에서 내가 넣은 flaky test 를 고쳤다.
`ingest_log_records_the_deleted_file_sweep` 이 두 run 의 로그 중 뒤엣것을
파일명 정렬로 골랐는데, run id 가 `<초 단위 타임스탬프>-<난수 hex>` 라 같은
초에 끝난 두 run 은 난수 쪽으로 정렬된다. 이번 전체 테스트에서 우연히 터져
잡았다. 첫 run 의 로그 집합을 기록해 두고 차집합으로 고르도록 바꿨다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
3회차 리뷰가 취소 처리에 CRITICAL/HIGH 가 없음을 확인하고(특히 break 후에도
`flush_vector_deletes` 가 돌아 고아 벡터가 안 생기는 것, `examined` 가 네
경계에서 모두 맞는 것) 머지 가능으로 결론냈다. 남은 지적 다섯을 반영한다.
1) 빈 스캔 + sweep 취소가 Aborted 가 아니라 Completed 로 보고됐다 (MEDIUM)
`was_cancelled` 는 asset 루프 **본문**에서만 세팅되는데, 스캔이 0건이면
본문이 한 번도 안 돈다. 하필 그게 sweep 이 가장 커지는 경우다 — 스캔이
0건이면 저장된 모든 경로가 후보이므로, "색인된 디렉토리를 통째로 지우고
재색인 → 긴 sweep → Ctrl-C" 가 정확히 이 구멍에 들어간다. 사용자는
취소했는데 "완료"를 본다.
플래그로 시드한다. `cancelling_a_sweep_with_nothing_left_to_scan_still_
reports_aborted` 로 고정했는데, 처음 쓴 버전은 워크스페이스 상위만
지워서 픽스처가 하위 디렉토리에 남았고 asset 루프가 한 번 돌아 **되돌려도
통과했다**. 재귀 삭제로 고치고, 전제(`ScanCompleted { total: 0 }`)를
어서션으로 박았다. 시드를 되돌리면 실패하는 것을 확인했다.
2) CLI 테스트의 position 어서션에 탐지력이 없었다 (LOW)
두 시나리오를 따로 돌려서 두 번째에 `SweepProgress` 가 없었고, position 은
`SweepStarted` 의 `set_position(0)` 이후 계속 0 이었다. 즉
`dress_bar_for_assets` 안의 `set_position(0)` 을 지워도 통과했다.
하나로 합쳐 sweep 이 position 을 3 까지 올린 뒤 복구를 본다.
3) 그 테스트 주석이 실제 범위보다 넓게 주장했다 (LOW)
"하트비트 키까지 고정한다" 고 적었는데 실제로는 length/position 만 본다.
indicatif 가 스타일을 되읽을 방법을 주지 않으므로, 그 절반은
`dress_bar_for_assets` 가 양쪽 phase 의 유일한 옷 입히는 자리라는 사실에
기댄다 — 주석을 그렇게 고쳐 적었다.
4) 취소 계약 독 주석이 stale 했다 (LOW)
`ingest_with_config` 의 §10 계약에 "in-flight asset 이 끝나고 이후는
스킵" 만 있고 sweep 이 이제 취소를 본다는 사실이 없었다.
5) DOGFOOD §1.8 이 자기 모순이었다 (LOW)
`sweep_completed.checked == total` 을 단정하고 바로 다음 줄에서 취소 시엔
다르다고 했다. 앞줄에 "취소 없이 완주하면" 을 붙였다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
2회차 리뷰가 1회차 지적 6건 모두 실질 해결됐음을 확인하고 머지 가능으로
결론냈다. 새로 나온 것 중 값이 있는 것을 반영한다.
1) sweep 이 취소 플래그를 보지 않았다 (MEDIUM)
CLI 의 첫 Ctrl-C 는 "aborting after current asset" 을 찍고 AtomicBool 을
세운다. 그런데 `sweep_deleted_files` 는 그걸 인자로 받지도 검사하지도
않았다. 즉 긴 sweep 중에는 그 안내가 사실이 아니었고, 사용자에게 남은
수단은 두 번째 Ctrl-C 뿐인데 그건 `exit(130)` 이라 버퍼에 쌓인 벡터
삭제(최대 5,000)가 고아로 남는다.
이슈 #228 자체가 "sweep 중 Ctrl-C 를 세 번 눌러 죽였다" 는 보고다.
눌렀을 때의 동작을 그대로 둔 채 표시만 고치는 건 절반만 고친 것이다.
루프 상단에서 검사하고 break 한다. `sweep_completed.checked` 는 예고한
`total` 이 아니라 실제 검사한 수로 나간다 — total 을 그대로 쓰면 하지
않은 일을 했다고 보고하는 셈이다. `a_cancelled_sweep_stops_and_reports_
only_what_it_examined` 로 고정.
2) 1회차 HIGH 가 테스트로 고정되지 않았다 (MEDIUM)
바 상태 오염은 "phase 를 하나 더 추가하면 또 밟는" 유형인데 회귀 가드가
없었다. 실제로 한 번 배포될 뻔했다. `sweep_hands_the_bar_back_to_the_
asset_loop` 이 sweep 중에는 후보 수를, 끝난 뒤에는 asset 수를 세는지
본다. `ProgressMode::Human { tty: false, quiet: true }` 면 바가 hidden
draw target 으로 살아 있어 터미널 없이도 상태를 볼 수 있다.
3) 잔가지 (LOW)
- `SweepProgress` 독 주석이 `removed` 를 두 경우로만 설명했다. 이번
PR 이 만든 세 번째 경우(purge 실패)가 빠져 있었다. 스키마 쪽은 이미
맞게 적혀 있었다.
- `sweep_deleted_files` 의 기존 독 주석이 "purge 실패가 per-file 레벨
에서 error 로 집계된다" 고 적었는데 사실이 아니다. 어떤 카운터에도
안 들어가고 `IngestReport.errors` 에도 반영되지 않는다. 실패 경로를
손댄 김에 고쳤다.
- HOTFIXES 와 DOGFOOD 가 `purge_failed` 와 취소 동작을 언급하지 않았다.
HOTFIXES 가 live SoT 인데 실제 표면보다 좁게 적힌 상태였다.
미반영: `SweepCompleted` 가 TTY 에서 `bar.println` 대신 stderr 로 직접
쓰는 것(기존 `AssetTimings`/`PdfOcr*` 과 같은 패턴이라 신규 회귀가 아니고,
바꾸려면 셋을 같이 옮겨야 한다). `ScanCompleted` 에서 스타일을 길이보다
먼저 세팅하는 순서가 이론상 `0/u64::MAX` 프레임을 허용한다는 지적 — 창이
마이크로초이고 `SweepCompleted` 쪽에서는 새 순서가 더 낫다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
리뷰 두 건에서 나온 지적을 반영한다.
1) sweep 이 끝나도 asset 진행바가 복구되지 않았다 (HIGH)
sweep 은 asset 진행바를 빌려 쓰면서 자기 라벨과 자기(더 작은) 총계를
씌운다. 그런데 `AssetStarted` 는 위치와 메시지만 세팅하고 길이·스타일은
건드리지 않는다. 그래서 sweep 이 한 번 돌면 그 뒤 색인 구간 전체가
`sweep [====] 4213/21` 로 그려졌다 — 라벨도 분모도 틀린다.
더 나쁜 건 스타일 교체가 v0.26.1 의 커스텀 키 `{asset_elapsed}` 를 같이
날린다는 점이다. 느린 asset 에서 `(Ns)` 가 도는 게 "멈춘 게 아님"의 유일한
신호인데 sweep 이 그걸 없앤다. 이 PR 이 sweep 구간에서 없앤 "hang 처럼
보임" 을 asset 구간에 새로 만드는 셈이었다.
바 세팅을 `dress_bar_for_assets` 로 빼고 `ScanCompleted` 와
`SweepCompleted` 양쪽에서 부른다. 스타일을 길이보다 먼저 세팅하는데,
indicatif 가 두 호출 사이에 다시 그릴 수 있어 과도기 프레임이 최소한
올바른 라벨을 달게 하기 위해서다.
TTY 전용이라 비-TTY 실측만 보고 있어서 놓쳤다. pty 로 재현해 고친 뒤
`ingest [====] 16/17 doc9.md` 로 나오는 것을 확인했다.
2) docs/DOGFOOD.md §1.8 이 갱신되지 않았다 (MEDIUM)
`--json` 이벤트 순서 목록에 sweep 3종이 빠져 있었다. verify 항목에
sweep 분모·연속성과 "sweep 뒤 진행바가 asset 분모로 돌아오는가" 를 넣었다
— 1번이 정확히 그 항목이 있었으면 잡혔을 결함이다.
3) ingest_progress.rs 의 ordering invariant 주석이 stale 했다 (MEDIUM)
§2.4a 순서 블록에 sweep 이 없었다. 설계 문서 자체는 frozen baseline 이고
HOTFIXES dated entry 가 있으니 규약상 문제없지만 코드 주석은 living 이다.
4) 잔가지 (LOW)
- purge 실패가 "디스크에 남아 있어 그냥 뒀다" 와 구분되지 않았다. 둘 다
`removed: false` 이고 ndjson 에는 아무것도 안 남아,
`sweep_summary` 의 `checked - purged` 차이로도 못 가른다. 사후 기록이
로그뿐이라는 게 이 PR 의 전제이므로 `purge_failed` 를 추가했다.
- `SweepProgress` 가 purge 할 때만 바 메시지를 세팅하고 비우지 않아,
마지막 purge 경로가 이후 후보를 훑는 내내 남았다. 안 지울 때는 비운다.
- 주석과 스키마가 신규 이벤트를 `v0.33.0` 이라고 적었는데, CLAUDE.md 의
bump 규칙상 이 변경은 patch 다 (additive-only wire + 관측성 개선, 새
명령·플래그·config 없음, 검색·색인 결과 불변 — 선례가 asset_phase).
`v0.32.1` 로 고쳤다.
미반영: `SweepCompleted` 가 TTY 에서 `bar.println` 대신 stderr 에 직접
쓰는 것 — 기존 `AssetTimings`/`PdfOcr*` 이 같은 패턴이라 신규 회귀가 아니고,
바꾸려면 그 셋을 같이 옮겨야 해서 별 건이다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
`sweep_deleted_files` 는 walker 가 끝난 직후 asset 루프 전에 도는데, 이
구간이 관측 가능한 신호를 하나도 내지 않았다. 진행바는 walker 총계
(`0/12115`)를 표시한 채 멈춰 보이고 ndjson 로그는 0바이트로 남는다.
`tracing::info!` 은 나가지만 wire 이벤트가 아니라 사용자가 볼 산출물이
없다. 프로세스는 CPU 100% 로 정상 동작 중인데 밖에서는 hang 과 구별할 수
없고, 실제 도그푸딩에서 세 번 연속 Ctrl-C 로 죽였다.
`ingest_progress.v1` 에 세 이벤트를 추가한다 (additive — 기존 소비자는
모르는 kind 를 무시한다).
sweep_started { total }
sweep_progress { idx, total, path, removed }
sweep_completed { checked, purged, ms }
`total` 은 `all_workspace_paths()` 에서 이번 스캔이 덮은 경로를 뺀 값이라
루프 진입 전에 확정 분모가 나온다 (이슈 제안 1). `removed` 는 "정말 없어서
문서를 지웠다" 와 "아직 디스크에 있어 그대로 뒀다" 를 가른다 — 수천 건을
훑고 하나도 안 지우는 sweep 이 있으므로, 지울 때만 움직이는 진행바는 바로
그 경우에 다시 멈춰 보인다.
`purged` 를 두 이벤트에서 다른 타입으로 쓰지 않으려고 sweep_progress 쪽은
`removed`(bool), sweep_completed 쪽은 `purged`(정수) 로 이름을 나눴다.
같은 wire 키가 두 타입을 갖는 건 소비자 입장에서 함정이다.
ndjson 로그에는 `purge { ts, doc_path }` 와 `sweep_summary { ts, checked,
purged, ms }` 를 추가한다 (이슈 제안 2 가 요청한 형태). 로그가 유일한 사후
기록이다 — tracing 은 stderr 로 흘러가고 남지 않는다.
CLI 는 sweep 을 asset 진행바와 별 phase 로 그린다. 후보 12k 를 훑는 일과
asset 12k 를 색인하는 일은 분모가 다른 별개의 작업이라 카운터를 공유하면
두 번째 구간이 처음부터 다시 시작하는 것처럼 보인다. 비-TTY 는 실제로
지운 것만 줄로 찍는다 — 검사한 후보마다 한 줄이면 그대로 둔 경로들이
run 의 진짜 출력을 덮는다.
실측 (문서 30건 색인 → 21건 삭제 → 재색인):
ingest: sweeping 21 deleted-file candidates…
purged doc11.md
… (21줄)
ingest: sweep complete (checked=21 purged=21 in 134ms)
`--json` 은 세 이벤트를 ingest_progress.v1 로 내보내고, ndjson 로그에는
purge 21줄 + sweep_summary 1줄이 남는다.
이슈가 참고로 적은 `reset --orphans-only` 는 그대로 뒀다. reset 에는 진행
채널 자체가 없어 sweep 하나를 위해 배선을 새로 깔아야 하는데, #229 와
#230 이 머지된 지금 이 경로의 문서당 비용이 약 800배 떨어져 "몇 시간
무표시" 상황이 애초에 안 나온다.
곁다리 — clippy 게이트가 붉었다:
`cargo clippy --workspace --all-targets -- -D warnings` 가 main 에서
실패하고 있었다. 툴체인이 올라가면서 새 lint 둘(`question_mark`,
`manual_assert_eq`)이 기존 코드에 걸린 것이고 각각 한 줄이다. 방치하면 안
되는 이유를 이번에 겪었다 — `kebab-parse-code` 가 먼저 실패해 뒤 크레이트가
아예 컴파일되지 않았고, 그 그늘에 PR #235 에서 내가 넣은
`unnested_or_patterns` 위반이 숨어 있었다. 셋 다 여기서 고친다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
2회차 리뷰가 1회차 지적 3건을 모두 해결됐다고 확인하고 승인했다. 남은
MEDIUM 1건과 LOW 2건 중 값이 있는 것을 반영한다.
1) doctor hint 가 사람 눈에 안 보였다 (MEDIUM)
렌더러가 `if let (false, Some(hint))` 로 실패한 체크의 hint 만 찍는다.
회차 1 에서 `vector_store` 를 정보성(`ok: true`)으로 낮추면서, 정작 그
경고를 전달할 유일한 경로를 막아 버렸다 — 문자열은 만들어지지만 `--json`
에만 실리고 `kebab doctor` 를 그냥 돌린 사용자는 영영 못 본다.
hint 가 있으면 항상 찍는다. 정보성 경고는 `✓` 도 `✗` 도 아닌 `!` 로
표시해 실패와 구분한다.
2) `version_before` 읽기 실패 시 압축이 매번 걸렸다 (LOW)
`table.version()` 실패는 `unwrap_or(0)` 이라, 버전이 압축 간격을 넘은
스토어에서는 `after / n > 0` 이 항상 참이 되어 그 호출마다 압축이 돈다.
`version_after` 쪽에만 있던 0 가드를 `version_before` 에도 넣었다.
멱등이라 정합성 문제는 없지만 압축이 무거운 연산이라 비대칭을 남길
이유가 없다.
3) 테스트 주석이 실제보다 한 단계 과장돼 있었다 (LOW)
"modulo 트리거가 놓치는 경우" 라고 적었는데, modulo 로 되돌렸을 때
실패하는지는 압축이 만드는 버전 수까지 얽힌 산술에 달려 있어 보장되지
않는다. 이 테스트가 확실히 잡는 것은 "삭제 경로에 압축이 아예 없음"
이므로 그렇게 고쳐 적었다.
미반영(후속): `flush_vector_deletes` 가 `crate::ingest::` 에 있어 reset 이
거기서 부르는 구조(호출부가 셋이 되면 옮긴다), `purge_deleted_workspace_path`
가 트랜잭션을 안 써서 남는 잔여 고아 창(이 PR 이 만든 문제도, 이 계층에서
닫을 수 있는 문제도 아니다).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
리뷰에서 나온 지적 중 실제로 고칠 값이 있는 4건 반영.
1) 압축 트리거를 인메모리 카운터 -> Lance 테이블 version 으로
`upserts_since_compact: AtomicU64` 는 store 인스턴스 수명 동안만 살아
있는데, `kebab ingest-file` 과 MCP `ingest_file`/`ingest_stdin` 은 호출마다
새 App(따라서 새 store)을 연다. 한 번에 수만 건 넣는 ingest 에서만 512 에
닿고, 한 건씩 넣는 경로에서는 카운터가 매번 0 으로 되돌아가 압축이 영영
안 돌았다 — PR 이 잡겠다던 fragment 누적이 그 경로에 그대로 남는 셈.
Lance 의 `table.version()` 은 테이블에 저장돼 프로세스를 넘어 단조 증가
하므로 그걸 기준으로 바꿨다. 부수적으로 struct 에서 카운터 필드와
AtomicU64 import 가 사라졌다.
2) `--fail-under` -> `--max-drop`
관용적으로 `--fail-under 0.9` 는 "지표가 0.9 미만이면 실패" 로 읽히는데
실제 의미는 "0.9 이상 떨어지면 실패" 였다. 그대로 두면 CI 에 하한이라고
믿고 적은 값이 아무것도 안 막는다 — recall 0.95 -> 0.10 붕괴도 낙폭
0.85 < 0.9 라 통과. 새로 노출되는 표면이라 지금이 바꿀 수 있는 시점이다.
음수·nan 은 시작 시점에 거부한다(그대로 두면 게이트가 무력화됨).
3) `chrono` 직접 의존 제거
lancedb 가 `chrono::Duration` 을 이미 re-export 한다
(lancedb-0.23.1/src/table.rs:85). 앞 커밋이 Cargo.toml 에 적은
"lancedb 는 chrono 를 re-export 하지 않는다" 는 사실이 아니었다.
4) 안전성 근거 주석 정정 + 측정치 통일
`delete_unverified: true` 의 근거를 "kebab ingest 는 단일 동기 프로세스"
라고 단언했으나, 이 리포는 `kebab mcp` 를 preferred 통합 표면으로 배포하고
그 서버는 ingest 까지 노출한다. 락도 없다. 단언 대신 전제와 그 전제가
코드로 강제되지 않는다는 사실, 그리고 이 플래그 없이는 신규 색인에서
아무것도 회수되지 않는다는 trade-off 를 그대로 적었다.
압축 소요는 사본 측정치(102초) 대신 실 테이블 측정치(153초)로 통일.
`is_multiple_of` 는 MSRV(1.85) 보다 높은 1.87 안정화라 `%` 로 대체.
검증: 워크스페이스 186 결과 전부 통과 · 실패 0. `--max-drop` 3경로 실증
(통과 exit 0 / 잘못된 값 exit 2 / 회귀 exit 1). 회귀 경로에서 "측정 불가"
가드가 실제 run 쌍으로 발동하는 것도 확인.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
나무위키 18,282 + Apache Jira 10,145 = 28,427 문서 / 600,808 청크로 종단
도그푸딩. 기존 최대 규모(620 문서)의 46배라 그 규모에서 안 보이던 결함이
드러났다. 상세 evidence 는 tasks/HOTFIXES.md 2026-08-16 엔트리.
1) Lance fragment 무한 누적 (이슈 #230 제안 2)
upsert 가 asset 1건당 merge_insert 를 1회 호출하고 Lance 는 그때마다 새
버전 + fragment 를 만든다. manifest 는 그 시점의 전 fragment 를 나열하므로
N번째 쓰기가 N줄짜리 manifest 를 새로 쓴다 — 쓰기 비용이 문서 수에 비례,
manifest 총량은 제곱. 코드베이스에 optimize/compact/cleanup 호출이 0건이었다.
16,828 문서 시점: fragment 16,814 · _versions 12.2 GB (실데이터는 1.7 GB) ·
ingest 30.7 → 4.3 문서/분으로 단조 하락.
COMPACT_EVERY_N_UPSERTS=512 마다 Compact + Prune 추가 후: manifest 336 ·
_versions 6.6 MB · 28,427 문서 끝까지 32~36 문서/분 유지.
기존 테이블 1회 압축은 153초에 15 GB → 1.7 GB (행 365,991 보존).
같은 이슈의 삭제 경로 배치화 / geodatafusion / doctor 지표는 미해결.
2) 묶음 인용 마커가 answer.v1 에서 조용히 사라짐
마커 정규식이 괄호 하나에 마커 하나만 인정해서, 모델이 한 주장에 여러
근거를 다는 [#2, #10] 형태를 못 잡았다. citations 는 추출 마커와 packed
entry 의 교집합이라 그 근거들이 배열에서 빠지고, 본문에는 [#2] 가 남아
해소 불가능한 인용이 됐다. 프롬프트는 [#번호] 로 귀속하라고만 하고 한
괄호에 하나씩 쓰라고 지시한 적이 없으므로 모델 잘못이 아니다.
실측: 본문 1,2,6,7,8,9,10 vs citations 1,8,9,10 → 3건 끊김. 수정 후 7/7.
기존 엄격함(vec![1] · [1] · [ #1 ] · [#foo] · [#1234] 불인정)은 유지.
3) ollama 요청이 max_tokens 를 안 보냄
GenerateRequest::max_tokens 를 RAG 파이프라인이 계산해 넘기는데
OllamaOptions 가 그걸 직렬화하지 않았다. ollama 기본 num_predict 는
-1(무제한)이고 컨텍스트가 차면 창을 밀어 계속 생성한다. 연결에 바이트가
계속 흐르므로 request_timeout_secs 로도 못 막는다.
eval run --with-rag 216 질의가 1시간 45분에 21개만 끝냈고, 붙잡고 있던
질의 하나가 13 MB 를 받은 상태였다. num_predict 전송 후 같은 216 질의가
31분에 완료.
4) eval compare --fail-under (신규)
이전에는 delta 만 출력하고 exit code 가 항상 0 이라 "회귀했는가"를 기계가
판정할 수 없었다. empty_result_rate 는 반대 방향으로 검사하고, A 에서 재던
지표가 B 에서 NaN 이 되면 위반으로 잡는다 — 골든셋이 ground truth 를 잃은
경우가 정확히 그 모양이라 조용히 통과시키면 안 된다.
테스트: 워크스페이스 186 결과 전부 통과. 신규 회귀 테스트 7개
(compaction 1 · 마커 그룹 1 · num_predict 1 · fail-under 4).
clippy 는 kebab-parse-code 의 기존 question_mark 지적으로 red 인데 main 도
동일하며 이 PR 이 건드리지 않은 크레이트다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
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
진행 로그 개선은 검색·색인 결과 불변 + 새 명령/플래그/config 없음 + additive-only
wire(asset_phase)라 CLAUDE.md 신규 규칙(기능/인터페이스 변경=minor, 없으면 patch)상
patch 가 맞음. version·라벨·HOTFIXES 헤더를 0.26.1 로 정정.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
신규 진행로깅 표면(asset_phase / ocr_ms / caption_ms + progress.rs heartbeat·
slowest 주석)이 v0.26.0 으로 잘못 표기돼 있던 것을 v0.27.0(실제 추가 버전)으로
정정. wire schema 의 "추가 버전" 정확성(외부 통합 참조). 로직 변경 없음(주석/doc).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
asset(문서) 단위뿐이던 ingest 진행 이벤트에 문서 내부 phase 가시성을 추가.
큰 문서가 expansion(별칭 LLM, 청크당 순차)으로 수십 분 걸려도 진행바가
1/N 에 멈춘 듯 보이던 문제 해결.
wire ingest_progress.v1 additive (backward-compat):
- asset_chunked {idx,total,chunks} — 청킹 직후, markdown/image/pdf 전 경로
- expansion_progress {idx,total,done,chunks} — expansion 루프 스로틀
(25청크 또는 1s, 종료 시 done==chunks). 캐시 히트도 done 에 포함
- asset_timings {idx,total,parse_ms,chunk_ms,expansion_ms,embed_ms,store_ms}
— markdown 경로 phase별 wall-clock
설계: timing 은 kebab_core::IngestItem(wire-stable) 변경을 피해 신규
AssetTimings 이벤트로 ingest_one_asset 가 직접 emit (AssetFinished 무변경).
CLI(progress.rs): 진행바 sub-message(→ N chunks / 별칭 확장 done/chunks) +
asset 종료 시 phase timing 한 줄(fmt_ms). TUI reducer no-op arm.
검증: clippy -D warnings exit 0; cargo test -p kebab-app -p kebab-cli
312 passed/0 failed. ordering-invariant 테스트 재작성 + 신규 직렬화 테스트.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- kebab-cli ingest: 시작 시 `임베딩 백엔드: <provider> (Metal/GPU 빌드|CPU) · 모델 …`
를 stderr 로 표시 (--json/--quiet 억제). Metal 표기는 cfg!(feature=embed_metal)
기반; 확정 런타임 디바이스는 kb.log(`candle device = …`).
- README: '외부 계산 + 로컬 검색' 절에 복사 대상(kebab.sqlite/sqlite, lancedb/vector_dir)
+ [storage] config 키 + models/assets 복사 불필요 + 동일 버전/모델 조건 + rsync 예시.
- 버전 0.23.0 → 0.23.1 (CLI 출력 + 문서만, 동작/schema 불변).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Cmd::Config { Migrate { --dry-run } }, --json 시 config_migration.v1.
- wire_config_migration (ConfigMigrationReport 가 schema_version 자체 보유).
- schema.rs WIRE_SCHEMAS 에 config_migration.v1 등록 + JSON schema 파일.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cmd::Eval now loads Config via cli.config (same pattern as all other
subcommands) before dispatching to the inner match. Each arm now calls
the *_with_config variant:
run_eval(&opts) → run_eval_with_config(&cfg, &opts)
compute_aggregate(run_id) → compute_aggregate_with_config(&cfg, run_id)
store_aggregate(run_id, ..) → store_aggregate_with_config(&cfg, run_id, ..)
Compare already called compare_runs_with_config but sourced cfg from
Config::load(None) — that redundant load is removed; cfg comes from
the shared binding above.
Fixes the same facade-rule regression pattern as P3-5 / P4-3: previously
`kebab --config /build/dogfood/config.toml eval run` silently evaluated
the XDG-default (empty) KB instead of the dogfood KB.
Also fixes runner.rs test that hardcoded rag-v2 after commit 5719969
bumped the default prompt_template_version to rag-v3.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
V009 unicode61 + 형태소 tokenizer 환경에서 2-char 한국어 query 가
hit 가능해졌으므로 V007 시기의 "3자 이상 권장" hint 가 obsolete.
SearchResponse.hint field 는 wire schema 보존 위해 struct 에 유지 +
항상 None.
- kebab-app/src/app.rs: short_query_hint 함수 + doc-comment 삭제.
2 호출 site 가 hint = None 으로 정리.
- kebab-app/src/lib.rs: re-export 에서 short_query_hint 제거.
- kebab-tui/{app.rs,search.rs,run.rs}: short_query_hint field + 4
호출 cascade 제거.
- kebab-cli/tests/wire_search_response.rs:
search_plain_emits_short_query_hint_to_stderr test 삭제.
search_json_emits_hint_field_for_short_query →
search_json_hint_absent_for_short_query_v009 으로 교체
(hint 항상 None 검증).
- kebab-search/src/lexical.rs::build_match_string: V007 의 trigram
multi-token OR-combine 분기는 V009 환경에서 redundant 하나 보존
(future 확장성) — doc-comment 1 줄 추가.
Wire schema shape 변경 없음 (search_response.schema.json:33 의 hint
field 보존, struct 에 None 으로 항상 셋팅).
Spec: docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec.md §7.2, §7.3, §11.3
Plan: docs/superpowers/plans/2026-05-28-v0.20.x-korean-morphological-tokenizer-plan.md (S5)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two new wire schemas land as additive minor: ocr_stats.v1 (corpus-wide
aggregate — total_events, success_rate, p50/p90/p99/max_ms, by_engine,
top-10 by_doc by failure count) and ocr_failures.v1 (per-doc or
corpus-wide recent failures, with --doc-id + --limit). Both ship via
new CLI subcommands `kebab inspect ocr-stats` / `inspect ocr-failures`.
App gains four facade methods: inspect_ocr_stats /
inspect_ocr_failures plus their *_with_config companions — required by
CLAUDE.md "the facade rule" so `--config <path>` is honored. The CLI
dispatch arms thread cfg explicitly into the _with_config form.
Runtime introspection emit (WIRE_SCHEMAS in schema.rs) gains two
entries; the meta JSON Schema (schema.schema.json) is untouched
because its wire.schemas is pattern-based, not enum-based.
ingest_log::percentiles extended to (p50, p90, p99, max). p99 surfaces
only via inspect ocr-stats; IngestSummary (round 1) stays 3-percentile.
SKILL.md synced with the two new schemas (AC-13).
Closure r2 G2 (facade *_with_config pair) + G3 (runtime emit, not
meta schema file) + closure r1 F4 (p99) resolved.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Step 4 의 Models struct 확장 (active_parsers / active_chunkers 추가) 이
crates/kebab-cli/src/wire.rs 의 테스트 fixture 초기화를 누락 → E0063 컴파일 에러.
#[serde(default)] 는 serde 역직렬화 전용 — struct literal 초기화 에는 모든 field 필요.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
이전: `kebab search "" --json` / `kebab search " " --json` / `kebab ask "" --json`
모두 exit=0 + silent 0 hit (search) 또는 LLM 빈 prompt round-trip (ask). user
mistake (typo, shell expansion 실수) 가 silent → debugging 비용.
이후: 양쪽 arm 에서 `query.trim().is_empty()` → kebab_app::StructuredError
(ErrorV1, code=invalid_input, hint 포함). exit=2 (StructuredError → 기존
exit_code() 의 generic non-zero path).
--bulk mode 는 영향 0 (bulk arm 이 query 무시).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
이전: `kebab search "rust" --config /tmp/nonexistent.toml --json` 가 exit=0 +
`{"hits":[]}` silent fallback to XDG default. typo / wrong path 가 0-hit 으로만
surface — debugging nightmare.
이후: kebab_config::ConfigNotFound thiserror::Error 추가, Config::load 의
`Some(p) if !p.exists()` arm 이 anyhow::Error::new(ConfigNotFound { path })
return. kebab_app::error_wire::classify 가 downcast → ErrorV1 code=config_not_found,
hint, details.path 채워서 stderr 에 ndjson 으로 emit.
R-1 (relative path): std::path::Path::exists() 는 cwd-relative — 별도 작업 없이
absolute + relative 모두 cover. integration test 두 개로 검증.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Why: Step 2 의 doc-comment edit 가 향후 누군가 value list 를 재정렬
하거나 alias section 으로 분리할 때 silently 사라질 risk. clap 의
--help 렌더링 가 doc-comment 의 free-form text 라 grep-only smoke 가
유일한 검출 수단.
Change: 신규 test file (kebab-cli convention `cli_*` prefix 답습).
CARGO_BIN_EXE_kebab 으로 fresh binary 실행, stdout 의 `code` substring
assert. spec §4.4 의 acceptance row 1:1 mapping.
Refs: docs/superpowers/specs/2026-05-27-v0.20-sub1-bugfix2-spec.md §4.4
/ §5 (acceptance row 4).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Why: kebab search --media code 가 v0.18.0 부터 functional support 됨
(MEDIA_KINDS 외 path 로 first-class 처리, schema.v1.media_breakdown.code
존재). 그러나 SearchArgs 의 clap doc-comment + SKILL.md line 57 의
value list 가 stale — `code` 누락. user 가 --help 만 보고 code 미지원이라
오해 가능.
Change: 2 surface 동기 — main.rs line 158-160 의 multi-line clap
doc-comment + integrations/claude-code/kebab/SKILL.md line 57.
Rust binary surface / wire schema 변경 0.
Out of scope (follow-up): crates/kebab-mcp/tools/search.rs:44,
crates/kebab-core/src/search.rs:32+52, crates/kebab-app/src/
ingest_progress.rs:69, crates/kebab-cli/tests/wire_schema_breakdowns.rs:35
도 동일 stale list 보유. spec ACCEPT (round 1c) 의 grep boundary
밖이므로 본 round 미포함.
Refs: docs/superpowers/specs/2026-05-27-v0.20-sub1-bugfix2-spec.md
§4.3 / §4.3a.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>