Skip to main content

khive_runtime/retrieval/
single_engine.rs

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