Long-Term Memory with Qdrant

9 min read Module 4 of 10 Topic 12 of 30

What you'll learn

  • Explain why episodic memory beyond the context window is essential for enterprise agent continuity
  • Set up a Qdrant collection with payload indexes and an HNSW configuration tuned for agent memory search
  • Design a memory schema that separates episodic, semantic, and procedural memory types
  • Implement importance/recency scoring and async memory consolidation to keep retrieval relevant over time
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

Every enterprise agent operates with a fixed context window, typically 128K to 200K tokens: but enterprise relationships, workflows, and institutional knowledge accumulate over months and years. An agent that forgets everything between sessions cannot build trust, learn from past mistakes, or maintain continuity across a customer journey. This lesson builds a production-grade long-term memory system using Qdrant, giving your agent network episodic recall that persists indefinitely and retrieves selectively.

Why Context Windows Are Not Enough

The fundamental mismatch is between context window capacity (hundreds of thousands of tokens, costing money per request) and enterprise memory requirements (millions of past interactions spanning years). You cannot stuff all prior conversations into every prompt, the cost would be prohibitive and the noise would overwhelm the signal. What you need is semantic retrieval: given the current context, find the five most relevant past memories and inject only those.

flowchart TD
    S["New Session Start\ncurrent context embedding"] --> VS["Vector Search\ncosine similarity in Qdrant"]
    VS --> R["Top-5 Relevant Memories\nepisodic + semantic"]
    R --> P["Prompt Assembly\nsystem prompt + memories + user message"]
    P --> AG["Agent LLM Call"]
    AG --> RE["Agent Response"]
    RE --> MW["Async Memory Write\n(non-blocking)"]
    MW --> MS["Memory Store\nQdrant"]

    style S fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style VS fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style R fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style P fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style AG fill:#fff7ed,stroke:#f59e0b,color:#0F172A
    style RE fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style MW fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style MS fill:#f0fdf9,stroke:#0D9488,color:#0F172A

Qdrant Setup and HNSW Index

Create a collection to hold the three classical memory types: episodic (specific past events), semantic (generalized knowledge derived from episodes), and procedural (learned workflows and tool-use patterns). Each memory is stored as a Qdrant point: a vector plus a JSON payload carrying everything else.

from qdrant_client import AsyncQdrantClient
from qdrant_client.models import (
    Distance, VectorParams, HnswConfigDiff, PayloadSchemaType,
)

client = AsyncQdrantClient(url="http://localhost:6333")

await client.create_collection(
    collection_name="agent_memories",
    vectors_config=VectorParams(
        size=1536,              # text-embedding-3-small dimensions
        distance=Distance.COSINE,
    ),
    # HNSW is Qdrant's default index. m=16 / ef_construct=64 are solid
    # defaults up to ~10M vectors; tune m upward for larger datasets.
    hnsw_config=HnswConfigDiff(m=16, ef_construct=64),
)

# Payload indexes make agent_id / memory_type / created_at / importance
# filters fast, the same job Postgres partial indexes would do.
await client.create_payload_index("agent_memories", "agent_id", PayloadSchemaType.KEYWORD)
await client.create_payload_index("agent_memories", "memory_type", PayloadSchemaType.KEYWORD)
await client.create_payload_index("agent_memories", "created_at", PayloadSchemaType.DATETIME)
await client.create_payload_index("agent_memories", "importance", PayloadSchemaType.FLOAT)

# Each memory point's payload shape:
#   agent_id: str, session_id: str, memory_type: "episodic"|"semantic"|"procedural",
#   content: str, importance: float (0.0-1.0, set by the scoring pipeline),
#   access_count: int, created_at: ISO datetime, last_accessed_at: ISO datetime,
#   metadata: dict

Memory Retrieval with Importance and Recency Scoring

Raw cosine similarity ranks memories by semantic closeness but ignores whether the memory is important or recent. A composite score, similarity * importance * recency_decay, produces far more useful retrieval results. Recency decay follows an exponential curve: a memory from yesterday decays little; one from two years ago decays significantly unless it was accessed frequently (indicating it is repeatedly relevant).

import numpy as np
from datetime import datetime, timezone
from qdrant_client.models import Filter, FieldCondition, MatchValue

DECAY_RATE = 0.0002  # Controls how fast importance decays; tune per domain

def recency_score(created_at: datetime, last_accessed: datetime) -> float:
    """
    Exponential recency decay based on days since last access.
    Frequent access resets the clock: a memory that keeps being
    retrieved stays fresh even if it was first created years ago.
    """
    now = datetime.now(timezone.utc)
    days_since_access = (now - last_accessed).days
    # e^(-rate * days) gives 1.0 for brand-new memories, approaching 0 over time
    return float(np.exp(-DECAY_RATE * days_since_access))

