Files
kebab/docs/superpowers/specs/2026-05-31-derivation-cache-design.md
altair823 88c5b83dea docs: derivation-cache spec/handoff 독자 관점 보강
PR #195 구현(e9b5202) 기준으로 빠졌던 디테일 보강:
- chunk_id(위치 기반 벡터 식별자) vs cache_key(내용 해시 조회 키) 구분 callout
- §7 호환성/마이그레이션 신설: 본문 재색인 불필요, V012 가산이나 binary 교체 필요,
  별칭 sentinel 묶음→개별 변경의 기존 KB 영향(레거시 호환)
- version_key 에 kind 토큰("doc|") 반영, orphan sentinel cleanup(LIKE prefix) 명시
- embed_with_cache 순서 보존 불변, 별칭 개별 벡터 근거(희석 13/18→16/18)
- 정정: derivation_cache_gc 는 메서드만 존재하고 미연결(캐시 현재 무한 누적, 후속)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 10:25:00 +00:00

263 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 내용 해시 기반 파생물 캐시 (Derivation Cache)
> 작성 2026-05-31. 비용 큰 ingest 파생물(embedding 벡터 / LLM 별칭 / 한국어 형태소)을
> 청크 **내용 해시** 키로 캐싱해, 문서 갱신·재색인 시 변경되지 않은 청크의 재계산을 없앤다.
## 1. 문제
현재 kebab ingest 는 **doc 단위 skip**(`try_skip_unchanged`, lib.rs:894)만 한다. 변경된
문서는 모든 청크를 재파싱·재청킹·재임베딩·재별칭한다(`put_chunks` 가 doc 의 청크를
통째 DELETE 후 재INSERT — documents.rs:113, embedding/alias/tokens 무조건 재계산).
측정 증거: 정답 18개 문서의 별칭 재생성에 **2.5시간**(gemma LLM, doc 당 ~39청크).
embedding 도 전체 재계산. 문서 한 줄만 고쳐도 동일 비용이 든다. 실사용(나무위키
~2천 문서) 시 재색인이 비현실적으로 느리다.
`chunk_id``id_for_block``ordinal + span`(ids.rs) 때문에 **위치 기반**이라,
chunk_id 를 캐시 키로 쓰면 중간 수정 시 뒤 청크가 전부 무효화된다 → 캐시 키는
**청크 text 의 내용 해시**여야 위치와 무관하게 재사용된다.
> **`chunk_id` vs `cache_key` — 둘은 완전히 별개다(가장 혼동하는 지점).**
> - **`chunk_id`** 는 LanceDB 벡터 / SQLite chunk row 의 **식별자**다. `id_for_block`
> 이 `ordinal + source_span`(ids.rs) 을 canonical-JSON+blake3 한 **위치 기반** 해시라,
> 문서 중간이 밀리면 뒤 청크의 chunk_id 가 바뀐다. 이 작업은 **chunk_id 생성 방식을
> 전혀 바꾸지 않는다**(frozen 동작 — §2 비목표).
> - **`cache_key`** 는 `derivation_cache` 테이블의 **조회 키**다. `chunk.text` 의 NFC
> 정규화 **내용 해시** + kind + version_key 로만 만든다(위치·chunk_id·문서 무관).
> - 즉 위치가 밀려 chunk_id 가 바뀌어도, 내용이 같은 청크는 같은 cache_key 로 캐시
> 히트한다. chunk_id 는 "이 벡터가 어디에 속하나", cache_key 는 "이 내용을 전에
> 계산했나" — 묻는 질문이 다르다. 별칭 sentinel chunk_id(`{orig}#alias#N`) 역시
> 벡터 식별자일 뿐 cache_key 와 무관하며, 별칭 dense 벡터의 cache_key 는 **별칭
> 문자열 자체**의 embedding 내용 해시다(§3.4).
구체 예: 문서 중간에 헤딩/내용이 삽입되면 뒤 청크들의 ordinal/span 이 밀려
chunk_id 가 바뀌고 `put_chunks` 가 그 문서의 row 를 **전부 재작성**한다(싼 DB
write — chunk row + LanceDB 벡터 재기록). 그러나 내용이 변하지 않은 청크는
내용 해시 cache_key 가 동일하므로 embedding·별칭 캐시가 **히트**한다 → 비싼
재계산(e5 forward / LLM)은 **0**, 새로 삽입된 청크만 실제로 계산된다. 즉
"row 재작성(싸다)"과 "compute 재실행(비싸다)"을 분리해, 위치가 밀려도 compute
는 변경분에만 든다. 이것이 chunk_id 를 위치 기반으로 두면서도(diff 불필요)
재색인 비용을 없애는 핵심이다.
## 2. 목표 / 비목표
**목표**
- ingest 시 청크별로 (embedding, alias, korean_tokens) 를 내용 해시로 캐싱.
- 캐시 히트 시 비싼 계산(embedder.embed / LLM.generate / lindera tokenize)을 건너뜀.
- 모델/프롬프트/토크나이저 버전을 캐시 키에 포함 → §9 version cascade 와 정합
(버전 변경 시 자동 cache miss → 재계산).
- 별칭뿐 아니라 비용 큰 파생물 전반에 동일 메커니즘.
**비목표**
- 청크 단위 diff (put_chunks 의 전체 DELETE/INSERT 는 그대로 둔다 — chunks 행 재생성은
싸다). 캐시는 *계산*만 절감한다.
- chunk_id 생성 방식 변경 (위치 기반 유지 — frozen 동작).
- doc 단위 skip(`try_skip_unchanged`) 변경 (그대로, 캐시와 독립).
## 3. 설계
### 3.1 캐시 키
```
cache_key = blake3_hex( kind || 0x00 || text_blake3 || 0x00 || version_key )[:32]
```
- `text_blake3` = blake3(chunk.text 의 NFC 정규화 UTF-8 bytes).
- `kind` ∈ { "embedding", "alias", "korean_tokens" }.
- `version_key` (kind 별, 버전 변경 시 캐시 무효화) — **구현 기준(e9b5202, lib.rs)**:
- embedding: `doc|{model_id}|{model_version}|{dimensions}` — 맨 앞의 **kind 토큰
`doc`** 은 PR #195 리뷰 반영. 임베더는 호출 kind 별 프리픽스(Document=`passage:`,
Query=`query:`)를 붙여 *같은 text* 라도 다른 벡터를 만든다. 현재 ingest 는 Document
고정이라 live 버그는 없지만, 미래에 query 임베딩이 같은 캐시를 타도 충돌하지 않도록
방어적으로 분리한다(현재 토큰은 `doc` 상수).
- alias: `{prompt_version}|{max_aliases_per_chunk}|{model}` (model="" 면 LLM 기본).
구현은 `expansion::PROMPT_VERSION`(현재 `"expansion-v1"`) + `max_aliases_per_chunk`
+ `exp.model``|` 로 join.
- korean_tokens: `{tokenizer_version}` (현재 lindera 고정 → 상수 "lindera-v1";
추후 토크나이저 교체 시 bump). **미구현(보류)** — embedding/LLM 이 주 비용이라 미적용.
text 내용이 같고 버전이 같으면 문서·위치·chunk_id 와 무관하게 동일 cache_key.
실제 키 함수는 `kebab-core::derivation_cache_key(kind, text, version_key)`
(derivation.rs): `blake3(kind ‖ 0x00 ‖ blake3(NFC(text)) ‖ 0x00 ‖ version_key)`
hex 앞 32자. `0x00` 구분자는 hex 다이제스트에 못 나오므로 kind/version 경계가 절대
섞이지 않는다.
### 3.2 저장소 — SQLite `derivation_cache` 테이블
신규 마이그레이션 `V012__derivation_cache.sql`:
```sql
CREATE TABLE derivation_cache (
cache_key TEXT PRIMARY KEY, -- §3.1
kind TEXT NOT NULL, -- 'embedding' | 'alias' | 'korean_tokens'
payload BLOB NOT NULL, -- kind 별 인코딩 (§3.3)
created_at TEXT NOT NULL,
last_used_at TEXT NOT NULL -- LRU 정리용
);
CREATE INDEX idx_dcache_kind ON derivation_cache(kind);
CREATE INDEX idx_dcache_last_used ON derivation_cache(last_used_at);
```
- `corpus_revision` 은 bump 하지 않는다 — 캐시 테이블 추가는 기존 데이터 무효화가
아니다(순수 가산). 단 V012 자체는 schema migration 이라 release bump 트리거(§Versioning).
### 3.3 payload 인코딩
- embedding: `dimensions × f32` little-endian 바이트열 (1024×4 = 4096 B/청크).
`derivation_payload::{encode,decode}_embedding`(kebab-app). 디코드는 길이가 4의
배수가 아니면(손상) `None` → 미스 강등.
- alias: 별칭 **묶음** 문자열의 UTF-8 (현행 `chunk.aliases` 와 동일 형식 — 줄바꿈 join).
즉 캐시 payload 는 LLM 이 청크당 생성한 별칭 *전체 묶음*이다. 이후 임베딩 단계에서
이 묶음을 줄 단위로 쪼개 개별 벡터로 색인하는 것(§3.4)과는 별개 — alias kind 캐시는
"이 청크 text 의 별칭 묶음을 LLM 으로 이미 뽑았나"만 기억한다.
- korean_tokens: 토큰 문자열 UTF-8. (미구현 — §3.1 참고.)
### 3.4 ingest 흐름 변경 (kebab-app lib.rs)
각 파생물 생성 직전에 캐시를 조회한다. 의사코드(e9b5202 lib.rs 기준):
```rust
// --- 별칭 (lib.rs ~1346) ---
if expansion.enabled {
for chunk in &mut chunks {
let key = cache_key("alias", &chunk.text, &alias_version_key);
if let Some(p) = cache.get(&key)? { // 히트 (비-UTF8 이면 None → 미스 강등)
chunk.aliases = Some(String::from_utf8(p)?);
} else if is_nav_boilerplate(chunk) { // (기존 skip 규칙 유지)
chunk.aliases = None; // 캐시에 넣지 않음(None 표현 불가)
} else { // 미스 → LLM
chunk.aliases = generator.generate(chunk);
if let Some(a) = &chunk.aliases { cache.put(&key, "alias", a.as_bytes())?; }
}
}
}
// --- embedding (lib.rs ~1434, fn embed_with_cache) ---
// 1) 각 청크 cache_key 계산 → 히트/미스 분리 (out: Vec<Option<Vec<f32>>>, 입력당 1슬롯)
// 2) 미스 청크만 emb.embed(&miss_inputs) (배치 축소)
// 3) 미스 결과를 캐시에 put
// 4) 히트 vector(슬롯)와 미스 vector(miss_indices 의 슬롯)를 각자 제자리에 채운 뒤,
// 슬롯 순서대로 collect → **입력 texts 순서와 1:1 보존**(off-by-one 없음).
// 이후 chunks.iter().zip(vectors) 로 VectorRecord 를 만들므로 순서 보존이
// 정확성에 직결된다.
```
순서 보존(§3.4 핵심 불변): `embed_with_cache` 는 히트/미스를 분리 계산하되 결과를
입력 인덱스 슬롯(`out[i]`)에 되돌려 채우고 그 순서대로 반환한다. 따라서 히트·미스가
섞여도 반환 벡터의 i번째는 항상 입력 text 의 i번째에 대응한다 — 호출부의
`chunks.iter().zip(vectors)` 가 잘못된 청크에 벡터를 붙이는 off-by-one 이 발생하지 않는다.
핵심: **embedding 캐시는 청크 본문 + 별칭 문자열 양쪽에 적용**된다(같은 `embed_with_cache`
+ 같은 `emb_version_key` 재사용). 같은 text 면 본문이든 별칭이든 같은 cache_key 로 적중하므로,
별칭과 동일한 문자열이 본문에도 있으면 한쪽 계산이 다른 쪽을 워밍한다(별칭 LLM 캐시 +
별칭 임베딩 캐시 2중 절감).
별칭은 **묶음 1벡터가 아니라 줄별 개별 sentinel 벡터**로 색인한다(`{orig}#alias#0`,
`#alias#1`, …). 근거: 측정(handoff §3.1)에서 청크당 별칭 8개를 줄바꿈으로 묶어 한 벡터로
임베딩하면 평균화로 특정 표현이 **희석**되어 오히려 변형 일관성이 악화했다(13/18). 줄별
개별 벡터로 바꾸자 16/18 로 회복. 구현은 `chunk.aliases`(묶음)를 `\n` 으로 split·trim 한
뒤 빈 줄을 거르고, 각 줄을 같은 청크 안에서 0부터 인덱싱해 `{chunk_id}#alias#{i}`
VectorRecord 를 만든다. 별칭 dense 벡터의 cache_key 는 **별칭 줄 문자열 자체**의 embedding
내용 해시이므로(본문 chunk text 가 아님), 같은 별칭 문자열이 재등장하면 캐시 히트한다.
// korean_tokens: tokenize 직전 cache 조회 + 미스만 lindera 호출 — **미구현(보류)**.
### 3.5 무효화 / 정리
- **버전 무효화**: version_key 가 cache_key 에 포함 → model/prompt/tokenizer 버전이 bump
되면 새 키가 되어 자동 miss(옛 엔트리는 고아). §9 cascade 와 자동 정합.
- **캐시 엔트리 고아 정리(GC)**: `derivation_cache_gc(ttl_days)``last_used_at`
N일(설계 기본 30) 지난 엔트리를 삭제한다(`ttl_days <= 0` 은 통째 wipe 방지 no-op).
히트 키는 `derivation_cache_touch``last_used_at` 을 갱신해 GC 가 live 청크를 유지.
**구현 상태(e9b5202)**: `touch` 는 ingest 종료 시 호출되어 wired 되어 있으나, `gc`
store 메서드로 **존재만 하고 아직 어느 호출부(ingest/doctor)에도 연결되지 않았다**.
즉 현재 캐시는 무한 누적이며, TTL/LRU 자동 정리는 후속 작업이다. 행수 임계(기본 50만)
LRU 삭제도 미구현. 당장은 `kebab reset`(같은 sqlite 라 같이 비워짐)이 유일한 정리 경로.
- **stale 별칭 sentinel cleanup**(별개 — 캐시 GC 아니라 *벡터 스토어* 정리, PR #195 MAJOR):
별칭 dense 벡터는 본문 청크가 아니라 줄별 sentinel `{orig}#alias#N` 로 LanceDB·
embedding_records 에 색인된다. 이 sentinel chunk_id 는 SQLite `chunks` 에 **존재하지
않아** 재색인/문서삭제 시 stale-set SELECT 에 안 잡힌다. 정리 안 하면 옛 별칭 벡터가
남아 검색에 hit 하는 누수(리뷰 MAJOR). 따라서 재색인·삭제 경로가 본문 chunk_id 와 함께
별칭 sentinel 을 양쪽에서 명시 삭제한다:
- **LanceDB**: `alias_sentinel_ids_to_delete(body_ids, max_aliases_per_chunk)`
(lib.rs) 가 본문 id + legacy `{orig}#alias` + `{orig}#alias#0..max-1` 를 모두
생성해 `delete_by_chunk_ids` 의 exact-match `IN (...)` 로 삭제. `max`
`expansion.max_aliases_per_chunk`(parse_aliases 가 강제하는 상한)라 index ≥ max 는
절대 안 나오고, 안 쓰인 index 는 무해한 no-op.
- **SQLite** `embedding_records`: `chunk_id LIKE chunks.chunk_id || '#alias%'`
프리픽스 매칭(store.rs / documents.rs)으로 본문 chunk_id 의 모든 별칭 sentinel 행을
함께 정리. 정확 일치 `|| '#alias'` 는 per-line sentinel 을 놓치므로 `%` 프리픽스 필수.
이 두 정리는 **별칭 expansion 을 켰던 KB** 에만 해당하고, derivation_cache GC 와는
독립적이다(캐시는 계산 결과 보관, sentinel 정리는 벡터 식별자 누수 방지).
- 캐시는 **순수 성능 레이어** — 손상/삭제되어도 정확성 영향 없음(miss → 재계산).
`embed_with_cache` 는 길이 misalign payload 를, 별칭 경로는 비-UTF8 payload 를
**미스로 강등**해 재계산한다(잘못된 결과 대신 재계산, §3.6 정확성 우선).
`kebab reset` 시 함께 비워진다(같은 sqlite).
### 3.6 정확성 보장
- 캐시 히트가 재계산과 **동일 결과**임을 보장하는 근거: embedding/LLM/tokenize 는 같은
입력(text) + 같은 버전에서 결정적이어야 한다. embedding(e5, temperature 무관) ✓.
LLM 별칭은 `temperature=0.0, seed=0`(config) 라 사실상 결정적 — 단 LLM 비결정성은
"캐시가 첫 생성 결과를 고정"하는 것이라 오히려 일관성↑(허용).
- 버전 키 누락이 가장 위험한 실패 모드(옛 모델 벡터 재사용). version_key 에 모든
cascade 인자를 넣고, 테스트로 "버전 변경 → cache miss" 를 고정한다.
## 4. 컴포넌트 / 파일
- `migrations/V012__derivation_cache.sql` — 신규 테이블.
- `kebab-core``derivation_cache_key(kind, text, version_key) -> String` 순수 함수
(도메인, 다른 crate 의존 없음). text NFC 정규화 + blake3.
- `kebab-store-sqlite``SqliteStore` 의 inherent 메서드(derivation_cache.rs):
`derivation_cache_get(key) -> Option<Vec<u8>>`, `derivation_cache_put(key, kind,
payload)`(INSERT OR REPLACE), `derivation_cache_touch(keys)`(last_used 갱신, 1tx),
`derivation_cache_gc(ttl_days)`(존재하나 미 wiring — §3.5). 별도 trait 안 만들고
store 에 직접 단다.
- `kebab-app``embed_with_cache`(lib.rs, 히트/미스 분리 + 순서 보존 §3.4) +
`derivation_payload`(embedding f32↔LE bytes encode/decode) + ingest hook(별칭/embedding
캐시 조회·저장, hit/miss 카운트 로깅, touch 호출).
- `kebab-chunk` — korean_tokens 캐시(선택, 우선순위 낮음 — embedding/LLM 이 주 비용).
**미구현(보류)**.
## 5. Allowed / forbidden deps
- `kebab-core` 의 키 함수는 순수(blake3 + unicode-normalization 만). 다른 kebab-* 금지.
- 캐시 저장소는 `kebab-store-sqlite`. UI crate 직접 접근 금지(facade 경유).
- `kebab-app` 만 캐시를 오케스트레이션(ingest 경로).
## 6. 측정 / 검증
- 동일 corpus 2회 ingest: 1회차(cold) vs 2회차(warm, 전부 캐시 히트) 시간 비교.
warm 재색인이 별칭 LLM 0회·embedding 0회여야(로그로 hit/miss 카운트 노출).
- 정답 18 문서 별칭: cold 2.5h → warm ~수십초(캐시 히트) 목표.
- golden eval: warm 재색인 후 variant 16/18 + refusal 동일(결과 불변 = 캐시 정확성).
- 버전 bump 시뮬: prompt_version 변경 → 별칭 전부 miss(재계산) 확인.
## 7. 호환성 / 마이그레이션 (기존 KB 영향)
이 작업이 기존 KB 를 어떻게 건드리는지 — 무엇이 재색인 필요하고 무엇이 그대로인지.
- **본문 청크 재색인 불필요.** chunk_id 생성 방식(위치 기반 `id_for_block`)을 안 바꿨고
본문 dense 벡터 색인 경로도 안 바꿨다. 같은 corpus 를 같은 parser/chunker/embedding
버전으로 다시 ingest 하면 본문 chunk_id·벡터가 그대로다. 캐시는 *계산*만 절감할 뿐
결과(벡터 값)는 동일하므로 기존 본문 데이터는 손대지 않아도 된다.
- **V012 는 순수 가산 — 자동 적용, 기존 데이터 불변.** 새 테이블 `derivation_cache`
추가하고 `corpus_revision` 을 bump 하지 않는다(§3.2). 기존 SQLite 를 새 binary 로 열면
refinery 가 V012 를 자동 적용하며 기존 행은 건드리지 않는다. **단 binary 교체는 필수**:
V012 가 적용된 DB 를 **이전 release binary** 로 열면 refinery 마이그레이션 상태가
mismatch 한다(이전 binary 는 V012 를 모름) → 새 binary 로만 열 것. 이 schema 변경은
CLAUDE.md §Versioning 의 release bump 트리거다.
- **별칭 dense 벡터 — expansion 을 켰던 KB 만 해당.** 별칭 색인 단위가 묶음 단일 sentinel
`{orig}#alias`(1벡터) → 줄별 개별 sentinel `{orig}#alias#N`(N벡터)로 바뀌었다.
- expansion 을 한 번도 안 켠 KB: 별칭 sentinel 자체가 없으므로 영향 0.
- 기존 단일 sentinel 이 남아 있어도 **검색은 그대로 동작**한다: candidate strip 이
`strip_alias_suffix`(ids.rs)의 `find("#alias")` 기반이라 legacy `{orig}#alias`
신형 `{orig}#alias#N` 를 똑같이 원본 chunk_id 로 환원한다.
- 개별 벡터의 검색 품질 이점(희석 회피, §3.4)을 원하면 **별칭만 재생성**하면 된다
(본문은 그대로). 강제 사항은 아니다.
- stale 별칭 sentinel 누수 방지는 §3.5 의 cleanup(LanceDB exact-match + SQLite
`#alias%` LIKE)이 재색인·삭제 시 자동 처리한다.
- **KB 이식성(외부 계산 워크플로).** `derivation_cache` 는 SQLite 안에 있고 cache_key 가
머신 독립적인 내용 해시라, 외부 서버에서 워밍한 `kebab.sqlite`(+`lancedb/`)를 그대로
복사해 오면 로컬 증분 수정 시에도 캐시가 히트한다(측정: handoff §5).
## 8. Risks / notes
- LLM 별칭의 미세한 비결정성: 캐시가 첫 결과를 고정하므로 재현성은 오히려 향상.
단 "더 나은 별칭" 재생성을 원하면 prompt_version bump 로 무효화.
- payload BLOB 크기: embedding 4KB/청크 × 캐시 엔트리. 50만 엔트리 ≈ 2GB. TTL/LRU 로 관리.
- V012 는 schema migration → release version bump 트리거(CLAUDE.md §Versioning).
- 본 설계는 frozen design contract(§9 versioning)의 *의미*를 바꾸지 않는다(캐시는 그
위의 성능 레이어). design 문서 수정 불필요; cascade 안전성만 version_key 로 보장.