Private AI Memory: Scalable System Built for Group Chats

One of the most requested features in AI chat is “remember me.” Users want their AI to know their preferences, their projects, their context – without re-explaining everything each conversation. But building memory that’s useful without being creepy, fast without being shallow, and private when it needs to be? That’s the hard part.

Here’s how we did it.

Starting Point: The Adaptive AI Memory Filter

We started with the great work of @alexgrama7 and Adaptive Memory an open webui filter that hooks into the request/response lifecycle. The filter implements two core methods:

async def inlet(
    self,
    body: Dict[str, Any],
    __event_emitter__: Optional[Callable[[Any], Awaitable[None]]] = None,
    __user__: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    """Pre-process: retrieve and inject relevant memories before LLM sees the message"""

async def outlet(
    self,
    body: dict,
    __event_emitter__: Optional[Callable[[Any], Awaitable[None]]] = None,
    __user__: Optional[dict] = None,
) -> dict:
    """Post-process: extract new memories from the conversation"""

The flow:

User Message → inlet() → Retrieve Memories → Inject Context → LLM → outlet() → Extract & Store

From there we wen and updated various parts to work within our collaborative context.

Memory Extraction

The extraction happens via a structured LLM call (GPT-4o-mini via OpenRouter). We send the conversation context and get back structured JSON:

memory_schema = {
    "type": "object",
    "properties": {
        "operations": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "operation": {"type": "string", "enum": ["NEW", "UPDATE", "DELETE"]},
                    "content": {"type": "string"},
                    "tags": {"type": "array", "items": {"type": "string"}},
                    "memory_bank": {"type": "string", "enum": ["Personal", "Interaction Patterns", "Project Memory"]},
                    "id": {"type": "string", "nullable": True}
                }
            }
        }
    }
}

Sample Categories we extract:

  • identity: Name, role, background
  • preference: Likes coffee, prefers bullet points
  • goal: Learning Python, building a SaaS
  • relationship: Has a sister named Jane, works with Bob
  • behavior: Works late, likes detailed explanations
  • possession: Has a MacBook Pro, uses VS Code

Storage

Memories are stored in two places:

  1. PostgreSQL – The source of truth with metadata
class Memory(Base):
    __tablename__ = "memory"

    id = Column(String, primary_key=True)          # UUID
    user_id = Column(String)                        # Owner
    folder_id = Column(String, nullable=True)       # Project scope
    content = Column(Text)                          # "[Tags: preference] User likes coffee"
    updated_at = Column(BigInteger)                 # Epoch timestamp
    created_at = Column(BigInteger)
  1. Vector DB (pgvector) – For similarity search
# Collection naming enforces isolation
user_collection = f"user-memory-{user_id}"
folder_collection = f"folder-memory-{folder_id}"

# Embedding model: all-MiniLM-L6-v2 (384 dimensions)
VECTOR_DB_CLIENT.insert(
    collection_name=user_collection,
    items=[{"id": memory_id, "vector": embedding, "metadata": {...}}]
)

Problem 1: LLM Call Latency

Vector search is fast (~10ms). LLM relevance scoring is not (~300-500ms).

Our v1 hit the LLM for every memory retrieval to score relevance. That’s unacceptable for a chat interface.

Solution: Tiered Relevance with Smart Skipping

async def get_relevant_memories(self, message: str, user_id: str) -> List[Dict]:
    # Step 1: Fast vector search
    query_embedding = await self._embed(message)
    candidates = VECTOR_DB_CLIENT.search(
        collection_name=f"user-memory-{user_id}",
        vectors=[query_embedding],
        limit=self.valves.reranking_top_k  # default: 50
    )

    # Step 2: Convert distances to similarity scores
    scored = []
    for candidate in candidates:
        similarity = 1.0 - (candidate.distance / 2.0)  # Cosine distance → similarity

        # HIGH CONFIDENCE: Skip LLM entirely
        if similarity > self.valves.llm_skip_relevance_threshold:  # default: 0.93
            scored.append((candidate, similarity))
            continue

        # MEDIUM CONFIDENCE: Optionally use LLM
        if self.valves.use_llm_for_relevance and similarity > self.valves.vector_similarity_threshold:
            llm_score = await self._llm_relevance_score(message, candidate.content)
            scored.append((candidate, llm_score))
        elif similarity > self.valves.vector_similarity_threshold:  # default: 0.7
            scored.append((candidate, similarity))

    # Step 3: Apply time decay
    return self._apply_time_decay(scored)

