feat(embed): candle 임베딩 provider (NUMA-안전, opt-in) #199

Merged
altair823 merged 6 commits from feat/embed-candle into main 2026-06-02 10:14:41 +00:00
Owner

요약

CPU-only 듀얼소켓 NUMA 서버(Xeon Silver 4214 ×2 = 48 logical)에서 kebab ingest 가 매 실행 double free or corruption (!prev) 로 죽는 문제를 해결한다. 근본 원인은 fastembed 4.9.1 이 onnxruntime intra-op 스레드를 available_parallelism()(=48) 로 하드코딩하고 InitOptions 에 override API 가 없어, NUMA 에서 onnxruntime 스레드풀이 힙을 손상시키는 것이다 (코드로 확정 — fastembed text_embedding/impl.rs:52,80).

해법으로 동일 모델 intfloat/multilingual-e5-largecandle(순수 Rust) 로 돌리는 임베딩 provider 를 추가한다. 순수 Rust 라 네이티브 힙 손상이 구조적으로 불가능하고, candle 의 CPU 백엔드는 rayon 글로벌 풀 크기로 스레드를 정하므로 NUMA-안전한 수로 캡할 수 있다. opt-in (models.embedding.provider = "candle") — 글로벌 default 는 fastembed 유지(일반 머신에선 더 빠름).

설계: docs/superpowers/specs/2026-06-01-embed-candle-track-spec.md (우산: 같은 디렉터리 2026-06-01-embedding-numa-backends-meta-spec.md / -meta-plan.md)

변경

  • 신규 crate crates/kebab-embed-candlekebab_core::Embedder 구현 (CandleEmbedder). safetensors via hf-hub → XLMRobertaModel forward(CPU) → attention-mask 가중 mean pooling → L2 → e5 query:/passage: prefix. candle 의존성 트리를 이 crate 에 격리 (kebab-core/kebab-config 외 다른 kebab-* 의존 0 — design §8 경계).
  • 스레드 캡: [models.embedding].num_threads(u32, default 0=auto) + env KEBAB_EMBED_THREADS(우선). CandleEmbedder::new 에서 글로벌 rayon 풀 1회 캡.
  • 주입 분기: kebab-app::App::embedder()provider 분기 — fastembed/onnx/(빈값) → 기존 FastembedEmbedder(동작·벡터 불변), candleCandleEmbedder, 미지값 → 에러. 비-e5-large 모델은 다운로드 전 fail-fast(모델 가드).
  • 버전 0.21.1 → 0.22.0 (신규 config surface — pre-1.0 minor bump). README / SMOKE / ARCHITECTURE / HANDOFF / HOTFIXES / release-notes 동기화.

검증

  • cargo clippy --workspace --all-targets -j 4 -- -D warnings → exit 0, warning 0.
  • cargo test -p kebab-embed-candle -p kebab-config -j 4 → exit 0 (candle 단위 6 + thread_cap 1 + parity 1 ignored, config 68). 독립 재실행으로 재확인.
  • 패리티 (재색인 0 근거): candle vs onnxruntime FastembedEmbedder, 동일 10문장(한/영, document+query 양쪽 prefix) → cosine_min = 1.000000, 차원별 max 절대오차 = 2.01e-7 (f32 커널 반올림 수준). embedding_version 유지 → 기존 LanceDB 색인 재사용. 재현: crates/kebab-embed-candle/tests/parity.rs (--ignored, 모델 ~2GB 필요).
  • 호환성: fastembed default 경로 불변, 옛 config.toml 은 num_threads serde default(0)로 파싱, wire schema 변경 없음.

시험 항목 (Test Plan)

  • clippy -D warnings 통과
  • candle 단위 / config 회귀 테스트 통과
  • 패리티 #[ignore] 수동 1회 (cosine 1.0 / max abs diff 2.01e-7)
  • (사용자 실행, Claude 불가) 듀얼소켓 NUMA 서버에서 provider=candle 로 5150-doc ingest double-free 없이 EXIT=0 완주 — meta-spec §4.3, 본 PR 의 최종 인수 게이트
  • (권장) provider=candle 골든 스위트 MRR/hit@k ≥ baseline — 패리티 2.01e-7 로 회귀 위험 낮음, NUMA 도그푸딩 시 확인

