1use anyhow::Result;
4use async_trait::async_trait;
5use serde::{Deserialize, Serialize};
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8use surrealdb::engine::local::RocksDb;
9use surrealdb::Surreal;
10use tokio::sync::OnceCell;
11
12use super::traits::*;
13
14#[derive(Clone)]
15pub struct SurrealMemory {
16 path: PathBuf,
17 cell: Arc<OnceCell<Surreal<surrealdb::engine::local::Db>>>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21struct MemoryRow {
22 namespace: String,
23 key: String,
24 value: String,
25 metadata: Option<serde_json::Value>,
26 created_at: String,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30struct ConversationRow {
31 chat_id: String,
32 sender_id: String,
33 role: String,
34 content: String,
35 seq: i64,
36 created_at: String,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40struct StickerRow {
41 sticker_id: String,
42 file_id: String,
43 description: String,
44 analyzed_at: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48struct EmbeddingRow {
49 namespace: String,
50 key: String,
51 vector: Vec<f32>,
52 text: String,
53 created_at: String,
54 #[serde(default)]
55 dim: Option<i64>,
56 #[serde(default)]
57 model: Option<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61struct FileIndexRow {
62 path: String,
63 hash: String,
64 last_indexed: String,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68struct ChunkRow {
69 file_path: String,
70 start_line: u32,
71 end_line: u32,
72 content: String,
73 embedding: Option<Vec<f32>>,
74 created_at: String,
75}
76
77impl SurrealMemory {
78 pub async fn db(&self) -> Result<Surreal<surrealdb::engine::local::Db>> {
79 let db = self
80 .cell
81 .get_or_try_init(|| async {
82 let db = Surreal::new::<RocksDb>(self.path.as_path()).await?;
83 db.use_ns("claw").use_db("memory").await?;
84 db.query(SCHEMA_SQL).await?;
85 Ok::<_, anyhow::Error>(db)
86 })
87 .await?;
88 Ok(db.clone())
89 }
90
91 pub async fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
92 let path = path.as_ref().to_path_buf();
93 if let Some(parent) = path.parent() {
94 if !parent.as_os_str().is_empty() {
95 tokio::fs::create_dir_all(parent).await?;
96 }
97 }
98 Ok(Self {
99 path,
100 cell: Arc::new(OnceCell::new()),
101 })
102 }
103
104 fn memory_id(namespace: &str, key: &str) -> String {
105 format!("{namespace}::{key}")
106 }
107}
108
109const SCHEMA_SQL: &str = r#"
110 DEFINE TABLE IF NOT EXISTS memories SCHEMALESS;
111 DEFINE FIELD IF NOT EXISTS namespace ON memories TYPE string;
112 DEFINE FIELD IF NOT EXISTS key ON memories TYPE string;
113 DEFINE FIELD IF NOT EXISTS value ON memories TYPE string;
114 DEFINE FIELD IF NOT EXISTS metadata ON memories TYPE option<object>;
115 DEFINE FIELD IF NOT EXISTS created_at ON memories TYPE string;
116 DEFINE INDEX IF NOT EXISTS memory_lookup_idx ON memories FIELDS namespace, key UNIQUE;
117 DEFINE INDEX IF NOT EXISTS memory_namespace_idx ON memories FIELDS namespace;
118
119 DEFINE TABLE IF NOT EXISTS conversations SCHEMALESS;
120 DEFINE FIELD IF NOT EXISTS chat_id ON conversations TYPE string;
121 DEFINE FIELD IF NOT EXISTS sender_id ON conversations TYPE string;
122 DEFINE FIELD IF NOT EXISTS role ON conversations TYPE string;
123 DEFINE FIELD IF NOT EXISTS content ON conversations TYPE string;
124 DEFINE FIELD IF NOT EXISTS seq ON conversations TYPE int;
125 DEFINE FIELD IF NOT EXISTS created_at ON conversations TYPE string;
126 DEFINE INDEX IF NOT EXISTS conversation_chat_idx ON conversations FIELDS chat_id, seq;
127
128 DEFINE TABLE IF NOT EXISTS sticker_cache SCHEMALESS;
129 DEFINE FIELD IF NOT EXISTS sticker_id ON sticker_cache TYPE string;
130 DEFINE FIELD IF NOT EXISTS file_id ON sticker_cache TYPE string;
131 DEFINE FIELD IF NOT EXISTS description ON sticker_cache TYPE string;
132 DEFINE FIELD IF NOT EXISTS analyzed_at ON sticker_cache TYPE string;
133 DEFINE INDEX IF NOT EXISTS sticker_id_idx ON sticker_cache FIELDS sticker_id UNIQUE;
134
135 DEFINE TABLE IF NOT EXISTS embeddings SCHEMALESS;
136 DEFINE FIELD IF NOT EXISTS namespace ON embeddings TYPE string;
137 DEFINE FIELD IF NOT EXISTS key ON embeddings TYPE string;
138 DEFINE FIELD IF NOT EXISTS vector ON embeddings TYPE array;
139 DEFINE FIELD IF NOT EXISTS text ON embeddings TYPE string;
140 DEFINE FIELD IF NOT EXISTS created_at ON embeddings TYPE string;
141 DEFINE FIELD IF NOT EXISTS dim ON embeddings TYPE option<int>;
142 DEFINE FIELD IF NOT EXISTS model ON embeddings TYPE option<string>;
143 DEFINE INDEX IF NOT EXISTS embedding_lookup_idx ON embeddings FIELDS namespace, key UNIQUE;
144 DEFINE INDEX IF NOT EXISTS embedding_namespace_idx ON embeddings FIELDS namespace;
145 DEFINE INDEX IF NOT EXISTS embedding_namespace_dim_idx ON embeddings FIELDS namespace, dim;
146 -- No MTREE index on `vector`, and this is now settled by measurement, not
147 -- by assumption. See `bench_mtree_feasibility` and
148 -- `bench_search_embeddings_scaling` in this file; numbers from a release
149 -- build on macOS arm64 at dim 1536.
150 --
151 -- `DEFINE INDEX ... MTREE DIMENSION 1536 DIST COSINE` is accepted, but:
152 -- 1. It locks the whole table to that one dimension. Storing a 3-dim
153 -- vector afterwards fails with "Incorrect vector dimension (3).
154 -- Expected a vector of 1536 dimension." So "one index per distinct
155 -- dimension" is not possible on a shared table at all — it would
156 -- need dimension-partitioned tables, not just extra indexes.
157 -- 2. It is ~25x more expensive to write: 29.76ms/row indexed versus
158 -- ~1.2ms/row unindexed.
159 -- 3. It does not even pay off on reads. At 2000 rows,
160 -- `vector <|10,COSINE|> $q` had a 2558ms median against 961ms for
161 -- the brute-force scan — 2.7x slower.
162 --
163 -- The scan itself is not cheap (~0.27ms per candidate row, so ~30ms at
164 -- 100 rows and ~2.5s at 10k), but the cost is materializing a
165 -- 1536-element array as SurrealDB `Value`s per row, not the cosine
166 -- arithmetic. Scoring in-engine with
167 -- `vector::similarity::cosine(...) ORDER BY score LIMIT k` was measured
168 -- too and is ~1.6x slower still, because it walks the same arrays.
169 -- Anything that actually fixes this has to make a row cheaper to read
170 -- (a compact encoding rather than an array of `Value`s), which MTREE
171 -- does not do.
172
173 DEFINE TABLE IF NOT EXISTS files SCHEMALESS;
174 DEFINE FIELD IF NOT EXISTS path ON files TYPE string;
175 DEFINE FIELD IF NOT EXISTS hash ON files TYPE string;
176 DEFINE FIELD IF NOT EXISTS last_indexed ON files TYPE string;
177 DEFINE INDEX IF NOT EXISTS file_path_idx ON files FIELDS path UNIQUE;
178
179 DEFINE TABLE IF NOT EXISTS chunks SCHEMALESS;
180 DEFINE FIELD IF NOT EXISTS file_path ON chunks TYPE string;
181 DEFINE FIELD IF NOT EXISTS start_line ON chunks TYPE int;
182 DEFINE FIELD IF NOT EXISTS end_line ON chunks TYPE int;
183 DEFINE FIELD IF NOT EXISTS content ON chunks TYPE string;
184 DEFINE FIELD IF NOT EXISTS embedding ON chunks TYPE option<array>;
185 DEFINE FIELD IF NOT EXISTS created_at ON chunks TYPE string;
186 DEFINE INDEX IF NOT EXISTS chunk_file_idx ON chunks FIELDS file_path;
187
188 DEFINE TABLE IF NOT EXISTS cron_jobs SCHEMALESS;
189 DEFINE FIELD IF NOT EXISTS name ON cron_jobs TYPE string;
190 DEFINE FIELD IF NOT EXISTS schedule ON cron_jobs TYPE string;
191 DEFINE FIELD IF NOT EXISTS task ON cron_jobs TYPE string;
192 DEFINE FIELD IF NOT EXISTS channel ON cron_jobs TYPE string;
193 DEFINE FIELD IF NOT EXISTS model ON cron_jobs TYPE string;
194 DEFINE FIELD IF NOT EXISTS enabled ON cron_jobs TYPE bool;
195 DEFINE FIELD IF NOT EXISTS last_run ON cron_jobs TYPE option<string>;
196 DEFINE FIELD IF NOT EXISTS next_run ON cron_jobs TYPE option<string>;
197 DEFINE INDEX IF NOT EXISTS cron_name_idx ON cron_jobs FIELDS name UNIQUE;
198
199 DEFINE TABLE IF NOT EXISTS memory_nodes SCHEMALESS;
200 DEFINE FIELD IF NOT EXISTS id ON memory_nodes TYPE string;
201 DEFINE FIELD IF NOT EXISTS kind ON memory_nodes TYPE string;
202 DEFINE FIELD IF NOT EXISTS text ON memory_nodes TYPE string;
203 DEFINE FIELD IF NOT EXISTS confidence ON memory_nodes TYPE float;
204 DEFINE FIELD IF NOT EXISTS status ON memory_nodes TYPE string;
205 DEFINE FIELD IF NOT EXISTS created_at ON memory_nodes TYPE string;
206 DEFINE INDEX IF NOT EXISTS memory_node_id_idx ON memory_nodes FIELDS id UNIQUE;
207
208 DEFINE TABLE IF NOT EXISTS memory_edges SCHEMALESS;
209 DEFINE FIELD IF NOT EXISTS from_id ON memory_edges TYPE string;
210 DEFINE FIELD IF NOT EXISTS to_id ON memory_edges TYPE string;
211 DEFINE FIELD IF NOT EXISTS rel ON memory_edges TYPE string;
212 DEFINE FIELD IF NOT EXISTS created_at ON memory_edges TYPE string;
213
214 DEFINE ANALYZER IF NOT EXISTS memory_analyzer TOKENIZERS blank, class FILTERS lowercase, snowball(english);
215 DEFINE INDEX IF NOT EXISTS memory_fts_idx ON memories FIELDS value
216 SEARCH ANALYZER memory_analyzer BM25;
217"#;
218
219fn parse_timestamp(value: &str) -> chrono::DateTime<chrono::Utc> {
220 chrono::DateTime::parse_from_rfc3339(value)
221 .map(|dt| dt.with_timezone(&chrono::Utc))
222 .unwrap_or_else(|_| chrono::Utc::now())
223}
224
225#[async_trait]
226impl MemoryBackend for SurrealMemory {
227 fn as_any(&self) -> &dyn std::any::Any {
228 self
229 }
230
231 async fn store(
232 &self,
233 namespace: &str,
234 key: &str,
235 value: &str,
236 metadata: Option<serde_json::Value>,
237 ) -> Result<()> {
238 let created_at = chrono::Utc::now().to_rfc3339();
239 let row = MemoryRow {
240 namespace: namespace.to_string(),
241 key: key.to_string(),
242 value: value.to_string(),
243 metadata,
244 created_at,
245 };
246 let _: Option<MemoryRow> = self
247 .db()
248 .await?
249 .upsert(("memories", Self::memory_id(namespace, key)))
250 .content(row)
251 .await?;
252 Ok(())
253 }
254
255 async fn recall(&self, namespace: &str, key: &str) -> Result<Option<MemoryEntry>> {
256 let row: Option<MemoryRow> = self
257 .db()
258 .await?
259 .select(("memories", Self::memory_id(namespace, key)))
260 .await?;
261 Ok(row.map(|entry| MemoryEntry {
262 key: entry.key,
263 value: entry.value,
264 metadata: entry.metadata,
265 created_at: parse_timestamp(&entry.created_at),
266 }))
267 }
268
269 async fn search(&self, namespace: &str, query: &str, limit: usize) -> Result<Vec<MemoryEntry>> {
270 let mut result = self
272 .db()
273 .await?
274 .query(
275 "SELECT *, search::score(1) AS score
276 FROM memories
277 WHERE namespace = $namespace
278 AND value @1@ $query
279 ORDER BY score DESC
280 LIMIT $limit",
281 )
282 .bind(("namespace", namespace.to_string()))
283 .bind(("query", query.to_string()))
284 .bind(("limit", limit as i64))
285 .await?;
286 let rows: Vec<MemoryRow> = result.take(0)?;
287
288 if !rows.is_empty() {
289 return Ok(rows
290 .into_iter()
291 .map(|entry| MemoryEntry {
292 key: entry.key,
293 value: entry.value,
294 metadata: entry.metadata,
295 created_at: parse_timestamp(&entry.created_at),
296 })
297 .collect());
298 }
299
300 let query_lower = query.to_lowercase();
302 let mut result = self.db().await?
303 .query(
304 "SELECT * FROM memories
305 WHERE namespace = $namespace
306 AND (string::lowercase(key) CONTAINS $query OR string::lowercase(value) CONTAINS $query)
307 ORDER BY created_at DESC
308 LIMIT $limit"
309 )
310 .bind(("namespace", namespace.to_string()))
311 .bind(("query", query_lower))
312 .bind(("limit", limit as i64))
313 .await?;
314 let rows: Vec<MemoryRow> = result.take(0)?;
315 Ok(rows
316 .into_iter()
317 .map(|entry| MemoryEntry {
318 key: entry.key,
319 value: entry.value,
320 metadata: entry.metadata,
321 created_at: parse_timestamp(&entry.created_at),
322 })
323 .collect())
324 }
325
326 async fn forget(&self, namespace: &str, key: &str) -> Result<()> {
327 let _: Option<MemoryRow> = self
328 .db()
329 .await?
330 .delete(("memories", Self::memory_id(namespace, key)))
331 .await?;
332 Ok(())
333 }
334
335 async fn list(&self, namespace: &str) -> Result<Vec<MemoryEntry>> {
336 let mut result = self.db().await?
337 .query("SELECT key, value, metadata, created_at FROM memories WHERE namespace = $namespace ORDER BY created_at DESC")
338 .bind(("namespace", namespace.to_string()))
339 .await?;
340 let rows: Vec<MemoryRow> = result.take(0)?;
341 Ok(rows
342 .into_iter()
343 .map(|entry| MemoryEntry {
344 key: entry.key,
345 value: entry.value,
346 metadata: entry.metadata,
347 created_at: parse_timestamp(&entry.created_at),
348 })
349 .collect())
350 }
351
352 async fn store_conversation(
353 &self,
354 chat_id: &str,
355 sender_id: &str,
356 role: &str,
357 content: &str,
358 ) -> Result<()> {
359 let now = chrono::Utc::now();
360 let row = ConversationRow {
361 chat_id: chat_id.to_string(),
362 sender_id: sender_id.to_string(),
363 role: role.to_string(),
364 content: content.to_string(),
365 seq: now.timestamp_millis(),
366 created_at: now.to_rfc3339(),
367 };
368 let _: Option<ConversationRow> = self
369 .db()
370 .await?
371 .create("conversations")
372 .content(row)
373 .await?;
374 Ok(())
375 }
376
377 async fn store_conversation_batch(&self, entries: &[(&str, &str, &str, &str)]) -> Result<()> {
378 for (offset, (chat_id, sender_id, role, content)) in entries.iter().enumerate() {
379 let now = chrono::Utc::now();
380 let row = ConversationRow {
381 chat_id: (*chat_id).to_string(),
382 sender_id: (*sender_id).to_string(),
383 role: (*role).to_string(),
384 content: (*content).to_string(),
385 seq: now.timestamp_millis() + offset as i64,
386 created_at: now.to_rfc3339(),
387 };
388 let _: Option<ConversationRow> = self
389 .db()
390 .await?
391 .create("conversations")
392 .content(row)
393 .await?;
394 }
395 Ok(())
396 }
397
398 async fn get_conversation_history(
399 &self,
400 chat_id: &str,
401 limit: usize,
402 ) -> Result<Vec<(String, String)>> {
403 let mut result = self
404 .db()
405 .await?
406 .query(
407 "SELECT * FROM conversations
408 WHERE chat_id = $chat_id
409 ORDER BY seq DESC
410 LIMIT $limit",
411 )
412 .bind(("chat_id", chat_id.to_string()))
413 .bind(("limit", limit as i64))
414 .await?;
415 let mut rows: Vec<ConversationRow> = result.take(0)?;
416 rows.reverse();
417 Ok(rows
418 .into_iter()
419 .map(|row| (row.role, row.content))
420 .collect())
421 }
422
423 async fn clear_conversation(&self, chat_id: &str) -> Result<()> {
424 self.db()
425 .await?
426 .query("DELETE FROM conversations WHERE chat_id = $chat_id")
427 .bind(("chat_id", chat_id.to_string()))
428 .await?
429 .check()?;
430 Ok(())
431 }
432
433 async fn search_conversations(
434 &self,
435 query: &str,
436 limit: usize,
437 chat_id: Option<&str>,
438 ) -> Result<Vec<ConversationSearchHit>> {
439 let query = query.to_lowercase();
440 let mut result = if let Some(chat_id) = chat_id {
441 self.db()
442 .await?
443 .query(
444 "SELECT * FROM conversations
445 WHERE chat_id = $chat_id
446 AND string::lowercase(content) CONTAINS $query
447 ORDER BY seq DESC
448 LIMIT $limit",
449 )
450 .bind(("chat_id", chat_id.to_string()))
451 .bind(("query", query))
452 .bind(("limit", limit as i64))
453 .await?
454 } else {
455 self.db()
456 .await?
457 .query(
458 "SELECT * FROM conversations
459 WHERE string::lowercase(content) CONTAINS $query
460 ORDER BY seq DESC
461 LIMIT $limit",
462 )
463 .bind(("query", query))
464 .bind(("limit", limit as i64))
465 .await?
466 };
467 let rows: Vec<ConversationRow> = result.take(0)?;
468 Ok(rows
469 .into_iter()
470 .map(|row| ConversationSearchHit {
471 chat_id: row.chat_id,
472 role: row.role,
473 content: row.content,
474 created_at: parse_timestamp(&row.created_at),
475 })
476 .collect())
477 }
478
479 async fn get_sticker_cache(&self, sticker_id: &str) -> Result<Option<String>> {
480 let row: Option<StickerRow> = self
481 .db()
482 .await?
483 .select(("sticker_cache", sticker_id))
484 .await?;
485 Ok(row.map(|entry| entry.description))
486 }
487
488 async fn store_sticker_cache(
489 &self,
490 sticker_id: &str,
491 file_id: &str,
492 description: &str,
493 ) -> Result<()> {
494 let row = StickerRow {
495 sticker_id: sticker_id.to_string(),
496 file_id: file_id.to_string(),
497 description: description.to_string(),
498 analyzed_at: chrono::Utc::now().to_rfc3339(),
499 };
500 let _: Option<StickerRow> = self
501 .db()
502 .await?
503 .upsert(("sticker_cache", sticker_id))
504 .content(row)
505 .await?;
506 Ok(())
507 }
508
509 async fn store_embedding(
512 &self,
513 namespace: &str,
514 key: &str,
515 vector: &[f32],
516 text: &str,
517 model: &str,
518 ) -> Result<()> {
519 let row = EmbeddingRow {
520 namespace: namespace.to_string(),
521 key: key.to_string(),
522 dim: Some(vector.len() as i64),
523 model: Some(model.to_string()),
524 vector: vector.to_vec(),
525 text: text.to_string(),
526 created_at: chrono::Utc::now().to_rfc3339(),
527 };
528 let id = Self::memory_id(namespace, key);
529 let _: Option<EmbeddingRow> = self
530 .db()
531 .await?
532 .upsert(("embeddings", &id))
533 .content(row)
534 .await?;
535 Ok(())
536 }
537
538 async fn search_embeddings(
539 &self,
540 namespace: &str,
541 query_vector: &[f32],
542 limit: usize,
543 ) -> Result<Vec<EmbeddingEntry>> {
544 if query_vector.is_empty() {
545 return Ok(Vec::new());
546 }
547 let dim = query_vector.len() as i64;
548
549 let mut result = self
564 .db()
565 .await?
566 .query("SELECT * FROM embeddings WHERE namespace = $namespace AND dim = $dim")
567 .bind(("namespace", namespace.to_string()))
568 .bind(("dim", dim))
569 .await?;
570 let rows: Vec<EmbeddingRow> = result.take(0)?;
571
572 let mut scored: Vec<(f32, EmbeddingRow)> = rows
576 .into_iter()
577 .filter_map(|row| cosine_similarity(query_vector, &row.vector).map(|sim| (sim, row)))
578 .collect();
579 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
580 scored.truncate(limit);
581
582 Ok(scored
583 .into_iter()
584 .map(|(_, row)| EmbeddingEntry {
585 namespace: row.namespace,
586 key: row.key,
587 vector: row.vector,
588 text: row.text,
589 created_at: parse_timestamp(&row.created_at),
590 })
591 .collect())
592 }
593
594 async fn store_file_index(&self, path: &str, hash: &str) -> Result<()> {
597 let row = FileIndexRow {
598 path: path.to_string(),
599 hash: hash.to_string(),
600 last_indexed: chrono::Utc::now().to_rfc3339(),
601 };
602 let id = format!("{:x}", md5_hash(path));
604 let _: Option<FileIndexRow> = self.db().await?.upsert(("files", &id)).content(row).await?;
605 Ok(())
606 }
607
608 async fn get_file_index(&self, path: &str) -> Result<Option<FileIndex>> {
609 let id = format!("{:x}", md5_hash(path));
610 let row: Option<FileIndexRow> = self.db().await?.select(("files", &id)).await?;
611 Ok(row.map(|r| FileIndex {
612 path: r.path,
613 hash: r.hash,
614 last_indexed: parse_timestamp(&r.last_indexed),
615 }))
616 }
617
618 async fn store_chunk(
621 &self,
622 file_path: &str,
623 start_line: u32,
624 end_line: u32,
625 content: &str,
626 embedding: Option<&[f32]>,
627 ) -> Result<()> {
628 let row = ChunkRow {
629 file_path: file_path.to_string(),
630 start_line,
631 end_line,
632 content: content.to_string(),
633 embedding: embedding.map(|e| e.to_vec()),
634 created_at: chrono::Utc::now().to_rfc3339(),
635 };
636 let _: Option<ChunkRow> = self.db().await?.create("chunks").content(row).await?;
637 Ok(())
638 }
639
640 async fn get_chunks_for_file(&self, file_path: &str) -> Result<Vec<Chunk>> {
641 let mut result = self
642 .db()
643 .await?
644 .query("SELECT * FROM chunks WHERE file_path = $file_path ORDER BY start_line ASC")
645 .bind(("file_path", file_path.to_string()))
646 .await?;
647 let rows: Vec<ChunkRow> = result.take(0)?;
648 Ok(rows
649 .into_iter()
650 .map(|r| Chunk {
651 file_path: r.file_path,
652 start_line: r.start_line,
653 end_line: r.end_line,
654 content: r.content,
655 embedding: r.embedding,
656 created_at: parse_timestamp(&r.created_at),
657 })
658 .collect())
659 }
660
661 async fn delete_chunks_for_file(&self, file_path: &str) -> Result<()> {
662 self.db()
663 .await?
664 .query("DELETE FROM chunks WHERE file_path = $file_path")
665 .bind(("file_path", file_path.to_string()))
666 .await?;
667 Ok(())
668 }
669}
670
671fn cosine_similarity(a: &[f32], b: &[f32]) -> Option<f32> {
677 if a.len() != b.len() || a.is_empty() {
678 return None;
679 }
680
681 let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
682 let ma: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
683 let mb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
684
685 if ma == 0.0 || mb == 0.0 {
686 return Some(0.0);
687 }
688
689 Some(dot / (ma * mb))
690}
691
692fn md5_hash(input: &str) -> u64 {
694 use std::hash::{Hash, Hasher};
695 let mut hasher = std::collections::hash_map::DefaultHasher::new();
696 input.hash(&mut hasher);
697 hasher.finish()
698}
699
700#[cfg(test)]
701mod tests {
702 use super::*;
703
704 #[test]
705 fn test_md5_hash_deterministic() {
706 let h1 = md5_hash("test-path");
707 let h2 = md5_hash("test-path");
708 assert_eq!(h1, h2);
709 }
710
711 #[test]
712 fn test_md5_hash_different_inputs() {
713 let h1 = md5_hash("path-a");
714 let h2 = md5_hash("path-b");
715 assert_ne!(h1, h2);
716 }
717
718 #[tokio::test]
719 async fn test_surreal_store_and_recall() {
720 let dir = tempfile::tempdir().unwrap();
721 let mem = SurrealMemory::new(dir.path()).await.unwrap();
722
723 mem.store("test", "greeting", "hello world", None)
724 .await
725 .unwrap();
726 let val = mem.recall("test", "greeting").await.unwrap();
727 assert!(val.is_some());
728 assert_eq!(val.unwrap().value, "hello world");
729 }
730
731 #[tokio::test]
732 async fn test_surreal_recall_missing() {
733 let dir = tempfile::tempdir().unwrap();
734 let mem = SurrealMemory::new(dir.path()).await.unwrap();
735
736 let val = mem.recall("test", "missing-key").await.unwrap();
737 assert!(val.is_none());
738 }
739
740 #[tokio::test]
741 async fn test_surreal_search() {
742 let dir = tempfile::tempdir().unwrap();
743 let mem = SurrealMemory::new(dir.path()).await.unwrap();
744
745 mem.store("ns", "k1", "the quick brown fox", None)
746 .await
747 .unwrap();
748 mem.store("ns", "k2", "lazy dog sleeps", None)
749 .await
750 .unwrap();
751 mem.store("ns", "k3", "fox runs fast", None).await.unwrap();
752
753 let results: Vec<MemoryEntry> = mem.search("ns", "fox", 10).await.unwrap();
754 assert!(!results.is_empty());
755 assert!(results.iter().any(|e| e.value.contains("fox")));
756 }
757
758 #[tokio::test]
759 async fn test_surreal_delete() {
760 let dir = tempfile::tempdir().unwrap();
761 let mem = SurrealMemory::new(dir.path()).await.unwrap();
762
763 mem.store("ns", "del-key", "to delete", None).await.unwrap();
764 assert!(mem.recall("ns", "del-key").await.unwrap().is_some());
765
766 mem.forget("ns", "del-key").await.unwrap();
767 assert!(mem.recall("ns", "del-key").await.unwrap().is_none());
768 }
769
770 #[tokio::test]
771 async fn test_surreal_conversation_history() {
772 let dir = tempfile::tempdir().unwrap();
773 let mem = SurrealMemory::new(dir.path()).await.unwrap();
774
775 mem.store_conversation("chat-1", "user-1", "user", "Hello")
776 .await
777 .unwrap();
778 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
780 mem.store_conversation("chat-1", "assistant", "assistant", "Hi there")
781 .await
782 .unwrap();
783
784 let history: Vec<(String, String)> =
785 mem.get_conversation_history("chat-1", 10).await.unwrap();
786 assert_eq!(history.len(), 2);
787 assert_eq!(history[0].1, "Hello");
788 assert_eq!(history[1].1, "Hi there");
789 }
790
791 #[tokio::test]
792 async fn test_surreal_clear_conversation_is_scoped_to_one_chat() {
793 let dir = tempfile::tempdir().unwrap();
794 let mem = SurrealMemory::new(dir.path()).await.unwrap();
795
796 mem.store_conversation("chat-1", "user-1", "user", "keep me out")
797 .await
798 .unwrap();
799 mem.store_conversation("chat-2", "user-1", "user", "keep me")
800 .await
801 .unwrap();
802
803 mem.clear_conversation("chat-1").await.unwrap();
804
805 assert!(mem
806 .get_conversation_history("chat-1", 10)
807 .await
808 .unwrap()
809 .is_empty());
810 let survivors = mem.get_conversation_history("chat-2", 10).await.unwrap();
811 assert_eq!(survivors.len(), 1);
812 assert_eq!(survivors[0].1, "keep me");
813
814 mem.clear_conversation("chat-1").await.unwrap();
816 }
817
818 #[tokio::test]
819 async fn test_surreal_embeddings() {
820 let dir = tempfile::tempdir().unwrap();
821 let mem = SurrealMemory::new(dir.path()).await.unwrap();
822
823 let vec1 = vec![1.0, 0.0, 0.0];
824 let vec2 = vec![0.0, 1.0, 0.0];
825 let vec3 = vec![0.9, 0.1, 0.0];
826
827 mem.store_embedding("ns", "e1", &vec1, "first", "test-model")
828 .await
829 .unwrap();
830 mem.store_embedding("ns", "e2", &vec2, "second", "test-model")
831 .await
832 .unwrap();
833 mem.store_embedding("ns", "e3", &vec3, "third", "test-model")
834 .await
835 .unwrap();
836
837 let results = mem.search_embeddings("ns", &vec1, 2).await.unwrap();
838 assert!(!results.is_empty());
839 assert!(results[0].key == "e1" || results[0].key == "e3");
841 }
842
843 #[test]
844 fn test_cosine_similarity_rejects_mismatched_lengths() {
845 assert_eq!(cosine_similarity(&[1.0, 0.0], &[1.0, 0.0, 0.0]), None);
846 assert_eq!(cosine_similarity(&[], &[]), None);
847 assert_eq!(cosine_similarity(&[1.0, 0.0], &[1.0, 0.0]), Some(1.0));
848 assert_eq!(cosine_similarity(&[0.0, 0.0], &[1.0, 0.0]), Some(0.0));
849 }
850
851 #[tokio::test]
852 async fn test_search_embeddings_excludes_other_dimensions() {
853 let dir = tempfile::tempdir().unwrap();
854 let mem = SurrealMemory::new(dir.path()).await.unwrap();
855
856 let old_dim = vec![1.0f32, 0.0, 0.0];
859 let new_dim = vec![0.25f32; 8];
860 mem.store_embedding("ns", "old", &old_dim, "old model", "old-model")
861 .await
862 .unwrap();
863 mem.store_embedding("ns", "new", &new_dim, "new model", "new-model")
864 .await
865 .unwrap();
866
867 let results = mem.search_embeddings("ns", &new_dim, 10).await.unwrap();
868 assert!(
869 results.iter().all(|e| e.key != "old"),
870 "a vector of a different dimension must never be returned"
871 );
872 assert!(results.iter().any(|e| e.key == "new"));
873
874 let results = mem.search_embeddings("ns", &old_dim, 10).await.unwrap();
876 assert!(results.iter().all(|e| e.key != "new"));
877 assert!(results.iter().any(|e| e.key == "old"));
878 }
879
880 #[tokio::test]
881 async fn test_search_embeddings_skips_rows_without_dimension() {
882 let dir = tempfile::tempdir().unwrap();
883 let mem = SurrealMemory::new(dir.path()).await.unwrap();
884 let db = mem.db().await.unwrap();
885
886 db.query(
888 "CREATE embeddings:legacy SET namespace = 'ns', key = 'legacy',
889 vector = [1.0, 0.0, 0.0], text = 'legacy', created_at = '2024-01-01T00:00:00Z'",
890 )
891 .await
892 .unwrap();
893
894 let results = mem
895 .search_embeddings("ns", &[1.0, 0.0, 0.0], 10)
896 .await
897 .unwrap();
898 assert!(
899 results.is_empty(),
900 "rows with no recorded dimension must not be returned"
901 );
902 }
903
904 #[tokio::test]
905 async fn test_surreal_file_index() {
906 let dir = tempfile::tempdir().unwrap();
907 let mem = SurrealMemory::new(dir.path()).await.unwrap();
908
909 mem.store_file_index("/src/main.rs", "abc123")
910 .await
911 .unwrap();
912 let idx = mem.get_file_index("/src/main.rs").await.unwrap();
913 assert!(idx.is_some());
914 assert_eq!(idx.unwrap().hash, "abc123");
915
916 let missing = mem.get_file_index("/src/nonexistent.rs").await.unwrap();
917 assert!(missing.is_none());
918 }
919
920 #[tokio::test]
921 async fn test_surreal_chunks() {
922 let dir = tempfile::tempdir().unwrap();
923 let mem = SurrealMemory::new(dir.path()).await.unwrap();
924
925 mem.store_chunk("/src/lib.rs", 1, 10, "fn main() {}", None)
926 .await
927 .unwrap();
928 mem.store_chunk("/src/lib.rs", 11, 20, "fn helper() {}", None)
929 .await
930 .unwrap();
931
932 let chunks = mem.get_chunks_for_file("/src/lib.rs").await.unwrap();
933 assert_eq!(chunks.len(), 2);
934 assert_eq!(chunks[0].start_line, 1);
935 assert_eq!(chunks[1].start_line, 11);
936
937 mem.delete_chunks_for_file("/src/lib.rs").await.unwrap();
938 let empty = mem.get_chunks_for_file("/src/lib.rs").await.unwrap();
939 assert!(empty.is_empty());
940 }
941
942 #[tokio::test]
943 async fn test_surreal_sticker_cache() {
944 let dir = tempfile::tempdir().unwrap();
945 let mem = SurrealMemory::new(dir.path()).await.unwrap();
946
947 mem.store_sticker_cache("stk-1", "file-1", "A happy cat")
948 .await
949 .unwrap();
950 let desc: Option<String> = mem.get_sticker_cache("stk-1").await.unwrap();
951 assert_eq!(desc, Some("A happy cat".to_string()));
952
953 let missing: Option<String> = mem.get_sticker_cache("stk-999").await.unwrap();
954 assert!(missing.is_none());
955 }
956
957 struct Xorshift(u64);
960
961 impl Xorshift {
962 fn next_f32(&mut self) -> f32 {
963 let mut x = self.0;
964 x ^= x << 13;
965 x ^= x >> 7;
966 x ^= x << 17;
967 self.0 = x;
968 ((x >> 40) as f32 / 16_777_216.0) * 2.0 - 1.0
969 }
970
971 fn unit_vector(&mut self, dim: usize) -> Vec<f32> {
972 let mut v: Vec<f32> = (0..dim).map(|_| self.next_f32()).collect();
973 let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
974 if norm > 0.0 {
975 for x in v.iter_mut() {
976 *x /= norm;
977 }
978 }
979 v
980 }
981 }
982
983 #[tokio::test]
990 #[ignore = "benchmark: slow, writes a large corpus"]
991 async fn bench_search_embeddings_scaling() {
992 const DIM: usize = 1536;
993 const QUERIES: usize = 100;
994 let sizes: Vec<usize> = match std::env::var("APOLLO_BENCH_SIZES") {
998 Ok(s) => s.split(',').filter_map(|p| p.trim().parse().ok()).collect(),
999 Err(_) => vec![100, 1_000, 10_000, 50_000],
1000 };
1001
1002 for size in sizes {
1003 let dir = tempfile::tempdir().unwrap();
1004 let mem = SurrealMemory::new(dir.path()).await.unwrap();
1005 let mut rng = Xorshift(0x2545_F491_4F6C_DD1D);
1006
1007 let write_start = std::time::Instant::now();
1008 for i in 0..size {
1009 let v = rng.unit_vector(DIM);
1010 mem.store_embedding("bench", &format!("k{i}"), &v, "text", "bench-model")
1011 .await
1012 .unwrap();
1013 }
1014 let write_elapsed = write_start.elapsed();
1015
1016 let queries: Vec<Vec<f32>> = (0..QUERIES).map(|_| rng.unit_vector(DIM)).collect();
1017 let mut samples: Vec<u128> = Vec::with_capacity(QUERIES);
1018 for q in &queries {
1019 let start = std::time::Instant::now();
1020 let hits = mem.search_embeddings("bench", q, 10).await.unwrap();
1021 samples.push(start.elapsed().as_micros());
1022 assert_eq!(hits.len(), 10.min(size));
1023 }
1024 let (median, p95, max) = percentiles(&mut samples);
1025
1026 println!(
1027 "size={size:>6} dim={DIM} search_median={median:>8.2}ms search_p95={p95:>8.2}ms \
1028 search_max={max:>8.2}ms insert_total={:>8.2}s insert_per_row={:>6.2}ms",
1029 write_elapsed.as_secs_f64(),
1030 write_elapsed.as_secs_f64() * 1000.0 / size as f64,
1031 );
1032
1033 let db = mem.db().await.unwrap();
1036 let mut samples: Vec<u128> = Vec::with_capacity(QUERIES);
1037 for q in &queries {
1038 let start = std::time::Instant::now();
1039 let mut result = db
1040 .query(
1041 "SELECT namespace, key, text, created_at, \
1042 vector::similarity::cosine(vector, $q) AS score \
1043 FROM embeddings WHERE namespace = $namespace AND dim = $dim \
1044 ORDER BY score DESC LIMIT 10",
1045 )
1046 .bind(("namespace", "bench".to_string()))
1047 .bind(("dim", DIM as i64))
1048 .bind(("q", q.clone()))
1049 .await
1050 .unwrap();
1051 let rows: Vec<serde_json::Value> = result.take(0).unwrap();
1052 samples.push(start.elapsed().as_micros());
1053 assert_eq!(rows.len(), 10.min(size));
1054 }
1055 let (median, p95, max) = percentiles(&mut samples);
1056 println!(
1057 "size={size:>6} dim={DIM} indb_median={median:>8.2}ms indb_p95={p95:>8.2}ms \
1058 indb_max={max:>8.2}ms"
1059 );
1060 }
1061 }
1062
1063 #[tokio::test]
1067 #[ignore = "benchmark: slow, writes a large corpus"]
1068 async fn bench_mtree_feasibility() {
1069 const DIM: usize = 1536;
1070 const SIZE: usize = 2_000;
1071
1072 let dir = tempfile::tempdir().unwrap();
1073 let mem = SurrealMemory::new(dir.path()).await.unwrap();
1074 let mut rng = Xorshift(0x2545_F491_4F6C_DD1D);
1075 let db = mem.db().await.unwrap();
1076
1077 let define = db
1078 .query(format!(
1079 "DEFINE INDEX IF NOT EXISTS embedding_vector_mtree_{DIM} ON embeddings \
1080 FIELDS vector MTREE DIMENSION {DIM} DIST COSINE"
1081 ))
1082 .await;
1083 println!("define_mtree_ok={}", define.is_ok());
1084 if let Err(e) = &define {
1085 println!("define_mtree_err={e}");
1086 return;
1087 }
1088
1089 let start = std::time::Instant::now();
1090 for i in 0..SIZE {
1091 let v = rng.unit_vector(DIM);
1092 mem.store_embedding("bench", &format!("k{i}"), &v, "text", "bench-model")
1093 .await
1094 .unwrap();
1095 }
1096 println!(
1097 "mtree_insert_total={:.2}s per_row={:.2}ms",
1098 start.elapsed().as_secs_f64(),
1099 start.elapsed().as_secs_f64() * 1000.0 / SIZE as f64
1100 );
1101
1102 let mismatched = mem
1104 .store_embedding("bench", "other-dim", &[1.0, 0.0, 0.0], "t", "small-model")
1105 .await;
1106 println!("mixed_dim_insert_ok={}", mismatched.is_ok());
1107 if let Err(e) = &mismatched {
1108 println!("mixed_dim_insert_err={e}");
1109 }
1110
1111 let mut knn: Vec<u128> = Vec::new();
1112 let mut scan: Vec<u128> = Vec::new();
1113 for _ in 0..20 {
1114 let q = rng.unit_vector(DIM);
1115
1116 let start = std::time::Instant::now();
1117 let mut result = db
1118 .query(
1119 "SELECT namespace, key, text, created_at FROM embeddings \
1120 WHERE vector <|10,COSINE|> $q",
1121 )
1122 .bind(("q", q.clone()))
1123 .await
1124 .unwrap();
1125 let rows: Vec<serde_json::Value> = result.take(0).unwrap();
1126 knn.push(start.elapsed().as_micros());
1127 assert!(!rows.is_empty(), "KNN returned nothing — index not used");
1128
1129 let start = std::time::Instant::now();
1130 mem.search_embeddings("bench", &q, 10).await.unwrap();
1131 scan.push(start.elapsed().as_micros());
1132 }
1133 let (km, kp, kx) = percentiles(&mut knn);
1134 let (sm, sp, sx) = percentiles(&mut scan);
1135 println!("size={SIZE} knn_median={km:.2}ms knn_p95={kp:.2}ms knn_max={kx:.2}ms");
1136 println!("size={SIZE} scan_median={sm:.2}ms scan_p95={sp:.2}ms scan_max={sx:.2}ms");
1137 }
1138
1139 fn percentiles(samples: &mut [u128]) -> (f64, f64, f64) {
1140 samples.sort_unstable();
1141 (
1142 samples[samples.len() / 2] as f64 / 1000.0,
1143 samples[(samples.len() * 95) / 100] as f64 / 1000.0,
1144 *samples.last().unwrap() as f64 / 1000.0,
1145 )
1146 }
1147}