Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 581e1d5d55 | |||
| c17d6e67a8 | |||
| af8fd34716 | |||
| 369aeb3d24 | |||
| 99f8cfa691 | |||
| d85d7348a5 | |||
| edac3ae737 | |||
| 6ec4e6809f | |||
| 1011c75fff | |||
| 8f7b6ee538 | |||
| 76841af7d3 |
820
Cargo.lock
generated
820
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ members = [
|
||||
"crates/kebab-search",
|
||||
"crates/kebab-embed",
|
||||
"crates/kebab-embed-local",
|
||||
"crates/kebab-embed-candle",
|
||||
"crates/kebab-llm",
|
||||
"crates/kebab-llm-local",
|
||||
"crates/kebab-rag",
|
||||
@@ -30,7 +31,7 @@ edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/altair823/kebab"
|
||||
version = "0.21.1" # v0.21.1 — config 마이그레이션(kebab config migrate): 기존 config.toml 에 빠진 섹션 주석과 함께 추가 + deprecated 정리 + schema_version 1→2 — CLAUDE.md §Release 도그푸딩 트리거
|
||||
version = "0.23.1" # v0.23.1 — ingest 시작 시 임베딩 백엔드/디바이스 한 줄 표시(터미널, --json/--quiet 존중) + README 에 KB 이전(어떤 파일/어느 config 키) 설명. 동작·schema 변경 없음. — 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),
|
||||
|
||||
@@ -30,6 +30,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
|
||||
|
||||
## 머지 후 발견된 버그 / 결정 (요약)
|
||||
|
||||
- **candle 임베딩 백엔드 다변화** (2026-06-01, Track 1, v0.22.0): `provider = "candle"` opt-in 추가 — 같은 `multilingual-e5-large` 모델을 순수 Rust(candle)로 돌려 듀얼소켓 NUMA 서버의 onnxruntime 48-스레드 double-free 를 회피. `[models.embedding].num_threads`(+env `KEBAB_EMBED_THREADS`)로 CPU 스레드 캡. fastembed default 동작·벡터 불변, `embedding_version` 유지(재색인 0). Phase 0 스파이크 패리티 cosine 1.000000. 상세 HOTFIXES 동일 일자.
|
||||
- **config 마이그레이션** (2026-05-31, PR #198): `kebab config migrate` 추가 — 기존 config.toml 에 빠진 섹션을 주석과 함께 채우고 deprecated 정리(멱등·`.bak`·dry-run, 값/주석 보존). `schema_version` 1→2, `init` 도 섹션 주석 포함, doctor 에 `config_migration` 체크. 상세 HOTFIXES 동일 일자.
|
||||
|
||||
머지 후 발견된 모든 deviation / hotfix 의 dated 로그는 [tasks/HOTFIXES.md](tasks/HOTFIXES.md). 본 요약은 \"누군가가 인수받을 때 알아두면 시간을 많이 절약하는\" 항목만:
|
||||
|
||||
46
README.md
46
README.md
@@ -51,7 +51,24 @@ embedding 벡터와 별칭 LLM 결과를 청크 **내용 해시** 로 캐싱한
|
||||
|
||||
### 외부 계산 + 로컬 검색 워크플로
|
||||
|
||||
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 가 돌지 않을 때.
|
||||
|
||||
### 멀티미디어 색인
|
||||
|
||||
@@ -97,9 +114,36 @@ root = "~/KnowledgeBase" # 색인할 폴더. 절대 / tilde / env / 상대 경
|
||||
# 상대 경로의 base 는 config.toml 위치 (cwd 무관).
|
||||
|
||||
[models.embedding]
|
||||
provider = "fastembed" # "fastembed"(기본, onnxruntime) / "candle"(순수 Rust)
|
||||
# / "none"(lexical-only). candle 는 같은 모델·같은 벡터를
|
||||
# 순수 Rust 로 돌려 NUMA 서버의 onnxruntime 48-스레드
|
||||
# double-free 를 피하는 opt-in 백엔드 (재색인 불필요).
|
||||
model = "multilingual-e5-large" # 다국어 sentence embedding (1024-dim).
|
||||
# 첫 ingest 시 ONNX (~1.3GB) 자동 다운로드.
|
||||
# candle provider 는 safetensors (~2GB) 다운로드.
|
||||
dimensions = 1024 # config 와 LanceDB stored dim 불일치 시 검색 0건.
|
||||
num_threads = 0 # candle 전용 CPU 스레드 캡 (0=auto=#cores).
|
||||
# env KEBAB_EMBED_THREADS 가 우선. NUMA 노드 바인딩은
|
||||
# numactl 과 조합. fastembed provider 는 무시.
|
||||
```
|
||||
|
||||
**Apple Silicon GPU 가속 (candle / macOS)**: M-시리즈 맥에서 candle 임베딩을
|
||||
GPU(Metal)로 돌리면 CPU 대비 대용량 ingest 가 크게 빨라진다. 빌드 또는 설치 시
|
||||
`embed_metal` feature 를 켠다:
|
||||
|
||||
```bash
|
||||
# 빌드만:
|
||||
cargo build --release --features embed_metal
|
||||
# 전역 설치 (~/.cargo/bin/kebab):
|
||||
cargo install --path crates/kebab-cli --features embed_metal --locked
|
||||
```
|
||||
|
||||
벡터는 CPU candle 과 동일 모델이라 호환되므로, 맥에서 GPU 로 색인한
|
||||
`kebab.sqlite` + `lancedb/` 를 그대로 Linux 서버(CPU candle)로 복사해 질의할 수
|
||||
있다. 색인 로그에 `candle device = Metal (GPU)` 가 보이면 GPU 사용 중. metal
|
||||
feature 는 macOS 전용 (Linux/서버는 기본 CPU 빌드).
|
||||
|
||||
```toml
|
||||
|
||||
[models.llm]
|
||||
endpoint = "http://localhost:11434" # Ollama host:port
|
||||
|
||||
@@ -18,6 +18,7 @@ kebab-store-vector = { path = "../kebab-store-vector" }
|
||||
kebab-search = { path = "../kebab-search" }
|
||||
kebab-embed = { path = "../kebab-embed" }
|
||||
kebab-embed-local = { path = "../kebab-embed-local" }
|
||||
kebab-embed-candle = { path = "../kebab-embed-candle" }
|
||||
kebab-llm = { path = "../kebab-llm" }
|
||||
kebab-llm-local = { path = "../kebab-llm-local" }
|
||||
kebab-rag = { path = "../kebab-rag" }
|
||||
@@ -99,6 +100,8 @@ reqwest = { version = "0.12", default-features = false, features = ["blocki
|
||||
# disable path 없음; 이 feature 는 spec §6.3 명시를 honor 하는 role 만.
|
||||
default = ["fts_korean_morphological"]
|
||||
fts_korean_morphological = []
|
||||
# opt-in (macOS): candle embedder runs on the Apple Silicon GPU. See kebab-embed-candle.
|
||||
embed_metal = ["kebab-embed-candle/metal"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -43,6 +43,7 @@ use kebab_core::{
|
||||
Answer, DocumentStore, Embedder, ExtractContext, Extractor, IndexVersion, LanguageModel,
|
||||
MediaType, Retriever, SearchHit, SearchMode, SearchOpts, SearchQuery, VectorStore,
|
||||
};
|
||||
use kebab_embed_candle::CandleEmbedder;
|
||||
use kebab_embed_local::FastembedEmbedder;
|
||||
use kebab_llm_local::OllamaLanguageModel;
|
||||
use kebab_parse_code::{
|
||||
@@ -833,9 +834,26 @@ impl App {
|
||||
if let Some(e) = self.embedder.get() {
|
||||
return Ok(Some(e.clone()));
|
||||
}
|
||||
let emb: Arc<dyn Embedder + Send + Sync> = Arc::new(
|
||||
FastembedEmbedder::new(&self.config).context("kb-app: load FastembedEmbedder")?,
|
||||
);
|
||||
// Provider branch (Track 1 spec §3). `embeddings_disabled()` above
|
||||
// already handled `"none"`; here we route the live providers.
|
||||
// `fastembed`/`onnx`/(empty) keep the default onnxruntime path
|
||||
// (vectors unchanged — `embedding_version` is preserved); `candle`
|
||||
// selects the pure-Rust NUMA-safe backend.
|
||||
let provider = self.config.models.embedding.provider.as_str();
|
||||
let emb: Arc<dyn Embedder + Send + Sync> = match provider {
|
||||
"fastembed" | "onnx" | "" => Arc::new(
|
||||
FastembedEmbedder::new(&self.config).context("kb-app: load FastembedEmbedder")?,
|
||||
),
|
||||
"candle" => Arc::new(
|
||||
CandleEmbedder::new(&self.config).context("kb-app: load CandleEmbedder")?,
|
||||
),
|
||||
other => {
|
||||
return Err(anyhow!(
|
||||
"kb-app: unknown embedding provider {other:?}; expected one of \
|
||||
`fastembed` (default), `candle`, or `none` (lexical-only)"
|
||||
));
|
||||
}
|
||||
};
|
||||
// `set` returns Err if another thread won the race; in that case
|
||||
// the loser still returns the (now-cached) winner via `get()`.
|
||||
let _ = self.embedder.set(emb.clone());
|
||||
|
||||
@@ -51,5 +51,10 @@ tempfile = { workspace = true }
|
||||
rusqlite = { workspace = true }
|
||||
time = { workspace = true }
|
||||
|
||||
[features]
|
||||
# opt-in (macOS): build the `kebab` binary with candle on the Apple Silicon GPU.
|
||||
# cargo build --release --features embed_metal
|
||||
embed_metal = ["kebab-app/embed_metal"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -155,11 +155,21 @@ impl NliCfg {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EmbeddingModelCfg {
|
||||
/// `fastembed` (default, onnxruntime) or `candle` (pure-Rust,
|
||||
/// NUMA-safe). `none` disables embeddings (lexical-only). Unknown
|
||||
/// values error at embedder construction.
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub version: String,
|
||||
pub dimensions: usize,
|
||||
pub batch_size: usize,
|
||||
/// Cap on the CPU worker threads the `candle` provider spins up
|
||||
/// (sizes the global rayon pool; env `KEBAB_EMBED_THREADS` overrides).
|
||||
/// `0` = auto (rayon default = #cores). Lever to sidestep the
|
||||
/// onnxruntime 48-thread NUMA double-free; ignored by the `fastembed`
|
||||
/// provider. Defaulted on load so pre-0.22 config files still parse.
|
||||
#[serde(default)]
|
||||
pub num_threads: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -707,6 +717,7 @@ impl Config {
|
||||
version: "v1".to_string(),
|
||||
dimensions: 1024,
|
||||
batch_size: 64,
|
||||
num_threads: 0,
|
||||
},
|
||||
llm: LlmCfg {
|
||||
provider: "ollama".to_string(),
|
||||
@@ -964,6 +975,11 @@ impl Config {
|
||||
self.models.embedding.batch_size = n;
|
||||
}
|
||||
}
|
||||
"KEBAB_MODELS_EMBEDDING_NUM_THREADS" => {
|
||||
if let Ok(n) = v.parse::<u32>() {
|
||||
self.models.embedding.num_threads = n;
|
||||
}
|
||||
}
|
||||
|
||||
// models.llm
|
||||
"KEBAB_MODELS_LLM_PROVIDER" => self.models.llm.provider = v.clone(),
|
||||
|
||||
47
crates/kebab-embed-candle/Cargo.toml
Normal file
47
crates/kebab-embed-candle/Cargo.toml
Normal file
@@ -0,0 +1,47 @@
|
||||
[package]
|
||||
name = "kebab-embed-candle"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
rust-version = { workspace = true }
|
||||
license = { workspace = true }
|
||||
repository = { workspace = true }
|
||||
description = "Pure-Rust candle adapter implementing kb_core::Embedder (multilingual-e5-large, NUMA-safe thread cap)"
|
||||
|
||||
[dependencies]
|
||||
kebab-core = { path = "../kebab-core" }
|
||||
kebab-config = { path = "../kebab-config" }
|
||||
# candle stack — pinned to the workspace-locked crates.io release (0.10.x),
|
||||
# same versions the Phase 0 spike compiled so build artifacts are reused.
|
||||
candle-core = "0.10.2"
|
||||
candle-nn = "0.10.2"
|
||||
candle-transformers = "0.10.2"
|
||||
tokenizers = "0.21"
|
||||
hf-hub = { version = "0.4", features = ["ureq"] }
|
||||
serde_json = { workspace = true }
|
||||
# Thread cap: a one-shot global rayon pool sizes candle's CPU threads
|
||||
# (the Phase 0 spike proved RAYON_NUM_THREADS caps candle), so a NUMA host
|
||||
# can keep onnxruntime's hard-coded 48-intra-op heap corruption at bay.
|
||||
rayon = "1"
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[features]
|
||||
# opt-in: run candle on the Apple Silicon GPU (Metal). macOS-only — the build
|
||||
# enables candle's metal backend and `select_device()` picks Metal (CPU fallback
|
||||
# on failure). Lets an M-series Mac ingest e5-large on GPU (10×+ vs CPU); the
|
||||
# resulting vectors are cross-compatible with the CPU path (same model), so the
|
||||
# Linux server can serve queries on CPU candle.
|
||||
metal = ["candle-core/metal", "candle-nn/metal", "candle-transformers/metal"]
|
||||
|
||||
[dev-dependencies]
|
||||
# Integration-test binaries can only see the library's public API + these,
|
||||
# not the library's own (non-dev) dependencies — so rayon/kebab-config/kebab-core
|
||||
# are repeated here for tests/parity.rs and tests/thread_cap.rs.
|
||||
kebab-embed-local = { path = "../kebab-embed-local" }
|
||||
kebab-config = { path = "../kebab-config" }
|
||||
kebab-core = { path = "../kebab-core" }
|
||||
rayon = "1"
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
444
crates/kebab-embed-candle/src/lib.rs
Normal file
444
crates/kebab-embed-candle/src/lib.rs
Normal file
@@ -0,0 +1,444 @@
|
||||
//! `kebab-embed-candle` — [`CandleEmbedder`], a pure-Rust (candle)
|
||||
//! implementation of [`Embedder`](kebab_core::Embedder).
|
||||
//!
|
||||
//! Runs the same `intfloat/multilingual-e5-large` model as the default
|
||||
//! [`FastembedEmbedder`](kebab_embed_local) but through `candle`
|
||||
//! (`candle-transformers`' XLM-RoBERTa) instead of onnxruntime. Motivation:
|
||||
//! fastembed 4.9's onnxruntime hard-codes 48 intra-op threads, which corrupts
|
||||
//! the heap (double-free) on dual-socket NUMA hosts. candle's CPU backend
|
||||
//! sizes its threads off the global rayon pool, so a one-shot
|
||||
//! [`rayon::ThreadPoolBuilder`] cap (config `num_threads` / env
|
||||
//! `KEBAB_EMBED_THREADS`) keeps the worker count NUMA-safe.
|
||||
//!
|
||||
//! Output parity with the onnxruntime path was proven by the Phase 0 spike
|
||||
//! (cosine 1.000000); this crate absorbs that pipeline verbatim:
|
||||
//!
|
||||
//! 1. e5 prefix (`passage: ` for documents, `query: ` for queries — the same
|
||||
//! convention as `kebab-embed-local`'s `prefix_input`);
|
||||
//! 2. tokenize (max_len 512, batch-longest padding, special tokens);
|
||||
//! 3. XLM-RoBERTa forward on `Device::Cpu`;
|
||||
//! 4. attention-mask-weighted mean pooling;
|
||||
//! 5. L2 normalization.
|
||||
//!
|
||||
//! Model files (`config.json`, `tokenizer.json`, `model.safetensors`) are
|
||||
//! fetched via `hf-hub` into `{config.storage.model_dir}/candle/`.
|
||||
//!
|
||||
//! This crate is **opt-in** (`config.models.embedding.provider = "candle"`);
|
||||
//! the default provider stays `fastembed`. See
|
||||
//! `docs/superpowers/specs/2026-06-01-embed-candle-track-spec.md`.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use candle_core::{DType, Device, Tensor};
|
||||
use candle_nn::VarBuilder;
|
||||
use candle_transformers::models::xlm_roberta::{Config as XlmConfig, XLMRobertaModel};
|
||||
use kebab_config::{Config, expand_path};
|
||||
use kebab_core::{Embedder, EmbeddingInput, EmbeddingKind, EmbeddingModelId, EmbeddingVersion};
|
||||
use tokenizers::{PaddingParams, PaddingStrategy, Tokenizer, TruncationParams};
|
||||
|
||||
/// Subdirectory under `config.storage.model_dir` where the candle adapter
|
||||
/// caches safetensors + tokenizer. Mirrors `kebab-embed-local`'s
|
||||
/// `fastembed/` subdir so the two backends never collide.
|
||||
const CANDLE_CACHE_SUBDIR: &str = "candle";
|
||||
|
||||
/// HuggingFace repo id for the multilingual e5 large model. Same weights the
|
||||
/// onnxruntime path uses, just the safetensors variant candle can read.
|
||||
const HF_MODEL: &str = "intfloat/multilingual-e5-large";
|
||||
|
||||
/// The only `config.models.embedding.model` value the candle adapter accepts
|
||||
/// (the e5-large weights `HF_MODEL` resolves to). Guards against silently
|
||||
/// downloading e5-large while `model_id()` reports a different name.
|
||||
const SUPPORTED_MODEL: &str = "multilingual-e5-large";
|
||||
|
||||
/// Token truncation length (e5 was trained at 512).
|
||||
const MAX_LEN: usize = 512;
|
||||
|
||||
/// Env var that overrides `config.models.embedding.num_threads`. Read once in
|
||||
/// [`CandleEmbedder::new`]; `0`/unset/unparseable means "leave rayon default".
|
||||
const ENV_EMBED_THREADS: &str = "KEBAB_EMBED_THREADS";
|
||||
|
||||
/// Pure-Rust candle adapter. Construct via [`CandleEmbedder::new`]; the
|
||||
/// constructor downloads the model on first use, so share one instance.
|
||||
pub struct CandleEmbedder {
|
||||
// candle's `forward` is `&self`, but `XLMRobertaModel` is not guaranteed
|
||||
// `Sync`; the `Mutex` both supplies that bound and serializes inference
|
||||
// (callers batch sequentially anyway — same rationale as
|
||||
// `FastembedEmbedder`).
|
||||
model: Mutex<XLMRobertaModel>,
|
||||
tokenizer: Tokenizer,
|
||||
device: Device,
|
||||
model_id: EmbeddingModelId,
|
||||
version: EmbeddingVersion,
|
||||
dimensions: usize,
|
||||
batch_size: usize,
|
||||
}
|
||||
|
||||
impl CandleEmbedder {
|
||||
/// Build an embedder from `Config`. Applies the NUMA thread cap, fetches
|
||||
/// the model into `{model_dir}/candle/`, and validates that the model's
|
||||
/// hidden size matches `config.models.embedding.dimensions` before
|
||||
/// returning.
|
||||
pub fn new(config: &Config) -> Result<Self> {
|
||||
// 1. NUMA thread cap. env `KEBAB_EMBED_THREADS` wins over the config
|
||||
// field; `0`/unset leaves rayon's default. `build_global` errors if
|
||||
// the pool was already initialized — intentionally ignored so a
|
||||
// second embedder (or a prior rayon user) is a no-op, not a failure.
|
||||
let n_threads = std::env::var(ENV_EMBED_THREADS)
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<usize>().ok())
|
||||
.unwrap_or(config.models.embedding.num_threads as usize);
|
||||
if n_threads > 0 {
|
||||
if apply_thread_cap(n_threads) {
|
||||
tracing::info!(
|
||||
target: "kebab-embed-candle",
|
||||
num_threads = n_threads,
|
||||
"capped global rayon pool for candle CPU backend"
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
target: "kebab-embed-candle",
|
||||
requested = n_threads,
|
||||
"global rayon pool already initialized; thread cap not applied"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 1b. Model guard. `HF_MODEL` is hard-coded (candle currently only wires
|
||||
// e5-large), so if the operator configured a *different* model name
|
||||
// we must NOT silently download e5-large and then label its vectors
|
||||
// with the configured name via `model_id()` — that would mislabel
|
||||
// `embedding_version` and corrupt a mixed index. Fail fast, before
|
||||
// the ~2GB download.
|
||||
let want = config.models.embedding.model.as_str();
|
||||
if want != SUPPORTED_MODEL && want != HF_MODEL {
|
||||
anyhow::bail!(
|
||||
"candle provider currently supports only '{SUPPORTED_MODEL}' (or \
|
||||
the HF id '{HF_MODEL}'), but config.models.embedding.model = \
|
||||
'{want}'. Use provider=fastembed for other models, or set \
|
||||
model = \"{SUPPORTED_MODEL}\"."
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Resolve `{data_dir}/models/candle/` exactly like the fastembed
|
||||
// adapter resolves its own subdir.
|
||||
let data_dir = expand_path(&config.storage.data_dir, "");
|
||||
let model_dir = expand_path(&config.storage.model_dir, &data_dir.to_string_lossy());
|
||||
let cache_dir = model_dir.join(CANDLE_CACHE_SUBDIR);
|
||||
std::fs::create_dir_all(&cache_dir)
|
||||
.with_context(|| format!("create candle cache dir {}", cache_dir.display()))?;
|
||||
|
||||
let device = select_device();
|
||||
|
||||
// 3. Fetch model files via hf-hub into the candle cache.
|
||||
tracing::info!(
|
||||
target: "kebab-embed-candle",
|
||||
cache_dir = %cache_dir.display(),
|
||||
model = HF_MODEL,
|
||||
"loading candle embedding model (first run downloads ~2GB safetensors)"
|
||||
);
|
||||
let api = hf_hub::api::sync::ApiBuilder::new()
|
||||
.with_cache_dir(cache_dir.clone())
|
||||
.build()
|
||||
.context("kb-embed-candle: build hf-hub api")?;
|
||||
let repo = api.model(HF_MODEL.to_string());
|
||||
let config_path = repo.get("config.json").context("download config.json")?;
|
||||
let tokenizer_path = repo
|
||||
.get("tokenizer.json")
|
||||
.context("download tokenizer.json")?;
|
||||
let weights_path = repo
|
||||
.get("model.safetensors")
|
||||
.context("download model.safetensors")?;
|
||||
|
||||
// 4. Build the candle XLM-RoBERTa model.
|
||||
let cfg_json = std::fs::read_to_string(&config_path)
|
||||
.with_context(|| format!("read {}", config_path.display()))?;
|
||||
let cfg: XlmConfig =
|
||||
serde_json::from_str(&cfg_json).context("kb-embed-candle: parse XLM-R config")?;
|
||||
|
||||
// Validate dim BEFORE building the model so a misconfigured
|
||||
// `dimensions` fails cheaply (matches FastembedEmbedder's contract).
|
||||
check_dim(cfg.hidden_size, config.models.embedding.dimensions)?;
|
||||
|
||||
let vb = unsafe {
|
||||
VarBuilder::from_mmaped_safetensors(&[weights_path], DType::F32, &device)
|
||||
.context("kb-embed-candle: mmap safetensors")?
|
||||
};
|
||||
let model =
|
||||
XLMRobertaModel::new(&cfg, vb).context("kb-embed-candle: build XLMRobertaModel")?;
|
||||
|
||||
let mut tokenizer = Tokenizer::from_file(&tokenizer_path)
|
||||
.map_err(|e| anyhow::anyhow!("kb-embed-candle: load tokenizer: {e}"))?;
|
||||
tokenizer
|
||||
.with_padding(Some(PaddingParams {
|
||||
strategy: PaddingStrategy::BatchLongest,
|
||||
..Default::default()
|
||||
}))
|
||||
.with_truncation(Some(TruncationParams {
|
||||
max_length: MAX_LEN,
|
||||
..Default::default()
|
||||
}))
|
||||
.map_err(|e| anyhow::anyhow!("kb-embed-candle: set truncation: {e}"))?;
|
||||
|
||||
tracing::info!(
|
||||
target: "kebab-embed-candle",
|
||||
dimensions = cfg.hidden_size,
|
||||
layers = cfg.num_hidden_layers,
|
||||
"candle embedding model loaded"
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
model: Mutex::new(model),
|
||||
tokenizer,
|
||||
device,
|
||||
model_id: EmbeddingModelId(config.models.embedding.model.clone()),
|
||||
version: EmbeddingVersion(config.models.embedding.version.clone()),
|
||||
dimensions: cfg.hidden_size,
|
||||
batch_size: config.models.embedding.batch_size.max(1),
|
||||
})
|
||||
}
|
||||
|
||||
/// Embed one batch of **already-prefixed** strings (the e5 `query:`/
|
||||
/// `passage:` prefix is applied by the caller [`CandleEmbedder::embed`])
|
||||
/// through the candle pipeline: tokenize → forward → masked mean pool → L2.
|
||||
fn embed_batch(&self, prefixed: &[String]) -> Result<Vec<Vec<f32>>> {
|
||||
let encodings = self
|
||||
.tokenizer
|
||||
.encode_batch(prefixed.to_vec(), true)
|
||||
.map_err(|e| anyhow::anyhow!("kb-embed-candle: encode_batch: {e}"))?;
|
||||
|
||||
let bsz = encodings.len();
|
||||
// `embed` already returns early on empty input and `.chunks()` never
|
||||
// yields an empty slice, so this is currently unreachable — but guard
|
||||
// the index so a future refactor can't turn it into a panic.
|
||||
let Some(first) = encodings.first() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let seq = first.get_ids().len();
|
||||
|
||||
let mut ids = Vec::with_capacity(bsz * seq);
|
||||
let mut mask = Vec::with_capacity(bsz * seq);
|
||||
for enc in &encodings {
|
||||
ids.extend(enc.get_ids().iter().copied());
|
||||
mask.extend(enc.get_attention_mask().iter().map(|&m| m as f32));
|
||||
}
|
||||
|
||||
let input_ids = Tensor::from_vec(ids, (bsz, seq), &self.device)?;
|
||||
let attn_f32 = Tensor::from_vec(mask, (bsz, seq), &self.device)?;
|
||||
let token_type_ids = input_ids.zeros_like()?;
|
||||
|
||||
let hidden = {
|
||||
let guard = self
|
||||
.model
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// forward: (input_ids, attention_mask, token_type_ids, past,
|
||||
// encoder_hidden, encoder_mask)
|
||||
guard.forward(&input_ids, &attn_f32, &token_type_ids, None, None, None)?
|
||||
};
|
||||
|
||||
// attention-mask-weighted mean pooling
|
||||
let mask3 = attn_f32.unsqueeze(2)?; // (b, seq, 1)
|
||||
let summed = hidden.broadcast_mul(&mask3)?.sum(1)?; // (b, hidden)
|
||||
// counts ≥ 1 always: every input is e5-prefixed AND special tokens are
|
||||
// added (encode_batch(_, true)), so no row has an all-zero mask. If that
|
||||
// invariant ever breaks, broadcast_div would emit NaN vectors.
|
||||
let counts = mask3.sum(1)?; // (b, 1)
|
||||
let mean = summed.broadcast_div(&counts)?;
|
||||
|
||||
// L2 normalize
|
||||
let norm = mean.sqr()?.sum_keepdim(1)?.sqrt()?;
|
||||
let normalized = mean.broadcast_div(&norm)?;
|
||||
|
||||
// `.contiguous()` before host copy: broadcast ops can leave a strided
|
||||
// view, which `to_vec2` rejects on the Metal backend (CPU tolerates it).
|
||||
Ok(normalized.contiguous()?.to_vec2::<f32>()?)
|
||||
}
|
||||
}
|
||||
|
||||
impl Embedder for CandleEmbedder {
|
||||
fn model_id(&self) -> EmbeddingModelId {
|
||||
self.model_id.clone()
|
||||
}
|
||||
|
||||
fn model_version(&self) -> EmbeddingVersion {
|
||||
self.version.clone()
|
||||
}
|
||||
|
||||
fn dimensions(&self) -> usize {
|
||||
self.dimensions
|
||||
}
|
||||
|
||||
fn embed(&self, inputs: &[EmbeddingInput<'_>]) -> Result<Vec<Vec<f32>>> {
|
||||
if inputs.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// e5 prefix per §11.3 BEFORE tokenization (same convention as
|
||||
// FastembedEmbedder so the two backends produce comparable vectors).
|
||||
let prefixed: Vec<String> = inputs.iter().map(prefix_input).collect();
|
||||
|
||||
let mut out: Vec<Vec<f32>> = Vec::with_capacity(prefixed.len());
|
||||
for chunk in prefixed.chunks(self.batch_size) {
|
||||
let batch = self.embed_batch(chunk)?;
|
||||
for v in &batch {
|
||||
if v.len() != self.dimensions {
|
||||
anyhow::bail!(
|
||||
"candle returned vector of length {} but adapter expects {}",
|
||||
v.len(),
|
||||
self.dimensions
|
||||
);
|
||||
}
|
||||
}
|
||||
out.extend(batch);
|
||||
}
|
||||
|
||||
debug_assert_eq!(out.len(), inputs.len());
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the e5-prefixed string for one [`EmbeddingInput`]. Free function so
|
||||
/// a unit test can pin the format without loading the model. Byte-identical to
|
||||
/// `kebab-embed-local`'s `prefix_input` — the two backends MUST agree here or
|
||||
/// their vectors diverge.
|
||||
fn prefix_input(input: &EmbeddingInput<'_>) -> String {
|
||||
match input.kind {
|
||||
EmbeddingKind::Document => format!("passage: {}", input.text),
|
||||
EmbeddingKind::Query => format!("query: {}", input.text),
|
||||
}
|
||||
}
|
||||
|
||||
/// Select the compute device. Built with the `metal` feature (Apple Silicon
|
||||
/// GPU), try Metal and fall back to CPU on failure; otherwise CPU. Metal only
|
||||
/// compiles/runs on macOS — the Linux server builds the CPU path. e5-large
|
||||
/// vectors are model-defined, so Metal-produced and CPU-produced embeddings are
|
||||
/// cross-compatible (a Mac can ingest on GPU, the server query on CPU).
|
||||
fn select_device() -> Device {
|
||||
#[cfg(feature = "metal")]
|
||||
{
|
||||
match Device::new_metal(0) {
|
||||
Ok(d) => {
|
||||
tracing::info!(target: "kebab-embed-candle", "candle device = Metal (GPU)");
|
||||
return d;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "kebab-embed-candle",
|
||||
error = %e,
|
||||
"Metal device unavailable; falling back to CPU"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(target: "kebab-embed-candle", "candle device = CPU");
|
||||
Device::Cpu
|
||||
}
|
||||
|
||||
/// Apply a one-shot global rayon thread cap (the NUMA-safety lever). Returns
|
||||
/// `true` if this call set the pool, `false` if it was already initialized
|
||||
/// (cap not applied) or `n_threads == 0`. `#[doc(hidden)] pub` so the
|
||||
/// thread-cap test can drive it without loading the 2GB model.
|
||||
#[doc(hidden)]
|
||||
pub fn apply_thread_cap(n_threads: usize) -> bool {
|
||||
if n_threads == 0 {
|
||||
return false;
|
||||
}
|
||||
rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(n_threads)
|
||||
.build_global()
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Compare model hidden size against the configured dim. Extracted so a unit
|
||||
/// test can exercise the error branch without loading the model.
|
||||
pub(crate) fn check_dim(model_dim: usize, cfg_dim: usize) -> Result<()> {
|
||||
if model_dim != cfg_dim {
|
||||
anyhow::bail!(
|
||||
"dimension mismatch: model={model_dim}, config={cfg_dim}; \
|
||||
update `config.models.embedding.dimensions` to match the model \
|
||||
(or pick a different model)."
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── prefix_input ─────────────────────────────────────────────────
|
||||
// Pin the exact e5 prefix strings; these MUST match
|
||||
// kebab-embed-local::prefix_input or candle vs fastembed parity breaks.
|
||||
|
||||
#[test]
|
||||
fn prefix_document_uses_passage() {
|
||||
let input = EmbeddingInput {
|
||||
text: "hello world",
|
||||
kind: EmbeddingKind::Document,
|
||||
};
|
||||
assert_eq!(prefix_input(&input), "passage: hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_query_uses_query() {
|
||||
let input = EmbeddingInput {
|
||||
text: "hello world",
|
||||
kind: EmbeddingKind::Query,
|
||||
};
|
||||
assert_eq!(prefix_input(&input), "query: hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_handles_empty_text() {
|
||||
let doc = EmbeddingInput {
|
||||
text: "",
|
||||
kind: EmbeddingKind::Document,
|
||||
};
|
||||
let qry = EmbeddingInput {
|
||||
text: "",
|
||||
kind: EmbeddingKind::Query,
|
||||
};
|
||||
assert_eq!(prefix_input(&doc), "passage: ");
|
||||
assert_eq!(prefix_input(&qry), "query: ");
|
||||
}
|
||||
|
||||
// ── check_dim ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn check_dim_passes_for_1024() {
|
||||
check_dim(1024, 1024).expect("matching dims must pass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_dim_rejects_384_vs_1024() {
|
||||
let err = check_dim(384, 1024).expect_err("dim mismatch must error");
|
||||
let msg = format!("{err}");
|
||||
assert!(
|
||||
msg.contains("384") && msg.contains("1024"),
|
||||
"error must mention both dims, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── model guard ──────────────────────────────────────────────────
|
||||
// A non-e5-large model name must fail fast (BEFORE the ~2GB download),
|
||||
// so we never download e5-large yet label its vectors with another name
|
||||
// via model_id() — which would mislabel embedding_version.
|
||||
|
||||
#[test]
|
||||
fn new_rejects_unsupported_model() {
|
||||
let mut config = kebab_config::Config::defaults();
|
||||
config.models.embedding.model = "multilingual-e5-small".to_string();
|
||||
// num_threads defaults to 0, so no global rayon side effect here.
|
||||
// `.err()` (not `expect_err`) avoids requiring `CandleEmbedder: Debug`
|
||||
// — it holds a Mutex/Tokenizer and intentionally derives no Debug.
|
||||
let err = CandleEmbedder::new(&config)
|
||||
.err()
|
||||
.expect("unsupported model must error");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("candle provider currently supports only"),
|
||||
"expected model-guard error, got: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
96
crates/kebab-embed-candle/tests/parity.rs
Normal file
96
crates/kebab-embed-candle/tests/parity.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
//! Parity test (spec §7, `#[ignore]` — needs the ~2GB model + network).
|
||||
//!
|
||||
//! Confirms the candle backend reproduces the onnxruntime `FastembedEmbedder`
|
||||
//! vectors closely enough that no re-index is required (spec D-reindex):
|
||||
//! per-sentence cosine ≥ 0.9999, and reports the dimension-wise max absolute
|
||||
//! difference (the number the re-index decision hangs on).
|
||||
//!
|
||||
//! Run manually:
|
||||
//! CARGO_TARGET_DIR=/build/out/cargo-target/target \
|
||||
//! cargo test -p kebab-embed-candle --release -- --ignored --nocapture
|
||||
//!
|
||||
//! Uses the canonical dogfood config so both backends resolve the same model
|
||||
//! identifiers and cache roots.
|
||||
|
||||
use kebab_config::Config;
|
||||
use kebab_core::{Embedder, EmbeddingInput, EmbeddingKind};
|
||||
use kebab_embed_candle::CandleEmbedder;
|
||||
use kebab_embed_local::FastembedEmbedder;
|
||||
|
||||
const DOGFOOD_CONFIG: &str = "/build/dogfood/config.toml";
|
||||
|
||||
/// Mixed Korean / English parity set (≥ 8 sentences, mirrors the Phase 0 spike).
|
||||
const SENTENCES: &[&str] = &[
|
||||
"The quick brown fox jumps over the lazy dog.",
|
||||
"오늘 날씨가 정말 좋아서 산책을 나가고 싶다.",
|
||||
"Rust is a systems programming language focused on safety and performance.",
|
||||
"벡터 검색은 임베딩 사이의 코사인 유사도를 이용한다.",
|
||||
"Machine learning models require large amounts of training data.",
|
||||
"한국어와 영어가 섞인 문장도 멀티링구얼 모델은 잘 처리한다.",
|
||||
"The capital of France is Paris, a city known for its art and culture.",
|
||||
"이 프로젝트는 로컬 우선 지식 베이스와 검색 증강 생성을 목표로 한다.",
|
||||
"Database indexing dramatically speeds up query performance.",
|
||||
"임베딩 모델을 candle 로 옮기면 NUMA 서버에서 안전하게 돌릴 수 있다.",
|
||||
];
|
||||
|
||||
fn cosine(a: &[f32], b: &[f32]) -> f32 {
|
||||
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
|
||||
let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
dot / (na * nb)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs ~2GB model + network; run manually for the re-index decision"]
|
||||
fn candle_matches_fastembed() {
|
||||
let config = Config::load(Some(std::path::Path::new(DOGFOOD_CONFIG)))
|
||||
.expect("load dogfood config for parity baseline");
|
||||
|
||||
let candle = CandleEmbedder::new(&config).expect("build CandleEmbedder");
|
||||
let fastembed = FastembedEmbedder::new(&config).expect("build FastembedEmbedder");
|
||||
|
||||
// Cover BOTH prefix paths (`passage:` for Document, `query:` for Query) so
|
||||
// a query-side prefix/pooling divergence can't slip through (reviewer note).
|
||||
let inputs: Vec<EmbeddingInput> = SENTENCES
|
||||
.iter()
|
||||
.flat_map(|s| {
|
||||
[EmbeddingKind::Document, EmbeddingKind::Query]
|
||||
.into_iter()
|
||||
.map(move |kind| EmbeddingInput { text: s, kind })
|
||||
})
|
||||
.collect();
|
||||
|
||||
let cv = candle.embed(&inputs).expect("candle embed");
|
||||
let fv = fastembed.embed(&inputs).expect("fastembed embed");
|
||||
|
||||
assert_eq!(cv.len(), fv.len(), "embedding counts must match");
|
||||
assert_eq!(cv.len(), inputs.len(), "one vector per input");
|
||||
assert_eq!(candle.dimensions(), 1024);
|
||||
|
||||
let mut min_cos = f32::INFINITY;
|
||||
let mut max_abs_diff = 0f32;
|
||||
for (i, inp) in inputs.iter().enumerate() {
|
||||
assert_eq!(cv[i].len(), 1024, "candle dim");
|
||||
assert_eq!(fv[i].len(), 1024, "fastembed dim");
|
||||
let c = cosine(&cv[i], &fv[i]);
|
||||
min_cos = min_cos.min(c);
|
||||
let diff = cv[i]
|
||||
.iter()
|
||||
.zip(&fv[i])
|
||||
.map(|(a, b)| (a - b).abs())
|
||||
.fold(0f32, f32::max);
|
||||
max_abs_diff = max_abs_diff.max(diff);
|
||||
let kind = match inp.kind {
|
||||
EmbeddingKind::Document => "doc",
|
||||
EmbeddingKind::Query => "qry",
|
||||
};
|
||||
let preview: String = inp.text.chars().take(36).collect();
|
||||
println!("[{i:>2}] {kind} cos={c:.6} max_abs_diff={diff:.6e} {preview}");
|
||||
}
|
||||
|
||||
println!("PARITY_SUMMARY cosine_min={min_cos:.6} max_abs_diff={max_abs_diff:.6e}");
|
||||
assert!(
|
||||
min_cos >= 0.9999,
|
||||
"candle vs fastembed cosine_min={min_cos:.6} < 0.9999 — investigate before merge"
|
||||
);
|
||||
}
|
||||
32
crates/kebab-embed-candle/tests/thread_cap.rs
Normal file
32
crates/kebab-embed-candle/tests/thread_cap.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
//! Thread-cap test (spec §7). Own integration binary → clean process, so the
|
||||
//! one-shot global rayon pool is initialized exactly once, by us.
|
||||
//!
|
||||
//! Verifies that `apply_thread_cap(4)` sizes the global rayon pool to 4, which
|
||||
//! is the lever that keeps candle's CPU backend NUMA-safe (vs onnxruntime's
|
||||
//! hard-coded 48 intra-op threads).
|
||||
|
||||
use kebab_embed_candle::apply_thread_cap;
|
||||
|
||||
#[test]
|
||||
fn thread_cap_sizes_global_rayon_pool() {
|
||||
// Must run before any other rayon use in this process. As the only test in
|
||||
// this binary that touches rayon, that holds.
|
||||
let applied = apply_thread_cap(4);
|
||||
assert!(applied, "first build_global call should succeed");
|
||||
assert_eq!(
|
||||
rayon::current_num_threads(),
|
||||
4,
|
||||
"global rayon pool must be capped at the requested 4 threads"
|
||||
);
|
||||
|
||||
// A second cap attempt is a no-op (pool already built), not a panic.
|
||||
assert!(
|
||||
!apply_thread_cap(8),
|
||||
"second build_global must report not-applied"
|
||||
);
|
||||
assert_eq!(
|
||||
rayon::current_num_threads(),
|
||||
4,
|
||||
"thread count must stay at the first cap"
|
||||
);
|
||||
}
|
||||
@@ -66,7 +66,8 @@ flowchart TB
|
||||
end
|
||||
subgraph Adapters ["traits + adapters"]
|
||||
embed["kebab-embed<br/>(trait)"]
|
||||
embedlocal["kebab-embed-local<br/>(fastembed)"]
|
||||
embedlocal["kebab-embed-local<br/>(fastembed, default)"]
|
||||
embedcandle["kebab-embed-candle<br/>(candle, NUMA-safe opt-in)"]
|
||||
llm["kebab-llm<br/>(trait)"]
|
||||
llmlocal["kebab-llm-local<br/>(Ollama)"]
|
||||
search["kebab-search"]
|
||||
@@ -92,6 +93,7 @@ flowchart TB
|
||||
app --> sqlite
|
||||
app --> vector
|
||||
app --> embedlocal
|
||||
app --> embedcandle
|
||||
app --> llmlocal
|
||||
app --> search
|
||||
app --> rag
|
||||
@@ -104,6 +106,8 @@ flowchart TB
|
||||
paud --> core
|
||||
pcode --> core
|
||||
embedlocal --> embed
|
||||
embedcandle --> core
|
||||
embedcandle --> config
|
||||
llmlocal --> llm
|
||||
rag --> search
|
||||
rag --> llm
|
||||
@@ -180,6 +184,7 @@ 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-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)
|
||||
|
||||
@@ -107,11 +107,16 @@ respect_markdown_headings = true
|
||||
chunker_version = "md-heading-v1"
|
||||
|
||||
[models.embedding]
|
||||
provider = "fastembed" # "none" 으로 두면 lexical-only — Ollama 불필요
|
||||
provider = "fastembed" # "fastembed"(기본) / "candle"(순수 Rust, NUMA-안전)
|
||||
# / "none"(lexical-only — Ollama 불필요)
|
||||
# ⚠ provider="candle" 사용 시 아래 model/dimensions 도
|
||||
# multilingual-e5-large / 1024 로 바꿔야 함
|
||||
# (candle 은 현재 e5-large 만 지원).
|
||||
model = "multilingual-e5-small"
|
||||
version = "v1"
|
||||
dimensions = 384
|
||||
batch_size = 64
|
||||
num_threads = 0 # candle 전용 CPU 스레드 캡 (0=auto). env KEBAB_EMBED_THREADS 우선.
|
||||
|
||||
[models.llm]
|
||||
provider = "ollama"
|
||||
|
||||
97
docs/release-notes/v0.22.0-draft.md
Normal file
97
docs/release-notes/v0.22.0-draft.md
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
title: kebab v0.22.0 release notes (draft)
|
||||
created: 2026-06-01
|
||||
status: draft
|
||||
release_trigger:
|
||||
- 신규 config surface (provider=candle, num_threads / KEBAB_EMBED_THREADS) — pre-1.0 minor bump
|
||||
- 임베딩 백엔드 다변화 (NUMA-안전 candle provider 추가, opt-in)
|
||||
---
|
||||
|
||||
# kebab v0.22.0 — candle 임베딩 provider (NUMA-안전, opt-in)
|
||||
|
||||
v0.21.1 (config 마이그레이션) 후속 minor release. 듀얼소켓 NUMA 서버에서
|
||||
onnxruntime 의 스레드 하드코딩이 일으키던 ingest 크래시를 피하기 위해, 같은
|
||||
임베딩 모델을 **순수 Rust(candle)** 로 돌리는 opt-in provider 를 추가한다.
|
||||
**기본 동작은 그대로다** — 기존 사용자는 아무것도 바꿀 필요가 없다.
|
||||
|
||||
---
|
||||
|
||||
## 핵심 변경
|
||||
|
||||
### candle 임베딩 provider (`provider = "candle"`)
|
||||
|
||||
**변경 사실.** `[models.embedding].provider` 에 `"candle"` 값이 추가됐다.
|
||||
`"fastembed"`(기본, onnxruntime) / `"candle"`(순수 Rust) / `"none"`(lexical-only)
|
||||
중 하나를 고를 수 있다. candle provider 는 fastembed 와 **완전히 같은 모델**
|
||||
(`intfloat/multilingual-e5-large`, 1024-dim)을 쓰고, e5 prefix → mean pooling
|
||||
→ L2 정규화 파이프라인도 동일하다. 첫 사용 시 safetensors(~2GB)를
|
||||
`{model_dir}/candle/` 아래로 자동 다운로드한다.
|
||||
|
||||
```toml
|
||||
[models.embedding]
|
||||
provider = "candle" # 기본은 "fastembed" — NUMA 서버에서만 candle 권장
|
||||
num_threads = 8 # candle CPU 스레드 캡 (0 = auto = #cores)
|
||||
```
|
||||
|
||||
```bash
|
||||
# env 로도 캡 가능 (config 보다 우선)
|
||||
KEBAB_EMBED_THREADS=8 kebab ingest
|
||||
```
|
||||
|
||||
**Trade-off.** candle 는 순수 Rust 라 onnxruntime 의 네이티브 SIMD 커널보다
|
||||
CPU latency 가 느리다 (Phase 0 스파이크 측정 ~4×). 그래서 **기본값은
|
||||
fastembed 를 유지**하고, candle 은 onnxruntime 가 죽는 NUMA 환경에서만 켜는
|
||||
opt-in 으로 둔다. 단일 워크스테이션 사용자는 fastembed 가 더 빠르다.
|
||||
|
||||
**Mitigation (왜 안전한가).** candle 의 CPU 백엔드는 글로벌 rayon 풀 크기로
|
||||
스레드를 정한다. `num_threads`(또는 env `KEBAB_EMBED_THREADS`)가 그 풀을 한 번
|
||||
캡하므로, onnxruntime 가 하드코딩하던 48 intra-op 스레드 → NUMA 힙 손상 →
|
||||
double-free 경로를 원천 차단한다. NUMA 노드 바인딩이 더 필요하면 `numactl`
|
||||
과 조합한다.
|
||||
|
||||
**Upgrade 절차.** 재색인 **불필요**. candle 과 fastembed 의 벡터는 사실상
|
||||
동일(Phase 0 스파이크 코사인 1.000000)해서 `embedding_version` 을 유지했고,
|
||||
기존 LanceDB 색인을 그대로 재사용한다. provider 를 바꿔도 검색 결과는
|
||||
바뀌지 않는다. 기존 `config.toml` 은 `num_threads` 가 자동으로 `0`(auto)으로
|
||||
채워져 그대로 로드된다 — `kebab config migrate` 도, 수동 편집도 필요 없다.
|
||||
|
||||
---
|
||||
|
||||
## 그 외
|
||||
|
||||
- 신규 crate `kebab-embed-candle` (candle 의존성 트리를 이 crate 에 격리,
|
||||
`kebab-core`/`kebab-config` 외 다른 kebab-* 의존 없음).
|
||||
- Phase 0 feasibility 스파이크(`spike-embed-candle`)는 production 흡수 후 제거.
|
||||
- 문서: README Configuration, `docs/SMOKE.md` config 예시, `docs/ARCHITECTURE.md`
|
||||
crate 그래프/트리에 candle provider 반영.
|
||||
|
||||
## 검증 / 도그푸딩
|
||||
|
||||
- **패리티 (candle vs onnxruntime)**: 동일 e5-large 가중치로 cosine_min =
|
||||
1.000000, 차원별 max 절대오차 = **2.01e-7**. 벡터가 사실상 동일 →
|
||||
`embedding_version` 유지(재색인 0). 재현: `crates/kebab-embed-candle/tests/parity.rs`
|
||||
(`--ignored`).
|
||||
- **전체 도그푸딩 (2026-06-02)**: `provider=candle` 로 도그푸딩 코퍼스 전체
|
||||
재색인 — **997 docs / 23,151 chunks, 에러 0** 완주 (≈9.5 h, 단일소켓 VM).
|
||||
candle 가 23k+ 청크를 메모리 오류 없이 처리함을 확인.
|
||||
- **A1(taskset/numactl) 반증**: NUMA 서버에서 `taskset -c 0-3` 으로 스레드를
|
||||
4개로 묶어도 onnxruntime 은 그대로 죽었다(6/5150 segfault). 스레드 축소는
|
||||
해법이 아니며, **`provider=candle` 만이 실 해법**이다 (candle 은 onnxruntime 을
|
||||
호출하지 않음).
|
||||
- **최종 인수 게이트 (사용자)**: 그 듀얼소켓 NUMA 서버에서 `provider=candle` 로
|
||||
ingest 가 EXIT=0 완주 — 배포·실사용이 이 검증을 겸한다.
|
||||
|
||||
## 성능 노트 (중요)
|
||||
|
||||
candle CPU 임베딩은 onnxruntime 대비 약 **3~4× 느리다** (e5-large/512-tok 의
|
||||
순수-Rust 커널 비용). 측정상 ~1.86 s/chunk, CPU 약 4코어 활용. **이는 의도된
|
||||
트레이드오프** — onnxruntime 이 전 코어를 AVX-512 로 빡빡하게 굴리는 바로 그
|
||||
경로가 NUMA 에서 힙을 손상시켜 죽기 때문이다. "느려도 완주" > "빨라도 크래시".
|
||||
|
||||
- Intel **MKL 가속을 실험했으나 부정 결과**: MKL 은 코어를 더 쓰지만(8~9코어)
|
||||
오히려 38~50% 느렸다(과다구독 + MKL 2020.1 오버헤드). 채택하지 않음.
|
||||
- 더 많은 코어/스레드로는 빨라지지 않는다(병목이 코어 수가 아님). 속도가
|
||||
critical 하면 청크 길이 단축 / 더 작은 모델 / GPU 가 레버다(별도 검토).
|
||||
- 9.5 h 는 **최초 전체 색인 1회 비용**이며, 이후 증분 ingest 는 새/변경 문서만
|
||||
처리해 저렴하다. 단일 워크스테이션(비-NUMA)에서는 기본 `fastembed` 가 더 빠르니
|
||||
candle 은 NUMA 호스트 전용 opt-in 으로 둔다.
|
||||
78
docs/superpowers/specs/2026-06-01-embed-candle-track-spec.md
Normal file
78
docs/superpowers/specs/2026-06-01-embed-candle-track-spec.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Track 1 Spec — candle e5-large 임베딩 provider (NUMA-안전)
|
||||
|
||||
- 날짜: 2026-06-01
|
||||
- 우산: [meta-spec](./2026-06-01-embedding-numa-backends-meta-spec.md) / [meta-plan](./2026-06-01-embedding-numa-backends-meta-plan.md)
|
||||
- 선행: Phase 0 스파이크 PASS+독립검증 (cosine 1.000000, 스레드 캡 가능, latency ~4×). 커밋 76841af.
|
||||
- 브랜치: `feat/embed-candle`
|
||||
|
||||
## 1. 목표
|
||||
|
||||
fastembed(onnxruntime) 의 "intra-op 스레드 48 하드코딩 → NUMA 힙 손상" 을 회피하기 위해, 동일 모델 `multilingual-e5-large` 를 **candle(순수 Rust)** 로 돌리는 임베딩 provider 를 추가한다. opt-in, 품질 중립, NUMA 스레드 캡 가능.
|
||||
|
||||
## 2. 확정 결정 (사용자 승인 2026-06-01)
|
||||
|
||||
- **D-reindex**: `embedding_version` **유지(재색인 0)** 를 목표. 구현 중 candle vs onnxruntime 벡터의 **차원별 max 절대오차**를 측정해 사실상 동일(예: max abs diff < 1e-5)함을 확인하고, 골든 스위트로 회귀 0 을 실측해 확정. 유의미한 차이가 나오면만 version bump + 재색인.
|
||||
- **D-default**: 글로벌 default provider 는 **onnxruntime 유지**, candle 은 **opt-in** (`models.embedding.provider = "candle"`).
|
||||
- **조기 종료**: candle 이 골든 baseline 충족 시 ollama/A2 트랙 생략 (A1 stopgap 문서만 별도).
|
||||
|
||||
## 3. 아키텍처
|
||||
|
||||
- **신규 crate `kebab-embed-candle`** — `kebab_core::Embedder` 구현. candle 의 큰 의존성 트리를 이 crate 에 격리.
|
||||
- 허용 deps: `candle-core`/`candle-nn`/`candle-transformers` (0.10.x), `tokenizers`, `hf-hub`, `kebab-core`, `kebab-config`, `anyhow`, `tracing`. **다른 `kebab-*` 의존 금지**(core/config 외) — design §8 경계.
|
||||
- **주입 분기**: `kebab-app/src/app.rs` 의 `embedder()` (현 :829-837, `FastembedEmbedder::new` 무조건 생성) 를 `config.models.embedding.provider` 로 분기:
|
||||
- `"fastembed"` | `"onnx"` | (빈값/기존) → `FastembedEmbedder` (default, 기존 동작 유지).
|
||||
- `"candle"` → `CandleEmbedder`.
|
||||
- 알 수 없는 값 → 명확한 에러.
|
||||
- **facade 규칙 준수**: UI crate 는 `kebab-app` 만. `kebab-app` 이 `kebab-embed-candle` 의존 추가.
|
||||
|
||||
## 4. CandleEmbedder 동작 (스파이크에서 검증된 파이프라인)
|
||||
|
||||
- 모델: `intfloat/multilingual-e5-large` 의 `model.safetensors` + `config.json` + `tokenizer.json` 을 `hf-hub` 으로 `{model_dir}/candle/` (config `storage.model_dir`) 아래에 캐시.
|
||||
- `candle_transformers::models::xlm_roberta::{Config, XLMRobertaModel}` 로 로드 (CPU `Device::Cpu`).
|
||||
- `embed()`: e5 프리픽스(`query: `/`passage: `, `EmbeddingInput` kind 기준 — `kebab-embed-local` 의 `prefix_input` 규약과 동일) → 토크나이즈(max_len 512, batch-longest 패딩, special tokens) → forward → **attention-mask 가중 mean pooling** → **L2 정규화**.
|
||||
- `dimensions()` = 1024, `model_id`/`model_version` = config 값(기존과 동일 식별자 유지).
|
||||
- **스레드 캡**: config 신규 필드 `models.embedding.num_threads`(u32, 0=auto) + env `KEBAB_EMBED_THREADS`. `CandleEmbedder::new` 에서 `rayon::ThreadPoolBuilder::new().num_threads(n).build_global()` 1회 적용(이미 초기화 시 무시). 0/auto 면 미설정(rayon 기본). NUMA 노드 바인딩은 `numactl`(A1) 과 조합 — 문서화.
|
||||
- `Mutex<XLMRobertaModel>` 또는 forward 가 `&self` 면 불필요 — candle forward 는 `&self` 가능, 단 내부 가변 없으면 `Send+Sync` 보장 확인.
|
||||
|
||||
## 5. config 변경
|
||||
|
||||
- `EmbeddingModelCfg` 에 `num_threads: u32`(default 0) 추가. env `KEBAB_EMBED_THREADS`.
|
||||
- `provider` 허용값 문서화: `fastembed`(default)/`candle`.
|
||||
- default toml + `Config::default()` 갱신, 기존 테스트 영향 확인.
|
||||
|
||||
## 6. 버전/캐스케이드
|
||||
|
||||
- D-reindex 에 따라 `embedding_version` 유지 (벡터 동일). cascade(design §9) 트리거 안 함 — 기존 색인 재사용. (max abs diff 확인 실패 시에만 bump.)
|
||||
- wire schema 변경 없음.
|
||||
|
||||
## 7. 테스트 (산출물)
|
||||
|
||||
- **단위**(`kebab-embed-candle`): `dimensions()==1024`; `embed()` 출력 L2≈1; 빈 입력 빈 출력; 프리픽스 적용 확인.
|
||||
- **패리티 테스트**(`#[ignore]`, 모델 2GB+네트워크 필요): candle vs `FastembedEmbedder` 동일 문장 cosine ≥ 0.9999 + max abs diff 보고. CI 기본 제외, 수동/도그푸딩에서 실행.
|
||||
- **통합**(`kebab-cli` 또는 `kebab-app`): `provider="candle"` 로 소량 fixture ingest → 청크/임베딩 카운트 > 0, 검색 1건 성공. (모델 필요 → `#[ignore]` 또는 feature.)
|
||||
- **스레드 캡**: `num_threads=4` 설정 시 `rayon::current_num_threads()==4` 확인.
|
||||
- **회귀**: 기존 fastembed 경로 default 동작 불변(provider 미지정 시).
|
||||
- clippy `-D warnings`, 빌드 직렬 `-j 4`.
|
||||
|
||||
## 8. 품질 게이트 (머지 전)
|
||||
|
||||
- `kebab-eval` 골든 스위트(`/build/dogfood/golden_queries.yaml`) 를 provider=candle 로 실행 → MRR/hit@k ≥ 현 baseline (회귀 0). [[feedback_search_quality_dogfood]]
|
||||
- 패러프레이즈 robustness(#195/#196) 스폿 확인.
|
||||
|
||||
## 9. 문서/릴리스 (머지 시 동일 PR)
|
||||
|
||||
- README: Configuration 에 `provider=candle` + `num_threads`/`KEBAB_EMBED_THREADS` 추가. SMOKE config 예시 동기화. [[feedback_readme_sync_rule]]
|
||||
- ARCHITECTURE: crate 그래프 + 디렉터리에 `kebab-embed-candle` 추가.
|
||||
- HANDOFF: 머지 후 한 줄(임베딩 백엔드 다변화).
|
||||
- HOTFIXES: 본 날짜 dated entry (NUMA double-free 진단 + candle provider 도입 + 스파이크 패리티 증거).
|
||||
- 버전 bump: 신규 config surface(provider=candle, num_threads) = pre-1.0 minor bump (0.21.1 → 0.22.0), release notes.
|
||||
|
||||
## 10. 범위 밖 / 후속
|
||||
|
||||
- candle crate feature-gate 로 빌드 비용 격리 (후속).
|
||||
- NUMA 노드 자동 바인딩(현재는 numactl 외부 조합).
|
||||
- ollama/A2/A1 트랙 (candle 게이트 통과 시 생략).
|
||||
|
||||
## 11. 잔여 게이트 (사용자 실행, Claude 불가)
|
||||
|
||||
- 그 듀얼소켓 NUMA 서버에서 `provider=candle` 로 5150-doc ingest **double-free 없이 EXIT=0 완주**. PR 머지 전/후 검증 예약. (meta-spec §4.3)
|
||||
@@ -0,0 +1,77 @@
|
||||
# Meta-Plan — NUMA-안전 임베딩 백엔드 실행 계획
|
||||
|
||||
- 날짜: 2026-06-01
|
||||
- 우산 스펙: [2026-06-01-embedding-numa-backends-meta-spec.md](./2026-06-01-embedding-numa-backends-meta-spec.md)
|
||||
- 실행 모델: 트랙별 worktree 격리 + omc teammate (omc-teams, sequential single-team). 트랙 내 단계는 spec → plan → 구현 → 테스트 → PR.
|
||||
|
||||
## 0. 즉시 (본 계획과 병행, 무코드)
|
||||
|
||||
- **A1 stopgap 문서화 + 사용자 제공**: `numactl --cpunodebind=0 --membind=0 kebab ingest` (또는 `taskset -c 0-11`). 현재 불통 해소용. 이건 트랙 4의 산출물 일부지만 지금 바로 안내.
|
||||
- 사용자 NUMA 서버에서 A1 로 5150-doc 완주되는지 1회 확인 → "스레드/NUMA 가 원인" 인과 확정(메타스펙 §1 보강).
|
||||
|
||||
## 1. 트랙 실행 순서 & 게이트
|
||||
|
||||
`candle → ollama → A2 → A1(정식 문서화)`. 한 트랙의 PR open + NUMA 검증 예약 전까지 다음 트랙 미착수.
|
||||
|
||||
**조기 종료 (D1 확정)**: candle 또는 ollama 가 허용 품질(골든 ≥ baseline 무회귀) + NUMA 안전을 만족하면 **거기서 종료**, 이후 트랙 미진행. 둘 다 품질 미달 시에만 A2 → A1 진행. candle 은 동일 e5-large 라 패리티 통과 시 종착 유력.
|
||||
|
||||
### 트랙 1 — candle (`feat/embed-candle`)
|
||||
|
||||
- **Phase 0 — 타당성 스파이크 (게이트, 최우선)**
|
||||
- worktree 에서 candle + candle-transformers 의존성 추가, `xlm_roberta::XLMRobertaModel` 로 `intfloat/multilingual-e5-large` safetensors 로드 (CPU).
|
||||
- 몇 개 문장 임베딩 → (a) onnxruntime e5-large 벡터와 cosine 패리티, (b) CPU latency, (c) `RAYON_NUM_THREADS` 로 스레드 캡 동작, (d) padding_idx 위치 임베딩 정확성.
|
||||
- 산출: 스파이크 리포트(패리티 수치 + latency + 스레드 제어 확인). **통과해야 Phase 1 진행.**
|
||||
- **Phase 1 — spec**: 트랙 spec 작성 (Embedder 구현, config provider="candle", embedding_version, 재색인 절차, 테스트 매트릭스).
|
||||
- **Phase 2 — plan**: 구현 plan.
|
||||
- **Phase 3 — 구현**: `kebab-embed-candle`(신규 crate) 또는 `kebab-embed-local` 내 provider 분기. Embedder 구현 + app.rs 주입 분기 + config.
|
||||
- **Phase 4 — 테스트**: 단위/통합 + 패리티 + 골든. 빌드는 직렬 `-j 4`.
|
||||
- **Phase 5 — PR + 검증**: gitea PR. 사용자 NUMA 서버 5150-doc 완주 + 골든 baseline 확인.
|
||||
|
||||
### 트랙 2 — ollama (`feat/embed-ollama`)
|
||||
|
||||
- spec → plan → 구현(`OllamaEmbedder`: `/api/embed` 호출, provider="ollama", 모델 선택[e5 GGUF 또는 bge-m3]) → 테스트(패리티/골든, 프로세스 격리로 double-free 부재) → PR + NUMA 검증.
|
||||
|
||||
### 트랙 3 — A2 (`feat/embed-ort-direct`)
|
||||
|
||||
- spec → plan → 구현(fastembed 우회, `ort` 세션 직접 + `with_intra_threads(N)` + NUMA affinity, 토크나이즈/mean-pool/L2 재현, provider="onnx" 기본 유지) → 테스트(기존 e5 벡터와 cosine≈1.0, 재색인 0) → PR + NUMA 검증.
|
||||
- **품질-중립 안전망**: 재색인 없이 즉시 default 가능.
|
||||
|
||||
### 트랙 4 — A1 정식화 (`docs/embed-numa-affinity`)
|
||||
|
||||
- 런처 래핑/문서 + (선택) config 노브로 affinity 힌트. README/SMOKE/HOTFIXES 동기화.
|
||||
|
||||
## 2. omc teammate 운용 (메모리 규약 준수)
|
||||
|
||||
- spawn: omc-teams tmux pane + brief 파일. **sequential single-team** (multi-team 동시 spawn 금지).
|
||||
- 모델 라우팅: executor + initial draft + round-1 review = **opus**; closure verify / micro-patch round = **sonnet**. (`OMC_TEAM_ROLE_OVERRIDES` env)
|
||||
- worker spawn 직후 completion polling shell `run_in_background=true` (phase=completed/failed 감지 → main session 자동 알림).
|
||||
- 빌드/테스트 직렬, `-j 4` 기본. `CARGO_TARGET_DIR=/build` 사용 (routinely clean 금지).
|
||||
|
||||
## 3. 워크트리 / 브랜치
|
||||
|
||||
| 트랙 | 브랜치 | worktree |
|
||||
|---|---|---|
|
||||
| 1 candle | `feat/embed-candle` | 신규 |
|
||||
| 2 ollama | `feat/embed-ollama` | 신규 |
|
||||
| 3 A2 | `feat/embed-ort-direct` | 신규 |
|
||||
| 4 A1 | `docs/embed-numa-affinity` | 신규 |
|
||||
|
||||
각 트랙 머지 후 다음 트랙 rebase. 트랙 간 공유 상태 없음(독립 provider).
|
||||
|
||||
## 4. 리스크 레지스터
|
||||
|
||||
- candle Phase 0 패리티 실패 → 트랙 1 강등, ollama 우선.
|
||||
- candle CPU latency 가 onnxruntime 대비 과도 → opt-in provider 로만.
|
||||
- ollama 모델이 e5 아님 → 골든 회귀 가능 → default 승격 보류.
|
||||
- NUMA 검증이 사용자 가용성에 의존 → 각 PR 은 검증 전까지 "merge-pending".
|
||||
- ort rc.9 자체 버그가 A2 에서도 재현 가능성 → A2 스레드 캡으로도 안 죽는지 NUMA 검증 필수.
|
||||
|
||||
## 5. 진행 상태 (라이브)
|
||||
|
||||
- [x] candle 타당성 desk-research (xlm_roberta 모듈 존재 + cembedd 선례) — 2026-06-01
|
||||
- [ ] A1 stopgap 사용자 NUMA 서버 확인
|
||||
- [x] 트랙 1 Phase 0 스파이크 — **VERDICT=PASS** (2026-06-01). cosine min=mean=1.000000(onnxruntime 동일), RAYON 스레드 캡 가능, latency ~4×(67.5 vs 16.8 ms/문장, 4 vs 12 스레드). 커밋 76841af. → **조기 종료 유력**: candle 이 품질 baseline 자동 충족 → ollama/A2/A1 불필요 전망. 잔여 게이트=골든 실측 + NUMA 서버 5150-doc 완주.
|
||||
- [ ] 트랙 1 spec/plan/impl/test/PR (진행)
|
||||
- [ ] 트랙 2 …
|
||||
- [ ] 트랙 3 …
|
||||
- [ ] 트랙 4 …
|
||||
@@ -0,0 +1,102 @@
|
||||
# Meta-Spec — NUMA-안전 임베딩 백엔드 (다중 트랙)
|
||||
|
||||
- 날짜: 2026-06-01
|
||||
- 상태: DRAFT (umbrella)
|
||||
- 범위: `kebab-embed-local` 및 임베더 주입 경로. 4개 트랙의 우산 스펙.
|
||||
- 하위 산출물: 각 트랙은 본 메타스펙을 참조하는 자체 spec(`tasks/` 또는 `docs/superpowers/specs/`)과 plan을 가진다.
|
||||
|
||||
## 1. 문제
|
||||
|
||||
CPU-only Ollama 서버(Intel Xeon Silver 4214 ×2 소켓 = 48 logical, NUMA 2노드)에서 `kebab ingest` 가 매 실행 힙 손상으로 죽는다:
|
||||
|
||||
```
|
||||
ingest [> ] 3/5150 double free or corruption (!prev)
|
||||
중지됨 (core dumped)
|
||||
```
|
||||
|
||||
근본 원인(코드로 확정): fastembed 4.9.1 (`text_embedding/impl.rs:52,80`) 이 ONNX intra-op 스레드를 `available_parallelism()`(=48) 로 **하드코딩**하고 `InitOptions` 에 이를 덮어쓸 API 가 없다. 듀얼소켓 NUMA 에서 onnxruntime(`ort 2.0.0-rc.9`) 스레드풀이 힙을 손상시킨다. 진단 근거: `tasks/HOTFIXES.md` 의 본 날짜 entry + 대화 로그.
|
||||
|
||||
- 모델/디스크/AVX/데이터 문제 아님 (모델 2.08GB 정상, AVX-512 완비). 순수 스레드/NUMA × 네이티브 런타임 버그.
|
||||
- onnxruntime 공식 문서도 듀얼소켓 NUMA 는 intra-op 스레드를 한 노드로 묶으라고 권고.
|
||||
|
||||
## 2. 목표 / 비목표
|
||||
|
||||
목표:
|
||||
- 그 NUMA 서버에서 5150-doc 코퍼스를 **double-free 없이 완주**하는 임베딩 경로 확보.
|
||||
- 검색 품질을 골든 스위트(MRR/hit@k) baseline 이상으로 유지.
|
||||
- `models.embedding.provider` 로 선택 가능한 백엔드들로 구현 (기존 provider 필드 활용).
|
||||
|
||||
비목표:
|
||||
- 랭킹 자동 조정 (별도 보류 결정, `[[project_ranking_deferred]]`).
|
||||
- 임베딩 모델 품질 개선 자체 (NUMA 안정성이 본 과제의 초점).
|
||||
- GPU 경로.
|
||||
|
||||
## 3. 공유 아키텍처
|
||||
|
||||
- 교체 지점은 **단일**: `crates/kebab-app/src/app.rs:836` 의 `FastembedEmbedder::new(&config)`.
|
||||
- 트레이트 표면이 작다: `kebab_core::Embedder` (`traits.rs:127`) — `model_id / model_version / dimensions / embed`. 새 백엔드는 이 4개만 구현.
|
||||
- 설정: `models.embedding.provider` (이미 존재), `model`, `version`, `dimensions`, `batch_size`. 신규로 트랙별 스레드/affinity 노브 추가 가능.
|
||||
|
||||
## 4. 횡단 정책 (모든 트랙 공통)
|
||||
|
||||
### 4.1 embedding_version & 재색인
|
||||
- 벡터가 바뀌면(=candle, ollama) **`embedding_version` bump → 전체 재색인** (design §9 cascade). A2/A1 은 동일 onnxruntime e5-large 라 벡터 불변 → 재색인 불필요.
|
||||
- 재색인 비용/절차를 각 트랙 spec 에 명시.
|
||||
|
||||
### 4.2 품질 검증 (필수 게이트)
|
||||
- 벡터가 바뀌는 트랙은 머지 전 `kebab-eval` 골든 스위트(`/build/dogfood/golden_queries.yaml`) 로 MRR/hit@k 측정, **baseline 이상**이어야 default 승격. baseline 미달이면 opt-in provider 로만 유지.
|
||||
- 패러프레이즈 robustness(#195/#196) 회귀 확인.
|
||||
|
||||
### 4.3 NUMA 서버 검증 (필수 게이트, 사용자 실행)
|
||||
- **결정적 증거는 그 서버에서만 난다 (Claude 접근 불가).** 각 트랙은 사용자가 그 서버에서 5150-doc 코퍼스 ingest 를 **double-free 없이 완주(EXIT=0)** 함을 확인해야 "검증 완료".
|
||||
- 각 트랙 spec 에 사용자-실행 검증 절차(명령 + 기대 출력)를 문서화.
|
||||
|
||||
### 4.4 스레드/NUMA 제어
|
||||
- 각 백엔드가 intra-op/worker 스레드를 캡하고 한 NUMA 노드로 묶을 수 있어야 함. 캡 못 하면 트랙 실패.
|
||||
|
||||
## 5. 트랙
|
||||
|
||||
선호/구현 순서: **candle → ollama → A2 → A1**. (단 A1 은 무코드 stopgap 이라 즉시 문서화해 당장의 불통을 해소; 구현 순서와 별개.)
|
||||
|
||||
| # | 트랙 | 백엔드 | 벡터 변경(재색인) | 핵심 리스크 | 격리 브랜치 |
|
||||
|---|------|--------|----|------|------|
|
||||
| 1 | candle | 순수 Rust (candle `xlm_roberta`) | 예 | XLM-R padding_idx/패리티/CPU 성능 | `feat/embed-candle` |
|
||||
| 2 | ollama | 별 프로세스 (Ollama `/api/embed`) | 예 | 모델이 e5 아님→품질, ingest 가 Ollama 의존 | `feat/embed-ollama` |
|
||||
| 3 | A2 | onnxruntime 직접(`ort` 세션) | 아니오 | fastembed 우회 후 토크나이즈/풀링 재현 정확도 | `feat/embed-ort-direct` |
|
||||
| 4 | A1 | onnxruntime + 실행 래핑(taskset/numactl) | 아니오 | 코드 변경 거의 없음, 문서/런처만 | `docs/embed-numa-affinity` |
|
||||
|
||||
### 5.1 트랙별 테스트 매트릭스 (각 트랙 spec 에서 구체화)
|
||||
|
||||
모든 트랙:
|
||||
- 단위: `embed()` 가 올바른 dim/정규화(L2≈1) 벡터 반환.
|
||||
- 통합: `kebab ingest` 소량 fixture → 청크/임베딩 카운트.
|
||||
- **NUMA 서버 검증**(§4.3): 5150-doc 완주.
|
||||
|
||||
벡터-변경 트랙(candle/ollama) 추가:
|
||||
- 패리티: onnxruntime e5-large 대비 동일 입력 cosine 유사도(가능 시) 또는 골든 스위트 동등성.
|
||||
- 골든: MRR/hit@k ≥ baseline (§4.2).
|
||||
- 재색인 절차 검증.
|
||||
|
||||
벡터-불변 트랙(A2/A1) 추가:
|
||||
- 회귀: 기존 e5-large 벡터와 cosine ≈ 1.0 (A2 는 같은 런타임이라 사실상 동일해야).
|
||||
|
||||
## 6. 결정사항 (확정 2026-06-01)
|
||||
|
||||
- **D1 조기 종료 (사용자 확정)**: 트랙을 선호 순서로 진행하되, candle 또는 ollama 가 **허용 품질 기준 + NUMA 안전**을 만족하면 **거기서 멈춘다** (이후 트랙 미진행). 둘 다 품질이 너무 낮으면 A2 → A1 까지 계속.
|
||||
- **허용 품질 기준**: 골든 스위트 MRR/hit@k 가 현 e5-large(onnxruntime) baseline 대비 유의미한 회귀 없음. candle 은 동일 e5-large 가중치라 패리티 통과 시 이 기준을 거의 자동 충족 → candle 이 종착 가능성 높음. ollama 는 모델이 달라 경계선이면 사용자 판단.
|
||||
- A2/A1 은 candle·ollama 둘 다 실패 시의 **fallback** (A2 는 재색인 0 품질-중립).
|
||||
- **D2 즉시 완화**: A1(taskset/numactl) 은 무코드라 본 작업과 무관하게 지금 바로 사용자에게 워크어라운드로 제공.
|
||||
- **D3 메타 산출물 위치**: 본 메타스펙 + 메타플랜은 `docs/superpowers/specs/`. 트랙별 spec 은 도달 시 작성.
|
||||
- **D4 frozen design 영향**: 임베딩 백엔드 다변화는 design §(임베딩) 갱신 가능 — 트랙 머지 시 동기화.
|
||||
|
||||
## 7. 성공 기준
|
||||
|
||||
- 그 NUMA 서버에서 최소 1개 트랙이 5150-doc 완주(EXIT=0).
|
||||
- default 로 승격되는 백엔드는 골든 baseline 이상.
|
||||
- 각 트랙이 자체 브랜치/워크트리 + 문서화된 테스트로 독립 검증.
|
||||
|
||||
## 8. 시퀀싱 게이트
|
||||
|
||||
1. candle **스파이크**(Phase 0) 가 패리티+CPU 성능+스레드 제어를 입증해야 candle 본 구현 진행. 실패 시 candle 트랙 강등/스킵 후 ollama 로.
|
||||
2. 각 트랙은 PR open + NUMA 서버 검증 예약 후 다음 트랙 시작 (omc-teams sequential single-team 제약).
|
||||
3. 벡터-변경 트랙은 골든 게이트 통과 전 default 승격 금지.
|
||||
@@ -14,6 +14,121 @@ historical contract that was implemented; this file accumulates the
|
||||
deltas so phase 5+ readers can find the live behavior without diffing
|
||||
git history.
|
||||
|
||||
## 2026-06-02 — ingest 백엔드/디바이스 표시 + KB 이전 문서 (v0.23.1)
|
||||
|
||||
**동기.** Metal 빌드가 실제로 GPU 를 쓰는지 사용자가 터미널에서 못 봐서 Activity
|
||||
Monitor 로 확인해야 했다(`select_device()` 의 device 로그는 kb.log 파일로만, 기본
|
||||
EnvFilter=warn 이라 `--verbose` 필요). 또 "어떤 DB 파일을 옮기나" 가 README 에
|
||||
구체적이지 않았다.
|
||||
|
||||
**무엇.** (1) `kebab-cli` ingest 시작 시 임베딩 백엔드/모델/차원을 stderr 한 줄로
|
||||
표시(`임베딩 백엔드: candle (Metal/GPU 빌드) · 모델 …`), `--json`/`--quiet` 에선
|
||||
억제. Metal 표기는 `cfg!(feature="embed_metal")` 기반(빌드 사실); 확정 런타임
|
||||
디바이스는 여전히 kb.log(`candle device = …`). (2) README "외부 계산 + 로컬 검색"
|
||||
절에 복사 대상 2개(`kebab.sqlite`/`sqlite`, `lancedb/`/`vector_dir`)와 `[storage]`
|
||||
config 키·`models/`·`assets/` 복사 불필요·동일 버전/모델 조건·rsync 예시 추가.
|
||||
|
||||
**범위.** CLI 출력 + 문서만. 동작·wire·schema·벡터 변경 없음. 버전 0.23.0 → 0.23.1.
|
||||
|
||||
## 2026-06-02 — candle Metal(Apple Silicon GPU) opt-in build feature
|
||||
|
||||
**동기.** candle CPU 임베딩은 e5-large/512-tok 에서 ~1.5~1.9 s/chunk 로 느리고,
|
||||
코어를 더 줘도(rayon/MKL) 안 빨라진다(병목=커널 효율). 대용량 코퍼스(수만 청크)는
|
||||
CPU 로는 수 시간. 사용자 워크플로: **M4 Pro 맥에서 GPU 로 빠르게 색인 → sqlite +
|
||||
lancedb 만 Linux NUMA 서버로 복사 → 서버는 CPU candle 로 질의** (벡터 동일 모델이라
|
||||
호환, KB 이식성은 06-01 항목 + workspace_path 상대경로 + chunks.text 저장으로 확인).
|
||||
|
||||
**무엇.** `kebab-embed-candle` 에 `metal` feature 추가 →
|
||||
`candle-core/-nn/-transformers` 의 metal 백엔드 활성. `select_device()` 가 metal
|
||||
빌드 시 `Device::new_metal(0)` 선택(실패 시 CPU fallback), 비-metal 빌드는 기존
|
||||
`Device::Cpu` 그대로. host 복사 전 `.contiguous()` 추가(Metal 의 strided view 가
|
||||
`to_vec2` 거부 — CPU 는 허용). feature passthrough: `kebab-app/embed_metal` →
|
||||
`kebab-cli/embed_metal`. 빌드: `cargo build --release --features embed_metal`(macOS).
|
||||
|
||||
**제약 / 검증 분담.** metal 은 **macOS 전용 컴파일** — Linux CPU 머신(개발/서버)은
|
||||
비-metal 경로만 빌드(검증: clippy 0 + candle 단위 6 + thread_cap + parity, exit 0).
|
||||
**Metal 실행·속도·벡터 패리티(GPU vs CPU)는 M4 Pro 에서 사용자 검증** (Claude 의
|
||||
Linux 환경에서 불가). 로그 `candle device = Metal (GPU)` 로 GPU 사용 확인.
|
||||
|
||||
**호환성.** default(비-metal) 동작·벡터 불변. wire/schema 변경 없음. 버전 0.22.0 →
|
||||
**0.23.0** (신규 opt-in build feature surface).
|
||||
|
||||
amends: `docs/superpowers/specs/2026-06-01-embed-candle-track-spec.md` (§10 후속 — GPU 가속).
|
||||
|
||||
## 2026-06-01 — candle 임베딩 provider (NUMA double-free 회피, opt-in)
|
||||
|
||||
**무엇이 문제였나.** 듀얼소켓 NUMA 서버에서 `provider=fastembed`(onnxruntime)로
|
||||
대규모 ingest(5150-doc)를 돌리면 onnxruntime 가 intra-op 스레드를 48개로
|
||||
하드코딩해 NUMA 힙을 손상시키고 double-free 로 프로세스가 죽었다. 스레드 수를
|
||||
config 로 줄일 surface 가 없었고, fastembed 4.9 의 ORT 바인딩은 이를 노출하지
|
||||
않는다.
|
||||
|
||||
**진단 / 결정 (사용자 승인 2026-06-01).** 같은 모델
|
||||
`intfloat/multilingual-e5-large` 를 **candle(순수 Rust)** 로 돌리는 임베딩
|
||||
provider 를 추가하기로 결정. candle 의 CPU 백엔드는 글로벌 rayon 풀 크기로
|
||||
스레드를 정하므로, 한 번의 `rayon::ThreadPoolBuilder::build_global` 캡으로
|
||||
스레드를 NUMA-안전한 수로 묶을 수 있다. **재색인 0 목표**(`embedding_version`
|
||||
유지) — Phase 0 스파이크(커밋 76841af)가 candle vs onnxruntime **코사인
|
||||
1.000000** 패리티를 입증했고, 본 Track 1 구현의 패리티 테스트로 차원별 max
|
||||
절대오차를 재실측해 확정.
|
||||
|
||||
**무엇을 건드렸나.**
|
||||
- 신규 crate `crates/kebab-embed-candle` — `kebab_core::Embedder` 구현
|
||||
(`CandleEmbedder`). 스파이크 파이프라인(safetensors via hf-hub → XLM-RoBERTa
|
||||
forward → attention-mask mean pooling → L2 → e5 prefix)을 production 으로
|
||||
흡수. deps 는 candle 트리를 이 crate 에 격리 (core/config 외 다른 kebab-*
|
||||
의존 0 — design §8 경계). 모델 캐시 `{model_dir}/candle/`.
|
||||
- 스레드 캡: `[models.embedding].num_threads`(u32, default 0=auto) + env
|
||||
`KEBAB_EMBED_THREADS`(우선). `CandleEmbedder::new` 에서 n>0 이면 글로벌 rayon
|
||||
풀 1회 캡(이미 init 시 no-op).
|
||||
- 주입 분기: `kebab-app::App::embedder()` 가 `config.models.embedding.provider`
|
||||
분기 — `fastembed`/`onnx`/(빈값) → 기존 `FastembedEmbedder`(동작 불변),
|
||||
`candle` → `CandleEmbedder`, 미지값 → 에러. `none` 은 기존 lexical-only 유지.
|
||||
- 스파이크 crate `crates/spike-embed-candle` 제거(학습은 production 으로 흡수됨).
|
||||
- 버전 0.21.1 → **0.22.0** (신규 config surface — pre-1.0 minor bump).
|
||||
|
||||
**패리티 증거.** candle vs `FastembedEmbedder`(onnxruntime), 동일 10문장
|
||||
(한/영 혼합, e5 `passage:`/`query:` prefix): **cosine_min = 1.000000,
|
||||
차원별 max 절대오차 = 2.01e-7** (f32 커널 반올림 수준 — 랭킹 영향 임계보다
|
||||
약 50배 작음). 재현: `cargo test -p kebab-embed-candle --release -- --ignored
|
||||
--nocapture` (`crates/kebab-embed-candle/tests/parity.rs`, 모델 ~2GB 필요라
|
||||
CI 기본 제외). 이 수치가 `embedding_version` 유지(재색인 0) 결정의 근거.
|
||||
|
||||
**호환성.** fastembed default 경로의 동작/벡터 불변. `embedding_version`
|
||||
유지 → 기존 색인 재사용(재색인 0). wire schema 변경 없음. 옛 config.toml 은
|
||||
`num_threads` 가 serde default(0)로 채워져 그대로 파싱.
|
||||
|
||||
**잔여 게이트 (사용자 실행, Claude 불가).** 그 듀얼소켓 NUMA 서버에서
|
||||
`provider=candle` 로 ingest 가 double-free 없이 EXIT=0 완주하는지 — 사용자
|
||||
배포·실사용이 곧 이 검증을 겸한다 (meta-spec §4.3).
|
||||
|
||||
**도그푸딩 (2026-06-02, 단일소켓 12-thread VM).** `provider=candle` +
|
||||
`config-candle.toml`(expansion off — 임베더 격리) 로 `/build/dogfood/corpus`
|
||||
전체 재색인: **scanned=998, new=997, errors=0, stderr=0, KB 997 docs /
|
||||
23,151 chunks**, duration ≈ 34,329 s (9.5 h). candle 가 23k+ 청크를 메모리
|
||||
오류 0 으로 완주 — onnxruntime 이 서버에서 6/5150 에 죽던 것과 정반대.
|
||||
(이 VM 은 비-NUMA 라 NUMA 자체 재현은 아니나, candle 은 onnxruntime 을
|
||||
호출하지 않으므로 동일 크래시 종류가 구조적으로 불가.)
|
||||
|
||||
**A1(taskset/numactl) 워크어라운드 실서버 반증 (2026-06-02).** 사용자가 NUMA
|
||||
서버에서 `taskset -c 0-3 kebab ingest`(fastembed/onnx 바이너리) 실행 → 4코어로
|
||||
제한했는데도 6/5150 에서 `세그멘테이션 오류 (core dumped)`. 스레드 축소가
|
||||
onnxruntime 힙 손상을 제거하지 못함(크래시 위치만 3→6 이동). 결론: 이 크래시는
|
||||
스레드 *수* 문제가 아니라 onnxruntime 네이티브 코드의 메모리 안전 결함 →
|
||||
**A1 은 신뢰 불가 우회책. candle(onnxruntime-free)이 유일한 실 해법.**
|
||||
|
||||
**MKL 가속 부정 결과 (2026-06-02).** "candle 이 코어를 더 쓰게" 하려고 candle
|
||||
`mkl` feature(Intel MKL) 를 벤치 (e5-large, 512-tok 청크, N=32):
|
||||
pure-Rust 1857 ms/chunk(381% CPU) vs MKL 2574 ms/chunk(896% CPU, rayon12+mkl12)
|
||||
/ 2792 ms/chunk(817% CPU, rayon1+mkl12). **MKL 은 코어를 더 쓰지만 모든 설정에서
|
||||
38~50% 더 느림** (MKL 2020.1 sgemm + 스레드 오버헤드/과다구독; candle 0.10.2 는
|
||||
f16 `hgemm_` 미해결로 링크도 실패 — 벤치는 호출 안 되는 스텁으로 우회). 또
|
||||
pure-Rust 는 rayon 8↔12 간 throughput 불변(~1.86 s/chunk) — 병목은 코어 수가
|
||||
아니라 candle e5-large/512tok 커널 효율. **결론: MKL 미채택, 순수-Rust 유지(안전
|
||||
최상 + CPU 에서 더 빠름). 속도 레버는 코어가 아니라 청크 길이/모델 크기/GPU.**
|
||||
|
||||
amends: `docs/superpowers/specs/2026-06-01-embed-candle-track-spec.md`.
|
||||
|
||||
## 2026-05-31 — config 마이그레이션 (`kebab config migrate`)
|
||||
|
||||
**Trigger**: config.toml 스키마가 진화해도(v0.21.0 의 `[ingest.expansion]` 등) 기존 사용자 파일은 serde default 로 *동작*만 호환될 뿐 새 섹션이 파일에 안 써져 사용자가 노브의 존재를 알 수 없었다. DB 의 V00X refinery 와 달리 config 엔 마이그레이션 메커니즘이 없어 추가. 설계 `docs/superpowers/specs/2026-05-31-config-migration-design.md`, 계획 `docs/superpowers/plans/2026-05-31-config-migration.md`, PR #198.
|
||||
|
||||
Reference in New Issue
Block a user