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

앞 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
This commit is contained in:
2026-08-16 17:02:01 +09:00
parent aeb65e6698
commit ed6cfd606f
6 changed files with 258 additions and 64 deletions

View File

@@ -2143,6 +2143,14 @@ 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.
let mut doomed_chunk_ids: Vec<kebab_core::ChunkId> = Vec::new();
for stored_path in stored_paths {
if scanned_paths.contains(&stored_path) {
@@ -2185,22 +2193,8 @@ fn sweep_deleted_files(
}
};
// Purge associated vectors (best-effort; partial failure
// acceptable — orphan vectors get cleaned by `kebab reset
// --vector-only` if they accumulate).
if let Some(vec) = vector_store {
if !chunk_ids.is_empty() {
use kebab_core::VectorStore as _;
if let Err(e) = vec.delete_by_chunk_ids(&chunk_ids) {
tracing::warn!(
target: "kebab-app",
path = %stored_path.0,
count = chunk_ids.len(),
error = %e,
"sweep_deleted_files: vector delete failed; SQLite side already cleaned"
);
}
}
if vector_store.is_some() {
doomed_chunk_ids.extend(chunk_ids);
}
tracing::info!(
@@ -2211,6 +2205,22 @@ 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"
);
}
}
Ok(purged)
}

View File

