Skip to content

Commit d326763

Browse files
authored
Modernize Python API and Implement High-Performance Batch Ingestion (v0.1.7) (#16)
* Modernize Python API: add/search, Collection, QueryBuilder & 100x batch ingestion * Fix formatting * Fixed the tests fails
1 parent 8ed561f commit d326763

9 files changed

Lines changed: 587 additions & 1046 deletions

File tree

README.md

Lines changed: 22 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,21 @@ CortexaDB exists to provide a **middle ground**: a hard-durable, embedded memory
2727
from cortexadb import CortexaDB
2828
from cortexadb.providers.openai import OpenAIEmbedder
2929

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

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

38-
# 3. Query with semantic and graph intelligence
39-
hits = db.ask("What are the user's preferences?", use_graph=True)
40-
print(f"Top Hit: {hits[0].id} (Score: {hits[0].score})")
38+
# 3. Fluent Query Builder
39+
hits = db.query("What are the user's preferences?") \
40+
.limit(5) \
41+
.use_graph() \
42+
.execute()
43+
44+
print(f"Top Hit: {hits[0].id}")
4145
```
4246

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

55-
**Rust**
56-
```toml
57-
[dependencies]
58-
cortexadb-core = { git = "https://github.com/anaslimem/CortexaDB.git" }
59-
```
60-
6159
---
6260

6361
### Core Capabilities
6462

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

7270
---
7371

7472
<details>
7573
<summary><b>Technical Architecture & Benchmarks</b></summary>
7674

77-
### Rust Architecture Overview
78-
79-
```
80-
┌──────────────────────────────────────────────────┐
81-
│ Python API (PyO3 Bindings) │
82-
│ CortexaDB, Namespace, Embedder, chunk(), etc. │
83-
└────────────────────────┬─────────────────────────┘
84-
85-
┌────────────────────────▼─────────────────────────┐
86-
│ CortexaDB Facade │
87-
│ High-level API (remember, ask, etc.) │
88-
└────────────────────────┬─────────────────────────┘
89-
90-
┌────────────────────────▼─────────────────────────┐
91-
│ CortexaDBStore │
92-
│ Concurrency coordinator & durability layer │
93-
│ ┌────────────────┐ ┌────────────────────────┐ │
94-
│ │ WriteState │ │ ReadSnapshot │ │
95-
│ │ (Mutex) │ │ (ArcSwap, lock-free) │ │
96-
│ └────────────────┘ └────────────────────────┘ │
97-
└───────┬──────────────────┬───────────────┬───────┘
98-
│ │ │
99-
┌───────▼─────┐ ┌───────▼───────┐ ┌────▼───────────┐
100-
│ Engine │ │ Segments │ │ Index Layer │
101-
│ (WAL) │ │ (Storage) │ │ │
102-
│ │ │ │ │ VectorIndex │
103-
│ Command │ │ MemoryEntry │ │ HnswBackend │
104-
│ recording │ │ persistence │ │ GraphIndex │
105-
│ │ │ │ │ TemporalIndex │
106-
│ Crash │ │ CRC32 │ │ │
107-
│ recovery │ │ checksums │ │ HybridQuery │
108-
└─────────────┘ └───────────────┘ └─────────────────┘
109-
110-
┌──────────▼──────────┐
111-
│ State Machine │
112-
│ (In-memory state) │
113-
│ - Memory entries │
114-
│ - Graph edges │
115-
│ - Temporal index │
116-
└─────────────────────┘
117-
```
118-
11975
### Performance Benchmarks (v0.1.7)
120-
Measured with 10,000 embeddings (384-dimensions) on a standard SSD.
76+
Measured on M2 Mac with 1,000 chunks of text.
12177

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

12784
</details>
12885

crates/cortexadb-core/src/facade.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,15 @@ pub struct CortexaDB {
184184
next_id: std::sync::atomic::AtomicU64,
185185
}
186186

187+
/// A record for batch insertion.
188+
#[derive(Debug, Clone)]
189+
pub struct BatchRecord {
190+
pub namespace: String,
191+
pub content: Vec<u8>,
192+
pub embedding: Option<Vec<f32>>,
193+
pub metadata: Option<HashMap<String, String>>,
194+
}
195+
187196
impl CortexaDB {
188197
/// Open a CortexaDB database at the given path with a required vector dimension,
189198
/// using standard safe defaults.
@@ -313,6 +322,29 @@ impl CortexaDB {
313322
Ok(id.0)
314323
}
315324

325+
/// Store a batch of memories efficiently.
326+
pub fn remember_batch(&self, records: Vec<BatchRecord>) -> Result<Vec<u64>> {
327+
let ts = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
328+
let mut entries = Vec::with_capacity(records.len());
329+
let mut ids = Vec::with_capacity(records.len());
330+
331+
for rec in records {
332+
let id = MemoryId(self.next_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
333+
let mut entry = MemoryEntry::new(id.clone(), rec.namespace, rec.content, ts);
334+
if let Some(emb) = rec.embedding {
335+
entry = entry.with_embedding(emb);
336+
}
337+
if let Some(meta) = rec.metadata {
338+
entry.metadata = meta;
339+
}
340+
ids.push(id.0);
341+
entries.push(entry);
342+
}
343+
344+
self.inner.insert_memories_batch(entries)?;
345+
Ok(ids)
346+
}
347+
316348
/// Query the database for the top-k most relevant memories.
317349
///
318350
/// The search uses cosine similarity on the vector embeddings and can optionally

crates/cortexadb-core/src/index/vector.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -554,18 +554,18 @@ impl VectorIndex {
554554
// 3. Rebuild HNSW backend if enabled
555555
if let Some(ref old_hnsw) = self.hnsw_backend {
556556
let config = old_hnsw.config.clone();
557-
557+
558558
// Create a fresh, clean HNSW backend
559559
let new_hnsw = HnswBackend::new(self.vector_dimension, config)
560560
.map_err(|_e| VectorError::NoEmbeddings)?;
561-
561+
562562
// Re-insert all live embeddings into the fresh backend
563563
for partition in self.partitions.values() {
564564
for (id, embedding) in &partition.embeddings {
565565
let _ = new_hnsw.add(*id, embedding);
566566
}
567567
}
568-
568+
569569
// Swap out the bloated instance for the pristine one
570570
self.hnsw_backend = Some(Arc::new(new_hnsw));
571571
}
@@ -1022,23 +1022,23 @@ mod tests {
10221022
for i in 0..10 {
10231023
index.index(MemoryId(i), vec![i as f32, 0.0, 0.0]).unwrap();
10241024
}
1025-
1025+
10261026
// Remove 8 items (they become tombstones in HNSW)
10271027
for i in 2..10 {
10281028
index.remove(MemoryId(i)).unwrap();
10291029
}
10301030

10311031
assert_eq!(index.len(), 2);
1032-
1032+
10331033
// Compact it to rebuild the HNSW index
10341034
let compacted_count = index.compact().unwrap();
10351035
assert_eq!(compacted_count, 2);
1036-
1036+
10371037
// Ensure the items are still searchable via HNSW
10381038
let results = index.search(&[0.5, 0.0, 0.0], 2).unwrap();
10391039
assert_eq!(results.len(), 2);
1040-
1041-
let ids: Vec<u64> = results.iter().map(|r| r.0.0).collect();
1040+
1041+
let ids: Vec<u64> = results.iter().map(|r| r.0 .0).collect();
10421042
assert!(ids.contains(&0));
10431043
assert!(ids.contains(&1));
10441044
}

crates/cortexadb-core/src/store.rs

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,7 @@ impl CortexaDBStore {
388388
let (guard, _) = cvar
389389
.wait_timeout(runtime, timeout)
390390
.expect("sync runtime wait poisoned");
391-
runtime = guard;
391+
runtime = guard;
392392
let timed_out = runtime
393393
.dirty_since
394394
.map(|d| d.elapsed() >= max_delay)
@@ -559,6 +559,65 @@ impl CortexaDBStore {
559559
self.execute_write_transaction_locked(&mut writer, WriteOp::InsertMemory(effective))
560560
}
561561

562+
pub fn insert_memories_batch(&self, entries: Vec<MemoryEntry>) -> Result<CommandId> {
563+
let mut writer = self.writer.lock().expect("writer lock poisoned");
564+
let sync_now = matches!(self.sync_policy, SyncPolicy::Strict);
565+
let mut last_cmd_id = CommandId(0);
566+
567+
for entry in entries {
568+
let mut effective = entry;
569+
// Check for previous state to handle partial updates if necessary
570+
if let Ok(prev) = writer.engine.get_state_machine().get_memory(effective.id) {
571+
let content_changed = prev.content != effective.content;
572+
if content_changed && effective.embedding.is_none() {
573+
return Err(CortexaDBStoreError::MissingEmbeddingOnContentChange(effective.id));
574+
}
575+
if !content_changed && effective.embedding.is_none() {
576+
effective.embedding = prev.embedding.clone();
577+
}
578+
}
579+
580+
// Validate dimension
581+
if let Some(embedding) = effective.embedding.as_ref() {
582+
if embedding.len() != writer.indexes.vector.dimension() {
583+
return Err(crate::index::vector::VectorError::DimensionMismatch {
584+
expected: writer.indexes.vector.dimension(),
585+
actual: embedding.len(),
586+
}
587+
.into());
588+
}
589+
}
590+
591+
// Execute unsynced for the whole batch
592+
last_cmd_id =
593+
writer.engine.execute_command_unsynced(Command::InsertMemory(effective.clone()))?;
594+
595+
// Update vector index
596+
match effective.embedding {
597+
Some(embedding) => {
598+
writer.indexes.vector_index_mut().index_in_namespace(
599+
&effective.namespace,
600+
effective.id,
601+
embedding,
602+
)?;
603+
}
604+
None => {
605+
let _ = writer.indexes.vector_index_mut().remove(effective.id);
606+
}
607+
}
608+
}
609+
610+
// Single flush for the entire batch if in strict mode
611+
if sync_now {
612+
writer.engine.flush()?;
613+
}
614+
615+
// Publish snapshot once after the batch
616+
self.publish_snapshot_from_write_state(&writer);
617+
618+
Ok(last_cmd_id)
619+
}
620+
562621
pub fn delete_memory(&self, id: MemoryId) -> Result<CommandId> {
563622
let mut writer = self.writer.lock().expect("writer lock poisoned");
564623
self.execute_write_transaction_locked(&mut writer, WriteOp::DeleteMemory(id))
@@ -1341,8 +1400,9 @@ mod tests {
13411400

13421401
// Add 5 items
13431402
for i in 0..5 {
1344-
let entry = MemoryEntry::new(MemoryId(i), "agent_x".to_string(), b"data".to_vec(), 1000)
1345-
.with_embedding(vec![1.0, 0.0, 0.0]);
1403+
let entry =
1404+
MemoryEntry::new(MemoryId(i), "agent_x".to_string(), b"data".to_vec(), 1000)
1405+
.with_embedding(vec![1.0, 0.0, 0.0]);
13461406
store.insert_memory(entry).unwrap();
13471407
}
13481408

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

1372-
let ids: Vec<u64> = search_results.iter().map(|s| s.0.0).collect();
1432+
let ids: Vec<u64> = search_results.iter().map(|s| s.0 .0).collect();
13731433
assert!(ids.contains(&0));
13741434
assert!(ids.contains(&1));
13751435
}
13761436
}
1377-

0 commit comments

Comments
 (0)