memex
     

A production-grade persistent memory service for AI agents. Agents forget everything between sessions by default β memex fixes that. It stores, retrieves, and ranks conversation memory using semantic search with recency decay, so agents surface what's relevant and recent, not just what's semantically closest.
POST /v1/memories β store a memory, embed it, persist to Postgres
POST /v1/memories/search β retrieve top-k memories ranked by similarity + recency
DELETE /v1/memories/{id} β forget a specific memory
GET /v1/memories/count β how many memories does this agent/user have
GET /health β liveness + DB connectivity check
GET /metrics β Prometheus metrics
---
Architecture
caller (agent / app)
β
βΌ
FastAPI (async)
β
ββββββ΄βββββ
β β
embeddings asyncpg pool (min=5, max=20)
(fastembed β
ONNX, βΌ
local) PostgreSQL 16
pgvector extension
ivfflat index (cosine)
Write path: content β fastembed ONNX inference (local, ~12 ms CPU, BAAI/bge-small-en-v1.5) β INSERT with 384-dim vector β return memory ID.
Read path: query β embed β pgvector cosine search (top_k Γ 3 candidates) β re-rank with recency decay in Python β return top_k results with scores.
---
Design decisions
1. Recency decay on top of semantic search
Pure vector similarity returns the most semantically similar memories, not the most useful ones. A fact from 90 days ago that's a 0.95 similarity match is often less useful than a 0.80 match from yesterday.
Score formula:
score = Ξ± Γ cosine_similarity + (1 β Ξ±) Γ exp(βΞ» Γ age_days)
Where Ξ» = ln(2) / half_life_days (default: 30 days, so a 30-day-old memory has 50% recency weight).
Ξ± is configurable per request (default 0.7). Task-focused agents use higher Ξ± (semantic dominates). Conversational agents use lower Ξ± (recency matters more).
2. Fetch 3Γ candidates, re-rank in Python
The pgvector query returns top_k Γ 3 candidates sorted by pure similarity. Python re-ranks with the decay formula and slices to top_k. This prevents recency decay from starving high-similarity older memories β they're still in the candidate pool.
At 10Γ scale (>1M memories per agent): push the scoring into a Postgres function using pg_proc to eliminate the Python re-ranking round-trip.
3. asyncpg + explicit pool sizing over SQLAlchemy async
SQLAlchemy adds ORM overhead on every query. The hot retrieval path β embed, query, re-rank β needs to be tight. asyncpg gives direct control over pool min/max (same instinct as tuning HikariCP in Java). pgvector queries require raw SQL for the <=> operator anyway.
Pool defaults: min=5, max=20. Right-size for a single-instance deployment. Override via DB_MAX_POOL_SIZE env var.
4. Rate limiting in Postgres, not Redis
Sliding window counter via upsert. One fewer dependency. Correct under concurrent requests (transactional upsert). At 10Γ scale with distributed deployments: replace with Redis INCR + EXPIRE β atomic operations, no lock contention.
5. ivfflat index, not HNSW
ivfflat has lower build cost and lower memory footprint β the right tradeoff at small-to-medium scale (<1M vectors). lists=100 works well up to ~1M rows. At 10Γ scale: switch to HNSW (m=16, ef_construction=64) for better recall at the cost of higher memory and build time.
---
Running locally
Prerequisites: Docker and Docker Compose. No API keys required β the entire stack runs locally.
git clone https://github.com/ayushagrawal288/memex
cd memex
docker compose up
The API is live at http://localhost:8000. Interactive docs at http://localhost:8000/docs.
---
API reference
Store a memory
curl -X POST http://localhost:8000/v1/memories \
-H "Content-Type: application/json" \
-d '{
"agent_id": "my-agent",
"user_id": "user-123",
"content": "User prefers concise responses and dislikes verbose explanations.",
"memory_type": "semantic",
"importance": 1.2
}'
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"agent_id": "my-agent",
"user_id": "user-123",
"content": "User prefers concise responses and dislikes verbose explanations.",
"importance": 1.2,
"memory_type": "semantic",
"created_at": "2026-05-26T10:30:00Z",
"score": null
}
Search memories
curl -X POST http://localhost:8000/v1/memories/search \
-H "Content-Type: application/json" \
-d '{
"agent_id": "my-agent",
"user_id": "user-123",
"query": "how does this user like to communicate",
"top_k": 5,
"alpha": 0.7
}'
{
"results": [
{
"id": "3fa85f64-...",
"content": "User prefers concise responses and dislikes verbose explanations.",
"memory_type": "semantic",
"created_at": "2026-05-26T10:30:00Z",
"score": 0.8921
}
],
"query": "how does this user like to communicate",
"total": 1
}
Memory types
| Type | Use for | |---|---| | episodic | Specific events, past conversations | | semantic | Facts, preferences, general knowledge | | procedural | Workflows, how-to instructions |
---
Load test results
Run on a MacBook M-series, Docker Desktop, single Postgres instance:
locust -f scripts/load_test.py --host=http://localhost:8000 \
--headless -u 50 -r 10 -t 60s
Realistic load (50 users, 100β300 ms think time β models actual agent traffic):
| Endpoint | RPS | p50 (ms) | p95 (ms) | p99 (ms) | Error rate | |---|---|---|---|---|---| | POST /v1/memories (write) | 27 | 160 | 270 | 330 | 0% | | POST /v1/memories/search | 83 | 110 | 200 | 250 | 0% | | Aggregated | 113 | 120 | 230 | 300 | 0% |
Saturation test (500 users, minimal think time β finds the throughput ceiling):
| Endpoint | RPS (plateau) | p50 (ms) | p99 (ms) | Error rate | |---|---|---|---|---| | POST /v1/memories (write) | 28 | 3,900 | 6,100 | 0% | | POST /v1/memories/search | 91 | 3,600 | 5,800 | 0% | | Aggregated | ~120 | 3,700 | 5,900 | 0% |
Run on MacBook M-series, Docker Desktop (4 CPUs), 4 uvicorn workers, 16 threads/worker. Embeddings: local ONNX (
BAAI/bge-small-en-v1.5) β zero external API calls, zero cost.
Why the ceiling is ~120 RPS: Every write and every search requires one ONNX inference (~10β15 ms on CPU). With 4 Docker CPUs: 4 cores / 12 ms β 333 embeddings/s theoretical max. After Python overhead, DB queries, and asyncio scheduling: ~120 RPS actual.
Path to higher throughput:
| Approach | Expected gain | Complexity | |---|---|---| | Embedding cache (Redis, key = SHA256 of text) | 2β3Γ (40β60% hit rate on repeated agent queries) | Low | | Horizontal scaling (N replicas behind a load balancer) | NΓ linear | Medium | | GPU inference (swap ONNX runtime β CUDA) | 10β50Γ | Medium | | Voyage-3 API (offload to Anthropic's inference fleet) | Scales to thousands of RPS, limited by API quota | Low code change |
---
Project structure
memex/
βββ app/
β βββ main.py # REST API β FastAPI, lifespan, router registration
β βββ mcp_server.py # MCP server β single-worker FastAPI on port 8001
β βββ core/
β β βββ config.py # All settings, loaded from env
β βββ db/
β β βββ pool.py # asyncpg pool, migrations
β βββ models/
β β βββ schemas.py # Pydantic request/response models
β βββ services/
β β βββ embeddings.py # fastembed ONNX inference (local, zero API calls)
β β βββ local_summarizer.py # Extractive summariser β Jaccard dedup + TF scoring
β β βββ memory.py # Core write/search/scoring logic
β β βββ metrics.py # Prometheus metric definitions
β β βββ summarizer.py # Background summarisation job
β β βββ rate_limit.py # Sliding window rate limiter
β βββ api/routes/
β βββ memories.py # Memory endpoints
β βββ health.py # Health + readiness
β βββ mcp_tools.py # MCP tool definitions (store, search, delete, count)
βββ scripts/
β βββ load_test.py # Locust load test
βββ docker-compose.yml
βββ Dockerfile
βββ requirements.txt
---
Observability
docker compose up starts Prometheus and Grafana alongside the API:
| Service | URL | Credentials | |---|---|---| | REST API docs | http://localhost:8000/docs | β | | MCP server | http://localhost:8001/mcp/ | β | | Prometheus | http://localhost:9090 | β | | Grafana | http://localhost:3000 | admin / admin |
The Grafana dashboard is provisioned automatically. Panels:
- HTTP request rate + latency p50/p99 β from
prometheus-fastapi-instrumentator - Embedding API latency p50/p99 β per-attempt histogram by operation (
embed/embed_batch) - Memory operations/s β create, search, delete throughput
- DB pool utilisation β active vs idle connections (update interval: 15 s)
- Summariser activity β memories condensed per hour, run outcomes
- Embedding errors/min β by operation and error type
Custom metrics are in app/services/metrics.py and exposed on /metrics alongside the standard FastAPI instrumentator metrics.
---
MCP endpoint
memex exposes itself as an MCP server so any MCP-aware agent (Claude Desktop, Claude Code, custom agents) can store and retrieve memories without custom HTTP integration.
Transport: Streamable HTTP (MCP 2024-11-05 spec). Single-worker process on port 8001 β session state is in-process, so a separate service avoids sticky-session complexity while keeping the REST API's multi-worker throughput.
Tools:
| Tool | Description | |---|---| | store_memory | Embed + persist a memory (type, importance configurable) | | search_memories | Semantic + recency ranked retrieval with configurable alpha | | delete_memory | Forget a specific memory by UUID | | count_memories | How many memories an agent/user pair has |
Connect from Claude Desktop
Add to ~/.config/claude/claude_desktop_config.json:
{
"mcpServers": {
"memex": {
"type": "streamable-http",
"url": "http://localhost:8001/mcp/"
}
}
}
Connect from Claude Code
claude mcp add --transport http memex http://localhost:8001/mcp/
Design: why a separate service
The MCP Streamable HTTP transport is session-stateful β initialize, tools/list, and tools/call must all reach the same server process. The REST API runs 4 uvicorn workers with round-robin routing; routing different MCP requests to different workers breaks session state.
Running a dedicated single-worker MCP service on port 8001 avoids sticky-session infrastructure (nginx ip_hash, Redis session store) while keeping the REST API fully multi-worker.
---
Memory summarisation
Runs as a background asyncio task on a configurable interval (default: every 5 minutes). Finds any (agent_id, user_id) pair where episodic memory count exceeds a threshold, condenses the oldest batch into a single semantic memory, then deletes the originals. Fully local β no LLM API calls.
How it summarises: Pure Python extractive algorithm. Sentences are deduplicated by Jaccard similarity (β₯ 0.7 threshold), scored by word frequency (TF), and the top-N are returned in original order. ~1 ms per summarisation, zero dependencies beyond the standard library.
Why episodic-only: Episodic memories are conversation events with natural time-based obsolescence. Semantic and procedural memories encode facts and skills β silently condensing them risks precision loss; they age out via recency decay instead.
Concurrency safety: Uses pg_try_advisory_xact_lock keyed on hashtext(agent_id|user_id). The lock is held only during the DB write transaction, not during the embedding call.
Tune via env vars:
| Var | Default | Description | |---|---|---| | SUMMARIZATION_ENABLED | true | Toggle the background job | | SUMMARIZATION_THRESHOLD | 100 | Episodic count to trigger per pair | | SUMMARIZATION_BATCH_SIZE | 50 | Oldest N memories to condense per run | | SUMMARIZATION_INTERVAL_SECONDS | 300 | How often the job wakes up |
---
What's next
- [x] Memory summarisation β background job to condense old episodic memories (local extractive algorithm, zero API calls) when count exceeds threshold
- [x] Prometheus + Grafana β p50/p99 latency dashboards, embedding API call duration, pool saturation
- [x] MCP-compatible endpoint β Streamable HTTP server on port 8001; 4 tools (store, search, delete, count); connects to Claude Desktop and Claude Code
- [ ] HNSW index option β flag to switch from ivfflat to HNSW for deployments with >1M vectors
- [ ] Importance-weighted retrieval β factor
importancescore into ranking formula alongside similarity and recency
---
Tech stack
| Layer | Choice | Why | |---|---|---| | API | FastAPI + uvicorn | Async-first, fast, excellent OpenAPI generation | | Embeddings | fastembed ONNX (BAAI/bge-small-en-v1.5) | Local, zero API calls, ~12 ms CPU inference, 384-dim | | Database | PostgreSQL 16 + pgvector | Relational + vector in one system, no extra infra | | Vector index | ivfflat | Lower build cost than HNSW at this scale | | Pool | asyncpg | Direct control, zero ORM overhead | | Summariser | Pure Python extractive | Jaccard dedup + TF scoring, zero ML deps, ~1 ms | | Retry | tenacity | Jitter-based backoff on transient errors | | Metrics | Prometheus + prometheus-fastapi-instrumentator | Standard observability | | Load testing | Locust | Python-native, realistic user simulation |











