Compare commits

..

73 Commits

Author SHA1 Message Date
8bfa4ba76e fix(ingest-progress): 리뷰 반영 — store_ms 경계 정정 + 중복 expansion 프레임 가드
- store_ms 에서 stale-vector orphan purge(LanceDB I/O) 제거 → embed/vector phase
  (embed_ms)로 이동. store_ms 가 이제 SQLite put_* 만 의미(진단 정확도; 편집
  재색인 시 920ms 오귀속 제거). purge 는 여전히 unconditional + upsert 이전.
- 최종 expansion_progress 프레임을 done != last_done 로 가드 (throttle 배수 시
  중복 프레임 + chunks==0 시 0/0 프레임 제거).
- schema/HOTFIXES: store_ms/embed_ms 설명 정정 + dangling IMPL_REPORT 참조 제거.

clippy -D warnings 0, test 312 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:49:02 +00:00
ad0ccf4ccf chore(ingest-progress): remove process artifacts before PR
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:13:36 +00:00
b351523e51 docs(worktree): IMPL_BRIEF + IMPL_REPORT for ingest-progress-detail
작업 입력(brief)과 산출 증거(report: 변경/이벤트/exit-code 검증/smoke 샘플/
잔여 리스크). 메인 세션이 PR 정리 시 드롭 가능한 worktree 메타 아티팩트.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 13:58:33 +00:00
a48b055358 feat(ingest): asset 내부 phase 진행 로깅 (asset_chunked/expansion_progress/asset_timings) + v0.24.0
asset(문서) 단위뿐이던 ingest 진행 이벤트에 문서 내부 phase 가시성을 추가.
큰 문서가 expansion(별칭 LLM, 청크당 순차)으로 수십 분 걸려도 진행바가
1/N 에 멈춘 듯 보이던 문제 해결.

wire ingest_progress.v1 additive (backward-compat):
- asset_chunked {idx,total,chunks} — 청킹 직후, markdown/image/pdf 전 경로
- expansion_progress {idx,total,done,chunks} — expansion 루프 스로틀
  (25청크 또는 1s, 종료 시 done==chunks). 캐시 히트도 done 에 포함
- asset_timings {idx,total,parse_ms,chunk_ms,expansion_ms,embed_ms,store_ms}
  — markdown 경로 phase별 wall-clock

설계: timing 은 kebab_core::IngestItem(wire-stable) 변경을 피해 신규
AssetTimings 이벤트로 ingest_one_asset 가 직접 emit (AssetFinished 무변경).

CLI(progress.rs): 진행바 sub-message(→ N chunks / 별칭 확장 done/chunks) +
asset 종료 시 phase timing 한 줄(fmt_ms). TUI reducer no-op arm.

검증: clippy -D warnings exit 0; cargo test -p kebab-app -p kebab-cli
312 passed/0 failed. ordering-invariant 테스트 재작성 + 신규 직렬화 테스트.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 13:58:27 +00:00
581e1d5d55 feat(cli): ingest 시 임베딩 백엔드/디바이스 한 줄 표시 + README KB 이전 문서 (v0.23.1)
- kebab-cli ingest: 시작 시 `임베딩 백엔드: <provider> (Metal/GPU 빌드|CPU) · 모델 …`
  를 stderr 로 표시 (--json/--quiet 억제). Metal 표기는 cfg!(feature=embed_metal)
  기반; 확정 런타임 디바이스는 kb.log(`candle device = …`).
- README: '외부 계산 + 로컬 검색' 절에 복사 대상(kebab.sqlite/sqlite, lancedb/vector_dir)
  + [storage] config 키 + models/assets 복사 불필요 + 동일 버전/모델 조건 + rsync 예시.
- 버전 0.23.0 → 0.23.1 (CLI 출력 + 문서만, 동작/schema 불변).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:25:45 +00:00
c17d6e67a8 Merge pull request 'feat(embed): candle Metal (Apple Silicon GPU) opt-in build feature' (#200) from feat/embed-candle-metal into main
Reviewed-on: #200
2026-06-02 11:40:52 +00:00
af8fd34716 docs(embed): README 에 cargo install --features embed_metal 안내 추가
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:38:28 +00:00
369aeb3d24 feat(embed): candle Metal (Apple Silicon GPU) opt-in build feature + v0.23.0
- kebab-embed-candle: `metal` feature → candle metal backend; select_device()
  picks Device::new_metal(0) (CPU fallback) under the feature, else Device::Cpu.
  .contiguous() before to_vec2 (Metal rejects strided views; CPU tolerates).
- feature passthrough: kebab-app/embed_metal → kebab-cli/embed_metal.
  Build on macOS: cargo build --release --features embed_metal.
- default (non-metal) path unchanged: clippy 0, candle units + thread_cap + parity pass.
- README + HOTFIXES: Mac-GPU-ingest → copy sqlite+lancedb → server CPU-query workflow.
- version 0.22.0 → 0.23.0 (opt-in build surface).

macOS-only compile; Metal execution/speed/parity validated by user on M4 Pro
(not buildable on the Linux CI/dev machine).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:37:08 +00:00
99f8cfa691 Merge pull request 'feat(embed): candle 임베딩 provider (NUMA-안전, opt-in)' (#199) from feat/embed-candle into main
Reviewed-on: #199
2026-06-02 10:14:39 +00:00
d85d7348a5 docs(embed-candle): 도그푸딩 + A1 반증 + MKL 부정결과 증거 기록
- HOTFIXES + release-notes: candle 전체 도그푸딩 997 docs/23,151 chunks/에러 0 (9.5h)
- A1(taskset -c 0-3) 실서버 반증: 4코어 제한에도 onnxruntime segfault → candle 만이 실 해법
- MKL 가속 부정 결과: 코어 더 쓰나 38~50% 느림 → 미채택, 순수-Rust 유지
- 패리티 2.01e-7 재확인, 성능 트레이드오프 명시

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:08:12 +00:00
edac3ae737 chore(embed-candle): PR #199 회차 1 리뷰 반영 — SMOKE.md candle 모델 주의 명시
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:01:35 +00:00
6ec4e6809f fix(embed-candle): address round-1 review
- commit track-spec + meta-spec/plan into branch (HIGH: dangling `amends:` ref)
- inline parity evidence (cosine 1.0, max_abs_diff 2.01e-7) into HOTFIXES +
  release notes; drop refs to deleted IMPL_REPORT/SPIKE_REPORT (MEDIUM)
- model guard: reject non-e5-large `model` before the 2GB download so
  model_id() can't mislabel vectors (MEDIUM) + unit test
- parity test now covers BOTH query: and passage: prefixes (MEDIUM)
- guard encodings.first() index; document zero-attention/pooling invariant;
  clarify embed_batch prefixing doc (LOW)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:54:20 +00:00
1011c75fff chore(embed-candle): remove spike/impl process artifacts before PR
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:37:46 +00:00
8f7b6ee538 feat(embed): candle 임베딩 provider (NUMA-안전, opt-in) + v0.22.0
duo-socket NUMA 서버에서 fastembed(onnxruntime)가 intra-op 스레드를 48개로
하드코딩해 NUMA 힙 손상 → double-free 로 ingest 가 죽는 문제를 회피하기 위해,
같은 multilingual-e5-large 모델을 순수 Rust(candle)로 돌리는 opt-in 임베딩
provider 를 추가한다.

- 신규 crate kebab-embed-candle: CandleEmbedder (kebab_core::Embedder).
  hf-hub safetensors → XLMRobertaModel forward → mask mean-pool → L2 → e5
  prefix. candle 의존성 트리를 이 crate 에 격리 (core/config 외 kebab-* 의존 0).
- 스레드 캡: [models.embedding].num_threads + env KEBAB_EMBED_THREADS →
  글로벌 rayon 풀 1회 캡 (NUMA-안전 레버).
- kebab-app::embedder() 가 provider 분기 (fastembed/onnx/"" → 기존 경로 불변,
  candle → CandleEmbedder, 미지값 → 에러).
- Phase 0 스파이크 crate 제거 (production 흡수).
- 버전 0.21.1 → 0.22.0 (신규 config surface, pre-1.0 minor bump).

패리티: cosine_min=1.000000, max abs diff=2.01e-7 (< 1e-5) → embedding_version
유지, 재색인 0. fastembed default 동작/벡터 불변. wire schema 변경 없음.

검증(파일+exit code): clippy -D warnings EXIT=0(warning 0), test EXIT=0
(candle unit 5 + thread_cap rayon=4 + config 68), parity #[ignore] EXIT=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:52:25 +00:00
76841af7d3 spike(embed-candle): candle e5-large 타당성 검증 — VERDICT PASS
Track 1 / Phase 0 격리 스파이크. candle(순수 Rust)로
intfloat/multilingual-e5-large 를 돌려 기존 onnxruntime
FastembedEmbedder 와 비교.

결과:
- 패리티: 한/영 10문장 cosine min=mean=1.000000 (완전 일치)
- padding_idx: XLM-R 규약 정상 (소스 + 패리티 이중 확인)
- 스레드 제어: RAYON_NUM_THREADS=4 로 컴퓨트 스레드 12→4 캡 확인
  (fastembed 4.9.1 의 48-하드코딩+override불가 문제 구조적 부재)
- latency: batch=32 candle 2.161s vs fastembed 0.536s (~4×, 4 vs 12 스레드)

→ candle 본 구현 진행 권고 (GREEN). 상세 SPIKE_REPORT.md.

candle 의존성은 crates/spike-embed-candle 에만 격리. 프로덕션
crate 동작 변경 없음. 결정적 NUMA 검증은 그 듀얼소켓 서버에서
사용자 실행 필요 (meta-spec §4.3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:23:51 +00:00
980e20fd8d docs: SMOKE/DOGFOOD 에 config migrate 플레이북 추가
SMOKE 에 config migrate 스모크 단계(dry-run/적용/멱등/--json), DOGFOOD §9 에
스키마 마이그레이션 시나리오(.bak byte-identical·값 보존·가시화·멱등·doctor).
v0.21.1 에 포함되도록 태그 이동.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 13:58:08 +00:00
cd79ed326c chore: bump version 0.21.0 → 0.21.1
config 마이그레이션(kebab config migrate, PR #198) — 신규 CLI 서브커맨드 +
doctor 체크 + init 섹션 주석 + wire config_migration.v1 + schema_version 1→2.
additive 변경(데이터 무효화 아님)이라 patch bump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 13:51:56 +00:00
9dbf9d781d Merge pull request 'feat(config): config.toml 마이그레이션 (kebab config migrate)' (#198) from feat/config-migration into main
Reviewed-on: #198
2026-05-31 13:48:10 +00:00
9501edd82b docs: config migrate surface 동기화 (README/HOTFIXES/HANDOFF)
README Configuration 에 kebab config migrate 불릿, HOTFIXES 에 dated entry
(메커니즘 + 도그푸딩 evidence 표 + 한계), HANDOFF 한 줄. lib.rs 백업 경로는
with_extension 유지(리뷰 nit: .toml config 엔 정상 동작, 회귀 위험 회피).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 13:25:42 +00:00
4b4a4c0b32 fix(config): init 헤더에 지원 확장자 상세 목록 유지
annotated_default_document 의 HEADER 가 기존 init 헤더의 '처리 가능한 형식'
상세 목록(.md / .png .jpg .jpeg / .pdf)을 보존하도록 복원. p9-fb-25 의
init_template 계약(지원 확장자 안내) 유지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 12:46:45 +00:00
f2cc325cf3 feat(cli): kebab config migrate 서브커맨드 + wire config_migration.v1
- Cmd::Config { Migrate { --dry-run } }, --json 시 config_migration.v1.
- wire_config_migration (ConfigMigrationReport 가 schema_version 자체 보유).
- schema.rs WIRE_SCHEMAS 에 config_migration.v1 등록 + JSON schema 파일.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 12:09:31 +00:00
b7e022a5e3 feat(app): config migrate facade + init 주석 공유 + doctor 체크
- config_migrate_with_config_path: 백업(.bak)+atomic write(tmp→rename)+dry-run,
  round-trip 검증으로 실패 시 원본 보존. ConfigMigrationReport 반환.
- init_workspace 가 annotated_default_document() 사용(섹션 주석 포함).
- doctor 에 config_migration 체크 추가(미동기 시 ok=false + hint).
- tests/config_migrate.rs 4개(백업/atomic/dry-run/멱등/doctor) 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 12:09:31 +00:00
bd7c4fd7ef feat(config): config 마이그레이션 엔진 (reconcile + step 체인)
- toml_edit 0.22 의존성 추가
- migrate.rs: CURRENT_SCHEMA_VERSION=2, annotated_default_document(주석
  카탈로그 공유 원천), reconcile(빠진 섹션/키 주석과 함께 추가, 값 불가침),
  step_1_to_2(workspace.include 제거), migrate_document(step+reconcile+stamp)
- schema_version default 1 → 2
- 56 tests green, clippy -D warnings clean

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 12:09:31 +00:00
4dcb4a45d6 feat(config): migrate 모듈 스캐폴딩 + toml_edit 의존성
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 11:41:32 +00:00
6d86214060 docs(plan): config 마이그레이션 구현 계획 (TDD, 13 tasks)
reconcile(additive)+step 체인(non-additive) 분리, init/migrate 공유
annotated_default_document, app facade 백업+atomic write, doctor 체크,
CLI config migrate, wire config_migration.v1. bite-sized TDD steps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 11:39:31 +00:00
6bbb8f854b docs(spec): config 마이그레이션 설계 계약
kickoff 인계(#197)의 brainstorm 결과를 확정한 spec. 트리거=명시 명령
`kebab config migrate`+doctor 안내, 주석 보존=toml_edit 부분 편집,
메커니즘=reconciliation(additive)+step 체인(non-additive) 하이브리드.
init/migrate 가 주석 달린 default 문서를 공유. 안전 3축(멱등·백업·dry-run)
+ atomic write. wire schema config_migration.v1 신설.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 11:34:19 +00:00
2a4df4d48d Merge pull request 'docs: config 마이그레이션 작업 인계 kickoff' (#197) from docs/config-migration-kickoff into main 2026-05-31 11:11:17 +00:00
16f3d6eef2 docs: config 마이그레이션 작업 인계 kickoff
config.toml 스키마 진화 시 기존 사용자 파일 자동 마이그레이션 기능의
별도 세션 인계 문서. 현황(serde default forward-compat 있음/파일 마이그레이션
없음/schema_version 장식), 핵심 난점(주석 보존), 설계 3안(전체재작성/toml_edit
append/백업), 트리거(명령 vs 자동), 방법론(v0.21.0 PR #195/#196 패턴) 정리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 11:11:08 +00:00
fa89c7b561 Merge pull request 'docs(readme): v0.21.0 전면 재구성' (#196) from feat/doc-side-expansion into main 2026-05-31 10:44:38 +00:00
a4c81fed86 docs(readme): v0.21.0 전면 재구성
Quick start 를 맨 앞(빠른 사용), 핵심 기능을 중간, 아키텍처·설계를 뒤로
재배치. kebab 무관 내용(ollama sudo-less tarball 설치, CPU 모델 트러블슈팅)
과 구식 버전 태그(fb-XX, p9-fb, V009, v0.17~v0.20.x 산재), stale 버전 문구
제거. v0.21.0 기준(doc-side expansion 별칭, 파생물 캐시, 외부 계산 워크플로)
서술. 302→206 줄.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 10:44:29 +00:00
5b7c02fe13 Merge pull request 'feat(expansion): doc-side expansion 별칭 개별 dense 벡터 + 파생물 캐시(V012)' (#195) from feat/doc-side-expansion into main 2026-05-31 10:25:45 +00:00
88c5b83dea docs: derivation-cache spec/handoff 독자 관점 보강
PR #195 구현(e9b5202) 기준으로 빠졌던 디테일 보강:
- chunk_id(위치 기반 벡터 식별자) vs cache_key(내용 해시 조회 키) 구분 callout
- §7 호환성/마이그레이션 신설: 본문 재색인 불필요, V012 가산이나 binary 교체 필요,
  별칭 sentinel 묶음→개별 변경의 기존 KB 영향(레거시 호환)
- version_key 에 kind 토큰("doc|") 반영, orphan sentinel cleanup(LIKE prefix) 명시
- embed_with_cache 순서 보존 불변, 별칭 개별 벡터 근거(희석 13/18→16/18)
- 정정: derivation_cache_gc 는 메서드만 존재하고 미연결(캐시 현재 무한 누적, 후속)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 10:25:00 +00:00
2619b7bff7 test(chunk): AST snapshot fixture에 aliases:null 필드 반영
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) <noreply@anthropic.com>
2026-05-31 09:57:16 +00:00
e9b520216e fix(expansion): per-alias sentinel orphan cleanup + 캐시 견고성 (PR #195 리뷰)
MAJOR: 별칭 dense 벡터의 chunk_id 가 레거시 단일 `{id}#alias` 에서 줄별
`{id}#alias#0`, `#alias#1`, … 로 바뀌었으나 orphan cleanup 이 단일 sentinel
하나만 삭제해 `#alias#N` 벡터가 LanceDB / embedding_records 에 누수됐다.

- kebab-app: `alias_sentinel_ids_to_delete` 헬퍼 추가(접근법 A) — 본문 +
  legacy `{id}#alias` + `{id}#alias#0`..`{id}#alias#{max-1}` 를 모두 delete-set
  에 포함. max=expansion.max_aliases_per_chunk(= parse_aliases 의 하드 cap)와
  일치. parser-bump / edited-asset / deleted-file 세 LanceDB cleanup 경로 모두
  이 헬퍼를 사용.
- kebab-store-sqlite: embedding_records 명시 DELETE 4 경로(put_chunks /
  purge_*_except_doc_id / purge_orphan_at_workspace_path /
  purge_deleted_workspace_path)를 정확 일치(`|| '#alias'`)에서 `{id}#alias%`
  프리픽스 LIKE 로 전환. 본문 chunk_id 는 32자 hex 라 LIKE 와일드카드 없음.

MINOR 1: alias 캐시 히트 시 비-UTF8 payload 를 미스로 강등(재생성 분기로)
— embedding 경로의 decode-실패→미스 강등과 동작 일치.
MINOR 2: embedding version_key 맨 앞에 kind 토큰("doc") 추가 — 임베더가
kind 별 프리픽스를 붙이므로 미래에 query 임베딩이 같은 캐시를 타도 충돌 방지.

회귀 테스트:
- kebab-app: alias_sentinel_ids_to_delete 단위 테스트 2건.
- kebab-store-sqlite: per-alias sentinel embedding_records 가 세 cleanup
  경로 모두에서 사라지는지 핀하는 통합 테스트 3건.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 09:14:34 +00:00
a8fd76499c feat(expansion): doc-side expansion 별칭 개별 dense 벡터 + 파생물 캐시(V012)
별칭을 줄별 개별 dense 벡터(sentinel `{chunk}#alias#N`)로 색인하고
boilerplate 청크는 별칭 생성을 skip. 묶음 1벡터 방식은 평균화로 특정
표현이 희석돼 오히려 회귀(13/18)했던 것을 폐기. 변형 일관성 14/18 →
16/18, mean_spread@10 0.222 → 0.111 (나무위키 ~1000 문서 CS corpus).
`kebab-core::strip_alias_suffix` 가 suffix 형과 per-alias 형 둘 다 처리.

파생물 캐시(V012): embedding 벡터 + 별칭 LLM 결과를 청크 내용 해시
키로 캐싱해 재색인 시 내용 불변 청크의 재계산을 skip. cache_key =
blake3(kind ‖ text_blake3 ‖ version_key)[:32], version_key 에
model/prompt/dimensions 포함 → §9 cascade 와 정합(버전 bump 시 자동
miss). 측정: 정답 3개 cold 1879s → warm 13s ≈ 145배. 순수 가산이라
corpus_revision bump 없음. search/ask 는 kebab.sqlite+lancedb 만으로
동작 → 외부 서버 색인 후 DB 만 복사하는 이식 워크플로 가능.

V012 schema migration + 신규 surface 로 workspace version 0.20.2 →
0.21.0 (minor) bump. README/HANDOFF/ARCHITECTURE/HOTFIXES sync.
known limitation: stack·svm 설명형 2개 잔존 + grounded 판정이 부분
인용을 grounded 로 오분류(후속 후보).

측정 상세: docs/superpowers/handoffs/2026-05-31-namu-wiki-alias-cache-study.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 08:24:04 +00:00
0282a81c67 fix(store): CASCADE 대체 4번째 경로 + V011 CHECK 복원 (Task 4.5 리뷰)
리뷰 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) <noreply@anthropic.com>
2026-05-30 14:02:46 +00:00
f3587b7143 feat(store): filter_chunks sentinel 별칭 candidate strip (committed 통과)
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) <noreply@anthropic.com>
2026-05-30 13:41:28 +00:00
483b1ec06b feat(store): V011 embedding_records FK 제거 + CASCADE 대체 명시 DELETE (sentinel 별칭 벡터)
별칭 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) <noreply@anthropic.com>
2026-05-30 13:41:20 +00:00
d279f343e7 docs(spec,plan): 별도 벡터 인프라 — FK 제거(V011) + CASCADE 대체 + filter_chunks
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) <noreply@anthropic.com>
2026-05-30 13:25:45 +00:00
b56469f010 fix(core): clippy uninlined_format_args — strip_alias 테스트 (리뷰 MAJOR-1)
workspace clippy --all-targets -D warnings 게이트 통과. format! 인자 인라인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 11:24:04 +00:00
6ba8cb2c88 feat(search): VectorRetriever sentinel 별칭 strip + dedup
별칭 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) <noreply@anthropic.com>
2026-05-30 11:09:32 +00:00
afa8af0f88 feat(app): 별칭 dense 별도 벡터 색인 + purge (sentinel) 2026-05-30 10:48:58 +00:00
b9d20d23d1 feat(config): ingest.expansion.embed_aliases flag (default off) 2026-05-30 10:31:07 +00:00
86b4e1ebd0 feat(core): ALIAS_SUFFIX + strip_alias_suffix (dense alias vectors) 2026-05-30 10:31:03 +00:00
825543549d docs(plan): 별칭 dense 별도 벡터 구현 plan
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) <noreply@anthropic.com>
2026-05-30 10:28:43 +00:00
bcb8b93751 docs(spec): 별칭 dense 별도 벡터 설계 spec
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) <noreply@anthropic.com>
2026-05-30 10:26:24 +00:00
116b3e6377 fix(app): clippy unused_self — build_request 를 associated fn 으로
CI 게이트(clippy --workspace --all-targets -D warnings) 통과. 동작 동일.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 03:47:06 +00:00
69b53d1c97 docs(spec): doc-side expansion 검색 메커니즘을 shipped 구현에 맞춰 정정
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) <noreply@anthropic.com>
2026-05-30 03:20:13 +00:00
a271352e33 feat(search): lexical body+alias 병합 검색 (pool-rescue)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 03:12:14 +00:00
cde4d75f6b feat(app): ingest 별칭 생성 hook (flag off 기본, fail-soft) 2026-05-30 03:03:09 +00:00
bddcd53688 fix(app): parse_aliases 접두 제거가 숫자/하이픈 선두 별칭 손상 (Task 4 리뷰 MAJOR-1)
탐욕적 trim_start_matches → 명시적 strip_list_marker(마커+공백 패턴만 1회).
"3D 렌더링"/"2단계"/"-fast" 보존, "- "/"1. " 마커만 제거. 회귀 테스트 2개.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 02:49:25 +00:00
2a207f9868 feat(app): ExpansionGenerator — 청크당 별칭 생성 (fail-soft)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 02:36:20 +00:00
cc31868d24 feat(config): [ingest.expansion] flag (default off) 2026-05-30 02:26:41 +00:00
0df47febf0 test(store): doc-side expansion Task 2 리뷰 보강 (M1/M2/N1)
- M1: chunk_aliases trigger 가드에 AND aliases <> '' (빈 문자열 미색인)
- M2: 재색인 멱등 테스트 (재-put 후 별칭 행 1개)
- N1: 본문 격리 음성 단언 (별칭 term 이 chunks_fts 로 누출 안 됨)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 02:24:24 +00:00
b12a616ab2 feat(store): V010 chunk_aliases_fts + put_chunks 별칭 영속화
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 02:15:27 +00:00
848b75c069 feat(core): Chunk.aliases 필드 (doc-side expansion)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 02:09:39 +00:00
467a974901 docs(plan): doc-side expansion 구현 plan + spec 정제 (별도 FTS 테이블)
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) <noreply@anthropic.com>
2026-05-30 02:04:58 +00:00
098413922b docs(spec): 색인시 doc-side expansion 설계 spec (Phase 2)
brainstorm 확정: 청크당 별칭 생성(같은언어+한↔영 번역), additive+수동
재색인, 1차 단순 품질제어. 별도 FTS5 aliases 채널 → RRF 3채널 융합.
flag off 기본, kebab eval variants 로 on/off 측정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 01:54:46 +00:00
695010ea7a Merge pull request 'docs: Phase 2 doc-side expansion 킥오프 + 구현 방법론 핸드오프' (#194) from docs/phase2-doc-expansion-kickoff into main
Reviewed-on: #194
2026-05-30 01:23:48 +00:00
8bb7c276d0 docs: Phase 2 doc-side expansion 킥오프 + 구현 방법론 핸드오프
새 세션이 Phase 2(색인시 doc-side expansion)를 자립적으로 이어받을 컨텍스트 문서.
배경(rerank 반증→재정의→Phase1 진단 B우세→딥리서치→PoC), 설계 방향(KO↔EN 번역 별칭
+ 별도 FTS5 필드 + RRF, flag off), 이미 만든 측정 도구(kebab eval variants + dogfood golden),
그리고 지금까지와 동일한 구현 방법론(brainstorm→spec→plan→OMC teammate sequential 구현+리뷰
+독립검증, 모델 라우팅, 빌드 redirect+exit, 측정=variant eval 프록시금지, gitea-pr 리뷰루프)을 담음.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 01:19:14 +00:00
01a03463a6 Merge pull request 'feat(eval): 변형 일관성(query-paraphrase robustness) 평가 프레임워크' (#193) from feat/paraphrase-robustness-eval into main
Reviewed-on: #193
2026-05-30 01:12:26 +00:00
b6ad947378 docs: README 명령 표 슬림 + ARCHITECTURE 상세 이전·동기화
README 의 괴물 셀(ingest 2891→544, search 2952→687, ask 1244→415, tui 2300→453자)을
"무엇 + 핵심 flag + 포인터"로 축소. 빠진 구조 detail 은 ARCHITECTURE 로 이전:
- symbol path 형식에 Go/Java/Kotlin/C/C++ 추가 + code chunk provenance(citation.kind/code_lang/repo)
- Markdown title 자동 채움 순서(md-frontmatter-v2)
- RAG groundedness 검증(mDeBERTa-v3 XNLI, nli_threshold gate) 결정 행 신설
- TUI 행을 P9-1~4 완료 + F1 cheatsheet 로 최신화 (stale "진행 예정" 제거)
flag 망라는 --help, TUI 키는 in-app F1 cheatsheet(권위 런타임 소스)로 위임 — stale 방지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 01:10:39 +00:00
1529e6d991 docs(readme): PR #193 회차 1 리뷰 반영 — eval 명령 표에 aggregate/variants 추가
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 01:00:32 +00:00
5ad1f98227 docs(handoff): doc-side expansion 딥리서치 + PoC 결과 (Phase 2 방향 확정)
딥리서치(104 agent): 어휘격차 pool-miss 최선책 = 색인시 doc-side expansion.
PoC(dogfood KB): recall@50=0 이던 3쿼리가 별칭 추가로 rank1~2 부활(hybrid+vector,
골든 verbatim 아님=일반화). 핵심 미검증 고리 실 corpus 정량 확인.
Phase 2 = 색인시 doc-side expansion(KO↔EN 번역 별칭) → 별도 FTS5 필드 → RRF, flag off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 00:53:24 +00:00
a58cae2ff3 docs(research): 어휘격차 pool-miss 해결 딥리서치 레퍼런스
deep-research 워크플로(104 agent, 5각도, 22소스, 25 claim 3-vote 검증, 22 confirmed/3 killed).
결론: 색인시 doc-side expansion(doc2query)이 pool-miss 최선책 — pool 자체를 키우고
per-query 지연 ~0(색인시 1회), 정확매칭 보존(별도 필드 append). 단 vanilla mt5는 같은언어라
한/영 갭은 색인시 KO↔EN 대체 query 생성 필요. query-side(HyDE=거부된 per-query LLM,
Vector-PRF=recall 주장 기각)는 부적합. 검증은 기존 variant eval 로 가능.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 00:53:24 +00:00
7a1dff1684 docs(handoff): query-paraphrase robustness Phase 1 완료 + (A)/(B) 진단
8그룹×4변형(dogfood) 측정: groups=8 A_dominant=2 B_dominant=4 spread@10=0.750.
진단 — 문제는 한/영이 아니라 어휘 거리(영어 풀어쓴 문장도 miss, 일부 한국어는 OK).
B(어휘격차, recall@50=0, rerank 불가) 우세 → 쿼리 확장/번역 처방 신호. A(순위출렁)는
cap_theorem/vector_database 2그룹뿐. "측정 먼저" 논제 정량 검증(rerank 단독은 부분해법).
Phase 2 처방 결정 대기.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 00:53:24 +00:00
0988f66331 feat(cli): kebab eval variants <run_id> — 변형 일관성 진단 리포트
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 00:53:24 +00:00
82e02aa4fe fix(eval): 변형 일관성 리뷰 H1/M1 — pool truncation 방어 + answer 판정 정렬
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 00:53:24 +00:00
db4af0cc72 feat(eval): 변형 일관성 메트릭 + A/B(순위출렁/어휘격차) 분류
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 00:53:24 +00:00
ab20202241 test(eval): Task1 리뷰 nit — 3+멤버 그룹/group=None 테스트 + 에러 메시지에 divergent query id
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 00:53:24 +00:00
a51e6395c0 feat(eval): GoldenQuery.group + 그룹 정합성 검증 (변형 일관성 기반) 2026-05-30 00:53:24 +00:00
fe4c854673 docs(plan): query-paraphrase robustness Phase 1 구현 계획
5개 task: (1) GoldenQuery.group + 그룹 정합성 검증, (2) 변형 일관성 메트릭
모듈 + A/B(순위출렁/어휘격차) 분류, (3) kebab eval variants CLI, (4) dogfood
golden 변형 그룹 큐레이션, (5) 측정 + 진단 리포트. TDD bite-sized, 완성 코드.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 00:53:24 +00:00
1de3f4ffca docs(spec): query-paraphrase robustness 평가 프레임워크 설계 (측정 먼저)
목표 재정의: 한/영 overlap → 같은 의미의 다양한 표현(동의어·다른 어휘·풀어쓴
문장·한영)에서 일관된 답변 품질. 지난 reranker 실험이 overlap 프록시 최적화로
헛돈 교훈 반영 — 처방 전 진짜 지표(변형 일관성)를 직접 재는 평가부터.

Phase 1(본 spec 구현): kebab-eval golden suite에 변형 그룹(intent group) +
변형 일관성 메트릭(recall_spread, answer_consistency) + recall@pool vs recall@k로
(A)순위출렁/(B)어휘격차 자동 판별. Phase 2(처방)는 측정 결과 게이트 뒤 조건부.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 00:53:24 +00:00
99 changed files with 11271 additions and 370 deletions

821
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@ members = [
"crates/kebab-search",
"crates/kebab-embed",
"crates/kebab-embed-local",
"crates/kebab-embed-candle",
"crates/kebab-llm",
"crates/kebab-llm-local",
"crates/kebab-rag",
@@ -30,7 +31,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.24.0" # v0.24.0 — 상세 ingest 진행 로깅: 신규 wire 이벤트 asset_chunked / expansion_progress / asset_timings (ingest_progress.v1 additive), CLI 진행바 sub-message + phase timing 한 줄. asset 내부 parse/chunk/expansion/embed/store 가시화. wire v1 backward-compat. — 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),

View File

@@ -30,8 +30,12 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
## 머지 후 발견된 버그 / 결정 (요약)
- **candle 임베딩 백엔드 다변화** (2026-06-01, Track 1, v0.22.0): `provider = "candle"` opt-in 추가 — 같은 `multilingual-e5-large` 모델을 순수 Rust(candle)로 돌려 듀얼소켓 NUMA 서버의 onnxruntime 48-스레드 double-free 를 회피. `[models.embedding].num_threads`(+env `KEBAB_EMBED_THREADS`)로 CPU 스레드 캡. fastembed default 동작·벡터 불변, `embedding_version` 유지(재색인 0). Phase 0 스파이크 패리티 cosine 1.000000. 상세 HOTFIXES 동일 일자.
- **config 마이그레이션** (2026-05-31, PR #198): `kebab config migrate` 추가 — 기존 config.toml 에 빠진 섹션을 주석과 함께 채우고 deprecated 정리(멱등·`.bak`·dry-run, 값/주석 보존). `schema_version` 1→2, `init` 도 섹션 주석 포함, doctor 에 `config_migration` 체크. 상세 HOTFIXES 동일 일자.
머지 후 발견된 모든 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).

349
README.md
View File

@@ -1,151 +1,177 @@
# kebab — Local-first Knowledge Base
# kebab — Local-first Knowledge Base + RAG
`kebab` 는 개인용 로컬 knowledge base + RAG 도구다. Markdown / PDF / 이미지를 한 곳에 색인하고, 의미 검색 + page-단위 citation 포함 LLM 답변을 단일 binary 로 제공한다. 모든 추론은 로컬 (Ollama / fastembed) 에서 돌아간다. 대상 하드웨어: M4 48GB MacBook 1대, 사용자 1명.
## 사전 요구
- **Rust toolchain** ≥ 1.85 (workspace 가 edition 2024 + resolver 3 사용). [rustup](https://rustup.rs) 권장.
- **Ollama** — `kebab ask` 와 이미지 OCR/caption 가 사용. `https://ollama.com/download` 에서 설치 후 `ollama serve` 실행. 기본 LLM 은 gemma4 계열 (`ollama pull gemma4:e4b`) — OCR / caption 도 같은 family 라 모델 하나만 pull 하면 됨. 더 큰 variant 원하면 `gemma4:26b` 등으로 config override. config 의 `[models.llm].endpoint` 에 host:port 명시.
- **CPU only / RAM ≤ 16 GB 환경 권장 모델**: gemma4:e4b (8B) 는 CPU 추론에 무거워 RAG 한 답변이 5분을 넘기기 쉽다 — `[models.llm] request_timeout_secs` 의 기본 300 s 한도에 걸려 `error: kb-rag: llm.generate_stream` 으로 떨어진다 (HOTFIXES 2026-05-25). `gemma3:4b` / `qwen2.5:3b` / `phi3:mini` 같은 ≤ 4B Q4 모델로 바꾸면 답변 1-3 분에 안정 동작 (확장 도그푸딩에서 검증). 모델 storage 가 부담이면 `OLLAMA_MODELS=/path` env 로 위치 분리 가능.
- **`request_timeout_secs` 노브 (v0.17.0)**: `[models.llm] request_timeout_secs = 1200` (또는 `KEBAB_MODELS_LLM_REQUEST_TIMEOUT_SECS=1200`) 로 한도를 늘려 큰 모델도 시도 가능. 단 응답 동안 RAM 점유가 길어진다. **`= 0` 은 disable 이 아니라 "즉시 timeout"** (reqwest 의 의미상) — "사실상 무제한" 의도면 `u64::MAX` 또는 `86400` 같이 큰 finite 값 사용.
- **sudo 없이 설치 (격리 디렉토리 사용)**: `install.sh``/usr/local/bin/ollama` + `systemd` 유닛까지 건드리는 게 부담이면 binary tarball 만 받아 사용자 디렉토리에 풀고 env 로 모델 위치 분리하면 된다.
```bash
mkdir -p /opt/ollama/{models,logs}
curl -fL https://ollama.com/download/ollama-linux-amd64.tar.zst -o /tmp/ollama.tar.zst
zstd -d /tmp/ollama.tar.zst -o /tmp/ollama.tar && tar -xf /tmp/ollama.tar -C /opt/ollama/
# bin/ollama + lib/ollama/ 가 풀린다. 모델 디렉토리는 OLLAMA_MODELS 로 분리.
OLLAMA_MODELS=/opt/ollama/models OLLAMA_HOST=127.0.0.1:11434 \
/opt/ollama/bin/ollama serve > /opt/ollama/logs/serve.log 2>&1 &
/opt/ollama/bin/ollama pull gemma3:4b
```
루트 디스크 부담을 분리하고 싶을 때 (`~/.ollama/models` 가 기본) 그대로 활용. systemd 가 없는 컨테이너 / WSL2 / 회사 머신 등에서 유용.
- **`kebab ask --stream` 권장 (fb-33)**: 모델 cold start 가 길 때 (8B+ 또는 첫 호출) `--stream` 으로 토큰을 stderr 에 ndjson 으로 흘려 받으면 5 분 timeout 한도 안에서도 첫 토큰이 빨리 보여 사용자 체감이 개선된다. 동일 inference 시간이라도 wait-and-pray 보다 progressive 가 안정적. CLI: `kebab ask "..." --stream 2> events.ndjson > final.json`. MCP host 도 `streaming_ask` capability flag 가 `true` 면 자동 사용 권장.
- **빌드 디스크** — 첫 빌드 시 `target/` 가 610 GB (Lance + DataFusion + fastembed). 여유 확인.
- **fastembed 모델** — 첫 `kebab ingest` 시 `multilingual-e5-large` (~1.3 GB, fb-39b) 자동 다운로드. `config.toml` 에서 `model = "multilingual-e5-small"` 로 명시하면 이전 모델 사용.
## 설치
표준 경로는 `cargo install` — `~/.cargo/bin/kebab` 가 PATH 에 있는지만 확인하면 끝.
```bash
# 1) repo clone
git clone https://gitea.altair823.xyz/altair823-org/kebab.git
cd kebab
# 2) binary 빌드 + 설치 (~/.cargo/bin/kebab)
cargo install --path crates/kebab-cli --locked
# 3) PATH 확인 (아직 추가 안 했으면 ~/.bashrc / ~/.zshrc 에 추가)
which kebab # → /Users/<you>/.cargo/bin/kebab 같은 경로
kebab --version # → kebab 0.1.0
```
git URL 직접 install 도 가능 (clone 없이):
```bash
cargo install --git https://gitea.altair823.xyz/altair823-org/kebab.git --bin kebab --locked
```
업데이트는 `git pull && cargo install --path crates/kebab-cli --locked --force` 또는 git URL 형식의 경우 `cargo install --git ... --force`.
제거는 `cargo uninstall kebab-cli`. 이 명령은 binary 만 지우고 워크스페이스 데이터는 그대로 남는다. 데이터까지 정리하려면 `kebab reset --all --yes` (config + data + cache + state 4 개 XDG 경로 모두 wipe — **irreversible**, 재시작 시 `kebab init` 다시 실행). 부분 wipe 는 `kebab reset --data-only` (config 보존), `kebab reset --vector-only` (Lance + `embedding_records` 만, 다음 ingest 가 re-embed), **`kebab reset --orphans-only`** (현재 walker scope 밖에 있는 stored doc 만 정리 — `config.workspace.include` 좁히거나 sub-dir 옮긴 후 explicit reconcile; fs 의 file 은 건드리지 않음) 등.
`kebab` 는 개인용 로컬 knowledge base + RAG 도구다. Markdown · PDF · 이미지 · 소스코드를 한 곳에 색인하고, 하이브리드 의미 검색과 근거 인용을 포함 LLM 답변을 **단일 binary** 로 제공한다. 모든 추론은 로컬 (Ollama + fastembed) 에서 돌아간다.
## Quick start
사전 요구는 두 가지뿐이다.
- **Rust toolchain** ≥ 1.85 (workspace 가 edition 2024 사용). [rustup](https://rustup.rs).
- **Ollama** — `kebab ask` 와 이미지/PDF OCR 가 사용. [공식 설치 안내](https://ollama.com/download) 참고 후 `ollama serve` 실행. 기본 LLM family 는 gemma4 (`ollama pull gemma4:e4b`) — OCR/caption 도 같은 family 라 모델 하나면 된다. CPU-only 환경이면 소형 모델 (예: `gemma3:4b`) 을 권장.
```bash
# 첫 실행 — XDG 경로에 데이터 디렉토리 + config.toml 생성
# 1) 빌드 + 설치 (~/.cargo/bin/kebab)
git clone https://gitea.altair823.xyz/altair823-org/kebab.git
cd kebab
cargo install --path crates/kebab-cli --locked
# 2) 데이터 디렉토리 + config.toml 생성 (XDG 경로)
kebab init
# config 손보 — workspace.root, 모델 endpoint 등 설정 (지원 형식: md / png / jpg / pdf / rs / py / ts / js / go)
# 3) config 최소 손보 — workspace.root (색인할 폴더) 와 LLM endpoint
${EDITOR:-vi} ~/.config/kebab/config.toml
# 색인 (Markdown / 이미지 / PDF 모두 한 번에)
# 4) 색인 (Markdown · PDF · 이미지 · 소스코드 한 번에)
kebab ingest
# 검색 (citation 의 source_span 이 매체별로 line / region / page)
kebab search "Markdown chunking 규칙" --mode hybrid
# 5) 검색 (hybrid = lexical + vector RRF, citation 포함)
kebab search "Markdown chunking 규칙"
# 질문 (Ollama 필요, PDF 인용 시 page 번호 surface)
# 6) 질문 (RAG 답변 + 근거 인용, Ollama 필요)
kebab ask "내 KB 설계에서 저장소 전략은?"
# Ratatui 셸 (Library + Search + Ask + Inspect 패널, desktop 진행 중)
kebab tui
# 헬스 체크 (config 경로 / 데이터 디렉토리 쓰기 가능 여부)
kebab doctor
```
격리된 임시 워크스페이스로 돌려보는 절차는 [docs/SMOKE.md](docs/SMOKE.md) — `--config <path>` 로 분리. 이미지 / PDF fixture 가 필요하면 두 example 바이너리 (`cargo run --release --example gen_smoke_pdf -p kebab-parse-pdf` / `gen_smoke_png -p kebab-parse-image`) 로 시스템 dep 없이 in-tree 생성 가능.
clone 없이 git URL 로 바로 설치할 수도 있다: `cargo install --git https://gitea.altair823.xyz/altair823-org/kebab.git --bin kebab --locked`. 업데이트는 동일 명령에 `--force`. 제거는 `cargo uninstall kebab-cli` (데이터는 보존 — 데이터까지 지우려면 `kebab reset --all --yes`).
설치 없이 dev 흐름으로 돌려볼 때는 `cargo run --release -p kebab-cli -- <subcommand>` 또는 `cargo build --release && ./target/release/kebab <subcommand>`.
설치 없이 dev 흐름으로 돌려볼 때는 `cargo run --release -p kebab-cli -- <subcommand>`. 격리된 임시 워크스페이스로 검증하는 절차는 [docs/SMOKE.md](docs/SMOKE.md) (`--config <path>` 로 분리).
## 핵심 기능
### 하이브리드 검색 + citation
lexical (FTS5 BM25) 과 vector (cosine) 두 채널을 **RRF fusion** 으로 합쳐 검색한다. 모든 hit 은 출처 위치를 매체별로 정확히 담는다 — Markdown/코드는 line, 이미지는 region, PDF 는 page. `--tag` · `--media` · `--lang` · `--path-glob` 등 다양한 필터와 `--max-tokens` · `--cursor` 같은 agent budget flag 를 지원한다.
### doc-side expansion 별칭 (opt-in)
색인 시 각 청크에 대해 "같은 의미의 다른 표현"(동의어 · 약어 · 한↔영 번역 · 풀어쓴 설명) 별칭을 LLM 으로 생성해 별도 dense 벡터로 색인한다. 설명형 query 나 cross-lingual query 의 검색 일관성을 높인다 (나무위키 ~1000 문서 CS corpus 측정: 변형 일관성 14/18 → 16/18, 대조군 false-positive 미유발). 청크당 LLM 호출이 들어 비용이 크므로 **default off**`[ingest.expansion] enabled = true` 로 opt-in.
### 파생물 캐시 (자동)
embedding 벡터와 별칭 LLM 결과를 청크 **내용 해시** 로 캐싱한다 (`derivation_cache`). 재색인·갱신 시 내용이 같은 청크는 재계산을 건너뛴다 (측정: cold 1879s → warm 13s ≈ 145배). 캐시 키에 모델·프롬프트·차원 버전이 포함돼 버전 변경 시 자동 무효화된다 (cascade 안전). 별도 설정 없이 투명하게 동작한다. (현재 TTL/LRU 자동 정리는 미구현 — 누적된 캐시는 `kebab reset` 으로만 정리.)
### 외부 계산 + 로컬 검색 워크플로
search/ask 는 원본 파일 없이 KB 산출물만으로 동작한다 (청크 본문이 SQLite 에 저장되고 문서 경로는 상대경로로 기록됨). 비싼 색인(임베딩·OCR·별칭 생성)을 성능 좋은 머신에서 수행한 뒤(예: Apple Silicon 맥에서 candle Metal GPU), **두 산출물만** 다른 머신(예: NUMA 서버)으로 복사하면 그대로 검색·질문할 수 있다.
**무엇을 복사하나 — `[storage]` 에서 정의된 두 경로:**
| 복사 대상 | config 키 (`[storage]`) | 기본 경로 | 내용 |
|-----------|------------------------|-----------|------|
| `kebab.sqlite` | `sqlite = "{data_dir}/kebab.sqlite"` | `{data_dir}/kebab.sqlite` | 문서·청크·본문·FTS5·메타 |
| `lancedb/` | `vector_dir = "{data_dir}/lancedb"` | `{data_dir}/lancedb/` | 임베딩 벡터 |
`{data_dir}``[storage].data_dir` (예: `~/.local/share/kebab`). `models/`(`model_dir``assets/`(`asset_dir`)는 **복사 불필요** — 모델은 각 머신이 자기 캐시를 받고, asset 원본 바이트는 검색·질문에 쓰이지 않는다 (단일파일/`stdin` 색인의 원본 재읽기·재색인까지 보존하려면 `assets/` 도 함께 복사).
```bash
# ingest 가 끝난(쓰기 없는) 상태에서 복사
rsync -a <src-data_dir>/kebab.sqlite user@server:<dst-data_dir>/
rsync -a <src-data_dir>/lancedb/ user@server:<dst-data_dir>/lancedb/
```
조건: **양쪽 동일 `kebab` 버전 + 동일 임베딩 모델/차원** (`[models.embedding].model`·`dimensions`). provider 는 달라도 됨 (예: 맥 `candle`/Metal ↔ 서버 `candle`/CPU 또는 `fastembed` — 같은 모델이면 벡터 호환). 복사는 반드시 ingest 가 돌지 않을 때.
### 멀티미디어 색인
Markdown · PDF · 이미지(OCR + caption) · 소스코드(Rust/Python/TS/JS/Go/Java/Kotlin/C/C++ AST) · 리소스(YAML/Dockerfile/TOML/JSON/XML 등)를 확장자에 따라 자동으로 적절한 chunker 에 라우팅한다. embedded text 가 없는 scanned PDF 는 `[pdf.ocr]` 로 page-단위 OCR (opt-in). 전체 확장자→chunker 매핑은 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
### RAG (근거 인용 + 거절)
검색 결과를 근거로 LLM 답변을 생성하고 [#번호] 인용을 단다. 근거가 부족하면 답을 지어내지 않고 거절한다. compound 질문은 `--multi-hop` 으로 분해→synthesize. 답변의 groundedness 는 mDeBERTa XNLI 로 검증할 수 있다 (`[rag] nli_threshold`, default off).
### TUI
`kebab tui` 는 Ratatui 셸 — Library / Search / Ask / Inspect 패널을 vim-style 모드로 다룬다. 키 매핑은 앱 내 `F1` cheatsheet 가 권위 소스다.
## 명령
| 명령 | 동작 |
|------|------|
| `kebab init` | XDG 경로에 데이터 디렉토리 + config.toml 생성 |
| `kebab ingest [<path>]` | Markdown / 이미지 / PDF / Rust 소스코드 색인 (idempotent). TTY 에서는 stderr 진행 바, non-TTY (CI / pipe) 는 stderr 한 줄씩, `--json` 은 stdout 에 `ingest_progress.v1` 라인 streaming 후 마지막에 `ingest_report.v1`. Ctrl-C 한 번이면 현재 asset 마무리 후 abort (부분 commit 보존, idempotent re-run), 두 번째 Ctrl-C 는 hard exit. Markdown title 이 frontmatter 에 없어도 첫 H1 → H2 → 첫 paragraph 80 자 → 파일명 순으로 자동 채움 (parser_version `md-frontmatter-v2`) — 기존 색인된 doc 도 다음 ingest 에서 새 title 로 갱신. **Incremental** (p9-fb-23): 두 번째 이후의 ingest 는 변하지 않은 doc (blake3 + parser/chunker/embedder version 모두 동일) 의 parse/chunk/embed/vector upsert 를 자동 스킵. final summary 에 `N unchanged` 카운트 표시. `--force-reingest` 로 skip 무시 강제 재처리. **지원 형식** (extractor 자동 결정 — config 에 명시 불가): Markdown (`.md`), 이미지 (`.png` / `.jpg` / `.jpeg`, OCR + caption), PDF (`.pdf`), **소스코드** (`.rs` → `code-rust-ast-v1`, `.py` → `code-python-ast-v1`, `.ts`/`.tsx` → `code-ts-ast-v1`, `.js`/`.mjs`/`.cjs`/`.jsx` → `code-js-ast-v1`, `.go` → `code-go-ast-v1`, `.java` → `code-java-ast-v1`, `.kt`/`.kts` → `code-kotlin-ast-v1`, `.c`/`.h` → `code-c-ast-v1`, `.cpp`/`.cc`/`.cxx`/`.hpp`/`.hh`/`.hxx` → `code-cpp-ast-v1` — 모두 tree-sitter AST chunker; **Tier 2 리소스 파일**: `.yaml`/`.yml` → `k8s-manifest-resource-v1` (apiVersion+kind 파싱), `Dockerfile`/`Dockerfile.*`/`*.dockerfile` → `dockerfile-file-v1` (전체 파일), `Cargo.toml`/`pyproject.toml`/`.toml`/`package.json`/`tsconfig.json`/`.json`/`pom.xml`/`.xml`/`build.gradle`/`.gradle`/`go.mod` → `manifest-file-v1` (전체 파일) — yaml (k8s) / dockerfile / toml / json / xml / groovy / go-mod 지원); **Tier 3 paragraph fallback** (`.sh`/`.bash`/`.zsh` → `code-text-paragraph-v1`, blank-line paragraph split + 80-line/20-overlap line-window. Tier 1/2 가 0 chunk 또는 Err 시 자동 fallback — 비-k8s YAML 같은 케이스 picked up. symbol = None, lang 은 원본 보존.). 다른 확장자는 자동 skip — `IngestItem.warnings` 에 사유 (`"unsupported media type: .docx"` 등), `IngestReport.skipped_by_extension` 에 카운트 분류, CLI / TUI summary 에 breakdown 표시. 코드 chunk 는 `citation.kind = "code"` 에 `citation.lang = "<lang>"` + `symbol` + line range 를 담고, SearchHit top-level 에 `code_lang` + `repo` (`.git/` walk-up 의 디렉토리 이름) 가 backfill 됨. `--code-lang rust` / `--code-lang python` / `--code-lang typescript` / `--code-lang javascript` / `--code-lang go` / `--code-lang java` / `--code-lang kotlin` / `--code-lang yaml` / `--code-lang dockerfile` / `--code-lang toml` / `--code-lang json` / `--code-lang xml` / `--code-lang groovy` / `--code-lang go-mod` / `--code-lang shell` / `--code-lang c` / `--code-lang cpp` / `--media code` filter 로 언어별·코드 전용 검색 가능 (p10-1A-1 filter flags). Python symbol 은 workspace 경로 → dotted module path prefix (예: `kebab_eval.metrics.compute_mrr`), TS/JS symbol 은 slash-style module path prefix (예: `src/Foo.Foo.search`), Go symbol 은 `package.Func` / `package.(*Receiver).Method` 형식, Java / Kotlin symbol 은 `com.foo.Foo.bar` 형식 (패키지 + 클래스 + 메서드/필드). |
| `kebab search --mode {lexical,vector,hybrid} "<query>" [--no-cache] [--max-tokens N] [--snippet-chars N] [--cursor <opaque>] [--tag T] [--lang L] [--path-glob G] [--trust-min LEVEL] [--media TYPE] [--ingested-after RFC3339] [--doc-id ID] [--trace] [--bulk] [--repo NAME ...] [--code-lang LIST]` | 검색. hybrid는 RRF fusion, citation 포함. 같은 process 안에서 동일 query (NFKC + trim + lowercase 정규화) 반복 시 in-process LRU 캐시 hit (capacity = `[search] cache_capacity`, default 256). `--no-cache` 로 강제 bypass — 디버깅용. ingest commit 발생 시 `kv['corpus_revision']` bump 으로 모든 entry 자동 stale. **`--max-tokens` / `--snippet-chars` / `--cursor` (p9-fb-34)** — agent budget controls. `--json` 출력은 `search_response.v1` wrapper (`{hits, next_cursor, truncated}`) — pre-fb-34 의 bare array 와 호환 안 됨. mismatched cursor → `error.v1.code = stale_cursor`. **filter flags (p9-fb-36):** `--tag` 는 반복 가능 flag (`--tag rust --tag async`) 로 OR 매칭, `--media` 는 `,` 구분 다중 값 OR 매칭, 나머지 flags 간은 AND 조합. `--trust-min` 은 `primary\|secondary\|generated` 중 하나 (해당 level 이상 포함). `--ingested-after` 는 RFC3339 UTC — 파싱 실패 시 `error.v1.code = config_invalid` (exit 2). `--media md` 는 `markdown` alias 로 정규화. 알 수 없는 `--media` 값은 무조건 empty hits (오류 아님). **`--trace` (p9-fb-37)** — `search_response.v1.trace` 에 lexical / vector pre-fusion 후보 + RRF union + per-stage timing (`lexical_ms` / `vector_ms` / `fusion_ms` / `total_ms`) 노출. trace 요청은 캐시 우회 (`--no-cache` 없이도 항상 cold). **`--bulk` (p9-fb-42)** — stdin ndjson 으로 N query 한 번에 실행. `--json` 면 stdout per-query ndjson (`bulk_search_item.v1`) + stderr summary (`bulk_summary: total=N succeeded=S failed=F`). Cap 100. agent 가 query decomposition 후 sub-query 일괄 실행 시 single round-trip — App instance 재사용으로 캐시 / embedder cold-start 비용 한 번만. Per-query failure 는 item 의 `error` (error.v1) 에 격리, 다른 query 계속 진행. 입력은 stdin ndjson — 줄당 한 query object, `{"query":"<text>"}` 만 필수 (string; nested object 아님), `mode`/`k`/`trust_min`/`ingested_after`/`media`/`tag`/`lang` optional (`docs/wire-schema/v1/bulk_search_input.schema.json`). 예: `echo '{"query":"한국","mode":"lexical","k":3}' | kebab search --bulk --json`. **code corpus filters (p10-1A-1):** `--repo` 는 반복 가능 (`--repo kebab --repo other`) OR 매칭. `--code-lang` 는 반복 또는 comma 다중 값 (`--code-lang rust,python`), 알 수 없는 값은 빈 hits. `--media code` 는 Tier 1/2/3 모든 code chunk 포함. 1A-1 시점에서는 indexed 된 code chunk 가 없어 filter 가 항상 빈 결과 — 1A-2 (Rust AST chunker) 머지 이후 실효. **v0.20.1 V009 morphological tokenizer (한국어 + 영어 동작 변경):** `chunks_fts` 가 FTS5 `unicode61` + 한국어 lindera ko-dic 형태소 분석 결과를 별 column 으로 prepend. **한국어 2자 query 지원** — '한국', '서울', '지하철' 같은 2자/3자 단어가 형태소 분해 후 hit. **영어는 whole-token 매칭** — V002 동작으로 회귀 (`tokenizer` query 는 `tokenizer` 토큰만 hit, `token` 같은 substring 은 hit X). substring recall 이 필요하면 vector/hybrid mode 권장. `kebab.sqlite` 파일 크기는 lindera ko-dic embedded dict 와 tokenized_korean_text column 의존성으로 다소 증가. V009 자동 backfill (`App::open_with_config` 의 first-boot hook) — re-ingest 불필요. |
| `kebab list docs` | 색인된 문서 목록. human-readable 출력은 `doc_id \t title \t doc_path` (title 은 heading 기반이라 중복 가능 — doc_path 로 구분). `--json` 은 `doc_summary.v1` array (title / doc_path 모두 포함, wire schema 불변). |
| `kebab inspect doc <id>` / `kebab inspect chunk <id>` | raw record 보기 |
| `kebab fetch chunk <id> [--context N]` / `kebab fetch doc <id> [--max-tokens N]` / `kebab fetch span <doc_id> <ls> <le> [--max-tokens N]` | (p9-fb-35) verbatim text fetch from indexed corpus. wire = `fetch_result.v1` (kind discriminator). chunk: target + ±N ordinal-context chunks. doc: full normalized markdown. span: 1-based line range (PDF/audio rejected as `error.v1.code = span_not_supported`). chars/4 budget on doc/span. |
| `kebab ask "<query>" [--show-citations / --hide-citations] [--session <id>] [--stream] [--multi-hop]` | RAG 답변 + 근거 인용. 답변 후 `근거:` block 으로 full path / line range / score 한 줄씩 (default ON — `--hide-citations` 로 끄기, pipe 시 유용). 근거 부족 시 거절. Ollama 필요. `--session <id>` 로 multi-turn — 첫 호출에서 SQLite `chat_sessions` 에 자동 생성, 이후 호출은 prior turns 를 history 로 받아 follow-up. session id 는 사용자 지정 (e.g. `kb-rust-async-2026-05`) — `kebab reset --data-only` 로 모든 session wipe. **`--stream` (p9-fb-33)** 로 ndjson `answer_event.v1` event (retrieval_done → token* → final) 를 stderr 에 흘리고 stdout 마지막 줄에 기존 `answer.v1` — agent 가 token 즉시 소비 가능. **`--multi-hop` (v0.18.0 fb-41)** — single-pass 대신 decompose → decide → synthesize 의 N-hop loop. compound 질문 (cross-doc / prereq chain) 에 효과적. 최종 답변 후 mDeBERTa-v3 XNLI 가 `(packed_chunks, generated_answer)` entailment 검사 — `[rag] nli_threshold > 0` (default 0.0 = disabled, production 권장 0.5) 일 때 활성. entailment < threshold → `refusal_reason = "nli_verification_failed"` (LLM-self-judge ceiling 극복, S7 caffeine hallucination 같은 케이스 catch). 첫 호출 시 ~280 MB ONNX model 자동 다운로드 + RAM peak ~7-8 GB (gemma3:4b 기준). model unavailable 시 `refusal_reason = "nli_model_unavailable"`, 우회는 `[rag] nli_threshold = 0` 임시 disable. |
| `kebab doctor` | 설정/모델/DB 헬스 체크 |
| `kebab tui` | Ratatui 셸 (Library + Search + Ask + Inspect 패널, desktop 진행 중). Library 에서 `r` 키로 background ingest 시작 — 화면 하단 status bar 가 진행 표시, 완료/abort 시 final 라인 잠시 유지 후 자동 hide. ingest 진행 중 `Esc` / `Ctrl-C` 가 cancel signal (그 외에는 quit). vim-style mode (header 우측 `-- NORMAL --` / `-- INSERT --`) — Library/Inspect 는 자동 NORMAL, Search/Ask 는 자동 INSERT. `i` 로 Normal→Insert (모든 pane — p9-fb-21), `Esc` 로 Insert→Normal 어디서나. mode-authoritative dispatch — Search 의 `j/k/o/g`, Ask 의 `e/j/k` 는 NORMAL 모드에서만 명령으로 동작, INSERT 에서는 입력 문자로 typing. (Search 의 chunk inspect 키는 `i`→`o` 로 rebind — `i` 가 universal Insert toggle.) **`F1` 로 cheatsheet popup** (현재 pane 의 키 매핑 + global 토글 표) — `Esc` / `F1` 로 닫기. Search 패널은 200ms debounce 후 background worker 가 검색 — 키 입력으로 UI freeze 안 됨, 사용자가 계속 타이핑하면 stale 결과 자동 폐기 (generation counter). Ask 패널은 multi-turn — 같은 conversation 안에서 Q1/A1, Q2/A2 transcript 누적, 다음 질문이 이전 턴을 history 로 받아 답변. 답변 본문은 markdown 렌더 (bold/italic/inline code/heading/list/code fence/table/blockquote, raw `**bold**` 가 실제 굵게 표시). `Ctrl-L` 로 새 conversation 시작. Search 의 `g` 키가 `$EDITOR` (기본 `vi`) 로 hit 의 citation 위치 열기 — 종료 후 TUI 화면이 자동으로 깨끗이 redraw. CLI `kebab ask` 는 raw markdown 그대로 (terminal 호환성 위해). Library 의 doc-list 가 한글 / 일본어 / 중국어 (CJK) 제목을 wide-char 정확한 column width 로 truncate — 한글 제목이 한 줄을 넘기지 않음 (CJK 1 자 = 2 col). Search/Ask/Filter 입력의 cursor 가 wide char 위에서 column 단위로 정렬 — 한글 입력 시 caret 이 글자 옆에 정확히 놓임. `← / →` 로 입력 문자열 중간 cursor 이동 (한글 한 글자 = 2 column 이라도 한 번에 이동), `Home / End` 로 양 끝 점프, `Delete` 로 cursor 위치 char 삭제 — 모든 input pane (Ask / Search / Library filter overlay) 동일 (p9-fb-22). Ask 트랜스크립트는 새 답변이 viewport 아래로 누적될 때 자동으로 tail 을 따라감 (auto-scroll); `j` / `k` 로 위로 스크롤하면 freeze, `Shift-G` 로 다시 bottom + auto-tail 재개. 화면 하단 hint line 은 한국어 동사구로 (`"위로"` / `"아래로"` / `"필터"` / `"타이핑 검색어"` / `"Esc 로 NORMAL 모드"` / `"i 입력모드"` 등) + 현재 (pane, mode) 조합에 맞춰 자동 분기, **첫 fragment 가 항상 `F1 도움말`** (cheatsheet 발견성 보장). 모든 모드에서 항상 떠 있는 상태바 — `kebab v<version> │ <pane> │ <docs> docs │ <state>` (state: streaming/searching/indexing/idle, ingest 진행 중에는 progress 가 같은 자리에 흡수됨). Ask 진입 시 conversation id 8 자 prefix 도 함께 표시. Ask 트랜스크립트와 Inspect 양쪽에서 `PgUp / PgDn` 으로 10 줄씩 페이지 스크롤. Library 의 doc list 위에는 `TITLE / TAGS / UPDATED / CHUNKS` 컬럼 헤더 행 표시 (display-width 정렬, Hangul / CJK 안전). |
| `kebab reset [--all / --data-only / --vector-only / --config-only] [--yes]` | XDG 데이터 wipe. **Irreversible.** TTY 면 confirm prompt, 아니면 `--yes` 필수. `--vector-only` 는 SQLite `embedding_records` 도 함께 truncate (orphan 방지) |
| `kebab eval run / compare` | golden query 회귀 측정 |
| `kebab schema [--json]` | introspection — wire schemas / capabilities / models / stats 한 번에. `--json` 은 `schema.v1` wire; 사람 모드는 서식 출력. **stats 에 (p9-fb-37) `media_breakdown` (5 keys: markdown / pdf / image / audio / other) + `lang_breakdown` (BCP-47 코드, NULL 은 literal `"null"`) + `index_bytes` (sqlite + lancedb on-disk 합계) + `stale_doc_count` (`config.search.stale_threshold_days` 초과 doc 수) 추가.** **`index_version` 두 곳 주의 (v0.20.2):** `schema.v1.models.index_version` = vector store (LanceDB) version, `search_hit.v1.index_version` = lexical (FTS5) version — 서로 다른 축, cascade 에서 별도 추적. |
| `kebab ingest-file <path>` | 단일 파일 ingest (workspace 외부 가능). 바이트는 `<workspace.root>/_external/<hash12>.<ext>` 로 copy. `.kebabignore` 매치 시 stderr warn 후 진행 (explicit ingest 가 bypass intent). |
| `kebab ingest-stdin --title <T> [--source-uri <URI>]` | stdin 의 markdown 본문 ingest. frontmatter (title + source_uri) 자동 prepend. v1 markdown only. |
| `kebab mcp` | MCP (Model Context Protocol) stdio server. agent host (Claude Code / Cursor / OpenAI Agents) 가 spawn 하여 tool 호출 (`search` / `bulk_search` / `ask` / `fetch` / `schema` / `doctor` / `ingest_file` / `ingest_stdin`). `--config` honor. |
| `kebab ingest [<path>]` | 워크스페이스 스캔 후 새/변경 문서 색인 (idempotent · incremental, `--force-reingest` 로 강제 재처리). 지원 확장자는 자동 skip. 진행바는 문서별 청크 수 · 별칭 확장 라이브 카운터 · 문서 종료 시 phase별 소요시간(parse/chunk/expand/embed/store)을 표시 (`--json``asset_chunked`/`expansion_progress`/`asset_timings` 이벤트로) |
| `kebab ingest-file <path>` | 단일 파일 ingest (workspace 외부 가능 — `_external/` 로 deterministic copy) |
| `kebab ingest-stdin --title <T>` | stdin 의 markdown 본문 ingest |
| `kebab search --mode {lexical,vector,hybrid} "<query>" [flags]` | 검색 (default hybrid = RRF fusion, citation 포함). 필터/budget flag 는 `--help` |
| `kebab ask "<query>" [flags]` | RAG 답변 + 근거 인용 (Ollama 필요). `--session` (multi-turn) · `--stream` · `--multi-hop` |
| `kebab list docs` | 색인된 문서 목록 |
| `kebab inspect doc <id>` / `inspect chunk <id>` | raw record 보기 |
| `kebab fetch chunk\|doc\|span <id> [flags]` | indexed corpus 에서 verbatim text fetch |
| `kebab eval run \| aggregate \| compare \| variants` | golden query 회귀 측정 + 변형 일관성 진단 |
| `kebab schema [--json]` | introspection — wire schemas / capabilities / models / stats |
| `kebab doctor` | 설정 / 모델 / DB 헬스 체크 |
| `kebab tui` | Ratatui 셸 (Library / Search / Ask / Inspect) |
| `kebab mcp` | MCP stdio server (`search` / `bulk_search` / `ask` / `fetch` / `schema` / `doctor` / `ingest_file` / `ingest_stdin`) |
| `kebab reset [--all \| --data-only \| --vector-only \| --config-only \| --orphans-only] [--yes]` | XDG 데이터 wipe (**irreversible**) |
모든 명령에 `--json` 플래그. 출력은 frozen wire schema v1 (`schema_version` 항상 포함, 예: `ingest_report.v1`, `ingest_progress.v1`, `search_hit.v1`, `answer.v1`, `doctor.v1`, `reset_report.v1`, `schema.v1`). `--json` 모드에서 fatal error 는 stderr 에 `error.v1` ndjson 으로 emit (exit code 0/1/2/3 unchanged).
모든 명령에 `--json` 플래그가 있고, 출력은 frozen **wire schema v1** 을 따른다 (`schema_version` 항상 포함). `--json` 모드에서 fatal error 는 stderr 에 `error.v1` ndjson 으로 emit (exit code 0/1/2/3 불변). 글로벌 flag: `--readonly` (write-path 비활성화), `--quiet` (human stderr 억제), env `KEBAB_PROGRESS=plain`. 전체 flag·wire 의미는 `kebab <cmd> --help` 와 [docs/wire-schema/v1/](docs/wire-schema/v1/). 외부 agent 통합(Claude Code skill / MCP)은 [docs/mcp-usage.md](docs/mcp-usage.md) 와 [integrations/](integrations/).
글로벌 플래그: `--readonly` (또는 `KEBAB_READONLY=1`) — 모든 write-path 명령 (`ingest` / `ingest-file` / `ingest-stdin` / `reset`) 을 비활성화, exit 1. `--quiet` — 진행 바 / hint 등 human-readable stderr 억제 (exit code / stdout 출력은 그대로). `KEBAB_PROGRESS=plain` — TTY 가 없는 환경에서도 진행 상황을 plain-text 한 줄씩 stderr 로 출력 (spinner 대신).
## Configuration
### `lang` vs `code_lang` (v0.20.2)
`~/.config/kebab/config.toml``kebab init` 가 XDG 경로에 생성한다. 핵심 노브만 정리한다 (전체 절은 생성된 파일 주석 참고, 예시는 [docs/SMOKE.md](docs/SMOKE.md)).
- `doc.lang` / search hit 의 `lang` 은 **자연어 prose** 의 언어 (lingua 감지 — Markdown / PDF 본문). 감지 불가 / 자연어 아님 → `"und"`.
- 소스코드 문서는 자연어 감지를 하지 않으므로 `lang = "und"` 가 정상이다. 소스 언어는 별도 `code_lang` (`rust` / `python` / ...) 에 담긴다.
- `schema --json` 의 `lang_breakdown` 에서 `und` 비중이 높은 것은 보통 code 문서 비중 때문 — `code_lang_breakdown` / `code_lang_chunk_breakdown` 로 소스 언어 분포를 확인한다.
```toml
[workspace]
root = "~/KnowledgeBase" # 색인할 폴더. 절대 / tilde / env / 상대 경로 가능.
# 상대 경로의 base 는 config.toml 위치 (cwd 무관).
### Score 해석 (fb-38)
`search_hit.v1.score` 는 **ranking signal** 이지 confidence 가 아니다. `score_kind` 필드로 의미 선언:
| `score_kind` | 의미 | 범위 |
|--------------|------|------|
| `rrf` (hybrid) | RRF normalized | `[0, 1]`, ceiling = 1.0 (양 채널 rank=1) |
| `bm25` (lexical) | raw BM25 | unbounded (≥ 0) |
| `cosine` (vector) | cosine sim | `[-1, 1]` |
#### RRF 수식 (hybrid mode)
```
chunk c 의 raw RRF = Σ_m 1 / (k_rrf + rank_m(c))
여기서 m ∈ {lexical, vector}, k_rrf = config.search.rrf_k (default 60).
양 채널 모두 rank=1 일 때 raw RRF = 2 / (k_rrf + 1) ≈ 0.0328.
normalize: rrf_score = raw_rrf / (2 / (k_rrf + 1))
→ rrf_score ∈ [0, 1]. 양쪽 rank=1 → 1.0, 한 쪽만 등장 → ≈ 0.5 천장.
[models.embedding]
provider = "fastembed" # "fastembed"(기본, onnxruntime) / "candle"(순수 Rust)
# / "none"(lexical-only). candle 는 같은 모델·같은 벡터를
# 순수 Rust 로 돌려 NUMA 서버의 onnxruntime 48-스레드
# double-free 를 피하는 opt-in 백엔드 (재색인 불필요).
model = "multilingual-e5-large" # 다국어 sentence embedding (1024-dim).
# 첫 ingest 시 ONNX (~1.3GB) 자동 다운로드.
# candle provider 는 safetensors (~2GB) 다운로드.
dimensions = 1024 # config 와 LanceDB stored dim 불일치 시 검색 0건.
num_threads = 0 # candle 전용 CPU 스레드 캡 (0=auto=#cores).
# env KEBAB_EMBED_THREADS 가 우선. NUMA 노드 바인딩은
# numactl 과 조합. fastembed provider 는 무시.
```
`rrf_score = 0.5` 의 의미: chunk 가 한 채널 (lexical 또는 vector) 에서만 rank 1 로 등장. confidence 50% 가 아님 — RRF 수식의 산술적 천장.
**Apple Silicon GPU 가속 (candle / macOS)**: M-시리즈 맥에서 candle 임베딩을
GPU(Metal)로 돌리면 CPU 대비 대용량 ingest 가 크게 빨라진다. 빌드 또는 설치 시
`embed_metal` feature 를 켠다:
agent 가 trust threshold 가 필요하면 top-level `score` 가 아닌 nested `retrieval.lexical_score` (BM25 raw) / `retrieval.vector_score` (cosine raw) 사용.
```bash
# 빌드만:
cargo build --release --features embed_metal
# 전역 설치 (~/.cargo/bin/kebab):
cargo install --path crates/kebab-cli --features embed_metal --locked
```
#### `score` ↔ `retrieval.*` 구조 (v0.20.2 정정)
벡터는 CPU candle 과 동일 모델이라 호환되므로, 맥에서 GPU 로 색인한
`kebab.sqlite` + `lancedb/` 를 그대로 Linux 서버(CPU candle)로 복사해 질의할 수
있다. 색인 로그에 `candle device = Metal (GPU)` 가 보이면 GPU 사용 중. metal
feature 는 macOS 전용 (Linux/서버는 기본 CPU 빌드).
`fusion_score` / `lexical_score` / `vector_score` / `lexical_rank` / `vector_rank` 는 모두 **`retrieval` object 내부**에 있다 (top-level 아님). top-level `score` 는 canonical ranking score 이며 그 의미는 `score_kind` 가 선언한다.
```toml
- **hybrid**: `score == retrieval.fusion_score` (RRF normalized `[0,1]`), `score_kind = "rrf"`.
- **lexical-only**: fusion 미실행 → `score == retrieval.fusion_score == retrieval.lexical_score` (raw BM25), `score_kind = "bm25"`.
- **vector-only**: `score == retrieval.fusion_score == retrieval.vector_score` (raw cosine), `score_kind = "cosine"`.
[models.llm]
endpoint = "http://localhost:11434" # Ollama host:port
model = "gemma4:e4b"
# request_timeout_secs = 300 # 큰 모델은 늘림. 0 은 disable 이 아니라 "즉시 timeout".
즉 single-mode 에서 `score`/`fusion_score`/(lexical|vector)_score 가 같은 값인 것은 fusion 단계가 없기 때문이며 정상이다 (Finding X).
[ingest.expansion] # doc-side expansion 별칭 (opt-in)
enabled = false # true 면 청크당 LLM 호출로 별칭 생성 — 비용 큼.
embed_aliases = true # 별칭을 줄별 개별 dense 벡터로 색인.
max_aliases_per_chunk = 8
## 논리 아키텍처
[search]
stale_threshold_days = 30 # search hit / citation 의 stale 플래그 기준 (0 = off).
[rag]
prompt_template_version = "rag-v3" # 답변 언어 = 질문 언어. rag-v1/v2 는 legacy.
nli_threshold = 0.0 # >0 (예: 0.5) 면 mDeBERTa XNLI groundedness 검증.
```
- **파생물 캐시** — embedding·별칭 결과를 내용 해시로 자동 캐싱한다 (위 「핵심 기능」 참고). 설정 항목 없음.
- **`[ingest.code]`** — code ingest 의 skip 정책 (`skip_generated_header`, `max_file_bytes`, `extra_skip_globs`). `.gitignore` 자동 honor, `.kebabignore` 는 추가 layer.
- **`[pdf.ocr]`** — scanned PDF 의 page-단위 OCR (default off / opt-in, page 당 ~수십 초 cost). 활성화 후 v0.19 시절 색인분은 `kebab ingest --force-reingest` 로 재처리.
- **`--config <path>`** — 임시 워크스페이스 / 격리 테스트용 (CLI · TUI 모두 honor).
- **`kebab config migrate`** — 새 버전에서 추가된 config 섹션을 기존 `config.toml` 에 설명 주석과 함께 채워 넣는다 (사용자가 손본 값·주석·순서는 보존, 멱등, 변경 시 자동 `.bak` 백업). `--dry-run` 으로 변경 미리보기. `kebab doctor` 가 갱신 필요 시 안내한다. `kebab init` 으로 새로 생성되는 config.toml 도 섹션별 주석을 포함한다.
- **`KEBAB_*` env** — 일부 키 override (`KEBAB_RAG_SCORE_GATE`, `KEBAB_EVAL_GOLDEN` 등).
- **XDG layout**: `~/.config/kebab/`, `~/.local/share/kebab/`, `~/.cache/kebab/`, `~/.local/state/kebab/`.
## 아키텍처
```mermaid
flowchart TB
@@ -162,7 +188,7 @@ flowchart TB
subgraph Pipeline["도메인 + 파이프라인"]
parse["parse-md / parse-pdf / parse-image / parse-code"]
chunker["chunker (md-heading-v1, pdf-page-v1, code-{rust,python,ts,js,go,java,kotlin,c,cpp}-ast-v1, k8s-manifest-resource-v1, dockerfile-file-v1, manifest-file-v1, code-text-paragraph-v1)"]
chunker["chunker (md / pdf / code-AST / manifest)"]
embedder["embedder (fastembed multilingual-e5-large)"]
retriever["retriever (lexical / vector / hybrid RRF)"]
rag["RAG pipeline"]
@@ -204,93 +230,22 @@ flowchart TB
rag --> ollama
```
`kebab-app` 가 facade — UI binary 가 store / parse / search / llm / rag 를 직접 참조하지 않는다 (frozen 설계 §8). 자세한 crate-level 의존성 + 디렉토리 + 핵심 기술 결정은 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
v0.21.0 기준 핵심 설계:
## Configuration
- **crate facade** — `kebab-app` 가 유일한 facade다. UI binary (`kebab-cli` / `kebab-tui`) 는 store / parse / search / llm / rag 를 직접 참조하지 않는다 (frozen 설계 §8). 각 user-facing 엔트리는 `*_with_config(cfg, …)` 동반 함수로 explicit config 를 thread 한다.
- **chunk_id 는 위치 기반** — chunk 의 정체성은 문서 내 위치(ordinal + span)다. 반면 파생물 캐시 키는 **내용 해시**라, 내용이 같으면 위치·문서가 달라도 동일 캐시를 재사용한다.
- **wire schema v1** — 모든 `--json` 출력은 `schema_version` 을 담는 frozen contract다. 깨는 변경은 `*.v2` major bump을 요구한다.
- **versioning cascade** — `parser_version` / `chunker_version` / `embedding_version` / `prompt_template_version` / `index_version` 변경은 downstream record(청크·임베딩·캐시·eval)를 무효화한다.
- `~/.config/kebab/config.toml` — `kebab init` 가 XDG 경로에 생성. `[workspace]` (root, exclude — include 필드는 제거됨, 지원 형식은 자동 결정), `[storage]`, `[chunking]`, `[models.embedding]`, `[models.llm]`, `[image.ocr]`, `[image.caption]`, `[pdf.ocr]`, `[search]`, `[rag]`, `[ui]` 절.
- `[models.embedding]` —
- `model` (default `"multilingual-e5-large"`, fb-39b) — 다국어 sentence embedding 모델. 1024-dim. ONNX (~1.3 GB) 첫 실행 시 fastembed cache (`config.storage.model_dir/fastembed/`) 에 자동 다운로드. `"multilingual-e5-small"` (384 dim) 는 backwards-compat 으로 사용 가능 — TOML 에 명시.
- `dimensions` (default `1024`) — 모델의 embedding 차원. config 와 LanceDB stored dim 불일치 시 검색 결과 0 건 (orphan table). 모델 변경 시 `kebab reset --vector-only && kebab ingest` 로 vector index 재구축 권장.
- `[ui] theme = "dark" | "light"` 로 TUI 팔레트 선택 (default `"dark"`, 알 수 없는 값은 dark fallback).
- `[search] stale_threshold_days = 30` (p9-fb-32) — search hit / RAG citation 의 `stale` 플래그 기준 (default 30 일, `0` 으로 비활성화). 옛 config 의 `workspace.include = [...]` 은 silently 무시 + 단발 deprecation warning (p9-fb-25).
- `[ingest.code]` (p10-1A-1) — code ingest 의 skip 정책 + chunker 기본값.
- `skip_generated_header = true` — 첫 ~512 byte 의 generated marker (`@generated` / `DO NOT EDIT` 등) 감지 시 skip.
- `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`.
- `[rag] prompt_template_version` (default `"rag-v3"`) — RAG system prompt version. `"rag-v1"` / `"rag-v2"` 은 legacy backwards-compat (명시 시 유지). v2 강화 규칙: (1) fact 인용 시 [#번호] 앞에 chunk 속 원문 큰따옴표 표기, (2) 학습 지식 동원 금지, (3) 근거 모호 시 "확실하지 않다" 명시. **v3 추가 규칙 (v0.20.2)**: 답변 언어 = 질문 언어 (query 가 영어면 영어로, 한국어면 한국어로). 근거 부족 refusal 문구도 언어중립화. **Known limitation**: gemma4:e4b 같은 소형 모델은 refusal 메시지의 언어가 query 언어와 불일치할 수 있음 — refusal 판정(marker 기반)은 정상, 표시 문구만 해당. v2 고정: `[rag] prompt_template_version = "rag-v2"`.
- `--config <path>` flag — 임시 워크스페이스 / 격리 테스트 시 사용. CLI / TUI 모두 honor.
- `KEBAB_*` env — 일부 키 override (`KEBAB_RAG_SCORE_GATE`, `KEBAB_EVAL_GOLDEN`, `KEBAB_COMMIT_HASH` 등).
- XDG layout: `~/.config/kebab/`, `~/.local/share/kebab/`, `~/.cache/kebab/`, `~/.local/state/kebab/`.
- `workspace.root` 경로 형식: 절대 (`/foo/bar`) / tilde (`~/KnowledgeBase`, default) / env (`${XDG_DATA_HOME}/kebab`) / 상대 (`./notes`, `notes`, `../shared/x`) 모두 가능. **상대 경로의 base 는 config.toml 자체가 위치한 디렉토리** — 사용자의 `cwd` 와 무관 (`--config /tmp/cfg.toml` + `root = "kb"` → `/tmp/kb`). p9-fb-05 정책.
config 예시는 [docs/SMOKE.md](docs/SMOKE.md) 의 `/tmp/kebab-smoke/config.toml` 블록 참조.
### `[pdf.ocr]` — scanned PDF OCR (v0.20.0+)
embedded text 가 없는 scanned PDF (책 스캔, 영수증, 카메라 page 등) 의 OCR 활성화. **default off (opt-in)** — OCR 한 page 당 ~45-100s (qwen2.5vl:3b on CPU) 의 cost 때문에 책 / 논문 archive 등 명시적 KB 에만 활성화.
```toml
[pdf.ocr]
enabled = false # opt-in: 책 / 논문 archive KB 에서 true
always_on = false # true 시 vector PDF page 도 dual-block OCR (confidence boost)
engine = "ollama-vision"
model = "qwen2.5vl:3b" # PoC alnum 94.79% page1 / 81.56% 받침 (vs gemma4:e4b 의 27%)
# endpoint = "http://localhost:11434" # 미명시 시 models.llm.endpoint fallback
languages = ["eng", "kor"]
max_pixels = 2048
request_timeout_secs = 600
valid_ratio_threshold = 0.5 # text-detect threshold — mojibake / scanned 판정 boundary
min_char_count = 20
lang_hint = "kor"
```
env override: `KEBAB_PDF_OCR_*` 11 변수 (예: `KEBAB_PDF_OCR_ENABLED=true kebab ingest`).
**v0.20 upgrade after**: scanned PDF 가 v0.19 에 빈 block + warning 으로 indexed 된 경우 자동으로 OCR 재실행 안 됨 (parser_version `"pdf-text-v1"` 보존). 명시적 재처리: `kebab ingest --force-reingest`.
## 외부 AI 통합
`--json` 출력 + frozen wire schema v1 가 stable contract. 통합 옵션:
- **Claude Code skill** — repo 의 [`integrations/claude-code/`](integrations/claude-code/) 가 ship-ready skill. `cp -r integrations/claude-code/kebab ~/.claude/skills/` 한 번이면 새 Claude Code 세션부터 자동 trigger (내부 시스템 / 위키 lookup / 사내 runbook 질문). multi-turn 은 `kebab ask --session <id> --json` 으로 영속 — skill 이 conversation id 관리하면 외부 agent 도 `--repl` 없이 stateful 대화 가능 (p9-fb-18).
- **Codex / 기타 agent host** — `--json` + frozen wire schema v1 가 stable contract. 동일 패턴으로 ~50줄 wrapper 작성 가능. `integrations/<host>/` 에 추가 PR 환영.
- **MCP server** — stdio JSON-RPC 로 `kebab-app` facade 1:1 노출. `kebab mcp` 참조.
- **HTTP wrapper** — `kebab serve --bind 127.0.0.1:7711` (P+, local-only 가치 신중).
## MCP 사용
`kebab mcp` 가 stdio MCP server. 8 tool: `search` / `bulk_search` (p9-fb-42 — N query 한 번에) / `ask` / `fetch` (p9-fb-35) / `schema` / `doctor` / `ingest_file` / `ingest_stdin`.
Claude Code 빠른 등록 (`~/.claude/mcp.json` 또는 host 동등 위치):
```json
{
"mcpServers": {
"kebab": {
"command": "kebab",
"args": ["mcp"]
}
}
}
```
자세한 사용법 (Cursor / OpenAI Agents / Copilot CLI config, per-tool 입출력 예시, troubleshooting, multi-turn ask + session 관리, performance / security) — **[docs/mcp-usage.md](docs/mcp-usage.md)** 참조.
crate-level 의존성 그래프 · 디렉토리 트리 · 확장자→chunker 전체 매핑 · 핵심 기술 결정은 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), 진척도는 [HANDOFF.md](HANDOFF.md).
## 비-목표
다중 사용자 SaaS / K8s / 원격 vector DB / enterprise RBAC / 실시간 협업 / 모든 파일 포맷의 완벽한 parsing / agent 임의 파일 수정 / multi-workspace / LLM-as-judge eval / CLIP 시각 embedding / `kebab://` protocol handler — frozen 설계 §11 / §0 참조.
다중 사용자 SaaS / K8s / 원격 vector DB / enterprise RBAC / 실시간 협업 / agent 임의 파일 수정 / multi-workspace / LLM-as-judge eval / CLIP 시각 embedding — frozen 설계 §0 / §11 참조.
## 라이선스
## 버전 / 라이선스 / 참고
`MIT OR Apache-2.0` (workspace `Cargo.toml` 의 `license` 필드).
## 참고
- 진척도: [HANDOFF.md](HANDOFF.md)
- 아키텍처: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
- Frozen 설계: [docs/superpowers/specs/2026-04-27-kebab-final-form-design.md](docs/superpowers/specs/2026-04-27-kebab-final-form-design.md)
- Task 인덱스: [tasks/INDEX.md](tasks/INDEX.md)
- 머지 후 hotfix 로그: [tasks/HOTFIXES.md](tasks/HOTFIXES.md)
- Smoke 절차: [docs/SMOKE.md](docs/SMOKE.md)
- **버전**: v0.21.0 (`kebab --version` 으로 확인).
- **라이선스**: `MIT OR Apache-2.0`.
- 진척도: [HANDOFF.md](HANDOFF.md) · 아키텍처: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) · Frozen 설계: [docs/superpowers/specs/2026-04-27-kebab-final-form-design.md](docs/superpowers/specs/2026-04-27-kebab-final-form-design.md)
- Task 인덱스: [tasks/INDEX.md](tasks/INDEX.md) · Hotfix 로그: [tasks/HOTFIXES.md](tasks/HOTFIXES.md) · Smoke 절차: [docs/SMOKE.md](docs/SMOKE.md) · MCP 사용: [docs/mcp-usage.md](docs/mcp-usage.md)

View File

@@ -18,6 +18,7 @@ kebab-store-vector = { path = "../kebab-store-vector" }
kebab-search = { path = "../kebab-search" }
kebab-embed = { path = "../kebab-embed" }
kebab-embed-local = { path = "../kebab-embed-local" }
kebab-embed-candle = { path = "../kebab-embed-candle" }
kebab-llm = { path = "../kebab-llm" }
kebab-llm-local = { path = "../kebab-llm-local" }
kebab-rag = { path = "../kebab-rag" }
@@ -71,6 +72,11 @@ base64 = { workspace = true }
rusqlite = { workspace = true }
[dev-dependencies]
kebab-config = { path = "../kebab-config" }
# 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 }
@@ -94,6 +100,8 @@ reqwest = { version = "0.12", default-features = false, features = ["blocki
# disable path 없음; 이 feature 는 spec §6.3 명시를 honor 하는 role 만.
default = ["fts_korean_morphological"]
fts_korean_morphological = []
# opt-in (macOS): candle embedder runs on the Apple Silicon GPU. See kebab-embed-candle.
embed_metal = ["kebab-embed-candle/metal"]
[lints]
workspace = true

View File

@@ -43,6 +43,7 @@ use kebab_core::{
Answer, DocumentStore, Embedder, ExtractContext, Extractor, IndexVersion, LanguageModel,
MediaType, Retriever, SearchHit, SearchMode, SearchOpts, SearchQuery, VectorStore,
};
use kebab_embed_candle::CandleEmbedder;
use kebab_embed_local::FastembedEmbedder;
use kebab_llm_local::OllamaLanguageModel;
use kebab_parse_code::{
@@ -833,9 +834,26 @@ impl App {
if let Some(e) = self.embedder.get() {
return Ok(Some(e.clone()));
}
let emb: Arc<dyn Embedder + Send + Sync> = Arc::new(
FastembedEmbedder::new(&self.config).context("kb-app: load FastembedEmbedder")?,
);
// Provider branch (Track 1 spec §3). `embeddings_disabled()` above
// already handled `"none"`; here we route the live providers.
// `fastembed`/`onnx`/(empty) keep the default onnxruntime path
// (vectors unchanged — `embedding_version` is preserved); `candle`
// selects the pure-Rust NUMA-safe backend.
let provider = self.config.models.embedding.provider.as_str();
let emb: Arc<dyn Embedder + Send + Sync> = match provider {
"fastembed" | "onnx" | "" => Arc::new(
FastembedEmbedder::new(&self.config).context("kb-app: load FastembedEmbedder")?,
),
"candle" => Arc::new(
CandleEmbedder::new(&self.config).context("kb-app: load CandleEmbedder")?,
),
other => {
return Err(anyhow!(
"kb-app: unknown embedding provider {other:?}; expected one of \
`fastembed` (default), `candle`, or `none` (lexical-only)"
));
}
};
// `set` returns Err if another thread won the race; in that case
// the loser still returns the (now-cached) winner via `get()`.
let _ = self.embedder.set(emb.clone());

View File

@@ -0,0 +1,61 @@
//! Derivation-cache payload encoding helpers (design 2026-05-31 §3.3).
//!
//! - embedding: `dimensions × f32` little-endian bytes (1024×4 = 4096 B/chunk).
//! - alias / korean_tokens: UTF-8 as-is (handled inline by the caller — no
//! helper needed, `String::as_bytes` / `String::from_utf8`).
/// Encode an embedding vector as a little-endian `f32` byte string (§3.3).
pub fn encode_embedding(vector: &[f32]) -> Vec<u8> {
let mut out = Vec::with_capacity(vector.len() * 4);
for &v in vector {
out.extend_from_slice(&v.to_le_bytes());
}
out
}
/// Decode a little-endian `f32` byte string back into a vector (§3.3).
///
/// Returns `None` if the payload length is not a multiple of 4 (corrupt
/// entry) — the caller treats this as a cache miss and recomputes, so a bad
/// payload never produces a wrong vector.
pub fn decode_embedding(payload: &[u8]) -> Option<Vec<f32>> {
if payload.len() % 4 != 0 {
return None;
}
Some(
payload
.chunks_exact(4)
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
.collect(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrips_vector() {
let v = vec![0.0_f32, 1.5, -2.25, 3.125e10, f32::MIN, f32::MAX];
let bytes = encode_embedding(&v);
assert_eq!(bytes.len(), v.len() * 4);
assert_eq!(decode_embedding(&bytes), Some(v));
}
#[test]
fn empty_vector_roundtrips() {
assert_eq!(encode_embedding(&[]), Vec::<u8>::new());
assert_eq!(decode_embedding(&[]), Some(vec![]));
}
#[test]
fn misaligned_payload_is_none() {
assert_eq!(decode_embedding(&[1, 2, 3]), None);
}
#[test]
fn little_endian_layout_is_fixed() {
// 1.0_f32 == 0x3F800000, little-endian bytes [0x00,0x00,0x80,0x3F].
assert_eq!(encode_embedding(&[1.0]), vec![0x00, 0x00, 0x80, 0x3F]);
}
}

View File

@@ -0,0 +1,274 @@
//! 색인시 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;
/// 별칭 프롬프트 템플릿 버전. derivation cache 의 alias version_key 에 포함되어
/// (§3.1), 프롬프트를 바꾸면 bump 해 캐시를 무효화한다(전부 miss → 재생성).
/// `build_request` 의 gemma 프롬프트와 한 쌍 — 프롬프트 수정 시 함께 bump.
pub const PROMPT_VERSION: &str = "expansion-v1";
/// 청크당 검색용 별칭을 생성한다.
///
/// 반환: 검증·상한 적용된 별칭들을 개행 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)를 구성한다. (self 미사용 — associated fn.)
fn build_request(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<String> {
// 나무위키 네비게이션 boilerplate 청크는 LLM 호출 없이 skip — 별칭
// 생성 가치가 없고 노이즈 sentinel 벡터만 만든다.
if is_nav_boilerplate(chunk) {
return None;
}
let req = Self::build_request(chunk);
let raw = match self.llm.generate_stream(req) {
Ok(iter) => {
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"))
}
}
}
/// 나무위키 네비게이션 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` 가 정당한 별칭을 손상시키던 버그 수정.)
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 → 목록 마커 1회 제거 → 빈 줄·과길이 drop →
/// 중복 제거 → 상한 N.
fn parse_aliases(raw: &str, max_aliases: usize) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for line in raw.lines() {
let t = strip_list_marker(line.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);
}
/// 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첫 항목");
}
fn mk_chunk_nav(text: &str, heading: Vec<String>) -> Chunk {
let mut c = mk_chunk(text);
c.heading_path = heading;
c
}
#[test]
fn nav_boilerplate_skips_alias_generation() {
// heading 없음 + nav 키워드 → boilerplate → LLM 호출 전에 None.
let llm = mock("별칭1\n별칭2");
let generator = ExpansionGenerator::new(&llm, 8);
let chunk = mk_chunk_nav("최근 변경 최근 토론 특수 기능", vec![]);
assert_eq!(generator.generate(&chunk), None);
}
#[test]
fn normal_body_chunk_generates_aliases() {
// heading 없지만 nav 키워드도 없음 → 정상 본문 → 별칭 생성.
let llm = mock("별칭1\n별칭2");
let generator = ExpansionGenerator::new(&llm, 8);
let chunk = mk_chunk_nav("러스트의 소유권과 빌림 검사기 개요", vec![]);
assert_eq!(generator.generate(&chunk).unwrap(), "별칭1\n별칭2");
}
#[test]
fn nav_keyword_with_heading_is_not_boilerplate() {
// nav 키워드가 있어도 heading 이 있으면 본문 섹션 → 생성.
let llm = mock("별칭1");
let generator = ExpansionGenerator::new(&llm, 8);
let chunk = mk_chunk_nav("최근 변경 내역 설명", vec!["문서 변경사항".into()]);
assert_eq!(generator.generate(&chunk).unwrap(), "별칭1");
}
#[test]
fn is_nav_boilerplate_unit() {
assert!(is_nav_boilerplate(&mk_chunk_nav("Recent changes list", vec![])));
assert!(is_nav_boilerplate(&mk_chunk_nav("편집 토론 역사", vec![])));
assert!(!is_nav_boilerplate(&mk_chunk_nav("일반 본문 텍스트", vec![])));
assert!(!is_nav_boilerplate(&mk_chunk_nav(
"최근 변경",
vec!["섹션".into()]
)));
}
#[test]
fn strip_list_marker_unit() {
assert_eq!(strip_list_marker("- 메모리"), "메모리");
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 릴리스");
}
}

View File

@@ -47,11 +47,21 @@ pub struct AggregateCounts {
///
/// ```text
/// ScanStarted < ScanCompleted
/// < (AssetStarted [< (PdfOcrStarted < PdfOcrFinished)*] < AssetFinished)*
/// < ( AssetStarted
/// [< (PdfOcrStarted < PdfOcrFinished)*]
/// [< AssetChunked]
/// [< ExpansionProgress*]
/// [< AssetTimings]
/// < AssetFinished )*
/// < (Completed | Aborted)
/// ```
///
/// `[]` = optional, per-PDF asset only (v0.20.0 sub-item 1).
/// `[]` = optional. `PdfOcr*` is per-PDF asset only (v0.20.0 sub-item 1).
/// `AssetChunked` / `ExpansionProgress` / `AssetTimings` are the v0.24.0
/// asset-internal phase events: `AssetChunked` fires once right after
/// chunking (markdown / image / PDF); `ExpansionProgress` is a throttled
/// counter through the alias-expansion loop (markdown, expansion enabled
/// only); `AssetTimings` reports per-phase wall-clock once (markdown only).
///
/// Embed-batch events (`embed_batch_started` / `embed_batch_finished`
/// in §2.4a) are reserved for a future iteration and are not emitted
@@ -82,6 +92,41 @@ pub enum IngestEvent {
result: IngestItemKind,
chunks: u32,
},
/// v0.24.0 (additive): emitted right after an asset is chunked, before
/// expansion / embed / store. Surfaces "this document is N chunks"
/// immediately so a single large document no longer looks frozen at
/// `idx/total` while its per-chunk phases churn. `chunks` is the chunk
/// count for asset `idx`.
AssetChunked { idx: u32, total: u32, chunks: u32 },
/// v0.24.0 (additive): throttled progress through the per-chunk
/// expansion (alias-LLM) loop — the slowest inner phase for large
/// documents (~14s per chunk against a remote GPU Ollama). `done` is
/// the number of chunks processed so far (cache hits included, so the
/// counter still advances on a warm re-run); `chunks` is the asset's
/// total chunk count. Emitted at most every 25 chunks or once per
/// second (see the loop in `ingest_one_asset`), plus a final
/// `done == chunks` frame.
ExpansionProgress {
idx: u32,
total: u32,
done: u32,
chunks: u32,
},
/// v0.24.0 (additive): per-phase wall-clock (milliseconds) for asset
/// `idx`, emitted once the asset's markdown pipeline finishes. Lets a
/// user see *where* the time went (parse / chunk / expansion / embed /
/// store) without parsing logs. Only the markdown path emits this; the
/// image / PDF paths surface `AssetChunked` but skip phase timing (their
/// phase shapes differ — OCR / caption rather than expansion).
AssetTimings {
idx: u32,
total: u32,
parse_ms: u64,
chunk_ms: u64,
expansion_ms: u64,
embed_ms: u64,
store_ms: u64,
},
/// Run finished normally. `counts` is the final aggregate.
Completed { counts: AggregateCounts },
/// Run finished by user cancellation. `counts` is the partial
@@ -199,6 +244,79 @@ mod tests {
assert_eq!(v.get("media").and_then(|s| s.as_str()), Some("markdown"));
}
#[test]
fn asset_chunked_serializes_with_discriminator() {
// v0.24.0 additive variant — `kind` must be snake_case
// `asset_chunked` so wire v1 consumers branch on it cleanly.
let ev = IngestEvent::AssetChunked {
idx: 3,
total: 10,
chunks: 142,
};
let v = serde_json::to_value(&ev).unwrap();
assert_eq!(
v.get("kind").and_then(|s| s.as_str()),
Some("asset_chunked")
);
assert_eq!(v.get("idx").and_then(serde_json::Value::as_u64), Some(3));
assert_eq!(
v.get("chunks").and_then(serde_json::Value::as_u64),
Some(142)
);
}
#[test]
fn expansion_progress_serializes_with_discriminator() {
let ev = IngestEvent::ExpansionProgress {
idx: 1,
total: 5,
done: 25,
chunks: 200,
};
let v = serde_json::to_value(&ev).unwrap();
assert_eq!(
v.get("kind").and_then(|s| s.as_str()),
Some("expansion_progress")
);
assert_eq!(v.get("done").and_then(serde_json::Value::as_u64), Some(25));
assert_eq!(
v.get("chunks").and_then(serde_json::Value::as_u64),
Some(200)
);
}
#[test]
fn asset_timings_serializes_all_phase_fields() {
let ev = IngestEvent::AssetTimings {
idx: 2,
total: 7,
parse_ms: 12,
chunk_ms: 3,
expansion_ms: 45_000,
embed_ms: 800,
store_ms: 20,
};
let v = serde_json::to_value(&ev).unwrap();
assert_eq!(
v.get("kind").and_then(|s| s.as_str()),
Some("asset_timings")
);
// All five phase fields are present (plain u64, always serialized).
for (field, want) in [
("parse_ms", 12u64),
("chunk_ms", 3),
("expansion_ms", 45_000),
("embed_ms", 800),
("store_ms", 20),
] {
assert_eq!(
v.get(field).and_then(serde_json::Value::as_u64),
Some(want),
"field {field}"
);
}
}
#[test]
fn ingest_event_completed_has_counts() {
let ev = IngestEvent::Completed {

View File

@@ -59,9 +59,11 @@ 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;
pub mod expansion;
pub mod external;
pub mod fetch;
pub mod ingest_log;
@@ -141,40 +143,10 @@ pub fn init_workspace(force: bool) -> anyhow::Result<()> {
std::fs::create_dir_all(&workspace_root)?;
if !cfg_path.exists() || force {
let cfg = kebab_config::Config::defaults();
let toml_text = toml::to_string_pretty(&cfg)?;
// p9-fb-05: prepend a header comment documenting the path
// policy so a user editing this file knows what's allowed
// for `workspace.root` (and how relative paths resolve).
// The actual key lives inside `[workspace]` further down;
// we keep the explanation up top because users skim header
// comments first.
let header = "\
# kebab config — `~/.config/kebab/config.toml`.
#
# `workspace.root` accepts:
# • absolute paths (`/home/me/KnowledgeBase`)
# • tilde (`~/KnowledgeBase`) ← default
# • env vars (`${XDG_DATA_HOME}/kebab`)
# • relative paths (`./notes`, `notes`, `../shared/x`)
# — relative paths resolve against the directory of THIS
# config file, NOT the user's `cwd` at invocation time.
#
# 처리 가능한 형식 (extractor 가 자동 결정 — config 에 명시할 수 없음):
# • Markdown: .md
# • 이미지: .png .jpg .jpeg (OCR + caption)
# • PDF: .pdf
# 다른 확장자는 ingest 시 자동 skip + warning. 처리 대상 폴더의
# 일부만 ingest 하고 싶으면 `kebab ingest <path>` 로 root 명시
# 또는 `.kebabignore` 파일 / 본 `workspace.exclude` 로 denylist.
#
# Override individual keys at runtime with `KEBAB_*` env vars
# (e.g. `KEBAB_WORKSPACE_ROOT=/tmp/test kebab ingest`).
\n";
let mut combined = String::with_capacity(header.len() + toml_text.len());
combined.push_str(header);
combined.push_str(&toml_text);
std::fs::write(&cfg_path, combined)?;
// init 과 migrate 가 동일한 "주석 달린 default" 문서를 공유한다
// (주석 카탈로그·헤더의 단일 원천 = kebab_config::migrate).
let doc = kebab_config::migrate::annotated_default_document();
std::fs::write(&cfg_path, doc.to_string())?;
}
Ok(())
@@ -508,6 +480,8 @@ pub fn ingest_with_config_opts(
let item = ingest_one_asset(
&app,
&asset,
idx,
scanned_count,
&parser_version,
&chunk_policy,
embedder.as_ref(),
@@ -1056,6 +1030,70 @@ fn unsupported_media_warning(path: &str) -> String {
}
}
/// Embed `texts` with the derivation cache (design 2026-05-31 §3.4).
///
/// 1) 각 text 의 embedding cache_key 계산 → 히트/미스 분리.
/// 2) 미스 text 만 `emb.embed`(축소 배치) 호출.
/// 3) 미스 결과를 `Vec<f32>` little-endian 으로 캐시 put.
/// 4) 히트(bytes→Vec<f32>) + 미스 벡터를 **원래 순서대로** 합쳐 반환.
///
/// 손상된 payload(길이 misalign)는 미스로 강등 → 재계산(정확성 우선, §3.5).
/// 히트 키는 `touch_keys` 에 누적(호출측이 배치로 last_used_at 갱신).
fn embed_with_cache(
emb: &dyn Embedder,
sqlite: &kebab_store_sqlite::SqliteStore,
texts: &[&str],
version_key: &str,
hit: &mut usize,
miss: &mut usize,
touch_keys: &mut Vec<String>,
) -> anyhow::Result<Vec<Vec<f32>>> {
let mut out: Vec<Option<Vec<f32>>> = Vec::with_capacity(texts.len());
let mut miss_indices: Vec<usize> = Vec::new();
let mut miss_inputs: Vec<EmbeddingInput<'_>> = Vec::new();
let mut keys: Vec<String> = Vec::with_capacity(texts.len());
for (i, text) in texts.iter().enumerate() {
let key = kebab_core::derivation_cache_key("embedding", text, version_key);
// 히트 = 캐시에 있고 payload 가 정상 디코드되는 경우. 손상 payload 는
// 미스로 강등(재계산, 정확성 우선 §3.5).
let cached = sqlite
.derivation_cache_get(&key)?
.and_then(|p| crate::derivation_payload::decode_embedding(&p));
if let Some(v) = cached {
*hit += 1;
touch_keys.push(key.clone());
out.push(Some(v));
} else {
*miss += 1;
miss_indices.push(i);
miss_inputs.push(EmbeddingInput {
text,
kind: EmbeddingKind::Document,
});
out.push(None);
}
keys.push(key);
}
if !miss_inputs.is_empty() {
let miss_vectors = emb.embed(&miss_inputs)?;
for (slot, v) in miss_indices.iter().zip(miss_vectors) {
sqlite.derivation_cache_put(
&keys[*slot],
"embedding",
&crate::derivation_payload::encode_embedding(&v),
)?;
out[*slot] = Some(v);
}
}
Ok(out
.into_iter()
.map(|v| v.expect("every slot filled by hit or miss"))
.collect())
}
/// Process a single asset: read bytes, parse, normalize, chunk,
/// persist, embed. Per-asset failures bubble up to the caller for
/// labelling as `IngestItemKind::Error` — they do NOT abort the
@@ -1064,6 +1102,8 @@ fn unsupported_media_warning(path: &str) -> String {
fn ingest_one_asset(
app: &App,
asset: &RawAsset,
idx: u32,
total: u32,
parser_version: &ParserVersion,
chunk_policy: &ChunkPolicy,
embedder: Option<&Arc<dyn Embedder + Send + Sync>>,
@@ -1096,18 +1136,23 @@ fn ingest_one_asset(
return ingest_one_image_asset(
app,
asset,
idx,
total,
chunk_policy,
embedder,
vector_store,
existing_doc_ids,
image_pipeline,
force_reingest,
progress,
);
}
MediaType::Pdf => {
return ingest_one_pdf_asset(
app,
asset,
idx,
total,
chunk_policy,
embedder,
vector_store,
@@ -1216,6 +1261,10 @@ fn ingest_one_asset(
return Ok(item);
}
// v0.24.0 phase timing: parse spans from here (byte read) through
// `build_canonical_document`, i.e. everything before the chunker runs.
let t_parse = std::time::Instant::now();
let bytes = std::fs::read(&path)
.with_context(|| format!("read asset bytes from {}", path.display()))?;
@@ -1250,9 +1299,134 @@ 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 parse_ms = u64::try_from(t_parse.elapsed().as_millis()).unwrap_or(u64::MAX);
let t_chunk = std::time::Instant::now();
let mut chunks = MdHeadingV1Chunker
.chunk(&canonical, chunk_policy)
.context("kb-chunk::MdHeadingV1Chunker::chunk")?;
let chunk_ms = u64::try_from(t_chunk.elapsed().as_millis()).unwrap_or(u64::MAX);
// v0.24.0: surface the chunk count immediately, before the (potentially
// very slow) expansion / embed phases — so a single large document no
// longer looks frozen at `idx/total` while its chunks churn.
let total_chunks = u32::try_from(chunks.len()).unwrap_or(u32::MAX);
crate::ingest_progress::emit(
progress,
crate::ingest_progress::IngestEvent::AssetChunked {
idx,
total,
chunks: total_chunks,
},
);
// Phase 2 doc-side expansion: flag on 이면 청크당 별칭 생성 (fail-soft).
// derivation cache(§3.4): 같은 청크 text + 같은 alias version_key 면 LLM
// 호출 없이 캐시된 별칭 재사용. version_key = {prompt_version}|{max}|{model}.
let mut alias_cache_hit = 0_usize;
let mut alias_cache_miss = 0_usize;
let mut alias_touch_keys: Vec<String> = Vec::new();
let t_expansion = std::time::Instant::now();
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 {
OllamaLanguageModel::with_model(&app.config, &exp.model)
};
match llm_built {
Ok(llm) => {
let generator =
crate::expansion::ExpansionGenerator::new(&llm, exp.max_aliases_per_chunk);
// v0.24.0: throttled live counter through the per-chunk
// expansion loop. Emit at most every 25 chunks or once per
// second — never per chunk (would flood the mpsc channel).
let mut done: u32 = 0;
let mut last_emit = std::time::Instant::now();
let mut last_done: u32 = 0;
for chunk in &mut chunks {
let key = kebab_core::derivation_cache_key(
"alias",
&chunk.text,
&alias_version_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 = Some(aliases);
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())?;
}
}
// Cache hits count toward `done` too (the brief: show the
// warm-run fast-forward). Throttle: every 25 chunks or
// ≥1s since the last emit.
done += 1;
if done % 25 == 0
|| last_emit.elapsed() >= std::time::Duration::from_secs(1)
{
crate::ingest_progress::emit(
progress,
crate::ingest_progress::IngestEvent::ExpansionProgress {
idx,
total,
done,
chunks: total_chunks,
},
);
last_emit = std::time::Instant::now();
last_done = done;
}
}
// Final frame so the counter lands on done == total — but only
// if the last in-loop emit didn't already report this `done`
// (avoids a duplicate frame when chunks is a multiple of the
// throttle, and skips a 0/0 frame when there are no chunks).
if done != last_done {
crate::ingest_progress::emit(
progress,
crate::ingest_progress::IngestEvent::ExpansionProgress {
idx,
total,
done,
chunks: total_chunks,
},
);
}
}
Err(e) => {
tracing::warn!(
target: "kebab-app", error = %e,
"kb-app::ingest: expansion LLM 빌드 실패 — 별칭 없이 진행"
);
}
}
}
let expansion_ms = u64::try_from(t_expansion.elapsed().as_millis()).unwrap_or(u64::MAX);
// Stamp chunker + embedding versions so Task 7's skip detection has
// data on the second run.
@@ -1265,7 +1439,7 @@ fn ingest_one_asset(
// (per-document tx semantics per design §5.8); composing them is
// the kb-app job. A failure mid-way leaves the DB in a state the
// next ingest run can re-converge (UPSERT + DELETE-then-INSERT).
purge_vector_orphans_for_workspace_path(app, asset, vector_store)?;
let t_store = std::time::Instant::now();
app.sqlite
.put_asset_with_bytes(asset, &bytes)
.context("DocumentStore::put_asset_with_bytes")?;
@@ -1278,23 +1452,45 @@ fn ingest_one_asset(
app.sqlite
.put_chunks(&canonical.doc_id, &chunks)
.context("DocumentStore::put_chunks")?;
let store_ms = u64::try_from(t_store.elapsed().as_millis()).unwrap_or(u64::MAX);
// Embed + vector upsert (only when both sides are configured).
let t_embed = std::time::Instant::now();
// Stale-vector purge is LanceDB I/O, so it belongs to the embed/vector
// phase — not the SQLite `store` phase. Keeping it here makes `store_ms`
// mean "SQLite persist only" and `embed_ms` cover all vector-store work
// (purge + upsert), so per-phase timings attribute the bottleneck
// correctly (review fix). Runs before any new upsert, as before.
purge_vector_orphans_for_workspace_path(app, asset, vector_store)?;
let mut emb_cache_hit = 0_usize;
let mut emb_cache_miss = 0_usize;
if let (Some(emb), Some(vec_store)) = (embedder, vector_store) {
if !chunks.is_empty() {
let inputs: Vec<EmbeddingInput<'_>> = chunks
.iter()
.map(|c| EmbeddingInput {
text: c.text.as_str(),
kind: EmbeddingKind::Document,
})
.collect();
let vectors = emb
.embed(&inputs)
.context("Embedder::embed (document chunks)")?;
let model_id = emb.model_id();
let model_version = emb.model_version();
let dimensions = emb.dimensions();
// derivation cache(§3.4): embedding version_key =
// {kind}|{model_id}|{model_version}|{dimensions}.
// 본문 청크 + 별칭 문자열 양쪽이 같은 메커니즘(같은 text → 같은 캐시).
// kind 토큰("doc") 을 맨 앞에 둔다: 임베더가 kind 별 프리픽스
// (Document=`passage:`, Query=`query:`)를 붙여 같은 text 라도 벡터가
// 달라지므로, 미래에 query 임베딩이 같은 캐시를 타도 충돌하지 않도록
// 방어적으로 분리(현재 ingest 는 Document 고정이라 live 버그 없음).
let emb_version_key =
format!("doc|{}|{}|{}", model_id.0, model_version.0, dimensions);
let mut emb_touch_keys: Vec<String> = Vec::new();
// 본문 청크 text 로 캐시 조회 → 미스만 embed → 원래 순서로 합침.
let body_texts: Vec<&str> = chunks.iter().map(|c| c.text.as_str()).collect();
let vectors = embed_with_cache(
&**emb,
&app.sqlite,
&body_texts,
&emb_version_key,
&mut emb_cache_hit,
&mut emb_cache_miss,
&mut emb_touch_keys,
)
.context("Embedder::embed (document chunks)")?;
let records: Vec<VectorRecord> = chunks
.iter()
.zip(vectors)
@@ -1315,10 +1511,116 @@ 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() {
// 각 별칭을 줄 단위로 분리해 개별 sentinel 벡터로 임베딩한다.
// 묶음 1벡터는 벡터를 희석시켜 효과가 없으므로(측정), 별칭 i
// 마다 chunk_id `{orig}#alias#{i}` 의 VectorRecord 를 만든다.
// `(청크 참조, 별칭 문자열)` 쌍을 평탄화한 뒤 한 번에 임베딩.
let alias_lines: Vec<(&kebab_core::Chunk, &str)> = alias_chunks
.iter()
.flat_map(|c| {
c.aliases
.as_deref()
.unwrap()
.split('\n')
.map(str::trim)
.filter(|line| !line.is_empty())
.map(move |line| (*c, line))
})
.collect();
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)")?;
// 같은 청크 안에서 별칭 인덱스를 0부터 매긴다.
let mut per_chunk_idx: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
for ((c, line), v) in alias_lines.iter().zip(alias_vectors) {
let i = per_chunk_idx.entry(c.chunk_id.0.clone()).or_insert(0);
let alias_chunk_id = kebab_core::ChunkId(format!(
"{}{}#{}",
c.chunk_id.0,
kebab_core::ALIAS_SUFFIX,
*i
));
*i += 1;
all_records.push(VectorRecord {
embedding_id: kebab_core::id_for_embedding(
&alias_chunk_id,
&model_id,
&model_version,
dimensions,
),
chunk_id: alias_chunk_id,
vector: v,
doc_id: canonical.doc_id.clone(),
text: (*line).to_string(),
heading_path: c.heading_path.clone(),
model_id: model_id.clone(),
model_version: model_version.clone(),
dimensions,
});
}
}
}
}
vec_store
.upsert(&all_records)
.context("VectorStore::upsert")?;
// 히트한 embedding 키들의 last_used_at 갱신(LRU 보존, §3.5).
app.sqlite.derivation_cache_touch(&emb_touch_keys)?;
}
}
let embed_ms = u64::try_from(t_embed.elapsed().as_millis()).unwrap_or(u64::MAX);
// v0.24.0: phase-timing breakdown for this asset (markdown path only).
crate::ingest_progress::emit(
progress,
crate::ingest_progress::IngestEvent::AssetTimings {
idx,
total,
parse_ms,
chunk_ms,
expansion_ms,
embed_ms,
store_ms,
},
);
// 히트한 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 {
@@ -1358,12 +1660,15 @@ fn ingest_one_asset(
fn ingest_one_image_asset(
app: &App,
asset: &RawAsset,
idx: u32,
total: u32,
chunk_policy: &ChunkPolicy,
embedder: Option<&Arc<dyn Embedder + Send + Sync>>,
vector_store: Option<&Arc<kebab_store_vector::LanceVectorStore>>,
existing_doc_ids: &std::collections::HashSet<String>,
image_pipeline: &ImagePipeline<'_>,
force_reingest: bool,
progress: Option<&std::sync::mpsc::Sender<crate::ingest_progress::IngestEvent>>,
) -> anyhow::Result<kebab_core::IngestItem> {
let ocr_engine = image_pipeline.ocr_engine;
let caption_llm = image_pipeline.caption_llm;
@@ -1516,6 +1821,17 @@ fn ingest_one_image_asset(
.chunk(&canonical, chunk_policy)
.context("kb-chunk::MdHeadingV1Chunker::chunk (image)")?;
// v0.24.0: surface chunk count for the image path too (phase timing is
// markdown-only, but AssetChunked is consistent across media).
crate::ingest_progress::emit(
progress,
crate::ingest_progress::IngestEvent::AssetChunked {
idx,
total,
chunks: u32::try_from(chunks.len()).unwrap_or(u32::MAX),
},
);
// 5. Persist + embed — identical sequence to markdown.
// Stamp chunker + embedding versions (image uses MdHeadingV1Chunker
// for its single-block doc, so we record that version).
@@ -1634,6 +1950,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<kebab_core::ChunkId> {
let mut out = body_ids.to_vec();
for id in body_ids {
// Legacy single sentinel (docs ingested before per-line split).
out.push(kebab_core::ChunkId(format!(
"{}{}",
id.0,
kebab_core::ALIAS_SUFFIX
)));
for i in 0..max_aliases_per_chunk {
out.push(kebab_core::ChunkId(format!(
"{}{}#{}",
id.0,
kebab_core::ALIAS_SUFFIX,
i
)));
}
}
out
}
/// v0.17.0 PR-B: parser-bump cascade. When a code extractor ships a
/// 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`.
@@ -1661,8 +2020,15 @@ 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 _;
// 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(&stale)
.delete_by_chunk_ids(&to_delete)
.context("VectorStore::delete_by_chunk_ids (parser-bump orphans)")?;
}
}
@@ -1706,8 +2072,15 @@ fn purge_vector_orphans_for_workspace_path(
return Ok(());
}
use kebab_core::VectorStore as _;
// 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(&stale)
.delete_by_chunk_ids(&to_delete)
.context("VectorStore::delete_by_chunk_ids (orphan vector cleanup)")?;
tracing::debug!(
target: "kebab-app",
@@ -1807,7 +2180,14 @@ 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) {
// 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",
path = %stored_path.0,
@@ -1857,6 +2237,8 @@ fn sweep_deleted_files(
fn ingest_one_pdf_asset(
app: &App,
asset: &RawAsset,
idx: u32,
total: u32,
chunk_policy: &ChunkPolicy,
embedder: Option<&Arc<dyn Embedder + Send + Sync>>,
vector_store: Option<&Arc<kebab_store_vector::LanceVectorStore>>,
@@ -2060,6 +2442,16 @@ fn ingest_one_pdf_asset(
.chunk(&canonical, chunk_policy)
.context("kb-chunk::PdfPageV1Chunker::chunk")?;
// v0.24.0: surface chunk count for the PDF path too.
crate::ingest_progress::emit(
progress,
crate::ingest_progress::IngestEvent::AssetChunked {
idx,
total,
chunks: u32::try_from(chunks.len()).unwrap_or(u32::MAX),
},
);
// Stamp chunker + embedding versions so Task 7's skip detection has
// data on the second run.
canonical.last_chunker_version = Some(chunker.chunker_version());
@@ -2911,6 +3303,48 @@ pub fn doctor_with_config_path(
hint: data_hint,
});
// config_migration — 사용자 파일이 새 스키마와 동기인지(dry-run 마이그레이션).
// 파일이 존재할 때만 점검(없으면 defaults 사용 중이라 마이그레이션 무의미).
if cfg_path.exists() {
if let Ok(text) = std::fs::read_to_string(&cfg_path) {
let outcome = kebab_config::migrate::migrate_document(&text);
let (mok, detail, hint) = if outcome.changed() {
let added = outcome
.changes
.iter()
.filter(|c| {
matches!(
c.kind,
kebab_config::migrate::ChangeKind::AddedSection
| kebab_config::migrate::ChangeKind::AddedKey
)
})
.count();
let removed = outcome.changes.len() - added;
(
false,
format!(
"{} pending changes (added {added}, removed {removed} deprecated)",
outcome.changes.len()
),
Some("run `kebab config migrate` to update your config.toml".to_string()),
)
} else {
(
true,
format!("config up to date (schema v{})", outcome.to_schema_version),
None,
)
};
checks.push(DoctorCheck {
name: "config_migration".to_string(),
ok: mok,
detail,
hint,
});
}
}
let ok = checks.iter().all(|c| c.ok);
Ok(DoctorReport {
schema_version: "doctor.v1".to_string(),
@@ -2927,6 +3361,66 @@ pub fn doctor() -> anyhow::Result<DoctorReport> {
doctor_with_config_path(None)
}
/// `kebab config migrate` 의 결과(wire `config_migration.v1` 소스).
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
pub struct ConfigMigrationReport {
/// 항상 `"config_migration.v1"`.
pub schema_version: String,
pub config_path: String,
pub dry_run: bool,
pub from_schema_version: u32,
pub to_schema_version: u32,
pub changed: bool,
pub backup_path: Option<String>,
pub changes: Vec<kebab_config::migrate::MigrationChange>,
}
/// 사용자 config.toml 을 새 스키마로 마이그레이션한다(facade).
/// `config_path` 미지정 시 XDG 기본. `dry_run=true` 면 파일·백업 미변경.
/// 안전: 변경 시 `.bak` 백업 후 tmp 에 쓰고 round-trip 검증 → atomic rename.
pub fn config_migrate_with_config_path(
config_path: Option<&std::path::Path>,
dry_run: bool,
) -> anyhow::Result<ConfigMigrationReport> {
let path: PathBuf = match config_path {
Some(p) => p.to_path_buf(),
None => kebab_config::Config::xdg_config_path(),
};
if !path.exists() {
anyhow::bail!(
"config 파일이 없습니다: {} — 먼저 `kebab init` 을 실행하세요.",
path.display()
);
}
let text = std::fs::read_to_string(&path)?;
let outcome = kebab_config::migrate::migrate_document(&text);
let mut backup_path = None;
if !dry_run && outcome.changed() {
let bak = path.with_extension("toml.bak");
std::fs::copy(&path, &bak)?;
backup_path = Some(bak.display().to_string());
let tmp = path.with_extension("toml.tmp");
std::fs::write(&tmp, &outcome.new_text)?;
if kebab_config::Config::from_file(&tmp).is_err() {
std::fs::remove_file(&tmp).ok();
anyhow::bail!("마이그레이션 결과가 유효하지 않아 원본을 보존합니다.");
}
std::fs::rename(&tmp, &path)?;
}
Ok(ConfigMigrationReport {
schema_version: "config_migration.v1".to_string(),
config_path: path.display().to_string(),
dry_run,
from_schema_version: outcome.from_schema_version,
to_schema_version: outcome.to_schema_version,
changed: outcome.changed(),
backup_path,
changes: outcome.changes,
})
}
/// Single-file ingest (p9-fb-31). Copies the file to
/// `<workspace.root>/_external/<blake3-12>.<ext>` and runs the
/// per-medium ingest pipeline on that single asset. Returns an
@@ -3068,3 +3562,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 만");
}
}

View File

@@ -108,6 +108,7 @@ const WIRE_SCHEMAS: &[&str] = &[
"doc_summary.v1",
"chunk_inspection.v1",
"doctor.v1",
"config_migration.v1",
"ingest_report.v1",
"ingest_progress.v1",
"reset_report.v1",

View File

@@ -0,0 +1,82 @@
use std::fs;
#[test]
fn migrate_writes_backup_and_atomic_with_dry_run_noop() {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.toml");
fs::write(
&cfg,
"schema_version = 1\n\n[workspace]\nroot = \"/n\"\ninclude = [\"*.md\"]\n",
)
.unwrap();
// dry-run: 파일·백업 미변경.
let report = kebab_app::config_migrate_with_config_path(Some(&cfg), true).unwrap();
assert!(report.changed);
assert!(report.dry_run);
assert!(report.backup_path.is_none());
assert!(!dir.path().join("config.toml.bak").exists());
assert!(
fs::read_to_string(&cfg).unwrap().contains("include"),
"dry-run modified file"
);
// 실제 적용: 백업 생성 + 파일 갱신.
let report = kebab_app::config_migrate_with_config_path(Some(&cfg), false).unwrap();
assert!(report.changed);
assert!(!report.dry_run);
assert!(report.backup_path.is_some());
assert!(dir.path().join("config.toml.bak").exists());
let new = fs::read_to_string(&cfg).unwrap();
assert!(!new.contains("include"));
assert!(new.contains("[ingest.expansion]"));
// 멱등: 재실행 changed=false.
let report = kebab_app::config_migrate_with_config_path(Some(&cfg), false).unwrap();
assert!(!report.changed);
}
#[test]
fn migrate_missing_file_errors() {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("nope.toml");
assert!(kebab_app::config_migrate_with_config_path(Some(&cfg), false).is_err());
}
#[test]
fn annotated_default_serialization_contains_section_comments() {
let doc = kebab_config::migrate::annotated_default_document();
let text = doc.to_string();
assert!(text.contains("doc-side 별칭"), "section comment missing:\n{text}");
assert!(text.contains("[ingest.expansion]"));
}
#[test]
fn doctor_flags_outdated_config() {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.toml");
fs::write(
&cfg,
"schema_version = 1\n\n[workspace]\nroot = \"/n\"\ninclude=[\"*.md\"]\n",
)
.unwrap();
let report = kebab_app::doctor_with_config_path(Some(&cfg)).unwrap();
let check = report
.checks
.iter()
.find(|c| c.name == "config_migration")
.unwrap();
assert!(!check.ok, "outdated config should fail check");
assert!(check.hint.as_deref().unwrap().contains("config migrate"));
assert!(!report.ok, "overall doctor should be false");
// migrate 후엔 통과.
kebab_app::config_migrate_with_config_path(Some(&cfg), false).unwrap();
let report = kebab_app::doctor_with_config_path(Some(&cfg)).unwrap();
let check = report
.checks
.iter()
.find(|c| c.name == "config_migration")
.unwrap();
assert!(check.ok, "after migrate should pass");
}

View File

@@ -69,40 +69,74 @@ fn progress_event_sequence_matches_design_section_2_4a() {
other => panic!("expected Completed last, got {other:?}"),
}
// Middle: 3 AssetStarted/AssetFinished pairs in monotonic idx order.
let asset_events: Vec<&IngestEvent> = events[2..events.len() - 1].iter().collect();
assert_eq!(
asset_events.len(),
6,
"expected 3 (Started + Finished) pairs, got {asset_events:?}"
);
for (chunk_idx, pair) in asset_events.chunks(2).enumerate() {
let expected_idx = chunk_idx as u32 + 1;
match (pair[0], pair[1]) {
(
IngestEvent::AssetStarted {
idx: si,
total: st,
media,
..
},
IngestEvent::AssetFinished {
idx: fi,
total: ft,
result,
chunks,
},
) => {
assert_eq!(*si, expected_idx, "Started idx mismatch: {pair:?}");
assert_eq!(*fi, expected_idx, "Finished idx mismatch: {pair:?}");
assert_eq!(*st, 3, "Started total mismatch");
assert_eq!(*ft, 3, "Finished total mismatch");
assert_eq!(media, "markdown", "fixture is markdown only");
assert_eq!(*result, IngestItemKind::New, "first ingest → New");
assert!(*chunks >= 1, "chunks: {pair:?}");
// Middle (v0.24.0 ordering invariant §2.4a): per asset the stream is
// AssetStarted < AssetChunked < [ExpansionProgress*] < AssetTimings
// < AssetFinished
// Expansion is disabled in the lexical fixture, so no ExpansionProgress
// frames appear here — but AssetChunked + AssetTimings are emitted for
// every markdown asset.
let middle = &events[2..events.len() - 1];
// 3 AssetStarted events, monotonic idx 1..=3, all markdown, total = 3.
let started: Vec<u32> = middle
.iter()
.filter_map(|e| match e {
IngestEvent::AssetStarted {
idx, total, media, ..
} => {
assert_eq!(*total, 3, "Started total mismatch: {e:?}");
assert_eq!(media, "markdown", "fixture is markdown only: {e:?}");
Some(*idx)
}
other => panic!("expected Started+Finished pair, got {other:?}"),
}
_ => None,
})
.collect();
assert_eq!(started, vec![1, 2, 3], "AssetStarted idx order: {middle:?}");
// 3 AssetFinished events, monotonic idx 1..=3, each New with ≥1 chunk.
let finished: Vec<u32> = middle
.iter()
.filter_map(|e| match e {
IngestEvent::AssetFinished {
idx,
total,
result,
chunks,
} => {
assert_eq!(*total, 3, "Finished total mismatch: {e:?}");
assert_eq!(*result, IngestItemKind::New, "first ingest → New: {e:?}");
assert!(*chunks >= 1, "chunks: {e:?}");
Some(*idx)
}
_ => None,
})
.collect();
assert_eq!(finished, vec![1, 2, 3], "AssetFinished idx order: {middle:?}");
// v0.24.0 additive events: exactly one AssetChunked + one AssetTimings
// per asset, each strictly bracketed by that asset's Started / Finished.
for target in 1u32..=3 {
let started_at = middle
.iter()
.position(|e| matches!(e, IngestEvent::AssetStarted { idx, .. } if *idx == target))
.unwrap_or_else(|| panic!("missing AssetStarted for idx {target}: {middle:?}"));
let finished_at = middle
.iter()
.position(|e| matches!(e, IngestEvent::AssetFinished { idx, .. } if *idx == target))
.unwrap_or_else(|| panic!("missing AssetFinished for idx {target}: {middle:?}"));
let chunked_at = middle
.iter()
.position(|e| matches!(e, IngestEvent::AssetChunked { idx, chunks, .. } if *idx == target && *chunks >= 1))
.unwrap_or_else(|| panic!("missing AssetChunked for idx {target}: {middle:?}"));
let timings_at = middle
.iter()
.position(|e| matches!(e, IngestEvent::AssetTimings { idx, .. } if *idx == target))
.unwrap_or_else(|| panic!("missing AssetTimings for idx {target}: {middle:?}"));
assert!(
started_at < chunked_at && chunked_at < timings_at && timings_at < finished_at,
"idx {target} ordering: started={started_at} chunked={chunked_at} \
timings={timings_at} finished={finished_at}: {middle:?}"
);
}
}

View File

@@ -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 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 + 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, 1, "fresh store post-V009 baseline = 1");
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!(

View File

@@ -152,6 +152,7 @@ fn make_chunk(
token_estimate,
chunker_version: chunker_version.clone(),
policy_hash: base_policy_hash.to_string(),
aliases: None,
}
}

View File

@@ -154,6 +154,7 @@ fn make_chunk(
token_estimate,
chunker_version: chunker_version.clone(),
policy_hash: base_policy_hash.to_string(),
aliases: None,
}
}

View File

@@ -154,6 +154,7 @@ fn make_chunk(
token_estimate,
chunker_version: chunker_version.clone(),
policy_hash: base_policy_hash.to_string(),
aliases: None,
}
}

View File

@@ -154,6 +154,7 @@ fn make_chunk(
token_estimate,
chunker_version: chunker_version.clone(),
policy_hash: base_policy_hash.to_string(),
aliases: None,
}
}

View File

@@ -154,6 +154,7 @@ fn make_chunk(
token_estimate,
chunker_version: chunker_version.clone(),
policy_hash: base_policy_hash.to_string(),
aliases: None,
}
}

View File

@@ -154,6 +154,7 @@ fn make_chunk(
token_estimate,
chunker_version: chunker_version.clone(),
policy_hash: base_policy_hash.to_string(),
aliases: None,
}
}

View File

@@ -154,6 +154,7 @@ fn make_chunk(
token_estimate,
chunker_version: chunker_version.clone(),
policy_hash: base_policy_hash.to_string(),
aliases: None,
}
}

View File

@@ -154,6 +154,7 @@ fn make_chunk(
token_estimate,
chunker_version: chunker_version.clone(),
policy_hash: base_policy_hash.to_string(),
aliases: None,
}
}

View File

@@ -154,6 +154,7 @@ fn make_chunk(
token_estimate,
chunker_version: chunker_version.clone(),
policy_hash: base_policy_hash.to_string(),
aliases: None,
}
}

View File

@@ -339,6 +339,7 @@ fn build_chunk(
token_estimate,
chunker_version: chunker_version.clone(),
policy_hash: policy_hash.to_string(),
aliases: None,
}
}

View File

@@ -177,6 +177,7 @@ impl Chunker for PdfPageV1Chunker {
token_estimate,
chunker_version: chunker_version.clone(),
policy_hash: base_policy_hash.clone(),
aliases: None,
});
}
}

View File

@@ -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,
}
}

View File

@@ -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"
],

File diff suppressed because one or more lines are too long

View File

@@ -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"
],

View File

@@ -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"
],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -51,5 +51,10 @@ tempfile = { workspace = true }
rusqlite = { workspace = true }
time = { workspace = true }
[features]
# opt-in (macOS): build the `kebab` binary with candle on the Apple Silicon GPU.
# cargo build --release --features embed_metal
embed_metal = ["kebab-app/embed_metal"]
[lints]
workspace = true

View File

@@ -60,6 +60,12 @@ enum Cmd {
force: bool,
},
/// config.toml 관리 (스키마 마이그레이션 등).
Config {
#[command(subcommand)]
what: ConfigWhat,
},
/// Scan the workspace and ingest new/updated documents.
Ingest {
/// Workspace root override.
@@ -346,6 +352,16 @@ enum Cmd {
},
}
#[derive(Subcommand, Debug)]
enum ConfigWhat {
/// 기존 config.toml 을 새 스키마로 마이그레이션(빠진 섹션 추가 + 멱등 + .bak 백업).
Migrate {
/// 변경만 출력하고 파일은 수정하지 않는다.
#[arg(long)]
dry_run: bool,
},
}
#[derive(Subcommand, Debug)]
enum ListWhat {
/// List documents currently indexed.
@@ -422,6 +438,14 @@ enum EvalWhat {
/// into `eval_runs.aggregate_json` (P5-2).
Aggregate { run_id: String },
/// Compute variant-consistency metrics for a stored run and print
/// a Markdown report (or JSON with `--json`).
Variants {
run_id: String,
#[arg(long)]
json: bool,
},
/// Diff two stored runs (P5-2). Default output is a Markdown
/// summary; use `--json` (top-level flag) for the raw report.
Compare {
@@ -608,6 +632,24 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
.map(|v| v.eq_ignore_ascii_case("plain"))
.unwrap_or(false);
let mode = progress::ProgressMode::from_flags(cli.json, cli.quiet, plain_env);
// Surface the active embedding backend/device on the terminal so the
// user sees it without grepping kb.log (the per-device tracing line
// only lands in the log file at --verbose). Suppressed under
// --json/--quiet. The Metal note reflects the build (`embed_metal`);
// the confirmed runtime device is in kb.log (`candle device = ...`).
if !cli.json && !cli.quiet {
let backend = match cfg.models.embedding.provider.as_str() {
"candle" if cfg!(feature = "embed_metal") => "candle (Metal/GPU 빌드)",
"candle" => "candle (CPU, 순수 Rust)",
"fastembed" | "onnx" | "" => "fastembed (onnxruntime)",
"none" => "비활성 (lexical-only)",
other => other,
};
eprintln!("임베딩 백엔드: {backend} · 모델 {} ({}-dim)",
cfg.models.embedding.model, cfg.models.embedding.dimensions);
}
let (tx, rx) = std::sync::mpsc::channel::<kebab_app::IngestEvent>();
let display_handle =
std::thread::spawn(move || progress::ProgressDisplay::new(mode).run(rx));
@@ -1302,6 +1344,42 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
Ok(())
}
Cmd::Config { what } => match what {
ConfigWhat::Migrate { dry_run } => {
let report =
kebab_app::config_migrate_with_config_path(cli.config.as_deref(), *dry_run)?;
if cli.json {
println!(
"{}",
serde_json::to_string(&wire::wire_config_migration(&report))?
);
} else if !report.changed {
println!(
"config 이미 최신입니다 (schema v{}).",
report.to_schema_version
);
} else {
let verb = if report.dry_run { "변경 예정" } else { "적용됨" };
println!(
"config 마이그레이션 {verb}: v{} → v{} ({} changes)",
report.from_schema_version,
report.to_schema_version,
report.changes.len()
);
for c in &report.changes {
println!(" - [{:?}] {}{}", c.kind, c.path, c.detail);
}
if let Some(bak) = &report.backup_path {
println!("백업: {bak}");
}
if report.dry_run {
println!("(--dry-run: 파일 미수정. 적용하려면 --dry-run 없이 재실행)");
}
}
Ok(())
}
},
Cmd::Doctor => {
let report = kebab_app::doctor_with_config_path(cli.config.as_deref())?;
if cli.json {
@@ -1392,6 +1470,16 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
Ok(())
}
EvalWhat::Variants { run_id, json } => {
let rep = kebab_eval::compute_variant_consistency_with_config(&cfg, run_id)?;
if *json {
println!("{}", serde_json::to_string_pretty(&rep)?);
} else {
print!("{}", kebab_eval::render_variants_md(&rep));
}
Ok(())
}
EvalWhat::Compare {
run_a,
run_b,

View File

@@ -157,6 +157,54 @@ impl ProgressDisplay {
// in Completed handles the final state. No per-asset bar update
// here avoids the duplicate-frame artifact in TTY scrollback.
}
// v0.24.0: asset-internal phase visibility. AssetChunked /
// ExpansionProgress use the bar *message* (live sub-progress for
// the current asset) — distinct from the per-file position draw,
// so a single large document no longer looks frozen. AssetTimings
// prints a one-line breakdown when the asset finishes.
IngestEvent::AssetChunked { idx, total, chunks } => {
if let Some(bar) = self.bar.as_ref() {
bar.set_message(format!("{chunks} chunks"));
}
if !tty && !quiet {
let mut err = std::io::stderr().lock();
let _ = writeln!(err, "ingest: {idx}/{total} → {chunks} chunks");
}
}
IngestEvent::ExpansionProgress {
done, chunks, ..
} => {
if let Some(bar) = self.bar.as_ref() {
bar.set_message(format!("별칭 확장 {done}/{chunks}"));
}
// Non-TTY: suppressed by default — throttled though it is, one
// line per emit would still spam CI logs. The bar message
// covers the interactive case; --json carries every frame.
}
IngestEvent::AssetTimings {
parse_ms,
chunk_ms,
expansion_ms,
embed_ms,
store_ms,
..
} => {
if let Some(bar) = self.bar.as_ref() {
bar.set_message("");
}
if !quiet {
let mut err = std::io::stderr().lock();
let _ = writeln!(
err,
" ⏱ parse {} · chunk {} · expand {} · embed {} · store {}",
fmt_ms(*parse_ms),
fmt_ms(*chunk_ms),
fmt_ms(*expansion_ms),
fmt_ms(*embed_ms),
fmt_ms(*store_ms),
);
}
}
IngestEvent::Completed { counts } => {
if let Some(bar) = self.bar.take() {
bar.finish_and_clear();
@@ -239,6 +287,17 @@ fn emit_json(event: &IngestEvent) -> anyhow::Result<()> {
Ok(())
}
/// Render a phase duration (milliseconds) compactly for the human-mode
/// `AssetTimings` line: `< 1000ms` stays in `ms`, larger spans collapse to
/// one-decimal seconds so a 45-second expansion reads `45.0s`, not `45000ms`.
fn fmt_ms(ms: u64) -> String {
if ms >= 1000 {
format!("{:.1}s", ms as f64 / 1000.0)
} else {
format!("{ms}ms")
}
}
/// Format the current wall-clock as RFC 3339 — used by `wire_ingest_progress`
/// so every emitted event carries an `ts` field per §2.4a / the wire schema.
pub(crate) fn now_rfc3339() -> anyhow::Result<String> {
@@ -285,6 +344,15 @@ mod tests {
}
}
#[test]
fn fmt_ms_switches_unit_at_one_second() {
assert_eq!(fmt_ms(0), "0ms");
assert_eq!(fmt_ms(999), "999ms");
assert_eq!(fmt_ms(1000), "1.0s");
assert_eq!(fmt_ms(45_000), "45.0s");
assert_eq!(fmt_ms(1500), "1.5s");
}
#[test]
fn now_rfc3339_parses_back() {
let s = now_rfc3339().unwrap();

View File

@@ -225,6 +225,12 @@ pub fn wire_bulk_search_item(item: &kebab_core::BulkSearchItem) -> Value {
v
}
/// `config_migration.v1` 직렬화. `ConfigMigrationReport` 가 `schema_version`
/// 필드를 자체 보유하므로(doctor 와 동일) 그대로 직렬화한다.
pub fn wire_config_migration(r: &kebab_app::ConfigMigrationReport) -> Value {
serde_json::to_value(r).expect("ConfigMigrationReport serializes")
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -15,6 +15,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
toml = "0.8"
toml_edit = "0.22"
dirs = "5"
# p9-fb-05: warn-log when current_dir() fails (chroot, deleted cwd)
# during workspace.root resolution.

View File

@@ -9,6 +9,7 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
mod paths;
pub mod migrate;
pub use paths::{expand_path, expand_path_with_base};
/// Signal: `Config::from_file` / `Config::load` failed due to missing path,
@@ -154,11 +155,21 @@ impl NliCfg {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct EmbeddingModelCfg {
/// `fastembed` (default, onnxruntime) or `candle` (pure-Rust,
/// NUMA-safe). `none` disables embeddings (lexical-only). Unknown
/// values error at embedder construction.
pub provider: String,
pub model: String,
pub version: String,
pub dimensions: usize,
pub batch_size: usize,
/// Cap on the CPU worker threads the `candle` provider spins up
/// (sizes the global rayon pool; env `KEBAB_EMBED_THREADS` overrides).
/// `0` = auto (rayon default = #cores). Lever to sidestep the
/// onnxruntime 48-thread NUMA double-free; ignored by the `fastembed`
/// provider. Defaulted on load so pre-0.22 config files still parse.
#[serde(default)]
pub num_threads: u32,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -595,6 +606,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,11 +648,39 @@ 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,
/// Whether alias embeddings are stored as separate dense vectors.
pub embed_aliases: bool,
}
impl Default for IngestExpansionCfg {
fn default() -> Self {
Self {
enabled: false,
model: String::new(),
max_aliases_per_chunk: 8,
prompt_version: "expansion-v1".to_string(),
embed_aliases: false,
}
}
}
impl Config {
/// Defaults per design §6.4.
pub fn defaults() -> Self {
Self {
schema_version: 1,
schema_version: crate::migrate::CURRENT_SCHEMA_VERSION,
workspace: WorkspaceCfg {
root: "~/KnowledgeBase".to_string(),
exclude: vec![
@@ -676,6 +717,7 @@ impl Config {
version: "v1".to_string(),
dimensions: 1024,
batch_size: 64,
num_threads: 0,
},
llm: LlmCfg {
provider: "ollama".to_string(),
@@ -933,6 +975,11 @@ impl Config {
self.models.embedding.batch_size = n;
}
}
"KEBAB_MODELS_EMBEDDING_NUM_THREADS" => {
if let Ok(n) = v.parse::<u32>() {
self.models.embedding.num_threads = n;
}
}
// models.llm
"KEBAB_MODELS_LLM_PROVIDER" => self.models.llm.provider = v.clone(),
@@ -1119,6 +1166,25 @@ 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::<usize>() {
self.ingest.expansion.max_aliases_per_chunk = n;
}
}
"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.
_ => {}
@@ -1846,6 +1912,42 @@ 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");
}
#[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)]

View File

@@ -0,0 +1,399 @@
//! config.toml 마이그레이션 엔진 (순수 변환, I/O 없음).
//!
//! 두 메커니즘: (1) reconciliation — default 구조에 있고 사용자 파일에
//! 없는 섹션/키를 주석과 함께 추가. (2) step 체인 — schema_version 기반
//! non-additive 변환(deprecated 제거 등). 자세한 계약은 spec
//! `docs/superpowers/specs/2026-05-31-config-migration-design.md`.
use toml_edit::DocumentMut;
/// 현재 바이너리가 이해하는 config 스키마 버전. 마이그레이션 완료 시
/// 사용자 파일의 `schema_version` 을 이 값으로 stamp 한다.
pub const CURRENT_SCHEMA_VERSION: u32 = 2;
/// 한 번의 마이그레이션에서 발생한 개별 변경.
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
pub struct MigrationChange {
pub kind: ChangeKind,
/// dotted path, 예: `ingest.expansion`, `workspace.include`.
pub path: String,
/// 사람·wire 용 한 줄 설명.
pub detail: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ChangeKind {
AddedSection,
AddedKey,
RemovedDeprecated,
}
/// 마이그레이션 결과 요약(순수 변환 단계 산출). I/O 계층이 backup_path
/// 등을 채워 wire 로 내보낸다.
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
pub struct MigrationOutcome {
pub from_schema_version: u32,
pub to_schema_version: u32,
pub changes: Vec<MigrationChange>,
/// 변환 후 직렬화된 새 문서 텍스트(멱등 시 입력과 동일).
pub new_text: String,
}
impl MigrationOutcome {
pub fn changed(&self) -> bool {
!self.changes.is_empty()
}
}
/// 문서 최상단 헤더(경로 정책 등). 기존 init 헤더를 이전.
const HEADER: &str = "\
# kebab config — `~/.config/kebab/config.toml`.
#
# `workspace.root` accepts: 절대 / tilde(~) / env(${VAR}) / 상대 경로.
# 상대 경로의 base 는 cwd 가 아니라 THIS config 파일의 디렉토리.
#
# 처리 가능한 형식 (extractor 가 자동 결정 — config 에 명시할 수 없음):
# • Markdown: .md
# • 이미지: .png .jpg .jpeg (OCR + caption)
# • PDF: .pdf
#
# 런타임 override: `KEBAB_*` env (예: KEBAB_WORKSPACE_ROOT=/tmp kebab ingest).
#
# 이 파일은 `kebab config migrate` 로 새 스키마에 맞춰 갱신할 수 있다
# (빠진 섹션 추가 + 손본 값·주석 보존).
";
/// 테이블 헤더(`[section]`) 위에 붙일 주석. dotted path → 한 줄(들).
fn section_comment(path: &str) -> Option<&'static str> {
Some(match path {
"workspace" => "# 색인 대상 워크스페이스.",
"storage" => "# XDG 저장 경로(데이터/sqlite/벡터/에셋/모델).",
"indexing" => "# 병렬도 + 파일시스템 watch.",
"chunking" => "# 청크 크기·오버랩·heading 존중.",
"models" => "# embedding / llm / nli 모델.",
"models.embedding" => "# 다국어 sentence embedding. dim 불일치 시 검색 0건.",
"models.llm" => "# Ollama host:port + 모델.",
"models.nli" => "# NLI(groundedness) 모델.",
"search" => "# 검색 기본 k·stale 기준·fusion.",
"rag" => "# 답변 생성: prompt 템플릿·score gate·NLI.",
"image" => "# 이미지 OCR + 캡션(기본 off, asset 당 모델 호출 비용).",
"image.ocr" => "# 이미지 OCR(기본 off).",
"image.caption" => "# 이미지 캡션(기본 off).",
"ui" => "# TUI 팔레트·role 스타일.",
"ingest" => "# ingest 정책(code skip 등).",
"ingest.code" => "# code ingest skip 정책(.gitignore 자동 honor).",
"ingest.expansion" => "# doc-side 별칭 확장(기본 off). 패러프레이즈 강건성↑, LLM 비용 큼.",
"pdf" => "# PDF ingest. scanned PDF OCR 은 기본 off(page 당 cost).",
"pdf.ocr" => "# scanned PDF page-단위 OCR(기본 off).",
"logging" => "# ingest 로그(기본 on, ~/.local/state/kebab/logs).",
_ => return None,
})
}
/// Config::defaults() 를 직렬화 + 주석 부착한 "완전체" 문서.
/// init 과 migrate reconciliation 의 단일 참조 원천.
pub fn annotated_default_document() -> DocumentMut {
let defaults = crate::Config::defaults();
let pretty = toml::to_string_pretty(&defaults).expect("defaults serialize");
let mut doc: DocumentMut = pretty.parse().expect("defaults parse as toml_edit");
// 헤더: 첫 최상위 항목의 prefix 로.
if let Some((mut first_key, _)) = doc.as_table_mut().iter_mut().next() {
first_key.leaf_decor_mut().set_prefix(format!("{HEADER}\n"));
}
annotate_table(doc.as_table_mut(), "");
doc
}
/// 재귀적으로 하위 테이블에 헤더 주석 부착. `prefix_path` 는 dotted 누적 경로.
/// annotated_default_document 는 항상 주석 없는 defaults 에서 새로 만들므로
/// 무조건 부착해도 중복되지 않는다.
fn annotate_table(table: &mut toml_edit::Table, prefix_path: &str) {
let keys: Vec<String> = table.iter().map(|(k, _)| k.to_string()).collect();
for key in keys {
let path = if prefix_path.is_empty() {
key.clone()
} else {
format!("{prefix_path}.{key}")
};
if let Some(item) = table.get_mut(&key) {
if let Some(sub) = item.as_table_mut() {
if let Some(c) = section_comment(&path) {
sub.decor_mut().set_prefix(format!("\n{c}\n"));
}
annotate_table(sub, &path);
}
}
}
}
/// 참조(주석 달린 default) 테이블 `reference` 를 기준으로, 사용자 테이블
/// `user` 에 없는 항목을 decor(주석) 포함 통째 복사한다. 이미 있는 키는
/// 건드리지 않는다(값 불가침). 양쪽이 테이블이면 하위로 재귀.
pub fn reconcile(
reference: &toml_edit::Table,
user: &mut toml_edit::Table,
prefix_path: &str,
changes: &mut Vec<MigrationChange>,
) {
for (key, ref_item) in reference.iter() {
let path = if prefix_path.is_empty() {
key.to_string()
} else {
format!("{prefix_path}.{key}")
};
match user.get_mut(key) {
None => {
// schema_version 키는 stamp 단계가 다룬다(change 기록 X).
if path == "schema_version" {
user.insert(key, ref_item.clone());
continue;
}
let kind = if ref_item.is_table() {
ChangeKind::AddedSection
} else {
ChangeKind::AddedKey
};
user.insert(key, ref_item.clone());
changes.push(MigrationChange {
kind,
path: path.clone(),
detail: section_comment(&path).map_or_else(
|| format!("{key} 추가"),
|c| c.trim_start_matches("# ").to_string(),
),
});
}
Some(existing) => {
if let (Some(ref_tbl), Some(user_tbl)) =
(ref_item.as_table(), existing.as_table_mut())
{
reconcile(ref_tbl, user_tbl, &path, changes);
}
// 둘 다 테이블이 아니면(스칼라 등) 값 불가침 → 무시.
}
}
}
}
/// v1 → v2: deprecated `workspace.include` 제거(p9-fb-25). 멱등.
pub fn step_1_to_2(doc: &mut DocumentMut, changes: &mut Vec<MigrationChange>) {
if let Some(ws) = doc.get_mut("workspace").and_then(|i| i.as_table_mut()) {
if ws.remove("include").is_some() {
changes.push(MigrationChange {
kind: ChangeKind::RemovedDeprecated,
path: "workspace.include".to_string(),
detail: "p9-fb-25: 처리 형식은 extractor 가 자동 결정 — 더 이상 사용 안 함."
.to_string(),
});
}
}
}
/// 파일의 schema_version(없으면 1) 부터 CURRENT 까지 step 적용.
fn run_steps(doc: &mut DocumentMut, from: u32, changes: &mut Vec<MigrationChange>) {
if from < 2 {
step_1_to_2(doc, changes);
}
// 미래 step: if from < 3 { step_2_to_3(...) } ...
}
/// 사용자 config.toml 텍스트를 받아 step 체인 + reconciliation + version
/// stamp 를 적용하고 결과를 반환한다. 순수 함수(I/O 없음). 파싱 실패 시
/// from=1, 변경 없음, new_text=입력 그대로(상위에서 파싱 에러를 따로 처리).
pub fn migrate_document(text: &str) -> MigrationOutcome {
let mut doc: DocumentMut = match text.parse() {
Ok(d) => d,
Err(_) => {
return MigrationOutcome {
from_schema_version: 1,
to_schema_version: CURRENT_SCHEMA_VERSION,
changes: Vec::new(),
new_text: text.to_string(),
};
}
};
let from = doc
.get("schema_version")
.and_then(toml_edit::Item::as_integer)
.unwrap_or(1) as u32;
let mut changes = Vec::new();
// 1. non-additive step 체인.
run_steps(&mut doc, from, &mut changes);
// 2. additive reconciliation(버전 무관).
let reference = annotated_default_document();
let ref_table = reference.as_table().clone();
reconcile(&ref_table, doc.as_table_mut(), "", &mut changes);
// 3. schema_version stamp.
let current_in_file = doc
.get("schema_version")
.and_then(toml_edit::Item::as_integer)
.unwrap_or(0) as u32;
if current_in_file != CURRENT_SCHEMA_VERSION {
doc["schema_version"] = toml_edit::value(i64::from(CURRENT_SCHEMA_VERSION));
}
MigrationOutcome {
from_schema_version: from,
to_schema_version: CURRENT_SCHEMA_VERSION,
changes,
new_text: doc.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn annotated_default_has_all_sections_and_parses_back_to_defaults() {
let doc = annotated_default_document();
let text = doc.to_string();
// PdfCfg/ImageCfg/ModelsCfg/IngestCfg 는 스칼라 필드가 없어 bare
// `[pdf]` 등은 안 나오고 `[pdf.ocr]` 같은 하위 테이블만 직렬화된다.
for section in [
"[workspace]",
"[ingest.expansion]",
"[pdf.ocr]",
"[logging]",
"[ui]",
] {
assert!(text.contains(section), "missing {section}:\n{text}");
}
assert!(text.contains("# "), "no comments attached");
let back: crate::Config = toml::from_str(&text).expect("parse annotated default");
assert_eq!(back, crate::Config::defaults());
}
#[test]
fn reconcile_adds_missing_section_preserving_user_values_and_comments() {
// ingest 는 code 만 있고 expansion 누락(v0.21.0 동기 시나리오),
// logging 통째 누락, score 는 사용자가 바꿈, 주석 보유.
let user_text = "\
schema_version = 1
[workspace]
root = \"/my/notes\" # 내 워크스페이스
[search]
default_k = 25
[ingest.code]
skip_generated_header = true
";
let mut user: DocumentMut = user_text.parse().unwrap();
let reference = annotated_default_document();
let ref_tbl = reference.as_table().clone();
let mut changes = Vec::new();
reconcile(&ref_tbl, user.as_table_mut(), "", &mut changes);
let out = user.to_string();
// 부분 존재하는 [ingest] 에 expansion 만 주석과 함께 추가.
assert!(out.contains("[ingest.expansion]"), "expansion not added:\n{out}");
// 통째 누락된 logging 추가.
assert!(out.contains("[logging]"), "logging not added");
// 사용자 값/주석/기존 섹션 보존.
assert!(out.contains("root = \"/my/notes\""));
assert!(out.contains("# 내 워크스페이스"));
assert!(out.contains("default_k = 25"));
assert!(out.contains("skip_generated_header = true"));
// 새 섹션 주석 부착.
assert!(out.contains("doc-side 별칭"));
// 부분 존재 부모로 재귀해 leaf 경로를 기록.
assert!(
changes
.iter()
.any(|c| c.kind == ChangeKind::AddedSection && c.path == "ingest.expansion"),
"changes: {changes:?}"
);
// 통째 누락 부모는 부모 경로로 한 번 기록.
assert!(
changes
.iter()
.any(|c| c.kind == ChangeKind::AddedSection && c.path == "logging")
);
}
#[test]
fn reconcile_does_not_overwrite_user_value_differing_from_default() {
let user_text = "\
schema_version = 2
[rag]
score_gate = 0.8
";
let mut user: DocumentMut = user_text.parse().unwrap();
let reference = annotated_default_document();
let ref_tbl = reference.as_table().clone();
let mut changes = Vec::new();
reconcile(&ref_tbl, user.as_table_mut(), "", &mut changes);
let out = user.to_string();
assert!(out.contains("score_gate = 0.8"), "user value clobbered:\n{out}");
assert!(!changes.iter().any(|c| c.path == "rag.score_gate"));
}
#[test]
fn step_1_to_2_removes_deprecated_workspace_include() {
let user_text = "\
[workspace]
root = \"/n\"
include = [\"*.md\"]
";
let mut user: DocumentMut = user_text.parse().unwrap();
let mut changes = Vec::new();
step_1_to_2(&mut user, &mut changes);
let out = user.to_string();
assert!(!out.contains("include"), "include not removed:\n{out}");
assert!(
changes
.iter()
.any(|c| c.kind == ChangeKind::RemovedDeprecated && c.path == "workspace.include")
);
let mut changes2 = Vec::new();
step_1_to_2(&mut user, &mut changes2);
assert!(changes2.is_empty());
}
fn read_schema_version(text: &str) -> u32 {
let doc: DocumentMut = text.parse().unwrap();
doc.get("schema_version")
.and_then(toml_edit::Item::as_integer)
.unwrap_or(1) as u32
}
#[test]
fn migrate_document_stamps_version_and_is_idempotent() {
let old = "\
schema_version = 1
[workspace]
root = \"/n\"
include = [\"*.md\"]
";
let outcome = migrate_document(old);
assert_eq!(outcome.from_schema_version, 1);
assert_eq!(outcome.to_schema_version, CURRENT_SCHEMA_VERSION);
assert!(outcome.changed());
assert!(!outcome.new_text.contains("include"));
assert!(outcome.new_text.contains("[ingest.expansion]"));
assert_eq!(read_schema_version(&outcome.new_text), CURRENT_SCHEMA_VERSION);
let again = migrate_document(&outcome.new_text);
assert!(!again.changed(), "not idempotent: {:?}", again.changes);
assert_eq!(again.new_text, outcome.new_text);
}
#[test]
fn migrate_document_missing_schema_version_treated_as_v1() {
let old = "[workspace]\nroot = \"/n\"\n";
let outcome = migrate_document(old);
assert_eq!(outcome.from_schema_version, 1);
assert_eq!(read_schema_version(&outcome.new_text), CURRENT_SCHEMA_VERSION);
}
}

View File

@@ -28,4 +28,35 @@ pub struct Chunk {
/// Bug #8 (한국어 2자 query) 해결을 위한 V009 cascade.
#[serde(default)]
pub tokenized_korean_text: Option<String>,
/// 색인시 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<String>,
}
#[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);
}
}

View File

@@ -0,0 +1,110 @@
//! Content-hash derivation cache key (design 2026-05-31 §3.1).
//!
//! Expensive ingest derivations (embedding vectors, LLM aliases, optional
//! Korean morphological tokens) are cached by the *content hash* of the chunk
//! text so that re-indexing an updated document skips recomputation for any
//! chunk whose text is unchanged — independent of position / `chunk_id`
//! (which is position-based, see `ids::id_for_block`).
//!
//! ```text
//! cache_key = blake3_hex( kind || 0x00 || text_blake3 || 0x00 || version_key )[:32]
//! ```
//! - `text_blake3` = blake3(NFC-normalized UTF-8 bytes of the chunk text).
//! - `kind` ∈ { "embedding", "alias", "korean_tokens" }.
//! - `version_key` folds every §9 version-cascade input for that kind
//! (model / prompt / tokenizer version). A version bump changes the key →
//! automatic cache miss → recompute, keeping the cache consistent with the
//! cascade contract (§3.5 / §3.6).
//!
//! Pure: depends only on `blake3` + `unicode-normalization`. No other
//! `kebab-*` crate is referenced (deps boundary §5).
use crate::normalize::nfc;
/// Derivation-cache key per design §3.1.
///
/// `text` is NFC-normalized before hashing so the same logical content always
/// maps to the same key regardless of Unicode encoding form. `kind` and
/// `version_key` are folded in with `0x00` separators (which cannot occur in
/// hex digests) so distinct kinds / versions never collide.
pub fn derivation_cache_key(kind: &str, text: &str, version_key: &str) -> String {
let text_blake3 = blake3::hash(nfc(text).as_bytes()).to_hex().to_string();
let mut hasher = blake3::Hasher::new();
hasher.update(kind.as_bytes());
hasher.update(&[0x00]);
hasher.update(text_blake3.as_bytes());
hasher.update(&[0x00]);
hasher.update(version_key.as_bytes());
hasher.finalize().to_hex().to_string()[..32].to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_is_32_hex_chars() {
let k = derivation_cache_key("embedding", "hello world", "v1");
assert_eq!(k.len(), 32);
assert!(k.bytes().all(|b| b.is_ascii_hexdigit()));
}
#[test]
fn same_inputs_same_key() {
let a = derivation_cache_key("embedding", "러스트 소유권", "model|1|1024");
let b = derivation_cache_key("embedding", "러스트 소유권", "model|1|1024");
assert_eq!(a, b);
}
#[test]
fn nfc_normalization_collapses_encoding_forms() {
// "가" as a precomposed syllable (NFC) vs decomposed jamo (NFD) must
// hash to the same key after NFC normalization.
let precomposed = "\u{AC00}"; // 가
let decomposed = "\u{1100}\u{1161}"; // ᄀ + ᅡ
assert_ne!(precomposed, decomposed);
let a = derivation_cache_key("embedding", precomposed, "v1");
let b = derivation_cache_key("embedding", decomposed, "v1");
assert_eq!(a, b);
}
#[test]
fn different_kind_different_key() {
let e = derivation_cache_key("embedding", "same text", "v1");
let a = derivation_cache_key("alias", "same text", "v1");
assert_ne!(e, a);
}
#[test]
fn different_version_key_different_key_miss() {
// §3.6 correctness guard: a version_key change MUST produce a different
// cache_key (so a stale derivation never gets reused after a cascade
// bump). This is the most safety-critical invariant of the cache.
let v1 = derivation_cache_key("embedding", "same text", "modelA|1|1024");
let v2 = derivation_cache_key("embedding", "same text", "modelA|2|1024");
assert_ne!(v1, v2);
// alias prompt_version bump → miss.
let p1 = derivation_cache_key("alias", "문단", "expansion-v1|8|");
let p2 = derivation_cache_key("alias", "문단", "expansion-v2|8|");
assert_ne!(p1, p2);
}
#[test]
fn different_text_different_key() {
let a = derivation_cache_key("embedding", "text one", "v1");
let b = derivation_cache_key("embedding", "text two", "v1");
assert_ne!(a, b);
}
#[test]
fn separator_prevents_field_smearing() {
// Without the 0x00 separators, ("ab","","c") and ("a","b","c") shaped
// inputs could collide. The kind/version boundaries must be distinct.
let a = derivation_cache_key("ab", "x", "c");
let b = derivation_cache_key("a", "x", "bc");
assert_ne!(a, b);
}
}

View File

@@ -58,6 +58,23 @@ 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 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 {
match id.find(ALIAS_SUFFIX) {
Some(pos) => &id[..pos],
None => id,
}
}
/// Canonical-JSON + blake3 + hex prefix 32. Per design §4.2.
pub fn id_from<T: Serialize>(tuple: T) -> String {
let bytes = serde_json_canonicalizer::to_vec(&tuple)
@@ -430,6 +447,20 @@ 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"), "");
// 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.
/// inputs:
/// collection="default",

View File

@@ -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,
@@ -43,8 +45,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};

View File

@@ -0,0 +1,47 @@
[package]
name = "kebab-embed-candle"
version = { workspace = true }
edition = { workspace = true }
rust-version = { workspace = true }
license = { workspace = true }
repository = { workspace = true }
description = "Pure-Rust candle adapter implementing kb_core::Embedder (multilingual-e5-large, NUMA-safe thread cap)"
[dependencies]
kebab-core = { path = "../kebab-core" }
kebab-config = { path = "../kebab-config" }
# candle stack — pinned to the workspace-locked crates.io release (0.10.x),
# same versions the Phase 0 spike compiled so build artifacts are reused.
candle-core = "0.10.2"
candle-nn = "0.10.2"
candle-transformers = "0.10.2"
tokenizers = "0.21"
hf-hub = { version = "0.4", features = ["ureq"] }
serde_json = { workspace = true }
# Thread cap: a one-shot global rayon pool sizes candle's CPU threads
# (the Phase 0 spike proved RAYON_NUM_THREADS caps candle), so a NUMA host
# can keep onnxruntime's hard-coded 48-intra-op heap corruption at bay.
rayon = "1"
anyhow = { workspace = true }
tracing = { workspace = true }
[features]
# opt-in: run candle on the Apple Silicon GPU (Metal). macOS-only — the build
# enables candle's metal backend and `select_device()` picks Metal (CPU fallback
# on failure). Lets an M-series Mac ingest e5-large on GPU (10×+ vs CPU); the
# resulting vectors are cross-compatible with the CPU path (same model), so the
# Linux server can serve queries on CPU candle.
metal = ["candle-core/metal", "candle-nn/metal", "candle-transformers/metal"]
[dev-dependencies]
# Integration-test binaries can only see the library's public API + these,
# not the library's own (non-dev) dependencies — so rayon/kebab-config/kebab-core
# are repeated here for tests/parity.rs and tests/thread_cap.rs.
kebab-embed-local = { path = "../kebab-embed-local" }
kebab-config = { path = "../kebab-config" }
kebab-core = { path = "../kebab-core" }
rayon = "1"
tempfile = { workspace = true }
[lints]
workspace = true

View File

@@ -0,0 +1,444 @@
//! `kebab-embed-candle` — [`CandleEmbedder`], a pure-Rust (candle)
//! implementation of [`Embedder`](kebab_core::Embedder).
//!
//! Runs the same `intfloat/multilingual-e5-large` model as the default
//! [`FastembedEmbedder`](kebab_embed_local) but through `candle`
//! (`candle-transformers`' XLM-RoBERTa) instead of onnxruntime. Motivation:
//! fastembed 4.9's onnxruntime hard-codes 48 intra-op threads, which corrupts
//! the heap (double-free) on dual-socket NUMA hosts. candle's CPU backend
//! sizes its threads off the global rayon pool, so a one-shot
//! [`rayon::ThreadPoolBuilder`] cap (config `num_threads` / env
//! `KEBAB_EMBED_THREADS`) keeps the worker count NUMA-safe.
//!
//! Output parity with the onnxruntime path was proven by the Phase 0 spike
//! (cosine 1.000000); this crate absorbs that pipeline verbatim:
//!
//! 1. e5 prefix (`passage: ` for documents, `query: ` for queries — the same
//! convention as `kebab-embed-local`'s `prefix_input`);
//! 2. tokenize (max_len 512, batch-longest padding, special tokens);
//! 3. XLM-RoBERTa forward on `Device::Cpu`;
//! 4. attention-mask-weighted mean pooling;
//! 5. L2 normalization.
//!
//! Model files (`config.json`, `tokenizer.json`, `model.safetensors`) are
//! fetched via `hf-hub` into `{config.storage.model_dir}/candle/`.
//!
//! This crate is **opt-in** (`config.models.embedding.provider = "candle"`);
//! the default provider stays `fastembed`. See
//! `docs/superpowers/specs/2026-06-01-embed-candle-track-spec.md`.
use std::sync::Mutex;
use anyhow::{Context, Result};
use candle_core::{DType, Device, Tensor};
use candle_nn::VarBuilder;
use candle_transformers::models::xlm_roberta::{Config as XlmConfig, XLMRobertaModel};
use kebab_config::{Config, expand_path};
use kebab_core::{Embedder, EmbeddingInput, EmbeddingKind, EmbeddingModelId, EmbeddingVersion};
use tokenizers::{PaddingParams, PaddingStrategy, Tokenizer, TruncationParams};
/// Subdirectory under `config.storage.model_dir` where the candle adapter
/// caches safetensors + tokenizer. Mirrors `kebab-embed-local`'s
/// `fastembed/` subdir so the two backends never collide.
const CANDLE_CACHE_SUBDIR: &str = "candle";
/// HuggingFace repo id for the multilingual e5 large model. Same weights the
/// onnxruntime path uses, just the safetensors variant candle can read.
const HF_MODEL: &str = "intfloat/multilingual-e5-large";
/// The only `config.models.embedding.model` value the candle adapter accepts
/// (the e5-large weights `HF_MODEL` resolves to). Guards against silently
/// downloading e5-large while `model_id()` reports a different name.
const SUPPORTED_MODEL: &str = "multilingual-e5-large";
/// Token truncation length (e5 was trained at 512).
const MAX_LEN: usize = 512;
/// Env var that overrides `config.models.embedding.num_threads`. Read once in
/// [`CandleEmbedder::new`]; `0`/unset/unparseable means "leave rayon default".
const ENV_EMBED_THREADS: &str = "KEBAB_EMBED_THREADS";
/// Pure-Rust candle adapter. Construct via [`CandleEmbedder::new`]; the
/// constructor downloads the model on first use, so share one instance.
pub struct CandleEmbedder {
// candle's `forward` is `&self`, but `XLMRobertaModel` is not guaranteed
// `Sync`; the `Mutex` both supplies that bound and serializes inference
// (callers batch sequentially anyway — same rationale as
// `FastembedEmbedder`).
model: Mutex<XLMRobertaModel>,
tokenizer: Tokenizer,
device: Device,
model_id: EmbeddingModelId,
version: EmbeddingVersion,
dimensions: usize,
batch_size: usize,
}
impl CandleEmbedder {
/// Build an embedder from `Config`. Applies the NUMA thread cap, fetches
/// the model into `{model_dir}/candle/`, and validates that the model's
/// hidden size matches `config.models.embedding.dimensions` before
/// returning.
pub fn new(config: &Config) -> Result<Self> {
// 1. NUMA thread cap. env `KEBAB_EMBED_THREADS` wins over the config
// field; `0`/unset leaves rayon's default. `build_global` errors if
// the pool was already initialized — intentionally ignored so a
// second embedder (or a prior rayon user) is a no-op, not a failure.
let n_threads = std::env::var(ENV_EMBED_THREADS)
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(config.models.embedding.num_threads as usize);
if n_threads > 0 {
if apply_thread_cap(n_threads) {
tracing::info!(
target: "kebab-embed-candle",
num_threads = n_threads,
"capped global rayon pool for candle CPU backend"
);
} else {
tracing::debug!(
target: "kebab-embed-candle",
requested = n_threads,
"global rayon pool already initialized; thread cap not applied"
);
}
}
// 1b. Model guard. `HF_MODEL` is hard-coded (candle currently only wires
// e5-large), so if the operator configured a *different* model name
// we must NOT silently download e5-large and then label its vectors
// with the configured name via `model_id()` — that would mislabel
// `embedding_version` and corrupt a mixed index. Fail fast, before
// the ~2GB download.
let want = config.models.embedding.model.as_str();
if want != SUPPORTED_MODEL && want != HF_MODEL {
anyhow::bail!(
"candle provider currently supports only '{SUPPORTED_MODEL}' (or \
the HF id '{HF_MODEL}'), but config.models.embedding.model = \
'{want}'. Use provider=fastembed for other models, or set \
model = \"{SUPPORTED_MODEL}\"."
);
}
// 2. Resolve `{data_dir}/models/candle/` exactly like the fastembed
// adapter resolves its own subdir.
let data_dir = expand_path(&config.storage.data_dir, "");
let model_dir = expand_path(&config.storage.model_dir, &data_dir.to_string_lossy());
let cache_dir = model_dir.join(CANDLE_CACHE_SUBDIR);
std::fs::create_dir_all(&cache_dir)
.with_context(|| format!("create candle cache dir {}", cache_dir.display()))?;
let device = select_device();
// 3. Fetch model files via hf-hub into the candle cache.
tracing::info!(
target: "kebab-embed-candle",
cache_dir = %cache_dir.display(),
model = HF_MODEL,
"loading candle embedding model (first run downloads ~2GB safetensors)"
);
let api = hf_hub::api::sync::ApiBuilder::new()
.with_cache_dir(cache_dir.clone())
.build()
.context("kb-embed-candle: build hf-hub api")?;
let repo = api.model(HF_MODEL.to_string());
let config_path = repo.get("config.json").context("download config.json")?;
let tokenizer_path = repo
.get("tokenizer.json")
.context("download tokenizer.json")?;
let weights_path = repo
.get("model.safetensors")
.context("download model.safetensors")?;
// 4. Build the candle XLM-RoBERTa model.
let cfg_json = std::fs::read_to_string(&config_path)
.with_context(|| format!("read {}", config_path.display()))?;
let cfg: XlmConfig =
serde_json::from_str(&cfg_json).context("kb-embed-candle: parse XLM-R config")?;
// Validate dim BEFORE building the model so a misconfigured
// `dimensions` fails cheaply (matches FastembedEmbedder's contract).
check_dim(cfg.hidden_size, config.models.embedding.dimensions)?;
let vb = unsafe {
VarBuilder::from_mmaped_safetensors(&[weights_path], DType::F32, &device)
.context("kb-embed-candle: mmap safetensors")?
};
let model =
XLMRobertaModel::new(&cfg, vb).context("kb-embed-candle: build XLMRobertaModel")?;
let mut tokenizer = Tokenizer::from_file(&tokenizer_path)
.map_err(|e| anyhow::anyhow!("kb-embed-candle: load tokenizer: {e}"))?;
tokenizer
.with_padding(Some(PaddingParams {
strategy: PaddingStrategy::BatchLongest,
..Default::default()
}))
.with_truncation(Some(TruncationParams {
max_length: MAX_LEN,
..Default::default()
}))
.map_err(|e| anyhow::anyhow!("kb-embed-candle: set truncation: {e}"))?;
tracing::info!(
target: "kebab-embed-candle",
dimensions = cfg.hidden_size,
layers = cfg.num_hidden_layers,
"candle embedding model loaded"
);
Ok(Self {
model: Mutex::new(model),
tokenizer,
device,
model_id: EmbeddingModelId(config.models.embedding.model.clone()),
version: EmbeddingVersion(config.models.embedding.version.clone()),
dimensions: cfg.hidden_size,
batch_size: config.models.embedding.batch_size.max(1),
})
}
/// Embed one batch of **already-prefixed** strings (the e5 `query:`/
/// `passage:` prefix is applied by the caller [`CandleEmbedder::embed`])
/// through the candle pipeline: tokenize → forward → masked mean pool → L2.
fn embed_batch(&self, prefixed: &[String]) -> Result<Vec<Vec<f32>>> {
let encodings = self
.tokenizer
.encode_batch(prefixed.to_vec(), true)
.map_err(|e| anyhow::anyhow!("kb-embed-candle: encode_batch: {e}"))?;
let bsz = encodings.len();
// `embed` already returns early on empty input and `.chunks()` never
// yields an empty slice, so this is currently unreachable — but guard
// the index so a future refactor can't turn it into a panic.
let Some(first) = encodings.first() else {
return Ok(Vec::new());
};
let seq = first.get_ids().len();
let mut ids = Vec::with_capacity(bsz * seq);
let mut mask = Vec::with_capacity(bsz * seq);
for enc in &encodings {
ids.extend(enc.get_ids().iter().copied());
mask.extend(enc.get_attention_mask().iter().map(|&m| m as f32));
}
let input_ids = Tensor::from_vec(ids, (bsz, seq), &self.device)?;
let attn_f32 = Tensor::from_vec(mask, (bsz, seq), &self.device)?;
let token_type_ids = input_ids.zeros_like()?;
let hidden = {
let guard = self
.model
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
// forward: (input_ids, attention_mask, token_type_ids, past,
// encoder_hidden, encoder_mask)
guard.forward(&input_ids, &attn_f32, &token_type_ids, None, None, None)?
};
// attention-mask-weighted mean pooling
let mask3 = attn_f32.unsqueeze(2)?; // (b, seq, 1)
let summed = hidden.broadcast_mul(&mask3)?.sum(1)?; // (b, hidden)
// counts ≥ 1 always: every input is e5-prefixed AND special tokens are
// added (encode_batch(_, true)), so no row has an all-zero mask. If that
// invariant ever breaks, broadcast_div would emit NaN vectors.
let counts = mask3.sum(1)?; // (b, 1)
let mean = summed.broadcast_div(&counts)?;
// L2 normalize
let norm = mean.sqr()?.sum_keepdim(1)?.sqrt()?;
let normalized = mean.broadcast_div(&norm)?;
// `.contiguous()` before host copy: broadcast ops can leave a strided
// view, which `to_vec2` rejects on the Metal backend (CPU tolerates it).
Ok(normalized.contiguous()?.to_vec2::<f32>()?)
}
}
impl Embedder for CandleEmbedder {
fn model_id(&self) -> EmbeddingModelId {
self.model_id.clone()
}
fn model_version(&self) -> EmbeddingVersion {
self.version.clone()
}
fn dimensions(&self) -> usize {
self.dimensions
}
fn embed(&self, inputs: &[EmbeddingInput<'_>]) -> Result<Vec<Vec<f32>>> {
if inputs.is_empty() {
return Ok(Vec::new());
}
// e5 prefix per §11.3 BEFORE tokenization (same convention as
// FastembedEmbedder so the two backends produce comparable vectors).
let prefixed: Vec<String> = inputs.iter().map(prefix_input).collect();
let mut out: Vec<Vec<f32>> = Vec::with_capacity(prefixed.len());
for chunk in prefixed.chunks(self.batch_size) {
let batch = self.embed_batch(chunk)?;
for v in &batch {
if v.len() != self.dimensions {
anyhow::bail!(
"candle returned vector of length {} but adapter expects {}",
v.len(),
self.dimensions
);
}
}
out.extend(batch);
}
debug_assert_eq!(out.len(), inputs.len());
Ok(out)
}
}
/// Build the e5-prefixed string for one [`EmbeddingInput`]. Free function so
/// a unit test can pin the format without loading the model. Byte-identical to
/// `kebab-embed-local`'s `prefix_input` — the two backends MUST agree here or
/// their vectors diverge.
fn prefix_input(input: &EmbeddingInput<'_>) -> String {
match input.kind {
EmbeddingKind::Document => format!("passage: {}", input.text),
EmbeddingKind::Query => format!("query: {}", input.text),
}
}
/// Select the compute device. Built with the `metal` feature (Apple Silicon
/// GPU), try Metal and fall back to CPU on failure; otherwise CPU. Metal only
/// compiles/runs on macOS — the Linux server builds the CPU path. e5-large
/// vectors are model-defined, so Metal-produced and CPU-produced embeddings are
/// cross-compatible (a Mac can ingest on GPU, the server query on CPU).
fn select_device() -> Device {
#[cfg(feature = "metal")]
{
match Device::new_metal(0) {
Ok(d) => {
tracing::info!(target: "kebab-embed-candle", "candle device = Metal (GPU)");
return d;
}
Err(e) => {
tracing::warn!(
target: "kebab-embed-candle",
error = %e,
"Metal device unavailable; falling back to CPU"
);
}
}
}
tracing::info!(target: "kebab-embed-candle", "candle device = CPU");
Device::Cpu
}
/// Apply a one-shot global rayon thread cap (the NUMA-safety lever). Returns
/// `true` if this call set the pool, `false` if it was already initialized
/// (cap not applied) or `n_threads == 0`. `#[doc(hidden)] pub` so the
/// thread-cap test can drive it without loading the 2GB model.
#[doc(hidden)]
pub fn apply_thread_cap(n_threads: usize) -> bool {
if n_threads == 0 {
return false;
}
rayon::ThreadPoolBuilder::new()
.num_threads(n_threads)
.build_global()
.is_ok()
}
/// Compare model hidden size against the configured dim. Extracted so a unit
/// test can exercise the error branch without loading the model.
pub(crate) fn check_dim(model_dim: usize, cfg_dim: usize) -> Result<()> {
if model_dim != cfg_dim {
anyhow::bail!(
"dimension mismatch: model={model_dim}, config={cfg_dim}; \
update `config.models.embedding.dimensions` to match the model \
(or pick a different model)."
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
// ── prefix_input ─────────────────────────────────────────────────
// Pin the exact e5 prefix strings; these MUST match
// kebab-embed-local::prefix_input or candle vs fastembed parity breaks.
#[test]
fn prefix_document_uses_passage() {
let input = EmbeddingInput {
text: "hello world",
kind: EmbeddingKind::Document,
};
assert_eq!(prefix_input(&input), "passage: hello world");
}
#[test]
fn prefix_query_uses_query() {
let input = EmbeddingInput {
text: "hello world",
kind: EmbeddingKind::Query,
};
assert_eq!(prefix_input(&input), "query: hello world");
}
#[test]
fn prefix_handles_empty_text() {
let doc = EmbeddingInput {
text: "",
kind: EmbeddingKind::Document,
};
let qry = EmbeddingInput {
text: "",
kind: EmbeddingKind::Query,
};
assert_eq!(prefix_input(&doc), "passage: ");
assert_eq!(prefix_input(&qry), "query: ");
}
// ── check_dim ────────────────────────────────────────────────────
#[test]
fn check_dim_passes_for_1024() {
check_dim(1024, 1024).expect("matching dims must pass");
}
#[test]
fn check_dim_rejects_384_vs_1024() {
let err = check_dim(384, 1024).expect_err("dim mismatch must error");
let msg = format!("{err}");
assert!(
msg.contains("384") && msg.contains("1024"),
"error must mention both dims, got: {msg}"
);
}
// ── model guard ──────────────────────────────────────────────────
// A non-e5-large model name must fail fast (BEFORE the ~2GB download),
// so we never download e5-large yet label its vectors with another name
// via model_id() — which would mislabel embedding_version.
#[test]
fn new_rejects_unsupported_model() {
let mut config = kebab_config::Config::defaults();
config.models.embedding.model = "multilingual-e5-small".to_string();
// num_threads defaults to 0, so no global rayon side effect here.
// `.err()` (not `expect_err`) avoids requiring `CandleEmbedder: Debug`
// — it holds a Mutex/Tokenizer and intentionally derives no Debug.
let err = CandleEmbedder::new(&config)
.err()
.expect("unsupported model must error");
let msg = format!("{err:#}");
assert!(
msg.contains("candle provider currently supports only"),
"expected model-guard error, got: {msg}"
);
}
}

View File

@@ -0,0 +1,96 @@
//! Parity test (spec §7, `#[ignore]` — needs the ~2GB model + network).
//!
//! Confirms the candle backend reproduces the onnxruntime `FastembedEmbedder`
//! vectors closely enough that no re-index is required (spec D-reindex):
//! per-sentence cosine ≥ 0.9999, and reports the dimension-wise max absolute
//! difference (the number the re-index decision hangs on).
//!
//! Run manually:
//! CARGO_TARGET_DIR=/build/out/cargo-target/target \
//! cargo test -p kebab-embed-candle --release -- --ignored --nocapture
//!
//! Uses the canonical dogfood config so both backends resolve the same model
//! identifiers and cache roots.
use kebab_config::Config;
use kebab_core::{Embedder, EmbeddingInput, EmbeddingKind};
use kebab_embed_candle::CandleEmbedder;
use kebab_embed_local::FastembedEmbedder;
const DOGFOOD_CONFIG: &str = "/build/dogfood/config.toml";
/// Mixed Korean / English parity set (≥ 8 sentences, mirrors the Phase 0 spike).
const SENTENCES: &[&str] = &[
"The quick brown fox jumps over the lazy dog.",
"오늘 날씨가 정말 좋아서 산책을 나가고 싶다.",
"Rust is a systems programming language focused on safety and performance.",
"벡터 검색은 임베딩 사이의 코사인 유사도를 이용한다.",
"Machine learning models require large amounts of training data.",
"한국어와 영어가 섞인 문장도 멀티링구얼 모델은 잘 처리한다.",
"The capital of France is Paris, a city known for its art and culture.",
"이 프로젝트는 로컬 우선 지식 베이스와 검색 증강 생성을 목표로 한다.",
"Database indexing dramatically speeds up query performance.",
"임베딩 모델을 candle 로 옮기면 NUMA 서버에서 안전하게 돌릴 수 있다.",
];
fn cosine(a: &[f32], b: &[f32]) -> f32 {
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
dot / (na * nb)
}
#[test]
#[ignore = "needs ~2GB model + network; run manually for the re-index decision"]
fn candle_matches_fastembed() {
let config = Config::load(Some(std::path::Path::new(DOGFOOD_CONFIG)))
.expect("load dogfood config for parity baseline");
let candle = CandleEmbedder::new(&config).expect("build CandleEmbedder");
let fastembed = FastembedEmbedder::new(&config).expect("build FastembedEmbedder");
// Cover BOTH prefix paths (`passage:` for Document, `query:` for Query) so
// a query-side prefix/pooling divergence can't slip through (reviewer note).
let inputs: Vec<EmbeddingInput> = SENTENCES
.iter()
.flat_map(|s| {
[EmbeddingKind::Document, EmbeddingKind::Query]
.into_iter()
.map(move |kind| EmbeddingInput { text: s, kind })
})
.collect();
let cv = candle.embed(&inputs).expect("candle embed");
let fv = fastembed.embed(&inputs).expect("fastembed embed");
assert_eq!(cv.len(), fv.len(), "embedding counts must match");
assert_eq!(cv.len(), inputs.len(), "one vector per input");
assert_eq!(candle.dimensions(), 1024);
let mut min_cos = f32::INFINITY;
let mut max_abs_diff = 0f32;
for (i, inp) in inputs.iter().enumerate() {
assert_eq!(cv[i].len(), 1024, "candle dim");
assert_eq!(fv[i].len(), 1024, "fastembed dim");
let c = cosine(&cv[i], &fv[i]);
min_cos = min_cos.min(c);
let diff = cv[i]
.iter()
.zip(&fv[i])
.map(|(a, b)| (a - b).abs())
.fold(0f32, f32::max);
max_abs_diff = max_abs_diff.max(diff);
let kind = match inp.kind {
EmbeddingKind::Document => "doc",
EmbeddingKind::Query => "qry",
};
let preview: String = inp.text.chars().take(36).collect();
println!("[{i:>2}] {kind} cos={c:.6} max_abs_diff={diff:.6e} {preview}");
}
println!("PARITY_SUMMARY cosine_min={min_cos:.6} max_abs_diff={max_abs_diff:.6e}");
assert!(
min_cos >= 0.9999,
"candle vs fastembed cosine_min={min_cos:.6} < 0.9999 — investigate before merge"
);
}

View File

@@ -0,0 +1,32 @@
//! Thread-cap test (spec §7). Own integration binary → clean process, so the
//! one-shot global rayon pool is initialized exactly once, by us.
//!
//! Verifies that `apply_thread_cap(4)` sizes the global rayon pool to 4, which
//! is the lever that keeps candle's CPU backend NUMA-safe (vs onnxruntime's
//! hard-coded 48 intra-op threads).
use kebab_embed_candle::apply_thread_cap;
#[test]
fn thread_cap_sizes_global_rayon_pool() {
// Must run before any other rayon use in this process. As the only test in
// this binary that touches rayon, that holds.
let applied = apply_thread_cap(4);
assert!(applied, "first build_global call should succeed");
assert_eq!(
rayon::current_num_threads(),
4,
"global rayon pool must be capped at the requested 4 threads"
);
// A second cap attempt is a no-op (pool already built), not a panic.
assert!(
!apply_thread_cap(8),
"second build_global must report not-applied"
);
assert_eq!(
rayon::current_num_threads(),
4,
"thread count must stay at the first cap"
);
}

View File

@@ -503,6 +503,7 @@ mod tests {
must_contain: vec![],
forbidden: vec![],
difficulty: None,
group: None,
};
let g = Some(&g);
// a miss, b hit → Win

View File

@@ -25,6 +25,7 @@ mod loader;
mod metrics;
mod runner;
mod types;
mod variant;
pub use compare::{
CompareOpts, CompareReport, ComparisonKind, QueryComparison, compare_runs,
@@ -37,3 +38,7 @@ pub use metrics::{
};
pub use runner::{run_eval, run_eval_with_config};
pub use types::{EvalRun, EvalRunOpts, GoldenQuery, QueryResult};
pub use variant::{
VariantClass, VariantConsistencyReport, VariantGroupReport, VariantResult,
compute_variant_consistency, compute_variant_consistency_with_config, render_variants_md,
};

View File

@@ -30,6 +30,7 @@ pub fn load_golden_set(path: &Path) -> Result<Vec<GoldenQuery>> {
let queries: Vec<GoldenQuery> = serde_yaml::from_slice(&bytes)
.with_context(|| format!("parse golden YAML at {}", path.display()))?;
check_unique_ids(&queries)?;
check_group_integrity(&queries)?;
Ok(queries)
}
@@ -54,6 +55,46 @@ pub(crate) fn load_golden_set_validated(
Ok(queries)
}
/// 같은 `group`에 속한 모든 쿼리가 동일한 `expected_doc_ids`(집합)를
/// 공유하는지 검증. 변형 일관성 메트릭은 "같은 정답을 가진 다른 표현들"을
/// 전제하므로, 그룹 내 정답이 갈리면 측정이 무의미해진다 → bail.
fn check_group_integrity(queries: &[GoldenQuery]) -> Result<()> {
use std::collections::BTreeMap;
// group -> (대표 정답 집합, 대표 query id). 첫 멤버를 canonical 로 삼고
// 이후 멤버가 다른 expected 를 가지면 offender 로 기록한다.
let mut canonical: BTreeMap<&str, (BTreeSet<String>, &str)> = BTreeMap::new();
// 그룹별 위반 메시지(정렬·dedup 위해 BTreeSet). canonical query id 와
// divergent query id 를 함께 담아 yaml 수정 시 바로 찾을 수 있게 한다.
let mut offenders: BTreeSet<String> = BTreeSet::new();
for q in queries {
let Some(group) = q.group.as_deref() else {
continue;
};
let docs: BTreeSet<String> = q.expected_doc_ids.iter().map(|d| d.0.clone()).collect();
match canonical.get(group) {
None => {
canonical.insert(group, (docs, q.id.as_str()));
}
Some((expected, first)) if *expected != docs => {
offenders.insert(format!(
"group '{group}' (query '{}' differs from canonical '{first}')",
q.id
));
}
Some(_) => {}
}
}
if offenders.is_empty() {
Ok(())
} else {
let list: Vec<String> = offenders.into_iter().collect();
Err(anyhow!(
"same group must share one expected_doc_ids set, but found divergence — {}",
list.join("; ")
))
}
}
fn check_unique_ids(queries: &[GoldenQuery]) -> Result<()> {
let mut seen: HashSet<&str> = HashSet::new();
let mut dups: BTreeSet<String> = BTreeSet::new();
@@ -149,6 +190,42 @@ mod tests {
use std::fs;
use tempfile::tempdir;
#[test]
fn group_integrity_flags_only_divergent_member_in_3plus_group() {
// g1(docA) canonical, g2(docB) divergent, g3(docA) matches canonical.
// Only g2 is an offender; g3 must pass. Error names g2, not g3.
let tmp = tempdir().unwrap();
let yaml_path = tmp.path().join("golden.yaml");
fs::write(
&yaml_path,
"- id: g1\n query: a\n group: gr\n expected_doc_ids: [\"docA\"]\n\
- id: g2\n query: b\n group: gr\n expected_doc_ids: [\"docB\"]\n\
- id: g3\n query: c\n group: gr\n expected_doc_ids: [\"docA\"]\n",
)
.unwrap();
let err = load_golden_set(&yaml_path).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("'g2'"), "should name the divergent query g2: {msg}");
assert!(!msg.contains("'g3'"), "g3 matches canonical, must not be flagged: {msg}");
}
#[test]
fn ungrouped_queries_skip_group_integrity() {
// group=None entries mixed with a valid group must not interfere.
let tmp = tempdir().unwrap();
let yaml_path = tmp.path().join("golden.yaml");
fs::write(
&yaml_path,
"- id: solo1\n query: x\n expected_doc_ids: [\"docA\"]\n\
- id: g1\n query: a\n group: gr\n expected_doc_ids: [\"docB\"]\n\
- id: solo2\n query: y\n expected_doc_ids: [\"docC\"]\n",
)
.unwrap();
let qs = load_golden_set(&yaml_path).unwrap();
assert_eq!(qs.len(), 3);
assert!(qs[0].group.is_none());
}
#[test]
fn rejects_unknown_expected_chunk_id() {
let tmp = tempdir().unwrap();
@@ -194,6 +271,37 @@ mod tests {
assert_eq!(qs.len(), 1);
}
#[test]
fn rejects_group_with_divergent_expected_docs() {
let tmp = tempdir().unwrap();
let yaml_path = tmp.path().join("golden.yaml");
fs::write(
&yaml_path,
"- id: g1\n query: \"러스트 소유권\"\n group: ownership\n expected_doc_ids: [\"docA\"]\n\
- id: g2\n query: \"rust ownership\"\n group: ownership\n expected_doc_ids: [\"docB\"]\n",
)
.unwrap();
let err = load_golden_set(&yaml_path).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("group"), "msg: {msg}");
assert!(msg.contains("ownership"), "msg: {msg}");
}
#[test]
fn accepts_group_with_matching_expected_docs() {
let tmp = tempdir().unwrap();
let yaml_path = tmp.path().join("golden.yaml");
fs::write(
&yaml_path,
"- id: g1\n query: \"러스트 소유권\"\n group: ownership\n expected_doc_ids: [\"docA\"]\n\
- id: g2\n query: \"rust ownership\"\n group: ownership\n expected_doc_ids: [\"docA\"]\n",
)
.unwrap();
let qs = load_golden_set(&yaml_path).unwrap();
assert_eq!(qs.len(), 2);
assert_eq!(qs[0].group.as_deref(), Some("ownership"));
}
fn seed_one_chunk(store: &SqliteStore, doc_id: &str, chunk_id: &str) {
let conn = store.read_conn();
let asset_id = format!("a_{doc_id}");

View File

@@ -165,7 +165,7 @@ pub(crate) fn resolve_golden_path() -> PathBuf {
}
}
fn load_golden_for_metrics() -> Result<Vec<GoldenQuery>> {
pub(crate) fn load_golden_for_metrics() -> Result<Vec<GoldenQuery>> {
let path = resolve_golden_path();
load_golden_set(&path).with_context(|| {
format!(
@@ -456,6 +456,7 @@ mod tests {
must_contain: vec![],
forbidden: vec![],
difficulty: None,
group: None,
}
}

View File

@@ -67,7 +67,7 @@ pub fn run_eval_with_config(cfg: &kebab_config::Config, opts: &EvalRunOpts) -> R
.context("run migrations for run_eval")?;
// ── 3. Build config_snapshot_json ─────────────────────────────────────
let config_snapshot_json = build_config_snapshot(cfg)?;
let config_snapshot_json = build_config_snapshot(cfg, opts.k)?;
let config_snapshot_text =
serde_json::to_string(&config_snapshot_json).context("serialize config_snapshot_json")?;
@@ -215,10 +215,11 @@ fn execute_query(app: &App, gq: &GoldenQuery, opts: &EvalRunOpts) -> QueryResult
/// stable run-time property of the config alone. P5-2 may compose it
/// from `embedding.{model,version,dimensions}` if it needs the field
/// for compare reports.
fn build_config_snapshot(cfg: &kebab_config::Config) -> Result<serde_json::Value> {
fn build_config_snapshot(cfg: &kebab_config::Config, eval_k: usize) -> Result<serde_json::Value> {
let cfg_value = serde_json::to_value(cfg).context("serialize Config")?;
Ok(serde_json::json!({
"config": cfg_value,
"eval_k": eval_k,
"chunker_version": cfg.chunking.chunker_version,
"embedding": {
"model": cfg.models.embedding.model,

View File

@@ -26,6 +26,11 @@ pub struct GoldenQuery {
pub forbidden: Vec<String>,
#[serde(default)]
pub difficulty: Option<String>,
/// 같은 의미의 여러 표현(동의어·다른 어휘·풀어쓴 문장·한/영)을 묶는
/// 의도 그룹 id. 같은 그룹의 모든 변형은 동일한 `expected_doc_ids`(집합)를
/// 공유해야 한다(loader가 강제). `None`이면 단독 쿼리(기존 동작 불변).
#[serde(default)]
pub group: Option<String>,
}
fn default_lang() -> Lang {

View File

@@ -0,0 +1,530 @@
//! 변형(paraphrase) 일관성 진단 메트릭.
//!
//! 같은 의도(`GoldenQuery.group`)의 여러 표현이 같은 정답 문서를 공유한다는
//! 전제 아래, 표현마다 검색/답변 품질이 얼마나 출렁이는지를 잰다. 핵심은
//! `recall@narrow`(사용자가 보는 top-10) vs `recall@pool`(넓은 후보 폭)의 대비:
//!
//! - (A) 순위 출렁(`MisRanked`): 정답이 pool엔 있는데 top-10 밖 → near-tie 흡수로 해결 후보.
//! - (B) 어휘 격차(`Missing`): 정답이 pool에도 없음 → 쿼리 확장/번역 필요.
//!
//! 진단 전용. 기존 [`crate::metrics::AggregateMetrics`] 경로는 건드리지 않는다.
use std::collections::{BTreeMap, HashMap, HashSet};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use kebab_config::Config;
use kebab_core::DocumentId;
use kebab_store_sqlite::SqliteStore;
use crate::types::{GoldenQuery, QueryResult};
/// 사용자가 실제 보는 답변 context 폭.
const NARROW_K: u32 = 10;
/// 넓은 후보 폭. recall@pool vs recall@narrow 대비로 A/B를 가른다.
/// eval run은 `--k`를 이 값 이상으로 줘서 `hits_top_k`가 pool을 담아야 한다.
const POOL_K: u32 = 50;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum VariantClass {
/// recall@narrow == 1.0 (정답 전부 top-10 안).
Ok,
/// recall@pool > recall@narrow (정답이 pool엔 있는데 top-10 밖). (A)
MisRanked,
/// recall@pool == recall@narrow < 1.0 (못 찾은 정답이 pool에도 없음). (B)
Missing,
/// 정답 문서 미지정(검증 불가).
NoExpected,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct VariantResult {
pub query_id: String,
pub query: String,
pub recall_narrow: f32,
pub recall_pool: f32,
/// must_contain 통과 여부. RAG 답변(`--with-rag`)이 없으면 `None`.
pub answer_ok: Option<bool>,
pub class: VariantClass,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct VariantGroupReport {
pub group: String,
pub variants: Vec<VariantResult>,
/// max-min recall_narrow (정답 지정 변형들만). 0 = 완전 일관.
pub recall_spread_narrow: f32,
pub worst_recall_narrow: f32,
/// 모든 변형이 must_contain 통과면 Some(true), 하나라도 실패 Some(false),
/// RAG 답변이 전혀 없으면 None.
pub answer_consistency: Option<bool>,
pub mis_ranked: u32,
pub missing: u32,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct VariantConsistencyReport {
pub groups: Vec<VariantGroupReport>,
pub mean_recall_spread_narrow: f32,
/// spread==0 && worst_recall_narrow==1.0 인 그룹 수.
pub fully_consistent_groups: u32,
pub total_groups: u32,
/// mis_ranked>0 && mis_ranked>=missing 인 그룹 수 (near-tie 처방 우선).
pub a_dominant_groups: u32,
/// missing>0 && missing>mis_ranked 인 그룹 수 (쿼리 확장 처방 우선).
pub b_dominant_groups: u32,
/// 관찰된 최대 rank 가 POOL_K 미만일 때 true — eval run 의 --k 가
/// POOL_K 보다 작아 pool 이 절단됐을 수 있음. MisRanked(A) 판정 불가.
pub pool_possibly_truncated: bool,
}
/// 저장된 run을 그룹으로 묶어 변형 일관성 리포트를 만든다.
/// `rows`는 [`crate::metrics::aggregate_from_rows`]와 동일한 입력
/// (저장된 per-query 결과). `group`이 없는 쿼리는 무시한다.
pub fn compute_variant_consistency(
queries: &[GoldenQuery],
rows: &[kebab_store_sqlite::EvalQueryResultRecord],
) -> Result<VariantConsistencyReport> {
let golden_by_id: HashMap<&str, &GoldenQuery> =
queries.iter().map(|q| (q.id.as_str(), q)).collect();
let mut grouped: BTreeMap<String, Vec<VariantResult>> = BTreeMap::new();
let mut observed_max_rank: u32 = 0;
let mut has_hits = false;
for row in rows {
let qr: QueryResult = serde_json::from_str(&row.result_json)
.with_context(|| format!("parse result_json for {}", row.query_id))?;
for hit in &qr.hits_top_k {
has_hits = true;
observed_max_rank = observed_max_rank.max(hit.rank);
}
let Some(gq) = golden_by_id.get(qr.query_id.as_str()) else {
continue;
};
let Some(group) = gq.group.clone() else {
continue;
};
let (recall_narrow, recall_pool) = recall_narrow_pool(&qr, &gq.expected_doc_ids);
// Mirrors metrics.rs groundedness guards: skip errored rows and
// vacuous-true (no must_contain/forbidden configured).
let answer_ok = if qr.error.is_some()
|| (gq.must_contain.is_empty() && gq.forbidden.is_empty())
{
None
} else {
qr.answer.as_ref().map(|a| {
gq.must_contain.iter().all(|s| a.answer.contains(s))
&& !gq.forbidden.iter().any(|s| a.answer.contains(s))
})
};
let class = classify(&gq.expected_doc_ids, recall_narrow, recall_pool);
grouped.entry(group).or_default().push(VariantResult {
query_id: qr.query_id.clone(),
query: qr.query.clone(),
recall_narrow,
recall_pool,
answer_ok,
class,
});
}
let mut groups: Vec<VariantGroupReport> = Vec::with_capacity(grouped.len());
for (group, variants) in grouped {
groups.push(rollup_group(group, variants));
}
let total_groups = u32::try_from(groups.len()).unwrap_or(u32::MAX);
let fully_consistent_groups = groups
.iter()
.filter(|g| g.recall_spread_narrow == 0.0 && g.worst_recall_narrow == 1.0)
.count() as u32;
let a_dominant_groups = groups
.iter()
.filter(|g| g.mis_ranked > 0 && g.mis_ranked >= g.missing)
.count() as u32;
let b_dominant_groups = groups
.iter()
.filter(|g| g.missing > 0 && g.missing > g.mis_ranked)
.count() as u32;
let mean_recall_spread_narrow = if groups.is_empty() {
0.0
} else {
groups.iter().map(|g| g.recall_spread_narrow).sum::<f32>() / groups.len() as f32
};
let pool_possibly_truncated = has_hits && observed_max_rank < POOL_K;
Ok(VariantConsistencyReport {
groups,
mean_recall_spread_narrow,
fully_consistent_groups,
total_groups,
a_dominant_groups,
b_dominant_groups,
pool_possibly_truncated,
})
}
/// 정답 문서 집합에 대한 recall@NARROW_K, recall@POOL_K.
/// 정답 미지정이면 (NaN, NaN).
fn recall_narrow_pool(qr: &QueryResult, expected: &[DocumentId]) -> (f32, f32) {
if expected.is_empty() {
return (f32::NAN, f32::NAN);
}
let exp: HashSet<&DocumentId> = expected.iter().collect();
let cover = |k: u32| -> f32 {
let topk: HashSet<&DocumentId> = qr
.hits_top_k
.iter()
.filter(|h| h.rank <= k)
.map(|h| &h.doc_id)
.collect();
exp.iter().filter(|d| topk.contains(*d)).count() as f32 / exp.len() as f32
};
(cover(NARROW_K), cover(POOL_K))
}
// Single label per query: when multiple expected docs produce mixed classes (e.g. one
// MisRanked + one Missing), recall_pool > recall_narrow (A: MisRanked) takes priority.
fn classify(expected: &[DocumentId], recall_narrow: f32, recall_pool: f32) -> VariantClass {
if expected.is_empty() {
VariantClass::NoExpected
} else if recall_narrow >= 1.0 {
VariantClass::Ok
} else if recall_pool > recall_narrow {
VariantClass::MisRanked
} else {
VariantClass::Missing
}
}
fn rollup_group(group: String, variants: Vec<VariantResult>) -> VariantGroupReport {
let measurable: Vec<f32> = variants
.iter()
.filter(|v| !v.recall_narrow.is_nan())
.map(|v| v.recall_narrow)
.collect();
let (recall_spread_narrow, worst_recall_narrow) = if measurable.is_empty() {
// All variants have no expected docs: spread=0/worst=NaN is intentional.
// This group won't match fully_consistent (NaN != 1.0) or A/B (both 0) —
// it's counted in total_groups but sits in a silent "limbo" bucket.
(0.0, f32::NAN)
} else {
let max = measurable.iter().copied().fold(f32::MIN, f32::max);
let min = measurable.iter().copied().fold(f32::MAX, f32::min);
(max - min, min)
};
let answer_flags: Vec<bool> = variants.iter().filter_map(|v| v.answer_ok).collect();
let answer_consistency = if answer_flags.is_empty() {
None
} else {
Some(answer_flags.iter().all(|&ok| ok))
};
let mis_ranked = variants.iter().filter(|v| v.class == VariantClass::MisRanked).count() as u32;
let missing = variants.iter().filter(|v| v.class == VariantClass::Missing).count() as u32;
VariantGroupReport {
group,
variants,
recall_spread_narrow,
worst_recall_narrow,
answer_consistency,
mis_ranked,
missing,
}
}
/// 활성 XDG Config로 저장된 run을 읽어 변형 일관성을 계산
/// ([`crate::metrics::compute_aggregate_with_config`]와 동일한 로딩 패턴).
pub fn compute_variant_consistency_with_config(
cfg: &Config,
run_id: &str,
) -> Result<VariantConsistencyReport> {
let store = SqliteStore::open(cfg).context("open SqliteStore for variant consistency")?;
store.run_migrations().context("run migrations")?;
let run_record = store
.load_eval_run(run_id)
.context("load eval_runs row")?
.ok_or_else(|| {
anyhow::anyhow!("compute_variant_consistency: no eval_runs row for run_id {run_id}")
})?;
let snapshot: serde_json::Value =
serde_json::from_str(&run_record.config_snapshot_json).unwrap_or(serde_json::Value::Null);
if let Some(eval_k) = snapshot["eval_k"].as_u64() {
let eval_k = eval_k as u32;
if eval_k < POOL_K {
anyhow::bail!(
"variant consistency needs the run to retrieve >= {POOL_K} candidates, \
but run used k={eval_k}; re-run `kebab eval run --k {POOL_K}` (or higher)"
);
}
}
let rows = store
.load_eval_query_results(run_id)
.context("load eval_query_results")?;
let queries = crate::metrics::load_golden_for_metrics()?;
compute_variant_consistency(&queries, &rows)
}
/// 변형 일관성 리포트를 사람이 읽는 마크다운 표로 렌더
/// ([`crate::render_report_md`] 스타일).
pub fn render_variants_md(rep: &VariantConsistencyReport) -> String {
use std::fmt::Write;
let mut s = String::new();
let _ = writeln!(s, "# Variant consistency\n");
let _ = writeln!(
s,
"groups={} fully_consistent={} A_dominant={} B_dominant={} mean_spread@{}={:.3} pool=top-{}\n",
rep.total_groups,
rep.fully_consistent_groups,
rep.a_dominant_groups,
rep.b_dominant_groups,
NARROW_K,
rep.mean_recall_spread_narrow,
POOL_K,
);
if rep.pool_possibly_truncated {
let _ = writeln!(
s,
"WARNING: max observed rank < {POOL_K} — pool possibly truncated. \
MisRanked(A) diagnoses may be suppressed. Re-run `kebab eval run --k {POOL_K}` (or higher).\n"
);
}
for g in &rep.groups {
let ac = match g.answer_consistency {
Some(true) => "all-ok",
Some(false) => "MIXED",
None => "n/a",
};
let _ = writeln!(
s,
"## {} — spread@{}={:.2} worst={:.2} A={} B={} answers={}",
g.group, NARROW_K, g.recall_spread_narrow, g.worst_recall_narrow, g.mis_ranked, g.missing, ac
);
let _ = writeln!(s, "| variant | recall@{NARROW_K} | recall@{POOL_K} | class | answer |");
let _ = writeln!(s, "|---|---|---|---|---|");
for v in &g.variants {
let ans = match v.answer_ok {
Some(true) => "ok",
Some(false) => "BAD",
None => "-",
};
let _ = writeln!(
s,
"| {} | {:.2} | {:.2} | {:?} | {} |",
v.query, v.recall_narrow, v.recall_pool, v.class, ans
);
}
let _ = writeln!(s);
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use kebab_core::{
ChunkId, ChunkerVersion, Citation, IndexVersion, RetrievalDetail, ScoreKind, SearchMode,
WorkspacePath,
};
use kebab_store_sqlite::EvalQueryResultRecord;
fn hit(doc: &str, rank: u32) -> kebab_core::SearchHit {
let path = WorkspacePath::new(format!("{doc}.md")).unwrap();
kebab_core::SearchHit {
rank,
chunk_id: ChunkId(format!("c-{doc}-{rank}")),
doc_id: DocumentId(doc.to_string()),
doc_path: path.clone(),
heading_path: vec![],
section_label: None,
snippet: String::new(),
citation: Citation::Line { path, start: 1, end: 1, section: None },
retrieval: RetrievalDetail {
method: SearchMode::Vector,
fusion_score: 1.0 / rank as f32,
lexical_score: None,
vector_score: Some(1.0 / rank as f32),
lexical_rank: None,
vector_rank: Some(rank),
},
index_version: IndexVersion("v1".into()),
embedding_model: None,
chunker_version: ChunkerVersion("v1".into()),
indexed_at: time::OffsetDateTime::UNIX_EPOCH,
stale: false,
score_kind: ScoreKind::Cosine,
repo: None,
code_lang: None,
}
}
fn gq(id: &str, group: &str, expected_doc: &str) -> GoldenQuery {
GoldenQuery {
id: id.into(),
query: id.into(),
lang: kebab_core::Lang(String::new()),
expected_doc_ids: vec![DocumentId(expected_doc.into())],
expected_chunk_ids: vec![],
must_contain: vec![],
forbidden: vec![],
difficulty: None,
group: Some(group.into()),
}
}
fn row(query_id: &str, hits: Vec<kebab_core::SearchHit>) -> EvalQueryResultRecord {
let qr = QueryResult {
query_id: query_id.into(),
query: query_id.into(),
mode: SearchMode::Vector,
hits_top_k: hits,
answer: None,
elapsed_ms: 0,
error: None,
};
EvalQueryResultRecord {
query_id: query_id.into(),
result_json: serde_json::to_string(&qr).unwrap(),
}
}
#[test]
fn classifies_mis_ranked_vs_missing_and_spread() {
// group "g": 정답 docX.
// v1: docX at rank 3 → narrow=1.0 → Ok
// v2: docX at rank 25 → narrow=0.0, pool=1.0 → MisRanked (A)
// v3: docX 없음 → narrow=0.0, pool=0.0 → Missing (B)
let queries = vec![gq("v1", "g", "docX"), gq("v2", "g", "docX"), gq("v3", "g", "docX")];
let rows = vec![
row("v1", vec![hit("docX", 3)]),
row("v2", vec![hit("docX", 25)]),
row("v3", vec![hit("other", 1)]),
];
let rep = compute_variant_consistency(&queries, &rows).unwrap();
assert_eq!(rep.total_groups, 1);
let g = &rep.groups[0];
assert_eq!(g.group, "g");
assert_eq!(g.variants.len(), 3);
// spread = max(1.0) - min(0.0) = 1.0
assert!((g.recall_spread_narrow - 1.0).abs() < 1e-6);
assert!((g.worst_recall_narrow - 0.0).abs() < 1e-6);
assert_eq!(g.mis_ranked, 1);
assert_eq!(g.missing, 1);
let classes: Vec<VariantClass> = g.variants.iter().map(|v| v.class).collect();
assert!(classes.contains(&VariantClass::Ok));
assert!(classes.contains(&VariantClass::MisRanked));
assert!(classes.contains(&VariantClass::Missing));
assert_eq!(rep.a_dominant_groups + rep.b_dominant_groups, 1); // tie→정의대로 하나로 분류
}
#[test]
fn fully_consistent_group_when_all_ok() {
let queries = vec![gq("v1", "g", "docX"), gq("v2", "g", "docX")];
let rows = vec![row("v1", vec![hit("docX", 1)]), row("v2", vec![hit("docX", 2)])];
let rep = compute_variant_consistency(&queries, &rows).unwrap();
assert_eq!(rep.fully_consistent_groups, 1);
assert!((rep.groups[0].recall_spread_narrow - 0.0).abs() < 1e-6);
}
#[test]
fn ungrouped_queries_are_ignored() {
let mut q = gq("solo", "g", "docX");
q.group = None;
let rep = compute_variant_consistency(&[q], &[row("solo", vec![hit("docX", 1)])]).unwrap();
assert_eq!(rep.total_groups, 0);
}
fn row_with_answer(
query_id: &str,
hits: Vec<kebab_core::SearchHit>,
answer_text: &str,
error: Option<&str>,
) -> EvalQueryResultRecord {
let hits_json = serde_json::to_value(&hits).unwrap();
let error_json =
error.map_or(serde_json::Value::Null, |e| serde_json::Value::String(e.into()));
let qr_json = serde_json::json!({
"query_id": query_id,
"query": query_id,
"mode": "vector",
"hits_top_k": hits_json,
"answer": {
"answer": answer_text,
"citations": [],
"grounded": false,
"refusal_reason": null,
"model": {"id": "test-model", "provider": "test", "dimensions": null},
"embedding": null,
"prompt_template_version": "v1",
"retrieval": {
"trace_id": "t0",
"mode": "vector",
"k": 10,
"score_gate": 0.0,
"top_score": 0.0,
"chunks_returned": 0,
"chunks_used": 0
},
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "latency_ms": 0},
"created_at": "1970-01-01T00:00:00Z"
},
"elapsed_ms": 0,
"error": error_json
});
EvalQueryResultRecord {
query_id: query_id.into(),
result_json: serde_json::to_string(&qr_json).unwrap(),
}
}
/// H1 회귀: eval k=10 으로 실행 시 모든 hit rank ≤ NARROW_K →
/// pool_possibly_truncated 플래그로 사용자에게 경고해야 한다.
#[test]
fn pool_truncation_flag_when_all_hits_within_narrow_k() {
let queries = vec![gq("v1", "g", "docX"), gq("v2", "g", "docX")];
let rows = vec![
row("v1", vec![hit("docX", 1)]),
row("v2", vec![hit("other", 7)]), // rank 7 ≤ NARROW_K=10
];
let rep = compute_variant_consistency(&queries, &rows).unwrap();
assert!(rep.pool_possibly_truncated, "all ranks ≤ NARROW_K must set pool_possibly_truncated");
// v2 misses docX, pool also has no rank>10 → classified Missing, not MisRanked
assert_eq!(rep.a_dominant_groups, 0);
assert_eq!(rep.b_dominant_groups, 1);
}
/// M1a: must_contain/forbidden 둘 다 빈 golden → vacuous-true 방지,
/// answer_ok = None (answer 있어도).
/// M1b: qr.error=Some → answer 있어도 answer_ok = None.
#[test]
fn answer_ok_vacuous_and_error_guarded() {
// M1a: gq() helper already has empty must_contain + forbidden
let gq_no_check = gq("v1", "g1", "docX");
let row_v1 = row_with_answer("v1", vec![], "any text", None);
let rep = compute_variant_consistency(&[gq_no_check], &[row_v1]).unwrap();
let v = &rep.groups[0].variants[0];
assert_eq!(v.answer_ok, None, "vacuous-true guard: no checks → answer_ok = None");
assert_eq!(rep.groups[0].answer_consistency, None);
// M1b: must_contain present but error is also set
let mut gq_check = gq("v2", "g2", "docY");
gq_check.must_contain = vec!["expected text".to_string()];
let row_v2 = row_with_answer("v2", vec![], "expected text", Some("llm error"));
let rep2 = compute_variant_consistency(&[gq_check], &[row_v2]).unwrap();
let v2 = &rep2.groups[0].variants[0];
assert_eq!(v2.answer_ok, None, "error guard: qr.error present → answer_ok = None");
}
/// N1 순수 B: 두 변형 모두 pool 에서도 정답 없음 → b_dominant=1, a_dominant=0.
#[test]
fn pure_b_dominant_group() {
let queries = vec![gq("v1", "g", "docX"), gq("v2", "g", "docX")];
let rows = vec![
row("v1", vec![hit("other1", 1)]), // docX 없음 → Missing (B)
row("v2", vec![hit("other2", 1)]), // docX 없음 → Missing (B)
];
let rep = compute_variant_consistency(&queries, &rows).unwrap();
assert_eq!(rep.b_dominant_groups, 1);
assert_eq!(rep.a_dominant_groups, 0);
}
}

View File

@@ -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<Self> {
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 {

View File

@@ -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<SearchHit> = 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<String> {
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<String> {
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
@@ -242,7 +274,7 @@ fn build_match_string(text: &str) -> Option<String> {
(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<RawRow> {
})
}
/// 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<Vec<RawRow>> {
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<Box<dyn ToSql>> = 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<RawRow> = 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<RawRow>, alias: Vec<RawRow>, limit: usize) -> Vec<RawRow> {
use std::collections::HashSet;
let mut seen: HashSet<String> = 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(

View File

@@ -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<SearchHit> = Vec::with_capacity(k.min(raw_hits.len()));
let mut seen: std::collections::HashSet<String> = 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,

View File

@@ -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::<Vec<_>>()
);
}
/// 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 빈 상태에서 본문 매칭 청크가 정상 회수돼야 한다 (회귀 안전)"
);
}

View File

@@ -0,0 +1,192 @@
//! Content-hash derivation cache store (design 2026-05-31 §3.2 / §3.5).
//!
//! Backs the `derivation_cache` table (`V012`). The cache stores expensive
//! ingest derivations (embedding vectors, LLM aliases, optional Korean
//! tokens) keyed by `derivation_cache_key` (§3.1). It is a pure performance
//! layer: corruption / deletion only forces recomputation, never wrong
//! results (§3.5). Timestamps follow the same RFC3339 `OffsetDateTime`
//! formatting the asset / document / embedding writers use.
use anyhow::{Context, Result};
use rusqlite::{OptionalExtension, params};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use crate::error::StoreError;
use crate::store::SqliteStore;
impl SqliteStore {
/// Look up a cached derivation payload by its content-hash key.
///
/// Pure read — does **not** bump `last_used_at`. Callers that want LRU
/// freshness on a hit collect the hit keys and call [`Self::touch`] once
/// per batch (cheaper than a write per `get`).
pub fn derivation_cache_get(&self, cache_key: &str) -> Result<Option<Vec<u8>>> {
let conn = self.lock_conn();
let payload: Option<Vec<u8>> = conn
.query_row(
"SELECT payload FROM derivation_cache WHERE cache_key = ?",
params![cache_key],
|row| row.get::<_, Vec<u8>>(0),
)
.optional()
.map_err(StoreError::from)
.context("derivation_cache_get")?;
Ok(payload)
}
/// Insert (or overwrite) a cached derivation payload.
///
/// `INSERT OR REPLACE` so a re-computation of the same key (e.g. after a
/// manual cache clear, or a non-deterministic LLM regenerating) refreshes
/// `created_at` / `last_used_at` to the new attempt. The key already folds
/// every version-cascade input (§3.1), so an overwrite is always the same
/// logical derivation.
pub fn derivation_cache_put(&self, cache_key: &str, kind: &str, payload: &[u8]) -> Result<()> {
let now = OffsetDateTime::now_utc()
.format(&Rfc3339)
.context("format derivation_cache.created_at")?;
let conn = self.lock_conn();
conn.execute(
"INSERT OR REPLACE INTO derivation_cache
(cache_key, kind, payload, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?)",
params![cache_key, kind, payload, now, now],
)
.map_err(StoreError::from)
.context("derivation_cache_put")?;
Ok(())
}
/// Bump `last_used_at` for the given hit keys (LRU freshness, §3.5).
///
/// Run in a single transaction. Missing keys are a no-op. Called once per
/// ingest batch with the keys that hit, so the GC pass keeps live chunks.
pub fn derivation_cache_touch(&self, keys: &[String]) -> Result<()> {
if keys.is_empty() {
return Ok(());
}
let now = OffsetDateTime::now_utc()
.format(&Rfc3339)
.context("format derivation_cache.last_used_at")?;
let mut conn = self.lock_conn();
let tx = conn.transaction().map_err(StoreError::from)?;
{
let mut stmt = tx
.prepare("UPDATE derivation_cache SET last_used_at = ? WHERE cache_key = ?")
.map_err(StoreError::from)?;
for key in keys {
stmt.execute(params![now, key])
.map_err(StoreError::from)
.context("derivation_cache_touch")?;
}
}
tx.commit().map_err(StoreError::from)?;
Ok(())
}
/// Delete cache entries whose `last_used_at` is older than `ttl_days`
/// (§3.5 lightweight GC). Returns the number of rows removed.
///
/// `ttl_days <= 0` is a no-op guard (never wipe the whole cache by an
/// accidental zero TTL).
pub fn derivation_cache_gc(&self, ttl_days: i64) -> Result<usize> {
if ttl_days <= 0 {
return Ok(0);
}
let cutoff = (OffsetDateTime::now_utc() - time::Duration::days(ttl_days))
.format(&Rfc3339)
.context("format derivation_cache gc cutoff")?;
let conn = self.lock_conn();
let removed = conn
.execute(
"DELETE FROM derivation_cache WHERE last_used_at < ?",
params![cutoff],
)
.map_err(StoreError::from)
.context("derivation_cache_gc")?;
Ok(removed)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::SqliteStore;
fn open_store() -> (tempfile::TempDir, SqliteStore) {
let dir = tempfile::tempdir().unwrap();
let mut cfg = kebab_config::Config::defaults();
cfg.storage.data_dir = dir.path().to_string_lossy().into_owned();
let store = SqliteStore::open(&cfg).unwrap();
store.run_migrations().unwrap();
(dir, store)
}
#[test]
fn put_then_get_roundtrips() {
let (_d, store) = open_store();
store
.derivation_cache_put("key1", "embedding", &[1, 2, 3, 4])
.unwrap();
let got = store.derivation_cache_get("key1").unwrap();
assert_eq!(got, Some(vec![1, 2, 3, 4]));
}
#[test]
fn get_miss_returns_none() {
let (_d, store) = open_store();
assert_eq!(store.derivation_cache_get("absent").unwrap(), None);
}
#[test]
fn put_replaces_existing() {
let (_d, store) = open_store();
store.derivation_cache_put("k", "alias", b"old").unwrap();
store.derivation_cache_put("k", "alias", b"new").unwrap();
assert_eq!(
store.derivation_cache_get("k").unwrap(),
Some(b"new".to_vec())
);
}
#[test]
fn touch_missing_keys_is_noop() {
let (_d, store) = open_store();
store
.derivation_cache_touch(&["nope".to_string()])
.unwrap();
assert_eq!(store.derivation_cache_get("nope").unwrap(), None);
}
#[test]
fn gc_zero_ttl_is_noop() {
let (_d, store) = open_store();
store.derivation_cache_put("k", "embedding", b"x").unwrap();
assert_eq!(store.derivation_cache_gc(0).unwrap(), 0);
assert!(store.derivation_cache_get("k").unwrap().is_some());
}
#[test]
fn gc_removes_stale_entries() {
let (_d, store) = open_store();
store.derivation_cache_put("fresh", "embedding", b"x").unwrap();
// Backdate one row by 100 days via a direct UPDATE.
let old = (OffsetDateTime::now_utc() - time::Duration::days(100))
.format(&Rfc3339)
.unwrap();
{
let conn = store.lock_conn();
conn.execute(
"INSERT INTO derivation_cache (cache_key, kind, payload, created_at, last_used_at)
VALUES ('stale', 'embedding', ?, ?, ?)",
params![&b"y"[..], &old, &old],
)
.unwrap();
}
let removed = store.derivation_cache_gc(30).unwrap();
assert_eq!(removed, 1);
assert!(store.derivation_cache_get("stale").unwrap().is_none());
assert!(store.derivation_cache_get("fresh").unwrap().is_some());
}
}

View File

@@ -98,6 +98,23 @@ 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 임베딩 레코드를 명시 정리.
// 원본 + 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) \
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)?;
tx.execute("DELETE FROM chunks WHERE doc_id = ?", params![doc.0])
.map_err(StoreError::from)?;
let mut stmt = tx
@@ -106,8 +123,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 +153,7 @@ impl kebab_core::DocumentStore for SqliteStore {
block_ids,
now,
chunk.tokenized_korean_text.as_deref(),
chunk.aliases.as_deref(),
])
.map_err(StoreError::from)?;
}
@@ -250,6 +268,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,
}))
}

View File

@@ -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<String> = {
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();

View File

@@ -19,6 +19,7 @@
mod answers;
mod chat_sessions;
mod derivation_cache;
mod documents;
mod embeddings;
mod error;

View File

@@ -570,6 +570,24 @@ impl SqliteStore {
keep_doc_id: &str,
) -> Result<()> {
let conn = self.lock_conn();
// CASCADE 제거(V011) 대체: documents→chunks CASCADE 가 chunks 를 지우기 전에
// 원본 + 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)) \
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)?;
conn.execute(
"DELETE FROM documents WHERE workspace_path = ?1 AND doc_id != ?2",
params![workspace_path, keep_doc_id],
@@ -627,7 +645,24 @@ pub(crate) fn purge_orphan_at_workspace_path(
return Ok(());
};
// documents → blocks / chunks / embedding_records via CASCADE.
// CASCADE 제거(V011) 대체: 이 asset 의 문서 chunk 임베딩 레코드를 명시 정리.
// 원본 + 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)) \
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)?;
// documents → blocks / chunks via CASCADE.
conn.execute(
"DELETE FROM documents WHERE asset_id = ?",
params![stale_asset_id],
@@ -706,8 +741,24 @@ 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 임베딩 레코드를 명시 정리(원본 +
// 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) \
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)?;
// 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],

View File

@@ -0,0 +1,220 @@
//! 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<String>) -> 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 에 행이 없어야 한다"
);
}
/// 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 에 색인되면 안 된다");
}

View File

@@ -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 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(), 1);
assert_eq!(store.corpus_revision(), 3);
}
/// Each `bump_corpus_revision` returns the new value monotonically
/// from the post-migration baseline.
/// 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(), 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.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(), 3);
assert_eq!(store.bump_corpus_revision().unwrap(), 4);
assert_eq!(store.corpus_revision(), 5);
assert_eq!(store.bump_corpus_revision().unwrap(), 6);
}

View File

@@ -0,0 +1,340 @@
//! 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()
}
/// 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]
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)"
);
}
/// 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)"
);
}
/// 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)"
);
}

View File

@@ -98,6 +98,7 @@ fn make_chunks(doc_id: &DocumentId) -> Vec<Chunk> {
chunker_version: ChunkerVersion("md-heading-v1".into()),
policy_hash: "deadbeefdeadbeef".into(),
tokenized_korean_text: None,
aliases: None,
}]
}

View File

@@ -154,7 +154,14 @@ fn apply_event(state: &mut IngestState, event: IngestEvent) {
}
// v0.20.0 sub-item 1: per-page PDF OCR events — TUI does not
// surface per-page OCR progress in v1; no counter to update.
IngestEvent::PdfOcrStarted { .. } | IngestEvent::PdfOcrFinished { .. } => {}
IngestEvent::PdfOcrStarted { .. }
| IngestEvent::PdfOcrFinished { .. }
// v0.24.0 asset-internal phase events: the status-bar reducer tracks
// per-asset counters, not sub-asset phase progress, so these are
// no-ops here (the CLI / --json surfaces render them).
| IngestEvent::AssetChunked { .. }
| IngestEvent::ExpansionProgress { .. }
| IngestEvent::AssetTimings { .. } => {}
}
}

View File

@@ -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,
}
}

View File

@@ -15,7 +15,7 @@ Cargo workspace, 함수 호출 기반 모듈러 모놀리스. UI binary (`kebab-
| 원본 저장 | filesystem + blake3 content-addressable copy (대용량은 reference + checksum) |
| metadata | SQLite + FTS5 (lexical search + v0.20.1 한국어 형태소 tokenizer via lindera-ko-dic) |
| vector | LanceDB (embedded, model 별 분리 table) |
| Markdown parser | `pulldown-cmark` |
| Markdown parser | `pulldown-cmark`. frontmatter 에 title 없으면 첫 H1 → H2 → 첫 paragraph 80 자 → 파일명 순으로 자동 채움 (`parser_version = md-frontmatter-v2`, 기존 doc 도 다음 ingest 에서 갱신) |
| embedding | `fastembed-rs` (`multilingual-e5-large`, 1024d, v0.18.0부터 default 업그레이드) |
| 한국어 형태소분석 | `lindera-ko-dic` (FTS5 외부 tokenizer, v0.20.1) — 2자 이상 한국어 query 지원 |
| LLM | Ollama HTTP (default `gemma4:e4b` ─ OCR / caption 와 family 통일. 사용자가 더 큰 variant `gemma4:26b` 등으로 override 가능) |
@@ -23,14 +23,17 @@ Cargo workspace, 함수 호출 기반 모듈러 모놀리스. UI binary (`kebab-
| OCR (image) | Ollama vision LM (default `gemma4:e4b`) — `OcrEngine` trait 으로 Tesseract / Apple Vision 등 future swap (HOTFIXES P6-2) |
| OCR (PDF, v0.20.0+) | Ollama vision LM (default `qwen2.5vl:3b`) — post-extract enrichment via `kebab-app::pdf_ocr_apply` (H-1 resolution). DCTDecode-only v1 (FlateDecode/CCITTFax skip + warning). family asymmetry vs image OCR: PoC alnum 94.79% (qwen2.5vl) >> 27% (gemma4:e4b 받침), 본 단계에서 PDF OCR 만 qwen2.5vl. |
| Image caption | Ollama vision LM, runtime gate `image.caption.enabled` (default OFF) |
| RAG groundedness 검증 | `kebab-nli` 의 mDeBERTa-v3 XNLI 가 `(packed_chunks, generated_answer)` entailment 검사 (fb-41). `[rag] nli_threshold > 0` (default 0 = disabled, production 권장 0.5) 일 때 활성 — 미달 시 `refusal_reason = nli_verification_failed` (LLM self-judge ceiling 보완). 첫 호출 시 ~280 MB ONNX 자동 다운로드 |
| PDF parser | `lopdf` per-page 텍스트 + scanned-page image extract (`page_image::extract_dctdecode_page_image`, v0.20.0). `chunker_version = "pdf-page-v1"` 하드코딩 (HOTFIXES P7-3). `parser_version = "pdf-text-v1"` 보존 (v0.20 OCR 후에도) — provenance event 로 OCR 사용 차별화. force-reingest 가 v0.19 indexed scanned PDF 의 재처리에 필요. |
| code parser | `tree-sitter` + `tree-sitter-rust` / `tree-sitter-python` / `tree-sitter-typescript` / `tree-sitter-javascript` / `tree-sitter-go` / `tree-sitter-java` / `tree-sitter-kotlin-ng`**parser-side** (`kebab-parse-code`), chunker-side 아님 (design §6.3). chunker versions: Rust = `code-rust-ast-v1`, Python = `code-python-ast-v1`, TypeScript = `code-ts-ast-v1`, JavaScript = `code-js-ast-v1`, Go = `code-go-ast-v1`, Java = `code-java-ast-v1`, Kotlin = `code-kotlin-ast-v1`. `ast_chunk_max_lines = 200` 상수 고정 (HOTFIXES 2026-05-19 — Chunker trait 이 per-medium config 미노출). Kotlin grammar 은 `tree-sitter-kotlin-ng` 사용 — bare `tree-sitter-kotlin` 은 tree-sitter 0.210.23 에 고착되어 있어 사용 불가. **Tier 2 (p10-2)**: YAML/k8s → `serde_yaml` + `k8s-manifest-resource-v1` (apiVersion+kind per resource), Dockerfile → `dockerfile-file-v1` (whole-file), Cargo.toml/go.mod/.json/.xml/.groovy → `manifest-file-v1` (whole-file). Tier 2 chunkers live in `kebab-chunk`; no tree-sitter grammar needed (structure from file type, not AST). **Tier 3 (p10-3)**: shell scripts (`.sh`/`.bash`/`.zsh`) direct → `code-text-paragraph-v1` (blank-line paragraph segmentation + 80-line / 20-overlap line-window for oversize). Same chunker also serves as fallback when Tier 1/2 emit 0 chunks or Err — non-k8s YAML / invalid YAML / AST extractor failures all picked up. symbol = None; lang preserved from input doc. **Tier 1 family complete (p10-1D)**: C (`tree-sitter-c`, `code-c-ast-v1`, `.c`/`.h`) + C++ (`tree-sitter-cpp`, `code-cpp-ast-v1`, `.cpp`/`.cc`/`.cxx`/`.hpp`/`.hh`/`.hxx`). C symbol = function name only; C++ symbol = `namespace::Class::method` (recursive nesting). `.h` 가 C++ syntax 만나면 tree-sitter-c parse 실패 → Tier 3 fallback. |
| 1B symbol path | workspace path → module path: Python = dotted prefix (`kebab_eval.metrics.compute_mrr`), TypeScript/JavaScript = slash-style prefix (`src/Foo.Foo.search`). Rust 1A-2 는 file-scope nesting 만 (workspace prefix 없음, 비일관 수용 — HOTFIXES 2026-05-20). |
| TUI | Ratatui + crossterm — P9-1 Library 패널, P9-2/3/4 진행 예정 |
| symbol path 형식 | workspace path → module path: Python = dotted prefix (`kebab_eval.metrics.compute_mrr`), TypeScript/JavaScript = slash-style prefix (`src/Foo.Foo.search`), Go = `package.Func` / `package.(*Receiver).Method`, Java/Kotlin = `com.foo.Foo.bar` (패키지+클래스+메서드/필드), C = 함수명, C++ = `namespace::Class::method`. Rust 1A-2 는 file-scope nesting 만 (workspace prefix 없음, 비일관 수용 — HOTFIXES 2026-05-20). code chunk 은 `citation.kind = "code"` + `citation.lang` + `symbol` + line range, SearchHit 에 `code_lang` + `repo`(`.git` walk-up 디렉토리명) backfill. |
| TUI | Ratatui + crossterm — Library / Search / Ask / Inspect 패널 (P9-1~4 완료), vim-style NORMAL/INSERT 모드 + `F1` cheatsheet (런타임 키 매핑 권위 소스) |
| Desktop | Tauri 2 + `pdfjs-dist` (native PDF render backend 금지) — P9-5 |
| 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 참조.
@@ -63,7 +66,8 @@ flowchart TB
end
subgraph Adapters ["traits + adapters"]
embed["kebab-embed<br/>(trait)"]
embedlocal["kebab-embed-local<br/>(fastembed)"]
embedlocal["kebab-embed-local<br/>(fastembed, default)"]
embedcandle["kebab-embed-candle<br/>(candle, NUMA-safe opt-in)"]
llm["kebab-llm<br/>(trait)"]
llmlocal["kebab-llm-local<br/>(Ollama)"]
search["kebab-search"]
@@ -89,6 +93,7 @@ flowchart TB
app --> sqlite
app --> vector
app --> embedlocal
app --> embedcandle
app --> llmlocal
app --> search
app --> rag
@@ -101,6 +106,8 @@ flowchart TB
paud --> core
pcode --> core
embedlocal --> embed
embedcandle --> core
embedcandle --> config
llmlocal --> llm
rag --> search
rag --> llm
@@ -161,7 +168,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)
@@ -174,9 +181,10 @@ 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-embed-candle/ # candle (pure-Rust) Embedder, NUMA-safe opt-in provider=candle (Track 1, v0.22.0)
│ ├── kebab-store-vector/ # LanceDB VectorStore (P3-3, P7-3 follow-up)
│ ├── kebab-llm/ kebab-llm-local/ # LanguageModel trait + Ollama adapter (P4-1, P4-2)
│ ├── kebab-rag/ # RAG pipeline (P4-3)
@@ -185,11 +193,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 트리
```

View File

@@ -683,6 +683,20 @@ ajv-cli validate -s docs/wire-schema/v1/<schema>.schema.json -d <output>
---
### config migrate (스키마 마이그레이션, v0.21.1)
```bash
# 옛 스키마 흉내(섹션 누락 + deprecated) 후 migrate.
printf 'schema_version = 1\n\n[workspace]\nroot = "~/MyNotes"\ninclude = ["*.md"]\n\n[search]\ndefault_k = 25\n' \
> "$DOGFOOD/old.toml"
"$RELEASE_BIN" --config "$DOGFOOD/old.toml" config migrate --dry-run # 미리보기, 파일 미수정
"$RELEASE_BIN" --config "$DOGFOOD/old.toml" config migrate # .bak + 빠진 섹션 주석과 함께 추가
"$RELEASE_BIN" --config "$DOGFOOD/old.toml" config migrate # 멱등
"$RELEASE_BIN" --config "$DOGFOOD/old.toml" doctor | grep config_migration # ok 확인
```
기대: dry-run 파일 미수정 → apply 시 `old.toml.bak`(원본 byte-identical) + `[ingest.expansion]`·`[logging]`·`[pdf.ocr]` 가시화 + 손본 `default_k`/주석 보존 + `workspace.include` 제거 → 재실행 멱등 → doctor `config_migration` ok. v0.21.1 evidence 는 `tasks/HOTFIXES.md` 2026-05-31.
## §10 Eval (P5)
### §10.1 Basic eval run

View File

@@ -107,11 +107,16 @@ respect_markdown_headings = true
chunker_version = "md-heading-v1"
[models.embedding]
provider = "fastembed" # "none" 으로 두면 lexical-only — Ollama 불필요
provider = "fastembed" # "fastembed"(기본) / "candle"(순수 Rust, NUMA-안전)
# / "none"(lexical-only — Ollama 불필요)
# ⚠ provider="candle" 사용 시 아래 model/dimensions 도
# multilingual-e5-large / 1024 로 바꿔야 함
# (candle 은 현재 e5-large 만 지원).
model = "multilingual-e5-small"
version = "v1"
dimensions = 384
batch_size = 64
num_threads = 0 # candle 전용 CPU 스레드 캡 (0=auto). env KEBAB_EMBED_THREADS 우선.
[models.llm]
provider = "ollama"
@@ -690,6 +695,20 @@ KB --json schema | jq '.stats.code_lang_breakdown'
- OCR / caption 부분 실패는 `errors` 카운터 미증가 — `kebab inspect doc <id>` 의 Provenance Warning 이벤트 또는 `--debug` 로그에서만 확인.
- (P7-3) `*.pdf` 자산을 워크스페이스에 두면 `kebab ingest` 출력에 PDF 도 `new` 카운터에 포함. `kebab inspect doc <pdf_doc_id>` 가 `parser_version = "pdf-text-v1"` + 페이지마다 `Block::Paragraph` + `SourceSpan::Page { page, char_start, char_end }`. 본문에 등장하는 단어로 `kebab search --mode hybrid` 시 PDF chunk 가 결과에 포함되고 `source_span.kind = "page"` 면 wiring 정상. 암호화 PDF 는 `errors+=1` 로 분류되며 `error` 필드에 `qpdf --decrypt` 안내 보존. 빈/스캔 페이지 (PDF 가 텍스트를 추출하지 못한 페이지) 는 0 chunk + `Provenance::Warning` ("scanned candidate") 로 표시 — P+ scanned-PDF OCR fallback 까지는 검색 불가.
## config migrate (마이그레이션)
```bash
# 옛 스키마처럼 섹션이 빠진 config 를 흉내내 migrate 동작 확인.
printf 'schema_version = 1\n\n[workspace]\nroot = "/tmp/kb"\ninclude = ["*.md"]\n' \
> /tmp/kebab-smoke/old.toml
kebab --config /tmp/kebab-smoke/old.toml config migrate --dry-run # 변경 미리보기 (파일 미수정)
kebab --config /tmp/kebab-smoke/old.toml config migrate # 적용 (.bak 백업)
kebab --config /tmp/kebab-smoke/old.toml config migrate # 멱등: "config 이미 최신입니다"
kebab --config /tmp/kebab-smoke/old.toml --json config migrate --dry-run | jq .schema_version
```
기대: dry-run 은 추가될 섹션(`[ingest.expansion]`·`[logging]` 등)과 제거될 `workspace.include` 를 출력하고 **파일을 수정하지 않는다**. 적용 시 `old.toml.bak`(원본과 동일)이 생기고 빠진 섹션이 주석과 함께 추가되며 사용자가 손본 값·주석은 보존된다. 재실행은 멱등(`config 이미 최신입니다`), `--json` 은 `config_migration.v1`.
## 정리
```bash

View File

@@ -0,0 +1,97 @@
---
title: kebab v0.22.0 release notes (draft)
created: 2026-06-01
status: draft
release_trigger:
- 신규 config surface (provider=candle, num_threads / KEBAB_EMBED_THREADS) — pre-1.0 minor bump
- 임베딩 백엔드 다변화 (NUMA-안전 candle provider 추가, opt-in)
---
# kebab v0.22.0 — candle 임베딩 provider (NUMA-안전, opt-in)
v0.21.1 (config 마이그레이션) 후속 minor release. 듀얼소켓 NUMA 서버에서
onnxruntime 의 스레드 하드코딩이 일으키던 ingest 크래시를 피하기 위해, 같은
임베딩 모델을 **순수 Rust(candle)** 로 돌리는 opt-in provider 를 추가한다.
**기본 동작은 그대로다** — 기존 사용자는 아무것도 바꿀 필요가 없다.
---
## 핵심 변경
### candle 임베딩 provider (`provider = "candle"`)
**변경 사실.** `[models.embedding].provider``"candle"` 값이 추가됐다.
`"fastembed"`(기본, onnxruntime) / `"candle"`(순수 Rust) / `"none"`(lexical-only)
중 하나를 고를 수 있다. candle provider 는 fastembed 와 **완전히 같은 모델**
(`intfloat/multilingual-e5-large`, 1024-dim)을 쓰고, e5 prefix → mean pooling
→ L2 정규화 파이프라인도 동일하다. 첫 사용 시 safetensors(~2GB)를
`{model_dir}/candle/` 아래로 자동 다운로드한다.
```toml
[models.embedding]
provider = "candle" # 기본은 "fastembed" — NUMA 서버에서만 candle 권장
num_threads = 8 # candle CPU 스레드 캡 (0 = auto = #cores)
```
```bash
# env 로도 캡 가능 (config 보다 우선)
KEBAB_EMBED_THREADS=8 kebab ingest
```
**Trade-off.** candle 는 순수 Rust 라 onnxruntime 의 네이티브 SIMD 커널보다
CPU latency 가 느리다 (Phase 0 스파이크 측정 ~4×). 그래서 **기본값은
fastembed 를 유지**하고, candle 은 onnxruntime 가 죽는 NUMA 환경에서만 켜는
opt-in 으로 둔다. 단일 워크스테이션 사용자는 fastembed 가 더 빠르다.
**Mitigation (왜 안전한가).** candle 의 CPU 백엔드는 글로벌 rayon 풀 크기로
스레드를 정한다. `num_threads`(또는 env `KEBAB_EMBED_THREADS`)가 그 풀을 한 번
캡하므로, onnxruntime 가 하드코딩하던 48 intra-op 스레드 → NUMA 힙 손상 →
double-free 경로를 원천 차단한다. NUMA 노드 바인딩이 더 필요하면 `numactl`
과 조합한다.
**Upgrade 절차.** 재색인 **불필요**. candle 과 fastembed 의 벡터는 사실상
동일(Phase 0 스파이크 코사인 1.000000)해서 `embedding_version` 을 유지했고,
기존 LanceDB 색인을 그대로 재사용한다. provider 를 바꿔도 검색 결과는
바뀌지 않는다. 기존 `config.toml``num_threads` 가 자동으로 `0`(auto)으로
채워져 그대로 로드된다 — `kebab config migrate` 도, 수동 편집도 필요 없다.
---
## 그 외
- 신규 crate `kebab-embed-candle` (candle 의존성 트리를 이 crate 에 격리,
`kebab-core`/`kebab-config` 외 다른 kebab-* 의존 없음).
- Phase 0 feasibility 스파이크(`spike-embed-candle`)는 production 흡수 후 제거.
- 문서: README Configuration, `docs/SMOKE.md` config 예시, `docs/ARCHITECTURE.md`
crate 그래프/트리에 candle provider 반영.
## 검증 / 도그푸딩
- **패리티 (candle vs onnxruntime)**: 동일 e5-large 가중치로 cosine_min =
1.000000, 차원별 max 절대오차 = **2.01e-7**. 벡터가 사실상 동일 →
`embedding_version` 유지(재색인 0). 재현: `crates/kebab-embed-candle/tests/parity.rs`
(`--ignored`).
- **전체 도그푸딩 (2026-06-02)**: `provider=candle` 로 도그푸딩 코퍼스 전체
재색인 — **997 docs / 23,151 chunks, 에러 0** 완주 (≈9.5 h, 단일소켓 VM).
candle 가 23k+ 청크를 메모리 오류 없이 처리함을 확인.
- **A1(taskset/numactl) 반증**: NUMA 서버에서 `taskset -c 0-3` 으로 스레드를
4개로 묶어도 onnxruntime 은 그대로 죽었다(6/5150 segfault). 스레드 축소는
해법이 아니며, **`provider=candle` 만이 실 해법**이다 (candle 은 onnxruntime 을
호출하지 않음).
- **최종 인수 게이트 (사용자)**: 그 듀얼소켓 NUMA 서버에서 `provider=candle`
ingest 가 EXIT=0 완주 — 배포·실사용이 이 검증을 겸한다.
## 성능 노트 (중요)
candle CPU 임베딩은 onnxruntime 대비 약 **3~4× 느리다** (e5-large/512-tok 의
순수-Rust 커널 비용). 측정상 ~1.86 s/chunk, CPU 약 4코어 활용. **이는 의도된
트레이드오프** — onnxruntime 이 전 코어를 AVX-512 로 빡빡하게 굴리는 바로 그
경로가 NUMA 에서 힙을 손상시켜 죽기 때문이다. "느려도 완주" > "빨라도 크래시".
- Intel **MKL 가속을 실험했으나 부정 결과**: MKL 은 코어를 더 쓰지만(8~9코어)
오히려 38~50% 느렸다(과다구독 + MKL 2020.1 오버헤드). 채택하지 않음.
- 더 많은 코어/스레드로는 빨라지지 않는다(병목이 코어 수가 아님). 속도가
critical 하면 청크 길이 단축 / 더 작은 모델 / GPU 가 레버다(별도 검토).
- 9.5 h 는 **최초 전체 색인 1회 비용**이며, 이후 증분 ingest 는 새/변경 문서만
처리해 저렴하다. 단일 워크스테이션(비-NUMA)에서는 기본 `fastembed` 가 더 빠르니
candle 은 NUMA 호스트 전용 opt-in 으로 둔다.

View File

@@ -0,0 +1,113 @@
---
title: Query-paraphrase robustness — Phase 1 (변형 일관성 평가) 완료 + (A)/(B) 진단
date: 2026-05-29
branch: feat/crossscript-rerank
status: Phase 1 구현·측정 완료 — Phase 2(처방) 결정 대기
related:
- docs/superpowers/specs/2026-05-29-query-paraphrase-robustness-eval-design.md
- docs/superpowers/plans/2026-05-29-query-paraphrase-robustness-eval.md
- docs/superpowers/handoffs/2026-05-29-crossscript-rerank-progress-handoff.md (선행 rerank 실험)
- memory: project_paraphrase_robustness, project_rerank_experiment, project_crossscript_diagnosis
---
# Query-paraphrase robustness — Phase 1 완료
## TL;DR
같은 의미를 다른 표현(한/영·동의어·풀어쓴 문장)으로 물어도 일관된 품질이 나오는지 **직접 측정**하는
프레임워크를 `kebab-eval` 에 구축하고(Phase 1), dogfood KB 에 8개 변형 그룹(32 변형)을 큐레이션해
측정했다. 결과: **문제는 한/영이 아니라 "어휘 거리"** 이고, **(B) 어휘격차가 (A) 순위출렁보다 우세**
(B_dominant=4 vs A_dominant=2). 즉 선행 rerank 실험(A형 처방)은 소수만 커버 — "측정 먼저" 논제가
정량 검증됨. Phase 2 처방(쿼리 확장/번역 vs near-tie 흡수)은 사용자 결정 대기.
## Phase 1 구현 (branch `feat/crossscript-rerank`, 머지 전)
| Task | 커밋 | 내용 | 리뷰 |
|---|---|---|---|
| 1 | `e491a7b`+`48c94de` | `GoldenQuery.group` + loader 그룹 정합성 검증 | sonnet APPROVE-WITH-NITS (반영) |
| 2 | `0ff38581`+`67e104f` | `kebab-eval::variant` 메트릭 + (A)/(B) 분류 | opus CHANGES-REQUESTED → H1/M1 수정 |
| 3 | `895dcea` | `kebab eval variants <run_id>` CLI | 직접 검증 |
- **메트릭**: 그룹 내 `recall@narrow(10)` vs `recall@pool(50)` 대비 →
`Ok`(top-10 안) / `MisRanked`(A: pool엔 있고 top-10 밖) / `Missing`(B: pool에도 없음).
그룹 롤업: recall_spread@10, worst@10, A/B dominant, fully_consistent, `pool_possibly_truncated`.
- **리뷰 H1 (실제 버그, 측정 전 차단)**: `POOL_K=50` 인데 `eval run --k` 기본=10 →
pool==narrow 항상 → A 영원히 안 나옴, 전부 B 오분류. 수정: `config_snapshot_json``eval_k`
추가 + `eval_k < 50` 이면 `bail` + `pool_possibly_truncated` 플래그. 회귀 테스트 고정.
- 전 task `cargo test`+`clippy -D warnings` green. 기존 `AggregateMetrics` 경로 불변(회귀 가드 통과).
## 측정 (Task 4 큐레이션 + Task 5)
- golden: `/build/dogfood/golden_queries.yaml` 에 8그룹×4변형(ko/en/동의어/풀어쓴문장) append.
정답 문서는 **corpus 의미로 판정**(검색 상위 자동채택 X — ownership 의 rank1 이 garbage-collection.md
의 대조 언급이라 정답 아님을 실증). `topics/` 군(1파일=1주제)이라 판정 명확.
- run: `kebab eval run --mode hybrid --k 50` (run_id `run_019e74dcae2778f3984df49ee79b725a`).
- 리포트: `kebab eval variants <run_id>` (⚠️ `KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml`
설정 필수 — 미설정 시 default golden → groups=0). 전체:
`/build/dogfood/logs/2026-05-29-paraphrase-robustness-variants-hybrid.txt`.
### 결과 (hybrid, k=50, err=0)
```
groups=8 fully_consistent=2 A_dominant=2 B_dominant=4 mean_spread@10=0.750 pool=top-50
```
| group | A(MisRanked) | B(Missing) | 분류 | 핵심 |
|---|---|---|---|---|
| ownership | 0 | 0 | 완전 일관 ✅ | 4변형 모두 recall 1.0 |
| isolation_levels | 0 | 0 | 완전 일관 ✅ | 4변형 모두 1.0 (한국어 "트랜잭션 격리 수준" 포함) |
| cap_theorem | 2 | 0 | (A) near-tie | 풀어쓴 문장(영/한) recall@50=1·@10=0 |
| vector_database | 2 | 0 | (A) near-tie | "벡터 데이터베이스"·"근사 최근접…" @50=1·@10=0 |
| raft | 0 | 1 | (B) 어휘격차 | 영어 풀어쓴 "how nodes agree…" @50=0 |
| mvcc | 0 | 1 | (B) 어휘격차 | 영어 풀어쓴 "how databases serve reads…" @50=0 |
| backprop | 0 | 2 | (B) 어휘격차 | 한국어 "역전파 알고리즘"·"연쇄 법칙…" @50=0 |
| gradient_descent | 0 | 2 | (B) 어휘격차 | 한국어 "경사 하강법"·"손실 함수…" @50=0 |
raw search 독립 검증: `kebab search "역전파 알고리즘" --k 50` → backprop doc(54e0ac…) **top-50 부재**
(top은 무관한 algorithm.md). eval 파이프라인 artifact 아님 확인.
### 진단 (Read 검증된 숫자 기반)
1. **문제는 실재하고 크다**: mean_spread@10=0.750 — 같은 의도의 표현 간 recall 이 평균 0.75 출렁.
2. **한/영 문제가 아니라 어휘 거리 문제**: 영어 풀어쓴 문장도 miss(raft/mvcc), 일부 한국어는 잘 됨
(러스트 소유권, 트랜잭션 격리 수준, MVCC 동작 원리, 래프트 합의 알고리즘). 사용자 재정의 목표
("정확한 단어가 아닌 같은 의미의 다른 단어")와 정확히 일치.
3. **(B) 어휘격차 우세 (4 vs 2)**: 못 찾은 정답이 top-50 pool 에도 없음 → 재정렬(rerank)로 해결 불가.
특히 ml-training(backprop/gradient_descent) 한국어는 영어 본문 문서를 의미·표층 둘 다 못 매칭.
**쿼리 확장/번역**(또는 더 나은 다국어 임베딩) 처방 신호.
4. **(A) 순위출렁은 소수 (cap_theorem/vector_database)**: 정답이 pool엔 있고 top-10 밖 →
near-tie 흡수 / rerank 후보. 선행 rerank 실험이 도움 됐을 그룹.
5. **"측정 먼저" 논제 검증**: rerank(A형) 단독은 6개 문제 그룹 중 2개만 커버. 선행 실험이 overlap
프록시로 헛돈 이유가 데이터로 드러남.
## Phase 2 (처방) — 결정 대기
본 spec §2 의 조건부 게이트대로:
- **(B) 우세이므로 쿼리 확장/번역이 1차 후보** (로컬 LLM gemma). cap_theorem/vector_database 의
(A) 성분엔 near-tie 흡수가 보조.
- 처방 효과는 본 Phase 1 평가셋(`kebab eval variants`)으로 재측정해 검증 (또 프록시 금지).
- 미결: 확장/번역의 형태(쿼리→영어 번역 후 retrieve, 양쪽 retrieve 합집합, HyDE 류 등),
latency·품질 trade-off, default on/off. → Phase 2 brainstorm/spec 에서.
## Phase 2 방향 — 딥리서치 + PoC (2026-05-30)
- **딥리서치** (`docs/superpowers/research/2026-05-30-vocabulary-gap-recall-fix-research.md`, 104 agent,
22 confirmed/3 killed): 어휘격차 pool-miss 최선책 = **색인시 doc-side expansion(doc2query)**.
pool 자체를 키우고(rerank 아님), per-query 지연 ~0(색인시 1회 → 사용자가 거부한 per-query LLM 아님),
정확매칭 보존(별도 필드 append). 단 vanilla mt5 doc2query 는 같은언어라 한/영 갭은 색인시 KO↔EN
대체 query 생성 필요. query-side(HyDE=거부된 per-query LLM, Vector-PRF=recall 주장 0-3 기각) 부적합.
learned-sparse(SPLADE/MILCO)는 CPU/Rust 경로 없거나 교차언어 약함.
- **PoC 확인** (`/build/dogfood/logs/2026-05-30-docexpansion-poc-result.md`): dogfood KB(3940 doc)에
backprop/raft 별칭추가판 ingest → recall@50=0 이던 3쿼리 전부 **rank 1~2 로 부활**(hybrid+vector),
별칭은 골든쿼리 verbatim 아님(일반화 확인). **딥리서치의 핵심 미검증 고리를 실 corpus 로 정량 확인.**
- ⚠️ dogfood KB 현재 3942 doc (PoC 2개 잔존, corpus/_poc 는 삭제). variant 골든은 원본 doc_id
타겟이라 baseline eval 무영향. pristine 필요 시 `kebab reset` + reingest.
- **Phase 2 권고**: 색인시 doc-side expansion(같은언어 + KO↔EN 번역 별칭, 로컬 gemma 색인시 1회) →
별도 FTS5 필드 → RRF. flag off 기본. 효과는 `kebab eval variants` 로 재측정. brainstorm→spec→plan.
## 다음 세션 첫 작업
1. 사용자와 Phase 2 방향 확정 (쿼리 확장/번역 설계 brainstorm).
2. 또는 Phase 1 코드(group + variant + CLI)를 main 머지할지 결정 (default off, eval 전용·additive,
기존 동작 무영향 → 머지 안전. PR 은 gitea-pr + 리뷰 루프).
3. `--with-rag` 변형 일관성(답변 품질 직접 측정)은 미실행 — recall 진단으로 충분했음. 필요 시 후속.

View File

@@ -0,0 +1,158 @@
---
title: Phase 2 킥오프 — doc-side expansion (색인시 별칭) + 구현 방법론
date: 2026-05-30
status: Phase 1 머지 완료(#193), Phase 2 설계 대기
audience: 새 세션 (자립적 컨텍스트 — 이 문서 + 아래 참조만으로 이어받기 가능)
related:
- docs/superpowers/handoffs/2026-05-29-query-paraphrase-robustness-phase1-handoff.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
- docs/superpowers/plans/2026-05-29-query-paraphrase-robustness-eval.md
- memory: project_paraphrase_robustness, project_rerank_experiment, project_crossscript_diagnosis,
feedback_omc_teams_usage, feedback_teammate_spawn_mode, feedback_teammate_model_routing,
feedback_worker_completion_polling, feedback_pr_workflow, feedback_search_quality_dogfood,
feedback_serial_build_only, feedback_skip_user_review_gates, feedback_explain_friendly
---
# Phase 2 킥오프 — doc-side expansion
## 0. TL;DR
같은 의미를 다른 표현으로 물어도 일관된 검색 품질을 내는 게 목표다. **Phase 1(평가 프레임워크)은
main 에 머지됨(#193).** 진단 결과 **어휘격차(B)가 우세** — 같은 뜻 다른 단어(동의어·풀어쓴 문장·
한/영)면 정답이 top-50 pool 에도 안 들어와(recall@50=0) rerank 로는 못 고친다. 딥리서치 + 우리
corpus PoC 가 처방을 **색인시 doc-side expansion**(문서를 넣을 때 "검색용 별칭"을 1회 생성해 붙이기)
으로 확정했다. **Phase 2 = 이 처방을 flag 뒤에 구현하고 `kebab eval variants` 로 효과 재측정.**
## 1. 여기까지 온 경로 (압축)
1. 한/영 음차 검색 불안정 → 원인은 **vector near-tie**([[project_crossscript_diagnosis]]).
2. 완화책으로 **cross-encoder reranker** 실험(`feat/crossscript-rerank`, default off). full chunk text
까지 시도했으나 **회귀 못 없앰 → 가설 반증**([[project_rerank_experiment]]). rerank 는 pool 안의
순서만 바꿔서, 정답이 pool 에 없으면 무력.
3. 사용자가 목표 재정의: "한/영뿐 아니라 **같은 의미의 다른 단어·표현**에서도 일관된 품질".
4. **Phase 1**: `kebab-eval` 에 변형 일관성 평가 추가(group + recall@10 vs recall@50 → A/B 분류).
dogfood 8그룹×32변형 측정 → **B(어휘격차) 우세, 문제는 한/영이 아니라 "어휘 거리"**
(영어 paraphrase 도 miss, 일부 한국어는 OK). #193 으로 main 머지.
5. **딥리서치**(104 agent, 적대검증): 최선책 = 색인시 doc-side expansion. query-side(HyDE=거부된
per-query LLM, Vector-PRF=recall 주장 기각) 부적합. learned-sparse(SPLADE/MILCO) CPU/Rust 경로
없거나 교차언어 약함. **PoC**(dogfood KB): backprop/raft 별칭추가판 ingest → recall@50=0 이던
3쿼리가 **rank 1~2 부활**(hybrid+vector, 골든 verbatim 아님=일반화). 핵심 미검증 고리 정량 확인.
## 2. Phase 2 설계 방향 (딥리서치 권고 — 합성, 우리 corpus 측정 필수)
**색인시 doc-side expansion** (`docs/superpowers/research/2026-05-30-vocabulary-gap-recall-fix-research.md`):
- **무엇**: 문서/청크를 색인할 때 로컬 LLM(gemma, config `models.llm` = `gemma4:e4b`, endpoint 이미
설정됨)으로 "이 문서를 찾을 법한 다른 표현/질문"을 **1회** 생성 — 같은언어 paraphrase + **KO↔EN
번역 별칭** — 해서 **별도 FTS5 필드**에 저장. RRF 가 {원문 body BM25, 별칭 BM25, e5 dense} 융합.
- **왜 우리 제약에 맞나**: (1) 색인시 1회 = 사용자가 거부한 "per-query LLM(밑 빠진 독)" 아님,
(2) e5-large dense 유지(bge-m3 dense 는 실측 더 나빴음), (3) 별도 필드라 원문 정확매칭(코드 식별자)
보존, (4) per-query 지연 ~0.
- **핵심 함정**: vanilla mt5 doc2query 는 *같은 언어* query 만 생성 → 한/영 갭 못 메움. 그래서
**색인시 KO↔EN 번역 별칭 생성**이 추가로 필요(이게 "합성/추론" 부분 — 논문 직접 벤치 없음 →
우리 corpus 로 반드시 측정).
- **(선택) 보조**: BGE-M3 sparse 채널(fastembed-rs `BGEM3Q`, CPU)을 4th RRF 채널로 — 단일언어
term-expansion lift, e5 dense 유지. (교차언어는 약하니 선택사항.)
**딥리서치 openQuestions = Phase 2 가 답할 것:**
1. 색인시 KO↔EN 별칭 생성이 *우리 corpus* 에서 recall@50 을 0→양수로 올리나? 생성 예산(별칭 수/문서,
모델 크기)의 cost/recall knee 는? → **`/build/dogfood` golden + `kebab eval variants` 로 측정.**
2. ONNX/fastembed 호환 교차언어 learned-sparse 체크포인트 있나, 아니면 색인시 expansion 으로만?
3. doc2query 가 FTS5 index 를 얼마나 부풀리나. Doc2Query--/++ 필터 가치 있나.
4. e5 dense 유지 + BGE-M3 **sparse 만** 추가가 순이득인가, 약한 다국어 sparse 가 노이즈인가.
**설계 시 고려(brainstorm 에서 확정):** ingest pipeline 의 어디에 hook(chunk 후?), 별도 FTS5 필드
스키마 + migration(V0XX), gemma 프롬프트(번역 별칭 품질), versioning cascade(별칭은 새
`chunker_version`/별도 version? re-index 정책), flag 이름·default off, 환각·index 팽창 제어(필터).
## 3. 이미 만든 측정 도구 (Phase 2 검증에 그대로 사용)
- **`kebab eval variants <run_id> [--json]`** — 변형 그룹 일관성 진단. recall@10 vs recall@50
`Ok`/`MisRanked`(A)/`Missing`(B) + group rollup + `pool_possibly_truncated`.
- **dogfood golden**: `/build/dogfood/golden_queries.yaml` 에 8 변형그룹×4 = 32 (ownership, raft,
mvcc, cap_theorem, gradient_descent, backprop, isolation_levels, vector_database). 같은 group =
동일 `expected_doc_ids`.
- **측정 절차**(⚠️ `KEBAB_EVAL_GOLDEN` 필수 — 미설정 시 default golden → groups=0):
```
KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml \
kebab eval run --config /build/dogfood/config.toml --mode hybrid --k 50 # k>=50 필수(아니면 진단 bail)
KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml \
kebab eval variants <run_id> --config /build/dogfood/config.toml
```
- **Phase 1 baseline**(처방 전): `groups=8 fully_consistent=2 A_dominant=2 B_dominant=4 spread@10=0.750`.
Phase 2 목표 = B_dominant↓, fully_consistent↑, spread↓ (처방 on/off 비교).
- **PoC 방법**(참고): 별칭 추가판 문서를 corpus 에 넣고 incremental ingest(기존 skip, +N) → 실패
쿼리로 새 doc_id 가 top-50 잡히는지. 비파괴적. (`/build/dogfood/logs/2026-05-30-docexpansion-poc-*`)
- ⚠️ dogfood KB 현재 3942 doc (PoC 별칭 2개 잔존, corpus/_poc 삭제됨). variant 골든은 원본 doc_id
타겟이라 baseline 무영향. pristine 필요 시 `kebab reset` + reingest.
## 4. 구현 방법론 (지금까지와 동일 — 그대로 따를 것)
### 4.1 워크플로 (superpowers)
**brainstorm → spec → plan → subagent 구현.** 각 단계:
- **brainstorm**(`superpowers:brainstorming`): 사용자와 한 번에 하나씩 질문(쉬운 비유·친절히 —
사용자는 검색/NLP 지식 적음 [[feedback_explain_friendly]]). 핵심 trade-off 만 AskUserQuestion.
- **spec**(`docs/superpowers/specs/YYYY-MM-DD-*.md`): self-review 후 진행. **사용자 컨펌 게이트
skip** ([[feedback_skip_user_review_gates]]) — self-review 만 + 바로 다음 단계.
- **plan**(`docs/superpowers/plans/*.md`): TDD bite-sized task, 완성 코드(placeholder 금지), self-review.
- **구현**: task 별 OMC teammate (아래).
### 4.2 OMC teammate 실행 ([[feedback_omc_teams_usage]] [[feedback_teammate_spawn_mode]])
- **sequential single-team only** (multi-team spawn 실측 fail). 한 팀 끝 → shutdown → 다음 팀.
- **spawn**: `OMC_TEAM_ROLE_OVERRIDES='{"<role>":{"model":"claude-opus-4-8|claude-sonnet-4-6"}}'
omc team 1:claude:<role> --no-decompose "Task X: read <brief-abs-path> and execute exactly; write
result to <result-abs-path>"`. role 예: executor, code-reviewer.
- **brief 파일 패턴**: task 내용을 `.omc/reviews/<date>-<id>-brief.md` 에 자립적으로 작성
(계획 task 참조 + 빌드/규약 + 결과파일 경로). spawn 의 task 텍스트는 짧게(brief read 지시).
- **완료 감지**: spawn 직후 background polling shell(`run_in_background=true`) — `omc team status
<slug>` 의 phase=completed/failed 또는 tasks completed>=1 감지 → task notification 자동 알림
([[feedback_worker_completion_polling]]). 작은 task sleep 10, 큰 task sleep 20.
- **모델 확인**: spawn 후 worker pane 캡처로 `Model: Sonnet/Opus` 검증 (`tmux capture-pane -pt <pane>`).
- **shutdown**: `omc team shutdown <slug> --force` (non-force 종종 실패). 다음 팀 전 필수.
- ⚠️ `omc team list` 같은 조회 명령 없음 — "list" 를 task 로 해석해 팀이 spawn 됨. 상태는 `omc team
status <slug>`. 잘못 뜬 팀은 즉시 `shutdown --force`.
### 4.3 모델 라우팅 ([[feedback_teammate_model_routing]])
- **작은 task → sonnet**, **복잡/핵심 로직 → opus**. 리뷰: 핵심 로직 = opus, 작은 변경 = sonnet.
micro-patch/fix 라운드 = sonnet.
- (실증: Phase 1 에서 opus 리뷰가 H1 실버그 — pool truncation 으로 진단 무력화 — 를 측정 전 차단.)
### 4.4 task 별 사이클
implement(executor) → **review(code-reviewer, 별도 teammate)** → CHANGES 면 fix 라운드 → **독립
검증**. teammate 보고를 신뢰하지 말고 직접 확인: `git show <hash> --stat`, redirect 파일에서 test/
clippy EXIT, 신규 심볼 grep. ([[feedback_serial_build_only]] 의 직렬 빌드 규약도.)
### 4.5 빌드/테스트 규약 (필수 — 어기면 깨진 커밋)
- `CARGO_TARGET_DIR=/build/out/cargo-target/target` (XFS 4TB), `-j 4` (fast mode 8, OOM 시 `-j 1`).
- **결과를 파일로 redirect + exit code 확인 후에만 커밋.** `cargo ... | grep | tail` **금지**
(pipe exit 가 grep 거라 cargo 실패 마스킹). 빌드는 백그라운드(run_in_background) 권장.
- cargo clean: /build avail<500G 또는 target>500G 일 때만 ([[feedback_cargo_clean_policy]]).
### 4.6 측정 규율 ([[feedback_search_quality_dogfood]] [[project_rerank_experiment]] 교훈)
- **프록시 금지**: overlap 같은 대리 지표 최적화로 헛돈 전적 있음. 진짜 지표(`kebab eval variants`
recall/일관성)로 처방 효과 측정.
- **측정값 절대 추측 금지**: grep clean 추출 → Read 로 확인한 값만 기록. (Phase 1 전 세션에서 숫자
fabrication 2회 발생·정정.)
- 처방은 flag off 기본, on/off 비교 측정 + 회귀(전체 golden) 확인.
### 4.7 PR ([[feedback_pr_workflow]])
- **gitea-pr + 리뷰 루프 모드** (단발/루프 묻지 말 것). 스크립트:
`/home/altair823/.claude/.omc-launch/skills/gitea-ops/bin/gitea-pr{,-status,-diff,-review}`.
reviewer login `gitea-ops-reviewer` 별도 계정. PR title 정규식 `^(feat|fix|docs|...)(\(scope\))?: .+`,
브랜치 `^<type>/<kebab>$`, body `## 요약`+`## 검증` 필수. 회차마다 review 등록, 한국어 본문은
손상 점검(다시 fetch). 머지는 사용자가 UI 에서 (Claude 자동 머지 안 함).
- **user-facing surface 변경 시 같은 PR 에서 README + HANDOFF + ARCHITECTURE 동기화**
([[feedback_readme_sync_rule]]): README 는 좁게(사용법+포인터), 상세는 ARCHITECTURE, flag 망라는
`--help`/in-app 권위 소스 위임(stale 방지). #193 에서 이 정리 수행함.
- **versioning cascade**: chunker/embedding version 등 변경 시 design §9 cascade — re-process job
또는 breaking bump. 별칭 필드가 새 version 축이면 migration(V0XX) + dogfood trigger.
## 5. 새 세션 첫 작업
1. 이 문서 + §0~4 참조 + 메모리 로드 확인.
2. **brainstorm Phase 2 설계**: doc-side expansion 의 구체(ingest hook 위치, 별도 FTS5 필드 스키마 +
migration, gemma 번역-별칭 프롬프트, versioning cascade, flag/config, 환각·팽창 제어). §2 의
openQuestions 를 설계로 흡수.
3. spec → plan → OMC teammate 구현(§4 방법론) → `kebab eval variants` 로 on/off 측정.
4. 효과 확인되면 gitea-pr 리뷰 루프 + README/ARCH sync. flag off 기본.

View File

@@ -0,0 +1,77 @@
# config 마이그레이션 — 작업 인계 (kickoff)
> 2026-05-31. config.toml **스키마 진화 시 기존 사용자 파일을 자동 마이그레이션**하는
> 기능. 새 세션은 이 문서 + 메모리 [[project_paraphrase_robustness]] 로 이어받는다.
> 본격 진행은 brainstorm → spec → plan → 구현 (방법론 §5).
## 1. 동기
v0.21.0 에서 `[ingest.expansion]`(별칭) 섹션을 추가했다. 기존 사용자 config.toml 은
serde default 로 **동작은 호환**(off 로 로드)되지만, 그 섹션이 **파일에 써지지 않아**
사용자가 파일을 열어도 새 기능의 존재·노브를 알 수 없다. DB 는 V00X refinery
마이그레이션이 있는데 **config 는 마이그레이션 메커니즘이 없다** — 이걸 만든다.
## 2. 현황 (코드, 현재 main = v0.21.0)
- **읽기는 이미 forward-compatible**: `crates/kebab-config/src/lib.rs` 의 모든 새
섹션/필드가 `#[serde(default)]` (예: ImageCfg L50, UiCfg L55, ingest.code L60,
PdfCfg L65, logging L70, nli L132 …). missing 필드는 default 로 로드돼 **기존
config 가 깨지지 않는다**. → 동작 호환성은 확보돼 있고, 만들 것은 *파일 갱신*이다.
- **`schema_version: u32`** (lib.rs:38, 현재 `1`) — **검증·마이그레이션에 안 쓰이는
장식**. 마이그레이션의 버전 축으로 활용할 자리.
- **파일 쓰기는 init 뿐**: `kebab init``toml::to_string(&Config::defaults())`
로 default config 생성(lib.rs:1349 부근). **기존 파일을 갱신하는 경로는 없다.**
- **deprecated 선례**: 옛 `workspace.include` 는 로드 시 무시 + 1회 deprecation
warning (p9-fb-25). 마이그레이션의 "deprecated 정리" 참고 패턴.
## 3. 풀어야 할 핵심 — 주석/순서 보존
`toml::to_string` 으로 통째 재작성하면 **사용자가 손본 주석·정렬·순서가 전부
날아간다**. 이게 config 마이그레이션의 본질적 난점. 접근 3안:
| 방식 | 주석 보존 | 복잡도 | 비고 |
|------|-----------|--------|------|
| A. 전체 재작성(로드→재직렬화) | ✗ | 낮음 | 사용자 값은 보존되나 주석 손실 |
| B. `toml_edit` 로 missing 섹션만 주석과 함께 append/수정 | ✓ | 중간 | 의존성 추가, 가장 사용자 친화적 |
| C. 백업(.bak) 후 재생성 + diff 안내 | △ | 낮음 | 안전하나 사용자가 주석 수동 복원 |
**B(`toml_edit`)** 가 사용자 손본 config 보존엔 최선. 의존성·복잡도 trade-off 를
brainstorm 에서 결정.
## 4. 설계 결정 (brainstorm 시작점)
1. **트리거**: `kebab config migrate` 명시 명령 vs `load` 시 자동(+백업). 자동은
편하나 예측 가능성/안전(쓰기 권한·손상)이 걸린다. 명시 명령 + `kebab doctor`
에서 "마이그레이션 필요" 안내가 무난할 수 있음.
2. **버전 축**: `schema_version` 기반 버전별 변환 함수 체인 (v1→v2→…, DB refinery
패턴 차용). 각 step 은 "이 버전에서 추가된 섹션/바뀐 형식/제거된 deprecated".
3. **동작**: (a) 새 섹션을 주석과 함께 추가 (b) deprecated 필드 정리/이동
(c) 형식 변경 변환. 모두 **멱등**(재실행 안전).
4. **안전**: 사용자 손본 config 손상 절대 금지 → **백업(.bak) 필수**, dry-run 옵션,
실패 시 원본 보존.
## 5. 방법론 (v0.21.0 작업과 동일 — PR #195/#196 참고)
brainstorm(사용자 컨펌 게이트 skip, self-review) → spec(self-review) → plan(TDD,
bite-sized) → executor(opus) 또는 OMC teammate 구현 → **gitea-pr 리뷰 루프**
(round1 리뷰 opus, closure verify sonnet) → 머지. 빌드는 항상
`CARGO_TARGET_DIR=/build/out/cargo-target/target cargo … -j 4 > /tmp/x.log 2>&1; echo EXIT=$?`
(절대 `cargo | grep` 금지). PR 은 gitea REST(`~/.netrc`), gh 안 됨.
## 6. 관련 파일
- `crates/kebab-config/src/lib.rs``Config` struct, `schema_version`, serde default
패턴, `load`/`defaults`/`to_string`. 마이그레이션 모듈을 여기 or 신규 `migrate.rs`.
- `crates/kebab-cli/src/*``init` 명령 옆에 `config migrate`(또는 `config`) 서브커맨드.
- `migrations/V0XX__*.sql` — DB 마이그레이션의 버전 체인 패턴 차용 참고.
- `toml_edit` 크레이트(주석 보존 편집) — B안 시 의존성 후보.
## 7. 주의
- config 마이그레이션은 **user-facing surface** → README(Configuration)/HOTFIXES 동기화
(이번 세션 패턴 [[feedback_readme_sync_rule]]). 마이그레이션 *동작 디테일*은 spec 에
충실히([[feedback_design_detail_docs]]).
- `schema_version` bump 가 release 트리거인지는 별도 판단 — DB schema(V00X)와 달리
config 버전은 데이터 무효화가 아니므로, additive 면 release 트리거 아닐 수 있음
(CLAUDE.md §Versioning 의 DB/wire 기준과 구분).
- 멱등 + 백업 + dry-run 이 안전의 3축.

View File

@@ -0,0 +1,114 @@
# 나무위키 대규모 측정 — 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).
- **위치 밀림에도 캐시가 듣는 이유**: 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 이식성 (외부 계산 워크플로)
- `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/`.

View File

@@ -0,0 +1,827 @@
# Query-paraphrase Robustness Eval (Phase 1) Implementation 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:** `kebab-eval`에 "같은 의미의 여러 표현(동의어·다른 어휘·풀어쓴 문장·한/영)"을 묶는 변형 그룹과, 그룹 내 답변/검색 품질 일관성을 재고 (A)순위출렁/(B)어휘격차를 판별하는 진단 메트릭을 추가한다.
**Architecture:** `GoldenQuery``group: Option<String>` 추가(additive) → loader가 그룹 정합성 검증 → 신규 `variant.rs`가 저장된 run의 per-query 결과를 그룹으로 묶어 recall@narrow(10) vs recall@pool(50) 대비로 변형 일관성 + A/B 분류 산출 → `kebab eval variants <run_id>` CLI로 표/JSON 리포트. 기존 `AggregateMetrics` 경로는 불변(group=None이면 기존 동작).
**Tech Stack:** Rust 2024, `kebab-eval` 크레이트, serde/serde_yaml, anyhow, rusqlite(간접). 측정은 release `kebab` + dogfood KB.
**빌드/테스트 규약 (이 환경 필수):** 모든 cargo는 `CARGO_TARGET_DIR=/build/out/cargo-target/target` + `-j 4`, 결과를 **파일 redirect + exit code 확인 후에만** 커밋 (`grep|tail` 금지 — pipe exit가 cargo 실패를 마스킹). 출력 노이즈로 빌드 오독 사례 다수.
---
## File Structure
| File | 책임 | 변경 |
|---|---|---|
| `crates/kebab-eval/src/types.rs` | `GoldenQuery``group` 필드 | Modify |
| `crates/kebab-eval/src/loader.rs` | 그룹 정합성 검증(`check_group_integrity`) | Modify |
| `crates/kebab-eval/src/variant.rs` | 변형 일관성 메트릭 + A/B 분류 + 렌더 | **Create** |
| `crates/kebab-eval/src/lib.rs` | `variant` 모듈 등록 + re-export | Modify |
| `crates/kebab-cli/src/main.rs` | `kebab eval variants <run_id>` 서브커맨드 | Modify |
| `/build/dogfood/golden_queries.yaml` | 변형 그룹 큐레이션 (in-repo 아님) | Modify (data) |
---
## Task 1: `group` 필드 + loader 그룹 정합성 검증
**모델:** sonnet (작은 스키마 + 검증 함수)
**Files:**
- Modify: `crates/kebab-eval/src/types.rs:13-29` (GoldenQuery)
- Modify: `crates/kebab-eval/src/loader.rs` (`load_golden_set` + 신규 `check_group_integrity`)
- Test: `crates/kebab-eval/src/loader.rs` (in-module `#[cfg(test)]`)
- [ ] **Step 1: `group` 필드 추가**
`crates/kebab-eval/src/types.rs``GoldenQuery``difficulty` 아래로 추가:
```rust
#[serde(default)]
pub difficulty: Option<String>,
/// 같은 의미의 여러 표현(동의어·다른 어휘·풀어쓴 문장·한/영)을 묶는
/// 의도 그룹 id. 같은 그룹의 모든 변형은 동일한 `expected_doc_ids`(집합)를
/// 공유해야 한다(loader가 강제). `None`이면 단독 쿼리(기존 동작 불변).
#[serde(default)]
pub group: Option<String>,
```
- [ ] **Step 2: 실패하는 테스트 작성**
`crates/kebab-eval/src/loader.rs``#[cfg(test)] mod tests` 안에 추가:
```rust
#[test]
fn rejects_group_with_divergent_expected_docs() {
let tmp = tempdir().unwrap();
let yaml_path = tmp.path().join("golden.yaml");
fs::write(
&yaml_path,
"- id: g1\n query: \"러스트 소유권\"\n group: ownership\n expected_doc_ids: [\"docA\"]\n\
- id: g2\n query: \"rust ownership\"\n group: ownership\n expected_doc_ids: [\"docB\"]\n",
)
.unwrap();
let err = load_golden_set(&yaml_path).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("group"), "msg: {msg}");
assert!(msg.contains("ownership"), "msg: {msg}");
}
#[test]
fn accepts_group_with_matching_expected_docs() {
let tmp = tempdir().unwrap();
let yaml_path = tmp.path().join("golden.yaml");
fs::write(
&yaml_path,
"- id: g1\n query: \"러스트 소유권\"\n group: ownership\n expected_doc_ids: [\"docA\"]\n\
- id: g2\n query: \"rust ownership\"\n group: ownership\n expected_doc_ids: [\"docA\"]\n",
)
.unwrap();
let qs = load_golden_set(&yaml_path).unwrap();
assert_eq!(qs.len(), 2);
assert_eq!(qs[0].group.as_deref(), Some("ownership"));
}
```
- [ ] **Step 3: 테스트 실패 확인**
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-eval -j 4 rejects_group_with_divergent > /build/cache/tmp/t1.txt 2>&1; echo "EXIT=$?"`
Expected: 컴파일은 되나 `rejects_group_with_divergent_expected_docs` FAIL (현재 정합성 검증 없음 → `load_golden_set`이 Ok 반환).
- [ ] **Step 4: `check_group_integrity` 구현 + 배선**
`crates/kebab-eval/src/loader.rs``load_golden_set`에서 `check_unique_ids(&queries)?;` 바로 다음 줄에 `check_group_integrity(&queries)?;` 추가. `check_unique_ids` 함수 아래에 신규 함수:
```rust
/// 같은 `group`에 속한 모든 쿼리가 동일한 `expected_doc_ids`(집합)를
/// 공유하는지 검증. 변형 일관성 메트릭은 "같은 정답을 가진 다른 표현들"을
/// 전제하므로, 그룹 내 정답이 갈리면 측정이 무의미해진다 → bail.
fn check_group_integrity(queries: &[GoldenQuery]) -> Result<()> {
use std::collections::BTreeMap;
// group -> (대표 정답 집합, 대표 query id)
let mut canonical: BTreeMap<&str, (BTreeSet<String>, &str)> = BTreeMap::new();
let mut offenders: BTreeSet<String> = BTreeSet::new();
for q in queries {
let Some(group) = q.group.as_deref() else {
continue;
};
let docs: BTreeSet<String> = q.expected_doc_ids.iter().map(|d| d.0.clone()).collect();
match canonical.get(group) {
None => {
canonical.insert(group, (docs, q.id.as_str()));
}
Some((expected, _first)) if *expected != docs => {
offenders.insert(group.to_string());
}
Some(_) => {}
}
}
if offenders.is_empty() {
Ok(())
} else {
let list: Vec<String> = offenders.into_iter().collect();
Err(anyhow!(
"group(s) with divergent expected_doc_ids (same group must share one expected doc set): {}",
list.join(", ")
))
}
}
```
`BTreeSet`는 파일 상단 `use std::collections::{BTreeSet, HashSet};`에 이미 포함됨(확인). 누락 시 추가.
- [ ] **Step 5: 테스트 통과 확인**
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-eval -j 4 group > /build/cache/tmp/t1b.txt 2>&1; echo "EXIT=$?"`
Expected: `rejects_group_with_divergent_expected_docs` + `accepts_group_with_matching_expected_docs` PASS. EXIT=0.
- [ ] **Step 6: clippy + 커밋**
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo clippy -p kebab-eval --all-targets -j 4 -- -D warnings > /build/cache/tmp/c1.txt 2>&1; echo "EXIT=$?"`
Expected: EXIT=0.
```bash
git add crates/kebab-eval/src/types.rs crates/kebab-eval/src/loader.rs
git commit -m "feat(eval): GoldenQuery.group + 그룹 정합성 검증 (변형 일관성 기반)"
```
---
## Task 2: 변형 일관성 메트릭 모듈 (`variant.rs`)
**모델:** opus (핵심 로직 — recall@narrow/pool, A/B 분류, 그룹 롤업)
**Files:**
- Create: `crates/kebab-eval/src/variant.rs`
- Modify: `crates/kebab-eval/src/lib.rs` (모듈 등록 + re-export)
- Test: `crates/kebab-eval/src/variant.rs` (in-module `#[cfg(test)]`)
- [ ] **Step 1: 모듈 골격 + 타입 작성**
`crates/kebab-eval/src/variant.rs` 생성:
```rust
//! 변형(paraphrase) 일관성 진단 메트릭.
//!
//! 같은 의도(`GoldenQuery.group`)의 여러 표현이 같은 정답 문서를 공유한다는
//! 전제 아래, 표현마다 검색/답변 품질이 얼마나 출렁이는지를 잰다. 핵심은
//! `recall@narrow`(사용자가 보는 top-10) vs `recall@pool`(넓은 후보 폭)의 대비:
//!
//! - (A) 순위 출렁(`MisRanked`): 정답이 pool엔 있는데 top-10 밖 → near-tie 흡수로 해결 후보.
//! - (B) 어휘 격차(`Missing`): 정답이 pool에도 없음 → 쿼리 확장/번역 필요.
//!
//! 진단 전용. 기존 [`crate::metrics::AggregateMetrics`] 경로는 건드리지 않는다.
use std::collections::{BTreeMap, HashMap, HashSet};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use kebab_config::Config;
use kebab_core::DocumentId;
use kebab_store_sqlite::SqliteStore;
use crate::types::{GoldenQuery, QueryResult};
/// 사용자가 실제 보는 답변 context 폭.
const NARROW_K: u32 = 10;
/// 넓은 후보 폭. recall@pool vs recall@narrow 대비로 A/B를 가른다.
/// eval run은 `--k`를 이 값 이상으로 줘서 `hits_top_k`가 pool을 담아야 한다.
const POOL_K: u32 = 50;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum VariantClass {
/// recall@narrow == 1.0 (정답 전부 top-10 안).
Ok,
/// recall@pool > recall@narrow (정답이 pool엔 있는데 top-10 밖). (A)
MisRanked,
/// recall@pool == recall@narrow < 1.0 (못 찾은 정답이 pool에도 없음). (B)
Missing,
/// 정답 문서 미지정(검증 불가).
NoExpected,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct VariantResult {
pub query_id: String,
pub query: String,
pub recall_narrow: f32,
pub recall_pool: f32,
/// must_contain 통과 여부. RAG 답변(`--with-rag`)이 없으면 `None`.
pub answer_ok: Option<bool>,
pub class: VariantClass,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct VariantGroupReport {
pub group: String,
pub variants: Vec<VariantResult>,
/// max-min recall_narrow (정답 지정 변형들만). 0 = 완전 일관.
pub recall_spread_narrow: f32,
pub worst_recall_narrow: f32,
/// 모든 변형이 must_contain 통과면 Some(true), 하나라도 실패 Some(false),
/// RAG 답변이 전혀 없으면 None.
pub answer_consistency: Option<bool>,
pub mis_ranked: u32,
pub missing: u32,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct VariantConsistencyReport {
pub groups: Vec<VariantGroupReport>,
pub mean_recall_spread_narrow: f32,
/// spread==0 && worst_recall_narrow==1.0 인 그룹 수.
pub fully_consistent_groups: u32,
pub total_groups: u32,
/// mis_ranked>0 && mis_ranked>=missing 인 그룹 수 (near-tie 처방 우선).
pub a_dominant_groups: u32,
/// missing>0 && missing>mis_ranked 인 그룹 수 (쿼리 확장 처방 우선).
pub b_dominant_groups: u32,
}
```
- [ ] **Step 2: 실패하는 테스트 작성**
같은 파일 하단에:
```rust
#[cfg(test)]
mod tests {
use super::*;
use kebab_core::{
ChunkId, ChunkerVersion, Citation, IndexVersion, RetrievalDetail, SearchMode, WorkspacePath,
ScoreKind,
};
use kebab_store_sqlite::EvalQueryResultRecord;
fn hit(doc: &str, rank: u32) -> kebab_core::SearchHit {
let path = WorkspacePath::new(format!("{doc}.md")).unwrap();
kebab_core::SearchHit {
rank,
chunk_id: ChunkId(format!("c-{doc}-{rank}")),
doc_id: DocumentId(doc.to_string()),
doc_path: path.clone(),
heading_path: vec![],
section_label: None,
snippet: String::new(),
citation: Citation::Line { path, start: 1, end: 1, section: None },
retrieval: RetrievalDetail {
method: SearchMode::Vector,
fusion_score: 1.0 / rank as f32,
lexical_score: None,
vector_score: Some(1.0 / rank as f32),
lexical_rank: None,
vector_rank: Some(rank),
},
index_version: IndexVersion("v1".into()),
embedding_model: None,
chunker_version: ChunkerVersion("v1".into()),
indexed_at: time::OffsetDateTime::UNIX_EPOCH,
stale: false,
score_kind: ScoreKind::Cosine,
repo: None,
code_lang: None,
}
}
fn gq(id: &str, group: &str, expected_doc: &str) -> GoldenQuery {
GoldenQuery {
id: id.into(),
query: id.into(),
lang: kebab_core::Lang(String::new()),
expected_doc_ids: vec![DocumentId(expected_doc.into())],
expected_chunk_ids: vec![],
must_contain: vec![],
forbidden: vec![],
difficulty: None,
group: Some(group.into()),
}
}
fn row(query_id: &str, hits: Vec<kebab_core::SearchHit>) -> EvalQueryResultRecord {
let qr = QueryResult {
query_id: query_id.into(),
query: query_id.into(),
mode: SearchMode::Vector,
hits_top_k: hits,
answer: None,
elapsed_ms: 0,
error: None,
};
EvalQueryResultRecord {
query_id: query_id.into(),
result_json: serde_json::to_string(&qr).unwrap(),
}
}
#[test]
fn classifies_mis_ranked_vs_missing_and_spread() {
// group "g": 정답 docX.
// v1: docX at rank 3 → narrow=1.0 → Ok
// v2: docX at rank 25 → narrow=0.0, pool=1.0 → MisRanked (A)
// v3: docX 없음 → narrow=0.0, pool=0.0 → Missing (B)
let queries = vec![gq("v1", "g", "docX"), gq("v2", "g", "docX"), gq("v3", "g", "docX")];
let rows = vec![
row("v1", vec![hit("docX", 3)]),
row("v2", vec![hit("docX", 25)]),
row("v3", vec![hit("other", 1)]),
];
let rep = compute_variant_consistency(&queries, &rows).unwrap();
assert_eq!(rep.total_groups, 1);
let g = &rep.groups[0];
assert_eq!(g.group, "g");
assert_eq!(g.variants.len(), 3);
// spread = max(1.0) - min(0.0) = 1.0
assert!((g.recall_spread_narrow - 1.0).abs() < 1e-6);
assert!((g.worst_recall_narrow - 0.0).abs() < 1e-6);
assert_eq!(g.mis_ranked, 1);
assert_eq!(g.missing, 1);
let classes: Vec<VariantClass> = g.variants.iter().map(|v| v.class).collect();
assert!(classes.contains(&VariantClass::Ok));
assert!(classes.contains(&VariantClass::MisRanked));
assert!(classes.contains(&VariantClass::Missing));
assert_eq!(rep.a_dominant_groups + rep.b_dominant_groups, 1); // tie→정의대로 하나로 분류
}
#[test]
fn fully_consistent_group_when_all_ok() {
let queries = vec![gq("v1", "g", "docX"), gq("v2", "g", "docX")];
let rows = vec![row("v1", vec![hit("docX", 1)]), row("v2", vec![hit("docX", 2)])];
let rep = compute_variant_consistency(&queries, &rows).unwrap();
assert_eq!(rep.fully_consistent_groups, 1);
assert!((rep.groups[0].recall_spread_narrow - 0.0).abs() < 1e-6);
}
#[test]
fn ungrouped_queries_are_ignored() {
let mut q = gq("solo", "g", "docX");
q.group = None;
let rep = compute_variant_consistency(&[q], &[row("solo", vec![hit("docX", 1)])]).unwrap();
assert_eq!(rep.total_groups, 0);
}
}
```
- [ ] **Step 3: 테스트 실패 확인**
먼저 `lib.rs`에 모듈 등록(아래 Step 5 일부 선행): `crates/kebab-eval/src/lib.rs`의 모듈 선언부에 `mod variant;` + `pub use variant::{VariantConsistencyReport, VariantGroupReport, VariantResult, VariantClass, compute_variant_consistency, compute_variant_consistency_with_config, render_variants_md};` 추가(아직 함수 미정의 → 다음 스텝에서 채움). 우선 컴파일 통과를 위해 `compute_variant_consistency`만 stub 없이 진행하면 컴파일 에러로 실패함을 확인.
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-eval -j 4 variant > /build/cache/tmp/t2.txt 2>&1; echo "EXIT=$?"`
Expected: 컴파일 에러(함수 미정의). 다음 스텝에서 구현.
- [ ] **Step 4: `compute_variant_consistency` + 헬퍼 구현**
`variant.rs`의 타입 정의 아래, `#[cfg(test)]` 위에 추가:
```rust
/// 저장된 run을 그룹으로 묶어 변형 일관성 리포트를 만든다.
/// `rows`는 [`crate::metrics::aggregate_from_rows`]와 동일한 입력
/// (저장된 per-query 결과). `group`이 없는 쿼리는 무시한다.
pub fn compute_variant_consistency(
queries: &[GoldenQuery],
rows: &[kebab_store_sqlite::EvalQueryResultRecord],
) -> Result<VariantConsistencyReport> {
let golden_by_id: HashMap<&str, &GoldenQuery> =
queries.iter().map(|q| (q.id.as_str(), q)).collect();
let mut grouped: BTreeMap<String, Vec<VariantResult>> = BTreeMap::new();
for row in rows {
let qr: QueryResult = serde_json::from_str(&row.result_json)
.with_context(|| format!("parse result_json for {}", row.query_id))?;
let Some(gq) = golden_by_id.get(qr.query_id.as_str()) else {
continue;
};
let Some(group) = gq.group.clone() else {
continue;
};
let (recall_narrow, recall_pool) = recall_narrow_pool(&qr, &gq.expected_doc_ids);
let answer_ok = qr.answer.as_ref().map(|a| {
gq.must_contain.iter().all(|s| a.answer.contains(s))
&& !gq.forbidden.iter().any(|s| a.answer.contains(s))
});
let class = classify(&gq.expected_doc_ids, recall_narrow, recall_pool);
grouped.entry(group).or_default().push(VariantResult {
query_id: qr.query_id.clone(),
query: qr.query.clone(),
recall_narrow,
recall_pool,
answer_ok,
class,
});
}
let mut groups: Vec<VariantGroupReport> = Vec::with_capacity(grouped.len());
for (group, variants) in grouped {
groups.push(rollup_group(group, variants));
}
let total_groups = u32::try_from(groups.len()).unwrap_or(u32::MAX);
let fully_consistent_groups = groups
.iter()
.filter(|g| g.recall_spread_narrow == 0.0 && g.worst_recall_narrow == 1.0)
.count() as u32;
let a_dominant_groups = groups
.iter()
.filter(|g| g.mis_ranked > 0 && g.mis_ranked >= g.missing)
.count() as u32;
let b_dominant_groups = groups
.iter()
.filter(|g| g.missing > 0 && g.missing > g.mis_ranked)
.count() as u32;
let mean_recall_spread_narrow = if groups.is_empty() {
0.0
} else {
groups.iter().map(|g| g.recall_spread_narrow).sum::<f32>() / groups.len() as f32
};
Ok(VariantConsistencyReport {
groups,
mean_recall_spread_narrow,
fully_consistent_groups,
total_groups,
a_dominant_groups,
b_dominant_groups,
})
}
/// 정답 문서 집합에 대한 recall@NARROW_K, recall@POOL_K.
/// 정답 미지정이면 (NaN, NaN).
fn recall_narrow_pool(qr: &QueryResult, expected: &[DocumentId]) -> (f32, f32) {
if expected.is_empty() {
return (f32::NAN, f32::NAN);
}
let exp: HashSet<&DocumentId> = expected.iter().collect();
let cover = |k: u32| -> f32 {
let topk: HashSet<&DocumentId> = qr
.hits_top_k
.iter()
.filter(|h| h.rank <= k)
.map(|h| &h.doc_id)
.collect();
exp.iter().filter(|d| topk.contains(*d)).count() as f32 / exp.len() as f32
};
(cover(NARROW_K), cover(POOL_K))
}
fn classify(expected: &[DocumentId], recall_narrow: f32, recall_pool: f32) -> VariantClass {
if expected.is_empty() {
VariantClass::NoExpected
} else if recall_narrow >= 1.0 {
VariantClass::Ok
} else if recall_pool > recall_narrow {
VariantClass::MisRanked
} else {
VariantClass::Missing
}
}
fn rollup_group(group: String, variants: Vec<VariantResult>) -> VariantGroupReport {
let measurable: Vec<f32> = variants
.iter()
.filter(|v| !v.recall_narrow.is_nan())
.map(|v| v.recall_narrow)
.collect();
let (recall_spread_narrow, worst_recall_narrow) = if measurable.is_empty() {
(0.0, f32::NAN)
} else {
let max = measurable.iter().cloned().fold(f32::MIN, f32::max);
let min = measurable.iter().cloned().fold(f32::MAX, f32::min);
(max - min, min)
};
let answer_flags: Vec<bool> = variants.iter().filter_map(|v| v.answer_ok).collect();
let answer_consistency = if answer_flags.is_empty() {
None
} else {
Some(answer_flags.iter().all(|&ok| ok))
};
let mis_ranked = variants.iter().filter(|v| v.class == VariantClass::MisRanked).count() as u32;
let missing = variants.iter().filter(|v| v.class == VariantClass::Missing).count() as u32;
VariantGroupReport {
group,
variants,
recall_spread_narrow,
worst_recall_narrow,
answer_consistency,
mis_ranked,
missing,
}
}
/// 활성 XDG Config로 저장된 run을 읽어 변형 일관성을 계산
/// ([`crate::metrics::compute_aggregate_with_config`]와 동일한 로딩 패턴).
pub fn compute_variant_consistency_with_config(
cfg: &Config,
run_id: &str,
) -> Result<VariantConsistencyReport> {
let store = SqliteStore::open(cfg).context("open SqliteStore for variant consistency")?;
store.run_migrations().context("run migrations")?;
if store.load_eval_run(run_id).context("load eval_runs row")?.is_none() {
anyhow::bail!("compute_variant_consistency: no eval_runs row for run_id {run_id}");
}
let rows = store
.load_eval_query_results(run_id)
.context("load eval_query_results")?;
let queries = crate::metrics::load_golden_for_metrics_pub()?;
compute_variant_consistency(&queries, &rows)
}
```
주: `compute_variant_consistency_with_config`는 golden 로드에 `metrics`의 비공개 헬퍼가 필요하다. `crates/kebab-eval/src/metrics.rs``fn load_golden_for_metrics()``pub(crate) fn load_golden_for_metrics_pub()`로 노출하는 얇은 래퍼를 추가하거나, 기존 `load_golden_for_metrics``pub(crate)`로 바꿔 `crate::metrics::load_golden_for_metrics()`로 직접 호출. **후자 채택**: `metrics.rs``fn load_golden_for_metrics``pub(crate) fn load_golden_for_metrics`로 변경하고, 위 호출을 `crate::metrics::load_golden_for_metrics()?`로 수정.
- [ ] **Step 5: 렌더 함수 + lib.rs 등록**
`variant.rs`에 사람이 읽는 표 렌더 추가(`#[cfg(test)]` 위):
```rust
/// 변형 일관성 리포트를 사람이 읽는 마크다운 표로 렌더
/// ([`crate::render_report_md`] 스타일).
pub fn render_variants_md(rep: &VariantConsistencyReport) -> String {
use std::fmt::Write;
let mut s = String::new();
let _ = writeln!(s, "# Variant consistency\n");
let _ = writeln!(
s,
"groups={} fully_consistent={} A_dominant={} B_dominant={} mean_spread@{}={:.3}\n",
rep.total_groups,
rep.fully_consistent_groups,
rep.a_dominant_groups,
rep.b_dominant_groups,
NARROW_K,
rep.mean_recall_spread_narrow,
);
for g in &rep.groups {
let ac = match g.answer_consistency {
Some(true) => "all-ok",
Some(false) => "MIXED",
None => "n/a",
};
let _ = writeln!(
s,
"## {} — spread@{}={:.2} worst={:.2} A={} B={} answers={}",
g.group, NARROW_K, g.recall_spread_narrow, g.worst_recall_narrow, g.mis_ranked, g.missing, ac
);
let _ = writeln!(s, "| variant | recall@{NARROW_K} | recall@{POOL_K} | class | answer |");
let _ = writeln!(s, "|---|---|---|---|---|");
for v in &g.variants {
let ans = match v.answer_ok {
Some(true) => "ok",
Some(false) => "BAD",
None => "-",
};
let _ = writeln!(
s,
"| {} | {:.2} | {:.2} | {:?} | {} |",
v.query, v.recall_narrow, v.recall_pool, v.class, ans
);
}
let _ = writeln!(s);
}
s
}
```
`crates/kebab-eval/src/lib.rs`: 모듈 선언 영역에 `mod variant;` 추가, re-export에 추가:
```rust
pub use variant::{
VariantClass, VariantConsistencyReport, VariantGroupReport, VariantResult,
compute_variant_consistency, compute_variant_consistency_with_config, render_variants_md,
};
```
(기존 `pub use` 패턴은 `lib.rs`에서 `compare`/`metrics` re-export를 보고 맞춤. 정확한 위치/형식은 그 패턴을 따른다.)
- [ ] **Step 6: 테스트 + clippy 통과 확인**
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-eval -j 4 > /build/cache/tmp/t2b.txt 2>&1; echo "EXIT=$?"`
Expected: 3개 신규 variant 테스트 + 기존 테스트 모두 PASS. EXIT=0. (기존 `aggregate` 테스트가 그대로 통과 = group=None 경로 불변 회귀 가드)
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo clippy -p kebab-eval --all-targets -j 4 -- -D warnings > /build/cache/tmp/c2.txt 2>&1; echo "EXIT=$?"`
Expected: EXIT=0.
- [ ] **Step 7: 커밋**
```bash
git add crates/kebab-eval/src/variant.rs crates/kebab-eval/src/lib.rs crates/kebab-eval/src/metrics.rs
git commit -m "feat(eval): 변형 일관성 메트릭 + A/B(순위출렁/어휘격차) 분류"
```
---
## Task 3: CLI `kebab eval variants <run_id>` 서브커맨드
**모델:** sonnet (작은 CLI 배선)
**Files:**
- Modify: `crates/kebab-cli/src/main.rs` (`EvalWhat` enum ~414 + `Cmd::Eval` 매치 ~1361)
- Test: 수동 (Task 5에서 실제 run으로 검증) + 컴파일/clippy
- [ ] **Step 1: `EvalWhat::Variants` 변형 추가**
`crates/kebab-cli/src/main.rs``enum EvalWhat``Aggregate` 변형 옆으로 추가 (clap 파생 스타일은 인접 변형을 그대로 따른다):
```rust
/// 변형 그룹 일관성 진단 — 같은 의도의 여러 표현에서 recall@10 vs
/// recall@50 대비로 (A)순위출렁/(B)어휘격차를 판별.
Variants {
/// 진단할 저장된 run_id.
run_id: String,
/// JSON으로 출력 (기본은 마크다운 표).
#[arg(long)]
json: bool,
},
```
- [ ] **Step 2: `Cmd::Eval` 매치 암(arm) 추가**
`Cmd::Eval { what } => { match what { ... } }` 내부, `EvalWhat::Aggregate { .. } => { .. }` 암 다음에:
```rust
EvalWhat::Variants { run_id, json } => {
let rep = kebab_eval::compute_variant_consistency_with_config(&cfg, run_id)?;
if *json {
println!("{}", serde_json::to_string_pretty(&rep)?);
} else {
print!("{}", kebab_eval::render_variants_md(&rep));
}
}
```
(`cfg`는 같은 스코프에서 `EvalWhat::Aggregate` 암이 쓰는 것과 동일하게 로드됨 — 그 암의 `cfg` 획득 방식을 그대로 따른다. `run_id``&String`이면 `compute_..._with_config(&cfg, run_id)`로 deref 강제됨; 필요시 `run_id.as_str()`.)
- [ ] **Step 3: 빌드 + clippy 통과 확인**
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo build -p kebab-cli -j 4 > /build/cache/tmp/t3.txt 2>&1; echo "EXIT=$?"`
Expected: EXIT=0.
Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo clippy -p kebab-cli --all-targets -j 4 -- -D warnings > /build/cache/tmp/c3.txt 2>&1; echo "EXIT=$?"`
Expected: EXIT=0.
- [ ] **Step 4: 커밋**
```bash
git add crates/kebab-cli/src/main.rs
git commit -m "feat(cli): kebab eval variants <run_id> — 변형 일관성 진단 리포트"
```
---
## Task 4: dogfood golden_queries.yaml 변형 그룹 큐레이션
**모델:** opus (정답 문서를 corpus 의미로 판정 — 판단 필요)
**Files:**
- Modify: `/build/dogfood/golden_queries.yaml` (in-repo 아님 — dogfood 데이터)
**큐레이션 원칙 (순환 회피, [[feedback_search_quality_dogfood]]):** 정답 *문서*는 corpus 의미로
판정한다. **검색 결과 상위를 정답으로 베끼지 말 것.** 의도에 맞는 문서를 corpus 내용으로 고른 뒤,
그 문서의 doc_id/chunk_id를 SQLite에서 조회한다.
- [ ] **Step 1: 의도(그룹) 610개 선정**
선행 ablation 토픽 재사용 + 동의어/다른어휘/풀어쓴문장 추가. 후보 의도(각 그룹 35 표현):
| group | 표현 예시 (한/영/동의어/풀어쓴문장) |
|---|---|
| `ownership` | "러스트 소유권" / "rust ownership" / "러스트 메모리 소유권 규칙" / "who owns a value in rust" |
| `lifetime` | "러스트 lifetime" / "rust lifetime" / "러스트 수명" / "빌림 검사기 수명" |
| `database_index` | "데이터베이스 인덱스" / "database index" / "DB 색인" / "쿼리 빠르게 하는 인덱스" |
| `gc` | "가비지 컬렉션" / "garbage collection" / "자동 메모리 회수" |
| `async` | "비동기 프로그래밍" / "async programming" / "논블로킹 동시성" |
| `kubernetes_deploy` | "쿠버네티스 배포" / "kubernetes deployment" / "k8s 앱 배포" |
(corpus에 명확한 정답 문서가 없는 의도는 제외. rust류 + 일반 토픽 섞기.)
- [ ] **Step 2: 각 의도의 정답 문서를 corpus 의미로 판정 + ID 조회**
dogfood KB(`/build/dogfood/config.toml`)에서, 의도별로 corpus 내용상 그 주제를 다루는 문서를
식별한다. doc_id/chunk_id 조회 (release 바이너리):
```bash
BIN=/build/out/cargo-target/target/release/kebab
CFG=/build/dogfood/config.toml
# 후보 문서를 폭넓게 본 뒤 내용으로 정답 판정 (상위 1개 자동채택 금지):
$BIN search "rust ownership" --config $CFG --mode hybrid --k 20 --json --quiet \
| python3 -c 'import sys,json; [print(h["doc_id"], h.get("doc_path"), h["chunk_id"]) for h in json.load(sys.stdin)["hits"]]'
```
각 그룹마다: 내용으로 맞는 문서 12개의 `doc_id`(+대표 `chunk_id`)를 확정. 같은 그룹의 모든 변형은
**동일한 `expected_doc_ids`** 를 갖는다(Task 1의 정합성 검증이 강제).
- [ ] **Step 3: must_contain 핵심 사실 큐레이션 (그룹 공유)**
각 그룹에 답변이 반드시 포함해야 할 핵심 substring 12개 (정답 문서 내용에서 발췌). 한/영 답변
모두에서 성립하는 표현으로 (예: 고유명사·숫자·식별자). 너무 길거나 표현 특정적이면 피한다.
- [ ] **Step 4: yaml에 그룹 엔트리 추가**
`/build/dogfood/golden_queries.yaml`에 그룹별로 추가 (기존 dg0xx 엔트리는 유지). 형식:
```yaml
# --- variant groups (paraphrase robustness, 2026-05-29) ---
- id: vg_ownership_ko
query: "러스트 소유권"
lang: ko
group: ownership
difficulty: medium
expected_doc_ids: ["<조회한 doc_id>"]
expected_chunk_ids: ["<조회한 chunk_id>"]
must_contain: ["<핵심 사실>"]
- id: vg_ownership_en
query: "rust ownership"
lang: en
group: ownership
difficulty: medium
expected_doc_ids: ["<같은 doc_id>"]
expected_chunk_ids: ["<같은 chunk_id>"]
must_contain: ["<같은 핵심 사실>"]
# ... (그룹당 35 변형, 그룹 610개)
```
- [ ] **Step 5: 로드 검증 (정합성 + ID 실재)**
release 바이너리로 eval run 시작 직전까지 가서 loader가 통과하는지 확인 (Task 5의 run이 시작 시
ID 실재 + 그룹 정합성을 검증 → bail 안 하면 OK). 빠른 단독 검증:
```bash
KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml \
$BIN eval run --config $CFG --mode hybrid --k 50 --json --quiet > /build/cache/tmp/t4_loadcheck.txt 2>&1
echo "EXIT=$?" # 0 또는 run 진행이면 로드 통과; "duplicate"/"divergent"/"missing" 이면 수정
```
(이 run 자체가 Task 5의 측정으로 이어짐 — 여기선 로드 통과만 확인.)
- [ ] **Step 6: 커밋 불요 (dogfood 데이터)**
`/build/dogfood/`는 repo 밖. 큐레이션 결과는 Task 5 측정 후 HOTFIXES에 그룹 목록을 요약 기록.
---
## Task 5: 측정 실행 + (A)/(B) 진단 리포트
**모델:** 오케스트레이터(나) 직접 또는 sonnet
**Files:**
- 산출: `/build/cache/tmp/rr_variant_*.txt`, `tasks/HOTFIXES.md`(dated entry), 핸드오프 갱신
- [ ] **Step 1: release 빌드**
Run (background): `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo build --release -p kebab-cli -j 4 > /build/cache/tmp/rr_variant_build.txt 2>&1; echo "EXIT=$?"`
Expected: EXIT=0. 바이너리 mtime이 갱신됐는지 확인.
- [ ] **Step 2: eval run (k=50, hybrid + vector, with-rag)**
```bash
BIN=/build/out/cargo-target/target/release/kebab
CFG=/build/dogfood/config.toml
export KEBAB_EVAL_GOLDEN=/build/dogfood/golden_queries.yaml
# 검색 전용(빠름) — recall 진단의 핵심:
$BIN eval run --config $CFG --mode hybrid --k 50 > /build/cache/tmp/rr_variant_run_hybrid.txt 2>&1; echo "EXIT=$?"
# run_id를 출력에서 추출 (clean grep)
```
`--with-rag`는 answer_consistency가 필요할 때만 (LLM 비용 큼). 1차는 검색 전용으로 recall 기반
A/B 진단부터. answer_consistency는 별도 `--with-rag` run으로.
- [ ] **Step 3: variants 리포트 산출**
```bash
$BIN eval variants <run_id> --config $CFG > /build/cache/tmp/rr_variant_report_hybrid.txt 2>&1; echo "EXIT=$?"
$BIN eval variants <run_id> --config $CFG --json > /build/cache/tmp/rr_variant_report_hybrid.json 2>&1; echo "EXIT=$?"
```
- [ ] **Step 4: 결과 Read 검증 + A/B 판정**
`/build/cache/tmp/rr_variant_report_hybrid.txt`를 Read로 직접 확인 (측정값 추측 절대 금지,
[[project_rerank_experiment]] 교훈). 판정:
- `a_dominant_groups > b_dominant_groups` → (A) 우세 → Phase 2 처방 = near-tie 흡수.
- `b_dominant_groups > a_dominant_groups` → (B) 우세 → Phase 2 처방 = 쿼리 확장/번역.
- 혼재면 그룹별로 분리 처방 + 토픽 특성 기록.
- [ ] **Step 5: HOTFIXES + 핸드오프 기록**
`tasks/HOTFIXES.md`에 dated entry: 그룹 목록, recall_spread/worst 표, A/B 분류, Phase 2 방향.
핸드오프 문서에 측정 결과 + Phase 2 게이트 결정.
```bash
git add tasks/HOTFIXES.md docs/superpowers/handoffs/2026-05-29-crossscript-rerank-progress-handoff.md
git commit -m "docs: 변형 일관성 측정 결과 + Phase 2 처방 방향 (A/B 진단)"
```
---
## Self-Review (작성자 점검)
**1. Spec coverage:**
- spec §2 Phase 1 "변형 그룹 + 일관성 메트릭 + A/B 판별 + 큐레이션 + 측정" → Task 1(그룹), Task 2(메트릭+A/B), Task 3(surface), Task 4(큐레이션), Task 5(측정). ✓
- spec §3 "kebab-eval 단독, AggregateMetrics 불변" → Task 2 Step 6이 기존 테스트 통과로 회귀 가드. ✓
- spec §5 "clean 측정 + Read 검증 + baseline이 deliverable" → Task 5 Step 4. ✓
- spec §7 미결: group 정합성=bail(Task 1), A/B 임계=classify 정의(Task 2), surface=`eval variants`(Task 3), 큐레이션(Task 4), must_contain(Task 4 Step 3). ✓
**2. Placeholder scan:** Task 4의 `<조회한 doc_id>` 등은 데이터 큐레이션의 실제 조회 산출물(코드 placeholder 아님). 코드 스텝은 전부 완성 코드. ✓
**3. Type consistency:** `compute_variant_consistency(queries, rows)` 시그니처가 Task 2 정의 ↔ Task 2 `_with_config` 호출 ↔ Task 3 CLI 호출에서 일치. `VariantConsistencyReport`/`render_variants_md` 이름이 lib.rs re-export(Task 2 Step 5) ↔ CLI(Task 3 Step 2)에서 일치. `EvalQueryResultRecord{query_id, result_json}` 필드가 Task 2 테스트 ↔ 실제 metrics.rs 사용과 일치. ✓
**의존성 주의:** Task 2가 `metrics::load_golden_for_metrics``pub(crate)`로 승격(Step 4 주석) → 그 변경이 Task 2 커밋에 포함됨(`git add ... metrics.rs`). Task 3는 Task 2의 re-export에 의존 → 순서 준수.

View File

@@ -0,0 +1,397 @@
# 별칭 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<String, String> =
[("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<EmbeddingInput<'_>> = 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<SearchHit> = Vec::with_capacity(k.min(raw_hits.len()));
let mut seen: std::collections::HashSet<String> = 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<VectorHit>` 라 `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 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.
- [ ] **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 세션 직접.

View File

@@ -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<String>` 필드 | 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<String>,
/// 색인시 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<String>,
```
- [ ] **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<String, String> = [
("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::<usize>() {
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<Result<TokenChunk>>` 를 모아 문자열로 합치고, 줄 단위 파싱 → 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<String> {
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<String> {
let mut out: Vec<String> = 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<String> {
build_match_string_for_column(text, "text")
}
/// `column` 은 FTS5 컬럼 필터 prefix ("text" 또는 "aliases").
fn build_match_string_for_column(text: &str, column: &str) -> Option<String> {
// ... 기존 본문 (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<Vec<RawRow>> {
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<dyn ToSql>,
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<RawRow>, alias: Vec<RawRow>, limit: usize) -> Vec<RawRow> {
use std::collections::HashSet;
let mut seen: HashSet<String> = 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<X.Y.Z>-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 <run_id> --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 <run_id> --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<X.Y.Z>-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<String>(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.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,102 @@
---
title: 어휘격차(vocabulary-gap) pool-miss 해결 — 딥리서치 레퍼런스
date: 2026-05-30
type: research-reference
provenance:
- "deep-research 워크플로 (wf_e76011c6-de8): 5 angle, 22 sources fetch, 103 claims 추출, 25 claim 3-vote 적대적 검증, 22 confirmed / 3 killed. 104 agent, ~3.5M subagent tokens."
related:
- docs/superpowers/handoffs/2026-05-29-query-paraphrase-robustness-phase1-handoff.md (B 어휘격차 우세 진단)
- docs/superpowers/research/2026-05-29-crossscript-synonym-retrieval-research.md (선행 — rerank 결정 중심)
- memory: project_paraphrase_robustness, project_crossscript_diagnosis
---
# 어휘격차 pool-miss 해결 — 딥리서치 레퍼런스
> Phase 1 진단(변형 일관성 측정)에서 **B(어휘격차) 우세** 확인 — 같은 의미를 다른 단어로 물으면
> 정답이 top-50 pool 에도 안 들어옴(recall@50=0), rerank 불가. 그 실패 모드 전용 처방을 조사.
## 0. 질문 (요약)
CPU-only 로컬 RAG(FTS5/BM25 + LanceDB dense e5-large + RRF, Rust/fastembed-rs/LanceDB) 에서
"같은 의미 다른 표현(동의어·풀어쓴 문장·한/영)이 top-50 pool 에도 못 들어오는" 실패를 가장
좋은 비용대비로 고치는 법. 제약: per-query LLM 확장 거부("밑 빠진 독"), e5-large 유지(bge-m3
dense 는 실측 더 나빴음), 코드 식별자 정확매칭 보존.
## 1. 적대적 검증 통과 결론 (confirmed)
### 1.1 색인시 doc-side expansion(doc2query/docTTTTTquery)이 pool-miss 의 최선책 (3-0)
- **유일하게 lexical pool 자체를 키운다**(rerank 아님): docTTTTTquery README — MS MARCO doc
Recall@1000 0.9180→0.9490(rerank 없는 1차 BM25 지표 → pool 진입 증가 실증). passage MRR@10
18.6→27.2, **per-query +9ms 만**(55→64ms, T5 추론은 색인시 1회·query 무영향).
- **메커니즘이 어휘격차 정조준**: SIGIR 2024 — N개 예측 query append 가 없던 term 주입(TF↑),
Doc2Query model card "generated queries contain synonyms → close the lexical gap".
- **Doc2Query++(2510.09557, 2025-10)**: 5개 BEIR 에서 sparse·dense 둘 다 **Recall@100** 개선
(SCIDOCS 0.3323→0.3749, FiQA 0.5864→0.6197). Recall@100=pool-membership → pool 확장 확인.
- 출처: github.com/castorini/docTTTTTquery, doc2query/msmarco-14langs-mt5-base-v1, mzzm24-sigir, 2510.09557.
### 1.2 ⚠️ vanilla 다국어 doc2query(mt5)는 한/영을 못 잇는다 (3-0)
- model card: "input passage 의 **같은 언어**로 query 생성". mMARCO 14개 언어에 **한국어 없음**.
- 귀결: doc2query 단독은 영어 paraphrase·동의어 pool-miss(raft "how nodes agree…")는 고치나,
**KO↔EN 갭(역전파→영어 backprop doc)은 못 고침** → 색인시 *교차언어* 대체 query 생성이 추가로 필요.
### 1.3 MILCO(교차언어 learned-sparse)가 한/영 갭 직접 해결, 단 배포 경로 없음 (3-0)
- 2510.00671(2025-10): query·doc 를 "공유 English lexical space"로 매핑. MKQA(한국어 포함)
zero-shot R@100 **76.6**(BGE-M3-Sparse +69%, BM25 +92%). **그러나 560M 연구모델, ONNX/
fastembed-rs 체크포인트 미확인** → research signal, turnkey 아님. (0.61ms 는 index lookup 만, 인코딩 제외.)
### 1.4 BGE-M3 sparse 채널은 CPU-native 추가 가능, 단 한/영은 못 고침 (3-0)
- 1 forward 로 dense+sparse+ColBERT 산출, fastembed-rs `BGEM3Q`(CPU 양자화, CUDA 주면 오히려 실패).
- 단일언어 향상: MIRACL Dense+Sparse 68.9 > Dense 67.8 → **3rd RRF 채널 후보**.
- **교차언어 약함**: BGE-M3 논문도 sparse cross-lingual MKQA 45.3 vs dense 67.8 "다른 언어라 공존 term
거의 없음". → KO↔EN 갭엔 무용. ("sparse 가 모든 언어서 BM25 압도" 주장은 **0-3 기각**.)
### 1.5 turnkey SPLADE 는 새 corpus 에서 자동 해결 못 함 (3-0)
- LSR 은 term expansion 으로 어휘격차 겨냥하나, SOTA(Echo-Mistral-SPLADE BEIR 55.07)는 **Mistral-7B
로 학습**(무겁다). AACL 2022: "SPLADE 는 저빈도 단어 exact match 에 약함 + 어휘/빈도 domain shift
시 성능 저하". → 개인 혼합 KO/EN corpus 에 drop-in 기대 금물, 코드 식별자 정확매칭도 약점.
### 1.6 query-side(HyDE, Vector-PRF)는 이 제약에 부적합 (confirmed)
- **HyDE**: query 마다 LLM 이 가설답변 생성(1~5s/query) = 사용자가 거부한 "밑 빠진 독" 바로 그것.
- **Vector-PRF**: per-query 생성은 피하나 2-pass 필요 + **recall 개선 주장 0-3 기각**(1.6/6.2/7.7%
Recall@100 gain 전부 refute). → 이 실패 모드 해결 증거 없음.
## 2. 기각 (killed — 믿지 말 것)
- "BGE-M3 sparse 가 모든 언어서 BM25 압도" (0-3).
- "Vector-PRF 가 Recall@100 을 1.6~7.7% 올린다(pool 확장)" (0-3).
- "Vector-PRF 가 여러 데이터셋서 dense 효과 개선" (0-3).
## 3. 권고 (minimal combination, medium conf — 합성/추론)
**(1) 색인시 doc2query-style 확장 → 별도 FTS5 lexical 필드** (원문 body 필드는 그대로 verbatim
index → 코드 식별자 정확매칭 보존, append-not-replace). RRF 가 {body-BM25, expansion-BM25, e5-dense} 융합.
**(2) 문서당 같은언어 query + 소수의 교차언어(KO↔EN) 대체 표현/번역**을 색인시 1회 생성(로컬 LLM
= gemma, **per-query 아님 → 사용자 제약 충족**). 역전파→backprop doc 의 직접 해법.
**(3) (선택) BGE-M3 sparse(fastembed-rs BGEM3Q)를 4th RRF 채널**로 단일언어 lift, e5-large dense 유지.
필터(Doc2Query--/++ topic-coverage)로 환각·index 팽창 제어.
- 사용자 제약 충족: 색인시 1회(per-query LLM 아님) + e5-large 유지 + 정확매칭 보존.
- 엄격 no-LLM 원하면 (2) 대신 seq2seq mt5 doc2query 로 폴백(단 한국어 미커버 → 한/영 갭 부분만 해결).
## 4. 미해결 질문 (= Phase 2 실험 설계, **기존 variant eval 로 측정 가능**)
1. **색인시 KO↔EN 대체 query 생성이 우리 corpus 에서 recall@50 을 0→양수로 올리나?** — 핵심 미검증
고리. `/build/dogfood` golden + `kebab eval variants` 로 직접 측정(또 프록시 금지).
2. ONNX/fastembed 호환 교차언어 learned-sparse(MILCO 또는 distill) 체크포인트가 있나, 아니면
교차언어는 전적으로 색인시 doc expansion 으로만 풀어야 하나.
3. doc2query 가 개인 KB(수천 doc/수만 chunk)의 FTS5 index 를 얼마나 부풀리나. Doc2Query--/++ 필터
가치 있나 vs plain mt5.
4. e5 dense 유지하고 BGE-M3 **sparse 만** 추가 시 paraphrase/동의어 recall@50 순이득인가, 약한
다국어 sparse 가 RRF 에 노이즈만 더하나.
## 5. 핵심 caveat (시점 민감)
- 최강 교차언어 근거(MILCO, Doc2Query++)는 2025-10 단일 논문·저자 보고 벤치 — research signal.
- **교차언어 권고(색인시 KO↔EN 생성)는 합성/추론** — "index-time LLM translation 이 한/영 recall@50
갭을 닫는다"를 직접 벤치한 논문 없음. confirmed fact 들의 논리적 조합. → **우리 corpus 측정 필수**.
- docTTTTTquery 의 MS MARCO recall 증가는 modest(+3.4% rel) — 순수 pool-rescue 크기는 우리 corpus 미검증.
- 정확매칭 보존은 architectural 논증(별도 필드), 코드 corpus 직접 측정 아님.
## 6. 출처
docTTTTTquery — github.com/castorini/docTTTTTquery · Doc2Query++ — arxiv 2510.09557 ·
mt5 14-lang doc2query — hf.co/doc2query/msmarco-14langs-mt5-base-v1 · SIGIR2024 doc-exp — jmmackenzie.io/pdf/mzzm24-sigir.pdf ·
MILCO — arxiv 2510.00671 · BGE-M3 — arxiv 2402.03216 · bge-m3-onnx — github.com/yuniko-software/bge-m3-onnx ·
Mistral-SPLADE — arxiv 2408.11119 · SPLADE domain-shift — arxiv 2211.03988 · HyDE/PRF — arxiv 2511.19349, 2504.01448, 2108.11044 ·
fastembed-rs — github.com/Anush008/fastembed-rs · KURE — github.com/nlpai-lab/KURE · arctic-embed-ko — hf.co/dragonkue/snowflake-arctic-embed-l-v2.0-ko

View File

@@ -0,0 +1,128 @@
---
title: Query-paraphrase robustness — 변형 일관성 평가 프레임워크 (측정 먼저)
date: 2026-05-29
status: design (approved-to-plan)
related:
- docs/superpowers/handoffs/2026-05-29-crossscript-rerank-progress-handoff.md
- docs/superpowers/specs/2026-05-29-crossscript-rerank-experiment-design.md (선행 실험 — overlap 프록시의 한계)
- memory: project_crossscript_diagnosis, project_rerank_experiment, project_ranking_deferred, feedback_search_quality_dogfood
goal_reframe: "한/영 cross-script overlap → 같은 의미의 다양한 표현(동의어·다른 어휘·풀어쓴 문장·한/영)에서 일관되게 좋은 답"
---
# Query-paraphrase robustness — 변형 일관성 평가 프레임워크
## 0. 한 문단 요약
같은 의미를 다른 표현(동의어, 다른 어휘, 풀어쓴 문장, 한국어/영어)으로 물어도 **답변 품질이
일관되게 좋아야 한다**는 것이 목표다. 지난 cross-encoder reranker 실험은 "한/영 top-k 겹침
(overlap)"이라는 **프록시 지표**를 최적화하다 헛돌았다 (full-chunk-text 까지 시도했으나 회귀가
1:1 재현 — 가설 반증, 핸드오프 참조). 이번엔 처방을 만들기 전에 **진짜 지표(변형 간 답변 품질
일관성)를 직접 재는 평가 프레임워크**를 먼저 만든다. 이 평가가 (A) "핵심 문서는 후보 풀에
들어왔는데 순위만 출렁" 인지 (B) "다른 단어로 물으니 핵심 문서가 아예 후보에서 빠짐" 인지를
숫자로 판별하고, 그 결과에 따라 처방(near-tie 흡수 vs 쿼리 확장)을 별도 spec 으로 확정한다.
**본 spec 의 구현 범위는 Phase 1 (평가 프레임워크) 까지.** Phase 2 (처방) 는 측정 결과 게이트
뒤의 조건부 설계다.
## 1. 진단 근거 (왜 측정 먼저인가)
- **확정된 근본 원인** ([[project_crossscript_diagnosis]]): vector near-tie 불안정 — 상위 후보들의
cosine 점수가 Δ0.003~0.005 로 다닥다닥 붙어, "top-k" 라는 칼같은 cutoff 가 near-tie 뭉치
한가운데를 지나면 표현 차이(동의어/한영)가 핵심 문서를 9등↔11등으로 흔든다.
- **사용자 실제 불편** (2026-05-29 brainstorm): "한쪽 답이 나쁘다" — 겹침이 아니라 **답변 품질
비대칭**이 핵심. 어느 쪽이 나쁠지는 **쿼리마다 다름** → 특정 언어의 구조적 결함이 아니라
near-tie 불안정이 표현마다 다르게 발현.
- **선행 실험의 교훈** ([[project_rerank_experiment]]): overlap 프록시 최적화 → cross-encoder 가
한/영 query 로 pool 을 독립 재정렬해 토픽별 수렴/발산. full-chunk-text 로도 database 4 등
회귀가 그대로. **원인(near-tie)을 모르고 프록시를 최적화하면 또 헛돈다.** → 측정 선결.
- **(A) vs (B) 미해결**: 표현이 바뀔 때 핵심 문서가 ① 후보 풀엔 있는데 순위만 밀린 건지(A,
near-tie), ② 후보 풀에서 아예 빠진 건지(B, 어휘 격차) 모른다. 처방이 완전히 다르므로 먼저 측정.
## 2. 범위 (scope)
**Phase 1 (본 spec 구현 대상):**
- `kebab-eval` 의 golden suite 에 **변형 그룹(intent group)** 개념 추가 — 같은 의도의 여러
표현이 같은 정답(expected_doc_ids / expected_chunk_ids / must_contain)을 공유.
- **변형 일관성 메트릭** 산출: 그룹 내 recall/답변정답 의 분산(spread)·최악값, 그리고
recall@pool vs recall@k 대비로 (A)/(B) 자동 분류.
- dogfood KB 에 큐레이션된 변형 그룹 ~610 개 (각 35 표현). 정답 문서는 **corpus 의미로 판정**
(순환 회피, [[feedback_search_quality_dogfood]]).
- 측정 실행 → (A)/(B) 진단 리포트 → Phase 2 결정 게이트.
**Phase 2 (조건부, 별도 spec — 본 구현 제외):**
- (A) 우세 → near-tie 밴드 흡수 (cutoff 를 near-tie band 까지 확장; 검색 순서 불변, 저위험).
- (B) 우세 → 쿼리 확장/번역 (로컬 LLM).
- Phase 1 평가셋으로 처방 효과를 진짜 지표로 검증.
**비범위 (YAGNI):**
- LLM-judge 기반 답변 채점. `must_contain`/`forbidden` substring groundedness 가 이미 있고
Phase 1 진단엔 충분. 필요성은 Phase 1 결과가 정한다.
- 임베딩 모델 교체(③). 전체 재임베딩 cascade 비용 + 효과 불확실 → 측정 후 최후 옵션.
- ranking 파라미터 자동 조정 ([[project_ranking_deferred]] 와 충돌 — 처방은 명시적 flag/설정).
## 3. 구조 (크레이트 경계 — design §8 준수)
- **`kebab-eval` 단독 변경.** retrieval/embedding/LLM 크레이트 직접 import 금지 규칙 유지
(runner 만 `kebab-app` facade 사용 — P5-1 상속).
- `types.rs`: `GoldenQuery``group: Option<String>` 추가 (backward-compat — 기존 쿼리는
`None`, 단독 그룹 취급). yaml 역직렬화 optional.
- `metrics.rs`: 기존 per-query 집계는 불변. per_query 결과를 `group` 으로 묶는 **변형 일관성
집계** 함수 신규 (`compute_variant_consistency` 류). 기존 `AggregateMetrics` 는 안 건드림.
- `loader.rs`: 그룹 필드 로드 + 그룹 내 expected 정합성 검증(같은 그룹은 같은 expected 공유
권장 — 경고/허용 정책은 plan 에서 확정).
- 측정 실행/리포트: 기존 `eval` 경로 재사용 (`--with-rag` 로 답변까지). 신규 metric 은 JSON
리포트 + 사람이 읽는 요약 (CLI surface 확정은 plan).
## 4. 데이터 흐름
```
golden_queries.yaml (변형 그룹 포함)
└─ loader → Vec<GoldenQuery>{group}
└─ runner (eval --with-rag, mode=hybrid/vector) → per_query: {recall@k, answer.must_contain pass}
├─ 기존 AggregateMetrics (전체 hit@k/MRR/recall) — 불변
└─ NEW: group 으로 묶어 변형 일관성:
· recall_spread@k = maxmin recall@k (그룹 내) → 0 이면 완전 일관
· worst_recall@k = min recall@k (약한 표현)
· answer_consistency = 모든 변형이 must_contain 통과한 그룹 비율
· A/B 분류: 변형별 (recall@pool_k high & recall@k low) → A(순위), recall@pool_k low → B(어휘)
```
`pool_k` = 진단용 넓은 후보 폭 (예: 50). near-tie 가설 검증을 위해 좁은 k(=답변 context 폭)와
넓은 pool 을 둘 다 측정.
## 5. 측정 / 수용 기준
- 변형 그룹 ≥6 개 (한/영 쌍 + 동의어 + 다른 어휘 + 풀어쓴 문장 골고루), 각 ≥3 표현.
- 평가 실행이 **clean**(err=0) 하고 결과가 파일로 추출 후 Read 검증됨 ([[project_rerank_experiment]]
교훈 — 측정값 추측 금지, grep clean 추출 후에만 기록).
- 산출물: 그룹별 recall_spread@k, worst_recall, answer_consistency + A/B 분류 표.
- **수용 기준은 "처방이 좋아지는지" 가 아니라 "진단이 나오는지"** — Phase 1 은 측정 프레임워크
완성 + (A)/(B) 판별이 목표. baseline 숫자 자체가 deliverable.
- 회귀 가드: 기존 golden suite 21쿼리의 AggregateMetrics 가 변형 그룹 추가 후에도 동일하게
계산되어야 함 (group=None 경로 불변 — 기존 테스트 green).
## 6. 롤백 / 버전
- `group` 필드는 additive — 기존 yaml/스키마 backward-compat, 버전 cascade 트리거 아님.
- 평가 전용 변경이라 wire schema (`search_hit.v1`/`answer.v1`) 불변. 바이너리 surface 변경 없음
(eval CLI 리포트 항목 추가 가능 — additive).
- golden_queries.yaml 의 변형 그룹은 dogfood KB 스냅샷(docs=3940 / chunks=34896, 2026-05-28)에
큐레이션 — reset/re-ingest 시 chunk_id stale → runner bail. 재큐레이션 정책은 기존과 동일.
## 7. 미결 (구현 계획에서 확정)
- `group` 정합성: 같은 그룹이 서로 다른 expected 를 가질 때 — 에러 bail vs 경고+합집합. (권장:
같은 그룹 = 같은 expected 강제, 위반 시 loader bail.)
- A/B 분류 임계값: "recall@pool_k high" 의 high 기준, near-tie band Δ 정의 (진단 리포트용).
- 변형 일관성 metric 의 CLI/JSON surface 형태 (기존 `eval` 출력에 합칠지 별도 서브커맨드일지).
- 변형 그룹 큐레이션: 어떤 의도 ~610 개를 고를지 — dogfood corpus 에서 한/영 양쪽으로 명확한
정답 문서가 있는 토픽 선정 (rust 류 + 일반 토픽 섞기, 선행 ablation 토픽 재사용 가능).
- 답변 정답 신호: `must_contain` 큐레이션 방식 (핵심 사실 substring) — 그룹 내 공유.
## 8. 실행 방식 (사용자 지정, 2026-05-29)
- spec → plan → subagent 구현. 각 작업·리뷰는 **OMC teammate** (tmux pane spawn,
[[feedback_teammate_spawn_mode]] / [[feedback_omc_teams_usage]]).
- 작은 작업은 sonnet, 복잡 작업은 opus ([[feedback_teammate_model_routing]] 조정).
- 테스트용 데이터는 dogfood 데이터셋(`/build/dogfood/corpus`, `/build/dogfood/golden_queries.yaml`)
에서 가져올 수 있음.
- 빌드/테스트는 파일 redirect + exit code 확인 후에만 커밋 ([[project_rerank_experiment]] 교훈).

View File

@@ -0,0 +1,151 @@
---
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.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 과 달리).
- 별칭 벡터는 sentinel row 라 본문 벡터와 독립. flag off 면 별칭 벡터 미생성 → 기존과 동일.
## 4. versioning (design §9)
- 별칭 dense 는 additive(별도 벡터). `try_skip_unchanged` 의 기존 5버전 판단 무변경(별칭 부재가 자동
재색인 트리거 안 함). 재생성은 `--force-reingest`.
- 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)
- 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 무변경.

View File

@@ -0,0 +1,220 @@
---
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)? # gemma 1회/청크
│ chunk.aliases = Some(aliases) # 상한·형식검증 적용
app.sqlite.put_chunks(doc_id, &chunks)? # chunks.aliases 컬럼 저장
(V010 chunk_aliases_au/ai trigger) → chunk_aliases_fts 별도 테이블 색인
embedder.embed(...) → vec_store.upsert(...) # dense는 body text 기준 (변경 없음)
검색 (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 내부 병합인가 (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 를 병합**해 단일 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 컴포넌트 (단위별 책임)
- **`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` 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`)
- `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)
- `chunks_fts.text`(body) 는 **verbatim 유지**, 별칭은 **별도 테이블** `chunk_aliases_fts`.
body-우선 merge 라 body 매칭이 항상 alias-only 보다 앞서 보존되어, 코드 식별자(`Vec::with_capacity`)
정확매칭이 별칭 노이즈에 희석되지 않음.
- dense(e5) 임베딩은 **body text 기준 그대로** — 별칭을 임베딩에 넣지 않음(research: e5 dense
유지, bge-m3 dense 는 실측 더 나빴음). 별칭은 lexical 채널에만 기여.
## 4. 스키마 / migration (V010)
현재 최신 = 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 테이블에 별칭 컬럼 (nullable; 미생성/flag off = NULL)
ALTER TABLE chunks ADD COLUMN aliases TEXT;
-- 2) 별도 FTS5 가상 테이블 (body 와 분리된 lexical 채널)
CREATE VIRTUAL TABLE chunk_aliases_fts USING fts5(
chunk_id UNINDEXED,
doc_id UNINDEXED,
aliases,
tokenize = 'unicode61' -- V009 본문과 동일 tokenizer
);
-- 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;
-- 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<String>` 필드 추가(`#[serde(default)]`).
## 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 <run_id> --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 검색: `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.
- 회귀: `chunk_aliases_fts` 빈 상태에서 lexical 결과가 V009 와 동일(alias 쿼리 0행 → merge no-op).
## 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단락.

View File

@@ -0,0 +1,268 @@
# config 마이그레이션 — 설계 (spec)
> 2026-05-31. config.toml **스키마 진화 시 기존 사용자 파일을 자동 갱신**하는 기능의
> 설계 계약. kickoff 인계 문서
> [`2026-05-31-config-migration-kickoff.md`](../handoffs/2026-05-31-config-migration-kickoff.md)
> 의 brainstorm 결과를 확정한 spec 이다. 본 문서를 기준으로 plan → 구현.
## 0. 결정 요약 (brainstorm 게이트)
| 축 | 결정 | 근거 |
|----|------|------|
| **트리거** | 명시 명령 `kebab config migrate` + `kebab doctor` 안내 | 예측 가능성·안전. load 시 자동 쓰기는 쓰기 권한/동시 실행/손상 위험. |
| **주석 보존** | `toml_edit` 부분 편집 | 사용자가 손본 값·주석·순서·정렬 100% 보존. 빠진 것만 추가. |
| **버전 메커니즘** | reconciliation(additive) + step 체인(non-additive) 하이브리드 | kebab config 는 `schema_version` 이 줄곧 `1` 인 채로 섹션이 누적돼 버전 번호만으로 "무엇이 빠졌는지" 구분 불가 → 구조 비교가 본질. |
## 1. 동기 (kickoff §1 재확인)
v0.21.0 에서 `[ingest.expansion]` 등 섹션이 늘었지만, 기존 사용자 config.toml 은
serde default 로 **동작은 호환**(off 로 로드)되나 그 섹션이 **파일에 써지지 않아**
사용자가 파일을 열어도 새 기능의 존재·노브를 알 수 없다. DB 는 V00X refinery
마이그레이션이 있는데 config 는 없다 — 이걸 만든다.
핵심: **데이터 무효화가 아니라 *파일 가시성* 문제**. 읽기 호환성은 이미 확보돼 있으므로
(`#[serde(default)]`), 만들 것은 *사용자 파일을 새 스키마에 맞춰 갱신*하는 것이다.
## 2. 비목표 (YAGNI)
- config 값의 **의미적 검증**(예: score_gate 범위 체크) — 별개 기능. 본 작업 범위 아님.
- **load 시 자동 마이그레이션** — 명시적으로 제외(트리거 결정). 추후 필요 시 별 작업.
- **다운그레이드**(새 → 옛 스키마) — 단방향만.
- 기존 사용자 **값의 재조정**(default 가 바뀌었다고 사용자 값 덮어쓰기) — 절대 안 함.
마이그레이션은 *없는 것 추가* + *deprecated 정리*만. 사용자가 명시한 값은 불가침.
## 3. 아키텍처 — 두 메커니즘
마이그레이션은 사용자 파일(`toml_edit::DocumentMut`)에 다음 순서로 적용한다.
```
원본 파일 → [1. step 체인(non-additive)] → [2. reconciliation(additive)] → [3. schema_version stamp] → 결과
```
### 3.1 Reconciliation (additive — 핵심 메커니즘)
**정의**: "default Config 구조에는 있지만 사용자 파일에 없는 테이블/키를, 설명 주석과
함께 사용자 파일에 추가한다." 버전과 무관하게 동작하며 멱등이다.
**참조 문서 = 주석 달린 default**: `annotated_default_document()` 가 단일 진실 원천이다.
```
fn annotated_default_document() -> toml_edit::DocumentMut
// Config::defaults() 를 toml_edit Document 로 직렬화한 뒤,
// 주석 카탈로그(§3.3)의 설명을 각 테이블/키의 decor(prefix)에 부착.
// → 이 문서가 "완전체 config.toml" 의 정의.
```
`kebab init` 도 이 함수의 출력을 그대로 파일로 쓴다(§5.2). 즉 **init 과 migrate 가
동일한 참조 문서를 공유** → 주석·구조의 단일 원천.
**reconcile 알고리즘** (참조 문서 `ref` → 사용자 문서 `user`, 재귀):
```
for each (key, ref_item) in ref (문서 순서 유지):
if key 가 user 에 없음:
user 에 ref_item 을 통째 복사 (decor=주석 포함). → change: added_section / added_key
else if ref_item 과 user[key] 가 둘 다 테이블:
recurse(ref_item, user[key]) # 하위만 비교
else:
# 키가 이미 존재(값이 default 와 달라도) → 건드리지 않음. (값 불가침)
```
- **삽입 위치**: 누락 키는 해당 테이블 **끝에 append**(결정적·단순). 사용자가 짜둔 기존
순서는 보존되고 새 항목만 뒤에 붙는다.
- **중첩 테이블**: `[ingest]` 는 있는데 `[ingest.expansion]` 이 없으면 `expansion`
하위 테이블만 추가. `[ingest]` 자체가 없으면 `[ingest]` + 그 안의 모든 하위를 추가.
- **값 불가침 예시**: 사용자가 `score_gate = 0.8` 로 바꿔뒀고 default 가 0.6 이어도,
키가 존재하므로 **0.8 유지**. 마이그레이션은 0.6 으로 되돌리지 않는다.
### 3.2 Step 체인 (non-additive)
`schema_version` 기반 버전별 변환 함수. additive 가 아닌 변경(deprecated 제거, rename,
형식 변환)을 담당한다. DB refinery 패턴 차용.
```
const CURRENT_SCHEMA_VERSION: u32 = 2; // 이번 작업에서 1 → 2
fn step_1_to_2(doc: &mut DocumentMut, changes: &mut Vec<MigrationChange>)
// v1 → v2 변환: 옛 `workspace.include` 키 제거 (p9-fb-25 deprecated).
// - doc["workspace"]["include"] 존재 시 remove → change: removed_deprecated.
// - 없으면 noop (멱등).
```
- **실행 범위**: 파일의 `schema_version`(없으면 1 로 간주) 부터 `CURRENT` 까지 순차 적용.
이미 `CURRENT` 이상이면 step 없음.
- 각 step 은 **개별적으로 멱등**(이미 적용된 상태에서 재실행해도 noop).
- 이번 작업의 유일한 step 은 `1→2`(workspace.include 제거). 누적된 섹션 추가
(image/ui/ingest/pdf/logging/expansion)는 **전부 reconciliation 이 처리**하므로
step 으로 만들지 않는다. step 체인은 "구조로 표현 못 하는 변환"만 담는다.
### 3.3 주석 카탈로그
"섹션/키 → 한국어 설명 주석" 매핑을 kebab-config 의 마이그레이션 모듈 한 곳에 정적
정의한다. 단일 원천 — README/SMOKE 와 중복하지 않고 여기를 정본으로.
- 기존 `init_workspace` 의 헤더(경로 정책 설명, `kebab-app/src/lib.rs:147~`)는
**문서 레벨 prefix** 로 이전한다(`annotated_default_document` 가 부착).
- 섹션별 주석은 README Configuration §의 노브 설명을 차용해 **간결**하게(1~2줄).
예: `[ingest.expansion]``# doc-side 별칭 확장 (기본 off). 검색 패러프레이즈 강건성↑.`
- 주석 문구는 짧게, 과하지 않게. 전체 문서는 생성된 파일·README·SMOKE 참고로 유도.
### 3.4 멱등성 보장 (안전 1축)
- reconciliation: 이미 있는 키는 skip → 두 번째 실행 시 changes 비어 있음.
- step: 각 step 이 noop-safe.
- 결과: **마이그레이션 후 재실행하면 `changed=false`, 파일 미변경.** 이것이 doctor
체크(§5.3)와 멱등 테스트의 핵심 단언.
## 4. 안전 3축 (kickoff §4.4)
1. **멱등** — §3.4.
2. **백업** — 파일 수정 직전 `<config>.bak` 생성(원본 복사). 기존 `.bak` 있으면 덮어씀
(단순화; 변경 내용은 dry-run 으로 사전 확인 가능). dry-run 시 백업도 안 만듦.
3. **dry-run**`--dry-run` 은 changes 만 계산·출력하고 **파일·백업 모두 미수정**.
**실패 시 원본 보존(atomic write)**: 편집 결과는 `<config>.tmp` 에 먼저 쓰고
`rename(tmp, config)` 로 교체. rename 이전 어느 단계에서 실패해도 원본 불변. 순서:
`백업 생성 → tmp 쓰기 → tmp 검증(재파싱 round-trip) → atomic rename`.
## 5. 표면 (surface)
### 5.1 CLI — `kebab config migrate`
신규 top-level `Config` 서브커맨드 그룹(clap nested, `Inspect`/`List` 패턴 차용):
```
kebab config migrate [--dry-run] [--json]
```
- 전역 `--config <path>` 존중 (facade rule). 미지정 시 XDG 기본 경로.
- 대상 파일이 없으면 에러: `config 파일이 없습니다. 먼저 kebab init 을 실행하세요.`
(`--json``error.v1`, code `config_not_found`).
- 사람용 출력: 변경 목록(추가된 섹션/키, 제거된 deprecated) + 백업 경로 + "N changes
applied" 또는 "already up to date".
- `--json`: `config_migration.v1` (§5.4).
**facade**: kebab-cli 는 kebab-app 의
`config_migrate_with_config_path(config_path: Option<&Path>, dry_run: bool)
-> anyhow::Result<ConfigMigrationReport>` 를 호출(파일 read/백업/atomic write
오케스트레이션은 app 계층, 순수 변환은 config 계층 — §6).
### 5.2 `kebab init` 영향 (user-visible)
`init_workspace``annotated_default_document()` 출력을 쓰도록 변경. 결과: init 이
생성하는 config.toml 이 **섹션별 주석을 포함**(기존엔 헤더만). 이는 user-visible surface
변경이므로 README Configuration §·docs/SMOKE.md 의 config 예시 블록 동기화 필요.
### 5.3 `kebab doctor` 체크 추가 (additive)
config load 체크 직후 `config_migration` 체크 1개 추가:
- 내부적으로 dry-run 마이그레이션 실행 → changes 비었으면 `ok=true`,
detail `config up to date (schema v2)`, hint=None.
- changes 있으면 `ok=false`, detail `N pending changes (added M sections, removed K
deprecated)`, hint `run kebab config migrate to update your config.toml`.
- **trade-off (확정)**: `DoctorCheck` 는 `ok: bool` 뿐이고 hint 는 `ok==false` 일 때
표시되는 규약이므로, "마이그레이션 필요"는 `ok=false` 로 신호한다. 이는 전체
`DoctorReport.ok`(모든 체크의 AND)를 false 로 만든다 — 즉 *완전히 동작하지만
config 가 옛 스키마인* 환경에서 `kebab doctor` 가 "비정상"으로 보고된다. 이를
의도된 동작으로 받아들인다(doctor = "정리할 것이 있는가"의 점검이고, hint 가 정확한
교정 명령을 제시). 새 키만 추가하는 additive 변경을 "건강 실패"로 과하게 보는 면이
있으나, 별도 warn 상태를 도입하는 것(스키마·표면 변경)보다 단순함을 택한다.
- `doctor.v1` 스키마는 변경 없음(checks 배열에 행 1개 추가 — additive, backward-compat).
### 5.4 wire schema `config_migration.v1` (신규)
`docs/wire-schema/v1/config_migration.schema.json` 신설. `--json` 출력:
```json
{
"schema_version": "config_migration.v1",
"dry_run": true,
"config_path": "/home/me/.config/kebab/config.toml",
"from_schema_version": 1,
"to_schema_version": 2,
"changed": true,
"backup_path": null,
"changes": [
{ "kind": "added_section", "path": "ingest.expansion", "detail": "doc-side 별칭 확장 (기본 off)" },
{ "kind": "added_key", "path": "logging.enabled", "detail": "ingest 로그 활성화" },
{ "kind": "removed_deprecated","path": "workspace.include","detail": "p9-fb-25: extractor 자동 결정" }
]
}
```
- `changed`: 실제(또는 dry-run 시 가정) 변경 발생 여부. false 면 changes=[].
- `backup_path`: 실제 적용 시 `.bak` 경로, dry-run 시 `null`.
- `kind` enum: `added_section | added_key | removed_deprecated`. (향후 `renamed`,
`reformatted` 확장 여지 — 본 작업은 3종.)
- additive 신규 스키마 → 기존 통합 영향 없음. wire major bump 아님(v1 추가).
## 6. 코드 배치 (crate 경계)
| 위치 | 책임 | 비고 |
|------|------|------|
| `crates/kebab-config/src/migrate.rs` (신규) | **순수 변환**: `annotated_default_document`, `reconcile`, step 체인, `CURRENT_SCHEMA_VERSION`, 주석 카탈로그, `MigrationChange`/`ConfigMigrationReport` 타입, `migrate_document(doc) -> Vec<MigrationChange>` | I/O 없음. 문자열 in → 문자열 out 로 테스트 가능. |
| `crates/kebab-config/Cargo.toml` | `toml_edit = "0.22"` 의존성 추가 | 주석 보존 편집 핵심. |
| `crates/kebab-app/src/lib.rs` | **I/O 오케스트레이션**: `config_migrate_with_config_path`(read → migrate_document → 백업 → tmp write → atomic rename), `init_workspace` 가 `annotated_default_document` 사용하도록 수정, doctor 에 체크 추가 | facade. fs 부작용은 app 계층. |
| `crates/kebab-cli/src/main.rs` | `Config { Migrate { dry_run } }` 서브커맨드, 사람용 출력 | kebab-app facade 만 호출. |
| `crates/kebab-cli/src/wire.rs` | `wire_config_migration(report) -> Value` | `config_migration.v1` 직렬화. |
| `docs/wire-schema/v1/config_migration.schema.json` (신규) | wire 계약 | |
**경계 근거**: kebab-config 는 이미 파일 *읽기*(`from_file`)를 하지만, *쓰기*는
`init_workspace`(app)에 있다. 일관성·테스트성 위해 순수 변환은 config, 부작용(백업·쓰기)
은 app. doctor(app)·cli 모두 동일 순수 변환을 재사용.
## 7. schema_version 의 새 의미
- 기존: 항상 `1`, 검증·로직에 안 쓰이는 장식.
- 신규: "이 파일이 sync 된 스키마 버전" 마커 + step 체인의 축.
- `Config::defaults().schema_version` 및 `CURRENT_SCHEMA_VERSION` 을 **2** 로 bump.
마이그레이션 완료 시 사용자 파일의 `schema_version` 을 `CURRENT` 로 stamp.
- 읽기 경로(`from_file`)는 여전히 `schema_version` 으로 **거부하지 않음**(forward-compat
유지). 즉 옛 바이너리로 새 파일을, 새 바이너리로 옛 파일을 읽어도 동작.
## 8. 문서 동기화 (user-facing surface)
- **README.md Configuration §**: `kebab config migrate` 한 줄 + init config 가 섹션
주석을 갖는다는 설명. config 예시 블록을 `annotated_default_document` 산출과 일치.
- **docs/SMOKE.md**: config 예시 블록 동기화. migrate dry-run smoke 단계 추가.
- **docs/DOGFOOD.md**: config 관련 section 에 migrate 시나리오(옛 파일 → migrate →
섹션 가시성 확인) 추가.
- **tasks/HOTFIXES.md**: 머지 후 dated entry(`## YYYY-MM-DD — config 마이그레이션`),
도그푸딩 evidence(옛 config 에 빠진 섹션 N개 추가 + workspace.include 제거 멱등 확인).
- **HANDOFF.md**: 해당되면 한 줄.
## 9. 릴리스 트리거 판단
- 신규 CLI 서브커맨드(`config migrate`) + doctor 체크 + init 출력 변경 = **user-visible
surface 변경** → 도그푸딩 필수, README 동기화 필수.
- `schema_version` bump(1→2)는 **additive**(데이터 무효화 아님, 읽기 호환 유지) →
CLAUDE.md §Versioning 의 DB/wire breaking 기준엔 해당 안 됨. 다만 surface 누적이
있으므로 **minor bump** 대상일 수 있음. 실제 bump/release 컷 시점은 사용자 판단.
## 10. 테스트 전략 (plan 의 TDD 근거)
순수 변환(kebab-config)이 테스트의 중심 — 문자열 in/out, fs 불필요:
1. **reconciliation 추가**: 옛 config 문자열(섹션 누락) → migrate → 누락 섹션이 주석과
함께 추가됐고, 기존 키·주석·순서는 보존.
2. **값 불가침**: 사용자가 바꾼 값(예: `score_gate = 0.8`)이 migrate 후에도 유지.
3. **멱등**: migrate 출력을 다시 migrate → `changed=false`, 동일 문자열.
4. **step (workspace.include 제거)**: 옛 키 있는 문자열 → 제거됨 + change 기록. 없으면 noop.
5. **schema_version stamp**: 결과의 `schema_version = 2`. 없던 파일엔 추가됨.
6. **주석 보존**: 사용자가 임의 키에 단 주석이 migrate 후에도 그대로.
7. (app) **백업·atomic·실패 보존**: 백업 파일 생성, tmp rename, 손상 입력 시 원본 불변.
8. (app) **dry-run**: 파일·백업 미생성, report.changed 정확.
9. (cli/wire) `config_migration.v1` 직렬화 형태.
## 11. Risks / notes
- `toml_edit` 신규 의존성 — kebab-config 에 추가. `toml`(0.8)과 공존(serde 경로는
여전히 `toml`, 편집 경로만 `toml_edit`). 버전은 구현 시 최신 0.22.x 확인.
- reconciliation 의 "끝에 append" 는 사용자가 짠 미적 순서를 흩뜨릴 수 있으나(새 섹션이
뒤로 몰림), 값·주석·기존 순서 보존이 우선이며 단순·결정적이라 채택.
- 첫 step(`1→2`)은 사실상 이미 무시되는 `workspace.include` 청소뿐 — step 체인은 주로
*프레임워크*로서 미래 non-additive 변경을 위해 깔아둔다.
- kickoff 인계 문서와의 차이: kickoff §4.2 는 "버전별 변환 함수 체인"만 제안했으나,
kebab 의 serde-default 특성상 additive 변경은 step 으로 표현하기 부적절(버전 무관) →
**reconciliation 을 1급 메커니즘으로 승격**하고 step 은 non-additive 전용으로 한정.

View File

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

View File

@@ -0,0 +1,78 @@
# Track 1 Spec — candle e5-large 임베딩 provider (NUMA-안전)
- 날짜: 2026-06-01
- 우산: [meta-spec](./2026-06-01-embedding-numa-backends-meta-spec.md) / [meta-plan](./2026-06-01-embedding-numa-backends-meta-plan.md)
- 선행: Phase 0 스파이크 PASS+독립검증 (cosine 1.000000, 스레드 캡 가능, latency ~4×). 커밋 76841af.
- 브랜치: `feat/embed-candle`
## 1. 목표
fastembed(onnxruntime) 의 "intra-op 스레드 48 하드코딩 → NUMA 힙 손상" 을 회피하기 위해, 동일 모델 `multilingual-e5-large`**candle(순수 Rust)** 로 돌리는 임베딩 provider 를 추가한다. opt-in, 품질 중립, NUMA 스레드 캡 가능.
## 2. 확정 결정 (사용자 승인 2026-06-01)
- **D-reindex**: `embedding_version` **유지(재색인 0)** 를 목표. 구현 중 candle vs onnxruntime 벡터의 **차원별 max 절대오차**를 측정해 사실상 동일(예: max abs diff < 1e-5)함을 확인하고, 골든 스위트로 회귀 0 을 실측해 확정. 유의미한 차이가 나오면만 version bump + 재색인.
- **D-default**: 글로벌 default provider 는 **onnxruntime 유지**, candle 은 **opt-in** (`models.embedding.provider = "candle"`).
- **조기 종료**: candle 이 골든 baseline 충족 시 ollama/A2 트랙 생략 (A1 stopgap 문서만 별도).
## 3. 아키텍처
- **신규 crate `kebab-embed-candle`** — `kebab_core::Embedder` 구현. candle 의 큰 의존성 트리를 이 crate 에 격리.
- 허용 deps: `candle-core`/`candle-nn`/`candle-transformers` (0.10.x), `tokenizers`, `hf-hub`, `kebab-core`, `kebab-config`, `anyhow`, `tracing`. **다른 `kebab-*` 의존 금지**(core/config 외) — design §8 경계.
- **주입 분기**: `kebab-app/src/app.rs``embedder()` (현 :829-837, `FastembedEmbedder::new` 무조건 생성) 를 `config.models.embedding.provider` 로 분기:
- `"fastembed"` | `"onnx"` | (빈값/기존) → `FastembedEmbedder` (default, 기존 동작 유지).
- `"candle"``CandleEmbedder`.
- 알 수 없는 값 → 명확한 에러.
- **facade 규칙 준수**: UI crate 는 `kebab-app` 만. `kebab-app``kebab-embed-candle` 의존 추가.
## 4. CandleEmbedder 동작 (스파이크에서 검증된 파이프라인)
- 모델: `intfloat/multilingual-e5-large``model.safetensors` + `config.json` + `tokenizer.json``hf-hub` 으로 `{model_dir}/candle/` (config `storage.model_dir`) 아래에 캐시.
- `candle_transformers::models::xlm_roberta::{Config, XLMRobertaModel}` 로 로드 (CPU `Device::Cpu`).
- `embed()`: e5 프리픽스(`query: `/`passage: `, `EmbeddingInput` kind 기준 — `kebab-embed-local``prefix_input` 규약과 동일) → 토크나이즈(max_len 512, batch-longest 패딩, special tokens) → forward → **attention-mask 가중 mean pooling****L2 정규화**.
- `dimensions()` = 1024, `model_id`/`model_version` = config 값(기존과 동일 식별자 유지).
- **스레드 캡**: config 신규 필드 `models.embedding.num_threads`(u32, 0=auto) + env `KEBAB_EMBED_THREADS`. `CandleEmbedder::new` 에서 `rayon::ThreadPoolBuilder::new().num_threads(n).build_global()` 1회 적용(이미 초기화 시 무시). 0/auto 면 미설정(rayon 기본). NUMA 노드 바인딩은 `numactl`(A1) 과 조합 — 문서화.
- `Mutex<XLMRobertaModel>` 또는 forward 가 `&self` 면 불필요 — candle forward 는 `&self` 가능, 단 내부 가변 없으면 `Send+Sync` 보장 확인.
## 5. config 변경
- `EmbeddingModelCfg``num_threads: u32`(default 0) 추가. env `KEBAB_EMBED_THREADS`.
- `provider` 허용값 문서화: `fastembed`(default)/`candle`.
- default toml + `Config::default()` 갱신, 기존 테스트 영향 확인.
## 6. 버전/캐스케이드
- D-reindex 에 따라 `embedding_version` 유지 (벡터 동일). cascade(design §9) 트리거 안 함 — 기존 색인 재사용. (max abs diff 확인 실패 시에만 bump.)
- wire schema 변경 없음.
## 7. 테스트 (산출물)
- **단위**(`kebab-embed-candle`): `dimensions()==1024`; `embed()` 출력 L2≈1; 빈 입력 빈 출력; 프리픽스 적용 확인.
- **패리티 테스트**(`#[ignore]`, 모델 2GB+네트워크 필요): candle vs `FastembedEmbedder` 동일 문장 cosine ≥ 0.9999 + max abs diff 보고. CI 기본 제외, 수동/도그푸딩에서 실행.
- **통합**(`kebab-cli` 또는 `kebab-app`): `provider="candle"` 로 소량 fixture ingest → 청크/임베딩 카운트 > 0, 검색 1건 성공. (모델 필요 → `#[ignore]` 또는 feature.)
- **스레드 캡**: `num_threads=4` 설정 시 `rayon::current_num_threads()==4` 확인.
- **회귀**: 기존 fastembed 경로 default 동작 불변(provider 미지정 시).
- clippy `-D warnings`, 빌드 직렬 `-j 4`.
## 8. 품질 게이트 (머지 전)
- `kebab-eval` 골든 스위트(`/build/dogfood/golden_queries.yaml`) 를 provider=candle 로 실행 → MRR/hit@k ≥ 현 baseline (회귀 0). [[feedback_search_quality_dogfood]]
- 패러프레이즈 robustness(#195/#196) 스폿 확인.
## 9. 문서/릴리스 (머지 시 동일 PR)
- README: Configuration 에 `provider=candle` + `num_threads`/`KEBAB_EMBED_THREADS` 추가. SMOKE config 예시 동기화. [[feedback_readme_sync_rule]]
- ARCHITECTURE: crate 그래프 + 디렉터리에 `kebab-embed-candle` 추가.
- HANDOFF: 머지 후 한 줄(임베딩 백엔드 다변화).
- HOTFIXES: 본 날짜 dated entry (NUMA double-free 진단 + candle provider 도입 + 스파이크 패리티 증거).
- 버전 bump: 신규 config surface(provider=candle, num_threads) = pre-1.0 minor bump (0.21.1 → 0.22.0), release notes.
## 10. 범위 밖 / 후속
- candle crate feature-gate 로 빌드 비용 격리 (후속).
- NUMA 노드 자동 바인딩(현재는 numactl 외부 조합).
- ollama/A2/A1 트랙 (candle 게이트 통과 시 생략).
## 11. 잔여 게이트 (사용자 실행, Claude 불가)
- 그 듀얼소켓 NUMA 서버에서 `provider=candle` 로 5150-doc ingest **double-free 없이 EXIT=0 완주**. PR 머지 전/후 검증 예약. (meta-spec §4.3)

View File

@@ -0,0 +1,77 @@
# Meta-Plan — NUMA-안전 임베딩 백엔드 실행 계획
- 날짜: 2026-06-01
- 우산 스펙: [2026-06-01-embedding-numa-backends-meta-spec.md](./2026-06-01-embedding-numa-backends-meta-spec.md)
- 실행 모델: 트랙별 worktree 격리 + omc teammate (omc-teams, sequential single-team). 트랙 내 단계는 spec → plan → 구현 → 테스트 → PR.
## 0. 즉시 (본 계획과 병행, 무코드)
- **A1 stopgap 문서화 + 사용자 제공**: `numactl --cpunodebind=0 --membind=0 kebab ingest` (또는 `taskset -c 0-11`). 현재 불통 해소용. 이건 트랙 4의 산출물 일부지만 지금 바로 안내.
- 사용자 NUMA 서버에서 A1 로 5150-doc 완주되는지 1회 확인 → "스레드/NUMA 가 원인" 인과 확정(메타스펙 §1 보강).
## 1. 트랙 실행 순서 & 게이트
`candle → ollama → A2 → A1(정식 문서화)`. 한 트랙의 PR open + NUMA 검증 예약 전까지 다음 트랙 미착수.
**조기 종료 (D1 확정)**: candle 또는 ollama 가 허용 품질(골든 ≥ baseline 무회귀) + NUMA 안전을 만족하면 **거기서 종료**, 이후 트랙 미진행. 둘 다 품질 미달 시에만 A2 → A1 진행. candle 은 동일 e5-large 라 패리티 통과 시 종착 유력.
### 트랙 1 — candle (`feat/embed-candle`)
- **Phase 0 — 타당성 스파이크 (게이트, 최우선)**
- worktree 에서 candle + candle-transformers 의존성 추가, `xlm_roberta::XLMRobertaModel``intfloat/multilingual-e5-large` safetensors 로드 (CPU).
- 몇 개 문장 임베딩 → (a) onnxruntime e5-large 벡터와 cosine 패리티, (b) CPU latency, (c) `RAYON_NUM_THREADS` 로 스레드 캡 동작, (d) padding_idx 위치 임베딩 정확성.
- 산출: 스파이크 리포트(패리티 수치 + latency + 스레드 제어 확인). **통과해야 Phase 1 진행.**
- **Phase 1 — spec**: 트랙 spec 작성 (Embedder 구현, config provider="candle", embedding_version, 재색인 절차, 테스트 매트릭스).
- **Phase 2 — plan**: 구현 plan.
- **Phase 3 — 구현**: `kebab-embed-candle`(신규 crate) 또는 `kebab-embed-local` 내 provider 분기. Embedder 구현 + app.rs 주입 분기 + config.
- **Phase 4 — 테스트**: 단위/통합 + 패리티 + 골든. 빌드는 직렬 `-j 4`.
- **Phase 5 — PR + 검증**: gitea PR. 사용자 NUMA 서버 5150-doc 완주 + 골든 baseline 확인.
### 트랙 2 — ollama (`feat/embed-ollama`)
- spec → plan → 구현(`OllamaEmbedder`: `/api/embed` 호출, provider="ollama", 모델 선택[e5 GGUF 또는 bge-m3]) → 테스트(패리티/골든, 프로세스 격리로 double-free 부재) → PR + NUMA 검증.
### 트랙 3 — A2 (`feat/embed-ort-direct`)
- spec → plan → 구현(fastembed 우회, `ort` 세션 직접 + `with_intra_threads(N)` + NUMA affinity, 토크나이즈/mean-pool/L2 재현, provider="onnx" 기본 유지) → 테스트(기존 e5 벡터와 cosine≈1.0, 재색인 0) → PR + NUMA 검증.
- **품질-중립 안전망**: 재색인 없이 즉시 default 가능.
### 트랙 4 — A1 정식화 (`docs/embed-numa-affinity`)
- 런처 래핑/문서 + (선택) config 노브로 affinity 힌트. README/SMOKE/HOTFIXES 동기화.
## 2. omc teammate 운용 (메모리 규약 준수)
- spawn: omc-teams tmux pane + brief 파일. **sequential single-team** (multi-team 동시 spawn 금지).
- 모델 라우팅: executor + initial draft + round-1 review = **opus**; closure verify / micro-patch round = **sonnet**. (`OMC_TEAM_ROLE_OVERRIDES` env)
- worker spawn 직후 completion polling shell `run_in_background=true` (phase=completed/failed 감지 → main session 자동 알림).
- 빌드/테스트 직렬, `-j 4` 기본. `CARGO_TARGET_DIR=/build` 사용 (routinely clean 금지).
## 3. 워크트리 / 브랜치
| 트랙 | 브랜치 | worktree |
|---|---|---|
| 1 candle | `feat/embed-candle` | 신규 |
| 2 ollama | `feat/embed-ollama` | 신규 |
| 3 A2 | `feat/embed-ort-direct` | 신규 |
| 4 A1 | `docs/embed-numa-affinity` | 신규 |
각 트랙 머지 후 다음 트랙 rebase. 트랙 간 공유 상태 없음(독립 provider).
## 4. 리스크 레지스터
- candle Phase 0 패리티 실패 → 트랙 1 강등, ollama 우선.
- candle CPU latency 가 onnxruntime 대비 과도 → opt-in provider 로만.
- ollama 모델이 e5 아님 → 골든 회귀 가능 → default 승격 보류.
- NUMA 검증이 사용자 가용성에 의존 → 각 PR 은 검증 전까지 "merge-pending".
- ort rc.9 자체 버그가 A2 에서도 재현 가능성 → A2 스레드 캡으로도 안 죽는지 NUMA 검증 필수.
## 5. 진행 상태 (라이브)
- [x] candle 타당성 desk-research (xlm_roberta 모듈 존재 + cembedd 선례) — 2026-06-01
- [ ] A1 stopgap 사용자 NUMA 서버 확인
- [x] 트랙 1 Phase 0 스파이크 — **VERDICT=PASS** (2026-06-01). cosine min=mean=1.000000(onnxruntime 동일), RAYON 스레드 캡 가능, latency ~4×(67.5 vs 16.8 ms/문장, 4 vs 12 스레드). 커밋 76841af. → **조기 종료 유력**: candle 이 품질 baseline 자동 충족 → ollama/A2/A1 불필요 전망. 잔여 게이트=골든 실측 + NUMA 서버 5150-doc 완주.
- [ ] 트랙 1 spec/plan/impl/test/PR (진행)
- [ ] 트랙 2 …
- [ ] 트랙 3 …
- [ ] 트랙 4 …

View File

@@ -0,0 +1,102 @@
# Meta-Spec — NUMA-안전 임베딩 백엔드 (다중 트랙)
- 날짜: 2026-06-01
- 상태: DRAFT (umbrella)
- 범위: `kebab-embed-local` 및 임베더 주입 경로. 4개 트랙의 우산 스펙.
- 하위 산출물: 각 트랙은 본 메타스펙을 참조하는 자체 spec(`tasks/` 또는 `docs/superpowers/specs/`)과 plan을 가진다.
## 1. 문제
CPU-only Ollama 서버(Intel Xeon Silver 4214 ×2 소켓 = 48 logical, NUMA 2노드)에서 `kebab ingest` 가 매 실행 힙 손상으로 죽는다:
```
ingest [> ] 3/5150 double free or corruption (!prev)
중지됨 (core dumped)
```
근본 원인(코드로 확정): fastembed 4.9.1 (`text_embedding/impl.rs:52,80`) 이 ONNX intra-op 스레드를 `available_parallelism()`(=48) 로 **하드코딩**하고 `InitOptions` 에 이를 덮어쓸 API 가 없다. 듀얼소켓 NUMA 에서 onnxruntime(`ort 2.0.0-rc.9`) 스레드풀이 힙을 손상시킨다. 진단 근거: `tasks/HOTFIXES.md` 의 본 날짜 entry + 대화 로그.
- 모델/디스크/AVX/데이터 문제 아님 (모델 2.08GB 정상, AVX-512 완비). 순수 스레드/NUMA × 네이티브 런타임 버그.
- onnxruntime 공식 문서도 듀얼소켓 NUMA 는 intra-op 스레드를 한 노드로 묶으라고 권고.
## 2. 목표 / 비목표
목표:
- 그 NUMA 서버에서 5150-doc 코퍼스를 **double-free 없이 완주**하는 임베딩 경로 확보.
- 검색 품질을 골든 스위트(MRR/hit@k) baseline 이상으로 유지.
- `models.embedding.provider` 로 선택 가능한 백엔드들로 구현 (기존 provider 필드 활용).
비목표:
- 랭킹 자동 조정 (별도 보류 결정, `[[project_ranking_deferred]]`).
- 임베딩 모델 품질 개선 자체 (NUMA 안정성이 본 과제의 초점).
- GPU 경로.
## 3. 공유 아키텍처
- 교체 지점은 **단일**: `crates/kebab-app/src/app.rs:836``FastembedEmbedder::new(&config)`.
- 트레이트 표면이 작다: `kebab_core::Embedder` (`traits.rs:127`) — `model_id / model_version / dimensions / embed`. 새 백엔드는 이 4개만 구현.
- 설정: `models.embedding.provider` (이미 존재), `model`, `version`, `dimensions`, `batch_size`. 신규로 트랙별 스레드/affinity 노브 추가 가능.
## 4. 횡단 정책 (모든 트랙 공통)
### 4.1 embedding_version & 재색인
- 벡터가 바뀌면(=candle, ollama) **`embedding_version` bump → 전체 재색인** (design §9 cascade). A2/A1 은 동일 onnxruntime e5-large 라 벡터 불변 → 재색인 불필요.
- 재색인 비용/절차를 각 트랙 spec 에 명시.
### 4.2 품질 검증 (필수 게이트)
- 벡터가 바뀌는 트랙은 머지 전 `kebab-eval` 골든 스위트(`/build/dogfood/golden_queries.yaml`) 로 MRR/hit@k 측정, **baseline 이상**이어야 default 승격. baseline 미달이면 opt-in provider 로만 유지.
- 패러프레이즈 robustness(#195/#196) 회귀 확인.
### 4.3 NUMA 서버 검증 (필수 게이트, 사용자 실행)
- **결정적 증거는 그 서버에서만 난다 (Claude 접근 불가).** 각 트랙은 사용자가 그 서버에서 5150-doc 코퍼스 ingest 를 **double-free 없이 완주(EXIT=0)** 함을 확인해야 "검증 완료".
- 각 트랙 spec 에 사용자-실행 검증 절차(명령 + 기대 출력)를 문서화.
### 4.4 스레드/NUMA 제어
- 각 백엔드가 intra-op/worker 스레드를 캡하고 한 NUMA 노드로 묶을 수 있어야 함. 캡 못 하면 트랙 실패.
## 5. 트랙
선호/구현 순서: **candle → ollama → A2 → A1**. (단 A1 은 무코드 stopgap 이라 즉시 문서화해 당장의 불통을 해소; 구현 순서와 별개.)
| # | 트랙 | 백엔드 | 벡터 변경(재색인) | 핵심 리스크 | 격리 브랜치 |
|---|------|--------|----|------|------|
| 1 | candle | 순수 Rust (candle `xlm_roberta`) | 예 | XLM-R padding_idx/패리티/CPU 성능 | `feat/embed-candle` |
| 2 | ollama | 별 프로세스 (Ollama `/api/embed`) | 예 | 모델이 e5 아님→품질, ingest 가 Ollama 의존 | `feat/embed-ollama` |
| 3 | A2 | onnxruntime 직접(`ort` 세션) | 아니오 | fastembed 우회 후 토크나이즈/풀링 재현 정확도 | `feat/embed-ort-direct` |
| 4 | A1 | onnxruntime + 실행 래핑(taskset/numactl) | 아니오 | 코드 변경 거의 없음, 문서/런처만 | `docs/embed-numa-affinity` |
### 5.1 트랙별 테스트 매트릭스 (각 트랙 spec 에서 구체화)
모든 트랙:
- 단위: `embed()` 가 올바른 dim/정규화(L2≈1) 벡터 반환.
- 통합: `kebab ingest` 소량 fixture → 청크/임베딩 카운트.
- **NUMA 서버 검증**(§4.3): 5150-doc 완주.
벡터-변경 트랙(candle/ollama) 추가:
- 패리티: onnxruntime e5-large 대비 동일 입력 cosine 유사도(가능 시) 또는 골든 스위트 동등성.
- 골든: MRR/hit@k ≥ baseline (§4.2).
- 재색인 절차 검증.
벡터-불변 트랙(A2/A1) 추가:
- 회귀: 기존 e5-large 벡터와 cosine ≈ 1.0 (A2 는 같은 런타임이라 사실상 동일해야).
## 6. 결정사항 (확정 2026-06-01)
- **D1 조기 종료 (사용자 확정)**: 트랙을 선호 순서로 진행하되, candle 또는 ollama 가 **허용 품질 기준 + NUMA 안전**을 만족하면 **거기서 멈춘다** (이후 트랙 미진행). 둘 다 품질이 너무 낮으면 A2 → A1 까지 계속.
- **허용 품질 기준**: 골든 스위트 MRR/hit@k 가 현 e5-large(onnxruntime) baseline 대비 유의미한 회귀 없음. candle 은 동일 e5-large 가중치라 패리티 통과 시 이 기준을 거의 자동 충족 → candle 이 종착 가능성 높음. ollama 는 모델이 달라 경계선이면 사용자 판단.
- A2/A1 은 candle·ollama 둘 다 실패 시의 **fallback** (A2 는 재색인 0 품질-중립).
- **D2 즉시 완화**: A1(taskset/numactl) 은 무코드라 본 작업과 무관하게 지금 바로 사용자에게 워크어라운드로 제공.
- **D3 메타 산출물 위치**: 본 메타스펙 + 메타플랜은 `docs/superpowers/specs/`. 트랙별 spec 은 도달 시 작성.
- **D4 frozen design 영향**: 임베딩 백엔드 다변화는 design §(임베딩) 갱신 가능 — 트랙 머지 시 동기화.
## 7. 성공 기준
- 그 NUMA 서버에서 최소 1개 트랙이 5150-doc 완주(EXIT=0).
- default 로 승격되는 백엔드는 골든 baseline 이상.
- 각 트랙이 자체 브랜치/워크트리 + 문서화된 테스트로 독립 검증.
## 8. 시퀀싱 게이트
1. candle **스파이크**(Phase 0) 가 패리티+CPU 성능+스레드 제어를 입증해야 candle 본 구현 진행. 실패 시 candle 트랙 강등/스킵 후 ollama 로.
2. 각 트랙은 PR open + NUMA 서버 검증 예약 후 다음 트랙 시작 (omc-teams sequential single-team 제약).
3. 벡터-변경 트랙은 골든 게이트 통과 전 default 승격 금지.

View File

@@ -0,0 +1,38 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "config_migration.v1",
"description": "Result of `kebab config migrate` — schema reconciliation of a user's config.toml.",
"type": "object",
"required": [
"schema_version",
"config_path",
"dry_run",
"from_schema_version",
"to_schema_version",
"changed",
"changes"
],
"properties": {
"schema_version": { "const": "config_migration.v1" },
"config_path": { "type": "string" },
"dry_run": { "type": "boolean" },
"from_schema_version": { "type": "integer" },
"to_schema_version": { "type": "integer" },
"changed": { "type": "boolean" },
"backup_path": { "type": ["string", "null"] },
"changes": {
"type": "array",
"items": {
"type": "object",
"required": ["kind", "path", "detail"],
"properties": {
"kind": {
"enum": ["added_section", "added_key", "removed_deprecated"]
},
"path": { "type": "string" },
"detail": { "type": "string" }
}
}
}
}
}

View File

@@ -14,6 +14,9 @@
"scan_completed",
"asset_started",
"asset_finished",
"asset_chunked",
"expansion_progress",
"asset_timings",
"embed_batch_started",
"embed_batch_finished",
"pdf_ocr_started",
@@ -33,7 +36,13 @@
"enum": ["new", "updated", "skipped", "error"],
"description": "asset_finished: per-asset outcome (mirrors `ingest_report.v1.items[].kind`)."
},
"chunks": { "type": "integer", "minimum": 0, "description": "asset_finished: chunk count produced for this asset." },
"chunks": { "type": "integer", "minimum": 0, "description": "asset_finished / asset_chunked / expansion_progress (v0.24.0): chunk count produced for this asset." },
"done": { "type": "integer", "minimum": 0, "description": "expansion_progress (v0.24.0, additive): chunks processed so far in the per-chunk alias-expansion loop (cache hits included). Throttled: emitted at most every 25 chunks or once per second, plus a final frame where done == chunks." },
"parse_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.24.0, additive): parse phase wall-clock (ms). Markdown path only." },
"chunk_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.24.0, additive): chunk phase wall-clock (ms). Markdown path only." },
"expansion_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.24.0, additive): alias-expansion phase wall-clock (ms). Markdown path only; 0 when expansion is disabled." },
"embed_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.24.0, additive): embed + vector phase wall-clock (ms) — embedding, vector upsert, and stale-vector purge. Markdown path only." },
"store_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.24.0, additive): SQLite persist phase wall-clock (ms) — put_asset/document/blocks/chunks only. Markdown path only." },
"n_chunks": { "type": "integer", "minimum": 0, "description": "embed_batch_started / embed_batch_finished: chunks in this embedding batch." },
"ms": { "type": "integer", "minimum": 0, "description": "embed_batch_finished / pdf_ocr_finished: wall-clock duration (ms). pdf_ocr_finished skip path 의 의미는 mixed (DCTDecode 부재 시 0, engine 실패 시 latency-before-bail)." },
"chars": { "type": "integer", "minimum": 0, "description": "pdf_ocr_finished: char count of OCR result. Skip 시 0." },

View File

@@ -0,0 +1,37 @@
-- 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'
);
-- 가드 `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;
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 AND new.aliases <> '';
END;
-- in-process LRU search cache 무효화 (V009 와 동일 패턴).
UPDATE kv SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT) WHERE key = 'corpus_revision';

View File

@@ -0,0 +1,46 @@
-- 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).
-- 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 위에서)
-- 사라진 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'
CHECK (status IN ('pending','committed','tombstone')), -- V003 와 동일 무결성 가드 보존
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';

View File

@@ -0,0 +1,22 @@
-- V012__derivation_cache.sql — 내용 해시 기반 파생물 캐시 (Derivation Cache).
--
-- 설계 spec docs/superpowers/specs/2026-05-31-derivation-cache-design.md §3.2.
-- 비용 큰 ingest 파생물(embedding 벡터 / LLM 별칭 / 선택적 한국어 형태소)을
-- 청크 text 의 *내용 해시* 키로 캐싱해, 문서 갱신·재색인 시 변경되지 않은
-- 청크의 재계산을 없앤다. cache_key = blake3(kind ‖ text_blake3 ‖ version_key)[:32]
-- (§3.1) — 위치 기반 chunk_id 와 달리 내용이 같으면 문서·위치 무관 동일 키.
--
-- 순수 가산(additive): 기존 데이터를 무효화하지 않으므로 corpus_revision 을
-- bump 하지 않는다(§3.2). 캐시는 순수 성능 레이어 — 손상/삭제되어도 정확성
-- 영향 없음(miss → 재계산). `kebab reset` 시 같은 sqlite 라 함께 비워진다.
CREATE TABLE derivation_cache (
cache_key TEXT PRIMARY KEY, -- §3.1 blake3 32-hex
kind TEXT NOT NULL, -- 'embedding' | 'alias' | 'korean_tokens'
payload BLOB NOT NULL, -- kind 별 인코딩 (§3.3)
created_at TEXT NOT NULL,
last_used_at TEXT NOT NULL -- LRU/TTL 정리용 (§3.5)
);
CREATE INDEX idx_dcache_kind ON derivation_cache(kind);
CREATE INDEX idx_dcache_last_used ON derivation_cache(last_used_at);

View File

@@ -14,6 +14,238 @@ historical contract that was implemented; this file accumulates the
deltas so phase 5+ readers can find the live behavior without diffing
git history.
## 2026-06-02 — 상세 ingest 진행 로깅 (asset 내부 phase 가시화, v0.24.0)
**무엇이 문제였나.** ingest 진행 이벤트가 asset(문서) 단위(`asset_started` /
`asset_finished`)뿐이라 한 문서 내부의 parse / chunk / **expansion(별칭 LLM,
청크당 순차 호출)** / embed / store 가 깜깜했다. expansion 은 청크당 ~1~4s
(원격 GPU Ollama)이고 큰 문서는 청크 수백~천 개 → 그 한 문서에서 수십 분이
걸리는데, 진행바는 `1/5150` 에 멈춘 듯 보여 사용자가 병목을 못 봤다.
**무엇을 추가했나 (wire `ingest_progress.v1` additive, 호환 유지).**
`IngestEvent` 에 세 변이 추가 — `#[serde(tag="kind")]` 라 신규 `kind` 추가는
wire v1 호환:
- `asset_chunked { idx, total, chunks }` — 청킹 직후(expansion/embed 전) 즉시
"이 문서가 N청크" 노출. markdown / image / pdf 세 경로 모두 emit.
- `expansion_progress { idx, total, done, chunks }` — expansion 루프 중
**스로틀** 발신(매 25청크 또는 ≥1s, 종료 시 `done == chunks` 1프레임 더).
캐시 히트 청크도 `done` 에 포함(warm 재색인 fast-forward 가시화). 채널 폭주
방지 — 매 청크 emit 금지.
- `asset_timings { idx, total, parse_ms, chunk_ms, expansion_ms, embed_ms,
store_ms }` — asset 처리 phase 별 소요시간. **markdown 경로만** emit
(image/pdf 는 phase shape 가 달라 생략; AssetChunked 만 emit).
**설계 결정 — AssetTimings 이벤트 vs AssetFinished 필드.** IMPL_BRIEF §1 은
`AssetFinished` 에 optional phase-timing 필드를, §2 는 대안으로 신규
`AssetTimings` 이벤트를 제시(권장). 후자를 택함 — `AssetFinished` 는 호출부
(`ingest_with_config_progress` 루프)에서 만들어지는데 timing 데이터는
`ingest_one_asset` 내부에만 있어, 필드를 채우려면 `kebab_core::IngestItem`
(wire-stable struct) 변경 또는 별도 plumbing 이 필요. `ingest_one_asset` 가
`progress` 핸들을 이미 들고 있으므로 새 이벤트를 직접 emit 하는 쪽이 crate
경계(kebab-core 불변)도 지키고 더 깔끔. `AssetFinished` 는 손대지 않음.
**CLI 렌더(`kebab-cli` progress.rs).** `asset_chunked` → 진행바 message `→ N
chunks`. `expansion_progress` → message `별칭 확장 {done}/{chunks}` (라이브).
`asset_timings` → asset 종료 시 `⏱ parse Xs · chunk Ys · expand Zs · embed Ws
· store Vs` 한 줄(`fmt_ms`: <1s 는 ms, ≥1s 는 1-decimal 초). `--json` 은
`emit_json` 이 임의 이벤트를 직렬화하므로 자동 처리. `--quiet` 억제, 비-TTY
expansion_progress 는 로그 폭주 방지로 기본 억제(진행바 message 로 커버).
**검증.** `cargo clippy --workspace --all-targets -- -D warnings` exit 0,
`cargo test -p kebab-app -p kebab-cli` exit 0. 단위 테스트: ingest_progress.rs
(3 신규 변이 직렬화 `kind` 판별 + 순서 불변식 재작성), progress.rs(`fmt_ms` 단위
전환), 통합(`--json`/human stderr 에 새 이벤트 흐름). 실동작 smoke: 2-문서 ingest
의 `--json` 에 `asset_chunked`/`asset_timings` 출현 + human `⏱ parse…·store…` 라인
확인. expansion 라이브 카운터는 원격 LLM 필요라 단위/통합으로 커버.
**리뷰 반영.** (1) `store_ms` 경계 정정 — stale-vector orphan purge(LanceDB I/O)를
`store_ms`(SQLite persist 전용)에서 빼 `embed_ms`(vector phase)로 이동. 진단
정확도: store_ms 가 이제 SQLite put_* 만 의미(편집 재색인 시 920ms 가 실은 벡터
삭제였던 오귀속 제거). purge 는 여전히 unconditional + 새 upsert 이전 실행 —
기능 동등. (2) 최종 `expansion_progress` 프레임을 `done != last_done` 로 가드 —
chunks 가 throttle 배수일 때의 중복 프레임 + chunks==0 시 0/0 프레임 제거.
**알려진 한계.** image/pdf 경로는 phase timing 없음(AssetChunked 만).
expansion_progress 비-TTY 억제는 의도적(필요 시 `--json` 으로 전량 관측).
## 2026-06-02 — ingest 백엔드/디바이스 표시 + KB 이전 문서 (v0.23.1)
**동기.** Metal 빌드가 실제로 GPU 를 쓰는지 사용자가 터미널에서 못 봐서 Activity
Monitor 로 확인해야 했다(`select_device()` 의 device 로그는 kb.log 파일로만, 기본
EnvFilter=warn 이라 `--verbose` 필요). 또 "어떤 DB 파일을 옮기나" 가 README 에
구체적이지 않았다.
**무엇.** (1) `kebab-cli` ingest 시작 시 임베딩 백엔드/모델/차원을 stderr 한 줄로
표시(`임베딩 백엔드: candle (Metal/GPU 빌드) · 모델 …`), `--json`/`--quiet` 에선
억제. Metal 표기는 `cfg!(feature="embed_metal")` 기반(빌드 사실); 확정 런타임
디바이스는 여전히 kb.log(`candle device = …`). (2) README "외부 계산 + 로컬 검색"
절에 복사 대상 2개(`kebab.sqlite`/`sqlite`, `lancedb/`/`vector_dir`)와 `[storage]`
config 키·`models/`·`assets/` 복사 불필요·동일 버전/모델 조건·rsync 예시 추가.
**범위.** CLI 출력 + 문서만. 동작·wire·schema·벡터 변경 없음. 버전 0.23.0 → 0.23.1.
## 2026-06-02 — candle Metal(Apple Silicon GPU) opt-in build feature
**동기.** candle CPU 임베딩은 e5-large/512-tok 에서 ~1.5~1.9 s/chunk 로 느리고,
코어를 더 줘도(rayon/MKL) 안 빨라진다(병목=커널 효율). 대용량 코퍼스(수만 청크)는
CPU 로는 수 시간. 사용자 워크플로: **M4 Pro 맥에서 GPU 로 빠르게 색인 → sqlite +
lancedb 만 Linux NUMA 서버로 복사 → 서버는 CPU candle 로 질의** (벡터 동일 모델이라
호환, KB 이식성은 06-01 항목 + workspace_path 상대경로 + chunks.text 저장으로 확인).
**무엇.** `kebab-embed-candle` 에 `metal` feature 추가 →
`candle-core/-nn/-transformers` 의 metal 백엔드 활성. `select_device()` 가 metal
빌드 시 `Device::new_metal(0)` 선택(실패 시 CPU fallback), 비-metal 빌드는 기존
`Device::Cpu` 그대로. host 복사 전 `.contiguous()` 추가(Metal 의 strided view 가
`to_vec2` 거부 — CPU 는 허용). feature passthrough: `kebab-app/embed_metal` →
`kebab-cli/embed_metal`. 빌드: `cargo build --release --features embed_metal`(macOS).
**제약 / 검증 분담.** metal 은 **macOS 전용 컴파일** — Linux CPU 머신(개발/서버)은
비-metal 경로만 빌드(검증: clippy 0 + candle 단위 6 + thread_cap + parity, exit 0).
**Metal 실행·속도·벡터 패리티(GPU vs CPU)는 M4 Pro 에서 사용자 검증** (Claude 의
Linux 환경에서 불가). 로그 `candle device = Metal (GPU)` 로 GPU 사용 확인.
**호환성.** default(비-metal) 동작·벡터 불변. wire/schema 변경 없음. 버전 0.22.0 →
**0.23.0** (신규 opt-in build feature surface).
amends: `docs/superpowers/specs/2026-06-01-embed-candle-track-spec.md` (§10 후속 — GPU 가속).
## 2026-06-01 — candle 임베딩 provider (NUMA double-free 회피, opt-in)
**무엇이 문제였나.** 듀얼소켓 NUMA 서버에서 `provider=fastembed`(onnxruntime)로
대규모 ingest(5150-doc)를 돌리면 onnxruntime 가 intra-op 스레드를 48개로
하드코딩해 NUMA 힙을 손상시키고 double-free 로 프로세스가 죽었다. 스레드 수를
config 로 줄일 surface 가 없었고, fastembed 4.9 의 ORT 바인딩은 이를 노출하지
않는다.
**진단 / 결정 (사용자 승인 2026-06-01).** 같은 모델
`intfloat/multilingual-e5-large` 를 **candle(순수 Rust)** 로 돌리는 임베딩
provider 를 추가하기로 결정. candle 의 CPU 백엔드는 글로벌 rayon 풀 크기로
스레드를 정하므로, 한 번의 `rayon::ThreadPoolBuilder::build_global` 캡으로
스레드를 NUMA-안전한 수로 묶을 수 있다. **재색인 0 목표**(`embedding_version`
유지) — Phase 0 스파이크(커밋 76841af)가 candle vs onnxruntime **코사인
1.000000** 패리티를 입증했고, 본 Track 1 구현의 패리티 테스트로 차원별 max
절대오차를 재실측해 확정.
**무엇을 건드렸나.**
- 신규 crate `crates/kebab-embed-candle` — `kebab_core::Embedder` 구현
(`CandleEmbedder`). 스파이크 파이프라인(safetensors via hf-hub → XLM-RoBERTa
forward → attention-mask mean pooling → L2 → e5 prefix)을 production 으로
흡수. deps 는 candle 트리를 이 crate 에 격리 (core/config 외 다른 kebab-*
의존 0 — design §8 경계). 모델 캐시 `{model_dir}/candle/`.
- 스레드 캡: `[models.embedding].num_threads`(u32, default 0=auto) + env
`KEBAB_EMBED_THREADS`(우선). `CandleEmbedder::new` 에서 n>0 이면 글로벌 rayon
풀 1회 캡(이미 init 시 no-op).
- 주입 분기: `kebab-app::App::embedder()` 가 `config.models.embedding.provider`
분기 — `fastembed`/`onnx`/(빈값) → 기존 `FastembedEmbedder`(동작 불변),
`candle` → `CandleEmbedder`, 미지값 → 에러. `none` 은 기존 lexical-only 유지.
- 스파이크 crate `crates/spike-embed-candle` 제거(학습은 production 으로 흡수됨).
- 버전 0.21.1 → **0.22.0** (신규 config surface — pre-1.0 minor bump).
**패리티 증거.** candle vs `FastembedEmbedder`(onnxruntime), 동일 10문장
(한/영 혼합, e5 `passage:`/`query:` prefix): **cosine_min = 1.000000,
차원별 max 절대오차 = 2.01e-7** (f32 커널 반올림 수준 — 랭킹 영향 임계보다
약 50배 작음). 재현: `cargo test -p kebab-embed-candle --release -- --ignored
--nocapture` (`crates/kebab-embed-candle/tests/parity.rs`, 모델 ~2GB 필요라
CI 기본 제외). 이 수치가 `embedding_version` 유지(재색인 0) 결정의 근거.
**호환성.** fastembed default 경로의 동작/벡터 불변. `embedding_version`
유지 → 기존 색인 재사용(재색인 0). wire schema 변경 없음. 옛 config.toml 은
`num_threads` 가 serde default(0)로 채워져 그대로 파싱.
**잔여 게이트 (사용자 실행, Claude 불가).** 그 듀얼소켓 NUMA 서버에서
`provider=candle` 로 ingest 가 double-free 없이 EXIT=0 완주하는지 — 사용자
배포·실사용이 곧 이 검증을 겸한다 (meta-spec §4.3).
**도그푸딩 (2026-06-02, 단일소켓 12-thread VM).** `provider=candle` +
`config-candle.toml`(expansion off — 임베더 격리) 로 `/build/dogfood/corpus`
전체 재색인: **scanned=998, new=997, errors=0, stderr=0, KB 997 docs /
23,151 chunks**, duration ≈ 34,329 s (9.5 h). candle 가 23k+ 청크를 메모리
오류 0 으로 완주 — onnxruntime 이 서버에서 6/5150 에 죽던 것과 정반대.
(이 VM 은 비-NUMA 라 NUMA 자체 재현은 아니나, candle 은 onnxruntime 을
호출하지 않으므로 동일 크래시 종류가 구조적으로 불가.)
**A1(taskset/numactl) 워크어라운드 실서버 반증 (2026-06-02).** 사용자가 NUMA
서버에서 `taskset -c 0-3 kebab ingest`(fastembed/onnx 바이너리) 실행 → 4코어로
제한했는데도 6/5150 에서 `세그멘테이션 오류 (core dumped)`. 스레드 축소가
onnxruntime 힙 손상을 제거하지 못함(크래시 위치만 3→6 이동). 결론: 이 크래시는
스레드 *수* 문제가 아니라 onnxruntime 네이티브 코드의 메모리 안전 결함 →
**A1 은 신뢰 불가 우회책. candle(onnxruntime-free)이 유일한 실 해법.**
**MKL 가속 부정 결과 (2026-06-02).** "candle 이 코어를 더 쓰게" 하려고 candle
`mkl` feature(Intel MKL) 를 벤치 (e5-large, 512-tok 청크, N=32):
pure-Rust 1857 ms/chunk(381% CPU) vs MKL 2574 ms/chunk(896% CPU, rayon12+mkl12)
/ 2792 ms/chunk(817% CPU, rayon1+mkl12). **MKL 은 코어를 더 쓰지만 모든 설정에서
38~50% 더 느림** (MKL 2020.1 sgemm + 스레드 오버헤드/과다구독; candle 0.10.2 는
f16 `hgemm_` 미해결로 링크도 실패 — 벤치는 호출 안 되는 스텁으로 우회). 또
pure-Rust 는 rayon 8↔12 간 throughput 불변(~1.86 s/chunk) — 병목은 코어 수가
아니라 candle e5-large/512tok 커널 효율. **결론: MKL 미채택, 순수-Rust 유지(안전
최상 + CPU 에서 더 빠름). 속도 레버는 코어가 아니라 청크 길이/모델 크기/GPU.**
amends: `docs/superpowers/specs/2026-06-01-embed-candle-track-spec.md`.
## 2026-05-31 — config 마이그레이션 (`kebab config migrate`)
**Trigger**: config.toml 스키마가 진화해도(v0.21.0 의 `[ingest.expansion]` 등) 기존 사용자 파일은 serde default 로 *동작*만 호환될 뿐 새 섹션이 파일에 안 써져 사용자가 노브의 존재를 알 수 없었다. DB 의 V00X refinery 와 달리 config 엔 마이그레이션 메커니즘이 없어 추가. 설계 `docs/superpowers/specs/2026-05-31-config-migration-design.md`, 계획 `docs/superpowers/plans/2026-05-31-config-migration.md`, PR #198.
### 메커니즘
`kebab config migrate` 가 (1) **reconciliation** — `Config::defaults()` 구조에 있고 사용자 파일에 없는 섹션/키를 주석과 함께 `toml_edit` 으로 추가(버전 무관·멱등) + (2) **step 체인** — `schema_version` 기반 non-additive 변환(첫 step v1→v2 = `workspace.include` 제거, p9-fb-25). `init` 과 migrate 가 `annotated_default_document()` 로 주석·헤더 단일 원천 공유 → init config 도 섹션 주석 보유. `schema_version` default 1→2(sync 마커+step 축). 안전 3축=멱등·백업(`.bak`, 원본 byte-identical)·dry-run + tmp atomic rename(round-trip 검증). 순수변환=`kebab-config/migrate.rs`, I/O facade=`kebab-app`.
### 도그푸딩 evidence (v0.21.0 release 바이너리)
옛 스키마 흉내(`schema_version=1`, `[workspace]`+`[search]`+`[rag]`, `workspace.include` 보유, 사용자가 `default_k=25`/`score_gate=0.8`+인라인 주석 손봄):
| 시나리오 | 결과 |
|----------|------|
| `migrate --dry-run` | 22 changes 나열, **파일 미수정** |
| `migrate` | 적용 v1→v2, `.bak` **원본과 byte-identical**(diff 0) |
| 값·주석 보존 | `root="~/MyNotes" # 내가 직접 바꾼…`, `default_k=25`, `score_gate=0.8` 유지 |
| deprecated 정리 | `workspace.include` 제거(grep 0) |
| 가시화 | `[ingest.expansion]`·`[logging]`·`[pdf.ocr]` 등장 |
| 멱등 | 재실행 → `config 이미 최신입니다 (schema v2)` |
| doctor | `✓ config_migration config up to date (schema v2)` |
| `--json` | `config_migration.v1` (kind=added_section/removed_deprecated) |
### 알려진 한계 / 결정
- 누락 섹션은 테이블 끝 append(순서 미보존, 값·주석·기존순서는 보존).
- 통째 누락 부모는 부모 경로 1건 기록, 부분 존재 부모는 leaf 경로 기록(재귀 깊이 차이).
- doctor 의 `config_migration` ok=false 가 전체 `DoctorReport.ok` 를 false 로 만듦(의도; hint 가 교정 명령 제시, warn 상태 미도입).
- `schema_version` bump(1→2)은 additive(데이터 무효화 아님, 읽기 호환 유지) → DB/wire breaking release 트리거 아님. 신규 CLI 서브커맨드+doctor 체크+init 출력 변경은 user-visible surface.
## 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 + 도그푸딩 전부 완료.