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:
@@ -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**) |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 에서 수정.
|
||||
|
||||
Reference in New Issue
Block a user