@@ -111,6 +111,48 @@ pub struct DoctorCheck {
pub hint: Option<String>,
}
/// Filesystem-level health of the Lance vector store: how many data
/// fragments exist and how much of the directory is version history
/// rather than vectors.
///
/// Read straight off disk rather than through `lancedb` so it costs
/// nothing and still reports when the embedding provider is `none`.
///
/// Issue #230: without periodic compaction every write left a fragment
/// and a manifest listing all prior fragments, so metadata grew
/// quadratically. A 16.8k-document KB held 12.2 GB of manifests against
/// 1.7 GB of vectors and its ingest had decayed sevenfold. Nothing
/// surfaced that — the only way to see it was to count files by hand.
fn lance_dir_stats(vector_dir: &std::path::Path) -> Option<(u64, u64, u64, u64)> {
let entries = std::fs::read_dir(vector_dir).ok()?;
let (mut fragments, mut data_bytes, mut manifests, mut meta_bytes) = (0u64, 0u64, 0u64, 0u64);
let mut saw_table = false;
for table in entries.flatten() {
let tp = table.path();
if !tp.is_dir() || tp.extension().is_none_or(|e| e != "lance") {
continue;
}
saw_table = true;
for (sub, count, bytes) in [
("data", &mut fragments, &mut data_bytes),
("_versions", &mut manifests, &mut meta_bytes),
] {
let Ok(rd) = std::fs::read_dir(tp.join(sub)) else {
continue;
};
for f in rd.flatten() {
if let Ok(md) = f.metadata() {
if md.is_file() {
*count += 1;
*bytes += md.len();
}
}
}
}
}
saw_table.then_some((fragments, data_bytes, manifests, meta_bytes))
}
/// Create XDG dirs and write a starter `config.toml`. Idempotent unless
/// `force=true` (which overwrites an existing config).
pub fn init_workspace(force: bool) -> anyhow::Result<()> {
@@ -403,6 +445,37 @@ pub fn doctor_with_config_path(
}
}
// vector_store — Lance fragment / version-history health (issue #230).
{
let cfg = loaded_cfg
.clone()
.unwrap_or_else(kebab_config::Config::defaults);
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 ok = checks.iter().all(|c| c.ok);
Ok(DoctorReport {
schema_version: "doctor.v1".to_string(),

View File

@@ -247,24 +247,17 @@ fn execute_orphans_only(cfg: &Config) -> Result<ResetReport> {
open_vector_store_if_configured(cfg, store.clone())?;
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).
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))?;
if let Some(ref vs) = vector_store {
if !chunk_ids.is_empty() {
use kebab_core::VectorStore as _;
if let Err(e) = vs.delete_by_chunk_ids(&chunk_ids) {
tracing::warn!(
target: "kebab-app",
path = %path.0,
count = chunk_ids.len(),
error = %e,
"reset --orphans-only: vector delete failed; SQLite side already cleaned"
);
}
}
if vector_store.is_some() {
doomed_chunk_ids.extend(chunk_ids);
}
tracing::info!(
@@ -275,6 +268,19 @@ 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"
);
}
}
let orphans_purged = u32::try_from(purged_paths.len()).unwrap_or(u32::MAX);
Ok(ResetReport {

View File

@@ -172,6 +172,50 @@ impl LanceVectorStore {
})
}
/// Compact when the table has accumulated `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
/// counting across processes — an in-memory counter would restart at
/// zero on every `kebab ingest-file` and every MCP `ingest_file` call,
/// 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.
///
/// Failure is not fatal — the data is already committed and a later
/// commit 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) {
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 {
return;
}
if let Err(e) = self.compact_table(table) {
tracing::warn!(
target: "kebab-store-vector",
table = %table_name,
error = %e,
"Lance compaction failed; writes will slow down until the next attempt"
);
} else {
tracing::info!(
target: "kebab-store-vector",
table = %table_name,
"Lance compaction done"
);
}
}
/// Compact the table's fragments and drop superseded versions.
///
/// `delete_unverified` tells Lance to reclaim files no manifest
@@ -353,39 +397,7 @@ impl VectorStore for LanceVectorStore {
"upsert committed"
);
// Fold the per-document fragments back together every so often.
//
// 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
// counting across processes — an in-memory counter would restart at
// zero on every `kebab ingest-file` and every MCP `ingest_file`
// call, which is exactly the workload that accumulates fragments
// one at a time and would therefore never compact.
//
// Failure here is not fatal — the data is already committed and a
// later commit crosses the next multiple — but it is logged at warn
// because the symptom of silently skipping it is a slow ingest much
// later.
let version = self
.runtime
.block_on(async { table.version().await })
.unwrap_or(0);
if version > 0 && self.compact_every > 0 && version % self.compact_every == 0 {
if let Err(e) = self.compact_table(&table) {
tracing::warn!(
target: "kebab-store-vector",
table = %table_name,
error = %e,
"Lance compaction failed; ingest continues but writes will slow down"
);
} else {
tracing::info!(
target: "kebab-store-vector",
table = %table_name,
"Lance compaction done"
);
}
}
self.maybe_compact(&table, &table_name);
Ok(())
}
@@ -411,7 +423,8 @@ impl VectorStore for LanceVectorStore {
// syntactically valid. We chunk into batches of 200 to keep the
// WHERE clause within typical SQL parser limits.
const BATCH: usize = 200;
self.runtime.block_on(async {
let touched = self.runtime.block_on(async {
let mut touched: Vec<(String, lancedb::Table)> = Vec::new();
let names = self
.connection
.table_names()
@@ -461,14 +474,21 @@ impl VectorStore for LanceVectorStore {
.await
.with_context(|| format!("Lance delete on {name} ({} ids)", batch.len()))?;
}
touched.push((name.clone(), table));
}
anyhow::Ok(())
anyhow::Ok(touched)
})?;
tracing::debug!(
target: "kebab-store-vector",
count = chunk_ids.len(),
"deleted vector rows by chunk_id"
);
// 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);
}
Ok(())
}

View File

