chore: PR #234 회차 1 리뷰 반영 — 압축 트리거·고아 창·doctor 판정

리뷰어 두 명이 독립적으로 같은 결함을 지목했고 실측으로 확인됐다.

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
This commit is contained in:
2026-08-16 17:26:33 +09:00
parent ed6cfd606f
commit 1ff547f4c5
8 changed files with 197 additions and 99 deletions

View File

@@ -91,7 +91,7 @@ Markdown · PDF · 이미지(OCR + caption) · 소스코드(Rust/Python/TS/JS/Go
| `kebab fetch chunk\|doc\|span <id> [flags]` | indexed corpus 에서 verbatim text fetch |
| `kebab eval run \| aggregate \| compare \| variants` | golden query 회귀 측정 + 변형 일관성 진단. `compare --max-drop <낙폭>` 은 어떤 지표든 그 이상 **떨어지면** exit 1 — 절대 하한이 아니라 델타 예산이다 (없으면 delta 만 출력하고 항상 exit 0) |
| `kebab schema [--json]` | introspection — wire schemas / capabilities / models / stats |
| `kebab doctor` | 설정 / 모델 / DB 헬스 체크 |
| `kebab doctor` | 설정 / 모델 / DB 헬스 체크. `vector_store` 체크는 Lance fragment·버전 수를 정보성으로 보여준다(종료 코드에 영향 없음) |
| `kebab mcp` | MCP stdio server (`search` / `bulk_search` / `ask` / `fetch` / `schema` / `doctor` / `ingest_file` / `ingest_stdin`) |
| `kebab reset [--all \| --data-only \| --vector-only \| --config-only \| --orphans-only] [--yes]` | XDG 데이터 wipe (**irreversible**) |

View File

@@ -2143,13 +2143,19 @@ fn sweep_deleted_files(
let workspace_root = app.config.resolve_workspace_root();
let mut purged: u32 = 0;
// Vector deletes are batched across the whole sweep instead of issued
// per file. Each `delete_by_chunk_ids` call is one Lance commit, so a
// per-file call made "documents purged" and "Lance commits" 1:1 —
// issue #230 measured 5,834 purges turning into 5,834 commits and a
// 32-hour sweep. SQLite stays per-path: it is the crash-safety
// boundary, and the pre-existing policy already tolerates orphan
// vectors if the run dies mid-sweep.
// Vector deletes are batched instead of issued per file. Each
// `delete_by_chunk_ids` call is one Lance commit, so a per-file call
// made "documents purged" and "Lance commits" 1:1 — issue #230
// measured 5,834 purges turning into 5,834 commits and a 32-hour
// sweep.
//
// Buffered rather than accumulated for the whole sweep. SQLite commits
// per path and does not roll back, so every id sitting in this buffer
// when the process dies is a vector whose document row is already
// gone — unreachable by any later sweep and only recoverable by
// `reset --vector-only` plus a full re-embed. The flush bound caps
// that exposure while still collapsing commits by three orders of
// magnitude.
let mut doomed_chunk_ids: Vec<kebab_core::ChunkId> = Vec::new();
for stored_path in stored_paths {
@@ -2193,8 +2199,11 @@ fn sweep_deleted_files(
}
};
if vector_store.is_some() {
if let Some(vec) = vector_store {
doomed_chunk_ids.extend(chunk_ids);
if doomed_chunk_ids.len() >= VECTOR_DELETE_FLUSH {
flush_vector_deletes(vec, &mut doomed_chunk_ids, purged);
}
}
tracing::info!(
@@ -2205,25 +2214,46 @@ fn sweep_deleted_files(
purged = purged.saturating_add(1);
}
// Purge associated vectors in one call (best-effort; partial failure
// acceptable — orphan vectors get cleaned by `kebab reset
// --vector-only` if they accumulate).
if let (Some(vec), false) = (vector_store, doomed_chunk_ids.is_empty()) {
use kebab_core::VectorStore as _;
if let Err(e) = vec.delete_by_chunk_ids(&doomed_chunk_ids) {
tracing::warn!(
target: "kebab-app",
count = doomed_chunk_ids.len(),
docs = purged,
error = %e,
"sweep_deleted_files: vector delete failed; SQLite side already cleaned"
);
}
if let Some(vec) = vector_store {
flush_vector_deletes(vec, &mut doomed_chunk_ids, purged);
}
Ok(purged)
}
/// Ids buffered before a batched Lance delete is forced out.
///
/// Trades Lance commits against the orphan-vector window: at 5,000 a
/// 600k-chunk workspace flushes ~120 times instead of once per document,
/// and an abort strands at most 5,000 vectors instead of the whole sweep.
pub(crate) const VECTOR_DELETE_FLUSH: usize = 5_000;
/// Delete the buffered chunk ids and clear the buffer.
///
/// Best-effort: a failure is logged and the ids dropped. The SQLite rows
/// are already gone, so retrying would need state this function does not
/// have — orphan vectors are reclaimed by `kebab reset --vector-only`.
pub(crate) fn flush_vector_deletes(
vec: &kebab_store_vector::LanceVectorStore,
buf: &mut Vec<kebab_core::ChunkId>,
docs: u32,
) {
if buf.is_empty() {
return;
}
use kebab_core::VectorStore as _;
if let Err(e) = vec.delete_by_chunk_ids(buf) {
tracing::warn!(
target: "kebab-app",
count = buf.len(),
docs,
error = %e,
"vector delete failed; SQLite side already cleaned"
);
}
buf.clear();
}
/// P7-3: process one `MediaType::Pdf` asset end-to-end.
///
/// - Reads bytes from disk.

View File

@@ -453,27 +453,47 @@ pub fn doctor_with_config_path(
let data_dir = kebab_config::expand_path(&cfg.storage.data_dir, "");
let vector_dir =
kebab_config::expand_path(&cfg.storage.vector_dir, &data_dir.to_string_lossy());
if let Some((fragments, data_bytes, manifests, meta_bytes)) = lance_dir_stats(&vector_dir) {
let mb = |b: u64| b as f64 / 1_048_576.0;
// Metadata outweighing vectors is the shape of the #230
// pathology; a healthy compacted table is the other way round
// by orders of magnitude.
let bloated = meta_bytes > data_bytes && data_bytes > 0;
checks.push(DoctorCheck {
name: "vector_store".to_string(),
ok: !bloated,
detail: format!(
"{fragments} fragments / {:.1} MB data, {manifests} versions / {:.1} MB metadata",
mb(data_bytes),
mb(meta_bytes)
),
hint: bloated.then(|| {
"version history is larger than the vectors it describes \
a `kebab ingest` on a build with periodic compaction reclaims it"
.to_string()
}),
});
}
let (detail, hint) = match lance_dir_stats(&vector_dir) {
Some((fragments, data_bytes, manifests, meta_bytes)) => {
let mb = |b: u64| b as f64 / 1_048_576.0;
// Scale-independent on purpose. Comparing metadata bytes to
// data bytes looks obvious but inverts on small stores:
// manifest bytes grow with the square of the fragment count
// while data grows with the corpus, so a healthy notes KB of
// a few hundred one-chunk documents would trip it while a
// genuinely bloated 600k-chunk store would not. Versions far
// past the compaction interval means compaction is not
// keeping up, at any size.
let ceiling = kebab_store_vector::COMPACT_EVERY_N_UPSERTS.saturating_mul(2);
let behind = manifests > ceiling;
(
format!(
"{fragments} fragments / {:.1} MB data, {manifests} versions / {:.1} MB metadata",
mb(data_bytes),
mb(meta_bytes)
),
behind.then(|| {
format!(
"{manifests} retained versions is past the {ceiling} compaction \
ceiling — version history will keep growing faster than the \
vectors it describes"
)
}),
)
}
// Reported rather than omitted: a silently missing check reads
// as a healthy one.
None => ("no Lance table yet".to_string(), None),
};
// Informational. `DoctorReport.ok` drives exit code 3, which
// scripts and agents branch on, and a store that needs compacting
// still answers every query correctly.
checks.push(DoctorCheck {
name: "vector_store".to_string(),
ok: true,
detail,
hint,
});
}
let ok = checks.iter().all(|c| c.ok);

View File

@@ -249,15 +249,38 @@ fn execute_orphans_only(cfg: &Config) -> Result<ResetReport> {
let mut purged_paths: Vec<WorkspacePath> = Vec::new();
// Batched for the same reason as `sweep_deleted_files`: one Lance
// commit per purged document is what makes a large orphan sweep take
// hours (issue #230).
// hours (issue #230). Bounded by the same flush threshold, and flushed
// on the error path below — a bare `?` here would strand every vector
// buffered so far, whose SQLite rows are already deleted and therefore
// unreachable by any later sweep.
let mut doomed_chunk_ids: Vec<kebab_core::ChunkId> = Vec::new();
for path in &orphans {
let chunk_ids = kebab_store_sqlite::purge_deleted_workspace_path(&store, path)
.with_context(|| format!("execute_orphans_only: purge {}", path.0))?;
let purged = kebab_store_sqlite::purge_deleted_workspace_path(&store, path)
.with_context(|| format!("execute_orphans_only: purge {}", path.0));
let chunk_ids = match purged {
Ok(ids) => ids,
Err(e) => {
if let Some(vs) = &vector_store {
crate::ingest::flush_vector_deletes(
vs,
&mut doomed_chunk_ids,
u32::try_from(purged_paths.len()).unwrap_or(u32::MAX),
);
}
return Err(e);
}
};
if vector_store.is_some() {
if let Some(vs) = &vector_store {
doomed_chunk_ids.extend(chunk_ids);
if doomed_chunk_ids.len() >= crate::ingest::VECTOR_DELETE_FLUSH {
crate::ingest::flush_vector_deletes(
vs,
&mut doomed_chunk_ids,
u32::try_from(purged_paths.len()).unwrap_or(u32::MAX),
);
}
}
tracing::info!(
@@ -268,17 +291,12 @@ fn execute_orphans_only(cfg: &Config) -> Result<ResetReport> {
purged_paths.push(path.clone());
}
if let (Some(vs), false) = (&vector_store, doomed_chunk_ids.is_empty()) {
use kebab_core::VectorStore as _;
if let Err(e) = vs.delete_by_chunk_ids(&doomed_chunk_ids) {
tracing::warn!(
target: "kebab-app",
count = doomed_chunk_ids.len(),
docs = purged_paths.len(),
error = %e,
"reset --orphans-only: vector delete failed; SQLite side already cleaned"
);
}
if let Some(vs) = &vector_store {
crate::ingest::flush_vector_deletes(
vs,
&mut doomed_chunk_ids,
u32::try_from(purged_paths.len()).unwrap_or(u32::MAX),
);
}
let orphans_purged = u32::try_from(purged_paths.len()).unwrap_or(u32::MAX);

View File

@@ -28,4 +28,4 @@ mod arrow_batch;
mod paths;
mod store;
pub use store::{INDEX_VERSION_STR, LanceVectorStore};
pub use store::{COMPACT_EVERY_N_UPSERTS, INDEX_VERSION_STR, LanceVectorStore};

View File

@@ -74,7 +74,7 @@ pub const INDEX_VERSION_STR: &str = INDEX_VERSION;
/// struct, so single-document paths (`kebab ingest-file`, MCP
/// `ingest_file`/`ingest_stdin`) still reach it: each opens a fresh
/// store, and an in-memory counter would restart at zero every call.
const COMPACT_EVERY_N_UPSERTS: u64 = 512;
pub const COMPACT_EVERY_N_UPSERTS: u64 = 512;
/// Lance VectorStore.
///
@@ -172,7 +172,15 @@ impl LanceVectorStore {
})
}
/// Compact when the table has accumulated `compact_every` commits.
/// Read the table's current version, or 0 if it cannot be read.
fn table_version(&self, table: &lancedb::Table) -> u64 {
self.runtime
.block_on(async { table.version().await })
.unwrap_or(0)
}
/// Compact when this write pushed the table across a multiple of
/// `compact_every` commits.
///
/// The trigger is Lance's own table version, not a counter on this
/// struct. Version is monotonic and lives in the table, so it keeps
@@ -181,23 +189,29 @@ impl LanceVectorStore {
/// which is exactly the workload that accumulates fragments one at a
/// time and would therefore never compact.
///
/// Both writers call this: `upsert` and `delete_by_chunk_ids`. A large
/// sweep is as capable of leaving thousands of fragments behind as a
/// large ingest is.
/// It compares the quotients on either side of the write rather than
/// testing `version % compact_every == 0`, because writers do not all
/// advance the version by one. `upsert` commits once, but
/// `delete_by_chunk_ids` commits once per 200-id batch, so a single
/// call can step from 28474 to 28507 — straight over a multiple
/// without ever landing on one. The modulo form made the delete path's
/// compaction fire roughly one call in `compact_every`.
///
/// Both writers call this. A large sweep is as capable of leaving
/// thousands of fragments behind as a large ingest is.
///
/// Failure is not fatal — the data is already committed and a later
/// commit crosses the next multiple — but it is logged at warn, because
/// write crosses the next multiple — but it is logged at warn, because
/// the symptom of silently skipping it only shows up much later as a
/// slow ingest.
fn maybe_compact(&self, table: &lancedb::Table, table_name: &str) {
fn maybe_compact(&self, table: &lancedb::Table, table_name: &str, version_before: u64) {
if self.compact_every == 0 {
return;
}
let version = self
.runtime
.block_on(async { table.version().await })
.unwrap_or(0);
if version == 0 || version % self.compact_every != 0 {
let version_after = self.table_version(table);
if version_after == 0
|| version_after / self.compact_every <= version_before / self.compact_every
{
return;
}
if let Err(e) = self.compact_table(table) {
@@ -378,6 +392,7 @@ impl VectorStore for LanceVectorStore {
.context("phase 1: stage pending embedding_records")?;
// Phase 2: Lance MergeInsert keyed on chunk_id.
let version_before = self.table_version(&table);
let batch = build_batch(recs, dim, now)?;
merge_insert_batch(&self.runtime, &table, batch).context("phase 2: Lance MergeInsert")?;
@@ -397,7 +412,7 @@ impl VectorStore for LanceVectorStore {
"upsert committed"
);
self.maybe_compact(&table, &table_name);
self.maybe_compact(&table, &table_name, version_before);
Ok(())
}
@@ -424,7 +439,7 @@ impl VectorStore for LanceVectorStore {
// WHERE clause within typical SQL parser limits.
const BATCH: usize = 200;
let touched = self.runtime.block_on(async {
let mut touched: Vec<(String, lancedb::Table)> = Vec::new();
let mut touched: Vec<(String, lancedb::Table, u64)> = Vec::new();
let names = self
.connection
.table_names()
@@ -447,6 +462,9 @@ impl VectorStore for LanceVectorStore {
continue;
}
};
// Captured inside the async block (no nested `block_on`);
// `maybe_compact` compares against it after the loop.
let version_before = table.version().await.unwrap_or(0);
for batch in chunk_ids.chunks(BATCH) {
// chunk_ids in production come from `id_for_chunk`
// which always emits 32 ASCII hex chars. The
@@ -474,7 +492,7 @@ impl VectorStore for LanceVectorStore {
.await
.with_context(|| format!("Lance delete on {name} ({} ids)", batch.len()))?;
}
touched.push((name.clone(), table));
touched.push((name.clone(), table, version_before));
}
anyhow::Ok(touched)
})?;
@@ -486,8 +504,8 @@ impl VectorStore for LanceVectorStore {
// Deletes commit just like upserts do, so the same fragment
// accumulation applies. Compaction runs outside the `block_on`
// above because `maybe_compact` drives its own.
for (name, table) in touched {
self.maybe_compact(&table, &name);
for (name, table, version_before) in touched {
self.maybe_compact(&table, &name, version_before);
}
Ok(())
}

View File

@@ -91,19 +91,30 @@ fn repeated_upserts_do_not_accumulate_lance_versions() {
/// The delete path commits just like the upsert path, and `sweep_deleted_files`
/// used to call it once per purged document — issue #230 measured 5,834 purges
/// becoming 5,834 Lance commits. Batching moved that to one call per sweep, but
/// a large sweep still commits per 200-id batch, so the delete path needs the
/// same compaction the upsert path got.
/// becoming 5,834 Lance commits. Batching moved that to one call per flush, but
/// one call still commits per 200-id batch, so the delete path needs the same
/// compaction the upsert path got.
///
/// Deliberately large enough that a single call spans more than
/// `compact_every` batches. That is the case a `version % compact_every == 0`
/// trigger misses: the call steps the version by several at once and clears the
/// multiple without ever landing on it. A handful of ids would pass against
/// either trigger and prove nothing.
#[test]
#[ignore = "requires AVX-capable hardware (LanceDB)"]
fn repeated_deletes_do_not_accumulate_lance_versions() {
fn batched_delete_spanning_many_commits_still_compacts() {
require_avx_or_panic();
let env = TestEnv::with_compact_interval(8);
const INTERVAL: u64 = 8;
// `delete_by_chunk_ids` commits per 200 ids, so this is ~10 commits in
// one call — comfortably past INTERVAL.
const IDS: u32 = 2_000;
let env = TestEnv::with_compact_interval(INTERVAL);
let doc = format!("{:032x}", 0xd0c0u32);
for i in 0..40u8 {
for i in 0..IDS {
env.seed_chunk(
&format!("{:032x}", 0x2000u32 + u32::from(i)),
&format!("{:032x}", 0x2000u32 + i),
&doc,
"note.md",
"en",
@@ -112,26 +123,25 @@ fn repeated_deletes_do_not_accumulate_lance_versions() {
);
}
let recs: Vec<_> = (0..40u8)
let recs: Vec<_> = (0..IDS)
.map(|i| {
let mut r = make_record(i, 0, vec![1.0, 0.0, 0.0, 0.0], "hi", &[], MODEL);
r.chunk_id = kebab_core::ChunkId(format!("{:032x}", 0x2000u32 + u32::from(i)));
let mut r = make_record(0, 0, vec![1.0, 0.0, 0.0, 0.0], "hi", &[], MODEL);
r.chunk_id = kebab_core::ChunkId(format!("{:032x}", 0x2000u32 + i));
r.embedding_id = kebab_core::EmbeddingId(format!("{:032x}", 0xee000000u32 + i));
r
})
.collect();
env.vector.upsert(&recs).unwrap();
let after_upsert = manifest_count(&env.data_dir());
// Delete one at a time — the shape the un-batched sweep produced.
for r in &recs {
env.vector
.delete_by_chunk_ids(std::slice::from_ref(&r.chunk_id))
.unwrap();
}
let ids: Vec<_> = recs.iter().map(|r| r.chunk_id.clone()).collect();
env.vector.delete_by_chunk_ids(&ids).unwrap();
let manifests = manifest_count(&env.data_dir());
assert!(
manifests <= 12,
"delete path accumulated Lance versions: {manifests} manifests after 40 deletes \
(compaction interval 8)"
manifests <= after_upsert + 2,
"batched delete left {manifests} manifests (was {after_upsert} before the delete) — \
a single {IDS}-id call spans ~{} commits and must still trigger compaction",
IDS / 200
);
}

View File

@@ -22,7 +22,7 @@ git history.
`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 벡터가 남는다" 를 허용한다.
chunk_id 를 sweep 전체에 걸쳐 모아 루프가 끝난 뒤 한 번만 부르도록 바꿨다. SQLite purge 는 경로마다 즉시 커밋된다(`purge_deleted_workspace_path` 는 트랜잭션을 열지 않는다 — DELETE 세 개가 각각 autocommit 이다). 그래서 버퍼는 무한정 쌓지 않고 5,000 id 마다 flush 한다. 버퍼에 남은 id 는 문서 행이 이미 지워져 있어 다음 sweep 이 다시 찾지 못하는 고아 벡터가 되고, 회수 수단이 `reset --vector-only` 전량 재임베딩뿐이기 때문이다. `reset --orphans-only` 는 에러로 조기 반환할 때도 flush 를 거친다.
실 KB 실측 (28,427 문서 / 600,808 청크, 나무위키 샤드 하나 = 364 문서 삭제):
@@ -36,11 +36,13 @@ chunk_id 를 sweep 전체에 걸쳐 모아 루프가 끝난 뒤 한 번만 부
### 삭제 경로에도 압축 (제안 2 보강)
압축이 upsert 에만 걸려 있어 `reset --orphans-only` 처럼 삭제만 하는 경로는 여전히 무한 누적이었다. 압축 트리거를 `maybe_compact` 헬퍼로 빼고 `delete_by_chunk_ids` 끝에서도 부른다. 회귀 테스트(`repeated_deletes_do_not_accumulate_lance_versions`)는 압축을 빼면 40회 삭제에 매니페스트 42개로 실패한다.
압축이 upsert 에만 걸려 있어 `reset --orphans-only` 처럼 삭제만 하는 경로는 여전히 무한 누적이었다. 트리거를 `maybe_compact` 헬퍼로 빼고 `delete_by_chunk_ids` 끝에서도 부른다.
판정을 `version % interval == 0` 에서 **구간 교차**(`after / interval > before / interval`)로 바꿨다. writer 마다 버전을 올리는 폭이 다르기 때문이다 — `upsert` 는 호출당 1 이라 배수를 반드시 밟지만 `delete_by_chunk_ids` 는 200-id 배치마다 커밋해서 한 번의 호출이 28474 → 28507 처럼 배수를 **건너뛴다**. 위 실측이 정확히 그 경우였고(28507 % 512 = 347), modulo 판정이었다면 삭제 경로 압축은 호출당 1/512 확률로만 걸렸을 것이다.
### doctor 에 Lance 지표 (제안 4)
`kebab doctor``vector_store` 체크 추가. fragment 수 / 데이터 크기 / 버전 수 / 메타데이터 크기를 보여주고, **메타데이터가 데이터보다 크면 fail** 로 잡는다 — 그게 #230 병리의 모양이다. lancedb 를 열지 않고 파일시스템만 읽어서 임베딩 provider 가 `none` 이어도 나오고 비용도 0 이다. `DoctorCheck` 목록에 항목을 더하는 것이라 `doctor.v1` wire 는 그대로다.
`kebab doctor``vector_store` 체크 추가. fragment 수 / 데이터 크기 / 버전 수 / 메타데이터 크기를 보여준다. 판정은 **정보성**(`ok` 항상 true)이다 — `DoctorReport.ok` 가 종료 코드 3 을 만들고 스크립트·에이전트가 그걸로 분기하는데, 압축이 밀린 스토어도 질의에는 정상 응답한다. 대신 보존 버전 수가 압축 간격의 2배를 넘으면 hint 로 경고한다. 메타데이터 대 데이터 바이트 비교는 규모에 따라 부호가 뒤집혀(작은 노트 KB 가 오탐) 쓰지 않았다. lancedb 를 열지 않고 파일시스템만 읽어서 임베딩 provider 가 `none` 이어도 나오고 비용도 0 이다. `DoctorCheck` 목록에 항목을 더하는 것이라 `doctor.v1` wire 는 그대로다.
현재 KB 출력: `✓ vector_store 336 fragments / 2832.7 MB data, 337 versions / 5.3 MB metadata`.