The Memory Problem: Building Persistent, Queryable Memory for Production AI Agents

By Brian Curry | Vector1 Research


“An agent without memory is not an agent. It is a very expensive stateless function.”


Abstract

The dominant approach to AI agent memory in 2026 is wrong — not wrong in intent, but wrong in architecture. Most production systems treat memory as a retrieval problem: embed interactions, store them in a vector database, retrieve semantically similar chunks at query time. This works well enough for simple factual recall but fails systematically on the problems that matter most in production: temporal reasoning across long horizons, multi-hop relationship traversal, memory consolidation and forgetting, cross-agent memory synchronization, and the management of contradictory or outdated information.

This article presents a complete production architecture for agentic memory that goes beyond retrieval. Drawing on cognitive science’s taxonomy of memory types — episodic, semantic, procedural, and working — and building on the Memory-Node Encapsulation (MNE) data structure introduced in prior Vector1 Research work, we design a four-tier memory system that handles persistence, temporal indexing, consolidation, multi-scope access, and decay. We implement it with a concrete technology stack — Redis, Neo4j, Qdrant, and PostgreSQL — and provide full production code for the components that matter most. The result is a memory architecture that transforms agents from sophisticated stateless functions into systems capable of genuine long-horizon reasoning, relationship awareness, and accumulated expertise.

Keywords: Agentic AI, Memory Architecture, Episodic Memory, Semantic Memory, Memory-Node Encapsulation, MNE, Knowledge Graphs, Vector Retrieval, Multi-Agent Systems, Production AI, LangChain, LangGraph


I. Why Memory Is Still Broken

The state of agentic memory in mid-2026 is best understood by what the benchmarks reveal. The LoCoMo benchmark — 1,540 questions covering single-hop, multi-hop, open-domain, and temporal recall — shows that current systems perform well on single-hop factual recall and poorly on almost everything else. The LongMemEval benchmark, which covers multi-session recall, knowledge updates, and temporal reasoning, reveals a consistent pattern: as conversation history grows, performance degrades faster than context grows. Most systems that score well on accuracy require 26,000+ tokens per query — not production-viable.

Perhaps most damning: a December 2025 benchmark study found that a plain filesystem storing memories as markdown files scored 74% on standard memory tasks — beating dedicated vector-store memory libraries. When a folder of text files outperforms purpose-built memory infrastructure, the infrastructure has a design problem.

The root cause is a category error. Most memory systems are built by ML engineers who think about memory as a retrieval problem. The relevant discipline is actually cognitive architecture — how biological systems store, consolidate, retrieve, and forget information over time. The engineering failure is treating all memory as semantically equivalent and retrieval as the only operation that matters.

Human memory is not a vector database. It has structure that vector similarity cannot capture:

Episodic memory is time-indexed and contextual — not just what happened but when, where, and what else was happening. “The client called angry about the Q3 report three weeks before the contract renewal” is an episodic memory. Its retrieval should be triggered by context (client, contract, report), not just semantic similarity to a query.

Semantic memory is structured, relational, and atemporal — facts and relationships that persist independently of when they were learned. “Client A is in the healthcare vertical, headquartered in Boston, and reports to an IT steering committee” is semantic memory. It lives in a knowledge graph, not a vector index.

Procedural memory is task-knowledge — how to accomplish specific types of work. “When a client escalates a billing issue, always involve the VP of Finance in the first response” is procedural memory. It is triggered by situation type, not content similarity.

Working memory is the active context window — what the agent is currently reasoning about. It is fast, bounded, and temporary.

These memory types require different storage backends, different retrieval strategies, different update mechanisms, and different forgetting curves. Building a production memory system means engineering all four — and the connections between them.


II. The MNE Foundation

Before the full architecture, the core data structure. Memory-Node Encapsulation (MNE), introduced in prior Vector1 Research work, provides the atomic unit on which the entire production memory system is built.

An MNE node encapsulates a memory as a self-contained unit with four components:

from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Optional
from enum import Enum
import uuid
import numpy as np

class MemoryType(Enum):
    EPISODIC   = "episodic"    # time-indexed events and interactions
    SEMANTIC   = "semantic"    # facts, relationships, domain knowledge
    PROCEDURAL = "procedural"  # task patterns and behavioral rules
    WORKING    = "working"     # active context, short-lived

class MemoryStatus(Enum):
    ACTIVE      = "active"
    CONSOLIDATED = "consolidated"  # moved from episodic → semantic
    DEPRECATED  = "deprecated"    # superseded by newer information
    ARCHIVED    = "archived"      # retained but low retrieval priority