@@ -88,3 +88,50 @@ fn repeated_upserts_do_not_accumulate_lance_versions() {
.unwrap();
assert_eq!(hits.len(), 1, "compaction dropped the upserted row");
}
/// 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.
#[test]
#[ignore = "requires AVX-capable hardware (LanceDB)"]
fn repeated_deletes_do_not_accumulate_lance_versions() {
require_avx_or_panic();
let env = TestEnv::with_compact_interval(8);
let doc = format!("{:032x}", 0xd0c0u32);
for i in 0..40u8 {
env.seed_chunk(
&format!("{:032x}", 0x2000u32 + u32::from(i)),
&doc,
"note.md",
"en",
&[],
"primary",
);
}
let recs: Vec<_> = (0..40u8)
.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)));
r
})
.collect();
env.vector.upsert(&recs).unwrap();
// 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 manifests = manifest_count(&env.data_dir());
assert!(
manifests <= 12,
"delete path accumulated Lance versions: {manifests} manifests after 40 deletes \
(compaction interval 8)"
);
}

View File

@@ -14,6 +14,44 @@ historical contract that was implemented; this file accumulates the
deltas so phase 5+ readers can find the live behavior without diffing
git history.
## 2026-08-16 — #230 나머지: 삭제 배치화 + 삭제 경로 압축 + doctor 지표
앞 엔트리가 #230 의 제안 2(압축 정책)만 닫았다. 남은 제안 1·3·4 를 여기서 처리.
### 삭제가 파일 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 문서 / 600,808 청크, 나무위키 샤드 하나 = 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 = 33). 즉 커밋 수가 **문서 수**가 아니라 **청크 수/200** 에 비례하게 됐다.
### 삭제 경로에도 압축 (제안 2 보강)
압축이 upsert 에만 걸려 있어 `reset --orphans-only` 처럼 삭제만 하는 경로는 여전히 무한 누적이었다. 압축 트리거를 `maybe_compact` 헬퍼로 빼고 `delete_by_chunk_ids` 끝에서도 부른다. 회귀 테스트(`repeated_deletes_do_not_accumulate_lance_versions`)는 압축을 빼면 40회 삭제에 매니페스트 42개로 실패한다.
### doctor 에 Lance 지표 (제안 4)
`kebab doctor``vector_store` 체크 추가. fragment 수 / 데이터 크기 / 버전 수 / 메타데이터 크기를 보여주고, **메타데이터가 데이터보다 크면 fail** 로 잡는다 — 그게 #230 병리의 모양이다. lancedb 를 열지 않고 파일시스템만 읽어서 임베딩 provider 가 `none` 이어도 나오고 비용도 0 이다. `DoctorCheck` 목록에 항목을 더하는 것이라 `doctor.v1` wire 는 그대로다.
현재 KB 출력: `✓ vector_store 336 fragments / 2832.7 MB data, 337 versions / 5.3 MB metadata`.
### geodatafusion (제안 3) — 미해결
lance 내부라 kebab 쪽에서 끌 수 있는 feature flag 가 없다. 다만 이 비용은 `table.delete()` 호출당 fragment 수만큼 곱해지므로, 위의 커밋 수 감소(364 → 33)가 그대로 곱셈 횟수 감소다. 근본 해결은 upstream 이슈로 올려야 한다.
### 부수 관찰 — sweep 자체는 여전히 느리다
364 문서 삭제에 38.7분이 걸렸다. Lance 쪽은 33 커밋으로 줄었으니 남은 비용은 **#229(chunks_fts 삭제가 FTS5 전체 스캔, 문서당 약 6초)** 다. 364 × 6초 ≈ 36분으로 거의 전부 설명된다. #230 을 고쳐도 sweep 이 빨라지지 않는 이유가 이것이고, #229 를 고쳐야 실제 체감이 바뀐다.
## 2026-08-16 — 28.4k 문서 도그푸딩: Lance 압축 부재 · 인용 마커 누락 · ollama 생성 무제한
나무위키 18,282 + Apache Jira 10,145 = **28,427 문서 / 600,808 청크** 코퍼스로 종단 도그푸딩. 기존 최대 규모(620 문서)의 46배라 그 규모에서는 보이지 않던 결함 3개가 드러났다. 셋 다 이 PR 에서 수정.