feat(p3-4): hybrid-fusion — VectorRetriever + HybridRetriever (RRF) #17

Merged
altair823 merged 1 commits from feat/p3-4-hybrid-fusion into main 2026-05-01 11:26:19 +00:00
Owner

변경 요약

P3-4 hybrid-fusion 작업입니다. 기존 kb-search 크레이트 (P2-2의 LexicalRetriever)를 확장해서 새로운 VectorRetriever 래퍼와 HybridRetriever 메디에이터를 추가합니다. P3 마지막 task — kb search --mode hybrid 경로가 lexical + vector 두 신호를 RRF로 융합해서 동작하게 됩니다.

무엇을 했는가

VectorRetriever

Arc<dyn VectorStore + Send + Sync> + Arc<dyn Embedder> + Arc<SqliteStore> + IndexVersion을 들고 있다가:

  1. query.textEmbeddingKind::Query으로 임베딩.
  2. VectorStore::search(query_vec, query.k * 2, &query.filters) 호출 — 필터 손실 보정으로 ×2 over-fetch. LanceVectorStore가 내부적으로 filter_chunks을 적용하므로 필터는 자연스럽게 전파.
  3. 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().
  4. snippet은 chunk 텍스트를 config.search.snippet_chars 기준으로 trim. vector mode는 FTS5 highlighting이 없어서 prefix가 best-effort.
  5. citation은 chunk의 첫 source span에서 — lexical.rs에서 추출한 새 citation_helper.rs 모듈에 lexical과 같은 로직을 공유. Byte/empty array fallback (Line { 1, 1 } + tracing::warn)도 동일.
  6. RetrievalDetail.method = Vector. fusion_scorevector_score은 LanceVectorStore의 shift된 cosine score. lexical_* None.

VectorRetriever::new signature가 spec 표면 (line 85)의 (store, embed, sqlite)에서 index_version 파라미터를 추가한 형태로 변경되었습니다. dyn VectorStore 트레이트 객체로는 LanceVectorStore에 저장된 IndexVersion을 노출할 수 없고, embedder의 model_id + dim으로 derive하면 의미가 흐릿해집니다 — LexicalRetriever::new(store, index_version)의 명시적 파라미터 패턴을 그대로 따른 결정.

HybridRetriever

  • Lexical / Vector 모드는 lexical.search / vector.search로 1:1 위임. 각 retriever가 이미 올바른 RetrievalDetail을 채워서 반환하므로 재구성 없음.
  • Hybrid 모드:
    1. 두 retriever 모두 query.k * 2로 fanout 호출 (disjoint set이 모두 fusion에 참여할 수 있게).
    2. RRF: score(c) = Σ 1/(k_rrf + rank_m(c)). k_rrfconfig.search.rrf_k (default 60). 한쪽에만 등장하는 chunk는 그 쪽 term만 합산.
    3. 정렬 키: (rrf_score DESC, lex_rank ASC, chunk_id ASC). 동점 두 청크가 발생해도 결정적 순서.
    4. 상위 query.k 슬라이스. rank 1..k으로 재할당.
    5. RetrievalDetail.method = Hybrid. fusion_score은 합산 RRF, lexical_* / vector_*은 각 retriever가 본 값(없으면 None). 한쪽 single-side chunk는 fallback으로 fusion_score을 reuse — 코멘트로 "RRF가 한 term만 더하므로 fusion_score == 그 쪽 normalized score"임을 명시.
  • fusion 수학은 f64에서 연산하고 SearchHit 경계에서만 f32로 캐스트. RRF 값은 (0, 2/k_rrf] 즉 ~0.033 이하라 f32 정밀도 충분 (코멘트로 명시).
  • 두 retriever 사이 chunk가 겹칠 때 lexical 쪽 hit가 snippet/citation/heading_path/chunker_version/embedding_model의 source — FTS5 highlighting이 vector의 trim된 텍스트보다 사용자에게 유용. vector-only chunk는 vector hit data로 fall-through. 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