@dataclass
class MNENode:
    """
    Memory-Node Encapsulation: the atomic unit of agentic memory.

    Each node represents a single memory — an event, a fact, a procedure,
    or a working context item — with full provenance, temporal indexing,
    relationship pointers, and a decay model.

    The four components:
    1. Content: the memory itself (text + structured metadata)
    2. Temporal: when it was created, last accessed, and how it decays
    3. Relational: connections to other nodes in the memory graph
    4. Epistemic: confidence, source quality, and contradiction flags
    """
    # Identity
    node_id: str = field(default_factory=lambda: str(uuid.uuid4()))
    memory_type: MemoryType = MemoryType.EPISODIC
    status: MemoryStatus = MemoryStatus.ACTIVE

    # Content
    content: str = ""                          # natural language representation
    structured: dict[str, Any] = field(default_factory=dict)
    embedding: Optional[np.ndarray] = None     # dense vector for similarity search

    # Temporal
    created_at: datetime = field(default_factory=datetime.utcnow)
    last_accessed: datetime = field(default_factory=datetime.utcnow)
    access_count: int = 0
    decay_rate: float = 0.01    # per day — episodic decays faster than semantic

    # Scope
    agent_id: Optional[str] = None    # which agent owns this memory
    session_id: Optional[str] = None  # which session created it
    user_id: Optional[str] = None     # which user it's associated with
    org_id: Optional[str] = None      # organizational scope

    # Relational
    parent_nodes: list[str] = field(default_factory=list)   # generalized from
    child_nodes: list[str] = field(default_factory=list)    # more specific than
    related_nodes: list[str] = field(default_factory=list)  # associated with

    # Epistemic
    confidence: float = 1.0          # 0-1 confidence in this memory
    source: str = ""                  # where this memory came from
    contradicts: list[str] = field(default_factory=list)  # conflicting node IDs

    def salience(self, current_time: datetime) -> float:
        """
        Computes current salience: how likely this memory is to be retrieved.
        Combines recency, access frequency, and confidence.

        Based on the Ebbinghaus forgetting curve, modified for digital systems:
        S(t) = confidence * access_boost * exp(-decay_rate * days_since_access)
        """
        days_since_access = (
            current_time - self.last_accessed
        ).total_seconds() / 86400

        # Access frequency boost (log scale — 10x accesses = 2x boost)
        access_boost = 1.0 + 0.5 * np.log1p(self.access_count)

        # Ebbinghaus-inspired decay
        recency_factor = np.exp(-self.decay_rate * days_since_access)

        return float(self.confidence * access_boost * recency_factor)

    def access(self) -> None:
        """Record an access — updates recency and count."""
        self.last_accessed = datetime.utcnow()
        self.access_count += 1

The salience() method is the core of the MNE design. Rather than treating all memories as equally retrievable, salience implements a computational forgetting curve — recent, frequently accessed, high-confidence memories are retrieved first. Old, rarely accessed memories fade unless periodically reinforced. This mirrors the neurological basis of human long-term memory retention and solves a real production problem: memory bloat. Without a forgetting mechanism, agent memory grows indefinitely and retrieval quality degrades.

The scope fields (agent_id, session_id, user_id, org_id) enable the multi-scope memory architecture that production systems require — the same memory infrastructure serving multiple agents, users, and organizational contexts simultaneously, with appropriate isolation.


III. The Four-Tier Production Architecture

The complete production architecture runs four storage tiers, each optimized for a specific memory type and access pattern:

┌─────────────────────────────────────────────────────────────────────┐
│  TIER 4: WORKING MEMORY                                             │
│  Redis (in-memory key-value)                                        │
│  Current session context · Active reasoning state · Tool results    │
│  TTL: session lifetime (minutes to hours)                           │
├─────────────────────────────────────────────────────────────────────┤
│  TIER 3: EPISODIC MEMORY                                            │
│  Qdrant (vector database) + PostgreSQL (temporal index)             │
│  Interaction history · Events · Observations                        │
│  TTL: weeks to months (salience-gated archival)                     │
├─────────────────────────────────────────────────────────────────────┤
│  TIER 2: SEMANTIC MEMORY                                            │
│  Neo4j (property graph)                                             │
│  Entities · Relationships · Facts · Domain Knowledge                │
│  TTL: indefinite (version-controlled updates)                       │
├─────────────────────────────────────────────────────────────────────┤
│  TIER 1: PROCEDURAL MEMORY                                          │
│  PostgreSQL (structured rules) + Qdrant (semantic matching)         │
│  Task patterns · Behavioral rules · Learned preferences            │
│  TTL: indefinite (reinforcement-updated)                            │
└─────────────────────────────────────────────────────────────────────┘

3.1 Technology Choices and Rationale

Redis for working memory — sub-millisecond access, TTL-native, supports complex data structures. The agent’s current reasoning state must be available in under 1ms; no other tier can meet this requirement.

Qdrant for episodic and procedural semantic search — the best production vector database for filtered similarity search. The must and should filter clauses allow scope-constrained retrieval (only memories from this user, only from the last 30 days) while maintaining vector similarity ranking.

