Compare commits

...

42 Commits

Author SHA1 Message Date
736d791056 Merge pull request 'feat(ingest): 진행 로그 개선 — 파일명/phase/heartbeat/slowest 요약' (#204) from feat/ingest-log-improve into main
Reviewed-on: #204
2026-06-03 11:04:19 +00:00
6c9c8df43e chore(version): 0.27.0 → 0.26.1 — 새 bump 규칙상 patch
진행 로그 개선은 검색·색인 결과 불변 + 새 명령/플래그/config 없음 + additive-only
wire(asset_phase)라 CLAUDE.md 신규 규칙(기능/인터페이스 변경=minor, 없으면 patch)상
patch 가 맞음. version·라벨·HOTFIXES 헤더를 0.26.1 로 정정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:02:16 +00:00
0263667684 docs(claude): pre-1.0 버전 bump 규칙 명확화 — 기능/인터페이스 변경=minor, 없으면 patch
minor 과다 사용 교정. 기능(behavior)·인터페이스(CLI/config/breaking wire/결과·동작/
migration) 변경 시 minor, 그 외(bug fix·refactor·관측성/로깅·additive-only wire·문서)
는 patch. 경계 예시 포함(진행로그+additive wire=patch, arctic provider/config=minor).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:00:49 +00:00
4918983d9c chore(ingest): PR #204 회차1 리뷰 반영 — 버전 라벨 v0.26.0 → v0.27.0
신규 진행로깅 표면(asset_phase / ocr_ms / caption_ms + progress.rs heartbeat·
slowest 주석)이 v0.26.0 으로 잘못 표기돼 있던 것을 v0.27.0(실제 추가 버전)으로
정정. wire schema 의 "추가 버전" 정확성(외부 통합 참조). 로직 변경 없음(주석/doc).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:57:17 +00:00
aeaa18a564 feat(ingest): 진행 로그 개선 — 파일명/phase/heartbeat/slowest 요약
OCR/caption 켜진 볼트 ingest 가 중간부터 느릴 때 TTY 진행바가 파일명·phase·
모델·경과시간을 안 보여 "멈춤"처럼 보이던 문제 해결.
- 신규 wire AssetPhase{idx,total,phase,model} + AssetTimings.ocr_ms/caption_ms
  (additive, ingest_progress.v1 유지)
- app: apply_ocr/apply_caption/embed 진입 시 AssetPhase emit + ocr/caption 시간 측정
- cli: TTY 진행바에 현재 파일명 + phase(model) + asset 경과초(heartbeat),
  종료 시 최장 소요 파일 top-5 요약(quiet 여도 출력, --json 미출력)
- wire schema / README / HANDOFF / HOTFIXES 동기화, version 0.26.0 → 0.27.0

검증(리더): clippy 0, kebab-app/cli 61그룹·parse-image/tui 14그룹 0실패(-j8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:52:26 +00:00
c91ff909ce docs(spec): ingest 로그 개선 spec + plan
arctic 도그푸딩에서 OCR/caption 켜진 Obsidian 볼트가 중간부터 느려졌으나
TTY 진행바가 파일명·phase·모델·경과시간을 안 보여 "멈춤"처럼 보인 문제.
파일명 표시 + 느린 phase(OCR/caption/embed)+모델 실시간 + asset 경과 heartbeat
+ 종료 시 최장 소요 top-N 요약. additive wire(asset_phase, ocr_ms/caption_ms).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:14:15 +00:00
8dee610a97 docs(hotfixes): arctic 종단 도그푸딩 evidence (recall@10 130/132)
kebab v0.26.0 실제 파이프라인(ollama arctic)으로 namu 재색인 → 확장 골든 eval
recall@10 130/132·recall@50 132/132·fully_consistent 22/24 종단 재현. 측정→구현
→실파이프라인 삼중 확인. 릴리스 전 도그푸딩 trigger(embedder 모델 변경) 충족.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 07:19:19 +00:00
d71ed2516b Merge pull request 'feat(embed): arctic-embed-l-v2.0 임베더(candle+ollama)' (#203) from feat/arctic-embedder into main
Reviewed-on: #203
2026-06-03 06:27:55 +00:00
095c9f37a2 docs(smoke): embedding config 블록 v0.26.0 동기화
SMOKE.md 의 [models.embedding] 예시 주석이 stale: provider 목록에 ollama 누락 +
"candle 은 e5-large 만 지원"(arctic 추가로 더 이상 사실 아님) + endpoint/arctic
미기재. CLAUDE.md §"README Configuration + SMOKE config 블록 동시 갱신" 규칙대로
보완 — provider 4종, arctic 모델(candle/ollama 태그), endpoint(ollama 전용, llm
endpoint fallback), e5↔arctic cascade 주석 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 05:11:03 +00:00
16ddb1dfc3 docs: arctic 임베더 문서 동기화 (README/ARCHITECTURE/HANDOFF/HOTFIXES)
README Configuration: provider candle/ollama + arctic 모델(candle CLS / ollama 태그)
+ endpoint + e5→arctic cascade 경고. ARCHITECTURE: 백엔드 그래프 노드(embedollama)
+ 임베딩 백엔드 결정표(채택 근거 측정 recall@10 130) + 디렉토리 트리. HANDOFF 1줄.
HOTFIXES 2026-06-03 arctic dated entry(레지스트리/pooling/prefix/cascade + 수동
cosine 0.999984 실측 결과).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 04:59:23 +00:00
72c99c452c feat(config,app): embedding provider=ollama 배선 + endpoint, version 0.26.0
kebab-config: EmbeddingModelCfg.endpoint: Option<String>(serde default, ollama용,
None→models.llm.endpoint 폴백) + provider 문서에 ollama + env
KEBAB_MODELS_EMBEDDING_ENDPOINT. kebab-app embedder(): provider match 에 ollama
분기(facade 경유). workspace member += kebab-embed-ollama, app dep 추가.
version 0.25.0 → 0.26.0(minor, +Cargo.lock) — 신규 임베딩 백엔드/모델은 CLAUDE.md
§Release 의 surface 변경 트리거.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 04:59:23 +00:00
cbcae69abf feat(embed): candle 모델 레지스트리 + arctic-embed-l-v2.0 (CLS pooling)
e5 하드코딩(HF_MODEL/SUPPORTED_MODEL/mean/query:+passage:) → 모델 레지스트리
EmbedModelSpec{name,hf_repo,pooling,query_prefix,doc_prefix,dim,version_tag}.
e5(mean, query:/passage:) + arctic(CLS, query:/무접두어). pooling 모델별 분기
(mean=attention-mask-weighted / CLS=hidden[:,0,:]), tokenize/forward/L2 공유.
arctic pooling=CLS 는 HF 1_Pooling/config.json(pooling_mode_cls_token:true) 확인.
model_version 은 arctic 일 때 +arctic-cls 태그(embedding_version cascade 트리거);
e5 는 fastembed-e5 호환(NUMA 드롭인) 위해 plain config.version 유지.

correctness 게이트: tests/arctic_ollama_parity.rs (#[ignore], live Ollama) —
candle arctic vs Ollama snowflake-arctic-embed2 per-sentence 코사인>0.99.
수동 실측 cosine_min=0.999984 (recall@10 130 재현 보장).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 04:59:11 +00:00
7505645008 feat(embed): kebab-embed-ollama 신규 크레이트 — Ollama /api/embed Embedder
arctic-embed-l-v2.0 의 폴백 백엔드(측정에 쓴 경로 그대로). reqwest::blocking
POST {endpoint}/api/embed {model,input:[...]} → embeddings. batch 48 +
fail-soft 재시도 3, 결과 L2 정규화(Ollama raw 반환 → 일관성), dim 검증.
query/doc prefix 는 모델 태그로 추론(arctic-embed→query:/무접두어, e5→query:/passage:).
model_version=ollama:{model}. endpoint=models.embedding.endpoint ?? models.llm.endpoint.
wiremock 테스트 3종(L2 정규화/dim mismatch/empty no-op) + 단위 5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 04:59:11 +00:00
e2ae9a4589 docs(spec): arctic-embed-l-v2.0 임베더 통합 spec + plan
별칭 제거 후 설명형 recall 보강 최선책(측정 arctic recall@10 130/132,
용어 무손실). candle 다중모델화(CLS pooling+query: prefix) 우선 + Ollama
embed provider 폴백. correctness 게이트 = candle≈Ollama 코사인>0.99.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 04:14:26 +00:00
1dfab6dfc5 Merge pull request 'refactor(app): doc-side expansion(별칭) 기능 제거' (#202) from refactor/remove-doc-expansion into main
Reviewed-on: #202
2026-06-03 00:39:26 +00:00
fc5103642e docs: 별칭 제거 문서 동기화 + version 0.25.0
HOTFIXES 2026-06-03 dated entry, 2026-05-30 design spec 제거 banner,
HANDOFF 1줄, README(별칭 섹션/config/명령표 정리), ARCHITECTURE(결정 표 +
디렉토리 트리), SMOKE/DOGFOOD config-migrate 예시 정정. workspace version
0.24.0 → 0.25.0 (+ Cargo.lock).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:37:58 +00:00
e03d03cb26 test: 별칭 전용 테스트 삭제 + 영향 테스트/fixture 갱신
kebab-search/tests/lexical.rs 의 alias 채널 테스트 + insert_chunk_with_aliases
헬퍼 제거(body 회수 회귀 테스트로 대체). Chunk 리터럴 aliases: None 제거
(embedding_records_fk/idempotency/inspect). chunk 스냅샷 fixture 의 aliases
키 제거. config_migrate 는 ingest.code 앵커로, corpus_revision/search_lexical
주석은 V013 비-bump 명시로 갱신.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:37:58 +00:00
16aadea222 feat(store): V013 마이그레이션 — chunk_aliases_fts + chunks.aliases DROP
forward-only 마이그레이션으로 V010 이 만든 chunk_aliases_fts(+트리거)와
chunks.aliases 컬럼 제거. 과거 V010 은 freeze 무수정. 순수 구조 변경 —
corpus_revision bump 안 함(spec §결정: 본문/임베딩 불변, in-process LRU 는
프로세스별·query 이전 실행이라 bump 무의미).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:37:44 +00:00
a48c405826 refactor(wire): ExpansionProgress 이벤트 + 렌더 제거
IngestEvent::ExpansionProgress variant + 직렬화 테스트 제거(AssetChunked/
AssetTimings 유지). CLI/TUI 의 expansion 렌더 제거, AssetTimings 한 줄에서
expand 세그먼트 제거. ingest_progress.v1 schema 의 expansion_progress kind
제거, expansion_ms 설명을 "값 0 유지"로 갱신.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:37:44 +00:00
21e02d8a93 refactor(app): ingest 별칭 생성·캐시·sentinel 벡터 루프 제거
ingest_one_asset 의 청크당 별칭 LLM 생성·derivation_cache 조회/저장·
embed_aliases sentinel 벡터(`{orig}#alias#N`) upsert 루프 제거.
expansion_ms 는 wire 호환 위해 0 고정. alias_sentinel_ids_to_delete 와
orphan purge 3개 호출부를 본문 chunk_id 직접 삭제로 단순화.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:37:43 +00:00
a64c31ee94 refactor(search): alias lexical arm 제거
run_alias_query / merge_body_alias 제거, 검색을 body_rows 직접 사용으로
단순화. build_match_string_for_column 의 column 매개변수 인라인(text 고정).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:36:56 +00:00
ec96648956 refactor(config): ExpansionCfg + [ingest.expansion] 제거
IngestExpansionCfg struct + IngestCfg.expansion 필드 + Default +
KEBAB_INGEST_EXPANSION_* env 파싱 + 테스트 제거. migrate.rs 의
ingest.expansion 섹션 주석 제거 — config migrate 테스트는 ingest.code 앵커로
정정(forward-compat: 기존 [ingest.expansion] 섹션은 serde 가 무시).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:36:56 +00:00
ecaf224381 refactor(chunk): Chunk 생성부의 aliases 리터럴 + store 컬럼 제거
kebab-chunk/* AST·md·tier2·pdf chunker 의 aliases: None 리터럴 삭제,
store-sqlite documents.rs chunks INSERT 컬럼/바인딩 + get_chunk 매핑에서
aliases 제거.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:36:44 +00:00
b1c5feb3f3 refactor(core): Chunk.aliases 필드 제거
doc-side expansion(별칭) 제거 — Chunk 의 aliases: Option<String> 필드와
serde default 테스트 제거. Metadata.aliases(Vec, 문서 메타)는 유지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:36:44 +00:00
ca8c0645fb docs(spec): doc-side expansion(별칭) 제거 spec + plan
연구문서(2026-06-03) 결론 따라 별칭 기능 제거 설계. 유지(Metadata.aliases,
AssetChunked/AssetTimings, embedding 캐시)/제거(Chunk.aliases, expansion.rs,
ExpansionCfg, chunk_aliases_fts, run_alias_query, ExpansionProgress) 경계 +
신규 DROP 마이그레이션 + wire expansion_progress 제거 결정. 구현 plan 8 task.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:13:12 +00:00
c7af6612b7 docs(research): expansion 비용 재고 + 별칭 대체 딥리서치
별칭(doc-side per-chunk LLM expansion)이 ingest 임계경로 병목으로 확정된 뒤
대안 조사. 동시성(OLLAMA_NUM_PARALLEL 최대 1.28×)·모델스왑(qwen3.5 중국어/
degeneration)·백그라운드(총량·treadmill 불변) 모두 실측 소진. Step 0 측정:
별칭 없이도 cross-lingual recall@10 완벽(en/ko/syn/abbr), 약점은 설명형뿐
→ 별칭 ROI 음수. Step 1: bge-m3 dense 는 lateral(설명형 +3 / 용어 -3, 순0).
4-agent 딥리서치: 잔존 = reverse-dictionary 과제, 측정-우선 계층(heading
enrichment → arctic-ko 임베더 → bge-reranker-v2-m3 → near-tie 게이트 expansion).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:07:42 +00:00
acb4fa6c65 Merge pull request 'feat(ingest): asset 내부 phase 진행 로깅 (asset_chunked/expansion_progress/asset_timings)' (#201) from feat/ingest-progress-detail into main
Reviewed-on: #201
2026-06-02 17:26:47 +00:00
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
76 changed files with 4347 additions and 1273 deletions

View File

@@ -82,7 +82,10 @@ Release 절차:
1. `gitea-release v<X.Y.Z>` (gitea-ops skill) 으로 tag + push + release notes.
2. release notes 는 사용자 도그푸딩에 영향이 가는 surface 변경을 위주로 — wire schema 추가, CLI flag 신규, TUI 키 변경, V00X migration 등 — 다룬다. 이때 추가된 기능과 변경사항은 유저가 이해할 수 있도록 친절하고 자세하게 풀어서 설명해야 하며, 단순히 commit subject 를 나열하는 형태로 끝내면 안 된다. 필요하다면 도그푸딩이나 테스트 결과도 함께 적어 둔다.
3. 프리-1.0 (`0.x.y`) 단계: minor bump 시 wire schema additive / surface 변경 누적, patch bump 시 bug fix only.
3. 프리-1.0 (`0.x.y`) 단계 bump 규칙 — **기능(behavior) 또는 인터페이스(interface) 변경 여부**로 판정:
- **minor bump** (`0.x.0`): 기능 또는 인터페이스에 *실질적* 변경이 있을 때. 인터페이스 = 신규/변경/삭제된 CLI subcommand·flag, config 키, wire schema 의 **breaking** 변경, 임베딩/검색/RAG 등 사용자가 받는 **결과·동작**의 변화, V00X migration, frozen 설계 변경. 기능 = 새 source 형식·검색 모드·백엔드 등 *할 수 있는 일*의 추가/변경.
- **patch bump** (`0.x.y`): 기능·인터페이스 변경이 **없을** 때. bug fix, 내부 refactor, 성능 개선, 로깅/진행표시 등 **관측성(observability) 개선**, **additive-only wire 변경**(backward-compat 신규 필드/이벤트라 기존 소비자 무영향), 문서. ← 즉 "결과가 같고 새 명령/플래그/config 도 없으면 patch".
- 경계 예: 진행 로그에 phase/파일명 추가 + additive wire 이벤트(asset_phase) = **patch** (검색·색인 결과 불변, 새 명령/플래그/config 없음). arctic 임베더 provider + 신규 config 키 = **minor** (인터페이스 추가). 별칭 기능 제거 + migration = **minor** (동작·인터페이스 변경).
**bump 시점 = release 시점 같은 commit**. 즉 commit `chore: bump version 0.x → 0.y` 직후 같은 commit 에 tag. v0.1.0 (`2319206`) 처럼 bump 없이 tag 만 찍는 패턴은 후속 release 가 대상 commit 을 헷갈리게 함 — pre-release snapshot 은 SHA reference 로 충분.

837
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,8 @@ members = [
"crates/kebab-search",
"crates/kebab-embed",
"crates/kebab-embed-local",
"crates/kebab-embed-candle",
"crates/kebab-embed-ollama",
"crates/kebab-llm",
"crates/kebab-llm-local",
"crates/kebab-rag",
@@ -30,7 +32,7 @@ edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
repository = "https://github.com/altair823/kebab"
version = "0.21.1" # v0.21.1 — config 마이그레이션(kebab config migrate): 기존 config.toml 에 빠진 섹션 주석과 함께 추가 + deprecated 정리 + schema_version 1→2 — CLAUDE.md §Release 도그푸딩 트리거
version = "0.26.1" # v0.26.1 — ingest 진행 로그 개선: TTY 진행바에 현재 파일명 + 느린 phase(ocr/caption/embed)+모델명 실시간 + 경과초 heartbeat `(Ns)`, 종료 시 최장 소요 파일 top-5 요약. 신규 wire 이벤트 `asset_phase{idx,total,phase,model}` + `asset_timings.ocr_ms`/`caption_ms` 추가(additive, ingest_progress.v1 유지, serde default 0). 기본 동작 불변. — 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,10 +30,14 @@ 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-06-03 ingest 진행 로그 개선** — v0.26.1. 이미지/PDF + OCR/caption on 볼트 ingest 가 "멈춘 듯" 보이던 문제 해소: TTY 진행바에 현재 파일명 + 느린 phase(ocr/caption/embed)+모델명 + 경과초 `(Ns)` heartbeat, 종료 시 최장 소요 파일 top-5 요약. 신규 wire `asset_phase{idx,total,phase,model}` + `asset_timings.ocr_ms`/`caption_ms`(additive, `ingest_progress.v1` 유지, serde default 0). 이미지·PDF 경로도 `asset_timings` emit(이전 markdown 만). 기본 동작 불변. 자세한 내용: `tasks/HOTFIXES.md` (2026-06-03 ingest 진행 로그), spec/plan `docs/superpowers/{specs,plans}/2026-06-03-ingest-log-improve-*.md`.
- **2026-06-03 arctic-embed-l-v2.0 임베더 통합** — v0.26.0. 별칭 제거 후 설명형 query recall 보강(측정 recall@10 130/132, e5 +7). `kebab-embed-candle` 모델 레지스트리화(e5 mean + `snowflake-arctic-embed-l-v2.0` CLS, 모델별 pooling/prefix) + 신규 `kebab-embed-ollama`(`provider="ollama"`, `/api/embed`). config `endpoint: Option<String>` 추가. 기본 e5 유지(opt-in), arctic 전환은 embedding_version cascade → 재색인. candle↔Ollama cosine>0.99 게이트로 pooling/prefix 정확성 고정(`#[ignore]`). 자세한 내용: `tasks/HOTFIXES.md` (2026-06-03 arctic), spec `docs/superpowers/specs/2026-06-03-arctic-embedder-spec.md`.
- **2026-06-03 doc-side expansion(별칭) 기능 완전 제거** — v0.25.0. 아래 2026-05-31 항목의 색인-시 청크당 LLM 별칭 생성 + 별칭 검색 채널을 **전부 제거**(ROI 음수: cross-lingual 은 e5-large 단독으로 충분, 기여는 설명형 +2 그룹뿐인데 대가가 청크당 색인-시 LLM). `Chunk.aliases`/`expansion.rs`/`IngestExpansionCfg`/alias lexical arm/`expansion_progress` wire kind 제거, 신규 마이그레이션 **V013**`chunk_aliases_fts`+`chunks.aliases` DROP. 별칭 default-off 였어 사용자 체감 0, 기존 KB 도 재색인 불요(잔존 별칭 벡터는 `strip_alias_suffix` graceful 매핑/`reset` 정리). `AssetTimings.expansion_ms` 는 wire 호환 위해 값 0 으로 유지. 자세한 내용: `tasks/HOTFIXES.md` (2026-06-03), spec `docs/superpowers/specs/2026-06-03-remove-doc-expansion-spec.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).

View File

@@ -41,17 +41,30 @@ clone 없이 git URL 로 바로 설치할 수도 있다: `cargo install --git ht
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` 으로만 정리.)
embedding 벡터를 청크 **내용 해시** 로 캐싱한다 (`derivation_cache`). 재색인·갱신 시 내용이 같은 청크는 재계산을 건너뛴다. 캐시 키에 모델·차원 버전이 포함돼 버전 변경 시 자동 무효화된다 (cascade 안전). 별도 설정 없이 투명하게 동작한다. (현재 TTL/LRU 자동 정리는 미구현 — 누적된 캐시는 `kebab reset` 으로만 정리.)
### 외부 계산 + 로컬 검색 워크플로
search/ask 는 asset 파일 없이 `kebab.sqlite` + `lancedb` 만으로 동작한다. 비싼 색인(임베딩·OCR·별칭 생성)을 성능 좋은 서버에서 수행한 뒤, 이 두 산출물만 로컬로 복사하면 그대로 검색·질문할 수 있다.
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 가 돌지 않을 때.
### 멀티미디어 색인
@@ -70,7 +83,7 @@ Markdown · PDF · 이미지(OCR + caption) · 소스코드(Rust/Python/TS/JS/Go
| 명령 | 동작 |
|------|------|
| `kebab init` | XDG 경로에 데이터 디렉토리 + config.toml 생성 |
| `kebab ingest [<path>]` | 워크스페이스 스캔 후 새/변경 문서 색인 (idempotent · incremental, `--force-reingest` 로 강제 재처리). 미지원 확장자는 자동 skip |
| `kebab ingest [<path>]` | 워크스페이스 스캔 후 새/변경 문서 색인 (idempotent · incremental, `--force-reingest` 로 강제 재처리). 미지원 확장자는 자동 skip. 진행바는 현재 **파일명** · 느린 **phase(ocr/caption/embed)+모델명** · **경과초**`(Ns)` · 문서별 청크 수 · phase별 소요시간(parse/chunk/ocr/caption/embed/store)을 표시하고, 종료 시 **최장 소요 파일 top-5** 를 요약한다 (`--json``asset_phase`/`asset_chunked`/`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` |
@@ -97,20 +110,70 @@ root = "~/KnowledgeBase" # 색인할 폴더. 절대 / tilde / env / 상대 경
# 상대 경로의 base 는 config.toml 위치 (cwd 무관).
[models.embedding]
provider = "fastembed" # "fastembed"(기본, onnxruntime) / "candle"(순수 Rust)
# / "ollama"(원격 HTTP) / "none"(lexical-only).
# candle 는 같은 모델·같은 벡터를 순수 Rust 로 돌려
# NUMA 서버의 onnxruntime 48-스레드 double-free 를 피하는
# opt-in 백엔드 (e5 는 재색인 불필요).
model = "multilingual-e5-large" # 다국어 sentence embedding (1024-dim).
# 첫 ingest 시 ONNX (~1.3GB) 자동 다운로드.
# candle provider 는 safetensors (~2GB) 다운로드.
# candle/ollama 는 "snowflake-arctic-embed-l-v2.0"
# (설명형 query 의 recall 보강) 도 지원 — 아래 참고.
dimensions = 1024 # config 와 LanceDB stored dim 불일치 시 검색 0건.
num_threads = 0 # candle 전용 CPU 스레드 캡 (0=auto=#cores).
# env KEBAB_EMBED_THREADS 가 우선. NUMA 노드 바인딩은
# numactl 과 조합. fastembed provider 는 무시.
# endpoint = "http://127.0.0.1:11434" # provider="ollama" 전용 HTTP endpoint.
# 생략 시 [models.llm].endpoint 로 폴백.
# fastembed/candle provider 는 무시.
```
**arctic-embed-l-v2.0 (설명형 query recall 보강)**: 기본 e5-large 대신
Snowflake `arctic-embed-l-v2.0` 임베더를 쓸 수 있다 (1024-dim, opt-in). 측정에서
설명형/약어/영문 용어 query 의 recall@10 이 e5 대비 향상됐다. 두 경로:
```toml
# (A) candle 백엔드 — 순수 Rust, in-process (NUMA 안전, Metal GPU 가능):
[models.embedding]
provider = "candle"
model = "snowflake-arctic-embed-l-v2.0" # CLS pooling, query 에 "query: " 접두어
# (문서는 무접두어). safetensors ~2GB 다운로드.
# (B) ollama 백엔드 — 원격/로컬 Ollama 데몬에 위임 (POST /api/embed):
[models.embedding]
provider = "ollama"
model = "snowflake-arctic-embed2" # Ollama 모델 태그 (ollama pull 필요)
endpoint = "http://127.0.0.1:11434" # 생략 시 [models.llm].endpoint
```
> ⚠️ e5 → arctic 전환은 `embedding_version` cascade 를 트리거한다 (모델이 다르면
> 벡터도 다름). 기존 e5 KB 와 혼용 불가 — 전환 시 **재색인** 필요 (`kebab reset`
> 후 재 ingest). 기본값은 e5 라 기존 사용자는 영향 없음.
**Apple Silicon GPU 가속 (candle / macOS)**: M-시리즈 맥에서 candle 임베딩을
GPU(Metal)로 돌리면 CPU 대비 대용량 ingest 가 크게 빨라진다. 빌드 또는 설치 시
`embed_metal` feature 를 켠다:
```bash
# 빌드만:
cargo build --release --features embed_metal
# 전역 설치 (~/.cargo/bin/kebab):
cargo install --path crates/kebab-cli --features embed_metal --locked
```
벡터는 CPU candle 과 동일 모델이라 호환되므로, 맥에서 GPU 로 색인한
`kebab.sqlite` + `lancedb/` 를 그대로 Linux 서버(CPU candle)로 복사해 질의할 수
있다. 색인 로그에 `candle device = Metal (GPU)` 가 보이면 GPU 사용 중. metal
feature 는 macOS 전용 (Linux/서버는 기본 CPU 빌드).
```toml
[models.llm]
endpoint = "http://localhost:11434" # Ollama host:port
model = "gemma4:e4b"
# request_timeout_secs = 300 # 큰 모델은 늘림. 0 은 disable 이 아니라 "즉시 timeout".
[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).
@@ -119,7 +182,7 @@ prompt_template_version = "rag-v3" # 답변 언어 = 질문 언어. rag-v1/v2
nli_threshold = 0.0 # >0 (예: 0.5) 면 mDeBERTa XNLI groundedness 검증.
```
- **파생물 캐시** — embedding·별칭 결과를 내용 해시로 자동 캐싱한다 (위 「핵심 기능」 참고). 설정 항목 없음.
- **파생물 캐시** — 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).

View File

@@ -18,6 +18,8 @@ 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-embed-ollama = { path = "../kebab-embed-ollama" }
kebab-llm = { path = "../kebab-llm" }
kebab-llm-local = { path = "../kebab-llm-local" }
kebab-rag = { path = "../kebab-rag" }
@@ -99,6 +101,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,7 +43,9 @@ 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_embed_ollama::OllamaEmbedder;
use kebab_llm_local::OllamaLanguageModel;
use kebab_parse_code::{
CAstExtractor, CppAstExtractor, GoAstExtractor, JavaAstExtractor, JavascriptAstExtractor,
@@ -833,9 +835,31 @@ 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 + arctic-embedder spec). The
// `embeddings_disabled()` check 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 (e5 or
// arctic via its model registry); `ollama` offloads to a remote
// `/api/embed` daemon.
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")?,
),
"ollama" => Arc::new(
OllamaEmbedder::new(&self.config).context("kb-app: load OllamaEmbedder")?,
),
other => {
return Err(anyhow!(
"kb-app: unknown embedding provider {other:?}; expected one of \
`fastembed` (default), `candle`, `ollama`, 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

@@ -1,274 +0,0 @@
//! 색인시 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,19 @@ pub struct AggregateCounts {
///
/// ```text
/// ScanStarted < ScanCompleted
/// < (AssetStarted [< (PdfOcrStarted < PdfOcrFinished)*] < AssetFinished)*
/// < ( AssetStarted
/// [< (PdfOcrStarted < PdfOcrFinished)*]
/// [< AssetChunked]
/// [< 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` / `AssetTimings` are the v0.24.0 asset-internal phase
/// events: `AssetChunked` fires once right after chunking (markdown /
/// image / PDF); `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 +90,52 @@ 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.26.1 (additive): emitted when an asset enters a *slow* internal
/// phase, so the interactive progress bar can show **which** phase
/// (and which model) is currently running instead of looking frozen.
/// `phase` ∈ {`"ocr"`, `"caption"`, `"embed"`}; short phases
/// (parse / chunk / store) are intentionally *not* emitted to avoid
/// noise. `model` is the model performing the phase — the vision LLM
/// id for `ocr` / `caption`, the embedder `model_id` for `embed`
/// (`None` when the phase runs without a configured model, e.g. embed
/// with no embedder wired). Emitted once per (asset, phase); no
/// throttle needed (low frequency). Wire v1 consumers that predate
/// this variant simply ignore the unknown `asset_phase` kind.
AssetPhase {
idx: u32,
total: u32,
phase: String,
model: Option<String>,
},
/// v0.24.0 (additive): per-phase wall-clock (milliseconds) for asset
/// `idx`, emitted once the asset's pipeline finishes. Lets a user see
/// *where* the time went (parse / chunk / ocr / caption / embed /
/// store) without parsing logs. The markdown path leaves `ocr_ms` /
/// `caption_ms` at 0 (no image analysis); the image / PDF paths fill
/// them so the slowest-asset summary attributes vision-model time
/// correctly. `expansion_ms` is retained for wire compatibility but is
/// always 0 since doc-side expansion was removed (HOTFIXES 2026-06-03).
/// `ocr_ms` / `caption_ms` (v0.26.1) are additive with serde default 0
/// so pre-v0.26.1 consumers deserialize cleanly.
AssetTimings {
idx: u32,
total: u32,
parse_ms: u64,
chunk_ms: u64,
expansion_ms: u64,
embed_ms: u64,
store_ms: u64,
#[serde(default)]
ocr_ms: u64,
#[serde(default)]
caption_ms: u64,
},
/// Run finished normally. `counts` is the final aggregate.
Completed { counts: AggregateCounts },
/// Run finished by user cancellation. `counts` is the partial
@@ -199,6 +253,121 @@ 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 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,
ocr_ms: 1_200,
caption_ms: 3_400,
};
let v = serde_json::to_value(&ev).unwrap();
assert_eq!(
v.get("kind").and_then(|s| s.as_str()),
Some("asset_timings")
);
// All 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),
("ocr_ms", 1_200),
("caption_ms", 3_400),
] {
assert_eq!(
v.get(field).and_then(serde_json::Value::as_u64),
Some(want),
"field {field}"
);
}
}
#[test]
fn asset_timings_ocr_caption_default_to_zero_for_legacy_wire() {
// v0.26.1 additive: a pre-v0.26.1 wire payload omits ocr_ms /
// caption_ms; serde `default` must fill 0 so old producers stay
// compatible.
let legacy = serde_json::json!({
"kind": "asset_timings",
"idx": 1, "total": 1,
"parse_ms": 5, "chunk_ms": 2, "expansion_ms": 0,
"embed_ms": 10, "store_ms": 3
});
let ev: IngestEvent = serde_json::from_value(legacy).unwrap();
match ev {
IngestEvent::AssetTimings {
ocr_ms,
caption_ms,
embed_ms,
..
} => {
assert_eq!(ocr_ms, 0);
assert_eq!(caption_ms, 0);
assert_eq!(embed_ms, 10);
}
other => panic!("unexpected event: {other:?}"),
}
}
#[test]
fn asset_phase_serializes_with_discriminator() {
// v0.26.1 additive variant — `kind` must be snake_case
// `asset_phase`, `phase` is the slow-phase label, `model` the
// model id (nullable).
let ev = IngestEvent::AssetPhase {
idx: 4,
total: 12,
phase: "ocr".into(),
model: Some("gemma4:e4b".into()),
};
let v = serde_json::to_value(&ev).unwrap();
assert_eq!(v.get("kind").and_then(|s| s.as_str()), Some("asset_phase"));
assert_eq!(v.get("idx").and_then(serde_json::Value::as_u64), Some(4));
assert_eq!(v.get("phase").and_then(|s| s.as_str()), Some("ocr"));
assert_eq!(v.get("model").and_then(|s| s.as_str()), Some("gemma4:e4b"));
}
#[test]
fn asset_phase_model_none_serializes_as_null() {
let ev = IngestEvent::AssetPhase {
idx: 1,
total: 1,
phase: "embed".into(),
model: None,
};
let v = serde_json::to_value(&ev).unwrap();
assert_eq!(v.get("phase").and_then(|s| s.as_str()), Some("embed"));
assert!(v.get("model").is_some_and(serde_json::Value::is_null));
}
#[test]
fn ingest_event_completed_has_counts() {
let ev = IngestEvent::Completed {

View File

@@ -63,7 +63,6 @@ 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;
@@ -480,6 +479,8 @@ pub fn ingest_with_config_opts(
let item = ingest_one_asset(
&app,
&asset,
idx,
scanned_count,
&parser_version,
&chunk_policy,
embedder.as_ref(),
@@ -1100,6 +1101,8 @@ fn embed_with_cache(
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>>,
@@ -1132,18 +1135,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,
@@ -1252,6 +1260,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()))?;
@@ -1286,75 +1298,30 @@ fn ingest_one_asset(
build_canonical_document(asset, metadata, parsed_blocks, parser_version, all_warnings)
.context("kb-parse-md::build_canonical_document")?;
let mut 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 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);
// Phase 2 doc-side expansion: flag on 이면 청크당 별칭 생성 (fail-soft).
// derivation cache(§3.4): 같은 청크 text + 같은 alias version_key 면 LLM
// 호출 없이 캐시된 별칭 재사용. version_key = {prompt_version}|{max}|{model}.
let mut alias_cache_hit = 0_usize;
let mut alias_cache_miss = 0_usize;
let mut alias_touch_keys: Vec<String> = Vec::new();
if app.config.ingest.expansion.enabled {
let exp = &app.config.ingest.expansion;
let alias_version_key = format!(
"{}|{}|{}",
crate::expansion::PROMPT_VERSION,
exp.max_aliases_per_chunk,
exp.model
);
let llm_built = if exp.model.is_empty() {
OllamaLanguageModel::new(&app.config)
} else {
OllamaLanguageModel::with_model(&app.config, &exp.model)
};
match llm_built {
Ok(llm) => {
let generator =
crate::expansion::ExpansionGenerator::new(&llm, exp.max_aliases_per_chunk);
for chunk in &mut chunks {
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())?;
}
}
}
}
Err(e) => {
tracing::warn!(
target: "kebab-app", error = %e,
"kb-app::ingest: expansion LLM 빌드 실패 — 별칭 없이 진행"
);
}
}
}
// 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,
},
);
// doc-side expansion(별칭) 제거됨 (HOTFIXES 2026-06-03). `expansion_ms`
// 는 wire 호환을 위해 AssetTimings 에 남기되 항상 0.
let expansion_ms = 0_u64;
// Stamp chunker + embedding versions so Task 7's skip detection has
// data on the second run.
@@ -1367,7 +1334,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")?;
@@ -1380,8 +1347,27 @@ 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).
// v0.26.1: surface the embed phase + model so a long embed run reads as
// "embedding(<model>)…" rather than a frozen bar (markdown path too).
crate::ingest_progress::emit(
progress,
crate::ingest_progress::IngestEvent::AssetPhase {
idx,
total,
phase: "embed".to_string(),
model: embedder.map(|e| e.model_id().0),
},
);
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) {
@@ -1431,97 +1417,38 @@ fn ingest_one_asset(
dimensions,
})
.collect();
// 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")?;
vec_store.upsert(&records).context("VectorStore::upsert")?;
// 히트한 embedding 키들의 last_used_at 갱신(LRU 보존, §3.5).
app.sqlite.derivation_cache_touch(&emb_touch_keys)?;
}
}
// 히트한 alias 키들의 last_used_at 갱신(LRU 보존, §3.5).
app.sqlite.derivation_cache_touch(&alias_touch_keys)?;
let embed_ms = u64::try_from(t_embed.elapsed().as_millis()).unwrap_or(u64::MAX);
// 검증용 hit/miss 카운트 노출(§3.4 / §6): warm 재색인이 LLM·embed 0회임을
// v0.24.0: phase-timing breakdown for this asset (markdown path).
// ocr_ms / caption_ms are 0 — markdown has no image-analysis phases.
crate::ingest_progress::emit(
progress,
crate::ingest_progress::IngestEvent::AssetTimings {
idx,
total,
parse_ms,
chunk_ms,
expansion_ms,
embed_ms,
store_ms,
ocr_ms: 0,
caption_ms: 0,
},
);
// 검증용 hit/miss 카운트 노출(§3.4 / §6): warm 재색인이 embed 0회임을
// 로그로 확인. tracing target 은 stderr 로 흐른다.
if alias_cache_hit + alias_cache_miss + emb_cache_hit + emb_cache_miss > 0 {
if 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}"
"derivation cache: embedding hit={emb_cache_hit} miss={emb_cache_miss}"
);
}
@@ -1564,12 +1491,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;
@@ -1629,9 +1559,11 @@ fn ingest_one_image_asset(
workspace_root: &workspace_root,
config: &extract_config,
};
let t_parse = std::time::Instant::now();
let mut canonical = app
.extract_for(&asset.media_type, &ctx, &bytes)
.context("kb-app::extract_for (image)")?;
let parse_ms = u64::try_from(t_parse.elapsed().as_millis()).unwrap_or(u64::MAX);
// 2 + 3. Apply OCR / caption when their adapters exist. Both are
// Lenient — failure is captured into Provenance Warning,
@@ -1646,44 +1578,74 @@ fn ingest_one_image_asset(
let lang_hint = lang_hint_from_doc(&canonical);
let now = time::OffsetDateTime::now_utc();
let mut warning_notes: Vec<String> = Vec::new();
// v0.26.1: vision phases (OCR / caption) are the usual bottleneck on an
// image-heavy vault and emitted no progress before — so the bar looked
// frozen. Surface each as an `AssetPhase` and measure its wall-clock for
// the slowest-asset summary.
let mut ocr_ms = 0_u64;
let mut caption_ms = 0_u64;
match canonical.blocks.first_mut() {
Some(Block::ImageRef(block)) => {
if let Some(engine) = ocr_engine
&& let Err(e) = apply_ocr(
if let Some(engine) = ocr_engine {
crate::ingest_progress::emit(
progress,
crate::ingest_progress::IngestEvent::AssetPhase {
idx,
total,
phase: "ocr".to_string(),
model: Some(engine.model().to_string()),
},
);
let t_ocr = std::time::Instant::now();
let res = apply_ocr(
engine,
&bytes,
block,
lang_hint.as_ref(),
&mut canonical.provenance.events,
)
{
record_image_analysis_failure(
asset,
&mut canonical.provenance.events,
&mut warning_notes,
"OcrFailed",
e,
now,
);
ocr_ms = u64::try_from(t_ocr.elapsed().as_millis()).unwrap_or(u64::MAX);
if let Err(e) = res {
record_image_analysis_failure(
asset,
&mut canonical.provenance.events,
&mut warning_notes,
"OcrFailed",
e,
now,
);
}
}
if let Some(llm) = caption_llm
&& let Err(e) = apply_caption(
if let Some(llm) = caption_llm {
crate::ingest_progress::emit(
progress,
crate::ingest_progress::IngestEvent::AssetPhase {
idx,
total,
phase: "caption".to_string(),
model: Some(llm.model_ref().id),
},
);
let t_caption = std::time::Instant::now();
let res = apply_caption(
llm,
&bytes,
block,
lang_hint.as_ref(),
&app.config,
&mut canonical.provenance.events,
)
{
record_image_analysis_failure(
asset,
&mut canonical.provenance.events,
&mut warning_notes,
"CaptionFailed",
e,
now,
);
caption_ms = u64::try_from(t_caption.elapsed().as_millis()).unwrap_or(u64::MAX);
if let Err(e) = res {
record_image_analysis_failure(
asset,
&mut canonical.provenance.events,
&mut warning_notes,
"CaptionFailed",
e,
now,
);
}
}
}
// P6-1 contract: image documents always have exactly one
@@ -1718,9 +1680,21 @@ fn ingest_one_image_asset(
// `Block::ImageRef` arm already produces a single chunk per
// image (P1-5). The chunk text now follows the (β) plain-concat
// contract per the kebab-chunk render_block_text update.
let t_chunk = std::time::Instant::now();
let chunks = MdHeadingV1Chunker
.chunk(&canonical, chunk_policy)
.context("kb-chunk::MdHeadingV1Chunker::chunk (image)")?;
let chunk_ms = u64::try_from(t_chunk.elapsed().as_millis()).unwrap_or(u64::MAX);
// v0.24.0: surface chunk count for the image path too.
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
@@ -1729,6 +1703,7 @@ fn ingest_one_image_asset(
if let Some(emb) = embedder {
canonical.last_embedding_version = Some(emb.model_version());
}
let t_store = std::time::Instant::now();
purge_vector_orphans_for_workspace_path(app, asset, vector_store)?;
app.sqlite
.put_asset_with_bytes(asset, &bytes)
@@ -1742,7 +1717,18 @@ fn ingest_one_image_asset(
app.sqlite
.put_chunks(&canonical.doc_id, &chunks)
.context("DocumentStore::put_chunks (image)")?;
let store_ms = u64::try_from(t_store.elapsed().as_millis()).unwrap_or(u64::MAX);
crate::ingest_progress::emit(
progress,
crate::ingest_progress::IngestEvent::AssetPhase {
idx,
total,
phase: "embed".to_string(),
model: embedder.map(|e| e.model_id().0),
},
);
let t_embed = std::time::Instant::now();
if let (Some(emb), Some(vec_store)) = (embedder, vector_store)
&& !chunks.is_empty()
{
@@ -1783,6 +1769,25 @@ fn ingest_one_image_asset(
.upsert(&records)
.context("VectorStore::upsert (image)")?;
}
let embed_ms = u64::try_from(t_embed.elapsed().as_millis()).unwrap_or(u64::MAX);
// v0.26.1: per-phase timing for the image path — ocr_ms / caption_ms
// carry the vision-model cost so the slowest-asset summary attributes
// an image-heavy run's bottleneck correctly.
crate::ingest_progress::emit(
progress,
crate::ingest_progress::IngestEvent::AssetTimings {
idx,
total,
parse_ms,
chunk_ms,
expansion_ms: 0,
embed_ms,
store_ms,
ocr_ms,
caption_ms,
},
);
let kind = if existing_doc_ids.contains(&canonical.doc_id.0) {
kebab_core::IngestItemKind::Updated
@@ -1840,49 +1845,6 @@ 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`.
@@ -1910,15 +1872,8 @@ 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(&to_delete)
.delete_by_chunk_ids(&stale)
.context("VectorStore::delete_by_chunk_ids (parser-bump orphans)")?;
}
}
@@ -1962,15 +1917,8 @@ 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(&to_delete)
.delete_by_chunk_ids(&stale)
.context("VectorStore::delete_by_chunk_ids (orphan vector cleanup)")?;
tracing::debug!(
target: "kebab-app",
@@ -2070,14 +2018,7 @@ fn sweep_deleted_files(
if let Some(vec) = vector_store {
if !chunk_ids.is_empty() {
use kebab_core::VectorStore as _;
// 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) {
if let Err(e) = vec.delete_by_chunk_ids(&chunk_ids) {
tracing::warn!(
target: "kebab-app",
path = %stored_path.0,
@@ -2127,6 +2068,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>>,
@@ -2188,9 +2131,11 @@ fn ingest_one_pdf_asset(
workspace_root: &workspace_root,
config: &extract_config,
};
let t_parse = std::time::Instant::now();
let mut canonical = app
.extract_for(&asset.media_type, &ctx, &bytes)
.context("kb-app::extract_for (pdf)")?;
let parse_ms = u64::try_from(t_parse.elapsed().as_millis()).unwrap_or(u64::MAX);
// v0.20 sub-item 1: post-extract OCR enrichment (PR #187 registry
// dispatch invariant 보존 — extract_for 가 normal entry).
@@ -2326,9 +2271,21 @@ fn ingest_one_pdf_asset(
// validates every block carries `SourceSpan::Page`; failure here
// means the parser drifted from its contract.
let chunker = PdfPageV1Chunker;
let t_chunk = std::time::Instant::now();
let chunks = chunker
.chunk(&canonical, chunk_policy)
.context("kb-chunk::PdfPageV1Chunker::chunk")?;
let chunk_ms = u64::try_from(t_chunk.elapsed().as_millis()).unwrap_or(u64::MAX);
// 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.
@@ -2337,6 +2294,7 @@ fn ingest_one_pdf_asset(
canonical.last_embedding_version = Some(emb.model_version());
}
let t_store = std::time::Instant::now();
purge_vector_orphans_for_workspace_path(app, asset, vector_store)?;
app.sqlite
.put_asset_with_bytes(asset, &bytes)
@@ -2350,7 +2308,18 @@ fn ingest_one_pdf_asset(
app.sqlite
.put_chunks(&canonical.doc_id, &chunks)
.context("DocumentStore::put_chunks (pdf)")?;
let store_ms = u64::try_from(t_store.elapsed().as_millis()).unwrap_or(u64::MAX);
crate::ingest_progress::emit(
progress,
crate::ingest_progress::IngestEvent::AssetPhase {
idx,
total,
phase: "embed".to_string(),
model: embedder.map(|e| e.model_id().0),
},
);
let t_embed = std::time::Instant::now();
if let (Some(emb), Some(vec_store)) = (embedder, vector_store)
&& !chunks.is_empty()
{
@@ -2389,6 +2358,25 @@ fn ingest_one_pdf_asset(
.upsert(&records)
.context("VectorStore::upsert (pdf)")?;
}
let embed_ms = u64::try_from(t_embed.elapsed().as_millis()).unwrap_or(u64::MAX);
// v0.26.1: per-phase timing for the PDF path. `ocr_ms` reuses the
// page-OCR total already computed above so a scanned-PDF run's OCR cost
// shows up in the slowest-asset summary; caption is markdown/image-only.
crate::ingest_progress::emit(
progress,
crate::ingest_progress::IngestEvent::AssetTimings {
idx,
total,
parse_ms,
chunk_ms,
expansion_ms: 0,
embed_ms,
store_ms,
ocr_ms: pdf_ocr_ms_total.unwrap_or(0),
caption_ms: 0,
},
);
let kind = if existing_doc_ids.contains(&canonical.doc_id.0) {
kebab_core::IngestItemKind::Updated
@@ -3441,48 +3429,3 @@ fn check_kebabignore_match(
.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

@@ -29,7 +29,7 @@ fn migrate_writes_backup_and_atomic_with_dry_run_noop() {
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]"));
assert!(new.contains("[ingest.code]"));
// 멱등: 재실행 changed=false.
let report = kebab_app::config_migrate_with_config_path(Some(&cfg), false).unwrap();
@@ -47,8 +47,11 @@ fn migrate_missing_file_errors() {
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]"));
assert!(
text.contains("code ingest skip 정책"),
"section comment missing:\n{text}"
);
assert!(text.contains("[ingest.code]"));
}
#[test]

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

@@ -111,7 +111,8 @@ fn first_ingest_bumps_corpus_revision() {
store_before.run_migrations().unwrap();
// 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.)
// (V012 derivation_cache + V013 drop-chunk-aliases are structural/additive
// — neither bumps corpus_revision.)
let baseline = store_before.corpus_revision();
assert_eq!(baseline, 3, "fresh store post-V011 baseline = 3");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -196,6 +196,5 @@ fn build_chunk_from_span(
token_estimate,
chunker_version: chunker_version.clone(),
policy_hash: base_policy_hash.to_string(),
aliases: None,
}
}

View File

@@ -1,6 +1,5 @@
[
{
"aliases": null,
"block_ids": [
"8149e12ca002489acb4a0f74c97a061a"
],
@@ -23,7 +22,6 @@
"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"
],
@@ -46,7 +44,6 @@
"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"
],
@@ -69,7 +66,6 @@
"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,6 +1,5 @@
[
{
"aliases": null,
"block_ids": [
"53292605459065d170cd36c118e20546"
],
@@ -23,7 +22,6 @@
"tokenized_korean_text": "# include < string > # include < vector > namespace kebab {"
},
{
"aliases": null,
"block_ids": [
"f349acad94c9fa4cf9ad1c0a93e83610"
],
@@ -46,7 +44,6 @@
"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"
],
@@ -69,7 +66,6 @@
"tokenized_korean_text": "template < typename T > T identity ( T value ) { return value ; }"
},
{
"aliases": null,
"block_ids": [
"1754cb6b971f6a4cb292f144a4f0570b"
],
@@ -92,7 +88,6 @@
"tokenized_korean_text": "void global _ helper ( ) { / / free function in kebab namespace }"
},
{
"aliases": null,
"block_ids": [
"14b5f3393d6d25f822f5b70763d24acd"
],

View File

@@ -1,6 +1,5 @@
[
{
"aliases": null,
"block_ids": [
"c182bf37e32c7fc1b868bd617f8eaf66"
],
@@ -23,7 +22,6 @@
"tokenized_korean_text": "import ( \" fmt \" \" os \" \" strings \" )"
},
{
"aliases": null,
"block_ids": [
"c9992cdcfdf3c2a7700a4abc4782a8a4"
],
@@ -46,7 +44,6 @@
"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"
],
@@ -69,7 +66,6 @@
"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"
],
@@ -92,7 +88,6 @@
"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"
],
@@ -115,7 +110,6 @@
"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"
],
@@ -138,7 +132,6 @@
"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"
],
@@ -161,7 +154,6 @@
"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"
],
@@ -184,7 +176,6 @@
"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"
],
@@ -207,7 +198,6 @@
"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"
],
@@ -230,7 +220,6 @@
"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

@@ -632,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));

View File

@@ -19,16 +19,23 @@
//! `Sender` end is dropped (i.e. when `ingest_with_config_progress`
//! returns).
use std::collections::HashMap;
use std::io::{IsTerminal, Write};
use std::sync::mpsc::Receiver;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressState, ProgressStyle};
use kebab_app::IngestEvent;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use crate::wire;
/// v0.26.1: number of slowest assets surfaced in the end-of-run summary.
/// Constant for now (spec defers the config knob).
const SLOWEST_TOP_N: usize = 5;
/// Rendering mode for `ProgressDisplay`. The mode is fixed at
/// construction — each `kebab ingest` invocation is a single mode
/// (chosen from `--json` plus `IsTerminal` detection).
@@ -65,11 +72,33 @@ impl ProgressMode {
pub struct ProgressDisplay {
mode: ProgressMode,
bar: Option<ProgressBar>,
/// v0.26.1 heartbeat: start `Instant` of the asset currently in
/// flight, shared with the bar's steady-tick custom template key so
/// the `(Ns)` elapsed counter advances *between* events (the drain
/// loop blocks on `recv()`, so without the ticker the counter would
/// freeze). `None` while scanning / between assets / after completion.
asset_start: Arc<Mutex<Option<Instant>>>,
/// v0.26.1: workspace path of the asset currently in flight — set on
/// `AssetStarted`, reused by `AssetPhase` to render `{path} · {phase}…`.
current_path: Option<String>,
/// v0.26.1 slowest summary: idx → path, captured from `AssetStarted`
/// so `AssetTimings` (which only carries `idx`) can name the asset.
asset_paths: HashMap<u32, String>,
/// v0.26.1 slowest summary: (path, total_ms) per asset that reported
/// `AssetTimings`. Sorted + truncated to top-N on `Completed`.
timings: Vec<(String, u64)>,
}
impl ProgressDisplay {
pub fn new(mode: ProgressMode) -> Self {
Self { mode, bar: None }
Self {
mode,
bar: None,
asset_start: Arc::new(Mutex::new(None)),
current_path: None,
asset_paths: HashMap::new(),
timings: Vec::new(),
}
}
/// Block until `rx` returns `Err` (sender dropped). Renders one
@@ -120,15 +149,43 @@ impl ProgressDisplay {
}
IngestEvent::ScanCompleted { total } => {
if let Some(bar) = self.bar.as_mut() {
bar.disable_steady_tick();
bar.set_length(u64::from(*total));
bar.set_position(0);
// v0.26.1: a custom `{asset_elapsed}` key reads the shared
// per-asset start `Instant` and appends ` (Ns)`. Combined
// with the steady tick below, the elapsed counter advances
// even while the drain loop is blocked on `recv()` waiting
// for the next (possibly very slow) phase event.
let asset_start = Arc::clone(&self.asset_start);
bar.set_style(
ProgressStyle::with_template("ingest [{bar:30}] {pos}/{len} {wide_msg}")
.unwrap()
.progress_chars("=> "),
ProgressStyle::with_template(
"ingest [{bar:30}] {pos}/{len} {wide_msg}{asset_elapsed}",
)
.unwrap()
.with_key(
"asset_elapsed",
move |_: &ProgressState, w: &mut dyn std::fmt::Write| {
if let Ok(guard) = asset_start.lock()
&& let Some(started) = *guard
{
let secs = started.elapsed().as_secs();
// Only show once the asset has been running
// a moment — avoids `(0s)` flicker on fast
// assets.
if secs >= 1 {
let _ = write!(w, " ({secs}s)");
}
}
},
)
.progress_chars("=> "),
);
bar.set_message("");
if tty && !quiet {
bar.enable_steady_tick(std::time::Duration::from_secs(1));
} else {
bar.disable_steady_tick();
}
}
if !tty && !quiet {
let mut err = std::io::stderr().lock();
@@ -141,11 +198,22 @@ impl ProgressDisplay {
path,
media,
} => {
// v0.26.1: remember the path so AssetPhase can render it and
// the slowest summary (keyed by idx in AssetTimings) can name
// the asset.
self.current_path = Some(path.clone());
self.asset_paths.insert(*idx, path.clone());
// v0.26.1: (re)start the per-asset heartbeat clock.
if let Ok(mut guard) = self.asset_start.lock() {
*guard = Some(Instant::now());
}
if let Some(bar) = self.bar.as_ref() {
// One draw per file: position only. set_message() would
// trigger a second independent draw and pollute TTY scrollback.
// Filename is visible in the non-TTY plain-line path below.
bar.set_position(u64::from(idx.saturating_sub(1)));
// v0.26.1: show the current filename on the bar (TTY).
// Previously position-only — the interactive user couldn't
// tell which file was in flight. The steady tick redraws
// in place, so this no longer pollutes scrollback.
bar.set_message(abbreviate_path(path));
}
if !tty && !quiet {
let mut err = std::io::stderr().lock();
@@ -154,13 +222,95 @@ impl ProgressDisplay {
}
IngestEvent::AssetFinished { .. } => {
// Position is advanced in AssetStarted; bar.finish_and_clear()
// in Completed handles the final state. No per-asset bar update
// here avoids the duplicate-frame artifact in TTY scrollback.
// in Completed handles the final state. v0.26.1: stop the
// heartbeat clock so the bar doesn't show a stale `(Ns)` in the
// gap before the next AssetStarted.
if let Ok(mut guard) = self.asset_start.lock() {
*guard = None;
}
self.current_path = None;
}
// v0.26.1: an asset entered a slow internal phase (ocr / caption /
// embed). Surface which phase + model is running so a multi-second
// vision-model call no longer looks frozen.
IngestEvent::AssetPhase {
idx,
total,
phase,
model,
} => {
let label = match model {
Some(m) => format!("{phase}({m})"),
None => phase.clone(),
};
if let Some(bar) = self.bar.as_ref() {
let path = self.current_path.as_deref().unwrap_or("");
bar.set_message(format!("{} · {label}", abbreviate_path(path)));
}
if !tty && !quiet {
let mut err = std::io::stderr().lock();
let _ = writeln!(err, "ingest: {idx}/{total} · {label}…");
}
}
// v0.24.0: asset-internal phase visibility. AssetChunked uses 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::AssetTimings {
idx,
parse_ms,
chunk_ms,
embed_ms,
store_ms,
ocr_ms,
caption_ms,
..
} => {
// v0.26.1: accumulate (path, total_ms) for the slowest summary.
// total = every measured phase (expansion_ms is always 0).
let total_ms = parse_ms + chunk_ms + embed_ms + store_ms + ocr_ms + caption_ms;
if let Some(path) = self.asset_paths.get(idx) {
self.timings.push((path.clone(), total_ms));
}
if let Some(bar) = self.bar.as_ref() {
bar.set_message("");
}
if !quiet {
let mut err = std::io::stderr().lock();
// v0.26.1: only print ocr / caption when they actually ran
// (markdown leaves them 0) so the text path stays uncluttered.
let mut parts = vec![
format!("parse {}", fmt_ms(*parse_ms)),
format!("chunk {}", fmt_ms(*chunk_ms)),
];
if *ocr_ms > 0 {
parts.push(format!("ocr {}", fmt_ms(*ocr_ms)));
}
if *caption_ms > 0 {
parts.push(format!("caption {}", fmt_ms(*caption_ms)));
}
parts.push(format!("embed {}", fmt_ms(*embed_ms)));
parts.push(format!("store {}", fmt_ms(*store_ms)));
let _ = writeln!(err, " ⏱ {}", parts.join(" · "));
}
}
IngestEvent::Completed { counts } => {
if let Some(bar) = self.bar.take() {
bar.finish_and_clear();
}
if let Ok(mut guard) = self.asset_start.lock() {
*guard = None;
}
// Always emit summary in both TTY and non-TTY (unless quiet).
// Bug fix: previously TTY had no summary line after bar.finish_and_clear().
if !quiet {
@@ -170,6 +320,10 @@ impl ProgressDisplay {
"ingest: complete (scanned={} new={} updated={} skipped={} errors={})",
counts.scanned, counts.new, counts.updated, counts.skipped, counts.errors,
);
// v0.26.1: slowest-asset summary. Useful in both TTY and
// non-TTY (it pinpoints the bottleneck file), so it prints
// unless --quiet. --json mode never reaches here (emit_json).
let _ = write_slowest_summary(&mut err, &self.timings, SLOWEST_TOP_N);
}
}
IngestEvent::Aborted { counts } => {
@@ -239,6 +393,59 @@ 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 embed 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")
}
}
/// v0.26.1: shorten an over-long workspace path for the progress-bar
/// message so the live `(Ns)` heartbeat suffix stays visible on a narrow
/// terminal. Keeps the tail (filename + a couple of parents) — that's the
/// distinguishing part — and prefixes `…` when truncated. Paths up to the
/// budget pass through verbatim.
fn abbreviate_path(path: &str) -> String {
const MAX: usize = 48;
let char_count = path.chars().count();
if char_count <= MAX {
return path.to_string();
}
// Keep the last MAX-1 chars (1 reserved for the leading ellipsis).
let tail: String = path
.chars()
.skip(char_count - (MAX - 1))
.collect::<String>();
format!("{tail}")
}
/// v0.26.1: render the end-of-run "slowest assets" summary. Sorts
/// `(path, total_ms)` descending by time, takes the top `n`, and writes a
/// compact table to `w`. No-op (writes nothing) when `timings` is empty so
/// a run with no per-asset timing (e.g. all-skipped) prints no stray header.
fn write_slowest_summary(
w: &mut impl Write,
timings: &[(String, u64)],
n: usize,
) -> std::io::Result<()> {
if timings.is_empty() {
return Ok(());
}
let mut sorted: Vec<&(String, u64)> = timings.iter().collect();
// desc by ms; ties broken by path for deterministic output.
sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let top = &sorted[..sorted.len().min(n)];
writeln!(w, "⏱ 최장 소요 top-{}:", top.len())?;
for (rank, (path, ms)) in top.iter().enumerate() {
writeln!(w, " {}. {} — {}", rank + 1, path, fmt_ms(*ms))?;
}
Ok(())
}
/// 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 +492,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();
@@ -292,4 +508,61 @@ mod tests {
// well-formed RFC 3339 string.
OffsetDateTime::parse(&s, &Rfc3339).expect("RFC 3339 round-trip");
}
#[test]
fn abbreviate_path_passes_short_paths_through() {
assert_eq!(abbreviate_path("notes/foo.md"), "notes/foo.md");
}
#[test]
fn abbreviate_path_keeps_tail_with_ellipsis() {
let long = "a/very/deeply/nested/directory/structure/that/exceeds/the/budget/file.md";
let out = abbreviate_path(long);
assert!(out.starts_with('…'), "should be prefixed with ellipsis: {out}");
assert!(out.ends_with("file.md"), "should keep the filename tail: {out}");
// 48-char budget: 1 ellipsis + 47 tail chars.
assert_eq!(out.chars().count(), 48);
}
#[test]
fn write_slowest_summary_empty_writes_nothing() {
let mut buf = Vec::new();
write_slowest_summary(&mut buf, &[], 5).unwrap();
assert!(buf.is_empty());
}
#[test]
fn write_slowest_summary_sorts_desc_and_truncates() {
let timings = vec![
("a.md".to_string(), 100),
("b.png".to_string(), 5_000),
("c.pdf".to_string(), 2_000),
("d.md".to_string(), 50),
];
let mut buf = Vec::new();
write_slowest_summary(&mut buf, &timings, 2).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("top-2:"), "{out}");
// b (5s) ranks first, c (2s) second; a/d excluded.
let b_pos = out.find("b.png").expect("b.png present");
let c_pos = out.find("c.pdf").expect("c.pdf present");
assert!(b_pos < c_pos, "b before c: {out}");
assert!(!out.contains("a.md"), "a.md excluded by top-2: {out}");
assert!(out.contains("5.0s"), "b renders as 5.0s: {out}");
}
#[test]
fn write_slowest_summary_tie_breaks_by_path() {
let timings = vec![
("z.md".to_string(), 1_000),
("a.md".to_string(), 1_000),
];
let mut buf = Vec::new();
write_slowest_summary(&mut buf, &timings, 5).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(
out.find("a.md").unwrap() < out.find("z.md").unwrap(),
"equal ms ties break alphabetically: {out}"
);
}
}

View File

@@ -155,11 +155,29 @@ impl NliCfg {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct EmbeddingModelCfg {
/// `fastembed` (default, onnxruntime), `candle` (pure-Rust, NUMA-safe),
/// or `ollama` (remote HTTP embedding endpoint). `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,
/// HTTP endpoint for the `ollama` embedding provider (e.g.
/// `"http://127.0.0.1:11434"`). `None` (or a missing key in TOML) means
/// "fall back to `models.llm.endpoint`" — same convention as the OCR /
/// vision endpoints. Ignored by the `fastembed` / `candle` providers.
/// Defaulted on load so pre-0.26 config files still parse.
#[serde(default)]
pub endpoint: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -596,8 +614,6 @@ 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
@@ -638,34 +654,6 @@ 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 {
@@ -707,6 +695,8 @@ impl Config {
version: "v1".to_string(),
dimensions: 1024,
batch_size: 64,
num_threads: 0,
endpoint: None,
},
llm: LlmCfg {
provider: "ollama".to_string(),
@@ -964,6 +954,17 @@ 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;
}
}
"KEBAB_MODELS_EMBEDDING_ENDPOINT" => {
// Empty value → None (= fall back to models.llm.endpoint),
// mirroring the OCR endpoint override semantics.
self.models.embedding.endpoint =
if v.is_empty() { None } else { Some(v.clone()) };
}
// models.llm
"KEBAB_MODELS_LLM_PROVIDER" => self.models.llm.provider = v.clone(),
@@ -1150,25 +1151,6 @@ 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.
_ => {}
@@ -1897,41 +1879,6 @@ max_context_tokens = 8000
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

@@ -15,7 +15,7 @@ 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`.
/// dotted path, 예: `ingest.code`, `workspace.include`.
pub path: String,
/// 사람·wire 용 한 줄 설명.
pub detail: String,
@@ -83,7 +83,6 @@ fn section_comment(path: &str) -> Option<&'static str> {
"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).",
@@ -259,7 +258,7 @@ mod tests {
// `[pdf]` 등은 안 나오고 `[pdf.ocr]` 같은 하위 테이블만 직렬화된다.
for section in [
"[workspace]",
"[ingest.expansion]",
"[ingest.code]",
"[pdf.ocr]",
"[logging]",
"[ui]",
@@ -273,8 +272,8 @@ mod tests {
#[test]
fn reconcile_adds_missing_section_preserving_user_values_and_comments() {
// ingest 는 code 만 있고 expansion 누락(v0.21.0 동기 시나리오),
// logging 통째 누락, score 는 사용자가 바꿈, 주석 보유.
// ingest 통째 누락(→ ingest.code 추가), logging 통째 누락,
// default_k 는 사용자가 바꿈, 주석 보유.
let user_text = "\
schema_version = 1
@@ -283,9 +282,6 @@ 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();
@@ -294,25 +290,22 @@ skip_generated_header = true
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}");
// 누락된 [ingest.code] 가 주석과 함께 추가.
assert!(out.contains("[ingest.code]"), "ingest.code 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!(out.contains("code ingest skip 정책"));
// 통째 누락 부모는 부모 경로로 한 번 기록.
assert!(
changes
.iter()
.any(|c| c.kind == ChangeKind::AddedSection && c.path == "ingest.expansion"),
"changes: {changes:?}"
.any(|c| c.kind == ChangeKind::AddedSection && c.path == "ingest")
);
// 통째 누락 부모는 부모 경로로 한 번 기록.
assert!(
changes
.iter()
@@ -381,7 +374,7 @@ include = [\"*.md\"]
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!(outcome.new_text.contains("[ingest.code]"));
assert_eq!(read_schema_version(&outcome.new_text), CURRENT_SCHEMA_VERSION);
let again = migrate_document(&outcome.new_text);

View File

@@ -28,13 +28,6 @@ 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)]
@@ -42,8 +35,8 @@ mod tests {
use super::*;
#[test]
fn aliases_defaults_to_none_on_deserialize() {
// aliases 필드가 없는 과거 JSON 도 파싱되어야 한다 (#[serde(default)]).
fn tokenized_korean_text_defaults_to_none_on_deserialize() {
// tokenized_korean_text 필드가 없는 과거 JSON 도 파싱되어야 한다 (#[serde(default)]).
let json = r#"{
"chunk_id": "c1",
"doc_id": "d1",
@@ -56,7 +49,6 @@ mod tests {
"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,50 @@
[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" }
# arctic↔Ollama parity test drives the real Ollama adapter for the reference
# vectors (tests/arctic_ollama_parity.rs, `#[ignore]` — live Ollama).
kebab-embed-ollama = { path = "../kebab-embed-ollama" }
kebab-config = { path = "../kebab-config" }
kebab-core = { path = "../kebab-core" }
rayon = "1"
tempfile = { workspace = true }
[lints]
workspace = true

View File

@@ -0,0 +1,619 @@
//! `kebab-embed-candle` — [`CandleEmbedder`], a pure-Rust (candle)
//! implementation of [`Embedder`](kebab_core::Embedder).
//!
//! Runs an XLM-RoBERTa-large embedding model through `candle`
//! (`candle-transformers`' XLM-RoBERTa) instead of onnxruntime. Two models
//! are wired through a small **registry** ([`MODEL_REGISTRY`]):
//!
//! * `multilingual-e5-large` — the same weights the default
//! [`FastembedEmbedder`](kebab_embed_local) uses (mean pooling,
//! `query: `/`passage: ` prefixes). candle is the NUMA-safe drop-in:
//! 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.
//! * `snowflake-arctic-embed-l-v2.0` — Snowflake's arctic-embed v2.0
//! (CLS pooling, `query: ` on queries / no prefix on documents). Same
//! XLM-RoBERTa-large architecture, dim 1024, so it rides the exact same
//! tokenize → forward → L2 pipeline; only the pooling step and prefixes
//! differ (both keyed off the per-model [`EmbedModelSpec`]).
//!
//! Output parity with the onnxruntime path (for e5) was proven by the
//! Phase 0 spike (cosine 1.000000); the arctic path's pooling/prefix
//! correctness is pinned by an `#[ignore]`d cosine>0.99 cross-check against
//! Ollama's `snowflake-arctic-embed2` (see `tests/arctic_ollama_parity.rs`).
//! The shared pipeline:
//!
//! 1. instruction prefix per [`EmbedModelSpec`] (query/doc);
//! 2. tokenize (max_len 512, batch-longest padding, special tokens);
//! 3. XLM-RoBERTa forward on the selected [`Device`];
//! 4. pooling — mean (attention-mask-weighted) or CLS (first token);
//! 5. L2 normalization.
//!
//! Model files (`config.json`, `tokenizer.json`, `model.safetensors`) are
//! fetched via `hf-hub` into `{config.storage.model_dir}/candle/` (hf-hub's
//! cache layout namespaces by repo, so e5 and arctic never collide).
//!
//! 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` and
//! `docs/superpowers/specs/2026-06-03-arctic-embedder-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";
/// Token truncation length (both e5 and arctic-embed-l-v2.0 train 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";
/// Pooling strategy over the model's last hidden state. Keyed per-model by
/// [`EmbedModelSpec::pooling`] — e5 is mean, arctic is CLS.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Pooling {
/// Attention-mask-weighted mean over all tokens (e5 / sentence-transformers
/// `pooling_mode_mean_tokens`).
Mean,
/// First token (`<s>`/`[CLS]`) hidden state (arctic-embed v2.0 —
/// `1_Pooling/config.json` has `pooling_mode_cls_token: true`).
Cls,
}
/// One supported embedding model: the HF repo candle downloads, the pooling
/// strategy, and the e5-style instruction prefixes. [`MODEL_REGISTRY`] maps a
/// `config.models.embedding.model` value to one of these.
#[derive(Clone, Copy, Debug)]
pub struct EmbedModelSpec {
/// The short `config.models.embedding.model` value that selects this spec.
pub name: &'static str,
/// HuggingFace repo id candle fetches `config.json` / `tokenizer.json` /
/// `model.safetensors` from.
pub hf_repo: &'static str,
/// Pooling over the last hidden state.
pub pooling: Pooling,
/// Prefix prepended to **query** inputs before tokenization.
pub query_prefix: &'static str,
/// Prefix prepended to **document** inputs before tokenization (arctic
/// uses `""` — documents are embedded raw).
pub doc_prefix: &'static str,
/// Expected embedding dimension (model hidden size).
pub dim: usize,
/// Suffix folded into `model_version` so switching **to** this model
/// triggers the `embedding_version` cascade even if the operator forgets
/// to bump `config.version`. `None` keeps the bare `config.version` — used
/// by e5 so candle-e5 and fastembed-e5 report the *same* version and stay
/// interchangeable (the NUMA drop-in invariant — Phase 0 cosine 1.0).
pub version_tag: Option<&'static str>,
}
/// The models the candle adapter can load. Adding a model = one entry here
/// (plus, for a non-XLM-R architecture, a new forward path — both current
/// entries are XLM-RoBERTa-large so they share everything but pooling/prefix).
static MODEL_REGISTRY: &[EmbedModelSpec] = &[
EmbedModelSpec {
name: "multilingual-e5-large",
hf_repo: "intfloat/multilingual-e5-large",
pooling: Pooling::Mean,
query_prefix: "query: ",
doc_prefix: "passage: ",
dim: 1024,
version_tag: None,
},
EmbedModelSpec {
name: "snowflake-arctic-embed-l-v2.0",
hf_repo: "Snowflake/snowflake-arctic-embed-l-v2.0",
pooling: Pooling::Cls,
query_prefix: "query: ",
doc_prefix: "",
dim: 1024,
version_tag: Some("arctic-cls"),
},
];
/// Look up a model spec by `config.models.embedding.model`. Accepts either the
/// short `name` or the full `hf_repo` id (mirrors the old e5 guard, which
/// accepted both `multilingual-e5-large` and `intfloat/multilingual-e5-large`).
pub(crate) fn lookup_spec(model: &str) -> Option<&'static EmbedModelSpec> {
MODEL_REGISTRY
.iter()
.find(|s| s.name == model || s.hf_repo == model)
}
/// Comma-separated list of supported model names, for the
/// unsupported-model error message.
fn supported_models() -> String {
MODEL_REGISTRY
.iter()
.map(|s| s.name)
.collect::<Vec<_>>()
.join("`, `")
}
/// 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,
/// The resolved model spec (pooling + prefixes) — drives `embed` and
/// `embed_batch`.
spec: &'static EmbedModelSpec,
model_id: EmbeddingModelId,
version: EmbeddingVersion,
dimensions: usize,
batch_size: usize,
}
impl CandleEmbedder {
/// Build an embedder from `Config`. Resolves the model spec from
/// `config.models.embedding.model`, 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 registry lookup. If the operator configured a model the
// candle adapter doesn't know, fail fast (BEFORE the ~2GB
// download) — never silently download one model and then label its
// vectors with another name via `model_id()`, which would mislabel
// `embedding_version` and corrupt a mixed index.
let want = config.models.embedding.model.as_str();
let spec = lookup_spec(want).ok_or_else(|| {
anyhow::anyhow!(
"candle provider supports the models `{}`, but \
config.models.embedding.model = '{want}'. Use provider=fastembed \
for other models, or pick a supported one.",
supported_models()
)
})?;
// 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 = spec.hf_repo,
pooling = ?spec.pooling,
"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(spec.hf_repo.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}"))?;
// model_version: fold the model tag in for non-e5 models so a switch
// triggers the embedding_version cascade; e5 keeps the bare
// config.version to stay interchangeable with fastembed-e5.
let version = match spec.version_tag {
Some(tag) => {
EmbeddingVersion(format!("{}+{}", config.models.embedding.version, tag))
}
None => EmbeddingVersion(config.models.embedding.version.clone()),
};
tracing::info!(
target: "kebab-embed-candle",
dimensions = cfg.hidden_size,
layers = cfg.num_hidden_layers,
model = spec.name,
"candle embedding model loaded"
);
Ok(Self {
model: Mutex::new(model),
tokenizer,
device,
spec,
model_id: EmbeddingModelId(config.models.embedding.model.clone()),
version,
dimensions: cfg.hidden_size,
batch_size: config.models.embedding.batch_size.max(1),
})
}
/// Embed one batch of **already-prefixed** strings (the per-model prefix
/// is applied by the caller [`CandleEmbedder::embed`]) through the candle
/// pipeline: tokenize → forward → pool (mean|CLS) → 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)?
};
// Pooling — per the model spec.
let pooled = match self.spec.pooling {
Pooling::Mean => {
// 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 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)
summed.broadcast_div(&counts)?
}
Pooling::Cls => {
// CLS pooling: the first token's hidden state. arctic-embed
// v2.0 prepends `<s>` (the XLM-R BOS/CLS) at index 0, so
// `hidden[:, 0, :]` is the sentence embedding.
hidden.narrow(1, 0, 1)?.squeeze(1)? // (b, hidden)
}
};
// L2 normalize
let norm = pooled.sqr()?.sum_keepdim(1)?.sqrt()?;
let normalized = pooled.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());
}
// Per-model instruction prefix BEFORE tokenization (same convention as
// FastembedEmbedder for e5; arctic uses `query: `/no-prefix).
let prefixed: Vec<String> = inputs.iter().map(|i| prefix_input(self.spec, i)).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 prefixed string for one [`EmbeddingInput`] using the model spec.
/// Free function so a unit test can pin the format without loading the model.
/// For e5 this is byte-identical to `kebab-embed-local`'s `prefix_input` — the
/// two backends MUST agree there or their vectors diverge.
fn prefix_input(spec: &EmbedModelSpec, input: &EmbeddingInput<'_>) -> String {
match input.kind {
EmbeddingKind::Document => format!("{}{}", spec.doc_prefix, input.text),
EmbeddingKind::Query => format!("{}{}", spec.query_prefix, 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. Embedding
/// 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::*;
fn e5_spec() -> &'static EmbedModelSpec {
lookup_spec("multilingual-e5-large").expect("e5 in registry")
}
fn arctic_spec() -> &'static EmbedModelSpec {
lookup_spec("snowflake-arctic-embed-l-v2.0").expect("arctic in registry")
}
// ── registry ─────────────────────────────────────────────────────
#[test]
fn registry_resolves_e5_by_name_and_hf_repo() {
assert_eq!(
lookup_spec("multilingual-e5-large").map(|s| s.name),
Some("multilingual-e5-large")
);
assert_eq!(
lookup_spec("intfloat/multilingual-e5-large").map(|s| s.name),
Some("multilingual-e5-large")
);
}
#[test]
fn registry_resolves_arctic_and_its_pooling_is_cls() {
let s = arctic_spec();
assert_eq!(s.name, "snowflake-arctic-embed-l-v2.0");
assert_eq!(s.hf_repo, "Snowflake/snowflake-arctic-embed-l-v2.0");
assert_eq!(s.pooling, Pooling::Cls);
assert_eq!(s.dim, 1024);
assert_eq!(s.version_tag, Some("arctic-cls"));
}
#[test]
fn registry_e5_is_mean_pooling_no_version_tag() {
let s = e5_spec();
assert_eq!(s.pooling, Pooling::Mean);
assert_eq!(s.version_tag, None);
}
#[test]
fn registry_rejects_unknown_model() {
assert!(lookup_spec("multilingual-e5-small").is_none());
}
// ── prefix_input ─────────────────────────────────────────────────
// e5 prefixes MUST match kebab-embed-local::prefix_input or candle vs
// fastembed parity breaks; arctic uses query-only prefixing.
#[test]
fn e5_prefix_document_uses_passage() {
let input = EmbeddingInput {
text: "hello world",
kind: EmbeddingKind::Document,
};
assert_eq!(prefix_input(e5_spec(), &input), "passage: hello world");
}
#[test]
fn e5_prefix_query_uses_query() {
let input = EmbeddingInput {
text: "hello world",
kind: EmbeddingKind::Query,
};
assert_eq!(prefix_input(e5_spec(), &input), "query: hello world");
}
#[test]
fn arctic_prefix_query_uses_query_doc_is_bare() {
let doc = EmbeddingInput {
text: "후입선출 자료구조",
kind: EmbeddingKind::Document,
};
let qry = EmbeddingInput {
text: "스택 자료구조",
kind: EmbeddingKind::Query,
};
// arctic: documents are embedded raw, queries get `query: `.
assert_eq!(prefix_input(arctic_spec(), &doc), "후입선출 자료구조");
assert_eq!(prefix_input(arctic_spec(), &qry), "query: 스택 자료구조");
}
#[test]
fn prefix_handles_empty_text() {
let doc = EmbeddingInput {
text: "",
kind: EmbeddingKind::Document,
};
let qry = EmbeddingInput {
text: "",
kind: EmbeddingKind::Query,
};
assert_eq!(prefix_input(e5_spec(), &doc), "passage: ");
assert_eq!(prefix_input(e5_spec(), &qry), "query: ");
assert_eq!(prefix_input(arctic_spec(), &doc), "");
assert_eq!(prefix_input(arctic_spec(), &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 model name not in the registry must fail fast (BEFORE the ~2GB
// download), so we never download one model 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 supports the models"),
"expected model-registry error, got: {msg}"
);
}
}

View File

@@ -0,0 +1,128 @@
//! arctic-embed-l-v2.0 correctness gate (`#[ignore]` — needs the ~2GB candle
//! model + a live Ollama serving `snowflake-arctic-embed2`).
//!
//! This is the load-bearing pooling/prefix check for the arctic integration.
//! The recall measurement that justified adopting arctic (recall@10 130/132)
//! went through Ollama's `snowflake-arctic-embed2`. The candle path
//! re-implements the model (XLM-RoBERTa-large + **CLS** pooling + `query: ` on
//! queries / no prefix on documents). If candle's pooling or prefix is wrong,
//! its vectors silently diverge from the measured route and the 130 number
//! does NOT carry over. This test pins them together: per-sentence cosine
//! between the candle vector and the Ollama vector must be **> 0.99**.
//!
//! `#[ignore]` because it depends on an external Ollama daemon (CI is
//! headless/offline). The leader MUST run it once before merge.
//!
//! ## Manual run
//!
//! 1. Confirm Ollama is reachable and has the model:
//! ```sh
//! curl -s http://192.168.0.47:11434/api/tags # should list snowflake-arctic-embed2
//! ```
//! 2. Run (downloads the ~2GB candle safetensors on first run):
//! ```sh
//! CARGO_TARGET_DIR=/build/out/cargo-target \
//! KEBAB_ARCTIC_OLLAMA_ENDPOINT=http://192.168.0.47:11434 \
//! cargo test -p kebab-embed-candle --test arctic_ollama_parity -- --ignored --nocapture
//! ```
//! The endpoint defaults to `http://192.168.0.47:11434` if the env is unset.
//!
//! Record the printed `ARCTIC_PARITY_SUMMARY cosine_min=...` in
//! `/tmp/arctic-result.md` + `tasks/HOTFIXES.md`.
use kebab_config::Config;
use kebab_core::{Embedder, EmbeddingInput, EmbeddingKind};
use kebab_embed_candle::CandleEmbedder;
use kebab_embed_ollama::OllamaEmbedder;
const DOGFOOD_CONFIG: &str = "/build/dogfood/config.toml";
const DEFAULT_OLLAMA_ENDPOINT: &str = "http://192.168.0.47:11434";
/// Mixed Korean / English + the descriptive-recall shapes arctic was adopted
/// for (synonym / abbreviation / English term). Covers both prefix paths.
const SENTENCES: &[&str] = &[
"스택 자료구조",
"후입선출 방식으로 동작하는 자료구조",
"큐는 선입선출 자료구조이다",
"Rust ownership and the borrow checker",
"소유권과 빌림 검사기는 메모리 안전성을 보장한다",
"SVM 은 support vector machine 의 약자이다",
"정렬 알고리즘의 시간 복잡도",
"The capital of France is Paris.",
];
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)
}
/// Base config: prefer the canonical dogfood config (for storage/cache roots),
/// fall back to `Config::defaults()` so the test still runs on a bare clone.
fn base_config() -> Config {
Config::load(Some(std::path::Path::new(DOGFOOD_CONFIG))).unwrap_or_else(|_| Config::defaults())
}
#[test]
#[ignore = "needs ~2GB candle model + live Ollama (snowflake-arctic-embed2); run manually before merge"]
fn candle_arctic_matches_ollama_arctic() {
let endpoint = std::env::var("KEBAB_ARCTIC_OLLAMA_ENDPOINT")
.unwrap_or_else(|_| DEFAULT_OLLAMA_ENDPOINT.to_string());
// candle side: the in-process arctic model.
let mut candle_cfg = base_config();
candle_cfg.models.embedding.provider = "candle".to_string();
candle_cfg.models.embedding.model = "snowflake-arctic-embed-l-v2.0".to_string();
candle_cfg.models.embedding.dimensions = 1024;
// Ollama side: the reference route the recall numbers came from.
let mut ollama_cfg = base_config();
ollama_cfg.models.embedding.provider = "ollama".to_string();
ollama_cfg.models.embedding.model = "snowflake-arctic-embed2".to_string();
ollama_cfg.models.embedding.dimensions = 1024;
ollama_cfg.models.embedding.endpoint = Some(endpoint.clone());
let candle = CandleEmbedder::new(&candle_cfg).expect("build candle arctic embedder");
let ollama = OllamaEmbedder::new(&ollama_cfg).expect("build ollama arctic embedder");
// Exercise BOTH prefix paths so a query-side divergence can't hide.
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 ov = ollama
.embed(&inputs)
.expect("ollama embed (is snowflake-arctic-embed2 pulled @ the endpoint?)");
assert_eq!(cv.len(), ov.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;
for (i, inp) in inputs.iter().enumerate() {
assert_eq!(cv[i].len(), 1024, "candle dim");
assert_eq!(ov[i].len(), 1024, "ollama dim");
let c = cosine(&cv[i], &ov[i]);
min_cos = min_cos.min(c);
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} {preview}");
}
println!("ARCTIC_PARITY_SUMMARY cosine_min={min_cos:.6} endpoint={endpoint}");
assert!(
min_cos > 0.99,
"candle arctic vs Ollama arctic cosine_min={min_cos:.6} ≤ 0.99 — \
pooling/prefix mismatch; the recall=130 measurement will NOT reproduce"
);
}

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

@@ -0,0 +1,30 @@
[package]
name = "kebab-embed-ollama"
version = { workspace = true }
edition = { workspace = true }
rust-version = { workspace = true }
license = { workspace = true }
repository = { workspace = true }
description = "Ollama HTTP adapter implementing kebab_core::Embedder (POST /api/embed, L2-normalized, batched + fail-soft)"
[dependencies]
kebab-core = { path = "../kebab-core" }
kebab-config = { path = "../kebab-config" }
# `default-features = false` drops native-tls (system OpenSSL); we pin rustls.
# reqwest 0.12's `blocking` feature wraps a private current-thread tokio
# runtime — this crate exposes NO async surface (no `async`/`await`/`tokio::*`
# symbols), matching the kebab-llm-local invariant.
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tracing = { workspace = true }
anyhow = { workspace = true }
[dev-dependencies]
# wiremock hosts the mock /api/embed server (needs a tokio runtime); tokio is
# also pulled transitively at runtime by reqwest's `blocking` feature.
wiremock = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt"] }
[lints]
workspace = true

View File

@@ -0,0 +1,310 @@
//! `kebab-embed-ollama` — [`OllamaEmbedder`], a `reqwest::blocking` adapter
//! implementing [`Embedder`](kebab_core::Embedder) over Ollama's
//! `POST /api/embed` endpoint.
//!
//! ## Why this exists
//!
//! The candle backend ([`kebab-embed-candle`]) runs arctic-embed-l-v2.0
//! in-process (pure Rust, NUMA-safe). This crate is the **fallback** path:
//! it offloads embedding to a local/remote Ollama daemon (`snowflake-arctic-embed2`),
//! which is exactly the route the recall measurements used — so it reproduces
//! the measured numbers (recall@10 130/132) byte-for-route. Opt-in via
//! `config.models.embedding.provider = "ollama"`.
//!
//! ## Wire shape
//!
//! Request (`POST {endpoint}/api/embed`):
//!
//! ```json
//! { "model": "snowflake-arctic-embed2", "input": ["query: 스택", "후입선출 ..."] }
//! ```
//!
//! Response:
//!
//! ```json
//! { "model": "...", "embeddings": [[0.01, ...], [0.02, ...]] }
//! ```
//!
//! ## Pipeline
//!
//! 1. instruction prefix per model ([`prefixes_for`] — arctic: `query: ` on
//! queries, no prefix on documents; e5: `query: `/`passage: `);
//! 2. batch into `BATCH` (48) inputs per request;
//! 3. `POST /api/embed`, with fail-soft retry (`MAX_RETRIES`);
//! 4. **L2 normalize** each returned vector — Ollama returns raw (un-normalized)
//! embeddings, so we normalize for cosine consistency with the candle path;
//! 5. dim check against `config.models.embedding.dimensions`.
//!
//! ## Send-safety
//!
//! `reqwest::blocking::Client: Send + Sync`; the adapter holds only the client,
//! an endpoint string, and small config scalars, so it is trivially `Send + Sync`
//! as the [`Embedder`] trait requires.
use std::time::Duration;
use anyhow::{Context, Result};
use kebab_core::{Embedder, EmbeddingInput, EmbeddingKind, EmbeddingModelId, EmbeddingVersion};
use serde::{Deserialize, Serialize};
/// Inputs per `/api/embed` request. Ollama handles arbitrary batch sizes, but
/// a cap keeps a single HTTP body bounded and lets a partial failure retry a
/// smaller unit.
const BATCH: usize = 48;
/// Fail-soft retry attempts per batch before the error propagates. Cold model
/// load on the Ollama side can transiently 500/timeout; a couple of retries
/// smooth that over without masking a hard misconfiguration.
const MAX_RETRIES: u32 = 3;
/// Default per-request HTTP timeout (seconds). Cold-loading an embedding model
/// on first call can take tens of seconds; this matches the generous default
/// used by the LLM adapter.
const REQUEST_TIMEOUT_SECS: u64 = 300;
/// Resolve the (query_prefix, doc_prefix) for an Ollama embedding model tag.
///
/// Mirrors `kebab-embed-candle`'s `MODEL_REGISTRY`, but keyed on the **Ollama
/// model tag** (which differs from the HF id — e.g. `snowflake-arctic-embed2`
/// vs `Snowflake/snowflake-arctic-embed-l-v2.0`). Kept here rather than shared
/// so this crate does not depend on the candle backend.
///
/// An unrecognized model gets no prefix (`("", "")`): many embedding models
/// are not instruction-tuned, so embedding the raw text is the correct default
/// — and a misspelled known model surfaces as a recall regression, not a silent
/// wrong-prefix, because the dim check still passes either way.
fn prefixes_for(model: &str) -> (&'static str, &'static str) {
let m = model.to_ascii_lowercase();
if m.contains("arctic-embed") {
// arctic-embed v2.0: `query: ` on queries, documents embedded raw.
("query: ", "")
} else if m.contains("e5") {
// multilingual-e5: `query: ` / `passage: `.
("query: ", "passage: ")
} else {
("", "")
}
}
/// `reqwest::blocking` adapter implementing [`Embedder`] over Ollama's
/// `/api/embed`. Construction is offline; the first network call happens in
/// [`Embedder::embed`].
pub struct OllamaEmbedder {
client: reqwest::blocking::Client,
/// Validated endpoint base (e.g. `"http://127.0.0.1:11434"`).
endpoint: String,
/// Ollama model tag (e.g. `"snowflake-arctic-embed2"`).
model: String,
query_prefix: &'static str,
doc_prefix: &'static str,
model_id: EmbeddingModelId,
version: EmbeddingVersion,
dimensions: usize,
}
impl OllamaEmbedder {
/// Build from a workspace [`kebab_config::Config`]. Reads
/// `config.models.embedding.{model, dimensions}` and resolves the endpoint
/// as `models.embedding.endpoint` → fallback `models.llm.endpoint`.
///
/// Does NOT touch the network. The caller (app layer) is expected to have
/// validated `provider == "ollama"`.
pub fn new(config: &kebab_config::Config) -> Result<Self> {
let emb = &config.models.embedding;
let endpoint = emb
.endpoint
.clone()
.filter(|e| !e.is_empty())
.unwrap_or_else(|| config.models.llm.endpoint.clone());
if endpoint.is_empty() {
anyhow::bail!(
"ollama embedding provider needs an endpoint: set \
`models.embedding.endpoint` (or `models.llm.endpoint`)"
);
}
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
.build()
.context("kb-embed-ollama: build reqwest client")?;
let (query_prefix, doc_prefix) = prefixes_for(&emb.model);
Ok(Self {
client,
endpoint,
model: emb.model.clone(),
query_prefix,
doc_prefix,
model_id: EmbeddingModelId(emb.model.clone()),
// model_version = `ollama:{model}` so a provider/model switch
// triggers the embedding_version cascade and never collides with
// the candle path's version string for the same model.
version: EmbeddingVersion(format!("ollama:{}", emb.model)),
dimensions: emb.dimensions,
})
}
/// Embed one already-prefixed batch via `/api/embed`, with fail-soft retry.
fn embed_batch(&self, prefixed: &[String]) -> Result<Vec<Vec<f32>>> {
let url = format!("{}/api/embed", self.endpoint.trim_end_matches('/'));
let body = EmbedRequest {
model: &self.model,
input: prefixed,
};
let mut last_err: Option<anyhow::Error> = None;
for attempt in 1..=MAX_RETRIES {
match self.try_once(&url, &body) {
Ok(resp) => return self.finalize(resp, prefixed.len()),
Err(e) => {
tracing::warn!(
target: "kebab-embed-ollama",
attempt,
max = MAX_RETRIES,
error = %e,
"ollama /api/embed attempt failed; retrying"
);
last_err = Some(e);
}
}
}
Err(last_err.unwrap_or_else(|| {
anyhow::anyhow!("kb-embed-ollama: all {MAX_RETRIES} attempts failed")
}))
}
/// One HTTP round-trip. Network / non-2xx / decode errors all map to
/// `Err` so the retry loop can decide.
fn try_once(&self, url: &str, body: &EmbedRequest<'_>) -> Result<EmbedResponse> {
let resp = self
.client
.post(url)
.json(body)
.send()
.with_context(|| format!("kb-embed-ollama: POST {url}"))?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().unwrap_or_default();
anyhow::bail!("kb-embed-ollama: /api/embed returned {status}: {text}");
}
resp.json::<EmbedResponse>()
.context("kb-embed-ollama: decode /api/embed response")
}
/// Validate count + dim, then L2-normalize each vector.
fn finalize(&self, resp: EmbedResponse, expected: usize) -> Result<Vec<Vec<f32>>> {
if resp.embeddings.len() != expected {
anyhow::bail!(
"kb-embed-ollama: expected {expected} embeddings, got {}",
resp.embeddings.len()
);
}
let mut out = Vec::with_capacity(resp.embeddings.len());
for v in resp.embeddings {
if v.len() != self.dimensions {
anyhow::bail!(
"kb-embed-ollama: model returned dim {} but config expects {} \
(check models.embedding.dimensions vs the Ollama model)",
v.len(),
self.dimensions
);
}
out.push(l2_normalize(v));
}
Ok(out)
}
}
impl Embedder for OllamaEmbedder {
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());
}
let prefixed: Vec<String> = inputs.iter().map(|i| self.prefix(i)).collect();
let mut out = Vec::with_capacity(prefixed.len());
for chunk in prefixed.chunks(BATCH) {
out.extend(self.embed_batch(chunk)?);
}
debug_assert_eq!(out.len(), inputs.len());
Ok(out)
}
}
impl OllamaEmbedder {
/// Prefix one input per the resolved model prefixes.
fn prefix(&self, input: &EmbeddingInput<'_>) -> String {
match input.kind {
EmbeddingKind::Document => format!("{}{}", self.doc_prefix, input.text),
EmbeddingKind::Query => format!("{}{}", self.query_prefix, input.text),
}
}
}
/// L2-normalize a vector in place-ish (consumes + returns). A zero vector is
/// returned unchanged (norm 0 → no division) so a degenerate embedding can
/// never produce NaNs.
fn l2_normalize(mut v: Vec<f32>) -> Vec<f32> {
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > 0.0 {
for x in &mut v {
*x /= norm;
}
}
v
}
// ── Wire types ──────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct EmbedRequest<'a> {
model: &'a str,
input: &'a [String],
}
#[derive(Deserialize)]
struct EmbedResponse {
embeddings: Vec<Vec<f32>>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prefixes_for_arctic_is_query_only() {
assert_eq!(prefixes_for("snowflake-arctic-embed2"), ("query: ", ""));
assert_eq!(prefixes_for("snowflake-arctic-embed2:latest"), ("query: ", ""));
}
#[test]
fn prefixes_for_e5_is_query_passage() {
assert_eq!(prefixes_for("multilingual-e5-large"), ("query: ", "passage: "));
}
#[test]
fn prefixes_for_unknown_is_bare() {
assert_eq!(prefixes_for("nomic-embed-text"), ("", ""));
}
#[test]
fn l2_normalize_unit_length() {
let v = l2_normalize(vec![3.0, 4.0]);
let norm = (v[0] * v[0] + v[1] * v[1]).sqrt();
assert!((norm - 1.0).abs() < 1e-6, "norm = {norm}");
}
#[test]
fn l2_normalize_zero_vector_is_unchanged() {
assert_eq!(l2_normalize(vec![0.0, 0.0, 0.0]), vec![0.0, 0.0, 0.0]);
}
}

View File

@@ -0,0 +1,99 @@
//! `/api/embed` behavior against a `wiremock`-hosted mock server.
//!
//! `wiremock` is async, so the tests are `#[tokio::test]`; the sync
//! [`OllamaEmbedder`] is driven from `spawn_blocking` to keep `reqwest::blocking`
//! off the async runtime (same pattern as `kebab-llm-local`'s streaming tests).
//! tokio is a `dev-dependency` only.
use kebab_config::Config;
use kebab_core::{Embedder, EmbeddingInput, EmbeddingKind};
use kebab_embed_ollama::OllamaEmbedder;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// Config pointing at the mock server, with a small dim so the mock body is
/// tiny. `model` is an arctic tag so prefix resolution is exercised.
fn cfg_for(endpoint: &str, dim: usize) -> Config {
let mut cfg = Config::defaults();
cfg.models.embedding.provider = "ollama".to_string();
cfg.models.embedding.model = "snowflake-arctic-embed2".to_string();
cfg.models.embedding.dimensions = dim;
cfg.models.embedding.endpoint = Some(endpoint.to_string());
cfg
}
async fn embed_blocking(
cfg: Config,
inputs: Vec<(String, EmbeddingKind)>,
) -> anyhow::Result<Vec<Vec<f32>>> {
tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<Vec<f32>>> {
let emb = OllamaEmbedder::new(&cfg)?;
let refs: Vec<EmbeddingInput<'_>> = inputs
.iter()
.map(|(t, k)| EmbeddingInput { text: t, kind: *k })
.collect();
emb.embed(&refs)
})
.await
.expect("blocking task panicked")
}
#[tokio::test]
async fn embed_returns_l2_normalized_vectors() {
let server = MockServer::start().await;
// Two raw (un-normalized) vectors of dim 2; the adapter must L2-normalize.
let body = r#"{"model":"snowflake-arctic-embed2","embeddings":[[3.0,4.0],[0.0,5.0]]}"#;
Mock::given(method("POST"))
.and(path("/api/embed"))
.respond_with(ResponseTemplate::new(200).set_body_string(body))
.mount(&server)
.await;
let out = embed_blocking(
cfg_for(&server.uri(), 2),
vec![
("스택 자료구조".to_string(), EmbeddingKind::Query),
("후입선출".to_string(), EmbeddingKind::Document),
],
)
.await
.expect("embed should succeed");
assert_eq!(out.len(), 2);
for v in &out {
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!((norm - 1.0).abs() < 1e-5, "expected unit norm, got {norm}");
}
// [3,4] → [0.6, 0.8].
assert!((out[0][0] - 0.6).abs() < 1e-5 && (out[0][1] - 0.8).abs() < 1e-5);
}
#[tokio::test]
async fn embed_rejects_dim_mismatch() {
let server = MockServer::start().await;
// Server returns dim 3, config expects dim 2 → hard error.
let body = r#"{"model":"snowflake-arctic-embed2","embeddings":[[1.0,2.0,3.0]]}"#;
Mock::given(method("POST"))
.and(path("/api/embed"))
.respond_with(ResponseTemplate::new(200).set_body_string(body))
.mount(&server)
.await;
let err = embed_blocking(
cfg_for(&server.uri(), 2),
vec![("q".to_string(), EmbeddingKind::Query)],
)
.await
.expect_err("dim mismatch must error");
let msg = format!("{err:#}");
assert!(msg.contains("dim"), "expected dim error, got: {msg}");
}
#[tokio::test]
async fn embed_empty_input_is_noop() {
// No mock needed — empty input must never hit the network.
let out = embed_blocking(cfg_for("http://127.0.0.1:1", 2), vec![])
.await
.expect("empty embed should be Ok(empty)");
assert!(out.is_empty());
}

View File

@@ -209,6 +209,13 @@ impl OllamaVisionOcr {
self.max_pixels
}
/// The Ollama model id this engine drives (e.g. `gemma4:e4b`).
/// Surfaced so the ingest progress display can name the model
/// running a slow OCR phase (`AssetPhase{phase:"ocr", model}`).
pub fn model(&self) -> &str {
&self.model
}
fn build_prompt(&self, lang_hint: Option<&Lang>) -> String {
let langs = if self.languages.is_empty() {
"any".to_string()

View File

@@ -123,29 +123,7 @@ impl Retriever for LexicalRetriever {
};
let conn = self.store.read_conn();
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 raw_rows = run_query(&conn, &match_str, self.snippet_words, filters, fetch_limit)?;
let mut hits: Vec<SearchHit> = Vec::with_capacity(raw_rows.len().min(k));
let mut rank: u32 = 0;
@@ -228,16 +206,6 @@ 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;
@@ -274,7 +242,7 @@ fn build_match_string_for_column(text: &str, column: &str) -> Option<String> {
(Some(w), Some(a)) if w == a => w,
(Some(w), Some(a)) => format!("({w}) OR ({a})"),
};
Some(format!("{column} : ({expression})"))
Some(format!("text : ({expression})"))
}
/// Return `Some(inner)` if `s` is wrapped in a matching pair of single
@@ -512,77 +480,6 @@ 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

@@ -144,42 +144,6 @@ 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();
@@ -1290,51 +1254,14 @@ fn lexical_raw_mode_can_opt_into_heading_path_filter() {
);
}
// ── doc-side expansion (V010) — body+alias merged search ──────────────────
// ── body-only lexical recall (regression-safety) ──────────────────────────
/// 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.
/// Body `chunks_fts` recall works for a plain term in the chunk text.
/// (Was previously the `empty_aliases_table_matches_baseline` regression
/// guard; doc-side expansion was removed 2026-06-03 so the body channel is
/// the only lexical channel.)
#[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() {
fn body_term_recalls_chunk() {
let env = Env::new();
let conn = env.raw_conn();
insert_document(
@@ -1346,7 +1273,6 @@ fn empty_aliases_table_matches_baseline() {
"primary",
&[],
);
// aliases=None → no chunk_aliases_fts row; body channel only.
insert_chunk(
&conn,
&id32("c1"),
@@ -1370,6 +1296,6 @@ fn empty_aliases_table_matches_baseline() {
.unwrap();
assert!(
hits.iter().any(|h| h.chunk_id.0 == id32("c1")),
"aliases 빈 상태에서 본문 매칭 청크가 정상 회수돼야 한다 (회귀 안전)"
"본문 매칭 청크가 정상 회수돼야 한다 (회귀 안전)"
);
}

View File

@@ -123,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, aliases
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
tokenized_korean_text
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.map_err(StoreError::from)?;
for chunk in chunks {
@@ -153,7 +153,6 @@ impl kebab_core::DocumentStore for SqliteStore {
block_ids,
now,
chunk.tokenized_korean_text.as_deref(),
chunk.aliases.as_deref(),
])
.map_err(StoreError::from)?;
}
@@ -268,7 +267,6 @@ 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

@@ -1,220 +0,0 @@
//! 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

@@ -23,6 +23,8 @@ fn open_store(tmp: &TempDir) -> SqliteStore {
/// 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`.
/// (V012 derivation_cache + V013 drop-chunk-aliases are structural/additive
/// and do NOT bump corpus_revision.)
#[test]
fn fresh_store_starts_at_post_migration_baseline() {
let tmp = TempDir::new().unwrap();

View File

@@ -160,7 +160,6 @@ fn put_chunks_cleans_original_and_sentinel_embeddings() {
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();
@@ -270,7 +269,6 @@ fn put_chunks_cleans_per_alias_sentinel_embeddings() {
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();

View File

@@ -98,7 +98,6 @@ 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,17 @@ 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::AssetTimings { .. }
// v0.26.1 slow-phase hint (ocr / caption / embed): the CLI bar uses
// it for a live phase message; the TUI status-bar reducer tracks only
// per-asset counters, so it's a no-op here.
| IngestEvent::AssetPhase { .. } => {}
}
}

View File

@@ -114,7 +114,6 @@ fn make_chunk() -> Chunk {
chunker_version: ChunkerVersion("md-heading-v1".into()),
policy_hash: "deadbeefdeadbeef".into(),
tokenized_korean_text: None,
aliases: None,
}
}

View File

@@ -16,7 +16,7 @@ Cargo workspace, 함수 호출 기반 모듈러 모놀리스. UI binary (`kebab-
| metadata | SQLite + FTS5 (lexical search + v0.20.1 한국어 형태소 tokenizer via lindera-ko-dic) |
| vector | LanceDB (embedded, model 별 분리 table) |
| 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 업그레이드) |
| embedding | `fastembed-rs` (`multilingual-e5-large`, 1024d, v0.18.0부터 default 업그레이드). opt-in 대안: candle (e5 또는 `snowflake-arctic-embed-l-v2.0`) / Ollama `/api/embed`. arctic = 설명형 query recall 보강 (v0.26.0, 아래 결정표) |
| 한국어 형태소분석 | `lindera-ko-dic` (FTS5 외부 tokenizer, v0.20.1) — 2자 이상 한국어 query 지원 |
| LLM | Ollama HTTP (default `gemma4:e4b` ─ OCR / caption 와 family 통일. 사용자가 더 큰 variant `gemma4:26b` 등으로 override 가능) |
| 음성 ASR | `whisper.cpp` (via `whisper-rs`) — P8 보류, 시스템 dep brainstorm 후 |
@@ -32,8 +32,8 @@ Cargo workspace, 함수 호출 기반 모듈러 모놀리스. UI binary (`kebab-
| citation 형식 | URI fragment (`path#L12-L34` / `path#p=12` / `path#xywh=0,0,100,50`, W3C Media Fragments) |
| ID 생성 | `blake3(canonical_json(tuple))[..32]` hex |
| RRF fusion_score | `[0, 1]` 정규화 — `2 / (k_rrf + 1)` 로 나눠 mode 간 비교 가능 (post-merge hotfix) |
| doc-side expansion 별칭 (v0.21.0) | 색인 시 LLM 이 청크별 "같은 의미 다른 표현" 별칭 생성. 별칭은 줄별 **개별 dense 벡터**(sentinel `{chunk}#alias#N`)로 색인하고 본문 벡터는 그대로 둠 (묶음 1벡터는 평균화로 희석 → 회귀, HOTFIXES 2026-05-31). boilerplate 청크는 별칭 skip. 검색 시 별칭 hit 는 `kebab-core::strip_alias_suffix`본 chunk_id 에 매핑. `[ingest.expansion]` default off (opt-in, 청크당 LLM 비용). |
| 파생물 캐시 `derivation_cache` (V012, v0.21.0) | 비싼 ingest 파생물(embedding 벡터 / 별칭 LLM 결과)을 청크 **내용 해시** 키로 SQLite 에 캐싱 → 재색인 시 내용 불변 청크는 재계산 skip. `cache_key = blake3(kind ‖ text_blake3 ‖ version_key)[:32]`; version_key 에 model/prompt/dimensions 포함 → §9 cascade 와 정합(버전 bump 시 자동 miss). 위치 기반 `chunk_id` 와 달리 내용이 같으면 문서·위치 무관 동일 키. 순수 가산 — `corpus_revision` bump 안 함, 손상/삭제돼도 정확성 영향 0(miss → 재계산). search/ask 는 `kebab.sqlite`+`lancedb` 만으로 동작하므로 외부 서버 색인 후 DB 만 복사하는 이식 워크플로 가능 (HOTFIXES 2026-05-31). |
| ~~doc-side expansion 별칭 (v0.21.0)~~ | **제거됨 (v0.25.0, HOTFIXES 2026-06-03)** — 색인-시 청크당 LLM 별칭 생성 + 별칭 검색 채널을 완전히 제거. 별칭 ROI 음수(cross-lingual 은 e5-large 단독으로 충분, 기여는 설명형 +2 그룹뿐인데 대가가 청크당 색인-시 LLM). V013 마이그레이션이 `chunk_aliases_fts` + `chunks.aliases` DROP. 기존 KB 의 잔존 별칭 벡터는 검색 시 `strip_alias_suffix` 로 본 chunk 에 매핑(graceful)되거나 `kebab reset` 으로 정리. spec: `docs/superpowers/specs/2026-06-03-remove-doc-expansion-spec.md`. |
| 파생물 캐시 `derivation_cache` (V012, v0.21.0) | 비싼 ingest 파생물(embedding 벡터)을 청크 **내용 해시** 키로 SQLite 에 캐싱 → 재색인 시 내용 불변 청크는 재계산 skip. `cache_key = blake3(kind ‖ text_blake3 ‖ version_key)[:32]`; version_key 에 model/dimensions 포함 → §9 cascade 와 정합(버전 bump 시 자동 miss). 위치 기반 `chunk_id` 와 달리 내용이 같으면 문서·위치 무관 동일 키. 순수 가산 — `corpus_revision` bump 안 함, 손상/삭제돼도 정확성 영향 0(miss → 재계산). search/ask 는 `kebab.sqlite`+`lancedb` 만으로 동작하므로 외부 서버 색인 후 DB 만 복사하는 이식 워크플로 가능 (HOTFIXES 2026-05-31). (별칭 LLM 캐싱 kind 는 v0.25.0 에서 제거 — embedding kind 만 남음.) |
| 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 참조.
@@ -66,7 +66,9 @@ 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, e5+arctic, NUMA-safe opt-in)"]
embedollama["kebab-embed-ollama<br/>(Ollama /api/embed, opt-in)"]
llm["kebab-llm<br/>(trait)"]
llmlocal["kebab-llm-local<br/>(Ollama)"]
search["kebab-search"]
@@ -92,6 +94,8 @@ flowchart TB
app --> sqlite
app --> vector
app --> embedlocal
app --> embedcandle
app --> embedollama
app --> llmlocal
app --> search
app --> rag
@@ -104,6 +108,10 @@ flowchart TB
paud --> core
pcode --> core
embedlocal --> embed
embedcandle --> core
embedcandle --> config
embedollama --> core
embedollama --> config
llmlocal --> llm
rag --> search
rag --> llm
@@ -132,6 +140,23 @@ UI → store/llm/parse 직접 의존 금지. 모든 user-facing 진입은 `kebab
`kebab-parse-code` 의 외부 tree-sitter grammar crate 의존: P10-1A-2 에서 `tree-sitter-rust` 추가, P10-1B 에서 `tree-sitter-python` / `tree-sitter-typescript` / `tree-sitter-javascript` 추가, P10-1C-Go 에서 `tree-sitter-go` 추가, P10-1C-JK 에서 `tree-sitter-java` / `tree-sitter-kotlin-ng` 추가, P10-1D 에서 `tree-sitter-c` / `tree-sitter-cpp` 추가. 모두 `kebab-parse-code` 에만 격리 (facade 룰 — UI crate / chunker 가 직접 import 금지). Kotlin 은 `tree-sitter-kotlin-ng` 사용 (bare `tree-sitter-kotlin` 은 tree-sitter 0.210.23 에 고착 — 사용 불가). v0.18.0+ 부터 `kebab-source-fs` 는 자체 `code_meta` 모듈 (lang detect + skip helpers + BUILTIN_BLACKLIST) 을 보유, kebab-parse-code 와 분리 (refactor 2026-05-26). v0.19.0 부터 `kebab-parse-md``kebab-parse-types` (parser intermediate types) + `kebab-normalize` (CanonicalDocument lift) 두 crate 를 흡수 — 24 → 22 crates, design §3.7b 재작성 (HOTFIXES 2026-05-26). v0.20.1 부터 `kebab-search``lindera-ko-dic` 를 의존해 한국어 FTS5 형태소 tokenizer 지원 — V009 migration 으로 2자 이상 한국어 query 매칭 (Bug #8 closure).
### 임베딩 백엔드 결정표 (v0.26.0)
| provider | 모델 | pooling / prefix | 위치 | 언제 |
|---|---|---|---|---|
| `fastembed` (기본) | `multilingual-e5-large` | mean / `query:`·`passage:` | in-process (onnxruntime) | 기본. 단일 소켓 호스트 |
| `candle` | e5 또는 `snowflake-arctic-embed-l-v2.0` | 모델별 (e5=mean, arctic=CLS) / arctic=`query:`·무접두어 | in-process (pure Rust) | NUMA 서버 (onnxruntime 48-스레드 double-free 회피), Apple Silicon Metal GPU |
| `ollama` | `snowflake-arctic-embed2` 등 | 모델 태그로 추론 / arctic=`query:`·무접두어 | 원격 HTTP (`/api/embed`) | candle 폴백, 측정에 쓴 경로 그대로 재현 |
**arctic-embed-l-v2.0 채택 근거**: 별칭(doc-side expansion) 제거(v0.25.0) 후 설명형
query 의 recall 보강책. 측정(`/build/dogfood/logs/2026-06-03-method-measurements.md`)에서
arctic = recall@10 130/132 (e5 대비 +7, 색인 1회·per-query 0·LLM 0, 용어 무손실).
candle 이 주 백엔드(in-process, NUMA 안전), Ollama 가 폴백(측정 경로 재현). 두 경로의
pooling/prefix 정확성은 `kebab-embed-candle/tests/arctic_ollama_parity.rs`
(candle arctic vs Ollama arctic 코사인>0.99, `#[ignore]`) 로 고정. e5 → arctic 전환은
`embedding_version` cascade (모델별 벡터 상이) → 재색인 필요. 기본값 e5 유지라 기존
사용자 무영향. 자세한 내용: [tasks/HOTFIXES.md](../tasks/HOTFIXES.md) 2026-06-03 arctic entry.
## 디렉토리 구조
```text
@@ -180,6 +205,8 @@ kebab/
│ ├── 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, 모델 레지스트리(e5 mean + arctic CLS), NUMA-safe opt-in provider=candle (Track 1, v0.22.0; arctic v0.26.0)
│ ├── kebab-embed-ollama/ # Ollama /api/embed Embedder, opt-in provider=ollama (arctic 폴백 경로, v0.26.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)
@@ -188,7 +215,7 @@ 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 본체). src/expansion.rs = 별칭 생성, src/derivation_payload.rs = 캐시 payload 인코딩 (v0.21.0)
│ ├── kebab-app/ # facade (P0 시그니처 + P3-5/P6-4/P7-3 본체). 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 강화)

View File

@@ -695,7 +695,7 @@ printf 'schema_version = 1\n\n[workspace]\nroot = "~/MyNotes"\ninclude = ["*.md"
"$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.
기대: dry-run 파일 미수정 → apply 시 `old.toml.bak`(원본 byte-identical) + `[ingest.code]`·`[logging]`·`[pdf.ocr]` 가시화 + 손본 `default_k`/주석 보존 + `workspace.include` 제거 → 재실행 멱등 → doctor `config_migration` ok. v0.21.1 evidence 는 `tasks/HOTFIXES.md` 2026-05-31.
## §10 Eval (P5)

View File

@@ -107,11 +107,18 @@ respect_markdown_headings = true
chunker_version = "md-heading-v1"
[models.embedding]
provider = "fastembed" # "none" 으로 두면 lexical-only — Ollama 불필요
model = "multilingual-e5-small"
provider = "fastembed" # "fastembed"(기본, onnxruntime) / "candle"(순수 Rust, NUMA-안전)
# / "ollama"(원격 HTTP /api/embed) / "none"(lexical-only — Ollama 불필요)
# ⚠ provider/model 변경 시 아래 dimensions 도 맞춰야 함.
model = "multilingual-e5-small" # candle/ollama 는 "snowflake-arctic-embed-l-v2.0"
# (ollama 태그 "snowflake-arctic-embed2", 1024-dim) 도 지원 —
# 설명형 query recall 보강. e5↔arctic 전환은
# embedding_version cascade (재색인 필요).
version = "v1"
dimensions = 384
dimensions = 384 # arctic / e5-large 는 1024.
batch_size = 64
num_threads = 0 # candle 전용 CPU 스레드 캡 (0=auto). env KEBAB_EMBED_THREADS 우선.
# endpoint = "http://127.0.0.1:11434" # provider="ollama" 전용; 생략 시 [models.llm].endpoint fallback.
[models.llm]
provider = "ollama"
@@ -702,7 +709,7 @@ kebab --config /tmp/kebab-smoke/old.toml config migrate # 멱등: "c
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`.
기대: dry-run 은 추가될 섹션(`[ingest.code]`·`[logging]` 등)과 제거될 `workspace.include` 를 출력하고 **파일을 수정하지 않는다**. 적용 시 `old.toml.bak`(원본과 동일)이 생기고 빠진 섹션이 주석과 함께 추가되며 사용자가 손본 값·주석은 보존된다. 재실행은 멱등(`config 이미 최신입니다`), `--json` 은 `config_migration.v1`.
## 정리

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,33 @@
# Plan: arctic-embed-l-v2.0 임베더 통합 구현
spec: `docs/superpowers/specs/2026-06-03-arctic-embedder-spec.md`. 브랜치 `feat/arctic-embedder`. 빌드 `CARGO_TARGET_DIR=/build/out/cargo-target`, `-j 4`(전체 test `-j 1`). cli 통합테스트용 `target` 심링크 필요 후 정리.
## Task 1 — kebab-embed-candle 모델 레지스트리
- e5 하드코딩(`HF_MODEL`/`SUPPORTED_MODEL`/mean pool/`query:`+`passage:`) → 레지스트리 구조체 `EmbedModelSpec { name, hf_repo, pooling: Pooling, query_prefix, doc_prefix, dim }`.
- 등록: e5(`intfloat/multilingual-e5-large`, Mean, `query: `/`passage: `, 1024) + arctic(`Snowflake/snowflake-arctic-embed-l-v2.0`, Cls, `query: `/``, 1024). **arctic pooling 은 모델 `1_Pooling/config.json` 로 확인 후 확정(CLS 추정).**
- `embed_batch` pooling 분기: Mean=기존 attention-mask-weighted, Cls=hidden_state[:,0,:]. tokenize/forward/L2 공유.
- `CandleEmbedder::new` 가 config model 로 spec 조회, 없으면 에러. `model_id`/`model_version` 에 모델명 반영.
- 단위테스트: 레지스트리 조회, prefix 적용, (가능하면) CLS vs mean pooling shape.
## Task 2 — kebab-embed-ollama 신규 크레이트
- `Cargo.toml`(workspace member), `Embedder` 구현. `reqwest::blocking` POST `/api/embed`.
- 배치(48) + fail-soft 재시도(3). query/doc prefix 모델별. L2 normalize. dim 검증(config 와 일치).
- endpoint = config.models.embedding.endpoint ?? models.llm.endpoint. model_version=`ollama:{model}`.
- 단위테스트: wiremock 으로 /api/embed mock → dim·정규화·prefix 검증.
## Task 3 — config + app 배선
- `kebab-config`: `EmbeddingCfg.provider` 문서/검증에 `ollama` 추가, `endpoint: Option<String>` 필드(serde default None). migrate.rs 주석.
- `kebab-app` `embedder()`(또는 해당 선택부, lib.rs ~836): provider match → fastembed | candle(레지스트리) | ollama. facade 통해 cfg 주입.
- config 직렬화/round-trip 테스트 갱신.
## Task 4 — correctness 검증 테스트 (핵심)
- candle arctic vs Ollama arctic 코사인>0.99 테스트: 테스트 문장 임베딩을 candle(arctic spec)로 1개 + Ollama(`snowflake-arctic-embed2` @192.168.0.47)로 1개 → cos>0.99 assert. live Ollama 의존이라 `#[ignore]`(이유: 외부 Ollama), 수동 실행 절차를 테스트 doc + HOTFIXES 에 기록. (CI 무인 환경 회피.)
- 단, **리더가 머지 전 이 테스트를 수동 실행해 통과 확인**(pooling/prefix 정확성 게이트).
## Task 5 — 검증 + 문서
- clippy 0 / 전체 test 통과(기존 e5 회귀 0).
- provider=candle+arctic, ollama+arctic, fastembed+e5(기본) 각 로드 스모크.
- 문서: README Configuration(provider candle/ollama + arctic + endpoint + metal), ARCHITECTURE(백엔드 그래프 + kebab-embed-ollama 크레이트 + 결정표), HANDOFF 1줄, HOTFIXES dated, Cargo.toml members + version minor bump(+Cargo.lock).
## 리뷰 루프
구현 완료 → 리더가 (a) clippy/test 독립 재확인 (b) **candle≈Ollama 코사인>0.99 수동 검증**`gitea-pr`(title `feat(embed): arctic-embed-l-v2.0 임베더(candle+ollama)`) → 리뷰 루프 → 사용자 머지. 머지 후 Mac Metal 도그푸딩(recall 130 재현).

View File

@@ -0,0 +1,33 @@
# Plan: ingest 로그 개선 구현
spec: `docs/superpowers/specs/2026-06-03-ingest-log-improve-spec.md`. 브랜치 `feat/ingest-log-improve`. 빌드 `CARGO_TARGET_DIR=/build/out/cargo-target -j 4`(전체 test `-j 1`). cli 통합테스트용 `target` 심링크 후 정리.
## Task 1 — wire 이벤트 (kebab-app/src/ingest_progress.rs)
- `IngestEvent``AssetPhase { idx: u32, total: u32, phase: String, model: Option<String> }` variant 추가(serde tag 규약 기존과 동일, snake `asset_phase`).
- `AssetTimings``ocr_ms: u64`, `caption_ms: u64` 필드 추가(기존 필드 뒤, serde default 0 → 구 소비자 호환).
- 직렬화 테스트 추가(asset_phase, 확장 timings).
## Task 2 — emit 지점 (kebab-app/src/lib.rs)
- 이미지 경로: `apply_ocr` 직전 `AssetPhase{phase:"ocr", model: <ocr model>}`, `apply_caption` 직전 `AssetPhase{phase:"caption", model: <llm model>}` emit. 각 호출 시간 측정 → `ocr_ms`/`caption_ms`.
- 임베딩 루프 진입 직전 `AssetPhase{phase:"embed", model: embedder.model_id}` emit(텍스트 포함 전 asset).
- `AssetTimings` 생성부에 ocr_ms/caption_ms 전달.
- 짧은 phase(parse/chunk/store)는 emit 안 함.
## Task 3 — CLI 렌더 (kebab-cli/src/progress.rs)
- **파일명**: AssetStarted TTY 핸들러 `bar.set_message(<path>)` (현재 위치-only 주석/로직 교체; path 길면 말미 축약). 비-TTY 줄 유지.
- **phase+모델**: AssetPhase 수신 → `bar.set_message(format!("{path} · {phase}({model})…"))`. 현재 path 를 핸들러 상태로 보관(AssetStarted 에서 저장).
- **heartbeat**: AssetStarted 에서 `Instant::now()` 보관 + `bar.enable_steady_tick(1s)` + 메시지 렌더에 경과초 `(Ns)`. AssetFinished/다음 AssetStarted 에서 리셋. (indicatif steady-tick + 커스텀 메시지.)
- **slowest 요약**: 핸들러에 `Vec<(path, total_ms)>` 누적 — AssetStarted 로 idx→path, AssetTimings 로 idx→sum(parse+chunk+embed+store+ocr+caption). `Completed` 수신 시 상위 5개 stderr 표 출력(`⏱ 최장 소요:`). `--json` 모드 미출력, quiet 여도 요약은 출력.
- `fmt_ms`(기존) 재사용.
## Task 4 — wire schema + 문서
- `docs/wire-schema/v1/ingest_progress.schema.json`: `asset_phase` kind(phase enum, model) + `ocr_ms`/`caption_ms` 필드 추가(additive). verbatim 일치.
- README(있으면 진행 표시 한 줄), HANDOFF 1줄, tasks/HOTFIXES dated entry, Cargo.toml version minor bump(+Cargo.lock).
## Task 5 — 검증
- clippy 0, 전체 test 통과(기존 progress 테스트 갱신).
- 스모크: 이미지/PDF 포함 임시 폴더 ingest → TTY 파일명+phase+모델+경과, 종료 top-N. 비-TTY 줄+요약. `--json` ndjson(asset_phase/ocr_ms) 확인, 사람텍스트 미혼입.
- 결과 요약 `/tmp/ingestlog-result.md`(게이트 + 스모크 캡처).
## 리뷰 루프
완료 → 리더 clippy/test 독립 재확인 → `gitea-pr`(title `feat(ingest): 진행 로그 개선 — 파일명/phase/heartbeat/slowest 요약`) → 리뷰 루프 → 사용자 머지. 머지 후 Obsidian 볼트 도그푸딩.

View File

@@ -0,0 +1,49 @@
# Plan: doc-side expansion(별칭) 제거 구현
spec: `docs/superpowers/specs/2026-06-03-remove-doc-expansion-spec.md`. 브랜치 `refactor/remove-doc-expansion`. 빌드 `CARGO_TARGET_DIR=/build/out/cargo-target`, 직렬 `-j 4`(전체 테스트는 `-j 1`).
원칙: 작은 단위로 컴파일 가능 상태 유지. 각 단계 후 `cargo build -p <crate> -j 4`. 최종 clippy+test.
## Task 1 — kebab-core: Chunk.aliases 필드 제거
- `chunk.rs`: `pub aliases: Option<String>` + serde default + `aliases_defaults_to_none_on_deserialize` 테스트 제거.
- **금지**: `metadata.rs` `Metadata.aliases`(Vec) 는 손대지 않음.
- 컴파일 깨짐 → Task 2~ 에서 Chunk 리터럴 정리하며 해소.
## Task 2 — Chunk 리터럴 정리 (kebab-chunk/*, kebab-parse-*/*, store-sqlite, app)
- `grep -rn "aliases: None" crates/*/src` 로 Chunk 생성부 전수 → `aliases: None,` 줄 삭제.
- store-sqlite `documents.rs`: chunks INSERT 컬럼리스트/바인딩에서 `aliases` 제거(line ~126/156), SELECT 매핑의 aliases 제거, `aliases: None`(271) 제거.
## Task 3 — kebab-app: expansion 모듈 + 루프 제거
- `lib.rs`: `pub mod expansion;` 삭제, `expansion.rs` 파일 삭제.
- ingest_one_asset: expansion 블록 전체(`if app.config.ingest.expansion.enabled { … }` + `alias_version_key`/`alias_cache_*`/`alias_touch_keys`/embed_aliases 임베딩/sentinel 벡터 생성) 제거. `expansion_ms` 타이밍은 0 고정 또는 AssetTimings 에서 필드 유지하되 항상 0 — **AssetTimings 의 expansion_ms 필드는 유지(wire 호환)**, 값 0.
- alias sentinel 벡터 upsert 경로 제거, purge_vector_orphans 는 본문 벡터 정리로 유지.
## Task 4 — kebab-config: ExpansionCfg 제거
- `lib.rs`: `ExpansionCfg` struct + `IngestCfg.expansion` 필드 + Default 제거.
- `migrate.rs`: `[ingest.expansion]` 처리/주석 제거.
- config 직렬화 테스트에서 expansion 기대 제거.
## Task 5 — kebab-search: alias lexical arm 제거
- `lexical.rs`: `run_alias_query`, `merge_body_alias`, alias 분기 제거. body_rows 직접 사용으로 단순화. alias 관련 테스트 제거/갱신.
## Task 6 — wire/progress 정리
- `kebab-app/ingest_progress.rs`: `IngestEvent::ExpansionProgress` variant + 직렬화 테스트 제거. AssetChunked/AssetTimings 유지.
- `kebab-cli/progress.rs`, `kebab-tui/ingest_progress.rs`: ExpansionProgress 매치/렌더 제거.
- `kebab-tui/inspect.rs`: 별칭 표시 제거.
- `docs/wire-schema/v1/ingest_progress.schema.json`: expansion_progress kind 제거.
## Task 7 — sqlite 마이그레이션: DROP chunk_aliases_fts + chunks.aliases
- `schema.rs`(refinery 마이그레이션 등록부) 확인 → 신규 forward 마이그레이션 추가: `DROP TABLE IF EXISTS chunk_aliases_fts` (+ 관련 트리거/shadow 테이블 chunk_aliases_fts_*), `ALTER TABLE chunks DROP COLUMN aliases`.
- chunk_aliases_fts 를 만들던 기존 마이그레이션은 **수정 금지**(과거 마이그레이션 freeze) — 새 마이그레이션으로 덮어 제거.
- `tests/chunk_aliases.rs` 삭제. `tests/migration.rs` 신규 마이그레이션 반영.
- 번들 sqlite DROP COLUMN 지원 확인(3.35+); 미지원이면 테이블 재생성 패턴.
## Task 8 — 검증 + 문서
- `cargo clippy --workspace --all-targets -j 4 -- -D warnings`.
- `cargo test --workspace --no-fail-fast -j 1`.
- fresh KB `.schema` 로 chunk_aliases_fts/aliases 부재 확인. `kebab ingest` 스모크(별칭 config 없이).
- grep 잔존 0 (spec Acceptance 의 정규식).
- 문서: HOTFIXES dated entry, 2026-05-30 doc-expansion-design spec Risks/notes cross-link, HANDOFF 1줄, wire schema, design 본문 removed 주석, Cargo.toml version bump.
## 리뷰 루프
구현 완료 → `gitea-pr`(title `refactor(app): doc-side expansion(별칭) 제거`, body 요약/검증) → gitea-pr 리뷰 루프(actionable 해소까지) → 사용자 머지.

View File

@@ -0,0 +1,163 @@
# Expansion 비용 재고 — 별칭(doc-side LLM expansion)을 대체할 방법 조사
**날짜**: 2026-06-03
**상태**: 조사 완료, 검증(측정) 대기
**선행**: [[2026-05-30-vocabulary-gap-recall-fix-research]] (당시 결론 = doc-side expansion), v0.21.0 별칭 구현(#195/#196)
**계기**: 도그푸딩에서 expansion 이 ingest 임계경로의 압도적 병목으로 확정(청크당 gemma4:e4b ~1.3s, 5150 청크 ≈ 1.9h). 동시성(A)·모델스왑(D) 실측 소진. 사용자가 두 구조적 반론 제기.
---
## 1. 문제 재정의 — 왜 "반창고"가 다 실패하는가
별칭은 **청크마다 LLM 1회 호출**(`kebab-app/src/lib.rs` expansion 루프). 비용이 **코퍼스 크기에 비례**하고, KB 가 살아있으므로(문서 수정·추가) **갱신 청크를 영원히 따라가야 함**.
소진된 레버 (전부 *같은 총량을 언제/어떻게 나눌지*만 바꿈, 총량 불변):
| 레버 | 실측(2026-06-02~03, Mac M4 Pro Metal) | 판정 |
|------|------|------|
| A. `OLLAMA_NUM_PARALLEL` + 클라 동시요청 | 슬롯 2/4, 동시 2/4/8 → **최대 1.28×** (GPU compute 포화) | ✖ 불충분 |
| D. 모델 스왑 | gemma4:e4b 1.22s/건(품질 합격선) · qwen2.5vl:3b 더 느림+무한반복 · **qwen3.5:2b-mlx 0.24s(~5배)지만 중국어(所有权系统)+47줄 degeneration** · 0.8b 입력에코 | ✖ gemma 품질 못 이김 |
| B. 백그라운드/별도명령 | 총량·팬·리소스 불변, 유지보수 treadmill 잔존 | ✖ 사용자 반론으로 기각 |
**사용자 반론(정확)**: ① 별도 명령이어도 맥 팬·리소스 총량 동일 ② 청크당 이렇게 비싸면 갱신을 못 따라감. → "청크마다 미리 LLM" 구조 자체가 부적합. **아키텍처를 의심해야 하는 지점.**
---
## 2. 학계/웹 조사 핵심 발견
### 2.1 결정타 — Expansion 은 강한 검색기에 오히려 해롭다
*"When do Generative Query and Document Expansions Fail?"* (arXiv 2309.08541): **검색기 성능과 expansion 이득 사이 강한 음의 상관**. 11개 expansion 기법 × 12개 데이터셋 × 24개 검색 모델에서 일관. 약한 모델엔 도움, **강한 모델엔 손해**(추가 noise 가 relevance 신호를 흐림, false positive 유발). 권고: *"target 이 학습 코퍼스와 크게 다르거나 약한 모델일 때만 expansion, 아니면 피하라."*
→ 함의: 별칭의 v0.21.0 이득이 **14/18→16/18(미미)** 였던 건 우연이 아님. e5-large 는 이미 준수한 다국어 검색기 → 별칭은 *목발*에 가깝고 ROI 가 0~음수 구간일 수 있음. **측정으로 즉시 확인 가능**(별칭 on/off 골든 비교).
### 2.2 어휘·교차언어 격차는 본질적으로 *임베딩* 문제
별칭은 "역전파↔backpropagation 이 벡터공간에서 안 가깝다"를 색인-시 텍스트로 우회한 것. 정공법 = **교차언어가 강한 임베더로 벡터공간 자체에서 정렬**. 비용 = LLM 0, **색인 1회 재계산**(살아있는 KB 에서도 신규/수정 청크 임베딩은 어차피 하는 일 — treadmill 없음).
### 2.3 임베더 후보 (로컬·오픈, 2026)
- **BGE-M3** (사용자 Mac 에 이미 pull 됨): XLM-RoBERTa-large 기반(= `kebab-embed-candle``XLMRobertaModel`**동일 아키텍처**), dense **1024-dim**(= e5-large, 벡터스토어 그대로), **prefix 불필요**(e5 의 `query:`/`passage:` 와 달리), 단 **CLS pooling**(e5 는 mean pooling — 통합 시 분기 필요). **dense+sparse(lexical)+multi-vector(ColBERT)** 3-헤드.
- 한↔영 실측(Belebele, 2507.08480): base bge-m3 ≈ base e5-large (EN→KO 는 e5 92.0 > m3 90.4, KO→EN 은 m3 88.4 > e5 86.5). **dense 단독 교체만으론 대박 아님**. 차별점은 sparse/multi-vector 헤드.
- **Qwen3-Embedding** (2026 초, MTEB v2 오픈웨이트 1위; 8B 는 무거움, 0.6B/4B 변형 존재): 다국어 최상위. 소형 변형이 로컬 가용하면 dense 업그레이드 후보.
- 다국어 일반 권고(2026 가이드들): "BGE-M3 또는 Nomic". e5-large 도 여전히 경쟁력.
### 2.4 Multi-vector(ColBERT)는 색인-전체가 아니라 *질의-시 rerank* 로
ColBERT/multi-vector 는 토큰당 벡터 1개 → 저장 폭증(10M doc ≈ 6TB vs bi-encoder 30GB). **전체 코퍼스 색인 금지.** 실용 패턴 = dense 1차 검색 → **top-50/100 만 multi-vector late-interaction rerank(질의-시, O(질의))**. 진단된 "near-tie 벡터 불안정"([[project_crossscript_diagnosis]])을 정조준하면서 색인 비용 0.
### 2.5 굳이 expansion 한다면 — query-side, single-pass
CTQE(2509.02377): LLM 한 번의 decoding 패스에서 candidate token 재활용 → **추가 inference 없이** query expansion. 비용 O(질의), 캐시 가능. doc-side 의 O(코퍼스)·treadmill 과 정반대.
---
## 3. 권고 아키텍처 — 청크당 LLM 0, 측정-우선 단계별
원칙: 비용을 **O(코퍼스 LLM)** 에서 **O(코퍼스 임베딩, 이미 수용중) + O(질의)** 로 이동. 각 단계는 기존 골든/variant eval 로 검증 후 다음 진행(사용자 "측정 먼저" 방법론).
- **Step 0 — 별칭 ROI 실측 (LLM 0, 코드 0)**: 현재 e5-large 에서 별칭 **on vs off** 골든/variant 비교. 2.1 예측대로 차이 미미/음수면 → **별칭 기능 통째 제거**(즉시 최대 승리: 청크당 LLM 영구 소멸). 차이가 유의미할 때만 Step 1+.
- **Step 1 — 강한 dense 임베더 (LLM 0, 색인 1회)**: BGE-M3 를 `kebab-embed-candle` 로 dense 임베더 교체 검증(같은 XLM-R, CLS pooling + prefix 제거, 1024-dim 동일). 소형 Qwen3-Embedding 가용 시 병행. `embedding_version` cascade = 전체 1회 재임베딩(0.48s/asset 관측, 수용 범위, treadmill 아님).
- **Step 2 — BGE-M3 sparse 헤드를 lexical arm 으로 (LLM 0)**: 학습된 sparse lexical 이 FTS5 보다 교차언어 우수. 같은 임베드 패스 산출물 → 추가 색인 비용 ≈ 0. RRF 의 lexical 항 보강/대체.
- **Step 3 — (선택) 질의-시 multi-vector rerank**: 잔존 near-tie 순위 출렁이면 top-50 만 BGE-M3 multi-vector late-interaction rerank(O(질의), 색인 bloat 0).
**통합 이점**: 사용자가 이미 NUMA 대응으로 만든 `kebab-embed-candle`(XLM-RoBERTa)가 BGE-M3 와 동일 아키텍처 → 가중치/풀링/헤드 추가 위주로 재사용. fastembed 도 bge-m3 지원(단 NUMA double-free 회피 위해 candle 경로 선호).
**리스크/주의**: ① dense 단독 교체 이득은 한↔영 데이터상 작을 수 있음 → sparse/multi-vector 가 실질 차별점, Step 1 단독 성패로 판단 말 것. ② CLS vs mean pooling, prefix 차이 → 정확히 구현 안 하면 품질 급락(검증 필수). ③ `embedding_version` bump = breaking, 재임베딩 필요(versioning cascade). ④ Mono-IR 소폭 저하 가능(2507.08480) — 골든의 한국어-단일 케이스도 같이 측정.
---
## 4. Step 0 측정 결과 (2026-06-03, v0.24.0 fresh)
namu corpus(997 docs / 23151 chunks, e5-large) + `namu_golden_step0.yaml`(doc_id 재매핑, 18그룹×4변형+10대조) hybrid k=50.
| arm | fully_consistent | recall@10 | recall@50 | mean_spread@10 | 색인 LLM 비용 |
|------|------|------|------|------|------|
| **OFF (별칭 없음, fresh v0.24.0)** | **14/18** | **68/72 (0.944)** | 70/72 (0.972) | **0.222** | **0** |
| ON (별칭, v0.21.0 prior, 동일 corpus/golden) | 16/18 | ~70/72 | ~72/72 | 0.111 | 별칭 LLM (정답 18문서만 **2.5h**, 전 corpus 수 시간) |
fresh OFF 가 이전 baseline(14/18, A2/B2, spread 0.222)을 **정확히 재현** → 이전 ON(16/18, A1/B1, spread 0.111, handoff 2026-05-31)과 직접 비교 유효. ON 재측정은 시드 캐시의 alias 행이 7개뿐이라 전 corpus cold 별칭생성=수 시간(= 사용자가 못 견디는 그 비용) → 비실시.
**변형 종류별 OFF recall@10 (별칭 0):**
`en 18/18 · ko 18/18 · syn 11/11 · abbr 7/7 · para 14/18`**교차언어(en↔ko)·동의어·약어는 별칭 없이 이미 완벽.** 유일한 약점 = 설명형(paraphrase) 4쿼리.
**결론 (Step 0)**:
- 별칭이 정조준한 **cross-lingual 격차는 e5-large 단독으로 이미 top-10 완벽 해결**(역전파↔backprop 우려는 기우였음). 별칭의 실제 기여 = **paraphrase 그룹 +2(14→16)** 뿐, 그것도 stack/svm 설명형 잔존.
- 그 +2 를 위해 **색인-시 수 시간 LLM + 살아있는 KB treadmill**(사용자 2대 반론) 을 지불 = ROI 음수 구간. §2.1 "강한 검색기엔 expansion 이 해롭다" 와 정합.
- **권고**: 별칭 default-off 유지하다 **제거 후보**로 격하. 단 paraphrase 잔존(4쿼리)을 §3 Step 1(BGE-M3 dense, LLM 0/색인 1회)이 닫는지 먼저 측정 → 닫으면 별칭 완전 삭제, 못 닫으면 query-side single-pass(§2.5) 소폭 보강. **어느 쪽도 청크당 LLM 0.**
산출물: `/build/dogfood/_archive/step0/`(config-off/on, kb-off, namu_golden_step0.yaml, fill_docids_step0.py), OFF run `run_019e89c524ca76a1befae126f0c77336`, `/tmp/step0_off_variants.json`.
## 5. Step 1 측정 결과 (2026-06-03) — bge-m3 dense = lateral, 업그레이드 아님
kebab(fastembed 4.9.1)은 bge-m3 dense 미지원(reranker V2M3 / BGE EN·ZH v1.5 / e5 만). candle 은 e5 전용(mean pool+prefix). → **standalone 측정**: kb-off 청크 23151개 + 변형 72쿼리를 Ollama `bge-m3:latest`(Mac GPU, /api/embed, prefix 없음)로 임베딩, exact cosine top-k recall. e5 baseline = kebab `--mode vector`(run_019e89d0...). 청크 임베딩 911s(~25/s), npz 캐시 `bge_m3_chunks.npz`.
| 변형 | e5-large dense | bge-m3 dense | Δ |
|------|------|------|------|
| en (영→한 cross-lingual) | 18/18 | 17/18 | 1 |
| ko | 18/18 | 18/18 | = |
| syn (동의어) | 11/11 | 10/11 | 1 |
| abbr (약어) | 7/7 | 6/7 | 1 |
| **para (설명형)** | 14/18 | **17/18** | **+3** |
| **recall@10 합계** | **68/72 (0.944)** | **68/72 (0.944)** | **0** |
| recall@50 합계 | 70/72 | 71/72 | +1 |
bge-m3 미스(recall@10): nn_syn(뉴럴 네트워크 모델), dp_abbr(DP 알고리즘), **stk_para**(stack 설명형 — 양쪽 공통 잔존), re_en(regular expression).
**결론(Step 1)**: bge-m3 dense 는 **맞교환** — 설명형 +3, 용어/약어/영어 3, 합계 동률. §2.3 KO-EN 연구("base bge-m3 ≈ base e5-large, 케이스별 한쪽씩")의 정량 재현. **dense 단독 임베더 교체는 정당화 안 됨**(이득 0, embedding_version cascade 재임베딩 비용만 발생). bge-m3 의 미검증 레버 = sparse+multivector hybrid(용어 손실을 sparse 가 회복하며 para 이득 유지 가능) — 단 별도/대형 작업(kebab 에 bge-m3 3-head 통합 필요).
## 6. 종합 결론 & 권고
- **별칭(doc-side expansion) = 제거 확정 후보.** Step 0: cross-lingual 은 e5 단독으로 이미 완벽, 별칭 기여는 para +2 뿐, 대가는 청크당 LLM(살아있는 KB 에 지속 불가). §2.1 문헌과 정합. **권고 = 별칭 기능 제거(또는 영구 default-off + 문서화), e5-large 유지.** → 사용자 2대 반론(총량·treadmill) 즉시 해소.
- **임베더 교체(e5→bge-m3 dense) = 보류.** Step 1: 이득 0(lateral). 추진 시에도 dense 단독 말고 **bge-m3 hybrid(sparse 포함)** 를 먼저 측정해야 의미 — 별도 조사 트랙.
- **잔존 약점(설명형 ~4쿼리, 특히 stack)**: 별칭으로도 bge-m3 로도 안 닫힘 → 별도 소형 과제(query-side single-pass §2.5 또는 bge-m3 sparse)로 분리, 우선순위 낮음.
**다음 행동**: 별칭 제거 spec → plan → 구현(gitea-pr 리뷰루프). bge-m3 hybrid 는 후속 조사 항목으로 파킹. 관련 메모리 [[project_expansion_perf]] · [[project_paraphrase_robustness]] · [[project_embedding_numa_backends]].
## 7. 딥리서치 — 별칭 대체 방법 (2026-06-03, 4-agent 병렬)
별칭이 정조준했던 목적(설명형/풀어쓴 질의 recall)을 사용자 제약(로컬, 비용∝질의 또는 색인-1회, 청크당 LLM 금지)에서 달성하는 방법을 4갈래 병렬 조사. 출처는 각 절 말미.
### 7.1 핵심 재프레이밍 (agent 1·4 수렴)
잔존 실패("마지막에 넣은 것을 먼저 꺼내는 자료구조"→스택)는 **reverse-dictionary / describe-to-term** 과제 — 설명에서 이름(용어)을 찾는, 본질적으로 **생성·추론** 문제. dense cosine(e5든 bge-m3든) 단독이 약한 이유. **함의: 빠진 표면 용어("스택/stack/LIFO")를 materialize 하는 방법 > 벡터만 평활화하는 방법(dense PRF 등).** 측정된 실패 분해(OFF): recall@10 68/72, recall@50 70/72 → **MisRanked ~2(top-50 안, top-10 밖) + Missing ~2(top-50 밖)**.
### 7.2 후보 shortlist (제약 적합)
| # | 방법 | 비용모델 | 설명형 효과 | 로컬 경로 | 통합 난이도 | 한계 |
|---|------|---------|-----------|----------|-----------|------|
| **A** | **heading/title chunk enrichment** (제목+가장 가까운 heading 을 청크 임베드 텍스트/FTS5 필드에 주입) | **per-doc, LLM 0**(heading 추출만, kebab `heading_path` 이미 존재) | terse doc 에 "손잡이" 부여 → Missing 완화. MC-indexing +16~43% recall(무학습) | 색인 1회 재임베딩 | 낮음 | 순수 paraphrase(용어가 doc 본문에도 없음)엔 부분적 |
| **B** | **임베더 교체 → `dragonkue/snowflake-arctic-embed-l-v2.0-ko`** | 색인 1회 재임베딩, LLM 0 | **e5 대비 Korean 전 벤치 우위**(Ko-StrategyQA·AutoRAGRetrieval·Belebele 설명형 + XPQA 용어 둘 다) — bge-m3 와 달리 **회귀 없는 업그레이드** | XLM-R-large·1024-dim·`query:` prefix = candle crate 거의 드롭인 | 낮음 | chunk >~1300토큰서 품질↓(긴 청크면 KURE-v1 고려) |
| **C** | **query-time rerank `bge-reranker-v2-m3`** (RRF top-50 재정렬) | **per-query**(O질의) | MisRanked 설명형의 정석 해결(cross-encoder 토큰 상호작용); 긴/설명형서 이득 최대 | **fastembed 4.9.1 `BGERerankerV2M3` 이미 보유**(ONNX int8 CPU/ M4 GPU) | 가장 낮음(신규 인덱스 0) | **Missing 못 고침**(top-50 밖). CPU FP32 느림→int8 필수. `dragonkue/...-ko` 파인튠 +3.5% |
| **D** | **term-style query expansion**(설명→핵심용어 ≤32토큰, 캐시, RRF 별 리스트 융합) | per-query, **캐시→amortized ~0** | reverse-dictionary 직접 공략(용어 materialize). pseudo-doc(HyDE) 아님 | 기존 Ollama 재사용 | 중간 | 드리프트 위험→원쿼리 융합 유지·하드질의만 게이트 |
| **E** | **bge-m3 sparse 헤드**를 RRF lexical 항으로 | 색인 1회(dense+sparse 1패스), LLM 0 | dense 의 용어/약어 손실 회복 가능(MLDR서 sparse>dense +10NDCG) | fastembed `SparseTextEmbedding`/ bge-m3 ONNX | 높음(3rd 인덱스+가중치) | FTS5 와 중복, dense 항은 Korean서 arctic-ko 보다 약함 |
### 7.3 수렴한 경고 (피할 것)
- **always-on HyDE / pseudo-document LLM**: 로컬 1~4B 에서 13s+/질의, 살아있는(long-tail) KB 서 환각·baseline 하회. → 쓰면 **term 변형 + 하드질의 게이트만**.
- **dense PRF 를 주해법으로**: 이미 recalled facet 만 강화, Missing 못 살림, 드리프트. (싼 add-on 이상 금물.)
- **학습형 QPP 를 트리거로**: 비신뢰·미일반화(2504.01101). → 대신 사용자가 이미 측정한 **near-tie Δcosine(0.003~0.005, [[project_crossscript_diagnosis]])** 를 corpus-보정 게이트로 사용.
- **SPLADE 전면 도입 / ColBERT 전체 색인**: Korean 약함·저장 폭증. (multi-vector 는 top-k rerank 로만.)
- **reranking 으로 Missing 기대**: 불가(1차에 없으면 못 살림).
### 7.4 권고 — 측정-우선 계층 (싼 것부터)
0. **무료 점검**: e5 `query:`/`passage:` prefix 정확 적용 확인(불일치 시 verbose↔terse 격차 악화). 코드 0.
1. **Layer A (heading enrichment)** — 가장 싸고 제약 완벽 적합, kebab 이 `heading_path` 보유. 재임베딩 1회 후 골든 측정. Missing 완화 기대.
2. **Layer B (arctic-ko 임베더)** — bge-m3 와 달리 회귀 없는 Korean 업그레이드 가설. candle 드롭인. A 와 직교·중첩 가능. 측정.
3. **Layer C (bge-reranker-v2-m3 top-50)** — MisRanked 해결 + **MisRanked:Missing 비율 진단 도구**(한 실험으로 둘 다). 이미 보유.
4. **Layer D (near-tie 게이트 term-expansion/triggered HyDE)** — A~C 후에도 순수 paraphrase 잔존 시에만, 하드질의에만.
각 계층은 기존 golden/variant eval 로 검증, 회귀(잘 되던 질의) 감시.
### 7.5 반대 의견 (agent 4, 정직히 기록)
단일 사용자 KB(본인이 쓴 corpus, 존재를 기억)에선 놓친 paraphrase 질의는 **용어로 재입력하면 초 단위 복구** — 멀티테넌트엔 없는 무료 fallback. 공격적 expansion 의 false-positive 비용 > 가끔의 miss 비용일 수 있음. reverse-dictionary 는 본질적으로 어려워(생성 추론) 무리한 추격은 16/18 잘 되는 걸 회귀시킬 위험([[project_ranking_deferred]]). **현실적 결론: 싼 A(+B) 로 80% 잡고, 잔존 paraphrase 꼬리는 "알려진 한계"로 문서화** — eval 이 그게 실사용의 큰 반복 비중임을 보이지 않는 한.
### 7.6 출처
- reverse-dictionary: [GEAR 2412.06654](https://arxiv.org/pdf/2412.06654) · [unified RD 2205.04602](https://arxiv.org/abs/2205.04602) · [RD probe 2402.14404](https://arxiv.org/pdf/2402.14404)
- query expansion: [CTQE 2509.02377](https://arxiv.org/abs/2509.02377) · [QE survey 2509.07794](https://arxiv.org/html/2509.07794) · [knowledge-leakage 2504.14175](https://arxiv.org/html/2504.14175v1) · [HyDE on local 1B/4B 2506.21568](https://arxiv.org/html/2506.21568v1)
- PRF: [LLM-VPRF 2504.01448](https://arxiv.org/abs/2504.01448) · [PRF pitfalls TOIS 3570724](https://dl.acm.org/doi/10.1145/3570724)
- rerank: [bge-reranker-v2-m3](https://huggingface.co/BAAI/bge-reranker-v2-m3) · [dragonkue ko 파인튠](https://huggingface.co/dragonkue/bge-reranker-v2-m3-ko) · [onnx-community ONNX](https://huggingface.co/onnx-community/bge-reranker-v2-m3-ONNX) · [FlashRank](https://github.com/PrithivirajDamodaran/FlashRank) · [Scaling Laws for Reranking 2603.04816](https://arxiv.org/pdf/2603.04816) · [SlideGar 2501.09186](https://arxiv.org/html/2501.09186v1)
- 임베더: [arctic-embed-l-v2.0-ko](https://huggingface.co/dragonkue/snowflake-arctic-embed-l-v2.0-ko) · [Arctic-Embed 2.0 2412.04506](https://arxiv.org/html/2412.04506v1) · [ko-embedding-leaderboard](https://github.com/OnAnd0n/ko-embedding-leaderboard) · [KURE](https://github.com/nlpai-lab/KURE) · [bge-m3 2402.03216](https://arxiv.org/html/2402.03216v3) · [bge-m3-onnx dense+sparse](https://github.com/yuniko-software/bge-m3-onnx)
- verbose-query/asymmetry: [Key Concepts in Verbose Queries (SIGIR'08)](https://dl.acm.org/doi/abs/10.1145/1390334.1390419) · [Collapse of Dense Retrievers 2503.05037](https://arxiv.org/pdf/2503.05037) · [MC-indexing 2404.15103](https://arxiv.org/pdf/2404.15103) · [Elastic title-into-chunk](https://www.elastic.co/search-labs/blog/multi-vector-documents) · [Adaptive-RAG 2403.14403](https://arxiv.org/pdf/2403.14403) · [QPP limits 2504.01101](https://arxiv.org/abs/2504.01101)
## 출처
- [When do Generative Query and Document Expansions Fail? (2309.08541)](https://arxiv.org/pdf/2309.08541)
- [Korean-English Cross-Lingual Retrieval data-centric study (2507.08480)](https://arxiv.org/html/2507.08480)
- [BGE M3-Embedding (2402.03216)](https://arxiv.org/html/2402.03216v3) · [HF BAAI/bge-m3](https://huggingface.co/BAAI/bge-m3) · [Ollama bge-m3](https://ollama.com/library/bge-m3)
- [Doc2Query++ (2510.09557)](https://arxiv.org/abs/2510.09557) · [Doc2Query-- When Less is More](https://www.semanticscholar.org/paper/7b2e78d4e7986914ae633fa6b30e73bad8a2b2c1)
- [CTQE — Upcycling Candidate Tokens for Query Expansion (2509.02377)](https://arxiv.org/pdf/2509.02377)
- [Query Expansion in the Age of LLMs: A Survey (2509.07794)](https://arxiv.org/abs/2509.07794)
- [Best Open-Source Embedding Models 2026 (BentoML)](https://www.bentoml.com/blog/a-guide-to-open-source-embedding-models)
- [ColBERT / late interaction storage tradeoff (Weaviate)](https://weaviate.io/blog/late-interaction-overview) · [PLAID (2205.09707)](https://arxiv.org/pdf/2205.09707)
- [MILCO — multilingual learned sparse (2510.00671)](https://www.arxiv.org/pdf/2510.00671)

View File

@@ -15,6 +15,12 @@ contract_sections:
# 색인시 doc-side expansion — 설계 spec
> **⚠️ 제거됨 (2026-06-03).** 본 spec 이 도입한 doc-side expansion(별칭) 기능은
> 2026-06-03 완전히 제거되었다. 근거: 별칭 ROI 음수(cross-lingual 은 e5-large
> 단독으로 충분, 기여는 설명형 +2 그룹뿐인데 대가가 살아있는 KB 에 지속 불가한
> 청크당 색인-시 LLM). 제거 spec: `docs/superpowers/specs/2026-06-03-remove-doc-expansion-spec.md`,
> HOTFIXES dated entry 2026-06-03. 이 문서는 역사적 contract 로 freeze 유지.
## 0. 한 줄 요약
문서를 색인할 때(ingest) 각 청크마다 로컬 LLM(gemma)에게 "이 내용을 찾을 사람이 던질 법한 다른

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,66 @@
# Spec: arctic-embed-l-v2.0 임베더 통합 (candle 우선 + Ollama provider)
**날짜**: 2026-06-03
**유형**: feature (신규 임베딩 백엔드/모델)
**근거**: `docs/superpowers/research/2026-06-03-expansion-cost-rethink-research.md` + `/build/dogfood/logs/2026-06-03-method-measurements.md`. 별칭 제거(v0.25.0) 후 설명형 recall 보강의 최선책. 측정: arctic-embed2 = recall@10 **130/132**, recall@50 **132/132**, **용어 무손실**(bge-m3 와 달리 syn/abbr/en 유지). e5 대비 +7, 색인 1회·per-query 0·LLM 0 = 살아있는 KB 최적합.
**사용자 결정**: candle 우선 + Ollama embed provider 폴백 둘 다.
## 목표
`models.embedding` 에서 arctic-embed-l-v2.0 을 선택 가능하게 한다. 두 백엔드:
1. **candle** (주): `kebab-embed-candle` 를 e5 전용 → 다중 모델로 일반화, arctic 추가. in-process pure-Rust, NUMA 안전.
2. **ollama** (폴백): 신규 Ollama embedding provider. 측정에 쓴 경로(`/api/embed`) 그대로 → 130 보장.
기본 동작 불변(기본 provider=fastembed e5). arctic 은 opt-in.
## 모델 사실 (구현 기준)
- 아키텍처: **XLM-RoBERTa-large** (candle `XLMRobertaModel` 로드 가능, e5 와 동일 계열).
- dim: **1024** (e5 와 동일 → 벡터스토어/lancedb 테이블 차원 불변, 단 테이블명은 모델명 포함).
- pooling: arctic-embed-l-v2.0 의 sentence-transformers `1_Pooling/config.json` 기준(**CLS 토큰 추정 — 반드시 config 로 확인**). e5 는 mean pooling → pooling 을 모델별 분기.
- prefix: **query 에 `query: ` 접두어, 문서는 무접두어**(e5 의 `query:`/`passage:` 와 다름). 모델별 분기.
- 정규화: L2 normalize (코사인 일관성, 기존 e5 경로와 동일).
- HF repo: `Snowflake/snowflake-arctic-embed-l-v2.0` (candle 다운로드). Ollama: `snowflake-arctic-embed2`.
## 작업 A — kebab-embed-candle 다중 모델화
- 현재 `HF_MODEL`/`SUPPORTED_MODEL` 상수(e5 하드코딩) → **모델 레지스트리**로: `{ name, hf_repo, pooling: Mean|Cls, query_prefix, doc_prefix, dim }`. e5(mean, `query: `/`passage: `) + arctic(cls, `query: `/``).
- `embed_batch` 의 pooling 단계를 모델별 분기(mean=attention-mask-weighted mean / cls=first token). 나머지(tokenize→forward→L2)는 공유.
- `model_id()` / `model_version()` 가 모델명+pooling 반영(전환 시 embedding_version cascade 트리거).
- config `models.embedding.model` 이 레지스트리에 없으면 기존처럼 명확한 에러.
- `[features] metal`/`mkl` 유지(arctic 도 동일 XLM-R 경로라 그대로 동작).
## 작업 B — Ollama embedding provider (신규)
- 신규 크레이트 `kebab-embed-ollama` (또는 kebab-embed-local 내 모듈 — **새 크레이트 권장**, 의존 분리). `Embedder` trait 구현.
- `reqwest::blocking` 으로 `POST {endpoint}/api/embed` `{model, input:[...]}``embeddings`. 배치(예: 48/req), fail-soft 재시도.
- query/doc prefix 모델별(arctic: query 에 `query: `). 결과 **L2 normalize**(Ollama raw 반환 → 일관성 위해 정규화).
- endpoint: `models.embedding.endpoint`(신규, 미설정 시 models.llm.endpoint fallback). model_version = `ollama:{model}`.
## 작업 C — config + app 배선
- `kebab-config`: `EmbeddingCfg.provider``"ollama"` 허용. 신규 `endpoint: Option<String>`(ollama 용). serde forward-compat 유지.
- `kebab-app`: embedder 선택 분기(`embedder()`)에 candle 다중모델 + ollama provider 추가. facade(`*_with_config`) 통해 config 주입(facade rule 준수).
- UI 크레이트는 kebab-app 만 touch(불변).
## 결정 사항
- **차원 1024 동일** → lancedb 테이블은 모델명 포함(`chunk_embeddings_{model}_{dim}`)이라 모델 전환 시 새 테이블, 충돌 없음.
- **embedding_version cascade**: arctic 으로 전환 = embedding_version 변경 → 전체 재임베딩 필요(breaking). 기존 e5 KB 와 혼용 불가(명확). 기본값 e5 유지라 기존 사용자 무영향.
- arctic **ko 파인튠(dragonkue)** 은 base(130) 로 충분 → 본 작업은 base. ko 는 후속 옵션(레지스트리에 추가만 하면 됨).
- A(heading enrichment) 는 측정상 arctic 에서 악화 → **미적용**.
## 검증 기준 (Acceptance)
- `cargo clippy --workspace --all-targets -j 4 -- -D warnings` 통과.
- `cargo test --workspace --no-fail-fast -j 1` 통과 — 기존 e5-candle/fastembed 테스트 회귀 0.
- **correctness 핵심**: candle arctic 으로 임베딩한 테스트 문장(예: `query: 스택 자료구조` + 문서 `후입선출 자료구조`)이 **Ollama `snowflake-arctic-embed2` 임베딩과 코사인 > 0.99 일치**(Ollama 192.168.0.47 도달 가능 — pooling/prefix 정확성 정밀 검증, 130 재현 위험 차단). live Ollama 없으면 `#[ignore]` + 수동 절차 문서화.
- ollama provider: mock 또는 live 로 dim 1024 정규화 벡터 반환 smoke.
- config provider=`candle`+arctic / `ollama`+arctic 각각 올바른 embedder 로드.
- 기본 provider=fastembed e5 동작 불변(스모크).
## 도그푸딩 (별도, Mac Metal — 본 PR acceptance 아님)
arctic 으로 namu 재임베딩 → `namu_golden_expanded.yaml` 로 recall@10 ≈ 130 재현 확인. CLAUDE.md §Dogfood trigger(embedder 모델 변경) 충족. 결과 HOTFIXES + release notes.
## 문서 동기화 (같은 PR)
- README Configuration: provider=candle/ollama + arctic 모델 + endpoint + Apple Silicon(metal) 안내.
- docs/ARCHITECTURE: 임베딩 백엔드 그래프 + 신규 크레이트(kebab-embed-ollama) + 결정 표(arctic 채택 근거 측정 링크).
- HANDOFF 1줄. tasks/HOTFIXES dated entry(측정 근거 + cascade).
- Cargo.toml workspace members += kebab-embed-ollama, version minor bump.
## 비범위
- e5 KB 자동 마이그레이션(전환 = 수동 재임베딩, cascade 규칙대로).
- dragonkue ko 파인튠(후속).
- D(query-side)·C(reranker) 통합(별도 후속, 본 PR 은 임베더만).

View File

@@ -0,0 +1,60 @@
# Spec: ingest 로그 개선 (파일명·phase·heartbeat·slowest 요약)
**날짜**: 2026-06-03
**유형**: feature (관측성/UX, additive wire)
**근거**: arctic 도그푸딩 중 Obsidian 볼트(이미지/PDF 혼재 + OCR/caption on)에서 ingest 가 중간부터 느려졌는데, **TTY 진행바가 파일명·현재 phase·모델·경과시간을 안 보여줘** "멈춘 것처럼" 보였다. 원인(비전 모델 스와핑)을 로그만으로 파악 불가. v0.24.0 상세 진행 로깅의 후속 — 느린 phase(특히 이미지 OCR/caption)와 병목 파일을 가시화한다.
## 현재 한계 (코드 근거)
- `kebab-cli/src/progress.rs:145` — TTY 에서 AssetStarted 는 **위치만 갱신, 파일명 메시지 미설정**(의도적; 비-TTY 줄에만 파일명). → 인터랙티브 실행 시 현재 파일 안 보임.
- 이미지 **OCR/caption 진행 이벤트 없음**`PdfOcrStarted/Finished`(PDF 페이지)만 존재. 이미지 OCR/caption(gemma 비전, 느림)은 무이벤트 → 진행바 정지처럼 보임(`lib.rs` apply_ocr/apply_caption 호출 주변).
- 한 asset 이 오래 걸려도 **경과시간 heartbeat 없음**(완료 후 `AssetTimings` ⏱ 한 번).
- 병목 파일을 **사후 파악할 요약 없음**.
## 목표 (사용자 결정: 1+2+3+4)
1. **파일명**을 TTY 진행바 메시지에 표시.
2. 느린 **phase(OCR/caption/embed) + 모델명** 실시간 표시.
3. 현재 asset **경과시간 heartbeat**.
4. 종료 시 **가장 오래 걸린 파일 top-N 요약**.
## 작업
### A. wire 이벤트 (additive, ingest_progress.v1)
- **신규 `AssetPhase { idx, total, phase, model }`** — asset 이 느린 phase 진입 시 emit. `phase: &str` ∈ {`"ocr"`,`"caption"`,`"embed"`}; `model: Option<String>`(그 phase 를 수행하는 모델 — OCR/caption=비전 LLM 모델 id, embed=임베더 model_id). 짧은 phase(parse/chunk/store)는 emit 안 함(노이즈 방지).
- **`AssetTimings` 확장**: `ocr_ms`, `caption_ms` 필드 추가(additive, 기본 0). 기존 parse/chunk/embed/store/expansion_ms 유지. → top-N 요약의 정확한 per-asset 총시간 계산 근거.
- `PdfOcrStarted/Finished`(기존) 유지 — PDF 페이지 단위 진행은 이미 있음.
- wire schema `docs/wire-schema/v1/ingest_progress.schema.json`: `asset_phase` kind + `phase`/`model` + `ocr_ms`/`caption_ms` 필드 문서화(additive, v1 유지).
### B. emit 지점 (kebab-app)
- `ingest_one_asset` / 이미지·미디어 경로(`apply_ocr`/`apply_caption` 호출 직전, `lib.rs:~1568/1586`): 각각 `AssetPhase{phase:"ocr"|"caption", model}` emit. 임베딩 루프 진입 시 `AssetPhase{phase:"embed", model:embedder.model_id}` emit(텍스트 asset 도 적용).
- OCR/caption 소요를 측정해 `AssetTimings.ocr_ms`/`caption_ms` 채움.
### C. CLI 렌더 (kebab-cli/src/progress.rs)
1. **파일명**: AssetStarted TTY 핸들러에서 `bar.set_message(<path 축약>)`(현재 위치-only 주석 제거). 비-TTY 줄은 그대로.
2. **phase+모델**: AssetPhase 수신 시 `bar.set_message("{path} · {phase}({model})…")`.
3. **heartbeat**: AssetStarted 에서 현재 asset 시작 시각 기록 + steady-tick(예: 1s)으로 메시지 끝에 `(Ns)` 경과 갱신. asset 전환/완료 시 리셋.
4. **slowest 요약**: AssetStarted(idx→path) + AssetTimings(idx→총ms=parse+chunk+embed+store+ocr+caption) 를 누적, `Completed` 수신 시 stderr 에 `⏱ 최장 소요 top-N`(기본 N=5) 표 출력. 비-TTY/quiet 에서도 요약은 출력(유용), `--json` 모드는 미출력(ndjson 오염 방지).
### 결정 사항
- 모두 **additive wire**`ingest_progress.v1` 유지(major bump 없음). 신규 소비자는 `asset_phase` 부재 허용.
- AssetPhase 는 **emit 스로틀 불필요**(asset·phase 당 1회, 빈도 낮음). PDF 페이지 OCR 은 기존 PdfOcrStarted 가 담당(페이지 많으면 그쪽 스로틀은 별도 — 본 spec 비범위).
- top-N 의 N: 상수 5(후속에 config 화 가능, 본 spec 비범위).
- `--quiet` 시 진행바·phase 메시지는 억제하되 **slowest 요약은 출력**(짧고 유용). `--json` 은 전부 ndjson 으로만.
## 검증 기준
- clippy 0 / 전체 test 통과(기존 진행 렌더 테스트 갱신 + 신규 이벤트 직렬화 테스트).
- TTY 스모크: 이미지/PDF 포함 폴더 ingest 시 진행바에 **파일명 + OCR/caption/embed phase + 모델 + 경과초** 표시, 종료 시 **top-N 요약**.
- 비-TTY: 기존 줄 로그 유지 + 종료 요약.
- `--json`: `asset_phase`/확장 `asset_timings` ndjson 출력, 사람용 텍스트 미혼입.
- wire schema 문서 동기화 + verbatim 일치(CI diff-check 있으면).
## 도그푸딩 (별도)
사용자 Obsidian 볼트(이미지/PDF + OCR on)로 재현 — 느린 구간에서 어떤 파일·phase·모델인지 즉시 보이는지, 종료 요약이 병목 파일을 짚는지 확인. HOTFIXES + release notes.
## 문서 동기화 (같은 PR)
- `docs/wire-schema/v1/ingest_progress.schema.json` (asset_phase, ocr_ms/caption_ms).
- README(진행 표시 설명 있으면 갱신, 명령표 영향 없음), HANDOFF 1줄, tasks/HOTFIXES dated entry, Cargo.toml version minor bump.
## 비범위
- PDF 페이지 OCR 진행 스로틀/요약(기존 이벤트 유지).
- 모델 스와핑 자체 해결(그건 Ollama 설정/OCR off — 본 작업은 가시화만).
- top-N 의 config 화.

View File

@@ -0,0 +1,55 @@
# Spec: doc-side expansion(별칭) 기능 제거
**날짜**: 2026-06-03
**유형**: 기능 제거 (refactor/removal)
**근거**: `docs/superpowers/research/2026-06-03-expansion-cost-rethink-research.md` (Step 0/1 측정 + 딥리서치). 별칭 ROI 음수: cross-lingual 은 e5-large 단독으로 이미 완벽, 별칭 기여는 설명형 +2 그룹뿐인데 대가가 청크당 색인-시 LLM(살아있는 KB 에 지속 불가). 문헌(arXiv 2309.08541)도 "강한 검색기엔 expansion 해롭다" 확인.
**design contract 영향**: design §(Phase 2 doc-side expansion) 에서 도입된 기능 제거 → `tasks/HOTFIXES.md` dated entry + 원 spec(`docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md`)의 Risks/notes 에 제거 cross-link 1줄. design 본문은 별도 spec PR 이 아닌 본 PR 에서 "deprecated/removed" 주석만.
## 목표
색인-시 청크당 LLM 별칭 생성 + 별칭 검색 경로를 **완전히 제거**한다. 기본 동작 불변(별칭은 이미 default-off)이라 일반 사용자 체감 0. 코드/스키마/wire 표면을 정리해 유지보수 부담을 없앤다.
## 제거 대상 (REMOVE)
- `crates/kebab-app/src/expansion.rs` — 모듈 전체 (ExpansionGenerator, is_nav_boilerplate, parse_aliases, strip_list_marker).
- `crates/kebab-app/src/lib.rs``pub mod expansion;`, ingest_one_asset 의 expansion 루프(별칭 생성·캐시 조회/저장·`alias_version_key`·`embed_aliases` 임베딩·alias sentinel 벡터 `{orig}#alias#N`), 관련 카운터(`alias_cache_hit/miss`, `alias_touch_keys`).
- `crates/kebab-config/src/lib.rs``ExpansionCfg` 구조체 + `IngestCfg.expansion` 필드 + 기본값.
- `crates/kebab-config/src/migrate.rs``[ingest.expansion]` 섹션 주석/마이그레이션 처리.
- `crates/kebab-core/src/chunk.rs``Chunk.aliases: Option<String>` 필드 (+ 관련 serde default 테스트). **주의: `crates/kebab-core/src/metadata.rs` 의 `Metadata.aliases: Vec<String>` 는 문서 메타데이터(§3.6)로 무관 — 유지.**
- `crates/kebab-search/src/lexical.rs``run_alias_query`, `merge_body_alias`, alias FTS 분기(`build_match_string_for_column(.., "aliases")`).
- `crates/kebab-store-sqlite``chunk_aliases_fts` 테이블 + 트리거 + `chunks.aliases` 컬럼: **신규 forward 마이그레이션(V0XX)으로 DROP**. INSERT/SELECT 경로(`documents.rs` 의 aliases 컬럼 쓰기/읽기) 제거.
- `crates/kebab-app/src/ingest_progress.rs``IngestEvent::ExpansionProgress` variant (+ 직렬화 테스트). **`AssetChunked`/`AssetTimings` 는 유지**(별칭과 무관, 청킹/타이밍 가시성).
- `crates/kebab-cli/src/progress.rs` + `crates/kebab-tui/src/ingest_progress.rs` — ExpansionProgress 렌더(`별칭 확장 N/chunks`).
- `crates/kebab-tui/src/inspect.rs` — chunk 별칭 표시(있으면).
- derivation_cache 의 `"alias"` kind: 쓰기 경로 제거. 기존 행은 무해(읽지 않음), `kebab reset` 시 정리. kind enum 에서 alias 제거는 선택(read 호환 위해 남겨도 무방).
## 유지 (KEEP — 제거 금지)
- `Metadata.aliases` (문서 메타데이터, metadata.rs).
- `AssetChunked`, `AssetTimings` wire 이벤트 + 렌더.
- derivation_cache 의 `embedding` kind (V012 임베딩 캐시 — 별칭과 독립, 성능 핵심).
- `chunks_fts`(본문 FTS) 전부.
- `Chunk` 구조체를 생성하는 모든 곳(kebab-chunk/*, kebab-parse-*/*): `aliases: None` 리터럴은 필드 제거에 맞춰 **삭제만**(기능 변경 아님).
## 결정 사항
- **마이그레이션**: 신규 forward-only 마이그레이션으로 `chunk_aliases_fts`(+ 트리거)와 `chunks.aliases` 컬럼 DROP. SQLite 3.35+ `DROP COLUMN` 사용(번들 sqlite 확인). down 마이그레이션 불필요(refinery forward-only 관행 따름). 기존 KB: 별칭 default-off 라 대부분 빈 데이터 → 손실 없음. corpus_revision cascade 불필요(별칭은 검색 보조였을 뿐, 본문/임베딩 불변).
- **wire schema**: `ingest_progress.v1` 에서 `expansion_progress` kind 제거. v0.24.0 에서 막 추가된 additive variant 라 소비자(agent/CLI)는 부재 허용 → major bump 불요. `docs/wire-schema/v1/ingest_progress.schema.json` 에서 해당 kind 정의 삭제 + 주석.
- **버전**: workspace `version` patch/minor bump(별칭 제거 = surface 정리, breaking schema 아님 — 단 chunk_aliases_fts DROP 마이그레이션 포함이라 이전 binary 가 새 DB 열 때 영향 없음(컬럼 제거는 구 binary 의 SELECT 깨뜨릴 수 있으나 단일 사용자·forward-only 전제). minor bump 권장.
- **config**: `[ingest.expansion]` 제거 후 기존 사용자 config.toml 에 해당 섹션이 있어도 serde forward-compat(unknown field ignore)로 무해. `kebab config migrate` 가 섹션 제거하도록 갱신(선택).
## 문서 동기화 (같은 PR)
- `tasks/HOTFIXES.md`: dated entry — 제거 근거(연구 링크) + 마이그레이션 + wire 변경.
- `docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md`: Risks/notes 에 "2026-06-03 제거됨, 본 spec 참조" 1줄.
- `README.md` / `HANDOFF.md`: 별칭이 README 에 노출돼 있으면 제거(default-off 라 노출 없을 가능성). HANDOFF 한 줄.
- `docs/wire-schema/v1/ingest_progress.schema.json`: expansion_progress 제거.
- design 본문(frozen contract)에 Phase 2 별칭 기술이 있으면 "removed (HOTFIXES 2026-06-03)" 주석.
## 검증 기준 (Acceptance)
- `cargo clippy --workspace --all-targets -j 4 -- -D warnings` 통과.
- `cargo test --workspace --no-fail-fast -j 1` 통과 — 별칭 전용 테스트(`tests/chunk_aliases.rs`, expansion.rs 테스트, lexical alias 테스트)는 삭제, 그 외 회귀 0.
- 신규 마이그레이션 적용된 fresh KB 에 `chunk_aliases_fts`/`chunks.aliases` 부재 확인(`.schema`).
- `kebab ingest`(별칭 config 없이) 정상 — AssetChunked/AssetTimings 진행 표시 유지, expansion_progress 미출력.
- 기존 별칭 데이터가 있던 KB 도 마이그레이션 후 search/ask 정상(별칭 벡터는 무시/정리).
- grep 잔존 0: `expansion::|ExpansionCfg|chunk_aliases|run_alias_query|merge_body_alias|ExpansionProgress|embed_aliases|is_nav_boilerplate|Chunk.*aliases`.
## 비범위 (out of scope)
- 별칭 대체 방법(heading enrichment / arctic-ko 임베더 / reranker / query-side) — 후속 별 작업(연구문서 §7 Layer A~D).
- `Metadata.aliases`(문서 메타) 변경.
- derivation_cache GC wiring.

View File

@@ -14,6 +14,9 @@
"scan_completed",
"asset_started",
"asset_finished",
"asset_chunked",
"asset_phase",
"asset_timings",
"embed_batch_started",
"embed_batch_finished",
"pdf_ocr_started",
@@ -33,7 +36,16 @@
"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 (v0.24.0): chunk count produced for this asset." },
"phase": { "type": "string", "enum": ["ocr", "caption", "embed"], "description": "asset_phase (v0.26.1): the slow internal phase the asset just entered. Short phases (parse/chunk/store) are not emitted." },
"model": { "type": ["string", "null"], "description": "asset_phase (v0.26.1): model performing the phase — vision LLM id for ocr/caption, embedder model_id for embed. null when the phase runs without a configured model." },
"parse_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.24.0, additive): parse phase wall-clock (ms). Emitted by markdown / image / PDF paths." },
"chunk_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.24.0, additive): chunk phase wall-clock (ms). Emitted by markdown / image / PDF paths." },
"expansion_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.24.0, additive): retained for wire compatibility but always 0 — doc-side expansion was removed (HOTFIXES 2026-06-03)." },
"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." },
"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." },
"ocr_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.26.1, additive, default 0): image/PDF OCR phase wall-clock (ms). 0 on the markdown path (no OCR)." },
"caption_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.26.1, additive, default 0): image caption phase wall-clock (ms). 0 on markdown / PDF paths." },
"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,25 @@
-- V013__drop_chunk_aliases.sql — doc-side expansion(별칭) 채널 제거.
--
-- 근거: docs/superpowers/specs/2026-06-03-remove-doc-expansion-spec.md +
-- tasks/HOTFIXES.md 2026-06-03 entry. V010 이 도입한 chunks.aliases 컬럼 +
-- chunk_aliases_fts FTS5 테이블 + sync trigger 를 forward-only 로 DROP 한다.
-- V010 자체는 과거 마이그레이션 freeze 규칙에 따라 무수정 — 본 마이그레이션이
-- 덮어서 제거한다.
--
-- 별칭은 default-off 였으므로 대부분의 KB 는 빈 데이터 → 손실 없음. 본문/임베딩은
-- 불변이라 corpus_revision cascade 불필요(spec §결정 사항) — in-process LRU 캐시는
-- 프로세스별 휘발성이고 마이그레이션은 query 이전(store open 시)에 돌므로 bump 가
-- 무의미. 따라서 본 마이그레이션은 순수 구조 변경(DROP)만 수행한다.
-- body chunks_fts (chunks_ai/ad/au) 와 그 컬럼은 aliases 를 참조하지 않으므로
-- DROP COLUMN 의 영향 없음. 번들 SQLite 3.45+ 가 ALTER TABLE DROP COLUMN 지원.
-- 1. aliases 를 참조하는 trigger 를 먼저 제거 (DROP COLUMN 전제).
DROP TRIGGER IF EXISTS chunk_aliases_ai;
DROP TRIGGER IF EXISTS chunk_aliases_ad;
DROP TRIGGER IF EXISTS chunk_aliases_au;
-- 2. 별칭 전용 FTS5 테이블 제거 (shadow 테이블 chunk_aliases_fts_* 함께 정리됨).
DROP TABLE IF EXISTS chunk_aliases_fts;
-- 3. 본문 chunks 의 별칭 컬럼 제거.
ALTER TABLE chunks DROP COLUMN aliases;

View File

@@ -14,6 +14,311 @@ 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-03 — ingest 진행 로그 개선: 파일명·phase·heartbeat·slowest 요약 (v0.26.1)
**무엇을 왜 추가했나.** arctic 도그푸딩 중 이미지/PDF 혼재 + OCR/caption on 볼트에서
ingest 가 중간부터 느려졌는데, TTY 진행바가 **파일명·현재 phase·모델·경과시간**을 안 보여
"멈춘 것처럼" 보였다. 원인(비전 모델 스와핑)을 진행 표시만으로 파악 불가. v0.24.0 상세
진행 로깅의 후속으로 느린 phase 와 병목 파일을 가시화했다. spec/plan:
`docs/superpowers/specs/2026-06-03-ingest-log-improve-spec.md` / `…/plans/2026-06-03-ingest-log-improve-plan.md`.
**무엇이 바뀌었나 (additive, `ingest_progress.v1` 유지 — major bump 없음).**
- 신규 wire 이벤트 `asset_phase { idx, total, phase, model }` — asset 이 느린 phase
(`ocr` / `caption` / `embed`) 진입 시 1회 emit. `model` 은 그 phase 의 모델 id
(ocr/caption = 비전 LLM, embed = 임베더 model_id), 없으면 `null`. 짧은 phase
(parse/chunk/store) 는 노이즈 방지로 미emit.
- `asset_timings``ocr_ms` / `caption_ms` 필드 추가 (serde `default` 0 → 구 소비자
호환). 이미지·PDF 경로도 이제 `asset_timings` 를 emit (이전엔 markdown 만) — slowest
요약이 비전-모델 병목을 정확히 집계.
- CLI 렌더(`kebab-cli/src/progress.rs`): AssetStarted 시 진행바 메시지에 파일명(긴 path 는
말미 축약), AssetPhase 시 `{path} · {phase}({model})…`, steady-tick 1s 커스텀 키로
경과초 `(Ns)` 라이브 갱신, `Completed` 시 stderr 에 `⏱ 최장 소요 top-5` 표.
`--quiet` 여도 요약은 출력, `--json` 은 ndjson 만(사람텍스트 미혼입).
**emit 지점.** `kebab-app/src/lib.rs` — 이미지 경로 `apply_ocr`/`apply_caption` 직전
+ ocr/caption 시간 측정, markdown/이미지/PDF 임베딩 루프 직전 `embed` phase, 각 경로
`asset_timings` 에 측정값 채움. PDF `ocr_ms` 는 기존 page-OCR 총합 재사용.
**알려진 한계.** code asset 경로는 진행 이벤트(AssetChunked/Timings) 무emit 이라 slowest
요약에 미포함(기존 동작 유지, 비범위). top-N 의 N=5 상수(config 화 비범위). PDF 페이지
OCR 진행은 기존 `pdf_ocr_started/finished` 가 담당(본 작업 비범위).
**도그푸딩 (별도).** 사용자 Obsidian 볼트(이미지/PDF + OCR on) 재현 — 느린 구간의
파일·phase·모델 즉시 가시 + 종료 요약이 병목 파일을 짚는지. release notes + 본 entry 갱신.
## 2026-06-03 — arctic-embed-l-v2.0 임베더 통합 (candle + Ollama) (v0.26.0)
**무엇을 왜 추가했나.** 별칭(doc-side expansion) 제거(v0.25.0) 후 설명형 query 의
recall 보강책으로 `snowflake-arctic-embed-l-v2.0` 임베더를 두 백엔드로 통합했다.
근거는 방법별 측정(`/build/dogfood/logs/2026-06-03-method-measurements.md`):
arctic = recall@10 **130/132**, recall@50 **132/132**, **용어 무손실**(syn/abbr/en
유지). e5-large 대비 +7, 색인 1회·per-query 0·LLM 0 = 살아있는 KB 에 지속 가능.
별칭이 청크당 색인-시 LLM(나무위키 18문서 cold 2.5h)을 요구한 것과 대조.
**무엇을 건드렸나.**
- `kebab-embed-candle`: e5 하드코딩(`HF_MODEL`/`SUPPORTED_MODEL`/mean/`query:`+`passage:`)을
**모델 레지스트리**(`MODEL_REGISTRY`: `EmbedModelSpec { name, hf_repo, pooling, query_prefix, doc_prefix, dim, version_tag }`)로
일반화. e5(mean, `query:`/`passage:`) + arctic(**CLS**, `query:`/무접두어). pooling
은 모델별 분기(mean=attention-mask-weighted / CLS=`hidden[:,0,:]`), tokenize/forward/L2
공유. arctic pooling=CLS 는 HF `1_Pooling/config.json`(`pooling_mode_cls_token:true`)로
확인. `model_version` 은 arctic 일 때 `+arctic-cls` 태그(switch 시 embedding_version
cascade 트리거); e5 는 fastembed-e5 와의 호환(NUMA 드롭인) 위해 plain `config.version` 유지.
- `kebab-embed-ollama` (신규 크레이트): `Embedder` 구현, `reqwest::blocking` POST
`/api/embed` `{model, input:[...]}``embeddings`. batch 48 + fail-soft 재시도 3,
결과 **L2 정규화**(Ollama raw 반환), dim 검증, query/doc prefix 모델 태그로 추론
(`arctic-embed``query:`/무접두어, `e5``query:`/`passage:`). `model_version=ollama:{model}`.
endpoint = `models.embedding.endpoint` ?? `models.llm.endpoint`.
- `kebab-config`: `EmbeddingModelCfg.endpoint: Option<String>`(serde default, ollama용) +
`provider` 문서에 `ollama` 추가 + env `KEBAB_MODELS_EMBEDDING_ENDPOINT`.
- `kebab-app::embedder()`: provider match 에 `ollama` 분기 추가(facade 경유).
- workspace member += `kebab-embed-ollama`, version 0.25.0 → **0.26.0**(minor).
**correctness 게이트.** candle arctic 임베딩이 측정에 쓴 Ollama `snowflake-arctic-embed2`
임베딩과 일치해야 pooling/prefix 정확성(=recall 130 재현)이 보장된다. 검증:
`kebab-embed-candle/tests/arctic_ollama_parity.rs`(`#[ignore]`, live Ollama 의존) 가
candle arctic vs 우리 Ollama 어댑터로 같은 문장(설명형/약어/영문 포함, doc+query
양 경로)을 임베딩해 per-sentence **코사인 > 0.99** 를 assert. 수동 실행 결과(코사인값)는
릴리스 전 본 entry 에 기록.
**수동 검증 결과** (2026-06-03 worker 실측, Ollama @192.168.0.47:11434
`snowflake-arctic-embed2`): 8문장 × (doc+query) 16벡터 per-sentence 코사인
**0.999984 ~ 0.999995**, `cosine_min = 0.999984` (게이트 0.99 대비 대폭 상회).
설명형("후입선출 방식으로 동작하는 자료구조")·약어("SVM 은 support vector machine")·
영문·한글 모두 일치. → candle arctic 의 CLS pooling + `query: ` prefix 가 Ollama 측정
경로와 정확히 동일 = recall@10 130 재현 보장. Ollama raw 도 이미 L2-정규화(norm 1.0)라
어댑터의 L2 정규화는 idempotent no-op. 로그: `/build/dogfood/logs/arctic-parity.log`,
요약: `/tmp/arctic-result.md`.
**종단 도그푸딩** (2026-06-03, kebab **v0.26.0** 바이너리, provider=ollama
`snowflake-arctic-embed2` @192.168.0.47). Python 하니스가 아닌 **실제 kebab
ingest→store→search 파이프라인**으로 검증: namu 코퍼스 997 docs / 23151 chunks
fresh 색인(`config-arctic.toml`, kb-arctic, errors=0) → 확장 골든
(`namu_golden_expanded.yaml`, 24그룹/132변형) hybrid k=50 eval
(run_019e8c5788a374e098d85d84eb900e23). 결과: **recall@10 130/132 (0.985)**,
**recall@50 132/132 (완벽)**, fully_consistent **22/24**(baseline e5 19/24 대비 +3),
MisRanked 2 / Missing 0, mean_spread@10 0.083(e5 0.208 대비 대폭 개선). 종류별
recall@10: abbr 7/7 · en 24/24 · ko 24/24 · syn 17/17 · para 23/24 · para2 18/18 ·
para3 17/18 = **용어 무손실 + 설명형 거의 완벽**. e5 baseline(123/132) + 측정 하니스
arctic(130) 와 종단 일치 — 측정→구현→실파이프라인 재현 삼중 확인. 잔존 MisRanked
2개는 D(query-side) 후속 보강 대상. 결과 `/tmp/arctic_e2e_variants.json`,
baseline 비교 `/build/dogfood/logs/2026-06-03-new-baseline-v025.md`.
**호환성.** 기본 provider=fastembed e5 동작/벡터 불변(arctic 은 opt-in). dim 1024
동일이나 LanceDB 테이블명에 모델명 포함(`chunk_embeddings_{model}_{dim}`)이라 충돌
없음. e5 → arctic 전환 = `embedding_version` cascade(모델별 벡터 상이) → **재색인 필요**
(기존 e5 KB 와 혼용 불가, 명확). A(heading enrichment)는 측정상 arctic 에서 악화 →
미적용. spec: `docs/superpowers/specs/2026-06-03-arctic-embedder-spec.md`, plan: 동일
디렉토리 `2026-06-03-arctic-embedder-plan.md`.
## 2026-06-03 — doc-side expansion(별칭) 기능 완전 제거 (v0.25.0)
**무엇을 왜 제거했나.** v0.21.0 (PR #195/#196) 에서 도입한 색인-시 청크당 LLM
별칭 생성 + 별칭 검색 채널을 **완전히 제거**했다. 근거는 비용 재고 연구
(`docs/superpowers/research/2026-06-03-expansion-cost-rethink-research.md`, Step 0/1
측정 + 딥리서치): 별칭 ROI 가 음수였다 — cross-lingual 검색은 e5-large 임베더
단독으로 이미 충분하고, 별칭의 실측 기여는 설명형 query +2 그룹(14/18→16/18)뿐인데,
그 대가가 **청크당 색인-시 LLM 호출**(살아있는 KB 에 지속 불가능한 비용; 나무위키
18문서 cold 2.5h)이었다. 문헌(arXiv 2309.08541)도 "강한 검색기에는 query/doc
expansion 이 오히려 해롭다"를 확인. 별칭은 default-off 였으므로 일반 사용자 체감 0.
**무엇이 제거됐나 (코드/스키마/wire).**
- 코드: `kebab-app/src/expansion.rs` 모듈 전체, `ingest_one_asset` 의 별칭 생성·캐시·
임베딩 루프, `Chunk.aliases` 필드, `kebab-config``IngestExpansionCfg`
(`[ingest.expansion]` 섹션 + `KEBAB_INGEST_EXPANSION_*` env), `kebab-search`
`run_alias_query`/`merge_body_alias` alias lexical arm, alias sentinel 벡터 upsert
경로 + `alias_sentinel_ids_to_delete`.
- wire: `ingest_progress.v1``expansion_progress` kind 제거 (v0.24.0 에서 막
추가된 additive variant 라 소비자는 부재 허용 → major bump 불요).
`asset_timings.expansion_ms` 필드는 **wire 호환 위해 유지하되 값 항상 0**.
- 스키마: 신규 forward-only 마이그레이션 **V013**`chunk_aliases_fts`(+ 트리거)
`chunks.aliases` 컬럼을 DROP. 과거 V010 은 freeze 무수정. 별칭 default-off 라
기존 KB 대부분 빈 데이터 → 손실 없음. corpus_revision bump (검색 캐시 무효화).
**무엇을 유지했나 (제거 금지).** `Metadata.aliases`(문서 메타데이터 Vec, expansion
과 무관), `AssetChunked`/`AssetTimings` wire 이벤트, derivation_cache 의 `embedding`
kind(V012 임베딩 캐시 — 성능 핵심), `chunks_fts`(본문 FTS) 전부, `ALIAS_SUFFIX`/
`strip_alias_suffix`(검색 시 기존 KB 의 잔존 별칭 벡터를 본문 chunk 로 graceful 매핑하는
read-side 하위호환).
**기존 KB 영향.** 별칭 벡터가 있던 KB 도 마이그레이션 후 search/ask 정상 — 잔존 별칭
sentinel 벡터(`{chunk}#alias#N`)는 검색 시 `strip_alias_suffix` 로 본문 chunk 에
매핑되거나 `kebab reset` 으로 정리된다. 본문/임베딩 불변이라 재색인 불요.
**spec/plan.** `docs/superpowers/specs/2026-06-03-remove-doc-expansion-spec.md` +
`docs/superpowers/plans/2026-06-03-remove-doc-expansion-plan.md`. 원 도입 spec
`2026-05-30-doc-side-expansion-design.md` 에 제거 banner 추가.
## 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.