pub enum FusionPolicy {
    Rrf { k_rrf: u32 },
}

향후 RRF 외 fusion 알고리즘 (CombSUM, weighted, learned-to-rank 등)을 추가할 때 enum variant로 확장.

테스트

  • default 라인 (38건): 25 unit (12 hybrid + 11 lexical + 2 vector) + 13 lexical integration. RRF 수학은 1/61 + 1/62 ≈ 0.03254f32::EPSILON * 10.0 tolerance로 검증 (이전 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에서 검증되었습니다.
  • snapshot fixture: 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 철학.
  • 워크스페이스 default 261 passed / 22 ignored / 0 failed. clippy clean.

의존성

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)이 의존 그래프에 등장하지 않습니다. VectorRetrieverArc<dyn Embedder> 트레이트 객체로 받아서 kb-app이 런타임에 주입.

변경 파일

  • crates/kb-search/Cargo.toml (kb-store-vector, kb-embed 추가; kb-embedmock feature은 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 (신규 — HybridEnv scaffolding + 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과 함께).
  • Snippet 빌더 통합 — vector mode의 prefix-trim과 lexical mode의 FTS5 highlighting이 다른 코드 경로. kb search --explain 출력 일관성을 위해 snippet 정책을 한 곳으로 모을 후보.
  • Reranker (P+).

Out of scope

  • Reranker (P+).
  • 멀티모달 검색 (P6+).
  • Score calibration across modes — RRF가 rank-comparable이라 absolute calibration은 P+.

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 참고.

## 변경 요약 P3-4 hybrid-fusion 작업입니다. 기존 `kb-search` 크레이트 (P2-2의 `LexicalRetriever`)를 확장해서 새로운 `VectorRetriever` 래퍼와 `HybridRetriever` 메디에이터를 추가합니다. P3 마지막 task — `kb search --mode hybrid` 경로가 lexical + vector 두 신호를 RRF로 융합해서 동작하게 됩니다. ## 무엇을 했는가 ### `VectorRetriever` `Arc<dyn VectorStore + Send + Sync>` + `Arc<dyn Embedder>` + `Arc<SqliteStore>` + `IndexVersion`을 들고 있다가: 1. `query.text`을 `EmbeddingKind::Query`으로 임베딩. 2. `VectorStore::search(query_vec, query.k * 2, &query.filters)` 호출 — 필터 손실 보정으로 ×2 over-fetch. LanceVectorStore가 내부적으로 `filter_chunks`을 적용하므로 필터는 자연스럽게 전파. 3. 각 `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()`. 4. snippet은 chunk 텍스트를 `config.search.snippet_chars` 기준으로 trim. vector mode는 FTS5 highlighting이 없어서 prefix가 best-effort. 5. citation은 chunk의 첫 source span에서 — `lexical.rs`에서 추출한 새 `citation_helper.rs` 모듈에 lexical과 같은 로직을 공유. Byte/empty array fallback (`Line { 1, 1 }` + `tracing::warn`)도 동일. 6. `RetrievalDetail.method = Vector`. `fusion_score`과 `vector_score`은 LanceVectorStore의 shift된 cosine score. `lexical_*` None. `VectorRetriever::new` signature가 spec 표면 (line 85)의 `(store, embed, sqlite)`에서 `index_version` 파라미터를 추가한 형태로 변경되었습니다. `dyn VectorStore` 트레이트 객체로는 `LanceVectorStore`에 저장된 `IndexVersion`을 노출할 수 없고, embedder의 `model_id + dim`으로 derive하면 의미가 흐릿해집니다 — `LexicalRetriever::new(store, index_version)`의 명시적 파라미터 패턴을 그대로 따른 결정. ### `HybridRetriever` - Lexical / Vector 모드는 `lexical.search` / `vector.search`로 1:1 위임. 각 retriever가 이미 올바른 `RetrievalDetail`을 채워서 반환하므로 재구성 없음. - Hybrid 모드: 1. 두 retriever 모두 `query.k * 2`로 fanout 호출 (disjoint set이 모두 fusion에 참여할 수 있게). 2. RRF: `score(c) = Σ 1/(k_rrf + rank_m(c))`. `k_rrf`은 `config.search.rrf_k` (default 60). 한쪽에만 등장하는 chunk는 그 쪽 term만 합산. 3. 정렬 키: `(rrf_score DESC, lex_rank ASC, chunk_id ASC)`. 동점 두 청크가 발생해도 결정적 순서. 4. 상위 `query.k` 슬라이스. rank 1..k으로 재할당. 5. `RetrievalDetail.method = Hybrid`. `fusion_score`은 합산 RRF, `lexical_*` / `vector_*`은 각 retriever가 본 값(없으면 None). 한쪽 single-side chunk는 fallback으로 `fusion_score`을 reuse — 코멘트로 \"RRF가 한 term만 더하므로 fusion_score == 그 쪽 normalized score\"임을 명시. - fusion 수학은 f64에서 연산하고 SearchHit 경계에서만 f32로 캐스트. RRF 값은 `(0, 2/k_rrf]` 즉 ~0.033 이하라 f32 정밀도 충분 (코멘트로 명시). - 두 retriever 사이 chunk가 겹칠 때 lexical 쪽 hit가 snippet/citation/heading_path/chunker_version/embedding_model의 source — FTS5 highlighting이 vector의 trim된 텍스트보다 사용자에게 유용. vector-only chunk는 vector hit data로 fall-through. `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` ```rust pub enum FusionPolicy { Rrf { k_rrf: u32 }, } ``` 향후 RRF 외 fusion 알고리즘 (CombSUM, weighted, learned-to-rank 등)을 추가할 때 enum variant로 확장. ## 테스트 - **default 라인 (38건)**: 25 unit (12 hybrid + 11 lexical + 2 vector) + 13 lexical integration. RRF 수학은 `1/61 + 1/62 ≈ 0.03254`을 `f32::EPSILON * 10.0` tolerance로 검증 (이전 `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에서 검증되었습니다. - **snapshot fixture**: `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 철학. - 워크스페이스 default 261 passed / 22 ignored / 0 failed. clippy clean. ## 의존성 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`의 `mock` feature은 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` (신규 — `HybridEnv` scaffolding + `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과 함께). - Snippet 빌더 통합 — vector mode의 prefix-trim과 lexical mode의 FTS5 highlighting이 다른 코드 경로. `kb search --explain` 출력 일관성을 위해 snippet 정책을 한 곳으로 모을 후보. - Reranker (P+). ## Out of scope - Reranker (P+). - 멀티모달 검색 (P6+). - Score calibration across modes — RRF가 rank-comparable이라 absolute calibration은 P+. 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 참고.
altair823 added 1 commit 2026-05-01 11:23:36 +00:00
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>
claude-reviewer-01 reviewed 2026-05-01 11:24:26 +00:00
claude-reviewer-01 left a comment
Member

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)은 후속 작업 후보로 분류.

