Skip to main content

velesdb_server/handlers/search/
batch.rs

1//! Batch search handler: multiple vector queries in a single request.
2
3use 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/// Batch search for multiple vectors.
23#[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    // Record query type only after confirming the collection exists, so
52    // 404s do not inflate queries_total or vector_queries.
53    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    // Gate the read (CORE-2) before dispatching the off-thread batch. A denied
75    // decision — or a scope narrowing, which batch has no per-query channel to
76    // apply — refuses the batch (fail closed).
77    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    // F-01 sweep: batch search is CPU-bound (one HNSW pass per query) and must
98    // not run on the async runtime thread — a large batch would otherwise block
99    // a Tokio worker and starve concurrent requests. Move it to a blocking
100    // worker like the other search endpoints. `spawn_blocking` needs a 'static
101    // closure, so owned query vectors + filters are moved in and the `&[f32]`
102    // views are rebuilt inside.
103    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
139/// Dispatch a batch search to the throughput-optimized parallel kernel when no
140/// query carries a filter, falling back to the per-query filtered kernel
141/// otherwise. Both paths share the same HNSW traversal, so results are
142/// identical for the unfiltered case — only the rayon parallelism differs.
143fn 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
156/// Record timing metrics and build the final batch response envelope.
157fn 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/// Validate that every query vector in a batch request matches the collection dimension.
174#[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/// Parse filters from each search in a batch request.
200#[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
231/// Convert batch search results into response objects, recording metrics for empty results.
232fn 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}