Compare commits

..

26 Commits

Author SHA1 Message Date
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
65 changed files with 1931 additions and 1329 deletions

63
Cargo.lock generated
View File

@@ -4724,7 +4724,7 @@ dependencies = [
[[package]]
name = "kebab-app"
version = "0.23.0"
version = "0.26.0"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -4739,6 +4739,7 @@ dependencies = [
"kebab-embed",
"kebab-embed-candle",
"kebab-embed-local",
"kebab-embed-ollama",
"kebab-llm",
"kebab-llm-local",
"kebab-nli",
@@ -4771,7 +4772,7 @@ dependencies = [
[[package]]
name = "kebab-chunk"
version = "0.23.0"
version = "0.26.0"
dependencies = [
"anyhow",
"blake3",
@@ -4789,7 +4790,7 @@ dependencies = [
[[package]]
name = "kebab-cli"
version = "0.23.0"
version = "0.26.0"
dependencies = [
"anyhow",
"clap",
@@ -4810,7 +4811,7 @@ dependencies = [
[[package]]
name = "kebab-config"
version = "0.23.0"
version = "0.26.0"
dependencies = [
"anyhow",
"dirs 5.0.1",
@@ -4826,7 +4827,7 @@ dependencies = [
[[package]]
name = "kebab-core"
version = "0.23.0"
version = "0.26.0"
dependencies = [
"anyhow",
"blake3",
@@ -4840,7 +4841,7 @@ dependencies = [
[[package]]
name = "kebab-embed"
version = "0.23.0"
version = "0.26.0"
dependencies = [
"anyhow",
"blake3",
@@ -4854,7 +4855,7 @@ dependencies = [
[[package]]
name = "kebab-embed-candle"
version = "0.23.0"
version = "0.26.0"
dependencies = [
"anyhow",
"candle-core",
@@ -4864,6 +4865,7 @@ dependencies = [
"kebab-config",
"kebab-core",
"kebab-embed-local",
"kebab-embed-ollama",
"rayon",
"serde_json",
"tempfile",
@@ -4873,7 +4875,7 @@ dependencies = [
[[package]]
name = "kebab-embed-local"
version = "0.23.0"
version = "0.26.0"
dependencies = [
"anyhow",
"fastembed",
@@ -4884,9 +4886,24 @@ dependencies = [
"tracing",
]
[[package]]
name = "kebab-embed-ollama"
version = "0.26.0"
dependencies = [
"anyhow",
"kebab-config",
"kebab-core",
"reqwest 0.12.28",
"serde",
"serde_json",
"tokio",
"tracing",
"wiremock",
]
[[package]]
name = "kebab-eval"
version = "0.23.0"
version = "0.26.0"
dependencies = [
"anyhow",
"kebab-app",
@@ -4905,7 +4922,7 @@ dependencies = [
[[package]]
name = "kebab-llm"
version = "0.23.0"
version = "0.26.0"
dependencies = [
"anyhow",
"kebab-core",
@@ -4914,7 +4931,7 @@ dependencies = [
[[package]]
name = "kebab-llm-local"
version = "0.23.0"
version = "0.26.0"
dependencies = [
"anyhow",
"kebab-config",
@@ -4931,7 +4948,7 @@ dependencies = [
[[package]]
name = "kebab-mcp"
version = "0.23.0"
version = "0.26.0"
dependencies = [
"anyhow",
"kebab-app",
@@ -4949,7 +4966,7 @@ dependencies = [
[[package]]
name = "kebab-nli"
version = "0.23.0"
version = "0.26.0"
dependencies = [
"anyhow",
"hf-hub",
@@ -4964,7 +4981,7 @@ dependencies = [
[[package]]
name = "kebab-parse-code"
version = "0.23.0"
version = "0.26.0"
dependencies = [
"anyhow",
"gix",
@@ -4987,7 +5004,7 @@ dependencies = [
[[package]]
name = "kebab-parse-image"
version = "0.23.0"
version = "0.26.0"
dependencies = [
"ab_glyph",
"anyhow",
@@ -5011,7 +5028,7 @@ dependencies = [
[[package]]
name = "kebab-parse-md"
version = "0.23.0"
version = "0.26.0"
dependencies = [
"anyhow",
"kebab-core",
@@ -5028,7 +5045,7 @@ dependencies = [
[[package]]
name = "kebab-parse-pdf"
version = "0.23.0"
version = "0.26.0"
dependencies = [
"anyhow",
"blake3",
@@ -5043,7 +5060,7 @@ dependencies = [
[[package]]
name = "kebab-rag"
version = "0.23.0"
version = "0.26.0"
dependencies = [
"anyhow",
"blake3",
@@ -5065,7 +5082,7 @@ dependencies = [
[[package]]
name = "kebab-search"
version = "0.23.0"
version = "0.26.0"
dependencies = [
"anyhow",
"globset",
@@ -5084,7 +5101,7 @@ dependencies = [
[[package]]
name = "kebab-source-fs"
version = "0.23.0"
version = "0.26.0"
dependencies = [
"anyhow",
"blake3",
@@ -5102,7 +5119,7 @@ dependencies = [
[[package]]
name = "kebab-store-sqlite"
version = "0.23.0"
version = "0.26.0"
dependencies = [
"anyhow",
"blake3",
@@ -5122,7 +5139,7 @@ dependencies = [
[[package]]
name = "kebab-store-vector"
version = "0.23.0"
version = "0.26.0"
dependencies = [
"anyhow",
"arrow",
@@ -5146,7 +5163,7 @@ dependencies = [
[[package]]
name = "kebab-tui"
version = "0.23.0"
version = "0.26.0"
dependencies = [
"anyhow",
"crossterm",

View File

@@ -12,6 +12,7 @@ members = [
"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",
@@ -31,7 +32,7 @@ edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
repository = "https://github.com/altair823/kebab"
version = "0.23.0" # v0.23.0 — candle Metal(Apple Silicon GPU) opt-in build feature (`--features embed_metal`): M-series 맥에서 e5-large 임베딩을 GPU 로 → 대용량 ingest 가속. macOS 전용, 벡터는 CPU candle 과 호환. default(비-metal) 동작 불변. — CLAUDE.md §Release 도그푸딩 트리거
version = "0.26.0" # v0.26.0 — arctic-embed-l-v2.0 임베더 통합: kebab-embed-candle 다중 모델 레지스트리(e5 mean + arctic CLS, 모델별 pooling/prefix 분기) + 신규 kebab-embed-ollama 크레이트(provider="ollama", POST /api/embed, L2 정규화, batch+fail-soft). config models.embedding.provider 에 "ollama" 추가 + endpoint: Option<String>. 기본 동작 불변(provider=fastembed e5), arctic 은 opt-in, embedding_version cascade(arctic-cls / ollama:{model} 태그). — 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

@@ -35,6 +35,8 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
머지 후 발견된 모든 deviation / hotfix 의 dated 로그는 [tasks/HOTFIXES.md](tasks/HOTFIXES.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별 소요시간(parse/chunk/embed/store)을 표시 (`--json``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` |
@@ -98,18 +111,46 @@ root = "~/KnowledgeBase" # 색인할 폴더. 절대 / tilde / env / 상대 경
[models.embedding]
provider = "fastembed" # "fastembed"(기본, onnxruntime) / "candle"(순수 Rust)
# / "none"(lexical-only). candle 는 같은 모델·같은 벡터를
# 순수 Rust 로 돌려 NUMA 서버의 onnxruntime 48-스레드
# double-free 를 피하는 opt-in 백엔드 (재색인 불필요).
# / "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 를 켠다:
@@ -133,11 +174,6 @@ 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).
@@ -146,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

@@ -19,6 +19,7 @@ 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" }

View File

@@ -45,6 +45,7 @@ use kebab_core::{
};
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,
@@ -834,11 +835,13 @@ impl App {
if let Some(e) = self.embedder.get() {
return Ok(Some(e.clone()));
}
// Provider branch (Track 1 spec §3). `embeddings_disabled()` above
// already handled `"none"`; here we route the live providers.
// `fastembed`/`onnx`/(empty) keep the default onnxruntime path
// (vectors unchanged — `embedding_version` is preserved); `candle`
// selects the pure-Rust NUMA-safe backend.
// 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(
@@ -847,10 +850,13 @@ impl App {
"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`, or `none` (lexical-only)"
`fastembed` (default), `candle`, `ollama`, or `none` (lexical-only)"
));
}
};

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,29 @@ pub enum IngestEvent {
result: IngestItemKind,
chunks: u32,
},
/// v0.24.0 (additive): emitted right after an asset is chunked, before
/// expansion / embed / store. Surfaces "this document is N chunks"
/// immediately so a single large document no longer looks frozen at
/// `idx/total` while its per-chunk phases churn. `chunks` is the chunk
/// count for asset `idx`.
AssetChunked { idx: u32, total: u32, chunks: u32 },
/// v0.24.0 (additive): per-phase wall-clock (milliseconds) for asset
/// `idx`, emitted once the asset's markdown pipeline finishes. Lets a
/// user see *where* the time went (parse / chunk / embed / store)
/// without parsing logs. Only the markdown path emits this; the
/// image / PDF paths surface `AssetChunked` but skip phase timing (their
/// phase shapes differ — OCR / caption). `expansion_ms` is retained for
/// wire compatibility but is always 0 since doc-side expansion was
/// removed (HOTFIXES 2026-06-03).
AssetTimings {
idx: u32,
total: u32,
parse_ms: u64,
chunk_ms: u64,
expansion_ms: u64,
embed_ms: u64,
store_ms: u64,
},
/// Run finished normally. `counts` is the final aggregate.
Completed { counts: AggregateCounts },
/// Run finished by user cancellation. `counts` is the partial
@@ -199,6 +230,59 @@ 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,
};
let v = serde_json::to_value(&ev).unwrap();
assert_eq!(
v.get("kind").and_then(|s| s.as_str()),
Some("asset_timings")
);
// All five phase fields are present (plain u64, always serialized).
for (field, want) in [
("parse_ms", 12u64),
("chunk_ms", 3),
("expansion_ms", 45_000),
("embed_ms", 800),
("store_ms", 20),
] {
assert_eq!(
v.get(field).and_then(serde_json::Value::as_u64),
Some(want),
"field {field}"
);
}
}
#[test]
fn ingest_event_completed_has_counts() {
let ev = IngestEvent::Completed {

View File

@@ -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,16 @@ fn ingest_one_asset(
app.sqlite
.put_chunks(&canonical.doc_id, &chunks)
.context("DocumentStore::put_chunks")?;
let store_ms = u64::try_from(t_store.elapsed().as_millis()).unwrap_or(u64::MAX);
// Embed + vector upsert (only when both sides are configured).
let t_embed = std::time::Instant::now();
// Stale-vector purge is LanceDB I/O, so it belongs to the embed/vector
// phase — not the SQLite `store` phase. Keeping it here makes `store_ms`
// mean "SQLite persist only" and `embed_ms` cover all vector-store work
// (purge + upsert), so per-phase timings attribute the bottleneck
// correctly (review fix). Runs before any new upsert, as before.
purge_vector_orphans_for_workspace_path(app, asset, vector_store)?;
let mut emb_cache_hit = 0_usize;
let mut emb_cache_miss = 0_usize;
if let (Some(emb), Some(vec_store)) = (embedder, vector_store) {
@@ -1431,97 +1406,35 @@ 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 only).
crate::ingest_progress::emit(
progress,
crate::ingest_progress::IngestEvent::AssetTimings {
idx,
total,
parse_ms,
chunk_ms,
expansion_ms,
embed_ms,
store_ms,
},
);
// 검증용 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 +1477,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;
@@ -1722,6 +1638,17 @@ fn ingest_one_image_asset(
.chunk(&canonical, chunk_policy)
.context("kb-chunk::MdHeadingV1Chunker::chunk (image)")?;
// v0.24.0: surface chunk count for the image path too (phase timing is
// markdown-only, but AssetChunked is consistent across media).
crate::ingest_progress::emit(
progress,
crate::ingest_progress::IngestEvent::AssetChunked {
idx,
total,
chunks: u32::try_from(chunks.len()).unwrap_or(u32::MAX),
},
);
// 5. Persist + embed — identical sequence to markdown.
// Stamp chunker + embedding versions (image uses MdHeadingV1Chunker
// for its single-block doc, so we record that version).
@@ -1840,49 +1767,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 +1794,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 +1839,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 +1940,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 +1990,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>>,
@@ -2330,6 +2195,16 @@ fn ingest_one_pdf_asset(
.chunk(&canonical, chunk_policy)
.context("kb-chunk::PdfPageV1Chunker::chunk")?;
// v0.24.0: surface chunk count for the PDF path too.
crate::ingest_progress::emit(
progress,
crate::ingest_progress::IngestEvent::AssetChunked {
idx,
total,
chunks: u32::try_from(chunks.len()).unwrap_or(u32::MAX),
},
);
// Stamp chunker + embedding versions so Task 7's skip detection has
// data on the second run.
canonical.last_chunker_version = Some(chunker.chunker_version());
@@ -3441,48 +3316,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

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

@@ -157,6 +157,42 @@ impl ProgressDisplay {
// in Completed handles the final state. No per-asset bar update
// here avoids the duplicate-frame artifact in TTY scrollback.
}
// v0.24.0: asset-internal phase visibility. AssetChunked 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 {
parse_ms,
chunk_ms,
embed_ms,
store_ms,
..
} => {
if let Some(bar) = self.bar.as_ref() {
bar.set_message("");
}
if !quiet {
let mut err = std::io::stderr().lock();
let _ = writeln!(
err,
" ⏱ parse {} · chunk {} · embed {} · store {}",
fmt_ms(*parse_ms),
fmt_ms(*chunk_ms),
fmt_ms(*embed_ms),
fmt_ms(*store_ms),
);
}
}
IngestEvent::Completed { counts } => {
if let Some(bar) = self.bar.take() {
bar.finish_and_clear();
@@ -239,6 +275,17 @@ fn emit_json(event: &IngestEvent) -> anyhow::Result<()> {
Ok(())
}
/// Render a phase duration (milliseconds) compactly for the human-mode
/// `AssetTimings` line: `< 1000ms` stays in `ms`, larger spans collapse to
/// one-decimal seconds so a 45-second 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")
}
}
/// 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 +332,15 @@ mod tests {
}
}
#[test]
fn fmt_ms_switches_unit_at_one_second() {
assert_eq!(fmt_ms(0), "0ms");
assert_eq!(fmt_ms(999), "999ms");
assert_eq!(fmt_ms(1000), "1.0s");
assert_eq!(fmt_ms(45_000), "45.0s");
assert_eq!(fmt_ms(1500), "1.5s");
}
#[test]
fn now_rfc3339_parses_back() {
let s = now_rfc3339().unwrap();

View File

@@ -155,9 +155,10 @@ impl NliCfg {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct EmbeddingModelCfg {
/// `fastembed` (default, onnxruntime) or `candle` (pure-Rust,
/// NUMA-safe). `none` disables embeddings (lexical-only). Unknown
/// values error at embedder construction.
/// `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,
@@ -170,6 +171,13 @@ pub struct EmbeddingModelCfg {
/// 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)]
@@ -606,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
@@ -648,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 {
@@ -718,6 +696,7 @@ impl Config {
dimensions: 1024,
batch_size: 64,
num_threads: 0,
endpoint: None,
},
llm: LlmCfg {
provider: "ollama".to_string(),
@@ -980,6 +959,12 @@ impl Config {
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(),
@@ -1166,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.
_ => {}
@@ -1913,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

@@ -38,6 +38,9 @@ metal = ["candle-core/metal", "candle-nn/metal", "candle-transformers/metal"]
# 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"

View File

@@ -1,31 +1,44 @@
//! `kebab-embed-candle` — [`CandleEmbedder`], a pure-Rust (candle)
//! implementation of [`Embedder`](kebab_core::Embedder).
//!
//! Runs the same `intfloat/multilingual-e5-large` model as the default
//! [`FastembedEmbedder`](kebab_embed_local) but through `candle`
//! (`candle-transformers`' XLM-RoBERTa) instead of onnxruntime. Motivation:
//! fastembed 4.9's onnxruntime hard-codes 48 intra-op threads, which corrupts
//! the heap (double-free) on dual-socket NUMA hosts. candle's CPU backend
//! sizes its threads off the global rayon pool, so a one-shot
//! [`rayon::ThreadPoolBuilder`] cap (config `num_threads` / env
//! `KEBAB_EMBED_THREADS`) keeps the worker count NUMA-safe.
//! 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`]):
//!
//! Output parity with the onnxruntime path was proven by the Phase 0 spike
//! (cosine 1.000000); this crate absorbs that pipeline verbatim:
//! * `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`]).
//!
//! 1. e5 prefix (`passage: ` for documents, `query: ` for queries — the same
//! convention as `kebab-embed-local`'s `prefix_input`);
//! 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 `Device::Cpu`;
//! 4. attention-mask-weighted mean pooling;
//! 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/`.
//! 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`.
//! `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;
@@ -42,22 +55,95 @@ use tokenizers::{PaddingParams, PaddingStrategy, Tokenizer, TruncationParams};
/// `fastembed/` subdir so the two backends never collide.
const CANDLE_CACHE_SUBDIR: &str = "candle";
/// HuggingFace repo id for the multilingual e5 large model. Same weights the
/// onnxruntime path uses, just the safetensors variant candle can read.
const HF_MODEL: &str = "intfloat/multilingual-e5-large";
/// The only `config.models.embedding.model` value the candle adapter accepts
/// (the e5-large weights `HF_MODEL` resolves to). Guards against silently
/// downloading e5-large while `model_id()` reports a different name.
const SUPPORTED_MODEL: &str = "multilingual-e5-large";
/// Token truncation length (e5 was trained at 512).
/// 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 {
@@ -68,6 +154,9 @@ pub struct CandleEmbedder {
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,
@@ -75,7 +164,8 @@ pub struct CandleEmbedder {
}
impl CandleEmbedder {
/// Build an embedder from `Config`. Applies the NUMA thread cap, fetches
/// 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.
@@ -104,21 +194,20 @@ impl CandleEmbedder {
}
}
// 1b. Model guard. `HF_MODEL` is hard-coded (candle currently only wires
// e5-large), so if the operator configured a *different* model name
// we must NOT silently download e5-large and then label its vectors
// with the configured name via `model_id()` — that would mislabel
// `embedding_version` and corrupt a mixed index. Fail fast, before
// the ~2GB download.
// 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();
if want != SUPPORTED_MODEL && want != HF_MODEL {
anyhow::bail!(
"candle provider currently supports only '{SUPPORTED_MODEL}' (or \
the HF id '{HF_MODEL}'), but config.models.embedding.model = \
'{want}'. Use provider=fastembed for other models, or set \
model = \"{SUPPORTED_MODEL}\"."
);
}
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.
@@ -134,14 +223,15 @@ impl CandleEmbedder {
tracing::info!(
target: "kebab-embed-candle",
cache_dir = %cache_dir.display(),
model = HF_MODEL,
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(HF_MODEL.to_string());
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")
@@ -180,10 +270,21 @@ impl CandleEmbedder {
}))
.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"
);
@@ -191,16 +292,17 @@ impl CandleEmbedder {
model: Mutex::new(model),
tokenizer,
device,
spec,
model_id: EmbeddingModelId(config.models.embedding.model.clone()),
version: EmbeddingVersion(config.models.embedding.version.clone()),
version,
dimensions: cfg.hidden_size,
batch_size: config.models.embedding.batch_size.max(1),
})
}
/// Embed one batch of **already-prefixed** strings (the e5 `query:`/
/// `passage:` prefix is applied by the caller [`CandleEmbedder::embed`])
/// through the candle pipeline: tokenize → forward → masked mean pool → L2.
/// 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
@@ -237,18 +339,30 @@ impl CandleEmbedder {
guard.forward(&input_ids, &attn_f32, &token_type_ids, None, None, None)?
};
// attention-mask-weighted mean pooling
let mask3 = attn_f32.unsqueeze(2)?; // (b, seq, 1)
let summed = hidden.broadcast_mul(&mask3)?.sum(1)?; // (b, hidden)
// counts ≥ 1 always: every input is e5-prefixed AND special tokens are
// added (encode_batch(_, true)), so no row has an all-zero mask. If that
// invariant ever breaks, broadcast_div would emit NaN vectors.
let counts = mask3.sum(1)?; // (b, 1)
let mean = summed.broadcast_div(&counts)?;
// 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 = mean.sqr()?.sum_keepdim(1)?.sqrt()?;
let normalized = mean.broadcast_div(&norm)?;
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).
@@ -274,9 +388,9 @@ impl Embedder for CandleEmbedder {
return Ok(Vec::new());
}
// e5 prefix per §11.3 BEFORE tokenization (same convention as
// FastembedEmbedder so the two backends produce comparable vectors).
let prefixed: Vec<String> = inputs.iter().map(prefix_input).collect();
// 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) {
@@ -298,22 +412,22 @@ impl Embedder for CandleEmbedder {
}
}
/// Build the e5-prefixed string for one [`EmbeddingInput`]. Free function so
/// a unit test can pin the format without loading the model. Byte-identical to
/// `kebab-embed-local`'s `prefix_input` — the two backends MUST agree here or
/// their vectors diverge.
fn prefix_input(input: &EmbeddingInput<'_>) -> String {
/// 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!("passage: {}", input.text),
EmbeddingKind::Query => format!("query: {}", input.text),
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. e5-large
/// vectors are model-defined, so Metal-produced and CPU-produced embeddings are
/// cross-compatible (a Mac can ingest on GPU, the server query on CPU).
/// 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")]
{
@@ -367,26 +481,85 @@ pub(crate) fn check_dim(model_dim: usize, cfg_dim: usize) -> Result<()> {
mod tests {
use super::*;
// ── prefix_input ─────────────────────────────────────────────────
// Pin the exact e5 prefix strings; these MUST match
// kebab-embed-local::prefix_input or candle vs fastembed parity breaks.
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 prefix_document_uses_passage() {
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(&input), "passage: hello world");
assert_eq!(prefix_input(e5_spec(), &input), "passage: hello world");
}
#[test]
fn prefix_query_uses_query() {
fn e5_prefix_query_uses_query() {
let input = EmbeddingInput {
text: "hello world",
kind: EmbeddingKind::Query,
};
assert_eq!(prefix_input(&input), "query: hello world");
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]
@@ -399,8 +572,10 @@ mod tests {
text: "",
kind: EmbeddingKind::Query,
};
assert_eq!(prefix_input(&doc), "passage: ");
assert_eq!(prefix_input(&qry), "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 ────────────────────────────────────────────────────
@@ -421,9 +596,9 @@ mod tests {
}
// ── model guard ──────────────────────────────────────────────────
// A non-e5-large model name must fail fast (BEFORE the ~2GB download),
// so we never download e5-large yet label its vectors with another name
// via model_id() — which would mislabel embedding_version.
// 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() {
@@ -437,8 +612,8 @@ mod tests {
.expect("unsupported model must error");
let msg = format!("{err:#}");
assert!(
msg.contains("candle provider currently supports only"),
"expected model-guard error, got: {msg}"
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,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

@@ -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,13 @@ 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 { .. } => {}
}
}

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 참조.
@@ -67,7 +67,8 @@ flowchart TB
subgraph Adapters ["traits + adapters"]
embed["kebab-embed<br/>(trait)"]
embedlocal["kebab-embed-local<br/>(fastembed, default)"]
embedcandle["kebab-embed-candle<br/>(candle, NUMA-safe opt-in)"]
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"]
@@ -94,6 +95,7 @@ flowchart TB
app --> vector
app --> embedlocal
app --> embedcandle
app --> embedollama
app --> llmlocal
app --> search
app --> rag
@@ -108,6 +110,8 @@ flowchart TB
embedlocal --> embed
embedcandle --> core
embedcandle --> config
embedollama --> core
embedollama --> config
llmlocal --> llm
rag --> search
rag --> llm
@@ -136,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
@@ -184,7 +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, NUMA-safe opt-in provider=candle (Track 1, v0.22.0)
│ ├── 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)
@@ -193,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,16 +107,18 @@ respect_markdown_headings = true
chunker_version = "md-heading-v1"
[models.embedding]
provider = "fastembed" # "fastembed"(기본) / "candle"(순수 Rust, NUMA-안전)
# / "none"(lexical-only — Ollama 불필요)
# ⚠ provider="candle" 사용 시 아래 model/dimensions 도
# multilingual-e5-large / 1024 로 바꿔야 함
# (candle 은 현재 e5-large 만 지원).
model = "multilingual-e5-small"
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"
@@ -707,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,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,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,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,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,8 @@
"scan_completed",
"asset_started",
"asset_finished",
"asset_chunked",
"asset_timings",
"embed_batch_started",
"embed_batch_finished",
"pdf_ocr_started",
@@ -33,7 +35,12 @@
"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." },
"parse_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.24.0, additive): parse phase wall-clock (ms). Markdown path only." },
"chunk_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.24.0, additive): chunk phase wall-clock (ms). Markdown path only." },
"expansion_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.24.0, additive): 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. Markdown path only." },
"store_ms": { "type": "integer", "minimum": 0, "description": "asset_timings (v0.24.0, additive): SQLite persist phase wall-clock (ms) — put_asset/document/blocks/chunks only. Markdown path only." },
"n_chunks": { "type": "integer", "minimum": 0, "description": "embed_batch_started / embed_batch_finished: chunks in this embedding batch." },
"ms": { "type": "integer", "minimum": 0, "description": "embed_batch_finished / pdf_ocr_finished: wall-clock duration (ms). pdf_ocr_finished skip path 의 의미는 mixed (DCTDecode 부재 시 0, engine 실패 시 latency-before-bail)." },
"chars": { "type": "integer", "minimum": 0, "description": "pdf_ocr_finished: char count of OCR result. Skip 시 0." },

View File

@@ -0,0 +1,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,179 @@ 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 — 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 로 느리고,