Skip to main content

Conversational Memory

Multi-turn RAG requires managing conversation history so that older exchanges do not overflow the context window while still giving the LLM enough context to respond coherently. Rag.NET provides two composable layers:

  1. ConversationMemoryPipeline — in-session history trimming (sliding window, token budget, optional summary).
  2. PersistentConversationMemory — cross-session recall: embeds each exchange and stores it in the vector store; injects relevant past exchanges as a system prefix on future turns.

Both implement IConversationMemory and are registered as a singleton via UseConversationMemory.


In-Session Memory (ConversationMemoryPipeline)​

Enabling​

services.AddRagNet(rag => rag
.UseConversationMemory(new ConversationMemoryOptions
{
MaxExchanges = 20,
}));

ConversationMemoryOptions​

OptionDefaultDescription
MaxExchangesnullMaximum user/assistant exchange pairs to keep; oldest removed first; system messages always preserved. Applied first.
MaxTokensnullMaximum token budget (cl100k_base); oldest non-system messages trimmed until within budget. Applied second.
UseSummaryfalseWhen true, trimmed messages are LLM-summarized into a system prefix instead of discarded. Requires IChatClient. Applied last.
SummaryPromptTemplatenullCustom prompt for the summary call. null uses the built-in default.

Trimming order​

When both MaxExchanges and MaxTokens are set, window trimming runs first, then token trimming runs on the result. Summary (when enabled) replaces trimmed messages with an LLM-generated summary prefix.

How AskAsync uses it​

When IConversationMemory is registered, the answer engines (ChatAnswerEngine, MapReduceAnswerEngine, RefineAnswerEngine) call ProcessAsync on the memory before every LLM call. The caller is still responsible for maintaining the ConversationHistory list and passing it via RagOptions:

var history = new List<ChatMessage>();

// First turn
var response = await pipeline.AskAsync("What is RAG?", new RagOptions
{
ConversationHistory = history,
});
history.Add(new(ChatRole.User, "What is RAG?"));
history.Add(new(ChatRole.Assistant, response.Answer));

// Second turn — memory trims history before the LLM call
var response2 = await pipeline.AskAsync("Can you give an example?", new RagOptions
{
ConversationHistory = history,
});

ProcessAsync is called internally by the pipeline — callers do not need to invoke it directly.


Persistent Memory (PersistentConversationMemory)​

Persistent memory adds cross-session recall on top of ConversationMemoryPipeline. Each exchange is embedded and stored in the vector store keyed by a sessionId. On subsequent calls, the current query is embedded and used to search for similar past exchanges; relevant ones are injected as a system prefix before the in-session trimming runs.

Enabling​

Nest UsePersistentMemory() inside UseConversationMemory:

services.AddRagNet(rag => rag
.UseConversationMemory(
options: new ConversationMemoryOptions { MaxExchanges = 20 },
configure: mem => mem.UsePersistentMemory()));

With custom options:

services.AddRagNet(rag => rag
.UseConversationMemory(
options: new ConversationMemoryOptions { MaxExchanges = 20 },
configure: mem => mem.UsePersistentMemory(new PersistentMemoryOptions
{
TopK = 5,
MinScore = 0.75,
})));

Requires IVectorStore and IEmbeddingGenerator to be registered in DI.

PersistentMemoryOptions​

OptionDefaultDescription
TopK3Maximum number of past exchanges to inject per turn
MinScore0.7Minimum cosine similarity for an exchange to be injected — only on a similarity-scaled store (see below)

Score scale. MinScore is a threshold on the similarity scale, and every store is assumed to be on that scale unless it says otherwise by implementing IScoreScaleAware. A store declaring ScoreScale.OpaqueRanking — currently FederatedVectorStore, whose scores are Reciprocal Rank Fusion sums peaking near 0.033 for two stores — has no thresholdable scale, so recall ignores MinScore there: it injects the store's top TopK matches in rank order and logs one warning per memory instance naming the store type and the ignored option. Recall works against such a store, but a minimum relevance cannot be enforced: every turn with a user message injects up to TopK past exchanges, however weakly related. Lower TopK if that is too much context, or back persistent memory with a dedicated similarity-scaled store when the threshold matters. See vector stores.

Storing exchanges​

Call StoreAsync after each turn to persist the exchange for future sessions:

var response = await pipeline.AskAsync("Explain the refund policy", new RagOptions
{
ConversationHistory = history,
});

// Persist this exchange under a stable session identifier
var memory = serviceProvider.GetRequiredService<IConversationMemory>();
await memory.StoreAsync(
userMessage: "Explain the refund policy",
assistantMessage: response.Answer,
sessionId: "user-42-session-1");

sessionId is used as the DocumentId in the vector store. Use a stable, user-scoped identifier (e.g., user ID, conversation ID) so past exchanges from the same user are grouped together.

How it works​

Each stored exchange is a single TextChunk:

  • Text = "User: {userMessage}\nAssistant: {assistantMessage}"
  • DocumentId = sessionId
  • ChunkIndex = sequential index within the session (process-scoped counter; resets on restart)

Error handling​

ConditionBehaviour
Vector store search failsLogged as warning; history unchanged; inner pipeline called normally
StoreAsync embedding failsLogged as warning; exchange not persisted (non-fatal)
StoreAsync store write failsLogged as warning; exchange not persisted (non-fatal)
All results below MinScore (similarity-scaled store)No prefix injected; inner pipeline called normally
Store declares ScoreScale.OpaqueRankingMinScore is not applied; the top TopK matches are injected in rank order, with one warning logged per memory instance
Store returns no matches at allNo prefix injected; inner pipeline called normally

IConversationMemory interface​

public interface IConversationMemory
{
/// <summary>
/// Trims or augments history before an LLM call.
/// Called automatically by answer engines when registered.
/// </summary>
Task<IReadOnlyList<ChatMessage>> ProcessAsync(
IReadOnlyList<ChatMessage> history,
CancellationToken cancellationToken = default);

/// <summary>
/// Persists a completed exchange for cross-session recall.
/// No-op on <see cref="ConversationMemoryPipeline"/>; stores in the vector store on
/// <see cref="PersistentConversationMemory"/>.
/// </summary>
Task StoreAsync(
string userMessage,
string assistantMessage,
SessionId sessionId,
CancellationToken cancellationToken = default);
}

StoreAsync is always safe to call regardless of which implementation is registered — it is a no-op on ConversationMemoryPipeline.