Skip to main content

mnemo_postgres/
pgvector_index.rs

1use std::path::Path;
2use std::sync::atomic::{AtomicUsize, Ordering};
3
4use mnemo_core::error::{Error, Result};
5use mnemo_core::index::VectorIndex;
6use pgvector::Vector;
7use sqlx::Row;
8use uuid::Uuid;
9
10/// A pgvector-backed [`VectorIndex`] for the PostgreSQL backend.
11///
12/// PostgreSQL stores each memory's embedding in a pgvector `vector` column
13/// (written by [`crate::PgStorage`]) and an HNSW index over it
14/// (`idx_memories_embedding_hnsw`, built with `vector_cosine_ops`). When
15/// constructed with a pool via [`PgVectorIndex::with_pool`], `search` /
16/// `filtered_search` run a real cosine-distance ANN query (`<=>`) against that
17/// index and return the top-k memory ids + distances — the same
18/// `(id, distance)` shape the USearch backend returns, so recall's
19/// `score = 1.0 - distance` conversion is identical across backends.
20///
21/// Constructed with [`PgVectorIndex::new`] (no pool) the ANN paths return the
22/// typed [`Error::BackendUnsupported`] rather than a silent empty set —
23/// silent-empty would make `semantic` / `auto` (hybrid) / `graph` /
24/// `domain_scoped` recall look like it legitimately "found nothing", the most
25/// dangerous failure mode for a memory database.
26///
27/// ## Runtime (v0.5.18)
28///
29/// [`VectorIndex::search`] / [`filtered_search`](VectorIndex::filtered_search)
30/// are **async**, so this backend `.await`s its `sqlx` query directly on the
31/// caller's ambient Tokio runtime. There is **no `block_on` bridge** — semantic
32/// recall works from inside the server/CLI `#[tokio::main]` runtime (any flavor,
33/// single- or multi-threaded) without the "Cannot start a runtime from within a
34/// runtime" panic or deadlock the old synchronous bridge risked. Integration
35/// tests run under `#[tokio::test]` / `#[tokio::test(flavor = "multi_thread")]`
36/// alike.
37///
38/// `add` / `remove` are intentional no-ops: the embedding is maintained by
39/// PostgreSQL on the `vector` column (via `PgStorage::insert_memory`), not by
40/// an in-process index. `len()` tracks an approximate element count for
41/// `is_empty()` callers.
42pub struct PgVectorIndex {
43    /// When `Some`, ANN search runs real pgvector SQL against this pool. When
44    /// `None`, the ANN paths fail loud with [`Error::BackendUnsupported`].
45    pool: Option<sqlx::PgPool>,
46    /// Width of the pgvector `vector(dim)` column; used to reject a
47    /// dimension-mismatched query with a clear message instead of a raw
48    /// Postgres error.
49    dimensions: usize,
50    count: AtomicUsize,
51}
52
53impl PgVectorIndex {
54    /// Create a pgvector index wrapper **without** a pool. The ANN search
55    /// paths return [`Error::BackendUnsupported`] (fail loud, never
56    /// silent-empty). Prefer [`PgVectorIndex::with_pool`] for a wired backend.
57    pub fn new() -> Self {
58        Self {
59            pool: None,
60            dimensions: 0,
61            count: AtomicUsize::new(0),
62        }
63    }
64
65    /// Create a pgvector index that runs real ANN search against `pool`.
66    ///
67    /// `dimensions` must match the `vector(dim)` column width the schema was
68    /// migrated with (the same value passed to `PgStorage::connect`).
69    pub fn with_pool(pool: sqlx::PgPool, dimensions: usize) -> Self {
70        Self {
71            pool: Some(pool),
72            dimensions,
73            count: AtomicUsize::new(0),
74        }
75    }
76
77    /// The cosine-distance ANN query against the HNSW index. Returns up to
78    /// `limit` `(id, distance)` rows, nearest first. `$1` (the query vector) is
79    /// referenced twice — once for the projected distance, once for the
80    /// index-ordered `ORDER BY` — from a single bind.
81    async fn ann_query(
82        pool: &sqlx::PgPool,
83        query: &Vector,
84        limit: usize,
85    ) -> Result<Vec<(Uuid, f32)>> {
86        let rows = sqlx::query(
87            "SELECT id, (embedding <=> $1) AS dist \
88             FROM memories \
89             WHERE embedding IS NOT NULL AND deleted_at IS NULL \
90             ORDER BY embedding <=> $1 \
91             LIMIT $2",
92        )
93        .bind(query)
94        .bind(limit as i64)
95        .fetch_all(pool)
96        .await
97        .map_err(map_ann_error)?;
98
99        let mut out = Vec::with_capacity(rows.len());
100        for row in &rows {
101            let id: Uuid = row.try_get("id").map_err(|e| Error::Index(e.to_string()))?;
102            let dist: f64 = row
103                .try_get("dist")
104                .map_err(|e| Error::Index(e.to_string()))?;
105            out.push((id, dist as f32));
106        }
107        Ok(out)
108    }
109
110    /// Validate the query dimension and resolve the pool, or fail loud.
111    fn pool_for(&self, query: &[f32]) -> Result<&sqlx::PgPool> {
112        let pool = self.pool.as_ref().ok_or_else(ann_unsupported)?;
113        if self.dimensions != 0 && query.len() != self.dimensions {
114            return Err(Error::Index(format!(
115                "query embedding has {} dims but the pgvector column is {} — \
116                 re-embed with the configured model",
117                query.len(),
118                self.dimensions
119            )));
120        }
121        Ok(pool)
122    }
123}
124
125impl Default for PgVectorIndex {
126    fn default() -> Self {
127        Self::new()
128    }
129}
130
131/// The typed error returned when ANN search is genuinely unavailable — the
132/// index was constructed without a pool, or the pgvector extension / operator
133/// is absent at runtime. Uses the structured [`Error::BackendUnsupported`]
134/// variant so callers can match on `backend` / `capability` programmatically
135/// instead of string-sniffing the message.
136fn ann_unsupported() -> Error {
137    Error::BackendUnsupported {
138        backend: "postgres".to_string(),
139        capability: "semantic_recall".to_string(),
140        detail: "pgvector ANN search is unavailable: the index has no database \
141                 pool, or the pgvector extension / `<=>` operator is not \
142                 installed. Ensure the `vector` extension and the \
143                 `idx_memories_embedding_hnsw` index exist (created by \
144                 migrations), or use strategy=\"lexical\"/\"exact\". \
145                 Tracking: https://github.com/sattyamjjain/mnemo/issues/99"
146            .to_string(),
147    }
148}
149
150/// Map a query error: a missing pgvector extension / `<=>` operator / `vector`
151/// type is a *capability-absent* condition → typed [`Error::BackendUnsupported`];
152/// anything else is a real, loud [`Error::Index`]. Never silent-empty.
153fn map_ann_error(e: sqlx::Error) -> Error {
154    let msg = e.to_string();
155    let lower = msg.to_lowercase();
156    let capability_absent = (lower.contains("operator does not exist") && lower.contains("<=>"))
157        || lower.contains("type \"vector\" does not exist")
158        || lower.contains("extension \"vector\"");
159    if capability_absent {
160        ann_unsupported()
161    } else {
162        Error::Index(format!("pgvector ANN query failed: {msg}"))
163    }
164}
165
166#[async_trait::async_trait]
167impl VectorIndex for PgVectorIndex {
168    fn add(&self, _id: Uuid, _vector: &[f32]) -> Result<()> {
169        // No-op: PostgreSQL maintains the embedding on the `vector` column.
170        self.count.fetch_add(1, Ordering::Relaxed);
171        Ok(())
172    }
173
174    fn remove(&self, _id: Uuid) -> Result<()> {
175        let _ = self
176            .count
177            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| {
178                Some(n.saturating_sub(1))
179            });
180        Ok(())
181    }
182
183    // Truly async: awaits the pgvector `sqlx` query on the ambient runtime, so
184    // it can never re-enter or deadlock the caller's `#[tokio::main]` runtime.
185    async fn search(&self, query: &[f32], limit: usize) -> Result<Vec<(Uuid, f32)>> {
186        let pool = self.pool_for(query)?;
187        let vec = Vector::from(query.to_vec());
188        Self::ann_query(pool, &vec, limit).await
189    }
190
191    async fn filtered_search(
192        &self,
193        query: &[f32],
194        limit: usize,
195        filter: &(dyn Fn(Uuid) -> bool + Send + Sync),
196    ) -> Result<Vec<(Uuid, f32)>> {
197        let pool = self.pool_for(query)?;
198        let vec = Vector::from(query.to_vec());
199        if limit == 0 {
200            return Ok(Vec::new());
201        }
202
203        // Permission-safe iterative oversample: start at 3x, double until we
204        // have `limit` accessible hits or the underlying table is exhausted
205        // (the ANN query returned fewer rows than we asked for). Mirrors the
206        // USearch backend so filtered recall never under-returns.
207        let mut oversample = limit.saturating_mul(3).max(1);
208        loop {
209            let candidates = Self::ann_query(pool, &vec, oversample).await?;
210            let exhausted = candidates.len() < oversample;
211            let filtered: Vec<(Uuid, f32)> = candidates
212                .into_iter()
213                .filter(|(id, _)| filter(*id))
214                .take(limit)
215                .collect();
216            if filtered.len() >= limit || exhausted {
217                return Ok(filtered);
218            }
219            oversample = oversample.saturating_mul(2);
220        }
221    }
222
223    fn save(&self, _path: &Path) -> Result<()> {
224        // No local state to persist -- vectors live in PostgreSQL.
225        Ok(())
226    }
227
228    fn load(&self, _path: &Path) -> Result<()> {
229        // No local state to restore -- vectors live in PostgreSQL.
230        Ok(())
231    }
232
233    fn len(&self) -> usize {
234        self.count.load(Ordering::Relaxed)
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[tokio::test]
243    async fn ann_search_fails_loud_not_silent_empty() {
244        // Constructed without a pool: ANN is genuinely unavailable and MUST
245        // fail loud with the typed variant, never Ok(empty).
246        let idx = PgVectorIndex::new();
247
248        // add/remove remain no-ops that maintain the approximate count.
249        idx.add(Uuid::nil(), &[0.1, 0.2, 0.3]).unwrap();
250        assert_eq!(idx.len(), 1);
251        idx.remove(Uuid::nil()).unwrap();
252        assert_eq!(idx.len(), 0);
253
254        assert!(
255            idx.search(&[0.1, 0.2, 0.3], 5).await.is_err(),
256            "search must fail loud, not return Ok(empty)"
257        );
258        assert!(
259            idx.filtered_search(&[0.1, 0.2, 0.3], 5, &|_| true)
260                .await
261                .is_err(),
262            "filtered_search must fail loud, not return Ok(empty)"
263        );
264
265        // It must be the structured, typed variant — callers match on
266        // backend/capability, not the message string.
267        match idx.search(&[0.0], 1).await.unwrap_err() {
268            Error::BackendUnsupported {
269                backend,
270                capability,
271                detail,
272            } => {
273                assert_eq!(backend, "postgres");
274                assert_eq!(capability, "semantic_recall");
275                assert!(
276                    detail.contains("issues/99"),
277                    "detail should reference the tracking issue: {detail}"
278                );
279            }
280            other => panic!("expected BackendUnsupported, got: {other}"),
281        }
282    }
283
284    #[tokio::test]
285    async fn dimension_mismatch_is_loud() {
286        // A pool-less index can't reach the dim check, but we can assert the
287        // helper's contract via a constructed-with-dims instance is not
288        // reachable without a live pool; the no-pool path already errors.
289        // (Live-pool dimension + ANN behaviour is covered by the
290        // MNEMO_TEST_POSTGRES_URL integration test.)
291        let idx = PgVectorIndex::new();
292        assert!(idx.search(&[0.1; 4], 3).await.is_err());
293    }
294}