velesdb_server/handlers/search/
batch.rs1use axum::{
4 extract::{Path, State},
5 http::StatusCode,
6 response::IntoResponse,
7 Json,
8};
9use std::sync::Arc;
10
11use crate::types::{BatchSearchRequest, BatchSearchResponse, ErrorResponse, SearchResponse};
12use crate::AppState;
13
14use super::pipeline::{
15 actionable_search_error, build_search_response, record_circuit_breaker,
16 validate_query_dimension,
17};
18use crate::handlers::helpers::{
19 apply_pre_check, extract_client_id, get_vector_collection_or_404, notify_query_timing,
20};
21
22#[utoipa::path(
24 post,
25 path = "/collections/{name}/search/batch",
26 tag = "search",
27 params(
28 ("name" = String, Path, description = "Collection name")
29 ),
30 request_body = BatchSearchRequest,
31 responses(
32 (status = 200, description = "Batch search results", body = BatchSearchResponse),
33 (status = 404, description = "Collection not found", body = ErrorResponse),
34 (status = 400, description = "Invalid request", body = ErrorResponse)
35 )
36)]
37pub async fn batch_search(
38 State(state): State<Arc<AppState>>,
39 headers: axum::http::HeaderMap,
40 Path(name): Path<String>,
41 Json(req): Json<BatchSearchRequest>,
42) -> impl IntoResponse {
43 let start = std::time::Instant::now();
44 state.onboarding_metrics.record_search_request();
45
46 let collection = match get_vector_collection_or_404(&state, &name) {
47 Ok(c) => c,
48 Err(resp) => return resp,
49 };
50
51 state.operational_metrics.record_vector_query();
54
55 let client_id = extract_client_id(&headers);
56 if let Err(resp) = apply_pre_check(collection.guard_rails(), &client_id) {
57 state.operational_metrics.inc_rate_limited();
58 return resp;
59 }
60
61 if let Err(resp) = validate_batch_dimensions(&state, &name, &collection, &req) {
62 state.operational_metrics.inc_errors();
63 return resp;
64 }
65
66 let filters = match parse_batch_filters(&state, &req) {
67 Ok(f) => f,
68 Err(resp) => {
69 state.operational_metrics.inc_errors();
70 return resp;
71 }
72 };
73
74 match state.db.authorize_read(
78 &name,
79 velesdb_core::observer::QueryOperationKind::VectorSearch,
80 None,
81 None,
82 ) {
83 Ok(None) => {}
84 Ok(Some(_)) | Err(_) => {
85 state.operational_metrics.inc_errors();
86 return (
87 StatusCode::FORBIDDEN,
88 Json(ErrorResponse {
89 error: "Read denied by governance policy".to_string(),
90 code: None,
91 }),
92 )
93 .into_response();
94 }
95 }
96
97 let collection_for_work = collection.clone();
104 let query_vectors: Vec<Vec<f32>> = req.searches.iter().map(|s| s.vector.clone()).collect();
105 let max_top_k = req.searches.iter().map(|s| s.top_k).max().unwrap_or(10);
106
107 let batch_result = match tokio::task::spawn_blocking(move || {
108 let queries: Vec<&[f32]> = query_vectors.iter().map(Vec::as_slice).collect();
109 run_batch_search(&collection_for_work, &queries, max_top_k, &filters)
110 })
111 .await
112 {
113 Ok(inner) => inner,
114 Err(_) => {
115 state.operational_metrics.inc_errors();
116 return (
117 StatusCode::INTERNAL_SERVER_ERROR,
118 Json(ErrorResponse {
119 error: "Batch search worker failed".to_string(),
120 code: None,
121 }),
122 )
123 .into_response();
124 }
125 };
126 record_circuit_breaker(&collection, &batch_result);
127
128 let all_results = match batch_result {
129 Ok(batch_results) => build_batch_responses(&state, batch_results, &req),
130 Err(e) => {
131 state.operational_metrics.inc_errors();
132 return (StatusCode::BAD_REQUEST, Json(actionable_search_error(&e))).into_response();
133 }
134 };
135
136 finish_batch_search(&state, &name, start, all_results)
137}
138
139fn run_batch_search(
144 collection: &velesdb_core::collection::VectorCollection,
145 queries: &[&[f32]],
146 max_top_k: usize,
147 filters: &[Option<velesdb_core::Filter>],
148) -> velesdb_core::Result<Vec<Vec<velesdb_core::SearchResult>>> {
149 if filters.iter().all(Option::is_none) {
150 collection.search_batch_parallel(queries, max_top_k)
151 } else {
152 collection.search_batch_with_filters(queries, max_top_k, filters)
153 }
154}
155
156fn finish_batch_search(
158 state: &AppState,
159 name: &str,
160 start: std::time::Instant,
161 results: Vec<SearchResponse>,
162) -> axum::response::Response {
163 let elapsed = start.elapsed();
164 let timing_ms = elapsed.as_secs_f64() * 1000.0;
165 notify_query_timing(state, name, start);
166 state
167 .query_duration_histogram
168 .observe(elapsed.as_secs_f64());
169
170 Json(BatchSearchResponse { results, timing_ms }).into_response()
171}
172
173#[allow(clippy::result_large_err)]
175fn validate_batch_dimensions(
176 state: &AppState,
177 name: &str,
178 collection: &velesdb_core::collection::VectorCollection,
179 req: &BatchSearchRequest,
180) -> Result<(), axum::response::Response> {
181 let expected_dimension = collection.config().dimension;
182 for (idx, search) in req.searches.iter().enumerate() {
183 if let Err(error) =
184 validate_query_dimension(state, name, expected_dimension, &search.vector)
185 {
186 return Err((
187 StatusCode::BAD_REQUEST,
188 Json(ErrorResponse {
189 error: format!("Invalid query at index {idx}: {}", error.error),
190 code: error.code.clone(),
191 }),
192 )
193 .into_response());
194 }
195 }
196 Ok(())
197}
198
199#[allow(clippy::result_large_err)]
201fn parse_batch_filters(
202 state: &AppState,
203 req: &BatchSearchRequest,
204) -> Result<Vec<Option<velesdb_core::Filter>>, axum::response::Response> {
205 let mut filters: Vec<Option<velesdb_core::Filter>> = Vec::with_capacity(req.searches.len());
206 for (idx, search) in req.searches.iter().enumerate() {
207 if let Some(filter_json) = &search.filter {
208 match serde_json::from_value(filter_json.clone()) {
209 Ok(filter) => filters.push(Some(filter)),
210 Err(e) => {
211 state.onboarding_metrics.record_filter_parse_error();
212 return Err((
213 StatusCode::BAD_REQUEST,
214 Json(ErrorResponse {
215 error: format!(
216 "Invalid filter at index {idx}: {e}. Hint: validate filter syntax and start with a broader query before reintroducing strict filters."
217 ),
218 code: None,
219 }),
220 )
221 .into_response());
222 }
223 }
224 } else {
225 filters.push(None);
226 }
227 }
228 Ok(filters)
229}
230
231fn build_batch_responses(
233 state: &AppState,
234 batch_results: Vec<Vec<velesdb_core::SearchResult>>,
235 req: &BatchSearchRequest,
236) -> Vec<SearchResponse> {
237 let empty_count = batch_results
238 .iter()
239 .filter(|results| results.is_empty())
240 .count();
241 for _ in 0..empty_count {
242 state.onboarding_metrics.record_empty_search_results();
243 }
244 debug_assert_eq!(
245 batch_results.len(),
246 req.searches.len(),
247 "search_batch_with_filters must return one result-vec per query"
248 );
249 batch_results
250 .into_iter()
251 .zip(req.searches.iter())
252 .map(|(results, search)| {
253 let truncated: Vec<_> = results.into_iter().take(search.top_k).collect();
254 build_search_response(truncated)
255 })
256 .collect()
257}