Chapter 13: Memory & Sessions - Chatting with Documents
Theoretical Foundations
In the previous chapters, we established the foundation of Retrieval-Augmented Generation (RAG) by treating documents as data sources and embeddings as the navigational charts for semantic search. We built systems that could answer a single, isolated query by retrieving relevant context and passing it to a Large Language Model (LLM). However, human conversation is rarely a series of disconnected questions; it is a continuous flow of ideas, follow-ups, and evolving context. This chapter introduces the concept of Conversation Memory, which transforms a stateless RAG system into a stateful conversational agent capable of maintaining context across multiple interactions.
To understand memory in a RAG application, we must first distinguish between two types of memory: Short-Term Memory (often called Working Memory or Buffer) and Long-Term Memory (often called Episodic Memory or Vector Store).
Short-Term Memory is analogous to the RAM (Random Access Memory) in a computer. It is fast, volatile, and holds the immediate context of the current conversation. When a user asks a follow-up question like, "What did the author say about the second point mentioned earlier?", the system needs to know what the "second point" was. This requires keeping a record of the last few messages exchanged. In technical terms, this is often implemented as a "sliding window" of chat history—a fixed-length buffer of the most recent messages. The limitation of RAM is its capacity; if we tried to store every single message from a user's entire life in this buffer, we would run out of space, and the LLM would eventually hit its context window limit (the maximum amount of text it can process in one go), causing it to "forget" the beginning of the conversation.
Long-Term Memory, conversely, is like a hard drive or a distributed file system. It is persistent, vast, and indexed for retrieval. In our RAG architecture, this is the Vector Store we discussed in Chapter 10. However, instead of just storing static documents, we can store conversational exchanges. When a user asks a question that requires historical context beyond the immediate short-term buffer, the system queries this vector store to find relevant past interactions. This is the "Chatting with Documents" paradigm extended to "Chatting with History."
The challenge lies in orchestrating these two memory types seamlessly. We need a mechanism that prioritizes immediate context (short-term) but can fall back to deep historical retrieval (long-term) when necessary. This is where Session Management becomes critical. A session is a unique identifier that groups a sequence of interactions, allowing the application to retrieve the correct memory state for a specific user or conversation thread.
The "Why": Contextual Continuity and User Experience
Why is this complexity necessary? Consider a legal assistant RAG application. A user asks, "Summarize the liability clauses in Contract A." The system retrieves the relevant text and generates a summary. The user then asks, "How does this compare to the standard clauses in Contract B?" A stateless system would fail here because it has no memory of "Contract A." It would treat the second query as a standalone request, likely retrieving clauses from Contract B but failing to draw the comparison because it lacks the context of the first answer.
By implementing memory, the system understands that "this" refers to the liability clauses of Contract A. It can retrieve Contract B, pass both the history (the summary of A) and the new retrieval (clauses of B) to the LLM, and generate a comparative analysis. This continuity mimics human cognition, where understanding is cumulative.
Furthermore, memory allows for personalization. If a user frequently discusses a specific topic (e.g., "quantum computing"), the system can detect this pattern over time. While the short-term buffer handles the immediate session, the long-term memory can store summarized insights or key entities (like "quantum computing") as metadata. In a future session, even if the user doesn't explicitly mention the topic immediately, the system can infer preferences or pre-load relevant documents, creating a proactive assistant rather than a reactive tool.
The Architecture: LangChain's ChatMessageHistory and Session Persistence
To implement this, we utilize LangChain's ChatMessageHistory. This is a data structure designed to store a sequence of chat messages, each tagged with a role (e.g., Human, AI, System). It acts as the backbone for our short-term memory.
However, storing this history in memory (RAM) is insufficient for a production application because serverless functions are ephemeral; they spin down after execution, wiping any in-memory state. We need Session Persistence. This involves serializing the ChatMessageHistory and storing it in a database (like Redis, PostgreSQL, or a dedicated NoSQL store) keyed by a sessionId.
The flow of data in this architecture resembles a pipeline:
- Input: A user sends a message via the client interface.
- Session Retrieval: The server retrieves the
sessionId(usually stored in a secure HTTP-only cookie or local storage). It queries the persistent storage to load the existingChatMessageHistory. - Context Augmentation: The system combines the loaded history with the new user message.
- Retrieval (Optional): If the query requires external document context, the system performs a vector search. Crucially, the search query might be augmented by the conversation history (e.g., using the last 3 messages to form a better search query).
- Generation: The LLM receives the conversation history, the retrieved document chunks, and the new query to generate a response.
- Persistence: The new user message and the AI's response are appended to the
ChatMessageHistoryand saved back to the persistent store.
Visualizing the Memory Pipeline
The following diagram illustrates the flow of data through the short-term and long-term memory systems within a RAG application.
Web Development Analogy: The Browser History vs. The Bookmarks Bar
To visualize this architecture, imagine a user browsing the web.
Short-Term Memory (The Browser History Tab):
When you are researching a topic, you open a tab, read an article, then click a link to a second article, then a third. The browser's "Back" button and history list represent the short-term memory. It is linear, ordered, and immediately accessible. If you close the browser, this history might be lost (unless saved). In our app, the ChatMessageHistory buffer is exactly this: a linear list of the most recent interactions. It allows the user to say "Go back to the previous point" or "What was the link I just saw?"
Long-Term Memory (The Bookmarks Bar / Favorites): Over years of browsing, you save specific, high-value pages to your Bookmarks Bar. You organize them into folders like "Work," "Recipes," or "Tech News." This is long-term memory. It is indexed, searchable, and persists across sessions. When you need a specific piece of information from six months ago, you don't scroll through your history; you search your bookmarks. In our RAG system, the Vector Store acts as this bookmarks bar. We don't just store raw text; we store embeddings (semantic fingerprints) of conversations or documents, allowing us to find "similar" past interactions even if the keywords differ.
The Hybrid Approach: A sophisticated user uses both. They might have a "Current Project" folder in bookmarks (long-term) but keep a stack of open tabs (short-term) for the day's work. Our RAG application does the same. It keeps the last 10 messages in the buffer (RAM) but queries the vector store (Hard Drive) when the buffer doesn't contain the answer.
Type Narrowing and Data Fetching in Server Components
In a Next.js environment, specifically using the App Router, we must be mindful of where these operations occur. This brings us to the concepts of Data Fetching in Server Components (SCs) and Type Narrowing.
Data Fetching in SCs: Because memory retrieval (fetching chat history from a database) is an I/O operation, it should happen on the server. By performing this fetch directly within a Server Component, we ensure the AI model has the necessary context before the page is sent to the client. This prevents "client-side waterfalls"—where the page loads, then JavaScript fetches history, causing a layout shift or delay. The Server Component acts as the orchestrator, assembling the short-term memory (from the DB) and the long-term memory (from the Vector Store) into a single context object.
Type Narrowing:
When dealing with conversation history, we often deal with union types. A message might be a HumanMessage or an AIMessage, or it might be a ToolMessage if we are using agents. In TypeScript, we might define a generic type:
type Message =
| { role: 'user'; content: string }
| { role: 'assistant'; content: string; tool_calls?: ToolCall[] }
| { role: 'system'; content: string };
When processing this array to construct the prompt for the LLM, we need to ensure we handle specific roles correctly. For example, we might want to filter out system messages or format tool calls differently. This is where Type Narrowing becomes essential. By using a type guard (a runtime check that validates the shape of the object), the TypeScript compiler can "narrow" the type from the broad Message union to a specific type like { role: 'user'; content: string }.
// A type guard function
function isUserMessage(msg: Message): msg is { role: 'user'; content: string } {
return msg.role === 'user';
}
// Usage in a loop
const history = await loadHistory(sessionId);
history.forEach(msg => {
if (isUserMessage(msg)) {
// TypeScript now knows 'msg' is strictly a user message here.
// We can safely access msg.content without checking for tool_calls.
console.log(`User said: ${msg.content}`);
}
});
This ensures that when we pass the conversation history to the LLM, we are not accidentally including malformed data or undefined properties, maintaining the integrity of the prompt structure.
Server Actions: Mutating State Securely
Finally, the mechanism to update this memory relies on Server Actions. When a user sends a message, the client does not manually construct a fetch request to an API route. Instead, we use a Server Action—a function that executes on the server.
This is critical for memory management because the history is a sensitive piece of state. If we allowed the client to directly manipulate the history store via a standard API endpoint, we would need complex validation logic to prevent a user from injecting false messages into another user's session. A Server Action, invoked via a standard HTML form submission or a useTransition hook in React, runs in a secure context. It receives the user's input, appends it to the loaded ChatMessageHistory, saves it to the database, and returns the updated UI. This abstraction simplifies the code and ensures that the memory mutation logic remains server-side, where it is safe and authoritative.
By combining these concepts—short-term buffers, long-term vector retrieval, persistent session storage, and type-safe server-side processing—we move from a simple Q&A bot to a true conversational partner that remembers, learns, and evolves with the user.
Basic Code Example
This example demonstrates a foundational implementation of conversation memory for a web application. We will build a simple Node.js server that handles chat requests. The core concept is maintaining a session-specific chat history in memory. This allows the AI to remember the immediate context of a conversation without needing a persistent database for every interaction.
This architecture aligns with the AI Chatbot Architecture definition: the complex logic (managing memory, handling the chat request) resides entirely on the server (Server Component/Action), minimizing client-side dependencies. The client simply sends a message and receives a response; the server handles the state.
We will use TypeScript for type safety and express for a simple web server. While we won't connect to an actual LLM or Vector Database in this "Hello World" example, we will simulate the LLM's response to focus purely on the mechanics of memory management.
The Core Concept: Session State
In a stateless web environment, every HTTP request is isolated. To "chat," we need to link requests together. We achieve this using a Session ID. The client generates or receives a unique ID (e.g., a UUID). The server uses this ID as a key to look up or create a specific chat history for that user.
Here is the logical flow of our application:
- Client sends a message along with a
sessionId. - Server looks up the
sessionIdin a global in-memory store. - Server retrieves the existing chat history (if any).
- Server appends the new user message to the history.
- Server (in a real app) sends the full history to the LLM; here, we simulate a response.
- Server appends the AI's response to the history.
- Server updates the store with the new history and sends the AI's response back to the client.
The Code
/**
* @fileoverview Basic In-Memory Session Management for a Chat Application
*
* This TypeScript file demonstrates how to maintain conversation state
* across multiple HTTP requests using a simple in-memory store.
*
* Dependencies:
* - express: Web server framework
* - uuid: For generating unique session IDs
* - zod: For validating JSON schema output (simulated)
*
* Run this file with: npx ts-node server.ts
*/
import express, { Request, Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
// ============================================================================
// 1. TYPE DEFINITIONS & INTERFACES
// ============================================================================
/**
* Represents a single message in the chat history.
* @typedef {object} ChatMessage
* @property {string} role - 'user' or 'ai'
* @property {string} content - The text content of the message
*/
interface ChatMessage {
role: 'user' | 'ai';
content: string;
}
/**
* Represents a session's data stored in memory.
* @typedef {object} SessionData
* @property {ChatMessage[]} history - The array of messages for this session
*/
interface SessionData {
history: ChatMessage[];
}
/**
* The global in-memory store for sessions.
* Key: Session ID (string), Value: SessionData
*
* NOTE: In a production environment, this would be a Redis cache or a database.
* Using a simple JS Map here for the "Hello World" demonstration.
*/
const sessionStore = new Map<string, SessionData>();
// ============================================================================
// 2. SERVER SETUP
// ============================================================================
const app = express();
const PORT = 3000;
// Middleware to parse JSON bodies
app.use(express.json());
// ============================================================================
// 3. API ENDPOINTS
// ============================================================================
/**
* POST /chat
*
* Handles the chat interaction.
*
* Request Body:
* {
* "sessionId": string | null, // If null, a new session is created
* "message": string // The user's input
* }
*
* Response Body:
* {
* "sessionId": string,
* "response": string, // The AI's simulated response
* "history": ChatMessage[] // The full updated history
* }
*/
app.post('/chat', (req: Request, res: Response) => {
const { sessionId, message } = req.body;
// --- Input Validation (Simulated) ---
if (!message || typeof message !== 'string') {
return res.status(400).json({ error: 'Invalid message format' });
}
// --- Session Management ---
let currentSessionId = sessionId;
let sessionData: SessionData;
if (!currentSessionId) {
// Create a new session if no ID is provided
currentSessionId = uuidv4();
sessionData = { history: [] };
console.log(`Created new session: ${currentSessionId}`);
} else {
// Retrieve existing session
const existingSession = sessionStore.get(currentSessionId);
if (!existingSession) {
return res.status(404).json({ error: 'Session not found' });
}
sessionData = existingSession;
}
// --- Memory Retrieval (Short-Term Memory) ---
// We retrieve the current history. In a real RAG app, this is where
// we might also query a vector store for long-term memory.
const currentHistory = sessionData.history;
console.log(`Retrieved history for ${currentSessionId}:`, currentHistory);
// --- Append User Message ---
const userMessage: ChatMessage = {
role: 'user',
content: message
};
currentHistory.push(userMessage);
// --- AI Interaction (Simulated) ---
// In a real app, we would call an LLM here, passing the 'currentHistory'.
// We simulate the LLM's JSON Schema output capability by generating
// a structured response string.
const simulatedAiResponse = `I understand you said: "${message}". This is session ${currentSessionId}.`;
const aiMessage: ChatMessage = {
role: 'ai',
content: simulatedAiResponse
};
// --- Append AI Response to Memory ---
currentHistory.push(aiMessage);
// --- Update State ---
// Update the store with the new history
sessionStore.set(currentSessionId, { history: currentHistory });
// --- Send Response ---
// We return the session ID (crucial for the client to persist it)
// and the AI's response.
res.json({
sessionId: currentSessionId,
response: simulatedAiResponse,
history: currentHistory // Optional: useful for debugging or UI syncing
});
});
// ============================================================================
// 4. SERVER EXECUTION
// ============================================================================
app.listen(PORT, () => {
console.log(`Memory & Sessions server running on http://localhost:${PORT}`);
console.log('Send a POST request to /chat with { "message": "Hello" }');
});
Line-by-Line Explanation
1. Imports and Type Definitions
* import express...: We import the Express web server framework.
* import { v4 as uuidv4 }...: We import the UUID generator. This is essential for creating unique identifiers for our sessions.
* interface ChatMessage: Defines the shape of a single message. Using TypeScript interfaces ensures type safety, preventing us from accidentally pushing malformed data into our history array.
* interface SessionData: Defines the structure of the data stored for a specific session. Currently, it only holds the history array.
* const sessionStore = new Map<string, SessionData>(): This is the "brain" of our memory system. It is a JavaScript Map acting as a key-value store. The key is the sessionId (string), and the value is the SessionData object. Note: This data is lost if the server restarts. In a production SaaS app, you would replace this with Redis or a database.
2. Server Setup
* const app = express(): Initializes the Express application.
* app.use(express.json()): Adds middleware that parses incoming JSON requests. This allows us to access req.body containing the user's message and session ID.
3. The /chat Endpoint
* Input Validation: We check if the message exists and is a string. This prevents errors later in the logic.
* Session Logic:
* if (!currentSessionId): If the client didn't send a session ID (e.g., the very first message), we generate a new one using uuidv4(). We initialize an empty history array.
* else: If a session ID was provided, we look it up in our sessionStore. If it doesn't exist, we return a 404 error (the session might have expired on server restart).
* Memory Retrieval: We access sessionData.history. This represents the short-term memory of the chatbot. It contains the immediate context of the conversation.
* Appending User Message: We create a ChatMessage object with the role 'user' and push it to the currentHistory array.
* AI Interaction (Simulation):
* In a real application, we would construct a prompt using the currentHistory and send it to an LLM.
* Here, we simulate the response. We demonstrate the concept of JSON Schema Output conceptually by structuring our simulated response, though in this simple example, it's just a string. In a complex app, the LLM might return a JSON object (e.g., { "answer": "...", "sources": [...] }) which we would parse using a library like Zod.
* Appending AI Response: We create a ChatMessage object with the role 'ai' and push it to the history. This is crucial; if we don't save the AI's response, the model won't remember what it just said in the next turn.
* Updating State: sessionStore.set(...) saves the updated history back into the memory store.
* Sending Response: We return the sessionId to the client so it can reuse it for the next message, and we return the response for display.
Common Pitfalls
-
Stateless Server Misconception:
- Issue: Developers often assume the server remembers the previous request without explicitly passing the state (session ID) back and forth.
- Fix: Always ensure the client receives the
sessionIdafter the first request and sends it back with every subsequent request. Do not rely on IP addresses alone for session identification.
-
Vercel/AWS Lambda Timeouts (Async/Await Loops):
- Issue: In serverless environments (like Vercel Edge or AWS Lambda), execution time is limited (e.g., 10-30 seconds). If you perform heavy processing (like embedding generation or large vector store queries) inside the main request thread, you risk hitting the timeout limit.
- Fix: For long-running tasks, decouple the request from the processing. Accept the request, acknowledge it immediately, and process the AI interaction in the background (using a queue like Upstash Redis or AWS SQS). Return the result via WebSockets or a polling mechanism.
-
Hallucinated JSON / Parsing Errors:
- Issue: When interacting with LLMs to output JSON (as per the definition), models can sometimes "hallucinate" invalid JSON syntax (e.g., trailing commas, unquoted keys). If your code blindly tries to
JSON.parse()this, the application will crash. - Fix: Never trust raw LLM output. Use a schema validation library like Zod. Define your expected schema and use
schema.safeParse(llmOutput)to validate and parse the response simultaneously. If parsing fails, handle the error gracefully (e.g., ask the model to retry).
- Issue: When interacting with LLMs to output JSON (as per the definition), models can sometimes "hallucinate" invalid JSON syntax (e.g., trailing commas, unquoted keys). If your code blindly tries to
-
Memory Leaks in In-Memory Stores:
- Issue: In the "Hello World" example, we used a
Map. If this application runs for a long time with many users, theMapwill grow indefinitely, consuming all available RAM and crashing the server. - Fix: Implement a Time-To-Live (TTL) mechanism. Sessions should expire after a period of inactivity (e.g., 30 minutes). In a production Redis store, this is handled automatically. In a custom
Map, you would need to implement a cleanup interval or use a specialized library.
- Issue: In the "Hello World" example, we used a
Visualization of Data Flow
The chapter continues with advanced code, exercises and solutions with analysis, you can find them on the ebook on Leanpub.com or Amazon
Loading knowledge check...
Code License: All code examples are released under the MIT License. Github repo.
Content Copyright: Copyright © 2026 Edgar Milvus | Privacy & Cookie Policy. All rights reserved.
All textual explanations, original diagrams, and illustrations are the intellectual property of the author. To support the maintenance of this site via AdSense, please read this content exclusively online. Copying, redistribution, or reproduction is strictly prohibited.