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}