Skip to main content

khive_runtime/retrieval/
single_engine.rs

1use khive_storage::{StorageCapability, StorageError, VectorSearchHit, VectorSearchRequest};
2use khive_types::SubstrateKind;
3use uuid::Uuid;
4
5use crate::{KhiveRuntime, NamespaceToken, RuntimeError, RuntimeResult};
6
7impl KhiveRuntime {
8    /// Search one configured embedding space using a raw vector or query text.
9    ///
10    /// This compatibility API selects the configured default (the first configured
11    /// peer). It never infers a space from vector dimensions or falls through to
12    /// another engine. Use [`Self::vector_search_in`] to name the space explicitly.
13    pub async fn vector_search(
14        &self,
15        token: &NamespaceToken,
16        query_embedding: Option<Vec<f32>>,
17        query_text: Option<&str>,
18        top_k: u32,
19        kind: Option<SubstrateKind>,
20    ) -> RuntimeResult<Vec<VectorSearchHit>> {
21        validate_query_input(query_embedding.as_deref(), query_text)?;
22        let engine = self.require_default_embedder()?;
23        self.vector_search_in(token, engine, query_embedding, query_text, top_k, kind)
24            .await
25    }
26
27    /// Search exactly the named engine's space, even when another engine has the
28    /// same dimensions. Built-in aliases resolve to their canonical model name.
29    ///
30    /// A supplied vector avoids embedding. Otherwise the named query embedder is
31    /// invoked with the caller's token. Unknown/excluded engines, wrong dimensions
32    /// and nonfinite coordinates refuse even when `top_k` is zero.
33    // REASON: preserve vector_search's arguments while adding an explicit engine identity.
34    #[allow(clippy::too_many_arguments)]
35    pub async fn vector_search_in(
36        &self,
37        token: &NamespaceToken,
38        engine: &str,
39        query_embedding: Option<Vec<f32>>,
40        query_text: Option<&str>,
41        top_k: u32,
42        kind: Option<SubstrateKind>,
43    ) -> RuntimeResult<Vec<VectorSearchHit>> {
44        validate_query_input(query_embedding.as_deref(), query_text)?;
45        let (engine, dimensions) = self.vector_model_metadata(engine)?;
46        let embedding = match query_embedding {
47            Some(vector) => vector,
48            None => {
49                let text = query_text.ok_or_else(missing_query)?;
50                self.embed_query_with_model_for_token(token, &engine, text)
51                    .await?
52            }
53        };
54        validate_vector(&engine, dimensions, &embedding, "vec_search")?;
55        let hits = self
56            .vectors_for_model(token, &engine)?
57            .search(VectorSearchRequest {
58                query_vectors: vec![embedding],
59                top_k,
60                namespace: Some(token.namespace().as_str().to_owned()),
61                kind,
62                embedding_model: None,
63                filter: None,
64                backend_hints: None,
65            })
66            .await;
67        crate::usage::count(crate::usage::UsageUnit::VectorPasses, 1);
68        hits.map_err(RuntimeError::from)
69    }
70
71    /// Exact entity KNN in the configured default (first configured peer) space.
72    /// Use [`Self::knn_in`] when the query vector belongs to a named engine.
73    pub async fn knn(
74        &self,
75        token: &NamespaceToken,
76        query_vector: Vec<f32>,
77        top_k: u32,
78    ) -> RuntimeResult<Vec<VectorSearchHit>> {
79        let engine = self.require_default_embedder()?;
80        self.knn_in(token, engine, query_vector, top_k).await
81    }
82
83    /// Exact entity KNN in the named engine's space and token namespace.
84    /// Unknown/excluded names and invalid vectors refuse before search, including
85    /// zero-result requests. No embedding service is instantiated for this API.
86    pub async fn knn_in(
87        &self,
88        token: &NamespaceToken,
89        engine: &str,
90        query_vector: Vec<f32>,
91        top_k: u32,
92    ) -> RuntimeResult<Vec<VectorSearchHit>> {
93        let (engine, dimensions) = self.vector_model_metadata(engine)?;
94        validate_vector(&engine, dimensions, &query_vector, "vec_search")?;
95        Ok(self
96            .vectors_for_model(token, &engine)?
97            .search(VectorSearchRequest {
98                query_vectors: vec![query_vector],
99                top_k,
100                namespace: Some(token.namespace().as_str().to_owned()),
101                kind: Some(SubstrateKind::Entity),
102                embedding_model: None,
103                filter: None,
104                backend_hints: None,
105            })
106            .await?)
107    }
108
109    /// Score only the supplied entity candidates in the configured default
110    /// (first configured peer) space. Use [`Self::rerank_in`] for a named space.
111    pub async fn rerank(
112        &self,
113        token: &NamespaceToken,
114        query_vector: &[f32],
115        candidate_ids: &[Uuid],
116        top_k: u32,
117    ) -> RuntimeResult<Vec<VectorSearchHit>> {
118        let engine = self.require_default_embedder()?;
119        self.rerank_in(token, engine, query_vector, candidate_ids, top_k)
120            .await
121    }
122
123    /// Score only the supplied entity candidates in the named engine's space.
124    ///
125    /// Missing or out-of-scope vectors are omitted; equal scores use ascending
126    /// IDs and duplicate IDs yield one hit. The result limit applies after all
127    /// candidates are scored. Unsupported storage propagates its capability error
128    /// without falling back to namespace-wide search. Engine identity, dimensions
129    /// and finite coordinates are checked even for empty candidates or zero limit.
130    pub async fn rerank_in(
131        &self,
132        token: &NamespaceToken,
133        engine: &str,
134        query_vector: &[f32],
135        candidate_ids: &[Uuid],
136        top_k: u32,
137    ) -> RuntimeResult<Vec<VectorSearchHit>> {
138        let (engine, dimensions) = self.vector_model_metadata(engine)?;
139        validate_vector(&engine, dimensions, query_vector, "score_candidates")?;
140        let mut hits = self
141            .vectors_for_model(token, &engine)?
142            .score_candidates(query_vector, candidate_ids, Some(SubstrateKind::Entity))
143            .await?;
144        hits.truncate(top_k as usize);
145        Ok(hits)
146    }
147}
148
149fn missing_query() -> RuntimeError {
150    RuntimeError::InvalidInput("vector search requires query_embedding or query_text".into())
151}
152
153fn validate_query_input(vector: Option<&[f32]>, text: Option<&str>) -> RuntimeResult<()> {
154    if vector.is_none() {
155        let text = text.ok_or_else(missing_query)?;
156        if text.trim().is_empty() {
157            return Err(RuntimeError::InvalidInput(
158                "query_text must not be empty".into(),
159            ));
160        }
161    }
162    Ok(())
163}
164
165fn validate_vector(
166    engine: &str,
167    dimensions: usize,
168    vector: &[f32],
169    operation: &'static str,
170) -> RuntimeResult<()> {
171    let message = if vector.len() != dimensions {
172        Some(format!(
173            "engine '{engine}' query has {} dims, expected {dimensions}",
174            vector.len()
175        ))
176    } else {
177        vector
178            .iter()
179            .position(|coordinate| !coordinate.is_finite())
180            .map(|index| {
181                format!("engine '{engine}' query has a nonfinite coordinate at index {index}")
182            })
183    };
184    if let Some(message) = message {
185        return Err(StorageError::InvalidInput {
186            capability: StorageCapability::Vectors,
187            operation: operation.into(),
188            message,
189        }
190        .into());
191    }
192    Ok(())
193}