Azen uses PostgreSQL as the source of truth for all memories. This page covers the database schema, indexing strategy, and how memories are stored and retrieved.
Database Schema
The core memories table stores all memory data:
CREATE TABLE memories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL ,
app_id TEXT NOT NULL DEFAULT 'default' ,
content TEXT NOT NULL ,
embedding VECTOR ( 1536 ),
metadata JSONB,
created_at TIMESTAMP NOT NULL DEFAULT NOW (),
updated_at TIMESTAMP NOT NULL DEFAULT NOW (),
expires_at TIMESTAMP
);
Defined in core/db/schema.ts:4 using Drizzle ORM:
export const memories = pgTable ( "memories" , {
id: uuid ( "id" ). defaultRandom (). primaryKey (),
userId: text ( "user_id" ). notNull (),
appId: text ( "app_id" ). notNull (). default ( "default" ),
content: text ( "content" ). notNull (),
embedding: vector ( "embedding" , { dimensions: 1536 }),
metadata: jsonb ( "metadata" ),
createdAt: timestamp ( "created_at" ). defaultNow (). notNull (),
updatedAt: timestamp ( "updated_at" ). defaultNow (). notNull (),
expiresAt: timestamp ( "expires_at" ),
})
Field Descriptions
Field Type Description idUUID Unique identifier, auto-generated user_idTEXT User identifier for multi-tenant isolation app_idTEXT Application identifier for further namespacing (default: “default”) contentTEXT The actual memory content (stored as plain text) embeddingVECTOR(1536) Vector representation of content for semantic search metadataJSONB Arbitrary JSON metadata (tags, source, timestamps, etc.) created_atTIMESTAMP When the memory was first created updated_atTIMESTAMP Last modification timestamp expires_atTIMESTAMP Optional TTL for automatic expiry
Indexes
Azen creates three composite indexes to optimize common queries:
{
userIdIdx : index ( "memories_user_id_idx" ). on ( t . userId ),
appIdIdx : index ( "memories_app_id_idx" ). on ( t . appId ),
userAppIdx : index ( "memories_user_app_idx" ). on ( t . userId , t . appId ),
}
These indexes make the following queries fast:
Single user query SELECT * FROM memories
WHERE user_id = 'user_123'
Uses memories_user_id_idx
User + app query SELECT * FROM memories
WHERE user_id = 'user_123'
AND app_id = 'chatbot'
Uses memories_user_app_idx
The composite index (user_id, app_id) is critical for multi-tenant applications where each user may have memories across multiple apps.
Memory Lifecycle
Creating a Memory
When a memory is created via POST /memories, the flow is:
const [ row ] = await db . insert ( memories ). values ({
userId: input . userId ,
appId: input . appId ?? "default" ,
content: input . content ,
metadata: input . metadata ,
expiresAt: input . expiresAt ,
}). returning ()
The database automatically:
Generates a UUID for id
Sets created_at and updated_at to current timestamp
Defaults app_id to “default” if not provided
The content is embedded using your configured provider:
const vector = await embeddingProvider . embed ( input . content )
For Ollama (core/embed/ollama.ts:7):
const res = await fetch ( ` ${ this . baseUrl } /api/embeddings` , {
method: "POST" ,
headers: { "Content-Type" : "application/json" },
body: JSON . stringify ({ model: this . model , prompt: text })
})
const data = await res . json () as { embedding : number [] }
return data . embedding
For OpenAI (core/embed/openai.ts:15):
const res = await this . client . embeddings . create ({
model: this . model ,
input: text
})
return res . data [ 0 ] ! . embedding
The embedding is stored in your configured vector store:
await vectorStore . upsert ( row . id , vector )
pgvector implementation (core/vectors/providers/pgvector.ts:6):
await db . execute ( sql `
UPDATE memories
SET embedding = ${ JSON . stringify ( vector ) } ::vector
WHERE id = ${ id }
` )
Qdrant implementation (core/vectors/providers/qdrant.ts:25):
await this . client . upsert ( COLLECTION , {
points: [{ id , vector }]
})
Finally, a graph node is created:
await graphOps . addMemory ( memory )
See core/graph/operations.ts:5 for implementation.
Full code reference: core/memories/service.ts:23
Retrieving a Memory
Direct retrieval by ID is a simple SQL query:
async get ( id : string ): Promise < Memory | null > {
const [row] = await db.select().from(memories).where(eq(memories.id, id))
return row ? toMemory(row) : null
}
Location: core/memories/service.ts:42
Listing Memories
List all memories for a user + app combination:
async list ( userId : string , appId = "default" ): Promise < Memory [] > {
const rows = await db . select (). from ( memories ). where (
and ( eq ( memories . userId , userId ), eq ( memories . appId , appId ))
)
return rows.map(toMemory)
}
Location: core/memories/service.ts:47
This query uses the memories_user_app_idx index for fast retrieval.
Updating a Memory
Updates modify the Postgres record and re-embed if content changes:
async update ( id : string , input : UpdateMemoryInput ): Promise < Memory | null > {
const [row] = await db.update(memories)
.set({
...(input.content && { content: input . content }),
... ( input . metadata && { metadata: input . metadata }),
updatedAt : new Date (),
})
. where ( eq ( memories . id , id ))
. returning ()
if ( ! row ) return null
if ( input . content ) {
const vector = await embeddingProvider . embed ( input . content )
await vectorStore . upsert ( row . id , vector )
}
return toMemory ( row )
}
Location: core/memories/service.ts:54
Updating only metadata does not trigger re-embedding. Only content changes regenerate the embedding vector.
Deleting a Memory
Deletion removes the memory from all three stores:
async delete ( id : string ) : Promise < void > {
await db . delete ( memories ). where ( eq ( memories . id , id ))
await vectorStore . delete ( id )
await graphOps . deleteMemory ( id )
}
Location: core/memories/service.ts:74
Bulk Deletion
Delete all memories for a user + app:
async deleteByUser ( userId : string , appId = "default" ): Promise < void > {
await db.delete(memories).where(
and ( eq (memories.userId, userId ), eq (memories.appId, appId ))
)
await vectorStore. deleteByUser ( userId , appId )
await graphOps. deleteByUser ( userId , appId )
}
Location: core/memories/service.ts:80
The metadata field is a JSONB column that accepts any valid JSON:
{
"userId" : "user_123" ,
"appId" : "chatbot" ,
"content" : "User prefers dark mode" ,
"metadata" : {
"source" : "settings" ,
"category" : "preferences" ,
"tags" : [ "ui" , "theme" ],
"confidence" : 0.95 ,
"detectedAt" : "2026-03-05T10:00:00Z"
}
}
Use metadata for filtering, categorization, and auditing. While Azen doesn’t currently support querying by metadata fields, you can add custom Drizzle queries in your own code.
TTL and Expiry
Set an expiration timestamp to automatically expire memories:
await MemoryService . add ({
userId: "user_123" ,
appId: "session" ,
content: "Temporary session data" ,
expiresAt: new Date ( Date . now () + 24 * 60 * 60 * 1000 ) // 24 hours
})
Important : Azen does not currently include an automatic cleanup job. Expired memories remain in the database until explicitly deleted. You must implement your own cron job or background task to clean up expired memories.
Example cleanup query:
DELETE FROM memories WHERE expires_at < NOW ();
Multi-Tenancy
Azen supports multi-tenant applications through the userId and appId fields:
Use the default appId: await MemoryService . add ({
userId: "alice" ,
content: "Alice's memory"
})
await MemoryService . add ({
userId: "bob" ,
content: "Bob's memory"
})
Memories are isolated by userId automatically. Use different appId values: await MemoryService . add ({
userId: "alice" ,
appId: "chatbot" ,
content: "Chatbot memory"
})
await MemoryService . add ({
userId: "alice" ,
appId: "email-assistant" ,
content: "Email assistant memory"
})
This allows Alice to have separate memory contexts for different applications. Use userId as an organization ID: await MemoryService . add ({
userId: "org_acme" ,
appId: "knowledge-base" ,
content: "Company-wide knowledge"
})
All users in the organization query with userId: "org_acme".
Database Migrations
Azen uses Drizzle ORM for migrations. To create a new migration:
bun run --filter ./core db:generate
To apply migrations:
bun run --filter ./core db:migrate
Migrations are automatically run on server startup (server/src/index.ts:9):
await runMigrations ()
await initGraph ()
Backup and Recovery
Since PostgreSQL is the source of truth, back it up regularly:
# Backup
pg_dump -h localhost -U postgres azen_dev > backup.sql
# Restore
psql -h localhost -U postgres azen_dev < backup.sql
After restoring from backup, you must rebuild the vector store and Neo4j graph. Future versions will include utilities for this.
Connection Pooling
For production, configure connection pooling in DATABASE_URL:
DATABASE_URL = postgresql://user:pass@host:5432/db? pool_timeout = 10 & max_connections = 20
Vacuum and Analyze
Regularly vacuum the memories table to maintain performance:
Index Maintenance
Monitor index usage and rebuild if necessary:
Next Steps
Semantic Search Learn how embeddings enable intelligent retrieval
Graph Traversal Navigate memory relationships with Neo4j