비범위 / 후속

  • candle crate feature-gate 로 빌드 비용 격리 (후속).
  • candle CPU latency 는 onnxruntime 대비 ~4× (스파이크 측정) — 그래서 default 아닌 NUMA opt-in. 단일 워크스테이션엔 비권장(README 명시).
  • ollama / A2(ort 직접) / A1(numactl) 트랙: candle 이 품질·NUMA 게이트 통과 시 생략(조기 종료).

Assisted-by: Claude Code

## 요약 CPU-only 듀얼소켓 NUMA 서버(Xeon Silver 4214 ×2 = 48 logical)에서 `kebab ingest` 가 매 실행 `double free or corruption (!prev)` 로 죽는 문제를 해결한다. 근본 원인은 fastembed 4.9.1 이 onnxruntime intra-op 스레드를 `available_parallelism()`(=48) 로 하드코딩하고 `InitOptions` 에 override API 가 없어, NUMA 에서 onnxruntime 스레드풀이 힙을 손상시키는 것이다 (코드로 확정 — fastembed `text_embedding/impl.rs:52,80`). 해법으로 동일 모델 `intfloat/multilingual-e5-large` 를 **candle(순수 Rust)** 로 돌리는 임베딩 provider 를 추가한다. 순수 Rust 라 네이티브 힙 손상이 구조적으로 불가능하고, candle 의 CPU 백엔드는 rayon 글로벌 풀 크기로 스레드를 정하므로 NUMA-안전한 수로 캡할 수 있다. **opt-in** (`models.embedding.provider = "candle"`) — 글로벌 default 는 fastembed 유지(일반 머신에선 더 빠름). 설계: docs/superpowers/specs/2026-06-01-embed-candle-track-spec.md (우산: 같은 디렉터리 `2026-06-01-embedding-numa-backends-meta-spec.md` / `-meta-plan.md`) ## 변경 - 신규 crate `crates/kebab-embed-candle` — `kebab_core::Embedder` 구현 (`CandleEmbedder`). safetensors via hf-hub → `XLMRobertaModel` forward(CPU) → attention-mask 가중 mean pooling → L2 → e5 `query:`/`passage:` prefix. candle 의존성 트리를 이 crate 에 격리 (kebab-core/kebab-config 외 다른 kebab-* 의존 0 — design §8 경계). - 스레드 캡: `[models.embedding].num_threads`(u32, default 0=auto) + env `KEBAB_EMBED_THREADS`(우선). `CandleEmbedder::new` 에서 글로벌 rayon 풀 1회 캡. - 주입 분기: `kebab-app::App::embedder()` 가 `provider` 분기 — `fastembed`/`onnx`/(빈값) → 기존 `FastembedEmbedder`(동작·벡터 불변), `candle` → `CandleEmbedder`, 미지값 → 에러. 비-e5-large 모델은 다운로드 전 fail-fast(모델 가드). - 버전 0.21.1 → 0.22.0 (신규 config surface — pre-1.0 minor bump). README / SMOKE / ARCHITECTURE / HANDOFF / HOTFIXES / release-notes 동기화. ## 검증 - `cargo clippy --workspace --all-targets -j 4 -- -D warnings` → exit 0, warning 0. - `cargo test -p kebab-embed-candle -p kebab-config -j 4` → exit 0 (candle 단위 6 + thread_cap 1 + parity 1 ignored, config 68). 독립 재실행으로 재확인. - **패리티 (재색인 0 근거)**: candle vs onnxruntime `FastembedEmbedder`, 동일 10문장(한/영, document+query 양쪽 prefix) → **cosine_min = 1.000000, 차원별 max 절대오차 = 2.01e-7** (f32 커널 반올림 수준). `embedding_version` 유지 → 기존 LanceDB 색인 재사용. 재현: `crates/kebab-embed-candle/tests/parity.rs` (`--ignored`, 모델 ~2GB 필요). - 호환성: fastembed default 경로 불변, 옛 config.toml 은 `num_threads` serde default(0)로 파싱, wire schema 변경 없음. ## 시험 항목 (Test Plan) - [x] clippy -D warnings 통과 - [x] candle 단위 / config 회귀 테스트 통과 - [x] 패리티 #[ignore] 수동 1회 (cosine 1.0 / max abs diff 2.01e-7) - [ ] **(사용자 실행, Claude 불가)** 듀얼소켓 NUMA 서버에서 `provider=candle` 로 5150-doc ingest double-free 없이 EXIT=0 완주 — meta-spec §4.3, 본 PR 의 최종 인수 게이트 - [ ] (권장) provider=candle 골든 스위트 MRR/hit@k ≥ baseline — 패리티 2.01e-7 로 회귀 위험 낮음, NUMA 도그푸딩 시 확인 ## 비범위 / 후속 - candle crate feature-gate 로 빌드 비용 격리 (후속). - candle CPU latency 는 onnxruntime 대비 ~4× (스파이크 측정) — 그래서 default 아닌 NUMA opt-in. 단일 워크스테이션엔 비권장(README 명시). - ollama / A2(ort 직접) / A1(numactl) 트랙: candle 이 품질·NUMA 게이트 통과 시 생략(조기 종료). Assisted-by: Claude Code
altair823 added 4 commits 2026-06-01 16:55:58 +00:00
Track 1 / Phase 0 격리 스파이크. candle(순수 Rust)로
intfloat/multilingual-e5-large 를 돌려 기존 onnxruntime
FastembedEmbedder 와 비교.

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
duo-socket NUMA 서버에서 fastembed(onnxruntime)가 intra-op 스레드를 48개로
하드코딩해 NUMA 힙 손상 → double-free 로 ingest 가 죽는 문제를 회피하기 위해,
같은 multilingual-e5-large 모델을 순수 Rust(candle)로 돌리는 opt-in 임베딩
provider 를 추가한다.

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- commit track-spec + meta-spec/plan into branch (HIGH: dangling `amends:` ref)
- inline parity evidence (cosine 1.0, max_abs_diff 2.01e-7) into HOTFIXES +
  release notes; drop refs to deleted IMPL_REPORT/SPIKE_REPORT (MEDIUM)