Result: 70% latency reduction for most messages while preserving accuracy where it matters.

Time Decay Scoring

Recent memories get boosted, old ones dampened:

def _apply_time_decay_score(self, semantic_score: float, memory: Dict) -> float:
    age_days = (time.time() - memory["updated_at"]) / 86400
    decay_factor = math.exp(-age_days / self.valves.time_decay_half_life_days)  # default: 60

    # Blend: 80% semantic, 20% recency
    weight = self.valves.recency_weight  # default: 0.2
    return (1 - weight) * semantic_score + weight * decay_factor

Problem 2: AI Memory Bloat

Active users generate 500+ memories within a month. Issues:

  • Retrieval noise dilutes signal
  • Can’t inject 500 memories into context

Solution: Background Summarization

We took the inspiration from Claude where memory is always just a summary that gets injected into the system prompt. We created a task that runs every 24 hours:

async def _summarize_old_memories_loop(self):
    while True:
        await asyncio.sleep(self.valves.summarization_interval)  # 86400 sec

        for user_id in self._get_users_above_threshold():
            # 1. Find clusters of related memories
            clusters = await self._find_memory_clusters(
                memories=self._get_old_memories(user_id, min_age_days=7),
                strategy=self.valves.summarization_strategy  # "embeddings", "tags", or "hybrid"
            )

            # 2. Summarize each cluster
            for cluster in clusters:
                if len(cluster) >= self.valves.summarization_min_cluster_size:  # default: 3
                    summary = await self._summarize_cluster(cluster)

                    # 3. Replace originals with summary
                    await self._store_memory(summary)
                    for mem in cluster:
                        await self._delete_memory(mem.id)

Clustering strategies:

  • embeddings: Greedy clustering by cosine similarity (most accurate)
  • tags: Group by extracted tags (fast)
  • hybrid: Combine both

Limit-Triggered Summarization

When approaching the limit (default: 200 memories), auto-trigger:

current_count = await self._get_memory_count(user_id)
threshold = self.valves.max_total_memories * self.valves.summarization_trigger_threshold  # 0.8

if current_count >= threshold:
    await self._trigger_emergency_summarization(
        user_id,
        target_reduction=self.valves.summarization_target_reduction  # 0.2 = free 40 slots
    )

Problem 3: Group Chat Privacy

The hard question: What happens when Alice, Bob, and Carol share a group chat?

If Alice mentions “I prefer bullet points,” should it be stored? To whom? Should Bob see Alice’s memories?

Solution: Privacy-First + Memory Banks

Rule 1: Personal memories are NEVER extracted from collaborative chats.

async def outlet(self, body: dict, __user__: dict) -> dict:
    # Check collaborative status
    is_collaborative = await self._is_collaborative_chat(body)

    if is_collaborative:
        logger.info("🤝 Collaborative chat detected - skipping memory extraction for privacy")
        # Still process but filter allowed banks

async def _is_collaborative_chat(self, body: Dict) -> bool:
    chat_id = body.get("metadata", {}).get("chat_id")
    if not chat_id or chat_id.startswith("local:"):
        return False
    chat = Chats.get_chat_by_id(chat_id)
    return chat.is_collaborative if chat else False

Rule 2: Memory Banks define access levels.

BankScopeCollaborative AccessUse Case
Personaluser_id onlyNEVER injected“User is named Alice”
Interaction Patternsuser_idAllowed“Prefers concise responses”
Project Memoryfolder_idAll folder members“Project uses React + TypeScript”
def get_collaborative_allowed_banks(self) -> Set[str]:
    """Banks that can be injected in collaborative contexts"""
    return {"Interaction Patterns", "Project Memory"}

# In memory extraction:
if is_collaborative:
    allowed_banks = self.get_collaborative_allowed_banks()
    filtered_memories = [
        m for m in extracted_memories
        if m.get("memory_bank") in allowed_banks
    ]

Vector Store Isolation

We use the documents_chunk table to manage embeddings and give each memory a collection_name that we can look for. Collections enforce isolation at the storage level:

async def get_relevant_memories(self, message: str, user_id: str, folder_id: str = None, is_collaborative: bool = False):
    results = []

    # Always query user's Interaction Patterns (safe for collaborative)
    if not is_collaborative:
        # Full personal memory access
        user_results = VECTOR_DB_CLIENT.search(
            collection_name=f"user-memory-{user_id}",
            vectors=[query_embedding],
            limit=search_limit
        )
        results.extend(user_results)
    else:
        # Filter to allowed banks only
        user_results = VECTOR_DB_CLIENT.search(
            collection_name=f"user-memory-{user_id}",
            vectors=[query_embedding],
            limit=search_limit,
            filter={"memory_bank": {"$in": ["Interaction Patterns"]}}
        )
        results.extend(user_results)

    # Query project memories if in a folder
    if folder_id:
        folder_results = VECTOR_DB_CLIENT.search(
            collection_name=f"folder-memory-{folder_id}",
            vectors=[query_embedding],
            limit=search_limit
        )
        for r in folder_results:
            r["_source"] = "project"  # Label for UI
        results.extend(folder_results)

    return self._deduplicate_and_rank(results)

Optimization: Core Memories (Fast Path)

Even optimized vector search adds latency. For users with 100+ AI memories, we wanted a near-instant path.

Core Memories: A pre-computed 200-word summary generated on login.

# core_memories.py
SUMMARY_PROMPT = """Create a brief user profile based ONLY on the memories below.

Write 1-2 concise paragraphs covering:
- Who they are (use their account name; only include role/location if EXPLICITLY stated)
- How they communicate and work (preferences, style)
- What they're focused on (current projects, goals, interests)

Guidelines:
- Write in third person ("The user...", "They...")
- ONLY include information EXPLICITLY stated in the memories
- Keep under 200 words
- NEVER invent details not present in the memories
"""

async def generate_core_memories_summary(user_id: str) -> Optional[str]:
    memories = Memories.get_memories_by_user_id(user_id)

    # Guard against hallucination with sparse data
    if len(memories) < MIN_MEMORIES_FOR_SUMMARY:  # 5
        return None

    # Take most recent N memories
    sorted_memories = sorted(memories, key=lambda m: m.updated_at, reverse=True)
    selected = sorted_memories[:MAX_MEMORIES_FOR_SUMMARY]  # 50

    # Generate via LLM
    summary = await _call_llm_for_summary(
        SUMMARY_PROMPT.format(
            user_name=user.name,
            formatted_memories=format_memories(selected)
        )
    )
    return summary

async def schedule_core_memories_update(user_id: str):
    """Called on login - fire-and-forget background task"""
    summary = await generate_core_memories_summary(user_id)
    if summary:
        user = Users.get_user_by_id(user_id)
        info = user.info or {}
        info["core_memories"] = summary
        info["core_memories_updated_at"] = int(time.time())
        Users.update_user_by_id(user_id, {"info": info})

Injection: Core memories go into every prompt without any vector search:

def _inject_core_memories(self, body: Dict, core_memories: str):
    system_msg = self._get_or_create_system_message(body)
    system_msg["content"] = f"""<userMemories>
{core_memories}
</userMemories>

{system_msg["content"]}"""

Example output:

Alex is a senior software engineer working on developer tools. They prefer concise, technical responses with code examples over lengthy explanations. Currently focused on building an AI coding assistant, they often work late and appreciate responses that account for their time zone (PST). They’re learning Rust and have strong opinions about TypeScript.

Deduplication Pipeline

New memories go through multi-stage deduplication:

async def _deduplicate_memory(self, new_memory: str, user_id: str) -> bool:
    """Returns True if memory is a duplicate and should be skipped"""

    existing = await self._get_user_memories(user_id)

    for existing_mem in existing:
        # Stage 1: Fast text similarity
        text_sim = SequenceMatcher(None, new_memory, existing_mem.content).ratio()
        if text_sim > 0.95:
            return True  # Near-exact duplicate

        # Stage 2: Semantic similarity (if enabled)
        if self.valves.use_embeddings_for_deduplication:
            new_emb = await self._embed(new_memory)
            existing_emb = self._get_cached_embedding(existing_mem.id)

            cosine_sim = np.dot(new_emb, existing_emb)
            if cosine_sim > self.valves.embedding_similarity_threshold:  # 0.97
                return True  # Semantic duplicate

    return False

Caching Strategy

Three LRU caches prevent unbounded memory growth:

class AdaptiveMemoryFilter:
    def __init__(self):
        self._memory_embeddings = LRUCache(maxsize=5000)      # memory_id → vector
        self._relevance_cache = LRUCache(maxsize=10000)       # (query_emb, mem_emb) → score
        self._memory_to_relevance_keys = LRUCache(maxsize=5000)  # Reverse index for invalidation

When a memory is updated or deleted, we invalidate its cache entries:

