Skip to main content

khive_retrieval/
hit.rs

1//! Search result types returned by hybrid retrieval pipelines.
2//!
3//! Search evidence and built-in strategy labels are shared here so pipelines can retain
4//! their own ranking and duplicate-signal policies without depending on a higher layer.
5
6use std::collections::{hash_map::Entry, HashMap};
7
8use khive_fusion::FusionStrategy;
9use khive_score::DeterministicScore;
10use uuid::Uuid;
11
12/// The strategy that produced a hit's ordering score, including local modifiers.
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum RankScoreKind {
15    /// Reciprocal rank fusion of the retrieval legs.
16    Rrf,
17    /// Vector similarity alone.
18    Vector,
19    /// Keyword score alone.
20    Keyword,
21    /// Weighted combination of the retrieval legs.
22    Weighted,
23    /// Union of the retrieval legs.
24    Union,
25}
26
27impl RankScoreKind {
28    /// Rank kind for a built-in strategy, or the borrowed custom executor arguments.
29    ///
30    /// Custom executors declare their own kind. Returning their name and parameters
31    /// leaves resolution and fallback policy with the caller; `Err` is not a refusal.
32    /// Callers that intentionally fall back to RRF can use `unwrap_or(Self::Rrf)`.
33    pub fn of(strategy: &FusionStrategy) -> Result<Self, (&str, &serde_json::Value)> {
34        match strategy {
35            FusionStrategy::Rrf { .. } | FusionStrategy::WeightedRrf { .. } => Ok(Self::Rrf),
36            FusionStrategy::VectorOnly => Ok(Self::Vector),
37            FusionStrategy::KeywordOnly => Ok(Self::Keyword),
38            FusionStrategy::Weighted { .. } => Ok(Self::Weighted),
39            FusionStrategy::Union => Ok(Self::Union),
40            FusionStrategy::Custom { name, params } => Err((name, params)),
41        }
42    }
43
44    /// Lowercase wire representation used by search serializers.
45    #[must_use]
46    pub const fn as_str(self) -> &'static str {
47        match self {
48            Self::Rrf => "rrf",
49            Self::Vector => "vector",
50            Self::Keyword => "keyword",
51            Self::Weighted => "weighted",
52            Self::Union => "union",
53        }
54    }
55}
56
57/// Retained component scores before fusion and strategy-local modifiers.
58/// An absent retrieval leg has no score, which is distinct from a measured zero.
59/// Scores belong to the backend and model that produced the retained hit;
60/// vector similarities from different embedding models are not comparable.
61#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
62pub struct SearchSignals {
63    /// Vector similarity, or `None` when the vector leg did not return the hit.
64    pub vector_similarity: Option<DeterministicScore>,
65    /// Keyword score, or `None` when the text leg did not return the hit.
66    pub keyword_score: Option<DeterministicScore>,
67}
68
69/// A unified search result combining vector and text signals.
70#[derive(Clone, Debug)]
71pub struct SearchHit {
72    /// Identifier of the matched record.
73    pub entity_id: Uuid,
74    /// Ordering score assigned by the fusion strategy.
75    pub score: DeterministicScore,
76    /// The strategy that produced `score`.
77    pub rank_score_kind: RankScoreKind,
78    /// Component scores retained before fusion.
79    pub signals: SearchSignals,
80    /// The retrieval leg or legs that returned the hit.
81    pub source: SearchSource,
82    /// Title of the matched record, when the text leg carried one.
83    pub title: Option<String>,
84    /// Excerpt of the matched text, when the text leg carried one.
85    pub snippet: Option<String>,
86}
87
88/// How repeated appearances contribute their per-leg signals.
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub enum SignalMerge {
91    /// Keep each first present signal, including a measured zero.
92    FirstPresent,
93    /// Keep the maximum present signal from each retrieval leg.
94    Maximum,
95}
96
97/// Accumulate evidence by ID without changing the first hit's score or rank kind.
98///
99/// Every appearance widens the source and fills missing title/snippet fields, even
100/// on a repeated visit from the same leg. Present empty strings are retained.
101/// This differs from `combine_leg_first_appearance`, which ignores a repeated leg
102/// entirely. Ranking and the final result limit remain the caller's responsibility.
103pub fn merge_hit_metadata(
104    metadata: &mut HashMap<Uuid, SearchHit>,
105    hit: SearchHit,
106    signals: SignalMerge,
107) {
108    match metadata.entry(hit.entity_id) {
109        Entry::Occupied(mut entry) => {
110            let existing = entry.get_mut();
111            existing.source = existing.source.union(hit.source);
112            existing.signals.vector_similarity = match signals {
113                SignalMerge::FirstPresent => existing
114                    .signals
115                    .vector_similarity
116                    .or(hit.signals.vector_similarity),
117                SignalMerge::Maximum => existing
118                    .signals
119                    .vector_similarity
120                    .max(hit.signals.vector_similarity),
121            };
122            existing.signals.keyword_score = match signals {
123                SignalMerge::FirstPresent => {
124                    existing.signals.keyword_score.or(hit.signals.keyword_score)
125                }
126                SignalMerge::Maximum => existing
127                    .signals
128                    .keyword_score
129                    .max(hit.signals.keyword_score),
130            };
131            if existing.title.is_none() {
132                existing.title = hit.title;
133            }
134            if existing.snippet.is_none() {
135                existing.snippet = hit.snippet;
136            }
137        }
138        Entry::Vacant(entry) => {
139            entry.insert(hit);
140        }
141    }
142}
143
144/// Result of a hybrid search: the fused hits — text hits alone when the vector
145/// arm failed — plus the vector arm's error, if any.
146#[derive(Clone, Debug)]
147pub struct HybridSearchOutcome {
148    /// The fused hits.
149    pub hits: Vec<SearchHit>,
150    /// The vector arm's error message, when that arm failed.
151    pub vector_error: Option<String>,
152}
153
154/// Which retrieval path(s) contributed to a hit.
155#[derive(Clone, Copy, Debug, PartialEq, Eq)]
156pub enum SearchSource {
157    /// Returned by the vector leg only.
158    Vector,
159    /// Returned by the text leg only.
160    Text,
161    /// Returned by both legs.
162    Both,
163}
164
165impl SearchSource {
166    /// Combine retrieval-leg membership from two appearances of the same hit.
167    #[must_use]
168    pub const fn union(self, other: Self) -> Self {
169        match (self, other) {
170            (Self::Text, Self::Text) => Self::Text,
171            (Self::Vector, Self::Vector) => Self::Vector,
172            _ => Self::Both,
173        }
174    }
175
176    /// Lowercase wire representation used by search serializers.
177    #[must_use]
178    pub const fn as_str(self) -> &'static str {
179        match self {
180            Self::Vector => "vector",
181            Self::Text => "text",
182            Self::Both => "both",
183        }
184    }
185}