velesdb_server/handlers/search/
multi.rs1use axum::{
4 extract::{Path, State},
5 http::StatusCode,
6 response::IntoResponse,
7 Json,
8};
9use std::sync::Arc;
10
11use crate::types::{ErrorResponse, MultiQuerySearchRequest, SearchIdsResponse, SearchResponse};
12use crate::AppState;
13
14use super::pipeline::{
15 finish_search_ids_with_cb, finish_search_with_cb, id_score_results, parse_filter_or_400,
16 validate_query_dimension,
17};
18use super::workers::run_blocking_search;
19use crate::handlers::helpers::{apply_pre_check, extract_client_id, get_vector_collection_or_404};
20
21#[allow(clippy::result_large_err)]
24fn parse_fusion_strategy(
25 req: &MultiQuerySearchRequest,
26 state: &AppState,
27) -> Result<velesdb_core::FusionStrategy, axum::response::Response> {
28 use velesdb_core::FusionStrategy;
29 match req.strategy.to_lowercase().as_str() {
30 "average" | "avg" => Ok(FusionStrategy::Average),
31 "maximum" | "max" => Ok(FusionStrategy::Maximum),
32 "rrf" => Ok(FusionStrategy::RRF { k: req.rrf_k }),
33 "weighted" => Ok(FusionStrategy::Weighted {
34 avg_weight: req.avg_weight,
35 max_weight: req.max_weight,
36 hit_weight: req.hit_weight,
37 }),
38 "relative_score" | "rsf" => Ok(FusionStrategy::RelativeScore {
39 dense_weight: req.dense_weight,
40 sparse_weight: req.sparse_weight,
41 }),
42 _ => {
43 state.operational_metrics.inc_errors();
44 Err((
45 StatusCode::BAD_REQUEST,
46 Json(ErrorResponse {
47 error: format!(
48 "Invalid strategy: {}. Valid: average, maximum, rrf, weighted, \
49 relative_score",
50 req.strategy
51 ),
52 code: None,
53 }),
54 )
55 .into_response())
56 }
57 }
58}
59
60#[allow(clippy::result_large_err)]
63fn validate_query_vectors(
64 state: &AppState,
65 name: &str,
66 expected_dimension: usize,
67 vectors: &[Vec<f32>],
68) -> Result<(), axum::response::Response> {
69 for (idx, vector) in vectors.iter().enumerate() {
70 if let Err(error) = validate_query_dimension(state, name, expected_dimension, vector) {
71 state.operational_metrics.inc_errors();
72 return Err((
73 StatusCode::BAD_REQUEST,
74 Json(ErrorResponse {
75 error: format!("Invalid query vector at index {idx}: {}", error.error),
76 code: error.code.clone(),
77 }),
78 )
79 .into_response());
80 }
81 }
82 Ok(())
83}
84
85#[allow(clippy::result_large_err)]
90fn prepare_multi_query(
91 state: &AppState,
92 headers: &axum::http::HeaderMap,
93 name: &str,
94 req: &MultiQuerySearchRequest,
95) -> Result<
96 (
97 velesdb_core::collection::VectorCollection,
98 velesdb_core::FusionStrategy,
99 ),
100 axum::response::Response,
101> {
102 state.onboarding_metrics.record_search_request();
103
104 let collection = get_vector_collection_or_404(state, name)?;
105
106 state.operational_metrics.record_vector_query();
109
110 let client_id = extract_client_id(headers);
111 if let Err(resp) = apply_pre_check(collection.guard_rails(), &client_id) {
112 state.operational_metrics.inc_rate_limited();
113 return Err(resp);
114 }
115
116 let strategy = parse_fusion_strategy(req, state)?;
117
118 let expected_dimension = collection.config().dimension;
119 validate_query_vectors(state, name, expected_dimension, &req.vectors)?;
120
121 Ok((collection, strategy))
122}
123
124#[utoipa::path(
126 post,
127 path = "/collections/{name}/search/multi",
128 tag = "search",
129 params(("name" = String, Path, description = "Collection name")),
130 request_body = MultiQuerySearchRequest,
131 responses(
132 (status = 200, description = "Multi-query search results", body = SearchResponse),
133 (status = 404, description = "Collection not found", body = ErrorResponse),
134 (status = 500, description = "Internal server error", body = ErrorResponse)
135 )
136)]
137#[allow(clippy::result_large_err)]
138pub async fn multi_query_search(
139 State(state): State<Arc<AppState>>,
140 headers: axum::http::HeaderMap,
141 Path(name): Path<String>,
142 Json(req): Json<MultiQuerySearchRequest>,
143) -> impl IntoResponse {
144 let (collection, strategy) = match prepare_multi_query(&state, &headers, &name, &req) {
145 Ok(v) => v,
146 Err(resp) => return resp,
147 };
148
149 let filter = match req.filter.as_ref() {
157 Some(filter_json) => match parse_filter_or_400(filter_json, &state.onboarding_metrics) {
158 Ok(f) => Some(f),
159 Err(resp) => {
160 state.operational_metrics.inc_errors();
161 return resp;
162 }
163 },
164 None => None,
165 };
166
167 match state.db.authorize_read(
171 &name,
172 velesdb_core::observer::QueryOperationKind::VectorSearch,
173 None,
174 None,
175 ) {
176 Ok(None) => {}
177 Ok(Some(_)) | Err(_) => {
178 state.operational_metrics.inc_errors();
179 return (
180 StatusCode::FORBIDDEN,
181 Json(ErrorResponse {
182 error: "Read denied by governance policy".to_string(),
183 code: None,
184 }),
185 )
186 .into_response();
187 }
188 }
189
190 let start = std::time::Instant::now();
191
192 let collection_for_work = collection.clone();
200 let vectors = req.vectors;
201 let top_k = req.top_k;
202
203 let work_result = run_blocking_search(move || {
204 let query_refs: Vec<&[f32]> = vectors.iter().map(Vec::as_slice).collect();
205 Ok(collection_for_work.multi_query_search(&query_refs, top_k, strategy, filter.as_ref()))
206 })
207 .await;
208
209 let search_result = match work_result {
210 Ok(inner) => inner,
211 Err(resp) => {
212 state.operational_metrics.inc_errors();
213 return resp;
214 }
215 };
216
217 finish_search_with_cb(&state, &name, start, &collection, search_result)
218}
219
220#[utoipa::path(
226 post,
227 path = "/collections/{name}/search/multi/ids",
228 tag = "search",
229 params(("name" = String, Path, description = "Collection name")),
230 request_body = MultiQuerySearchRequest,
231 responses(
232 (status = 200, description = "Multi-query ids-only results", body = SearchIdsResponse),
233 (status = 400, description = "Invalid request", body = ErrorResponse),
234 (status = 404, description = "Collection not found", body = ErrorResponse)
235 )
236)]
237#[allow(clippy::result_large_err)]
238pub async fn multi_query_search_ids(
239 State(state): State<Arc<AppState>>,
240 headers: axum::http::HeaderMap,
241 Path(name): Path<String>,
242 Json(req): Json<MultiQuerySearchRequest>,
243) -> impl IntoResponse {
244 let (collection, strategy) = match prepare_multi_query(&state, &headers, &name, &req) {
245 Ok(v) => v,
246 Err(resp) => return resp,
247 };
248
249 if req.filter.is_some() {
252 state.operational_metrics.inc_errors();
253 return (
254 StatusCode::BAD_REQUEST,
255 Json(ErrorResponse {
256 error: "Metadata filters are not supported on /search/multi/ids; \
257 use /search/multi for filtered multi-query search."
258 .to_string(),
259 code: None,
260 }),
261 )
262 .into_response();
263 }
264
265 match state.db.authorize_read(
267 &name,
268 velesdb_core::observer::QueryOperationKind::VectorSearch,
269 None,
270 None,
271 ) {
272 Ok(None) => {}
273 Ok(Some(_)) | Err(_) => {
274 state.operational_metrics.inc_errors();
275 return (
276 StatusCode::FORBIDDEN,
277 Json(ErrorResponse {
278 error: "Read denied by governance policy".to_string(),
279 code: None,
280 }),
281 )
282 .into_response();
283 }
284 }
285
286 let start = std::time::Instant::now();
287 let collection_for_work = collection.clone();
288 let vectors = req.vectors;
289 let top_k = req.top_k;
290
291 let work_result = run_blocking_search(move || {
292 let query_refs: Vec<&[f32]> = vectors.iter().map(Vec::as_slice).collect();
293 Ok(collection_for_work
294 .multi_query_search_ids(&query_refs, top_k, strategy)
295 .map(id_score_results))
296 })
297 .await;
298
299 let search_result = match work_result {
300 Ok(inner) => inner,
301 Err(resp) => {
302 state.operational_metrics.inc_errors();
303 return resp;
304 }
305 };
306
307 finish_search_ids_with_cb(&state, &name, start, &collection, search_result)
308}