마지막 commit. 모든 .md 안의 `kb` 단어 일괄 갱신. - 19 개 crate 이름 (`kb-core`, `kb-app`, …) → `kebab-*` (Rust 모듈 path 표기 `kb_*` → `kebab_*` 포함). - 미래 component (`kb-tui`, `kb-desktop`, `kb-asr-whisper`, `kb-ocr`, `kb-mcp`, `kb-vlm`, `kb-rerank`, `kb-vision-ocr`, `kb-index`, `kb-smoke`, `kb-architecture`) → `kebab-*` (P6+ 가 시작될 때 같은 prefix 사용). - CLI 명령 예제: `kb ingest` / `kb search` / `kb ask` / `kb init` / `kb doctor` / `kb inspect` / `kb list` / `kb eval` → `kebab <verb>`. fenced code block + 인라인 backtick 모두. - XDG paths + env vars + binary 경로 (`target/release/kb` → `target/release/kebab`) 동기화. - design doc / 최초 보고서 / SMOKE / HOTFIXES / phase epic / task spec 모든 reference 통일. - task-decomposition.md 의 `git -c user.name=kb` 는 과거 git history 기록용 author 정보라 그대로 유지 (실제 git history 의 author 는 변경 불가). - `tasks/phase-5-evaluation.md` 의 `status: planned` → `completed` 도 같이 (P5-1 + P5-2 PR 머지 후 미반영분). ## 검증 - `grep -rEn "\bkb-[a-z]|\bkb_[a-z]|\.config/kb\b|kb\.sqlite|\bKB_[A-Z]" --include="*.md"` 0 hits (task-decomposition.md 의 git author 제외). - 모든 file path reference 살아있음 (renamed file 들 모두 새 path 로 update). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6.0 KiB
phase, title, status, depends_on, source
| phase | title | status | depends_on | source | |
|---|---|---|---|---|---|
| P3 | Local embedding + LanceDB + hybrid search | completed |
|
kebab_local_rust_report.md §10, §11, §15, §17 Phase 3 |
P3 — Local embedding + LanceDB + hybrid search
목표
local embedding 으로 chunk vector 화 → LanceDB 저장 → vector 검색 + lexical 융합 (hybrid). kebab search --mode {lexical,vector,hybrid} 동작.
산출 crate
| crate | 역할 |
|---|---|
kebab-embed |
Embedder trait + EmbeddingInput/output 타입 |
kebab-embed-local |
fastembed-rs adapter (1차). later: Ollama embed endpoint, candle |
kebab-store-vector |
LanceDB 연동. table 관리, upsert, vector search |
kebab-search |
lexical + vector 병행 + score fusion |
Embedder
pub trait Embedder {
fn model_id(&self) -> &str;
fn dimensions(&self) -> usize;
fn embed_texts(&self, inputs: &[EmbeddingInput]) -> anyhow::Result<Vec<Vec<f32>>>;
}
pub struct EmbeddingInput<'a> {
pub text: &'a str,
pub kind: EmbeddingKind, // Document | Query
}
- query 와 document 분리 prompt (e5 계열은 prefix 다름).
- batch_size config 화.
- 동기 인터페이스. 내부에서 ONNX runtime 사용.
기본 모델: multilingual-e5-small (config 가능). 차원/모델 ID 는 record 에 항상 같이 저장.
LanceDB schema
table: chunk_embeddings
chunk_id : utf8 (primary)
doc_id : utf8
embedding : fixed-size-list<float32, D>
model_id : utf8
embedding_version : utf8
text : utf8 # 미리보기/rerank 용
heading_path: utf8
created_at : timestamp
- D 는 모델 차원. 모델 변경 시 새 table (
chunk_embeddings_<model_id>) 로 분리. mix 금지. - index: IVF_PQ 또는 cosine flat. 코퍼스 < 100K chunk 면 flat 으로 충분.
- LanceDB Rust SDK 사용 (
lancedbcrate).
Indexing job
kebab index --embeddings [--model <id>] [--batch-size N] [--resume]
- chunk 중
embedding_id = chunk_id + model_id + dim가 vector store 에 없는 것만 처리. - resume: 마지막 처리된 chunk_id checkpoint (
jobstable). - LLM generation 동시 실행 시 batch_size / 병렬도 낮춤 (config
models.embedding.batch_size, §12).
Hybrid search
pub enum SearchMode { Lexical, Vector, Hybrid }
Hybrid 점수 융합 (1차): RRF (Reciprocal Rank Fusion).
raw(chunk) = sum_over_methods( 1 / (k_rrf + rank_method(chunk)) )
fusion_score = raw / (num_retrievers / (k_rrf + 1)) # ∈ [0, 1]
k_rrf 기본 60.
이유: bm25 score 와 cosine sim 의 절대값 스케일이 다름. RRF 는 rank 기반이라 안정적.
정규화 (2026-05 hotfix): raw RRF top score 가 num_retrievers / (k_rrf+1) (k_rrf=60에서 ≈ 0.0328) 로 bounded 라 lexical / vector 의 [0, 1] 점수와 incomparable 했고 config.rag.score_gate default 0.05 와도 incompatible (모든 hybrid query 가 ScoreGate refusal). 2/(k_rrf+1) 로 나눠서 fusion_score 가 모든 mode 에서 [0, 1] 로 정렬되게 함. 자세한 이력은 HOTFIXES.md 참조.
P3 범위에선 reranker 미도입 (P+ 단계 노트).
kebab-search 구조
pub struct HybridRetriever {
lexical: Box<dyn Retriever>,
vector: Box<dyn Retriever>,
fusion: FusionPolicy,
}
- 각 sub retriever 는
Retrievertrait 구현. kebab-app::search가 mode 따라 dispatch.
kebab-app facade 확장
P3 동안 kebab-app facade 의 ingest / search / list_docs / inspect_doc / inspect_chunk 의 stub 본체를 실제 라이브러리 호출로 대체. P0 부터 시그니처는 frozen 이므로 signature 변경 없이 body 만 swap.
pub fn ingest(scope: SourceScope, summary_only: bool) -> anyhow::Result<IngestReport>; // p3-5
pub fn search(query: SearchQuery) -> anyhow::Result<Vec<SearchHit>>; // p3-5
pub fn list_docs(filter: DocFilter) -> anyhow::Result<Vec<DocSummary>>; // p3-5
pub fn inspect_doc(id: &DocumentId) -> anyhow::Result<CanonicalDocument>;// p3-5
pub fn inspect_chunk(id: &ChunkId) -> anyhow::Result<Chunk>; // p3-5
pub fn ask(query: &str, opts: AskOpts) -> anyhow::Result<Answer>; // p4-3 (stub remains)
p3-5 는 LLM 미관여 facade 모두 (ask 제외) 를 한 번에 wire. 이후 cargo run -p kebab-cli -- index 와 cargo run -p kebab-cli -- search --mode {lexical,vector,hybrid} 가 실 동작.
CLI
kebab index --embeddings
kebab search --mode vector "비슷한 설계 원칙"
kebab search --mode hybrid "Markdown chunking 규칙"
테스트
- embedding determinism: 동일 입력 + 동일 모델 → 동일 vector (within fp tolerance).
- vector search smoke: fixture corpus 에서 paraphrase query 로 의도한 chunk 회수.
- hybrid 가 lexical 단독보다 hit@k 높음 (golden query 일부로 sanity check, 본격 측정은 P5).
- embedding_id collision 없음.
- 모델 교체 시 별도 table 분리 동작.
의존성 경계
kebab-embed-local만 ONNX/모델 binding 의존. 다른 crate 는 trait 만 사용.kebab-store-vector는lancedb의존. SQLite 와 cross-write 금지 (각 store 책임 분리).- LLM crate 와 분리 (§11.1).
완료 조건
kebab index(=kebab-app::ingest) 로 모든 chunk 가 SQLite + LanceDB 에 저장 (p3-5)kebab search --mode vector정상 hitkebab search --mode hybrid정상 hit, citation 포함- 모델/차원 변경 시 별도 table 로 분리 저장
- resume 시 미완료 chunk 만 처리 (P+ 로 deferred)
- hit@k 측정 가능한 형태로 결과 구조화 (P5 준비)
kebab-app::{ingest,search,list_docs,inspect_doc,inspect_chunk}가 실 동작 (ask는 P4-3 까지 stub) — p3-5
리스크 / 주의
- 모델 차원 변경 = vector index 호환 안 됨. 새 table 필수.
- M4 48GB 에서 LLM 과 embedding 동시 실행 시 thermal throttle 가능 (§12). embedding 은 background priority.
- RRF k_rrf 튜닝은 golden set 생기기 전엔 의미 없음. 기본값 고정.
- e5 query/document prefix 빠뜨리면 품질 급락. adapter 에서 강제.