From 3c605b1a5d87646bd51e32a45a253ad40639b9b4 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Sun, 10 May 2026 17:49:02 +0900 Subject: [PATCH] feat(core): ScoreKind enum + SearchHit.score_kind (fb-38) --- crates/kebab-core/src/lib.rs | 2 +- crates/kebab-core/src/search.rs | 62 +++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/crates/kebab-core/src/lib.rs b/crates/kebab-core/src/lib.rs index 1cee095..3d55855 100644 --- a/crates/kebab-core/src/lib.rs +++ b/crates/kebab-core/src/lib.rs @@ -51,7 +51,7 @@ pub use metadata::{ TrustLevel, }; pub use search::{ - DocFilter, DocSummary, IndexBytes, MEDIA_KINDS, RetrievalDetail, SearchFilters, SearchHit, + DocFilter, DocSummary, IndexBytes, MEDIA_KINDS, RetrievalDetail, ScoreKind, SearchFilters, SearchHit, SearchMode, SearchOpts, SearchQuery, SearchTrace, TraceCandidate, TraceFusionInput, TraceTiming, }; diff --git a/crates/kebab-core/src/search.rs b/crates/kebab-core/src/search.rs index 38e41ad..3262ca3 100644 --- a/crates/kebab-core/src/search.rs +++ b/crates/kebab-core/src/search.rs @@ -31,6 +31,17 @@ pub struct SearchQuery { /// before populating this Vec. pub const MEDIA_KINDS: &[&str] = &["markdown", "pdf", "image", "audio", "other"]; +/// p9-fb-38: top-level `SearchHit.score` declaration. +/// `Rrf` (hybrid) / `Bm25` (lexical-only) / `Cosine` (vector-only). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ScoreKind { + #[default] + Rrf, + Bm25, + Cosine, +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct SearchFilters { pub tags_any: Vec, @@ -73,6 +84,11 @@ pub struct SearchHit { /// p9-fb-32: server-computed `now - indexed_at > threshold` per /// `config.search.stale_threshold_days`. `false` when threshold = 0. pub stale: bool, + /// p9-fb-38: declares the meaning of the top-level `score`. + /// `Rrf` (hybrid mode), `Bm25` (lexical-only), `Cosine` (vector-only). + /// 옛 wire (fb-38 미만) 부재 시 `Rrf` default — hybrid 가 기본 mode. + #[serde(default)] + pub score_kind: ScoreKind, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -214,6 +230,7 @@ mod tests { chunker_version: ChunkerVersion("c1".to_string()), indexed_at: datetime!(2026-05-09 12:00:00 UTC), stale: true, + score_kind: ScoreKind::Rrf, }; let v = serde_json::to_value(&hit).unwrap(); assert_eq!(v["indexed_at"], "2026-05-09T12:00:00Z"); @@ -294,4 +311,49 @@ mod tests { let opts = SearchOpts::default(); assert!(!opts.trace); } + + #[test] + fn score_kind_serde_roundtrip() { + use ScoreKind::*; + for (kind, expected) in [(Rrf, "rrf"), (Bm25, "bm25"), (Cosine, "cosine")] { + let v = serde_json::to_value(kind).unwrap(); + assert_eq!(v.as_str(), Some(expected)); + let back: ScoreKind = serde_json::from_value(v).unwrap(); + assert_eq!(back, kind); + } + } + + #[test] + fn score_kind_default_is_rrf() { + assert_eq!(ScoreKind::default(), ScoreKind::Rrf); + } + + #[test] + fn search_hit_deserialize_without_score_kind_defaults_to_rrf() { + let json = serde_json::json!({ + "rank": 1, + "chunk_id": "c1", + "doc_id": "d1", + "doc_path": "a.md", + "heading_path": [], + "section_label": null, + "snippet": "x", + "citation": { "kind": "line", "path": "a.md", "start": 1, "end": 1, "section": null }, + "retrieval": { + "method": "lexical", + "fusion_score": 0.5, + "lexical_score": 0.5, + "vector_score": null, + "lexical_rank": 1, + "vector_rank": null + }, + "index_version": "v1", + "embedding_model": null, + "chunker_version": "c1", + "indexed_at": "2026-05-10T12:00:00Z", + "stale": false + }); + let hit: SearchHit = serde_json::from_value(json).unwrap(); + assert_eq!(hit.score_kind, ScoreKind::Rrf); + } }