diff --git a/Cargo.lock b/Cargo.lock index 0ecb252..a50a47e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4276,7 +4276,7 @@ dependencies = [ [[package]] name = "kebab-app" -version = "0.20.2" +version = "0.21.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -4322,7 +4322,7 @@ dependencies = [ [[package]] name = "kebab-chunk" -version = "0.20.2" +version = "0.21.0" dependencies = [ "anyhow", "blake3", @@ -4340,7 +4340,7 @@ dependencies = [ [[package]] name = "kebab-cli" -version = "0.20.2" +version = "0.21.0" dependencies = [ "anyhow", "clap", @@ -4361,7 +4361,7 @@ dependencies = [ [[package]] name = "kebab-config" -version = "0.20.2" +version = "0.21.0" dependencies = [ "anyhow", "dirs 5.0.1", @@ -4376,7 +4376,7 @@ dependencies = [ [[package]] name = "kebab-core" -version = "0.20.2" +version = "0.21.0" dependencies = [ "anyhow", "blake3", @@ -4390,7 +4390,7 @@ dependencies = [ [[package]] name = "kebab-embed" -version = "0.20.2" +version = "0.21.0" dependencies = [ "anyhow", "blake3", @@ -4404,7 +4404,7 @@ dependencies = [ [[package]] name = "kebab-embed-local" -version = "0.20.2" +version = "0.21.0" dependencies = [ "anyhow", "fastembed", @@ -4417,7 +4417,7 @@ dependencies = [ [[package]] name = "kebab-eval" -version = "0.20.2" +version = "0.21.0" dependencies = [ "anyhow", "kebab-app", @@ -4436,7 +4436,7 @@ dependencies = [ [[package]] name = "kebab-llm" -version = "0.20.2" +version = "0.21.0" dependencies = [ "anyhow", "kebab-core", @@ -4445,7 +4445,7 @@ dependencies = [ [[package]] name = "kebab-llm-local" -version = "0.20.2" +version = "0.21.0" dependencies = [ "anyhow", "kebab-config", @@ -4462,7 +4462,7 @@ dependencies = [ [[package]] name = "kebab-mcp" -version = "0.20.2" +version = "0.21.0" dependencies = [ "anyhow", "kebab-app", @@ -4480,7 +4480,7 @@ dependencies = [ [[package]] name = "kebab-nli" -version = "0.20.2" +version = "0.21.0" dependencies = [ "anyhow", "hf-hub", @@ -4495,7 +4495,7 @@ dependencies = [ [[package]] name = "kebab-parse-code" -version = "0.20.2" +version = "0.21.0" dependencies = [ "anyhow", "gix", @@ -4518,7 +4518,7 @@ dependencies = [ [[package]] name = "kebab-parse-image" -version = "0.20.2" +version = "0.21.0" dependencies = [ "ab_glyph", "anyhow", @@ -4542,7 +4542,7 @@ dependencies = [ [[package]] name = "kebab-parse-md" -version = "0.20.2" +version = "0.21.0" dependencies = [ "anyhow", "kebab-core", @@ -4559,7 +4559,7 @@ dependencies = [ [[package]] name = "kebab-parse-pdf" -version = "0.20.2" +version = "0.21.0" dependencies = [ "anyhow", "blake3", @@ -4574,7 +4574,7 @@ dependencies = [ [[package]] name = "kebab-rag" -version = "0.20.2" +version = "0.21.0" dependencies = [ "anyhow", "blake3", @@ -4596,7 +4596,7 @@ dependencies = [ [[package]] name = "kebab-search" -version = "0.20.2" +version = "0.21.0" dependencies = [ "anyhow", "globset", @@ -4615,7 +4615,7 @@ dependencies = [ [[package]] name = "kebab-source-fs" -version = "0.20.2" +version = "0.21.0" dependencies = [ "anyhow", "blake3", @@ -4633,7 +4633,7 @@ dependencies = [ [[package]] name = "kebab-store-sqlite" -version = "0.20.2" +version = "0.21.0" dependencies = [ "anyhow", "blake3", @@ -4653,7 +4653,7 @@ dependencies = [ [[package]] name = "kebab-store-vector" -version = "0.20.2" +version = "0.21.0" dependencies = [ "anyhow", "arrow", @@ -4677,7 +4677,7 @@ dependencies = [ [[package]] name = "kebab-tui" -version = "0.20.2" +version = "0.21.0" dependencies = [ "anyhow", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index b601d5a..6022b0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ edition = "2024" rust-version = "1.85" license = "MIT OR Apache-2.0" repository = "https://github.com/altair823/kebab" -version = "0.20.2" # v0.20.2 — Ask 응답언어 rag-v3 + 8 dogfood findings + 검색 품질 eval baseline (golden suite) — CLAUDE.md §Release 도그푸딩 트리거 +version = "0.21.0" # v0.21.0 — doc-side expansion 별칭(개별 dense 벡터) + 파생물 캐시(V012, 내용 해시 키) — CLAUDE.md §Release 도그푸딩 트리거 # pre-v0.18 workspace-wide cleanup: enable clippy::pedantic group with # intentional allow-list. The allowed lints are either cosmetic (doc style), diff --git a/HANDOFF.md b/HANDOFF.md index cc07c9c..109cdeb 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -32,6 +32,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능. 머지 후 발견된 모든 deviation / hotfix 의 dated 로그는 [tasks/HOTFIXES.md](tasks/HOTFIXES.md). 본 요약은 \"누군가가 인수받을 때 알아두면 시간을 많이 절약하는\" 항목만: +- **2026-05-31 Phase 2 doc-side expansion 별칭(개별 dense 벡터) + 파생물 캐시(V012)** — v0.21.0 cut. 색인 시 LLM 이 청크별 별칭("같은 의미 다른 표현")을 생성, 줄별 **개별 dense 벡터**(sentinel `{chunk}#alias#N`)로 색인 (묶음 1벡터는 평균화 희석으로 회귀 → 폐기) + boilerplate 청크 skip. `[ingest.expansion]` default off. 측정(나무위키 ~1000 문서 CS corpus): 변형 일관성 14/18 → **16/18**, spread 0.222→0.111, 대조군 false-positive 별칭 무죄. 비용 병목(별칭 18문서 2.5h)은 **파생물 캐시(V012, 청크 내용 해시 키)**로 해소 — 정답 3개 cold 1879s → warm 13s **≈ 145배**, embedding+별칭 LLM 캐싱, version_key cascade 정합. search/ask 가 `kebab.sqlite`+`lancedb` 만으로 동작 → 외부 서버 색인 후 DB 만 복사하는 이식 워크플로 가능. **결정/known limitation**: grounded/refusal 판정이 부분 인용을 grounded 로 오분류(정직한 거부가 false-positive 로 집계) — 별도 개선 후보. stack·svm 설명형 2개 잔존. 자세한 내용: `tasks/HOTFIXES.md` (2026-05-31), 측정: `docs/superpowers/handoffs/2026-05-31-namu-wiki-alias-cache-study.md`. - **2026-05-29 v0.20.2 dogfood findings + 검색 품질 baseline** — 8-finding 라운드 완료. (1) Ask 응답언어: rag-v3 default (질문 언어 = 답변 언어). (2) eval `--config` facade 패치 로 dogfood KB 직접 eval 가능. (3) 검색 품질 baseline — hybrid hit@3=1.0 / MRR=0.833, lexical hit@3=1.0 / MRR=0.7 (golden 10 query). **O-2 known limitation**: 소형 모델(gemma4:e4b) refusal 메시지의 query 언어 불일치 가능 — 판정은 정상, 표시 문구만 해당. 자세한 내용: `tasks/HOTFIXES.md` (2026-05-29). - **v0.20 sub-item 1 (scanned PDF OCR via qwen2.5vl:3b)**: post-extract enrichment pattern (`kebab-app::pdf_ocr_apply`, H-1 resolution), DCTDecode-only v1 scope (FlateDecode/CCITTFax page 는 warning + skip), parser_version `"pdf-text-v1"` 보존 + force-reingest UX 명문 (H-4). - **2026-05-26 kebab-normalize + kebab-parse-types 흡수 (24 → 22 crates, design §3.7b 재작성)** — v0.19.0 cut. 4 parser 중 markdown 한 갈래만 lift 를 경유하는 reality 가 design §3.7b 의 fan-in ≥ 2 가정과 diverge → thin layer (`kebab-parse-types`) + `kebab-normalize` 두 crate 가 `kebab-parse-md` 로 흡수. 5 사용 type + 3 forward-declared struct 모두 `kebab-parse-md::{types,normalize}` module 의 `pub` re-export 로 보존. wire / surface impact = 0 (CLI / TUI / MCP / `--json` / config / XDG / parser_version 모두 unchanged). 자세한 내용: `tasks/HOTFIXES.md` (2026-05-26 design deviation entry). diff --git a/README.md b/README.md index 08bd09b..6476ad4 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,11 @@ flowchart TB - `max_file_bytes = 262144` (256 KiB) / `max_file_lines = 5000` — 파일당 cap, 초과 시 skip. - `extra_skip_globs = []` — 사용자 추가 skip 패턴 (`.gitignore` 문법). - `.gitignore` honor: 자동 적용. `.kebabignore` 는 추가 layer. 우선순위: built-in safety net (`node_modules/` / `target/` / `__pycache__/` / `.venv/` / `venv/` / `env/`) > `.gitignore` > `.kebabignore`. +- `[ingest.expansion]` — **doc-side expansion (별칭 색인)**. 색인 시 각 청크에 대해 LLM 이 "같은 의미의 다른 표현"(동의어·약어·한↔영 번역·풀어쓴 설명) 별칭을 생성해, 설명형·cross-lingual query 의 검색 일관성을 높인다. **default off (opt-in)** — 청크당 LLM 호출이라 비용이 크다. + - `enabled = false` — opt-in. `embed_aliases = true` 면 별칭을 줄별 **개별 dense 벡터**(sentinel `{chunk}#alias#N`)로 색인하고 본문 벡터는 그대로 둔다. 검색 시 별칭 hit 는 원본 문서로 매핑돼 "query 표현 ↔ 문서 용어"의 다리 역할을 한다. + - `max_aliases_per_chunk = 8` / `prompt_version = "expansion-v1"` / `model = ""`(빈 값 = `models.llm` 기본). + - 효과 측정(나무위키 ~1000 문서 CS corpus): 변형 일관성 14/18 → 16/18 (설명형·cross-lingual 회복), 대조군 false-positive 미유발. 상세: [docs/superpowers/handoffs/2026-05-31-namu-wiki-alias-cache-study.md](docs/superpowers/handoffs/2026-05-31-namu-wiki-alias-cache-study.md). +- **파생물 캐시 (`derivation_cache`, V012)** — embedding 벡터와 별칭 LLM 결과를 청크 **내용 해시**로 캐싱한다. 문서 재색인·갱신 시 내용이 같은 청크는 재계산을 건너뛴다(측정: 별칭 18문서 재색인 2.5h → ~80s). 캐시 키에 모델·프롬프트·차원 버전이 포함돼 버전 변경 시 자동 무효화(cascade 안전). 비싼 계산은 자동·투명하게 캐시되며 별도 설정이 없다. 응용: 비싼 색인을 외부 서버에서 수행한 뒤 `kebab.sqlite`+`lancedb` 만 복사해 로컬에서 검색할 수 있다(search/ask 는 asset 파일이 필요 없다). - `[rag] prompt_template_version` (default `"rag-v3"`) — RAG system prompt version. `"rag-v1"` / `"rag-v2"` 은 legacy backwards-compat (명시 시 유지). v2 강화 규칙: (1) fact 인용 시 [#번호] 앞에 chunk 속 원문 큰따옴표 표기, (2) 학습 지식 동원 금지, (3) 근거 모호 시 "확실하지 않다" 명시. **v3 추가 규칙 (v0.20.2)**: 답변 언어 = 질문 언어 (query 가 영어면 영어로, 한국어면 한국어로). 근거 부족 refusal 문구도 언어중립화. **Known limitation**: gemma4:e4b 같은 소형 모델은 refusal 메시지의 언어가 query 언어와 불일치할 수 있음 — refusal 판정(marker 기반)은 정상, 표시 문구만 해당. v2 고정: `[rag] prompt_template_version = "rag-v2"`. - `--config ` flag — 임시 워크스페이스 / 격리 테스트 시 사용. CLI / TUI 모두 honor. - `KEBAB_*` env — 일부 키 override (`KEBAB_RAG_SCORE_GATE`, `KEBAB_EVAL_GOLDEN`, `KEBAB_COMMIT_HASH` 등). diff --git a/crates/kebab-app/src/derivation_payload.rs b/crates/kebab-app/src/derivation_payload.rs new file mode 100644 index 0000000..72443f9 --- /dev/null +++ b/crates/kebab-app/src/derivation_payload.rs @@ -0,0 +1,61 @@ +//! Derivation-cache payload encoding helpers (design 2026-05-31 §3.3). +//! +//! - embedding: `dimensions × f32` little-endian bytes (1024×4 = 4096 B/chunk). +//! - alias / korean_tokens: UTF-8 as-is (handled inline by the caller — no +//! helper needed, `String::as_bytes` / `String::from_utf8`). + +/// Encode an embedding vector as a little-endian `f32` byte string (§3.3). +pub fn encode_embedding(vector: &[f32]) -> Vec { + let mut out = Vec::with_capacity(vector.len() * 4); + for &v in vector { + out.extend_from_slice(&v.to_le_bytes()); + } + out +} + +/// Decode a little-endian `f32` byte string back into a vector (§3.3). +/// +/// Returns `None` if the payload length is not a multiple of 4 (corrupt +/// entry) — the caller treats this as a cache miss and recomputes, so a bad +/// payload never produces a wrong vector. +pub fn decode_embedding(payload: &[u8]) -> Option> { + if payload.len() % 4 != 0 { + return None; + } + Some( + payload + .chunks_exact(4) + .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrips_vector() { + let v = vec![0.0_f32, 1.5, -2.25, 3.125e10, f32::MIN, f32::MAX]; + let bytes = encode_embedding(&v); + assert_eq!(bytes.len(), v.len() * 4); + assert_eq!(decode_embedding(&bytes), Some(v)); + } + + #[test] + fn empty_vector_roundtrips() { + assert_eq!(encode_embedding(&[]), Vec::::new()); + assert_eq!(decode_embedding(&[]), Some(vec![])); + } + + #[test] + fn misaligned_payload_is_none() { + assert_eq!(decode_embedding(&[1, 2, 3]), None); + } + + #[test] + fn little_endian_layout_is_fixed() { + // 1.0_f32 == 0x3F800000, little-endian bytes [0x00,0x00,0x80,0x3F]. + assert_eq!(encode_embedding(&[1.0]), vec![0x00, 0x00, 0x80, 0x3F]); + } +} diff --git a/crates/kebab-app/src/expansion.rs b/crates/kebab-app/src/expansion.rs index d4cafaa..c57d882 100644 --- a/crates/kebab-app/src/expansion.rs +++ b/crates/kebab-app/src/expansion.rs @@ -7,6 +7,11 @@ use kebab_core::{Chunk, GenerateRequest, LanguageModel}; /// 별칭 1줄의 최대 글자 수(이 이상은 문장형/환각으로 보고 drop). const MAX_ALIAS_CHARS: usize = 120; +/// 별칭 프롬프트 템플릿 버전. derivation cache 의 alias version_key 에 포함되어 +/// (§3.1), 프롬프트를 바꾸면 bump 해 캐시를 무효화한다(전부 miss → 재생성). +/// `build_request` 의 gemma 프롬프트와 한 쌍 — 프롬프트 수정 시 함께 bump. +pub const PROMPT_VERSION: &str = "expansion-v1"; + /// 청크당 검색용 별칭을 생성한다. /// /// 반환: 검증·상한 적용된 별칭들을 개행 join 한 문자열. 생성 0개 / LLM @@ -45,6 +50,11 @@ impl<'a> ExpansionGenerator<'a> { } pub fn generate(&self, chunk: &Chunk) -> Option { + // 나무위키 네비게이션 boilerplate 청크는 LLM 호출 없이 skip — 별칭 + // 생성 가치가 없고 노이즈 sentinel 벡터만 만든다. + if is_nav_boilerplate(chunk) { + return None; + } let req = Self::build_request(chunk); let raw = match self.llm.generate_stream(req) { Ok(iter) => { @@ -69,6 +79,26 @@ impl<'a> ExpansionGenerator<'a> { } } +/// 나무위키 네비게이션 boilerplate 청크 판정. +/// +/// heading_path 가 비어 있고(문서 본문 섹션이 아닌 머리/꼬리 nav), text 앞부분에 +/// nav 키워드("최근 변경" 등)가 하나라도 있으면 boilerplate 로 본다. 둘 다 +/// 만족할 때만 true — 정상 본문(heading 있음, 또는 nav 키워드 없음)은 false. +pub fn is_nav_boilerplate(chunk: &Chunk) -> bool { + const NAV_KEYWORDS: [&str; 5] = [ + "최근 변경", + "Recent changes", + "최근 토론", + "특수 기능", + "편집 토론 역사", + ]; + if !chunk.heading_path.is_empty() { + return false; + } + let head: String = chunk.text.chars().take(200).collect(); + NAV_KEYWORDS.iter().any(|kw| head.contains(kw)) +} + /// 줄 선두의 목록 마커만 1회 제거한다. **마커 뒤 공백이 필수** — 별칭 내용이 /// 숫자/하이픈/별표로 시작하는 경우(예: "3D 렌더링", "-fast", "2단계")는 보존한다. /// (Task 4 리뷰 MAJOR-1: 탐욕적 `trim_start_matches` 가 정당한 별칭을 손상시키던 버그 수정.) @@ -185,6 +215,50 @@ mod tests { assert_eq!(out, "3D 렌더링\n2단계 커밋\n-fast 플래그\n메모리 안전성\n첫 항목"); } + fn mk_chunk_nav(text: &str, heading: Vec) -> Chunk { + let mut c = mk_chunk(text); + c.heading_path = heading; + c + } + + #[test] + fn nav_boilerplate_skips_alias_generation() { + // heading 없음 + nav 키워드 → boilerplate → LLM 호출 전에 None. + let llm = mock("별칭1\n별칭2"); + let generator = ExpansionGenerator::new(&llm, 8); + let chunk = mk_chunk_nav("최근 변경 최근 토론 특수 기능", vec![]); + assert_eq!(generator.generate(&chunk), None); + } + + #[test] + fn normal_body_chunk_generates_aliases() { + // heading 없지만 nav 키워드도 없음 → 정상 본문 → 별칭 생성. + let llm = mock("별칭1\n별칭2"); + let generator = ExpansionGenerator::new(&llm, 8); + let chunk = mk_chunk_nav("러스트의 소유권과 빌림 검사기 개요", vec![]); + assert_eq!(generator.generate(&chunk).unwrap(), "별칭1\n별칭2"); + } + + #[test] + fn nav_keyword_with_heading_is_not_boilerplate() { + // nav 키워드가 있어도 heading 이 있으면 본문 섹션 → 생성. + let llm = mock("별칭1"); + let generator = ExpansionGenerator::new(&llm, 8); + let chunk = mk_chunk_nav("최근 변경 내역 설명", vec!["문서 변경사항".into()]); + assert_eq!(generator.generate(&chunk).unwrap(), "별칭1"); + } + + #[test] + fn is_nav_boilerplate_unit() { + assert!(is_nav_boilerplate(&mk_chunk_nav("Recent changes list", vec![]))); + assert!(is_nav_boilerplate(&mk_chunk_nav("편집 토론 역사", vec![]))); + assert!(!is_nav_boilerplate(&mk_chunk_nav("일반 본문 텍스트", vec![]))); + assert!(!is_nav_boilerplate(&mk_chunk_nav( + "최근 변경", + vec!["섹션".into()] + ))); + } + #[test] fn strip_list_marker_unit() { assert_eq!(strip_list_marker("- 메모리"), "메모리"); diff --git a/crates/kebab-app/src/lib.rs b/crates/kebab-app/src/lib.rs index c7f1709..320b7ff 100644 --- a/crates/kebab-app/src/lib.rs +++ b/crates/kebab-app/src/lib.rs @@ -59,6 +59,7 @@ use kebab_source_fs::FsSourceConnector; mod app; mod bulk; pub mod cursor; +pub mod derivation_payload; pub mod doctor_signal; pub mod error_signal; pub mod error_wire; @@ -1057,6 +1058,70 @@ fn unsupported_media_warning(path: &str) -> String { } } +/// Embed `texts` with the derivation cache (design 2026-05-31 §3.4). +/// +/// 1) 각 text 의 embedding cache_key 계산 → 히트/미스 분리. +/// 2) 미스 text 만 `emb.embed`(축소 배치) 호출. +/// 3) 미스 결과를 `Vec` little-endian 으로 캐시 put. +/// 4) 히트(bytes→Vec) + 미스 벡터를 **원래 순서대로** 합쳐 반환. +/// +/// 손상된 payload(길이 misalign)는 미스로 강등 → 재계산(정확성 우선, §3.5). +/// 히트 키는 `touch_keys` 에 누적(호출측이 배치로 last_used_at 갱신). +fn embed_with_cache( + emb: &dyn Embedder, + sqlite: &kebab_store_sqlite::SqliteStore, + texts: &[&str], + version_key: &str, + hit: &mut usize, + miss: &mut usize, + touch_keys: &mut Vec, +) -> anyhow::Result>> { + let mut out: Vec>> = Vec::with_capacity(texts.len()); + let mut miss_indices: Vec = Vec::new(); + let mut miss_inputs: Vec> = Vec::new(); + let mut keys: Vec = Vec::with_capacity(texts.len()); + + for (i, text) in texts.iter().enumerate() { + let key = kebab_core::derivation_cache_key("embedding", text, version_key); + // 히트 = 캐시에 있고 payload 가 정상 디코드되는 경우. 손상 payload 는 + // 미스로 강등(재계산, 정확성 우선 §3.5). + let cached = sqlite + .derivation_cache_get(&key)? + .and_then(|p| crate::derivation_payload::decode_embedding(&p)); + if let Some(v) = cached { + *hit += 1; + touch_keys.push(key.clone()); + out.push(Some(v)); + } else { + *miss += 1; + miss_indices.push(i); + miss_inputs.push(EmbeddingInput { + text, + kind: EmbeddingKind::Document, + }); + out.push(None); + } + keys.push(key); + } + + if !miss_inputs.is_empty() { + let miss_vectors = emb.embed(&miss_inputs)?; + for (slot, v) in miss_indices.iter().zip(miss_vectors) { + sqlite.derivation_cache_put( + &keys[*slot], + "embedding", + &crate::derivation_payload::encode_embedding(&v), + )?; + out[*slot] = Some(v); + } + } + + Ok(out + .into_iter() + .map(|v| v.expect("every slot filled by hit or miss")) + .collect()) +} + /// Process a single asset: read bytes, parse, normalize, chunk, /// persist, embed. Per-asset failures bubble up to the caller for /// labelling as `IngestItemKind::Error` — they do NOT abort the @@ -1256,8 +1321,19 @@ fn ingest_one_asset( .context("kb-chunk::MdHeadingV1Chunker::chunk")?; // Phase 2 doc-side expansion: flag on 이면 청크당 별칭 생성 (fail-soft). + // derivation cache(§3.4): 같은 청크 text + 같은 alias version_key 면 LLM + // 호출 없이 캐시된 별칭 재사용. version_key = {prompt_version}|{max}|{model}. + let mut alias_cache_hit = 0_usize; + let mut alias_cache_miss = 0_usize; + let mut alias_touch_keys: Vec = Vec::new(); if app.config.ingest.expansion.enabled { let exp = &app.config.ingest.expansion; + let alias_version_key = format!( + "{}|{}|{}", + crate::expansion::PROMPT_VERSION, + exp.max_aliases_per_chunk, + exp.model + ); let llm_built = if exp.model.is_empty() { OllamaLanguageModel::new(&app.config) } else { @@ -1268,7 +1344,29 @@ fn ingest_one_asset( let generator = crate::expansion::ExpansionGenerator::new(&llm, exp.max_aliases_per_chunk); for chunk in &mut chunks { - chunk.aliases = generator.generate(chunk); + let key = kebab_core::derivation_cache_key( + "alias", + &chunk.text, + &alias_version_key, + ); + if let Some(payload) = app.sqlite.derivation_cache_get(&key)? { + // 히트: 저장된 별칭(UTF-8) 재사용. LLM 호출 없음. + chunk.aliases = String::from_utf8(payload).ok(); + alias_cache_hit += 1; + alias_touch_keys.push(key); + } else if crate::expansion::is_nav_boilerplate(chunk) { + // 미스지만 nav boilerplate → 생성 가치 없음(기존 skip 규칙). + // 캐시에 넣지 않음(None 은 payload 로 표현 불가, 다음 run 도 동일 판정). + chunk.aliases = None; + } else { + // 미스 → LLM 생성 후 캐시 저장. + chunk.aliases = generator.generate(chunk); + alias_cache_miss += 1; + if let Some(a) = &chunk.aliases { + app.sqlite + .derivation_cache_put(&key, "alias", a.as_bytes())?; + } + } } } Err(e) => { @@ -1306,21 +1404,30 @@ fn ingest_one_asset( .context("DocumentStore::put_chunks")?; // Embed + vector upsert (only when both sides are configured). + let mut emb_cache_hit = 0_usize; + let mut emb_cache_miss = 0_usize; if let (Some(emb), Some(vec_store)) = (embedder, vector_store) { if !chunks.is_empty() { - let inputs: Vec> = chunks - .iter() - .map(|c| EmbeddingInput { - text: c.text.as_str(), - kind: EmbeddingKind::Document, - }) - .collect(); - let vectors = emb - .embed(&inputs) - .context("Embedder::embed (document chunks)")?; 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}. + // 본문 청크 + 별칭 문자열 양쪽이 같은 메커니즘(같은 text → 같은 캐시). + let emb_version_key = + format!("{}|{}|{}", 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(); + let vectors = embed_with_cache( + &**emb, + &app.sqlite, + &body_texts, + &emb_version_key, + &mut emb_cache_hit, + &mut emb_cache_miss, + &mut emb_touch_keys, + ) + .context("Embedder::embed (document chunks)")?; let records: Vec = chunks .iter() .zip(vectors) @@ -1350,47 +1457,91 @@ fn ingest_one_asset( .filter(|c| c.aliases.as_deref().is_some_and(|a| !a.is_empty())) .collect(); if !alias_chunks.is_empty() { - let alias_inputs: Vec> = alias_chunks + // 각 별칭을 줄 단위로 분리해 개별 sentinel 벡터로 임베딩한다. + // 묶음 1벡터는 벡터를 희석시켜 효과가 없으므로(측정), 별칭 i + // 마다 chunk_id `{orig}#alias#{i}` 의 VectorRecord 를 만든다. + // `(청크 참조, 별칭 문자열)` 쌍을 평탄화한 뒤 한 번에 임베딩. + let alias_lines: Vec<(&kebab_core::Chunk, &str)> = alias_chunks .iter() - .map(|c| EmbeddingInput { - text: c.aliases.as_deref().unwrap(), - kind: EmbeddingKind::Document, + .flat_map(|c| { + c.aliases + .as_deref() + .unwrap() + .split('\n') + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(move |line| (*c, line)) }) .collect(); - let alias_vectors = emb - .embed(&alias_inputs) + if !alias_lines.is_empty() { + // 별칭 dense 벡터도 본문과 동일한 embedding 캐시 재사용: + // 같은 별칭 문자열이면 본문 embedding 캐시와 같은 키로 적중(§3.4). + let alias_texts: Vec<&str> = + alias_lines.iter().map(|(_, line)| *line).collect(); + let alias_vectors = embed_with_cache( + &**emb, + &app.sqlite, + &alias_texts, + &emb_version_key, + &mut emb_cache_hit, + &mut emb_cache_miss, + &mut emb_touch_keys, + ) .context("Embedder::embed (alias vectors)")?; - for (c, v) in alias_chunks.iter().zip(alias_vectors) { - let alias_chunk_id = kebab_core::ChunkId(format!( - "{}{}", - c.chunk_id.0, - kebab_core::ALIAS_SUFFIX - )); - all_records.push(VectorRecord { - embedding_id: kebab_core::id_for_embedding( - &alias_chunk_id, - &model_id, - &model_version, + // 같은 청크 안에서 별칭 인덱스를 0부터 매긴다. + let mut per_chunk_idx: std::collections::HashMap = + std::collections::HashMap::new(); + for ((c, line), v) in alias_lines.iter().zip(alias_vectors) { + let i = per_chunk_idx.entry(c.chunk_id.0.clone()).or_insert(0); + let alias_chunk_id = kebab_core::ChunkId(format!( + "{}{}#{}", + c.chunk_id.0, + kebab_core::ALIAS_SUFFIX, + *i + )); + *i += 1; + all_records.push(VectorRecord { + embedding_id: kebab_core::id_for_embedding( + &alias_chunk_id, + &model_id, + &model_version, + dimensions, + ), + chunk_id: alias_chunk_id, + vector: v, + doc_id: canonical.doc_id.clone(), + text: (*line).to_string(), + heading_path: c.heading_path.clone(), + model_id: model_id.clone(), + model_version: model_version.clone(), dimensions, - ), - chunk_id: alias_chunk_id, - vector: v, - doc_id: canonical.doc_id.clone(), - text: c.aliases.clone().unwrap_or_default(), - heading_path: c.heading_path.clone(), - model_id: model_id.clone(), - model_version: model_version.clone(), - dimensions, - }); + }); + } } } } vec_store .upsert(&all_records) .context("VectorStore::upsert")?; + // 히트한 embedding 키들의 last_used_at 갱신(LRU 보존, §3.5). + app.sqlite.derivation_cache_touch(&emb_touch_keys)?; } } + // 히트한 alias 키들의 last_used_at 갱신(LRU 보존, §3.5). + app.sqlite.derivation_cache_touch(&alias_touch_keys)?; + + // 검증용 hit/miss 카운트 노출(§3.4 / §6): warm 재색인이 LLM·embed 0회임을 + // 로그로 확인. tracing target 은 stderr 로 흐른다. + if alias_cache_hit + alias_cache_miss + emb_cache_hit + emb_cache_miss > 0 { + tracing::info!( + target: "kebab-app", + doc = %canonical.doc_id.0, + "derivation cache: embedding hit={emb_cache_hit} miss={emb_cache_miss}, \ + alias hit={alias_cache_hit} miss={alias_cache_miss}" + ); + } + let kind = if existing_doc_ids.contains(&canonical.doc_id.0) { kebab_core::IngestItemKind::Updated } else { diff --git a/crates/kebab-app/tests/search_lexical.rs b/crates/kebab-app/tests/search_lexical.rs index 29d8333..226c64e 100644 --- a/crates/kebab-app/tests/search_lexical.rs +++ b/crates/kebab-app/tests/search_lexical.rs @@ -109,10 +109,11 @@ fn first_ingest_bumps_corpus_revision() { let env = TestEnv::lexical_only(); let store_before = kebab_store_sqlite::SqliteStore::open(&env.config).unwrap(); store_before.run_migrations().unwrap(); - // V004 seeds 0; V009 + V010 migrations each bump by 1 to invalidate - // stale LRU caches (spec §5.2). Baseline before ingest = 2. + // V004 seeds 0; V009 + V010 + V011 migrations each bump by 1 to + // invalidate stale LRU caches (spec §5.2). Baseline before ingest = 3. + // (V012 derivation_cache is purely additive — does NOT bump.) let baseline = store_before.corpus_revision(); - assert_eq!(baseline, 2, "fresh store post-V010 baseline = 2"); + assert_eq!(baseline, 3, "fresh store post-V011 baseline = 3"); let report = kebab_app::ingest_with_config(env.config.clone(), env.scope(), true).unwrap(); assert!( diff --git a/crates/kebab-core/src/derivation.rs b/crates/kebab-core/src/derivation.rs new file mode 100644 index 0000000..6583428 --- /dev/null +++ b/crates/kebab-core/src/derivation.rs @@ -0,0 +1,110 @@ +//! Content-hash derivation cache key (design 2026-05-31 §3.1). +//! +//! Expensive ingest derivations (embedding vectors, LLM aliases, optional +//! Korean morphological tokens) are cached by the *content hash* of the chunk +//! text so that re-indexing an updated document skips recomputation for any +//! chunk whose text is unchanged — independent of position / `chunk_id` +//! (which is position-based, see `ids::id_for_block`). +//! +//! ```text +//! cache_key = blake3_hex( kind || 0x00 || text_blake3 || 0x00 || version_key )[:32] +//! ``` +//! - `text_blake3` = blake3(NFC-normalized UTF-8 bytes of the chunk text). +//! - `kind` ∈ { "embedding", "alias", "korean_tokens" }. +//! - `version_key` folds every §9 version-cascade input for that kind +//! (model / prompt / tokenizer version). A version bump changes the key → +//! automatic cache miss → recompute, keeping the cache consistent with the +//! cascade contract (§3.5 / §3.6). +//! +//! Pure: depends only on `blake3` + `unicode-normalization`. No other +//! `kebab-*` crate is referenced (deps boundary §5). + +use crate::normalize::nfc; + +/// Derivation-cache key per design §3.1. +/// +/// `text` is NFC-normalized before hashing so the same logical content always +/// maps to the same key regardless of Unicode encoding form. `kind` and +/// `version_key` are folded in with `0x00` separators (which cannot occur in +/// hex digests) so distinct kinds / versions never collide. +pub fn derivation_cache_key(kind: &str, text: &str, version_key: &str) -> String { + let text_blake3 = blake3::hash(nfc(text).as_bytes()).to_hex().to_string(); + + let mut hasher = blake3::Hasher::new(); + hasher.update(kind.as_bytes()); + hasher.update(&[0x00]); + hasher.update(text_blake3.as_bytes()); + hasher.update(&[0x00]); + hasher.update(version_key.as_bytes()); + + hasher.finalize().to_hex().to_string()[..32].to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_is_32_hex_chars() { + let k = derivation_cache_key("embedding", "hello world", "v1"); + assert_eq!(k.len(), 32); + assert!(k.bytes().all(|b| b.is_ascii_hexdigit())); + } + + #[test] + fn same_inputs_same_key() { + let a = derivation_cache_key("embedding", "러스트 소유권", "model|1|1024"); + let b = derivation_cache_key("embedding", "러스트 소유권", "model|1|1024"); + assert_eq!(a, b); + } + + #[test] + fn nfc_normalization_collapses_encoding_forms() { + // "가" as a precomposed syllable (NFC) vs decomposed jamo (NFD) must + // hash to the same key after NFC normalization. + let precomposed = "\u{AC00}"; // 가 + let decomposed = "\u{1100}\u{1161}"; // ᄀ + ᅡ + assert_ne!(precomposed, decomposed); + let a = derivation_cache_key("embedding", precomposed, "v1"); + let b = derivation_cache_key("embedding", decomposed, "v1"); + assert_eq!(a, b); + } + + #[test] + fn different_kind_different_key() { + let e = derivation_cache_key("embedding", "same text", "v1"); + let a = derivation_cache_key("alias", "same text", "v1"); + assert_ne!(e, a); + } + + #[test] + fn different_version_key_different_key_miss() { + // §3.6 correctness guard: a version_key change MUST produce a different + // cache_key (so a stale derivation never gets reused after a cascade + // bump). This is the most safety-critical invariant of the cache. + let v1 = derivation_cache_key("embedding", "same text", "modelA|1|1024"); + let v2 = derivation_cache_key("embedding", "same text", "modelA|2|1024"); + assert_ne!(v1, v2); + + // alias prompt_version bump → miss. + let p1 = derivation_cache_key("alias", "문단", "expansion-v1|8|"); + let p2 = derivation_cache_key("alias", "문단", "expansion-v2|8|"); + assert_ne!(p1, p2); + } + + #[test] + fn different_text_different_key() { + let a = derivation_cache_key("embedding", "text one", "v1"); + let b = derivation_cache_key("embedding", "text two", "v1"); + assert_ne!(a, b); + } + + #[test] + fn separator_prevents_field_smearing() { + // Without the 0x00 separators, ("ab","","c") and ("a","b","c") shaped + // inputs could collide. The kind/version boundaries must be distinct. + let a = derivation_cache_key("ab", "x", "c"); + let b = derivation_cache_key("a", "x", "bc"); + assert_ne!(a, b); + } +} diff --git a/crates/kebab-core/src/ids.rs b/crates/kebab-core/src/ids.rs index 7fa07ef..e811905 100644 --- a/crates/kebab-core/src/ids.rs +++ b/crates/kebab-core/src/ids.rs @@ -61,10 +61,18 @@ fn validate_hex32(s: &str) -> Result<(), CoreError> { /// Suffix appended to a chunk's vector ID to mark an alias embedding row. pub const ALIAS_SUFFIX: &str = "#alias"; -/// Strip `#alias` suffix from `id`, returning the bare chunk ID. -/// If `id` does not end with `ALIAS_SUFFIX`, returns `id` unchanged. +/// Strip the alias marker from `id`, returning the bare chunk ID. +/// +/// Returns everything before the first occurrence of `ALIAS_SUFFIX`. This +/// handles both the suffix form `{orig}#alias` and the per-alias form +/// `{orig}#alias#N`. A bare chunk ID is blake3 hex (32 chars, no `#`), so the +/// first `#alias` always marks the boundary. If `id` contains no `ALIAS_SUFFIX`, +/// returns `id` unchanged. pub fn strip_alias_suffix(id: &str) -> &str { - id.strip_suffix(ALIAS_SUFFIX).unwrap_or(id) + match id.find(ALIAS_SUFFIX) { + Some(pos) => &id[..pos], + None => id, + } } /// Canonical-JSON + blake3 + hex prefix 32. Per design §4.2. @@ -447,6 +455,10 @@ mod tests { assert_eq!(strip_alias_suffix(bare), bare); assert_eq!(strip_alias_suffix(""), ""); assert_eq!(strip_alias_suffix("#alias"), ""); + // Per-alias form `{orig}#alias#N` strips to the bare chunk ID. + assert_eq!(strip_alias_suffix(&format!("{bare}{ALIAS_SUFFIX}#3")), bare); + assert_eq!(strip_alias_suffix(&format!("{bare}{ALIAS_SUFFIX}#0")), bare); + assert_eq!(strip_alias_suffix("#alias#3"), ""); } /// Independent pin for id_for_index. diff --git a/crates/kebab-core/src/lib.rs b/crates/kebab-core/src/lib.rs index b4ddb35..f3337ba 100644 --- a/crates/kebab-core/src/lib.rs +++ b/crates/kebab-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod answer; pub mod asset; pub mod chunk; pub mod citation; +pub mod derivation; pub mod document; pub mod errors; pub mod fetch; @@ -35,6 +36,7 @@ pub use answer::{ pub use asset::{AssetStorage, RawAsset, SourceUri, WorkspacePath}; pub use chunk::Chunk; pub use citation::Citation; +pub use derivation::derivation_cache_key; pub use document::{ AudioRefBlock, Block, CanonicalDocument, CodeBlock, CommonBlock, HeadingBlock, ImageRefBlock, Inline, ListBlock, ModelCaption, OcrRegion, OcrText, SourceSpan, TableBlock, TextBlock, diff --git a/crates/kebab-store-sqlite/src/derivation_cache.rs b/crates/kebab-store-sqlite/src/derivation_cache.rs new file mode 100644 index 0000000..0d60796 --- /dev/null +++ b/crates/kebab-store-sqlite/src/derivation_cache.rs @@ -0,0 +1,192 @@ +//! Content-hash derivation cache store (design 2026-05-31 §3.2 / §3.5). +//! +//! Backs the `derivation_cache` table (`V012`). The cache stores expensive +//! ingest derivations (embedding vectors, LLM aliases, optional Korean +//! tokens) keyed by `derivation_cache_key` (§3.1). It is a pure performance +//! layer: corruption / deletion only forces recomputation, never wrong +//! results (§3.5). Timestamps follow the same RFC3339 `OffsetDateTime` +//! formatting the asset / document / embedding writers use. + +use anyhow::{Context, Result}; +use rusqlite::{OptionalExtension, params}; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; + +use crate::error::StoreError; +use crate::store::SqliteStore; + +impl SqliteStore { + /// Look up a cached derivation payload by its content-hash key. + /// + /// Pure read — does **not** bump `last_used_at`. Callers that want LRU + /// freshness on a hit collect the hit keys and call [`Self::touch`] once + /// per batch (cheaper than a write per `get`). + pub fn derivation_cache_get(&self, cache_key: &str) -> Result>> { + let conn = self.lock_conn(); + let payload: Option> = conn + .query_row( + "SELECT payload FROM derivation_cache WHERE cache_key = ?", + params![cache_key], + |row| row.get::<_, Vec>(0), + ) + .optional() + .map_err(StoreError::from) + .context("derivation_cache_get")?; + Ok(payload) + } + + /// Insert (or overwrite) a cached derivation payload. + /// + /// `INSERT OR REPLACE` so a re-computation of the same key (e.g. after a + /// manual cache clear, or a non-deterministic LLM regenerating) refreshes + /// `created_at` / `last_used_at` to the new attempt. The key already folds + /// every version-cascade input (§3.1), so an overwrite is always the same + /// logical derivation. + pub fn derivation_cache_put(&self, cache_key: &str, kind: &str, payload: &[u8]) -> Result<()> { + let now = OffsetDateTime::now_utc() + .format(&Rfc3339) + .context("format derivation_cache.created_at")?; + let conn = self.lock_conn(); + conn.execute( + "INSERT OR REPLACE INTO derivation_cache + (cache_key, kind, payload, created_at, last_used_at) + VALUES (?, ?, ?, ?, ?)", + params![cache_key, kind, payload, now, now], + ) + .map_err(StoreError::from) + .context("derivation_cache_put")?; + Ok(()) + } + + /// Bump `last_used_at` for the given hit keys (LRU freshness, §3.5). + /// + /// Run in a single transaction. Missing keys are a no-op. Called once per + /// ingest batch with the keys that hit, so the GC pass keeps live chunks. + pub fn derivation_cache_touch(&self, keys: &[String]) -> Result<()> { + if keys.is_empty() { + return Ok(()); + } + let now = OffsetDateTime::now_utc() + .format(&Rfc3339) + .context("format derivation_cache.last_used_at")?; + let mut conn = self.lock_conn(); + let tx = conn.transaction().map_err(StoreError::from)?; + { + let mut stmt = tx + .prepare("UPDATE derivation_cache SET last_used_at = ? WHERE cache_key = ?") + .map_err(StoreError::from)?; + for key in keys { + stmt.execute(params![now, key]) + .map_err(StoreError::from) + .context("derivation_cache_touch")?; + } + } + tx.commit().map_err(StoreError::from)?; + Ok(()) + } + + /// Delete cache entries whose `last_used_at` is older than `ttl_days` + /// (§3.5 lightweight GC). Returns the number of rows removed. + /// + /// `ttl_days <= 0` is a no-op guard (never wipe the whole cache by an + /// accidental zero TTL). + pub fn derivation_cache_gc(&self, ttl_days: i64) -> Result { + if ttl_days <= 0 { + return Ok(0); + } + let cutoff = (OffsetDateTime::now_utc() - time::Duration::days(ttl_days)) + .format(&Rfc3339) + .context("format derivation_cache gc cutoff")?; + let conn = self.lock_conn(); + let removed = conn + .execute( + "DELETE FROM derivation_cache WHERE last_used_at < ?", + params![cutoff], + ) + .map_err(StoreError::from) + .context("derivation_cache_gc")?; + Ok(removed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::SqliteStore; + + fn open_store() -> (tempfile::TempDir, SqliteStore) { + let dir = tempfile::tempdir().unwrap(); + let mut cfg = kebab_config::Config::defaults(); + cfg.storage.data_dir = dir.path().to_string_lossy().into_owned(); + let store = SqliteStore::open(&cfg).unwrap(); + store.run_migrations().unwrap(); + (dir, store) + } + + #[test] + fn put_then_get_roundtrips() { + let (_d, store) = open_store(); + store + .derivation_cache_put("key1", "embedding", &[1, 2, 3, 4]) + .unwrap(); + let got = store.derivation_cache_get("key1").unwrap(); + assert_eq!(got, Some(vec![1, 2, 3, 4])); + } + + #[test] + fn get_miss_returns_none() { + let (_d, store) = open_store(); + assert_eq!(store.derivation_cache_get("absent").unwrap(), None); + } + + #[test] + fn put_replaces_existing() { + let (_d, store) = open_store(); + store.derivation_cache_put("k", "alias", b"old").unwrap(); + store.derivation_cache_put("k", "alias", b"new").unwrap(); + assert_eq!( + store.derivation_cache_get("k").unwrap(), + Some(b"new".to_vec()) + ); + } + + #[test] + fn touch_missing_keys_is_noop() { + let (_d, store) = open_store(); + store + .derivation_cache_touch(&["nope".to_string()]) + .unwrap(); + assert_eq!(store.derivation_cache_get("nope").unwrap(), None); + } + + #[test] + fn gc_zero_ttl_is_noop() { + let (_d, store) = open_store(); + store.derivation_cache_put("k", "embedding", b"x").unwrap(); + assert_eq!(store.derivation_cache_gc(0).unwrap(), 0); + assert!(store.derivation_cache_get("k").unwrap().is_some()); + } + + #[test] + fn gc_removes_stale_entries() { + let (_d, store) = open_store(); + store.derivation_cache_put("fresh", "embedding", b"x").unwrap(); + // Backdate one row by 100 days via a direct UPDATE. + let old = (OffsetDateTime::now_utc() - time::Duration::days(100)) + .format(&Rfc3339) + .unwrap(); + { + let conn = store.lock_conn(); + conn.execute( + "INSERT INTO derivation_cache (cache_key, kind, payload, created_at, last_used_at) + VALUES ('stale', 'embedding', ?, ?, ?)", + params![&b"y"[..], &old, &old], + ) + .unwrap(); + } + let removed = store.derivation_cache_gc(30).unwrap(); + assert_eq!(removed, 1); + assert!(store.derivation_cache_get("stale").unwrap().is_none()); + assert!(store.derivation_cache_get("fresh").unwrap().is_some()); + } +} diff --git a/crates/kebab-store-sqlite/src/lib.rs b/crates/kebab-store-sqlite/src/lib.rs index 8618900..e88edf5 100644 --- a/crates/kebab-store-sqlite/src/lib.rs +++ b/crates/kebab-store-sqlite/src/lib.rs @@ -19,6 +19,7 @@ mod answers; mod chat_sessions; +mod derivation_cache; mod documents; mod embeddings; mod error; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6e63013..45cd3c5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -32,6 +32,8 @@ Cargo workspace, 함수 호출 기반 모듈러 모놀리스. UI binary (`kebab- | citation 형식 | URI fragment (`path#L12-L34` / `path#p=12` / `path#xywh=0,0,100,50`, W3C Media Fragments) | | ID 생성 | `blake3(canonical_json(tuple))[..32]` hex | | RRF fusion_score | `[0, 1]` 정규화 — `2 / (k_rrf + 1)` 로 나눠 mode 간 비교 가능 (post-merge hotfix) | +| doc-side expansion 별칭 (v0.21.0) | 색인 시 LLM 이 청크별 "같은 의미 다른 표현" 별칭 생성. 별칭은 줄별 **개별 dense 벡터**(sentinel `{chunk}#alias#N`)로 색인하고 본문 벡터는 그대로 둠 (묶음 1벡터는 평균화로 희석 → 회귀, HOTFIXES 2026-05-31). boilerplate 청크는 별칭 skip. 검색 시 별칭 hit 는 `kebab-core::strip_alias_suffix` 로 원본 chunk_id 에 매핑. `[ingest.expansion]` default off (opt-in, 청크당 LLM 비용). | +| 파생물 캐시 `derivation_cache` (V012, v0.21.0) | 비싼 ingest 파생물(embedding 벡터 / 별칭 LLM 결과)을 청크 **내용 해시** 키로 SQLite 에 캐싱 → 재색인 시 내용 불변 청크는 재계산 skip. `cache_key = blake3(kind ‖ text_blake3 ‖ version_key)[:32]`; version_key 에 model/prompt/dimensions 포함 → §9 cascade 와 정합(버전 bump 시 자동 miss). 위치 기반 `chunk_id` 와 달리 내용이 같으면 문서·위치 무관 동일 키. 순수 가산 — `corpus_revision` bump 안 함, 손상/삭제돼도 정확성 영향 0(miss → 재계산). search/ask 는 `kebab.sqlite`+`lancedb` 만으로 동작하므로 외부 서버 색인 후 DB 만 복사하는 이식 워크플로 가능 (HOTFIXES 2026-05-31). | | layout | XDG (`~/.local/share/kebab/`, `~/.config/kebab/`, …) | 전체 frozen 설계는 [docs/superpowers/specs/2026-04-27-kebab-final-form-design.md](superpowers/specs/2026-04-27-kebab-final-form-design.md) 12 sections 참조. @@ -162,7 +164,7 @@ kebab/ │ ├── p8/p8-1, p8-2 # (2 — 보류) │ └── p9/p9-1 … p9-5 # (5) ├── crates/ -│ ├── kebab-core/ kebab-config/ # 도메인 + 설정 (P0) +│ ├── kebab-core/ kebab-config/ # 도메인 + 설정 (P0). kebab-core/src/derivation.rs = 파생물 캐시 키 순수 함수 (blake3 내용 해시, v0.21.0) │ ├── kebab-source-fs/ # 워크스페이스 walk + checksum (P1-1) │ ├── kebab-parse-md/ # Markdown frontmatter + blocks + types + ParsedBlock → CanonicalDocument lift (P1-2/3/4 — v0.19.0 흡수) │ ├── kebab-chunk/ # heading-aware + pdf-page-v1 + code-*-ast-v1 (Tier 1) + k8s-manifest-resource-v1 + dockerfile-file-v1 + manifest-file-v1 + tier2_shared (P10-2) + code-text-paragraph-v1 (P10-3) chunker (P1-5, P7-2, P10-1A-2, P10-1B, P10-1C-Go, P10-1C-JK, P10-2, P10-3, P10-1D) @@ -175,7 +177,7 @@ kebab/ │ │ ├── manifest_file_v1.rs # Tier 2 (p10-2): whole-file Cargo.toml / go.mod / .json / .xml / .groovy │ │ ├── code_text_paragraph_v1.rs # Tier 3 (p10-3): blank-line paragraph + 80/20 line-window fallback │ │ └── tier2_shared.rs # Tier 2 (p10-2): shared oversize fallback + Chunk builder helpers -│ ├── kebab-store-sqlite/ # SQLite + FTS5 (V001/V002/V003) (P1-6, P2-1, P3-3) +│ ├── kebab-store-sqlite/ # SQLite + FTS5 (V001/V002/V003) (P1-6, P2-1, P3-3). src/derivation_cache.rs = derivation_cache 테이블 저장소 (V012, v0.21.0) │ ├── kebab-search/ # Lexical + Vector + Hybrid retriever (P2-2, P3-4) │ ├── kebab-embed/ kebab-embed-local/ # Embedder trait + fastembed adapter (P3-1, P3-2) │ ├── kebab-store-vector/ # LanceDB VectorStore (P3-3, P7-3 follow-up) @@ -186,11 +188,11 @@ kebab/ │ ├── kebab-parse-image/ # ImageExtractor + Ollama OCR + caption (P6) │ ├── kebab-parse-pdf/ # lopdf per-page text extractor (P7-1) │ ├── kebab-parse-code/ # tree-sitter AST extractors: Rust (P10-1A-2), Python + TypeScript + JavaScript (P10-1B), Go (P10-1C-Go), Java + Kotlin (P10-1C-JK — java.rs + kotlin.rs), C + C++ (P10-1D — c.rs + cpp.rs); chunker lives in kebab-chunk -│ ├── kebab-app/ # facade (P0 시그니처 + P3-5/P6-4/P7-3 본체) +│ ├── kebab-app/ # facade (P0 시그니처 + P3-5/P6-4/P7-3 본체). src/expansion.rs = 별칭 생성, src/derivation_payload.rs = 캐시 payload 인코딩 (v0.21.0) │ ├── kebab-tui/ # Ratatui shell + Library 패널 (P9-1) │ ├── kebab-mcp/ # stdio MCP server — tools: schema, doctor, search, ask (P9-FB-30) │ └── kebab-cli/ # binary (P0 → 핫픽스로 --config flag wiring 강화) -├── migrations/ # SQLite refinery V001/V002/V003 +├── migrations/ # SQLite refinery V001..V012 (V012 = derivation_cache, v0.21.0) └── fixtures/ # 테스트 fixture 트리 ``` diff --git a/docs/superpowers/handoffs/2026-05-31-namu-wiki-alias-cache-study.md b/docs/superpowers/handoffs/2026-05-31-namu-wiki-alias-cache-study.md new file mode 100644 index 0000000..26d32de --- /dev/null +++ b/docs/superpowers/handoffs/2026-05-31-namu-wiki-alias-cache-study.md @@ -0,0 +1,108 @@ +# 나무위키 대규모 측정 — doc-side expansion 별칭 효과 + 파생물 캐시 + +> 2026-05-31. Phase 2 doc-side expansion(별칭) 의 효과를 실사용 규모(한국어 나무위키 +> corpus)로 검증하고, 그 과정에서 드러난 별칭 생성 비용 문제를 "내용 해시 기반 파생물 +> 캐시"로 해결한 기록. 선행: `2026-05-30-phase2-doc-expansion-kickoff.md`, +> 설계: `../specs/2026-05-30-dense-alias-vectors-design.md`, +> `../specs/2026-05-31-derivation-cache-design.md`. + +## 1. 출발 질문 (사용자 제기) + +측정을 진행하며 사용자가 던진 질문들이 설계를 단계적으로 교정했다: + +1. **"테스트 모수가 너무 적지 않나? 더 넓게(대규모, 영+한 혼합) 테스트하자."** + → 기존 8~32개 golden 으로는 "변형 일관성 개선"이 우연인지 실재인지 판단 불가. +2. **"실사용은 약 2천 개 한국어 위키 문서다."** + 기존 크롤링한 나무위키 parquet + (`/build/cache/namu-crawler/pages.parquet`, 119만 문서) 제공. + → 측정 corpus 를 실사용에 맞춤. 노이즈는 크게, 별칭은 정답 문서에만(비용). +3. **"정답과 주제가 완전히 다르면(야구·게임) 검색이 너무 쉬워 별칭 효과가 과소평가된다. + 실사용은 한 개발조직 위키 = 유사 주제 밀집이다."** + → 노이즈를 정답과 같은 분야(CS/IT)로 교체. 진짜 어려운 "유사 경쟁" 환경 구성. +4. **"대조군(정답 없는 질문)도 측정하자."** → false-positive(별칭이 노이즈를 grounded + answer 로 끌어오는지) 검증. +5. **"별칭 벡터 생성이 너무 오래 걸린다(18문서 2.5시간). 캐싱이 절실하다 — 별칭뿐 아니라 + 비용 큰 모든 데이터에."** → 내용 해시 기반 파생물 캐시 설계·구현. +6. **"비싼 계산을 외부 CPU ollama 서버에서 하고 결과 DB 파일만 가져오고 싶다. 가능한가?"** + → KB 이식성 검증. + +## 2. corpus 구축 + +- 소스: 나무위키 덤프 119만 문서(`pages.parquet`, redirect 제외 완료). +- **노이즈 979개**: 본문 3k~30k자 + "분류" 헤더에 CS 키워드(컴퓨터공학·프로그래밍·알고리즘 + …)가 있는 문서 ~70% 정밀도로 필터 → 무작위 샘플(CCleaner·LLaMA·SQL·멀티스레딩 등). + 정답과 같은 임베딩 공간(유사 주제 밀집)이라 현실적 난이도. +- **정답 18개**: 명확한 CS 개념(경사하강법·TCP·정렬·이진탐색·뮤텍스·정규표현식 …), + 전부 한국어 문서 → 영어 변형은 자동으로 cross-lingual(영→한) 시나리오. +- **변환 핵심 교훈**: nawiki `text_extracted` 는 **개행 0**인 한 덩어리라 md 청커(단락 + 경계 분할)가 거대 청크(4000+토큰)를 만들어 e5 512토큰 한계에서 잘렸다. → `html` + 컬럼을 pandoc(`-f html -t markdown_strict-raw_html`)으로 변환 + base64/링크 정제 → + 헤딩·단락 구조 복원 → 청크 중앙값 272토큰으로 정상화. +- golden: 변형 18그룹 × 4변형(한국어 용어 / 영어 용어 / 동의어·약어 / 설명형) + 대조군 10 + (`/build/dogfood/namu_golden.yaml`). + +## 3. 측정 결과 + +### 3.1 변형 일관성 (search run, hybrid k=50) + +| 구성 | fully_consistent | A(MisRanked) | B(Missing) | mean_spread@10 | +|------|------------------|--------------|------------|----------------| +| baseline (별칭 off) | 14/18 | 2 | 2 | 0.222 | +| 별도-벡터 (별칭 묶음 1벡터) | 13/18 | 2 | 3 | 0.278 (악화) | +| **개선 (별칭 개별 벡터 + boilerplate skip)** | **16/18** | 1 | 1 | **0.111** | + +- baseline 약점은 **전부 "설명형" 변형**(용어·약어·영어는 18그룹 전부 완벽). 자연어 설명이 + 문서 전문용어와 어휘가 멀어 벡터 검색이 못 잡음 = "어휘 격차". +- **별도-벡터(묶음)가 오히려 악화**한 원인 진단: ① 청크당 별칭 8개를 줄바꿈으로 묶어 한 + 벡터로 임베딩 → 평균화로 특정 표현 **희석** ② 나무위키 메뉴(boilerplate) 청크에도 별칭 + 생성 → 18문서 공통 노이즈. +- **개선판**: 별칭을 줄별 **개별 sentinel 벡터**(`{orig}#alias#N`) + boilerplate 청크 skip. + → linked_list·sorting 회복, tcp 회귀 복구. 남은 약점은 stack·svm 설명형 2개. + +### 3.2 대조군 (RAG run, refusal_correctness) + +- refusal 0.6 (대조군 10개 중 6개 정상 거부, 4개 grounded). +- **false-positive 4개(graphql·oauth·react·grpc)의 인용 출처는 전부 노이즈 본문** + (GitHub_Mobile·API·Svelte), **별칭 sentinel 인용 0** → 별칭이 false-positive 를 + 유발하지 않음(별칭 무죄). 게다가 answer 는 "근거에서 찾을 수 없다"고 정직히 거부했는데 + grounded 판정이 "부분 언급 인용 있음"을 grounded 로 오분류 → 실제 refusal 은 0.6 보다 높음. + (kebab grounded/refusal 판정의 별도 개선 여지 — HOTFIXES 후보.) + +### 3.3 정답 RAG + +- 변형 72개 중 대부분 grounded=True + 정답 문서 다수 인용(sort 28·linked_list 23 등). 양호. + +## 4. 파생물 캐시 (V012) + +별칭 18문서 재생성 2.5시간이 근본 병목. `chunk_id` 가 `ordinal+span`(위치) 기반이라 +chunk_id 캐싱은 중간 수정 시 무력 → **청크 text 내용 해시**를 키로 한 범용 캐시 설계. + +- `derivation_cache(cache_key, kind, payload, created_at, last_used_at)` (SQLite, V012). +- `cache_key = blake3(kind ‖ text_blake3 ‖ version_key)`. version_key 에 model/prompt/ + dimensions 포함 → §9 cascade 와 정합(버전 bump 시 자동 miss). +- 적용: embedding(본문 + 별칭 벡터 양쪽) + 별칭 LLM. korean_tokens 는 우선순위 낮아 보류. +- **측정: 정답 3개 cold 1879초(31분) → warm 13초 ≈ 145배.** 18문서 환산 시 2.5h → ~80s. + derivation_cache 1237 엔트리(alias 140 + embedding 1097). + +## 5. KB 이식성 (외부 계산 워크플로) + +- `storage_path`(asset 절대경로)는 search/ask 경로에서 **사용처 0** — 저장·재처리에서만. +- **search/ask 는 `kebab.sqlite` + `lancedb` 만으로 동작**(asset 불필요). +- 실증: 원본 KB 와 다른 경로로 복사한 portable KB(asset 제외)의 search 결과가 score·순서· + 문서까지 **완전 동일**. +- 결론 워크플로: + ``` + [외부 CPU ollama 서버] 같은 corpus + 같은 e5 모델/버전 + 같은 parser/chunker/embedding 버전 + kebab ingest → 별칭 LLM + embedding (비싼 계산, 캐시 워밍) + ↓ kebab.sqlite(+derivation_cache) + lancedb/ 만 복사 + [로컬] kebab search/ask → 계산 0. 증분 수정 시 외부 캐시가 머신 독립적으로 히트. + ``` + +## 6. 결정 / 후속 + +- **채택**: 별칭 개별 sentinel 벡터 + boilerplate skip(효과·안전 입증) + 파생물 캐시(V012). +- **보류**: stack·svm 설명형 2그룹 추가 개선, korean_tokens 캐시, 이식용 캐시 export/import + 명령, 별칭 default-on 여부(현재 off-by-default, 실사용 관찰 후 재결정). +- **별도 이슈**: grounded/refusal 판정이 부분 인용을 grounded 로 오분류 — 정직한 거부가 + false-positive 로 집계됨. +- 측정 데이터: corpus `/build/dogfood/corpus/markdown/namu-wiki/`, + golden `/build/dogfood/namu_golden.yaml`, 로그 `/build/dogfood/logs/`. diff --git a/docs/superpowers/specs/2026-05-31-derivation-cache-design.md b/docs/superpowers/specs/2026-05-31-derivation-cache-design.md new file mode 100644 index 0000000..2cbb106 --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-derivation-cache-design.md @@ -0,0 +1,155 @@ +# 내용 해시 기반 파생물 캐시 (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:160) 때문에 **위치 기반**이라, +chunk_id 를 캐시 키로 쓰면 중간 수정 시 뒤 청크가 전부 무효화된다 → 캐시 키는 +**청크 text 의 내용 해시**여야 위치와 무관하게 재사용된다. + +## 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 별, 버전 변경 시 캐시 무효화): + - embedding: `{model_id}|{model_version}|{dimensions}` + - alias: `{prompt_version}|{max_aliases_per_chunk}|{model}` (model="" 면 LLM 기본) + - korean_tokens: `{tokenizer_version}` (현재 lindera 고정 → 상수 "lindera-v1"; + 추후 토크나이저 교체 시 bump) + +text 내용이 같고 버전이 같으면 문서·위치·chunk_id 와 무관하게 동일 cache_key. + +### 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/청크). +- alias: 별칭 묶음 문자열의 UTF-8 (현행 `chunk.aliases` 와 동일 형식 — 줄바꿈 join). +- korean_tokens: 토큰 문자열 UTF-8. + +### 3.4 ingest 흐름 변경 (kebab-app lib.rs) + +각 파생물 생성 직전에 캐시를 조회한다. 의사코드: +```rust +// --- 별칭 (lib.rs ~1259) --- +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)? { // 히트 + chunk.aliases = Some(String::from_utf8(p)?); + } else if is_nav_boilerplate(chunk) { // (기존 skip 규칙 유지) + chunk.aliases = None; + } else { // 미스 → LLM + chunk.aliases = generator.generate(chunk); + if let Some(a) = &chunk.aliases { cache.put(&key, "alias", a.as_bytes())?; } + } + } +} + +// --- embedding (lib.rs ~1309) --- +// 1) 각 청크 cache_key 계산 → 히트/미스 분리 +// 2) 미스 청크만 emb.embed(&miss_inputs) (배치 축소) +// 3) 미스 결과를 캐시에 put +// 4) 히트 vector + 미스 vector 를 합쳐 VectorRecord 생성 → lance upsert +// (별칭 dense 벡터도 동일하게 alias text 의 embedding 을 캐시; 별칭 개별 벡터는 +// 각 별칭 문자열 text 로 embedding cache_key 재사용 → 별칭 임베딩도 캐시 적중) + +// --- korean_tokens (chunker 내부 또는 호출부) --- +// tokenize 직전 cache 조회, 미스만 lindera 호출. +``` + +핵심: **embedding 캐시는 청크 본문 + 별칭 문자열 양쪽에 적용**된다. 별칭 dense 벡터도 +"같은 별칭 문자열"이면 재사용된다(별칭 LLM 캐시 + 별칭 임베딩 캐시 2중 절감). + +### 3.5 무효화 / 정리 +- **버전 무효화**: version_key 가 cache_key 에 포함 → model/prompt/tokenizer 버전이 bump + 되면 새 키가 되어 자동 miss(옛 엔트리는 고아). §9 cascade 와 자동 정합. +- **고아 정리**: `kebab doctor` 또는 ingest 종료 시, `last_used_at` 이 N일(기본 30) 지난 + 엔트리를 삭제하는 경량 GC. 또는 테이블 행수가 임계(기본 50만) 초과 시 LRU 삭제. + (정리 정책은 plan 에서 상수화; 초기엔 30일 TTL 만.) +- 캐시는 **순수 성능 레이어** — 손상/삭제되어도 정확성 영향 없음(miss → 재계산). + `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` — `DerivationCache` 저장소: `get(key) -> Option>`, + `put(key, kind, payload)`, `touch(keys)`(last_used 갱신), `gc(ttl_days)`. + `DocumentStore` 또는 별도 trait. +- `kebab-app` lib.rs ingest hook — 별칭/embedding 캐시 조회·저장 통합. embedding 미스 + 배치 분리 로직. +- `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. 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 로 보장. diff --git a/migrations/V012__derivation_cache.sql b/migrations/V012__derivation_cache.sql new file mode 100644 index 0000000..dc01406 --- /dev/null +++ b/migrations/V012__derivation_cache.sql @@ -0,0 +1,22 @@ +-- V012__derivation_cache.sql — 내용 해시 기반 파생물 캐시 (Derivation Cache). +-- +-- 설계 spec docs/superpowers/specs/2026-05-31-derivation-cache-design.md §3.2. +-- 비용 큰 ingest 파생물(embedding 벡터 / LLM 별칭 / 선택적 한국어 형태소)을 +-- 청크 text 의 *내용 해시* 키로 캐싱해, 문서 갱신·재색인 시 변경되지 않은 +-- 청크의 재계산을 없앤다. cache_key = blake3(kind ‖ text_blake3 ‖ version_key)[:32] +-- (§3.1) — 위치 기반 chunk_id 와 달리 내용이 같으면 문서·위치 무관 동일 키. +-- +-- 순수 가산(additive): 기존 데이터를 무효화하지 않으므로 corpus_revision 을 +-- bump 하지 않는다(§3.2). 캐시는 순수 성능 레이어 — 손상/삭제되어도 정확성 +-- 영향 없음(miss → 재계산). `kebab reset` 시 같은 sqlite 라 함께 비워진다. + +CREATE TABLE derivation_cache ( + cache_key TEXT PRIMARY KEY, -- §3.1 blake3 32-hex + 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/TTL 정리용 (§3.5) +); + +CREATE INDEX idx_dcache_kind ON derivation_cache(kind); +CREATE INDEX idx_dcache_last_used ON derivation_cache(last_used_at); diff --git a/tasks/HOTFIXES.md b/tasks/HOTFIXES.md index 95172e3..c219e13 100644 --- a/tasks/HOTFIXES.md +++ b/tasks/HOTFIXES.md @@ -14,6 +14,38 @@ historical contract that was implemented; this file accumulates the deltas so phase 5+ readers can find the live behavior without diffing git history. +## 2026-05-31 — doc-side expansion 별칭 개선 + 파생물 캐시(V012) + +**Trigger**: Phase 2 doc-side expansion(별칭) 효과를 실사용 규모(한국어 나무위키 ~1000 문서 CS corpus)로 검증하고, 그 과정에서 드러난 별칭 생성 비용을 "내용 해시 기반 파생물 캐시"로 해소. v0.21.0 cut. 측정 상세: `docs/superpowers/handoffs/2026-05-31-namu-wiki-alias-cache-study.md`, 설계: `docs/superpowers/specs/2026-05-31-derivation-cache-design.md`. + +### (a) 별칭 개별 dense 벡터 + boilerplate skip + +초기 별도-벡터(청크당 별칭 8개를 줄바꿈으로 묶어 한 벡터로 임베딩) 방식은 평균화로 특정 표현이 **희석**되고 나무위키 메뉴(boilerplate) 청크에도 별칭이 생성돼 **오히려 회귀**(13/18). 개선판은 별칭을 줄별 **개별 sentinel 벡터**(`{chunk}#alias#N`)로 색인하고 본문 벡터는 그대로 두며, boilerplate 청크는 별칭 생성을 skip 한다. `kebab-core::strip_alias_suffix` 가 suffix 형(`{orig}#alias`)과 per-alias 형(`{orig}#alias#N`) 둘 다 처리(bare chunk_id 는 `#` 없는 blake3 32-hex 라 첫 `#alias` 가 경계). + +| 구성 | fully_consistent | mean_spread@10 | +|------|------------------|----------------| +| baseline (별칭 off) | 14/18 | 0.222 | +| 별도-벡터 (별칭 묶음 1벡터) | 13/18 | 0.278 (악화) | +| **개선 (별칭 개별 벡터 + boilerplate skip)** | **16/18** | **0.111** | + +baseline 약점은 전부 "설명형" 변형(용어·약어·영어는 18그룹 완벽) = 자연어 설명과 문서 전문용어의 "어휘 격차". 개선판이 linked_list·sorting 회복 + tcp 회귀 복구. 파일: `crates/kebab-core/src/ids.rs` (`strip_alias_suffix` find 기반), `crates/kebab-app/src/lib.rs`, `crates/kebab-app/src/expansion.rs`. `[ingest.expansion]` default off (opt-in). + +### (b) 대조군 false-positive — 별칭 무죄 + +대조군(정답 없는 질문) 10개 RAG run 에서 refusal 0.6 (4개 grounded). false-positive 4개(graphql·oauth·react·grpc)의 인용 출처는 **전부 노이즈 본문**(GitHub_Mobile·API·Svelte 등), **별칭 sentinel 인용 0** → 별칭이 false-positive 를 유발하지 않음(별칭 무죄, default-on 안전성 근거). + +### (c) 파생물 캐시 145배 + 외부 계산 이식 워크플로 + +별칭 18문서 재생성 2.5시간이 근본 병목. `chunk_id` 가 위치(`ordinal+span`) 기반이라 chunk_id 캐싱은 중간 수정 시 무력 → 청크 text **내용 해시**를 키로 한 범용 캐시(V012). `cache_key = blake3(kind ‖ text_blake3 ‖ version_key)[:32]`, version_key 에 model/prompt/dimensions 포함 → §9 cascade 와 자동 정합(버전 bump 시 자동 miss). embedding(본문 + 별칭 벡터 양쪽) + 별칭 LLM 결과 캐싱. **측정: 정답 3개 cold 1879s → warm 13s ≈ 145배**(18문서 환산 2.5h → ~80s). `corpus_revision` 은 bump 안 함(순수 가산). 파일: `migrations/V012__derivation_cache.sql`, `crates/kebab-core/src/derivation.rs`, `crates/kebab-store-sqlite/src/derivation_cache.rs`, `crates/kebab-app/src/derivation_payload.rs`. + +**이식**: search/ask 는 `kebab.sqlite` + `lancedb` 만으로 동작(`storage_path` asset 은 search/ask 경로에서 사용처 0). 비싼 색인(별칭 LLM + embedding)을 외부 CPU ollama 서버에서 돌린 뒤 sqlite(+derivation_cache) + lancedb 만 로컬로 복사하면 동일 동작 + 증분 캐시 히트가 머신 독립적으로 적용. + +### Known limitation + +- **stack·svm 설명형 잔존**: 개선 후에도 2개 설명형 변형은 별칭으로 못 메움(추가 개선 보류). +- **grounded/refusal 오분류**: answer 가 "근거에서 찾을 수 없다"고 정직히 거부했는데도 부분 언급 인용이 있으면 grounded 로 오분류 → 실제 refusal 은 0.6 보다 높음. kebab grounded/refusal 판정의 별도 개선 여지(후속 후보). +- **korean_tokens 캐시 / export-import 명령 / 별칭 default-on**: 보류. + ## 2026-05-29 — v0.20.2 dogfood findings + 검색 품질 baseline **Trigger**: v0.20.2 release 준비 8-finding dogfood 라운드 (2026-05-29). 구현 + eval + 도그푸딩 전부 완료.