Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 22 additions & 65 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,21 @@ CortexaDB exists to provide a **middle ground**: a hard-durable, embedded memory
from cortexadb import CortexaDB
from cortexadb.providers.openai import OpenAIEmbedder

# 1. Open database with an embedder for automatic text-to-vector
# 1. Open database with an embedder
db = CortexaDB.open("agent.mem", embedder=OpenAIEmbedder())

# 2. Store facts and connect them logically
mid1 = db.remember("The user prefers dark mode.")
mid2 = db.remember("User works at Stripe.")
# 2. Add facts
mid1 = db.add("The user prefers dark mode.")
mid2 = db.add("User works at Stripe.")
db.connect(mid1, mid2, "relates_to")

# 3. Query with semantic and graph intelligence
hits = db.ask("What are the user's preferences?", use_graph=True)
print(f"Top Hit: {hits[0].id} (Score: {hits[0].score})")
# 3. Fluent Query Builder
hits = db.query("What are the user's preferences?") \
.limit(5) \
.use_graph() \
.execute()

print(f"Top Hit: {hits[0].id}")
```

---
Expand All @@ -52,77 +56,30 @@ pip install cortexadb
pip install cortexadb[docs,pdf] # Optional: For PDF/Docx support
```

**Rust**
```toml
[dependencies]
cortexadb-core = { git = "https://github.com/anaslimem/CortexaDB.git" }
```

---

### Core Capabilities

- **100x Faster Ingestion**: New batch insertion system allows processing 5,000+ chunks/second.
- **Hybrid Retrieval**: Search by semantic similarity (Vector), structural relationship (Graph), and time-based recency in a single query.
- **Ultra-Fast Indexing**: Uses **HNSW (USearch)** for sub-millisecond approximate nearest neighbor search with 95%+ recall.
- **Hard Durability**: A Write-Ahead Log (WAL) and segmented storage ensure zero data loss, even after a crash.
- **Smart Document Ingestion**: Built-in recursive, semantic, and markdown chunking for TXT, MD, PDF, and DOCX files.
- **Privacy First**: Completely local and embedded. Your agent's data never leaves its environment unless you want it to.
- **Deterministic Replay**: Capture session operations for debugging or syncing memory across different agents.
- **Ultra-Fast Indexing**: Uses **HNSW (USearch)** for sub-millisecond approximate nearest neighbor search.
- **Fluent API**: Chainable QueryBuilder for expressive searching and collection scoping.
- **Hard Durability**: WAL-backed storage ensures zero data loss.
- **Privacy First**: Completely local. Your agent's memory stays on your machine.

---

<details>
<summary><b>Technical Architecture & Benchmarks</b></summary>

### Rust Architecture Overview

```
┌──────────────────────────────────────────────────┐
│ Python API (PyO3 Bindings) │
│ CortexaDB, Namespace, Embedder, chunk(), etc. │
└────────────────────────┬─────────────────────────┘
┌────────────────────────▼─────────────────────────┐
│ CortexaDB Facade │
│ High-level API (remember, ask, etc.) │
└────────────────────────┬─────────────────────────┘
┌────────────────────────▼─────────────────────────┐
│ CortexaDBStore │
│ Concurrency coordinator & durability layer │
│ ┌────────────────┐ ┌────────────────────────┐ │
│ │ WriteState │ │ ReadSnapshot │ │
│ │ (Mutex) │ │ (ArcSwap, lock-free) │ │
│ └────────────────┘ └────────────────────────┘ │
└───────┬──────────────────┬───────────────┬───────┘
│ │ │
┌───────▼─────┐ ┌───────▼───────┐ ┌────▼───────────┐
│ Engine │ │ Segments │ │ Index Layer │
│ (WAL) │ │ (Storage) │ │ │
│ │ │ │ │ VectorIndex │
│ Command │ │ MemoryEntry │ │ HnswBackend │
│ recording │ │ persistence │ │ GraphIndex │
│ │ │ │ │ TemporalIndex │
│ Crash │ │ CRC32 │ │ │
│ recovery │ │ checksums │ │ HybridQuery │
└─────────────┘ └───────────────┘ └─────────────────┘
┌──────────▼──────────┐
│ State Machine │
│ (In-memory state) │
│ - Memory entries │
│ - Graph edges │
│ - Temporal index │
└─────────────────────┘
```

### Performance Benchmarks (v0.1.7)
Measured with 10,000 embeddings (384-dimensions) on a standard SSD.
Measured on M2 Mac with 1,000 chunks of text.

