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}