Skip to main content

cratefield_core/ports/
vector_index.rs

1//! The `VectorIndex` port (issue #561, ADR 0024): similarity search over
2//! embedding vectors, asked for by **namespace**, never by vendor (ADR
3//! 0002). A module holds `Arc<dyn VectorIndex>` and never learns whether
4//! the nearest neighbours came from Cloudflare Vectorize, Postgres, or the
5//! in-process [`ExactVectorIndex`]; the vectors come from the
6//! [`Embedder`](crate::Embedder) port, the sibling of this one.
7//!
8//! **One namespace per tenant, and a query never crosses namespaces** —
9//! every call names a [`VectorNamespace`], the tenant-scoping rule a table
10//! has (RECONCILIATION.md §2) expressed as a namespace. Scores are cosine
11//! similarity, higher is closer. No outcome enum, as on
12//! [`TextModel`](crate::TextModel): the unwired answer is
13//! [`VectorIndexError::NotConfigured`], so a module that cannot degrade
14//! without its index fails loudly.
15
16use std::collections::{BTreeMap, HashMap};
17
18use async_trait::async_trait;
19use serde_json::Value;
20
21use crate::tenant::TenantId;
22
23// The guard over the index's own corpus — adapter storage, not request
24// state; the allow follows the workspace `clippy.toml` policy.
25#[allow(clippy::disallowed_types)]
26type Guarded<T> = std::sync::Mutex<T>;
27
28/// The longest namespace name accepted, in bytes: Cloudflare Vectorize's
29/// limit, and so the port's.
30pub const MAX_NAMESPACE_BYTES: usize = 64;
31
32/// The search space one call sees: one tenant's slice of the index.
33#[derive(Debug, Clone, PartialEq, Eq, Hash)]
34pub struct VectorNamespace(String);
35
36impl VectorNamespace {
37    /// The namespace of a tenant: the tenant id itself — always under
38    /// [`MAX_NAMESPACE_BYTES`], so this cannot fail.
39    #[must_use]
40    pub fn for_tenant(tenant: &TenantId) -> Self {
41        Self(tenant.as_str().to_owned())
42    }
43
44    /// A namespace named directly, for tests and non-tenant corpora.
45    ///
46    /// # Errors
47    /// [`VectorIndexError::InvalidInput`] when the name is empty or over
48    /// [`MAX_NAMESPACE_BYTES`] bytes.
49    pub fn new(namespace: impl Into<String>) -> Result<Self, VectorIndexError> {
50        let namespace = namespace.into();
51        let bytes = namespace.len();
52        if bytes == 0 || bytes > MAX_NAMESPACE_BYTES {
53            return Err(VectorIndexError::InvalidInput(format!(
54                "namespace must be 1..={MAX_NAMESPACE_BYTES} bytes, got {bytes}"
55            )));
56        }
57        Ok(Self(namespace))
58    }
59
60    #[must_use]
61    pub fn as_str(&self) -> &str {
62        &self.0
63    }
64}
65
66impl std::fmt::Display for VectorNamespace {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        f.write_str(&self.0)
69    }
70}
71
72/// One stored vector: its id, its embedding, and the metadata a filter can
73/// select on — carried back on every [`VectorMatch`].
74#[derive(Debug, Clone, PartialEq)]
75pub struct VectorRecord {
76    pub id: String,
77    pub values: Vec<f32>,
78    pub metadata: BTreeMap<String, Value>,
79}
80
81impl VectorRecord {
82    /// A record with the id and the embedding; metadata follows with
83    /// [`VectorRecord::with_metadata`].
84    #[must_use]
85    pub fn new(id: impl Into<String>, values: Vec<f32>) -> Self {
86        Self {
87            id: id.into(),
88            values,
89            metadata: BTreeMap::new(),
90        }
91    }
92
93    /// Attaches one metadata entry, chainable.
94    #[must_use]
95    pub fn with_metadata(mut self, key: impl Into<String>, value: Value) -> Self {
96        self.metadata.insert(key.into(), value);
97        self
98    }
99}
100
101/// Equality on metadata: a record matches when its metadata holds **every**
102/// key with an equal value; values compare as JSON, so `"1"` and `1`
103/// differ. The default matches all. Hosted indexes may filter only on keys
104/// pre-registered as metadata indexes; the exact index filters on all.
105#[derive(Debug, Clone, PartialEq, Default)]
106pub struct VectorFilter {
107    eq: BTreeMap<String, Value>,
108}
109
110impl VectorFilter {
111    /// The filter that matches everything.
112    #[must_use]
113    pub fn new() -> Self {
114        Self::default()
115    }
116
117    /// Requires `metadata[key] == value`.
118    #[must_use]
119    pub fn eq(mut self, key: impl Into<String>, value: Value) -> Self {
120        self.eq.insert(key.into(), value);
121        self
122    }
123
124    /// The equality constraints, for an adapter to translate.
125    #[must_use]
126    pub fn filters(&self) -> &BTreeMap<String, Value> {
127        &self.eq
128    }
129
130    /// Whether `metadata` satisfies this filter — the one definition of
131    /// "matches".
132    #[must_use]
133    pub fn matches(&self, metadata: &BTreeMap<String, Value>) -> bool {
134        self.eq
135            .iter()
136            .all(|(key, value)| metadata.get(key) == Some(value))
137    }
138}
139
140/// One nearest neighbour: the record's id, its cosine similarity to the
141/// query (higher is closer), and its metadata.
142#[derive(Debug, Clone, PartialEq)]
143pub struct VectorMatch {
144    pub id: String,
145    pub score: f32,
146    pub metadata: BTreeMap<String, Value>,
147}
148
149/// Vector-index failures. Provider-text variants are scrubbed in `Display`
150/// (issue #235); `Debug` stays raw for tests.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub enum VectorIndexError {
153    /// No vector index is wired — the venture did not provide one.
154    NotConfigured,
155    /// The request was malformed before any provider saw it. Not retryable.
156    InvalidInput(String),
157    /// The call failed against the provider (or on the way to it).
158    Operation(String),
159}
160
161impl std::fmt::Display for VectorIndexError {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        let scrub = crate::logging::scrub_text;
164        match self {
165            Self::NotConfigured => f.write_str("no vector index is wired"),
166            Self::InvalidInput(message) => {
167                write!(f, "vector index rejected the request: {}", scrub(message))
168            }
169            Self::Operation(message) => {
170                write!(f, "vector index operation failed: {}", scrub(message))
171            }
172        }
173    }
174}
175
176impl std::error::Error for VectorIndexError {}
177
178/// Stores and ranks embedding vectors, within the namespace every call
179/// names — the sibling of [`Embedder`](crate::Embedder), which makes the
180/// vectors this one keeps and searches. Hosted adapters (Vectorize) are
181/// eventually consistent — a write may take seconds to become queryable —
182/// while [`ExactVectorIndex`] is immediate.
183#[async_trait]
184pub trait VectorIndex: Send + Sync {
185    /// Writes every record into `namespace`, replacing any existing record
186    /// with the same id — values and metadata both, never a merge.
187    ///
188    /// # Errors
189    /// [`VectorIndexError::InvalidInput`] on an empty or wrong-width
190    /// vector; [`VectorIndexError::NotConfigured`] when unwired;
191    /// [`VectorIndexError::Operation`] when the call fails.
192    async fn upsert(
193        &self,
194        namespace: &VectorNamespace,
195        records: &[VectorRecord],
196    ) -> Result<(), VectorIndexError>;
197
198    /// The at-most-`k` nearest neighbours of `vector` in `namespace`,
199    /// cosine similarity descending, every match satisfying `filter`.
200    ///
201    /// # Errors
202    /// [`VectorIndexError::InvalidInput`] on an empty or wrong-width
203    /// vector; [`VectorIndexError::NotConfigured`] when unwired;
204    /// [`VectorIndexError::Operation`] when the call failed.
205    async fn query(
206        &self,
207        namespace: &VectorNamespace,
208        vector: &[f32],
209        k: usize,
210        filter: &VectorFilter,
211    ) -> Result<Vec<VectorMatch>, VectorIndexError>;
212
213    /// Removes the records named by `ids` from `namespace`. A missing id is
214    /// not an error: the delete is idempotent.
215    ///
216    /// # Errors
217    /// [`VectorIndexError::NotConfigured`] when unwired;
218    /// [`VectorIndexError::Operation`] when the call fails.
219    async fn delete(
220        &self,
221        namespace: &VectorNamespace,
222        ids: &[String],
223    ) -> Result<(), VectorIndexError>;
224}
225
226/// What an upsert leaves behind: the embedding and its metadata.
227type Stored = (Vec<f32>, BTreeMap<String, Value>);
228
229/// The native, in-process [`VectorIndex`]: exact (brute-force) cosine
230/// ranking over an in-memory map — the true top-`k`, ties broken by id
231/// ascending, so the same corpus and query always answer in the same
232/// order. For development, tests, and small single-process corpora:
233/// **data is not persisted**, and a restart starts empty.
234pub struct ExactVectorIndex {
235    dimensions: usize,
236    // The index's own corpus — adapter storage, not request state
237    // (clippy.toml), the way `adapter-sqlite` guards its connection.
238    #[allow(clippy::disallowed_types)]
239    namespaces: Guarded<HashMap<String, BTreeMap<String, Stored>>>,
240}
241
242impl ExactVectorIndex {
243    /// An empty index whose vectors are all `dimensions` wide — the width
244    /// of whatever [`Embedder`](crate::Embedder) is wired alongside it.
245    #[must_use]
246    pub fn new(dimensions: usize) -> Self {
247        Self {
248            dimensions,
249            namespaces: Guarded::new(HashMap::new()),
250        }
251    }
252
253    /// Rejects records a hosted index would refuse: an empty, wrong-width
254    /// or non-finite vector, or metadata it cannot store.
255    fn validate(&self, records: &[VectorRecord]) -> Result<(), VectorIndexError> {
256        for record in records {
257            if record.values.is_empty() {
258                return Err(VectorIndexError::InvalidInput(format!(
259                    "vector for record `{}` is empty",
260                    record.id
261                )));
262            }
263            if record.values.len() != self.dimensions {
264                return Err(VectorIndexError::InvalidInput(format!(
265                    "vector for record `{}` has {} dimensions, index expects {}",
266                    record.id,
267                    record.values.len(),
268                    self.dimensions
269                )));
270            }
271            if record.values.iter().any(|value| !value.is_finite()) {
272                return Err(VectorIndexError::InvalidInput(format!(
273                    "vector for record `{}` has a non-finite component",
274                    record.id
275                )));
276            }
277            if !vectorize_metadata(&record.metadata) {
278                return Err(VectorIndexError::InvalidInput(format!(
279                    "metadata of record `{}` holds a value Vectorize cannot store",
280                    record.id
281                )));
282            }
283        }
284        Ok(())
285    }
286}
287
288/// Whether every metadata value is one Vectorize stores and filters on —
289/// a string, number, boolean, or array of strings — so dev fails like prod.
290fn vectorize_metadata(metadata: &BTreeMap<String, Value>) -> bool {
291    metadata.values().all(|value| match value {
292        Value::String(_) | Value::Number(_) | Value::Bool(_) => true,
293        Value::Array(items) => items.iter().all(Value::is_string),
294        _ => false,
295    })
296}
297
298/// Cosine similarity, the port's score; two zero vectors answer `0.0`.
299fn cosine(a: &[f32], b: &[f32]) -> f32 {
300    let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
301    let norms =
302        a.iter().map(|x| x * x).sum::<f32>().sqrt() * b.iter().map(|y| y * y).sum::<f32>().sqrt();
303    if norms == 0.0 { 0.0 } else { dot / norms }
304}
305
306#[async_trait]
307impl VectorIndex for ExactVectorIndex {
308    async fn upsert(
309        &self,
310        namespace: &VectorNamespace,
311        records: &[VectorRecord],
312    ) -> Result<(), VectorIndexError> {
313        self.validate(records)?;
314        let mut namespaces = self.namespaces.lock().expect("lock uncontended");
315        let index = namespaces.entry(namespace.as_str().to_owned()).or_default();
316        for record in records {
317            index.insert(
318                record.id.clone(),
319                (record.values.clone(), record.metadata.clone()),
320            );
321        }
322        Ok(())
323    }
324
325    async fn query(
326        &self,
327        namespace: &VectorNamespace,
328        vector: &[f32],
329        k: usize,
330        filter: &VectorFilter,
331    ) -> Result<Vec<VectorMatch>, VectorIndexError> {
332        if vector.is_empty() {
333            return Err(VectorIndexError::InvalidInput(
334                "query vector is empty".to_owned(),
335            ));
336        }
337        if vector.len() != self.dimensions {
338            return Err(VectorIndexError::InvalidInput(format!(
339                "query vector has {} dimensions, index expects {}",
340                vector.len(),
341                self.dimensions
342            )));
343        }
344        if vector.iter().any(|value| !value.is_finite()) {
345            return Err(VectorIndexError::InvalidInput(
346                "query vector has a non-finite component".to_owned(),
347            ));
348        }
349        if k == 0 {
350            return Ok(Vec::new());
351        }
352        let namespaces = self.namespaces.lock().expect("lock uncontended");
353        let Some(index) = namespaces.get(namespace.as_str()) else {
354            return Ok(Vec::new());
355        };
356        // A `BTreeMap` iterates id-ascending and `sort_by` is stable, so
357        // equal scores come out id-ascending — the deterministic order the
358        // adapter promises.
359        let mut scored: Vec<(f32, &Stored, &String)> = index
360            .iter()
361            .filter(|(_, (_, metadata))| filter.matches(metadata))
362            .map(|(id, stored)| (cosine(vector, &stored.0), stored, id))
363            .collect();
364        scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
365        Ok(scored
366            .into_iter()
367            .take(k)
368            .map(|(score, stored, id)| VectorMatch {
369                id: id.clone(),
370                score,
371                metadata: stored.1.clone(),
372            })
373            .collect())
374    }
375
376    async fn delete(
377        &self,
378        namespace: &VectorNamespace,
379        ids: &[String],
380    ) -> Result<(), VectorIndexError> {
381        let mut namespaces = self.namespaces.lock().expect("lock uncontended");
382        if let Some(index) = namespaces.get_mut(namespace.as_str()) {
383            for id in ids {
384                index.remove(id);
385            }
386        }
387        Ok(())
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn namespaces_are_one_to_max_namespace_bytes() {
397        assert!(VectorNamespace::new("").is_err());
398        assert!(VectorNamespace::new("x".repeat(MAX_NAMESPACE_BYTES)).is_ok());
399        assert!(VectorNamespace::new("x".repeat(MAX_NAMESPACE_BYTES + 1)).is_err());
400        // Tenant ids are minted by core's resolution layer, well under the
401        // limit, so `for_tenant` cannot fail.
402        let tenant = crate::tenant::TenantId::new("tenant-1");
403        assert_eq!(VectorNamespace::for_tenant(&tenant).as_str(), "tenant-1");
404    }
405
406    // Issue #235: provider text must not survive into a log line; `Debug`
407    // stays raw for tests.
408    #[test]
409    fn error_display_scrubs_provider_text() {
410        let error = VectorIndexError::Operation(
411            "vectorize 500 at https://api.example.test?token=secret-abcdef".to_owned(),
412        );
413        let text = error.to_string();
414        assert!(text.contains("vector index operation failed"), "{text}");
415        assert!(!text.contains("secret-abcdef"), "{text}");
416        assert!(format!("{error:?}").contains("secret-abcdef"));
417        assert_eq!(
418            VectorIndexError::NotConfigured.to_string(),
419            "no vector index is wired"
420        );
421    }
422
423    #[test]
424    fn malformed_vectors_and_metadata_are_invalid_input() {
425        let index = ExactVectorIndex::new(2);
426        let ns = VectorNamespace::new("ns").expect("valid");
427        let upsert = |values: &[f32]| {
428            pollster::block_on(index.upsert(&ns, &[VectorRecord::new("r", values.to_owned())]))
429                .unwrap_err()
430        };
431        let query = |vector: &[f32]| {
432            pollster::block_on(index.query(&ns, vector, 5, &VectorFilter::default())).unwrap_err()
433        };
434        for (error, message) in [
435            (upsert(&[1.0, 0.0, 0.0]), "3 dimensions"),
436            (upsert(&[]), "is empty"),
437            (upsert(&[f32::NAN, 1.0]), "non-finite"),
438            (query(&[]), "is empty"),
439            (query(&[1.0, 0.0, 0.0]), "3 dimensions"),
440            (query(&[f32::INFINITY, 1.0]), "non-finite"),
441        ] {
442            assert!(
443                matches!(error, VectorIndexError::InvalidInput(_)),
444                "{error}"
445            );
446            assert!(error.to_string().contains(message), "{error}");
447        }
448        // Vectorize stores string | number | boolean | string[] metadata;
449        // the exact index refuses the rest, so dev fails like prod.
450        let error = pollster::block_on(
451            index.upsert(
452                &ns,
453                &[VectorRecord::new("r", vec![1.0, 0.0])
454                    .with_metadata("nested", serde_json::json!({ "a": 1 }))],
455            ),
456        )
457        .unwrap_err();
458        assert!(
459            matches!(error, VectorIndexError::InvalidInput(_)),
460            "{error}"
461        );
462        assert!(
463            error.to_string().contains("Vectorize cannot store"),
464            "{error}"
465        );
466    }
467}