fix(store-vector): #230 나머지 — 삭제 배치화 + 삭제 경로 압축 + doctor 지표 #234

Merged
altair823 merged 3 commits from fix/lance-delete-batching into main 2026-08-16 09:20:34 +00:00
Owner

요약

PR #233 이 이슈 #230 의 네 제안 중 두 번째(압축 정책)만 닫았다. 이 PR 이 나머지 1·3·4 를 처리해 #230 을 닫는다.

1. 삭제가 파일 1건당 Lance 커밋 1회 (제안 1)

sweep_deleted_files(ingest.rs)와 execute_orphans_only(reset.rs)가 루프 안에서 파일마다 delete_by_chunk_ids 를 불렀다. 그 호출 하나가 Lance 커밋 하나라 문서 삭제 수와 커밋 수가 1:1 이었다 — #230 이 보고한 "삭제 5,834건 → 커밋 5,834회" 가 이것이다.

chunk_id 를 sweep 전체에 걸쳐 모아 루프가 끝난 뒤 한 번만 부른다. SQLite purge 는 건별 커밋을 유지했다. 그쪽이 crash-safety 경계이고, 기존 정책이 이미 중간에 죽으면 orphan 벡터가 남는 것을 허용한다.

실 KB 실측 — 28,427 문서 KB 에서 나무위키 샤드 하나(364 문서)를 예비로 옮기고 재색인:

수정 전 예상 실측
Lance 커밋 364 (문서당 1) 33
최신 버전 번호 28474 → 28838 28474 → 28507
_transactions 336 → 700 336 → 369

33 은 delete_by_chunk_ids 내부의 200개 배치 상한에서 나온다(364 문서 × 약 18 청크 ≈ 6,500 id / 200). 커밋 수가 문서 수가 아니라 청크 수/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 는 그대로다(additive).

실제 출력: ✓ vector_store 200 fragments / 2832.3 MB data, 201 versions / 2.0 MB metadata

4. geodatafusion (제안 3) — 미해결

lance 내부라 kebab 쪽에서 끌 수 있는 feature flag 가 없다. 다만 이 비용은 table.delete() 호출당 fragment 수만큼 곱해지므로, 위의 커밋 수 감소(364 → 33)가 그대로 곱셈 횟수 감소다. 근본 해결은 upstream 이슈로 올려야 한다.

검증

  • 워크스페이스 테스트 186 결과 전부 통과, 실패 0
  • 신규 회귀 테스트 repeated_deletes_do_not_accumulate_lance_versions — 삭제 경로 압축을 빼면 40회 삭제에 매니페스��� 42개로 실제로 실패하는 것을 확인한 뒤 고쳤다
  • 실 KB 364 문서 삭제로 커밋 수 감소를 파일시스템 카운트로 직접 확인
  • clippy -p kebab-store-vector 통과. kebab-appkebab-parse-code 의 기존 question_mark 지적이 전이돼 red 인데 main 도 동일하며 이 PR 이 건드리지 않은 크레이트다

시험 항목 (Test Plan)

  • cargo test --workspace --no-fail-fast
  • cargo test -p kebab-store-vector -- --ignored (compaction 2건)
  • 실 KB 삭제 sweep 전후 fragment/manifest/version/transaction 카운트
  • kebab doctor 신규 체크 출력 확인

비범위

  • #230 제안 3(geodatafusion) — lance upstream 사안
  • sweep 자체의 속도는 이 PR 로 안 빨라진다. 364 문서 삭제에 38.7분이 걸렸고 Lance 쪽은 33 커밋뿐이므로 남은 비용은 #229(chunks_fts 삭제가 FTS5 전체 스캔, 문서당 약 6초)다. 364 × 6초 ≈ 36분으로 거의 전부 설명된다

Assisted-by: Claude Code

