1use super::*;
3use crate::storage::unified::dsl::{calculate_entity_similarity, extract_searchable_text};
4
5struct Candidate {
6 collection: String,
7 entity: UnifiedEntity,
8 text: f32,
9 vector: f32,
10}
11
12fn matches_node(entity: &UnifiedEntity, pattern: &RuntimeGraphPattern) -> bool {
13 matches!(&entity.kind, EntityKind::GraphNode(node)
14 if pattern.node_label.as_ref().is_none_or(|label| *label == node.label)
15 && pattern.node_type.as_ref().is_none_or(|kind| *kind == node.node_type))
16}
17
18impl RedDBRuntime {
19 pub fn search_hybrid(
20 &self,
21 vector: Option<Vec<f32>>,
22 query: Option<String>,
23 k: Option<usize>,
24 collections: Option<Vec<String>>,
25 entity_types: Option<Vec<String>>,
26 capabilities: Option<Vec<String>>,
27 graph_pattern: Option<RuntimeGraphPattern>,
28 filters: Vec<RuntimeFilter>,
29 weights: Option<RuntimeQueryWeights>,
30 min_score: Option<f32>,
31 limit: Option<usize>,
32 ) -> RedDBResult<DslQueryResult> {
33 let started = std::time::Instant::now();
34 let query = query
35 .map(|text| text.trim().to_lowercase())
36 .filter(|text| !text.is_empty());
37 if vector.is_none() && query.is_none() {
38 return Err(RedDBError::Query(
39 "field 'query' or 'vector' is required for hybrid search".into(),
40 ));
41 }
42 if vector.as_ref().is_some_and(|values| {
43 values.is_empty() || values.iter().any(|value| !value.is_finite())
44 }) {
45 return Err(RedDBError::Query(
46 "hybrid search vector must contain finite values".into(),
47 ));
48 }
49 let weights = weights.unwrap_or(RuntimeQueryWeights {
50 vector: 0.5,
51 graph: 0.3,
52 filter: 0.2,
53 });
54 if [weights.vector, weights.graph, weights.filter]
55 .iter()
56 .any(|weight| !weight.is_finite() || *weight < 0.0)
57 {
58 return Err(RedDBError::Query(
59 "hybrid search weights must be finite and nonnegative".into(),
60 ));
61 }
62 let filters = filters
63 .into_iter()
64 .map(runtime_filter_to_dsl)
65 .collect::<RedDBResult<Vec<_>>>()?;
66 let limit = limit.or(k).unwrap_or(10).max(1);
67 let min_score = min_score
68 .filter(|score| score.is_finite())
69 .unwrap_or(0.0)
70 .max(0.0);
71 let relation_labels = graph_pattern
72 .as_ref()
73 .map(|pattern| pattern.edge_labels.as_slice())
74 .unwrap_or_default();
75 let relations = !relation_labels.is_empty();
76 let explicit_scope = collections.as_ref().is_some_and(|names| !names.is_empty());
77 let collections =
78 runtime_search_collections(&self.inner.db, collections).unwrap_or_default();
79 let (snapshot, _retention) = self.native_read_snapshot()?;
80 let _snapshot = super::execution_context::CurrentSnapshotGuard::install(snapshot.clone());
81 self.refresh_memory_accounting();
82 let store = self.db().store();
83 let mut candidates = Vec::new();
84 let mut reservations = Vec::new();
85 let mut policies = HashMap::new();
86 let terms: Vec<_> = query
87 .as_deref()
88 .unwrap_or_default()
89 .split_whitespace()
90 .collect();
91 for collection in collections {
92 if super::impl_ddl::is_system_schema_name(&collection) || collection.starts_with("red_")
93 {
94 continue;
95 }
96 if let Err(error) =
97 self.check_collection_privilege(reddb_rql::ast::PolicyAction::Select, &collection)
98 {
99 if explicit_scope {
100 return Err(error);
101 }
102 continue;
103 }
104 let Some(manager) = store.get_collection(&collection) else {
105 continue;
106 };
107 let (_scan_memory, read_bytes_max) = self.reserve_index_scan(&manager)?;
108 let mut rows = Vec::new();
109 super::function_budget::try_scan_result(
110 |visit| manager.try_for_each_entity(read_bytes_max, visit),
111 |entity| {
112 if super::execution_context::entity_visible_with_context(
113 Some(&snapshot),
114 entity,
115 ) {
116 let bytes = crate::storage::unified::memory_size::entity_bytes(entity)
119 .saturating_mul(3)
120 .saturating_add(collection.len().saturating_mul(4))
121 .saturating_add(1024);
122 let reservation = self
123 .try_reserve_query_buffer_from_sample(bytes)
124 .ok_or_else(|| {
125 RedDBError::Query(
126 "hybrid search exceeds the query memory budget".into(),
127 )
128 })?;
129 reservations.push(reservation);
130 rows.push(entity.clone());
131 }
132 Ok(true)
133 },
134 )?;
135 for entity in rows {
137 super::function_budget::charge(1)?;
138 if !self.search_entity_allowed(&collection, &entity, Some(&snapshot), &mut policies)
139 {
140 continue;
141 }
142 let text = if terms.is_empty() {
143 0.0
144 } else {
145 let body = extract_searchable_text(&entity).to_lowercase();
146 terms.iter().filter(|term| body.contains(**term)).count() as f32
147 / terms.len() as f32
148 };
149 let similarity = vector.as_ref().map_or(0.0, |vector| {
150 calculate_entity_similarity(&entity, vector, &None)
151 });
152 candidates.push(Candidate {
153 collection: collection.clone(),
154 entity,
155 text,
156 vector: similarity,
157 });
158 }
159 }
160 let mut matches: HashMap<EntityId, ScoredMatch> = HashMap::new();
161 for candidate in &candidates {
162 let graph = if !relations
163 && vector.is_some()
164 && graph_pattern
165 .as_ref()
166 .is_some_and(|pattern| matches_node(&candidate.entity, pattern))
167 {
168 1.0
169 } else {
170 0.0
171 };
172 let score = if vector.is_none() && !relations {
173 candidate.text
174 } else {
175 candidate.vector * weights.vector
176 + candidate.text * weights.filter
177 + graph * weights.graph
178 };
179 if score > 0.0 {
180 matches.insert(
181 candidate.entity.id,
182 ScoredMatch {
183 entity: candidate.entity.clone(),
184 score,
185 path: None,
186 components: MatchComponents {
187 vector_similarity: (candidate.vector > 0.0).then_some(candidate.vector),
188 text_relevance: (candidate.text > 0.0).then_some(candidate.text),
189 graph_match: (graph > 0.0).then_some(graph),
190 ..Default::default()
191 },
192 },
193 );
194 }
195 }
196 if relations {
197 expand_relations(
198 &candidates,
199 relation_labels,
200 k.unwrap_or(limit).max(1),
201 weights.graph,
202 &mut matches,
203 )?;
204 }
205 let mut result = DslQueryResult {
206 matches: matches
207 .into_values()
208 .filter(|item| {
209 item.score >= min_score
210 && apply_filters(&item.entity, &filters)
211 && graph_pattern.as_ref().is_none_or(|pattern| {
212 (!relations && vector.is_some()) || matches_node(&item.entity, pattern)
215 })
216 })
217 .collect(),
218 scanned: candidates.len(),
219 execution_time_us: started.elapsed().as_micros() as u64,
220 explanation: if relations {
221 "Hybrid search with authorized one-hop relations"
222 } else {
223 "Hybrid multi-modal query"
224 }
225 .into(),
226 };
227 for item in &mut result.matches {
228 item.components.filter_match = true;
229 item.components.structured_match = Some(1.0);
230 item.components.final_score = Some(item.score);
231 }
232 normalize_runtime_dsl_result_scores(&mut result);
233 runtime_filter_dsl_result(&mut result, entity_types, capabilities);
234 result.matches.truncate(limit);
235 Ok(result)
236 }
237}
238
239fn expand_relations(
240 candidates: &[Candidate],
241 labels: &[String],
242 seed_limit: usize,
243 weight: f32,
244 matches: &mut HashMap<EntityId, ScoredMatch>,
245) -> RedDBResult<()> {
246 let mut seeds: Vec<_> = candidates
247 .iter()
248 .filter(|candidate| matches!(candidate.entity.kind, EntityKind::GraphNode(_)))
249 .map(|candidate| (candidate.entity.id, candidate.text.max(candidate.vector)))
250 .filter(|(_, score)| *score > 0.0)
251 .collect();
252 seeds.sort_by(|left, right| {
253 right
254 .1
255 .total_cmp(&left.1)
256 .then_with(|| left.0.cmp(&right.0))
257 });
258 seeds.truncate(seed_limit);
259 let seeds: HashMap<_, _> = seeds.into_iter().collect();
260 if seeds.is_empty() || weight == 0.0 {
261 return Ok(());
262 }
263 let mut nodes: HashMap<(String, String), Vec<usize>> = HashMap::new();
264 for (index, candidate) in candidates.iter().enumerate() {
265 if let EntityKind::GraphNode(node) = &candidate.entity.kind {
266 for alias in [
267 node.label.clone(),
268 candidate.entity.id.raw().to_string(),
269 candidate.entity.logical_id().raw().to_string(),
270 ] {
271 let entries = nodes
272 .entry((candidate.collection.clone(), alias))
273 .or_default();
274 if entries.last() != Some(&index) {
275 entries.push(index);
276 }
277 }
278 }
279 }
280 for candidate in candidates {
281 let EntityKind::GraphEdge(edge) = &candidate.entity.kind else {
282 continue;
283 };
284 if !labels.contains(&edge.label) {
285 continue;
286 }
287 let Some(from) = nodes.get(&(candidate.collection.clone(), edge.from_node.clone())) else {
288 continue;
289 };
290 let Some(to) = nodes.get(&(candidate.collection.clone(), edge.to_node.clone())) else {
291 continue;
292 };
293 for &source in from {
294 for &target in to {
295 super::function_budget::charge(1)?;
296 for (source, target) in [(source, target), (target, source)] {
297 let source = &candidates[source].entity;
298 let target = &candidates[target].entity;
299 if source.id == target.id {
300 continue;
301 }
302 let Some(&strength) = seeds.get(&source.id) else {
303 continue;
304 };
305 let path = vec![source.id, candidate.entity.id, target.id];
306 let item = matches.entry(target.id).or_insert_with(|| ScoredMatch {
307 entity: target.clone(),
308 score: 0.0,
309 components: MatchComponents::default(),
310 path: None,
311 });
312 let previous = item.components.graph_match.unwrap_or(0.0);
313 if strength > previous
314 || (strength == previous
315 && item.path.as_ref().is_none_or(|old| path < *old))
316 {
317 item.score += (strength - previous) * weight;
318 item.components.graph_match = Some(strength);
319 item.components.hop_distance = Some(1);
320 item.path = Some(path);
321 }
322 }
323 }
324 }
325 }
326 Ok(())
327}