핵심 포인트:

  • RRF 수학을 f64로 누산 후 boundary에서 f32 캐스트, 정렬 키에 f64::total_cmp — NaN 트랩 회피 + 결정적 ordering.
  • (None, None) arm을 unreachable!()로 바꿔 silent bug 가능성 차단.
  • spec Risks/notes의 "mismatched index_version 신호" 요건을 생성자 시점 tracing::warn!으로 충족.
  • citation 로직을 lexical에서 추출해 citation_helper.rs에 공유 — 두 retriever가 같은 fallback 동작을 보장.
  • struct doc-comment에 lexical-side preference 규약을 명문화 — kb search --explain의 사용자 contract.
  • snapshot fixture는 AVX VM에서 실제 regenerate된 4-row 데이터. placeholder 아님.

워크스페이스 default 261 passed / 22 ignored / 0 failed. clippy clean. 통합 테스트 3건은 host-passthrough된 VM에서 직접 실행 검증 완료.

inline 코멘트는 모두 잘 만든 결정에 대한 노트입니다. 머지 진행해도 됩니다. P3 단계 완료 — 다음은 P4-1 llm-trait.

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)은 후속 작업 후보로 분류. 핵심 포인트: - RRF 수학을 f64로 누산 후 boundary에서 f32 캐스트, 정렬 키에 `f64::total_cmp` — NaN 트랩 회피 + 결정적 ordering. - `(None, None)` arm을 `unreachable!()`로 바꿔 silent bug 가능성 차단. - spec Risks/notes의 \"mismatched index_version 신호\" 요건을 생성자 시점 `tracing::warn!`으로 충족. - citation 로직을 lexical에서 추출해 `citation_helper.rs`에 공유 — 두 retriever가 같은 fallback 동작을 보장. - struct doc-comment에 lexical-side preference 규약을 명문화 — `kb search --explain`의 사용자 contract. - snapshot fixture는 AVX VM에서 실제 regenerate된 4-row 데이터. placeholder 아님. 워크스페이스 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 a