## 요약 PR #233 이 이슈 #230 의 네 제안 중 두 번째(압축 정책)만 닫았다. 이 PR 이 나머지 1·3·4 를 처리해 #230 을 닫는다. ### 1. 삭제가 파일 1건당 Lance 커밋 1회 (제안 1) `sweep_deleted_files`(`ingest.rs`)와 `execute_orphans_only`(`reset.rs`)가 루프 안에서 파일마다 `delete_by_chunk_ids` 를 불렀다. 그 호출 하나가 Lance 커밋 하나라 **문서 삭제 수와 커밋 수가 1:1** 이었다 — #230 이 보고한 "삭제 5,834건 → 커밋 5,834회" 가 이것이다. chunk_id 를 sweep 전체에 걸쳐 모아 루프가 끝난 뒤 한 번만 부른다. SQLite purge 는 건별 커밋을 유지했다. 그쪽이 crash-safety 경계이고, 기존 정책이 이미 중간에 죽으면 orphan 벡터가 남는 것을 허용한다. 실 KB 실측 — 28,427 문서 KB 에서 나무위키 샤드 하나(364 문서)를 예비로 옮기고 재색인: | | 수정 전 예상 | 실측 | |---|---|---| | Lance 커밋 | 364 (문서당 1) | **33** | | 최신 버전 번호 | 28474 → 28838 | 28474 → **28507** | | `_transactions` | 336 → 700 | 336 → **369** | 33 은 `delete_by_chunk_ids` 내부의 200개 배치 상한에서 나온다(364 문서 × 약 18 청크 ≈ 6,500 id / 200). 커밋 수가 **문서 수**가 아니라 **청크 수/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 는 그대로다(additive). 실제 출력: `✓ vector_store 200 fragments / 2832.3 MB data, 201 versions / 2.0 MB metadata` ### 4. geodatafusion (제안 3) — 미해결 lance 내부라 kebab 쪽에서 끌 수 있는 feature flag 가 없다. 다만 이 비용은 `table.delete()` 호출당 fragment 수만큼 곱해지므로, 위의 커밋 수 감소(364 → 33)가 그대로 곱셈 횟수 감소다. 근본 해결은 upstream 이슈로 올려야 한다. ## 검증 - 워크스페이스 테스트 **186 결과 전부 통과, 실패 0** - 신규 회귀 테스트 `repeated_deletes_do_not_accumulate_lance_versions` — 삭제 경로 압축을 빼면 40회 삭제에 매니페스��� 42개로 **실제로 실패하는 것을 확인**한 뒤 고쳤다 - 실 KB 364 문서 삭제로 커밋 수 감소를 파일시스템 카운트로 직접 확인 - `clippy -p kebab-store-vector` 통과. `kebab-app` 은 `kebab-parse-code` 의 기존 `question_mark` 지적이 전이돼 red 인데 main 도 동일하며 이 PR 이 건드리지 않은 크레이트다 ## 시험 항목 (Test Plan) - [x] `cargo test --workspace --no-fail-fast` - [x] `cargo test -p kebab-store-vector -- --ignored` (compaction 2건) - [x] 실 KB 삭제 sweep 전후 fragment/manifest/version/transaction 카운트 - [x] `kebab doctor` 신규 체크 출력 확인 ## 비범위 - #230 제안 3(geodatafusion) — lance upstream 사안 - **sweep 자체의 속도는 이 PR 로 안 빨라진다.** 364 문서 삭제에 38.7분이 걸렸고 Lance 쪽은 33 커밋뿐이므로 남은 비용은 #229(chunks_fts 삭제가 FTS5 전체 스캔, 문서당 약 6초)다. 364 × 6초 ≈ 36분으로 거의 전부 설명된다 Assisted-by: Claude Code
altair823 added 1 commit 2026-08-16 08:02:33 +00:00
앞 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
altair823 added 1 commit 2026-08-16 08:26:39 +00:00
리뷰어 두 명이 독립적으로 같은 결함을 지목했고 실측으로 확인됐다.

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
altair823 added 1 commit 2026-08-16 08:44:21 +00:00
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
altair823 merged commit 5fa0e79967 into main 2026-08-16 09:20:34 +00:00
altair823 deleted branch fix/lance-delete-batching 2026-08-16 09:20:35 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: altair823-org/kebab#234