1use anyhow::Result;
4use async_trait::async_trait;
5use base64::engine::general_purpose::STANDARD as BASE64;
6use base64::Engine as _;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use surrealdb::engine::local::RocksDb;
12use surrealdb::Surreal;
13use tokio::sync::{OnceCell, RwLock};
14
15use super::traits::*;
16
17#[derive(Clone)]
18pub struct SurrealMemory {
19 path: PathBuf,
20 cell: Arc<OnceCell<Surreal<surrealdb::engine::local::Db>>>,
21 vectors: Arc<RwLock<HashMap<String, Vec<CachedVector>>>>,
25}
26
27struct CachedVector {
33 key: String,
34 dim: i64,
35 vector: Vec<f32>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39struct MemoryRow {
40 namespace: String,
41 key: String,
42 value: String,
43 metadata: Option<serde_json::Value>,
44 created_at: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48struct ConversationRow {
49 chat_id: String,
50 sender_id: String,
51 role: String,
52 content: String,
53 seq: i64,
54 created_at: String,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58struct StickerRow {
59 sticker_id: String,
60 file_id: String,
61 description: String,
62 analyzed_at: String,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66struct EmbeddingRow {
67 namespace: String,
68 key: String,
69 vector_b64: String,
82 text: String,
83 created_at: String,
84 #[serde(default)]
85 dim: Option<i64>,
86 #[serde(default)]
87 model: Option<String>,
88}
89
90#[derive(Debug, Clone, Deserialize)]
97struct EmbeddingCandidate {
98 key: String,
99 dim: i64,
100 #[serde(default)]
101 vector_b64: Option<String>,
102 #[serde(default)]
103 vector: Option<Vec<f32>>,
104}
105
106#[derive(Debug, Clone, Deserialize)]
109struct EmbeddingDetail {
110 key: String,
111 text: String,
112 created_at: String,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116struct FileIndexRow {
117 path: String,
118 hash: String,
119 last_indexed: String,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
123struct ChunkRow {
124 file_path: String,
125 start_line: u32,
126 end_line: u32,
127 content: String,
128 embedding: Option<Vec<f32>>,
129 created_at: String,
130}
131
132impl SurrealMemory {
133 pub async fn db(&self) -> Result<Surreal<surrealdb::engine::local::Db>> {
134 let db = self
135 .cell
136 .get_or_try_init(|| async {
137 let db = Surreal::new::<RocksDb>(self.path.as_path()).await?;
138 db.use_ns("claw").use_db("memory").await?;
139 db.query(SCHEMA_SQL).await?;
140 Ok::<_, anyhow::Error>(db)
141 })
142 .await?;
143 Ok(db.clone())
144 }
145
146 pub async fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
147 let path = path.as_ref().to_path_buf();
148 if let Some(parent) = path.parent() {
149 if !parent.as_os_str().is_empty() {
150 tokio::fs::create_dir_all(parent).await?;
151 }
152 }
153 Ok(Self {
154 path,
155 cell: Arc::new(OnceCell::new()),
156 vectors: Arc::new(RwLock::new(HashMap::new())),
157 })
158 }
159
160 fn memory_id(namespace: &str, key: &str) -> String {
161 format!("{namespace}::{key}")
162 }
163
164 async fn ensure_vectors_loaded(&self, namespace: &str) -> Result<()> {
172 if self.vectors.read().await.contains_key(namespace) {
173 return Ok(());
174 }
175
176 let mut result = self
180 .db()
181 .await?
182 .query(
183 "SELECT key, dim, vector_b64, vector FROM embeddings \
184 WHERE namespace = $namespace AND dim != NONE",
185 )
186 .bind(("namespace", namespace.to_string()))
187 .await?;
188 let rows: Vec<EmbeddingCandidate> = result.take(0)?;
189
190 let mut raw: Vec<u8> = Vec::new();
191 let mut entries: Vec<CachedVector> = Vec::with_capacity(rows.len());
192 for row in rows {
193 let mut vector = Vec::new();
194 match (&row.vector_b64, row.vector) {
195 (Some(encoded), _) => unpack_vector_into(encoded, &mut raw, &mut vector),
196 (None, Some(v)) => vector = v,
197 (None, None) => continue,
200 }
201 entries.push(CachedVector {
202 key: row.key,
203 dim: row.dim,
204 vector,
205 });
206 }
207
208 self.vectors
209 .write()
210 .await
211 .insert(namespace.to_string(), entries);
212 Ok(())
213 }
214
215 async fn cache_vector(&self, namespace: &str, key: &str, vector: &[f32]) {
221 let mut cache = self.vectors.write().await;
222 let Some(entries) = cache.get_mut(namespace) else {
223 return;
224 };
225 let entry = CachedVector {
226 key: key.to_string(),
227 dim: vector.len() as i64,
228 vector: vector.to_vec(),
229 };
230 match entries.iter_mut().find(|e| e.key == key) {
231 Some(existing) => *existing = entry,
232 None => entries.push(entry),
233 }
234 }
235}
236
237const SCHEMA_SQL: &str = r#"
238 DEFINE TABLE IF NOT EXISTS memories SCHEMALESS;
239 DEFINE FIELD IF NOT EXISTS namespace ON memories TYPE string;
240 DEFINE FIELD IF NOT EXISTS key ON memories TYPE string;
241 DEFINE FIELD IF NOT EXISTS value ON memories TYPE string;
242 DEFINE FIELD IF NOT EXISTS metadata ON memories TYPE option<object>;
243 DEFINE FIELD IF NOT EXISTS created_at ON memories TYPE string;
244 DEFINE INDEX IF NOT EXISTS memory_lookup_idx ON memories FIELDS namespace, key UNIQUE;
245 DEFINE INDEX IF NOT EXISTS memory_namespace_idx ON memories FIELDS namespace;
246
247 DEFINE TABLE IF NOT EXISTS conversations SCHEMALESS;
248 DEFINE FIELD IF NOT EXISTS chat_id ON conversations TYPE string;
249 DEFINE FIELD IF NOT EXISTS sender_id ON conversations TYPE string;
250 DEFINE FIELD IF NOT EXISTS role ON conversations TYPE string;
251 DEFINE FIELD IF NOT EXISTS content ON conversations TYPE string;
252 DEFINE FIELD IF NOT EXISTS seq ON conversations TYPE int;
253 DEFINE FIELD IF NOT EXISTS created_at ON conversations TYPE string;
254 DEFINE INDEX IF NOT EXISTS conversation_chat_idx ON conversations FIELDS chat_id, seq;
255
256 DEFINE TABLE IF NOT EXISTS sticker_cache SCHEMALESS;
257 DEFINE FIELD IF NOT EXISTS sticker_id ON sticker_cache TYPE string;
258 DEFINE FIELD IF NOT EXISTS file_id ON sticker_cache TYPE string;
259 DEFINE FIELD IF NOT EXISTS description ON sticker_cache TYPE string;
260 DEFINE FIELD IF NOT EXISTS analyzed_at ON sticker_cache TYPE string;
261 DEFINE INDEX IF NOT EXISTS sticker_id_idx ON sticker_cache FIELDS sticker_id UNIQUE;
262
263 DEFINE TABLE IF NOT EXISTS embeddings SCHEMALESS;
264 DEFINE FIELD IF NOT EXISTS namespace ON embeddings TYPE string;
265 DEFINE FIELD IF NOT EXISTS key ON embeddings TYPE string;
266 -- `vector` is the legacy array encoding. New writes emit `blob` instead, so
267 -- the field is optional and only present on rows written before the change.
268 DEFINE FIELD IF NOT EXISTS vector ON embeddings TYPE option<array>;
269 DEFINE FIELD IF NOT EXISTS vector_b64 ON embeddings TYPE option<string>;
270 DEFINE FIELD IF NOT EXISTS text ON embeddings TYPE string;
271 DEFINE FIELD IF NOT EXISTS created_at ON embeddings TYPE string;
272 DEFINE FIELD IF NOT EXISTS dim ON embeddings TYPE option<int>;
273 DEFINE FIELD IF NOT EXISTS model ON embeddings TYPE option<string>;
274 DEFINE INDEX IF NOT EXISTS embedding_lookup_idx ON embeddings FIELDS namespace, key UNIQUE;
275 DEFINE INDEX IF NOT EXISTS embedding_namespace_idx ON embeddings FIELDS namespace;
276 DEFINE INDEX IF NOT EXISTS embedding_namespace_dim_idx ON embeddings FIELDS namespace, dim;
277 -- No MTREE index on `vector`, and this is now settled by measurement, not
278 -- by assumption. See `bench_mtree_feasibility` and
279 -- `bench_search_embeddings_scaling` in this file; numbers from a release
280 -- build on macOS arm64 at dim 1536.
281 --
282 -- `DEFINE INDEX ... MTREE DIMENSION 1536 DIST COSINE` is accepted, but:
283 -- 1. It locks the whole table to that one dimension. Storing a 3-dim
284 -- vector afterwards fails with "Incorrect vector dimension (3).
285 -- Expected a vector of 1536 dimension." So "one index per distinct
286 -- dimension" is not possible on a shared table at all — it would
287 -- need dimension-partitioned tables, not just extra indexes.
288 -- 2. It is ~25x more expensive to write: 29.76ms/row indexed versus
289 -- ~1.2ms/row unindexed.
290 -- 3. It does not even pay off on reads. At 2000 rows,
291 -- `vector <|10,COSINE|> $q` had a 2558ms median against 961ms for
292 -- the brute-force scan — 2.7x slower.
293 --
294 -- The scan itself is not cheap (~0.27ms per candidate row, so ~30ms at
295 -- 100 rows and ~2.5s at 10k), but the cost is materializing a
296 -- 1536-element array as SurrealDB `Value`s per row, not the cosine
297 -- arithmetic. Scoring in-engine with
298 -- `vector::similarity::cosine(...) ORDER BY score LIMIT k` was measured
299 -- too and is ~1.6x slower still, because it walks the same arrays.
300 -- Anything that actually fixes this has to make a row cheaper to read
301 -- (a compact encoding rather than an array of `Value`s), which MTREE
302 -- does not do.
303
304 DEFINE TABLE IF NOT EXISTS files SCHEMALESS;
305 DEFINE FIELD IF NOT EXISTS path ON files TYPE string;
306 DEFINE FIELD IF NOT EXISTS hash ON files TYPE string;
307 DEFINE FIELD IF NOT EXISTS last_indexed ON files TYPE string;
308 DEFINE INDEX IF NOT EXISTS file_path_idx ON files FIELDS path UNIQUE;
309
310 DEFINE TABLE IF NOT EXISTS chunks SCHEMALESS;
311 DEFINE FIELD IF NOT EXISTS file_path ON chunks TYPE string;
312 DEFINE FIELD IF NOT EXISTS start_line ON chunks TYPE int;
313 DEFINE FIELD IF NOT EXISTS end_line ON chunks TYPE int;
314 DEFINE FIELD IF NOT EXISTS content ON chunks TYPE string;
315 DEFINE FIELD IF NOT EXISTS embedding ON chunks TYPE option<array>;
316 DEFINE FIELD IF NOT EXISTS created_at ON chunks TYPE string;
317 DEFINE INDEX IF NOT EXISTS chunk_file_idx ON chunks FIELDS file_path;
318
319 DEFINE TABLE IF NOT EXISTS cron_jobs SCHEMALESS;
320 DEFINE FIELD IF NOT EXISTS name ON cron_jobs TYPE string;
321 DEFINE FIELD IF NOT EXISTS schedule ON cron_jobs TYPE string;
322 DEFINE FIELD IF NOT EXISTS task ON cron_jobs TYPE string;
323 DEFINE FIELD IF NOT EXISTS channel ON cron_jobs TYPE string;
324 DEFINE FIELD IF NOT EXISTS model ON cron_jobs TYPE string;
325 DEFINE FIELD IF NOT EXISTS enabled ON cron_jobs TYPE bool;
326 DEFINE FIELD IF NOT EXISTS last_run ON cron_jobs TYPE option<string>;
327 DEFINE FIELD IF NOT EXISTS next_run ON cron_jobs TYPE option<string>;
328 DEFINE INDEX IF NOT EXISTS cron_name_idx ON cron_jobs FIELDS name UNIQUE;
329
330 DEFINE TABLE IF NOT EXISTS memory_nodes SCHEMALESS;
331 DEFINE FIELD IF NOT EXISTS id ON memory_nodes TYPE string;
332 DEFINE FIELD IF NOT EXISTS kind ON memory_nodes TYPE string;
333 DEFINE FIELD IF NOT EXISTS text ON memory_nodes TYPE string;
334 DEFINE FIELD IF NOT EXISTS confidence ON memory_nodes TYPE float;
335 DEFINE FIELD IF NOT EXISTS status ON memory_nodes TYPE string;
336 DEFINE FIELD IF NOT EXISTS created_at ON memory_nodes TYPE string;
337 DEFINE INDEX IF NOT EXISTS memory_node_id_idx ON memory_nodes FIELDS id UNIQUE;
338
339 DEFINE TABLE IF NOT EXISTS memory_edges SCHEMALESS;
340 DEFINE FIELD IF NOT EXISTS from_id ON memory_edges TYPE string;
341 DEFINE FIELD IF NOT EXISTS to_id ON memory_edges TYPE string;
342 DEFINE FIELD IF NOT EXISTS rel ON memory_edges TYPE string;
343 DEFINE FIELD IF NOT EXISTS created_at ON memory_edges TYPE string;
344
345 DEFINE ANALYZER IF NOT EXISTS memory_analyzer TOKENIZERS blank, class FILTERS lowercase, snowball(english);
346 DEFINE INDEX IF NOT EXISTS memory_fts_idx ON memories FIELDS value
347 SEARCH ANALYZER memory_analyzer BM25;
348"#;
349
350fn parse_timestamp(value: &str) -> chrono::DateTime<chrono::Utc> {
351 chrono::DateTime::parse_from_rfc3339(value)
352 .map(|dt| dt.with_timezone(&chrono::Utc))
353 .unwrap_or_else(|_| chrono::Utc::now())
354}
355
356#[async_trait]
357impl MemoryBackend for SurrealMemory {
358 fn as_any(&self) -> &dyn std::any::Any {
359 self
360 }
361
362 async fn store(
363 &self,
364 namespace: &str,
365 key: &str,
366 value: &str,
367 metadata: Option<serde_json::Value>,
368 ) -> Result<()> {
369 let created_at = chrono::Utc::now().to_rfc3339();
370 let row = MemoryRow {
371 namespace: namespace.to_string(),
372 key: key.to_string(),
373 value: value.to_string(),
374 metadata,
375 created_at,
376 };
377 let _: Option<MemoryRow> = self
378 .db()
379 .await?
380 .upsert(("memories", Self::memory_id(namespace, key)))
381 .content(row)
382 .await?;
383 Ok(())
384 }
385
386 async fn recall(&self, namespace: &str, key: &str) -> Result<Option<MemoryEntry>> {
387 let row: Option<MemoryRow> = self
388 .db()
389 .await?
390 .select(("memories", Self::memory_id(namespace, key)))
391 .await?;
392 Ok(row.map(|entry| MemoryEntry {
393 key: entry.key,
394 value: entry.value,
395 metadata: entry.metadata,
396 created_at: parse_timestamp(&entry.created_at),
397 }))
398 }
399
400 async fn search(&self, namespace: &str, query: &str, limit: usize) -> Result<Vec<MemoryEntry>> {
401 let mut result = self
403 .db()
404 .await?
405 .query(
406 "SELECT *, search::score(1) AS score
407 FROM memories
408 WHERE namespace = $namespace
409 AND value @1@ $query
410 ORDER BY score DESC
411 LIMIT $limit",
412 )
413 .bind(("namespace", namespace.to_string()))
414 .bind(("query", query.to_string()))
415 .bind(("limit", limit as i64))
416 .await?;
417 let rows: Vec<MemoryRow> = result.take(0)?;
418
419 if !rows.is_empty() {
420 return Ok(rows
421 .into_iter()
422 .map(|entry| MemoryEntry {
423 key: entry.key,
424 value: entry.value,
425 metadata: entry.metadata,
426 created_at: parse_timestamp(&entry.created_at),
427 })
428 .collect());
429 }
430
431 let query_lower = query.to_lowercase();
433 let mut result = self.db().await?
434 .query(
435 "SELECT * FROM memories
436 WHERE namespace = $namespace
437 AND (string::lowercase(key) CONTAINS $query OR string::lowercase(value) CONTAINS $query)
438 ORDER BY created_at DESC
439 LIMIT $limit"
440 )
441 .bind(("namespace", namespace.to_string()))
442 .bind(("query", query_lower))
443 .bind(("limit", limit as i64))
444 .await?;
445 let rows: Vec<MemoryRow> = result.take(0)?;
446 Ok(rows
447 .into_iter()
448 .map(|entry| MemoryEntry {
449 key: entry.key,
450 value: entry.value,
451 metadata: entry.metadata,
452 created_at: parse_timestamp(&entry.created_at),
453 })
454 .collect())
455 }
456
457 async fn forget(&self, namespace: &str, key: &str) -> Result<()> {
458 let _: Option<MemoryRow> = self
459 .db()
460 .await?
461 .delete(("memories", Self::memory_id(namespace, key)))
462 .await?;
463 Ok(())
464 }
465
466 async fn list(&self, namespace: &str) -> Result<Vec<MemoryEntry>> {
467 let mut result = self.db().await?
468 .query("SELECT key, value, metadata, created_at FROM memories WHERE namespace = $namespace ORDER BY created_at DESC")
469 .bind(("namespace", namespace.to_string()))
470 .await?;
471 let rows: Vec<MemoryRow> = result.take(0)?;
472 Ok(rows
473 .into_iter()
474 .map(|entry| MemoryEntry {
475 key: entry.key,
476 value: entry.value,
477 metadata: entry.metadata,
478 created_at: parse_timestamp(&entry.created_at),
479 })
480 .collect())
481 }
482
483 async fn store_conversation(
484 &self,
485 chat_id: &str,
486 sender_id: &str,
487 role: &str,
488 content: &str,
489 ) -> Result<()> {
490 let now = chrono::Utc::now();
491 let row = ConversationRow {
492 chat_id: chat_id.to_string(),
493 sender_id: sender_id.to_string(),
494 role: role.to_string(),
495 content: content.to_string(),
496 seq: now.timestamp_millis(),
497 created_at: now.to_rfc3339(),
498 };
499 let _: Option<ConversationRow> = self
500 .db()
501 .await?
502 .create("conversations")
503 .content(row)
504 .await?;
505 Ok(())
506 }
507
508 async fn store_conversation_batch(&self, entries: &[(&str, &str, &str, &str)]) -> Result<()> {
509 for (offset, (chat_id, sender_id, role, content)) in entries.iter().enumerate() {
510 let now = chrono::Utc::now();
511 let row = ConversationRow {
512 chat_id: (*chat_id).to_string(),
513 sender_id: (*sender_id).to_string(),
514 role: (*role).to_string(),
515 content: (*content).to_string(),
516 seq: now.timestamp_millis() + offset as i64,
517 created_at: now.to_rfc3339(),
518 };
519 let _: Option<ConversationRow> = self
520 .db()
521 .await?
522 .create("conversations")
523 .content(row)
524 .await?;
525 }
526 Ok(())
527 }
528
529 async fn get_conversation_history(
530 &self,
531 chat_id: &str,
532 limit: usize,
533 ) -> Result<Vec<(String, String)>> {
534 let mut result = self
535 .db()
536 .await?
537 .query(
538 "SELECT * FROM conversations
539 WHERE chat_id = $chat_id
540 ORDER BY seq DESC
541 LIMIT $limit",
542 )
543 .bind(("chat_id", chat_id.to_string()))
544 .bind(("limit", limit as i64))
545 .await?;
546 let mut rows: Vec<ConversationRow> = result.take(0)?;
547 rows.reverse();
548 Ok(rows
549 .into_iter()
550 .map(|row| (row.role, row.content))
551 .collect())
552 }
553
554 async fn clear_conversation(&self, chat_id: &str) -> Result<()> {
555 self.db()
556 .await?
557 .query("DELETE FROM conversations WHERE chat_id = $chat_id")
558 .bind(("chat_id", chat_id.to_string()))
559 .await?
560 .check()?;
561 Ok(())
562 }
563
564 async fn search_conversations(
565 &self,
566 query: &str,
567 limit: usize,
568 chat_id: Option<&str>,
569 ) -> Result<Vec<ConversationSearchHit>> {
570 let query = query.to_lowercase();
571 let mut result = if let Some(chat_id) = chat_id {
572 self.db()
573 .await?
574 .query(
575 "SELECT * FROM conversations
576 WHERE chat_id = $chat_id
577 AND string::lowercase(content) CONTAINS $query
578 ORDER BY seq DESC
579 LIMIT $limit",
580 )
581 .bind(("chat_id", chat_id.to_string()))
582 .bind(("query", query))
583 .bind(("limit", limit as i64))
584 .await?
585 } else {
586 self.db()
587 .await?
588 .query(
589 "SELECT * FROM conversations
590 WHERE string::lowercase(content) CONTAINS $query
591 ORDER BY seq DESC
592 LIMIT $limit",
593 )
594 .bind(("query", query))
595 .bind(("limit", limit as i64))
596 .await?
597 };
598 let rows: Vec<ConversationRow> = result.take(0)?;
599 Ok(rows
600 .into_iter()
601 .map(|row| ConversationSearchHit {
602 chat_id: row.chat_id,
603 role: row.role,
604 content: row.content,
605 created_at: parse_timestamp(&row.created_at),
606 })
607 .collect())
608 }
609
610 async fn get_sticker_cache(&self, sticker_id: &str) -> Result<Option<String>> {
611 let row: Option<StickerRow> = self
612 .db()
613 .await?
614 .select(("sticker_cache", sticker_id))
615 .await?;
616 Ok(row.map(|entry| entry.description))
617 }
618
619 async fn store_sticker_cache(
620 &self,
621 sticker_id: &str,
622 file_id: &str,
623 description: &str,
624 ) -> Result<()> {
625 let row = StickerRow {
626 sticker_id: sticker_id.to_string(),
627 file_id: file_id.to_string(),
628 description: description.to_string(),
629 analyzed_at: chrono::Utc::now().to_rfc3339(),
630 };
631 let _: Option<StickerRow> = self
632 .db()
633 .await?
634 .upsert(("sticker_cache", sticker_id))
635 .content(row)
636 .await?;
637 Ok(())
638 }
639
640 async fn store_embedding(
643 &self,
644 namespace: &str,
645 key: &str,
646 vector: &[f32],
647 text: &str,
648 model: &str,
649 ) -> Result<()> {
650 let row = EmbeddingRow {
651 namespace: namespace.to_string(),
652 key: key.to_string(),
653 dim: Some(vector.len() as i64),
654 model: Some(model.to_string()),
655 vector_b64: pack_vector(vector),
656 text: text.to_string(),
657 created_at: chrono::Utc::now().to_rfc3339(),
658 };
659 let id = Self::memory_id(namespace, key);
660 let _: Option<EmbeddingRow> = self
661 .db()
662 .await?
663 .upsert(("embeddings", &id))
664 .content(row)
665 .await?;
666 self.cache_vector(namespace, key, vector).await;
667 Ok(())
668 }
669
670 async fn search_embeddings(
671 &self,
672 namespace: &str,
673 query_vector: &[f32],
674 limit: usize,
675 ) -> Result<Vec<EmbeddingEntry>> {
676 if query_vector.is_empty() {
677 return Ok(Vec::new());
678 }
679 let dim = query_vector.len() as i64;
680
681 self.ensure_vectors_loaded(namespace).await?;
704
705 let ranked: Vec<(String, Vec<f32>)> = {
709 let cache = self.vectors.read().await;
710 let Some(entries) = cache.get(namespace) else {
711 return Ok(Vec::new());
712 };
713 let query_magnitude = magnitude(query_vector);
714 let mut scored: Vec<(f32, usize)> = Vec::with_capacity(entries.len());
715 for (idx, entry) in entries.iter().enumerate() {
716 if entry.dim != dim {
717 continue;
718 }
719 let sim =
720 cosine_similarity_with_magnitude(query_vector, query_magnitude, &entry.vector);
721 if let Some(sim) = sim {
722 scored.push((sim, idx));
723 }
724 }
725 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
726 scored.truncate(limit);
727 scored
728 .into_iter()
729 .map(|(_, idx)| (entries[idx].key.clone(), entries[idx].vector.clone()))
730 .collect()
731 };
732
733 if ranked.is_empty() {
734 return Ok(Vec::new());
735 }
736
737 let keys: Vec<String> = ranked.iter().map(|(key, _)| key.clone()).collect();
746 let mut result = self
747 .db()
748 .await?
749 .query(
750 "SELECT key, text, created_at FROM embeddings \
751 WHERE namespace = $namespace AND key IN $keys",
752 )
753 .bind(("namespace", namespace.to_string()))
754 .bind(("keys", keys))
755 .await?;
756 let rows: Vec<EmbeddingDetail> = result.take(0)?;
757 let details: std::collections::HashMap<String, EmbeddingDetail> =
758 rows.into_iter().map(|r| (r.key.clone(), r)).collect();
759
760 Ok(ranked
761 .into_iter()
762 .filter_map(|(key, vector)| {
763 let detail = details.get(&key)?;
764 Some(EmbeddingEntry {
765 namespace: namespace.to_string(),
766 key,
767 vector,
768 text: detail.text.clone(),
769 created_at: parse_timestamp(&detail.created_at),
770 })
771 })
772 .collect())
773 }
774
775 async fn store_file_index(&self, path: &str, hash: &str) -> Result<()> {
778 let row = FileIndexRow {
779 path: path.to_string(),
780 hash: hash.to_string(),
781 last_indexed: chrono::Utc::now().to_rfc3339(),
782 };
783 let id = format!("{:x}", md5_hash(path));
785 let _: Option<FileIndexRow> = self.db().await?.upsert(("files", &id)).content(row).await?;
786 Ok(())
787 }
788
789 async fn get_file_index(&self, path: &str) -> Result<Option<FileIndex>> {
790 let id = format!("{:x}", md5_hash(path));
791 let row: Option<FileIndexRow> = self.db().await?.select(("files", &id)).await?;
792 Ok(row.map(|r| FileIndex {
793 path: r.path,
794 hash: r.hash,
795 last_indexed: parse_timestamp(&r.last_indexed),
796 }))
797 }
798
799 async fn store_chunk(
802 &self,
803 file_path: &str,
804 start_line: u32,
805 end_line: u32,
806 content: &str,
807 embedding: Option<&[f32]>,
808 ) -> Result<()> {
809 let row = ChunkRow {
810 file_path: file_path.to_string(),
811 start_line,
812 end_line,
813 content: content.to_string(),
814 embedding: embedding.map(|e| e.to_vec()),
815 created_at: chrono::Utc::now().to_rfc3339(),
816 };
817 let _: Option<ChunkRow> = self.db().await?.create("chunks").content(row).await?;
818 Ok(())
819 }
820
821 async fn get_chunks_for_file(&self, file_path: &str) -> Result<Vec<Chunk>> {
822 let mut result = self
823 .db()
824 .await?
825 .query("SELECT * FROM chunks WHERE file_path = $file_path ORDER BY start_line ASC")
826 .bind(("file_path", file_path.to_string()))
827 .await?;
828 let rows: Vec<ChunkRow> = result.take(0)?;
829 Ok(rows
830 .into_iter()
831 .map(|r| Chunk {
832 file_path: r.file_path,
833 start_line: r.start_line,
834 end_line: r.end_line,
835 content: r.content,
836 embedding: r.embedding,
837 created_at: parse_timestamp(&r.created_at),
838 })
839 .collect())
840 }
841
842 async fn delete_chunks_for_file(&self, file_path: &str) -> Result<()> {
843 self.db()
844 .await?
845 .query("DELETE FROM chunks WHERE file_path = $file_path")
846 .bind(("file_path", file_path.to_string()))
847 .await?;
848 Ok(())
849 }
850}
851
852fn pack_vector(vector: &[f32]) -> String {
857 let mut bytes = Vec::with_capacity(vector.len() * 4);
858 for x in vector {
859 bytes.extend_from_slice(&x.to_le_bytes());
860 }
861 BASE64.encode(&bytes)
862}
863
864fn unpack_vector_into(encoded: &str, scratch: &mut Vec<u8>, out: &mut Vec<f32>) {
870 out.clear();
871 scratch.clear();
872 if BASE64.decode_vec(encoded, scratch).is_err() {
873 return;
874 }
875 out.reserve(scratch.len() / 4);
876 for chunk in scratch.chunks_exact(4) {
877 out.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
878 }
879}
880
881#[cfg(test)]
883fn unpack_vector(encoded: &str) -> Vec<f32> {
884 let mut scratch = Vec::new();
885 let mut out = Vec::new();
886 unpack_vector_into(encoded, &mut scratch, &mut out);
887 out
888}
889
890#[cfg(test)]
899fn cosine_similarity(a: &[f32], b: &[f32]) -> Option<f32> {
900 cosine_similarity_with_magnitude(a, magnitude(a), b)
901}
902
903fn cosine_similarity_with_magnitude(a: &[f32], ma: f32, b: &[f32]) -> Option<f32> {
910 if a.len() != b.len() || a.is_empty() {
911 return None;
912 }
913
914 let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
915 let mb: f32 = magnitude(b);
916
917 if ma == 0.0 || mb == 0.0 {
918 return Some(0.0);
919 }
920
921 Some(dot / (ma * mb))
922}
923
924fn magnitude(v: &[f32]) -> f32 {
925 v.iter().map(|x| x * x).sum::<f32>().sqrt()
926}
927
928fn md5_hash(input: &str) -> u64 {
930 use std::hash::{Hash, Hasher};
931 let mut hasher = std::collections::hash_map::DefaultHasher::new();
932 input.hash(&mut hasher);
933 hasher.finish()
934}
935
936#[cfg(test)]
937mod tests {
938 use super::*;
939
940 #[test]
941 fn test_md5_hash_deterministic() {
942 let h1 = md5_hash("test-path");
943 let h2 = md5_hash("test-path");
944 assert_eq!(h1, h2);
945 }
946
947 #[test]
948 fn test_md5_hash_different_inputs() {
949 let h1 = md5_hash("path-a");
950 let h2 = md5_hash("path-b");
951 assert_ne!(h1, h2);
952 }
953
954 #[tokio::test]
955 async fn test_surreal_store_and_recall() {
956 let dir = tempfile::tempdir().unwrap();
957 let mem = SurrealMemory::new(dir.path()).await.unwrap();
958
959 mem.store("test", "greeting", "hello world", None)
960 .await
961 .unwrap();
962 let val = mem.recall("test", "greeting").await.unwrap();
963 assert!(val.is_some());
964 assert_eq!(val.unwrap().value, "hello world");
965 }
966
967 #[tokio::test]
968 async fn test_surreal_recall_missing() {
969 let dir = tempfile::tempdir().unwrap();
970 let mem = SurrealMemory::new(dir.path()).await.unwrap();
971
972 let val = mem.recall("test", "missing-key").await.unwrap();
973 assert!(val.is_none());
974 }
975
976 #[tokio::test]
977 async fn test_surreal_search() {
978 let dir = tempfile::tempdir().unwrap();
979 let mem = SurrealMemory::new(dir.path()).await.unwrap();
980
981 mem.store("ns", "k1", "the quick brown fox", None)
982 .await
983 .unwrap();
984 mem.store("ns", "k2", "lazy dog sleeps", None)
985 .await
986 .unwrap();
987 mem.store("ns", "k3", "fox runs fast", None).await.unwrap();
988
989 let results: Vec<MemoryEntry> = mem.search("ns", "fox", 10).await.unwrap();
990 assert!(!results.is_empty());
991 assert!(results.iter().any(|e| e.value.contains("fox")));
992 }
993
994 #[tokio::test]
995 async fn test_surreal_delete() {
996 let dir = tempfile::tempdir().unwrap();
997 let mem = SurrealMemory::new(dir.path()).await.unwrap();
998
999 mem.store("ns", "del-key", "to delete", None).await.unwrap();
1000 assert!(mem.recall("ns", "del-key").await.unwrap().is_some());
1001
1002 mem.forget("ns", "del-key").await.unwrap();
1003 assert!(mem.recall("ns", "del-key").await.unwrap().is_none());
1004 }
1005
1006 #[tokio::test]
1007 async fn test_surreal_conversation_history() {
1008 let dir = tempfile::tempdir().unwrap();
1009 let mem = SurrealMemory::new(dir.path()).await.unwrap();
1010
1011 mem.store_conversation("chat-1", "user-1", "user", "Hello")
1012 .await
1013 .unwrap();
1014 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1016 mem.store_conversation("chat-1", "assistant", "assistant", "Hi there")
1017 .await
1018 .unwrap();
1019
1020 let history: Vec<(String, String)> =
1021 mem.get_conversation_history("chat-1", 10).await.unwrap();
1022 assert_eq!(history.len(), 2);
1023 assert_eq!(history[0].1, "Hello");
1024 assert_eq!(history[1].1, "Hi there");
1025 }
1026
1027 #[tokio::test]
1028 async fn test_surreal_clear_conversation_is_scoped_to_one_chat() {
1029 let dir = tempfile::tempdir().unwrap();
1030 let mem = SurrealMemory::new(dir.path()).await.unwrap();
1031
1032 mem.store_conversation("chat-1", "user-1", "user", "keep me out")
1033 .await
1034 .unwrap();
1035 mem.store_conversation("chat-2", "user-1", "user", "keep me")
1036 .await
1037 .unwrap();
1038
1039 mem.clear_conversation("chat-1").await.unwrap();
1040
1041 assert!(mem
1042 .get_conversation_history("chat-1", 10)
1043 .await
1044 .unwrap()
1045 .is_empty());
1046 let survivors = mem.get_conversation_history("chat-2", 10).await.unwrap();
1047 assert_eq!(survivors.len(), 1);
1048 assert_eq!(survivors[0].1, "keep me");
1049
1050 mem.clear_conversation("chat-1").await.unwrap();
1052 }
1053
1054 #[tokio::test]
1055 async fn test_surreal_embeddings() {
1056 let dir = tempfile::tempdir().unwrap();
1057 let mem = SurrealMemory::new(dir.path()).await.unwrap();
1058
1059 let vec1 = vec![1.0, 0.0, 0.0];
1060 let vec2 = vec![0.0, 1.0, 0.0];
1061 let vec3 = vec![0.9, 0.1, 0.0];
1062
1063 mem.store_embedding("ns", "e1", &vec1, "first", "test-model")
1064 .await
1065 .unwrap();
1066 mem.store_embedding("ns", "e2", &vec2, "second", "test-model")
1067 .await
1068 .unwrap();
1069 mem.store_embedding("ns", "e3", &vec3, "third", "test-model")
1070 .await
1071 .unwrap();
1072
1073 let results = mem.search_embeddings("ns", &vec1, 2).await.unwrap();
1074 assert!(!results.is_empty());
1075 assert!(results[0].key == "e1" || results[0].key == "e3");
1077 }
1078
1079 #[tokio::test]
1083 async fn test_search_embeddings_sees_writes_after_cache_is_warm() {
1084 let dir = tempfile::tempdir().unwrap();
1085 let mem = SurrealMemory::new(dir.path()).await.unwrap();
1086
1087 mem.store_embedding("ns", "a", &[1.0, 0.0, 0.0], "a", "m")
1088 .await
1089 .unwrap();
1090 assert_eq!(
1092 mem.search_embeddings("ns", &[1.0, 0.0, 0.0], 10)
1093 .await
1094 .unwrap()
1095 .len(),
1096 1
1097 );
1098
1099 mem.store_embedding("ns", "b", &[0.0, 1.0, 0.0], "b", "m")
1100 .await
1101 .unwrap();
1102 let results = mem
1103 .search_embeddings("ns", &[0.0, 1.0, 0.0], 10)
1104 .await
1105 .unwrap();
1106 assert_eq!(results.len(), 2);
1107 assert_eq!(results[0].key, "b");
1108
1109 mem.store_embedding("ns", "b", &[1.0, 0.0, 0.0], "b2", "m")
1111 .await
1112 .unwrap();
1113 let results = mem
1114 .search_embeddings("ns", &[0.0, 1.0, 0.0], 10)
1115 .await
1116 .unwrap();
1117 assert_eq!(results.len(), 2);
1118 assert_eq!(results[0].key, "a");
1119 assert_eq!(results.iter().filter(|r| r.key == "b").count(), 1);
1120 assert_eq!(
1121 results.iter().find(|r| r.key == "b").unwrap().text,
1122 "b2",
1123 "text must come from the table, not a stale cache entry"
1124 );
1125 }
1126
1127 #[test]
1128 fn test_pack_vector_round_trips() {
1129 let v = vec![1.0f32, -0.5, 0.0, 1e-8, f32::MAX, f32::MIN];
1130 assert_eq!(unpack_vector(&pack_vector(&v)), v);
1131 assert!(unpack_vector(&pack_vector(&[])).is_empty());
1132 assert!(unpack_vector("not base64 !!!").is_empty());
1135 }
1136
1137 #[tokio::test]
1141 async fn test_search_embeddings_reads_legacy_array_rows() {
1142 let dir = tempfile::tempdir().unwrap();
1143 let mem = SurrealMemory::new(dir.path()).await.unwrap();
1144 let db = mem.db().await.unwrap();
1145
1146 db.query(
1147 "CREATE embeddings:legacy SET namespace = 'ns', key = 'legacy',
1148 vector = [1.0, 0.0, 0.0], text = 'legacy row',
1149 created_at = '2024-01-01T00:00:00Z', dim = 3",
1150 )
1151 .await
1152 .unwrap();
1153 mem.store_embedding("ns", "modern", &[0.0, 1.0, 0.0], "modern row", "m")
1154 .await
1155 .unwrap();
1156
1157 let results = mem
1160 .search_embeddings("ns", &[1.0, 0.0, 0.0], 10)
1161 .await
1162 .unwrap();
1163 assert_eq!(results.len(), 2);
1164 assert_eq!(results[0].key, "legacy");
1165 assert_eq!(results[0].vector, vec![1.0, 0.0, 0.0]);
1166 }
1167
1168 #[test]
1169 fn test_cosine_similarity_rejects_mismatched_lengths() {
1170 assert_eq!(cosine_similarity(&[1.0, 0.0], &[1.0, 0.0, 0.0]), None);
1171 assert_eq!(cosine_similarity(&[], &[]), None);
1172 assert_eq!(cosine_similarity(&[1.0, 0.0], &[1.0, 0.0]), Some(1.0));
1173 assert_eq!(cosine_similarity(&[0.0, 0.0], &[1.0, 0.0]), Some(0.0));
1174 }
1175
1176 #[tokio::test]
1177 async fn test_search_embeddings_excludes_other_dimensions() {
1178 let dir = tempfile::tempdir().unwrap();
1179 let mem = SurrealMemory::new(dir.path()).await.unwrap();
1180
1181 let old_dim = vec![1.0f32, 0.0, 0.0];
1184 let new_dim = vec![0.25f32; 8];
1185 mem.store_embedding("ns", "old", &old_dim, "old model", "old-model")
1186 .await
1187 .unwrap();
1188 mem.store_embedding("ns", "new", &new_dim, "new model", "new-model")
1189 .await
1190 .unwrap();
1191
1192 let results = mem.search_embeddings("ns", &new_dim, 10).await.unwrap();
1193 assert!(
1194 results.iter().all(|e| e.key != "old"),
1195 "a vector of a different dimension must never be returned"
1196 );
1197 assert!(results.iter().any(|e| e.key == "new"));
1198
1199 let results = mem.search_embeddings("ns", &old_dim, 10).await.unwrap();
1201 assert!(results.iter().all(|e| e.key != "new"));
1202 assert!(results.iter().any(|e| e.key == "old"));
1203 }
1204
1205 #[tokio::test]
1206 async fn test_search_embeddings_skips_rows_without_dimension() {
1207 let dir = tempfile::tempdir().unwrap();
1208 let mem = SurrealMemory::new(dir.path()).await.unwrap();
1209 let db = mem.db().await.unwrap();
1210
1211 db.query(
1213 "CREATE embeddings:legacy SET namespace = 'ns', key = 'legacy',
1214 vector = [1.0, 0.0, 0.0], text = 'legacy', created_at = '2024-01-01T00:00:00Z'",
1215 )
1216 .await
1217 .unwrap();
1218
1219 let results = mem
1220 .search_embeddings("ns", &[1.0, 0.0, 0.0], 10)
1221 .await
1222 .unwrap();
1223 assert!(
1224 results.is_empty(),
1225 "rows with no recorded dimension must not be returned"
1226 );
1227 }
1228
1229 #[tokio::test]
1230 async fn test_surreal_file_index() {
1231 let dir = tempfile::tempdir().unwrap();
1232 let mem = SurrealMemory::new(dir.path()).await.unwrap();
1233
1234 mem.store_file_index("/src/main.rs", "abc123")
1235 .await
1236 .unwrap();
1237 let idx = mem.get_file_index("/src/main.rs").await.unwrap();
1238 assert!(idx.is_some());
1239 assert_eq!(idx.unwrap().hash, "abc123");
1240
1241 let missing = mem.get_file_index("/src/nonexistent.rs").await.unwrap();
1242 assert!(missing.is_none());
1243 }
1244
1245 #[tokio::test]
1246 async fn test_surreal_chunks() {
1247 let dir = tempfile::tempdir().unwrap();
1248 let mem = SurrealMemory::new(dir.path()).await.unwrap();
1249
1250 mem.store_chunk("/src/lib.rs", 1, 10, "fn main() {}", None)
1251 .await
1252 .unwrap();
1253 mem.store_chunk("/src/lib.rs", 11, 20, "fn helper() {}", None)
1254 .await
1255 .unwrap();
1256
1257 let chunks = mem.get_chunks_for_file("/src/lib.rs").await.unwrap();
1258 assert_eq!(chunks.len(), 2);
1259 assert_eq!(chunks[0].start_line, 1);
1260 assert_eq!(chunks[1].start_line, 11);
1261
1262 mem.delete_chunks_for_file("/src/lib.rs").await.unwrap();
1263 let empty = mem.get_chunks_for_file("/src/lib.rs").await.unwrap();
1264 assert!(empty.is_empty());
1265 }
1266
1267 #[tokio::test]
1268 async fn test_surreal_sticker_cache() {
1269 let dir = tempfile::tempdir().unwrap();
1270 let mem = SurrealMemory::new(dir.path()).await.unwrap();
1271
1272 mem.store_sticker_cache("stk-1", "file-1", "A happy cat")
1273 .await
1274 .unwrap();
1275 let desc: Option<String> = mem.get_sticker_cache("stk-1").await.unwrap();
1276 assert_eq!(desc, Some("A happy cat".to_string()));
1277
1278 let missing: Option<String> = mem.get_sticker_cache("stk-999").await.unwrap();
1279 assert!(missing.is_none());
1280 }
1281
1282 struct Xorshift(u64);
1285
1286 impl Xorshift {
1287 fn next_f32(&mut self) -> f32 {
1288 let mut x = self.0;
1289 x ^= x << 13;
1290 x ^= x >> 7;
1291 x ^= x << 17;
1292 self.0 = x;
1293 ((x >> 40) as f32 / 16_777_216.0) * 2.0 - 1.0
1294 }
1295
1296 fn unit_vector(&mut self, dim: usize) -> Vec<f32> {
1297 let mut v: Vec<f32> = (0..dim).map(|_| self.next_f32()).collect();
1298 let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
1299 if norm > 0.0 {
1300 for x in v.iter_mut() {
1301 *x /= norm;
1302 }
1303 }
1304 v
1305 }
1306 }
1307
1308 #[tokio::test]
1315 #[ignore = "benchmark: slow, writes a large corpus"]
1316 async fn bench_search_embeddings_scaling() {
1317 const DIM: usize = 1536;
1318 const QUERIES: usize = 100;
1319 let sizes: Vec<usize> = match std::env::var("APOLLO_BENCH_SIZES") {
1323 Ok(s) => s.split(',').filter_map(|p| p.trim().parse().ok()).collect(),
1324 Err(_) => vec![100, 1_000, 10_000, 50_000],
1325 };
1326
1327 for size in sizes {
1328 let dir = tempfile::tempdir().unwrap();
1329 let mem = SurrealMemory::new(dir.path()).await.unwrap();
1330 let mut rng = Xorshift(0x2545_F491_4F6C_DD1D);
1331
1332 let write_start = std::time::Instant::now();
1333 for i in 0..size {
1334 let v = rng.unit_vector(DIM);
1335 mem.store_embedding("bench", &format!("k{i}"), &v, "text", "bench-model")
1336 .await
1337 .unwrap();
1338 }
1339 let write_elapsed = write_start.elapsed();
1340
1341 let queries: Vec<Vec<f32>> = (0..QUERIES).map(|_| rng.unit_vector(DIM)).collect();
1342 let mut samples: Vec<u128> = Vec::with_capacity(QUERIES);
1343 for q in &queries {
1344 let start = std::time::Instant::now();
1345 let hits = mem.search_embeddings("bench", q, 10).await.unwrap();
1346 samples.push(start.elapsed().as_micros());
1347 assert_eq!(hits.len(), 10.min(size));
1348 }
1349 let (median, p95, max) = percentiles(&mut samples);
1350
1351 println!(
1352 "packed size={size:>6} dim={DIM} search_median={median:>8.2}ms \
1353 search_p95={p95:>8.2}ms search_max={max:>8.2}ms insert_total={:>8.2}s \
1354 insert_per_row={:>6.2}ms disk={:>7.1}MB",
1355 write_elapsed.as_secs_f64(),
1356 write_elapsed.as_secs_f64() * 1000.0 / size as f64,
1357 dir_size_mb(dir.path()),
1358 );
1359 drop(mem);
1360 drop(dir);
1361
1362 let dir = tempfile::tempdir().unwrap();
1372 let mem = SurrealMemory::new(dir.path()).await.unwrap();
1373 let db = mem.db().await.unwrap();
1374 let mut rng = Xorshift(0x2545_F491_4F6C_DD1D);
1375
1376 let write_start = std::time::Instant::now();
1377 for i in 0..size {
1378 let v = rng.unit_vector(DIM);
1379 db.query(
1380 "CREATE type::thing('embeddings', $id) SET namespace = 'bench', \
1381 key = $id, vector = $v, text = 'text', \
1382 created_at = '2024-01-01T00:00:00Z', dim = $dim, model = 'bench-model'",
1383 )
1384 .bind(("id", format!("k{i}")))
1385 .bind(("v", v))
1386 .bind(("dim", DIM as i64))
1387 .await
1388 .unwrap();
1389 }
1390 let write_elapsed = write_start.elapsed();
1391
1392 let mut samples: Vec<u128> = Vec::with_capacity(QUERIES);
1393 for q in &queries {
1394 let start = std::time::Instant::now();
1395 let hits = mem.search_embeddings("bench", q, 10).await.unwrap();
1396 samples.push(start.elapsed().as_micros());
1397 assert_eq!(hits.len(), 10.min(size));
1398 }
1399 let (median, p95, max) = percentiles(&mut samples);
1400 println!(
1401 "array size={size:>6} dim={DIM} search_median={median:>8.2}ms \
1402 search_p95={p95:>8.2}ms search_max={max:>8.2}ms insert_total={:>8.2}s \
1403 insert_per_row={:>6.2}ms disk={:>7.1}MB",
1404 write_elapsed.as_secs_f64(),
1405 write_elapsed.as_secs_f64() * 1000.0 / size as f64,
1406 dir_size_mb(dir.path()),
1407 );
1408 }
1409 }
1410
1411 fn dir_size_mb(path: &Path) -> f64 {
1412 fn walk(path: &Path) -> u64 {
1413 let Ok(entries) = std::fs::read_dir(path) else {
1414 return 0;
1415 };
1416 entries
1417 .flatten()
1418 .map(|e| match e.metadata() {
1419 Ok(m) if m.is_dir() => walk(&e.path()),
1420 Ok(m) => m.len(),
1421 Err(_) => 0,
1422 })
1423 .sum()
1424 }
1425 walk(path) as f64 / (1024.0 * 1024.0)
1426 }
1427
1428 #[tokio::test]
1432 #[ignore = "benchmark: slow, writes a large corpus"]
1433 async fn bench_mtree_feasibility() {
1434 const DIM: usize = 1536;
1435 const SIZE: usize = 2_000;
1436
1437 let dir = tempfile::tempdir().unwrap();
1438 let mem = SurrealMemory::new(dir.path()).await.unwrap();
1439 let mut rng = Xorshift(0x2545_F491_4F6C_DD1D);
1440 let db = mem.db().await.unwrap();
1441
1442 let define = db
1443 .query(format!(
1444 "DEFINE INDEX IF NOT EXISTS embedding_vector_mtree_{DIM} ON embeddings \
1445 FIELDS vector MTREE DIMENSION {DIM} DIST COSINE"
1446 ))
1447 .await;
1448 println!("define_mtree_ok={}", define.is_ok());
1449 if let Err(e) = &define {
1450 println!("define_mtree_err={e}");
1451 return;
1452 }
1453
1454 let start = std::time::Instant::now();
1455 for i in 0..SIZE {
1456 let v = rng.unit_vector(DIM);
1457 mem.store_embedding("bench", &format!("k{i}"), &v, "text", "bench-model")
1458 .await
1459 .unwrap();
1460 }
1461 println!(
1462 "mtree_insert_total={:.2}s per_row={:.2}ms",
1463 start.elapsed().as_secs_f64(),
1464 start.elapsed().as_secs_f64() * 1000.0 / SIZE as f64
1465 );
1466
1467 let mismatched = mem
1469 .store_embedding("bench", "other-dim", &[1.0, 0.0, 0.0], "t", "small-model")
1470 .await;
1471 println!("mixed_dim_insert_ok={}", mismatched.is_ok());
1472 if let Err(e) = &mismatched {
1473 println!("mixed_dim_insert_err={e}");
1474 }
1475
1476 let mut knn: Vec<u128> = Vec::new();
1477 let mut scan: Vec<u128> = Vec::new();
1478 for _ in 0..20 {
1479 let q = rng.unit_vector(DIM);
1480
1481 let start = std::time::Instant::now();
1482 let mut result = db
1483 .query(
1484 "SELECT namespace, key, text, created_at FROM embeddings \
1485 WHERE vector <|10,COSINE|> $q",
1486 )
1487 .bind(("q", q.clone()))
1488 .await
1489 .unwrap();
1490 let rows: Vec<serde_json::Value> = result.take(0).unwrap();
1491 knn.push(start.elapsed().as_micros());
1492 assert!(!rows.is_empty(), "KNN returned nothing — index not used");
1493
1494 let start = std::time::Instant::now();
1495 mem.search_embeddings("bench", &q, 10).await.unwrap();
1496 scan.push(start.elapsed().as_micros());
1497 }
1498 let (km, kp, kx) = percentiles(&mut knn);
1499 let (sm, sp, sx) = percentiles(&mut scan);
1500 println!("size={SIZE} knn_median={km:.2}ms knn_p95={kp:.2}ms knn_max={kx:.2}ms");
1501 println!("size={SIZE} scan_median={sm:.2}ms scan_p95={sp:.2}ms scan_max={sx:.2}ms");
1502 }
1503
1504 fn percentiles(samples: &mut [u128]) -> (f64, f64, f64) {
1505 samples.sort_unstable();
1506 (
1507 samples[samples.len() / 2] as f64 / 1000.0,
1508 samples[(samples.len() * 95) / 100] as f64 / 1000.0,
1509 *samples.last().unwrap() as f64 / 1000.0,
1510 )
1511 }
1512}