async def retrieve_memories(
    client: AsyncQdrantClient,
    agent_id: str,
    query_embedding: list[float],
    top_k: int = 5,
    memory_type: str | None = None,
) -> list[dict]:
    """
    Retrieve the top-k most relevant memories using a composite score.
    query_points uses the HNSW index to get a candidate set; we re-rank
    in Python with the composite score. This is the standard two-stage
    retrieval pattern: fast approximate search, then precise re-rank.
    """
    must = [FieldCondition(key="agent_id", match=MatchValue(value=agent_id))]
    if memory_type:
        must.append(FieldCondition(key="memory_type", match=MatchValue(value=memory_type)))

    result = await client.query_points(
        collection_name="agent_memories",
        query=query_embedding,
        query_filter=Filter(must=must),
        limit=50,               # fetch 50 candidates, re-rank to top_k
        with_payload=True,
    )

    # Re-rank candidates with the composite importance/recency score.
    # point.score is the cosine similarity Qdrant already computed.
    scored = []
    for point in result.points:
        p = point.payload
        recency = recency_score(
            datetime.fromisoformat(p["created_at"]),
            datetime.fromisoformat(p["last_accessed_at"]),
        )
        composite = point.score * p["importance"] * recency
        scored.append({"id": point.id, **p, "composite_score": composite})

    scored.sort(key=lambda r: r["composite_score"], reverse=True)
    top = scored[:top_k]

    # Update access bookkeeping for retrieved memories (tracks usage for
    # decay). set_payload patches just these fields without re-uploading
    # the vector. last_accessed_at is identical across the batch, so it
    # goes in one call; access_count differs per point.
    now_iso = datetime.now(timezone.utc).isoformat()
    await client.set_payload(
        collection_name="agent_memories",
        payload={"last_accessed_at": now_iso},
        points=[r["id"] for r in top],
    )
    for r in top:
        await client.set_payload(
            collection_name="agent_memories",
            payload={"access_count": r["access_count"] + 1},
            points=[r["id"]],
        )

    return top

Async Memory Writes to Avoid Blocking Agents

Writing a memory, embedding generation plus an upsert, takes 200–500ms. If you do this synchronously after every agent response, you add that latency to every user-facing turn. Instead, fire-and-forget to a background task queue. The agent returns its response immediately; the memory write happens asynchronously.

sequenceDiagram
    participant U as User
    participant AG as Agent
    participant Q as Async Queue
    participant MW as Memory Writer
    participant DB as Qdrant

    U->>AG: Send message
    AG->>DB: Retrieve top-5 memories (fast read)
    AG->>U: Return response (fast path)
    AG->>Q: Enqueue memory write (non-blocking)
    Note over AG,U: User sees response immediately
    Q->>MW: Dequeue write task
    MW->>MW: Generate embedding
    MW->>DB: Upsert new memory point + score importance

    style U fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style AG fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style Q fill:#fff7ed,stroke:#f59e0b,color:#0F172A
    style MW fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style DB fill:#f0fdf9,stroke:#0D9488,color:#0F172A

Memory Consolidation Pipeline

Left unchecked, episodic memory grows without bound and retrieval quality degrades as low-signal memories crowd out important ones. Consolidation is the process of compressing many related episodic memories into a single semantic memory, analogous to how human long-term memory generalizes from specific experiences to abstract knowledge.

Run a nightly consolidation job per agent: cluster episodic memories from the past 30 days by semantic similarity (k-means on embeddings), then for each cluster with more than ten members and average importance below 0.4, summarize the cluster content using an LLM and store the summary as a single new point with memory_type: "semantic" and recalculated importance. Mark the source episodic memories with a consolidated: true payload field via set_payload, and exclude them from future retrieval with a filter condition. This keeps your active memory set lean and highly relevant without destroying history.

Importance scoring itself warrants explicit logic: assign high importance (0.8–1.0) to memories that contained explicit user corrections, novel information not seen in prior sessions, or decisions that changed agent behavior. Assign low importance (0.1–0.3) to routine confirmations, repeated queries, and small talk. A simple classifier, even a rules-based one, applied at write time is sufficient for most enterprise domains.

In the next lesson, we leave the memory layer and tackle enterprise API integration: connecting your agent network to Salesforce CRM, SAP ERP, and other enterprise platforms using typed clients, OAuth 2.0 credential management, and resilient retry logic.

Knowledge Check

3 questions to test your understanding

1 An agent finished a customer support session 3 weeks ago. A new session starts with the same customer. The agent needs relevant context but the prior session is long gone from the context window. What is the correct architectural solution?

2 Your memory store has accumulated 10 million episodic memories over 18 months. Retrieval latency is degrading. What is the most effective index type for approximate nearest-neighbor search at this scale?

3 An agent has 50,000 episodic memories. Most are old, low-importance interactions that clutter retrieval results. What pattern prevents stale memories from outranking recent relevant ones?

Discussion

Questions and notes from learners on this topic

Loading discussion…

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan