Merge pull request 'fix(store-vector): #230 나머지 — 삭제 배치화 + 삭제 경로 압축 + doctor 지표' (#234) from fix/lance-delete-batching into main

This commit was merged in pull request #234.
This commit is contained in:
2026-08-16 09:20:32 +00:00
9 changed files with 371 additions and 67 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,6 +2143,20 @@ fn sweep_deleted_files(
let workspace_root = app.config.resolve_workspace_root();
let mut purged: u32 = 0;
// 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 {
if scanned_paths.contains(&stored_path) {
@@ -2185,21 +2199,10 @@ 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"
);
}
doomed_chunk_ids.extend(chunk_ids);
if doomed_chunk_ids.len() >= VECTOR_DELETE_FLUSH {
flush_vector_deletes(vec, &mut doomed_chunk_ids, purged);
}
}
@@ -2211,9 +2214,46 @@ fn sweep_deleted_files(
purged = purged.saturating_add(1);
}
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

@@ -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,57 @@ 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());
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);
Ok(DoctorReport {
schema_version: "doctor.v1".to_string(),

View File

@@ -247,23 +247,39 @@ 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). 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))?;
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"
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 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),
);
}
}
@@ -275,6 +291,14 @@ fn execute_orphans_only(cfg: &Config) -> Result<ResetReport> {
purged_paths.push(path.clone());
}
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);
Ok(ResetReport {

View File

@@ -1374,9 +1374,18 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
println!("{}", serde_json::to_string(&wire::wire_doctor(&report))?);
} else {
for c in &report.checks {
let mark = if c.ok { "" } else { "" };
// A hint is printed whenever one exists, not only on
// failure: an informational check (one that reports a
// condition worth acting on without blocking anything)
// carries its warning here, and gating on `!ok` would
// leave that text reachable only via `--json`.
let mark = match (c.ok, c.hint.is_some()) {
(false, _) => "",
(true, true) => "!",
(true, false) => "",
};
println!("{mark} {:<20} {}", c.name, c.detail);
if let (false, Some(hint)) = (c.ok, c.hint.as_ref()) {
if let Some(hint) = c.hint.as_ref() {
println!(" hint: {hint}");
}
}

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,6 +172,68 @@ impl LanceVectorStore {
})
}
/// 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
/// 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.
///
/// 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
/// 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, version_before: u64) {
if self.compact_every == 0 {
return;
}
let version_after = self.table_version(table);
// A version read that failed reports 0. Bailing on either side keeps
// that from being read as "crossed every boundary since zero", which
// would run this expensive pass on every write.
if version_before == 0
|| version_after == 0
|| version_after / self.compact_every <= version_before / self.compact_every
{
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
@@ -334,6 +396,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")?;
@@ -353,39 +416,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, version_before);
Ok(())
}
@@ -411,7 +442,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, u64)> = Vec::new();
let names = self
.connection
.table_names()
@@ -434,6 +466,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
@@ -461,14 +496,21 @@ impl VectorStore for LanceVectorStore {
.await
.with_context(|| format!("Lance delete on {name} ({} ids)", batch.len()))?;
}
touched.push((name.clone(), table, version_before));
}
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, version_before) in touched {
self.maybe_compact(&table, &name, version_before);
}
Ok(())
}

View File

@@ -88,3 +88,59 @@ 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 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, because that is the shape the batched sweep
/// actually ships. A handful of ids commits once or twice and would pass
/// even with no compaction on the delete path at all, proving nothing.
#[test]
#[ignore = "requires AVX-capable hardware (LanceDB)"]
fn batched_delete_spanning_many_commits_still_compacts() {
require_avx_or_panic();
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..IDS {
env.seed_chunk(
&format!("{:032x}", 0x2000u32 + i),
&doc,
"note.md",
"en",
&[],
"primary",
);
}
let recs: Vec<_> = (0..IDS)
.map(|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());
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 <= 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

@@ -14,6 +14,46 @@ 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 는 경로마다 즉시 커밋된다(`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 문서 삭제):
| | 수정 전 예상 | 실측 |
|---|---|---|
| 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` 끝에서도 부른다.
판정을 `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 수 / 데이터 크기 / 버전 수 / 메타데이터 크기를 보여준다. 판정은 **정보성**(`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`.
### 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 에서 수정.