def _invalidate_memory_caches(self, memory_id: str):
    # Remove embedding
    self._memory_embeddings.pop(memory_id, None)

    # Remove relevance scores that used this memory
    keys_to_remove = self._memory_to_relevance_keys.get(memory_id, set())
    for key in keys_to_remove:
        self._relevance_cache.pop(key, None)

The Architecture Today

┌─────────────────────────────────────────────────────────────────┐
│                         Chat Request                            │
│              body.metadata.chat_id, __user__                    │
└─────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                  inlet() - Pre-processing                       │
│  1. Check if collaborative chat                                 │
│  2. Determine allowed memory banks                              │
└─────────────────────────────────────────────────────────────────┘
                                │
              ┌─────────────────┴─────────────────┐
              ▼                                   ▼
┌─────────────────────────┐         ┌─────────────────────────────┐
│   Core Memories (Fast)  │         │  Vector Search (Detailed)   │
│   user.info["core_mem"] │         │  ChromaDB collections:      │
│   ~200 words, 0ms       │         │  • user-memory-{user_id}    │
│                         │         │  • folder-memory-{folder_id}│
└─────────────────────────┘         │  ~10-50ms                   │
              │                     └─────────────────────────────┘
              │                                   │
              │                     ┌─────────────┴─────────────┐
              │                     ▼                           ▼
              │        ┌─────────────────────┐    ┌─────────────────────┐
              │        │ Time Decay Scoring  │    │ Semantic Reranking  │
              │        │ exp(-age/half_life) │    │ (optional)          │
              │        └─────────────────────┘    └─────────────────────┘
              │                     │                           │
              └─────────────────────┼───────────────────────────┘
                                    ▼
┌─────────────────────────────────────────────────────────────────┐
│                 Inject into System Prompt                       │
│   <userMemories>Core summary</userMemories>                     │
│   + "I recall the following about you: [relevant memories]"     │
└─────────────────────────────────────────────────────────────────┘
                                │
                                ▼
                          LLM Response
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                   outlet() - Post-processing                    │
│  1. Skip if collaborative && Personal bank                      │
│  2. Extract memories via LLM (structured JSON)                  │
│  3. Deduplicate (text + semantic)                               │
│  4. Assign to memory bank                                        │
│  5. Store in PostgreSQL + ChromaDB                              │
└─────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│              Background Tasks (async)                            │
│  • Summarization loop (24h) - cluster & consolidate             │
│  • Cache cleanup - evict stale entries                          │
│  • Core memories refresh - on login                             │
└─────────────────────────────────────────────────────────────────┘

Configuration

We have a lot of valves for every aspect of the memory management. The important ones:

# Performance
use_llm_for_relevance: bool = False           # Expensive but accurate
llm_skip_relevance_threshold: float = 0.93    # Skip LLM above this
vector_similarity_threshold: float = 0.7      # Initial filter

# Limits
max_total_memories: int = 200                 # Per-user cap
pruning_strategy: str = "fifo"                # "fifo" or "least_relevant"

# Time decay
time_decay_half_life_days: int = 60
recency_weight: float = 0.2                   # 20% recency boost

# Summarization
summarization_interval: int = 86400           # 24 hours
summarization_trigger_threshold: float = 0.8  # Trigger at 80% capacity
summarization_strategy: str = "hybrid"        # "embeddings", "tags", "hybrid"

Key Takeaways

  1. Tiered retrieval is essential. Fast vector search for most cases, expensive LLM scoring only when confidence is low.
  2. Privacy boundaries must be structural, not policy. We enforce isolation through collection naming and bank filtering, not just “please don’t leak this.”
  3. Pre-computation beats real-time. Core memories eliminate vector search latency for the common case. Detailed memories are available when needed.
  4. Background tasks keep the system healthy. Summarization, deduplication, and cache cleanup run async. The hot path stays fast.
  5. Give users control. /memory list, /memory forget [id], /memory assign_bank [id] [bank] – users can inspect and manage their memories directly.

Memory is one of those features that seems simple until you build it. The naive version (store everything, retrieve by similarity) works for demos but breaks down at scale and in multi-user contexts.

The version that actually works? Layers of pragmatic trade-offs: fast paths for common cases, privacy boundaries that fail safe, and background processes that keep the system healthy over time.

And we’re still iterating.


Questions? Find us on GitHub or reach out at hello@cochat.ai

Table of Contents

Research with confidence

Your research second brain. CoChat searches, organizes, and verifies your sources.
Grounded in 200M+ real papers across every major academic database.