- model guard: reject non-e5-large `model` before the 2GB download so
  model_id() can't mislabel vectors (MEDIUM) + unit test
- parity test now covers BOTH query: and passage: prefixes (MEDIUM)
- guard encodings.first() index; document zero-attention/pooling invariant;
  clarify embed_batch prefixing doc (LOW)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
claude-reviewer-01 requested changes 2026-06-01 17:01:08 +00:00
Dismissed
claude-reviewer-01 left a comment
Member

회차 1 — 독립 재리뷰(opus). Critical/High/Medium 0건. 임베딩 파이프라인(prefix·mask mean pool·L2), provider 분기 무회귀, 모델 가드(다운로드 전 fail-fast + 단위테스트), §8 의존 경계, 패리티 query/passage 양쪽 커버, 문서/HOTFIXES 정합 모두 확인. clippy 0. SMOKE.md candle 예시 nit 1건만 반영 요청.

회차 1 — 독립 재리뷰(opus). Critical/High/Medium 0건. 임베딩 파이프라인(prefix·mask mean pool·L2), provider 분기 무회귀, 모델 가드(다운로드 전 fail-fast + 단위테스트), §8 의존 경계, 패리티 query/passage 양쪽 커버, 문서/HOTFIXES 정합 모두 확인. clippy 0. SMOKE.md candle 예시 nit 1건만 반영 요청.
@@ -108,3 +108,3 @@
[models.embedding]
provider = "fastembed" # "none" 으로 두면 lexical-only — Ollama 불필요
provider = "fastembed" # "fastembed"(기본) / "candle"(순수 Rust, NUMA-안전)