PostgreSQL for temporal indexing and procedural rules — relational database with native timestamp indexing. Episodic memory is fundamentally a time-series problem; PostgreSQL’s partial indexes and timestamp range queries outperform vector databases for temporal access patterns.

Neo4j for semantic memory — property graphs are the correct data model for entity-relationship knowledge. The question “what do I know about Client A and everyone they’re connected to” is a graph traversal, not a vector similarity search. Neo4j’s Cypher query language handles multi-hop relationship traversal that would require multiple round-trips in a vector database.

3.2 The Memory Manager

The MemoryManager is the single interface through which agents interact with all four tiers:

import redis
import asyncpg
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchValue
from neo4j import AsyncGraphDatabase
from sentence_transformers import SentenceTransformer
from datetime import datetime, timedelta
from typing import Optional
import json
import numpy as np

class MemoryManager:
    """
    Single interface for all four memory tiers.

    Handles:
    - Write routing: which tier(s) receive a new memory
    - Read orchestration: querying multiple tiers and merging results
    - Consolidation: promoting episodic → semantic as patterns emerge
    - Decay: archiving low-salience memories on a background schedule
    - Scope enforcement: isolating memories by agent, user, session, org

    Example:
        manager = MemoryManager(config)
        await manager.remember(node)                    # write
        results = await manager.recall("client issue")  # read
        await manager.consolidate()                     # background job
    """

    EMBEDDING_DIM = 768

    def __init__(self, config: dict):
        # Working memory: Redis
        self.redis = redis.Redis(
            host=config['redis']['host'],
            port=config['redis']['port'],
            decode_responses=False
        )

        # Episodic memory: Qdrant + PostgreSQL
        self.qdrant = QdrantClient(
            host=config['qdrant']['host'],
            port=config['qdrant']['port']
        )
        self._ensure_collections()

        # Semantic memory: Neo4j
        self.neo4j = AsyncGraphDatabase.driver(
            config['neo4j']['uri'],
            auth=(config['neo4j']['user'], config['neo4j']['password'])
        )

        # Embedding model
        self.embedder = SentenceTransformer('all-mpnet-base-v2')

    # ── Write ──────────────────────────────────────────────────────────

    async def remember(self, node: MNENode) -> str:
        """
        Write a memory node to the appropriate tier(s).
        Routing logic:
        - WORKING  → Redis only (TTL = session)
        - EPISODIC → Qdrant (vector) + PostgreSQL (temporal)
        - SEMANTIC → Neo4j (graph)
        - PROCEDURAL → PostgreSQL (rules) + Qdrant (semantic match)
        """
        # Generate embedding if not already set
        if node.embedding is None:
            node.embedding = self.embedder.encode(node.content)

        if node.memory_type == MemoryType.WORKING:
            await self._write_working(node)

        elif node.memory_type == MemoryType.EPISODIC:
            await self._write_episodic(node)

        elif node.memory_type == MemoryType.SEMANTIC:
            await self._write_semantic(node)

        elif node.memory_type == MemoryType.PROCEDURAL:
            await self._write_procedural(node)

        return node.node_id

    async def _write_episodic(self, node: MNENode) -> None:
        """Write episodic node to Qdrant + PostgreSQL."""
        # Qdrant: vector search index
        self.qdrant.upsert(
            collection_name="episodic",
            points=[PointStruct(
                id=node.node_id,
                vector=node.embedding.tolist(),
                payload={
                    "content": node.content,
                    "agent_id": node.agent_id,
                    "user_id": node.user_id,
                    "session_id": node.session_id,
                    "created_at": node.created_at.isoformat(),
                    "confidence": node.confidence,
                    "memory_type": node.memory_type.value,
                    "structured": json.dumps(node.structured)
                }
            )]
        )

        # PostgreSQL: temporal index for time-range queries
        async with self.pg_pool.acquire() as conn:
            await conn.execute("""
                INSERT INTO episodic_memories
                    (node_id, agent_id, user_id, session_id, content,
                     created_at, last_accessed, access_count, confidence,
                     decay_rate, structured, status)
                VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
                ON CONFLICT (node_id) DO UPDATE SET
                    last_accessed = EXCLUDED.last_accessed,
                    access_count = episodic_memories.access_count + 1
            """,
                node.node_id, node.agent_id, node.user_id,
                node.session_id, node.content, node.created_at,
                node.last_accessed, node.access_count, node.confidence,
                node.decay_rate, json.dumps(node.structured),
                node.status.value
            )

    async def _write_semantic(self, node: MNENode) -> None:
        """Write semantic node to Neo4j knowledge graph."""
        entity_type = node.structured.get("entity_type", "Concept")
        properties = {
            "node_id": node.node_id,
            "content": node.content,
            "confidence": node.confidence,
            "source": node.source,
            "created_at": node.created_at.isoformat(),
            **{k: v for k, v in node.structured.items()
               if isinstance(v, (str, int, float, bool))}
        }

        async with self.neo4j.session() as session:
            # Merge entity node
            await session.run(f"""
                MERGE (n:{entity_type} {{node_id: $node_id}})
                SET n += $properties
            """, node_id=node.node_id, properties=properties)

            # Create relationships to related nodes
            for related_id in node.related_nodes:
                rel_type = node.structured.get("relationship_type", "RELATED_TO")
                await session.run(f"""
                    MATCH (a {{node_id: $source_id}})
                    MATCH (b {{node_id: $target_id}})
                    MERGE (a)-[r:{rel_type}]->(b)
                    SET r.confidence = $confidence
                        r.created_at = $created_at
                """,
                    source_id=node.node_id,
                    target_id=related_id,
                    confidence=node.confidence,
                    created_at=node.created_at.isoformat()
                )

    # ── Read ───────────────────────────────────────────────────────────

    async def recall(
        self,
        query: str,
        memory_types: list[MemoryType] | None = None,
        agent_id: str | None = None,
        user_id: str | None = None,
        session_id: str | None = None,
        time_range_days: int | None = None,
        top_k: int = 10,
        min_salience: float = 0.1
    ) -> list[MNENode]:
        """
        Retrieve memories relevant to a query, across all applicable tiers.

        The recall pipeline:
        1. Embed the query
        2. Query each relevant tier with scope filters
        3. Merge and deduplicate results
        4. Re-rank by composite score: salience × semantic_similarity
        5. Return top_k results, update access counts
        """
        query_embedding = self.embedder.encode(query)
        memory_types = memory_types or [
            MemoryType.EPISODIC,
            MemoryType.SEMANTIC,
            MemoryType.PROCEDURAL
        ]

        results = []

        if MemoryType.EPISODIC in memory_types:
            episodic = await self._recall_episodic(
                query_embedding, agent_id, user_id, session_id,
                time_range_days, top_k * 2
            )
            results.extend(episodic)

        if MemoryType.SEMANTIC in memory_types:
            semantic = await self._recall_semantic(
                query, agent_id, top_k
            )
            results.extend(semantic)

        if MemoryType.PROCEDURAL in memory_types:
            procedural = await self._recall_procedural(
                query_embedding, agent_id, top_k
            )
            results.extend(procedural)

        # Deduplicate and rank
        seen = set()
        unique_results = []
        for node in results:
            if node.node_id not in seen:
                seen.add(node.node_id)
                unique_results.append(node)

        # Composite ranking: salience × cosine similarity
        now = datetime.utcnow()
        scored = []
        for node in unique_results:
            if node.embedding is not None:
                sim = float(np.dot(query_embedding, node.embedding) /
                           (np.linalg.norm(query_embedding) *
                            np.linalg.norm(node.embedding) + 1e-8))
            else:
                sim = 0.5

            sal = node.salience(now)
            if sal >= min_salience:
                scored.append((node, sal * sim))

        scored.sort(key=lambda x: x[1], reverse=True)
        top_results = [node for node, _ in scored[:top_k]]

        # Update access counts
        for node in top_results:
            node.access()
            await self._update_access(node)

        return top_results

    async def _recall_episodic(
        self,
        query_embedding: np.ndarray,
        agent_id: str | None,
        user_id: str | None,
        session_id: str | None,
        time_range_days: int | None,
        limit: int
    ) -> list[MNENode]:
        """Retrieve episodic memories from Qdrant with scope + time filters."""
        must_conditions = []

        if agent_id:
            must_conditions.append(
                FieldCondition(key="agent_id", match=MatchValue(value=agent_id))
            )
        if user_id:
            must_conditions.append(
                FieldCondition(key="user_id", match=MatchValue(value=user_id))
            )

        # Time range filter (last N days)
        if time_range_days:
            cutoff = (datetime.utcnow() - timedelta(days=time_range_days)).isoformat()
            must_conditions.append(
                FieldCondition(key="created_at", range={"gte": cutoff})
            )

        search_filter = Filter(must=must_conditions) if must_conditions else None

        hits = self.qdrant.search(
            collection_name="episodic",
            query_vector=query_embedding.tolist(),
            query_filter=search_filter,
            limit=limit,
            with_payload=True
        )

        nodes = []
        for hit in hits:
            payload = hit.payload
            node = MNENode(
                node_id=str(hit.id),
                memory_type=MemoryType.EPISODIC,
                content=payload.get("content", ""),
                agent_id=payload.get("agent_id"),
                user_id=payload.get("user_id"),
                session_id=payload.get("session_id"),
                confidence=payload.get("confidence", 1.0),
                created_at=datetime.fromisoformat(
                    payload.get("created_at", datetime.utcnow().isoformat())
                ),
                structured=json.loads(payload.get("structured", "{}")),
            )
            node.embedding = query_embedding  # approximate — recompute if needed
            nodes.append(node)

        return nodes

    async def _recall_semantic(
        self,
        query: str,
        agent_id: str | None,
        limit: int
    ) -> list[MNENode]:
        """Retrieve semantic memories from Neo4j using full-text + relationship traversal."""
        async with self.neo4j.session() as session:
            result = await session.run("""
                CALL db.index.fulltext.queryNodes('memory_content', $query)
                YIELD node, score
                WHERE ($agent_id IS NULL OR node.agent_id = $agent_id)
                  AND node.confidence >= 0.5
                RETURN node, score
                ORDER BY score DESC
                LIMIT $limit
            """, query=query, agent_id=agent_id, limit=limit)

            nodes = []
            async for record in result:
                props = dict(record["node"])
                node = MNENode(
                    node_id=props.get("node_id", ""),
                    memory_type=MemoryType.SEMANTIC,
                    content=props.get("content", ""),
                    confidence=props.get("confidence", 1.0),
                    source=props.get("source", ""),
                    structured={k: v for k, v in props.items()
                                if k not in ["node_id", "content",
                                             "confidence", "source"]}
                )
                nodes.append(node)

        return nodes

