fix(expansion): per-alias sentinel orphan cleanup + 캐시 견고성 (PR #195 리뷰)

MAJOR: 별칭 dense 벡터의 chunk_id 가 레거시 단일 `{id}#alias` 에서 줄별
`{id}#alias#0`, `#alias#1`, … 로 바뀌었으나 orphan cleanup 이 단일 sentinel
하나만 삭제해 `#alias#N` 벡터가 LanceDB / embedding_records 에 누수됐다.

- kebab-app: `alias_sentinel_ids_to_delete` 헬퍼 추가(접근법 A) — 본문 +
  legacy `{id}#alias` + `{id}#alias#0`..`{id}#alias#{max-1}` 를 모두 delete-set
  에 포함. max=expansion.max_aliases_per_chunk(= parse_aliases 의 하드 cap)와
  일치. parser-bump / edited-asset / deleted-file 세 LanceDB cleanup 경로 모두
  이 헬퍼를 사용.
- kebab-store-sqlite: embedding_records 명시 DELETE 4 경로(put_chunks /
  purge_*_except_doc_id / purge_orphan_at_workspace_path /
  purge_deleted_workspace_path)를 정확 일치(`|| '#alias'`)에서 `{id}#alias%`
  프리픽스 LIKE 로 전환. 본문 chunk_id 는 32자 hex 라 LIKE 와일드카드 없음.

MINOR 1: alias 캐시 히트 시 비-UTF8 payload 를 미스로 강등(재생성 분기로)
— embedding 경로의 decode-실패→미스 강등과 동작 일치.
MINOR 2: embedding version_key 맨 앞에 kind 토큰("doc") 추가 — 임베더가
kind 별 프리픽스를 붙이므로 미래에 query 임베딩이 같은 캐시를 타도 충돌 방지.

회귀 테스트:
- kebab-app: alias_sentinel_ids_to_delete 단위 테스트 2건.
- kebab-store-sqlite: per-alias sentinel embedding_records 가 세 cleanup
  경로 모두에서 사라지는지 핀하는 통합 테스트 3건.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 09:14:34 +00:00
parent a8fd76499c
commit e9b520216e
4 changed files with 297 additions and 44 deletions

View File

@@ -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)?;

View File

@@ -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)?;

View File

@@ -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)"
);
}