Skip to main content
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

FieldTypeDescription
idUUIDUnique identifier, auto-generated
user_idTEXTUser identifier for multi-tenant isolation
app_idTEXTApplication identifier for further namespacing (default: “default”)
contentTEXTThe actual memory content (stored as plain text)
embeddingVECTOR(1536)Vector representation of content for semantic search
metadataJSONBArbitrary JSON metadata (tags, source, timestamps, etc.)
created_atTIMESTAMPWhen the memory was first created
updated_atTIMESTAMPLast modification timestamp
expires_atTIMESTAMPOptional 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:
1
Insert into PostgreSQL
2
const [row] = await db.insert(memories).values({
  userId: input.userId,
  appId: input.appId ?? "default",
  content: input.content,
  metadata: input.metadata,
  expiresAt: input.expiresAt,
}).returning()
3
The database automatically:
4
  • Generates a UUID for id
  • Sets created_at and updated_at to current timestamp
  • Defaults app_id to “default” if not provided
  • 5
    Generate embedding
    6
    The content is embedded using your configured provider:
    7
    const vector = await embeddingProvider.embed(input.content)
    
    8
    For Ollama (core/embed/ollama.ts:7):
    9
    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
    
    10
    For OpenAI (core/embed/openai.ts:15):
    11
    const res = await this.client.embeddings.create({
      model: this.model,
      input: text
    })
    return res.data[0]!.embedding
    
    12
    Store in vector store
    13
    The embedding is stored in your configured vector store:
    14
    await vectorStore.upsert(row.id, vector)
    
    15
    pgvector implementation (core/vectors/providers/pgvector.ts:6):
    16
    await db.execute(sql`
      UPDATE memories
      SET embedding = ${JSON.stringify(vector)}::vector
      WHERE id = ${id}
    `)
    
    17
    Qdrant implementation (core/vectors/providers/qdrant.ts:25):
    18
    await this.client.upsert(COLLECTION, {
      points: [{ id, vector }]
    })
    
    19
    Create Neo4j node
    20
    Finally, a graph node is created:
    21
    await graphOps.addMemory(memory)
    
    22
    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

    Using Metadata

    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.

    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.

    Performance Tuning

    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:
    VACUUM ANALYZE memories;
    

    Index Maintenance

    Monitor index usage and rebuild if necessary:
    REINDEX TABLE memories;
    

    Next Steps

    Semantic Search

    Learn how embeddings enable intelligent retrieval

    Graph Traversal

    Navigate memory relationships with Neo4j

    Build docs developers (and LLMs) love