lexical과 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 변형이 추가되어도 한 곳만 손대면 됩니다.

lexical과 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이 먼저 수정되어야 한다는 신호.

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 as

spec Risks/notes line 143의 "mismatched index_version 발견 시 사용자에게 신호" 요건을 생성자 시점 tracing::warn!으로 충족. trait의 Retriever::index_version은 매 호출마다 한 IndexVersion만 반환하므로, 스칼라 사용자에게는 "hybrid:lex+vec" 합성 문자열을 주고 mismatch 자체는 로그로 surface — depth-1 경고로 trade-off 적절합니다.

spec 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를 쓰지 않는가"의 합리적 답을 즉시 얻습니다.

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로 재현 가능한 순서가 보장됩니다.

정렬 키 `(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을 silent continue 대신 unreachable!()로 바꾼 결정이 정답입니다. all_ids이 union에서 만들어지므로 양쪽 모두 None인 경우는 진정 unreachable인데, continue로 두면 미래에 union 구성이 깨질 때 정렬 결과만 조용히 줄어드는 silent bug가 됩니다. unreachable로 두면 fail-fast ��� 디버깅 비용 절약.

`(None, None)` arm을 silent `continue` 대신 `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을 그대로 ToSql binding하고 to_string 클론도 회피 — 성능 + 메모리 효율 양쪽 깔끔합니다.

SQLite hydration이 `IN (?, ?, ...)` 단일 쿼리. N개 chunk_id에 대해 N+1 쿼리가 아니라 단일 round-trip. `params_from_iter(unique.iter().copied())`로 `&str`을 그대로 `ToSql` binding하고 `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이 신뢰 가능합니다.

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=1 regen 경로가 silent return이 아니라 eprintln! + panic. P3-2 (SNAPSHOT_HASH_BASELINE = 0)과 P3-3 (placeholder _comment marker)와 일치하는 fail-loud 패턴. CI에서 환경변수가 잘못 설정되어 fixture가 매 run마다 덮어써지는 silent-pass 시나리오를 차단합니다.

`KB_UPDATE_SNAPSHOTS=1` regen 경로가 silent return이 아니라 `eprintln!` + panic. P3-2 (`SNAPSHOT_HASH_BASELINE = 0`)과 P3-3 (placeholder `_comment` marker)와 일치하는 fail-loud 패턴. CI에서 환경변수가 잘못 설정되어 fixture가 매 run마다 덮어써지는 silent-pass 시나리오를 차단합니다.
altair823 merged commit d32e54622c into main 2026-05-01 11:26:19 +00:00
altair823 deleted branch feat/p3-4-hybrid-fusion 2026-05-01 11:26:21 +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#17