diff --git a/crates/kebab-app/src/lib.rs b/crates/kebab-app/src/lib.rs index 320b7ff..c1143d5 100644 --- a/crates/kebab-app/src/lib.rs +++ b/crates/kebab-app/src/lib.rs @@ -1349,9 +1349,17 @@ fn ingest_one_asset( &chunk.text, &alias_version_key, ); - if let Some(payload) = app.sqlite.derivation_cache_get(&key)? { + // 히트 = 캐시에 있고 payload 가 정상 UTF-8 로 디코드되는 + // 경우만. 손상(비-UTF8) payload 는 미스로 강등해 재생성 + // 분기로 보낸다(embedding 경로의 decode-실패→미스 강등과 + // 동작 일치, 정확성 우선 §3.5). + let cached_aliases = app + .sqlite + .derivation_cache_get(&key)? + .and_then(|payload| String::from_utf8(payload).ok()); + if let Some(aliases) = cached_aliases { // 히트: 저장된 별칭(UTF-8) 재사용. LLM 호출 없음. - chunk.aliases = String::from_utf8(payload).ok(); + chunk.aliases = Some(aliases); alias_cache_hit += 1; alias_touch_keys.push(key); } else if crate::expansion::is_nav_boilerplate(chunk) { @@ -1411,10 +1419,15 @@ fn ingest_one_asset( let model_id = emb.model_id(); let model_version = emb.model_version(); let dimensions = emb.dimensions(); - // derivation cache(§3.4): embedding version_key = {model_id}|{model_version}|{dimensions}. + // derivation cache(§3.4): embedding version_key = + // {kind}|{model_id}|{model_version}|{dimensions}. // 본문 청크 + 별칭 문자열 양쪽이 같은 메커니즘(같은 text → 같은 캐시). + // kind 토큰("doc") 을 맨 앞에 둔다: 임베더가 kind 별 프리픽스 + // (Document=`passage:`, Query=`query:`)를 붙여 같은 text 라도 벡터가 + // 달라지므로, 미래에 query 임베딩이 같은 캐시를 타도 충돌하지 않도록 + // 방어적으로 분리(현재 ingest 는 Document 고정이라 live 버그 없음). let emb_version_key = - format!("{}|{}|{}", model_id.0, model_version.0, dimensions); + format!("doc|{}|{}|{}", model_id.0, model_version.0, dimensions); let mut emb_touch_keys: Vec = Vec::new(); // 본문 청크 text 로 캐시 조회 → 미스만 embed → 원래 순서로 합침. let body_texts: Vec<&str> = chunks.iter().map(|c| c.text.as_str()).collect(); @@ -1857,6 +1870,49 @@ fn record_image_analysis_failure( warning_notes.push(note); } +/// Expand a set of body `chunk_id`s into every per-alias sentinel +/// `chunk_id` that orphan cleanup must also delete. +/// +/// PR #195 review (MAJOR): alias dense vectors moved from a single +/// legacy sentinel `{orig}#alias` to per-line sentinels +/// `{orig}#alias#0`, `{orig}#alias#1`, … (one VectorRecord per alias +/// line). These sentinel chunk_ids never appear in SQLite `chunks`, so +/// they are absent from the stale-set the cleanup paths SELECT. Because +/// `delete_by_chunk_ids` matches on exact `chunk_id IN (...)` (not a +/// prefix), deleting only `{orig}#alias` leaked `{orig}#alias#N` rows +/// into LanceDB — stale aliases could still hit search. +/// +/// We reuse the existing exact-match delete infra (approach A): for each +/// body id emit `{id}#alias` (legacy, backward-compat) plus +/// `{id}#alias#0` .. `{id}#alias#{max-1}`. `max` is +/// `expansion.max_aliases_per_chunk`, which is the hard cap +/// `parse_aliases` enforces (it `break`s once `out.len() >= max`), so no +/// index ≥ max is ever produced at ingest time. Indices that were never +/// written are harmless no-ops in an `IN (...)` delete. +fn alias_sentinel_ids_to_delete( + body_ids: &[kebab_core::ChunkId], + max_aliases_per_chunk: usize, +) -> Vec { + let mut out = body_ids.to_vec(); + for id in body_ids { + // Legacy single sentinel (docs ingested before per-line split). + out.push(kebab_core::ChunkId(format!( + "{}{}", + id.0, + kebab_core::ALIAS_SUFFIX + ))); + for i in 0..max_aliases_per_chunk { + out.push(kebab_core::ChunkId(format!( + "{}{}#{}", + id.0, + kebab_core::ALIAS_SUFFIX, + i + ))); + } + } + out +} + /// v0.17.0 PR-B: parser-bump cascade. When a code extractor ships a /// new `PARSER_VERSION` (e.g. `code-c-v1` → `code-c-v2`), the same /// (workspace_path, asset_id) pair re-emerges with a fresh `doc_id`. @@ -1884,12 +1940,13 @@ fn purge_workspace_path_for_parser_bump(app: &App, asset: &RawAsset) -> anyhow:: if !stale.is_empty() { if let Some(vec_store) = app.vector().context("App::vector")? { use kebab_core::VectorStore as _; - // sentinel 별칭 벡터(`{id}#alias`)는 SQLite chunks 에 없어 stale 에 - // 안 잡힌다 → 명시적으로 함께 삭제(orphan 누적 방지). - let mut to_delete = stale.clone(); - to_delete.extend(stale.iter().map(|id| { - kebab_core::ChunkId(format!("{}{}", id.0, kebab_core::ALIAS_SUFFIX)) - })); + // per-alias sentinel 벡터(`{id}#alias#N`)는 SQLite chunks 에 없어 + // stale 에 안 잡힌다 → 본문 + 모든 별칭 sentinel 을 명시적으로 함께 + // 삭제(orphan 누적 방지, PR #195 MAJOR). + let to_delete = alias_sentinel_ids_to_delete( + &stale, + app.config.ingest.expansion.max_aliases_per_chunk, + ); vec_store .delete_by_chunk_ids(&to_delete) .context("VectorStore::delete_by_chunk_ids (parser-bump orphans)")?; @@ -1935,12 +1992,13 @@ fn purge_vector_orphans_for_workspace_path( return Ok(()); } use kebab_core::VectorStore as _; - // sentinel 별칭 벡터(`{id}#alias`)는 SQLite chunks 에 없어 stale 에 - // 안 잡힌다 → 명시적으로 함께 삭제(orphan 누적 방지). - let mut to_delete = stale.clone(); - to_delete.extend(stale.iter().map(|id| { - kebab_core::ChunkId(format!("{}{}", id.0, kebab_core::ALIAS_SUFFIX)) - })); + // per-alias sentinel 벡터(`{id}#alias#N`)는 SQLite chunks 에 없어 stale 에 + // 안 잡힌다 → 본문 + 모든 별칭 sentinel 을 명시적으로 함께 삭제(orphan + // 누적 방지, PR #195 MAJOR). + let to_delete = alias_sentinel_ids_to_delete( + &stale, + app.config.ingest.expansion.max_aliases_per_chunk, + ); vec_store .delete_by_chunk_ids(&to_delete) .context("VectorStore::delete_by_chunk_ids (orphan vector cleanup)")?; @@ -2042,12 +2100,13 @@ fn sweep_deleted_files( if let Some(vec) = vector_store { if !chunk_ids.is_empty() { use kebab_core::VectorStore as _; - // sentinel 별칭 벡터(`{id}#alias`)는 SQLite chunks 에 없어 - // chunk_ids 에 안 잡힌다 → 명시적으로 함께 삭제(orphan 누적 방지). - let mut to_delete = chunk_ids.clone(); - to_delete.extend(chunk_ids.iter().map(|id| { - kebab_core::ChunkId(format!("{}{}", id.0, kebab_core::ALIAS_SUFFIX)) - })); + // per-alias sentinel 벡터(`{id}#alias#N`)는 SQLite chunks 에 없어 + // chunk_ids 에 안 잡힌다 → 본문 + 모든 별칭 sentinel 을 명시적으로 + // 함께 삭제(orphan 누적 방지, PR #195 MAJOR). + let to_delete = alias_sentinel_ids_to_delete( + &chunk_ids, + app.config.ingest.expansion.max_aliases_per_chunk, + ); if let Err(e) = vec.delete_by_chunk_ids(&to_delete) { tracing::warn!( target: "kebab-app", @@ -3309,3 +3368,49 @@ fn check_kebabignore_match( .matched(source_path, source_path.is_dir()) .is_ignore() } + +#[cfg(test)] +mod orphan_cleanup_tests { + use super::alias_sentinel_ids_to_delete; + use kebab_core::ChunkId; + + /// PR #195 MAJOR: alias dense 벡터가 줄별 `{id}#alias#N` sentinel 로 색인되므로 + /// orphan cleanup 의 LanceDB delete-set 은 본문 + legacy `{id}#alias` + + /// `{id}#alias#0` .. `{id}#alias#{max-1}` 를 모두 포함해야 한다. 이전 코드는 + /// 단일 `{id}#alias` 만 넣어 per-line sentinel 을 LanceDB 에 누수시켰다. + #[test] + fn expands_body_legacy_and_per_alias_sentinels() { + let body = ChunkId("aabbccddeeff00112233445566778899".to_string()); + let max = 3; + let out = alias_sentinel_ids_to_delete(std::slice::from_ref(&body), max); + let ids: Vec<&str> = out.iter().map(|c| c.0.as_str()).collect(); + + assert!(ids.contains(&body.0.as_str()), "본문 chunk_id 포함"); + assert!( + ids.contains(&"aabbccddeeff00112233445566778899#alias"), + "하위호환 legacy 단일 sentinel 포함" + ); + for i in 0..max { + let expected = format!("aabbccddeeff00112233445566778899#alias#{i}"); + assert!( + ids.contains(&expected.as_str()), + "per-alias sentinel #{i} 포함 (max={max})" + ); + } + // body(1) + legacy(1) + per-alias(max) = max + 2. + assert_eq!(out.len(), max + 2, "정확히 max+2 개 id"); + // max 상한과 일치: #alias#{max} 는 절대 생성 안 함(parse_aliases 가 cap). + assert!( + !ids.contains(&"aabbccddeeff00112233445566778899#alias#3"), + "상한(max) 이상 인덱스는 생성하지 않음" + ); + } + + /// max=0 (확장 비활성 동등) 이면 per-alias sentinel 없이 본문 + legacy 만. + #[test] + fn zero_max_emits_body_and_legacy_only() { + let body = ChunkId("00000000000000000000000000000000".to_string()); + let out = alias_sentinel_ids_to_delete(std::slice::from_ref(&body), 0); + assert_eq!(out.len(), 2, "본문 + legacy sentinel 만"); + } +} diff --git a/crates/kebab-store-sqlite/src/documents.rs b/crates/kebab-store-sqlite/src/documents.rs index b8a964c..48cf0c1 100644 --- a/crates/kebab-store-sqlite/src/documents.rs +++ b/crates/kebab-store-sqlite/src/documents.rs @@ -99,14 +99,19 @@ impl kebab_core::DocumentStore for SqliteStore { let mut conn = self.lock_conn(); let tx = conn.transaction().map_err(StoreError::from)?; // CASCADE 제거(V011) 대체: 이 doc 의 chunk 임베딩 레코드를 명시 정리. - // 원본 + sentinel({id}#alias) 둘 다. 별칭 dense 벡터(sentinel chunk_id)는 - // chunks FK 가 없어 CASCADE 로 자동 정리되지 않으므로 여기서 직접 지운다. - // chunks 행이 살아있는 동안(아래 DELETE FROM chunks 직전) 실행해야 서브쿼리가 + // 원본 + per-alias sentinel({id}#alias#N) 모두. 별칭 dense 벡터는 줄별 + // sentinel chunk_id(`{orig}#alias#0`, `#alias#1`, …)로 색인되는데 chunks + // FK 가 없어 CASCADE 로 자동 정리되지 않으므로 여기서 직접 지운다. 정확 + // 일치(|| '#alias')는 per-line sentinel 을 놓치므로(PR #195 MAJOR) 본문 + // chunk_id 와 그 `{id}#alias%` 프리픽스를 LIKE 로 함께 매칭한다. chunks + // 행이 살아있는 동안(아래 DELETE FROM chunks 직전) 실행해야 서브쿼리가 // chunk_id 를 본다. 설계 spec 2026-05-30-dense-alias-vectors-design.md §3.5-2. tx.execute( "DELETE FROM embedding_records WHERE chunk_id IN \ - (SELECT chunk_id FROM chunks WHERE doc_id = ?1 \ - UNION SELECT chunk_id || '#alias' FROM chunks WHERE doc_id = ?1)", + (SELECT chunk_id FROM chunks WHERE doc_id = ?1) \ + OR EXISTS (SELECT 1 FROM chunks \ + WHERE chunks.doc_id = ?1 \ + AND embedding_records.chunk_id LIKE chunks.chunk_id || '#alias%')", params![doc.0], ) .map_err(StoreError::from)?; diff --git a/crates/kebab-store-sqlite/src/store.rs b/crates/kebab-store-sqlite/src/store.rs index ebebf42..8c3e86f 100644 --- a/crates/kebab-store-sqlite/src/store.rs +++ b/crates/kebab-store-sqlite/src/store.rs @@ -571,16 +571,20 @@ impl SqliteStore { ) -> Result<()> { let conn = self.lock_conn(); // CASCADE 제거(V011) 대체: documents→chunks CASCADE 가 chunks 를 지우기 전에 - // 원본 + sentinel({id}#alias) embedding_records 를 명시 정리. 별칭 dense - // 벡터는 chunks FK 가 없어 자동 정리되지 않으므로 chunks 가 살아있는 동안 - // 직접 지운다(안 하면 tombstone trigger 가 남긴 행이 누적). 설계 spec - // 2026-05-30-dense-alias-vectors-design.md §3.5-2. (Task 4.5 리뷰 MAJOR.) + // 원본 + per-alias sentinel({id}#alias#N) embedding_records 를 명시 정리. + // 별칭 dense 벡터는 줄별 sentinel chunk_id 로 색인되며 chunks FK 가 없어 + // 자동 정리되지 않으므로 chunks 가 살아있는 동안 직접 지운다(안 하면 + // tombstone trigger 가 남긴 행이 누적). 정확 일치(|| '#alias')는 per-line + // sentinel 을 놓치므로(PR #195 MAJOR) `{id}#alias%` 프리픽스를 LIKE 로 매칭. + // 설계 spec 2026-05-30-dense-alias-vectors-design.md §3.5-2. (Task 4.5 리뷰 MAJOR.) conn.execute( "DELETE FROM embedding_records WHERE chunk_id IN \ (SELECT chunk_id FROM chunks WHERE doc_id IN \ - (SELECT doc_id FROM documents WHERE workspace_path = ?1 AND doc_id != ?2) \ - UNION SELECT chunk_id || '#alias' FROM chunks WHERE doc_id IN \ - (SELECT doc_id FROM documents WHERE workspace_path = ?1 AND doc_id != ?2))", + (SELECT doc_id FROM documents WHERE workspace_path = ?1 AND doc_id != ?2)) \ + OR EXISTS (SELECT 1 FROM chunks \ + WHERE chunks.doc_id IN \ + (SELECT doc_id FROM documents WHERE workspace_path = ?1 AND doc_id != ?2) \ + AND embedding_records.chunk_id LIKE chunks.chunk_id || '#alias%')", params![workspace_path, keep_doc_id], ) .map_err(StoreError::from)?; @@ -642,15 +646,19 @@ pub(crate) fn purge_orphan_at_workspace_path( }; // CASCADE 제거(V011) 대체: 이 asset 의 문서 chunk 임베딩 레코드를 명시 정리. - // 원본 + sentinel({id}#alias) 둘 다. 별칭 dense 벡터는 chunks FK 가 없어 - // documents→chunks CASCADE 로 자동 정리되지 않으므로 chunks 가 살아있는 동안 - // 직접 지운다. 설계 spec 2026-05-30-dense-alias-vectors-design.md §3.5-2. + // 원본 + per-alias sentinel({id}#alias#N) 모두. 별칭 dense 벡터는 줄별 + // sentinel chunk_id 로 색인되며 chunks FK 가 없어 documents→chunks CASCADE 로 + // 자동 정리되지 않으므로 chunks 가 살아있는 동안 직접 지운다. 정확 + // 일치(|| '#alias')는 per-line sentinel 을 놓치므로(PR #195 MAJOR) `{id}#alias%` + // 프리픽스를 LIKE 로 매칭. 설계 spec 2026-05-30-dense-alias-vectors-design.md §3.5-2. conn.execute( "DELETE FROM embedding_records WHERE chunk_id IN \ (SELECT chunk_id FROM chunks WHERE doc_id IN \ - (SELECT doc_id FROM documents WHERE asset_id = ?1) \ - UNION SELECT chunk_id || '#alias' FROM chunks WHERE doc_id IN \ - (SELECT doc_id FROM documents WHERE asset_id = ?1))", + (SELECT doc_id FROM documents WHERE asset_id = ?1)) \ + OR EXISTS (SELECT 1 FROM chunks \ + WHERE chunks.doc_id IN \ + (SELECT doc_id FROM documents WHERE asset_id = ?1) \ + AND embedding_records.chunk_id LIKE chunks.chunk_id || '#alias%')", params![stale_asset_id], ) .map_err(StoreError::from)?; @@ -734,13 +742,17 @@ pub fn purge_deleted_workspace_path( drop(stmt); // 1b. CASCADE 제거(V011) 대체: chunk 임베딩 레코드를 명시 정리(원본 + - // sentinel {id}#alias). 별칭 dense 벡터는 chunks FK 가 없어 - // documents→chunks CASCADE 로 자동 정리되지 않는다. chunks 가 + // per-alias sentinel {id}#alias#N). 별칭 dense 벡터는 줄별 sentinel + // chunk_id 로 색인되며 chunks FK 가 없어 documents→chunks CASCADE 로 + // 자동 정리되지 않는다. 정확 일치(|| '#alias')는 per-line sentinel 을 + // 놓치므로(PR #195 MAJOR) `{id}#alias%` 프리픽스를 LIKE 로 매칭. chunks 가 // 살아있는 동안(2번 DELETE 직전) 실행. spec §3.5-2. conn.execute( "DELETE FROM embedding_records WHERE chunk_id IN \ - (SELECT chunk_id FROM chunks WHERE doc_id = ?1 \ - UNION SELECT chunk_id || '#alias' FROM chunks WHERE doc_id = ?1)", + (SELECT chunk_id FROM chunks WHERE doc_id = ?1) \ + OR EXISTS (SELECT 1 FROM chunks \ + WHERE chunks.doc_id = ?1 \ + AND embedding_records.chunk_id LIKE chunks.chunk_id || '#alias%')", rusqlite::params![doc_id], ) .map_err(StoreError::from)?; diff --git a/crates/kebab-store-sqlite/tests/embedding_records_fk.rs b/crates/kebab-store-sqlite/tests/embedding_records_fk.rs index a739551..896e73b 100644 --- a/crates/kebab-store-sqlite/tests/embedding_records_fk.rs +++ b/crates/kebab-store-sqlite/tests/embedding_records_fk.rs @@ -83,6 +83,19 @@ fn embed_count(store: &SqliteStore, chunk_id: &str) -> i64 { .unwrap() } +/// Count embedding rows whose chunk_id begins with `prefix`. Used to +/// assert that *every* per-alias sentinel (`{id}#alias#0`, `#alias#1`, …) +/// is gone, not just the legacy single `{id}#alias`. +fn embed_count_prefix(store: &SqliteStore, prefix: &str) -> i64 { + let conn = store.read_conn(); + conn.query_row( + "SELECT COUNT(*) FROM embedding_records WHERE chunk_id LIKE ? || '%'", + params![prefix], + |r| r.get::<_, i64>(0), + ) + .unwrap() +} + /// V011 후 sentinel chunk_id(`chunks` 에 없는 id)로 `embedding_records` 를 /// INSERT 해도 FK 위반 없이 성공해야 한다. #[test] @@ -207,3 +220,121 @@ fn purge_except_doc_id_cleans_original_and_sentinel_embeddings() { "purge_except_doc_id: sentinel embedding_records 정리 (chunks FK 없음 → 명시 DELETE)" ); } + +/// Seed body chunk + its per-line alias sentinel embedding rows +/// (`{c1}#alias#0`, `{c1}#alias#1`) plus the legacy `{c1}#alias`. Returns +/// the chunk's bare id. Used by the PR #195 per-alias orphan regressions. +fn seed_body_and_alias_sentinels(store: &SqliteStore, c1: &str) { + seed_chunk(store, c1); + store + .put_embedding_records_pending(&[ + embed_row("e_orig_000000000000000000000000000", c1), + embed_row("e_alias0_00000000000000000000000", &format!("{c1}#alias#0")), + embed_row("e_alias1_00000000000000000000000", &format!("{c1}#alias#1")), + // legacy single sentinel (docs ingested before per-line split). + embed_row("e_alias_legacy_00000000000000000", &format!("{c1}#alias")), + ]) + .unwrap(); + store + .mark_embedding_records_committed(&[ + "e_orig_000000000000000000000000000".to_string(), + "e_alias0_00000000000000000000000".to_string(), + "e_alias1_00000000000000000000000".to_string(), + "e_alias_legacy_00000000000000000".to_string(), + ]) + .unwrap(); +} + +/// PR #195 MAJOR regression: alias dense 벡터가 단일 `{id}#alias` 에서 줄별 +/// `{id}#alias#0`, `#alias#1`, … 로 바뀐 뒤, `put_chunks` 재인제스트 시 명시 +/// DELETE 가 본문 + **모든** per-alias sentinel embedding_records 를 정리해야 +/// 한다. 이전 코드(`|| '#alias'` 정확 일치)는 `#alias#N` 을 놓쳐 누수했다. +#[test] +fn put_chunks_cleans_per_alias_sentinel_embeddings() { + let tmp = TempDir::new().unwrap(); + let store = open_store(&tmp); + let c1 = "11111111111111111111111111111111"; + seed_body_and_alias_sentinels(&store, c1); + assert_eq!(embed_count(&store, c1), 1); + assert_eq!(embed_count_prefix(&store, &format!("{c1}#alias")), 3); + + let doc_id = DocumentId(DOC_ID.to_string()); + let chunk = Chunk { + chunk_id: ChunkId(c1.to_string()), + doc_id: doc_id.clone(), + block_ids: Vec::new(), + text: "hi".to_string(), + heading_path: Vec::new(), + source_spans: Vec::new(), + token_estimate: 1, + chunker_version: ChunkerVersion("v1".to_string()), + policy_hash: "h".to_string(), + tokenized_korean_text: None, + aliases: None, + }; + store.put_chunks(&doc_id, std::slice::from_ref(&chunk)).unwrap(); + + assert_eq!( + embed_count(&store, c1), + 0, + "본문 embedding_records 정리 (CASCADE 대체)" + ); + assert_eq!( + embed_count_prefix(&store, &format!("{c1}#alias")), + 0, + "모든 per-alias sentinel embedding_records 정리 (#alias#N + legacy #alias)" + ); +} + +/// PR #195 MAJOR regression: parser-bump 재인제스트 경로 +/// (`purge_document_at_workspace_path_except_doc_id`)도 본문 + 모든 per-alias +/// sentinel embedding_records 를 정리해야 한다. +#[test] +fn purge_except_doc_id_cleans_per_alias_sentinel_embeddings() { + let tmp = TempDir::new().unwrap(); + let store = open_store(&tmp); + let c1 = "11111111111111111111111111111111"; + seed_body_and_alias_sentinels(&store, c1); // doc DOC_ID @ 'x.md' + assert_eq!(embed_count(&store, c1), 1); + assert_eq!(embed_count_prefix(&store, &format!("{c1}#alias")), 3); + + store + .purge_document_at_workspace_path_except_doc_id("x.md", "0000000000000000000000000000ffff") + .unwrap(); + + assert_eq!(embed_count(&store, c1), 0, "본문 정리"); + assert_eq!( + embed_count_prefix(&store, &format!("{c1}#alias")), + 0, + "모든 per-alias sentinel 정리 (#alias#N + legacy #alias)" + ); +} + +/// PR #195 MAJOR regression: 파일 삭제 sweep 경로 +/// (`purge_deleted_workspace_path`)도 본문 + 모든 per-alias sentinel +/// embedding_records 를 정리해야 한다. +#[test] +fn purge_deleted_workspace_path_cleans_per_alias_sentinel_embeddings() { + let tmp = TempDir::new().unwrap(); + let store = open_store(&tmp); + let c1 = "11111111111111111111111111111111"; + seed_body_and_alias_sentinels(&store, c1); // doc DOC_ID @ 'x.md' + assert_eq!(embed_count(&store, c1), 1); + assert_eq!(embed_count_prefix(&store, &format!("{c1}#alias")), 3); + + let returned = kebab_store_sqlite::purge_deleted_workspace_path( + &store, + &kebab_core::WorkspacePath("x.md".to_string()), + ) + .unwrap(); + // 반환된 body chunk_ids 는 kebab-app 이 LanceDB 측 별칭 sentinel 까지 + // 삭제하는 데 쓰인다(`alias_sentinel_ids_to_delete`). 본문 1개. + assert_eq!(returned.len(), 1); + + assert_eq!(embed_count(&store, c1), 0, "본문 정리"); + assert_eq!( + embed_count_prefix(&store, &format!("{c1}#alias")), + 0, + "모든 per-alias sentinel 정리 (#alias#N + legacy #alias)" + ); +}