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
리뷰어 두 명이 독립적으로 같은 결함을 지목했고 실측으로 확인됐다.
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
`delete_by_chunk_ids` 의 SQL IN(...) 입력에 대한 hex invariant 를
`debug_assert!` 로 명시. `id_for_chunk` 가 항상 hex 를 emit 하지만
`ChunkId(pub String)` 가 hand-construct 가능해 미래 contributor 가
tainted 문자열을 넣을 가능성 차단. dev / test build 에서 즉시
panic 으로 잡힘 (release 는 그대로 SQL 진행 — 운영 경로는 hex 가
강제되므로 false positive 없음).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P7-3 의 storage UNIQUE bug fix 가 SQLite 측 (documents → blocks /
chunks / embedding_records) 만 sweep 했음. LanceDB 의 vector 는 별도
store 라 옛 chunk_id 를 가진 row 가 디스크에 잔존. 검색에는 영향 없지만
디스크는 무한 누적. HOTFIXES `2026-05-02 P7-3` caveat 의 "P+ task" 약속을
같은 후속 PR 안에서 닫음.
변경:
- `VectorStore::delete_by_chunk_ids(&[ChunkId])` trait method 추가 (default
no-op 제공 — 테스트 fake / 기존 impl 이 그대로 컴파일).
- `LanceVectorStore::delete_by_chunk_ids` 가 connection 의 모든
`chunk_embeddings_*` 테이블을 순회 + `Table::delete("chunk_id IN (...)")`
를 batch=200 단위로 실행. 다중 모델 워크스페이스 (마이그레이션 중간 등)
에서도 안전.
- `SqliteStore::stale_chunk_ids_at(workspace_path, new_asset_id)` 가
read-only SELECT 로 옛 chunk_id 들 반환. CASCADE 가 흐르기 *전* 에
caller 가 호출.
- `kebab-app::purge_vector_orphans_for_workspace_path` 가 위 두 단계를
orchestrate. 세 ingest path (markdown / image / pdf) 의
`put_asset_with_bytes` 호출 직전에 한 줄로 호출.
Smoke 검증 (release binary, fastembed enabled):
- whitepaper.pdf 첫 ingest → chunk_ids = {f616…, 4e0f…}, vector store 에
그 두 ID 의 row 존재.
- byte 변경 후 re-ingest → 새 doc_id (3741…) + 새 chunk_ids
(ed0c…, e13c…). vector search "REWRITTEN chapter two" → 새 chunk_ids 만
hit. 옛 query "Edited page two body" 시도해도 옛 chunk_ids 는 vector
store 에 더 이상 없음 (의미적으로 가장 가까운 새 chunks 가 hit).
HOTFIXES `2026-05-02 P7-3` 의 \"vector store cleanup\" 항목이 \"deferred\" →
\"closed by follow-up PR\" 로 갱신. SMOKE.md 의 알려진 동작 (\"옛 vector
잔존\") 도 \"두 store 정합\" 으로 갱신.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>