IV. Memory Consolidation: Episodic → Semantic

The most important — and most neglected — operation in production memory systems is consolidation: the process by which episodic memories (raw interaction history) are promoted into semantic memories (structured knowledge). Without consolidation, episodic memory grows without bound and semantic memory never reflects what the agent has actually learned.

Biological consolidation happens during sleep. In production agents, it runs as a background job on a configurable schedule.

class MemoryConsolidator:
    """
    Promotes episodic memories into semantic knowledge through pattern detection.

    The consolidation pipeline:
    1. Cluster recent episodic memories by semantic similarity
    2. For each cluster above the density threshold, extract the common pattern
    3. Check if the pattern contradicts existing semantic memories
    4. If novel and consistent: create a new semantic MNE node
    5. If contradictory: flag both nodes for human review or update semantic
    6. Archive the consolidated episodic nodes (reduce salience, don't delete)

    Runs as a scheduled background task — typically nightly or hourly
    for high-volume agents.
    """

    def __init__(
        self,
        memory_manager: MemoryManager,
        llm_client,
        min_cluster_size: int = 3,
        similarity_threshold: float = 0.75
    ):
        self.mm = memory_manager
        self.llm = llm_client
        self.min_cluster_size = min_cluster_size
        self.sim_threshold = similarity_threshold

    async def consolidate(
        self,
        agent_id: str,
        lookback_days: int = 7
    ) -> dict:
        """
        Run the consolidation pipeline for a specific agent.
        Returns statistics on consolidation results.
        """
        # 1. Fetch recent episodic memories
        recent_episodic = await self._fetch_recent_episodic(
            agent_id, lookback_days
        )

        if len(recent_episodic) < self.min_cluster_size:
            return {"status": "skipped", "reason": "insufficient episodic memories"}

        # 2. Cluster by semantic similarity
        clusters = self._cluster_episodic(recent_episodic)

        consolidated_count = 0
        contradiction_count = 0

        for cluster in clusters:
            if len(cluster) < self.min_cluster_size:
                continue

            # 3. Extract semantic pattern from cluster
            pattern = await self._extract_pattern(cluster)

            if not pattern:
                continue

            # 4. Check for contradictions with existing semantic memory
            existing = await self.mm.recall(
                query=pattern['content'],
                memory_types=[MemoryType.SEMANTIC],
                agent_id=agent_id,
                top_k=3
            )

            contradiction = self._detect_contradiction(pattern, existing)

            if contradiction:
                # Flag for review rather than auto-updating
                await self._flag_contradiction(pattern, contradiction)
                contradiction_count += 1
            else:
                # Create new semantic memory node
                semantic_node = MNENode(
                    memory_type=MemoryType.SEMANTIC,
                    content=pattern['content'],
                    structured=pattern.get('structured', {}),
                    confidence=pattern.get('confidence', 0.8),
                    agent_id=agent_id,
                    source="consolidation",
                    parent_nodes=[n.node_id for n in cluster],
                    decay_rate=0.001  # semantic memory decays very slowly
                )
                await self.mm.remember(semantic_node)
                consolidated_count += 1

                # Archive source episodic nodes
                for episodic_node in cluster:
                    episodic_node.status = MemoryStatus.CONSOLIDATED
                    episodic_node.decay_rate *= 3.0  # accelerate decay post-consolidation
                    await self.mm._update_status(episodic_node)

        return {
            "status": "completed",
            "episodic_processed": len(recent_episodic),
            "clusters_found": len(clusters),
            "consolidated": consolidated_count,
            "contradictions_flagged": contradiction_count
        }

    async def _extract_pattern(self, cluster: list[MNENode]) -> dict | None:
        """
        Use LLM to extract the common semantic pattern from a cluster
        of episodic memories.
        """
        cluster_text = "\n\n".join([
            f"[{i+1}] {node.content}" for i, node in enumerate(cluster[:10])
        ])

        response = await self.llm.complete(f"""
        These are {len(cluster)} related interaction memories from an AI agent.
        Extract the core semantic pattern or fact they collectively establish.

        Memories:
        {cluster_text}

        Return JSON with:
        - "content": a single clear statement of the pattern/fact
        - "entity_type": the type of entity this is about (Person, Organization, Process, etc.)
        - "confidence": 0-1 confidence that this is a reliable pattern
        - "structured": key-value pairs of structured properties

        Return null if no clear pattern emerges.
        """)

        try:
            return json.loads(response.text)
        except (json.JSONDecodeError, AttributeError):
            return None

    def _cluster_episodic(
        self,
        nodes: list[MNENode]
    ) -> list[list[MNENode]]:
        """Cluster episodic nodes by embedding similarity."""
        if not nodes:
            return []

        embeddings = np.array([
            node.embedding for node in nodes
            if node.embedding is not None
        ])

        if len(embeddings) < 2:
            return [nodes]

        from sklearn.cluster import DBSCAN
        clustering = DBSCAN(
            eps=1 - self.sim_threshold,  # cosine distance threshold
            min_samples=self.min_cluster_size,
            metric='cosine'
        ).fit(embeddings)

        clusters = {}
        for i, label in enumerate(clustering.labels_):
            if label == -1:  # noise point
                continue
            clusters.setdefault(label, []).append(nodes[i])

        return list(clusters.values())

