Skip to main content

nexo_memory/
vector_backend.rs

1//! Phase 81.26 — `VectorBackend` trait + wire shapes.
2//!
3//! Subprocess plugins declaring
4//! `[plugin.extends].memory_backends = [...]` register
5//! implementations of this trait into the daemon's
6//! `VectorBackendRegistry`. Operator-side consumers
7//! (`LongTermMemory.recall_vector` lookup) wire to the
8//! registry in 81.26.b — v1 ships the trait + wire surface
9//! only.
10//!
11//! Vector-only scope: short-term + long-term memory keep their
12//! existing SQLite implementation. Plugins replace ONLY the
13//! vector index. The primary use case is Pinecone / Qdrant /
14//! Weaviate / pgvector, where operators want managed-vector-DB
15//! features (filtering, sharding, hybrid retrieval) the
16//! sqlite-vec extension doesn't ship.
17
18use async_trait::async_trait;
19use serde::{Deserialize, Serialize};
20
21/// One vector record. `embedding` is the pre-computed dense
22/// vector (host-side embedder or LLM provider produces it);
23/// `metadata` is opaque JSON the backend may filter against.
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25pub struct VectorRecord {
26    pub id: String,
27    pub content: String,
28    pub embedding: Vec<f32>,
29    /// Backend-specific metadata. Pinecone / Qdrant / Weaviate
30    /// each accept different shapes; the host passes whatever
31    /// the operator's caller provides as opaque JSON.
32    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
33    pub metadata: serde_json::Value,
34}
35
36/// Vector search request. `filter` is an opaque JSON value the
37/// backend interprets per its own convention (Pinecone metadata
38/// filter, Qdrant filter expression, etc.) — the host does NOT
39/// validate or rewrite it.
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
41pub struct VectorQuery {
42    pub embedding: Vec<f32>,
43    pub limit: u32,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub filter: Option<serde_json::Value>,
46}
47
48/// One match returned by `VectorBackend::search`. `score` is on
49/// the backend's native scale (cosine vs dot-product vs distance);
50/// each backend documents its convention.
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
52pub struct VectorMatch {
53    pub id: String,
54    pub content: String,
55    pub score: f32,
56    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
57    pub metadata: serde_json::Value,
58}
59
60#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
61pub struct UpsertAck {
62    pub count: u32,
63}
64
65#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
66pub struct DeleteAck {
67    pub count: u32,
68}
69
70/// Pluggable vector store. v1 covers `upsert / search / delete`;
71/// hybrid retrieval (vector + tag filter) stays in
72/// `LongTermMemory` and orchestrates this trait when wired.
73#[async_trait]
74pub trait VectorBackend: Send + Sync + 'static {
75    /// Stable backend identifier matching the operator's
76    /// `agents.yaml.<id>.vector_backend = "<name>"` selector
77    /// (consumer wiring lands in 81.26.b).
78    fn name(&self) -> &str;
79
80    async fn upsert(
81        &self,
82        collection: &str,
83        records: Vec<VectorRecord>,
84    ) -> anyhow::Result<UpsertAck>;
85
86    async fn search(
87        &self,
88        collection: &str,
89        query: VectorQuery,
90    ) -> anyhow::Result<Vec<VectorMatch>>;
91
92    async fn delete(&self, collection: &str, ids: Vec<String>) -> anyhow::Result<DeleteAck>;
93}
94
95// ── Tests ────────────────────────────────────────────────────────
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn vector_record_round_trips() {
103        let r = VectorRecord {
104            id: "r1".into(),
105            content: "hello".into(),
106            embedding: vec![0.1, 0.2, 0.3],
107            metadata: serde_json::json!({"source": "kb"}),
108        };
109        let s = serde_json::to_string(&r).unwrap();
110        let back: VectorRecord = serde_json::from_str(&s).unwrap();
111        assert_eq!(back, r);
112    }
113
114    #[test]
115    fn vector_query_round_trips() {
116        let q = VectorQuery {
117            embedding: vec![0.4, 0.5],
118            limit: 10,
119            filter: Some(serde_json::json!({"namespace": "tenant-1"})),
120        };
121        let s = serde_json::to_string(&q).unwrap();
122        let back: VectorQuery = serde_json::from_str(&s).unwrap();
123        assert_eq!(back, q);
124
125        // Filter omitted when None.
126        let q_no_filter = VectorQuery {
127            embedding: vec![1.0],
128            limit: 5,
129            filter: None,
130        };
131        let s = serde_json::to_string(&q_no_filter).unwrap();
132        assert!(!s.contains("filter"));
133    }
134
135    #[test]
136    fn vector_match_round_trips() {
137        let m = VectorMatch {
138            id: "r1".into(),
139            content: "hello".into(),
140            score: 0.97,
141            metadata: serde_json::json!({"source": "kb"}),
142        };
143        let s = serde_json::to_string(&m).unwrap();
144        let back: VectorMatch = serde_json::from_str(&s).unwrap();
145        assert_eq!(back, m);
146    }
147
148    #[test]
149    fn upsert_ack_round_trips() {
150        let a = UpsertAck { count: 42 };
151        let s = serde_json::to_string(&a).unwrap();
152        let back: UpsertAck = serde_json::from_str(&s).unwrap();
153        assert_eq!(back, a);
154
155        let d = DeleteAck { count: 7 };
156        let s = serde_json::to_string(&d).unwrap();
157        let back: DeleteAck = serde_json::from_str(&s).unwrap();
158        assert_eq!(back, d);
159    }
160}