`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
2회차 리뷰가 1회차 지적 6건 모두 실질 해결을 확인하고(마이크로초 전환에
누락된 누적 지점 없음, 단위 1000배 오차 없음) 머지 가능으로 결론냈다.
남은 셋을 반영한다.
1) HOTFIXES 두 표의 모수가 정확히 2배 어긋났다 (MEDIUM)
A/B 표는 "문서 792건 / 16,379 청크", 내역 표는 "1,584건 / 32,758" 이었다.
원인을 추적해 보니 **측정 설정의 artifact** 였다. 시험용 config 를 dogfood
config 에서 sed 로 만들면서 `[[workspace.sources]]` 두 개(wiki / jira)의
root 가 같은 디렉토리를 가리키게 됐고, walker 가 파일 1,157개를 두 번
스캔해 run 하나가 자산 처리 1,584건을 낸다.
A/B 두 run 이 완전히 같은 설정을 쓰므로 비교 자체는 유효하지만, 이 PR 의
산출물이 "실측 근거" 이므로 무엇을 몇 건 쟀는지 정확히 적어야 한다.
코퍼스 / 저장 결과 / run 당 조회 수를 나눠 적고 artifact 를 명시했다.
상한 2.2초의 유도(0.6 + 1,584 × 1 ms)도 이제 모수와 맞는다.
2) emit 시점 ms 절삭이 계통적 하한으로 남아 있었다 (MEDIUM)
내부 누적만 마이크로초가 됐고 wire 필드는 여전히 내림이라, 저자 자신의
데이터대로면 90% 자산이 계속 0 으로 찍힌다. 소비자가 합산하면 자산 수 ×
최대 1 ms 만큼 계통적으로 과소 계상된다.
`div_ceil` 로 올림했다. 같은 run 을 다시 재니 0 으로 찍히는 자산이 하나도
없고 합이 2.1초다 — 내림 0.6초가 하한, 올림 2.1초가 상한이므로 앞서
산술로 낸 0.6~2.2초 구간이 실측으로 확인됐다.
3) cache_ms 라벨이 blake3 키 해싱을 빠뜨렸다 (MEDIUM)
`t_cache` 타이머는 `derivation_cache_key` 계산부터 시작한다. 청크 본문
전체를 해싱하는 순수 CPU 비용이라, "lookup" 만 적힌 라벨은 미스 위주
run 에서 실제로 오해를 만든다. 구조체 주석·필드 주석·스키마 셋 다 고쳤다.
4) 잔가지 (LOW)
- `t_decode` 주석이 "디코드" 라고만 해서 실제로는 히트/미스 분류 루프
전체를 감싼다는 점이 안 드러났다.
- `CacheStats` 의 hit / miss 필드에만 주석이 없었다.
- DOGFOOD 의 "warm 재색인이면 cache_miss == 0" 은 `--force-reingest`
일 때만 성립한다. 그냥 재색인하면 변경 없는 문서가 통째로 skip 되어
`asset_timings` 자체가 안 나온다.
미반영: `get_many` 의 `prepare_cached` 가 배치 크기마다 SQL 문자열이 달라져
사실상 캐시 미스라는 지적 — 정확하지만 누수도 정확성 문제도 없고, 버킷
패딩은 1% 짜리에 낼 복잡도가 아니다. tracing 로그가 us 라 자릿수가 길다는
점도 단위 표기와 값이 맞으므로 그대로 둔다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
리뷰 두 건이 머지 가능으로 결론냈지만, 이 PR 의 핵심이 "실측 근거" 인데 그
근거 쪽에 문제가 있다는 지적이 나왔다. 그쪽을 우선 고친다.
1) cache_ms 가 체계적으로 과소계상돼 있었다 (MEDIUM)
문서 하나당 `as_millis()` 절삭 지점이 3곳(조회 / 삽입 / touch)이었고,
각 구간이 대개 1 ms 미만이라 값이 통째로 사라졌다. 실제로 문서 1,584건
중 **1,422건(90%)이 0 으로 찍혔다**. 초안이 "캐시 경로 전체 0.6초" 를
점추정으로 적고 그 숫자를 근거로 제안 3·6 을 기각했는데, 0.6초는
하한이었다.
내부 누적을 마이크로초로 바꿔 절삭을 emit 시점 1회로 줄였다. 재측정한
구간은 **0.6~2.2초 (run 141.3초의 0.4~1.6%)** 다. 결론은 구간 어느
쪽에서도 같지만, 점추정으로 적어 둘 값은 아니었다.
히트 payload 를 `Vec<f32>` 로 되돌리는 디코드 비용도 캐시 경로에
계상했다. SQL 경계에서 멈추는 지표는 캐시를 실제보다 싸 보이게 한다.
2) embed_ms 를 "Lance upsert" 로만 라벨했다 (MEDIUM)
`t_embed` 스팬은 orphan purge + 캐시 경로 + 임베더 + 레코드 구성 +
Lance upsert + touch 를 전부 감싼다. 코드 주석 자신이 "purge + upsert"
라고 적고 있는데 HOTFIXES 가 더 좁게 적었다.
그리고 cache_ms 는 embed_ms 의 **부분집합**이지 별도 가산 항목이 아니다.
스키마 설명이 "embedder 호출 제외 — that is embed_ms" 라 두 값이 겹치지
않는 것처럼 읽혔고, 외부 소비자가 phase 를 합산하면 이중 계상한다.
"included in embed_ms" 를 명시했다.
3) CacheStats 가 embed_with_cache 의 doc 블록을 가로챘다 (MEDIUM)
구조체를 doc 블록과 `fn` 사이에 끼워 넣어서, 함수 설명 전체가 구조체의
문서가 되고 함수는 문서가 하나도 없는 상태였다. 구조체를 위로 올렸다.
4) 계측의 사각지대를 명시했다 (MEDIUM/LOW)
- code 자산은 `asset_timings` 를 아예 emit 하지 않는다(이 PR 이전부터의
공백). 채우려면 code 경로에 parse/chunk/store 타이머를 새로 깔아야 해서
#231 범위 밖이다. 스키마와 DOGFOOD 에 적었다.
- `cache_*` 는 임베딩 kind 만 센다. 같은 테이블을 쓰는 OCR·caption 파생은
단건 API 라 안 잡히고, 이미지 위주 코퍼스에서는 캐시가 한 일을 과소
표현한다.
5) README 미갱신 (MEDIUM)
`⏱` 줄에 `cache 히트/전체 소요` 세그먼트가 추가됐는데 README 의 ingest
설명이 phase 목록만 적고 있었다.
미반영: `get_many` 의 `prepare_cached` 가 배치 크기마다 SQL 문자열이 달라져
사실상 캐시 미스라는 지적 — 정확하지만 누수도 정확성 문제도 없고, 버킷
패딩은 1% 짜리에 낼 복잡도가 아니다. `put_many` 시그니처의 불필요한 할당,
자산 단위 피크 메모리 2배(자산 단위로 유계) 도 같은 이유로 남긴다.
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
3회차 리뷰가 2회차 지적 8건이 동작 수준에서 해결됐음을 확인하고 머지
가능으로 결론냈다. 남은 LOW 중 값이 있는 셋을 반영한다.
1) 마이그레이션 이력을 못 읽었을 때 파괴적 조언으로 떨어졌다
`Some(Some((None, Ok(어긋남))))` — 버전은 모르는데 표본은 읽힌 경우 —
가 `ok: false` + "`kebab reset`" 분기로 갔다. 버전을 모르는 것은 KB 를
날리라고 말할 근거가 못 된다. 판정을 "V016 이상임이 확인된 스토어"로
한정하고, 이력을 못 읽으면 `ok: true` + "판정 보류" 로 둔다. 실제
도달 경로는 사실상 없지만(chunks_fts 가 있으면 이력도 있다) 분기의
기본값이 파괴적인 쪽인 게 문제였다.
2) "점검하지 못했다" hint 가 가장 흔한 원인을 안 적었다
2회차에서 고친 HIGH — 이전 버전 doctor 가 남긴 0바이트 kebab.sqlite —
가 정확히 이 분기로 온다. 테이블이 없어 읽기에 실패한다. "아직 색인
전이거나" 를 앞에 넣었다.
3) vector_store 블록이 아직 env 를 안 얹고 있었다
2회차에서 fts_shadow 에 대해 고친 것과 같은 결함이 #234 에서 들어온
바로 아래 블록에 그대로 남아 있었다. 정보성 체크라 종료 코드에는
영향이 없지만 detail 이 엉뚱한 디렉토리를 가리킨다. 같이 맞췄다.
미반영: shadow 의 chunk_id 가 NULL 이면 분자에서 빠지는 과소 계수 —
트리거가 NOT NULL 컬럼을 미러링하므로 도달 불가이고 방향도 오탐이 아닌
과소 쪽이다. 새 테스트가 `KEBAB_STORAGE_DATA_DIR` 이 export 된 셸에서
실패하는 것은 점검이 제대로 동작하는 결과라, 원인을 빨리 찾도록 테스트
주석에 적어 두는 선에서 끝냈다.
실측 확인: KB 없음 / V015 / V016 / env override 네 경로 모두 의도한 문구가
나오고, KB 없는 경로는 파일을 남기지 않는다. env override 를 주면
fts_shadow 와 vector_store 가 같은 디렉토리를 본다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
2회차 리뷰가 1회차 지적 6건은 반영됐다고 확인했고, 대신 **1회차 대응으로
새로 넣은 `fts_shadow` 점검 자체에** HIGH 1건 + MEDIUM 2건을 찾았다.
1) 진단 명령이 없던 스토어를 만들었다 (HIGH)
`SqliteStore::open` 은 마이그레이션은 안 돌리지만 `Connection::open` 이
파일을 만든다. KB 없는 머신에서 `kebab doctor` 한 번이
`<data_dir>/kebab.sqlite` 를 남겼고, 그러면 이후 `open_existing` 이
성공해 버려 `not_indexed` 로 갈렸어야 할 경로가 일반 오류로 바뀐다.
`--readonly` 규약도 진단 명령이 깬다.
`SQLITE_FILE` 을 공개하고 파일 존재를 먼저 확인한 뒤에만 연다.
`doctor_does_not_create_a_store_where_none_exists` 로 고정.
2) V016 미적용 스토어를 고장 났다고 하고, 파괴적 조치를 권했다 (MEDIUM)
doctor 는 마이그레이션을 돌리지 않으므로 V015 스토어를 그대로 읽는다.
그런데 V009 백필이 rowid 를 명시하지 않아서, 그 시점 `chunks.rowid` 에
구멍이 있던 스토어는 V015 에서 **이미 어긋나 있다**. 거기서는 삭제가
`chunk_id` 기준이라 무해한데, 새 점검이 `ok: false` → exit 3 + "`kebab
reset` 후 재색인" 을 냈다. 바이너리를 올리고 doctor 부터 돌리는 자연스러운
순서에서 멀쩡한 KB 를 날리라고 안내한 셈이다.
`migration_version()` 을 보고 V016 미만이면 `ok: true` + "마이그레이션
후 점검된다"로 간다. 실제 해법이 그것이다 — V016 의 명시 rowid
repopulate 가 이 어긋남을 고쳐 준다.
`doctor_does_not_call_a_pre_v016_store_broken` 으로 고정.
3) env override 를 무시해 엉뚱한 DB 를 검사했다 (MEDIUM)
바로 위 `data_dir_writable` 은 "Config::load 와 같은 precedence 유지"를
이유로 env 를 다시 얹는데 이 블록은 안 했다. `KEBAB_STORAGE_DATA_DIR` 을
쓰면 data_dir 은 A 로 보고하면서 인덱스는 B 를 검사한다. 같은 방식으로
맞췄다.
4) 잔가지 (LOW)
- `None` 분기가 "스토어 없음 / 열기 실패 / 읽기 실패"를 전부 "KB 없음"
으로 뭉갰다. 조용한 실패를 드러내려는 점검이 자기 실패를 삼키면 안
되므로 "점검하지 못했다" 를 따로 뒀다 (hint 가 있으므로 CLI 가 `!`
로 찍는다).
- "표본 400행" 이 chunk 400개 미만인 스토어에서 거짓이었다. ASC/DESC
두 창이 겹치면 분자와 분모를 둘 다 두 번 셌다. UNION 으로 바꾸고
실제 표본 수를 함께 돌려준다 — 반환형이 `(checked, misaligned)` 다.
- `pub const SQLITE_FILE` 위에 "Kept private" 이라는 옛 독 주석이
남아 있었다.
- README 의 doctor 행에 `fts_shadow` 가 exit 3 을 낼 수 있다고 적었다.
새 플래그도 config 키도 없지만 **doctor 가 실패하는 새 사유**는
스크립트와 에이전트가 분기하는 사용자 표면이다.
- `tasks/phase-2-lexical-search.md` 가 `kebab index --rebuild-fts` 를
산출물로 나열하고 있었다. 배선된 적 없는 명령이고 이 PR 이 "CLI
경로 없음"이라고 못박은 것과 어긋나서 정정했다.
실측 확인: env override 를 준 doctor 가 지정한 KB 를 검사하고("양끝 400행
표본"), V015 실제 KB(28,427 문서)는 "V016 적용 전 (현재 V015)" 로 나오며,
KB 없는 경로에서는 파일을 남기지 않는다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
리뷰 두 건에서 나온 지적을 반영한다.
1) 실패 양상이 바뀐 것을 다루지 않았다 (MEDIUM)
`chunk_id` 로 행을 찾던 때는 shadow 정렬이 어긋나도 느릴 뿐 정확했다.
rowid 로 찾으면 어긋난 순간 `chunks_ad` 가 남의 문서 shadow 행을 지우고
아무 오류도 내지 않는다. 즉 이 PR 은 실패 양상을 "느림"에서 "조용한
오삭제"로 바꿨는데, 그 불변식이 눈에 안 보이는 상태였다.
`kebab doctor` 에 `fts_shadow` 점검을 넣었다. 전수 대조는 60만 chunk
에서 33초라 doctor 앞에 둘 수 없어 rowid 범위 앞뒤 200행씩만 본다 —
실측 10 ms 이고, 현실적인 드리프트가 취하는 전면 재번호는 잡는다.
표본이라는 사실을 detail 에 적어 정렬 증명으로 읽히지 않게 했다.
`SqliteStore::fts_shadow_misaligned_sample` 이 질의를 들고 있다.
2) VACUUM 위험을 과장했다 (정정)
초안이 "VACUUM 이 rowid 를 다시 매길 수 있고 그러면 정렬이 깨진다"고
단정했다. 실제로 재보니 다시 매기지 않았다 — 실제 KB 사본(60만 chunk,
문서 3,000건을 지워 rowid 에 구멍을 낸 뒤)과 소형 합성 DB 양쪽에서
VACUUM 후 전수 대조 불일치가 0 이었다 (sqlite 3.53.4). SQLite 문서가
"다시 매길 수 있다"고 적은 것은 보장이 없다는 뜻이지 실제로 그렇게
한다는 뜻이 아니다. 문구를 실측대로 고쳤다.
남는 실제 경로는 앞으로 `chunks` 를 테이블 재작성 방식으로 바꾸는
마이그레이션이다. V016 주석에 "그런 마이그레이션은 repopulate 를 같이
돌려야 한다"는 울타리를 박았다.
3) 같은 실측치를 파일마다 다르게 적었다 (MEDIUM)
삭제 시간이 커밋 메시지·HOTFIXES 는 2.0초, 마이그레이션 주석·테스트
독스트링·설계 문서는 0.73초였다. 0.73초는 손으로 마이그레이션한 사본을
따뜻한 캐시에서 잰 값이고 2.0초는 릴리스 바이너리가 마이그레이션한 새
사본에서 잰 값이다. 보수적인 2.0초로 통일했다. '한국' hit 수도
15,837(문서 200건 삭제 후) 과 15,977(전체 코퍼스) 이 섞여 있어
15,977 로 통일했다.
4) docs/ARCHITECTURE.md 디렉토리 트리가 V001..V015 로 멈춰 있었다 (MEDIUM)
V016 까지로 갱신. README 는 손대지 않는다 — 새 서브커맨드·플래그·config
키·`--json` 필드가 없다.
5) 잔가지 (LOW)
`:=` 검사가 번들 SQLite 의 FTS5 idxStr 인코딩에 기대는 것을 assert
메시지에 적었다 (rusqlite 를 올린 직후 실패하면 거기부터 보라는 뜻).
가상 테이블은 항상 `SCAN` 으로 찍히므로 `SEARCH` 로 대체 검사할 방법이
없다는 것도 독스트링에 남겼다. `kb index --rebuild-fts` 라는 옛 이름 +
존재하지 않는 명령 참조 두 곳을 지웠다.
`fts_v016_shadow_probe_detects_forced_drift` 로 탐지 자체를 시험한다 —
어긋난 shadow 행을 억지로 만들어 점검이 잡는지 본다. 잡지 못하는 점검은
없느니만 못하다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
리뷰어 두 명이 독립적으로 같은 결함을 지목했고 실측으로 확인됐다.
1) 삭제 경로 압축이 실제로는 안 걸렸다
`maybe_compact` 가 `version % compact_every == 0` 으로 판정했는데, writer
마다 버전을 올리는 폭이 다르다. `upsert` 는 호출당 1 이라 배수를 반드시
밟지만 `delete_by_chunk_ids` 는 200-id 배치마다 커밋해서 한 번의 호출이
여러 칸을 건너뛴다. 이 PR 이 실측한 28474 → 28507 이 정확히 그 경우다
(28507 % 512 = 347, 구간에 512 배수 없음). 즉 추가한 압축이 호출당
1/512 확률로만 걸렸다.
구간 교차 판정(`after / n > before / n`)으로 바꿨다. 쓰기 전 버전을
인자로 받는다.
기존 테스트도 이걸 못 잡았다 — id 를 하나씩 40회 나눠 불러 버전이 1씩
올라가는, 이 PR 이 없앤 옛 형태였다. 한 번의 호출로 2,000 id(=10 커밋)를
보내는 실제 출하 형태로 다시 썼고, modulo 판정으로 되돌리면 매니페스트
12개로 실패한다.
2) 고아 벡터 창이 문서 1건에서 sweep 전체로 커졌다
`execute_orphans_only` 는 purge 실패 시 `?` 로 즉시 반환하는데, 그러면
루프 뒤의 배치 삭제가 아예 실행되지 않아 그때까지 버퍼에 쌓인 벡터가
전부 고아가 된다. documents 행은 이미 지워져 다음 sweep 도 못 찾으므로
회수 수단이 `reset --vector-only` 전량 재임베딩뿐이다. 기존 건별 삭제보다
명확히 나빠지는 회귀였다.
5,000 id 마다 flush 하고, reset 의 에러 경로도 flush 를 거쳐 반환한다.
커밋 수는 여전히 문서 수의 수십 분의 1이라 #230 목적은 그대로다.
더불어 HOTFIXES 의 "SQLite purge 는 crash-safety 경계" 는 사실이 아니라
정정했다 — `purge_deleted_workspace_path` 는 트랜잭션을 열지 않고 DELETE
세 개가 각각 autocommit 이다.
3) doctor 판정이 규모에 따라 뒤집혔다
`meta_bytes > data_bytes` 는 절대 바이트 비교라, manifest 는 fragment 수의
제곱으로 data 는 코퍼스 크기에 비례해 자라는 탓에 작은 노트 KB 가 오탐으로
fail 했다. 리뷰어 계산으로 문서당 1청크면 105 문서부터 걸린다.
보존 버전 수가 압축 간격의 2배를 넘는지로 바꿨다(규모 무관). 그리고
판정을 정보성으로 낮췄다 — `DoctorReport.ok` 가 종료 코드 3 을 만들고
스크립트·에이전트가 그걸로 분기하는데, 압축이 밀린 스토어도 질의에는
정상 응답한다. 테이블을 못 찾았을 때도 체크를 남긴다(조용히 사라지면
정상으로 읽힌다).
검증: 워크스페이스 186 결과 전부 통과. 실 KB doctor 출력 확인
(`200 fragments / 2832.3 MB data, 201 versions / 2.0 MB metadata`, 종료 코드
영향 없음).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
앞 PR(#233)이 #230 의 제안 2(압축 정책)만 닫았다. 남은 1·3·4 를 처리한다.
1) 삭제가 파일 1건당 Lance 커밋 1회 (제안 1)
`sweep_deleted_files` 와 `execute_orphans_only` 가 루프 안에서 파일마다
`delete_by_chunk_ids` 를 불렀다. 그 호출 하나가 Lance 커밋 하나라 문서
삭제 수와 커밋 수가 1:1 이었다 — #230 이 보고한 "삭제 5,834건 → 커밋
5,834회" 가 이것이다.
chunk_id 를 sweep 전체에 걸쳐 모아 루프가 끝난 뒤 한 번만 부른다. SQLite
purge 는 건별 커밋을 유지한다. 그쪽이 crash-safety 경계이고, 기존 정책이
이미 중간에 죽으면 orphan 벡터가 남는 것을 허용한다.
실 KB 실측(28,427 문서, 나무위키 샤드 하나 = 364 문서 삭제):
Lance 커밋 364 → 33, 버전 번호 28474 → 28507, _transactions 336 → 369.
33 은 `delete_by_chunk_ids` 의 200개 배치 상한에서 나온다(364 문서 ×
약 18 청크 ≈ 6,500 id / 200). 커밋 수가 문서 수가 아니라 청크 수에
비례하게 됐다.
2) 삭제 경로에도 압축
압축이 upsert 에만 걸려 있어 `reset --orphans-only` 처럼 삭제만 하는
경로는 여전히 무한 누적이었다. 트리거를 `maybe_compact` 헬퍼로 빼고
`delete_by_chunk_ids` 끝에서도 부른다.
3) doctor 에 Lance 지표 (제안 4)
`vector_store` 체크 추가 — fragment 수 / 데이터 크기 / 버전 수 /
메타데이터 크기를 보여주고 메타데이터가 데이터보다 크면 fail 로 잡는다.
그게 #230 병리의 모양이다. lancedb 를 열지 않고 파일시스템만 읽어서
임베딩 provider 가 none 이어도 나오고 비용이 0 이다. `DoctorCheck` 목록에
항목을 더하는 것이라 `doctor.v1` wire 는 그대로다.
4) geodatafusion (제안 3) 은 미해결
lance 내부라 kebab 쪽 feature flag 가 없다. 다만 이 비용은
`table.delete()` 호출당 fragment 수만큼 곱해지므로 위의 커밋 수 감소가
그대로 곱셈 횟수 감소다. 근본 해결은 upstream 이슈.
부수 관찰: 364 문서 삭제에 38.7분이 걸렸는데 Lance 쪽은 33 커밋뿐이므로
남은 비용은 #229(chunks_fts 삭제가 FTS5 전체 스캔, 문서당 약 6초)다.
364 × 6초 ≈ 36분으로 거의 전부 설명된다. #230 을 고쳐도 sweep 체감이
안 바뀌는 이유가 이것이다.
검증: 워크스페이스 186 결과 전부 통과. 신규 회귀 테스트
`repeated_deletes_do_not_accumulate_lance_versions` 는 삭제 경로 압축을
빼면 40회 삭제에 매니페스트 42개로 실패한다.
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
kebab-embed/kebab-llm 은 "새 type 없음"을 자처한 순수 re-export 셸이었다
(trait 은 이미 kebab-core 소유, mock + test helper 만 보유). "kebab-core
재구성 시 안정 surface" 라는 명분은 1인 RAG 엔 speculative YAGNI. 흡수:
- kebab-core 에 default-OFF `mock` feature + src/mock.rs (MockEmbedder,
MockLanguageModel, assert_vector_shape/assert_unit_norm/assert_finish_chunk
을 kebab_core:: → crate:: import 만 바꿔 verbatim 이동).
- production import 2곳(kebab-embed-local, kebab-llm-local) +
test import 다수(search/rag/parse-image/embed-local) 를 kebab_core 로 repoint.
mock 쓰는 crate 는 dev-dep 에 features=["mock"] (default 빌드 무영향).
- shim 자체 테스트: mock 동작 테스트는 kebab-core/tests/ 로 이동,
reexports.rs(셸 재수출 테스트)는 폐기.
- crates/kebab-embed, crates/kebab-llm 삭제 + workspace member/deps 정리.
- ARCHITECTURE/HANDOFF/component README 의 crate 그래프·표·rationale 갱신
(22 → 20 crates). llm-local 의 broken intra-doc link 2건도 정리.
trait surface·동작 불변 (test-only + import-rename). workspace build 는 mock
default-OFF 라 mock 코드 미컴파일. 적대적 검증 3렌즈(build-test-integrity +
behavior-identity[mock byte-identical] + dead-crate-completeness) 통과,
clippy --workspace -D warnings 클린.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Mc6W1fgsrbFKTsqA6P8La
리뷰 nit: collapsed AST chunk arm 의 .context(format!(...)) 는 성공 경로에서도
String 을 매번 할당. .with_context(|| format!(...)) 로 에러 경로에만 alloc.
(나머지 nit — 일반화된 error-text "CodeAstV1Chunker" — 은 리뷰어가 no-action:
테스트가 새 substring 으로 맞춰져 있고 7개 arm 과 .context prefix 통일됨.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Mc6W1fgsrbFKTsqA6P8La
code_{c,cpp,go,java,js,kotlin,python,rust,ts}_ast_v1.rs 9개는 struct 이름과
const VERSION_LABEL 문자열만 다른 byte-identical 복붙이었다 (chunker 는
tree-sitter 를 안 쓰고 lang 은 SourceSpan::Code 데이터에서 흘러나옴). 하나로
통합:
- 신규 CodeAstV1Chunker { version_label: &'static str } + for_lang(lang) 생성자.
9개 lang → 기존 VERSION_LABEL 문자열을 verbatim 매핑 (rust→code-rust-ast-v1,
typescript→code-ts-ast-v1, javascript→code-js-ast-v1, ...). chunker_version()
이 라벨을 그대로 반환 → chunk_id (id_for_chunk) byte-identical → 재인덱싱·
버전 캐스케이드 0.
- chunk/policy_hash/make_chunk/split_oversize 는 code_rust_ast_v1.rs 에서 verbatim
이식. 인라인 유닛 테스트 1세트 + 9-lang 라벨 매핑 테스트 추가.
- 9개 src 파일 삭제, lib.rs 9 mod+pub use → 1.
- ingest.rs 의 두 디스패치(chunker_version 스탬프 ~2700 + .chunk() ~3298)에서
9개 AST arm 을 단일 arm 으로 collapse, for_lang(code_lang) 사용. 비-AST
chunker(K8s/Dockerfile/Manifest/TextParagraph) arm 은 무손상.
byte-identical 증명: 9개 골든 스냅샷 테스트(tests/code_*_ast_snapshot.rs)가
assertion/expected-data 수정 0으로 통과 — construction/import 줄만 변경.
net −3030줄 (20파일 +143/−3173). 적대적 검증 3렌즈(byte-identical-proof +
version-string-integrity[9 라벨 old==new] + completeness) 통과, clippy -D warnings 클린.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Mc6W1fgsrbFKTsqA6P8La
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
markdown ingest arm 이 그동안 유일하게 `App::extract_for` extractor
registry 를 우회하고 `kebab_parse_md::{parse_frontmatter, parse_blocks,
build_canonical_document}` free function 을 직접 호출했다 ("the single
biggest asymmetry"). 이를 image/pdf/code 와 동일하게 registry 경유로
통일.
- `kebab-parse-md` 에 `MarkdownExtractor` 신설 — 기존 free function 3종을
동일 순서·동일 인자로 감싸 `bytes → CanonicalDocument` 생산만 담당.
fm_span_end / count_lines_in / build_body_hints 헬퍼도 함께 이식.
- `App.extractors` registry 에 등록 (11 → 12 entry), markdown 이 `supports`
로 발견되도록 첫 entry 로 배치.
- `ExtractContext` 에 `source_id` / `source_trust` 필드 추가 — markdown
frontmatter 가 per-source trust 기본값을 override 하고 그 precedence 가
`parse_frontmatter` *내부*에서 결정되므로 ctx 가 carry 해야 함. 다른
extractor 는 None (post-extract 에서 source_id stamp 유지).
- 핸들러는 추출 stage 만 registry 로 이전 — version stamping / chunking /
embedding / store 는 그대로. IngestItem.warnings 는 pdf/code 처럼
`canonical.provenance` 의 Warning 이벤트에서 도출.
byte-identical 검증: parity gate-ingest (all-markdown 183 doc / 7676 chunk)
CHUNKS / SEARCH / ASK 모두 IDENTICAL. clippy 0, kebab-app + kebab-parse-md
test 전체 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Mc6W1fgsrbFKTsqA6P8La
Phase 2 Unit 1. OcrCfg(image) 13필드가 PdfOcrCfg(pdf) 와 전부 중복(image 고유
0, pdf 고유 4) + apply_env 에 KEBAB_IMAGE_OCR_*/KEBAB_PDF_OCR_* 27 arm 복제를
제거한다.
- 신규 SharedOcrEngineCfg(13 공유 필드, 전부 Option, default None) = [ingest.ocr].
엔진 설정 단일 출처. image/pdf 블록은 on/off 토글 + override.
- load-time resolution(Config::resolve_ocr, from_file 호출): 공유 필드가 Some 이고
미디어 블록이 그 키 미명시면 concrete OcrCfg/PdfOcrCfg 로 overlay(presence 는
toml::Value 로 판정; 미디어 > 공유 > 내장 default). struct 필드는 그대로 두고
엔진 필드에 #[serde(default)] 만 추가(slim 블록 파싱) → image(gemma4:e4b/1600)
vs pdf(qwen2.5vl:3b/2048) 미디어별 기본값 보존.
- resolver Config::image_ocr()/pdf_ocr() 추가. consumer(kebab-parse-image,
kebab-app build_*_ocr_engine·ingest gate·pdf_ocr_apply·ingest_config_signature)가
전부 경유 → god-struct 직접 read 제거.
- apply_env: 27 arm → 공유 KEBAB_OCR_* 12 arm(image+pdf 동시) + pdf 고유 4 arm +
미디어별 KEBAB_IMAGE_OCR_ENABLED/KEBAB_PDF_OCR_ENABLED.
- step_4_to_5: [ingest.image.ocr] 12 엔진 키를 [ingest.ocr] 로 move_table(enabled
제외). pdf 블록 무손상(reconcile 이 채워 공유 overlay 오염 X). annotated_default
도 동일 통합으로 v5 canonical 형상. CURRENT_SCHEMA_VERSION=5.
- v4→v5 round-trip 테스트(비-default image engine 보존 + pdf 오염 X + 멱등). effective
OCR 바이트 동일 → ingest_config_signature 불변 → 강제 재색인 없음.
검증: clippy --workspace --all-targets 0 / kebab-config·kebab-parse-image·
kebab-parse-pdf·kebab-app 테스트 pass. surface: README [ingest.ocr] 절 + SMOKE
config 블록 + DOGFOOD env + HOTFIXES dated entry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Mc6W1fgsrbFKTsqA6P8La
md-heading-v2(PR #209)의 oversize 분할을 PDF 청커에도 적용. v1.1 의 chunk_page 는
문장/문단 경계로만 잘라서 경계 없는 거대 페이지(빽빽한 scanned page 한 줄 OCR)가
통째로 한 청크 → strict 임베더 실패. v1.2 는 2-tier: tier-1(문장/문단 greedy +
overlap) 후 segment 가 max_chunk_tokens 초과면 tier-2 가 공유 text_pieces 로
재분할 → 모든 PDF 청크 ≤ 예산.
- 신규 공유 모듈 crate::oversize (text_pieces/char_pieces/BYTES_PER_TOKEN) — md 와
PDF 가 공유(단일 진실 공급원). md-heading-v2 는 호출만, 출력 byte-identical(md
라벨·동작 불변, parity 테스트 전부 통과).
- PdfPageV1Chunker { max_chunk_tokens } + policy_hash budget fold(md 동형) +
pdf_chunker_from_config(kebab-app). 신규 config 키 없음.
- 분할 조각 chunk_id 는 #c{segment_start}s{i}(미분할 단일 segment 는 bare
#c{segment_start} 유지 → 공통 경우 v1.1 동일).
- Page span: 분할 조각은 부모 segment 의 char 범위를 그대로 가짐(segment-granular,
md 동형). per-piece narrowing 은 text_pieces 의 줄 구분자 소실로 drift 하는
버그라 코드 리뷰 후 제거 — 회귀 테스트
oversize_pdf_page_with_newlines_splits_without_span_drift 로 잠금.
- chunker_version v1.1→v1.2 → 다음 ingest 에서 PDF 1회 자동 재청크(md/code 무영향).
검증: kebab-chunk lib 93 pass(span 회귀 포함), kebab-app green, clippy 0. 도그푸딩
(실험 KB, scanned PDF + arctic@Lemonade, budget 200): 625/625 errors=0,
scanned_page1 1→3 청크·scanned_page2 3→7 청크(둘 다 pdf-page-v1.2), 전 코퍼스
10215 청크 전부 ≤200. 버전 bump 은 follow-up 들과 함께 배치 릴리스에서 일괄.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Mc6W1fgsrbFKTsqA6P8La
v1 의 "블록 미분할" 한계를 일반화. 거대 list/code/table/paragraph 가 한 청크로
임베더 컨텍스트를 초과해 임베딩이 통째로 실패하던 문제를 해소한다. v2 는 v1 과
모든 출력이 동일하되, 청크의 실제 임베드 text 크기(`text.len()/3`)가
`max_chunk_tokens`(신규 config, byte/3, default 4000)를 넘는 청크만 줄(`\n`)
경계로, 단일 거대 줄은 UTF-8 char 경계로 잘라 각 조각이 예산 이하가 되게 한다.
- 판정 기준은 저장 `token_estimate` 가 아니라 실제 `text` 길이: ImageRef/AudioRef
청크는 image-only 규약으로 token_estimate=0 이지만 OCR/caption text 는 클 수
있다(빽빽한 스크린샷). 도그푸딩 일치 재테스트에서 이 image-OCR 구멍 발견·수정.
- 분할 조각 chunk_id 는 동일 block_ids 를 공유하므로 id-input 해시에 `#seg{i}`
접미사로 충돌 회피(저장 policy_hash 는 bare — pdf-page-v1 의 `#L` 레시피 동형).
- `max_chunk_tokens` 는 v2 의 policy_hash 에만 fold(공유 ChunkPolicy 미변경 →
코드/PDF 청커 cascade 무영향). 값 변경 시 markdown 만 재청크.
- 미분할 청크는 v1 과 byte-identical. `chunker_version` v1→v2 → 다음 plain
`kebab ingest` 에서 markdown 1회 자동 재청크(--force 불필요, 코드/PDF 무영향).
동기: strict 임베더(AMD Lemonade `/api/embed`)는 oversize 입력을 truncate 아닌
거부(`500 too large`) — ollama 가 조용히 truncate 하던 걸 청커가 애초에 안 만들게.
검증(실험 KB, arctic-embed-l-v2 @ Lemonade): v2 전 620 중 2 doc(거대 jira list
블록) 임베드 실패 → v2 후 620/620 errors=0, 7114 청크 전부 ≤ 4000. 사용자 실
config 일치 재테스트(이미지 OCR + PDF OCR paddle-onnx ON)에서 image-OCR 구멍
발견·수정 후 dense 이미지 OCR 텍스트가 budget 초과 시 분할(token_estimate=0 →
1청크였던 것이 실제 text 기준 다중 청크로) 실증. frozen 설계 doc / frozen p1-5
spec 미변경(설계 §9 가 md-heading-v2 라벨 bump 를 변경 메커니즘으로 명시).
known limitation: PDF 는 별도 청커 pdf-page-v1.1 이라 이 split 미적용(후속 후보).
Cargo.toml 0.29.0 → 0.30.0 (신규 config 키 + 청커 동작 변경 = pre-1.0 minor +
도그푸딩 트리거). docs cascade: HOTFIXES / release-notes-v0.30.0-draft /
plan(2026-06-24) / normalize-chunk README / README / SMOKE / HANDOFF.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Mc6W1fgsrbFKTsqA6P8La
ocr_engine_version_for_sig 가 det/rec/dict 를 호출자(미디어별)로부터 받도록
인자화 — image 는 [ingest.image.ocr], pdf 는 [ingest.pdf.ocr]. v2 의 pdf↔image
paddle 비대칭 제거. engine_version_for_paths 신설(kebab-parse-image). 출력
문자열은 값 기반이라 v2 와 바이트 동일(불변식 #1). test seam + 골든 추가.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>