feat(p3-4): hybrid-fusion — VectorRetriever + HybridRetriever (RRF) #17
Reference in New Issue
Block a user
Delete Branch "feat/p3-4-hybrid-fusion"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
변경 요약
P3-4 hybrid-fusion 작업입니다. 기존
kb-search크레이트 (P2-2의LexicalRetriever)를 확장해서 새로운VectorRetriever래퍼와HybridRetriever메디에이터를 추가합니다. P3 마지막 task —kb search --mode hybrid경로가 lexical + vector 두 신호를 RRF로 융합해서 동작하게 됩니다.무엇을 했는가
VectorRetrieverArc<dyn VectorStore + Send + Sync>+Arc<dyn Embedder>+Arc<SqliteStore>+IndexVersion을 들고 있다가:query.text을EmbeddingKind::Query으로 임베딩.VectorStore::search(query_vec, query.k * 2, &query.filters)호출 — 필터 손실 보정으로 ×2 over-fetch. LanceVectorStore가 내부적으로filter_chunks을 적용하므로 필터는 자연스럽게 전파.VectorHit을 SQLite IN-clause batch lookup (N+1 회피)으로 hydrate —documents.workspace_path(doc_path),chunks.section_label/source_spans_json/chunker_version, embedder의model_id().config.search.snippet_chars기준으로 trim. vector mode는 FTS5 highlighting이 없어서 prefix가 best-effort.lexical.rs에서 추출한 새citation_helper.rs모듈에 lexical과 같은 로직을 공유. Byte/empty array fallback (Line { 1, 1 }+tracing::warn)도 동일.RetrievalDetail.method = Vector.fusion_score과vector_score은 LanceVectorStore의 shift된 cosine score.lexical_*None.VectorRetriever::newsignature가 spec 표면 (line 85)의(store, embed, sqlite)에서index_version파라미터를 추가한 형태로 변경되었습니다.dyn VectorStore트레이트 객체로는LanceVectorStore에 저장된IndexVersion을 노출할 수 없고, embedder의model_id + dim으로 derive하면 의미가 흐릿해집니다 —LexicalRetriever::new(store, index_version)의 명시적 파라미터 패턴을 그대로 따른 결정.HybridRetrieverlexical.search/vector.search로 1:1 위임. 각 retriever가 이미 올바른RetrievalDetail을 채워서 반환하므로 재구성 없음.query.k * 2로 fanout 호출 (disjoint set이 모두 fusion에 참여할 수 있게).score(c) = Σ 1/(k_rrf + rank_m(c)).k_rrf은config.search.rrf_k(default 60). 한쪽에만 등장하는 chunk는 그 쪽 term만 합산.(rrf_score DESC, lex_rank ASC, chunk_id ASC). 동점 두 청크가 발생해도 결정적 순서.query.k슬라이스. rank 1..k으로 재할당.RetrievalDetail.method = Hybrid.fusion_score은 합산 RRF,lexical_*/vector_*은 각 retriever가 본 값(없으면 None). 한쪽 single-side chunk는 fallback으로fusion_score을 reuse — 코멘트로 "RRF가 한 term만 더하므로 fusion_score == 그 쪽 normalized score"임을 명시.(0, 2/k_rrf]즉 ~0.033 이하라 f32 정밀도 충분 (코멘트로 명시).kb search --explain의 snippet provenance 규약과 일치 (struct doc-comment에 명시).index_version()은format!(\"hybrid:{}+{}\", lex_iv, vec_iv)반환. 생성자에서lex.index_version() != vec.index_version()이면tracing::warn!으로 "stale index 가능성" 신호 (spec line 143).FusionPolicy향후 RRF 외 fusion 알고리즘 (CombSUM, weighted, learned-to-rank 등)을 추가할 때 enum variant로 확장.
테스트
1/61 + 1/62 ≈ 0.03254을f32::EPSILON * 10.0tolerance로 검증 (이전1e-7은 f32 epsilon 아래라 fragile했음 — 리뷰 NIT 반영).#[ignore]통합 라인 (3건, AVX 필요): hybrid disjoint-corpus recall (lex=[A,B], vec=[C,D] → hybrid=[A,B,C,D]), 동일 query 두 번 결정성, snapshot stability. 모두 P3-3에서 host-passthrough된 VM에서 검증되었습니다.crates/kb-search/tests/fixtures/search/hybrid/run-1.json— AVX VM에서 regenerate된 실제 4-row 결과 (c1/c2 lex+vec 양쪽, c3/c4 vec-only). placeholder 아님.KB_UPDATE_SNAPSHOTS=1정책: regen 후 silent return이 아니라eprintln!+ panic — P3-2/P3-3와 동일한 fail-loud-instead-of-silent-pass 철학.의존성
Allowed deps 준수:
kb-core,kb-config,kb-store-sqlite,kb-store-vector,kb-embed(trait only) + 기존 P2-2 의존성 (rusqlite,globset,serde_json,anyhow,tracing,thiserror).Forbidden deps 준수 — 특히
kb-embed-local(concrete fastembed adapter)이 의존 그래프에 등장하지 않습니다.VectorRetriever은Arc<dyn Embedder>트레이트 객체로 받아서kb-app이 런타임에 주입.변경 파일
crates/kb-search/Cargo.toml(kb-store-vector,kb-embed추가;kb-embed의mockfeature은 dev-deps에만)crates/kb-search/src/lib.rs(mod 등록 + re-export)crates/kb-search/src/lexical.rs(citation 헬퍼 추출 — 동작 무변)crates/kb-search/src/citation_helper.rs(신규)crates/kb-search/src/vector.rs(신규 —VectorRetriever)crates/kb-search/src/hybrid.rs(신규 —HybridRetriever+FusionPolicy)crates/kb-search/tests/common/mod.rs(신규 —HybridEnvscaffolding +require_avx_or_panic)crates/kb-search/tests/hybrid.rs(신규 — 3 ignored 통합)crates/kb-search/tests/fixtures/search/hybrid/run-1.json(regenerate된 실 fixture)후속 작업 후보
require_avx_or_panic이 kb-store-vector와 kb-search 두 곳에 duplicated —kb-test-utils같은 작은 dev 크레이트로 promote (P+ refactor).expand_path헬퍼 promote (P3-2/P3-3 follow-up과 함께).kb search --explain출력 일관성을 위해 snippet 정책을 한 곳으로 모을 후보.Out of scope
P3 단계 완료 — chunk → embed → vector index → lexical/vector/hybrid 검색 경로가 모두 ���결되었습니다. 다음은 P4 (LLM trait + Ollama adapter + RAG pipeline).
design §3.7, §6.4 search, §0 Q3, §1.6 참고.
Composes the existing LexicalRetriever (P2-2) with a new VectorRetriever wrapper around LanceVectorStore (P3-3) into a single Retriever that dispatches by SearchMode. For SearchMode::Hybrid, fuses lexical and vector candidates via Reciprocal Rank Fusion and populates the full RetrievalDetail per SearchHit so kb search --explain can attribute scores back to each side. Public surface (kb-search crate): - pub struct VectorRetriever — Arc<dyn VectorStore + Send + Sync>, Arc<dyn Embedder>, Arc<SqliteStore>, IndexVersion at construction. - pub struct HybridRetriever { lexical, vector, fusion, k }. - pub enum FusionPolicy { Rrf { k_rrf: u32 } }. VectorRetriever: - Embeds query.text as EmbeddingKind::Query before delegating to VectorStore::search(query_vec, query.k * 2, &query.filters). Over- fetches by ×2 for filter losses; LanceVectorStore applies the filters internally so they propagate naturally. - Hydrates each VectorHit into a full SearchHit by joining on chunk_id in a single IN-clause batch (no N+1): doc_path, section_label, chunker_version, source_spans for citation, plus embedding_model from embedder.model_id(). - Snippet trimmed to config.search.snippet_chars (vector mode lacks FTS5 highlighting; chunk text prefix is the next-best signal). - Citation built from the chunk's first source span via the shared citation_helper module — extracted from lexical.rs so both retrievers compute citations identically (Byte/empty fallback to Line{1,1} preserved with tracing::warn). - RetrievalDetail.method = Vector for standalone calls; both fusion_score and vector_score set to the LanceVectorStore-shifted cosine score; lexical_* None. HybridRetriever: - Lexical / Vector modes delegate 1:1 — no rebuild of RetrievalDetail. - Hybrid mode runs both retrievers with k * 2 fanout, fuses with RRF (score(c) = Σ 1/(k_rrf + rank_m(c))), sorts fused-score DESC with deterministic tiebreaker (lex_rank ASC then chunk_id ASC), takes top query.k. Fusion math runs in f64 throughout; cast to f32 only at the SearchHit boundary where bounded magnitude (≤ ~0.033 at k_rrf=60) makes f32 precision sufficient for ranking. - Per-hit lexical preferred for snippet/citation/heading_path/ chunker_version/embedding_model when the chunk appears in both retrievers — FTS5 highlighting is more user-relevant than vector's truncated text. Vector-only chunks fall through to vector hit data. - index_version returns format!("hybrid:{}+{}", lex_iv, vec_iv) at construction; mismatched lex/vec versions trigger a tracing::warn so users notice stale indexes (spec line 143). kb-search additions: - citation_helper.rs — pub(crate) citation_from_first_span shared between lexical and vector retrievers. Extracted from lexical.rs; no behavior drift. Tests (38 default + 3 ignored): - 12 unit tests in hybrid.rs covering RRF math (1/61 + 1/62 within f32 epsilon × 10 tolerance), lexical/vector mode delegation, hybrid preserves single-side hits with the missing side's RetrievalDetail None, deterministic tiebreaker on identical fused scores, composite index_version, mismatched-version warn at construction. - 2 unit tests in vector.rs covering the snippet-prefix and citation fallback paths. - 11 unit tests in lexical.rs (unchanged from P2-2). - 13 lexical integration tests (unchanged). - 3 #[ignore] AVX-gated hybrid integration tests: disjoint-corpus recall (lex returns A,B; vec returns C,D; hybrid returns all 4), determinism over two queries, snapshot stability against tests/fixtures/search/hybrid/run-1.json. Snapshot fixture was regenerated against this branch on an AVX-enabled VM and contains 4 real chunks (c1/c2 lex+vec, c3/c4 vec-only). - KB_UPDATE_SNAPSHOTS=1 path now panics after writing instead of silently passing — matches the P3-2/P3-3 fail-loud-instead-of- silent-pass philosophy. Allowed deps respected (kb-core, kb-config, kb-store-sqlite, kb-store-vector, kb-embed, tracing, thiserror) plus pre-existing kb-search deps from P2-2 (rusqlite, globset, serde_json, anyhow). kb-embed-local does NOT appear — VectorRetriever takes Arc<dyn Embedder> trait object; the concrete adapter is runtime-injected by kb-app. Out of scope: reranker (P+), score calibration across modes (RRF is rank-comparable so absolute calibration is P+), multimodal retrieval (P6+). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>P3-4 코드 리뷰 — 셀프 머지 게이트로 인해 COMMENT only.
spec compliance + code quality 양쪽 리뷰 모두 BLOCKER / MUST-FIX 0건. 코드 quality NIT 13건 중 11건 (unreachable assertion, redundant clone 제거, f32 캐스트 코멘트 + tolerance loosen, 불필요 max + Vec alloc 제거, to_string 클론 제거, fallback 코멘트, snapshot fail-loud, 죽은 변수 제거, struct doc-comment)을 PR에 반영했습니다. 나머지 2건 (chain idiom 주관적 + kb-test-utils crate promote — P+ refactor)은 후속 작업 후보로 분류.
핵심 포인트:
f64::total_cmp— NaN 트랩 회피 + 결정적 ordering.(None, None)arm을unreachable!()로 바꿔 silent bug 가능성 차단.tracing::warn!으로 충족.citation_helper.rs에 공유 — 두 retriever가 같은 fallback 동작을 보장.kb search --explain의 사용자 contract.워크스페이스 default 261 passed / 22 ignored / 0 failed. clippy clean. 통합 테스트 3건은 host-passthrough된 VM에서 직접 실행 검증 완료.
inline 코멘트는 모두 잘 만든 결정에 대한 노트입니다. 머지 진행해도 됩니다. P3 단계 완료 — 다음은 P4-1 llm-trait.
@@ -0,0 +1,74 @@//! Shared helpers for building `kb_core::Citation` values from alexical과 vector 두 retriever에 같은 citation 로직이 필요한데 P2-2 코드에서 lexical에 inline되어 있던 부분을
pub(crate) citation_from_first_span(...)으로 추출. 동작 drift 없이 (Byte/empty array fallback에tracing::warn!+Citation::Line { 1, 1 }) 재사용 가능. 향후 Region/Caption/Time 변형이 추가되어도 한 곳만 손대면 됩니다.@@ -0,0 +57,4 @@/// For chunks that appear in both retrievers, the lexical-side hit/// supplies `snippet`, `citation`, `heading_path`, `chunker_version`,/// and `embedding_model` — lexical search has FTS5 highlighting that's/// more user-relevant than the vector retriever's truncated text.struct doc-comment에 "chunk가 두 retriever에 모두 등장할 때 lexical hit가 snippet/citation/heading_path 등의 source"라는 규약이 박혀있습니다.
kb search --explain이 user에게 snippet provenance를 설명할 때 이 규약을 참조하므로 lexical-side preference가 단순한 구현 디테일이 아니라 사용자 contract라는 점을 명문화 — 미래에 "vector snippet이 더 좋을 것 같으니 바꾸자"는 PR이 들어오면 이 doc이 먼저 수정되어야 한다는 신호.@@ -0,0 +87,4 @@};// Surface mismatched index_version up front so users see it// (e.g. lexical at v2, vector at v1 means a stale index that// the user should refresh). Spec line 144 calls this out asspec Risks/notes line 143의 "mismatched index_version 발견 시 사용자에게 신호" 요건을 생성자 시점
tracing::warn!으로 충족. trait의Retriever::index_version은 매 호출마다 한 IndexVersion만 반환하므로, 스칼라 사용자에게는 "hybrid:lex+vec" 합성 문자열을 주고 mismatch 자체는 로그로 surface — depth-1 경고로 trade-off 적절합니다.@@ -0,0 +197,4 @@struct Scored {chunk_id: String,rrf: f64,lex_rank: Option<u32>,RRF 핵심 루프가 깔끔합니다. f64 누산 후 boundary에서만 f32 캐스트. RRF 값이 (0, 2/k_rrf]에 bounded되어 있어 (k_rrf=60 default에서 ≤ 0.033) f32 정밀도가 ranking에 충분하다는 점도 코멘트로 명시되어 있어 미래 리더가 "왜 f64를 쓰지 않는가"의 합리적 답을 즉시 얻습니다.
@@ -0,0 +227,4 @@// total_cmp keeps the sort stable under future tweaks).scored.sort_by(|a, b| {b.rrf.total_cmp(&a.rrf)정렬 키
(rrf_score DESC via f64::total_cmp, lex_rank ASC, chunk_id ASC). f32-NaN 트랩 회피를 위해 total_cmp을 쓴 점이 핵심 —partial_cmp은 NaN 입력에서 None을 반환하지만 RRF는 NaN을 만들 수 없기에 total_cmp이 deterministic + safe. 동점 두 청크가 발생해도 lex_rank와 chunk_id로 재현 가능한 순서가 보장됩니다.@@ -0,0 +248,4 @@(Some((_, lex)), _) => lex.clone(),(None, Some((_, vec))) => vec.clone(),// `all_ids` is the union of `lex_index` and// `vec_index` keys, so this arm cannot fire.(None, None)arm을 silentcontinue대신unreachable!()로 바꾼 결정이 정답입니다.all_ids이 union에서 만들어지므로 양쪽 모두 None인 경우는 진정 unreachable인데,continue로 두면 미래에 union 구성이 깨질 때 정렬 결과만 조용히 줄어드는 silent bug가 됩니다. unreachable로 두면 fail-fast ��� 디버깅 비용 절약.@@ -0,0 +217,4 @@.filter(|id| seen.insert(*id)).collect();let placeholders = vec!["?"; unique.len()].join(",");SQLite hydration이
IN (?, ?, ...)단일 쿼리. N개 chunk_id에 대해 N+1 쿼리가 아니라 단일 round-trip.params_from_iter(unique.iter().copied())로&str을 그대로ToSqlbinding하고to_string클론도 회피 — 성능 + 메모리 효율 양쪽 깔끔합니다.@@ -0,0 +1,42 @@[AVX-capable VM (host-passthrough 적용 후)에서 regenerate된 실제 fixture. placeholder 아니고 4-row 실제 결과 (c1/c2가 lex+vec 양쪽에서 surfaced되어 RRF가 두 term 합산, c3/c4는 vec-only로 한 term만). 향후 regression이 발생하면 정확히 이 4-row 분포가 깨지므로 detection이 신뢰 가능합니다.
@@ -0,0 +167,4 @@// philosophy as P3-2's `SNAPSHOT_HASH_BASELINE = 0` and P3-3's// placeholder fixture guards.panic!("[snapshot] regenerated {}, re-run without KB_UPDATE_SNAPSHOTS to verify pin",KB_UPDATE_SNAPSHOTS=1regen 경로가 silent return이 아니라eprintln!+ panic. P3-2 (SNAPSHOT_HASH_BASELINE = 0)과 P3-3 (placeholder_commentmarker)와 일치하는 fail-loud 패턴. CI에서 환경변수가 잘못 설정되어 fixture가 매 run마다 덮어써지는 silent-pass 시나리오를 차단합니다.