nit: 이 예시는 model = multilingual-e5-small / dimensions = 384 인데 주석에 candle 을 제시한다. 사용자가 이 파일에서 provider = "candle" 로만 바꾸면 candle 모델 가드(SUPPORTED_MODEL = multilingual-e5-large)에 걸려 에러가 난다. candle 사용 시 model = multilingual-e5-large / dimensions = 1024 도 함께 바꿔야 함을 한 줄로 명시하면 self-correcting 에러에 의존하지 않아도 된다.

nit: 이 예시는 `model = multilingual-e5-small` / `dimensions = 384` 인데 주석에 `candle` 을 제시한다. 사용자가 이 파일에서 `provider = "candle"` 로만 바꾸면 candle 모델 가드(`SUPPORTED_MODEL = multilingual-e5-large`)에 걸려 에러가 난다. candle 사용 시 `model = multilingual-e5-large` / `dimensions = 1024` 도 함께 바꿔야 함을 한 줄로 명시하면 self-correcting 에러에 의존하지 않아도 된다.
altair823 added 1 commit 2026-06-01 17:01:41 +00:00
claude-reviewer-01 approved these changes 2026-06-01 17:02:11 +00:00
Dismissed
claude-reviewer-01 left a comment
Member

회차 2 — 회차 1 nit 반영 확인(SMOKE.md 에 provider=candle 시 model/dimensions 동반 변경 주의 명시, edac3ae). 잔여 actionable 0건. 코드 정확·clippy 0·테스트 녹색·문서 정합·패리티 2.01e-7 로 재색인 0 근거 충족. 머지 동의. 유일 잔여 게이트는 사용자 실행 NUMA 서버 5150-doc 완주(meta-spec §4.3) — 코드 레벨 승인과 무관.

회차 2 — 회차 1 nit 반영 확인(SMOKE.md 에 provider=candle 시 model/dimensions 동반 변경 주의 명시, edac3ae). 잔여 actionable 0건. 코드 정확·clippy 0·테스트 녹색·문서 정합·패리티 2.01e-7 로 재색인 0 근거 충족. 머지 동의. 유일 잔여 게이트는 사용자 실행 NUMA 서버 5150-doc 완주(meta-spec §4.3) — 코드 레벨 승인과 무관.
altair823 added 1 commit 2026-06-02 09:08:17 +00:00
- HOTFIXES + release-notes: candle 전체 도그푸딩 997 docs/23,151 chunks/에러 0 (9.5h)
- A1(taskset -c 0-3) 실서버 반증: 4코어 제한에도 onnxruntime segfault → candle 만이 실 해법
- MKL 가속 부정 결과: 코어 더 쓰나 38~50% 느림 → 미채택, 순수-Rust 유지
- 패리티 2.01e-7 재확인, 성능 트레이드오프 명시

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
claude-reviewer-01 approved these changes 2026-06-02 09:08:33 +00:00
claude-reviewer-01 left a comment
Member

회차 3 — 증거 커밋(d85d734) 검토. 도그푸딩(997 docs/23,151 chunks/에러 0), A1(taskset -c 0-3) 실서버 반증, MKL 부정 결과(38~50% 느림)를 HOTFIXES + release-notes 두 곳에 정확히 기록. 수치는 실측과 일치, 문서만 변경(코드/테스트 무영향). 잔여 actionable 0건. candle 트랙 코드 레벨 완료 — 머지 동의. 유일 잔여는 사용자 NUMA 서버 실배포 검증(코드 승인과 무관).

회차 3 — 증거 커밋(d85d734) 검토. 도그푸딩(997 docs/23,151 chunks/에러 0), A1(taskset -c 0-3) 실서버 반증, MKL 부정 결과(38~50% 느림)를 HOTFIXES + release-notes 두 곳에 정확히 기록. 수치는 실측과 일치, 문서만 변경(코드/테스트 무영향). 잔여 actionable 0건. candle 트랙 코드 레벨 완료 — 머지 동의. 유일 잔여는 사용자 NUMA 서버 실배포 검증(코드 승인과 무관).
altair823 merged commit 99f8cfa691 into main 2026-06-02 10:14:41 +00:00
altair823 deleted branch feat/embed-candle 2026-06-02 10:14:43 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: altair823-org/kebab#199