feat(expansion): doc-side expansion 별칭 개별 dense 벡터 + 파생물 캐시(V012)
별칭을 줄별 개별 dense 벡터(sentinel `{chunk}#alias#N`)로 색인하고
boilerplate 청크는 별칭 생성을 skip. 묶음 1벡터 방식은 평균화로 특정
표현이 희석돼 오히려 회귀(13/18)했던 것을 폐기. 변형 일관성 14/18 →
16/18, mean_spread@10 0.222 → 0.111 (나무위키 ~1000 문서 CS corpus).
`kebab-core::strip_alias_suffix` 가 suffix 형과 per-alias 형 둘 다 처리.
파생물 캐시(V012): embedding 벡터 + 별칭 LLM 결과를 청크 내용 해시
키로 캐싱해 재색인 시 내용 불변 청크의 재계산을 skip. cache_key =
blake3(kind ‖ text_blake3 ‖ version_key)[:32], version_key 에
model/prompt/dimensions 포함 → §9 cascade 와 정합(버전 bump 시 자동
miss). 측정: 정답 3개 cold 1879s → warm 13s ≈ 145배. 순수 가산이라
corpus_revision bump 없음. search/ask 는 kebab.sqlite+lancedb 만으로
동작 → 외부 서버 색인 후 DB 만 복사하는 이식 워크플로 가능.
V012 schema migration + 신규 surface 로 workspace version 0.20.2 →
0.21.0 (minor) bump. README/HANDOFF/ARCHITECTURE/HOTFIXES sync.
known limitation: stack·svm 설명형 2개 잔존 + grounded 판정이 부분
인용을 grounded 로 오분류(후속 후보).
측정 상세: docs/superpowers/handoffs/2026-05-31-namu-wiki-alias-cache-study.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
44
Cargo.lock
generated
44
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 <path>` flag — 임시 워크스페이스 / 격리 테스트 시 사용. CLI / TUI 모두 honor.
|
||||
- `KEBAB_*` env — 일부 키 override (`KEBAB_RAG_SCORE_GATE`, `KEBAB_EVAL_GOLDEN`, `KEBAB_COMMIT_HASH` 등).
|
||||
|
||||
61
crates/kebab-app/src/derivation_payload.rs
Normal file
61
crates/kebab-app/src/derivation_payload.rs
Normal file
@@ -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<u8> {
|
||||
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<Vec<f32>> {
|
||||
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::<u8>::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]);
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
// 나무위키 네비게이션 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<String>) -> 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("- 메모리"), "메모리");
|
||||
|
||||
@@ -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<f32>` little-endian 으로 캐시 put.
|
||||
/// 4) 히트(bytes→Vec<f32>) + 미스 벡터를 **원래 순서대로** 합쳐 반환.
|
||||
///
|
||||
/// 손상된 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<String>,
|
||||
) -> anyhow::Result<Vec<Vec<f32>>> {
|
||||
let mut out: Vec<Option<Vec<f32>>> = Vec::with_capacity(texts.len());
|
||||
let mut miss_indices: Vec<usize> = Vec::new();
|
||||
let mut miss_inputs: Vec<EmbeddingInput<'_>> = Vec::new();
|
||||
let mut keys: Vec<String> = 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<String> = 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<EmbeddingInput<'_>> = 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<String> = 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<VectorRecord> = 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<EmbeddingInput<'_>> = 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<String, usize> =
|
||||
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 {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
110
crates/kebab-core/src/derivation.rs
Normal file
110
crates/kebab-core/src/derivation.rs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
192
crates/kebab-store-sqlite/src/derivation_cache.rs
Normal file
192
crates/kebab-store-sqlite/src/derivation_cache.rs
Normal file
@@ -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<Option<Vec<u8>>> {
|
||||
let conn = self.lock_conn();
|
||||
let payload: Option<Vec<u8>> = conn
|
||||
.query_row(
|
||||
"SELECT payload FROM derivation_cache WHERE cache_key = ?",
|
||||
params![cache_key],
|
||||
|row| row.get::<_, Vec<u8>>(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<usize> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
mod answers;
|
||||
mod chat_sessions;
|
||||
mod derivation_cache;
|
||||
mod documents;
|
||||
mod embeddings;
|
||||
mod error;
|
||||
|
||||
@@ -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 트리
|
||||
```
|
||||
|
||||
|
||||
@@ -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/`.
|
||||
155
docs/superpowers/specs/2026-05-31-derivation-cache-design.md
Normal file
155
docs/superpowers/specs/2026-05-31-derivation-cache-design.md
Normal file
@@ -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<Vec<u8>>`,
|
||||
`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 로 보장.
|
||||
22
migrations/V012__derivation_cache.sql
Normal file
22
migrations/V012__derivation_cache.sql
Normal file
@@ -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);
|
||||
@@ -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 + 도그푸딩 전부 완료.
|
||||
|
||||
Reference in New Issue
Block a user