Skip to main content

vectorizer_sdk/client/
search.rs

1//! Search surface: text/vector search, intelligent search, semantic
2//! search, contextual search, multi-collection search, hybrid
3//! (dense + sparse) search.
4//!
5//! Six methods covering every search variant the v3 server exposes.
6//! Discovery (multi-stage filter + score + expand) lives in
7//! [`super::discovery`]; per-file search variants in [`super::files`].
8
9use super::VectorizerClient;
10use crate::error::{Result, VectorizerError};
11use crate::models::hybrid_search::{
12    HybridScoringAlgorithm, HybridSearchRequest, HybridSearchResponse,
13};
14use crate::models::{ExplainRequest, ExplainResponse, ExplainTrace, *};
15
16impl VectorizerClient {
17    /// Text search against one collection. The server embeds the
18    /// query with the collection's provider, runs ANN search, and
19    /// returns up to `limit` (default 10) hits scored above the
20    /// optional `score_threshold`.
21    pub async fn search_vectors(
22        &self,
23        collection: &str,
24        query: &str,
25        limit: Option<usize>,
26        score_threshold: Option<f32>,
27    ) -> Result<SearchResponse> {
28        let mut payload = serde_json::Map::new();
29        payload.insert(
30            "query".to_string(),
31            serde_json::Value::String(query.to_string()),
32        );
33        payload.insert(
34            "limit".to_string(),
35            serde_json::Value::Number(limit.unwrap_or(10).into()),
36        );
37        if let Some(threshold) = score_threshold {
38            payload.insert(
39                "score_threshold".to_string(),
40                serde_json::Value::Number(serde_json::Number::from_f64(threshold as f64).unwrap()),
41            );
42        }
43        let response = self
44            .make_request(
45                "POST",
46                &format!("/collections/{collection}/search/text"),
47                Some(serde_json::Value::Object(payload)),
48            )
49            .await?;
50        let search_response: SearchResponse = serde_json::from_str(&response).map_err(|e| {
51            VectorizerError::server(format!("Failed to parse search response: {e}"))
52        })?;
53        Ok(search_response)
54    }
55
56    /// Intelligent search — multi-query expansion + MMR
57    /// diversification + domain term boosting.
58    pub async fn intelligent_search(
59        &self,
60        request: IntelligentSearchRequest,
61    ) -> Result<IntelligentSearchResponse> {
62        let response = self
63            .make_request(
64                "POST",
65                "/intelligent_search",
66                Some(serde_json::to_value(request).unwrap()),
67            )
68            .await?;
69        serde_json::from_str(&response).map_err(|e| {
70            VectorizerError::server(format!("Failed to parse intelligent search response: {e}"))
71        })
72    }
73
74    /// Semantic search — advanced reranking + similarity-threshold
75    /// filtering on top of the base text search.
76    pub async fn semantic_search(
77        &self,
78        request: SemanticSearchRequest,
79    ) -> Result<SemanticSearchResponse> {
80        let response = self
81            .make_request(
82                "POST",
83                "/semantic_search",
84                Some(serde_json::to_value(request).unwrap()),
85            )
86            .await?;
87        serde_json::from_str(&response).map_err(|e| {
88            VectorizerError::server(format!("Failed to parse semantic search response: {e}"))
89        })
90    }
91
92    /// Context-aware search with metadata filtering and contextual
93    /// reranking.
94    pub async fn contextual_search(
95        &self,
96        request: ContextualSearchRequest,
97    ) -> Result<ContextualSearchResponse> {
98        let response = self
99            .make_request(
100                "POST",
101                "/contextual_search",
102                Some(serde_json::to_value(request).unwrap()),
103            )
104            .await?;
105        serde_json::from_str(&response).map_err(|e| {
106            VectorizerError::server(format!("Failed to parse contextual search response: {e}"))
107        })
108    }
109
110    /// Multi-collection search with cross-collection reranking and
111    /// aggregation.
112    pub async fn multi_collection_search(
113        &self,
114        request: MultiCollectionSearchRequest,
115    ) -> Result<MultiCollectionSearchResponse> {
116        let response = self
117            .make_request(
118                "POST",
119                "/multi_collection_search",
120                Some(serde_json::to_value(request).unwrap()),
121            )
122            .await?;
123        serde_json::from_str(&response).map_err(|e| {
124            VectorizerError::server(format!(
125                "Failed to parse multi-collection search response: {e}"
126            ))
127        })
128    }
129
130    /// Search a collection for vectors associated with a given file path.
131    ///
132    /// Calls `POST /collections/{name}/search/file` with `{file_path, limit?}`.
133    /// Returns a [`SearchResponse`] (may be empty if the file is not indexed).
134    pub async fn search_by_file(
135        &self,
136        collection: &str,
137        request: SearchByFileRequest,
138    ) -> Result<SearchResponse> {
139        let url = format!("/collections/{collection}/search/file");
140        let payload = serde_json::json!({
141            "file_path": request.file_path,
142            "limit": request.limit.unwrap_or(10),
143        });
144        let response = self.make_request("POST", &url, Some(payload)).await?;
145        serde_json::from_str(&response).map_err(|e| {
146            VectorizerError::server(format!("Failed to parse search_by_file response: {e}"))
147        })
148    }
149
150    // ── Phase-14: observability ────────────────────────────────────────────────
151
152    /// Run a search and return the full HNSW execution trace (phase14).
153    ///
154    /// Calls `POST /collections/{name}/explain` with
155    /// `{"vector": [f32…], "k": <u64>}`.
156    ///
157    /// The trace includes: `visited_nodes`, `ef_search`, `hnsw_search_ms`,
158    /// `payload_filter_evals`, `quantization_score_ms`, and `total_ms`. The
159    /// results are identical to a normal search — there is no separate explain
160    /// engine; the real code path is instrumented.
161    pub async fn explain_search(
162        &self,
163        collection: &str,
164        request: crate::models::ExplainRequest,
165    ) -> Result<crate::models::ExplainResponse> {
166        let mut payload = serde_json::json!({ "vector": request.vector });
167        if let Some(k) = request.k {
168            payload
169                .as_object_mut()
170                .map(|o| o.insert("k".to_string(), serde_json::json!(k)));
171        }
172        let response = self
173            .make_request(
174                "POST",
175                &format!("/collections/{collection}/explain"),
176                Some(payload),
177            )
178            .await?;
179        serde_json::from_str(&response).map_err(|e| {
180            VectorizerError::server(format!("Failed to parse explain_search response: {e}"))
181        })
182    }
183
184    /// Hybrid search combining dense and sparse vectors with one of
185    /// three scoring algorithms (RRF, weighted, alpha-blending).
186    pub async fn hybrid_search(
187        &self,
188        request: HybridSearchRequest,
189    ) -> Result<HybridSearchResponse> {
190        let url = format!("/collections/{}/hybrid_search", request.collection);
191        let payload = serde_json::json!({
192            "query": request.query,
193            "alpha": request.alpha,
194            "algorithm": match request.algorithm {
195                HybridScoringAlgorithm::ReciprocalRankFusion => "rrf",
196                HybridScoringAlgorithm::WeightedCombination => "weighted",
197                HybridScoringAlgorithm::AlphaBlending => "alpha",
198            },
199            "dense_k": request.dense_k,
200            "sparse_k": request.sparse_k,
201            "final_k": request.final_k,
202            "query_sparse": request.query_sparse.as_ref().map(|sv| serde_json::json!({
203                "indices": sv.indices,
204                "values": sv.values,
205            })),
206        });
207        let response = self.make_request("POST", &url, Some(payload)).await?;
208        serde_json::from_str(&response).map_err(|e| {
209            VectorizerError::server(format!("Failed to parse hybrid search response: {e}"))
210        })
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use serde_json::json;
217
218    use crate::models::{ExplainRequest, ExplainResponse, ExplainTrace};
219
220    #[test]
221    fn explain_request_serialize_with_k() {
222        let req = ExplainRequest {
223            vector: vec![0.1, 0.2, 0.3],
224            k: Some(5),
225        };
226        let v = serde_json::to_value(&req).unwrap();
227        assert_eq!(v["k"], 5);
228        // f32 → f64 promotion introduces sub-microsecond error; use epsilon.
229        let first = v["vector"][0].as_f64().unwrap();
230        assert!((first - 0.1_f64).abs() < 1e-6, "unexpected value: {first}");
231    }
232
233    #[test]
234    fn explain_request_serialize_without_k() {
235        let req = ExplainRequest {
236            vector: vec![0.1],
237            k: None,
238        };
239        let v = serde_json::to_value(&req).unwrap();
240        // k is skip_serializing_if = "Option::is_none"
241        assert!(v.get("k").is_none());
242    }
243
244    #[test]
245    fn explain_response_wire_shape() {
246        // Mirror of `POST /collections/{name}/explain` response.
247        let raw = json!({
248            "collection": "docs",
249            "k": 10,
250            "results": [
251                { "id": "vec-1", "score": 0.95, "payload": null }
252            ],
253            "trace": {
254                "visited_nodes": 120,
255                "ef_search": 100,
256                "hnsw_search_ms": 1.23,
257                "payload_filter_evals": 0,
258                "quantization_score_ms": 0.45,
259                "total_ms": 2.10,
260            }
261        });
262        let resp: ExplainResponse = serde_json::from_value(raw).unwrap();
263        assert_eq!(resp.collection, "docs");
264        assert_eq!(resp.k, 10);
265        assert_eq!(resp.results.len(), 1);
266        assert_eq!(resp.trace.visited_nodes, 120);
267        assert_eq!(resp.trace.ef_search, 100);
268        assert!((resp.trace.hnsw_search_ms - 1.23).abs() < 1e-6);
269    }
270
271    #[test]
272    fn explain_trace_deserializes_all_fields() {
273        let raw = json!({
274            "visited_nodes": 200,
275            "ef_search": 64,
276            "hnsw_search_ms": 3.5,
277            "payload_filter_evals": 10,
278            "quantization_score_ms": 0.8,
279            "total_ms": 5.0,
280        });
281        let t: ExplainTrace = serde_json::from_value(raw).unwrap();
282        assert_eq!(t.payload_filter_evals, 10);
283        assert!((t.quantization_score_ms - 0.8).abs() < 1e-9);
284    }
285}