From 098413922b86c60156000a210bd52e2b195a5696 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 30 May 2026 01:54:46 +0000 Subject: [PATCH 01/27] =?UTF-8?q?docs(spec):=20=EC=83=89=EC=9D=B8=EC=8B=9C?= =?UTF-8?q?=20doc-side=20expansion=20=EC=84=A4=EA=B3=84=20spec=20(Phase=20?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit brainstorm 확정: 청크당 별칭 생성(같은언어+한↔영 번역), additive+수동 재색인, 1차 단순 품질제어. 별도 FTS5 aliases 채널 → RRF 3채널 융합. flag off 기본, kebab eval variants 로 on/off 측정. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-05-30-doc-side-expansion-design.md | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md diff --git a/docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md b/docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md new file mode 100644 index 0000000..76ae6eb --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md @@ -0,0 +1,204 @@ +--- +title: 색인시 doc-side expansion (검색용 별칭 생성) — 설계 spec +date: 2026-05-30 +status: 설계 확정 (brainstorm 완료) — plan 대기 +phase: Phase 2 (query-paraphrase robustness 처방) +related: + - docs/superpowers/handoffs/2026-05-30-phase2-doc-expansion-kickoff.md + - docs/superpowers/research/2026-05-30-vocabulary-gap-recall-fix-research.md + - docs/superpowers/specs/2026-05-29-query-paraphrase-robustness-eval-design.md + - memory: project_paraphrase_robustness, project_crossscript_diagnosis, feedback_search_quality_dogfood +contract_sections: + - "design §6 (retrieval / hybrid fusion)" + - "design §9 (versioning cascade)" +--- + +# 색인시 doc-side expansion — 설계 spec + +## 0. 한 줄 요약 + +문서를 색인할 때(ingest) 각 청크마다 로컬 LLM(gemma)에게 "이 내용을 찾을 사람이 던질 법한 다른 +표현·질문"(같은언어 paraphrase + 한↔영 번역 별칭)을 **1회** 생성하게 해, **별도 FTS5 채널**에 +저장한다. 검색 시 RRF 가 `{body-BM25, aliases-BM25, e5-dense}` 3채널을 융합한다. 어휘격차(B)로 +정답이 top-50 pool 에도 안 들어오던 실패(`recall@50=0`)를 lexical pool 자체를 키워 해결하는 게 목표. +**flag off 기본**, on/off 를 `kebab eval variants` 로 정량 비교한다. + +## 1. 배경 / 문제 (압축) + +- Phase 1 진단: 같은 의미를 다른 단어로 물으면 정답이 top-50 pool 에도 안 들어옴(`recall@50=0`). + rerank 는 pool 안 순서만 바꿔 무력(`[[project_rerank_experiment]]` 가설 반증). +- 딥리서치(104 agent, 적대검증): pool-miss 의 최선책 = **색인시 doc-side expansion**. query-side + (HyDE=거부된 per-query LLM, Vector-PRF=recall 주장 기각) 부적합. learned-sparse(SPLADE/MILCO) + CPU/Rust turnkey 경로 없음. +- 핵심 함정: vanilla mt5 doc2query 는 *같은 언어* query 만 생성 → 한/영 갭 못 메움. 따라서 색인시 + **KO↔EN 번역 별칭**을 함께 생성해야 함 (research §1.2). 이 교차언어 부분은 직접 벤치 논문 없는 + **합성 권고** → 우리 corpus 측정 필수. + +## 2. 설계 결정 (brainstorm 확정) + +| # | 결정 | 선택 | 근거 | +|---|------|------|------| +| D1 | 별칭 생성 단위 | **청크당 1회** | 각 조각의 세부 내용에 맞는 정밀 별칭. ingest 느려지나 효과 측정이 1순위(§4.6 측정 규율). | +| D2 | 별칭 내용 | **같은언어 paraphrase + 한↔영 번역**, 1 LLM 호출 | 진단상 영어 paraphrase 도 miss(어휘 거리), 한/영 갭은 번역 별칭으로만 메움. 한 호출로 둘 다 → 추가 호출비용 0. | +| D3 | 기존 문서 처리 | **additive + 수동 재색인** | 별칭은 "있으면 쓰고 없으면 본문만". flag on 이 전체 자동 재색인을 트리거하지 않음. `--force` 로 원할 때 재생성. 측정은 dogfood reset→reingest 로 통제. | +| D4 | 품질 제어 (1차) | **단순**: 개수 상한 + 형식 검증만 | 정교한 환각 필터(임베딩 유사도, Doc2Query--)는 research openQuestion 3 = 측정 대상. 1차는 단순히 만들고 환각·팽창이 실제 문제인지 측정 후 결정. | + +## 3. 아키텍처 + +### 3.1 데이터 흐름 + +``` +ingest_one_asset (kebab-app/src/lib.rs:~1253) + chunks = MdHeadingV1Chunker.chunk(&canonical, policy)? + │ + ├─ [NEW] if config.ingest.expansion.enabled: + │ for chunk in &mut chunks: + │ aliases = ExpansionGenerator.generate(chunk.text)? # gemma 1회/청크 + │ chunk.aliases = Some(aliases) # 상한·형식검증 적용 + │ + app.sqlite.put_chunks(doc_id, &chunks)? # chunks.aliases 컬럼 저장 + │ + (V010 trigger) → chunks_fts.aliases 컬럼 색인 + │ + embedder.embed(...) → vec_store.upsert(...) # dense는 body text 기준 (변경 없음) + +검색 (kebab-search/src/hybrid.rs fuse): + body-lex = chunks_fts MATCH on text 컬럼 (rank_body) + alias-lex = chunks_fts MATCH on aliases 컬럼 (rank_alias) # [NEW] + vec = LanceDB e5-dense (rank_vec) + RRF: score(c) = 1/(k+rank_body) + 1/(k+rank_alias) + 1/(k+rank_vec) +``` + +### 3.2 컴포넌트 (단위별 책임) + +- **`ExpansionGenerator`** (kebab-app, LLM trait 경계로 mock 가능) + - 입력: 청크 본문(+ heading_path 컨텍스트), config(model, max_aliases, prompt_version). + - 출력: 검증된 별칭 문자열(개행/공백 join). 빈 출력·과길이 drop, 개수 상한 적용. + - 의존: `kebab_llm::LanguageModel` (기존 `OllamaLanguageModel` 재사용). LLM 호출 실패 시 + 해당 청크는 별칭 없이 진행(ingest 비중단 — fail-soft). +- **V010 migration** — `chunks.aliases TEXT` 컬럼 + `chunks_fts` 에 `aliases` 컬럼 + trigger 3종 + (`chunks_ai/ad/au`) 개정. 한국어 별칭도 본문과 동일 토크나이즈 정책 적용(V009 호환). +- **`fuse` 3채널 확장** (kebab-search/hybrid.rs) — 기존 2채널 → 3채널. alias 채널은 + `chunks_fts` 의 `aliases` 컬럼만 MATCH(FTS5 column filter). 별칭 없는 청크는 alias 채널에서 + 안 잡힘 → additive 보장. +- **config `[ingest.expansion]`** — `IngestExpansionCfg`: + - `enabled: bool` (default **false**) + - `model: String` (default = `models.llm.model`) + - `max_aliases_per_chunk: usize` (default 8) + - `prompt_version: String` (default `expansion-v1`) + - env override: `KEBAB_INGEST_EXPANSION_ENABLED`, `KEBAB_INGEST_EXPANSION_MODEL`, + `KEBAB_INGEST_EXPANSION_MAX_ALIASES`, `KEBAB_INGEST_EXPANSION_PROMPT_VERSION`. + +### 3.3 격리 / 코드 식별자 보존 (load-bearing) + +- `text`(body) 컬럼은 **verbatim 유지**. 별칭은 `aliases` 별도 컬럼/채널 → body BM25 매칭과 + RRF 채널이 독립. 코드 식별자(`Vec::with_capacity`)의 정확매칭이 별칭 노이즈에 오염되지 않음. +- dense(e5) 임베딩은 **body text 기준 그대로** — 별칭을 임베딩에 넣지 않음(research: e5 dense + 유지, bge-m3 dense 는 실측 더 나빴음). 별칭은 lexical 채널에만 기여. + +## 4. 스키마 / migration (V010) + +현재 최신 = V009. 신규 = **V010__chunk_aliases.sql**. + +```sql +-- 1) chunks 테이블에 별칭 컬럼 +ALTER TABLE chunks ADD COLUMN aliases TEXT; -- nullable; 미생성/flag off = NULL + +-- 2) FTS5 가상 테이블에 aliases 컬럼 추가 +-- (FTS5 는 ALTER ADD COLUMN 미지원 → drop & recreate & rebuild) +DROP TABLE chunks_fts; +CREATE VIRTUAL TABLE chunks_fts USING fts5( + chunk_id UNINDEXED, + doc_id UNINDEXED, + heading_path, + text, + aliases, -- [NEW] 별도 lexical 채널 + tokenize = 'unicode61' -- V009 와 동일 +); + +-- 3) trigger 3종 개정 (aliases 포함; body 는 V009 의 tokenized_korean 합성 유지) +DROP TRIGGER chunks_ai; DROP TRIGGER chunks_ad; DROP TRIGGER chunks_au; +CREATE TRIGGER chunks_ai AFTER INSERT ON chunks BEGIN + INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text, aliases) + VALUES (new.chunk_id, new.doc_id, new.heading_path_json, + CASE WHEN new.tokenized_korean_text IS NOT NULL + THEN new.tokenized_korean_text || ' ' || new.text + ELSE new.text END, + COALESCE(new.aliases, '')); +END; +-- chunks_ad: DELETE FROM chunks_fts WHERE chunk_id = old.chunk_id; +-- chunks_au: ad + ai 합성 (DELETE then INSERT) + +-- 4) 기존 행 재색인 (aliases 는 전부 NULL→'' 이므로 본문 색인 동일, 무영향) +INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text, aliases) + SELECT chunk_id, doc_id, heading_path_json, + CASE WHEN tokenized_korean_text IS NOT NULL + THEN tokenized_korean_text || ' ' || text ELSE text END, + COALESCE(aliases, '') + FROM chunks; +``` + +- migration 은 refinery 자동 embed/apply. **migration = breaking schema change** → CLAUDE.md + §Release / Dogfood trigger 발동(V010, dogfood + release notes). +- `kebab_core::Chunk` 에 `aliases: Option` 필드 추가. `put_chunks` INSERT 에 컬럼 추가. + +## 5. gemma 프롬프트 (expansion-v1) + +청크 본문 + heading_path 를 주고, **검색 별칭만** 줄 단위로 출력하게 한다(설명·번호 금지). +같은언어 표현 + 반대언어(한↔영) 번역을 섞어 최대 `max_aliases_per_chunk` 개. + +요지(plan 단계에서 정확한 문구·few-shot 확정): +- "다음 문단을 검색할 사용자가 쓸 법한 짧은 질의/표현을 생성하라. 동의어·풀어쓴 표현 포함. + 문단이 한국어면 영어 표현도, 영어면 한국어 표현도 섞어라. 한 줄에 하나, 설명 없이." +- 출력 파싱: 줄 단위 split → trim → 빈 줄/번호접두/과길이(예: >120자) drop → 상한 N개. +- 결정성: `temperature` 낮게, `seed` 고정(config 의 llm seed 재사용) → 재색인 재현성. + +## 6. versioning cascade (design §9) + +- 별칭은 **additive** → `try_skip_unchanged`(kebab-app:~886) 의 기존 5버전(parser/chunker/ + embedding…) 판단에 **넣지 않는다**. 즉 flag 토글이 전체 문서를 stale 로 만들지 않음(D3). +- `expansion_version`(= `prompt_version`)을 documents 레코드에 기록(추적용). 프롬프트가 바뀌면 + 추후 재생성 대상 식별 가능. 단 자동 cascade 는 걸지 않음(수동 `--force`). +- 측정/실사용에서 별칭을 새로 입히려면: `kebab ingest --force`(전체 재처리) 또는 dogfood + `kebab reset` + reingest. + +## 7. 측정 (§4.6 측정 규율 — 프록시 금지, 추측 금지) + +``` +# baseline (flag off, 또는 Phase 1 기록): groups=8 fully_consistent=2 A=2 B=4 spread@10=0.750 +KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml \ + kebab eval run --config /build/dogfood/config.toml --mode hybrid --k 50 +KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml \ + kebab eval variants --config /build/dogfood/config.toml + +# 처방 on: expansion enabled 로 reset+reingest 후 동일 측정 +``` + +- 성공 기준: **B_dominant↓, fully_consistent↑, spread@10↓** (on vs off). 전체 golden 회귀 확인 + (기존 Ok 그룹이 깨지지 않는지). +- 측정값은 grep clean 추출 → Read 확인값만 기록(추측 금지). HOTFIXES + release notes-draft 에 cascade. + +## 8. 범위 밖 (YAGNI) + +- **BGE-M3 sparse 4th RRF 채널** — research §1.4: 교차언어 약함(우리 핵심은 KO↔EN 갭). 측정 후 + 단일언어 lift 가 필요하다 판단되면 별도 작업. +- **임베딩 유사도 환각 필터 / Doc2Query--/++** — D4. 측정에서 환각·팽창이 실제 문제일 때. +- **문서/혼합 단위 생성** — D1 에서 청크당으로 확정. +- **별칭의 dense 임베딩** — body 기준 유지(§3.3). + +## 9. 테스트 전략 (TDD — plan 에서 task 분해) + +- migration: V010 적용 후 `chunks.aliases` + `chunks_fts.aliases` 존재, 기존 행 본문 색인 동일. +- `put_chunks`/`get` round-trip: `aliases=Some(..)` 저장·조회. +- FTS5 alias 검색: aliases 에만 있는 term 으로 MATCH 시 해당 chunk 회수. +- RRF 3채널: alias 채널에만 매칭되는 청크가 fused 결과 pool 에 진입(additive 효과 핵심 회귀). +- `ExpansionGenerator`(LLM mock): 프롬프트→파싱, 상한 N 적용, 빈/과길이 drop, LLM 실패 시 fail-soft. +- flag off 회귀: expansion disabled 시 색인·검색 결과가 V009 와 동일(별칭 컬럼 NULL, 채널 무영향). + +## 10. PR / 문서 동기화 + +- gitea-pr 리뷰 루프(`[[feedback_pr_workflow]]`). flag off 기본. +- user-facing surface(신규 config `[ingest.expansion]`, `KEBAB_INGEST_EXPANSION_*` env, V010 + migration) → 같은 PR 에서 README(좁게: flag 존재+포인터) + HANDOFF + ARCHITECTURE 동기화 + (`[[feedback_readme_sync_rule]]`). flag 망라는 `--help`/config 예제에 위임. +- V010 = breaking schema → dogfood evidence(HOTFIXES dated entry) + release notes-draft 4단락. -- 2.49.1 From 467a974901725673fbff1f3e12e59a399bd85cbe Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 30 May 2026 02:04:58 +0000 Subject: [PATCH 02/27] =?UTF-8?q?docs(plan):=20doc-side=20expansion=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20plan=20+=20spec=20=EC=A0=95=EC=A0=9C=20(?= =?UTF-8?q?=EB=B3=84=EB=8F=84=20FTS=20=ED=85=8C=EC=9D=B4=EB=B8=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spec: chunks_fts §5.5 verbatim 충돌 회피 → 별도 chunk_aliases_fts 테이블 + lexical 내부 body+alias 병합(RetrievalDetail/wire schema 무변경)으로 정제. plan: 7 task TDD (Chunk 필드 → V010 → config → ExpansionGenerator → ingest hook → lexical 병합 → 측정/문서). 완성 코드 + 빌드 규약. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-05-30-doc-side-expansion.md | 918 ++++++++++++++++++ .../2026-05-30-doc-side-expansion-design.md | 117 +-- 2 files changed, 981 insertions(+), 54 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-30-doc-side-expansion.md diff --git a/docs/superpowers/plans/2026-05-30-doc-side-expansion.md b/docs/superpowers/plans/2026-05-30-doc-side-expansion.md new file mode 100644 index 0000000..c967ac2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-doc-side-expansion.md @@ -0,0 +1,918 @@ +# 색인시 doc-side expansion (검색용 별칭) 구현 Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 문서 색인 시 각 청크마다 로컬 LLM(gemma)으로 "검색용 별칭"(같은언어 paraphrase + 한↔영 번역)을 1회 생성해 별도 FTS5 테이블에 저장하고, lexical 검색이 본문+별칭을 함께 조회해 어휘격차로 pool 에서 누락되던 정답을 회수한다. + +**Architecture:** 별도 `chunk_aliases_fts` 가상 테이블(기존 `chunks_fts` §5.5 verbatim 블록 무수정) + `chunks.aliases` 컬럼 + 별도 sync trigger. ingest 경로에 flag(`[ingest.expansion]`, default off) 게이트로 `ExpansionGenerator`(LanguageModel trait, mock 가능) hook. 검색은 `LexicalRetriever` 가 본문 쿼리 + 별칭 쿼리 결과를 Rust 에서 병합(body 우선, alias-only append) — `HybridRetriever`/`RetrievalDetail`/wire schema 무변경. 별칭 테이블이 비면 기존과 동일 동작(회귀 안전). + +**Tech Stack:** Rust 2024 workspace, rusqlite + FTS5(unicode61), refinery migrations, `kebab_llm::LanguageModel`(Ollama), `kebab-eval` variants 측정. + +**빌드/테스트 규약 (모든 Run 스텝에 적용):** +- `CARGO_TARGET_DIR=/build/out/cargo-target/target`, `-j 4`(OOM 시 `-j 1`). +- 결과를 파일로 redirect + exit code 확인 후 커밋. `cargo ... | grep` 금지(pipe exit 마스킹). +- 예: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-store-sqlite -j 4 > /tmp/t.log 2>&1; echo "EXIT=$?"` → 파일에서 EXIT + 결과 확인. + +**참조 spec:** `docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md` + +--- + +## File Structure + +| 파일 | 역할 | Task | +|------|------|------| +| `crates/kebab-core/src/chunk.rs` | `Chunk.aliases: Option` 필드 | 1 | +| `migrations/V010__chunk_aliases.sql` | `chunks.aliases` 컬럼 + `chunk_aliases_fts` + trigger 3종 | 2 | +| `crates/kebab-store-sqlite/src/documents.rs` | `put_chunks` INSERT 에 `aliases` 컬럼 | 2 | +| `crates/kebab-store-sqlite/tests/` | migration + put/get + trigger 동기화 테스트 | 2 | +| `crates/kebab-config/src/lib.rs` | `IngestExpansionCfg` + default + env override | 3 | +| `crates/kebab-app/src/expansion.rs` (Create) | `ExpansionGenerator` — 프롬프트·파싱·상한·fail-soft | 4 | +| `crates/kebab-app/src/lib.rs` | ingest hook (flag 게이트, chunk 직후) | 5 | +| `crates/kebab-search/src/lexical.rs` | `run_alias_query` + body/alias 병합 + 컬럼 파라미터화 | 6 | +| README / HANDOFF / ARCHITECTURE / HOTFIXES / release-notes | 문서 동기화 + 측정 기록 | 7 | + +각 Task 는 자체로 컴파일·테스트 통과하는 단위다. Task 6 까지 끝나면 flag on 시 end-to-end 동작, Task 7 은 측정/문서. + +--- + +## Task 1: `Chunk.aliases` 필드 추가 + +**Files:** +- Modify: `crates/kebab-core/src/chunk.rs:16-31` +- Test: 동 파일 인라인(또는 기존 core 테스트) — 직렬화 default 확인 + +- [ ] **Step 1: 실패 테스트 작성** + +`crates/kebab-core/src/chunk.rs` 하단에 `#[cfg(test)]` 모듈(없으면 신설): + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aliases_defaults_to_none_on_deserialize() { + // aliases 필드가 없는 과거 JSON 도 파싱되어야 한다 (#[serde(default)]). + let json = r#"{ + "chunk_id": "c1", + "doc_id": "d1", + "block_ids": [], + "text": "hello", + "heading_path": [], + "source_spans": [], + "token_estimate": 1, + "chunker_version": "md-heading-v1", + "policy_hash": "abc" + }"#; + let c: Chunk = serde_json::from_str(json).unwrap(); + assert_eq!(c.aliases, None); + assert_eq!(c.tokenized_korean_text, None); + } +} +``` + +- [ ] **Step 2: 실패 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-core aliases_defaults -j 4 > /tmp/t1.log 2>&1; echo "EXIT=$?"` +Expected: 컴파일 실패 — `Chunk` 에 `aliases` 필드 없음 (`no field 'aliases'`). + +- [ ] **Step 3: 필드 추가** + +`crates/kebab-core/src/chunk.rs` 의 `Chunk` 구조체에서 `tokenized_korean_text` 바로 아래에 추가: + +```rust + #[serde(default)] + pub tokenized_korean_text: Option, + /// 색인시 doc-side expansion (Phase 2) 으로 생성된 "검색용 별칭" + /// (같은언어 paraphrase + 한↔영 번역, 개행 join). `[ingest.expansion]` + /// flag off 또는 미생성이면 None — 별도 FTS5 테이블 `chunk_aliases_fts` + /// 에만 색인되고 본문 매칭/dense 임베딩에는 영향 없음. 설계 spec + /// `2026-05-30-doc-side-expansion-design.md` §3.3. + #[serde(default)] + pub aliases: Option, +``` + +- [ ] **Step 4: 통과 확인 + 컴파일 영향 점검** + +`Chunk` 를 리터럴로 만드는 곳이 `aliases` 누락으로 깨질 수 있다. 점검: + +Run: `cd /home/altair823/kebab && grep -rn "Chunk {" crates --include=*.rs | grep -v "test" | head -30` + +각 생성 지점에 `aliases: None,` 추가(특히 `crates/kebab-chunk*`/chunker). 그 후: + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-core aliases_defaults -j 4 > /tmp/t1.log 2>&1; echo "EXIT=$?"` +Expected: PASS. 이어서 워크스페이스 컴파일 확인: +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo build -p kebab-chunk -p kebab-store-sqlite -j 4 > /tmp/t1b.log 2>&1; echo "EXIT=$?"` +Expected: EXIT=0 (chunker 가 `aliases: None` 으로 컴파일). + +- [ ] **Step 5: 커밋** + +```bash +git add crates/kebab-core/src/chunk.rs crates +git commit -m "feat(core): Chunk.aliases 필드 (doc-side expansion)" +``` + +--- + +## Task 2: V010 migration + `put_chunks` 별칭 영속화 + +**Files:** +- Create: `migrations/V010__chunk_aliases.sql` +- Modify: `crates/kebab-store-sqlite/src/documents.rs:103-140` (`put_chunks` INSERT) +- Test: `crates/kebab-store-sqlite/tests/` (기존 `fts.rs` 패턴 따라 신규 `chunk_aliases.rs` 또는 기존 파일에 추가) + +- [ ] **Step 1: migration 작성** + +`migrations/V010__chunk_aliases.sql` 생성 — 기존 `chunks_fts`/`chunks_ai/ad/au`(§5.5 verbatim CI 대상)는 **건드리지 않는다**: + +```sql +-- V010__chunk_aliases.sql — doc-side expansion (Phase 2) 검색용 별칭 채널. +-- +-- 설계 spec docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md §4. +-- chunks 에 nullable `aliases` 컬럼 + 별도 FTS5 테이블 chunk_aliases_fts + +-- 별도 sync trigger. 기존 chunks_fts / chunks_ai/ad/au (design §5.5 verbatim, +-- CI test fts_v009_matches_design_section_5_5_verbatim) 는 무수정. +-- aliases 는 additive: 미생성/flag off 이면 NULL → chunk_aliases_fts 빈 채로 +-- 시작, 검색 UNION 둘째 절 0행 → 기존 동작과 동일. 자동 backfill 없음. + +ALTER TABLE chunks ADD COLUMN aliases TEXT; + +CREATE VIRTUAL TABLE chunk_aliases_fts USING fts5( + chunk_id UNINDEXED, + doc_id UNINDEXED, + aliases, + tokenize = 'unicode61' +); + +CREATE TRIGGER chunk_aliases_ai AFTER INSERT ON chunks WHEN new.aliases IS NOT NULL BEGIN + INSERT INTO chunk_aliases_fts(chunk_id, doc_id, aliases) + VALUES (new.chunk_id, new.doc_id, new.aliases); +END; +CREATE TRIGGER chunk_aliases_ad AFTER DELETE ON chunks BEGIN + DELETE FROM chunk_aliases_fts WHERE chunk_id = old.chunk_id; +END; +CREATE TRIGGER chunk_aliases_au AFTER UPDATE ON chunks BEGIN + DELETE FROM chunk_aliases_fts WHERE chunk_id = old.chunk_id; + INSERT INTO chunk_aliases_fts(chunk_id, doc_id, aliases) + SELECT new.chunk_id, new.doc_id, new.aliases WHERE new.aliases IS NOT NULL; +END; + +-- in-process LRU search cache 무효화 (V009 와 동일 패턴). +UPDATE kv SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT) WHERE key = 'corpus_revision'; +``` + +- [ ] **Step 2: 실패 테스트 작성** + +먼저 기존 store 테스트가 임시 SqliteStore 를 어떻게 여는지 확인: +Run: `cd /home/altair823/kebab && sed -n '1,60p' crates/kebab-store-sqlite/tests/fts.rs` + +그 헬퍼 패턴(보통 `SqliteStore::open(tempfile)` 가 모든 migration 적용)을 따라 `crates/kebab-store-sqlite/tests/chunk_aliases.rs` 생성. `put_chunks` 로 `aliases=Some(..)` 청크를 저장하면 `chunk_aliases_fts` MATCH 로 회수되고, `aliases=None` 이면 안 들어가는지 검증: + +```rust +// 기존 fts.rs 의 store 오픈 + Chunk 생성 헬퍼를 동일하게 재사용/복제할 것. +// 아래는 검증 핵심부 — 헬퍼 시그니처는 fts.rs 실제 코드에 맞춘다. +use kebab_core::{Chunk, ChunkId, ChunkerVersion, DocumentId}; + +#[test] +fn aliases_indexed_into_chunk_aliases_fts() { + let store = open_temp_store_with_one_document(); // fts.rs 헬퍼 패턴 + let doc = DocumentId("d1".into()); + let chunk = Chunk { + chunk_id: ChunkId("c1".into()), + doc_id: doc.clone(), + block_ids: vec![], + text: "Rust ownership and borrowing".into(), + heading_path: vec![], + source_spans: vec![], + token_estimate: 5, + chunker_version: ChunkerVersion("md-heading-v1".into()), + policy_hash: "h".into(), + tokenized_korean_text: None, + aliases: Some("메모리 안전성\nwho owns the value".into()), + }; + store.put_chunks(&doc, &[chunk]).unwrap(); + + let conn = store.read_conn(); + // 별칭에만 있는 한국어 term 으로 chunk_aliases_fts 검색 → c1 회수. + let n: i64 = conn + .query_row( + "SELECT count(*) FROM chunk_aliases_fts WHERE chunk_aliases_fts MATCH 'aliases : (\"메모리\")'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(n, 1, "aliases 의 한국어 term 이 chunk_aliases_fts 에 색인돼야 한다"); +} + +#[test] +fn none_aliases_not_indexed() { + let store = open_temp_store_with_one_document(); + let doc = DocumentId("d1".into()); + let chunk = Chunk { /* 위와 동일하되 */ aliases: None, ..base_chunk("c1", &doc) }; + store.put_chunks(&doc, &[chunk]).unwrap(); + let conn = store.read_conn(); + let n: i64 = conn + .query_row("SELECT count(*) FROM chunk_aliases_fts", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n, 0, "aliases=None 이면 chunk_aliases_fts 에 행이 없어야 한다"); +} +``` + +- [ ] **Step 3: 실패 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-store-sqlite --test chunk_aliases -j 4 > /tmp/t2.log 2>&1; echo "EXIT=$?"` +Expected: 실패 — `put_chunks` INSERT 에 `aliases` 컬럼이 없어 `chunk_aliases_fts` 가 비어 있음 (또는 SQL 컬럼 수 불일치). 파일에서 실패 사유 확인. + +- [ ] **Step 4: `put_chunks` 수정** + +`crates/kebab-store-sqlite/src/documents.rs` 의 INSERT 문(라인 103-110)과 `stmt.execute`(126-139) 에 `aliases` 컬럼 추가: + +```rust + let mut stmt = tx + .prepare( + "INSERT INTO chunks ( + chunk_id, doc_id, text, heading_path_json, + section_label, source_spans_json, token_estimate, + chunker_version, policy_hash, block_ids_json, created_at, + tokenized_korean_text, aliases + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .map_err(StoreError::from)?; +``` + +`stmt.execute(params![ ... ])` 의 마지막(`chunk.tokenized_korean_text.as_deref(),`) 다음에: + +```rust + chunk.tokenized_korean_text.as_deref(), + chunk.aliases.as_deref(), +``` + +- [ ] **Step 5: 통과 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-store-sqlite -j 4 > /tmp/t2.log 2>&1; echo "EXIT=$?"` +Expected: EXIT=0, `aliases_indexed_into_chunk_aliases_fts` + `none_aliases_not_indexed` PASS, 기존 store 테스트 전부 PASS(특히 `fts_v009_matches_design_section_5_5_verbatim` — V010 이 §5.5 블록을 안 건드리므로 그대로 통과해야 함). 파일에서 통과 수 확인. + +- [ ] **Step 6: 커밋** + +```bash +git add migrations/V010__chunk_aliases.sql crates/kebab-store-sqlite +git commit -m "feat(store): V010 chunk_aliases_fts + put_chunks 별칭 영속화" +``` + +--- + +## Task 3: `[ingest.expansion]` config + +**Files:** +- Modify: `crates/kebab-config/src/lib.rs` (`IngestCfg` 확장 + `IngestExpansionCfg` + `defaults()` + `apply_env`) +- Test: 동 crate 인라인 테스트 + +- [ ] **Step 1: 실패 테스트 작성** + +`crates/kebab-config/src/lib.rs` 의 기존 `#[cfg(test)] mod tests` 에 추가(없으면 신설): + +```rust + #[test] + fn expansion_defaults_off() { + let cfg = Config::defaults(); + assert!(!cfg.ingest.expansion.enabled, "expansion 은 기본 off"); + assert_eq!(cfg.ingest.expansion.max_aliases_per_chunk, 8); + assert_eq!(cfg.ingest.expansion.prompt_version, "expansion-v1"); + // model 비면 models.llm.model 로 폴백할 수 있게 빈 문자열 default. + assert_eq!(cfg.ingest.expansion.model, ""); + } + + #[test] + fn expansion_env_override() { + let mut cfg = Config::defaults(); + let env: std::collections::HashMap = [ + ("KEBAB_INGEST_EXPANSION_ENABLED".to_string(), "true".to_string()), + ("KEBAB_INGEST_EXPANSION_MAX_ALIASES".to_string(), "12".to_string()), + ("KEBAB_INGEST_EXPANSION_MODEL".to_string(), "gemma4:e4b".to_string()), + ("KEBAB_INGEST_EXPANSION_PROMPT_VERSION".to_string(), "expansion-v2".to_string()), + ] + .into_iter() + .collect(); + cfg.apply_env(&env); + assert!(cfg.ingest.expansion.enabled); + assert_eq!(cfg.ingest.expansion.max_aliases_per_chunk, 12); + assert_eq!(cfg.ingest.expansion.model, "gemma4:e4b"); + assert_eq!(cfg.ingest.expansion.prompt_version, "expansion-v2"); + } +``` + +- [ ] **Step 2: 실패 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-config expansion_ -j 4 > /tmp/t3.log 2>&1; echo "EXIT=$?"` +Expected: 컴파일 실패 — `ingest.expansion` 필드 없음. + +- [ ] **Step 3: 구조체 + default + env 추가** + +(3a) `IngestCfg`(라인 ~596) 에 필드 추가: + +```rust +pub struct IngestCfg { + pub code: IngestCodeCfg, + #[serde(default)] + pub expansion: IngestExpansionCfg, +} +``` + +(3b) `IngestCodeCfg` 정의 아래에 신규 구조체: + +```rust +/// Phase 2 doc-side expansion: 색인시 LLM 으로 청크당 "검색용 별칭" +/// (같은언어 paraphrase + 한↔영 번역) 1회 생성. 별도 chunk_aliases_fts +/// 채널에 저장, lexical 검색이 본문+별칭 병합. default off (additive). +/// 설계 spec 2026-05-30-doc-side-expansion-design.md §3.2. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct IngestExpansionCfg { + /// 색인시 별칭 생성 활성화. off 면 chunks.aliases=NULL (기존 동작). + pub enabled: bool, + /// 별칭 생성에 쓸 LLM 모델. 빈 문자열이면 `models.llm.model` 로 폴백. + pub model: String, + /// 청크당 별칭 최대 개수(상한). 초과분 drop. + pub max_aliases_per_chunk: usize, + /// 프롬프트 버전(추적용). 변경 시 재생성 대상 식별. + pub prompt_version: String, +} + +impl Default for IngestExpansionCfg { + fn default() -> Self { + Self { + enabled: false, + model: String::new(), + max_aliases_per_chunk: 8, + prompt_version: "expansion-v1".to_string(), + } + } +} +``` + +(3c) `Config::defaults()` 의 `ingest: IngestCfg::default(),` 는 이미 `IngestCfg::default()` 를 쓰므로(라인 716) — `IngestCfg` 가 `Default` 파생인지 확인. 만약 `IngestCfg` 가 수동 default 면 `expansion: IngestExpansionCfg::default()` 추가. (확인: `grep -n "impl Default for IngestCfg\|derive.*Default.*\n.*struct IngestCfg" crates/kebab-config/src/lib.rs`) + +(3d) `apply_env`(라인 ~861-1090) 에 env 키 추가. 기존 `parse_bool` 헬퍼 사용: + +```rust + "KEBAB_INGEST_EXPANSION_ENABLED" => self.ingest.expansion.enabled = parse_bool(v), + "KEBAB_INGEST_EXPANSION_MODEL" => self.ingest.expansion.model = v.clone(), + "KEBAB_INGEST_EXPANSION_MAX_ALIASES" => { + if let Ok(n) = v.parse::() { + self.ingest.expansion.max_aliases_per_chunk = n; + } + } + "KEBAB_INGEST_EXPANSION_PROMPT_VERSION" => { + self.ingest.expansion.prompt_version = v.clone() + } +``` + +- [ ] **Step 4: 통과 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-config -j 4 > /tmp/t3.log 2>&1; echo "EXIT=$?"` +Expected: EXIT=0, `expansion_defaults_off` + `expansion_env_override` PASS, 기존 config 테스트 전부 PASS. + +- [ ] **Step 5: 커밋** + +```bash +git add crates/kebab-config +git commit -m "feat(config): [ingest.expansion] flag (default off)" +``` + +--- + +## Task 4: `ExpansionGenerator` + +**Files:** +- Create: `crates/kebab-app/src/expansion.rs` +- Modify: `crates/kebab-app/src/lib.rs` (`mod expansion;` 선언) +- Modify: `crates/kebab-app/Cargo.toml` ([dev-dependencies] 에 `kebab-llm` 의 `mock` feature) +- Test: `crates/kebab-app/src/expansion.rs` 인라인 + +`LanguageModel::generate_stream(req) -> Iterator>` 를 모아 문자열로 합치고, 줄 단위 파싱 → trim → 빈 줄/과길이(>120 chars) drop → 상한 N → 개행 join. LLM 호출 실패/빈 결과 시 `None`(fail-soft). + +- [ ] **Step 1: 실패 테스트 작성** + +`crates/kebab-app/src/expansion.rs` 생성: + +```rust +//! 색인시 doc-side expansion (Phase 2) — 청크당 "검색용 별칭" 생성. +//! +//! 설계 spec docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md §3.2 / §5. + +use kebab_core::{Chunk, GenerateRequest, LanguageModel}; + +/// 별칭 1줄의 최대 글자 수(이 이상은 문장형/환각으로 보고 drop). +const MAX_ALIAS_CHARS: usize = 120; + +/// 청크당 검색용 별칭을 생성한다. +/// +/// 반환: 검증·상한 적용된 별칭들을 개행 join 한 문자열. 생성 0개 / LLM +/// 실패 / 빈 출력이면 `None` (호출측은 chunk.aliases 를 None 으로 두고 진행). +pub struct ExpansionGenerator<'a> { + llm: &'a dyn LanguageModel, + max_aliases: usize, +} + +impl<'a> ExpansionGenerator<'a> { + pub fn new(llm: &'a dyn LanguageModel, max_aliases: usize) -> Self { + Self { llm, max_aliases } + } + + /// gemma 프롬프트(expansion-v1)를 구성한다. + fn build_request(&self, chunk: &Chunk) -> GenerateRequest { + let heading = chunk.heading_path.join(" > "); + let system = "당신은 검색 색인용 별칭 생성기다. 주어진 문단을 찾을 사용자가 \ +입력할 법한 짧은 검색어/질문을 생성한다. 동의어·풀어쓴 표현을 포함하라. \ +문단이 한국어면 영어 표현도, 영어면 한국어 표현도 섞어라. \ +한 줄에 하나씩, 설명·번호·머리기호 없이 검색어만 출력하라." + .to_string(); + let user = format!( + "제목 경로: {heading}\n\n문단:\n{}\n\n검색 별칭(한 줄에 하나):", + chunk.text + ); + GenerateRequest { + system, + user, + stop: vec![], + max_tokens: 256, + temperature: 0.0, + seed: Some(0), + images: vec![], + } + } + + pub fn generate(&self, chunk: &Chunk) -> Option { + let req = self.build_request(chunk); + let raw = match self.llm.generate_stream(req) { + Ok(iter) => { + let mut acc = String::new(); + for ch in iter { + match ch { + Ok(kebab_core::TokenChunk::Token(t)) => acc.push_str(&t), + Ok(kebab_core::TokenChunk::Done { .. }) => {} + Err(_) => return None, // fail-soft + } + } + acc + } + Err(_) => return None, // fail-soft (connection refused 등) + }; + let aliases = parse_aliases(&raw, self.max_aliases); + if aliases.is_empty() { + None + } else { + Some(aliases.join("\n")) + } + } +} + +/// LLM 출력 문자열 → 검증된 별칭 리스트. +/// 줄 단위 split → trim → 번호/머리기호 접두 제거 → 빈 줄·과길이 drop → +/// 중복 제거 → 상한 N. +fn parse_aliases(raw: &str, max_aliases: usize) -> Vec { + let mut out: Vec = Vec::new(); + for line in raw.lines() { + let t = line.trim(); + // 번호("1." "1)") / 머리기호("- " "* ") 접두 제거. + let t = t + .trim_start_matches(|c: char| c.is_ascii_digit() || c == '.' || c == ')' || c == '-' || c == '*') + .trim(); + if t.is_empty() || t.chars().count() > MAX_ALIAS_CHARS { + continue; + } + let s = t.to_string(); + if !out.contains(&s) { + out.push(s); + } + if out.len() >= max_aliases { + break; + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use kebab_core::{ChunkId, ChunkerVersion, DocumentId, FinishReason, TokenUsage}; + use kebab_llm::MockLanguageModel; + + fn mk_chunk(text: &str) -> Chunk { + Chunk { + chunk_id: ChunkId("c1".into()), + doc_id: DocumentId("d1".into()), + block_ids: vec![], + text: text.into(), + heading_path: vec!["Guide".into()], + source_spans: vec![], + token_estimate: 3, + chunker_version: ChunkerVersion("md-heading-v1".into()), + policy_hash: "h".into(), + tokenized_korean_text: None, + aliases: None, + } + } + + fn mock(resp: &str) -> MockLanguageModel { + MockLanguageModel { + model_id: "gemma4:e4b".into(), + provider: "ollama".into(), + context_tokens: 32768, + canned_response: resp.into(), + canned_finish: FinishReason::Stop, + canned_usage: TokenUsage { prompt_tokens: 0, completion_tokens: 0 }, + } + } + + #[test] + fn parses_lines_strips_bullets_and_caps() { + let llm = mock("- 메모리 안전성\n1. who owns the value\nborrow checker\n\n* 소유권"); + let gen = ExpansionGenerator::new(&llm, 2); + let out = gen.generate(&mk_chunk("Rust ownership")).unwrap(); + // 상한 2 → 앞 2개만, 접두 제거됨. + assert_eq!(out, "메모리 안전성\nwho owns the value"); + } + + #[test] + fn drops_overlong_lines() { + let long = "x".repeat(200); + let llm = mock(&format!("{long}\n짧은 별칭")); + let gen = ExpansionGenerator::new(&llm, 8); + let out = gen.generate(&mk_chunk("t")).unwrap(); + assert_eq!(out, "짧은 별칭", "120자 초과 줄은 drop"); + } + + #[test] + fn empty_output_returns_none() { + let llm = mock(" \n\n"); + let gen = ExpansionGenerator::new(&llm, 8); + assert_eq!(gen.generate(&mk_chunk("t")), None); + } +} +``` + +- [ ] **Step 2: 모듈 선언 + dev-dep** + +`crates/kebab-app/src/lib.rs` 상단 모듈 선언부에 `mod expansion;` 추가(필요 시 `pub mod`). +`crates/kebab-app/Cargo.toml` 의 `[dev-dependencies]` 에 mock feature 활성화(이미 kebab-llm 의존 시): + +```toml +[dev-dependencies] +kebab-llm = { workspace = true, features = ["mock"] } +``` + +(확인: `grep -n "kebab-llm" crates/kebab-app/Cargo.toml`. 이미 `[dependencies]` 에 있으면 dev-dep 에서 features 만 추가하거나, `[dev-dependencies]` 줄 신설.) + +- [ ] **Step 3: 실패 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-app expansion:: -j 4 > /tmp/t4.log 2>&1; echo "EXIT=$?"` +Expected: 위 구현이 이미 들어 있으면 PASS 할 수도 있으나, mock feature/모듈 선언 누락 시 컴파일 실패. 파일에서 사유 확인 후 Step 2 보완. + +- [ ] **Step 4: 통과 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-app expansion:: -j 4 > /tmp/t4.log 2>&1; echo "EXIT=$?"` +Expected: EXIT=0, 3개 테스트 PASS. + +- [ ] **Step 5: 커밋** + +```bash +git add crates/kebab-app/src/expansion.rs crates/kebab-app/src/lib.rs crates/kebab-app/Cargo.toml +git commit -m "feat(app): ExpansionGenerator — 청크당 별칭 생성 (fail-soft)" +``` + +--- + +## Task 5: ingest hook (flag 게이트) + +**Files:** +- Modify: `crates/kebab-app/src/lib.rs` (ingest 진입부에 expansion LLM 빌드 ~388-400 근방; `ingest_one_asset` chunk 직후 ~1253) + +`OllamaLanguageModel` 은 `kebab-llm-local` 의 타입. caption_llm 패턴(라인 394-400) 을 그대로 따른다. expansion LLM 은 `ingest_one_asset` 까지 전달돼야 하므로, caption 처럼 ingest 함수 시그니처/호출 체인을 따라 내려보낸다(`ingest_one_asset` 가 `app` 을 받으므로 `app.config.ingest.expansion` 으로 분기하고 LLM 을 함수 내에서 빌드하는 게 가장 단순 — per-asset 빌드 비용은 무시 가능하지만, 더 깔끔히 하려면 ingest 루프 밖에서 1회 빌드해 `&dyn LanguageModel` 로 전달). + +> **구현 노트(executor 판단):** 우선 가장 단순한 형태 — `ingest_one_asset` 내부에서 `app.config.ingest.expansion.enabled` 이면 LLM 1회 빌드 후 청크 루프. caption_llm 처럼 ingest 루프 밖 1회 빌드가 가능하면 그쪽이 낫다(LLM 핸들 재사용). 단 **테스트 가능성**을 위해 별칭 부여 로직은 Task 4 의 `ExpansionGenerator` 에 이미 격리돼 있으므로, 여기선 "flag 분기 + 청크 루프 + chunk.aliases 세팅"만 한다. + +- [ ] **Step 1: hook 코드 작성** + +`crates/kebab-app/src/lib.rs` 의 `ingest_one_asset` 에서 chunk 생성 직후(라인 1253-1255 의 `let chunks = ...?;` 다음, 버전 스탬핑 전후), `chunks` 를 `mut` 로 바꾸고 추가: + +```rust + let mut chunks = MdHeadingV1Chunker + .chunk(&canonical, chunk_policy) + .context("kb-chunk::MdHeadingV1Chunker::chunk")?; + + // Phase 2 doc-side expansion: flag on 이면 청크당 별칭 생성 (fail-soft). + // 설계 spec 2026-05-30-doc-side-expansion-design.md §3.1. + if app.config.ingest.expansion.enabled { + let exp = &app.config.ingest.expansion; + let model = if exp.model.is_empty() { + app.config.models.llm.model.clone() + } else { + exp.model.clone() + }; + match kebab_llm_local::OllamaLanguageModel::with_model(&app.config, &model) { + Ok(llm) => { + let generator = + crate::expansion::ExpansionGenerator::new(&llm, exp.max_aliases_per_chunk); + for chunk in &mut chunks { + chunk.aliases = generator.generate(chunk); + } + } + Err(e) => { + // fail-soft: 별칭 없이 색인 진행 (본문 검색은 정상). + tracing::warn!( + target: "kebab-app", + error = %e, + "kb-app::ingest: expansion LLM 빌드 실패 — 별칭 없이 진행" + ); + } + } + } +``` + +> `OllamaLanguageModel::with_model(&config, &model)` 가 없으면 — `OllamaLanguageModel::new(&config)`(config.models.llm.model 사용) 로 폴백하고, model override 가 필요하면 `kebab-llm-local` 에 `with_model` 생성자를 추가한다. 확인: `grep -n "impl OllamaLanguageModel\|pub fn new\|pub fn with" crates/kebab-llm-local/src/ollama.rs`. override 가 과하면 1차는 `new(&app.config)` 만 쓰고 `exp.model` 은 무시(spec §3.2 의 model 폴백 동작은 Task 7 에서 README 에 "현재 models.llm 사용"으로 명시) — **executor 가 실제 생성자 확인 후 결정**. + +- [ ] **Step 2: 컴파일 + 회귀 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo build -p kebab-app -j 4 > /tmp/t5.log 2>&1; echo "EXIT=$?"` +Expected: EXIT=0. 실패 시 생성자 시그니처(위 노트) 보정. + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-app -j 4 > /tmp/t5b.log 2>&1; echo "EXIT=$?"` +Expected: EXIT=0 (flag default off 라 기존 ingest 테스트 무영향). + +- [ ] **Step 3: 통합 테스트 (flag on, mock 불가 시 생략 가능)** + +실제 Ollama 가 필요하므로 단위 테스트로는 검증이 어렵다. 대신 flag off 회귀만 단위로 보장하고, flag on end-to-end 는 Task 7 의 dogfood 측정에서 검증한다. (이 Step 은 "flag off 시 chunk.aliases 가 None 으로 유지됨"을 보장하는 기존 테스트로 충분 — 추가 테스트 불필요.) + +- [ ] **Step 4: 커밋** + +```bash +git add crates/kebab-app/src/lib.rs +git commit -m "feat(app): ingest 별칭 생성 hook (flag off 기본, fail-soft)" +``` + +--- + +## Task 6: `LexicalRetriever` body+alias 병합 검색 + +**Files:** +- Modify: `crates/kebab-search/src/lexical.rs` (`build_match_string` 컬럼 파라미터화, `run_alias_query` 추가, `search()` 병합) +- Test: `crates/kebab-search/tests/` (기존 lexical 통합 테스트 패턴) 또는 lexical.rs 인라인 + +핵심: `build_match_string` 은 현재 `text : (...)` 컬럼 필터를 반환. alias 검색은 `aliases : (...)` 가 필요하므로 컬럼명을 파라미터화한다. `search()` 는 body 결과(`run_query`) + alias 결과(`run_alias_query`)를 병합 — **body 우선, alias-only 를 뒤에 append**, `chunk_aliases_fts` 가 비면 alias 결과 0 → 기존과 동일. + +- [ ] **Step 1: 실패 테스트 작성** + +`crates/kebab-search/tests/` 의 기존 lexical 테스트가 store 를 어떻게 채우는지 확인: +Run: `cd /home/altair823/kebab && ls crates/kebab-search/tests/ && grep -rln "LexicalRetriever" crates/kebab-search/tests/` + +그 패턴으로, **본문에 없고 별칭에만 있는 term** 으로 검색 시 해당 청크가 회수되는 테스트 작성(핵심 pool-rescue 회귀): + +```rust +// 헬퍼(store 오픈 + put_chunks)는 기존 테스트 패턴 재사용. +#[test] +fn alias_only_term_recalls_chunk() { + let store = /* temp store + 1 document */; + // 본문엔 "backpropagation" 만, 별칭에 "역전파" 추가. + let chunk = Chunk { + /* ... */ + text: "backpropagation computes gradients".into(), + aliases: Some("역전파\n신경망 오차 역전달".into()), + /* ... */ + }; + store.put_chunks(&doc, &[chunk]).unwrap(); + + let retr = LexicalRetriever::with_settings(store.clone(), IndexVersion("v1".into()), 220); + // 본문에 없는 한국어로 검색 → 별칭 덕에 회수돼야 한다. + let q = SearchQuery { text: "역전파".into(), mode: SearchMode::Lexical, k: 10, filters: Default::default() }; + let hits = retr.search(&q).unwrap(); + assert!(hits.iter().any(|h| h.chunk_id.0 == "c1"), + "별칭에만 있는 term 으로도 청크가 회수돼야 한다 (pool-rescue)"); +} + +#[test] +fn empty_aliases_table_matches_baseline() { + // aliases 전부 None → chunk_aliases_fts 빈 상태 → 본문 검색 결과가 + // 별칭 도입 전과 동일해야 한다 (회귀 안전). + let store = /* temp store, aliases=None 청크들 */; + let retr = LexicalRetriever::with_settings(store, IndexVersion("v1".into()), 220); + let q = SearchQuery { text: "ownership".into(), mode: SearchMode::Lexical, k: 10, filters: Default::default() }; + let hits = retr.search(&q).unwrap(); + // 본문 매칭 청크가 정상 회수 (별칭 경로가 결과를 바꾸지 않음). + assert!(hits.iter().any(|h| h.chunk_id.0 == "c1")); +} +``` + +- [ ] **Step 2: 실패 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-search alias_only_term_recalls -j 4 > /tmp/t6.log 2>&1; echo "EXIT=$?"` +Expected: 실패 — 현재 `search()` 는 본문(`chunks_fts`)만 보므로 별칭-only term 회수 0. + +- [ ] **Step 3: `build_match_string` 컬럼 파라미터화** + +`build_match_string` 의 마지막 줄 `Some(format!("text : ({expression})"))` 을 컬럼 인자로: + +```rust +fn build_match_string(text: &str) -> Option { + build_match_string_for_column(text, "text") +} + +/// `column` 은 FTS5 컬럼 필터 prefix ("text" 또는 "aliases"). +fn build_match_string_for_column(text: &str, column: &str) -> Option { + // ... 기존 본문 (whole_candidate / token_and_candidate / expression) 그대로 ... + Some(format!("{column} : ({expression})")) +} +``` + +(기존 `build_match_string("rust cargo")` 테스트는 `text : (...)` 를 기대하므로 그대로 통과.) + +- [ ] **Step 4: `run_alias_query` 추가** + +`run_query` 아래에 별칭 전용 쿼리. 필터는 1차에선 미적용(별칭 회수가 목적; 측정 후 필요 시 공유)하되, snippet 은 `chunks.text` 앞부분으로 대체: + +```rust +/// chunk_aliases_fts 를 검색해 RawRow 를 만든다. snippet 은 별칭이 아닌 +/// 본문(c.text) 앞부분으로 채워 UI 일관성 유지. chunk_aliases_fts 가 비면 +/// 0행 반환(회귀 안전). 1차는 filters 미적용 — body 쪽에서 필터가 적용되고, +/// 별칭 경로는 pool 진입이 목적(측정 후 필요 시 filters 공유). +fn run_alias_query( + conn: &Connection, + match_str: &str, + snippet_chars: usize, + fetch_limit: usize, +) -> Result> { + let sql = "SELECT \ + af.chunk_id, af.doc_id, \ + bm25(chunk_aliases_fts) AS score, \ + substr(c.text, 1, ?) AS snippet, \ + c.heading_path_json, c.section_label, c.source_spans_json, \ + c.chunker_version, \ + d.workspace_path, d.updated_at \ + FROM chunk_aliases_fts af \ + JOIN chunks c ON c.chunk_id = af.chunk_id \ + JOIN documents d ON d.doc_id = af.doc_id \ + WHERE chunk_aliases_fts MATCH ? \ + ORDER BY score, af.chunk_id LIMIT ?"; + let mut stmt = conn + .prepare(sql) + .context("kb-search lexical: prepare alias FTS5 statement")?; + let rows = stmt + .query_map( + params_from_iter(vec![ + Box::new(snippet_chars as i64) as Box, + Box::new(match_str.to_owned()), + Box::new(i64::try_from(fetch_limit).unwrap_or(i64::MAX)), + ] + .iter() + .map(std::convert::AsRef::as_ref)), + row_from_sql, + ) + .context("kb-search lexical: execute alias FTS5 query")?; + let mut out = Vec::new(); + for r in rows { + out.push(r.context("kb-search lexical: read alias row")?); + } + Ok(out) +} +``` + +- [ ] **Step 5: `search()` 에서 병합** + +`LexicalRetriever::search` 에서 `run_query` 호출 직후, body+alias 병합. 기존: + +```rust + let raw_rows = run_query(&conn, &match_str, self.snippet_words, filters, fetch_limit)?; +``` + +를 다음으로 교체: + +```rust + let body_rows = run_query(&conn, &match_str, self.snippet_words, filters, fetch_limit)?; + // 별칭 채널: 같은 query 를 aliases 컬럼 필터로 다시 매칭. 테이블이 + // 비면 0행 → body_rows 그대로(회귀 안전). body 우선, alias-only append. + let alias_rows = match build_match_string_for_column(&query.text, "aliases") { + Some(am) => run_alias_query(&conn, &am, self.snippet_chars, fetch_limit)?, + None => Vec::new(), + }; + let raw_rows = merge_body_alias(body_rows, alias_rows, fetch_limit); +``` + +병합 헬퍼 추가(`run_alias_query` 아래): + +```rust +/// body 결과 우선, body 에 없는 alias-only 청크를 뒤에 append. fetch_limit +/// 로 절단. body_rows 는 이미 bm25 오름차순; alias_rows 도 그러하므로 +/// alias-only 부분도 별칭 적합도 순으로 들어간다. +fn merge_body_alias(body: Vec, alias: Vec, limit: usize) -> Vec { + use std::collections::HashSet; + let mut seen: HashSet = body.iter().map(|r| r.chunk_id.clone()).collect(); + let mut out = body; + for r in alias { + if out.len() >= limit { + break; + } + if seen.insert(r.chunk_id.clone()) { + out.push(r); + } + } + out.truncate(limit); + out +} +``` + +> `query.text` 가 `search()` 스코프에 있는지 확인(있음 — `match_opt = build_match_string(&query.text)`). `self.snippet_chars` 필드도 존재(LexicalRetriever 구조체). + +- [ ] **Step 6: 통과 + 전체 회귀 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-search -j 4 > /tmp/t6.log 2>&1; echo "EXIT=$?"` +Expected: EXIT=0, `alias_only_term_recalls_chunk` + `empty_aliases_table_matches_baseline` PASS, 기존 lexical/hybrid 테스트 전부 PASS(`build_match_string_default_emits_or_of_phrase_and_and` 포함 — `text : (...)` 유지). + +- [ ] **Step 7: 커밋** + +```bash +git add crates/kebab-search/src/lexical.rs crates/kebab-search/tests +git commit -m "feat(search): lexical body+alias 병합 검색 (pool-rescue)" +``` + +--- + +## Task 7: 측정 + 문서 동기화 + +**Files:** +- 측정: dogfood KB (`/build/dogfood`) +- Modify: `README.md`, `HANDOFF.md`, `docs/ARCHITECTURE.md`, `tasks/HOTFIXES.md`, `docs/release-notes/v-draft.md` + +- [ ] **Step 1: 전체 빌드 + clippy 게이트** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo build --release -j 4 > /tmp/t7build.log 2>&1; echo "EXIT=$?"` +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo clippy --workspace --all-targets -j 4 -- -D warnings > /tmp/t7clippy.log 2>&1; echo "EXIT=$?"` +Expected: 둘 다 EXIT=0. 파일에서 확인. + +- [ ] **Step 2: baseline (flag off) 측정** + +`/build/dogfood/config.toml` 의 `[ingest.expansion]` 미설정(=off) 상태. dogfood KB 가 V010 migration 을 받도록 한 번 ingest(또는 reset+reingest — pristine 필요 시): + +``` +KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml \ + /build/out/cargo-target/target/release/kebab eval run --config /build/dogfood/config.toml --mode hybrid --k 50 > /tmp/t7-off-run.log 2>&1; echo "EXIT=$?" +# run_id 추출 (Read 로 확인 — 추측 금지) +KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml \ + /build/out/cargo-target/target/release/kebab eval variants --config /build/dogfood/config.toml > /tmp/t7-off-var.log 2>&1; echo "EXIT=$?" +``` + +`/tmp/t7-off-var.log` 를 **Read 로 열어** `groups / fully_consistent / A_dominant / B_dominant / spread@10` 값을 그대로 기록. (Phase 1 baseline: `groups=8 fully_consistent=2 A_dominant=2 B_dominant=4 spread@10=0.750` 와 대조.) + +- [ ] **Step 3: 처방 (flag on) 측정** + +`/build/dogfood/config.toml` 에 추가: + +```toml +[ingest.expansion] +enabled = true +max_aliases_per_chunk = 8 +``` + +reset + reingest (별칭 생성 — Ollama gemma 필요, 시간 소요. 진행은 `kebab ingest` ndjson 으로 확인): + +``` +/build/out/cargo-target/target/release/kebab reset --config /build/dogfood/config.toml --yes > /tmp/t7-reset.log 2>&1; echo "EXIT=$?" +/build/out/cargo-target/target/release/kebab ingest --config /build/dogfood/config.toml > /tmp/t7-ingest.log 2>&1; echo "EXIT=$?" +KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml \ + /build/out/cargo-target/target/release/kebab eval run --config /build/dogfood/config.toml --mode hybrid --k 50 > /tmp/t7-on-run.log 2>&1; echo "EXIT=$?" +KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml \ + /build/out/cargo-target/target/release/kebab eval variants --config /build/dogfood/config.toml > /tmp/t7-on-var.log 2>&1; echo "EXIT=$?" +``` + +`/tmp/t7-on-var.log` 를 Read 로 열어 값 기록. **성공 기준**: B_dominant↓ / fully_consistent↑ / spread@10↓ (off 대비). 회귀: 기존 Ok 그룹이 깨지지 않는지. + +> ⚠️ 측정값 추측 금지([[feedback_search_quality_dogfood]]). grep clean 추출 + Read 확인값만 기록. 효과가 없거나 음수면 — spec §2 의 가설(KO↔EN 별칭이 우리 corpus 에서 recall 회복) 반증으로 보고, HOTFIXES 에 기록 후 사용자와 다음 단계 상의(default off 유지, 또는 프롬프트/단위 조정 재측정). + +- [ ] **Step 4: 문서 동기화** + +- `README.md`: **Configuration** 에 `[ingest.expansion]`(off 기본) 한 줄 + "별칭 생성은 색인 시간을 늘리며 Ollama LLM 필요" 포인터. flag 망라는 config 예제/`--help` 위임. +- `docs/ARCHITECTURE.md`: ingest 파이프라인에 expansion hook + `chunk_aliases_fts` 채널 1~2줄. lexical 병합 검색 언급. +- `HANDOFF.md`: "머지 후 발견된 버그/결정" 에 Phase 2 doc-side expansion 한 줄(측정 결과 요약). +- `tasks/HOTFIXES.md`: dated entry(2026-05-30 이후) — V010, 측정 표(off vs on), known limitation(필터 미적용 등). +- `docs/release-notes/v-draft.md`: V010 breaking schema → 4단락(변경/trade-off/mitigation/upgrade). 측정 evidence link. + +- [ ] **Step 5: 커밋** + +```bash +git add README.md docs/ARCHITECTURE.md HANDOFF.md tasks/HOTFIXES.md docs/release-notes +git commit -m "docs: doc-side expansion 측정 결과 + 문서 동기화 (V010)" +``` + +--- + +## Self-Review (작성자 체크 — plan 검토) + +- **Spec 커버리지:** §2 결정(D1~D4)→Task 4·5(청크당, 내용)·Task 1·2·5(additive)·Task 4(단순 품질). §3 아키텍처→Task 2(별도 테이블)·Task 6(lexical 병합). §4 스키마→Task 2. §5 프롬프트→Task 4. §6 versioning(try_skip 미변경)→Task 5 가 별칭 부재를 skip 판단에 안 넣음(기존 try_skip_unchanged 무수정). §7 측정→Task 7. §8 YAGNI(3채널/sparse/필터 제외)→plan 에 미포함(의도적). §9 테스트→각 Task TDD. §10 PR/문서→Task 7. ✅ +- **Placeholder 스캔:** Task 5 의 `OllamaLanguageModel::with_model` / dev-dep 줄은 "executor 가 실제 시그니처 확인 후 결정" 노트로 명시(미정이 아니라 분기 지시). Task 1 Step 4 / Task 6 Step 1 의 `grep` 은 주변 코드 확인 지시(완성 코드 자체는 제시). ✅ +- **타입 일관성:** `Chunk.aliases`(Task1) ↔ put_chunks(Task2) ↔ ExpansionGenerator.generate→Option(Task4) ↔ ingest hook `chunk.aliases = generator.generate(chunk)`(Task5). `build_match_string_for_column`(Task6 Step3) ↔ search() 호출(Step5). `RawRow`/`row_from_sql`/`build_hit` 재사용(Task6). ✅ +- **알려진 리스크:** Task 6 의 body/alias bm25 스케일 차이로 lexical 내부 순서가 근사 — hybrid 가 rank 변환하므로 pool 진입(핵심)은 보장, 정밀 순위는 측정 후. Task 5 end-to-end 는 Ollama 필요라 단위 테스트 불가 → Task 7 dogfood 로 검증. + +--- + +## Execution Handoff + +이 plan 은 핸드오프 §4.2 의 **OMC teammate(sequential single-team)** 로 task 별 구현 → code-reviewer 리뷰 → 독립 검증한다. Task 1~6 은 코드(executor), Task 7 은 측정+문서. 모델 라우팅(§4.3): Task 2·4·6(핵심 로직)=opus, Task 1·3·5(작은 변경)=sonnet, 리뷰는 핵심=opus. diff --git a/docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md b/docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md index 76ae6eb..305ce0e 100644 --- a/docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md +++ b/docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md @@ -53,34 +53,44 @@ ingest_one_asset (kebab-app/src/lib.rs:~1253) │ ├─ [NEW] if config.ingest.expansion.enabled: │ for chunk in &mut chunks: - │ aliases = ExpansionGenerator.generate(chunk.text)? # gemma 1회/청크 + │ aliases = ExpansionGenerator.generate(chunk)? # gemma 1회/청크 │ chunk.aliases = Some(aliases) # 상한·형식검증 적용 │ app.sqlite.put_chunks(doc_id, &chunks)? # chunks.aliases 컬럼 저장 │ - (V010 trigger) → chunks_fts.aliases 컬럼 색인 + (V010 chunk_aliases_au/ai trigger) → chunk_aliases_fts 별도 테이블 색인 │ embedder.embed(...) → vec_store.upsert(...) # dense는 body text 기준 (변경 없음) -검색 (kebab-search/src/hybrid.rs fuse): - body-lex = chunks_fts MATCH on text 컬럼 (rank_body) - alias-lex = chunks_fts MATCH on aliases 컬럼 (rank_alias) # [NEW] - vec = LanceDB e5-dense (rank_vec) - RRF: score(c) = 1/(k+rank_body) + 1/(k+rank_alias) + 1/(k+rank_vec) +검색 (kebab-search/src/lexical.rs run_query — body ∪ alias 한 SQL): + body = chunks_fts MATCH ? (bm25) ┐ UNION ALL → GROUP BY chunk_id + alias = chunk_aliases_fts MATCH ? (bm25) ┘ → 단일 lexical 결과 (rank 부여) + │ + HybridRetriever.fuse: RRF(lexical, vector) # 2채널 그대로 — RetrievalDetail/wire 무변경 ``` +**왜 lexical 내부 UNION 인가 (3채널 RRF 대신):** `RetrievalDetail` 은 `lexical_score`/ +`vector_score`/`*_rank` 만 보유하고 wire schema `search_hit.v1` 가 이를 그대로 노출한다. 정통 +3채널 RRF 는 `RetrievalDetail` + wire schema + `HybridRetriever` 시그니처 + 다수 테스트를 침습 +변경한다. alias-only 청크가 lexical 결과(→ hybrid pool)에 진입하기만 하면 pool-rescue 목적은 +동일하게 달성되므로, **`LexicalRetriever` 내부에서 body+alias 를 UNION** 해 단일 lexical 결과로 +내보낸다. `chunk_aliases_fts` 가 비면(flag off / 미생성) UNION 둘째 절이 0행 → 기존 동작과 +비트 단위로 동일 → **search-side 는 flag 게이트 불필요, ingest-side 만 게이트**. + ### 3.2 컴포넌트 (단위별 책임) -- **`ExpansionGenerator`** (kebab-app, LLM trait 경계로 mock 가능) - - 입력: 청크 본문(+ heading_path 컨텍스트), config(model, max_aliases, prompt_version). - - 출력: 검증된 별칭 문자열(개행/공백 join). 빈 출력·과길이 drop, 개수 상한 적용. - - 의존: `kebab_llm::LanguageModel` (기존 `OllamaLanguageModel` 재사용). LLM 호출 실패 시 - 해당 청크는 별칭 없이 진행(ingest 비중단 — fail-soft). -- **V010 migration** — `chunks.aliases TEXT` 컬럼 + `chunks_fts` 에 `aliases` 컬럼 + trigger 3종 - (`chunks_ai/ad/au`) 개정. 한국어 별칭도 본문과 동일 토크나이즈 정책 적용(V009 호환). -- **`fuse` 3채널 확장** (kebab-search/hybrid.rs) — 기존 2채널 → 3채널. alias 채널은 - `chunks_fts` 의 `aliases` 컬럼만 MATCH(FTS5 column filter). 별칭 없는 청크는 alias 채널에서 - 안 잡힘 → additive 보장. +- **`ExpansionGenerator`** (kebab-app, `kebab_llm::LanguageModel` trait 경계로 mock 가능) + - 입력: 청크(본문 + heading_path 컨텍스트), config(model, max_aliases, prompt_version). + - 출력: 검증된 별칭 문자열(개행 join). 빈 출력·과길이 drop, 개수 상한 적용. + - 의존: `LanguageModel::generate_stream`(스트림을 모아 문자열). 기존 `OllamaLanguageModel` + 재사용. LLM 호출 실패/빈 결과 시 해당 청크는 별칭 없이 진행(ingest 비중단 — **fail-soft**). +- **V010 migration** — `chunks.aliases TEXT` 컬럼 + **별도 `chunk_aliases_fts` virtual table** + + 별도 trigger 3종(`chunk_aliases_ai/ad/au`). 기존 `chunks_fts` / `chunks_ai/ad/au`(§5.5 + verbatim CI 대상)는 **무수정**. tokenizer `unicode61`(V009 동일). +- **`LexicalRetriever.run_query` UNION 확장** (kebab-search/lexical.rs) — `chunks_fts` 와 + `chunk_aliases_fts` 를 `UNION ALL` 서브쿼리로 검색 후 `GROUP BY chunk_id`(같은 청크가 양쪽 + 매칭 시 더 좋은=작은 bm25 채택), `chunks`/`documents` JOIN 으로 snippet·메타 보강. alias-only + 청크도 결과에 진입. `chunk_aliases_fts` 가 비면 UNION 둘째 절 0행 → 기존과 동일(회귀 안전). - **config `[ingest.expansion]`** — `IngestExpansionCfg`: - `enabled: bool` (default **false**) - `model: String` (default = `models.llm.model`) @@ -91,56 +101,54 @@ ingest_one_asset (kebab-app/src/lib.rs:~1253) ### 3.3 격리 / 코드 식별자 보존 (load-bearing) -- `text`(body) 컬럼은 **verbatim 유지**. 별칭은 `aliases` 별도 컬럼/채널 → body BM25 매칭과 - RRF 채널이 독립. 코드 식별자(`Vec::with_capacity`)의 정확매칭이 별칭 노이즈에 오염되지 않음. +- `chunks_fts.text`(body) 는 **verbatim 유지**, 별칭은 **별도 테이블** `chunk_aliases_fts`. + UNION 후 GROUP BY 라도 body 매칭의 bm25 가 그대로 보존되어, 코드 식별자(`Vec::with_capacity`) + 정확매칭이 별칭 노이즈에 희석되지 않음. - dense(e5) 임베딩은 **body text 기준 그대로** — 별칭을 임베딩에 넣지 않음(research: e5 dense 유지, bge-m3 dense 는 실측 더 나빴음). 별칭은 lexical 채널에만 기여. ## 4. 스키마 / migration (V010) -현재 최신 = V009. 신규 = **V010__chunk_aliases.sql**. +현재 최신 = V009. 신규 = **V010__chunk_aliases.sql**. 기존 `chunks_fts` / `chunks_ai/ad/au` +(§5.5 verbatim CI `fts_v009_matches_design_section_5_5_verbatim` 대상)는 **건드리지 않는다.** ```sql --- 1) chunks 테이블에 별칭 컬럼 -ALTER TABLE chunks ADD COLUMN aliases TEXT; -- nullable; 미생성/flag off = NULL +-- 1) chunks 테이블에 별칭 컬럼 (nullable; 미생성/flag off = NULL) +ALTER TABLE chunks ADD COLUMN aliases TEXT; --- 2) FTS5 가상 테이블에 aliases 컬럼 추가 --- (FTS5 는 ALTER ADD COLUMN 미지원 → drop & recreate & rebuild) -DROP TABLE chunks_fts; -CREATE VIRTUAL TABLE chunks_fts USING fts5( - chunk_id UNINDEXED, - doc_id UNINDEXED, - heading_path, - text, - aliases, -- [NEW] 별도 lexical 채널 - tokenize = 'unicode61' -- V009 와 동일 +-- 2) 별도 FTS5 가상 테이블 (body 와 분리된 lexical 채널) +CREATE VIRTUAL TABLE chunk_aliases_fts USING fts5( + chunk_id UNINDEXED, + doc_id UNINDEXED, + aliases, + tokenize = 'unicode61' -- V009 본문과 동일 tokenizer ); --- 3) trigger 3종 개정 (aliases 포함; body 는 V009 의 tokenized_korean 합성 유지) -DROP TRIGGER chunks_ai; DROP TRIGGER chunks_ad; DROP TRIGGER chunks_au; -CREATE TRIGGER chunks_ai AFTER INSERT ON chunks BEGIN - INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text, aliases) - VALUES (new.chunk_id, new.doc_id, new.heading_path_json, - CASE WHEN new.tokenized_korean_text IS NOT NULL - THEN new.tokenized_korean_text || ' ' || new.text - ELSE new.text END, - COALESCE(new.aliases, '')); +-- 3) 별도 sync trigger 3종 (aliases NULL 이면 색인 안 함) +CREATE TRIGGER chunk_aliases_ai AFTER INSERT ON chunks WHEN new.aliases IS NOT NULL BEGIN + INSERT INTO chunk_aliases_fts(chunk_id, doc_id, aliases) + VALUES (new.chunk_id, new.doc_id, new.aliases); +END; +CREATE TRIGGER chunk_aliases_ad AFTER DELETE ON chunks BEGIN + DELETE FROM chunk_aliases_fts WHERE chunk_id = old.chunk_id; +END; +CREATE TRIGGER chunk_aliases_au AFTER UPDATE ON chunks BEGIN + DELETE FROM chunk_aliases_fts WHERE chunk_id = old.chunk_id; + INSERT INTO chunk_aliases_fts(chunk_id, doc_id, aliases) + SELECT new.chunk_id, new.doc_id, new.aliases WHERE new.aliases IS NOT NULL; END; --- chunks_ad: DELETE FROM chunks_fts WHERE chunk_id = old.chunk_id; --- chunks_au: ad + ai 합성 (DELETE then INSERT) --- 4) 기존 행 재색인 (aliases 는 전부 NULL→'' 이므로 본문 색인 동일, 무영향) -INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text, aliases) - SELECT chunk_id, doc_id, heading_path_json, - CASE WHEN tokenized_korean_text IS NOT NULL - THEN tokenized_korean_text || ' ' || text ELSE text END, - COALESCE(aliases, '') - FROM chunks; +-- 4) backfill 불필요: 기존 행 aliases 전부 NULL → chunk_aliases_fts 빈 채로 시작. + +-- 5) corpus_revision bump (in-process search cache 무효화; V009 와 동일 패턴) +UPDATE kv SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT) WHERE key = 'corpus_revision'; ``` +- `put_chunks` 의 DELETE-then-INSERT(documents.rs:101) 는 `chunk_aliases_ad`(DELETE) + + `chunk_aliases_ai`(INSERT) 를 발화 → 별칭 동기화 자동. INSERT 문에 `aliases` 컬럼만 추가. - migration 은 refinery 자동 embed/apply. **migration = breaking schema change** → CLAUDE.md §Release / Dogfood trigger 발동(V010, dogfood + release notes). -- `kebab_core::Chunk` 에 `aliases: Option` 필드 추가. `put_chunks` INSERT 에 컬럼 추가. +- `kebab_core::Chunk` 에 `aliases: Option` 필드 추가(`#[serde(default)]`). ## 5. gemma 프롬프트 (expansion-v1) @@ -190,10 +198,11 @@ KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml \ - migration: V010 적용 후 `chunks.aliases` + `chunks_fts.aliases` 존재, 기존 행 본문 색인 동일. - `put_chunks`/`get` round-trip: `aliases=Some(..)` 저장·조회. -- FTS5 alias 검색: aliases 에만 있는 term 으로 MATCH 시 해당 chunk 회수. -- RRF 3채널: alias 채널에만 매칭되는 청크가 fused 결과 pool 에 진입(additive 효과 핵심 회귀). +- FTS5 alias 검색: `chunk_aliases_fts` 의 term 으로 MATCH 시 해당 chunk 회수. +- lexical UNION: body 에 없고 alias 에만 있는 term 으로 검색 시 alias-only 청크가 `LexicalRetriever` + 결과(→ hybrid pool)에 진입(pool-rescue 핵심 회귀). 양쪽 매칭 청크는 중복 없이 1개. - `ExpansionGenerator`(LLM mock): 프롬프트→파싱, 상한 N 적용, 빈/과길이 drop, LLM 실패 시 fail-soft. -- flag off 회귀: expansion disabled 시 색인·검색 결과가 V009 와 동일(별칭 컬럼 NULL, 채널 무영향). +- 회귀: `chunk_aliases_fts` 빈 상태에서 `run_query` 결과가 V009 와 동일(UNION 둘째 절 0행). ## 10. PR / 문서 동기화 -- 2.49.1 From 848b75c069149c0fede80ea36e930016eebd0fba Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 30 May 2026 02:09:39 +0000 Subject: [PATCH 03/27] =?UTF-8?q?feat(core):=20Chunk.aliases=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20(doc-side=20expansion)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- crates/kebab-chunk/src/code_c_ast_v1.rs | 1 + crates/kebab-chunk/src/code_cpp_ast_v1.rs | 1 + crates/kebab-chunk/src/code_go_ast_v1.rs | 1 + crates/kebab-chunk/src/code_java_ast_v1.rs | 1 + crates/kebab-chunk/src/code_js_ast_v1.rs | 1 + crates/kebab-chunk/src/code_kotlin_ast_v1.rs | 1 + crates/kebab-chunk/src/code_python_ast_v1.rs | 1 + crates/kebab-chunk/src/code_rust_ast_v1.rs | 1 + crates/kebab-chunk/src/code_ts_ast_v1.rs | 1 + crates/kebab-chunk/src/md_heading_v1.rs | 1 + crates/kebab-chunk/src/pdf_page_v1.rs | 1 + crates/kebab-chunk/src/tier2_shared.rs | 1 + crates/kebab-core/src/chunk.rs | 31 +++++++++++++++++++ crates/kebab-store-sqlite/src/documents.rs | 1 + .../kebab-store-sqlite/tests/idempotency.rs | 1 + crates/kebab-tui/tests/inspect.rs | 1 + 16 files changed, 46 insertions(+) diff --git a/crates/kebab-chunk/src/code_c_ast_v1.rs b/crates/kebab-chunk/src/code_c_ast_v1.rs index 642f9d3..9bedb5a 100644 --- a/crates/kebab-chunk/src/code_c_ast_v1.rs +++ b/crates/kebab-chunk/src/code_c_ast_v1.rs @@ -152,6 +152,7 @@ fn make_chunk( token_estimate, chunker_version: chunker_version.clone(), policy_hash: base_policy_hash.to_string(), + aliases: None, } } diff --git a/crates/kebab-chunk/src/code_cpp_ast_v1.rs b/crates/kebab-chunk/src/code_cpp_ast_v1.rs index f9ca1a1..06804bf 100644 --- a/crates/kebab-chunk/src/code_cpp_ast_v1.rs +++ b/crates/kebab-chunk/src/code_cpp_ast_v1.rs @@ -154,6 +154,7 @@ fn make_chunk( token_estimate, chunker_version: chunker_version.clone(), policy_hash: base_policy_hash.to_string(), + aliases: None, } } diff --git a/crates/kebab-chunk/src/code_go_ast_v1.rs b/crates/kebab-chunk/src/code_go_ast_v1.rs index 22e9310..825a003 100644 --- a/crates/kebab-chunk/src/code_go_ast_v1.rs +++ b/crates/kebab-chunk/src/code_go_ast_v1.rs @@ -154,6 +154,7 @@ fn make_chunk( token_estimate, chunker_version: chunker_version.clone(), policy_hash: base_policy_hash.to_string(), + aliases: None, } } diff --git a/crates/kebab-chunk/src/code_java_ast_v1.rs b/crates/kebab-chunk/src/code_java_ast_v1.rs index 07e0ab8..0b24a86 100644 --- a/crates/kebab-chunk/src/code_java_ast_v1.rs +++ b/crates/kebab-chunk/src/code_java_ast_v1.rs @@ -154,6 +154,7 @@ fn make_chunk( token_estimate, chunker_version: chunker_version.clone(), policy_hash: base_policy_hash.to_string(), + aliases: None, } } diff --git a/crates/kebab-chunk/src/code_js_ast_v1.rs b/crates/kebab-chunk/src/code_js_ast_v1.rs index 8ae1fc5..8480075 100644 --- a/crates/kebab-chunk/src/code_js_ast_v1.rs +++ b/crates/kebab-chunk/src/code_js_ast_v1.rs @@ -154,6 +154,7 @@ fn make_chunk( token_estimate, chunker_version: chunker_version.clone(), policy_hash: base_policy_hash.to_string(), + aliases: None, } } diff --git a/crates/kebab-chunk/src/code_kotlin_ast_v1.rs b/crates/kebab-chunk/src/code_kotlin_ast_v1.rs index 1c1a386..4a3a6cb 100644 --- a/crates/kebab-chunk/src/code_kotlin_ast_v1.rs +++ b/crates/kebab-chunk/src/code_kotlin_ast_v1.rs @@ -154,6 +154,7 @@ fn make_chunk( token_estimate, chunker_version: chunker_version.clone(), policy_hash: base_policy_hash.to_string(), + aliases: None, } } diff --git a/crates/kebab-chunk/src/code_python_ast_v1.rs b/crates/kebab-chunk/src/code_python_ast_v1.rs index ac62678..17aeb0d 100644 --- a/crates/kebab-chunk/src/code_python_ast_v1.rs +++ b/crates/kebab-chunk/src/code_python_ast_v1.rs @@ -154,6 +154,7 @@ fn make_chunk( token_estimate, chunker_version: chunker_version.clone(), policy_hash: base_policy_hash.to_string(), + aliases: None, } } diff --git a/crates/kebab-chunk/src/code_rust_ast_v1.rs b/crates/kebab-chunk/src/code_rust_ast_v1.rs index 365ed87..c44bd49 100644 --- a/crates/kebab-chunk/src/code_rust_ast_v1.rs +++ b/crates/kebab-chunk/src/code_rust_ast_v1.rs @@ -154,6 +154,7 @@ fn make_chunk( token_estimate, chunker_version: chunker_version.clone(), policy_hash: base_policy_hash.to_string(), + aliases: None, } } diff --git a/crates/kebab-chunk/src/code_ts_ast_v1.rs b/crates/kebab-chunk/src/code_ts_ast_v1.rs index 42dd4ac..e66c5d3 100644 --- a/crates/kebab-chunk/src/code_ts_ast_v1.rs +++ b/crates/kebab-chunk/src/code_ts_ast_v1.rs @@ -154,6 +154,7 @@ fn make_chunk( token_estimate, chunker_version: chunker_version.clone(), policy_hash: base_policy_hash.to_string(), + aliases: None, } } diff --git a/crates/kebab-chunk/src/md_heading_v1.rs b/crates/kebab-chunk/src/md_heading_v1.rs index 0265d1f..d2559cb 100644 --- a/crates/kebab-chunk/src/md_heading_v1.rs +++ b/crates/kebab-chunk/src/md_heading_v1.rs @@ -339,6 +339,7 @@ fn build_chunk( token_estimate, chunker_version: chunker_version.clone(), policy_hash: policy_hash.to_string(), + aliases: None, } } diff --git a/crates/kebab-chunk/src/pdf_page_v1.rs b/crates/kebab-chunk/src/pdf_page_v1.rs index e615163..9f7a5ac 100644 --- a/crates/kebab-chunk/src/pdf_page_v1.rs +++ b/crates/kebab-chunk/src/pdf_page_v1.rs @@ -177,6 +177,7 @@ impl Chunker for PdfPageV1Chunker { token_estimate, chunker_version: chunker_version.clone(), policy_hash: base_policy_hash.clone(), + aliases: None, }); } } diff --git a/crates/kebab-chunk/src/tier2_shared.rs b/crates/kebab-chunk/src/tier2_shared.rs index 8f67d79..a385ce3 100644 --- a/crates/kebab-chunk/src/tier2_shared.rs +++ b/crates/kebab-chunk/src/tier2_shared.rs @@ -196,5 +196,6 @@ fn build_chunk_from_span( token_estimate, chunker_version: chunker_version.clone(), policy_hash: base_policy_hash.to_string(), + aliases: None, } } diff --git a/crates/kebab-core/src/chunk.rs b/crates/kebab-core/src/chunk.rs index 10dce5f..eaa81db 100644 --- a/crates/kebab-core/src/chunk.rs +++ b/crates/kebab-core/src/chunk.rs @@ -28,4 +28,35 @@ pub struct Chunk { /// Bug #8 (한국어 2자 query) 해결을 위한 V009 cascade. #[serde(default)] pub tokenized_korean_text: Option, + /// 색인시 doc-side expansion (Phase 2) 으로 생성된 "검색용 별칭" + /// (같은언어 paraphrase + 한↔영 번역, 개행 join). `[ingest.expansion]` + /// flag off 또는 미생성이면 None — 별도 FTS5 테이블 `chunk_aliases_fts` + /// 에만 색인되고 본문 매칭/dense 임베딩에는 영향 없음. 설계 spec + /// `2026-05-30-doc-side-expansion-design.md` §3.3. + #[serde(default)] + pub aliases: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aliases_defaults_to_none_on_deserialize() { + // aliases 필드가 없는 과거 JSON 도 파싱되어야 한다 (#[serde(default)]). + let json = r#"{ + "chunk_id": "c1", + "doc_id": "d1", + "block_ids": [], + "text": "hello", + "heading_path": [], + "source_spans": [], + "token_estimate": 1, + "chunker_version": "md-heading-v1", + "policy_hash": "abc" + }"#; + let c: Chunk = serde_json::from_str(json).unwrap(); + assert_eq!(c.aliases, None); + assert_eq!(c.tokenized_korean_text, None); + } } diff --git a/crates/kebab-store-sqlite/src/documents.rs b/crates/kebab-store-sqlite/src/documents.rs index e1dcd57..e02db50 100644 --- a/crates/kebab-store-sqlite/src/documents.rs +++ b/crates/kebab-store-sqlite/src/documents.rs @@ -250,6 +250,7 @@ impl kebab_core::DocumentStore for SqliteStore { chunker_version: kebab_core::ChunkerVersion(row.chunker_version), policy_hash: row.policy_hash, tokenized_korean_text: row.tokenized_korean_text, + aliases: None, })) } diff --git a/crates/kebab-store-sqlite/tests/idempotency.rs b/crates/kebab-store-sqlite/tests/idempotency.rs index 1171c0a..080389e 100644 --- a/crates/kebab-store-sqlite/tests/idempotency.rs +++ b/crates/kebab-store-sqlite/tests/idempotency.rs @@ -98,6 +98,7 @@ fn make_chunks(doc_id: &DocumentId) -> Vec { chunker_version: ChunkerVersion("md-heading-v1".into()), policy_hash: "deadbeefdeadbeef".into(), tokenized_korean_text: None, + aliases: None, }] } diff --git a/crates/kebab-tui/tests/inspect.rs b/crates/kebab-tui/tests/inspect.rs index 4e0525f..d3dbe70 100644 --- a/crates/kebab-tui/tests/inspect.rs +++ b/crates/kebab-tui/tests/inspect.rs @@ -114,6 +114,7 @@ fn make_chunk() -> Chunk { chunker_version: ChunkerVersion("md-heading-v1".into()), policy_hash: "deadbeefdeadbeef".into(), tokenized_korean_text: None, + aliases: None, } } -- 2.49.1 From b12a616ab2cfeee3ce38e91f42408397df01d19e Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 30 May 2026 02:15:27 +0000 Subject: [PATCH 04/27] =?UTF-8?q?feat(store):=20V010=20chunk=5Faliases=5Ff?= =?UTF-8?q?ts=20+=20put=5Fchunks=20=EB=B3=84=EC=B9=AD=20=EC=98=81=EC=86=8D?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- crates/kebab-store-sqlite/src/documents.rs | 5 +- .../kebab-store-sqlite/tests/chunk_aliases.rs | 163 ++++++++++++++++++ .../tests/corpus_revision.rs | 18 +- migrations/V010__chunk_aliases.sql | 33 ++++ 4 files changed, 208 insertions(+), 11 deletions(-) create mode 100644 crates/kebab-store-sqlite/tests/chunk_aliases.rs create mode 100644 migrations/V010__chunk_aliases.sql diff --git a/crates/kebab-store-sqlite/src/documents.rs b/crates/kebab-store-sqlite/src/documents.rs index e02db50..b1cc3de 100644 --- a/crates/kebab-store-sqlite/src/documents.rs +++ b/crates/kebab-store-sqlite/src/documents.rs @@ -106,8 +106,8 @@ impl kebab_core::DocumentStore for SqliteStore { chunk_id, doc_id, text, heading_path_json, section_label, source_spans_json, token_estimate, chunker_version, policy_hash, block_ids_json, created_at, - tokenized_korean_text - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + tokenized_korean_text, aliases + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .map_err(StoreError::from)?; for chunk in chunks { @@ -136,6 +136,7 @@ impl kebab_core::DocumentStore for SqliteStore { block_ids, now, chunk.tokenized_korean_text.as_deref(), + chunk.aliases.as_deref(), ]) .map_err(StoreError::from)?; } diff --git a/crates/kebab-store-sqlite/tests/chunk_aliases.rs b/crates/kebab-store-sqlite/tests/chunk_aliases.rs new file mode 100644 index 0000000..96c7cd4 --- /dev/null +++ b/crates/kebab-store-sqlite/tests/chunk_aliases.rs @@ -0,0 +1,163 @@ +//! V010 doc-side expansion: `put_chunks` 가 `chunk.aliases` 를 chunks.aliases +//! 컬럼에 영속화하고, chunk_aliases_ai trigger 가 별도 `chunk_aliases_fts` +//! 가상 테이블로 mirror 하는지 검증. +//! +//! `put_chunks` 는 store-owned conn(FK ON)에서 도므로 chunks 의 +//! `doc_id REFERENCES documents(doc_id)` FK 를 만족시키려면 asset + +//! document 그래프가 먼저 있어야 한다. 헬퍼는 `idempotency.rs` 패턴 복제. +//! 인덱싱 검증은 side-channel `env.with_conn` 으로 chunk_aliases_fts 를 직접 +//! MATCH 한다(같은 established 패턴). + +use std::path::PathBuf; + +use kebab_core::{ + AssetId, AssetStorage, Block, CanonicalDocument, Checksum, Chunk, ChunkerVersion, CommonBlock, + DocumentId, DocumentStore, HeadingBlock, Lang, MediaType, Metadata, ParserVersion, Provenance, + SourceSpan, SourceType, SourceUri, TextBlock, TrustLevel, WorkspacePath, +}; +use kebab_store_sqlite::SqliteStore; +use time::OffsetDateTime; + +mod common; + +fn make_asset() -> kebab_core::RawAsset { + let bytes = b"dummy"; + kebab_core::RawAsset { + asset_id: AssetId("a".repeat(32)), + source_uri: SourceUri::File(PathBuf::from("/tmp/foo.md")), + workspace_path: WorkspacePath::new("notes/foo.md".into()).unwrap(), + media_type: MediaType::Markdown, + byte_len: bytes.len() as u64, + checksum: Checksum(blake3::hash(bytes).to_hex().to_string()), + discovered_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(), + stored: AssetStorage::Reference { + path: PathBuf::from("/tmp/foo.md"), + sha: Checksum(blake3::hash(bytes).to_hex().to_string()), + }, + } +} + +fn make_metadata() -> Metadata { + Metadata { + aliases: vec![], + tags: vec![], + created_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(), + updated_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(), + source_type: SourceType::Markdown, + trust_level: TrustLevel::Primary, + user_id_alias: None, + user: Default::default(), + repo: None, + git_branch: None, + git_commit: None, + code_lang: None, + } +} + +fn make_doc() -> CanonicalDocument { + let doc_id = DocumentId("d".repeat(32)); + let span = SourceSpan::Line { start: 1, end: 1 }; + let block = Block::Heading(HeadingBlock { + common: CommonBlock { + block_id: kebab_core::BlockId("b".repeat(32)), + heading_path: vec![], + source_span: span.clone(), + }, + level: 1, + text: "Title".into(), + }); + let para = Block::Paragraph(TextBlock { + common: CommonBlock { + block_id: kebab_core::BlockId("c".repeat(32)), + heading_path: vec!["Title".into()], + source_span: span, + }, + text: "body".into(), + inlines: vec![], + }); + CanonicalDocument { + doc_id, + source_asset_id: AssetId("a".repeat(32)), + workspace_path: WorkspacePath::new("notes/foo.md".into()).unwrap(), + title: "Title".into(), + lang: Lang("en".into()), + blocks: vec![block, para], + metadata: make_metadata(), + provenance: Provenance { events: vec![] }, + parser_version: ParserVersion("test-parser".into()), + schema_version: 1, + doc_version: 1, + last_chunker_version: None, + last_embedding_version: None, + } +} + +/// 단일 청크 생성. `aliases` 만 호출측이 지정. +fn base_chunk(chunk_id: &str, doc_id: &DocumentId, aliases: Option) -> Chunk { + Chunk { + chunk_id: kebab_core::ChunkId(chunk_id.into()), + doc_id: doc_id.clone(), + block_ids: vec![kebab_core::BlockId("b".repeat(32))], + text: "Rust ownership and borrowing".into(), + heading_path: vec!["Title".into()], + source_spans: vec![SourceSpan::Line { start: 1, end: 1 }], + token_estimate: 5, + chunker_version: ChunkerVersion("md-heading-v1".into()), + policy_hash: "h".into(), + tokenized_korean_text: None, + aliases, + } +} + +/// asset + document 그래프를 깔고 마이그레이션된 store 를 돌려준다. +fn open_store_with_document(env: &common::TestEnv) -> SqliteStore { + let store = SqliteStore::open(&env.config()).unwrap(); + store.run_migrations().unwrap(); + store.put_asset(&make_asset()).expect("put_asset"); + store.put_document(&make_doc()).expect("put_document"); + store +} + +#[test] +fn aliases_indexed_into_chunk_aliases_fts() { + let env = common::TestEnv::new(); + let store = open_store_with_document(&env); + let doc = DocumentId("d".repeat(32)); + let chunk = base_chunk( + &"e".repeat(32), + &doc, + Some("메모리 안전성\nwho owns the value".into()), + ); + store.put_chunks(&doc, &[chunk]).unwrap(); + + // 별칭에만 있는 한국어 term 으로 chunk_aliases_fts 검색 → 청크 회수. + let n: i64 = env.with_conn(|c| { + c.query_row( + "SELECT count(*) FROM chunk_aliases_fts \ + WHERE chunk_aliases_fts MATCH 'aliases : (\"메모리\")'", + [], + |r| r.get(0), + ) + }); + assert_eq!( + n, 1, + "aliases 의 한국어 term 이 chunk_aliases_fts 에 색인돼야 한다" + ); +} + +#[test] +fn none_aliases_not_indexed() { + let env = common::TestEnv::new(); + let store = open_store_with_document(&env); + let doc = DocumentId("d".repeat(32)); + let chunk = base_chunk(&"e".repeat(32), &doc, None); + store.put_chunks(&doc, &[chunk]).unwrap(); + + let n: i64 = env.with_conn(|c| { + c.query_row("SELECT count(*) FROM chunk_aliases_fts", [], |r| r.get(0)) + }); + assert_eq!( + n, 0, + "aliases=None 이면 chunk_aliases_fts 에 행이 없어야 한다" + ); +} diff --git a/crates/kebab-store-sqlite/tests/corpus_revision.rs b/crates/kebab-store-sqlite/tests/corpus_revision.rs index 2ba6026..488d81a 100644 --- a/crates/kebab-store-sqlite/tests/corpus_revision.rs +++ b/crates/kebab-store-sqlite/tests/corpus_revision.rs @@ -20,26 +20,26 @@ fn open_store(tmp: &TempDir) -> SqliteStore { store } -/// Fresh store baseline: V004 seeds `corpus_revision = 0`, then V009 -/// migration bumps it by one to invalidate any pre-V009 LRU cache — -/// so a fresh store after `run_migrations()` reads back as `1`. +/// Fresh store baseline: V004 seeds `corpus_revision = 0`, then both V009 +/// and V010 migrations bump it by one each to invalidate any stale LRU +/// cache — so a fresh store after `run_migrations()` reads back as `2`. #[test] fn fresh_store_starts_at_post_migration_baseline() { let tmp = TempDir::new().unwrap(); let store = open_store(&tmp); - assert_eq!(store.corpus_revision(), 1); + assert_eq!(store.corpus_revision(), 2); } /// Each `bump_corpus_revision` returns the new value monotonically -/// from the post-migration baseline. +/// from the post-migration baseline (V009 + V010 → 2). #[test] fn bump_increments_monotonically() { let tmp = TempDir::new().unwrap(); let store = open_store(&tmp); - assert_eq!(store.bump_corpus_revision().unwrap(), 2); assert_eq!(store.bump_corpus_revision().unwrap(), 3); assert_eq!(store.bump_corpus_revision().unwrap(), 4); - assert_eq!(store.corpus_revision(), 4); + assert_eq!(store.bump_corpus_revision().unwrap(), 5); + assert_eq!(store.corpus_revision(), 5); } /// `corpus_revision` survives a store re-open (persisted in SQLite). @@ -52,6 +52,6 @@ fn revision_persists_across_reopen() { store.bump_corpus_revision().unwrap(); } // store dropped — file closed let store = open_store(&tmp); - assert_eq!(store.corpus_revision(), 3); - assert_eq!(store.bump_corpus_revision().unwrap(), 4); + assert_eq!(store.corpus_revision(), 4); + assert_eq!(store.bump_corpus_revision().unwrap(), 5); } diff --git a/migrations/V010__chunk_aliases.sql b/migrations/V010__chunk_aliases.sql new file mode 100644 index 0000000..130dbad --- /dev/null +++ b/migrations/V010__chunk_aliases.sql @@ -0,0 +1,33 @@ +-- V010__chunk_aliases.sql — doc-side expansion (Phase 2) 검색용 별칭 채널. +-- +-- 설계 spec docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md §4. +-- chunks 에 nullable `aliases` 컬럼 + 별도 FTS5 테이블 chunk_aliases_fts + +-- 별도 sync trigger. 기존 chunks_fts / chunks_ai/ad/au (design §5.5 verbatim, +-- CI test fts_v009_matches_design_section_5_5_verbatim) 는 무수정. +-- aliases 는 additive: 미생성/flag off 이면 NULL → chunk_aliases_fts 빈 채로 +-- 시작, 검색 UNION 둘째 절 0행 → 기존 동작과 동일. 자동 backfill 없음. + +ALTER TABLE chunks ADD COLUMN aliases TEXT; + +CREATE VIRTUAL TABLE chunk_aliases_fts USING fts5( + chunk_id UNINDEXED, + doc_id UNINDEXED, + aliases, + tokenize = 'unicode61' +); + +CREATE TRIGGER chunk_aliases_ai AFTER INSERT ON chunks WHEN new.aliases IS NOT NULL BEGIN + INSERT INTO chunk_aliases_fts(chunk_id, doc_id, aliases) + VALUES (new.chunk_id, new.doc_id, new.aliases); +END; +CREATE TRIGGER chunk_aliases_ad AFTER DELETE ON chunks BEGIN + DELETE FROM chunk_aliases_fts WHERE chunk_id = old.chunk_id; +END; +CREATE TRIGGER chunk_aliases_au AFTER UPDATE ON chunks BEGIN + DELETE FROM chunk_aliases_fts WHERE chunk_id = old.chunk_id; + INSERT INTO chunk_aliases_fts(chunk_id, doc_id, aliases) + SELECT new.chunk_id, new.doc_id, new.aliases WHERE new.aliases IS NOT NULL; +END; + +-- in-process LRU search cache 무효화 (V009 와 동일 패턴). +UPDATE kv SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT) WHERE key = 'corpus_revision'; -- 2.49.1 From 0df47febf0a5d05d852dcc63985d51bc7ad5b4b8 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 30 May 2026 02:24:24 +0000 Subject: [PATCH 05/27] =?UTF-8?q?test(store):=20doc-side=20expansion=20Tas?= =?UTF-8?q?k=202=20=EB=A6=AC=EB=B7=B0=20=EB=B3=B4=EA=B0=95=20(M1/M2/N1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M1: chunk_aliases trigger 가드에 AND aliases <> '' (빈 문자열 미색인) - M2: 재색인 멱등 테스트 (재-put 후 별칭 행 1개) - N1: 본문 격리 음성 단언 (별칭 term 이 chunks_fts 로 누출 안 됨) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../kebab-store-sqlite/tests/chunk_aliases.rs | 57 +++++++++++++++++++ migrations/V010__chunk_aliases.sql | 8 ++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/crates/kebab-store-sqlite/tests/chunk_aliases.rs b/crates/kebab-store-sqlite/tests/chunk_aliases.rs index 96c7cd4..cbfc818 100644 --- a/crates/kebab-store-sqlite/tests/chunk_aliases.rs +++ b/crates/kebab-store-sqlite/tests/chunk_aliases.rs @@ -161,3 +161,60 @@ fn none_aliases_not_indexed() { "aliases=None 이면 chunk_aliases_fts 에 행이 없어야 한다" ); } + +/// Task 2 리뷰 M2: 같은 doc 을 두 번 `put_chunks` 해도 `chunk_aliases_fts` +/// 행이 중복되지 않아야 한다. put_chunks 의 DELETE-then-INSERT 가 +/// chunk_aliases_ad → chunk_aliases_ai 를 발화해 멱등 재동기화하는지 검증. +#[test] +fn reput_keeps_single_alias_row() { + let env = common::TestEnv::new(); + let store = open_store_with_document(&env); + let doc = DocumentId("d".repeat(32)); + let mk = || base_chunk(&"e".repeat(32), &doc, Some("메모리 안전성".into())); + + store.put_chunks(&doc, &[mk()]).unwrap(); + store.put_chunks(&doc, &[mk()]).unwrap(); // 같은 doc 재-put + + let n: i64 = env.with_conn(|c| { + c.query_row("SELECT count(*) FROM chunk_aliases_fts", [], |r| r.get(0)) + }); + assert_eq!(n, 1, "재색인 후에도 별칭 행은 1개여야 한다 (중복/누락 없음)"); +} + +/// Task 2 리뷰 N1: 별칭 term 이 본문 `chunks_fts` 로 새지 않아야 한다(§3.3 격리). +/// 본문엔 없고 별칭에만 있는 한국어 term 으로 chunks_fts 를 MATCH 하면 0행. +#[test] +fn aliases_dont_leak_into_body_fts() { + let env = common::TestEnv::new(); + let store = open_store_with_document(&env); + let doc = DocumentId("d".repeat(32)); + // 본문 "Rust ownership and borrowing" 에 "메모리" 없음, 별칭에만 있음. + let chunk = base_chunk(&"e".repeat(32), &doc, Some("메모리 안전성".into())); + store.put_chunks(&doc, &[chunk]).unwrap(); + + let body_hits: i64 = env.with_conn(|c| { + c.query_row( + "SELECT count(*) FROM chunks_fts WHERE chunks_fts MATCH 'text : (\"메모리\")'", + [], + |r| r.get(0), + ) + }); + assert_eq!(body_hits, 0, "별칭 term 이 본문 chunks_fts 로 누출되면 안 된다"); +} + +/// Task 2 리뷰 M1: 빈 문자열 별칭은 색인하지 않는다(trigger 가드 +/// `AND new.aliases <> ''`). producer 가 Some("") 를 넘겨도 무용한 행이 +/// chunk_aliases_fts 에 쌓이지 않아야 한다. +#[test] +fn empty_string_alias_not_indexed() { + let env = common::TestEnv::new(); + let store = open_store_with_document(&env); + let doc = DocumentId("d".repeat(32)); + let chunk = base_chunk(&"e".repeat(32), &doc, Some(String::new())); + store.put_chunks(&doc, &[chunk]).unwrap(); + + let n: i64 = env.with_conn(|c| { + c.query_row("SELECT count(*) FROM chunk_aliases_fts", [], |r| r.get(0)) + }); + assert_eq!(n, 0, "빈 문자열 별칭은 chunk_aliases_fts 에 색인되면 안 된다"); +} diff --git a/migrations/V010__chunk_aliases.sql b/migrations/V010__chunk_aliases.sql index 130dbad..e88d19e 100644 --- a/migrations/V010__chunk_aliases.sql +++ b/migrations/V010__chunk_aliases.sql @@ -16,7 +16,10 @@ CREATE VIRTUAL TABLE chunk_aliases_fts USING fts5( tokenize = 'unicode61' ); -CREATE TRIGGER chunk_aliases_ai AFTER INSERT ON chunks WHEN new.aliases IS NOT NULL BEGIN +-- 가드 `IS NOT NULL AND <> ''`: producer 가 Some("") 를 넘겨도 내용 없는 +-- 행이 chunk_aliases_fts 에 쌓이지 않게 한다(Task 2 리뷰 M1). +CREATE TRIGGER chunk_aliases_ai AFTER INSERT ON chunks + WHEN new.aliases IS NOT NULL AND new.aliases <> '' BEGIN INSERT INTO chunk_aliases_fts(chunk_id, doc_id, aliases) VALUES (new.chunk_id, new.doc_id, new.aliases); END; @@ -26,7 +29,8 @@ END; CREATE TRIGGER chunk_aliases_au AFTER UPDATE ON chunks BEGIN DELETE FROM chunk_aliases_fts WHERE chunk_id = old.chunk_id; INSERT INTO chunk_aliases_fts(chunk_id, doc_id, aliases) - SELECT new.chunk_id, new.doc_id, new.aliases WHERE new.aliases IS NOT NULL; + SELECT new.chunk_id, new.doc_id, new.aliases + WHERE new.aliases IS NOT NULL AND new.aliases <> ''; END; -- in-process LRU search cache 무효화 (V009 와 동일 패턴). -- 2.49.1 From cc31868d2438a44f32a76101758c4487a2d7d442 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 30 May 2026 02:26:41 +0000 Subject: [PATCH 06/27] feat(config): [ingest.expansion] flag (default off) --- crates/kebab-config/src/lib.rs | 65 ++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/crates/kebab-config/src/lib.rs b/crates/kebab-config/src/lib.rs index 907954a..34ca1ba 100644 --- a/crates/kebab-config/src/lib.rs +++ b/crates/kebab-config/src/lib.rs @@ -595,6 +595,8 @@ impl UiCfg { #[serde(default)] pub struct IngestCfg { pub code: IngestCodeCfg, + #[serde(default)] + pub expansion: IngestExpansionCfg, } /// p10-1A-1: settings for the code ingest pipeline. All fields have @@ -635,6 +637,31 @@ impl Default for IngestCodeCfg { } } +/// doc-side expansion config. Default: disabled (requires explicit opt-in). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct IngestExpansionCfg { + /// Whether doc-side alias expansion is enabled during ingest. + pub enabled: bool, + /// Ollama model used for alias generation (empty = use LLM default). + pub model: String, + /// Maximum aliases generated per chunk. + pub max_aliases_per_chunk: usize, + /// Prompt template version tag. + pub prompt_version: String, +} + +impl Default for IngestExpansionCfg { + fn default() -> Self { + Self { + enabled: false, + model: String::new(), + max_aliases_per_chunk: 8, + prompt_version: "expansion-v1".to_string(), + } + } +} + impl Config { /// Defaults per design §6.4. pub fn defaults() -> Self { @@ -1119,6 +1146,22 @@ impl Config { self.pdf.ocr.lang_hint = if v.is_empty() { None } else { Some(v.clone()) }; } + // ingest.expansion + "KEBAB_INGEST_EXPANSION_ENABLED" => { + self.ingest.expansion.enabled = parse_bool(v); + } + "KEBAB_INGEST_EXPANSION_MODEL" => { + self.ingest.expansion.model = v.clone(); + } + "KEBAB_INGEST_EXPANSION_MAX_ALIASES" => { + if let Ok(n) = v.parse::() { + self.ingest.expansion.max_aliases_per_chunk = n; + } + } + "KEBAB_INGEST_EXPANSION_PROMPT_VERSION" => { + self.ingest.expansion.prompt_version = v.clone(); + } + // Unknown KEBAB_* keys are silently ignored — see // `env_unknown_key_is_ignored` test. _ => {} @@ -1846,6 +1889,28 @@ max_context_tokens = 8000 let cfg: Config = toml::from_str(&toml_text).unwrap(); assert_eq!(cfg.ingest.code.max_file_bytes, 524_288); } + + #[test] + fn expansion_defaults_off() { + let cfg = Config::defaults(); + assert!(!cfg.ingest.expansion.enabled); + assert_eq!(cfg.ingest.expansion.max_aliases_per_chunk, 8); + assert_eq!(cfg.ingest.expansion.prompt_version, "expansion-v1"); + } + + #[test] + fn expansion_env_override() { + let mut env = HashMap::new(); + env.insert("KEBAB_INGEST_EXPANSION_ENABLED".into(), "true".into()); + env.insert("KEBAB_INGEST_EXPANSION_MODEL".into(), "gemma3:4b".into()); + env.insert("KEBAB_INGEST_EXPANSION_MAX_ALIASES".into(), "12".into()); + env.insert("KEBAB_INGEST_EXPANSION_PROMPT_VERSION".into(), "expansion-v2".into()); + let c = Config::defaults().apply_env(&env); + assert!(c.ingest.expansion.enabled); + assert_eq!(c.ingest.expansion.model, "gemma3:4b"); + assert_eq!(c.ingest.expansion.max_aliases_per_chunk, 12); + assert_eq!(c.ingest.expansion.prompt_version, "expansion-v2"); + } } #[cfg(test)] -- 2.49.1 From 2a207f986837edfb164c02b48aac6cfee0081e58 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 30 May 2026 02:36:20 +0000 Subject: [PATCH 07/27] =?UTF-8?q?feat(app):=20ExpansionGenerator=20?= =?UTF-8?q?=E2=80=94=20=EC=B2=AD=ED=81=AC=EB=8B=B9=20=EB=B3=84=EC=B9=AD=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20(fail-soft)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- crates/kebab-app/Cargo.toml | 4 + crates/kebab-app/src/expansion.rs | 160 ++++++++++++++++++++++++++++++ crates/kebab-app/src/lib.rs | 1 + 3 files changed, 165 insertions(+) create mode 100644 crates/kebab-app/src/expansion.rs diff --git a/crates/kebab-app/Cargo.toml b/crates/kebab-app/Cargo.toml index 53b4df4..526e7b8 100644 --- a/crates/kebab-app/Cargo.toml +++ b/crates/kebab-app/Cargo.toml @@ -71,6 +71,10 @@ base64 = { workspace = true } rusqlite = { workspace = true } [dev-dependencies] +# doc-side expansion (Phase 2) Task 4: ExpansionGenerator unit tests build +# MockLanguageModel (gated behind kebab-llm's `mock` feature, default OFF in +# [dependencies]). Enabling it here turns it on for the test build only. +kebab-llm = { path = "../kebab-llm", features = ["mock"] } rusqlite = { workspace = true } filetime = "0.2" tempfile = { workspace = true } diff --git a/crates/kebab-app/src/expansion.rs b/crates/kebab-app/src/expansion.rs new file mode 100644 index 0000000..678d4ce --- /dev/null +++ b/crates/kebab-app/src/expansion.rs @@ -0,0 +1,160 @@ +//! 색인시 doc-side expansion (Phase 2) — 청크당 "검색용 별칭" 생성. +//! +//! 설계 spec docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md §3.2 / §5. + +use kebab_core::{Chunk, GenerateRequest, LanguageModel}; + +/// 별칭 1줄의 최대 글자 수(이 이상은 문장형/환각으로 보고 drop). +const MAX_ALIAS_CHARS: usize = 120; + +/// 청크당 검색용 별칭을 생성한다. +/// +/// 반환: 검증·상한 적용된 별칭들을 개행 join 한 문자열. 생성 0개 / LLM +/// 실패 / 빈 출력이면 `None` (호출측은 chunk.aliases 를 None 으로 두고 진행). +pub struct ExpansionGenerator<'a> { + llm: &'a dyn LanguageModel, + max_aliases: usize, +} + +impl<'a> ExpansionGenerator<'a> { + pub fn new(llm: &'a dyn LanguageModel, max_aliases: usize) -> Self { + Self { llm, max_aliases } + } + + /// gemma 프롬프트(expansion-v1)를 구성한다. + fn build_request(&self, chunk: &Chunk) -> GenerateRequest { + let heading = chunk.heading_path.join(" > "); + let system = "당신은 검색 색인용 별칭 생성기다. 주어진 문단을 찾을 사용자가 \ +입력할 법한 짧은 검색어/질문을 생성한다. 동의어·풀어쓴 표현을 포함하라. \ +문단이 한국어면 영어 표현도, 영어면 한국어 표현도 섞어라. \ +한 줄에 하나씩, 설명·번호·머리기호 없이 검색어만 출력하라." + .to_string(); + let user = format!( + "제목 경로: {heading}\n\n문단:\n{}\n\n검색 별칭(한 줄에 하나):", + chunk.text + ); + GenerateRequest { + system, + user, + stop: vec![], + max_tokens: 256, + temperature: 0.0, + seed: Some(0), + images: vec![], + } + } + + pub fn generate(&self, chunk: &Chunk) -> Option { + let req = self.build_request(chunk); + let raw = match self.llm.generate_stream(req) { + Ok(iter) => { + let mut acc = String::new(); + for ch in iter { + match ch { + Ok(kebab_core::TokenChunk::Token(t)) => acc.push_str(&t), + Ok(kebab_core::TokenChunk::Done { .. }) => {} + Err(_) => return None, // fail-soft + } + } + acc + } + Err(_) => return None, // fail-soft (connection refused 등) + }; + let aliases = parse_aliases(&raw, self.max_aliases); + if aliases.is_empty() { + None + } else { + Some(aliases.join("\n")) + } + } +} + +/// LLM 출력 문자열 → 검증된 별칭 리스트. +/// 줄 단위 split → trim → 번호/머리기호 접두 제거 → 빈 줄·과길이 drop → +/// 중복 제거 → 상한 N. +fn parse_aliases(raw: &str, max_aliases: usize) -> Vec { + let mut out: Vec = Vec::new(); + for line in raw.lines() { + let t = line.trim(); + // 번호("1." "1)") / 머리기호("- " "* ") 접두 제거. + let t = t + .trim_start_matches(|c: char| { + c.is_ascii_digit() || c == '.' || c == ')' || c == '-' || c == '*' + }) + .trim(); + if t.is_empty() || t.chars().count() > MAX_ALIAS_CHARS { + continue; + } + let s = t.to_string(); + if !out.contains(&s) { + out.push(s); + } + if out.len() >= max_aliases { + break; + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use kebab_core::{ChunkId, ChunkerVersion, DocumentId, FinishReason, TokenUsage}; + use kebab_llm::MockLanguageModel; + + fn mk_chunk(text: &str) -> Chunk { + Chunk { + chunk_id: ChunkId("c1".into()), + doc_id: DocumentId("d1".into()), + block_ids: vec![], + text: text.into(), + heading_path: vec!["Guide".into()], + source_spans: vec![], + token_estimate: 3, + chunker_version: ChunkerVersion("md-heading-v1".into()), + policy_hash: "h".into(), + tokenized_korean_text: None, + aliases: None, + } + } + + fn mock(resp: &str) -> MockLanguageModel { + MockLanguageModel { + model_id: "gemma4:e4b".into(), + provider: "ollama".into(), + context_tokens: 32768, + canned_response: resp.into(), + canned_finish: FinishReason::Stop, + canned_usage: TokenUsage { + prompt_tokens: 0, + completion_tokens: 0, + latency_ms: 0, + }, + } + } + + #[test] + fn parses_lines_strips_bullets_and_caps() { + let llm = mock("- 메모리 안전성\n1. who owns the value\nborrow checker\n\n* 소유권"); + let generator = ExpansionGenerator::new(&llm, 2); + let out = generator.generate(&mk_chunk("Rust ownership")).unwrap(); + // 상한 2 → 앞 2개만, 접두 제거됨. + assert_eq!(out, "메모리 안전성\nwho owns the value"); + } + + #[test] + fn drops_overlong_lines() { + let long = "x".repeat(200); + let llm = mock(&format!("{long}\n짧은 별칭")); + let generator = ExpansionGenerator::new(&llm, 8); + let out = generator.generate(&mk_chunk("t")).unwrap(); + assert_eq!(out, "짧은 별칭", "120자 초과 줄은 drop"); + } + + #[test] + fn empty_output_returns_none() { + let llm = mock(" \n\n"); + let generator = ExpansionGenerator::new(&llm, 8); + assert_eq!(generator.generate(&mk_chunk("t")), None); + } +} diff --git a/crates/kebab-app/src/lib.rs b/crates/kebab-app/src/lib.rs index b5c30bd..d05d05c 100644 --- a/crates/kebab-app/src/lib.rs +++ b/crates/kebab-app/src/lib.rs @@ -62,6 +62,7 @@ pub mod cursor; pub mod doctor_signal; pub mod error_signal; pub mod error_wire; +pub mod expansion; pub mod external; pub mod fetch; pub mod ingest_log; -- 2.49.1 From bddcd53688c56ad9505514775719158d2d5f8cb3 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 30 May 2026 02:49:25 +0000 Subject: [PATCH 08/27] =?UTF-8?q?fix(app):=20parse=5Faliases=20=EC=A0=91?= =?UTF-8?q?=EB=91=90=20=EC=A0=9C=EA=B1=B0=EA=B0=80=20=EC=88=AB=EC=9E=90/?= =?UTF-8?q?=ED=95=98=EC=9D=B4=ED=94=88=20=EC=84=A0=EB=91=90=20=EB=B3=84?= =?UTF-8?q?=EC=B9=AD=20=EC=86=90=EC=83=81=20(Task=204=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20MAJOR-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 탐욕적 trim_start_matches → 명시적 strip_list_marker(마커+공백 패턴만 1회). "3D 렌더링"/"2단계"/"-fast" 보존, "- "/"1. " 마커만 제거. 회귀 테스트 2개. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/kebab-app/src/expansion.rs | 56 ++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/crates/kebab-app/src/expansion.rs b/crates/kebab-app/src/expansion.rs index 678d4ce..b9df50e 100644 --- a/crates/kebab-app/src/expansion.rs +++ b/crates/kebab-app/src/expansion.rs @@ -69,19 +69,35 @@ impl<'a> ExpansionGenerator<'a> { } } +/// 줄 선두의 목록 마커만 1회 제거한다. **마커 뒤 공백이 필수** — 별칭 내용이 +/// 숫자/하이픈/별표로 시작하는 경우(예: "3D 렌더링", "-fast", "2단계")는 보존한다. +/// (Task 4 리뷰 MAJOR-1: 탐욕적 `trim_start_matches` 가 정당한 별칭을 손상시키던 버그 수정.) +fn strip_list_marker(s: &str) -> &str { + // 1) 머리기호 + 공백 ("- " / "* " / "• "). + for marker in ["- ", "* ", "• "] { + if let Some(rest) = s.strip_prefix(marker) { + return rest.trim_start(); + } + } + // 2) 번호 + ('.' | ')') + 공백 ("1. " / "2) "). 마커 뒤 공백이 없으면 + // ("3D", "2단계") 번호가 아니라 내용으로 보고 보존. + let digit_end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()); + if digit_end > 0 { + let after = &s[digit_end..]; + if let Some(rest) = after.strip_prefix(". ").or_else(|| after.strip_prefix(") ")) { + return rest.trim_start(); + } + } + s +} + /// LLM 출력 문자열 → 검증된 별칭 리스트. -/// 줄 단위 split → trim → 번호/머리기호 접두 제거 → 빈 줄·과길이 drop → +/// 줄 단위 split → trim → 목록 마커 1회 제거 → 빈 줄·과길이 drop → /// 중복 제거 → 상한 N. fn parse_aliases(raw: &str, max_aliases: usize) -> Vec { let mut out: Vec = Vec::new(); for line in raw.lines() { - let t = line.trim(); - // 번호("1." "1)") / 머리기호("- " "* ") 접두 제거. - let t = t - .trim_start_matches(|c: char| { - c.is_ascii_digit() || c == '.' || c == ')' || c == '-' || c == '*' - }) - .trim(); + let t = strip_list_marker(line.trim()); if t.is_empty() || t.chars().count() > MAX_ALIAS_CHARS { continue; } @@ -157,4 +173,28 @@ mod tests { let generator = ExpansionGenerator::new(&llm, 8); assert_eq!(generator.generate(&mk_chunk("t")), None); } + + /// Task 4 리뷰 MAJOR-1 회귀: 숫자/하이픈/별표로 시작하는 정당한 별칭은 + /// 손상 없이 보존돼야 한다(목록 마커는 마커 뒤 공백이 있을 때만 제거). + #[test] + fn preserves_numeric_and_dash_leading_aliases() { + let llm = mock("3D 렌더링\n2단계 커밋\n-fast 플래그\n- 메모리 안전성\n1. 첫 항목"); + let generator = ExpansionGenerator::new(&llm, 8); + let out = generator.generate(&mk_chunk("graphics")).unwrap(); + // 마커 없는 선두 숫자/하이픈은 보존; "- "/"1. " 만 마커로 제거. + assert_eq!(out, "3D 렌더링\n2단계 커밋\n-fast 플래그\n메모리 안전성\n첫 항목"); + } + + #[test] + fn strip_list_marker_unit() { + assert_eq!(strip_list_marker("- 메모리"), "메모리"); + assert_eq!(strip_list_marker("* 소유권"), "소유권"); + assert_eq!(strip_list_marker("1. who owns"), "who owns"); + assert_eq!(strip_list_marker("2) 항목"), "항목"); + // 마커 뒤 공백 없음 → 보존. + assert_eq!(strip_list_marker("3D 렌더링"), "3D 렌더링"); + assert_eq!(strip_list_marker("-fast"), "-fast"); + assert_eq!(strip_list_marker("2단계"), "2단계"); + assert_eq!(strip_list_marker("2.0 릴리스"), "2.0 릴리스"); + } } -- 2.49.1 From cde4d75f6b0596466e7d968ac85a0bc823bb1932 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 30 May 2026 03:03:09 +0000 Subject: [PATCH 09/27] =?UTF-8?q?feat(app):=20ingest=20=EB=B3=84=EC=B9=AD?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20hook=20(flag=20off=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?,=20fail-soft)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/kebab-app/src/lib.rs | 27 +++++++++++++++++++++++- crates/kebab-app/tests/search_lexical.rs | 6 +++--- crates/kebab-llm-local/src/ollama.rs | 18 ++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/crates/kebab-app/src/lib.rs b/crates/kebab-app/src/lib.rs index d05d05c..7dd0b78 100644 --- a/crates/kebab-app/src/lib.rs +++ b/crates/kebab-app/src/lib.rs @@ -1251,10 +1251,35 @@ fn ingest_one_asset( build_canonical_document(asset, metadata, parsed_blocks, parser_version, all_warnings) .context("kb-parse-md::build_canonical_document")?; - let chunks = MdHeadingV1Chunker + let mut chunks = MdHeadingV1Chunker .chunk(&canonical, chunk_policy) .context("kb-chunk::MdHeadingV1Chunker::chunk")?; + // Phase 2 doc-side expansion: flag on 이면 청크당 별칭 생성 (fail-soft). + if app.config.ingest.expansion.enabled { + let exp = &app.config.ingest.expansion; + let llm_built = if exp.model.is_empty() { + OllamaLanguageModel::new(&app.config) + } else { + OllamaLanguageModel::with_model(&app.config, &exp.model) + }; + match llm_built { + Ok(llm) => { + let generator = + crate::expansion::ExpansionGenerator::new(&llm, exp.max_aliases_per_chunk); + for chunk in &mut chunks { + chunk.aliases = generator.generate(chunk); + } + } + Err(e) => { + tracing::warn!( + target: "kebab-app", error = %e, + "kb-app::ingest: expansion LLM 빌드 실패 — 별칭 없이 진행" + ); + } + } + } + // Stamp chunker + embedding versions so Task 7's skip detection has // data on the second run. canonical.last_chunker_version = Some(MdHeadingV1Chunker.chunker_version()); diff --git a/crates/kebab-app/tests/search_lexical.rs b/crates/kebab-app/tests/search_lexical.rs index 3534867..29d8333 100644 --- a/crates/kebab-app/tests/search_lexical.rs +++ b/crates/kebab-app/tests/search_lexical.rs @@ -109,10 +109,10 @@ 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 migration bumps to 1 to invalidate any pre-V009 - // LRU cache (spec §5.2). Baseline before ingest = post-migration value. + // V004 seeds 0; V009 + V010 migrations each bump by 1 to invalidate + // stale LRU caches (spec §5.2). Baseline before ingest = 2. let baseline = store_before.corpus_revision(); - assert_eq!(baseline, 1, "fresh store post-V009 baseline = 1"); + assert_eq!(baseline, 2, "fresh store post-V010 baseline = 2"); let report = kebab_app::ingest_with_config(env.config.clone(), env.scope(), true).unwrap(); assert!( diff --git a/crates/kebab-llm-local/src/ollama.rs b/crates/kebab-llm-local/src/ollama.rs index 7f45ff5..e42fe73 100644 --- a/crates/kebab-llm-local/src/ollama.rs +++ b/crates/kebab-llm-local/src/ollama.rs @@ -95,6 +95,24 @@ impl OllamaLanguageModel { default_seed: llm.seed, }) } + + /// `new` 와 동일하되 모델 ID 만 override. doc-side expansion(Task 5)이 + /// `[ingest.expansion].model` 을 쓸 수 있게 한다. 빈 문자열이면 호출측이 + /// `new` 를 쓰도록 분기(여기선 비어있지 않은 model_id 를 신뢰). + pub fn with_model(config: &kebab_config::Config, model_id: &str) -> anyhow::Result { + let llm = &config.models.llm; + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(llm.request_timeout_secs)) + .build()?; + Ok(Self { + client, + endpoint: llm.endpoint.clone(), + model_id: model_id.to_string(), + context_tokens: llm.context_tokens, + default_temperature: llm.temperature, + default_seed: llm.seed, + }) + } } impl LanguageModel for OllamaLanguageModel { -- 2.49.1 From a271352e33138661750c8ffc2378ba05bd3c2b82 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 30 May 2026 03:12:14 +0000 Subject: [PATCH 10/27] =?UTF-8?q?feat(search):=20lexical=20body+alias=20?= =?UTF-8?q?=EB=B3=91=ED=95=A9=20=EA=B2=80=EC=83=89=20(pool-rescue)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- crates/kebab-search/src/lexical.rs | 107 +++++++++++++++++++++++- crates/kebab-search/tests/lexical.rs | 120 +++++++++++++++++++++++++++ 2 files changed, 225 insertions(+), 2 deletions(-) diff --git a/crates/kebab-search/src/lexical.rs b/crates/kebab-search/src/lexical.rs index 8101e5b..3b2ef9e 100644 --- a/crates/kebab-search/src/lexical.rs +++ b/crates/kebab-search/src/lexical.rs @@ -123,7 +123,29 @@ impl Retriever for LexicalRetriever { }; let conn = self.store.read_conn(); - let raw_rows = run_query(&conn, &match_str, self.snippet_words, filters, fetch_limit)?; + let body_rows = run_query(&conn, &match_str, self.snippet_words, filters, fetch_limit)?; + // doc-side expansion (V010): re-run the same query against the + // `aliases` column of `chunk_aliases_fts`. Empty table → 0 rows → + // `body_rows` unchanged (regression-safe). body wins; alias-only + // chunks are appended so a term present only in a chunk's aliases + // still enters the pool. + // + // Raw mode (`'...'`) is a body-FTS5 escape hatch and may reference + // body-only columns (e.g. `heading_path : ...`) that don't exist on + // `chunk_aliases_fts`. Running such an expression against the alias + // table is a hard FTS5 error, so we skip the alias channel for raw + // queries — they target the body intentionally. + let alias_rows = if strip_single_quotes(query.text.trim()).is_some() { + Vec::new() + } else { + match build_match_string_for_column(&query.text, "aliases") { + Some(alias_match) => { + run_alias_query(&conn, &alias_match, self.snippet_chars, fetch_limit)? + } + None => Vec::new(), + } + }; + let raw_rows = merge_body_alias(body_rows, alias_rows, fetch_limit); let mut hits: Vec = Vec::with_capacity(raw_rows.len().min(k)); let mut rank: u32 = 0; @@ -206,6 +228,16 @@ impl Retriever for LexicalRetriever { /// match is scoped to the body column. FTS5's column-filter syntax /// accepts an arbitrary OR/AND sub-expression inside the parens. fn build_match_string(text: &str) -> Option { + build_match_string_for_column(text, "text") +} + +/// Column-parameterized variant of [`build_match_string`]. `column` is the +/// FTS5 column-filter prefix the combined expression is scoped to — `"text"` +/// for the body channel (`chunks_fts`) or `"aliases"` for the doc-side +/// expansion channel (`chunk_aliases_fts`, V010). Raw mode (`'...'`) is still +/// passed through verbatim without any column scoping, so an explicit +/// user-supplied column filter is honored unchanged. +fn build_match_string_for_column(text: &str, column: &str) -> Option { let trimmed = text.trim(); if trimmed.is_empty() { return None; @@ -242,7 +274,7 @@ fn build_match_string(text: &str) -> Option { (Some(w), Some(a)) if w == a => w, (Some(w), Some(a)) => format!("({w}) OR ({a})"), }; - Some(format!("text : ({expression})")) + Some(format!("{column} : ({expression})")) } /// Return `Some(inner)` if `s` is wrapped in a matching pair of single @@ -480,6 +512,77 @@ fn row_from_sql(row: &Row<'_>) -> rusqlite::Result { }) } +/// Search the doc-side expansion channel (`chunk_aliases_fts`, V010) and +/// build [`RawRow`]s with the **same 10-column shape** as [`run_query`] so +/// `row_from_sql` / `build_hit` can be reused verbatim. The snippet is taken +/// from the body (`substr(c.text, 1, ?)`) rather than the alias text so the +/// rendered hit stays consistent with the body channel. When +/// `chunk_aliases_fts` is empty (no chunk carries aliases) this returns 0 +/// rows, making the merge a no-op (regression-safe). +/// +/// 1차는 filters 미적용 — body 채널이 필터를 적용하고, 별칭 경로는 pool 진입 +/// (회수)이 목적이다(측정 후 필요 시 filters 공유). `bm25(chunk_aliases_fts)` +/// 오름차순 + `af.chunk_id` tie-break 로 결정적 순서. +fn run_alias_query( + conn: &Connection, + match_str: &str, + snippet_chars: usize, + fetch_limit: usize, +) -> Result> { + let sql = "SELECT \ + af.chunk_id, af.doc_id, \ + bm25(chunk_aliases_fts) AS score, \ + substr(c.text, 1, ?) AS snippet, \ + c.heading_path_json, c.section_label, c.source_spans_json, \ + c.chunker_version, \ + d.workspace_path, d.updated_at \ + FROM chunk_aliases_fts af \ + JOIN chunks c ON c.chunk_id = af.chunk_id \ + JOIN documents d ON d.doc_id = af.doc_id \ + WHERE chunk_aliases_fts MATCH ? \ + ORDER BY score, af.chunk_id LIMIT ?"; + let params: Vec> = vec![ + Box::new(snippet_chars as i64), + Box::new(match_str.to_owned()), + Box::new(i64::try_from(fetch_limit).unwrap_or(i64::MAX)), + ]; + let mut stmt = conn + .prepare(sql) + .context("kb-search lexical: prepare alias FTS5 statement")?; + let rows = stmt + .query_map( + params_from_iter(params.iter().map(std::convert::AsRef::as_ref)), + row_from_sql, + ) + .context("kb-search lexical: execute alias FTS5 query")?; + let mut out: Vec = Vec::new(); + for r in rows { + out.push(r.context("kb-search lexical: read alias row")?); + } + Ok(out) +} + +/// Merge body + alias rows: body rows first (already bm25-ordered), then +/// any alias-only chunk (not already present in the body result) appended in +/// alias-relevance order. Capped at `limit`. An empty `alias` slice leaves +/// `body` unchanged, so an empty `chunk_aliases_fts` reproduces the +/// pre-expansion behavior exactly. +fn merge_body_alias(body: Vec, alias: Vec, limit: usize) -> Vec { + use std::collections::HashSet; + let mut seen: HashSet = body.iter().map(|r| r.chunk_id.clone()).collect(); + let mut out = body; + for r in alias { + if out.len() >= limit { + break; + } + if seen.insert(r.chunk_id.clone()) { + out.push(r); + } + } + out.truncate(limit); + out +} + // ── Hit construction ───────────────────────────────────────────────────── fn build_hit( diff --git a/crates/kebab-search/tests/lexical.rs b/crates/kebab-search/tests/lexical.rs index beb8151..9e9efbc 100644 --- a/crates/kebab-search/tests/lexical.rs +++ b/crates/kebab-search/tests/lexical.rs @@ -144,6 +144,42 @@ fn insert_chunk( .expect("insert chunk"); } +/// Like [`insert_chunk`] but also writes the `chunks.aliases` column so the +/// `chunk_aliases_ai` trigger (V010) mirrors the row into `chunk_aliases_fts`. +/// `aliases=None` leaves the column NULL (trigger skips → no alias row). +#[allow(clippy::too_many_arguments)] +fn insert_chunk_with_aliases( + conn: &Connection, + chunk_id: &str, + doc_id: &str, + text: &str, + heading_path: &[&str], + section_label: Option<&str>, + source_spans_json: &str, + chunker_version: &str, + aliases: Option<&str>, +) { + let heading_json = serde_json::to_string(heading_path).unwrap(); + conn.execute( + "INSERT INTO chunks ( + chunk_id, doc_id, text, heading_path_json, section_label, + source_spans_json, token_estimate, chunker_version, + policy_hash, block_ids_json, created_at, aliases + ) VALUES (?, ?, ?, ?, ?, ?, 0, ?, 'h', '[]', '2024-01-01T00:00:00Z', ?)", + rusqlite::params![ + chunk_id, + doc_id, + text, + heading_json, + section_label, + source_spans_json, + chunker_version, + aliases, + ], + ) + .expect("insert chunk with aliases"); +} + /// Pad a short ID to the 32-hex shape kebab_core newtypes expect. fn id32(prefix: &str) -> String { let mut s = prefix.to_string(); @@ -1253,3 +1289,87 @@ fn lexical_raw_mode_can_opt_into_heading_path_filter() { "raw-mode heading_path filter must hit the seeded chunk" ); } + +// ── doc-side expansion (V010) — body+alias merged search ────────────────── + +/// pool-rescue core: a term present ONLY in `chunks.aliases` (not in the +/// body) must still recall the chunk via the `chunk_aliases_fts` channel. +/// Body is English ("backpropagation…"); the Korean term "역전파" lives only +/// in the alias text, so the body `chunks_fts` MATCH alone would miss it. +#[test] +fn alias_only_term_recalls_chunk() { + let env = Env::new(); + let conn = env.raw_conn(); + insert_document(&conn, &id32("d"), "notes/nn.md", "NN", "en", "primary", &[]); + insert_chunk_with_aliases( + &conn, + &id32("c1"), + &id32("d"), + "backpropagation computes gradients", + &["NN"], + None, + r#"[{"kind":"line","start":1,"end":1}]"#, + "v1", + Some("역전파\n신경망 오차 역전달"), + ); + drop(conn); + + let r = env.retriever(); + let hits = r + .search(&SearchQuery { + text: "역전파".to_string(), + mode: SearchMode::Lexical, + k: 10, + filters: SearchFilters::default(), + }) + .unwrap(); + assert!( + hits.iter().any(|h| h.chunk_id.0 == id32("c1")), + "별칭에만 있는 term 으로도 청크가 회수돼야 한다 (pool-rescue); got {:?}", + hits.iter().map(|h| h.chunk_id.0.clone()).collect::>() + ); +} + +/// Regression-safety: with every chunk's `aliases=NULL` the +/// `chunk_aliases_fts` table is empty, so the alias channel yields 0 rows +/// and the body search result is identical to the pre-expansion behavior. +#[test] +fn empty_aliases_table_matches_baseline() { + let env = Env::new(); + let conn = env.raw_conn(); + insert_document( + &conn, + &id32("d"), + "notes/own.md", + "Own", + "en", + "primary", + &[], + ); + // aliases=None → no chunk_aliases_fts row; body channel only. + insert_chunk( + &conn, + &id32("c1"), + &id32("d"), + "rust ownership and borrow checker", + &["Own"], + None, + r#"[{"kind":"line","start":1,"end":1}]"#, + "v1", + ); + drop(conn); + + let r = env.retriever(); + let hits = r + .search(&SearchQuery { + text: "ownership".to_string(), + mode: SearchMode::Lexical, + k: 10, + filters: SearchFilters::default(), + }) + .unwrap(); + assert!( + hits.iter().any(|h| h.chunk_id.0 == id32("c1")), + "aliases 빈 상태에서 본문 매칭 청크가 정상 회수돼야 한다 (회귀 안전)" + ); +} -- 2.49.1 From 69b53d1c97d0f4021f97844e0b79d19a398ce123 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 30 May 2026 03:20:13 +0000 Subject: [PATCH 11/27] =?UTF-8?q?docs(spec):=20doc-side=20expansion=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EB=A9=94=EC=BB=A4=EB=8B=88=EC=A6=98?= =?UTF-8?q?=EC=9D=84=20shipped=20=EA=B5=AC=ED=98=84=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=B0=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 6 리뷰 MINOR-1: spec 본문이 단일 UNION ALL+GROUP BY 로 기술됐으나 shipped = 2-query(run_query+run_alias_query) + Rust merge_body_alias(body 우선). 서로 다른 FTS 테이블 bm25 절대값 비교가 무의미해 body-우선 merge 가 더 깨끗. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-05-30-doc-side-expansion-design.md | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md b/docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md index 305ce0e..938164e 100644 --- a/docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md +++ b/docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md @@ -62,20 +62,26 @@ ingest_one_asset (kebab-app/src/lib.rs:~1253) │ embedder.embed(...) → vec_store.upsert(...) # dense는 body text 기준 (변경 없음) -검색 (kebab-search/src/lexical.rs run_query — body ∪ alias 한 SQL): - body = chunks_fts MATCH ? (bm25) ┐ UNION ALL → GROUP BY chunk_id - alias = chunk_aliases_fts MATCH ? (bm25) ┘ → 단일 lexical 결과 (rank 부여) - │ +검색 (kebab-search/src/lexical.rs — body·alias 두 쿼리 + Rust merge): + body = run_query(chunks_fts MATCH 'text : (..)') (bm25 asc) ┐ merge_body_alias: + alias = run_alias_query(chunk_aliases_fts MATCH 'aliases : (..)') ┘ body 우선 + alias-only append + │ → 단일 lexical 결과 (rank 부여) HybridRetriever.fuse: RRF(lexical, vector) # 2채널 그대로 — RetrievalDetail/wire 무변경 ``` -**왜 lexical 내부 UNION 인가 (3채널 RRF 대신):** `RetrievalDetail` 은 `lexical_score`/ +**왜 lexical 내부 병합인가 (3채널 RRF 대신):** `RetrievalDetail` 은 `lexical_score`/ `vector_score`/`*_rank` 만 보유하고 wire schema `search_hit.v1` 가 이를 그대로 노출한다. 정통 3채널 RRF 는 `RetrievalDetail` + wire schema + `HybridRetriever` 시그니처 + 다수 테스트를 침습 변경한다. alias-only 청크가 lexical 결과(→ hybrid pool)에 진입하기만 하면 pool-rescue 목적은 -동일하게 달성되므로, **`LexicalRetriever` 내부에서 body+alias 를 UNION** 해 단일 lexical 결과로 -내보낸다. `chunk_aliases_fts` 가 비면(flag off / 미생성) UNION 둘째 절이 0행 → 기존 동작과 -비트 단위로 동일 → **search-side 는 flag 게이트 불필요, ingest-side 만 게이트**. +동일하게 달성되므로, **`LexicalRetriever` 내부에서 body+alias 를 병합**해 단일 lexical 결과로 +내보낸다. `chunk_aliases_fts` 가 비면(flag off / 미생성) alias 쿼리가 0행 → merge no-op → 기존 +동작과 동일 → **search-side 는 flag 게이트 불필요, ingest-side 만 게이트**. + +> **구현 메커니즘 (shipped):** 단일 `UNION ALL + GROUP BY` SQL 이 아니라 **두 쿼리(`run_query` + +> `run_alias_query`) + Rust `merge_body_alias`(body 우선, body 에 없는 alias-only 만 append, +> `fetch_limit` 절단)**. 서로 다른 FTS 테이블의 bm25 절대값을 `GROUP BY MIN` 으로 비교하는 것은 +> 무의미하므로 body-우선 Rust 병합이 의미상 더 깨끗하다(§3.3 body 보존과도 일치). raw 모드 +> (작은따옴표 식)는 body-only 컬럼 참조 가능성 때문에 alias 채널에서 제외한다(방어 가드). ### 3.2 컴포넌트 (단위별 책임) @@ -87,10 +93,11 @@ ingest_one_asset (kebab-app/src/lib.rs:~1253) - **V010 migration** — `chunks.aliases TEXT` 컬럼 + **별도 `chunk_aliases_fts` virtual table** + 별도 trigger 3종(`chunk_aliases_ai/ad/au`). 기존 `chunks_fts` / `chunks_ai/ad/au`(§5.5 verbatim CI 대상)는 **무수정**. tokenizer `unicode61`(V009 동일). -- **`LexicalRetriever.run_query` UNION 확장** (kebab-search/lexical.rs) — `chunks_fts` 와 - `chunk_aliases_fts` 를 `UNION ALL` 서브쿼리로 검색 후 `GROUP BY chunk_id`(같은 청크가 양쪽 - 매칭 시 더 좋은=작은 bm25 채택), `chunks`/`documents` JOIN 으로 snippet·메타 보강. alias-only - 청크도 결과에 진입. `chunk_aliases_fts` 가 비면 UNION 둘째 절 0행 → 기존과 동일(회귀 안전). +- **`LexicalRetriever` body+alias 병합** (kebab-search/lexical.rs) — 기존 `run_query`(body) + + 신규 `run_alias_query`(`chunk_aliases_fts` MATCH, `chunks`/`documents` JOIN, snippet 은 본문 + `substr(c.text,1,?)`) 를 각각 실행하고 `merge_body_alias`(body 우선, body 에 없는 alias-only 만 + append, `fetch_limit` 절단)로 합친다. `build_match_string` 은 컬럼 파라미터화(`text :` / `aliases :`). + alias-only 청크가 결과에 진입. `chunk_aliases_fts` 가 비면 alias 쿼리 0행 → 기존과 동일(회귀 안전). - **config `[ingest.expansion]`** — `IngestExpansionCfg`: - `enabled: bool` (default **false**) - `model: String` (default = `models.llm.model`) @@ -102,7 +109,7 @@ ingest_one_asset (kebab-app/src/lib.rs:~1253) ### 3.3 격리 / 코드 식별자 보존 (load-bearing) - `chunks_fts.text`(body) 는 **verbatim 유지**, 별칭은 **별도 테이블** `chunk_aliases_fts`. - UNION 후 GROUP BY 라도 body 매칭의 bm25 가 그대로 보존되어, 코드 식별자(`Vec::with_capacity`) + body-우선 merge 라 body 매칭이 항상 alias-only 보다 앞서 보존되어, 코드 식별자(`Vec::with_capacity`) 정확매칭이 별칭 노이즈에 희석되지 않음. - dense(e5) 임베딩은 **body text 기준 그대로** — 별칭을 임베딩에 넣지 않음(research: e5 dense 유지, bge-m3 dense 는 실측 더 나빴음). 별칭은 lexical 채널에만 기여. @@ -202,7 +209,7 @@ KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml \ - lexical UNION: body 에 없고 alias 에만 있는 term 으로 검색 시 alias-only 청크가 `LexicalRetriever` 결과(→ hybrid pool)에 진입(pool-rescue 핵심 회귀). 양쪽 매칭 청크는 중복 없이 1개. - `ExpansionGenerator`(LLM mock): 프롬프트→파싱, 상한 N 적용, 빈/과길이 drop, LLM 실패 시 fail-soft. -- 회귀: `chunk_aliases_fts` 빈 상태에서 `run_query` 결과가 V009 와 동일(UNION 둘째 절 0행). +- 회귀: `chunk_aliases_fts` 빈 상태에서 lexical 결과가 V009 와 동일(alias 쿼리 0행 → merge no-op). ## 10. PR / 문서 동기화 -- 2.49.1 From 116b3e6377b10d835f8838d2c5f81c4ae7e04fca Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 30 May 2026 03:47:06 +0000 Subject: [PATCH 12/27] =?UTF-8?q?fix(app):=20clippy=20unused=5Fself=20?= =?UTF-8?q?=E2=80=94=20build=5Frequest=20=EB=A5=BC=20associated=20fn=20?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 게이트(clippy --workspace --all-targets -D warnings) 통과. 동작 동일. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/kebab-app/src/expansion.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/kebab-app/src/expansion.rs b/crates/kebab-app/src/expansion.rs index b9df50e..d4cafaa 100644 --- a/crates/kebab-app/src/expansion.rs +++ b/crates/kebab-app/src/expansion.rs @@ -21,8 +21,8 @@ impl<'a> ExpansionGenerator<'a> { Self { llm, max_aliases } } - /// gemma 프롬프트(expansion-v1)를 구성한다. - fn build_request(&self, chunk: &Chunk) -> GenerateRequest { + /// gemma 프롬프트(expansion-v1)를 구성한다. (self 미사용 — associated fn.) + fn build_request(chunk: &Chunk) -> GenerateRequest { let heading = chunk.heading_path.join(" > "); let system = "당신은 검색 색인용 별칭 생성기다. 주어진 문단을 찾을 사용자가 \ 입력할 법한 짧은 검색어/질문을 생성한다. 동의어·풀어쓴 표현을 포함하라. \ @@ -45,7 +45,7 @@ impl<'a> ExpansionGenerator<'a> { } pub fn generate(&self, chunk: &Chunk) -> Option { - let req = self.build_request(chunk); + let req = Self::build_request(chunk); let raw = match self.llm.generate_stream(req) { Ok(iter) => { let mut acc = String::new(); -- 2.49.1 From bcb8b93751f80e6b92d50d239e1306b2447e963b Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 30 May 2026 10:26:24 +0000 Subject: [PATCH 13/27] =?UTF-8?q?docs(spec):=20=EB=B3=84=EC=B9=AD=20dense?= =?UTF-8?q?=20=EB=B3=84=EB=8F=84=20=EB=B2=A1=ED=84=B0=20=EC=84=A4=EA=B3=84?= =?UTF-8?q?=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PoC(concat) 측정: dense 별칭이 6/0/2/0.25 (설명형은 dense 본령 실증), 단 영어 설명형 2개는 concat 본문 희석으로 미회복. 처방: 별칭을 sentinel chunk_id 별도 벡터로 색인(본문 벡터 불변=회귀 안전, 별칭 순수 신호). flag ingest.expansion.embed_aliases default off. lexical 완화는 폐기. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-05-30-dense-alias-vectors-design.md | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-30-dense-alias-vectors-design.md diff --git a/docs/superpowers/specs/2026-05-30-dense-alias-vectors-design.md b/docs/superpowers/specs/2026-05-30-dense-alias-vectors-design.md new file mode 100644 index 0000000..8c655f1 --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-dense-alias-vectors-design.md @@ -0,0 +1,118 @@ +--- +title: 별칭 dense 별도 벡터 — 설계 spec +date: 2026-05-30 +status: 설계 확정 (brainstorm + PoC 측정 완료) — plan 대기 +phase: Phase 2 (query-paraphrase robustness 처방 — dense 활용) +related: + - docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md + - memory: project_paraphrase_robustness, project_ranking_deferred, feedback_search_quality_dogfood +contract_sections: + - "design §6 (retrieval / vector store + hybrid)" + - "design §9 (versioning cascade)" +--- + +# 별칭 dense 별도 벡터 + +## 0. 한 줄 요약 + +doc-side expansion 의 별칭(`chunk.aliases`)은 현재 lexical FTS 채널(`chunk_aliases_fts`)에만 색인돼 +dense(e5)가 활용하지 못한다. 설명형 패러프레이즈는 dense 의 영역인데(단어 안 겹쳐도 의미 매칭), dense 가 +별칭 덕을 못 봐 `recall@50=0` 으로 남았다. **별칭을 별도 dense 벡터로 색인**(sentinel chunk_id, 본문 +벡터 불변)해 dense 가 별칭 순수 신호로 설명형을 잡게 한다. **flag off 기본**, variants + 전체 golden 회귀로 측정. + +## 1. 진단 (PoC 측정 근거, 2026-05-30) + +별칭을 **본문에 concat 해 한 벡터**로 임베딩한 PoC(dogfood topics 7 doc): +- 종합 `fully_consistent 2→6, A_dominant 2→0, B_dominant 4→2, spread@10 0.75→0.25` — **명사형·한국어 + 설명형·일부 영어 설명형 회복, 명사형 회귀 0**. dense 가 설명형의 본령임을 실증. +- 남은 미회복: mvcc/raft **영어 설명형**(`how databases serve reads without locking rows`, + `how nodes agree on a single ordered log`) — vector/hybrid 모두 top-50 밖. +- 질문형 프롬프트 강화(`max_tokens` 384 + "질문 형태 생성") 시도 → 동일 `6/0/2/0.25`, 영어 설명형 미회복. +- **가설**: concat 은 긴 본문 + 짧은 별칭을 한 벡터로 합쳐 **본문 의미가 별칭 신호를 희석**. 한국어 + 설명형은 한국어 별칭이 풍부해 회복됐으나, 영어 설명형은 별칭 신호가 약함. → 별칭을 **별도 순수 벡터**로 + 색인하면 본문 희석 없이 dense 매칭 가능(미검증 — 본 작업이 검증). + +## 2. 설계 결정 + +| # | 결정 | 선택 | 근거 | +|---|------|------|------| +| D1 | 별칭 dense 색인 방식 | **별도 벡터(sentinel chunk_id)** | concat 은 본문 벡터 변경(전체 corpus 회귀 부담) + 본문 희석. 별도 벡터는 본문 벡터 불변(회귀 안전) + 별칭 순수 신호. lexical `chunk_aliases_fts` 와 대칭. | +| D2 | flag | **`ingest.expansion.embed_aliases` default false** | `expansion.enabled`(별칭 생성)와 별개 축. 독립 on/off 측정([[feedback_search_quality_dogfood]]). | +| D3 | RRF 통합 | VectorRetriever 내부 dedup (2채널 유지) | lexical 의 body+alias merge 와 대칭. `RetrievalDetail`/wire schema `search_hit.v1` 무변경. | + +## 3. 아키텍처 + +### 3.1 데이터 흐름 + +``` +ingest_one_asset (embed + upsert): + body : emb.embed(chunk.text) → VectorRecord{chunk_id: orig} (변경 없음) + alias : if embed_aliases && aliases → emb.embed(aliases) [NEW] + → VectorRecord{chunk_id: "{orig}#alias", text: aliases, doc_id: 동일} + vec_store.upsert([body, alias]) # LanceDB MergeInsert keyed on chunk_id → 별도 row 공존 + +검색 (VectorRetriever.search): + store.search(query_vec) → raw_hits (orig + "{orig}#alias" 섞임) + 각 hit: chunk_id 가 "#alias" 로 끝나면 → 원본 strip + seen(원본 chunk_id) dedup: 같은 원본이 body+alias 둘 다 → 첫(높은 score) 유지 + hydrate(원본 chunk_id) → SearchHit (원본 chunk_id, body 메타) + → 단일 vector 결과. HybridRetriever.fuse(lexical, vector) 2채널 그대로. +``` + +### 3.2 sentinel chunk_id + +- `ALIAS_SUFFIX = "#alias"`. ChunkId 는 blake3 hex(32 영숫자)라 `#` 미포함 → 충돌 없음. +- alias VectorRecord: `chunk_id = format!("{orig}{ALIAS_SUFFIX}")`, `embedding_id = + id_for_embedding(&alias_chunk_id, ...)`, `text = aliases`(별칭 원문), `doc_id`/`heading_path` 동일. +- strip 헬퍼: `fn strip_alias_suffix(id: &str) -> &str { id.strip_suffix(ALIAS_SUFFIX).unwrap_or(id) }`. + +### 3.3 컴포넌트 + +- **ingest (kebab-app/src/lib.rs)**: embed 블록 확장. `embed_aliases` on 이고 별칭 있는 청크는 별칭도 + 임베딩 → alias VectorRecord 생성. body VectorRecord 는 그대로(chunk.text). 한 `upsert` 에 body+alias 함께. +- **VectorRetriever.search (kebab-search/src/vector.rs)**: raw_hits 순회 시 chunk_id strip + seen + dedup. candidate_ids/hydrate 는 strip 된 원본 사용. build_hit 도 원본 chunk_id. overfetch + multiplier 상향(별칭 벡터로 dedup 후 k 미달 방지 — `VECTOR_OVERFETCH_MULTIPLIER` 2→3). +- **purge**: `purge_vector_orphans_for_workspace_path`(stale_chunk_ids_at 기반) + `sweep_deleted_files` + 가 stale/삭제 chunk_id 의 `{id}#alias` 도 함께 `delete_by_chunk_ids`. (별칭 벡터는 SQLite chunks 에 + 없어 stale 목록에 안 잡히므로 명시 추가 — 안 하면 orphan 별칭 벡터 누적.) +- **config**: `IngestExpansionCfg.embed_aliases: bool`(default false) + `KEBAB_INGEST_EXPANSION_EMBED_ALIASES`. + +### 3.4 격리 / 회귀 안전 + +- body 벡터(chunk.text 임베딩) **불변** → 기존 명사형/본문 dense 매칭 회귀 0(concat 과 달리). +- 별칭 벡터는 sentinel row 라 본문 벡터와 독립. flag off 면 별칭 벡터 미생성 → 기존과 동일. + +## 4. versioning (design §9) + +- 별칭 dense 는 additive(별도 벡터). `try_skip_unchanged` 의 기존 5버전 판단 무변경(별칭 부재가 자동 + 재색인 트리거 안 함). 재생성은 `--force-reingest`. +- embed_aliases flag 토글은 임베딩 정책 변경이나 별도 벡터라 body 임베딩 version 불변. wire 무변경(flag off). + +## 5. 측정 (§4.6) + +- dogfood topics 7 doc, embed_aliases on 재임베딩 → `kebab eval variants`. +- **효과**: 영어 설명형(mvcc/raft) `recall@50` 0→양수 회복되는지(concat 미회복분). 종합 B_dominant↓. +- **회귀**: body 벡터 불변이라 명사형/단일쿼리 회귀 0 기대 — 전체 golden 로 확인. +- concat PoC(6/0/2/0.25) 대비 별도 벡터가 영어 설명형까지 잡으면 추가 개선, 못 잡으면 e5 한계로 기록. + +## 6. 범위 밖 (YAGNI) + +- dense 모델 교체(e5 유지 — research 권고). +- 별칭별 다중 벡터(별칭 전체를 1벡터로). +- lexical 긴 쿼리 완화(content-OR) — dense 가 설명형 본령이라 폐기(2026-05-30 brainstorm). + +## 7. 테스트 (TDD) + +- `strip_alias_suffix`: `"abc#alias"`→`"abc"`, `"abc"`→`"abc"`. +- ingest: embed_aliases on + 별칭 청크 → vector store 에 `{orig}#alias` row 존재. off → 없음. +- VectorRetriever dedup: 같은 원본이 body+alias 둘 다 hit → 결과에 1개(원본 chunk_id), 높은 score 유지. +- VectorRetriever strip: alias-only hit → 원본 chunk_id 로 hydrate(원본 chunk 메타). +- purge: 청크 재처리 시 `{orig}#alias` 벡터도 삭제(orphan 잔존 0). +- 회귀: embed_aliases off → vector 결과가 기존과 동일. + +## 8. PR / 문서 + +- doc-side expansion 과 같은 PR. README Configuration 에 `embed_aliases`(off 기본) 명시. + ARCHITECTURE 에 별칭 dense 별도 벡터(sentinel) 1~2줄. HOTFIXES dated entry(lexical 별칭 + dense 별칭 측정 표). +- versioning cascade 없음(body 임베딩 불변). flag off 라 wire 무변경. -- 2.49.1 From 825543549d238f18c8bce442ac5694b4f69d8b22 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 30 May 2026 10:28:43 +0000 Subject: [PATCH 14/27] =?UTF-8?q?docs(plan):=20=EB=B3=84=EC=B9=AD=20dense?= =?UTF-8?q?=20=EB=B3=84=EB=8F=84=20=EB=B2=A1=ED=84=B0=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ALIAS_SUFFIX(core) → embed_aliases flag → ingest sentinel 벡터+purge → VectorRetriever strip+dedup → 측정. TDD, 완성 코드. doc-side expansion PR. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-05-30-dense-alias-vectors.md | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-30-dense-alias-vectors.md diff --git a/docs/superpowers/plans/2026-05-30-dense-alias-vectors.md b/docs/superpowers/plans/2026-05-30-dense-alias-vectors.md new file mode 100644 index 0000000..7e31cdf --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-dense-alias-vectors.md @@ -0,0 +1,287 @@ +# 별칭 dense 별도 벡터 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`). + +**Goal:** `chunk.aliases`를 별도 dense 벡터(sentinel chunk_id `{orig}#alias`)로 색인해, dense(e5)가 별칭 순수 신호로 설명형 패러프레이즈를 잡게 한다. 본문 벡터 불변(회귀 안전). + +**Architecture:** `ingest.expansion.embed_aliases`(default off) on 이면 별칭을 e5 passage 임베딩 → sentinel chunk_id VectorRecord upsert. VectorRetriever 가 sentinel hit 을 원본 chunk_id 로 strip + dedup(2채널 유지, wire 무변경). purge 가 sentinel 벡터도 정리. + +**Tech Stack:** Rust 2024, fastembed e5, LanceVectorStore(MergeInsert keyed on chunk_id), kebab-core/config/app/search. + +**빌드 규약:** `CARGO_TARGET_DIR=/build/out/cargo-target/target`, `-j 4`. 결과 redirect + `echo "EXIT=$?"` 후 커밋. `cargo|grep` 금지. 브랜치 `feat/doc-side-expansion`(같은 PR). + +**참조 spec:** `docs/superpowers/specs/2026-05-30-dense-alias-vectors-design.md` + +--- + +## File Structure + +| 파일 | 역할 | Task | +|------|------|------| +| `crates/kebab-core/src/ids.rs` | `ALIAS_SUFFIX` 상수 + `strip_alias_suffix` 헬퍼 | 1 | +| `crates/kebab-config/src/lib.rs` | `IngestExpansionCfg.embed_aliases` + env | 2 | +| `crates/kebab-app/src/lib.rs` | ingest 별칭 임베딩 + sentinel VectorRecord + purge sentinel | 3 | +| `crates/kebab-search/src/vector.rs` | VectorRetriever sentinel strip + dedup + overfetch↑ | 4 | +| `docs/`, dogfood | 측정 + 문서 | 5 | + +--- + +## Task 1: `ALIAS_SUFFIX` + `strip_alias_suffix` (kebab-core) + +**Files:** Modify `crates/kebab-core/src/ids.rs` (+ `lib.rs` re-export) + +- [ ] **Step 1: 실패 테스트** — `ids.rs` `#[cfg(test)] mod tests` 에: + +```rust + #[test] + fn strip_alias_suffix_roundtrip() { + assert_eq!(strip_alias_suffix("abc123#alias"), "abc123"); + assert_eq!(strip_alias_suffix("abc123"), "abc123"); // 접미 없으면 그대로 + assert_eq!(ALIAS_SUFFIX, "#alias"); + } +``` + +- [ ] **Step 2: 실패 확인** — `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-core strip_alias_suffix -j 4 > /tmp/dv-t1.log 2>&1; echo "EXIT=$?"` → 컴파일 실패. + +- [ ] **Step 3: 구현** — `ids.rs` 상단(pub 영역)에: + +```rust +/// 별칭 dense 벡터의 sentinel chunk_id 접미. 본문 벡터(원본 chunk_id)와 +/// 별칭 벡터(`{orig}#alias`)를 LanceDB(chunk_id 키)에서 공존시킨다. ChunkId 는 +/// blake3 hex(영숫자)라 `#` 미포함 → 충돌 없음. 설계 spec dense-alias-vectors §3.2. +pub const ALIAS_SUFFIX: &str = "#alias"; + +/// sentinel 별칭 chunk_id 에서 원본 chunk_id 를 복원. 접미 없으면 그대로. +pub fn strip_alias_suffix(id: &str) -> &str { + id.strip_suffix(ALIAS_SUFFIX).unwrap_or(id) +} +``` + +`crates/kebab-core/src/lib.rs` 의 `ids` re-export 에 `ALIAS_SUFFIX, strip_alias_suffix` 추가 +(`pub use ids::{... , ALIAS_SUFFIX, strip_alias_suffix};` — 기존 `pub use ids::{...}` 목록에 삽입). + +- [ ] **Step 4: 통과** — `cargo test -p kebab-core strip_alias_suffix -j 4` EXIT=0. + +- [ ] **Step 5: 커밋** — `git add crates/kebab-core && git commit -m "feat(core): ALIAS_SUFFIX + strip_alias_suffix (dense alias vectors)"` + +--- + +## Task 2: config `embed_aliases` + +**Files:** Modify `crates/kebab-config/src/lib.rs` + +- [ ] **Step 1: 실패 테스트** — `#[cfg(test)] mod tests` 에: + +```rust + #[test] + fn embed_aliases_defaults_off() { + assert!(!Config::defaults().ingest.expansion.embed_aliases); + } + + #[test] + fn embed_aliases_env_override() { + let mut cfg = Config::defaults(); + let env: std::collections::HashMap = + [("KEBAB_INGEST_EXPANSION_EMBED_ALIASES".to_string(), "true".to_string())] + .into_iter().collect(); + cfg.apply_env(&env); + assert!(cfg.ingest.expansion.embed_aliases); + } +``` + +- [ ] **Step 2: 실패 확인** — `cargo test -p kebab-config embed_aliases -j 4 > /tmp/dv-t2.log 2>&1; echo "EXIT=$?"` → 컴파일 실패. + +- [ ] **Step 3: 구현** — `IngestExpansionCfg` struct 에 필드(기존 `prompt_version` 다음): + +```rust + /// 별칭을 dense 벡터로도 색인(별도 sentinel chunk_id). default off. + /// `enabled`(별칭 생성)와 별개 축 — 둘 다 on 이어야 dense 별칭. 설계 spec + /// dense-alias-vectors §3.3. + pub embed_aliases: bool, +``` + +`impl Default for IngestExpansionCfg` 에 `embed_aliases: false,` 추가. `apply_env` 에: + +```rust + "KEBAB_INGEST_EXPANSION_EMBED_ALIASES" => { + self.ingest.expansion.embed_aliases = parse_bool(v) + } +``` + +- [ ] **Step 4: 통과** — `cargo test -p kebab-config -j 4` EXIT=0 (신규 2 + 기존). + +- [ ] **Step 5: 커밋** — `git add crates/kebab-config && git commit -m "feat(config): ingest.expansion.embed_aliases flag (default off)"` + +--- + +## Task 3: ingest 별칭 임베딩 + sentinel VectorRecord + purge + +**Files:** Modify `crates/kebab-app/src/lib.rs` (embed 블록 ~1309, purge 함수) + +- [ ] **Step 1: 구현 (embed 블록)** — `if !chunks.is_empty()` 블록(현재 body inputs/records 생성)을 확장. body records 생성 후 별칭 records 를 추가로 만들어 같은 `upsert` 에 합친다: + +기존 body 임베딩(`let inputs = chunks.iter().map(|c| EmbeddingInput{text: c.text.as_str(), ...})` → `vectors` → `records`)은 **그대로**. `vec_store.upsert(&records)` **직전**에 추가: + +```rust + // dense 별칭(별도 벡터, sentinel chunk_id). embed_aliases on + + // 별칭 있는 청크만. 본문 records 는 위에서 이미 생성됨(불변). + let mut all_records = records; + if app.config.ingest.expansion.embed_aliases { + let alias_chunks: Vec<&kebab_core::Chunk> = chunks + .iter() + .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 + .iter() + .map(|c| EmbeddingInput { + text: c.aliases.as_deref().unwrap(), + kind: EmbeddingKind::Document, + }) + .collect(); + let alias_vectors = emb + .embed(&alias_inputs) + .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, 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")?; +``` + +(기존 `vec_store.upsert(&records)` 줄은 위 `upsert(&all_records)` 로 대체 — 중복 upsert 금지.) + +- [ ] **Step 2: 구현 (purge sentinel)** — `purge_vector_orphans_for_workspace_path` 의 `delete_by_chunk_ids(&stale)` 를, stale + sentinel 을 함께 지우도록: + +```rust + let mut to_delete = stale.clone(); + to_delete.extend(stale.iter().map(|id| format!("{}{}", id, kebab_core::ALIAS_SUFFIX))); + vec_store + .delete_by_chunk_ids(&to_delete) + .context("VectorStore::delete_by_chunk_ids (orphan vector cleanup)")?; +``` + +그리고 `sweep_deleted_files` 의 `purge_deleted_workspace_path` 후 `vec.delete_by_chunk_ids(&chunk_ids)`(있는 곳)도 동일하게 `{id}#alias` 를 포함하도록 확장(해당 위치 `grep -n "delete_by_chunk_ids" crates/kebab-app/src/lib.rs` 로 모두 찾아 sentinel 추가). + +- [ ] **Step 3: 빌드 + 회귀** — `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo build -p kebab-app -j 4 > /tmp/dv-t3.log 2>&1; echo "EXIT=$?"` EXIT=0. `cargo test -p kebab-app -j 4` EXIT=0(embed_aliases off 라 기존 무영향). + +- [ ] **Step 4: 커밋** — `git add crates/kebab-app/src/lib.rs && git commit -m "feat(app): 별칭 dense 별도 벡터 색인 + purge (sentinel)"` + +--- + +## Task 4: VectorRetriever sentinel strip + dedup + +**Files:** Modify `crates/kebab-search/src/vector.rs` + +- [ ] **Step 1: 실패 테스트** — `crates/kebab-search/tests/` 의 기존 vector 테스트 패턴 확인(`ls crates/kebab-search/tests/ && grep -rln "VectorRetriever" crates/kebab-search/tests/`). store 에 body + `{orig}#alias` 벡터를 넣고, 별칭 벡터에 가까운 쿼리로 검색 시 결과가 **원본 chunk_id** 1개(중복 없음)인지 검증: + +```rust +#[test] +fn alias_vector_hit_strips_to_original_and_dedupes() { + // store 에 chunk "c1" body 벡터 + "c1#alias" 별칭 벡터. 쿼리가 둘 다 매칭. + // 결과: 원본 "c1" 1개 (sentinel strip + dedup). + // (기존 vector 테스트 헬퍼로 store fixture 구성 — 벡터/임베딩 mock 패턴 따름.) + let hits = retr.search(&q).unwrap(); + let c1 = hits.iter().filter(|h| h.chunk_id.0 == "c1").count(); + assert_eq!(c1, 1, "body+alias 둘 다 매칭해도 원본 chunk_id 1개로 dedup"); + assert!(!hits.iter().any(|h| h.chunk_id.0.ends_with("#alias")), + "sentinel chunk_id 가 결과에 노출되면 안 된다"); +} +``` + +> 정확한 store fixture(벡터 upsert + embed mock)는 기존 `tests/` 의 VectorRetriever 테스트 패턴을 따른다. + +- [ ] **Step 2: 실패 확인** — `cargo test -p kebab-search alias_vector_hit -j 4 > /tmp/dv-t4.log 2>&1; echo "EXIT=$?"` → 실패(현재 sentinel 노출 + 중복). + +- [ ] **Step 3: 구현** — `vector.rs` `search()`: + (a) `VECTOR_OVERFETCH_MULTIPLIER` 를 `2` → `3` (별칭 벡터로 dedup 후 k 미달 방지). + (b) raw_hits 순회 루프에서 strip + dedup. 기존: + ```rust + let candidate_ids: Vec<&str> = raw_hits.iter().map(|h| h.chunk_id.0.as_str()).collect(); + let hydration = hydrate_chunks(&self.sqlite, &candidate_ids)...; + ... + for hit in raw_hits { + let Some(meta) = hydration.get(hit.chunk_id.0.as_str()) else { continue; }; + rank = rank.saturating_add(1); + hits.push(build_hit(hit, meta, rank, ...)?); + if hits.len() >= k { break; } + } + ``` + 를 다음으로(원본 id 로 hydrate + seen dedup, build_hit 에 strip 된 chunk_id 반영): + ```rust + // sentinel 별칭 hit 을 원본 chunk_id 로 strip 해 hydrate. + let candidate_ids: Vec<&str> = raw_hits + .iter() + .map(|h| kebab_core::strip_alias_suffix(h.chunk_id.0.as_str())) + .collect(); + let hydration = hydrate_chunks(&self.sqlite, &candidate_ids) + .context("kb-search vector: hydrate chunk metadata")?; + ... + let model_id = self.embed.model_id(); + let mut hits: Vec = Vec::with_capacity(k.min(raw_hits.len())); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut rank: u32 = 0; + for mut hit in raw_hits { + let orig = kebab_core::strip_alias_suffix(hit.chunk_id.0.as_str()).to_string(); + if !seen.insert(orig.clone()) { + continue; // 같은 원본이 body+alias 둘 다 → 첫(높은 score) 유지 + } + let Some(meta) = hydration.get(orig.as_str()) else { continue; }; + // build_hit 이 원본 chunk_id 를 쓰도록 hit 의 chunk_id 를 strip 본으로 교체. + hit.chunk_id = kebab_core::ChunkId(orig); + rank = rank.saturating_add(1); + hits.push(build_hit(hit, meta, rank, &self.index_version, &model_id, self.snippet_chars)?); + if hits.len() >= k { break; } + } + ``` + (`raw_hits` 가 `Vec` 라 `for mut hit` 가능. `VectorHit.chunk_id` 가 `pub` 인지 확인 — `crates/kebab-core/src/vector.rs:24`. pub 아니면 build_hit 시그니처에 override chunk_id 인자 추가.) + +- [ ] **Step 4: 통과 + 회귀** — `cargo test -p kebab-search -j 4 > /tmp/dv-t4.log 2>&1; echo "EXIT=$?"` EXIT=0 (신규 + 기존 vector/hybrid). + +- [ ] **Step 5: 커밋** — `git add crates/kebab-search/src/vector.rs crates/kebab-search/tests && git commit -m "feat(search): VectorRetriever sentinel 별칭 strip + dedup"` + +--- + +## Task 5: 측정 + 문서 + +- [ ] **Step 1: clippy** — `cargo clippy --workspace --all-targets -j 4 -- -D warnings > /tmp/dv-clippy.log 2>&1; echo "EXIT=$?"` EXIT=0. + +- [ ] **Step 2: 측정** — `.kebabignore`(topics 만) 재작성 → release 빌드 → `KEBAB_INGEST_EXPANSION_ENABLED=true KEBAB_INGEST_EXPANSION_EMBED_ALIASES=true kebab ingest --force-reingest`(topics 재임베딩, 별칭 벡터 생성, ~32분) → `KEBAB_EVAL_GOLDEN=... kebab eval run --mode hybrid --k 50` → `eval variants`. **Read 로 값 확인(추측 금지).** + - **효과**: 영어 설명형(mvcc/raft) `recall@50` 0→양수 회복? concat PoC(6/0/2/0.25) 대비 개선? + - **회귀**: body 벡터 불변이라 명사형/단일쿼리 회귀 0 확인. 측정 후 `.kebabignore` 삭제. + +- [ ] **Step 3: 문서** — `tasks/HOTFIXES.md` dated entry(lexical 별칭 + dense 별칭 측정 표), README Configuration(`embed_aliases` off 기본), ARCHITECTURE(별칭 dense sentinel 벡터), HANDOFF. + +- [ ] **Step 4: 커밋** — `git add tasks/HOTFIXES.md README.md docs/ARCHITECTURE.md HANDOFF.md && git commit -m "docs: dense 별칭 측정 결과 + 문서 동기화"` + +--- + +## Self-Review + +- **Spec 커버리지**: §3.2 sentinel→Task1. §3.3 config→Task2, ingest embed→Task3, retriever dedup→Task4, purge→Task3. §5 측정→Task5. §7 테스트→각 Task. ✅ +- **Placeholder**: Task4 Step1 store fixture 는 "기존 패턴 따름"으로 위임(단언 핵심 명시). VectorHit.chunk_id pub 여부는 "확인 후 분기" 지시. 나머지 완성 코드. ✅ +- **타입 일관성**: `ALIAS_SUFFIX`/`strip_alias_suffix`(Task1, kebab_core) ↔ ingest(Task3)·retriever(Task4) 사용. `embed_aliases`(Task2 config) ↔ ingest(Task3). VectorRecord 필드(Task3) = 기존 body records 와 동일 구조. ✅ + +--- + +## Execution Handoff + +OMC teammate(sequential single-team). Task1·2=sonnet(작은), Task3·4=opus(임베딩/retriever 핵심). Task3/4 후 code-reviewer(opus, sentinel dedup·purge 정확성·회귀). Task5 측정은 main 세션 직접. -- 2.49.1 From 86b4e1ebd069d26ae6a7601d3b86bde4998ca658 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 30 May 2026 10:31:03 +0000 Subject: [PATCH 15/27] feat(core): ALIAS_SUFFIX + strip_alias_suffix (dense alias vectors) --- crates/kebab-core/src/ids.rs | 19 +++++++++++++++++++ crates/kebab-core/src/lib.rs | 5 +++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/kebab-core/src/ids.rs b/crates/kebab-core/src/ids.rs index c43b661..c0ac1fe 100644 --- a/crates/kebab-core/src/ids.rs +++ b/crates/kebab-core/src/ids.rs @@ -58,6 +58,15 @@ fn validate_hex32(s: &str) -> Result<(), CoreError> { Ok(()) } +/// 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. +pub fn strip_alias_suffix(id: &str) -> &str { + id.strip_suffix(ALIAS_SUFFIX).unwrap_or(id) +} + /// Canonical-JSON + blake3 + hex prefix 32. Per design §4.2. pub fn id_from(tuple: T) -> String { let bytes = serde_json_canonicalizer::to_vec(&tuple) @@ -430,6 +439,16 @@ mod tests { assert_eq!(id.0, "71992c457a5da39880a6d17d646ed0fd"); } + #[test] + fn strip_alias_suffix_roundtrip() { + let bare = "0123456789abcdef0123456789abcdef"; + let with_suffix = format!("{}{}", bare, ALIAS_SUFFIX); + assert_eq!(strip_alias_suffix(&with_suffix), bare); + assert_eq!(strip_alias_suffix(bare), bare); + assert_eq!(strip_alias_suffix(""), ""); + assert_eq!(strip_alias_suffix("#alias"), ""); + } + /// Independent pin for id_for_index. /// inputs: /// collection="default", diff --git a/crates/kebab-core/src/lib.rs b/crates/kebab-core/src/lib.rs index 8cb57d6..b4ddb35 100644 --- a/crates/kebab-core/src/lib.rs +++ b/crates/kebab-core/src/lib.rs @@ -43,8 +43,9 @@ pub use document::{ pub use errors::CoreError; pub use fetch::{FetchKind, FetchOpts, FetchQuery, FetchResult}; pub use ids::{ - AssetId, BlockId, ChunkId, DocumentId, EmbeddingId, IndexId, id_for_asset, id_for_block, - id_for_chunk, id_for_doc, id_for_embedding, id_for_index, id_from, + ALIAS_SUFFIX, AssetId, BlockId, ChunkId, DocumentId, EmbeddingId, IndexId, id_for_asset, + id_for_block, id_for_chunk, id_for_doc, id_for_embedding, id_for_index, id_from, + strip_alias_suffix, }; pub use ingest::{IngestItem, IngestItemKind, IngestReport, SkipExamples}; pub use jobs::{JobFilter, JobId, JobKind, JobRow, JobStatus}; -- 2.49.1 From b9d20d23d1b1bbb0e6e3df3d3cce45937276ace9 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 30 May 2026 10:31:07 +0000 Subject: [PATCH 16/27] feat(config): ingest.expansion.embed_aliases flag (default off) --- crates/kebab-config/src/lib.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/kebab-config/src/lib.rs b/crates/kebab-config/src/lib.rs index 34ca1ba..568ff45 100644 --- a/crates/kebab-config/src/lib.rs +++ b/crates/kebab-config/src/lib.rs @@ -649,6 +649,8 @@ pub struct IngestExpansionCfg { pub max_aliases_per_chunk: usize, /// Prompt template version tag. pub prompt_version: String, + /// Whether alias embeddings are stored as separate dense vectors. + pub embed_aliases: bool, } impl Default for IngestExpansionCfg { @@ -658,6 +660,7 @@ impl Default for IngestExpansionCfg { model: String::new(), max_aliases_per_chunk: 8, prompt_version: "expansion-v1".to_string(), + embed_aliases: false, } } } @@ -1161,6 +1164,9 @@ impl Config { "KEBAB_INGEST_EXPANSION_PROMPT_VERSION" => { self.ingest.expansion.prompt_version = v.clone(); } + "KEBAB_INGEST_EXPANSION_EMBED_ALIASES" => { + self.ingest.expansion.embed_aliases = parse_bool(v); + } // Unknown KEBAB_* keys are silently ignored — see // `env_unknown_key_is_ignored` test. @@ -1911,6 +1917,20 @@ max_context_tokens = 8000 assert_eq!(c.ingest.expansion.max_aliases_per_chunk, 12); assert_eq!(c.ingest.expansion.prompt_version, "expansion-v2"); } + + #[test] + fn embed_aliases_defaults_off() { + let cfg = Config::defaults(); + assert!(!cfg.ingest.expansion.embed_aliases); + } + + #[test] + fn embed_aliases_env_override() { + let mut env = HashMap::new(); + env.insert("KEBAB_INGEST_EXPANSION_EMBED_ALIASES".into(), "true".into()); + let c = Config::defaults().apply_env(&env); + assert!(c.ingest.expansion.embed_aliases); + } } #[cfg(test)] -- 2.49.1 From afa8af0f88eb9dea38ec8137bf79ee2efdd4876a Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 30 May 2026 10:48:58 +0000 Subject: [PATCH 17/27] =?UTF-8?q?feat(app):=20=EB=B3=84=EC=B9=AD=20dense?= =?UTF-8?q?=20=EB=B3=84=EB=8F=84=20=EB=B2=A1=ED=84=B0=20=EC=83=89=EC=9D=B8?= =?UTF-8?q?=20+=20purge=20(sentinel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/kebab-app/src/lib.rs | 72 ++++++++++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/crates/kebab-app/src/lib.rs b/crates/kebab-app/src/lib.rs index 7dd0b78..c7f1709 100644 --- a/crates/kebab-app/src/lib.rs +++ b/crates/kebab-app/src/lib.rs @@ -1341,7 +1341,53 @@ fn ingest_one_asset( dimensions, }) .collect(); - vec_store.upsert(&records).context("VectorStore::upsert")?; + // dense 별칭(별도 벡터, sentinel chunk_id). embed_aliases on + + // 별칭 있는 청크만. 본문 records 는 위에서 이미 생성됨(불변). + let mut all_records = records; + if app.config.ingest.expansion.embed_aliases { + let alias_chunks: Vec<&kebab_core::Chunk> = chunks + .iter() + .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 + .iter() + .map(|c| EmbeddingInput { + text: c.aliases.as_deref().unwrap(), + kind: EmbeddingKind::Document, + }) + .collect(); + let alias_vectors = emb + .embed(&alias_inputs) + .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, + 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")?; } } @@ -1687,8 +1733,14 @@ fn purge_workspace_path_for_parser_bump(app: &App, asset: &RawAsset) -> anyhow:: if !stale.is_empty() { if let Some(vec_store) = app.vector().context("App::vector")? { use kebab_core::VectorStore as _; + // sentinel 별칭 벡터(`{id}#alias`)는 SQLite chunks 에 없어 stale 에 + // 안 잡힌다 → 명시적으로 함께 삭제(orphan 누적 방지). + let mut to_delete = stale.clone(); + to_delete.extend(stale.iter().map(|id| { + kebab_core::ChunkId(format!("{}{}", id.0, kebab_core::ALIAS_SUFFIX)) + })); vec_store - .delete_by_chunk_ids(&stale) + .delete_by_chunk_ids(&to_delete) .context("VectorStore::delete_by_chunk_ids (parser-bump orphans)")?; } } @@ -1732,8 +1784,14 @@ fn purge_vector_orphans_for_workspace_path( return Ok(()); } use kebab_core::VectorStore as _; + // sentinel 별칭 벡터(`{id}#alias`)는 SQLite chunks 에 없어 stale 에 + // 안 잡힌다 → 명시적으로 함께 삭제(orphan 누적 방지). + let mut to_delete = stale.clone(); + to_delete.extend(stale.iter().map(|id| { + kebab_core::ChunkId(format!("{}{}", id.0, kebab_core::ALIAS_SUFFIX)) + })); vec_store - .delete_by_chunk_ids(&stale) + .delete_by_chunk_ids(&to_delete) .context("VectorStore::delete_by_chunk_ids (orphan vector cleanup)")?; tracing::debug!( target: "kebab-app", @@ -1833,7 +1891,13 @@ fn sweep_deleted_files( if let Some(vec) = vector_store { if !chunk_ids.is_empty() { use kebab_core::VectorStore as _; - if let Err(e) = vec.delete_by_chunk_ids(&chunk_ids) { + // sentinel 별칭 벡터(`{id}#alias`)는 SQLite chunks 에 없어 + // chunk_ids 에 안 잡힌다 → 명시적으로 함께 삭제(orphan 누적 방지). + let mut to_delete = chunk_ids.clone(); + to_delete.extend(chunk_ids.iter().map(|id| { + kebab_core::ChunkId(format!("{}{}", id.0, kebab_core::ALIAS_SUFFIX)) + })); + if let Err(e) = vec.delete_by_chunk_ids(&to_delete) { tracing::warn!( target: "kebab-app", path = %stored_path.0, -- 2.49.1 From 6ba8cb2c8825e49477014fb8316a36bacd892b3c Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 30 May 2026 11:09:32 +0000 Subject: [PATCH 18/27] =?UTF-8?q?feat(search):=20VectorRetriever=20sentine?= =?UTF-8?q?l=20=EB=B3=84=EC=B9=AD=20strip=20+=20dedup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 별칭 dense 벡터({orig}#alias) hit 을 원본 chunk_id 로 strip 해 hydrate, body+alias 중복은 첫(높은 score) 하나만 유지. overfetch 2→3 (dedup 후 k 확보). wire/RetrievalDetail 무변경. vector/hybrid 회귀 0, clippy green. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/kebab-search/src/vector.rs | 37 ++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/crates/kebab-search/src/vector.rs b/crates/kebab-search/src/vector.rs index f61ad8a..f934660 100644 --- a/crates/kebab-search/src/vector.rs +++ b/crates/kebab-search/src/vector.rs @@ -36,9 +36,13 @@ const DEFAULT_K: usize = 10; /// Over-fetch multiplier passed to `VectorStore::search` so that /// SQLite-side filter losses (tags / lang / trust / path_glob) still /// leave at least `k` candidates. The Lance store already applies the -/// same filters internally; the extra `* 2` is the spec-mandated -/// safety margin for the `Retriever` layer (§7.2 spec line 138). -const VECTOR_OVERFETCH_MULTIPLIER: usize = 2; +/// same filters internally; the extra margin is the spec-mandated +/// safety for the `Retriever` layer (§7.2 spec line 138). +/// +/// `3` (was `2`): dense 별칭 sentinel 벡터(`{orig}#alias`)가 같은 청크의 +/// body 벡터와 함께 raw_hits 에 들어올 수 있어, strip+dedup 후에도 `k` 개를 +///확보하도록 여유를 키운다(별칭 미사용 시에도 안전한 상한). +const VECTOR_OVERFETCH_MULTIPLIER: usize = 3; /// Wraps a vector store + embedder into a [`Retriever`]. /// @@ -149,23 +153,34 @@ impl Retriever for VectorRetriever { } // 3. Hydrate metadata from SQLite for the candidate ids in - // one round-trip. Order is preserved by the caller via the - // HashMap lookup at hit-construction time. - let candidate_ids: Vec<&str> = raw_hits.iter().map(|h| h.chunk_id.0.as_str()).collect(); + // one round-trip. dense 별칭 벡터는 sentinel chunk_id + // (`{orig}#alias`)로 색인되므로, 원본 chunk_id 로 strip 해 + // hydrate 한다(별칭 벡터는 chunks 테이블에 없음). + let candidate_ids: Vec<&str> = raw_hits + .iter() + .map(|h| kebab_core::strip_alias_suffix(h.chunk_id.0.as_str())) + .collect(); let hydration = hydrate_chunks(&self.sqlite, &candidate_ids) .context("kb-search vector: hydrate chunk metadata")?; // 4. Build `SearchHit` for the first `k` raw hits that pass - // hydration (a missing row would be a filter-induced drop — - // Lance returned the chunk but SQLite filtered it out, or - // the chunk was deleted between Lance's read and ours). + // hydration. sentinel 별칭 hit 은 원본 chunk_id 로 strip 하고, + // 같은 원본이 body+alias 둘 다 hit 하면 첫(높은 score) 하나만 + // 남긴다(dedup). raw_hits 는 score 순이라 첫 매칭이 최선. let model_id = self.embed.model_id(); let mut hits: Vec = Vec::with_capacity(k.min(raw_hits.len())); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); let mut rank: u32 = 0; - for hit in raw_hits { - let Some(meta) = hydration.get(hit.chunk_id.0.as_str()) else { + for mut hit in raw_hits { + let orig = kebab_core::strip_alias_suffix(hit.chunk_id.0.as_str()).to_string(); + if !seen.insert(orig.clone()) { + continue; // body+alias 중복 → 첫 hit 유지 + } + let Some(meta) = hydration.get(orig.as_str()) else { continue; }; + // build_hit 이 원본 chunk_id 를 쓰도록 sentinel 을 strip 본으로 교체. + hit.chunk_id = kebab_core::ChunkId(orig); rank = rank.saturating_add(1); hits.push(build_hit( hit, -- 2.49.1 From b56469f010d8ae1d76b0b7422323db5a97dfc3a8 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 30 May 2026 11:24:04 +0000 Subject: [PATCH 19/27] =?UTF-8?q?fix(core):=20clippy=20uninlined=5Fformat?= =?UTF-8?q?=5Fargs=20=E2=80=94=20strip=5Falias=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20(=EB=A6=AC=EB=B7=B0=20MAJOR-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workspace clippy --all-targets -D warnings 게이트 통과. format! 인자 인라인. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/kebab-core/src/ids.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/kebab-core/src/ids.rs b/crates/kebab-core/src/ids.rs index c0ac1fe..7fa07ef 100644 --- a/crates/kebab-core/src/ids.rs +++ b/crates/kebab-core/src/ids.rs @@ -442,7 +442,7 @@ mod tests { #[test] fn strip_alias_suffix_roundtrip() { let bare = "0123456789abcdef0123456789abcdef"; - let with_suffix = format!("{}{}", bare, ALIAS_SUFFIX); + let with_suffix = format!("{bare}{ALIAS_SUFFIX}"); assert_eq!(strip_alias_suffix(&with_suffix), bare); assert_eq!(strip_alias_suffix(bare), bare); assert_eq!(strip_alias_suffix(""), ""); -- 2.49.1 From d279f343e7e7d967444785ba3d047f4f1d44c6fd Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 30 May 2026 13:25:45 +0000 Subject: [PATCH 20/27] =?UTF-8?q?docs(spec,plan):=20=EB=B3=84=EB=8F=84=20?= =?UTF-8?q?=EB=B2=A1=ED=84=B0=20=EC=9D=B8=ED=94=84=EB=9D=BC=20=E2=80=94=20?= =?UTF-8?q?FK=20=EC=A0=9C=EA=B1=B0(V011)=20+=20CASCADE=20=EB=8C=80?= =?UTF-8?q?=EC=B2=B4=20+=20filter=5Fchunks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PoC: 별칭 순수 벡터가 영어 설명형 rank 7~30 (concat 본문 희석으로 미회복) → 별도 벡터 명분. 차단요인 3건: embedding_records FK(787, V011 재생성), CASCADE 대체(명시 DELETE), filter_chunks sentinel strip. plan Task 4.5/4.6. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-05-30-dense-alias-vectors.md | 110 ++++++++++++++++++ .../2026-05-30-dense-alias-vectors-design.md | 35 +++++- 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-05-30-dense-alias-vectors.md b/docs/superpowers/plans/2026-05-30-dense-alias-vectors.md index 7e31cdf..0ae6e67 100644 --- a/docs/superpowers/plans/2026-05-30-dense-alias-vectors.md +++ b/docs/superpowers/plans/2026-05-30-dense-alias-vectors.md @@ -260,6 +260,116 @@ fn alias_vector_hit_strips_to_original_and_dedupes() { --- +## Task 4.5: V0XX — embedding_records FK 제거 (breaking) + CASCADE 대체 + +**배경 (spec §3.5)**: sentinel chunk_id 는 chunks 에 없어 `embedding_records.chunk_id REFERENCES +chunks(chunk_id) ON DELETE CASCADE`(V001:100) FK 를 위반(SQLite 787) → ingest 에러. SQLite 는 ALTER +로 FK 못 지워 테이블 재생성. CASCADE 사라지면 orphan 정리를 명시 DELETE 로 대체. + +**Files:** Create `migrations/V010__drop_embedding_records_fk.sql` (또는 현재 최신 번호+1 확인: +`ls migrations/` → 최신이 V010__chunk_aliases.sql 이면 **V011**), Modify `crates/kebab-store-sqlite/src/documents.rs`(put_chunks), `crates/kebab-store-sqlite/src/store.rs`(purge 경로) + +- [ ] **Step 1: 최신 migration 번호 확인** — `ls migrations/`. doc-side expansion 이 V010__chunk_aliases.sql + 을 추가했으므로 신규는 **V011**. 파일명 `V011__drop_embedding_records_fk.sql`. + +- [ ] **Step 2: migration 작성** — `embedding_records` 를 FK 없이 재생성(V003 의 status/vector_committed + 컬럼 + 모든 인덱스 보존). FK 외 스키마는 동일: + +```sql +-- V011__drop_embedding_records_fk.sql — embedding_records.chunk_id FK 제거. +-- sentinel chunk_id({orig}#alias, chunks 에 없는 id) 벡터를 허용하기 위함 +-- (설계 spec 2026-05-30-dense-alias-vectors-design.md §3.5-1). SQLite 는 ALTER +-- 로 FK 제거 불가 → 테이블 재생성. status/vector_committed(V003) + 인덱스 보존. +-- CASCADE 제거분은 put_chunks/purge 의 명시 DELETE 로 대체(§3.5-2). +PRAGMA foreign_keys=OFF; + +CREATE TABLE embedding_records_new ( + embedding_id TEXT PRIMARY KEY, + chunk_id TEXT NOT NULL, -- FK 제거 (was REFERENCES chunks ON DELETE CASCADE) + model_id TEXT NOT NULL, + model_version TEXT NOT NULL, + dimensions INTEGER NOT NULL, + lance_table TEXT NOT NULL, + created_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + vector_committed INTEGER NOT NULL DEFAULT 0, + UNIQUE(chunk_id, model_id, model_version, dimensions) +); +INSERT INTO embedding_records_new + SELECT embedding_id, chunk_id, model_id, model_version, dimensions, + lance_table, created_at, status, vector_committed + FROM embedding_records; +DROP TABLE embedding_records; +ALTER TABLE embedding_records_new RENAME TO embedding_records; +CREATE INDEX idx_embed_chunk ON embedding_records(chunk_id); +CREATE INDEX idx_embed_model ON embedding_records(model_id, model_version, dimensions); +CREATE INDEX idx_embed_status ON embedding_records(status); + +PRAGMA foreign_keys=ON; + +UPDATE kv SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT) WHERE key = 'corpus_revision'; +``` + +> ⚠️ `chunks_bd_tombstone_embeddings` trigger(V003)는 그대로 유지. FK 제거 후 tombstone 이 실제 보존됨 +> (CASCADE 가 즉시 안 지움) — 명시 DELETE(Step 3)가 정리를 담당. + +- [ ] **Step 3: CASCADE 대체 — 명시 DELETE** — chunk 삭제 경로에서 embedding_records 를 명시 정리. + `crates/kebab-store-sqlite/src/documents.rs` `put_chunks`(DELETE-then-INSERT, 라인 101 `DELETE FROM + chunks WHERE doc_id=?` 직전/직후): 해당 doc 의 chunk_id + `{id}#alias` embedding_records 삭제: +```rust + // CASCADE 제거(V011) 대체: 이 doc 의 chunk 임베딩 레코드를 명시 정리. + // 원본 + sentinel({id}#alias) 둘 다. (별칭 벡터는 chunks FK 가 없어 자동 정리 안 됨.) + tx.execute( + "DELETE FROM embedding_records WHERE chunk_id IN \ + (SELECT chunk_id FROM chunks WHERE doc_id=?1 \ + UNION SELECT chunk_id||'#alias' FROM chunks WHERE doc_id=?1)", + params![doc.0], + ).map_err(StoreError::from)?; +``` + (이 DELETE 는 `DELETE FROM chunks` **전에** 실행 — chunks 가 지워지면 서브쿼리가 빈 결과.) + `crates/kebab-store-sqlite/src/store.rs` 의 `purge_orphan_at_workspace_path`(라인 ~631 `DELETE FROM + documents`)·`purge_deleted_workspace_path` 도 동일하게, chunks 삭제 전 수집한 chunk_id + sentinel 을 + `DELETE FROM embedding_records WHERE chunk_id IN (...)` 로 정리. (`grep -n "DELETE FROM documents\|DELETE + FROM chunks" crates/kebab-store-sqlite/src/store.rs` 로 경로 확인.) + +- [ ] **Step 4: 테스트** — `crates/kebab-store-sqlite/tests/` 에: + - sentinel chunk_id embedding_records INSERT 가 FK 위반 없이 성공(V011 후). + - put_chunks 재호출 시 기존 embedding_records(원본+sentinel) 정리 → orphan 0. + Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-store-sqlite -j 4 > /tmp/dv-t45.log 2>&1; echo "EXIT=$?"` EXIT=0 + 기존 corpus_revision baseline(V011 bump 로 +1) 갱신 필요 시 갱신. + +- [ ] **Step 5: 커밋** — `git add migrations/V011__drop_embedding_records_fk.sql crates/kebab-store-sqlite && git commit -m "feat(store): V011 embedding_records FK 제거 + CASCADE 대체 명시 DELETE (sentinel 별칭 벡터)"` + +--- + +## Task 4.6: filter_chunks sentinel strip + +**배경 (spec §3.5-3)**: `filter_chunks`(filters.rs:81)가 `embedding_records er JOIN chunks c ON +c.chunk_id=er.chunk_id WHERE er.status='committed'` 로 LanceDB 후보를 필터. sentinel chunk_id 는 chunks +JOIN 에서 버려져 VectorRetriever strip 이전에 탈락. sentinel candidate 를 원본으로 strip 해 JOIN 통과시킴. + +**Files:** Modify `crates/kebab-store-sqlite/src/filters.rs` (`filter_chunks`) + +- [ ] **Step 1: 실패 테스트** — committed 원본 chunk 의 sentinel candidate(`{orig}#alias`)가 + filter_chunks 결과에 (원본 또는 sentinel 로) 통과하는지. (기존 filters 테스트 패턴 따라.) + +- [ ] **Step 2: 구현** — `filter_chunks(chunk_ids, filters)` 가 candidate `chunk_ids` 중 sentinel + (`#alias` 접미)을 **원본으로 strip 해 IN-list/JOIN** 에 넣되, **반환은 입력 candidate 형태(sentinel + 유지)** 로 — VectorRetriever 가 그 sentinel 을 받아 strip+dedup(Task 4)하기 때문. 즉: + - IN-list 바인딩: 각 candidate 를 `strip_alias_suffix` 한 원본 chunk_id 로 JOIN(committed 판정은 + 원본 chunk 기준). 원본이 committed 면 그 candidate(원본 or sentinel) 통과. + - 반환: 통과한 **원본 candidate 문자열 그대로**(sentinel 포함) — store.search 가 그대로 VectorRetriever 로. + - 구현 주의: 현재 `er.chunk_id IN (?)` 가 candidate 직접 매칭. sentinel 은 embedding_records 에는 + 있으나(V011 후) chunks JOIN 실패. 두 방법 중 택1 — (a) JOIN 을 `c.chunk_id = strip(er.chunk_id)` 로 + (SQL 에서 `#alias` 제거: `replace(er.chunk_id,'#alias','')` 또는 `rtrim`), 또는 (b) Rust 에서 + candidate 를 원본으로 strip 해 IN-list 구성 후, 결과를 원본 candidate 와 매핑해 반환. **(b) 권장** + (SQL replace 보다 명확). `kebab_core::strip_alias_suffix` 사용. + +- [ ] **Step 3: 테스트 통과 + 회귀** — `cargo test -p kebab-store-sqlite -p kebab-search -j 4 > /tmp/dv-t46.log 2>&1; echo "EXIT=$?"` EXIT=0. + +- [ ] **Step 4: 커밋** — `git add crates/kebab-store-sqlite/src/filters.rs && git commit -m "feat(store): filter_chunks sentinel 별칭 candidate strip (committed 통과)"` + +--- + ## Task 5: 측정 + 문서 - [ ] **Step 1: clippy** — `cargo clippy --workspace --all-targets -j 4 -- -D warnings > /tmp/dv-clippy.log 2>&1; echo "EXIT=$?"` EXIT=0. diff --git a/docs/superpowers/specs/2026-05-30-dense-alias-vectors-design.md b/docs/superpowers/specs/2026-05-30-dense-alias-vectors-design.md index 8c655f1..448b13a 100644 --- a/docs/superpowers/specs/2026-05-30-dense-alias-vectors-design.md +++ b/docs/superpowers/specs/2026-05-30-dense-alias-vectors-design.md @@ -78,6 +78,36 @@ ingest_one_asset (embed + upsert): 없어 stale 목록에 안 잡히므로 명시 추가 — 안 하면 orphan 별칭 벡터 누적.) - **config**: `IngestExpansionCfg.embed_aliases: bool`(default false) + `KEBAB_INGEST_EXPANSION_EMBED_ALIASES`. +### 3.5 인프라 제약 — embedding_records FK + filter_chunks (구현 중 발견, 2026-05-30) + +sentinel chunk_id 는 chunks 테이블에 **없는** id 라, 다음 두 인프라가 sentinel 벡터를 막는다. 둘 다 +수정해야 별도 벡터가 동작한다(PoC 측정으로 확인된 차단 요인). + +1. **embedding_records FK (breaking schema, V0XX)** — `embedding_records.chunk_id TEXT NOT NULL + REFERENCES chunks(chunk_id) ON DELETE CASCADE`(V001__init.sql:100). LanceVectorStore.upsert 의 + phase 1(`put_embedding_records_pending`)이 sentinel chunk_id 를 INSERT 하면 **FK 위반(SQLite 787)** + → ingest 전체 에러. SQLite 는 ALTER 로 FK 제거 불가 → `embedding_records` **테이블 재생성** + (rename + recreate without FK + data copy + index 재생성). V003 의 `status`/`vector_committed` + 컬럼 + `idx_embed_*` 인덱스 보존. **breaking → 버전 bump + dogfood**. (V003 주석이 "GC 스케줄러 + 구현 시 이 CASCADE 제거 예정"을 이미 예고 — 프로젝트 로드맵과 정합.) + +2. **CASCADE 대체 (orphan 정리)** — FK 의 `ON DELETE CASCADE` 가 사라지면 chunk DELETE 시 + embedding_records 가 자동 정리 안 됨. `put_chunks`(DELETE-then-INSERT) + purge 경로 + (`purge_orphan_at_workspace_path` / `purge_deleted_workspace_path`)에 **명시 + `DELETE FROM embedding_records WHERE chunk_id IN (...)`**(원본 + `{id}#alias`) 추가. V003 의 + `chunks_bd_tombstone_embeddings` BEFORE-DELETE trigger 는 FK 제거 후 오히려 tombstone 을 보존하므로, + 명시 DELETE 와 함께 정책 일관성 확인(tombstone 누적 시 GC 는 P+ 로드맵). + +3. **filter_chunks sentinel strip (검색 차단)** — `filter_chunks`(filters.rs:81)가 LanceDB 후보를 + `embedding_records er JOIN chunks c ON c.chunk_id = er.chunk_id WHERE er.status='committed'` 로 + 필터한다. sentinel chunk_id 는 chunks 에 JOIN 안 돼 **버려짐** → VectorRetriever 의 strip(§3.3) + 이전에 이미 탈락. 따라서 filter_chunks 도 candidate 의 sentinel 을 **원본으로 strip 해 JOIN** + (committed 통과)하도록 수정. 원본 chunk 가 committed 면 sentinel 후보도 통과시킴. + +> **PoC 근거**: 별칭-문서(별칭 순수 벡터 근사)로 영어 설명형이 rank 7~30 으로 잡힘(concat 은 본문 +> 희석으로 미회복). golden 의 특정 영어 표현은 무관 영어 코드 문서 경쟁으로 경계선 — 별도 벡터 정식 +> 구현 후 golden variants 로 회복 정도 측정. (한국어 설명형은 concat·별도 둘 다 회복.) + ### 3.4 격리 / 회귀 안전 - body 벡터(chunk.text 임베딩) **불변** → 기존 명사형/본문 dense 매칭 회귀 0(concat 과 달리). @@ -87,7 +117,10 @@ ingest_one_asset (embed + upsert): - 별칭 dense 는 additive(별도 벡터). `try_skip_unchanged` 의 기존 5버전 판단 무변경(별칭 부재가 자동 재색인 트리거 안 함). 재생성은 `--force-reingest`. -- embed_aliases flag 토글은 임베딩 정책 변경이나 별도 벡터라 body 임베딩 version 불변. wire 무변경(flag off). +- embed_aliases flag 토글은 임베딩 정책 변경이나 별도 벡터라 body 임베딩 version 불변. flag off 면 wire 무변경. +- **§3.5-1 의 embedding_records FK 제거(V0XX)는 breaking schema** → CLAUDE.md §Release 트리거: 워크스페이스 + `version` bump + 새 release cut + dogfood evidence. 기존 release binary 는 새 embedding_records 스키마와 + 호환되나(FK 만 제거, 컬럼 동일), migration 자동 적용. wire schema 자체는 불변(search_hit.v1 그대로). ## 5. 측정 (§4.6) -- 2.49.1 From 483b1ec06b2b34e1af485e4ecf120d6793342ba7 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 30 May 2026 13:41:20 +0000 Subject: [PATCH 21/27] =?UTF-8?q?feat(store):=20V011=20embedding=5Frecords?= =?UTF-8?q?=20FK=20=EC=A0=9C=EA=B1=B0=20+=20CASCADE=20=EB=8C=80=EC=B2=B4?= =?UTF-8?q?=20=EB=AA=85=EC=8B=9C=20DELETE=20(sentinel=20=EB=B3=84=EC=B9=AD?= =?UTF-8?q?=20=EB=B2=A1=ED=84=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 별칭 dense 벡터를 sentinel chunk_id({orig}#alias)로 색인하려면 chunks 에 없는 chunk_id 가 embedding_records 에 들어가야 한다. V001 의 chunk_id REFERENCES chunks ON DELETE CASCADE FK 가 이를 SQLite 787 로 막으므로 테이블을 FK 없이 재생성한다. status/vector_committed(V003) + 3개 인덱스 보존, chunks_bd_tombstone_embeddings trigger 무수정. DROP→RENAME 시 dangling trigger 재파싱을 피하려 legacy_alter_table=ON. 사라진 CASCADE 는 put_chunks + purge 두 경로(purge_orphan_at_workspace_path, purge_deleted_workspace_path)의 명시 DELETE 로 대체 — chunks 삭제 직전 원본 + {id}#alias sentinel embedding_records 를 함께 정리. corpus_revision baseline 2→3. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/kebab-store-sqlite/src/documents.rs | 12 ++ crates/kebab-store-sqlite/src/store.rs | 31 +++- .../tests/corpus_revision.rs | 18 +- .../tests/embedding_records_fk.rs | 164 ++++++++++++++++++ .../V011__drop_embedding_records_fk.sql | 41 +++++ 5 files changed, 254 insertions(+), 12 deletions(-) create mode 100644 crates/kebab-store-sqlite/tests/embedding_records_fk.rs create mode 100644 migrations/V011__drop_embedding_records_fk.sql diff --git a/crates/kebab-store-sqlite/src/documents.rs b/crates/kebab-store-sqlite/src/documents.rs index b1cc3de..b8a964c 100644 --- a/crates/kebab-store-sqlite/src/documents.rs +++ b/crates/kebab-store-sqlite/src/documents.rs @@ -98,6 +98,18 @@ impl kebab_core::DocumentStore for SqliteStore { .context("format chunk created_at")?; let mut conn = self.lock_conn(); let tx = conn.transaction().map_err(StoreError::from)?; + // CASCADE 제거(V011) 대체: 이 doc 의 chunk 임베딩 레코드를 명시 정리. + // 원본 + sentinel({id}#alias) 둘 다. 별칭 dense 벡터(sentinel chunk_id)는 + // chunks FK 가 없어 CASCADE 로 자동 정리되지 않으므로 여기서 직접 지운다. + // chunks 행이 살아있는 동안(아래 DELETE FROM chunks 직전) 실행해야 서브쿼리가 + // chunk_id 를 본다. 설계 spec 2026-05-30-dense-alias-vectors-design.md §3.5-2. + tx.execute( + "DELETE FROM embedding_records WHERE chunk_id IN \ + (SELECT chunk_id FROM chunks WHERE doc_id = ?1 \ + UNION SELECT chunk_id || '#alias' FROM chunks WHERE doc_id = ?1)", + params![doc.0], + ) + .map_err(StoreError::from)?; tx.execute("DELETE FROM chunks WHERE doc_id = ?", params![doc.0]) .map_err(StoreError::from)?; let mut stmt = tx diff --git a/crates/kebab-store-sqlite/src/store.rs b/crates/kebab-store-sqlite/src/store.rs index 0948470..1837557 100644 --- a/crates/kebab-store-sqlite/src/store.rs +++ b/crates/kebab-store-sqlite/src/store.rs @@ -627,7 +627,20 @@ pub(crate) fn purge_orphan_at_workspace_path( return Ok(()); }; - // documents → blocks / chunks / embedding_records via CASCADE. + // CASCADE 제거(V011) 대체: 이 asset 의 문서 chunk 임베딩 레코드를 명시 정리. + // 원본 + sentinel({id}#alias) 둘 다. 별칭 dense 벡터는 chunks FK 가 없어 + // documents→chunks CASCADE 로 자동 정리되지 않으므로 chunks 가 살아있는 동안 + // 직접 지운다. 설계 spec 2026-05-30-dense-alias-vectors-design.md §3.5-2. + conn.execute( + "DELETE FROM embedding_records WHERE chunk_id IN \ + (SELECT chunk_id FROM chunks WHERE doc_id IN \ + (SELECT doc_id FROM documents WHERE asset_id = ?1) \ + UNION SELECT chunk_id || '#alias' FROM chunks WHERE doc_id IN \ + (SELECT doc_id FROM documents WHERE asset_id = ?1))", + params![stale_asset_id], + ) + .map_err(StoreError::from)?; + // documents → blocks / chunks via CASCADE. conn.execute( "DELETE FROM documents WHERE asset_id = ?", params![stale_asset_id], @@ -706,8 +719,20 @@ pub fn purge_deleted_workspace_path( .map_err(StoreError::from)?; drop(stmt); - // 2. DELETE the document row (CASCADE clears blocks / chunks / - // embedding_records via the FK constraints in V001). + // 1b. CASCADE 제거(V011) 대체: chunk 임베딩 레코드를 명시 정리(원본 + + // sentinel {id}#alias). 별칭 dense 벡터는 chunks FK 가 없어 + // documents→chunks CASCADE 로 자동 정리되지 않는다. chunks 가 + // 살아있는 동안(2번 DELETE 직전) 실행. spec §3.5-2. + conn.execute( + "DELETE FROM embedding_records WHERE chunk_id IN \ + (SELECT chunk_id FROM chunks WHERE doc_id = ?1 \ + UNION SELECT chunk_id || '#alias' FROM chunks WHERE doc_id = ?1)", + rusqlite::params![doc_id], + ) + .map_err(StoreError::from)?; + + // 2. DELETE the document row (CASCADE clears blocks / chunks via the + // FK constraints in V001; embedding_records handled above). conn.execute( "DELETE FROM documents WHERE doc_id = ?", rusqlite::params![doc_id], diff --git a/crates/kebab-store-sqlite/tests/corpus_revision.rs b/crates/kebab-store-sqlite/tests/corpus_revision.rs index 488d81a..1ac5db9 100644 --- a/crates/kebab-store-sqlite/tests/corpus_revision.rs +++ b/crates/kebab-store-sqlite/tests/corpus_revision.rs @@ -20,26 +20,26 @@ fn open_store(tmp: &TempDir) -> SqliteStore { store } -/// Fresh store baseline: V004 seeds `corpus_revision = 0`, then both V009 -/// and V010 migrations bump it by one each to invalidate any stale LRU -/// cache — so a fresh store after `run_migrations()` reads back as `2`. +/// Fresh store baseline: V004 seeds `corpus_revision = 0`, then V009, +/// V010, and V011 migrations bump it by one each to invalidate any stale +/// LRU cache — so a fresh store after `run_migrations()` reads back as `3`. #[test] fn fresh_store_starts_at_post_migration_baseline() { let tmp = TempDir::new().unwrap(); let store = open_store(&tmp); - assert_eq!(store.corpus_revision(), 2); + assert_eq!(store.corpus_revision(), 3); } /// Each `bump_corpus_revision` returns the new value monotonically -/// from the post-migration baseline (V009 + V010 → 2). +/// from the post-migration baseline (V009 + V010 + V011 → 3). #[test] fn bump_increments_monotonically() { let tmp = TempDir::new().unwrap(); let store = open_store(&tmp); - assert_eq!(store.bump_corpus_revision().unwrap(), 3); assert_eq!(store.bump_corpus_revision().unwrap(), 4); assert_eq!(store.bump_corpus_revision().unwrap(), 5); - assert_eq!(store.corpus_revision(), 5); + assert_eq!(store.bump_corpus_revision().unwrap(), 6); + assert_eq!(store.corpus_revision(), 6); } /// `corpus_revision` survives a store re-open (persisted in SQLite). @@ -52,6 +52,6 @@ fn revision_persists_across_reopen() { store.bump_corpus_revision().unwrap(); } // store dropped — file closed let store = open_store(&tmp); - assert_eq!(store.corpus_revision(), 4); - assert_eq!(store.bump_corpus_revision().unwrap(), 5); + assert_eq!(store.corpus_revision(), 5); + assert_eq!(store.bump_corpus_revision().unwrap(), 6); } diff --git a/crates/kebab-store-sqlite/tests/embedding_records_fk.rs b/crates/kebab-store-sqlite/tests/embedding_records_fk.rs new file mode 100644 index 0000000..d247a60 --- /dev/null +++ b/crates/kebab-store-sqlite/tests/embedding_records_fk.rs @@ -0,0 +1,164 @@ +//! V011: `embedding_records.chunk_id` FK 제거 + CASCADE 대체 명시 DELETE. +//! +//! 별칭 dense 벡터는 sentinel chunk_id(`{orig}#alias`)로 색인되는데, 이 id 는 +//! `chunks` 에 행이 없다. V001 의 `chunk_id REFERENCES chunks ON DELETE CASCADE` +//! FK 가 살아 있으면 sentinel `embedding_records` INSERT 가 SQLite 787 로 실패한다. +//! V011 이 FK 를 제거하고, 사라진 CASCADE 는 `put_chunks` / purge 경로의 명시 +//! DELETE 로 대체한다(설계 spec 2026-05-30-dense-alias-vectors-design.md §3.5). + +use kebab_config::Config; +use kebab_core::{ + Chunk, ChunkId, ChunkerVersion, DocumentId, DocumentStore, +}; +use kebab_store_sqlite::{EmbeddingRecordRow, SqliteStore}; +use rusqlite::params; +use tempfile::TempDir; +use time::OffsetDateTime; + +fn open_store(tmp: &TempDir) -> SqliteStore { + let mut c = Config::defaults(); + c.storage.data_dir = tmp.path().to_string_lossy().into_owned(); + let store = SqliteStore::open(&c).unwrap(); + store.run_migrations().unwrap(); + store +} + +const DOC_ID: &str = "fedcba9876543210fedcba9876543210"; + +/// Seed asset + document + one chunk so the *original* chunk_id has a +/// `chunks` row. The sentinel `{chunk_id}#alias` deliberately gets NO +/// chunks row — that is the case V011 must allow. +fn seed_chunk(store: &SqliteStore, chunk_id: &str) { + let conn = store.read_conn(); + conn.execute( + "INSERT INTO assets ( + asset_id, source_uri, workspace_path, media_type, byte_len, + checksum, storage_kind, storage_path, discovered_at + ) VALUES (?, ?, ?, '{}', 0, 'deadbeefdeadbeefdeadbeefdeadbeef', + 'reference', '/tmp/x', '1970-01-01T00:00:00Z')", + params!["0123456789abcdef0123456789abcdef", "file:///tmp/x", "x.md"], + ) + .unwrap(); + conn.execute( + "INSERT INTO documents ( + doc_id, asset_id, workspace_path, title, lang, source_type, + trust_level, parser_version, doc_version, schema_version, + metadata_json, provenance_json, created_at, updated_at + ) VALUES (?, ?, 'x.md', NULL, 'en', 'markdown', 'primary', 'v1', 1, 1, + '{}', '{}', '1970-01-01T00:00:00Z', '1970-01-01T00:00:00Z')", + params![DOC_ID, "0123456789abcdef0123456789abcdef"], + ) + .unwrap(); + conn.execute( + "INSERT INTO chunks ( + chunk_id, doc_id, text, heading_path_json, section_label, + source_spans_json, token_estimate, chunker_version, + policy_hash, block_ids_json, created_at + ) VALUES (?, ?, 'hi', '[]', NULL, '[]', 1, 'v1', 'h', '[]', + '1970-01-01T00:00:00Z')", + params![chunk_id, DOC_ID], + ) + .unwrap(); +} + +fn embed_row(embedding_id: &str, chunk_id: &str) -> EmbeddingRecordRow { + EmbeddingRecordRow { + embedding_id: embedding_id.to_string(), + chunk_id: chunk_id.to_string(), + model_id: "m".to_string(), + model_version: "v1".to_string(), + dimensions: 4, + lance_table: "t".to_string(), + created_at: OffsetDateTime::UNIX_EPOCH, + } +} + +fn embed_count(store: &SqliteStore, chunk_id: &str) -> i64 { + let conn = store.read_conn(); + conn.query_row( + "SELECT COUNT(*) FROM embedding_records WHERE chunk_id = ?", + params![chunk_id], + |r| r.get::<_, i64>(0), + ) + .unwrap() +} + +/// V011 후 sentinel chunk_id(`chunks` 에 없는 id)로 `embedding_records` 를 +/// INSERT 해도 FK 위반 없이 성공해야 한다. +#[test] +fn sentinel_embedding_record_insert_succeeds_without_fk() { + let tmp = TempDir::new().unwrap(); + let store = open_store(&tmp); + let c1 = "11111111111111111111111111111111"; + seed_chunk(&store, c1); + + // sentinel: chunks 에 행이 없는 `{c1}#alias`. + let sentinel = format!("{c1}{}", kebab_core::ALIAS_SUFFIX); + let result = + store.put_embedding_records_pending(&[embed_row("e_sentinel_0000000000000000000000", &sentinel)]); + assert!( + result.is_ok(), + "sentinel embedding_records insert must not violate a chunks FK after V011: {result:?}" + ); + assert_eq!( + embed_count(&store, &sentinel), + 1, + "sentinel embedding row must be persisted" + ); +} + +/// `put_chunks` 재호출(재인제스트) 시, 명시 DELETE 가 그 doc 의 원본 + sentinel +/// `embedding_records` 를 모두 정리해 orphan 0 이 되어야 한다(CASCADE 대체). +#[test] +fn put_chunks_cleans_original_and_sentinel_embeddings() { + let tmp = TempDir::new().unwrap(); + let store = open_store(&tmp); + let c1 = "11111111111111111111111111111111"; + seed_chunk(&store, c1); + let sentinel = format!("{c1}{}", kebab_core::ALIAS_SUFFIX); + + // 원본 + sentinel embedding_records 색인 (committed). + store + .put_embedding_records_pending(&[ + embed_row("e_orig_000000000000000000000000000", c1), + embed_row("e_sentinel_0000000000000000000000", &sentinel), + ]) + .unwrap(); + store + .mark_embedding_records_committed(&[ + "e_orig_000000000000000000000000000".to_string(), + "e_sentinel_0000000000000000000000".to_string(), + ]) + .unwrap(); + assert_eq!(embed_count(&store, c1), 1); + assert_eq!(embed_count(&store, &sentinel), 1); + + // 재인제스트: 같은 chunk 를 put_chunks 로 다시 쓴다. 명시 DELETE 가 + // 원본 + sentinel embedding_records 를 정리한 뒤 chunk 재삽입. + let doc_id = DocumentId(DOC_ID.to_string()); + let chunk = Chunk { + chunk_id: ChunkId(c1.to_string()), + doc_id: doc_id.clone(), + block_ids: Vec::new(), + text: "hi".to_string(), + heading_path: Vec::new(), + source_spans: Vec::new(), + token_estimate: 1, + chunker_version: ChunkerVersion("v1".to_string()), + policy_hash: "h".to_string(), + tokenized_korean_text: None, + aliases: None, + }; + store.put_chunks(&doc_id, std::slice::from_ref(&chunk)).unwrap(); + + assert_eq!( + embed_count(&store, c1), + 0, + "original embedding_records must be cleaned on re-ingest (CASCADE replacement)" + ); + assert_eq!( + embed_count(&store, &sentinel), + 0, + "sentinel embedding_records must be cleaned on re-ingest (no chunks FK → explicit DELETE)" + ); +} diff --git a/migrations/V011__drop_embedding_records_fk.sql b/migrations/V011__drop_embedding_records_fk.sql new file mode 100644 index 0000000..9f156f2 --- /dev/null +++ b/migrations/V011__drop_embedding_records_fk.sql @@ -0,0 +1,41 @@ +-- V011__drop_embedding_records_fk.sql — embedding_records.chunk_id FK 제거. +-- sentinel chunk_id({orig}#alias, chunks 에 없는 id) 벡터를 허용하기 위함 +-- (설계 spec 2026-05-30-dense-alias-vectors-design.md §3.5-1). SQLite 는 ALTER +-- 로 FK 제거 불가 → 테이블 재생성. status/vector_committed(V003) + 인덱스 보존. +-- CASCADE 제거분은 put_chunks/purge 의 명시 DELETE 로 대체(§3.5-2). +PRAGMA foreign_keys=OFF; +-- legacy_alter_table=ON: DROP embedding_records 직후 V003 의 +-- chunks_bd_tombstone_embeddings trigger 가 (아직 존재하는 chunks 위에서) +-- 사라진 embedding_records 를 참조하는 dangling 상태가 된다. 이후 RENAME 이 +-- 기본(legacy off) 모드면 스키마 전체를 재파싱하며 그 trigger 에서 +-- "no such table: embedding_records" 로 실패한다. legacy 모드는 RENAME 시 +-- trigger/view 본문 재파싱을 생략하므로 trigger 를 건드리지 않고 통과한다 +-- (SQLite ALTER TABLE 문서의 권장 table-redefinition 절차). +PRAGMA legacy_alter_table=ON; + +CREATE TABLE embedding_records_new ( + embedding_id TEXT PRIMARY KEY, + chunk_id TEXT NOT NULL, -- FK 제거 (was REFERENCES chunks ON DELETE CASCADE) + model_id TEXT NOT NULL, + model_version TEXT NOT NULL, + dimensions INTEGER NOT NULL, + lance_table TEXT NOT NULL, + created_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + vector_committed INTEGER NOT NULL DEFAULT 0, + UNIQUE(chunk_id, model_id, model_version, dimensions) +); +INSERT INTO embedding_records_new + SELECT embedding_id, chunk_id, model_id, model_version, dimensions, + lance_table, created_at, status, vector_committed + FROM embedding_records; +DROP TABLE embedding_records; +ALTER TABLE embedding_records_new RENAME TO embedding_records; +CREATE INDEX idx_embed_chunk ON embedding_records(chunk_id); +CREATE INDEX idx_embed_model ON embedding_records(model_id, model_version, dimensions); +CREATE INDEX idx_embed_status ON embedding_records(status); + +PRAGMA legacy_alter_table=OFF; +PRAGMA foreign_keys=ON; + +UPDATE kv SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT) WHERE key = 'corpus_revision'; -- 2.49.1 From f3587b7143b7c52d600b9d8df84a0ab9645df800 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 30 May 2026 13:41:28 +0000 Subject: [PATCH 22/27] =?UTF-8?q?feat(store):=20filter=5Fchunks=20sentinel?= =?UTF-8?q?=20=EB=B3=84=EC=B9=AD=20candidate=20strip=20(committed=20?= =?UTF-8?q?=ED=86=B5=EA=B3=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LanceDB 후보의 sentinel chunk_id({orig}#alias)는 chunks JOIN 에서 탈락해 VectorRetriever strip 이전에 사라진다. candidate 를 kebab_core::strip_alias_suffix 로 원본 chunk_id 로 strip 해 IN-list/JOIN 에 넣어(committed 판정은 원본 body chunk 기준) 통과시키되, 반환은 입력 candidate 형태(sentinel 유지) — VectorRetriever 가 그 sentinel 을 받아 strip+dedup 한다. SQL replace 대신 (b) Rust strip 채택(명확). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/kebab-store-sqlite/src/filters.rs | 71 ++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 5 deletions(-) diff --git a/crates/kebab-store-sqlite/src/filters.rs b/crates/kebab-store-sqlite/src/filters.rs index f2e6648..ff3b899 100644 --- a/crates/kebab-store-sqlite/src/filters.rs +++ b/crates/kebab-store-sqlite/src/filters.rs @@ -59,15 +59,25 @@ impl SqliteStore { return Ok(Vec::new()); } - // Deduplicate the IN-list so a pathological caller passing - // `[c1, c1, c1]` doesn't blow the SQL placeholder count. + // sentinel 별칭 candidate({orig}#alias)는 chunks 에 원본 chunk 가 없어 + // (chunks JOIN 실패) committed 판정을 못 받는다. 후보를 원본 chunk_id 로 + // strip 해 IN-list/JOIN 에 넣고(committed 판정은 원본 body chunk 기준), + // 통과 여부는 원본 기준으로 매핑하되 반환은 입력 candidate 형태(sentinel + // 유지) — VectorRetriever(Task 4)가 그 sentinel 을 받아 strip+dedup 한다. + // 설계 spec 2026-05-30-dense-alias-vectors-design.md §3.5-3. + // + // Deduplicate the IN-list (on the stripped original) so a + // pathological caller passing `[c1, c1, c1]` — or a body+alias + // pair `[c1, c1#alias]` that strips to the same original — + // doesn't blow the SQL placeholder count. let unique_ids: Vec = { let mut seen = HashSet::new(); chunk_ids .iter() .filter_map(|c| { - if seen.insert(c.0.as_str()) { - Some(c.0.clone()) + let orig = kebab_core::strip_alias_suffix(c.0.as_str()); + if seen.insert(orig.to_string()) { + Some(orig.to_string()) } else { None } @@ -242,7 +252,11 @@ impl SqliteStore { let mut out = Vec::with_capacity(chunk_ids.len()); for cand in chunk_ids { - let workspace_path = match allowed.get(&cand.0) { + // committed 판정은 원본 chunk 기준(allowed 는 원본 chunk_id 로 키됨). + // candidate 가 sentinel 이면 strip 한 원본으로 조회하고, 통과 시 + // 입력 candidate 형태 그대로 반환한다. + let orig = kebab_core::strip_alias_suffix(cand.0.as_str()); + let workspace_path = match allowed.get(orig) { Some(p) => p, None => continue, }; @@ -558,6 +572,53 @@ mod tests { assert_eq!(out, vec![cid(c1)]); } + #[test] + fn filter_chunks_sentinel_alias_candidate_passes_via_original() { + // 별칭 dense 벡터 sentinel candidate({orig}#alias)는 원본 chunk 가 + // committed 면 통과해야 한다(strip 해 JOIN). 반환은 입력 candidate + // 형태 그대로(sentinel 유지) — VectorRetriever 가 strip+dedup. + let tmp = TempDir::new().unwrap(); + let store = open_store(&tmp); + let c1 = "11111111111111111111111111111111"; + seed_committed( + &store, + c1, + "d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1", + "a.md", + "en", + &[], + "primary", + ); + + // sentinel candidate 단독 → 원본 c1 committed 라 통과, sentinel 형태 유지. + let sentinel = format!("{c1}{}", kebab_core::ALIAS_SUFFIX); + let out = store + .filter_chunks(&[cid(&sentinel)], &SearchFilters::default()) + .unwrap(); + assert_eq!( + out, + vec![cid(&sentinel)], + "sentinel candidate must pass via its committed original and be returned verbatim" + ); + + // body + sentinel 둘 다 입력 → 둘 다 통과, 입력 순서 보존. + let out = store + .filter_chunks(&[cid(c1), cid(&sentinel)], &SearchFilters::default()) + .unwrap(); + assert_eq!(out, vec![cid(c1), cid(&sentinel)]); + + // 원본이 미존재(uncommitted)면 sentinel 도 탈락. + let orphan_sentinel = + format!("99999999999999999999999999999999{}", kebab_core::ALIAS_SUFFIX); + let out = store + .filter_chunks(&[cid(&orphan_sentinel)], &SearchFilters::default()) + .unwrap(); + assert!( + out.is_empty(), + "sentinel whose original is not committed must be dropped" + ); + } + #[test] fn filter_chunks_tags_any_lang_trust_path_glob() { let tmp = TempDir::new().unwrap(); -- 2.49.1 From 0282a81c67b42f8f5cfc6bda8b499952d06041a9 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 30 May 2026 14:02:46 +0000 Subject: [PATCH 23/27] =?UTF-8?q?fix(store):=20CASCADE=20=EB=8C=80?= =?UTF-8?q?=EC=B2=B4=204=EB=B2=88=EC=A7=B8=20=EA=B2=BD=EB=A1=9C=20+=20V011?= =?UTF-8?q?=20CHECK=20=EB=B3=B5=EC=9B=90=20(Task=204.5=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 MAJOR: purge_document_at_workspace_path_except_doc_id(parser-bump 경로)에 원본+sentinel embedding_records 명시 DELETE 누락 → tombstone 누적. 추가 + 회귀 테스트. MINOR: V011 status CHECK(pending/committed/tombstone) 복원. NIT: foreign_keys PRAGMA no-op 주석. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/kebab-store-sqlite/src/store.rs | 14 ++++++ .../tests/embedding_records_fk.rs | 45 +++++++++++++++++++ .../V011__drop_embedding_records_fk.sql | 7 ++- 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/crates/kebab-store-sqlite/src/store.rs b/crates/kebab-store-sqlite/src/store.rs index 1837557..ebebf42 100644 --- a/crates/kebab-store-sqlite/src/store.rs +++ b/crates/kebab-store-sqlite/src/store.rs @@ -570,6 +570,20 @@ impl SqliteStore { keep_doc_id: &str, ) -> Result<()> { let conn = self.lock_conn(); + // CASCADE 제거(V011) 대체: documents→chunks CASCADE 가 chunks 를 지우기 전에 + // 원본 + sentinel({id}#alias) embedding_records 를 명시 정리. 별칭 dense + // 벡터는 chunks FK 가 없어 자동 정리되지 않으므로 chunks 가 살아있는 동안 + // 직접 지운다(안 하면 tombstone trigger 가 남긴 행이 누적). 설계 spec + // 2026-05-30-dense-alias-vectors-design.md §3.5-2. (Task 4.5 리뷰 MAJOR.) + conn.execute( + "DELETE FROM embedding_records WHERE chunk_id IN \ + (SELECT chunk_id FROM chunks WHERE doc_id IN \ + (SELECT doc_id FROM documents WHERE workspace_path = ?1 AND doc_id != ?2) \ + UNION SELECT chunk_id || '#alias' FROM chunks WHERE doc_id IN \ + (SELECT doc_id FROM documents WHERE workspace_path = ?1 AND doc_id != ?2))", + params![workspace_path, keep_doc_id], + ) + .map_err(StoreError::from)?; conn.execute( "DELETE FROM documents WHERE workspace_path = ?1 AND doc_id != ?2", params![workspace_path, keep_doc_id], diff --git a/crates/kebab-store-sqlite/tests/embedding_records_fk.rs b/crates/kebab-store-sqlite/tests/embedding_records_fk.rs index d247a60..a739551 100644 --- a/crates/kebab-store-sqlite/tests/embedding_records_fk.rs +++ b/crates/kebab-store-sqlite/tests/embedding_records_fk.rs @@ -162,3 +162,48 @@ fn put_chunks_cleans_original_and_sentinel_embeddings() { "sentinel embedding_records must be cleaned on re-ingest (no chunks FK → explicit DELETE)" ); } + +/// Task 4.5 리뷰 MAJOR: `purge_document_at_workspace_path_except_doc_id` +/// (parser-bump 재인제스트 경로)도 원본 + sentinel embedding_records 를 +/// 명시 DELETE 로 정리해 orphan 0 이어야 한다. (이 경로 누락 시 tombstone 누적.) +#[test] +fn purge_except_doc_id_cleans_original_and_sentinel_embeddings() { + let tmp = TempDir::new().unwrap(); + let store = open_store(&tmp); + let c1 = "11111111111111111111111111111111"; + seed_chunk(&store, c1); // doc DOC_ID @ workspace 'x.md' + let sentinel = format!("{c1}{}", kebab_core::ALIAS_SUFFIX); + + store + .put_embedding_records_pending(&[ + embed_row("e_orig_000000000000000000000000000", c1), + embed_row("e_sentinel_0000000000000000000000", &sentinel), + ]) + .unwrap(); + store + .mark_embedding_records_committed(&[ + "e_orig_000000000000000000000000000".to_string(), + "e_sentinel_0000000000000000000000".to_string(), + ]) + .unwrap(); + assert_eq!(embed_count(&store, c1), 1); + assert_eq!(embed_count(&store, &sentinel), 1); + + // workspace 'x.md' 에서 DOC_ID(=현재 문서) 외 문서만 보존 → DOC_ID 가 + // 삭제 대상(parser-bump: 같은 path 의 옛 doc_id 정리). keep_doc_id 를 + // DOC_ID 와 다른 값으로 주면 DOC_ID 문서 + 그 chunk embedding 이 정리돼야. + store + .purge_document_at_workspace_path_except_doc_id("x.md", "0000000000000000000000000000ffff") + .unwrap(); + + assert_eq!( + embed_count(&store, c1), + 0, + "purge_except_doc_id: 원본 embedding_records 정리 (CASCADE 대체)" + ); + assert_eq!( + embed_count(&store, &sentinel), + 0, + "purge_except_doc_id: sentinel embedding_records 정리 (chunks FK 없음 → 명시 DELETE)" + ); +} diff --git a/migrations/V011__drop_embedding_records_fk.sql b/migrations/V011__drop_embedding_records_fk.sql index 9f156f2..823f222 100644 --- a/migrations/V011__drop_embedding_records_fk.sql +++ b/migrations/V011__drop_embedding_records_fk.sql @@ -3,6 +3,10 @@ -- (설계 spec 2026-05-30-dense-alias-vectors-design.md §3.5-1). SQLite 는 ALTER -- 로 FK 제거 불가 → 테이블 재생성. status/vector_committed(V003) + 인덱스 보존. -- CASCADE 제거분은 put_chunks/purge 의 명시 DELETE 로 대체(§3.5-2). +-- NOTE: PRAGMA foreign_keys 는 refinery 가 마이그레이션을 트랜잭션으로 감싸므로 +-- 트랜잭션 내에서 no-op(SQLite: "FK enforcement may only be changed when no +-- transaction is pending"). 실제 안전장치는 아래 legacy_alter_table — trigger +-- 재파싱 회피가 본 마이그레이션의 핵심 보호다. (Task 4.5 리뷰 NIT.) PRAGMA foreign_keys=OFF; -- legacy_alter_table=ON: DROP embedding_records 직후 V003 의 -- chunks_bd_tombstone_embeddings trigger 가 (아직 존재하는 chunks 위에서) @@ -21,7 +25,8 @@ CREATE TABLE embedding_records_new ( dimensions INTEGER NOT NULL, lance_table TEXT NOT NULL, created_at TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','committed','tombstone')), -- V003 와 동일 무결성 가드 보존 vector_committed INTEGER NOT NULL DEFAULT 0, UNIQUE(chunk_id, model_id, model_version, dimensions) ); -- 2.49.1 From a8fd76499c55a007aadce148c3b427fe8f73242c Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 31 May 2026 08:24:04 +0000 Subject: [PATCH 24/27] =?UTF-8?q?feat(expansion):=20doc-side=20expansion?= =?UTF-8?q?=20=EB=B3=84=EC=B9=AD=20=EA=B0=9C=EB=B3=84=20dense=20=EB=B2=A1?= =?UTF-8?q?=ED=84=B0=20+=20=ED=8C=8C=EC=83=9D=EB=AC=BC=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C(V012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 별칭을 줄별 개별 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) --- Cargo.lock | 44 ++-- Cargo.toml | 2 +- HANDOFF.md | 1 + README.md | 5 + crates/kebab-app/src/derivation_payload.rs | 61 +++++ crates/kebab-app/src/expansion.rs | 74 ++++++ crates/kebab-app/src/lib.rs | 227 +++++++++++++++--- crates/kebab-app/tests/search_lexical.rs | 7 +- crates/kebab-core/src/derivation.rs | 110 +++++++++ crates/kebab-core/src/ids.rs | 18 +- crates/kebab-core/src/lib.rs | 2 + .../src/derivation_cache.rs | 192 +++++++++++++++ crates/kebab-store-sqlite/src/lib.rs | 1 + docs/ARCHITECTURE.md | 10 +- .../2026-05-31-namu-wiki-alias-cache-study.md | 108 +++++++++ .../2026-05-31-derivation-cache-design.md | 155 ++++++++++++ migrations/V012__derivation_cache.sql | 22 ++ tasks/HOTFIXES.md | 32 +++ 18 files changed, 1000 insertions(+), 71 deletions(-) create mode 100644 crates/kebab-app/src/derivation_payload.rs create mode 100644 crates/kebab-core/src/derivation.rs create mode 100644 crates/kebab-store-sqlite/src/derivation_cache.rs create mode 100644 docs/superpowers/handoffs/2026-05-31-namu-wiki-alias-cache-study.md create mode 100644 docs/superpowers/specs/2026-05-31-derivation-cache-design.md create mode 100644 migrations/V012__derivation_cache.sql 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 + 도그푸딩 전부 완료. -- 2.49.1 From e9b520216e2b33c7d6608fb0a6a4a7af9aed269a Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 31 May 2026 09:14:34 +0000 Subject: [PATCH 25/27] =?UTF-8?q?fix(expansion):=20per-alias=20sentinel=20?= =?UTF-8?q?orphan=20cleanup=20+=20=EC=BA=90=EC=8B=9C=20=EA=B2=AC=EA=B3=A0?= =?UTF-8?q?=EC=84=B1=20(PR=20#195=20=EB=A6=AC=EB=B7=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MAJOR: 별칭 dense 벡터의 chunk_id 가 레거시 단일 `{id}#alias` 에서 줄별 `{id}#alias#0`, `#alias#1`, … 로 바뀌었으나 orphan cleanup 이 단일 sentinel 하나만 삭제해 `#alias#N` 벡터가 LanceDB / embedding_records 에 누수됐다. - kebab-app: `alias_sentinel_ids_to_delete` 헬퍼 추가(접근법 A) — 본문 + legacy `{id}#alias` + `{id}#alias#0`..`{id}#alias#{max-1}` 를 모두 delete-set 에 포함. max=expansion.max_aliases_per_chunk(= parse_aliases 의 하드 cap)와 일치. parser-bump / edited-asset / deleted-file 세 LanceDB cleanup 경로 모두 이 헬퍼를 사용. - kebab-store-sqlite: embedding_records 명시 DELETE 4 경로(put_chunks / purge_*_except_doc_id / purge_orphan_at_workspace_path / purge_deleted_workspace_path)를 정확 일치(`|| '#alias'`)에서 `{id}#alias%` 프리픽스 LIKE 로 전환. 본문 chunk_id 는 32자 hex 라 LIKE 와일드카드 없음. MINOR 1: alias 캐시 히트 시 비-UTF8 payload 를 미스로 강등(재생성 분기로) — embedding 경로의 decode-실패→미스 강등과 동작 일치. MINOR 2: embedding version_key 맨 앞에 kind 토큰("doc") 추가 — 임베더가 kind 별 프리픽스를 붙이므로 미래에 query 임베딩이 같은 캐시를 타도 충돌 방지. 회귀 테스트: - kebab-app: alias_sentinel_ids_to_delete 단위 테스트 2건. - kebab-store-sqlite: per-alias sentinel embedding_records 가 세 cleanup 경로 모두에서 사라지는지 핀하는 통합 테스트 3건. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/kebab-app/src/lib.rs | 149 +++++++++++++++--- crates/kebab-store-sqlite/src/documents.rs | 15 +- crates/kebab-store-sqlite/src/store.rs | 46 ++++-- .../tests/embedding_records_fk.rs | 131 +++++++++++++++ 4 files changed, 297 insertions(+), 44 deletions(-) diff --git a/crates/kebab-app/src/lib.rs b/crates/kebab-app/src/lib.rs index 320b7ff..c1143d5 100644 --- a/crates/kebab-app/src/lib.rs +++ b/crates/kebab-app/src/lib.rs @@ -1349,9 +1349,17 @@ fn ingest_one_asset( &chunk.text, &alias_version_key, ); - if let Some(payload) = app.sqlite.derivation_cache_get(&key)? { + // 히트 = 캐시에 있고 payload 가 정상 UTF-8 로 디코드되는 + // 경우만. 손상(비-UTF8) payload 는 미스로 강등해 재생성 + // 분기로 보낸다(embedding 경로의 decode-실패→미스 강등과 + // 동작 일치, 정확성 우선 §3.5). + let cached_aliases = app + .sqlite + .derivation_cache_get(&key)? + .and_then(|payload| String::from_utf8(payload).ok()); + if let Some(aliases) = cached_aliases { // 히트: 저장된 별칭(UTF-8) 재사용. LLM 호출 없음. - chunk.aliases = String::from_utf8(payload).ok(); + chunk.aliases = Some(aliases); alias_cache_hit += 1; alias_touch_keys.push(key); } else if crate::expansion::is_nav_boilerplate(chunk) { @@ -1411,10 +1419,15 @@ fn ingest_one_asset( 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}. + // derivation cache(§3.4): embedding version_key = + // {kind}|{model_id}|{model_version}|{dimensions}. // 본문 청크 + 별칭 문자열 양쪽이 같은 메커니즘(같은 text → 같은 캐시). + // kind 토큰("doc") 을 맨 앞에 둔다: 임베더가 kind 별 프리픽스 + // (Document=`passage:`, Query=`query:`)를 붙여 같은 text 라도 벡터가 + // 달라지므로, 미래에 query 임베딩이 같은 캐시를 타도 충돌하지 않도록 + // 방어적으로 분리(현재 ingest 는 Document 고정이라 live 버그 없음). let emb_version_key = - format!("{}|{}|{}", model_id.0, model_version.0, dimensions); + format!("doc|{}|{}|{}", model_id.0, model_version.0, dimensions); let mut emb_touch_keys: Vec = Vec::new(); // 본문 청크 text 로 캐시 조회 → 미스만 embed → 원래 순서로 합침. let body_texts: Vec<&str> = chunks.iter().map(|c| c.text.as_str()).collect(); @@ -1857,6 +1870,49 @@ fn record_image_analysis_failure( warning_notes.push(note); } +/// Expand a set of body `chunk_id`s into every per-alias sentinel +/// `chunk_id` that orphan cleanup must also delete. +/// +/// PR #195 review (MAJOR): alias dense vectors moved from a single +/// legacy sentinel `{orig}#alias` to per-line sentinels +/// `{orig}#alias#0`, `{orig}#alias#1`, … (one VectorRecord per alias +/// line). These sentinel chunk_ids never appear in SQLite `chunks`, so +/// they are absent from the stale-set the cleanup paths SELECT. Because +/// `delete_by_chunk_ids` matches on exact `chunk_id IN (...)` (not a +/// prefix), deleting only `{orig}#alias` leaked `{orig}#alias#N` rows +/// into LanceDB — stale aliases could still hit search. +/// +/// We reuse the existing exact-match delete infra (approach A): for each +/// body id emit `{id}#alias` (legacy, backward-compat) plus +/// `{id}#alias#0` .. `{id}#alias#{max-1}`. `max` is +/// `expansion.max_aliases_per_chunk`, which is the hard cap +/// `parse_aliases` enforces (it `break`s once `out.len() >= max`), so no +/// index ≥ max is ever produced at ingest time. Indices that were never +/// written are harmless no-ops in an `IN (...)` delete. +fn alias_sentinel_ids_to_delete( + body_ids: &[kebab_core::ChunkId], + max_aliases_per_chunk: usize, +) -> Vec { + let mut out = body_ids.to_vec(); + for id in body_ids { + // Legacy single sentinel (docs ingested before per-line split). + out.push(kebab_core::ChunkId(format!( + "{}{}", + id.0, + kebab_core::ALIAS_SUFFIX + ))); + for i in 0..max_aliases_per_chunk { + out.push(kebab_core::ChunkId(format!( + "{}{}#{}", + id.0, + kebab_core::ALIAS_SUFFIX, + i + ))); + } + } + out +} + /// v0.17.0 PR-B: parser-bump cascade. When a code extractor ships a /// new `PARSER_VERSION` (e.g. `code-c-v1` → `code-c-v2`), the same /// (workspace_path, asset_id) pair re-emerges with a fresh `doc_id`. @@ -1884,12 +1940,13 @@ fn purge_workspace_path_for_parser_bump(app: &App, asset: &RawAsset) -> anyhow:: if !stale.is_empty() { if let Some(vec_store) = app.vector().context("App::vector")? { use kebab_core::VectorStore as _; - // sentinel 별칭 벡터(`{id}#alias`)는 SQLite chunks 에 없어 stale 에 - // 안 잡힌다 → 명시적으로 함께 삭제(orphan 누적 방지). - let mut to_delete = stale.clone(); - to_delete.extend(stale.iter().map(|id| { - kebab_core::ChunkId(format!("{}{}", id.0, kebab_core::ALIAS_SUFFIX)) - })); + // per-alias sentinel 벡터(`{id}#alias#N`)는 SQLite chunks 에 없어 + // stale 에 안 잡힌다 → 본문 + 모든 별칭 sentinel 을 명시적으로 함께 + // 삭제(orphan 누적 방지, PR #195 MAJOR). + let to_delete = alias_sentinel_ids_to_delete( + &stale, + app.config.ingest.expansion.max_aliases_per_chunk, + ); vec_store .delete_by_chunk_ids(&to_delete) .context("VectorStore::delete_by_chunk_ids (parser-bump orphans)")?; @@ -1935,12 +1992,13 @@ fn purge_vector_orphans_for_workspace_path( return Ok(()); } use kebab_core::VectorStore as _; - // sentinel 별칭 벡터(`{id}#alias`)는 SQLite chunks 에 없어 stale 에 - // 안 잡힌다 → 명시적으로 함께 삭제(orphan 누적 방지). - let mut to_delete = stale.clone(); - to_delete.extend(stale.iter().map(|id| { - kebab_core::ChunkId(format!("{}{}", id.0, kebab_core::ALIAS_SUFFIX)) - })); + // per-alias sentinel 벡터(`{id}#alias#N`)는 SQLite chunks 에 없어 stale 에 + // 안 잡힌다 → 본문 + 모든 별칭 sentinel 을 명시적으로 함께 삭제(orphan + // 누적 방지, PR #195 MAJOR). + let to_delete = alias_sentinel_ids_to_delete( + &stale, + app.config.ingest.expansion.max_aliases_per_chunk, + ); vec_store .delete_by_chunk_ids(&to_delete) .context("VectorStore::delete_by_chunk_ids (orphan vector cleanup)")?; @@ -2042,12 +2100,13 @@ fn sweep_deleted_files( if let Some(vec) = vector_store { if !chunk_ids.is_empty() { use kebab_core::VectorStore as _; - // sentinel 별칭 벡터(`{id}#alias`)는 SQLite chunks 에 없어 - // chunk_ids 에 안 잡힌다 → 명시적으로 함께 삭제(orphan 누적 방지). - let mut to_delete = chunk_ids.clone(); - to_delete.extend(chunk_ids.iter().map(|id| { - kebab_core::ChunkId(format!("{}{}", id.0, kebab_core::ALIAS_SUFFIX)) - })); + // per-alias sentinel 벡터(`{id}#alias#N`)는 SQLite chunks 에 없어 + // chunk_ids 에 안 잡힌다 → 본문 + 모든 별칭 sentinel 을 명시적으로 + // 함께 삭제(orphan 누적 방지, PR #195 MAJOR). + let to_delete = alias_sentinel_ids_to_delete( + &chunk_ids, + app.config.ingest.expansion.max_aliases_per_chunk, + ); if let Err(e) = vec.delete_by_chunk_ids(&to_delete) { tracing::warn!( target: "kebab-app", @@ -3309,3 +3368,49 @@ fn check_kebabignore_match( .matched(source_path, source_path.is_dir()) .is_ignore() } + +#[cfg(test)] +mod orphan_cleanup_tests { + use super::alias_sentinel_ids_to_delete; + use kebab_core::ChunkId; + + /// PR #195 MAJOR: alias dense 벡터가 줄별 `{id}#alias#N` sentinel 로 색인되므로 + /// orphan cleanup 의 LanceDB delete-set 은 본문 + legacy `{id}#alias` + + /// `{id}#alias#0` .. `{id}#alias#{max-1}` 를 모두 포함해야 한다. 이전 코드는 + /// 단일 `{id}#alias` 만 넣어 per-line sentinel 을 LanceDB 에 누수시켰다. + #[test] + fn expands_body_legacy_and_per_alias_sentinels() { + let body = ChunkId("aabbccddeeff00112233445566778899".to_string()); + let max = 3; + let out = alias_sentinel_ids_to_delete(std::slice::from_ref(&body), max); + let ids: Vec<&str> = out.iter().map(|c| c.0.as_str()).collect(); + + assert!(ids.contains(&body.0.as_str()), "본문 chunk_id 포함"); + assert!( + ids.contains(&"aabbccddeeff00112233445566778899#alias"), + "하위호환 legacy 단일 sentinel 포함" + ); + for i in 0..max { + let expected = format!("aabbccddeeff00112233445566778899#alias#{i}"); + assert!( + ids.contains(&expected.as_str()), + "per-alias sentinel #{i} 포함 (max={max})" + ); + } + // body(1) + legacy(1) + per-alias(max) = max + 2. + assert_eq!(out.len(), max + 2, "정확히 max+2 개 id"); + // max 상한과 일치: #alias#{max} 는 절대 생성 안 함(parse_aliases 가 cap). + assert!( + !ids.contains(&"aabbccddeeff00112233445566778899#alias#3"), + "상한(max) 이상 인덱스는 생성하지 않음" + ); + } + + /// max=0 (확장 비활성 동등) 이면 per-alias sentinel 없이 본문 + legacy 만. + #[test] + fn zero_max_emits_body_and_legacy_only() { + let body = ChunkId("00000000000000000000000000000000".to_string()); + let out = alias_sentinel_ids_to_delete(std::slice::from_ref(&body), 0); + assert_eq!(out.len(), 2, "본문 + legacy sentinel 만"); + } +} diff --git a/crates/kebab-store-sqlite/src/documents.rs b/crates/kebab-store-sqlite/src/documents.rs index b8a964c..48cf0c1 100644 --- a/crates/kebab-store-sqlite/src/documents.rs +++ b/crates/kebab-store-sqlite/src/documents.rs @@ -99,14 +99,19 @@ impl kebab_core::DocumentStore for SqliteStore { let mut conn = self.lock_conn(); let tx = conn.transaction().map_err(StoreError::from)?; // CASCADE 제거(V011) 대체: 이 doc 의 chunk 임베딩 레코드를 명시 정리. - // 원본 + sentinel({id}#alias) 둘 다. 별칭 dense 벡터(sentinel chunk_id)는 - // chunks FK 가 없어 CASCADE 로 자동 정리되지 않으므로 여기서 직접 지운다. - // chunks 행이 살아있는 동안(아래 DELETE FROM chunks 직전) 실행해야 서브쿼리가 + // 원본 + per-alias sentinel({id}#alias#N) 모두. 별칭 dense 벡터는 줄별 + // sentinel chunk_id(`{orig}#alias#0`, `#alias#1`, …)로 색인되는데 chunks + // FK 가 없어 CASCADE 로 자동 정리되지 않으므로 여기서 직접 지운다. 정확 + // 일치(|| '#alias')는 per-line sentinel 을 놓치므로(PR #195 MAJOR) 본문 + // chunk_id 와 그 `{id}#alias%` 프리픽스를 LIKE 로 함께 매칭한다. chunks + // 행이 살아있는 동안(아래 DELETE FROM chunks 직전) 실행해야 서브쿼리가 // chunk_id 를 본다. 설계 spec 2026-05-30-dense-alias-vectors-design.md §3.5-2. tx.execute( "DELETE FROM embedding_records WHERE chunk_id IN \ - (SELECT chunk_id FROM chunks WHERE doc_id = ?1 \ - UNION SELECT chunk_id || '#alias' FROM chunks WHERE doc_id = ?1)", + (SELECT chunk_id FROM chunks WHERE doc_id = ?1) \ + OR EXISTS (SELECT 1 FROM chunks \ + WHERE chunks.doc_id = ?1 \ + AND embedding_records.chunk_id LIKE chunks.chunk_id || '#alias%')", params![doc.0], ) .map_err(StoreError::from)?; diff --git a/crates/kebab-store-sqlite/src/store.rs b/crates/kebab-store-sqlite/src/store.rs index ebebf42..8c3e86f 100644 --- a/crates/kebab-store-sqlite/src/store.rs +++ b/crates/kebab-store-sqlite/src/store.rs @@ -571,16 +571,20 @@ impl SqliteStore { ) -> Result<()> { let conn = self.lock_conn(); // CASCADE 제거(V011) 대체: documents→chunks CASCADE 가 chunks 를 지우기 전에 - // 원본 + sentinel({id}#alias) embedding_records 를 명시 정리. 별칭 dense - // 벡터는 chunks FK 가 없어 자동 정리되지 않으므로 chunks 가 살아있는 동안 - // 직접 지운다(안 하면 tombstone trigger 가 남긴 행이 누적). 설계 spec - // 2026-05-30-dense-alias-vectors-design.md §3.5-2. (Task 4.5 리뷰 MAJOR.) + // 원본 + per-alias sentinel({id}#alias#N) embedding_records 를 명시 정리. + // 별칭 dense 벡터는 줄별 sentinel chunk_id 로 색인되며 chunks FK 가 없어 + // 자동 정리되지 않으므로 chunks 가 살아있는 동안 직접 지운다(안 하면 + // tombstone trigger 가 남긴 행이 누적). 정확 일치(|| '#alias')는 per-line + // sentinel 을 놓치므로(PR #195 MAJOR) `{id}#alias%` 프리픽스를 LIKE 로 매칭. + // 설계 spec 2026-05-30-dense-alias-vectors-design.md §3.5-2. (Task 4.5 리뷰 MAJOR.) conn.execute( "DELETE FROM embedding_records WHERE chunk_id IN \ (SELECT chunk_id FROM chunks WHERE doc_id IN \ - (SELECT doc_id FROM documents WHERE workspace_path = ?1 AND doc_id != ?2) \ - UNION SELECT chunk_id || '#alias' FROM chunks WHERE doc_id IN \ - (SELECT doc_id FROM documents WHERE workspace_path = ?1 AND doc_id != ?2))", + (SELECT doc_id FROM documents WHERE workspace_path = ?1 AND doc_id != ?2)) \ + OR EXISTS (SELECT 1 FROM chunks \ + WHERE chunks.doc_id IN \ + (SELECT doc_id FROM documents WHERE workspace_path = ?1 AND doc_id != ?2) \ + AND embedding_records.chunk_id LIKE chunks.chunk_id || '#alias%')", params![workspace_path, keep_doc_id], ) .map_err(StoreError::from)?; @@ -642,15 +646,19 @@ pub(crate) fn purge_orphan_at_workspace_path( }; // CASCADE 제거(V011) 대체: 이 asset 의 문서 chunk 임베딩 레코드를 명시 정리. - // 원본 + sentinel({id}#alias) 둘 다. 별칭 dense 벡터는 chunks FK 가 없어 - // documents→chunks CASCADE 로 자동 정리되지 않으므로 chunks 가 살아있는 동안 - // 직접 지운다. 설계 spec 2026-05-30-dense-alias-vectors-design.md §3.5-2. + // 원본 + per-alias sentinel({id}#alias#N) 모두. 별칭 dense 벡터는 줄별 + // sentinel chunk_id 로 색인되며 chunks FK 가 없어 documents→chunks CASCADE 로 + // 자동 정리되지 않으므로 chunks 가 살아있는 동안 직접 지운다. 정확 + // 일치(|| '#alias')는 per-line sentinel 을 놓치므로(PR #195 MAJOR) `{id}#alias%` + // 프리픽스를 LIKE 로 매칭. 설계 spec 2026-05-30-dense-alias-vectors-design.md §3.5-2. conn.execute( "DELETE FROM embedding_records WHERE chunk_id IN \ (SELECT chunk_id FROM chunks WHERE doc_id IN \ - (SELECT doc_id FROM documents WHERE asset_id = ?1) \ - UNION SELECT chunk_id || '#alias' FROM chunks WHERE doc_id IN \ - (SELECT doc_id FROM documents WHERE asset_id = ?1))", + (SELECT doc_id FROM documents WHERE asset_id = ?1)) \ + OR EXISTS (SELECT 1 FROM chunks \ + WHERE chunks.doc_id IN \ + (SELECT doc_id FROM documents WHERE asset_id = ?1) \ + AND embedding_records.chunk_id LIKE chunks.chunk_id || '#alias%')", params![stale_asset_id], ) .map_err(StoreError::from)?; @@ -734,13 +742,17 @@ pub fn purge_deleted_workspace_path( drop(stmt); // 1b. CASCADE 제거(V011) 대체: chunk 임베딩 레코드를 명시 정리(원본 + - // sentinel {id}#alias). 별칭 dense 벡터는 chunks FK 가 없어 - // documents→chunks CASCADE 로 자동 정리되지 않는다. chunks 가 + // per-alias sentinel {id}#alias#N). 별칭 dense 벡터는 줄별 sentinel + // chunk_id 로 색인되며 chunks FK 가 없어 documents→chunks CASCADE 로 + // 자동 정리되지 않는다. 정확 일치(|| '#alias')는 per-line sentinel 을 + // 놓치므로(PR #195 MAJOR) `{id}#alias%` 프리픽스를 LIKE 로 매칭. chunks 가 // 살아있는 동안(2번 DELETE 직전) 실행. spec §3.5-2. conn.execute( "DELETE FROM embedding_records WHERE chunk_id IN \ - (SELECT chunk_id FROM chunks WHERE doc_id = ?1 \ - UNION SELECT chunk_id || '#alias' FROM chunks WHERE doc_id = ?1)", + (SELECT chunk_id FROM chunks WHERE doc_id = ?1) \ + OR EXISTS (SELECT 1 FROM chunks \ + WHERE chunks.doc_id = ?1 \ + AND embedding_records.chunk_id LIKE chunks.chunk_id || '#alias%')", rusqlite::params![doc_id], ) .map_err(StoreError::from)?; diff --git a/crates/kebab-store-sqlite/tests/embedding_records_fk.rs b/crates/kebab-store-sqlite/tests/embedding_records_fk.rs index a739551..896e73b 100644 --- a/crates/kebab-store-sqlite/tests/embedding_records_fk.rs +++ b/crates/kebab-store-sqlite/tests/embedding_records_fk.rs @@ -83,6 +83,19 @@ fn embed_count(store: &SqliteStore, chunk_id: &str) -> i64 { .unwrap() } +/// Count embedding rows whose chunk_id begins with `prefix`. Used to +/// assert that *every* per-alias sentinel (`{id}#alias#0`, `#alias#1`, …) +/// is gone, not just the legacy single `{id}#alias`. +fn embed_count_prefix(store: &SqliteStore, prefix: &str) -> i64 { + let conn = store.read_conn(); + conn.query_row( + "SELECT COUNT(*) FROM embedding_records WHERE chunk_id LIKE ? || '%'", + params![prefix], + |r| r.get::<_, i64>(0), + ) + .unwrap() +} + /// V011 후 sentinel chunk_id(`chunks` 에 없는 id)로 `embedding_records` 를 /// INSERT 해도 FK 위반 없이 성공해야 한다. #[test] @@ -207,3 +220,121 @@ fn purge_except_doc_id_cleans_original_and_sentinel_embeddings() { "purge_except_doc_id: sentinel embedding_records 정리 (chunks FK 없음 → 명시 DELETE)" ); } + +/// Seed body chunk + its per-line alias sentinel embedding rows +/// (`{c1}#alias#0`, `{c1}#alias#1`) plus the legacy `{c1}#alias`. Returns +/// the chunk's bare id. Used by the PR #195 per-alias orphan regressions. +fn seed_body_and_alias_sentinels(store: &SqliteStore, c1: &str) { + seed_chunk(store, c1); + store + .put_embedding_records_pending(&[ + embed_row("e_orig_000000000000000000000000000", c1), + embed_row("e_alias0_00000000000000000000000", &format!("{c1}#alias#0")), + embed_row("e_alias1_00000000000000000000000", &format!("{c1}#alias#1")), + // legacy single sentinel (docs ingested before per-line split). + embed_row("e_alias_legacy_00000000000000000", &format!("{c1}#alias")), + ]) + .unwrap(); + store + .mark_embedding_records_committed(&[ + "e_orig_000000000000000000000000000".to_string(), + "e_alias0_00000000000000000000000".to_string(), + "e_alias1_00000000000000000000000".to_string(), + "e_alias_legacy_00000000000000000".to_string(), + ]) + .unwrap(); +} + +/// PR #195 MAJOR regression: alias dense 벡터가 단일 `{id}#alias` 에서 줄별 +/// `{id}#alias#0`, `#alias#1`, … 로 바뀐 뒤, `put_chunks` 재인제스트 시 명시 +/// DELETE 가 본문 + **모든** per-alias sentinel embedding_records 를 정리해야 +/// 한다. 이전 코드(`|| '#alias'` 정확 일치)는 `#alias#N` 을 놓쳐 누수했다. +#[test] +fn put_chunks_cleans_per_alias_sentinel_embeddings() { + let tmp = TempDir::new().unwrap(); + let store = open_store(&tmp); + let c1 = "11111111111111111111111111111111"; + seed_body_and_alias_sentinels(&store, c1); + assert_eq!(embed_count(&store, c1), 1); + assert_eq!(embed_count_prefix(&store, &format!("{c1}#alias")), 3); + + let doc_id = DocumentId(DOC_ID.to_string()); + let chunk = Chunk { + chunk_id: ChunkId(c1.to_string()), + doc_id: doc_id.clone(), + block_ids: Vec::new(), + text: "hi".to_string(), + heading_path: Vec::new(), + source_spans: Vec::new(), + token_estimate: 1, + chunker_version: ChunkerVersion("v1".to_string()), + policy_hash: "h".to_string(), + tokenized_korean_text: None, + aliases: None, + }; + store.put_chunks(&doc_id, std::slice::from_ref(&chunk)).unwrap(); + + assert_eq!( + embed_count(&store, c1), + 0, + "본문 embedding_records 정리 (CASCADE 대체)" + ); + assert_eq!( + embed_count_prefix(&store, &format!("{c1}#alias")), + 0, + "모든 per-alias sentinel embedding_records 정리 (#alias#N + legacy #alias)" + ); +} + +/// PR #195 MAJOR regression: parser-bump 재인제스트 경로 +/// (`purge_document_at_workspace_path_except_doc_id`)도 본문 + 모든 per-alias +/// sentinel embedding_records 를 정리해야 한다. +#[test] +fn purge_except_doc_id_cleans_per_alias_sentinel_embeddings() { + let tmp = TempDir::new().unwrap(); + let store = open_store(&tmp); + let c1 = "11111111111111111111111111111111"; + seed_body_and_alias_sentinels(&store, c1); // doc DOC_ID @ 'x.md' + assert_eq!(embed_count(&store, c1), 1); + assert_eq!(embed_count_prefix(&store, &format!("{c1}#alias")), 3); + + store + .purge_document_at_workspace_path_except_doc_id("x.md", "0000000000000000000000000000ffff") + .unwrap(); + + assert_eq!(embed_count(&store, c1), 0, "본문 정리"); + assert_eq!( + embed_count_prefix(&store, &format!("{c1}#alias")), + 0, + "모든 per-alias sentinel 정리 (#alias#N + legacy #alias)" + ); +} + +/// PR #195 MAJOR regression: 파일 삭제 sweep 경로 +/// (`purge_deleted_workspace_path`)도 본문 + 모든 per-alias sentinel +/// embedding_records 를 정리해야 한다. +#[test] +fn purge_deleted_workspace_path_cleans_per_alias_sentinel_embeddings() { + let tmp = TempDir::new().unwrap(); + let store = open_store(&tmp); + let c1 = "11111111111111111111111111111111"; + seed_body_and_alias_sentinels(&store, c1); // doc DOC_ID @ 'x.md' + assert_eq!(embed_count(&store, c1), 1); + assert_eq!(embed_count_prefix(&store, &format!("{c1}#alias")), 3); + + let returned = kebab_store_sqlite::purge_deleted_workspace_path( + &store, + &kebab_core::WorkspacePath("x.md".to_string()), + ) + .unwrap(); + // 반환된 body chunk_ids 는 kebab-app 이 LanceDB 측 별칭 sentinel 까지 + // 삭제하는 데 쓰인다(`alias_sentinel_ids_to_delete`). 본문 1개. + assert_eq!(returned.len(), 1); + + assert_eq!(embed_count(&store, c1), 0, "본문 정리"); + assert_eq!( + embed_count_prefix(&store, &format!("{c1}#alias")), + 0, + "모든 per-alias sentinel 정리 (#alias#N + legacy #alias)" + ); +} -- 2.49.1 From 2619b7bff7379d80574cf06d72feac6c1821b98c Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 31 May 2026 09:57:16 +0000 Subject: [PATCH 26/27] =?UTF-8?q?test(chunk):=20AST=20snapshot=20fixture?= =?UTF-8?q?=EC=97=90=20aliases:null=20=ED=95=84=EB=93=9C=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chunk 구조체에 aliases 필드가 추가된(별칭 인프라) 뒤 chunk-*-ast-v1 snapshot fixture 들이 미갱신 상태로 남아 drift FAIL 이었다. chunk_id· text·policy_hash·tokenized 는 전부 불변 — 직렬화에 "aliases": null 한 필드만 추가됐다(청크 생성 로직 무변경, 회귀 아님). UPDATE_SNAPSHOTS=1 로 10개 fixture(code c/cpp/go/java/js/kotlin/python/rust/ts + long_section) 재베이크. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/fixtures/code-sample.c.chunks.snapshot.json | 4 ++++ .../tests/fixtures/code-sample.chunks.snapshot.json | 8 ++++++++ .../fixtures/code-sample.cpp.chunks.snapshot.json | 5 +++++ .../fixtures/code-sample.go.chunks.snapshot.json | 11 +++++++++++ .../fixtures/code-sample.java.chunks.snapshot.json | 8 ++++++++ .../fixtures/code-sample.js.chunks.snapshot.json | 8 ++++++++ .../fixtures/code-sample.kt.chunks.snapshot.json | 8 ++++++++ .../fixtures/code-sample.py.chunks.snapshot.json | 8 ++++++++ .../fixtures/code-sample.ts.chunks.snapshot.json | 8 ++++++++ 9 files changed, 68 insertions(+) diff --git a/crates/kebab-chunk/tests/fixtures/code-sample.c.chunks.snapshot.json b/crates/kebab-chunk/tests/fixtures/code-sample.c.chunks.snapshot.json index ddd7223..03b17fe 100644 --- a/crates/kebab-chunk/tests/fixtures/code-sample.c.chunks.snapshot.json +++ b/crates/kebab-chunk/tests/fixtures/code-sample.c.chunks.snapshot.json @@ -1,5 +1,6 @@ [ { + "aliases": null, "block_ids": [ "8149e12ca002489acb4a0f74c97a061a" ], @@ -22,6 +23,7 @@ "tokenized_korean_text": "# include < stdio . h > # include < stdlib . h > # define MAX _ BUF 4096 typedef enum { OK = 0 , ERR _ PARSE , ERR _ IO , } status _ t ; typedef struct { int id ; char name [ 64 ]; status _ t status ; } record _ t ; static int counter = 0 ;" }, { + "aliases": null, "block_ids": [ "1baaa89f21a47b2f32d6396a24a85454" ], @@ -44,6 +46,7 @@ "tokenized_korean_text": "int parse _ record ( const char * line , record _ t * out ) { if ( line == NULL || out == NULL ) return ERR _ PARSE ; return OK ; }" }, { + "aliases": null, "block_ids": [ "8d0e14cbcc6d1e92d7878ab796ea68b8" ], @@ -66,6 +69,7 @@ "tokenized_korean_text": "void print _ record ( const record _ t * r ) { printf (\"[% d ] % s ( status =% d )\\ n \", r -> id , r -> name , r -> status ); }" }, { + "aliases": null, "block_ids": [ "9c2ede84423871b615d48c38fefb1853" ], diff --git a/crates/kebab-chunk/tests/fixtures/code-sample.chunks.snapshot.json b/crates/kebab-chunk/tests/fixtures/code-sample.chunks.snapshot.json index 8d2f54d..03075aa 100644 --- a/crates/kebab-chunk/tests/fixtures/code-sample.chunks.snapshot.json +++ b/crates/kebab-chunk/tests/fixtures/code-sample.chunks.snapshot.json @@ -1,5 +1,6 @@ [ { + "aliases": null, "block_ids": [ "7a43438772cdada66439790d2b5bed52" ], @@ -22,6 +23,7 @@ "tokenized_korean_text": "use std : : collections : : HashMap ; use std : : fmt ; const MAX : usize = 1024 ; const MIN : usize = 0 ;" }, { + "aliases": null, "block_ids": [ "b362849d469e23a4939022720ecb53d6" ], @@ -44,6 +46,7 @@ "tokenized_korean_text": "pub fn parse ( input : & str ) -> Option < u 32 > { input . trim ( ) . parse ( ) . ok ( ) }" }, { + "aliases": null, "block_ids": [ "f4ad850ca5808ab8b6cc4f06d489cfc6" ], @@ -66,6 +69,7 @@ "tokenized_korean_text": "pub struct Foo { pub name : String , pub value : u 32 , pub tags : Vec < String >, pub meta : Option < String >, pub count : usize , }" }, { + "aliases": null, "block_ids": [ "88ce619db53971c7f384769d96277c65" ], @@ -88,6 +92,7 @@ "tokenized_korean_text": "pub trait Frobable { fn frob (& self ) -> String ; fn frob _ twice (& self ) -> String { let a = self . frob (); let b = self . frob (); format !(\"{ a }{ b }\") } fn name (& self ) -> & str ; }" }, { + "aliases": null, "block_ids": [ "47ca198facaf74c1959ac8b8ceb5ab2a" ], @@ -110,6 +115,7 @@ "tokenized_korean_text": "impl Foo { pub fn double (& self ) -> u 32 { self . value . checked _ mul ( 2 ) . unwrap _ or ( u 32 : : MAX ) } }" }, { + "aliases": null, "block_ids": [ "cc16070e62953f7ec6aebff00db0f21d" ], @@ -132,6 +138,7 @@ "tokenized_korean_text": "impl Foo { pub fn triple (& self ) -> u 32 { self . value . checked _ mul ( 3 ) . unwrap _ or ( u 32 : : MAX ) } }" }, { + "aliases": null, "block_ids": [ "e03092fec8a585435fd3f077df76503f" ], @@ -154,6 +161,7 @@ "tokenized_korean_text": "pub fn big _ fn ( input : &[ u 8 ] ) -> Vec < u 8 > { let v 0 = input . get ( 0 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 1 = input . get ( 1 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 2 = input . get ( 2 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 3 = input . get ( 3 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 4 = input . get ( 4 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 5 = input . get ( 5 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 6 = input . get ( 6 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 7 = input . get ( 7 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 8 = input . get ( 8 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 9 = input . get ( 9 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 10 = input . get ( 10 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 11 = input . get ( 11 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 12 = input . get ( 12 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 13 = input . get ( 13 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 14 = input . get ( 14 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 15 = input . get ( 15 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 16 = input . get ( 16 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 17 = input . get ( 17 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 18 = input . get ( 18 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 19 = input . get ( 19 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 20 = input . get ( 20 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 21 = input . get ( 21 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 22 = input . get ( 22 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 23 = input . get ( 23 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 24 = input . get ( 24 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 25 = input . get ( 25 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 26 = input . get ( 26 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 27 = input . get ( 27 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 28 = input . get ( 28 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 29 = input . get ( 29 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 30 = input . get ( 30 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 31 = input . get ( 31 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 32 = input . get ( 32 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 33 = input . get ( 33 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 34 = input . get ( 34 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 35 = input . get ( 35 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 36 = input . get ( 36 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 37 = input . get ( 37 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 38 = input . get ( 38 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 39 = input . get ( 39 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 40 = input . get ( 40 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 41 = input . get ( 41 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 42 = input . get ( 42 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 43 = input . get ( 43 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 44 = input . get ( 44 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 45 = input . get ( 45 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 46 = input . get ( 46 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 47 = input . get ( 47 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 48 = input . get ( 48 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 49 = input . get ( 49 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 50 = input . get ( 50 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 51 = input . get ( 51 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 52 = input . get ( 52 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 53 = input . get ( 53 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 54 = input . get ( 54 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 55 = input . get ( 55 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 56 = input . get ( 56 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 57 = input . get ( 57 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 58 = input . get ( 58 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 59 = input . get ( 59 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 60 = input . get ( 60 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 61 = input . get ( 61 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 62 = input . get ( 62 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 63 = input . get ( 63 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 64 = input . get ( 64 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 65 = input . get ( 65 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 66 = input . get ( 66 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 67 = input . get ( 67 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 68 = input . get ( 68 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 69 = input . get ( 69 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 70 = input . get ( 70 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 71 = input . get ( 71 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 72 = input . get ( 72 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 73 = input . get ( 73 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 74 = input . get ( 74 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 75 = input . get ( 75 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 76 = input . get ( 76 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 77 = input . get ( 77 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 78 = input . get ( 78 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 79 = input . get ( 79 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 80 = input . get ( 80 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 81 = input . get ( 81 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 82 = input . get ( 82 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 83 = input . get ( 83 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 84 = input . get ( 84 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 85 = input . get ( 85 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 86 = input . get ( 86 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 87 = input . get ( 87 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 88 = input . get ( 88 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 89 = input . get ( 89 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 90 = input . get ( 90 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 91 = input . get ( 91 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 92 = input . get ( 92 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 93 = input . get ( 93 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 94 = input . get ( 94 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 95 = input . get ( 95 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 96 = input . get ( 96 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 97 = input . get ( 97 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 98 = input . get ( 98 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 99 = input . get ( 99 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 100 = input . get ( 100 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 101 = input . get ( 101 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 102 = input . get ( 102 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 103 = input . get ( 103 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 104 = input . get ( 104 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 105 = input . get ( 105 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 106 = input . get ( 106 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 107 = input . get ( 107 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 108 = input . get ( 108 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 109 = input . get ( 109 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 110 = input . get ( 110 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 111 = input . get ( 111 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 112 = input . get ( 112 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 113 = input . get ( 113 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 114 = input . get ( 114 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 115 = input . get ( 115 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 116 = input . get ( 116 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 117 = input . get ( 117 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 118 = input . get ( 118 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 119 = input . get ( 119 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 120 = input . get ( 120 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 121 = input . get ( 121 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 122 = input . get ( 122 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 123 = input . get ( 123 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 124 = input . get ( 124 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 125 = input . get ( 125 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 126 = input . get ( 126 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 127 = input . get ( 127 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 128 = input . get ( 128 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 129 = input . get ( 129 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 130 = input . get ( 130 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 131 = input . get ( 131 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 132 = input . get ( 132 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 133 = input . get ( 133 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 134 = input . get ( 134 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 135 = input . get ( 135 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 136 = input . get ( 136 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 137 = input . get ( 137 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 138 = input . get ( 138 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 139 = input . get ( 139 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 140 = input . get ( 140 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 141 = input . get ( 141 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 142 = input . get ( 142 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 143 = input . get ( 143 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 144 = input . get ( 144 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 145 = input . get ( 145 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 146 = input . get ( 146 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 147 = input . get ( 147 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 148 = input . get ( 148 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 149 = input . get ( 149 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 150 = input . get ( 150 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 151 = input . get ( 151 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 152 = input . get ( 152 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 153 = input . get ( 153 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 154 = input . get ( 154 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 155 = input . get ( 155 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 156 = input . get ( 156 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 157 = input . get ( 157 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 158 = input . get ( 158 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 159 = input . get ( 159 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 160 = input . get ( 160 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 161 = input . get ( 161 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 162 = input . get ( 162 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 163 = input . get ( 163 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 164 = input . get ( 164 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 165 = input . get ( 165 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 166 = input . get ( 166 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 167 = input . get ( 167 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 168 = input . get ( 168 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 169 = input . get ( 169 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 170 = input . get ( 170 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 171 = input . get ( 171 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 172 = input . get ( 172 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 173 = input . get ( 173 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 174 = input . get ( 174 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 175 = input . get ( 175 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 176 = input . get ( 176 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 177 = input . get ( 177 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 178 = input . get ( 178 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 179 = input . get ( 179 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 180 = input . get ( 180 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 181 = input . get ( 181 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 182 = input . get ( 182 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 183 = input . get ( 183 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 184 = input . get ( 184 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 185 = input . get ( 185 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 186 = input . get ( 186 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 187 = input . get ( 187 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 188 = input . get ( 188 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 189 = input . get ( 189 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 190 = input . get ( 190 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 191 = input . get ( 191 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 192 = input . get ( 192 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 193 = input . get ( 193 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 194 = input . get ( 194 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 195 = input . get ( 195 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 196 = input . get ( 196 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 197 = input . get ( 197 as usize ) . copied ( ) . unwrap _ or ( 0 ); let v 198 = input . get ( 198 as usize ) . copied ( ) . unwrap _ or ( 0 );" }, { + "aliases": null, "block_ids": [ "e03092fec8a585435fd3f077df76503f" ], diff --git a/crates/kebab-chunk/tests/fixtures/code-sample.cpp.chunks.snapshot.json b/crates/kebab-chunk/tests/fixtures/code-sample.cpp.chunks.snapshot.json index f6afec8..4a12ba6 100644 --- a/crates/kebab-chunk/tests/fixtures/code-sample.cpp.chunks.snapshot.json +++ b/crates/kebab-chunk/tests/fixtures/code-sample.cpp.chunks.snapshot.json @@ -1,5 +1,6 @@ [ { + "aliases": null, "block_ids": [ "53292605459065d170cd36c118e20546" ], @@ -22,6 +23,7 @@ "tokenized_korean_text": "# include < string > # include < vector > namespace kebab {" }, { + "aliases": null, "block_ids": [ "f349acad94c9fa4cf9ad1c0a93e83610" ], @@ -44,6 +46,7 @@ "tokenized_korean_text": "class MdHeadingV 1 Chunker { public : MdHeadingV 1 Chunker ( ) = default ; ~ MdHeadingV 1 Chunker ( ) = default ; std : : string chunk _ doc ( const std : : string & doc ) { return doc ; } int operator ( ) ( int x ) const { return x * 2 ; } private : int counter _ = 0 ; };" }, { + "aliases": null, "block_ids": [ "8b9811387717d0bd4abf84abcc35b8b1" ], @@ -66,6 +69,7 @@ "tokenized_korean_text": "template < typename T > T identity ( T value ) { return value ; }" }, { + "aliases": null, "block_ids": [ "1754cb6b971f6a4cb292f144a4f0570b" ], @@ -88,6 +92,7 @@ "tokenized_korean_text": "void global _ helper ( ) { / / free function in kebab namespace }" }, { + "aliases": null, "block_ids": [ "14b5f3393d6d25f822f5b70763d24acd" ], diff --git a/crates/kebab-chunk/tests/fixtures/code-sample.go.chunks.snapshot.json b/crates/kebab-chunk/tests/fixtures/code-sample.go.chunks.snapshot.json index d5add54..4b8211b 100644 --- a/crates/kebab-chunk/tests/fixtures/code-sample.go.chunks.snapshot.json +++ b/crates/kebab-chunk/tests/fixtures/code-sample.go.chunks.snapshot.json @@ -1,5 +1,6 @@ [ { + "aliases": null, "block_ids": [ "c182bf37e32c7fc1b868bd617f8eaf66" ], @@ -22,6 +23,7 @@ "tokenized_korean_text": "import ( \" fmt \" \" os \" \" strings \" )" }, { + "aliases": null, "block_ids": [ "c9992cdcfdf3c2a7700a4abc4782a8a4" ], @@ -44,6 +46,7 @@ "tokenized_korean_text": "func ComputeMRR ( scores [ ] float 64 ) float 64 { if len ( scores ) == 0 { return 0 . 0 } _ = fmt . Sprintf (\"% v \", scores ) return 1 . 0 / float 64 ( len ( scores ) ) }" }, { + "aliases": null, "block_ids": [ "5f18dc3e79fe946ba05d32c3bfc00684" ], @@ -66,6 +69,7 @@ "tokenized_korean_text": "type MetricsCollector struct { Scores [ ] float 64 Labels [ ] string Counts map [ string ] int Totals map [ string ] float 64 Tags [ ] string }" }, { + "aliases": null, "block_ids": [ "3009cc022ca832c323393e4f9bcdb388" ], @@ -88,6 +92,7 @@ "tokenized_korean_text": "type BaseEvaluator struct { Name string } func ( e * BaseEvaluator ) Evaluate ( data [ ] string ) error { _ = os . Stderr _ = strings . Join ( data , \",\") return nil }" }, { + "aliases": null, "block_ids": [ "e0e83d1d7f9327a1902ae9a8f67c1f1c" ], @@ -110,6 +115,7 @@ "tokenized_korean_text": "func ( m * MetricsCollector ) Run ( inputs [ ] float 64 ) { for _, inp := range inputs { m . Scores = append ( m . Scores , inp , ) } }" }, { + "aliases": null, "block_ids": [ "0e6a572bc3fe2bd6d173fe614bd1b763" ], @@ -132,6 +138,7 @@ "tokenized_korean_text": "func ( m * MetricsCollector ) Report ( ) map [ string ] interface {} { return map [ string ] interface {}{ \" mean \": 0 . 0 , \" count \": len ( m . Scores ) , \" tags \": m . Tags , } }" }, { + "aliases": null, "block_ids": [ "5d269745b2e5dbdcbef0c09ba54b0bd6" ], @@ -154,6 +161,7 @@ "tokenized_korean_text": "func BigCompute ( data [ ] int ) int { v 0 := 0 if 0 < len ( data ) { v 0 = data [ 0 ] } v 1 := 0 if 1 < len ( data ) { v 1 = data [ 1 ] } v 2 := 0 if 2 < len ( data ) { v 2 = data [ 2 ] } v 3 := 0 if 3 < len ( data ) { v 3 = data [ 3 ] } v 4 := 0 if 4 < len ( data ) { v 4 = data [ 4 ] } v 5 := 0 if 5 < len ( data ) { v 5 = data [ 5 ] } v 6 := 0 if 6 < len ( data ) { v 6 = data [ 6 ] } v 7 := 0 if 7 < len ( data ) { v 7 = data [ 7 ] } v 8 := 0 if 8 < len ( data ) { v 8 = data [ 8 ] } v 9 := 0 if 9 < len ( data ) { v 9 = data [ 9 ] } v 10 := 0 if 10 < len ( data ) { v 10 = data [ 10 ] } v 11 := 0 if 11 < len ( data ) { v 11 = data [ 11 ] } v 12 := 0 if 12 < len ( data ) { v 12 = data [ 12 ] } v 13 := 0 if 13 < len ( data ) { v 13 = data [ 13 ] } v 14 := 0 if 14 < len ( data ) { v 14 = data [ 14 ] } v 15 := 0 if 15 < len ( data ) { v 15 = data [ 15 ] } v 16 := 0 if 16 < len ( data ) { v 16 = data [ 16 ] } v 17 := 0 if 17 < len ( data ) { v 17 = data [ 17 ] } v 18 := 0 if 18 < len ( data ) { v 18 = data [ 18 ] } v 19 := 0 if 19 < len ( data ) { v 19 = data [ 19 ] } v 20 := 0 if 20 < len ( data ) { v 20 = data [ 20 ] } v 21 := 0 if 21 < len ( data ) { v 21 = data [ 21 ] } v 22 := 0 if 22 < len ( data ) { v 22 = data [ 22 ] } v 23 := 0 if 23 < len ( data ) { v 23 = data [ 23 ] } v 24 := 0 if 24 < len ( data ) { v 24 = data [ 24 ] } v 25 := 0 if 25 < len ( data ) { v 25 = data [ 25 ] } v 26 := 0 if 26 < len ( data ) { v 26 = data [ 26 ] } v 27 := 0 if 27 < len ( data ) { v 27 = data [ 27 ] } v 28 := 0 if 28 < len ( data ) { v 28 = data [ 28 ] } v 29 := 0 if 29 < len ( data ) { v 29 = data [ 29 ] } v 30 := 0 if 30 < len ( data ) { v 30 = data [ 30 ] } v 31 := 0 if 31 < len ( data ) { v 31 = data [ 31 ] } v 32 := 0 if 32 < len ( data ) { v 32 = data [ 32 ] } v 33 := 0 if 33 < len ( data ) { v 33 = data [ 33 ] } v 34 := 0 if 34 < len ( data ) { v 34 = data [ 34 ] } v 35 := 0 if 35 < len ( data ) { v 35 = data [ 35 ] } v 36 := 0 if 36 < len ( data ) { v 36 = data [ 36 ] } v 37 := 0 if 37 < len ( data ) { v 37 = data [ 37 ] } v 38 := 0 if 38 < len ( data ) { v 38 = data [ 38 ] } v 39 := 0 if 39 < len ( data ) { v 39 = data [ 39 ] } v 40 := 0 if 40 < len ( data ) { v 40 = data [ 40 ] } v 41 := 0 if 41 < len ( data ) { v 41 = data [ 41 ] } v 42 := 0 if 42 < len ( data ) { v 42 = data [ 42 ] } v 43 := 0 if 43 < len ( data ) { v 43 = data [ 43 ] } v 44 := 0 if 44 < len ( data ) { v 44 = data [ 44 ] } v 45 := 0 if 45 < len ( data ) { v 45 = data [ 45 ] } v 46 := 0 if 46 < len ( data ) { v 46 = data [ 46 ] } v 47 := 0 if 47 < len ( data ) { v 47 = data [ 47 ] } v 48 := 0 if 48 < len ( data ) { v 48 = data [ 48 ] } v 49 := 0 if 49 < len ( data ) { v 49 = data [ 49 ]" }, { + "aliases": null, "block_ids": [ "5d269745b2e5dbdcbef0c09ba54b0bd6" ], @@ -176,6 +184,7 @@ "tokenized_korean_text": "} v 50 := 0 if 50 < len ( data ) { v 50 = data [ 50 ] } v 51 := 0 if 51 < len ( data ) { v 51 = data [ 51 ] } v 52 := 0 if 52 < len ( data ) { v 52 = data [ 52 ] } v 53 := 0 if 53 < len ( data ) { v 53 = data [ 53 ] } v 54 := 0 if 54 < len ( data ) { v 54 = data [ 54 ] } v 55 := 0 if 55 < len ( data ) { v 55 = data [ 55 ] } v 56 := 0 if 56 < len ( data ) { v 56 = data [ 56 ] } v 57 := 0 if 57 < len ( data ) { v 57 = data [ 57 ] } v 58 := 0 if 58 < len ( data ) { v 58 = data [ 58 ] } v 59 := 0 if 59 < len ( data ) { v 59 = data [ 59 ] } v 60 := 0 if 60 < len ( data ) { v 60 = data [ 60 ] } v 61 := 0 if 61 < len ( data ) { v 61 = data [ 61 ] } v 62 := 0 if 62 < len ( data ) { v 62 = data [ 62 ] } v 63 := 0 if 63 < len ( data ) { v 63 = data [ 63 ] } v 64 := 0 if 64 < len ( data ) { v 64 = data [ 64 ] } v 65 := 0 if 65 < len ( data ) { v 65 = data [ 65 ] } v 66 := 0 if 66 < len ( data ) { v 66 = data [ 66 ] } v 67 := 0 if 67 < len ( data ) { v 67 = data [ 67 ] } v 68 := 0 if 68 < len ( data ) { v 68 = data [ 68 ] } v 69 := 0 if 69 < len ( data ) { v 69 = data [ 69 ] } v 70 := 0 if 70 < len ( data ) { v 70 = data [ 70 ] } v 71 := 0 if 71 < len ( data ) { v 71 = data [ 71 ] } v 72 := 0 if 72 < len ( data ) { v 72 = data [ 72 ] } v 73 := 0 if 73 < len ( data ) { v 73 = data [ 73 ] } v 74 := 0 if 74 < len ( data ) { v 74 = data [ 74 ] } v 75 := 0 if 75 < len ( data ) { v 75 = data [ 75 ] } v 76 := 0 if 76 < len ( data ) { v 76 = data [ 76 ] } v 77 := 0 if 77 < len ( data ) { v 77 = data [ 77 ] } v 78 := 0 if 78 < len ( data ) { v 78 = data [ 78 ] } v 79 := 0 if 79 < len ( data ) { v 79 = data [ 79 ] } v 80 := 0 if 80 < len ( data ) { v 80 = data [ 80 ] } v 81 := 0 if 81 < len ( data ) { v 81 = data [ 81 ] } v 82 := 0 if 82 < len ( data ) { v 82 = data [ 82 ] } v 83 := 0 if 83 < len ( data ) { v 83 = data [ 83 ] } v 84 := 0 if 84 < len ( data ) { v 84 = data [ 84 ] } v 85 := 0 if 85 < len ( data ) { v 85 = data [ 85 ] } v 86 := 0 if 86 < len ( data ) { v 86 = data [ 86 ] } v 87 := 0 if 87 < len ( data ) { v 87 = data [ 87 ] } v 88 := 0 if 88 < len ( data ) { v 88 = data [ 88 ] } v 89 := 0 if 89 < len ( data ) { v 89 = data [ 89 ] } v 90 := 0 if 90 < len ( data ) { v 90 = data [ 90 ] } v 91 := 0 if 91 < len ( data ) { v 91 = data [ 91 ] } v 92 := 0 if 92 < len ( data ) { v 92 = data [ 92 ] } v 93 := 0 if 93 < len ( data ) { v 93 = data [ 93 ] } v 94 := 0 if 94 < len ( data ) { v 94 = data [ 94 ] } v 95 := 0 if 95 < len ( data ) { v 95 = data [ 95 ] } v 96 := 0 if 96 < len ( data ) { v 96 = data [ 96 ] } v 97 := 0 if 97 < len ( data ) { v 97 = data [ 97 ] } v 98 := 0 if 98 < len ( data ) { v 98 = data [ 98 ] } v 99 := 0 if 99 < len ( data ) { v 99 = data [ 99 ]" }, { + "aliases": null, "block_ids": [ "5d269745b2e5dbdcbef0c09ba54b0bd6" ], @@ -198,6 +207,7 @@ "tokenized_korean_text": "} v 100 := 0 if 100 < len ( data ) { v 100 = data [ 100 ] } v 101 := 0 if 101 < len ( data ) { v 101 = data [ 101 ] } v 102 := 0 if 102 < len ( data ) { v 102 = data [ 102 ] } v 103 := 0 if 103 < len ( data ) { v 103 = data [ 103 ] } v 104 := 0 if 104 < len ( data ) { v 104 = data [ 104 ] } v 105 := 0 if 105 < len ( data ) { v 105 = data [ 105 ] } v 106 := 0 if 106 < len ( data ) { v 106 = data [ 106 ] } v 107 := 0 if 107 < len ( data ) { v 107 = data [ 107 ] } v 108 := 0 if 108 < len ( data ) { v 108 = data [ 108 ] } v 109 := 0 if 109 < len ( data ) { v 109 = data [ 109 ] } v 110 := 0 if 110 < len ( data ) { v 110 = data [ 110 ] } v 111 := 0 if 111 < len ( data ) { v 111 = data [ 111 ] } v 112 := 0 if 112 < len ( data ) { v 112 = data [ 112 ] } v 113 := 0 if 113 < len ( data ) { v 113 = data [ 113 ] } v 114 := 0 if 114 < len ( data ) { v 114 = data [ 114 ] } v 115 := 0 if 115 < len ( data ) { v 115 = data [ 115 ] } v 116 := 0 if 116 < len ( data ) { v 116 = data [ 116 ] } v 117 := 0 if 117 < len ( data ) { v 117 = data [ 117 ] } v 118 := 0 if 118 < len ( data ) { v 118 = data [ 118 ] } v 119 := 0 if 119 < len ( data ) { v 119 = data [ 119 ] } v 120 := 0 if 120 < len ( data ) { v 120 = data [ 120 ] } v 121 := 0 if 121 < len ( data ) { v 121 = data [ 121 ] } v 122 := 0 if 122 < len ( data ) { v 122 = data [ 122 ] } v 123 := 0 if 123 < len ( data ) { v 123 = data [ 123 ] } v 124 := 0 if 124 < len ( data ) { v 124 = data [ 124 ] } v 125 := 0 if 125 < len ( data ) { v 125 = data [ 125 ] } v 126 := 0 if 126 < len ( data ) { v 126 = data [ 126 ] } v 127 := 0 if 127 < len ( data ) { v 127 = data [ 127 ] } v 128 := 0 if 128 < len ( data ) { v 128 = data [ 128 ] } v 129 := 0 if 129 < len ( data ) { v 129 = data [ 129 ] } v 130 := 0 if 130 < len ( data ) { v 130 = data [ 130 ] } v 131 := 0 if 131 < len ( data ) { v 131 = data [ 131 ] } v 132 := 0 if 132 < len ( data ) { v 132 = data [ 132 ] } v 133 := 0 if 133 < len ( data ) { v 133 = data [ 133 ] } v 134 := 0 if 134 < len ( data ) { v 134 = data [ 134 ] } v 135 := 0 if 135 < len ( data ) { v 135 = data [ 135 ] } v 136 := 0 if 136 < len ( data ) { v 136 = data [ 136 ] } v 137 := 0 if 137 < len ( data ) { v 137 = data [ 137 ] } v 138 := 0 if 138 < len ( data ) { v 138 = data [ 138 ] } v 139 := 0 if 139 < len ( data ) { v 139 = data [ 139 ] } v 140 := 0 if 140 < len ( data ) { v 140 = data [ 140 ] } v 141 := 0 if 141 < len ( data ) { v 141 = data [ 141 ] } v 142 := 0 if 142 < len ( data ) { v 142 = data [ 142 ] } v 143 := 0 if 143 < len ( data ) { v 143 = data [ 143 ] } v 144 := 0 if 144 < len ( data ) { v 144 = data [ 144 ] } v 145 := 0 if 145 < len ( data ) { v 145 = data [ 145 ] } v 146 := 0 if 146 < len ( data ) { v 146 = data [ 146 ] } v 147 := 0 if 147 < len ( data ) { v 147 = data [ 147 ] } v 148 := 0 if 148 < len ( data ) { v 148 = data [ 148 ] } v 149 := 0 if 149 < len ( data ) { v 149 = data [ 149 ]" }, { + "aliases": null, "block_ids": [ "5d269745b2e5dbdcbef0c09ba54b0bd6" ], @@ -220,6 +230,7 @@ "tokenized_korean_text": "} v 150 := 0 if 150 < len ( data ) { v 150 = data [ 150 ] } v 151 := 0 if 151 < len ( data ) { v 151 = data [ 151 ] } v 152 := 0 if 152 < len ( data ) { v 152 = data [ 152 ] } v 153 := 0 if 153 < len ( data ) { v 153 = data [ 153 ] } v 154 := 0 if 154 < len ( data ) { v 154 = data [ 154 ] } v 155 := 0 if 155 < len ( data ) { v 155 = data [ 155 ] } v 156 := 0 if 156 < len ( data ) { v 156 = data [ 156 ] } v 157 := 0 if 157 < len ( data ) { v 157 = data [ 157 ] } v 158 := 0 if 158 < len ( data ) { v 158 = data [ 158 ] } v 159 := 0 if 159 < len ( data ) { v 159 = data [ 159 ] } v 160 := 0 if 160 < len ( data ) { v 160 = data [ 160 ] } v 161 := 0 if 161 < len ( data ) { v 161 = data [ 161 ] } v 162 := 0 if 162 < len ( data ) { v 162 = data [ 162 ] } v 163 := 0 if 163 < len ( data ) { v 163 = data [ 163 ] } v 164 := 0 if 164 < len ( data ) { v 164 = data [ 164 ] } v 165 := 0 if 165 < len ( data ) { v 165 = data [ 165 ] } v 166 := 0 if 166 < len ( data ) { v 166 = data [ 166 ] } v 167 := 0 if 167 < len ( data ) { v 167 = data [ 167 ] } v 168 := 0 if 168 < len ( data ) { v 168 = data [ 168 ] } v 169 := 0 if 169 < len ( data ) { v 169 = data [ 169 ] } v 170 := 0 if 170 < len ( data ) { v 170 = data [ 170 ] } v 171 := 0 if 171 < len ( data ) { v 171 = data [ 171 ] } v 172 := 0 if 172 < len ( data ) { v 172 = data [ 172 ] } v 173 := 0 if 173 < len ( data ) { v 173 = data [ 173 ] } v 174 := 0 if 174 < len ( data ) { v 174 = data [ 174 ] } v 175 := 0 if 175 < len ( data ) { v 175 = data [ 175 ] } v 176 := 0 if 176 < len ( data ) { v 176 = data [ 176 ] } v 177 := 0 if 177 < len ( data ) { v 177 = data [ 177 ] } v 178 := 0 if 178 < len ( data ) { v 178 = data [ 178 ] } v 179 := 0 if 179 < len ( data ) { v 179 = data [ 179 ] } v 180 := 0 if 180 < len ( data ) { v 180 = data [ 180 ] } v 181 := 0 if 181 < len ( data ) { v 181 = data [ 181 ] } v 182 := 0 if 182 < len ( data ) { v 182 = data [ 182 ] } v 183 := 0 if 183 < len ( data ) { v 183 = data [ 183 ] } v 184 := 0 if 184 < len ( data ) { v 184 = data [ 184 ] } v 185 := 0 if 185 < len ( data ) { v 185 = data [ 185 ] } v 186 := 0 if 186 < len ( data ) { v 186 = data [ 186 ] } v 187 := 0 if 187 < len ( data ) { v 187 = data [ 187 ] } v 188 := 0 if 188 < len ( data ) { v 188 = data [ 188 ] } v 189 := 0 if 189 < len ( data ) { v 189 = data [ 189 ] } v 190 := 0 if 190 < len ( data ) { v 190 = data [ 190 ] } v 191 := 0 if 191 < len ( data ) { v 191 = data [ 191 ] } v 192 := 0 if 192 < len ( data ) { v 192 = data [ 192 ] } v 193 := 0 if 193 < len ( data ) { v 193 = data [ 193 ] } v 194 := 0 if 194 < len ( data ) { v 194 = data [ 194 ] } v 195 := 0 if 195 < len ( data ) { v 195 = data [ 195 ] } v 196 := 0 if 196 < len ( data ) { v 196 = data [ 196 ] } v 197 := 0 if 197 < len ( data ) { v 197 = data [ 197 ] } v 198 := 0 if 198 < len ( data ) { v 198 = data [ 198 ] } v 199 := 0 if 199 < len ( data ) { v 199 = data [ 199 ]" }, { + "aliases": null, "block_ids": [ "5d269745b2e5dbdcbef0c09ba54b0bd6" ], diff --git a/crates/kebab-chunk/tests/fixtures/code-sample.java.chunks.snapshot.json b/crates/kebab-chunk/tests/fixtures/code-sample.java.chunks.snapshot.json index b5205c1..83ad156 100644 --- a/crates/kebab-chunk/tests/fixtures/code-sample.java.chunks.snapshot.json +++ b/crates/kebab-chunk/tests/fixtures/code-sample.java.chunks.snapshot.json @@ -1,5 +1,6 @@ [ { + "aliases": null, "block_ids": [ "03d62d5e6fe70e2b05546e2e65001238" ], @@ -22,6 +23,7 @@ "tokenized_korean_text": "import java . util . List ; import java . util . Map ; import java . util . ArrayList ; import java . util . HashMap ; import java . util . stream . Collectors ;" }, { + "aliases": null, "block_ids": [ "bbd220978bbe6cf920664d0d4007a1eb" ], @@ -44,6 +46,7 @@ "tokenized_korean_text": "public static double computeMRR ( List < Double > scores ) { if ( scores . isEmpty ( ) ) { return 0 . 0 ; } return 1 . 0 / scores . size (); }" }, { + "aliases": null, "block_ids": [ "484d1abcad06dabb45c81284ad887a25" ], @@ -66,6 +69,7 @@ "tokenized_korean_text": "public class MetricsCollector { private List < Double > scores ; private List < String > labels ; private Map < String , Integer > counts ; private Map < String , Double > totals ; private List < String > tags ; }" }, { + "aliases": null, "block_ids": [ "8b1cb841f509de0ce14425e09bd959ad" ], @@ -88,6 +92,7 @@ "tokenized_korean_text": "public class BaseEvaluator { private String name ; public BaseEvaluator ( String name ) { this . name = name ; } public void evaluate ( List < String > data ) throws Exception { String joined = String . join (\",\", data ); } }" }, { + "aliases": null, "block_ids": [ "761545865918f8f5e94aa346c9bb2012" ], @@ -110,6 +115,7 @@ "tokenized_korean_text": "public void run ( List < Double > inputs ) { for ( Double inp : inputs ) { scores . add ( inp ); } }" }, { + "aliases": null, "block_ids": [ "bc2f4d65b9b2d43adbfb352e6e0d4d3c" ], @@ -132,6 +138,7 @@ "tokenized_korean_text": "public Map < String , Object > report ( ) { Map < String , Object > result = new HashMap <>(); result . put (\" mean \", 0 . 0 ); result . put (\" count \", scores . size ()); result . put (\" tags \", tags ); return result ; }" }, { + "aliases": null, "block_ids": [ "f22ed2da0b1c3d24cc606aebd24bf6d1" ], @@ -154,6 +161,7 @@ "tokenized_korean_text": "public class BigCompute { public int compute ( int [ ] data ) { int v 0 = 0 < data . length ? data [ 0 ] : 0 ; int v 1 = 1 < data . length ? data [ 1 ] : 0 ; int v 2 = 2 < data . length ? data [ 2 ] : 0 ; int v 3 = 3 < data . length ? data [ 3 ] : 0 ; int v 4 = 4 < data . length ? data [ 4 ] : 0 ; int v 5 = 5 < data . length ? data [ 5 ] : 0 ; int v 6 = 6 < data . length ? data [ 6 ] : 0 ; int v 7 = 7 < data . length ? data [ 7 ] : 0 ; int v 8 = 8 < data . length ? data [ 8 ] : 0 ; int v 9 = 9 < data . length ? data [ 9 ] : 0 ; int v 10 = 10 < data . length ? data [ 10 ] : 0 ; int v 11 = 11 < data . length ? data [ 11 ] : 0 ; int v 12 = 12 < data . length ? data [ 12 ] : 0 ; int v 13 = 13 < data . length ? data [ 13 ] : 0 ; int v 14 = 14 < data . length ? data [ 14 ] : 0 ; int v 15 = 15 < data . length ? data [ 15 ] : 0 ; int v 16 = 16 < data . length ? data [ 16 ] : 0 ; int v 17 = 17 < data . length ? data [ 17 ] : 0 ; int v 18 = 18 < data . length ? data [ 18 ] : 0 ; int v 19 = 19 < data . length ? data [ 19 ] : 0 ; int v 20 = 20 < data . length ? data [ 20 ] : 0 ; int v 21 = 21 < data . length ? data [ 21 ] : 0 ; int v 22 = 22 < data . length ? data [ 22 ] : 0 ; int v 23 = 23 < data . length ? data [ 23 ] : 0 ; int v 24 = 24 < data . length ? data [ 24 ] : 0 ; int v 25 = 25 < data . length ? data [ 25 ] : 0 ; int v 26 = 26 < data . length ? data [ 26 ] : 0 ; int v 27 = 27 < data . length ? data [ 27 ] : 0 ; int v 28 = 28 < data . length ? data [ 28 ] : 0 ; int v 29 = 29 < data . length ? data [ 29 ] : 0 ; int v 30 = 30 < data . length ? data [ 30 ] : 0 ; int v 31 = 31 < data . length ? data [ 31 ] : 0 ; int v 32 = 32 < data . length ? data [ 32 ] : 0 ; int v 33 = 33 < data . length ? data [ 33 ] : 0 ; int v 34 = 34 < data . length ? data [ 34 ] : 0 ; int v 35 = 35 < data . length ? data [ 35 ] : 0 ; int v 36 = 36 < data . length ? data [ 36 ] : 0 ; int v 37 = 37 < data . length ? data [ 37 ] : 0 ; int v 38 = 38 < data . length ? data [ 38 ] : 0 ; int v 39 = 39 < data . length ? data [ 39 ] : 0 ; int v 40 = 40 < data . length ? data [ 40 ] : 0 ; int v 41 = 41 < data . length ? data [ 41 ] : 0 ; int v 42 = 42 < data . length ? data [ 42 ] : 0 ; int v 43 = 43 < data . length ? data [ 43 ] : 0 ; int v 44 = 44 < data . length ? data [ 44 ] : 0 ; int v 45 = 45 < data . length ? data [ 45 ] : 0 ; int v 46 = 46 < data . length ? data [ 46 ] : 0 ; int v 47 = 47 < data . length ? data [ 47 ] : 0 ; int v 48 = 48 < data . length ? data [ 48 ] : 0 ; int v 49 = 49 < data . length ? data [ 49 ] : 0 ; int v 50 = 50 < data . length ? data [ 50 ] : 0 ; int v 51 = 51 < data . length ? data [ 51 ] : 0 ; int v 52 = 52 < data . length ? data [ 52 ] : 0 ; int v 53 = 53 < data . length ? data [ 53 ] : 0 ; int v 54 = 54 < data . length ? data [ 54 ] : 0 ; int v 55 = 55 < data . length ? data [ 55 ] : 0 ; int v 56 = 56 < data . length ? data [ 56 ] : 0 ; int v 57 = 57 < data . length ? data [ 57 ] : 0 ; int v 58 = 58 < data . length ? data [ 58 ] : 0 ; int v 59 = 59 < data . length ? data [ 59 ] : 0 ; int v 60 = 60 < data . length ? data [ 60 ] : 0 ; int v 61 = 61 < data . length ? data [ 61 ] : 0 ; int v 62 = 62 < data . length ? data [ 62 ] : 0 ; int v 63 = 63 < data . length ? data [ 63 ] : 0 ; int v 64 = 64 < data . length ? data [ 64 ] : 0 ; int v 65 = 65 < data . length ? data [ 65 ] : 0 ; int v 66 = 66 < data . length ? data [ 66 ] : 0 ; int v 67 = 67 < data . length ? data [ 67 ] : 0 ; int v 68 = 68 < data . length ? data [ 68 ] : 0 ; int v 69 = 69 < data . length ? data [ 69 ] : 0 ; int v 70 = 70 < data . length ? data [ 70 ] : 0 ; int v 71 = 71 < data . length ? data [ 71 ] : 0 ; int v 72 = 72 < data . length ? data [ 72 ] : 0 ; int v 73 = 73 < data . length ? data [ 73 ] : 0 ; int v 74 = 74 < data . length ? data [ 74 ] : 0 ; int v 75 = 75 < data . length ? data [ 75 ] : 0 ; int v 76 = 76 < data . length ? data [ 76 ] : 0 ; int v 77 = 77 < data . length ? data [ 77 ] : 0 ; int v 78 = 78 < data . length ? data [ 78 ] : 0 ; int v 79 = 79 < data . length ? data [ 79 ] : 0 ; int v 80 = 80 < data . length ? data [ 80 ] : 0 ; int v 81 = 81 < data . length ? data [ 81 ] : 0 ; int v 82 = 82 < data . length ? data [ 82 ] : 0 ; int v 83 = 83 < data . length ? data [ 83 ] : 0 ; int v 84 = 84 < data . length ? data [ 84 ] : 0 ; int v 85 = 85 < data . length ? data [ 85 ] : 0 ; int v 86 = 86 < data . length ? data [ 86 ] : 0 ; int v 87 = 87 < data . length ? data [ 87 ] : 0 ; int v 88 = 88 < data . length ? data [ 88 ] : 0 ; int v 89 = 89 < data . length ? data [ 89 ] : 0 ; int v 90 = 90 < data . length ? data [ 90 ] : 0 ; int v 91 = 91 < data . length ? data [ 91 ] : 0 ; int v 92 = 92 < data . length ? data [ 92 ] : 0 ; int v 93 = 93 < data . length ? data [ 93 ] : 0 ; int v 94 = 94 < data . length ? data [ 94 ] : 0 ; int v 95 = 95 < data . length ? data [ 95 ] : 0 ; int v 96 = 96 < data . length ? data [ 96 ] : 0 ; int v 97 = 97 < data . length ? data [ 97 ] : 0 ; int v 98 = 98 < data . length ? data [ 98 ] : 0 ; int v 99 = 99 < data . length ? data [ 99 ] : 0 ; int v 100 = 100 < data . length ? data [ 100 ] : 0 ; int v 101 = 101 < data . length ? data [ 101 ] : 0 ; int v 102 = 102 < data . length ? data [ 102 ] : 0 ; int v 103 = 103 < data . length ? data [ 103 ] : 0 ; int v 104 = 104 < data . length ? data [ 104 ] : 0 ; int v 105 = 105 < data . length ? data [ 105 ] : 0 ; int v 106 = 106 < data . length ? data [ 106 ] : 0 ; int v 107 = 107 < data . length ? data [ 107 ] : 0 ; int v 108 = 108 < data . length ? data [ 108 ] : 0 ; int v 109 = 109 < data . length ? data [ 109 ] : 0 ; int v 110 = 110 < data . length ? data [ 110 ] : 0 ; int v 111 = 111 < data . length ? data [ 111 ] : 0 ; int v 112 = 112 < data . length ? data [ 112 ] : 0 ; int v 113 = 113 < data . length ? data [ 113 ] : 0 ; int v 114 = 114 < data . length ? data [ 114 ] : 0 ; int v 115 = 115 < data . length ? data [ 115 ] : 0 ; int v 116 = 116 < data . length ? data [ 116 ] : 0 ; int v 117 = 117 < data . length ? data [ 117 ] : 0 ; int v 118 = 118 < data . length ? data [ 118 ] : 0 ; int v 119 = 119 < data . length ? data [ 119 ] : 0 ; int v 120 = 120 < data . length ? data [ 120 ] : 0 ; int v 121 = 121 < data . length ? data [ 121 ] : 0 ; int v 122 = 122 < data . length ? data [ 122 ] : 0 ; int v 123 = 123 < data . length ? data [ 123 ] : 0 ; int v 124 = 124 < data . length ? data [ 124 ] : 0 ; int v 125 = 125 < data . length ? data [ 125 ] : 0 ; int v 126 = 126 < data . length ? data [ 126 ] : 0 ; int v 127 = 127 < data . length ? data [ 127 ] : 0 ; int v 128 = 128 < data . length ? data [ 128 ] : 0 ; int v 129 = 129 < data . length ? data [ 129 ] : 0 ; int v 130 = 130 < data . length ? data [ 130 ] : 0 ; int v 131 = 131 < data . length ? data [ 131 ] : 0 ; int v 132 = 132 < data . length ? data [ 132 ] : 0 ; int v 133 = 133 < data . length ? data [ 133 ] : 0 ; int v 134 = 134 < data . length ? data [ 134 ] : 0 ; int v 135 = 135 < data . length ? data [ 135 ] : 0 ; int v 136 = 136 < data . length ? data [ 136 ] : 0 ; int v 137 = 137 < data . length ? data [ 137 ] : 0 ; int v 138 = 138 < data . length ? data [ 138 ] : 0 ; int v 139 = 139 < data . length ? data [ 139 ] : 0 ; int v 140 = 140 < data . length ? data [ 140 ] : 0 ; int v 141 = 141 < data . length ? data [ 141 ] : 0 ; int v 142 = 142 < data . length ? data [ 142 ] : 0 ; int v 143 = 143 < data . length ? data [ 143 ] : 0 ; int v 144 = 144 < data . length ? data [ 144 ] : 0 ; int v 145 = 145 < data . length ? data [ 145 ] : 0 ; int v 146 = 146 < data . length ? data [ 146 ] : 0 ; int v 147 = 147 < data . length ? data [ 147 ] : 0 ; int v 148 = 148 < data . length ? data [ 148 ] : 0 ; int v 149 = 149 < data . length ? data [ 149 ] : 0 ; int v 150 = 150 < data . length ? data [ 150 ] : 0 ; int v 151 = 151 < data . length ? data [ 151 ] : 0 ; int v 152 = 152 < data . length ? data [ 152 ] : 0 ; int v 153 = 153 < data . length ? data [ 153 ] : 0 ; int v 154 = 154 < data . length ? data [ 154 ] : 0 ; int v 155 = 155 < data . length ? data [ 155 ] : 0 ; int v 156 = 156 < data . length ? data [ 156 ] : 0 ; int v 157 = 157 < data . length ? data [ 157 ] : 0 ; int v 158 = 158 < data . length ? data [ 158 ] : 0 ; int v 159 = 159 < data . length ? data [ 159 ] : 0 ; int v 160 = 160 < data . length ? data [ 160 ] : 0 ; int v 161 = 161 < data . length ? data [ 161 ] : 0 ; int v 162 = 162 < data . length ? data [ 162 ] : 0 ; int v 163 = 163 < data . length ? data [ 163 ] : 0 ; int v 164 = 164 < data . length ? data [ 164 ] : 0 ; int v 165 = 165 < data . length ? data [ 165 ] : 0 ; int v 166 = 166 < data . length ? data [ 166 ] : 0 ; int v 167 = 167 < data . length ? data [ 167 ] : 0 ; int v 168 = 168 < data . length ? data [ 168 ] : 0 ; int v 169 = 169 < data . length ? data [ 169 ] : 0 ; int v 170 = 170 < data . length ? data [ 170 ] : 0 ; int v 171 = 171 < data . length ? data [ 171 ] : 0 ; int v 172 = 172 < data . length ? data [ 172 ] : 0 ; int v 173 = 173 < data . length ? data [ 173 ] : 0 ; int v 174 = 174 < data . length ? data [ 174 ] : 0 ; int v 175 = 175 < data . length ? data [ 175 ] : 0 ; int v 176 = 176 < data . length ? data [ 176 ] : 0 ; int v 177 = 177 < data . length ? data [ 177 ] : 0 ; int v 178 = 178 < data . length ? data [ 178 ] : 0 ; int v 179 = 179 < data . length ? data [ 179 ] : 0 ; int v 180 = 180 < data . length ? data [ 180 ] : 0 ; int v 181 = 181 < data . length ? data [ 181 ] : 0 ; int v 182 = 182 < data . length ? data [ 182 ] : 0 ; int v 183 = 183 < data . length ? data [ 183 ] : 0 ; int v 184 = 184 < data . length ? data [ 184 ] : 0 ; int v 185 = 185 < data . length ? data [ 185 ] : 0 ; int v 186 = 186 < data . length ? data [ 186 ] : 0 ; int v 187 = 187 < data . length ? data [ 187 ] : 0 ; int v 188 = 188 < data . length ? data [ 188 ] : 0 ; int v 189 = 189 < data . length ? data [ 189 ] : 0 ; int v 190 = 190 < data . length ? data [ 190 ] : 0 ; int v 191 = 191 < data . length ? data [ 191 ] : 0 ; int v 192 = 192 < data . length ? data [ 192 ] : 0 ; int v 193 = 193 < data . length ? data [ 193 ] : 0 ; int v 194 = 194 < data . length ? data [ 194 ] : 0 ; int v 195 = 195 < data . length ? data [ 195 ] : 0 ; int v 196 = 196 < data . length ? data [ 196 ] : 0 ; int v 197 = 197 < data . length ? data [ 197 ] : 0 ;" }, { + "aliases": null, "block_ids": [ "f22ed2da0b1c3d24cc606aebd24bf6d1" ], diff --git a/crates/kebab-chunk/tests/fixtures/code-sample.js.chunks.snapshot.json b/crates/kebab-chunk/tests/fixtures/code-sample.js.chunks.snapshot.json index 6af5efe..e40f3ed 100644 --- a/crates/kebab-chunk/tests/fixtures/code-sample.js.chunks.snapshot.json +++ b/crates/kebab-chunk/tests/fixtures/code-sample.js.chunks.snapshot.json @@ -1,5 +1,6 @@ [ { + "aliases": null, "block_ids": [ "cc724d960aebe9fb36062d24f4626c66" ], @@ -22,6 +23,7 @@ "tokenized_korean_text": "const fs = require (' fs '); const path = require (' path '); const { EventEmitter } = require (' events '); const assert = require (' assert '); const crypto = require (' crypto ');" }, { + "aliases": null, "block_ids": [ "f0d00af94e8b3e0fe4249f66d27caedd" ], @@ -44,6 +46,7 @@ "tokenized_korean_text": "export function add ( a , b ) { if ( typeof a !== ' number ') throw new TypeError (' a '); if ( typeof b !== ' number ') throw new TypeError (' b '); const result = a + b ; assert ( isFinite ( result )); return result ; }" }, { + "aliases": null, "block_ids": [ "19e5e07c316d04e18ec0b10598c20ec7" ], @@ -66,6 +69,7 @@ "tokenized_korean_text": "class EventBus { constructor ( ) { this ._ handlers = new Map (); this ._ history = []; this ._ maxHistory = 100 ; this ._ seq = 0 ; } }" }, { + "aliases": null, "block_ids": [ "8e016bd376edde2c49320c5094d01b67" ], @@ -88,6 +92,7 @@ "tokenized_korean_text": "class BaseHandler { handle ( event ) { throw new Error (' not implemented '); } batchHandle ( events ) { const results = []; for ( const ev of events ) { results . push ( this . handle ( ev )); } return results ; } }" }, { + "aliases": null, "block_ids": [ "e06656d11af2c1d7928856766382d168" ], @@ -110,6 +115,7 @@ "tokenized_korean_text": "class EventBus { emit ( name , payload ) { const handlers = this ._ handlers . get ( name ) ?? []; for ( const h of handlers ) { h ( payload ); } return this ; } }" }, { + "aliases": null, "block_ids": [ "601a8af776f0634cfb4ccfa97e612afc" ], @@ -132,6 +138,7 @@ "tokenized_korean_text": "class EventBus { on ( name , handler ) { if (! this ._ handlers . has ( name ) ) { this ._ handlers . set ( name , []); } this ._ handlers . get ( name ) . push ( handler ); return this ; } }" }, { + "aliases": null, "block_ids": [ "2bc61a811414be749c17290832857c7f" ], @@ -154,6 +161,7 @@ "tokenized_korean_text": "function bigTransform ( items ) { const v 0 = items [ 0 ] !== undefined ? items [ 0 ] : null ; const v 1 = items [ 1 ] !== undefined ? items [ 1 ] : null ; const v 2 = items [ 2 ] !== undefined ? items [ 2 ] : null ; const v 3 = items [ 3 ] !== undefined ? items [ 3 ] : null ; const v 4 = items [ 4 ] !== undefined ? items [ 4 ] : null ; const v 5 = items [ 5 ] !== undefined ? items [ 5 ] : null ; const v 6 = items [ 6 ] !== undefined ? items [ 6 ] : null ; const v 7 = items [ 7 ] !== undefined ? items [ 7 ] : null ; const v 8 = items [ 8 ] !== undefined ? items [ 8 ] : null ; const v 9 = items [ 9 ] !== undefined ? items [ 9 ] : null ; const v 10 = items [ 10 ] !== undefined ? items [ 10 ] : null ; const v 11 = items [ 11 ] !== undefined ? items [ 11 ] : null ; const v 12 = items [ 12 ] !== undefined ? items [ 12 ] : null ; const v 13 = items [ 13 ] !== undefined ? items [ 13 ] : null ; const v 14 = items [ 14 ] !== undefined ? items [ 14 ] : null ; const v 15 = items [ 15 ] !== undefined ? items [ 15 ] : null ; const v 16 = items [ 16 ] !== undefined ? items [ 16 ] : null ; const v 17 = items [ 17 ] !== undefined ? items [ 17 ] : null ; const v 18 = items [ 18 ] !== undefined ? items [ 18 ] : null ; const v 19 = items [ 19 ] !== undefined ? items [ 19 ] : null ; const v 20 = items [ 20 ] !== undefined ? items [ 20 ] : null ; const v 21 = items [ 21 ] !== undefined ? items [ 21 ] : null ; const v 22 = items [ 22 ] !== undefined ? items [ 22 ] : null ; const v 23 = items [ 23 ] !== undefined ? items [ 23 ] : null ; const v 24 = items [ 24 ] !== undefined ? items [ 24 ] : null ; const v 25 = items [ 25 ] !== undefined ? items [ 25 ] : null ; const v 26 = items [ 26 ] !== undefined ? items [ 26 ] : null ; const v 27 = items [ 27 ] !== undefined ? items [ 27 ] : null ; const v 28 = items [ 28 ] !== undefined ? items [ 28 ] : null ; const v 29 = items [ 29 ] !== undefined ? items [ 29 ] : null ; const v 30 = items [ 30 ] !== undefined ? items [ 30 ] : null ; const v 31 = items [ 31 ] !== undefined ? items [ 31 ] : null ; const v 32 = items [ 32 ] !== undefined ? items [ 32 ] : null ; const v 33 = items [ 33 ] !== undefined ? items [ 33 ] : null ; const v 34 = items [ 34 ] !== undefined ? items [ 34 ] : null ; const v 35 = items [ 35 ] !== undefined ? items [ 35 ] : null ; const v 36 = items [ 36 ] !== undefined ? items [ 36 ] : null ; const v 37 = items [ 37 ] !== undefined ? items [ 37 ] : null ; const v 38 = items [ 38 ] !== undefined ? items [ 38 ] : null ; const v 39 = items [ 39 ] !== undefined ? items [ 39 ] : null ; const v 40 = items [ 40 ] !== undefined ? items [ 40 ] : null ; const v 41 = items [ 41 ] !== undefined ? items [ 41 ] : null ; const v 42 = items [ 42 ] !== undefined ? items [ 42 ] : null ; const v 43 = items [ 43 ] !== undefined ? items [ 43 ] : null ; const v 44 = items [ 44 ] !== undefined ? items [ 44 ] : null ; const v 45 = items [ 45 ] !== undefined ? items [ 45 ] : null ; const v 46 = items [ 46 ] !== undefined ? items [ 46 ] : null ; const v 47 = items [ 47 ] !== undefined ? items [ 47 ] : null ; const v 48 = items [ 48 ] !== undefined ? items [ 48 ] : null ; const v 49 = items [ 49 ] !== undefined ? items [ 49 ] : null ; const v 50 = items [ 50 ] !== undefined ? items [ 50 ] : null ; const v 51 = items [ 51 ] !== undefined ? items [ 51 ] : null ; const v 52 = items [ 52 ] !== undefined ? items [ 52 ] : null ; const v 53 = items [ 53 ] !== undefined ? items [ 53 ] : null ; const v 54 = items [ 54 ] !== undefined ? items [ 54 ] : null ; const v 55 = items [ 55 ] !== undefined ? items [ 55 ] : null ; const v 56 = items [ 56 ] !== undefined ? items [ 56 ] : null ; const v 57 = items [ 57 ] !== undefined ? items [ 57 ] : null ; const v 58 = items [ 58 ] !== undefined ? items [ 58 ] : null ; const v 59 = items [ 59 ] !== undefined ? items [ 59 ] : null ; const v 60 = items [ 60 ] !== undefined ? items [ 60 ] : null ; const v 61 = items [ 61 ] !== undefined ? items [ 61 ] : null ; const v 62 = items [ 62 ] !== undefined ? items [ 62 ] : null ; const v 63 = items [ 63 ] !== undefined ? items [ 63 ] : null ; const v 64 = items [ 64 ] !== undefined ? items [ 64 ] : null ; const v 65 = items [ 65 ] !== undefined ? items [ 65 ] : null ; const v 66 = items [ 66 ] !== undefined ? items [ 66 ] : null ; const v 67 = items [ 67 ] !== undefined ? items [ 67 ] : null ; const v 68 = items [ 68 ] !== undefined ? items [ 68 ] : null ; const v 69 = items [ 69 ] !== undefined ? items [ 69 ] : null ; const v 70 = items [ 70 ] !== undefined ? items [ 70 ] : null ; const v 71 = items [ 71 ] !== undefined ? items [ 71 ] : null ; const v 72 = items [ 72 ] !== undefined ? items [ 72 ] : null ; const v 73 = items [ 73 ] !== undefined ? items [ 73 ] : null ; const v 74 = items [ 74 ] !== undefined ? items [ 74 ] : null ; const v 75 = items [ 75 ] !== undefined ? items [ 75 ] : null ; const v 76 = items [ 76 ] !== undefined ? items [ 76 ] : null ; const v 77 = items [ 77 ] !== undefined ? items [ 77 ] : null ; const v 78 = items [ 78 ] !== undefined ? items [ 78 ] : null ; const v 79 = items [ 79 ] !== undefined ? items [ 79 ] : null ; const v 80 = items [ 80 ] !== undefined ? items [ 80 ] : null ; const v 81 = items [ 81 ] !== undefined ? items [ 81 ] : null ; const v 82 = items [ 82 ] !== undefined ? items [ 82 ] : null ; const v 83 = items [ 83 ] !== undefined ? items [ 83 ] : null ; const v 84 = items [ 84 ] !== undefined ? items [ 84 ] : null ; const v 85 = items [ 85 ] !== undefined ? items [ 85 ] : null ; const v 86 = items [ 86 ] !== undefined ? items [ 86 ] : null ; const v 87 = items [ 87 ] !== undefined ? items [ 87 ] : null ; const v 88 = items [ 88 ] !== undefined ? items [ 88 ] : null ; const v 89 = items [ 89 ] !== undefined ? items [ 89 ] : null ; const v 90 = items [ 90 ] !== undefined ? items [ 90 ] : null ; const v 91 = items [ 91 ] !== undefined ? items [ 91 ] : null ; const v 92 = items [ 92 ] !== undefined ? items [ 92 ] : null ; const v 93 = items [ 93 ] !== undefined ? items [ 93 ] : null ; const v 94 = items [ 94 ] !== undefined ? items [ 94 ] : null ; const v 95 = items [ 95 ] !== undefined ? items [ 95 ] : null ; const v 96 = items [ 96 ] !== undefined ? items [ 96 ] : null ; const v 97 = items [ 97 ] !== undefined ? items [ 97 ] : null ; const v 98 = items [ 98 ] !== undefined ? items [ 98 ] : null ; const v 99 = items [ 99 ] !== undefined ? items [ 99 ] : null ; const v 100 = items [ 100 ] !== undefined ? items [ 100 ] : null ; const v 101 = items [ 101 ] !== undefined ? items [ 101 ] : null ; const v 102 = items [ 102 ] !== undefined ? items [ 102 ] : null ; const v 103 = items [ 103 ] !== undefined ? items [ 103 ] : null ; const v 104 = items [ 104 ] !== undefined ? items [ 104 ] : null ; const v 105 = items [ 105 ] !== undefined ? items [ 105 ] : null ; const v 106 = items [ 106 ] !== undefined ? items [ 106 ] : null ; const v 107 = items [ 107 ] !== undefined ? items [ 107 ] : null ; const v 108 = items [ 108 ] !== undefined ? items [ 108 ] : null ; const v 109 = items [ 109 ] !== undefined ? items [ 109 ] : null ; const v 110 = items [ 110 ] !== undefined ? items [ 110 ] : null ; const v 111 = items [ 111 ] !== undefined ? items [ 111 ] : null ; const v 112 = items [ 112 ] !== undefined ? items [ 112 ] : null ; const v 113 = items [ 113 ] !== undefined ? items [ 113 ] : null ; const v 114 = items [ 114 ] !== undefined ? items [ 114 ] : null ; const v 115 = items [ 115 ] !== undefined ? items [ 115 ] : null ; const v 116 = items [ 116 ] !== undefined ? items [ 116 ] : null ; const v 117 = items [ 117 ] !== undefined ? items [ 117 ] : null ; const v 118 = items [ 118 ] !== undefined ? items [ 118 ] : null ; const v 119 = items [ 119 ] !== undefined ? items [ 119 ] : null ; const v 120 = items [ 120 ] !== undefined ? items [ 120 ] : null ; const v 121 = items [ 121 ] !== undefined ? items [ 121 ] : null ; const v 122 = items [ 122 ] !== undefined ? items [ 122 ] : null ; const v 123 = items [ 123 ] !== undefined ? items [ 123 ] : null ; const v 124 = items [ 124 ] !== undefined ? items [ 124 ] : null ; const v 125 = items [ 125 ] !== undefined ? items [ 125 ] : null ; const v 126 = items [ 126 ] !== undefined ? items [ 126 ] : null ; const v 127 = items [ 127 ] !== undefined ? items [ 127 ] : null ; const v 128 = items [ 128 ] !== undefined ? items [ 128 ] : null ; const v 129 = items [ 129 ] !== undefined ? items [ 129 ] : null ; const v 130 = items [ 130 ] !== undefined ? items [ 130 ] : null ; const v 131 = items [ 131 ] !== undefined ? items [ 131 ] : null ; const v 132 = items [ 132 ] !== undefined ? items [ 132 ] : null ; const v 133 = items [ 133 ] !== undefined ? items [ 133 ] : null ; const v 134 = items [ 134 ] !== undefined ? items [ 134 ] : null ; const v 135 = items [ 135 ] !== undefined ? items [ 135 ] : null ; const v 136 = items [ 136 ] !== undefined ? items [ 136 ] : null ; const v 137 = items [ 137 ] !== undefined ? items [ 137 ] : null ; const v 138 = items [ 138 ] !== undefined ? items [ 138 ] : null ; const v 139 = items [ 139 ] !== undefined ? items [ 139 ] : null ; const v 140 = items [ 140 ] !== undefined ? items [ 140 ] : null ; const v 141 = items [ 141 ] !== undefined ? items [ 141 ] : null ; const v 142 = items [ 142 ] !== undefined ? items [ 142 ] : null ; const v 143 = items [ 143 ] !== undefined ? items [ 143 ] : null ; const v 144 = items [ 144 ] !== undefined ? items [ 144 ] : null ; const v 145 = items [ 145 ] !== undefined ? items [ 145 ] : null ; const v 146 = items [ 146 ] !== undefined ? items [ 146 ] : null ; const v 147 = items [ 147 ] !== undefined ? items [ 147 ] : null ; const v 148 = items [ 148 ] !== undefined ? items [ 148 ] : null ; const v 149 = items [ 149 ] !== undefined ? items [ 149 ] : null ; const v 150 = items [ 150 ] !== undefined ? items [ 150 ] : null ; const v 151 = items [ 151 ] !== undefined ? items [ 151 ] : null ; const v 152 = items [ 152 ] !== undefined ? items [ 152 ] : null ; const v 153 = items [ 153 ] !== undefined ? items [ 153 ] : null ; const v 154 = items [ 154 ] !== undefined ? items [ 154 ] : null ; const v 155 = items [ 155 ] !== undefined ? items [ 155 ] : null ; const v 156 = items [ 156 ] !== undefined ? items [ 156 ] : null ; const v 157 = items [ 157 ] !== undefined ? items [ 157 ] : null ; const v 158 = items [ 158 ] !== undefined ? items [ 158 ] : null ; const v 159 = items [ 159 ] !== undefined ? items [ 159 ] : null ; const v 160 = items [ 160 ] !== undefined ? items [ 160 ] : null ; const v 161 = items [ 161 ] !== undefined ? items [ 161 ] : null ; const v 162 = items [ 162 ] !== undefined ? items [ 162 ] : null ; const v 163 = items [ 163 ] !== undefined ? items [ 163 ] : null ; const v 164 = items [ 164 ] !== undefined ? items [ 164 ] : null ; const v 165 = items [ 165 ] !== undefined ? items [ 165 ] : null ; const v 166 = items [ 166 ] !== undefined ? items [ 166 ] : null ; const v 167 = items [ 167 ] !== undefined ? items [ 167 ] : null ; const v 168 = items [ 168 ] !== undefined ? items [ 168 ] : null ; const v 169 = items [ 169 ] !== undefined ? items [ 169 ] : null ; const v 170 = items [ 170 ] !== undefined ? items [ 170 ] : null ; const v 171 = items [ 171 ] !== undefined ? items [ 171 ] : null ; const v 172 = items [ 172 ] !== undefined ? items [ 172 ] : null ; const v 173 = items [ 173 ] !== undefined ? items [ 173 ] : null ; const v 174 = items [ 174 ] !== undefined ? items [ 174 ] : null ; const v 175 = items [ 175 ] !== undefined ? items [ 175 ] : null ; const v 176 = items [ 176 ] !== undefined ? items [ 176 ] : null ; const v 177 = items [ 177 ] !== undefined ? items [ 177 ] : null ; const v 178 = items [ 178 ] !== undefined ? items [ 178 ] : null ; const v 179 = items [ 179 ] !== undefined ? items [ 179 ] : null ; const v 180 = items [ 180 ] !== undefined ? items [ 180 ] : null ; const v 181 = items [ 181 ] !== undefined ? items [ 181 ] : null ; const v 182 = items [ 182 ] !== undefined ? items [ 182 ] : null ; const v 183 = items [ 183 ] !== undefined ? items [ 183 ] : null ; const v 184 = items [ 184 ] !== undefined ? items [ 184 ] : null ; const v 185 = items [ 185 ] !== undefined ? items [ 185 ] : null ; const v 186 = items [ 186 ] !== undefined ? items [ 186 ] : null ; const v 187 = items [ 187 ] !== undefined ? items [ 187 ] : null ; const v 188 = items [ 188 ] !== undefined ? items [ 188 ] : null ; const v 189 = items [ 189 ] !== undefined ? items [ 189 ] : null ; const v 190 = items [ 190 ] !== undefined ? items [ 190 ] : null ; const v 191 = items [ 191 ] !== undefined ? items [ 191 ] : null ; const v 192 = items [ 192 ] !== undefined ? items [ 192 ] : null ; const v 193 = items [ 193 ] !== undefined ? items [ 193 ] : null ; const v 194 = items [ 194 ] !== undefined ? items [ 194 ] : null ; const v 195 = items [ 195 ] !== undefined ? items [ 195 ] : null ; const v 196 = items [ 196 ] !== undefined ? items [ 196 ] : null ; const v 197 = items [ 197 ] !== undefined ? items [ 197 ] : null ; const v 198 = items [ 198 ] !== undefined ? items [ 198 ] : null ;" }, { + "aliases": null, "block_ids": [ "2bc61a811414be749c17290832857c7f" ], diff --git a/crates/kebab-chunk/tests/fixtures/code-sample.kt.chunks.snapshot.json b/crates/kebab-chunk/tests/fixtures/code-sample.kt.chunks.snapshot.json index 97244e1..d629f7e 100644 --- a/crates/kebab-chunk/tests/fixtures/code-sample.kt.chunks.snapshot.json +++ b/crates/kebab-chunk/tests/fixtures/code-sample.kt.chunks.snapshot.json @@ -1,5 +1,6 @@ [ { + "aliases": null, "block_ids": [ "d11c97fb8204b59f00fccc5c8b64492e" ], @@ -22,6 +23,7 @@ "tokenized_korean_text": "import kotlin . collections . List import kotlin . collections . Map import kotlin . collections . MutableList import kotlin . collections . MutableMap import kotlin . collections . mutableListOf" }, { + "aliases": null, "block_ids": [ "8cd5b3ab9657de15405ee3ac5fcf75c1" ], @@ -44,6 +46,7 @@ "tokenized_korean_text": "fun computeMRR ( scores : List < Double >): Double { if ( scores . isEmpty ( ) ) { return 0 . 0 } return 1 . 0 / scores . size }" }, { + "aliases": null, "block_ids": [ "378cc4eede82b166c9b35b0e85b8c62f" ], @@ -66,6 +69,7 @@ "tokenized_korean_text": "data class MetricsCollector ( val scores : MutableList < Double > = mutableListOf ( ) , val labels : MutableList < String > = mutableListOf ( ) , val counts : MutableMap < String , Int > = mutableMapOf ( ) , val totals : MutableMap < String , Double > = mutableMapOf ( ) , val tags : MutableList < String > = mutableListOf ( ) , )" }, { + "aliases": null, "block_ids": [ "6f2dd4880b621f9340771a9dccb3b5f1" ], @@ -88,6 +92,7 @@ "tokenized_korean_text": "open class BaseEvaluator ( val name : String ) { fun evaluate ( data : List < String >) { val joined = data . joinToString (\",\") println ( joined ) } open fun describe ( ) : String = name }" }, { + "aliases": null, "block_ids": [ "f6f2b4549b29dc68836950d508a00207" ], @@ -110,6 +115,7 @@ "tokenized_korean_text": "fun MetricsCollector . run ( inputs : List < Double >) { for ( inp in inputs ) { scores . add ( inp ) } }" }, { + "aliases": null, "block_ids": [ "32386dba97278b43f9c892c8c4e78e9d" ], @@ -132,6 +138,7 @@ "tokenized_korean_text": "fun MetricsCollector . report ( ) : Map < String , Any > { return mapOf ( \" mean \" to 0 . 0 , \" count \" to scores . size , \" tags \" to tags , ) }" }, { + "aliases": null, "block_ids": [ "fbefaf4289794148afd38f22b2e1bd1d" ], @@ -154,6 +161,7 @@ "tokenized_korean_text": "class BigCompute { fun compute ( data : IntArray ) : Int { val v 0 = if ( 0 < data . size ) data [ 0 ] else 0 val v 1 = if ( 1 < data . size ) data [ 1 ] else 0 val v 2 = if ( 2 < data . size ) data [ 2 ] else 0 val v 3 = if ( 3 < data . size ) data [ 3 ] else 0 val v 4 = if ( 4 < data . size ) data [ 4 ] else 0 val v 5 = if ( 5 < data . size ) data [ 5 ] else 0 val v 6 = if ( 6 < data . size ) data [ 6 ] else 0 val v 7 = if ( 7 < data . size ) data [ 7 ] else 0 val v 8 = if ( 8 < data . size ) data [ 8 ] else 0 val v 9 = if ( 9 < data . size ) data [ 9 ] else 0 val v 10 = if ( 10 < data . size ) data [ 10 ] else 0 val v 11 = if ( 11 < data . size ) data [ 11 ] else 0 val v 12 = if ( 12 < data . size ) data [ 12 ] else 0 val v 13 = if ( 13 < data . size ) data [ 13 ] else 0 val v 14 = if ( 14 < data . size ) data [ 14 ] else 0 val v 15 = if ( 15 < data . size ) data [ 15 ] else 0 val v 16 = if ( 16 < data . size ) data [ 16 ] else 0 val v 17 = if ( 17 < data . size ) data [ 17 ] else 0 val v 18 = if ( 18 < data . size ) data [ 18 ] else 0 val v 19 = if ( 19 < data . size ) data [ 19 ] else 0 val v 20 = if ( 20 < data . size ) data [ 20 ] else 0 val v 21 = if ( 21 < data . size ) data [ 21 ] else 0 val v 22 = if ( 22 < data . size ) data [ 22 ] else 0 val v 23 = if ( 23 < data . size ) data [ 23 ] else 0 val v 24 = if ( 24 < data . size ) data [ 24 ] else 0 val v 25 = if ( 25 < data . size ) data [ 25 ] else 0 val v 26 = if ( 26 < data . size ) data [ 26 ] else 0 val v 27 = if ( 27 < data . size ) data [ 27 ] else 0 val v 28 = if ( 28 < data . size ) data [ 28 ] else 0 val v 29 = if ( 29 < data . size ) data [ 29 ] else 0 val v 30 = if ( 30 < data . size ) data [ 30 ] else 0 val v 31 = if ( 31 < data . size ) data [ 31 ] else 0 val v 32 = if ( 32 < data . size ) data [ 32 ] else 0 val v 33 = if ( 33 < data . size ) data [ 33 ] else 0 val v 34 = if ( 34 < data . size ) data [ 34 ] else 0 val v 35 = if ( 35 < data . size ) data [ 35 ] else 0 val v 36 = if ( 36 < data . size ) data [ 36 ] else 0 val v 37 = if ( 37 < data . size ) data [ 37 ] else 0 val v 38 = if ( 38 < data . size ) data [ 38 ] else 0 val v 39 = if ( 39 < data . size ) data [ 39 ] else 0 val v 40 = if ( 40 < data . size ) data [ 40 ] else 0 val v 41 = if ( 41 < data . size ) data [ 41 ] else 0 val v 42 = if ( 42 < data . size ) data [ 42 ] else 0 val v 43 = if ( 43 < data . size ) data [ 43 ] else 0 val v 44 = if ( 44 < data . size ) data [ 44 ] else 0 val v 45 = if ( 45 < data . size ) data [ 45 ] else 0 val v 46 = if ( 46 < data . size ) data [ 46 ] else 0 val v 47 = if ( 47 < data . size ) data [ 47 ] else 0 val v 48 = if ( 48 < data . size ) data [ 48 ] else 0 val v 49 = if ( 49 < data . size ) data [ 49 ] else 0 val v 50 = if ( 50 < data . size ) data [ 50 ] else 0 val v 51 = if ( 51 < data . size ) data [ 51 ] else 0 val v 52 = if ( 52 < data . size ) data [ 52 ] else 0 val v 53 = if ( 53 < data . size ) data [ 53 ] else 0 val v 54 = if ( 54 < data . size ) data [ 54 ] else 0 val v 55 = if ( 55 < data . size ) data [ 55 ] else 0 val v 56 = if ( 56 < data . size ) data [ 56 ] else 0 val v 57 = if ( 57 < data . size ) data [ 57 ] else 0 val v 58 = if ( 58 < data . size ) data [ 58 ] else 0 val v 59 = if ( 59 < data . size ) data [ 59 ] else 0 val v 60 = if ( 60 < data . size ) data [ 60 ] else 0 val v 61 = if ( 61 < data . size ) data [ 61 ] else 0 val v 62 = if ( 62 < data . size ) data [ 62 ] else 0 val v 63 = if ( 63 < data . size ) data [ 63 ] else 0 val v 64 = if ( 64 < data . size ) data [ 64 ] else 0 val v 65 = if ( 65 < data . size ) data [ 65 ] else 0 val v 66 = if ( 66 < data . size ) data [ 66 ] else 0 val v 67 = if ( 67 < data . size ) data [ 67 ] else 0 val v 68 = if ( 68 < data . size ) data [ 68 ] else 0 val v 69 = if ( 69 < data . size ) data [ 69 ] else 0 val v 70 = if ( 70 < data . size ) data [ 70 ] else 0 val v 71 = if ( 71 < data . size ) data [ 71 ] else 0 val v 72 = if ( 72 < data . size ) data [ 72 ] else 0 val v 73 = if ( 73 < data . size ) data [ 73 ] else 0 val v 74 = if ( 74 < data . size ) data [ 74 ] else 0 val v 75 = if ( 75 < data . size ) data [ 75 ] else 0 val v 76 = if ( 76 < data . size ) data [ 76 ] else 0 val v 77 = if ( 77 < data . size ) data [ 77 ] else 0 val v 78 = if ( 78 < data . size ) data [ 78 ] else 0 val v 79 = if ( 79 < data . size ) data [ 79 ] else 0 val v 80 = if ( 80 < data . size ) data [ 80 ] else 0 val v 81 = if ( 81 < data . size ) data [ 81 ] else 0 val v 82 = if ( 82 < data . size ) data [ 82 ] else 0 val v 83 = if ( 83 < data . size ) data [ 83 ] else 0 val v 84 = if ( 84 < data . size ) data [ 84 ] else 0 val v 85 = if ( 85 < data . size ) data [ 85 ] else 0 val v 86 = if ( 86 < data . size ) data [ 86 ] else 0 val v 87 = if ( 87 < data . size ) data [ 87 ] else 0 val v 88 = if ( 88 < data . size ) data [ 88 ] else 0 val v 89 = if ( 89 < data . size ) data [ 89 ] else 0 val v 90 = if ( 90 < data . size ) data [ 90 ] else 0 val v 91 = if ( 91 < data . size ) data [ 91 ] else 0 val v 92 = if ( 92 < data . size ) data [ 92 ] else 0 val v 93 = if ( 93 < data . size ) data [ 93 ] else 0 val v 94 = if ( 94 < data . size ) data [ 94 ] else 0 val v 95 = if ( 95 < data . size ) data [ 95 ] else 0 val v 96 = if ( 96 < data . size ) data [ 96 ] else 0 val v 97 = if ( 97 < data . size ) data [ 97 ] else 0 val v 98 = if ( 98 < data . size ) data [ 98 ] else 0 val v 99 = if ( 99 < data . size ) data [ 99 ] else 0 val v 100 = if ( 100 < data . size ) data [ 100 ] else 0 val v 101 = if ( 101 < data . size ) data [ 101 ] else 0 val v 102 = if ( 102 < data . size ) data [ 102 ] else 0 val v 103 = if ( 103 < data . size ) data [ 103 ] else 0 val v 104 = if ( 104 < data . size ) data [ 104 ] else 0 val v 105 = if ( 105 < data . size ) data [ 105 ] else 0 val v 106 = if ( 106 < data . size ) data [ 106 ] else 0 val v 107 = if ( 107 < data . size ) data [ 107 ] else 0 val v 108 = if ( 108 < data . size ) data [ 108 ] else 0 val v 109 = if ( 109 < data . size ) data [ 109 ] else 0 val v 110 = if ( 110 < data . size ) data [ 110 ] else 0 val v 111 = if ( 111 < data . size ) data [ 111 ] else 0 val v 112 = if ( 112 < data . size ) data [ 112 ] else 0 val v 113 = if ( 113 < data . size ) data [ 113 ] else 0 val v 114 = if ( 114 < data . size ) data [ 114 ] else 0 val v 115 = if ( 115 < data . size ) data [ 115 ] else 0 val v 116 = if ( 116 < data . size ) data [ 116 ] else 0 val v 117 = if ( 117 < data . size ) data [ 117 ] else 0 val v 118 = if ( 118 < data . size ) data [ 118 ] else 0 val v 119 = if ( 119 < data . size ) data [ 119 ] else 0 val v 120 = if ( 120 < data . size ) data [ 120 ] else 0 val v 121 = if ( 121 < data . size ) data [ 121 ] else 0 val v 122 = if ( 122 < data . size ) data [ 122 ] else 0 val v 123 = if ( 123 < data . size ) data [ 123 ] else 0 val v 124 = if ( 124 < data . size ) data [ 124 ] else 0 val v 125 = if ( 125 < data . size ) data [ 125 ] else 0 val v 126 = if ( 126 < data . size ) data [ 126 ] else 0 val v 127 = if ( 127 < data . size ) data [ 127 ] else 0 val v 128 = if ( 128 < data . size ) data [ 128 ] else 0 val v 129 = if ( 129 < data . size ) data [ 129 ] else 0 val v 130 = if ( 130 < data . size ) data [ 130 ] else 0 val v 131 = if ( 131 < data . size ) data [ 131 ] else 0 val v 132 = if ( 132 < data . size ) data [ 132 ] else 0 val v 133 = if ( 133 < data . size ) data [ 133 ] else 0 val v 134 = if ( 134 < data . size ) data [ 134 ] else 0 val v 135 = if ( 135 < data . size ) data [ 135 ] else 0 val v 136 = if ( 136 < data . size ) data [ 136 ] else 0 val v 137 = if ( 137 < data . size ) data [ 137 ] else 0 val v 138 = if ( 138 < data . size ) data [ 138 ] else 0 val v 139 = if ( 139 < data . size ) data [ 139 ] else 0 val v 140 = if ( 140 < data . size ) data [ 140 ] else 0 val v 141 = if ( 141 < data . size ) data [ 141 ] else 0 val v 142 = if ( 142 < data . size ) data [ 142 ] else 0 val v 143 = if ( 143 < data . size ) data [ 143 ] else 0 val v 144 = if ( 144 < data . size ) data [ 144 ] else 0 val v 145 = if ( 145 < data . size ) data [ 145 ] else 0 val v 146 = if ( 146 < data . size ) data [ 146 ] else 0 val v 147 = if ( 147 < data . size ) data [ 147 ] else 0 val v 148 = if ( 148 < data . size ) data [ 148 ] else 0 val v 149 = if ( 149 < data . size ) data [ 149 ] else 0 val v 150 = if ( 150 < data . size ) data [ 150 ] else 0 val v 151 = if ( 151 < data . size ) data [ 151 ] else 0 val v 152 = if ( 152 < data . size ) data [ 152 ] else 0 val v 153 = if ( 153 < data . size ) data [ 153 ] else 0 val v 154 = if ( 154 < data . size ) data [ 154 ] else 0 val v 155 = if ( 155 < data . size ) data [ 155 ] else 0 val v 156 = if ( 156 < data . size ) data [ 156 ] else 0 val v 157 = if ( 157 < data . size ) data [ 157 ] else 0 val v 158 = if ( 158 < data . size ) data [ 158 ] else 0 val v 159 = if ( 159 < data . size ) data [ 159 ] else 0 val v 160 = if ( 160 < data . size ) data [ 160 ] else 0 val v 161 = if ( 161 < data . size ) data [ 161 ] else 0 val v 162 = if ( 162 < data . size ) data [ 162 ] else 0 val v 163 = if ( 163 < data . size ) data [ 163 ] else 0 val v 164 = if ( 164 < data . size ) data [ 164 ] else 0 val v 165 = if ( 165 < data . size ) data [ 165 ] else 0 val v 166 = if ( 166 < data . size ) data [ 166 ] else 0 val v 167 = if ( 167 < data . size ) data [ 167 ] else 0 val v 168 = if ( 168 < data . size ) data [ 168 ] else 0 val v 169 = if ( 169 < data . size ) data [ 169 ] else 0 val v 170 = if ( 170 < data . size ) data [ 170 ] else 0 val v 171 = if ( 171 < data . size ) data [ 171 ] else 0 val v 172 = if ( 172 < data . size ) data [ 172 ] else 0 val v 173 = if ( 173 < data . size ) data [ 173 ] else 0 val v 174 = if ( 174 < data . size ) data [ 174 ] else 0 val v 175 = if ( 175 < data . size ) data [ 175 ] else 0 val v 176 = if ( 176 < data . size ) data [ 176 ] else 0 val v 177 = if ( 177 < data . size ) data [ 177 ] else 0 val v 178 = if ( 178 < data . size ) data [ 178 ] else 0 val v 179 = if ( 179 < data . size ) data [ 179 ] else 0 val v 180 = if ( 180 < data . size ) data [ 180 ] else 0 val v 181 = if ( 181 < data . size ) data [ 181 ] else 0 val v 182 = if ( 182 < data . size ) data [ 182 ] else 0 val v 183 = if ( 183 < data . size ) data [ 183 ] else 0 val v 184 = if ( 184 < data . size ) data [ 184 ] else 0 val v 185 = if ( 185 < data . size ) data [ 185 ] else 0 val v 186 = if ( 186 < data . size ) data [ 186 ] else 0 val v 187 = if ( 187 < data . size ) data [ 187 ] else 0 val v 188 = if ( 188 < data . size ) data [ 188 ] else 0 val v 189 = if ( 189 < data . size ) data [ 189 ] else 0 val v 190 = if ( 190 < data . size ) data [ 190 ] else 0 val v 191 = if ( 191 < data . size ) data [ 191 ] else 0 val v 192 = if ( 192 < data . size ) data [ 192 ] else 0 val v 193 = if ( 193 < data . size ) data [ 193 ] else 0 val v 194 = if ( 194 < data . size ) data [ 194 ] else 0 val v 195 = if ( 195 < data . size ) data [ 195 ] else 0 val v 196 = if ( 196 < data . size ) data [ 196 ] else 0 val v 197 = if ( 197 < data . size ) data [ 197 ] else 0" }, { + "aliases": null, "block_ids": [ "fbefaf4289794148afd38f22b2e1bd1d" ], diff --git a/crates/kebab-chunk/tests/fixtures/code-sample.py.chunks.snapshot.json b/crates/kebab-chunk/tests/fixtures/code-sample.py.chunks.snapshot.json index 48507b0..2cd6026 100644 --- a/crates/kebab-chunk/tests/fixtures/code-sample.py.chunks.snapshot.json +++ b/crates/kebab-chunk/tests/fixtures/code-sample.py.chunks.snapshot.json @@ -1,5 +1,6 @@ [ { + "aliases": null, "block_ids": [ "bd1be1fd8b8f77e2874755010b36e617" ], @@ -22,6 +23,7 @@ "tokenized_korean_text": "import os import sys from typing import List from pathlib import Path from collections import defaultdict" }, { + "aliases": null, "block_ids": [ "2fe948bb529221e94c5139951cc65acf" ], @@ -44,6 +46,7 @@ "tokenized_korean_text": "def compute _ mrr ( scores ) : if not scores : return 0 . 0 return sum ( 1 . 0 / r for r in scores ) / len ( scores )" }, { + "aliases": null, "block_ids": [ "ff944bad66bea107fd2500c35d7ddf68" ], @@ -66,6 +69,7 @@ "tokenized_korean_text": "class MetricsCollector : def __ init __( self ) : self . scores = [ ] self . labels = [ ] self . counts = defaultdict ( int ) self . totals = defaultdict ( float ) self . tags = [ ]" }, { + "aliases": null, "block_ids": [ "1e75f40c64ba21ad0bada0f5d35dc232" ], @@ -88,6 +92,7 @@ "tokenized_korean_text": "class BaseEvaluator : def evaluate ( self , data ) : raise NotImplementedError def batch _ evaluate ( self , items ) : results = [ ] for item in items : results . append ( self . evaluate ( item ) ) return results def name ( self ) : return type ( self ).__ name __" }, { + "aliases": null, "block_ids": [ "33d08d6405adb459e90b8d67bab5cc80" ], @@ -110,6 +115,7 @@ "tokenized_korean_text": "class MetricsCollector : def run ( self , inputs ) : for inp in inputs : score = self ._ score ( inp ) self . scores . append ( score )" }, { + "aliases": null, "block_ids": [ "af3d89eb1be6e11dfd14af3c86a8ba9c" ], @@ -132,6 +138,7 @@ "tokenized_korean_text": "class MetricsCollector : def report ( self ) : return { ' mean ': sum ( self . scores ) / max ( len ( self . scores ) , 1 ) , ' count ': len ( self . scores ) , ' tags ': self . tags , }" }, { + "aliases": null, "block_ids": [ "c86acf6ae110d7f5681093c93ee0e5e5" ], @@ -154,6 +161,7 @@ "tokenized_korean_text": "def big _ compute ( data ) : v 0 = data [ 0 ] if 0 < len ( data ) else 0 v 1 = data [ 1 ] if 1 < len ( data ) else 0 v 2 = data [ 2 ] if 2 < len ( data ) else 0 v 3 = data [ 3 ] if 3 < len ( data ) else 0 v 4 = data [ 4 ] if 4 < len ( data ) else 0 v 5 = data [ 5 ] if 5 < len ( data ) else 0 v 6 = data [ 6 ] if 6 < len ( data ) else 0 v 7 = data [ 7 ] if 7 < len ( data ) else 0 v 8 = data [ 8 ] if 8 < len ( data ) else 0 v 9 = data [ 9 ] if 9 < len ( data ) else 0 v 10 = data [ 10 ] if 10 < len ( data ) else 0 v 11 = data [ 11 ] if 11 < len ( data ) else 0 v 12 = data [ 12 ] if 12 < len ( data ) else 0 v 13 = data [ 13 ] if 13 < len ( data ) else 0 v 14 = data [ 14 ] if 14 < len ( data ) else 0 v 15 = data [ 15 ] if 15 < len ( data ) else 0 v 16 = data [ 16 ] if 16 < len ( data ) else 0 v 17 = data [ 17 ] if 17 < len ( data ) else 0 v 18 = data [ 18 ] if 18 < len ( data ) else 0 v 19 = data [ 19 ] if 19 < len ( data ) else 0 v 20 = data [ 20 ] if 20 < len ( data ) else 0 v 21 = data [ 21 ] if 21 < len ( data ) else 0 v 22 = data [ 22 ] if 22 < len ( data ) else 0 v 23 = data [ 23 ] if 23 < len ( data ) else 0 v 24 = data [ 24 ] if 24 < len ( data ) else 0 v 25 = data [ 25 ] if 25 < len ( data ) else 0 v 26 = data [ 26 ] if 26 < len ( data ) else 0 v 27 = data [ 27 ] if 27 < len ( data ) else 0 v 28 = data [ 28 ] if 28 < len ( data ) else 0 v 29 = data [ 29 ] if 29 < len ( data ) else 0 v 30 = data [ 30 ] if 30 < len ( data ) else 0 v 31 = data [ 31 ] if 31 < len ( data ) else 0 v 32 = data [ 32 ] if 32 < len ( data ) else 0 v 33 = data [ 33 ] if 33 < len ( data ) else 0 v 34 = data [ 34 ] if 34 < len ( data ) else 0 v 35 = data [ 35 ] if 35 < len ( data ) else 0 v 36 = data [ 36 ] if 36 < len ( data ) else 0 v 37 = data [ 37 ] if 37 < len ( data ) else 0 v 38 = data [ 38 ] if 38 < len ( data ) else 0 v 39 = data [ 39 ] if 39 < len ( data ) else 0 v 40 = data [ 40 ] if 40 < len ( data ) else 0 v 41 = data [ 41 ] if 41 < len ( data ) else 0 v 42 = data [ 42 ] if 42 < len ( data ) else 0 v 43 = data [ 43 ] if 43 < len ( data ) else 0 v 44 = data [ 44 ] if 44 < len ( data ) else 0 v 45 = data [ 45 ] if 45 < len ( data ) else 0 v 46 = data [ 46 ] if 46 < len ( data ) else 0 v 47 = data [ 47 ] if 47 < len ( data ) else 0 v 48 = data [ 48 ] if 48 < len ( data ) else 0 v 49 = data [ 49 ] if 49 < len ( data ) else 0 v 50 = data [ 50 ] if 50 < len ( data ) else 0 v 51 = data [ 51 ] if 51 < len ( data ) else 0 v 52 = data [ 52 ] if 52 < len ( data ) else 0 v 53 = data [ 53 ] if 53 < len ( data ) else 0 v 54 = data [ 54 ] if 54 < len ( data ) else 0 v 55 = data [ 55 ] if 55 < len ( data ) else 0 v 56 = data [ 56 ] if 56 < len ( data ) else 0 v 57 = data [ 57 ] if 57 < len ( data ) else 0 v 58 = data [ 58 ] if 58 < len ( data ) else 0 v 59 = data [ 59 ] if 59 < len ( data ) else 0 v 60 = data [ 60 ] if 60 < len ( data ) else 0 v 61 = data [ 61 ] if 61 < len ( data ) else 0 v 62 = data [ 62 ] if 62 < len ( data ) else 0 v 63 = data [ 63 ] if 63 < len ( data ) else 0 v 64 = data [ 64 ] if 64 < len ( data ) else 0 v 65 = data [ 65 ] if 65 < len ( data ) else 0 v 66 = data [ 66 ] if 66 < len ( data ) else 0 v 67 = data [ 67 ] if 67 < len ( data ) else 0 v 68 = data [ 68 ] if 68 < len ( data ) else 0 v 69 = data [ 69 ] if 69 < len ( data ) else 0 v 70 = data [ 70 ] if 70 < len ( data ) else 0 v 71 = data [ 71 ] if 71 < len ( data ) else 0 v 72 = data [ 72 ] if 72 < len ( data ) else 0 v 73 = data [ 73 ] if 73 < len ( data ) else 0 v 74 = data [ 74 ] if 74 < len ( data ) else 0 v 75 = data [ 75 ] if 75 < len ( data ) else 0 v 76 = data [ 76 ] if 76 < len ( data ) else 0 v 77 = data [ 77 ] if 77 < len ( data ) else 0 v 78 = data [ 78 ] if 78 < len ( data ) else 0 v 79 = data [ 79 ] if 79 < len ( data ) else 0 v 80 = data [ 80 ] if 80 < len ( data ) else 0 v 81 = data [ 81 ] if 81 < len ( data ) else 0 v 82 = data [ 82 ] if 82 < len ( data ) else 0 v 83 = data [ 83 ] if 83 < len ( data ) else 0 v 84 = data [ 84 ] if 84 < len ( data ) else 0 v 85 = data [ 85 ] if 85 < len ( data ) else 0 v 86 = data [ 86 ] if 86 < len ( data ) else 0 v 87 = data [ 87 ] if 87 < len ( data ) else 0 v 88 = data [ 88 ] if 88 < len ( data ) else 0 v 89 = data [ 89 ] if 89 < len ( data ) else 0 v 90 = data [ 90 ] if 90 < len ( data ) else 0 v 91 = data [ 91 ] if 91 < len ( data ) else 0 v 92 = data [ 92 ] if 92 < len ( data ) else 0 v 93 = data [ 93 ] if 93 < len ( data ) else 0 v 94 = data [ 94 ] if 94 < len ( data ) else 0 v 95 = data [ 95 ] if 95 < len ( data ) else 0 v 96 = data [ 96 ] if 96 < len ( data ) else 0 v 97 = data [ 97 ] if 97 < len ( data ) else 0 v 98 = data [ 98 ] if 98 < len ( data ) else 0 v 99 = data [ 99 ] if 99 < len ( data ) else 0 v 100 = data [ 100 ] if 100 < len ( data ) else 0 v 101 = data [ 101 ] if 101 < len ( data ) else 0 v 102 = data [ 102 ] if 102 < len ( data ) else 0 v 103 = data [ 103 ] if 103 < len ( data ) else 0 v 104 = data [ 104 ] if 104 < len ( data ) else 0 v 105 = data [ 105 ] if 105 < len ( data ) else 0 v 106 = data [ 106 ] if 106 < len ( data ) else 0 v 107 = data [ 107 ] if 107 < len ( data ) else 0 v 108 = data [ 108 ] if 108 < len ( data ) else 0 v 109 = data [ 109 ] if 109 < len ( data ) else 0 v 110 = data [ 110 ] if 110 < len ( data ) else 0 v 111 = data [ 111 ] if 111 < len ( data ) else 0 v 112 = data [ 112 ] if 112 < len ( data ) else 0 v 113 = data [ 113 ] if 113 < len ( data ) else 0 v 114 = data [ 114 ] if 114 < len ( data ) else 0 v 115 = data [ 115 ] if 115 < len ( data ) else 0 v 116 = data [ 116 ] if 116 < len ( data ) else 0 v 117 = data [ 117 ] if 117 < len ( data ) else 0 v 118 = data [ 118 ] if 118 < len ( data ) else 0 v 119 = data [ 119 ] if 119 < len ( data ) else 0 v 120 = data [ 120 ] if 120 < len ( data ) else 0 v 121 = data [ 121 ] if 121 < len ( data ) else 0 v 122 = data [ 122 ] if 122 < len ( data ) else 0 v 123 = data [ 123 ] if 123 < len ( data ) else 0 v 124 = data [ 124 ] if 124 < len ( data ) else 0 v 125 = data [ 125 ] if 125 < len ( data ) else 0 v 126 = data [ 126 ] if 126 < len ( data ) else 0 v 127 = data [ 127 ] if 127 < len ( data ) else 0 v 128 = data [ 128 ] if 128 < len ( data ) else 0 v 129 = data [ 129 ] if 129 < len ( data ) else 0 v 130 = data [ 130 ] if 130 < len ( data ) else 0 v 131 = data [ 131 ] if 131 < len ( data ) else 0 v 132 = data [ 132 ] if 132 < len ( data ) else 0 v 133 = data [ 133 ] if 133 < len ( data ) else 0 v 134 = data [ 134 ] if 134 < len ( data ) else 0 v 135 = data [ 135 ] if 135 < len ( data ) else 0 v 136 = data [ 136 ] if 136 < len ( data ) else 0 v 137 = data [ 137 ] if 137 < len ( data ) else 0 v 138 = data [ 138 ] if 138 < len ( data ) else 0 v 139 = data [ 139 ] if 139 < len ( data ) else 0 v 140 = data [ 140 ] if 140 < len ( data ) else 0 v 141 = data [ 141 ] if 141 < len ( data ) else 0 v 142 = data [ 142 ] if 142 < len ( data ) else 0 v 143 = data [ 143 ] if 143 < len ( data ) else 0 v 144 = data [ 144 ] if 144 < len ( data ) else 0 v 145 = data [ 145 ] if 145 < len ( data ) else 0 v 146 = data [ 146 ] if 146 < len ( data ) else 0 v 147 = data [ 147 ] if 147 < len ( data ) else 0 v 148 = data [ 148 ] if 148 < len ( data ) else 0 v 149 = data [ 149 ] if 149 < len ( data ) else 0 v 150 = data [ 150 ] if 150 < len ( data ) else 0 v 151 = data [ 151 ] if 151 < len ( data ) else 0 v 152 = data [ 152 ] if 152 < len ( data ) else 0 v 153 = data [ 153 ] if 153 < len ( data ) else 0 v 154 = data [ 154 ] if 154 < len ( data ) else 0 v 155 = data [ 155 ] if 155 < len ( data ) else 0 v 156 = data [ 156 ] if 156 < len ( data ) else 0 v 157 = data [ 157 ] if 157 < len ( data ) else 0 v 158 = data [ 158 ] if 158 < len ( data ) else 0 v 159 = data [ 159 ] if 159 < len ( data ) else 0 v 160 = data [ 160 ] if 160 < len ( data ) else 0 v 161 = data [ 161 ] if 161 < len ( data ) else 0 v 162 = data [ 162 ] if 162 < len ( data ) else 0 v 163 = data [ 163 ] if 163 < len ( data ) else 0 v 164 = data [ 164 ] if 164 < len ( data ) else 0 v 165 = data [ 165 ] if 165 < len ( data ) else 0 v 166 = data [ 166 ] if 166 < len ( data ) else 0 v 167 = data [ 167 ] if 167 < len ( data ) else 0 v 168 = data [ 168 ] if 168 < len ( data ) else 0 v 169 = data [ 169 ] if 169 < len ( data ) else 0 v 170 = data [ 170 ] if 170 < len ( data ) else 0 v 171 = data [ 171 ] if 171 < len ( data ) else 0 v 172 = data [ 172 ] if 172 < len ( data ) else 0 v 173 = data [ 173 ] if 173 < len ( data ) else 0 v 174 = data [ 174 ] if 174 < len ( data ) else 0 v 175 = data [ 175 ] if 175 < len ( data ) else 0 v 176 = data [ 176 ] if 176 < len ( data ) else 0 v 177 = data [ 177 ] if 177 < len ( data ) else 0 v 178 = data [ 178 ] if 178 < len ( data ) else 0 v 179 = data [ 179 ] if 179 < len ( data ) else 0 v 180 = data [ 180 ] if 180 < len ( data ) else 0 v 181 = data [ 181 ] if 181 < len ( data ) else 0 v 182 = data [ 182 ] if 182 < len ( data ) else 0 v 183 = data [ 183 ] if 183 < len ( data ) else 0 v 184 = data [ 184 ] if 184 < len ( data ) else 0 v 185 = data [ 185 ] if 185 < len ( data ) else 0 v 186 = data [ 186 ] if 186 < len ( data ) else 0 v 187 = data [ 187 ] if 187 < len ( data ) else 0 v 188 = data [ 188 ] if 188 < len ( data ) else 0 v 189 = data [ 189 ] if 189 < len ( data ) else 0 v 190 = data [ 190 ] if 190 < len ( data ) else 0 v 191 = data [ 191 ] if 191 < len ( data ) else 0 v 192 = data [ 192 ] if 192 < len ( data ) else 0 v 193 = data [ 193 ] if 193 < len ( data ) else 0 v 194 = data [ 194 ] if 194 < len ( data ) else 0 v 195 = data [ 195 ] if 195 < len ( data ) else 0 v 196 = data [ 196 ] if 196 < len ( data ) else 0 v 197 = data [ 197 ] if 197 < len ( data ) else 0 v 198 = data [ 198 ] if 198 < len ( data ) else 0" }, { + "aliases": null, "block_ids": [ "c86acf6ae110d7f5681093c93ee0e5e5" ], diff --git a/crates/kebab-chunk/tests/fixtures/code-sample.ts.chunks.snapshot.json b/crates/kebab-chunk/tests/fixtures/code-sample.ts.chunks.snapshot.json index e9de78f..54e5587 100644 --- a/crates/kebab-chunk/tests/fixtures/code-sample.ts.chunks.snapshot.json +++ b/crates/kebab-chunk/tests/fixtures/code-sample.ts.chunks.snapshot.json @@ -1,5 +1,6 @@ [ { + "aliases": null, "block_ids": [ "29c56554514c80a92a9d12410056e168" ], @@ -22,6 +23,7 @@ "tokenized_korean_text": "import { readFileSync } from ' fs '; import { join } from ' path '; import type { Config } from './ config '; import { Logger } from './ logger '; import { EventEmitter } from ' events ';" }, { + "aliases": null, "block_ids": [ "e3f542c4928032926a1e21a159686a34" ], @@ -44,6 +46,7 @@ "tokenized_korean_text": "export function parseInput ( raw : string ) : number | null { const trimmed = raw . trim (); const n = Number ( trimmed ); if ( isNaN ( n ) ) return null ; return n ; }" }, { + "aliases": null, "block_ids": [ "77d7f5ea7af7be27611adcbcee7c2e8f" ], @@ -66,6 +69,7 @@ "tokenized_korean_text": "export interface Frobable { frob ( ) : string ; frobTwice ( ) : string ; readonly name : string ; readonly tags : string []; count : number ; reset ( ) : void ; }" }, { + "aliases": null, "block_ids": [ "ee878891c19c9bacebe2e2d262c2ea77" ], @@ -88,6 +92,7 @@ "tokenized_korean_text": "export class Foo implements Frobable { constructor ( public readonly name : string , public value : number , public tags : string [ ] = [ ] , ) {} frob ( ) : string { return this . name ; } frobTwice ( ) : string { return this . name . repeat ( 2 ); } reset ( ) : void { this . value = 0 ; } }" }, { + "aliases": null, "block_ids": [ "df08aa572f5c85d0e5d28d6490acc7bc" ], @@ -110,6 +115,7 @@ "tokenized_korean_text": "export class Foo { double ( ) : number { const result = this . value * 2 ; if ( result > Number . MAX _ SAFE _ INTEGER ) { return Number . MAX _ SAFE _ INTEGER ; } return result ; } }" }, { + "aliases": null, "block_ids": [ "91aadf18fa97c1d7c94019e0968bc9c8" ], @@ -132,6 +138,7 @@ "tokenized_korean_text": "export class Foo { triple ( ) : number { const result = this . value * 3 ; if ( result > Number . MAX _ SAFE _ INTEGER ) { return Number . MAX _ SAFE _ INTEGER ; } return result ; } }" }, { + "aliases": null, "block_ids": [ "d719400f1d79b522d0a1267331966be0" ], @@ -154,6 +161,7 @@ "tokenized_korean_text": "export class BigProcessor { process ( items : string [ ] ) : string [ ] { const v 0 = items [ 0 ] ?? ''; const v 1 = items [ 1 ] ?? ''; const v 2 = items [ 2 ] ?? ''; const v 3 = items [ 3 ] ?? ''; const v 4 = items [ 4 ] ?? ''; const v 5 = items [ 5 ] ?? ''; const v 6 = items [ 6 ] ?? ''; const v 7 = items [ 7 ] ?? ''; const v 8 = items [ 8 ] ?? ''; const v 9 = items [ 9 ] ?? ''; const v 10 = items [ 10 ] ?? ''; const v 11 = items [ 11 ] ?? ''; const v 12 = items [ 12 ] ?? ''; const v 13 = items [ 13 ] ?? ''; const v 14 = items [ 14 ] ?? ''; const v 15 = items [ 15 ] ?? ''; const v 16 = items [ 16 ] ?? ''; const v 17 = items [ 17 ] ?? ''; const v 18 = items [ 18 ] ?? ''; const v 19 = items [ 19 ] ?? ''; const v 20 = items [ 20 ] ?? ''; const v 21 = items [ 21 ] ?? ''; const v 22 = items [ 22 ] ?? ''; const v 23 = items [ 23 ] ?? ''; const v 24 = items [ 24 ] ?? ''; const v 25 = items [ 25 ] ?? ''; const v 26 = items [ 26 ] ?? ''; const v 27 = items [ 27 ] ?? ''; const v 28 = items [ 28 ] ?? ''; const v 29 = items [ 29 ] ?? ''; const v 30 = items [ 30 ] ?? ''; const v 31 = items [ 31 ] ?? ''; const v 32 = items [ 32 ] ?? ''; const v 33 = items [ 33 ] ?? ''; const v 34 = items [ 34 ] ?? ''; const v 35 = items [ 35 ] ?? ''; const v 36 = items [ 36 ] ?? ''; const v 37 = items [ 37 ] ?? ''; const v 38 = items [ 38 ] ?? ''; const v 39 = items [ 39 ] ?? ''; const v 40 = items [ 40 ] ?? ''; const v 41 = items [ 41 ] ?? ''; const v 42 = items [ 42 ] ?? ''; const v 43 = items [ 43 ] ?? ''; const v 44 = items [ 44 ] ?? ''; const v 45 = items [ 45 ] ?? ''; const v 46 = items [ 46 ] ?? ''; const v 47 = items [ 47 ] ?? ''; const v 48 = items [ 48 ] ?? ''; const v 49 = items [ 49 ] ?? ''; const v 50 = items [ 50 ] ?? ''; const v 51 = items [ 51 ] ?? ''; const v 52 = items [ 52 ] ?? ''; const v 53 = items [ 53 ] ?? ''; const v 54 = items [ 54 ] ?? ''; const v 55 = items [ 55 ] ?? ''; const v 56 = items [ 56 ] ?? ''; const v 57 = items [ 57 ] ?? ''; const v 58 = items [ 58 ] ?? ''; const v 59 = items [ 59 ] ?? ''; const v 60 = items [ 60 ] ?? ''; const v 61 = items [ 61 ] ?? ''; const v 62 = items [ 62 ] ?? ''; const v 63 = items [ 63 ] ?? ''; const v 64 = items [ 64 ] ?? ''; const v 65 = items [ 65 ] ?? ''; const v 66 = items [ 66 ] ?? ''; const v 67 = items [ 67 ] ?? ''; const v 68 = items [ 68 ] ?? ''; const v 69 = items [ 69 ] ?? ''; const v 70 = items [ 70 ] ?? ''; const v 71 = items [ 71 ] ?? ''; const v 72 = items [ 72 ] ?? ''; const v 73 = items [ 73 ] ?? ''; const v 74 = items [ 74 ] ?? ''; const v 75 = items [ 75 ] ?? ''; const v 76 = items [ 76 ] ?? ''; const v 77 = items [ 77 ] ?? ''; const v 78 = items [ 78 ] ?? ''; const v 79 = items [ 79 ] ?? ''; const v 80 = items [ 80 ] ?? ''; const v 81 = items [ 81 ] ?? ''; const v 82 = items [ 82 ] ?? ''; const v 83 = items [ 83 ] ?? ''; const v 84 = items [ 84 ] ?? ''; const v 85 = items [ 85 ] ?? ''; const v 86 = items [ 86 ] ?? ''; const v 87 = items [ 87 ] ?? ''; const v 88 = items [ 88 ] ?? ''; const v 89 = items [ 89 ] ?? ''; const v 90 = items [ 90 ] ?? ''; const v 91 = items [ 91 ] ?? ''; const v 92 = items [ 92 ] ?? ''; const v 93 = items [ 93 ] ?? ''; const v 94 = items [ 94 ] ?? ''; const v 95 = items [ 95 ] ?? ''; const v 96 = items [ 96 ] ?? ''; const v 97 = items [ 97 ] ?? ''; const v 98 = items [ 98 ] ?? ''; const v 99 = items [ 99 ] ?? ''; const v 100 = items [ 100 ] ?? ''; const v 101 = items [ 101 ] ?? ''; const v 102 = items [ 102 ] ?? ''; const v 103 = items [ 103 ] ?? ''; const v 104 = items [ 104 ] ?? ''; const v 105 = items [ 105 ] ?? ''; const v 106 = items [ 106 ] ?? ''; const v 107 = items [ 107 ] ?? ''; const v 108 = items [ 108 ] ?? ''; const v 109 = items [ 109 ] ?? ''; const v 110 = items [ 110 ] ?? ''; const v 111 = items [ 111 ] ?? ''; const v 112 = items [ 112 ] ?? ''; const v 113 = items [ 113 ] ?? ''; const v 114 = items [ 114 ] ?? ''; const v 115 = items [ 115 ] ?? ''; const v 116 = items [ 116 ] ?? ''; const v 117 = items [ 117 ] ?? ''; const v 118 = items [ 118 ] ?? ''; const v 119 = items [ 119 ] ?? ''; const v 120 = items [ 120 ] ?? ''; const v 121 = items [ 121 ] ?? ''; const v 122 = items [ 122 ] ?? ''; const v 123 = items [ 123 ] ?? ''; const v 124 = items [ 124 ] ?? ''; const v 125 = items [ 125 ] ?? ''; const v 126 = items [ 126 ] ?? ''; const v 127 = items [ 127 ] ?? ''; const v 128 = items [ 128 ] ?? ''; const v 129 = items [ 129 ] ?? ''; const v 130 = items [ 130 ] ?? ''; const v 131 = items [ 131 ] ?? ''; const v 132 = items [ 132 ] ?? ''; const v 133 = items [ 133 ] ?? ''; const v 134 = items [ 134 ] ?? ''; const v 135 = items [ 135 ] ?? ''; const v 136 = items [ 136 ] ?? ''; const v 137 = items [ 137 ] ?? ''; const v 138 = items [ 138 ] ?? ''; const v 139 = items [ 139 ] ?? ''; const v 140 = items [ 140 ] ?? ''; const v 141 = items [ 141 ] ?? ''; const v 142 = items [ 142 ] ?? ''; const v 143 = items [ 143 ] ?? ''; const v 144 = items [ 144 ] ?? ''; const v 145 = items [ 145 ] ?? ''; const v 146 = items [ 146 ] ?? ''; const v 147 = items [ 147 ] ?? ''; const v 148 = items [ 148 ] ?? ''; const v 149 = items [ 149 ] ?? ''; const v 150 = items [ 150 ] ?? ''; const v 151 = items [ 151 ] ?? ''; const v 152 = items [ 152 ] ?? ''; const v 153 = items [ 153 ] ?? ''; const v 154 = items [ 154 ] ?? ''; const v 155 = items [ 155 ] ?? ''; const v 156 = items [ 156 ] ?? ''; const v 157 = items [ 157 ] ?? ''; const v 158 = items [ 158 ] ?? ''; const v 159 = items [ 159 ] ?? ''; const v 160 = items [ 160 ] ?? ''; const v 161 = items [ 161 ] ?? ''; const v 162 = items [ 162 ] ?? ''; const v 163 = items [ 163 ] ?? ''; const v 164 = items [ 164 ] ?? ''; const v 165 = items [ 165 ] ?? ''; const v 166 = items [ 166 ] ?? ''; const v 167 = items [ 167 ] ?? ''; const v 168 = items [ 168 ] ?? ''; const v 169 = items [ 169 ] ?? ''; const v 170 = items [ 170 ] ?? ''; const v 171 = items [ 171 ] ?? ''; const v 172 = items [ 172 ] ?? ''; const v 173 = items [ 173 ] ?? ''; const v 174 = items [ 174 ] ?? ''; const v 175 = items [ 175 ] ?? ''; const v 176 = items [ 176 ] ?? ''; const v 177 = items [ 177 ] ?? ''; const v 178 = items [ 178 ] ?? ''; const v 179 = items [ 179 ] ?? ''; const v 180 = items [ 180 ] ?? ''; const v 181 = items [ 181 ] ?? ''; const v 182 = items [ 182 ] ?? ''; const v 183 = items [ 183 ] ?? ''; const v 184 = items [ 184 ] ?? ''; const v 185 = items [ 185 ] ?? ''; const v 186 = items [ 186 ] ?? ''; const v 187 = items [ 187 ] ?? ''; const v 188 = items [ 188 ] ?? ''; const v 189 = items [ 189 ] ?? ''; const v 190 = items [ 190 ] ?? ''; const v 191 = items [ 191 ] ?? ''; const v 192 = items [ 192 ] ?? ''; const v 193 = items [ 193 ] ?? ''; const v 194 = items [ 194 ] ?? ''; const v 195 = items [ 195 ] ?? ''; const v 196 = items [ 196 ] ?? ''; const v 197 = items [ 197 ] ?? '';" }, { + "aliases": null, "block_ids": [ "d719400f1d79b522d0a1267331966be0" ], -- 2.49.1 From 88c5b83dea47de0096ddff160ada3949e16f4523 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 31 May 2026 10:25:00 +0000 Subject: [PATCH 27/27] =?UTF-8?q?docs:=20derivation-cache=20spec/handoff?= =?UTF-8?q?=20=EB=8F=85=EC=9E=90=20=EA=B4=80=EC=A0=90=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #195 구현(e9b5202) 기준으로 빠졌던 디테일 보강: - chunk_id(위치 기반 벡터 식별자) vs cache_key(내용 해시 조회 키) 구분 callout - §7 호환성/마이그레이션 신설: 본문 재색인 불필요, V012 가산이나 binary 교체 필요, 별칭 sentinel 묶음→개별 변경의 기존 KB 영향(레거시 호환) - version_key 에 kind 토큰("doc|") 반영, orphan sentinel cleanup(LIKE prefix) 명시 - embed_with_cache 순서 보존 불변, 별칭 개별 벡터 근거(희석 13/18→16/18) - 정정: derivation_cache_gc 는 메서드만 존재하고 미연결(캐시 현재 무한 누적, 후속) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-05-31-namu-wiki-alias-cache-study.md | 6 + .../2026-05-31-derivation-cache-design.md | 167 ++++++++++++++---- 2 files changed, 143 insertions(+), 30 deletions(-) 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 index 26d32de..39f0650 100644 --- 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 @@ -79,9 +79,15 @@ 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). +- **위치 밀림에도 캐시가 듣는 이유**: chunk_id 는 위치(ordinal+span) 기반이라 문서 중간 + 삽입 시 뒤 청크의 chunk_id 가 바뀌어 row 가 재작성되지만(싼 DB write), cache_key 는 + *내용 해시*라 내용 불변 청크는 히트 → 비싼 재계산(embedding/LLM) 0. chunk_id 와 + cache_key 가 별개라는 게 핵심. 설계 근거·동작은 spec §1 / §3.4 참조. - 적용: embedding(본문 + 별칭 벡터 양쪽) + 별칭 LLM. korean_tokens 는 우선순위 낮아 보류. - **측정: 정답 3개 cold 1879초(31분) → warm 13초 ≈ 145배.** 18문서 환산 시 2.5h → ~80s. derivation_cache 1237 엔트리(alias 140 + embedding 1097). +- 기존 KB 호환성(본문 재색인 불필요 / V012 가산 / 이전 binary mismatch / 별칭 재생성은 + 선택)은 설계 spec §7 참조 — 이 handoff 는 측정 과정·결과만 담는다. ## 5. KB 이식성 (외부 계산 워크플로) diff --git a/docs/superpowers/specs/2026-05-31-derivation-cache-design.md b/docs/superpowers/specs/2026-05-31-derivation-cache-design.md index 2cbb106..6a240d7 100644 --- a/docs/superpowers/specs/2026-05-31-derivation-cache-design.md +++ b/docs/superpowers/specs/2026-05-31-derivation-cache-design.md @@ -13,10 +13,32 @@ embedding 도 전체 재계산. 문서 한 줄만 고쳐도 동일 비용이 든다. 실사용(나무위키 ~2천 문서) 시 재색인이 비현실적으로 느리다. -`chunk_id` 는 `id_for_block` 의 `ordinal + span`(ids.rs:160) 때문에 **위치 기반**이라, +`chunk_id` 는 `id_for_block` 의 `ordinal + span`(ids.rs) 때문에 **위치 기반**이라, chunk_id 를 캐시 키로 쓰면 중간 수정 시 뒤 청크가 전부 무효화된다 → 캐시 키는 **청크 text 의 내용 해시**여야 위치와 무관하게 재사용된다. +> **`chunk_id` vs `cache_key` — 둘은 완전히 별개다(가장 혼동하는 지점).** +> - **`chunk_id`** 는 LanceDB 벡터 / SQLite chunk row 의 **식별자**다. `id_for_block` +> 이 `ordinal + source_span`(ids.rs) 을 canonical-JSON+blake3 한 **위치 기반** 해시라, +> 문서 중간이 밀리면 뒤 청크의 chunk_id 가 바뀐다. 이 작업은 **chunk_id 생성 방식을 +> 전혀 바꾸지 않는다**(frozen 동작 — §2 비목표). +> - **`cache_key`** 는 `derivation_cache` 테이블의 **조회 키**다. `chunk.text` 의 NFC +> 정규화 **내용 해시** + kind + version_key 로만 만든다(위치·chunk_id·문서 무관). +> - 즉 위치가 밀려 chunk_id 가 바뀌어도, 내용이 같은 청크는 같은 cache_key 로 캐시 +> 히트한다. chunk_id 는 "이 벡터가 어디에 속하나", cache_key 는 "이 내용을 전에 +> 계산했나" — 묻는 질문이 다르다. 별칭 sentinel chunk_id(`{orig}#alias#N`) 역시 +> 벡터 식별자일 뿐 cache_key 와 무관하며, 별칭 dense 벡터의 cache_key 는 **별칭 +> 문자열 자체**의 embedding 내용 해시다(§3.4). + +구체 예: 문서 중간에 헤딩/내용이 삽입되면 뒤 청크들의 ordinal/span 이 밀려 +chunk_id 가 바뀌고 `put_chunks` 가 그 문서의 row 를 **전부 재작성**한다(싼 DB +write — chunk row + LanceDB 벡터 재기록). 그러나 내용이 변하지 않은 청크는 +내용 해시 cache_key 가 동일하므로 embedding·별칭 캐시가 **히트**한다 → 비싼 +재계산(e5 forward / LLM)은 **0**, 새로 삽입된 청크만 실제로 계산된다. 즉 +"row 재작성(싸다)"과 "compute 재실행(비싸다)"을 분리해, 위치가 밀려도 compute +는 변경분에만 든다. 이것이 chunk_id 를 위치 기반으로 두면서도(diff 불필요) +재색인 비용을 없애는 핵심이다. + ## 2. 목표 / 비목표 **목표** @@ -41,13 +63,23 @@ 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 기본) +- `version_key` (kind 별, 버전 변경 시 캐시 무효화) — **구현 기준(e9b5202, lib.rs)**: + - embedding: `doc|{model_id}|{model_version}|{dimensions}` — 맨 앞의 **kind 토큰 + `doc`** 은 PR #195 리뷰 반영. 임베더는 호출 kind 별 프리픽스(Document=`passage:`, + Query=`query:`)를 붙여 *같은 text* 라도 다른 벡터를 만든다. 현재 ingest 는 Document + 고정이라 live 버그는 없지만, 미래에 query 임베딩이 같은 캐시를 타도 충돌하지 않도록 + 방어적으로 분리한다(현재 토큰은 `doc` 상수). + - alias: `{prompt_version}|{max_aliases_per_chunk}|{model}` (model="" 면 LLM 기본). + 구현은 `expansion::PROMPT_VERSION`(현재 `"expansion-v1"`) + `max_aliases_per_chunk` + + `exp.model` 을 `|` 로 join. - korean_tokens: `{tokenizer_version}` (현재 lindera 고정 → 상수 "lindera-v1"; - 추후 토크나이저 교체 시 bump) + 추후 토크나이저 교체 시 bump). **미구현(보류)** — embedding/LLM 이 주 비용이라 미적용. text 내용이 같고 버전이 같으면 문서·위치·chunk_id 와 무관하게 동일 cache_key. +실제 키 함수는 `kebab-core::derivation_cache_key(kind, text, version_key)` +(derivation.rs): `blake3(kind ‖ 0x00 ‖ blake3(NFC(text)) ‖ 0x00 ‖ version_key)` 의 +hex 앞 32자. `0x00` 구분자는 hex 다이제스트에 못 나오므로 kind/version 경계가 절대 +섞이지 않는다. ### 3.2 저장소 — SQLite `derivation_cache` 테이블 @@ -68,21 +100,26 @@ CREATE INDEX idx_dcache_last_used ON derivation_cache(last_used_at); ### 3.3 payload 인코딩 - embedding: `dimensions × f32` little-endian 바이트열 (1024×4 = 4096 B/청크). -- alias: 별칭 묶음 문자열의 UTF-8 (현행 `chunk.aliases` 와 동일 형식 — 줄바꿈 join). -- korean_tokens: 토큰 문자열 UTF-8. + `derivation_payload::{encode,decode}_embedding`(kebab-app). 디코드는 길이가 4의 + 배수가 아니면(손상) `None` → 미스 강등. +- alias: 별칭 **묶음** 문자열의 UTF-8 (현행 `chunk.aliases` 와 동일 형식 — 줄바꿈 join). + 즉 캐시 payload 는 LLM 이 청크당 생성한 별칭 *전체 묶음*이다. 이후 임베딩 단계에서 + 이 묶음을 줄 단위로 쪼개 개별 벡터로 색인하는 것(§3.4)과는 별개 — alias kind 캐시는 + "이 청크 text 의 별칭 묶음을 LLM 으로 이미 뽑았나"만 기억한다. +- korean_tokens: 토큰 문자열 UTF-8. (미구현 — §3.1 참고.) ### 3.4 ingest 흐름 변경 (kebab-app lib.rs) -각 파생물 생성 직전에 캐시를 조회한다. 의사코드: +각 파생물 생성 직전에 캐시를 조회한다. 의사코드(e9b5202 lib.rs 기준): ```rust -// --- 별칭 (lib.rs ~1259) --- +// --- 별칭 (lib.rs ~1346) --- if expansion.enabled { for chunk in &mut chunks { let key = cache_key("alias", &chunk.text, &alias_version_key); - if let Some(p) = cache.get(&key)? { // 히트 + if let Some(p) = cache.get(&key)? { // 히트 (비-UTF8 이면 None → 미스 강등) chunk.aliases = Some(String::from_utf8(p)?); } else if is_nav_boilerplate(chunk) { // (기존 skip 규칙 유지) - chunk.aliases = None; + chunk.aliases = None; // 캐시에 넣지 않음(None 표현 불가) } else { // 미스 → LLM chunk.aliases = generator.generate(chunk); if let Some(a) = &chunk.aliases { cache.put(&key, "alias", a.as_bytes())?; } @@ -90,28 +127,66 @@ if expansion.enabled { } } -// --- embedding (lib.rs ~1309) --- -// 1) 각 청크 cache_key 계산 → 히트/미스 분리 +// --- embedding (lib.rs ~1434, fn embed_with_cache) --- +// 1) 각 청크 cache_key 계산 → 히트/미스 분리 (out: Vec>>, 입력당 1슬롯) // 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 호출. +// 4) 히트 vector(슬롯)와 미스 vector(miss_indices 의 슬롯)를 각자 제자리에 채운 뒤, +// 슬롯 순서대로 collect → **입력 texts 순서와 1:1 보존**(off-by-one 없음). +// 이후 chunks.iter().zip(vectors) 로 VectorRecord 를 만들므로 순서 보존이 +// 정확성에 직결된다. ``` -핵심: **embedding 캐시는 청크 본문 + 별칭 문자열 양쪽에 적용**된다. 별칭 dense 벡터도 -"같은 별칭 문자열"이면 재사용된다(별칭 LLM 캐시 + 별칭 임베딩 캐시 2중 절감). +순서 보존(§3.4 핵심 불변): `embed_with_cache` 는 히트/미스를 분리 계산하되 결과를 +입력 인덱스 슬롯(`out[i]`)에 되돌려 채우고 그 순서대로 반환한다. 따라서 히트·미스가 +섞여도 반환 벡터의 i번째는 항상 입력 text 의 i번째에 대응한다 — 호출부의 +`chunks.iter().zip(vectors)` 가 잘못된 청크에 벡터를 붙이는 off-by-one 이 발생하지 않는다. + +핵심: **embedding 캐시는 청크 본문 + 별칭 문자열 양쪽에 적용**된다(같은 `embed_with_cache` ++ 같은 `emb_version_key` 재사용). 같은 text 면 본문이든 별칭이든 같은 cache_key 로 적중하므로, +별칭과 동일한 문자열이 본문에도 있으면 한쪽 계산이 다른 쪽을 워밍한다(별칭 LLM 캐시 + +별칭 임베딩 캐시 2중 절감). + +별칭은 **묶음 1벡터가 아니라 줄별 개별 sentinel 벡터**로 색인한다(`{orig}#alias#0`, +`#alias#1`, …). 근거: 측정(handoff §3.1)에서 청크당 별칭 8개를 줄바꿈으로 묶어 한 벡터로 +임베딩하면 평균화로 특정 표현이 **희석**되어 오히려 변형 일관성이 악화했다(13/18). 줄별 +개별 벡터로 바꾸자 16/18 로 회복. 구현은 `chunk.aliases`(묶음)를 `\n` 으로 split·trim 한 +뒤 빈 줄을 거르고, 각 줄을 같은 청크 안에서 0부터 인덱싱해 `{chunk_id}#alias#{i}` 의 +VectorRecord 를 만든다. 별칭 dense 벡터의 cache_key 는 **별칭 줄 문자열 자체**의 embedding +내용 해시이므로(본문 chunk text 가 아님), 같은 별칭 문자열이 재등장하면 캐시 히트한다. + +// korean_tokens: tokenize 직전 cache 조회 + 미스만 lindera 호출 — **미구현(보류)**. ### 3.5 무효화 / 정리 - **버전 무효화**: version_key 가 cache_key 에 포함 → model/prompt/tokenizer 버전이 bump 되면 새 키가 되어 자동 miss(옛 엔트리는 고아). §9 cascade 와 자동 정합. -- **고아 정리**: `kebab doctor` 또는 ingest 종료 시, `last_used_at` 이 N일(기본 30) 지난 - 엔트리를 삭제하는 경량 GC. 또는 테이블 행수가 임계(기본 50만) 초과 시 LRU 삭제. - (정리 정책은 plan 에서 상수화; 초기엔 30일 TTL 만.) +- **캐시 엔트리 고아 정리(GC)**: `derivation_cache_gc(ttl_days)` 가 `last_used_at` 이 + N일(설계 기본 30) 지난 엔트리를 삭제한다(`ttl_days <= 0` 은 통째 wipe 방지 no-op). + 히트 키는 `derivation_cache_touch` 로 `last_used_at` 을 갱신해 GC 가 live 청크를 유지. + **구현 상태(e9b5202)**: `touch` 는 ingest 종료 시 호출되어 wired 되어 있으나, `gc` 는 + store 메서드로 **존재만 하고 아직 어느 호출부(ingest/doctor)에도 연결되지 않았다**. + 즉 현재 캐시는 무한 누적이며, TTL/LRU 자동 정리는 후속 작업이다. 행수 임계(기본 50만) + LRU 삭제도 미구현. 당장은 `kebab reset`(같은 sqlite 라 같이 비워짐)이 유일한 정리 경로. +- **stale 별칭 sentinel cleanup**(별개 — 캐시 GC 아니라 *벡터 스토어* 정리, PR #195 MAJOR): + 별칭 dense 벡터는 본문 청크가 아니라 줄별 sentinel `{orig}#alias#N` 로 LanceDB· + embedding_records 에 색인된다. 이 sentinel chunk_id 는 SQLite `chunks` 에 **존재하지 + 않아** 재색인/문서삭제 시 stale-set SELECT 에 안 잡힌다. 정리 안 하면 옛 별칭 벡터가 + 남아 검색에 hit 하는 누수(리뷰 MAJOR). 따라서 재색인·삭제 경로가 본문 chunk_id 와 함께 + 별칭 sentinel 을 양쪽에서 명시 삭제한다: + - **LanceDB**: `alias_sentinel_ids_to_delete(body_ids, max_aliases_per_chunk)` + (lib.rs) 가 본문 id + legacy `{orig}#alias` + `{orig}#alias#0..max-1` 를 모두 + 생성해 `delete_by_chunk_ids` 의 exact-match `IN (...)` 로 삭제. `max` 는 + `expansion.max_aliases_per_chunk`(parse_aliases 가 강제하는 상한)라 index ≥ max 는 + 절대 안 나오고, 안 쓰인 index 는 무해한 no-op. + - **SQLite** `embedding_records`: `chunk_id LIKE chunks.chunk_id || '#alias%'` + 프리픽스 매칭(store.rs / documents.rs)으로 본문 chunk_id 의 모든 별칭 sentinel 행을 + 함께 정리. 정확 일치 `|| '#alias'` 는 per-line sentinel 을 놓치므로 `%` 프리픽스 필수. + + 이 두 정리는 **별칭 expansion 을 켰던 KB** 에만 해당하고, derivation_cache GC 와는 + 독립적이다(캐시는 계산 결과 보관, sentinel 정리는 벡터 식별자 누수 방지). - 캐시는 **순수 성능 레이어** — 손상/삭제되어도 정확성 영향 없음(miss → 재계산). + `embed_with_cache` 는 길이 misalign payload 를, 별칭 경로는 비-UTF8 payload 를 + **미스로 강등**해 재계산한다(잘못된 결과 대신 재계산, §3.6 정확성 우선). `kebab reset` 시 함께 비워진다(같은 sqlite). ### 3.6 정확성 보장 @@ -127,12 +202,16 @@ if expansion.enabled { - `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-store-sqlite` — `SqliteStore` 의 inherent 메서드(derivation_cache.rs): + `derivation_cache_get(key) -> Option>`, `derivation_cache_put(key, kind, + payload)`(INSERT OR REPLACE), `derivation_cache_touch(keys)`(last_used 갱신, 1tx), + `derivation_cache_gc(ttl_days)`(존재하나 미 wiring — §3.5). 별도 trait 안 만들고 + store 에 직접 단다. +- `kebab-app` — `embed_with_cache`(lib.rs, 히트/미스 분리 + 순서 보존 §3.4) + + `derivation_payload`(embedding f32↔LE bytes encode/decode) + ingest hook(별칭/embedding + 캐시 조회·저장, hit/miss 카운트 로깅, touch 호출). - `kebab-chunk` — korean_tokens 캐시(선택, 우선순위 낮음 — embedding/LLM 이 주 비용). + **미구현(보류)**. ## 5. Allowed / forbidden deps - `kebab-core` 의 키 함수는 순수(blake3 + unicode-normalization 만). 다른 kebab-* 금지. @@ -146,7 +225,35 @@ if expansion.enabled { - golden eval: warm 재색인 후 variant 16/18 + refusal 동일(결과 불변 = 캐시 정확성). - 버전 bump 시뮬: prompt_version 변경 → 별칭 전부 miss(재계산) 확인. -## 7. Risks / notes +## 7. 호환성 / 마이그레이션 (기존 KB 영향) + +이 작업이 기존 KB 를 어떻게 건드리는지 — 무엇이 재색인 필요하고 무엇이 그대로인지. + +- **본문 청크 재색인 불필요.** chunk_id 생성 방식(위치 기반 `id_for_block`)을 안 바꿨고 + 본문 dense 벡터 색인 경로도 안 바꿨다. 같은 corpus 를 같은 parser/chunker/embedding + 버전으로 다시 ingest 하면 본문 chunk_id·벡터가 그대로다. 캐시는 *계산*만 절감할 뿐 + 결과(벡터 값)는 동일하므로 기존 본문 데이터는 손대지 않아도 된다. +- **V012 는 순수 가산 — 자동 적용, 기존 데이터 불변.** 새 테이블 `derivation_cache` 만 + 추가하고 `corpus_revision` 을 bump 하지 않는다(§3.2). 기존 SQLite 를 새 binary 로 열면 + refinery 가 V012 를 자동 적용하며 기존 행은 건드리지 않는다. **단 binary 교체는 필수**: + V012 가 적용된 DB 를 **이전 release binary** 로 열면 refinery 마이그레이션 상태가 + mismatch 한다(이전 binary 는 V012 를 모름) → 새 binary 로만 열 것. 이 schema 변경은 + CLAUDE.md §Versioning 의 release bump 트리거다. +- **별칭 dense 벡터 — expansion 을 켰던 KB 만 해당.** 별칭 색인 단위가 묶음 단일 sentinel + `{orig}#alias`(1벡터) → 줄별 개별 sentinel `{orig}#alias#N`(N벡터)로 바뀌었다. + - expansion 을 한 번도 안 켠 KB: 별칭 sentinel 자체가 없으므로 영향 0. + - 기존 단일 sentinel 이 남아 있어도 **검색은 그대로 동작**한다: candidate strip 이 + `strip_alias_suffix`(ids.rs)의 `find("#alias")` 기반이라 legacy `{orig}#alias` 와 + 신형 `{orig}#alias#N` 를 똑같이 원본 chunk_id 로 환원한다. + - 개별 벡터의 검색 품질 이점(희석 회피, §3.4)을 원하면 **별칭만 재생성**하면 된다 + (본문은 그대로). 강제 사항은 아니다. + - stale 별칭 sentinel 누수 방지는 §3.5 의 cleanup(LanceDB exact-match + SQLite + `#alias%` LIKE)이 재색인·삭제 시 자동 처리한다. +- **KB 이식성(외부 계산 워크플로).** `derivation_cache` 는 SQLite 안에 있고 cache_key 가 + 머신 독립적인 내용 해시라, 외부 서버에서 워밍한 `kebab.sqlite`(+`lancedb/`)를 그대로 + 복사해 오면 로컬 증분 수정 시에도 캐시가 히트한다(측정: handoff §5). + +## 8. Risks / notes - LLM 별칭의 미세한 비결정성: 캐시가 첫 결과를 고정하므로 재현성은 오히려 향상. 단 "더 나은 별칭" 재생성을 원하면 prompt_version bump 로 무효화. - payload BLOB 크기: embedding 4KB/청크 × 캐시 엔트리. 50만 엔트리 ≈ 2GB. TTL/LRU 로 관리. -- 2.49.1