V. Multi-Agent Memory Synchronization

Production deployments rarely involve a single agent. A typical enterprise deployment has a coordinator agent, several specialist agents, and potentially user-facing agents — all needing access to a shared memory space with appropriate isolation.

The challenge is memory topology: which agents can read from which memory scopes, how writes propagate, and how conflicts between agents’ memories are resolved.

class MultiAgentMemoryBus:
    """
    Coordinates memory access across a multi-agent system.

    Memory visibility rules:
    - org_id scope:     visible to all agents in the organization
    - user_id scope:    visible to all agents serving this user
    - agent_id scope:   visible only to the specific agent
    - session_id scope: visible only within the current session

    Write propagation:
    - Agent writes to its own scope
    - Consolidation promotes to user or org scope based on generality
    - Coordinator agent can explicitly promote memories to wider scope
    """

    def __init__(self, memory_manager: MemoryManager):
        self.mm = memory_manager
        self._subscriptions: dict[str, list[callable]] = {}

    async def broadcast_memory(
        self,
        node: MNENode,
        target_scope: str,  # 'agent' | 'user' | 'org'
        source_agent_id: str
    ) -> None:
        """
        Broadcast a memory to a wider scope.
        Used when a specialist agent learns something that all agents should know.
        """
        broadcast_node = MNENode(
            memory_type=node.memory_type,
            content=node.content,
            structured=node.structured,
            confidence=node.confidence * 0.9,  # slight confidence reduction on broadcast
            source=f"broadcast_from:{source_agent_id}",
            parent_nodes=[node.node_id],
            decay_rate=node.decay_rate
        )

        if target_scope == 'org':
            broadcast_node.org_id = node.org_id
            broadcast_node.agent_id = None  # org-wide
        elif target_scope == 'user':
            broadcast_node.user_id = node.user_id
            broadcast_node.agent_id = None  # user-wide

        await self.mm.remember(broadcast_node)

        # Notify subscribed agents
        for callback in self._subscriptions.get(target_scope, []):
            await callback(broadcast_node)

    def subscribe(self, scope: str, callback: callable) -> None:
        """Subscribe an agent to memory broadcasts at a given scope."""
        self._subscriptions.setdefault(scope, []).append(callback)

    async def resolve_conflict(
        self,
        node_a: MNENode,
        node_b: MNENode,
        resolution_strategy: str = "confidence_weighted"
    ) -> MNENode:
        """
        Resolve conflicting memories from different agents.

        Strategies:
        - confidence_weighted: weight content by confidence scores
        - recency: prefer the more recent memory
        - authority: prefer the memory from the designated authoritative agent
        """
        if resolution_strategy == "confidence_weighted":
            if node_a.confidence >= node_b.confidence:
                winner = node_a
                loser = node_b
            else:
                winner = node_b
                loser = node_a

            # Mark loser as deprecated but don't delete
            loser.status = MemoryStatus.DEPRECATED
            loser.contradicts.append(winner.node_id)
            await self.mm._update_status(loser)

            return winner

        elif resolution_strategy == "recency":
            return node_a if node_a.created_at > node_b.created_at else node_b

        return node_a  # fallback

