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:
@@ -1349,9 +1349,17 @@ fn ingest_one_asset(
|
|||||||
&chunk.text,
|
&chunk.text,
|
||||||
&alias_version_key,
|
&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 호출 없음.
|
// 히트: 저장된 별칭(UTF-8) 재사용. LLM 호출 없음.
|
||||||
chunk.aliases = String::from_utf8(payload).ok();
|
chunk.aliases = Some(aliases);
|
||||||
alias_cache_hit += 1;
|
alias_cache_hit += 1;
|
||||||
alias_touch_keys.push(key);
|
alias_touch_keys.push(key);
|
||||||
} else if crate::expansion::is_nav_boilerplate(chunk) {
|
} else if crate::expansion::is_nav_boilerplate(chunk) {
|
||||||
@@ -1411,10 +1419,15 @@ fn ingest_one_asset(
|
|||||||
let model_id = emb.model_id();
|
let model_id = emb.model_id();
|
||||||
let model_version = emb.model_version();
|
let model_version = emb.model_version();
|
||||||
let dimensions = emb.dimensions();
|
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 → 같은 캐시).
|
// 본문 청크 + 별칭 문자열 양쪽이 같은 메커니즘(같은 text → 같은 캐시).
|
||||||
|
// kind 토큰("doc") 을 맨 앞에 둔다: 임베더가 kind 별 프리픽스
|
||||||
|
// (Document=`passage:`, Query=`query:`)를 붙여 같은 text 라도 벡터가
|
||||||
|
// 달라지므로, 미래에 query 임베딩이 같은 캐시를 타도 충돌하지 않도록
|
||||||
|
// 방어적으로 분리(현재 ingest 는 Document 고정이라 live 버그 없음).
|
||||||
let emb_version_key =
|
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<String> = Vec::new();
|
let mut emb_touch_keys: Vec<String> = Vec::new();
|
||||||
// 본문 청크 text 로 캐시 조회 → 미스만 embed → 원래 순서로 합침.
|
// 본문 청크 text 로 캐시 조회 → 미스만 embed → 원래 순서로 합침.
|
||||||
let body_texts: Vec<&str> = chunks.iter().map(|c| c.text.as_str()).collect();
|
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);
|
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<kebab_core::ChunkId> {
|
||||||
|
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
|
/// 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
|
/// 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`.
|
/// (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 !stale.is_empty() {
|
||||||
if let Some(vec_store) = app.vector().context("App::vector")? {
|
if let Some(vec_store) = app.vector().context("App::vector")? {
|
||||||
use kebab_core::VectorStore as _;
|
use kebab_core::VectorStore as _;
|
||||||
// sentinel 별칭 벡터(`{id}#alias`)는 SQLite chunks 에 없어 stale 에
|
// per-alias sentinel 벡터(`{id}#alias#N`)는 SQLite chunks 에 없어
|
||||||
// 안 잡힌다 → 명시적으로 함께 삭제(orphan 누적 방지).
|
// stale 에 안 잡힌다 → 본문 + 모든 별칭 sentinel 을 명시적으로 함께
|
||||||
let mut to_delete = stale.clone();
|
// 삭제(orphan 누적 방지, PR #195 MAJOR).
|
||||||
to_delete.extend(stale.iter().map(|id| {
|
let to_delete = alias_sentinel_ids_to_delete(
|
||||||
kebab_core::ChunkId(format!("{}{}", id.0, kebab_core::ALIAS_SUFFIX))
|
&stale,
|
||||||
}));
|
app.config.ingest.expansion.max_aliases_per_chunk,
|
||||||
|
);
|
||||||
vec_store
|
vec_store
|
||||||
.delete_by_chunk_ids(&to_delete)
|
.delete_by_chunk_ids(&to_delete)
|
||||||
.context("VectorStore::delete_by_chunk_ids (parser-bump orphans)")?;
|
.context("VectorStore::delete_by_chunk_ids (parser-bump orphans)")?;
|
||||||
@@ -1935,12 +1992,13 @@ fn purge_vector_orphans_for_workspace_path(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
use kebab_core::VectorStore as _;
|
use kebab_core::VectorStore as _;
|
||||||
// sentinel 별칭 벡터(`{id}#alias`)는 SQLite chunks 에 없어 stale 에
|
// per-alias sentinel 벡터(`{id}#alias#N`)는 SQLite chunks 에 없어 stale 에
|
||||||
// 안 잡힌다 → 명시적으로 함께 삭제(orphan 누적 방지).
|
// 안 잡힌다 → 본문 + 모든 별칭 sentinel 을 명시적으로 함께 삭제(orphan
|
||||||
let mut to_delete = stale.clone();
|
// 누적 방지, PR #195 MAJOR).
|
||||||
to_delete.extend(stale.iter().map(|id| {
|
let to_delete = alias_sentinel_ids_to_delete(
|
||||||
kebab_core::ChunkId(format!("{}{}", id.0, kebab_core::ALIAS_SUFFIX))
|
&stale,
|
||||||
}));
|
app.config.ingest.expansion.max_aliases_per_chunk,
|
||||||
|
);
|
||||||
vec_store
|
vec_store
|
||||||
.delete_by_chunk_ids(&to_delete)
|
.delete_by_chunk_ids(&to_delete)
|
||||||
.context("VectorStore::delete_by_chunk_ids (orphan vector cleanup)")?;
|
.context("VectorStore::delete_by_chunk_ids (orphan vector cleanup)")?;
|
||||||
@@ -2042,12 +2100,13 @@ fn sweep_deleted_files(
|
|||||||
if let Some(vec) = vector_store {
|
if let Some(vec) = vector_store {
|
||||||
if !chunk_ids.is_empty() {
|
if !chunk_ids.is_empty() {
|
||||||
use kebab_core::VectorStore as _;
|
use kebab_core::VectorStore as _;
|
||||||
// sentinel 별칭 벡터(`{id}#alias`)는 SQLite chunks 에 없어
|
// per-alias sentinel 벡터(`{id}#alias#N`)는 SQLite chunks 에 없어
|
||||||
// chunk_ids 에 안 잡힌다 → 명시적으로 함께 삭제(orphan 누적 방지).
|
// chunk_ids 에 안 잡힌다 → 본문 + 모든 별칭 sentinel 을 명시적으로
|
||||||
let mut to_delete = chunk_ids.clone();
|
// 함께 삭제(orphan 누적 방지, PR #195 MAJOR).
|
||||||
to_delete.extend(chunk_ids.iter().map(|id| {
|
let to_delete = alias_sentinel_ids_to_delete(
|
||||||
kebab_core::ChunkId(format!("{}{}", id.0, kebab_core::ALIAS_SUFFIX))
|
&chunk_ids,
|
||||||
}));
|
app.config.ingest.expansion.max_aliases_per_chunk,
|
||||||
|
);
|
||||||
if let Err(e) = vec.delete_by_chunk_ids(&to_delete) {
|
if let Err(e) = vec.delete_by_chunk_ids(&to_delete) {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "kebab-app",
|
target: "kebab-app",
|
||||||
@@ -3309,3 +3368,49 @@ fn check_kebabignore_match(
|
|||||||
.matched(source_path, source_path.is_dir())
|
.matched(source_path, source_path.is_dir())
|
||||||
.is_ignore()
|
.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 만");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -99,14 +99,19 @@ impl kebab_core::DocumentStore for SqliteStore {
|
|||||||
let mut conn = self.lock_conn();
|
let mut conn = self.lock_conn();
|
||||||
let tx = conn.transaction().map_err(StoreError::from)?;
|
let tx = conn.transaction().map_err(StoreError::from)?;
|
||||||
// CASCADE 제거(V011) 대체: 이 doc 의 chunk 임베딩 레코드를 명시 정리.
|
// CASCADE 제거(V011) 대체: 이 doc 의 chunk 임베딩 레코드를 명시 정리.
|
||||||
// 원본 + sentinel({id}#alias) 둘 다. 별칭 dense 벡터(sentinel chunk_id)는
|
// 원본 + per-alias sentinel({id}#alias#N) 모두. 별칭 dense 벡터는 줄별
|
||||||
// chunks FK 가 없어 CASCADE 로 자동 정리되지 않으므로 여기서 직접 지운다.
|
// sentinel chunk_id(`{orig}#alias#0`, `#alias#1`, …)로 색인되는데 chunks
|
||||||
// chunks 행이 살아있는 동안(아래 DELETE FROM 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.
|
// chunk_id 를 본다. 설계 spec 2026-05-30-dense-alias-vectors-design.md §3.5-2.
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"DELETE FROM embedding_records WHERE chunk_id IN \
|
"DELETE FROM embedding_records WHERE chunk_id IN \
|
||||||
(SELECT chunk_id FROM chunks WHERE doc_id = ?1 \
|
(SELECT chunk_id FROM chunks WHERE doc_id = ?1) \
|
||||||
UNION SELECT chunk_id || '#alias' 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],
|
params![doc.0],
|
||||||
)
|
)
|
||||||
.map_err(StoreError::from)?;
|
.map_err(StoreError::from)?;
|
||||||
|
|||||||
@@ -571,16 +571,20 @@ impl SqliteStore {
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let conn = self.lock_conn();
|
let conn = self.lock_conn();
|
||||||
// CASCADE 제거(V011) 대체: documents→chunks CASCADE 가 chunks 를 지우기 전에
|
// CASCADE 제거(V011) 대체: documents→chunks CASCADE 가 chunks 를 지우기 전에
|
||||||
// 원본 + sentinel({id}#alias) embedding_records 를 명시 정리. 별칭 dense
|
// 원본 + per-alias sentinel({id}#alias#N) embedding_records 를 명시 정리.
|
||||||
// 벡터는 chunks FK 가 없어 자동 정리되지 않으므로 chunks 가 살아있는 동안
|
// 별칭 dense 벡터는 줄별 sentinel chunk_id 로 색인되며 chunks FK 가 없어
|
||||||
// 직접 지운다(안 하면 tombstone trigger 가 남긴 행이 누적). 설계 spec
|
// 자동 정리되지 않으므로 chunks 가 살아있는 동안 직접 지운다(안 하면
|
||||||
// 2026-05-30-dense-alias-vectors-design.md §3.5-2. (Task 4.5 리뷰 MAJOR.)
|
// 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(
|
conn.execute(
|
||||||
"DELETE FROM embedding_records WHERE chunk_id IN \
|
"DELETE FROM embedding_records WHERE chunk_id IN \
|
||||||
(SELECT chunk_id FROM chunks WHERE doc_id IN \
|
(SELECT chunk_id 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)) \
|
||||||
UNION SELECT chunk_id || '#alias' FROM chunks WHERE doc_id IN \
|
OR EXISTS (SELECT 1 FROM chunks \
|
||||||
(SELECT doc_id FROM documents WHERE workspace_path = ?1 AND doc_id != ?2))",
|
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],
|
params![workspace_path, keep_doc_id],
|
||||||
)
|
)
|
||||||
.map_err(StoreError::from)?;
|
.map_err(StoreError::from)?;
|
||||||
@@ -642,15 +646,19 @@ pub(crate) fn purge_orphan_at_workspace_path(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// CASCADE 제거(V011) 대체: 이 asset 의 문서 chunk 임베딩 레코드를 명시 정리.
|
// CASCADE 제거(V011) 대체: 이 asset 의 문서 chunk 임베딩 레코드를 명시 정리.
|
||||||
// 원본 + sentinel({id}#alias) 둘 다. 별칭 dense 벡터는 chunks FK 가 없어
|
// 원본 + per-alias sentinel({id}#alias#N) 모두. 별칭 dense 벡터는 줄별
|
||||||
// documents→chunks CASCADE 로 자동 정리되지 않으므로 chunks 가 살아있는 동안
|
// sentinel chunk_id 로 색인되며 chunks FK 가 없어 documents→chunks CASCADE 로
|
||||||
// 직접 지운다. 설계 spec 2026-05-30-dense-alias-vectors-design.md §3.5-2.
|
// 자동 정리되지 않으므로 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(
|
conn.execute(
|
||||||
"DELETE FROM embedding_records WHERE chunk_id IN \
|
"DELETE FROM embedding_records WHERE chunk_id IN \
|
||||||
(SELECT chunk_id FROM chunks WHERE doc_id IN \
|
(SELECT chunk_id FROM chunks WHERE doc_id IN \
|
||||||
(SELECT doc_id FROM documents WHERE asset_id = ?1) \
|
(SELECT doc_id FROM documents WHERE asset_id = ?1)) \
|
||||||
UNION SELECT chunk_id || '#alias' FROM chunks WHERE doc_id IN \
|
OR EXISTS (SELECT 1 FROM chunks \
|
||||||
(SELECT doc_id FROM documents WHERE asset_id = ?1))",
|
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],
|
params![stale_asset_id],
|
||||||
)
|
)
|
||||||
.map_err(StoreError::from)?;
|
.map_err(StoreError::from)?;
|
||||||
@@ -734,13 +742,17 @@ pub fn purge_deleted_workspace_path(
|
|||||||
drop(stmt);
|
drop(stmt);
|
||||||
|
|
||||||
// 1b. CASCADE 제거(V011) 대체: chunk 임베딩 레코드를 명시 정리(원본 +
|
// 1b. CASCADE 제거(V011) 대체: chunk 임베딩 레코드를 명시 정리(원본 +
|
||||||
// sentinel {id}#alias). 별칭 dense 벡터는 chunks FK 가 없어
|
// per-alias sentinel {id}#alias#N). 별칭 dense 벡터는 줄별 sentinel
|
||||||
// documents→chunks CASCADE 로 자동 정리되지 않는다. chunks 가
|
// chunk_id 로 색인되며 chunks FK 가 없어 documents→chunks CASCADE 로
|
||||||
|
// 자동 정리되지 않는다. 정확 일치(|| '#alias')는 per-line sentinel 을
|
||||||
|
// 놓치므로(PR #195 MAJOR) `{id}#alias%` 프리픽스를 LIKE 로 매칭. chunks 가
|
||||||
// 살아있는 동안(2번 DELETE 직전) 실행. spec §3.5-2.
|
// 살아있는 동안(2번 DELETE 직전) 실행. spec §3.5-2.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM embedding_records WHERE chunk_id IN \
|
"DELETE FROM embedding_records WHERE chunk_id IN \
|
||||||
(SELECT chunk_id FROM chunks WHERE doc_id = ?1 \
|
(SELECT chunk_id FROM chunks WHERE doc_id = ?1) \
|
||||||
UNION SELECT chunk_id || '#alias' 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],
|
rusqlite::params![doc_id],
|
||||||
)
|
)
|
||||||
.map_err(StoreError::from)?;
|
.map_err(StoreError::from)?;
|
||||||
|
|||||||
@@ -83,6 +83,19 @@ fn embed_count(store: &SqliteStore, chunk_id: &str) -> i64 {
|
|||||||
.unwrap()
|
.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` 를
|
/// V011 후 sentinel chunk_id(`chunks` 에 없는 id)로 `embedding_records` 를
|
||||||
/// INSERT 해도 FK 위반 없이 성공해야 한다.
|
/// INSERT 해도 FK 위반 없이 성공해야 한다.
|
||||||
#[test]
|
#[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)"
|
"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)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user