| Mode | Query (p50) | Throughput | Recall |
|------|-------------|-----------|--------|
| Exact (baseline) | 1.34ms | 690 QPS | 100% |
| HNSW | 0.29ms | 3,203 QPS | 95% |
| Operation | v0.1.6 (Sync) | v0.1.7 (Batch) | Improvement |
|-----------|---------------|----------------|-------------|
| Ingestion | 12.4s | **0.12s** | **103x Faster** |
| Memory Add| 15ms | 1ms | 15x Faster |
| HNSW Search| 0.3ms | 0.28ms | - |

</details>

Expand Down
32 changes: 32 additions & 0 deletions crates/cortexadb-core/src/facade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,15 @@ pub struct CortexaDB {
next_id: std::sync::atomic::AtomicU64,
}

/// A record for batch insertion.
#[derive(Debug, Clone)]
pub struct BatchRecord {
pub namespace: String,
pub content: Vec<u8>,
pub embedding: Option<Vec<f32>>,
pub metadata: Option<HashMap<String, String>>,
}

impl CortexaDB {
/// Open a CortexaDB database at the given path with a required vector dimension,
/// using standard safe defaults.
Expand Down Expand Up @@ -313,6 +322,29 @@ impl CortexaDB {
Ok(id.0)
}

/// Store a batch of memories efficiently.
pub fn remember_batch(&self, records: Vec<BatchRecord>) -> Result<Vec<u64>> {
let ts = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
let mut entries = Vec::with_capacity(records.len());
let mut ids = Vec::with_capacity(records.len());

for rec in records {
let id = MemoryId(self.next_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
let mut entry = MemoryEntry::new(id.clone(), rec.namespace, rec.content, ts);
if let Some(emb) = rec.embedding {
entry = entry.with_embedding(emb);
}
if let Some(meta) = rec.metadata {
entry.metadata = meta;
}
ids.push(id.0);
entries.push(entry);
}

self.inner.insert_memories_batch(entries)?;
Ok(ids)
}
Comment on lines +325 to +346
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remember_batch() returns the last command ID (last_cmd_id.0) rather than an inserted memory ID or list of IDs. This is inconsistent with remember*() methods that return the inserted memory ID, and it’s easy for callers to misinterpret (as the Rust example does). Consider returning the inserted IDs (or at least the last inserted memory ID), or rename/retag the return value/type to make it unambiguous (e.g., CommandId).

Copilot uses AI. Check for mistakes.

/// Query the database for the top-k most relevant memories.
///
/// The search uses cosine similarity on the vector embeddings and can optionally
Expand Down
16 changes: 8 additions & 8 deletions crates/cortexadb-core/src/index/vector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -554,18 +554,18 @@ impl VectorIndex {
// 3. Rebuild HNSW backend if enabled
if let Some(ref old_hnsw) = self.hnsw_backend {
let config = old_hnsw.config.clone();

// Create a fresh, clean HNSW backend
let new_hnsw = HnswBackend::new(self.vector_dimension, config)
.map_err(|_e| VectorError::NoEmbeddings)?;

// Re-insert all live embeddings into the fresh backend
for partition in self.partitions.values() {
for (id, embedding) in &partition.embeddings {
let _ = new_hnsw.add(*id, embedding);
}
}

// Swap out the bloated instance for the pristine one
self.hnsw_backend = Some(Arc::new(new_hnsw));
}
Expand Down Expand Up @@ -1022,23 +1022,23 @@ mod tests {
for i in 0..10 {
index.index(MemoryId(i), vec![i as f32, 0.0, 0.0]).unwrap();
}

// Remove 8 items (they become tombstones in HNSW)
for i in 2..10 {
index.remove(MemoryId(i)).unwrap();
}

assert_eq!(index.len(), 2);

// Compact it to rebuild the HNSW index
let compacted_count = index.compact().unwrap();
assert_eq!(compacted_count, 2);

// Ensure the items are still searchable via HNSW
let results = index.search(&[0.5, 0.0, 0.0], 2).unwrap();
assert_eq!(results.len(), 2);
let ids: Vec<u64> = results.iter().map(|r| r.0.0).collect();

let ids: Vec<u64> = results.iter().map(|r| r.0 .0).collect();
assert!(ids.contains(&0));
assert!(ids.contains(&1));
}
Expand Down
69 changes: 64 additions & 5 deletions crates/cortexadb-core/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ impl CortexaDBStore {
let (guard, _) = cvar
.wait_timeout(runtime, timeout)
.expect("sync runtime wait poisoned");
runtime = guard;
runtime = guard;
let timed_out = runtime
.dirty_since
.map(|d| d.elapsed() >= max_delay)
Expand Down Expand Up @@ -559,6 +559,65 @@ impl CortexaDBStore {
self.execute_write_transaction_locked(&mut writer, WriteOp::InsertMemory(effective))
}

pub fn insert_memories_batch(&self, entries: Vec<MemoryEntry>) -> Result<CommandId> {
let mut writer = self.writer.lock().expect("writer lock poisoned");
let sync_now = matches!(self.sync_policy, SyncPolicy::Strict);
let mut last_cmd_id = CommandId(0);

for entry in entries {
let mut effective = entry;
// Check for previous state to handle partial updates if necessary
if let Ok(prev) = writer.engine.get_state_machine().get_memory(effective.id) {
let content_changed = prev.content != effective.content;
if content_changed && effective.embedding.is_none() {
return Err(CortexaDBStoreError::MissingEmbeddingOnContentChange(effective.id));
}
Comment on lines +567 to +574
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

insert_memories_batch() may partially apply the batch: if an error occurs mid-loop (dimension mismatch, index error, engine error), earlier entries have already been written via execute_command_unsynced. If callers expect all-or-nothing semantics, pre-validate all entries before writing (or document/rename the method to make partial success explicit).

Copilot uses AI. Check for mistakes.
if !content_changed && effective.embedding.is_none() {
effective.embedding = prev.embedding.clone();
}
}

// Validate dimension
if let Some(embedding) = effective.embedding.as_ref() {
if embedding.len() != writer.indexes.vector.dimension() {
return Err(crate::index::vector::VectorError::DimensionMismatch {
expected: writer.indexes.vector.dimension(),
actual: embedding.len(),
}
.into());
}
}

// Execute unsynced for the whole batch
last_cmd_id =
writer.engine.execute_command_unsynced(Command::InsertMemory(effective.clone()))?;

// Update vector index
match effective.embedding {
Some(embedding) => {
writer.indexes.vector_index_mut().index_in_namespace(
&effective.namespace,
effective.id,
embedding,
)?;
}
None => {
let _ = writer.indexes.vector_index_mut().remove(effective.id);
Comment on lines +591 to +605
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The batch path clones the entire MemoryEntry (effective.clone()) just to pass it into Command::InsertMemory, which duplicates the content bytes for every entry and can significantly reduce the claimed ingestion speedup for large documents. Consider moving effective into the command and separately retaining only the small fields needed for indexing (id/namespace/embedding), or cloning only the embedding instead of the whole entry.

Suggested change
// Execute unsynced for the whole batch
last_cmd_id =
writer.engine.execute_command_unsynced(Command::InsertMemory(effective.clone()))?;
// Update vector index
match effective.embedding {
Some(embedding) => {
writer.indexes.vector_index_mut().index_in_namespace(
&effective.namespace,
effective.id,
embedding,
)?;
}
None => {
let _ = writer.indexes.vector_index_mut().remove(effective.id);
// Capture data needed for indexing before moving `effective` into the command
let id = effective.id;
let namespace = effective.namespace.clone();
let embedding_for_index = effective.embedding.clone();
// Execute unsynced for the whole batch
last_cmd_id =
writer.engine.execute_command_unsynced(Command::InsertMemory(effective))?;
// Update vector index
match embedding_for_index {
Some(embedding) => {
writer.indexes.vector_index_mut().index_in_namespace(
&namespace,
id,
embedding,
)?;
}
None => {
let _ = writer.indexes.vector_index_mut().remove(id);

Copilot uses AI. Check for mistakes.
}
}
}

// Single flush for the entire batch if in strict mode
if sync_now {
writer.engine.flush()?;
}

// Publish snapshot once after the batch
self.publish_snapshot_from_write_state(&writer);

Ok(last_cmd_id)
}

pub fn delete_memory(&self, id: MemoryId) -> Result<CommandId> {
let mut writer = self.writer.lock().expect("writer lock poisoned");
self.execute_write_transaction_locked(&mut writer, WriteOp::DeleteMemory(id))
Expand Down Expand Up @@ -1341,8 +1400,9 @@ mod tests {

// Add 5 items
for i in 0..5 {
let entry = MemoryEntry::new(MemoryId(i), "agent_x".to_string(), b"data".to_vec(), 1000)
.with_embedding(vec![1.0, 0.0, 0.0]);
let entry =
MemoryEntry::new(MemoryId(i), "agent_x".to_string(), b"data".to_vec(), 1000)
.with_embedding(vec![1.0, 0.0, 0.0]);
store.insert_memory(entry).unwrap();
}

Expand All @@ -1369,9 +1429,8 @@ mod tests {
.unwrap();
assert_eq!(search_results.len(), 2);

let ids: Vec<u64> = search_results.iter().map(|s| s.0.0).collect();
let ids: Vec<u64> = search_results.iter().map(|s| s.0 .0).collect();
assert!(ids.contains(&0));
assert!(ids.contains(&1));
}
}

Loading