VI. The Production Memory Interface: Connecting to LangChain / LangGraph

The architecture above is framework-agnostic. Here is how to connect it to LangChain as a drop-in memory replacement:

from langchain.memory import BaseMemory
from langchain.schema import BaseMessage, HumanMessage, AIMessage
from typing import Dict, List, Any

class MNEMemory(BaseMemory):
    """
    LangChain-compatible memory class backed by the full MNE architecture.

    Replaces LangChain's built-in memory types with the four-tier
    MNE system. Drop-in replacement — existing LangChain chains and
    agents work without modification.

    Usage:
        memory = MNEMemory(
            memory_manager=manager,
            agent_id="sales_agent_001",
            user_id="user_abc",
            session_id="session_xyz"
        )
        chain = LLMChain(llm=llm, prompt=prompt, memory=memory)
    """

    memory_manager: Any  # MemoryManager instance
    agent_id: str
    user_id: str
    session_id: str
    memory_key: str = "chat_history"
    return_messages: bool = True

    class Config:
        arbitrary_types_allowed = True

    @property
    def memory_variables(self) -> List[str]:
        return [self.memory_key]

    def load_memory_variables(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
        """
        Load relevant memories for the current input.
        Called by LangChain before each LLM invocation.
        """
        import asyncio

        query = inputs.get("input", inputs.get("question", ""))

        # Run async recall in sync context
        loop = asyncio.get_event_loop()
        memories = loop.run_until_complete(
            self.memory_manager.recall(
                query=query,
                agent_id=self.agent_id,
                user_id=self.user_id,
                session_id=self.session_id,
                top_k=8
            )
        )

        # Format as LangChain messages
        messages = []
        for mem in memories:
            if mem.structured.get("role") == "human":
                messages.append(HumanMessage(content=mem.content))
            else:
                messages.append(AIMessage(content=mem.content))

        return {self.memory_key: messages}

    def save_context(
        self,
        inputs: Dict[str, Any],
        outputs: Dict[str, str]
    ) -> None:
        """
        Save the current interaction to episodic memory.
        Called by LangChain after each LLM invocation.
        """
        import asyncio

        human_input = inputs.get("input", inputs.get("question", ""))
        ai_output = outputs.get("output", outputs.get("response", ""))

        loop = asyncio.get_event_loop()

        # Save human turn
        human_node = MNENode(
            memory_type=MemoryType.EPISODIC,
            content=human_input,
            agent_id=self.agent_id,
            user_id=self.user_id,
            session_id=self.session_id,
            structured={"role": "human"},
            decay_rate=0.02
        )
        loop.run_until_complete(self.memory_manager.remember(human_node))

        # Save AI turn
        ai_node = MNENode(
            memory_type=MemoryType.EPISODIC,
            content=ai_output,
            agent_id=self.agent_id,
            user_id=self.user_id,
            session_id=self.session_id,
            structured={"role": "ai"},
            decay_rate=0.02,
            parent_nodes=[human_node.node_id]
        )
        loop.run_until_complete(self.memory_manager.remember(ai_node))

    def clear(self) -> None:
        """Clear working memory for the current session."""
        self.memory_manager.redis.delete(f"working:{self.session_id}")

VII. Production Considerations

7.1 Memory Drift and Contradiction Management

The most insidious production failure mode is memory drift: the agent’s semantic memory accumulates contradictory or outdated facts without a mechanism to detect or resolve them. An agent that knew a client’s budget was $50K last quarter but has never updated that fact will confidently provide wrong information.

The solution is temporal versioning on all semantic memory nodes. Every update creates a new node version; the previous version is deprecated but retained for audit purposes. Contradiction detection runs as part of the write pipeline, not as a background job:

async def write_with_contradiction_check(
    self,
    new_node: MNENode,
    contradiction_threshold: float = 0.85
) -> tuple[str, list[str]]:
    """
    Write a memory node, checking for contradictions first.
    Returns the node_id and a list of contradicting node_ids if found.
    """
    # Find semantically similar existing memories
    similar = await self.recall(
        query=new_node.content,
        memory_types=[new_node.memory_type],
        agent_id=new_node.agent_id,
        top_k=5,
        min_salience=0.0  # check all, even low-salience
    )

    contradictions = []
    for existing in similar:
        if existing.embedding is not None and new_node.embedding is not None:
            sim = float(np.dot(new_node.embedding, existing.embedding) /
                       (np.linalg.norm(new_node.embedding) *
                        np.linalg.norm(existing.embedding) + 1e-8))

            # High similarity but different structured content = likely contradiction
            if sim > contradiction_threshold:
                if self._content_contradicts(new_node, existing):
                    contradictions.append(existing.node_id)
                    existing.contradicts.append(new_node.node_id)
                    await self._update_status(existing)

    new_node.contradicts = contradictions
    node_id = await self.remember(new_node)
    return node_id, contradictions

7.2 Benchmarking Your Memory System

Before shipping a production memory system, validate against the three standard benchmarks:

  • LoCoMo (1,540 questions, single-hop, multi-hop, temporal): target >85% accuracy
  • LongMemEval (500 questions, knowledge updates, multi-session): target >75% on knowledge update category specifically
  • BEAM (1M and 10M token scale): measure latency and token consumption alongside accuracy

The production viability test: if your system requires >5,000 tokens per query at P95, it is not production-viable regardless of accuracy. Optimize for the accuracy/token tradeoff explicitly.

7.3 Deployment Architecture

# docker-compose.yml for local development
services:
  redis:
    image: redis:7-alpine
    ports: ["6379:6379"]
    command: redis-server --maxmemory 2gb --maxmemory-policy allkeys-lru

  qdrant:
    image: qdrant/qdrant:latest
    ports: ["6333:6333", "6334:6334"]
    volumes: ["./qdrant_storage:/qdrant/storage"]

  neo4j:
    image: neo4j:5-community
    ports: ["7474:7474", "7687:7687"]
    environment:
      NEO4J_AUTH: neo4j/canary_memory
      NEO4J_PLUGINS: '["apoc", "graph-data-science"]'
    volumes: ["./neo4j_data:/data"]

  postgres:
    image: postgres:16-alpine
    ports: ["5432:5432"]
    environment:
      POSTGRES_DB: agent_memory
      POSTGRES_USER: agent
      POSTGRES_PASSWORD: memory_pass
    volumes: ["./pg_data:/var/lib/postgresql/data"]

For production on Kubernetes, use managed services: Redis Cloud or Upstash for working memory, Qdrant Cloud for vector search, Neo4j AuraDB for the knowledge graph, and RDS PostgreSQL for temporal indexing.


VIII. Where MNE Goes Next

The architecture in this article represents the production layer of MNE. Several research directions extend it further:

Multimodal memory — extending MNENode to hold image, audio, and structured data embeddings alongside text. MemVerse (December 2025) demonstrated viable multimodal episodic memory; the MNE structure supports this extension natively through the structured dict.

Reinforcement-updated procedural memory — using agent outcome data to update the confidence weights of procedural memory nodes. Procedures that consistently produce good outcomes gain confidence; those associated with failures decay faster.

Cross-agent memory transfer — formalizing the protocol for agents to share memory graphs. The multi-agent bus described in Section V is a starting point; a full transfer protocol would include memory provenance attestation, conflict resolution across organizational boundaries, and privacy-preserving memory sharing.

Memory as fine-tuning signal — using high-salience, high-confidence MNE nodes as training signal for model fine-tuning. The memories an agent returns to most frequently represent the most important learned context; distilling that into model weights is a natural extension.


Getting Started

The MNE data structure and the MemoryManager described in this article are available as part of ongoing Vector1 Research open-source work:

# Coming soon
pip install mne-memory

The full architecture is currently in active development. Design discussions, issue tracking, and contribution opportunities are available at github.com/Bodhi8/mne.

For the original MNE specification and cognitive architecture foundations, see the Memory-Node Encapsulation paper on Medium.


References

  1. Tulving, E. (1972). Episodic and semantic memory. In E. Tulving & W. Donaldson (Eds.), Organization of Memory. Academic Press. [Foundational memory taxonomy]
  2. Chhikara, P. et al. (2025). Mem0: Building production-ready AI agents with scalable long-term memory. ECAI 2025. arXiv:2504.19413.
  3. Xu, X. et al. (2025). A-Mem: Agentic memory for LLM agents. arXiv preprint.
  4. Hu, Y. et al. (2025). A survey on the memory mechanism of large language model based agents. ACM TOIS.
  5. Kinniment, M. et al. (2024). Evaluating language-model agents on realistic autonomous tasks. NeurIPS 2024.
  6. Lewis, P. et al. (2020). Retrieval-augmented generation for knowledge-intensive NLP tasks. NeurIPS 2020. [RAG foundations]
  7. Jiang, D. et al. (2026). MAGMA: A multi-graph based agentic memory architecture. arXiv:2601.03236.
  8. Huo, Y. et al. (2026). AtomMem: Learnable dynamic agentic memory with atomic memory operation. arXiv:2601.08323.
  9. Vectorize.io. (2026, March). Best AI agent memory systems in 2026: 8 frameworks compared.
  10. Mem0.ai. (2026, May). State of AI agent memory 2026: Benchmarks, architectures, and production gaps.
  11. Ebbinghaus, H. (1885). Über das Gedächtnis [On Memory]. Duncker & Humblot. [Forgetting curve foundations]
  12. Curry, B. (2024). Memory-Node Encapsulation (MNE): A revolutionary data structure for artificial episodic memory. Vector1 Research / Medium.
  13. Curry, B. (2024–2026). Vector1 Research Series. Medium / vector1.ai.

About the Author

Brian Curry is a Kansas City–based AI researcher, data scientist, and founder of Vector1 Research, where he works at the intersection of cognitive AI architecture, production agent systems, and knowledge engineering. He is the creator of Memory-Node Encapsulation (MNE), Papilon, and MeaningFlow.

Connect: Medium · LinkedIn · vector1.ai · brian@vector1.ai

Related research