Agent Memory & Semantic Cache
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page’s examples target PostgreSQL 17 with pgvector 0.8.6, Spring Boot 4.1 with Spring AI 2.0.1,
LangChain / langchain-core / langgraph 1.4 / 1.6 / 1.2, and Ollama nomic-embed-text (768 dims, cosine) for
the DocsAssistant scenario used throughout this section.
Short-term and long-term memory
An agent that only sees the current prompt re-derives everything about the conversation from scratch on every turn. Two complementary memory shapes fix that, and DocsAssistant uses both. The full memory taxonomy (working, semantic, episodic and procedural), LangGraph checkpointers and stores, and LangChain4j and Spring AI chat memory are covered on Memory in the AI Agents sub-section; this page covers the vector-store side.
Conversation window (short-term memory)
The simplest form of memory is just the last few turns, kept in process memory or passed back on every
request: the most recent N messages of the current conversation, appended to the prompt so the model can
refer to "it" or "that index" and mean the thing just discussed. It costs nothing to look up, degrades
gracefully as the window slides, but disappears with the process and cannot recall anything said before it
scrolled out of the window, or in a different conversation entirely.
Vector-backed memory (long-term memory)
Long-term memory keeps facts and past exchanges as embeddings in a vector store, so the agent can retrieve
"what we discussed about ef_search three days ago" by meaning rather than by conversation id or recency.
This is the same similarity-search machinery Similarity Metrics & Semantic Search
describes, applied to a different corpus: instead of documentation chunks, the indexed items are past
messages, summaries, or extracted facts. It survives process restarts and spans conversations, at the cost
of an embedding call per item stored and a similarity search per item retrieved.
Episodic and semantic memory as embeddings
Borrowing the cognitive-science distinction, it is useful to keep two kinds of long-term memory distinct even though both end up as embedded rows in the same kind of table:
-
Episodic memory — specific past events: "the user asked about HNSW parameters on 2026-09-12", stored as the raw message text (or a turn-level summary) and its embedding. DocsAssistant's
messages/message_embeddingstables below are exactly this. -
Semantic memory — generalized facts extracted from those events and stored independently of when they were said: "the user prefers Java examples over Python", "the user’s project uses PostgreSQL, not SQLite". Nothing in DocsAssistant's schema forces a separate table for this; a common approach is a small
agent_facts(id, subject, fact, embedding, updated_at)table populated by an extraction step (an LLM call that turns a conversation into zero or more standalone facts) rather than embedding raw turns.
Both are retrieved the same way: embed the current query, run a similarity search, splice the results into the prompt.
Conversation search as agent memory
DocsAssistant implements episodic long-term memory as conversation search: every message is embedded as it arrives, and a turn can pull in nearby or similar past messages instead of only the current window.
The schema
CREATE TABLE conversations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
title text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id uuid NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
message_index int NOT NULL,
role text NOT NULL, -- 'user' | 'assistant' | 'tool'
content text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (conversation_id, message_index)
);
CREATE TABLE message_embeddings (
message_id uuid PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
embedding vector(768) NOT NULL
);
CREATE INDEX message_embeddings_hnsw
ON message_embeddings USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
This mirrors the documents / chunks / chunk_embeddings split used for the documentation corpus
(PostgreSQL & pgvector): a narrow table holding the vector, one row per
embeddable unit, joined back to its parent row rather than widening it. See
pgvector: Storing vectors and
PostgreSQL: UUID type.
Incremental embedding
Messages are embedded one at a time, as they arrive, rather than in a batch job: each turn inserts its
messages row, calls the embedding model once, and inserts the matching message_embeddings row in the
same transaction, so a message is never searchable half-written.
import ollama
import psycopg
from pgvector.psycopg import register_vector
def store_message(conn: psycopg.Connection, conversation_id: str, message_index: int,
role: str, content: str) -> None:
embedding = ollama.embed(model="nomic-embed-text", input=f"search_document: {content}")["embeddings"][0]
with conn.transaction():
message_id = conn.execute(
"""
INSERT INTO messages (conversation_id, message_index, role, content)
VALUES (%s, %s, %s, %s)
RETURNING id
""",
(conversation_id, message_index, role, content),
).fetchone()[0]
conn.execute(
"INSERT INTO message_embeddings (message_id, embedding) VALUES (%s, %s)",
(message_id, embedding),
)
The `search_document: ` prefix follows the same asymmetric-model convention as the documentation corpus, described on Generating Embeddings in Practice. See Ollama: Embedding models.
Context-window retrieval (±3 messages) in SQL
Beyond similarity search, a hit in message_embeddings is often more useful with its surrounding
conversational context attached, the same "context expansion" idea applied to chunks elsewhere in this
section: given a matching message_index, pull the three messages before and after it from the same
conversation.
WITH hit AS (
SELECT m.conversation_id, m.message_index
FROM message_embeddings me
JOIN messages m ON m.id = me.message_id
ORDER BY me.embedding <=> '[...]'::vector
LIMIT 1
)
SELECT m.message_index, m.role, m.content
FROM messages m
JOIN hit ON hit.conversation_id = m.conversation_id
WHERE m.message_index BETWEEN hit.message_index - 3 AND hit.message_index + 3
ORDER BY m.message_index;
This is the conversation-memory analogue of PostgreSQL & pgvector's
chunk-level schema: a numeric, gap-free message_index per conversation makes a BETWEEN range query
sufficient, with no separate adjacency structure to maintain. See
PostgreSQL: WITH queries (CTEs).
Privacy considerations
Conversation memory stores exactly what users typed, which routinely includes names, account details, or other data the user did not intend to become permanently searchable:
-
Retention and deletion.
ON DELETE CASCADEfromconversationsdown tomessagesandmessage_embeddingsmeans deleting a conversation actually removes its embeddings too, not just its displayed text — a real requirement, not a convenience, once a user asks for their data to be erased. -
Scope leakage. A long-term memory search must always be scoped to the requesting user or tenant (an additional
WHERE conversation_id IN (…)or auser_idcolumn joined in), never a global similarity search across every user’s conversations — see the semantic-cache leakage risk below, which is the same failure mode. -
What gets embedded. Embedding raw message content means the embedding provider (local Ollama, in this scenario, or a hosted API in general) receives that content; a hosted embedding API changes the data- residency and retention picture even though the vectors themselves are not human-readable.
-
Minimization. Storing an extracted semantic fact ("prefers Java examples") instead of the verbatim message that revealed it reduces what long-term memory retains, at the cost of the extraction step described above.
Spring AI ChatMemory and LangGraph long-term memory
Spring AI ChatMemory and MessageChatMemoryAdvisor
Spring AI’s ChatMemory abstraction is the short-term-window side of this page: it stores and replays the
last messages of a conversation, keyed by a conversation id. MessageChatMemoryAdvisor reads that window
before the call and appends the new turn after it:
package com.example.docsassistant;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.memory.repository.jdbc.JdbcChatMemoryRepository;
import org.springframework.stereotype.Service;
@Service
public class DocsAssistantChatService {
private final ChatClient chatClient;
public DocsAssistantChatService(ChatClient.Builder chatClientBuilder,
JdbcChatMemoryRepository chatMemoryRepository) {
ChatMemory chatMemory = MessageWindowChatMemory.builder()
.chatMemoryRepository(chatMemoryRepository)
.maxMessages(20)
.build();
this.chatClient = chatClientBuilder
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
.build();
}
public String ask(String conversationId, String question) {
return chatClient.prompt()
.user(question)
.advisors(advisor -> advisor.param(ChatMemory.CONVERSATION_ID, conversationId))
.call()
.content();
}
}
MessageWindowChatMemory keeps the last maxMessages messages of each conversation (20 here), and the
conversationId passed as an advisor parameter is the shared scenario’s conversations.id, so both views key
on the same identifier. They do not share rows, though: JdbcChatMemoryRepository (from the
spring-ai-starter-model-chat-memory-repository-jdbc starter) persists messages in its own
framework-managed SPRING_AI_CHAT_MEMORY table, created by its schema initializer
(spring.ai.chat.memory.repository.jdbc.initialize-schema), not in the hand-written conversations /
messages tables above — the same "frameworks own their schema" rule as PgVectorStore’s own table.
Making Spring AI read and write `messages directly would need a custom ChatMemoryRepository
implementation over those tables. See
Spring AI: Chat Memory.
VectorStoreChatMemoryAdvisor for long-term memory
Where MessageChatMemoryAdvisor replays a bounded recent window, VectorStoreChatMemoryAdvisor instead
retrieves the messages most similar to the current question from a VectorStore — the framework-level
equivalent of the message_embeddings similarity query above:
package com.example.docsassistant;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.vectorstore.VectorStoreChatMemoryAdvisor;
import org.springframework.ai.vectorstore.VectorStore;
public class DocsAssistantLongTermMemory {
public ChatClient chatClient(ChatClient.Builder chatClientBuilder, VectorStore messageMemoryStore) {
return chatClientBuilder
.defaultAdvisors(VectorStoreChatMemoryAdvisor.builder(messageMemoryStore).build())
.build();
}
}
messageMemoryStore here is a VectorStore dedicated to conversation memory (its own collection or table,
kept separate from the DocsAssistant documentation store), and the advisor still keys retrieval by the
same conversationId, so short-term and long-term memory can be combined on the same ChatClient by
registering both advisors. See
Spring AI: Chat Memory and
Integrating with Spring AI:
chat memory for the wider ChatMemory surface.
LangGraph checkpointer and store for long-term memory
LangGraph separates the same two concerns explicitly: a checkpointer persists a graph run’s state (the
thread-scoped, short-term side, comparable to ChatMemory’s window) so a run can pause and resume, while a
store holds memory that is meant to outlive any single thread (the long-term side) and is looked up across
threads, typically by similarity search over embedded items — the LangGraph analogue of
`message_embeddings. Both are pluggable against a persistent backend such as PostgreSQL rather than only
the in-memory default: PostgresSaver (package langgraph-checkpoint-postgres) and PostgresStore (in the
same package) each create and own their tables through setup(), separate from the hand-written schema
above:
from langchain.agents import create_agent
from langchain_ollama import ChatOllama, OllamaEmbeddings
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.store.postgres import PostgresStore
from docsassistant.search import search_docs
DB_URI = "postgresql://docs:docs@localhost:5432/docsassistant"
embeddings = OllamaEmbeddings(model="nomic-embed-text")
with (
PostgresSaver.from_conn_string(DB_URI) as checkpointer,
PostgresStore.from_conn_string(DB_URI, index={"embed": embeddings, "dims": 768}) as store,
):
checkpointer.setup() # creates the checkpoint tables
store.setup() # creates the store tables, with a pgvector index for `index`
user_id = "user-42"
# Long-term, cross-thread memory: an extracted semantic fact, embedded on write.
store.put((user_id, "facts"), "lang-pref", {"text": "prefers Java examples over Python"})
agent = create_agent(
model=ChatOllama(model="llama3.1:8b"),
tools=[search_docs],
checkpointer=checkpointer, # short-term, thread-scoped state
store=store, # long-term memory, readable from tools via runtime.store
)
# thread_id plays the role of conversations.id: resuming it replays that thread's messages.
config = {"configurable": {"thread_id": "3f2c9a1e-conversation"}}
agent.invoke({"messages": [{"role": "user", "content": "What does ef_search control?"}]}, config)
# Similarity search over the user's long-term memories, across every thread.
memories = store.search((user_id, "facts"), query="which language should examples use?", limit=3)
The checkpointer makes a second agent.invoke(…) with the same thread_id continue the same
conversation, and the store’s search(…) is the LangGraph counterpart of the message_embeddings /
agent_facts similarity query above, scoped by the (user_id, "facts") namespace the same way a
user_id filter scopes the SQL version. See
LangChain: Short-term memory for
PostgresSaver, LangChain: Long-term memory
for PostgresStore and reading it from tools, and
LangGraph: Persistence for checkpointers,
threads and stores in general. Agentic RAG covers the agent loop
itself.
Semantic caching
A semantic cache answers a paraphrased repeat of a question without calling the LLM again, by matching on the embedding of the question rather than its exact text.
The answer_cache table
CREATE TABLE answer_cache (
id bigserial PRIMARY KEY,
question text NOT NULL,
embedding vector(768) NOT NULL,
answer text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX answer_cache_hnsw
ON answer_cache USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
This is structurally the simplest table in the whole section: one row per cached question, no join to another table, because the cached answer itself is the payload rather than a pointer to one. See pgvector: Getting started.
Lookup with a similarity threshold
A cache lookup embeds the incoming question, searches answer_cache for the nearest neighbor, and treats it
as a hit only if the neighbor is close enough:
SELECT id, question, answer,
1 - (embedding <=> '[...]'::vector) AS similarity
FROM answer_cache
ORDER BY embedding <=> '[...]'::vector
LIMIT 1;
def lookup_cache(conn, question_embedding, threshold: float = 0.92) -> str | None:
row = conn.execute(
"""
SELECT answer, 1 - (embedding <=> %s) AS similarity
FROM answer_cache
ORDER BY embedding <=> %s
LIMIT 1
""",
(question_embedding, question_embedding),
).fetchone()
if row is not None and row[1] >= threshold:
return row[0]
return None
A cosine-similarity hit threshold around 0.92 is a reasonable starting point for nomic-embed-text on
short technical questions, but it is not a universal constant: it is specific to the embedding model, the
kind of text being embedded, and how costly a wrong hit is for the application. It sits well above the loose
"probably related" band (around 0.6-0.8) that
Similarity Metrics & Semantic Search describes for retrieval, because a
cache hit substitutes a whole answer rather than one candidate among
several re-ranked results — a false positive here is served directly to the user with no downstream check.
Any deployment should re-tune the threshold against its own labeled paraphrase/non-paraphrase question pairs
rather than reuse this figure unexamined.
Insert
A cache miss falls through to the LLM, and its answer is written back once generated, in the same shape as the lookup:
def store_cache_entry(conn, question: str, question_embedding, answer: str) -> None:
conn.execute(
"INSERT INTO answer_cache (question, embedding, answer) VALUES (%s, %s, %s)",
(question, question_embedding, answer),
)
Invalidation
An answer cached today can become wrong tomorrow, so answer_cache needs at least one invalidation
mechanism beyond "never delete anything":
-
TTL. A scheduled job (or a
created_at-filteredWHEREclause added to every lookup) drops or ignores rows older than a fixed age, bounding how stale a served answer can be:DELETE FROM answer_cache WHERE created_at < now() - interval '7 days'; -
Corpus version. When the underlying documentation corpus changes (a new
chunk_embeddingsre-embedding pass, per Generating Embeddings in Practice's re-embedding guidance, or a content update), the cache should be invalidated alongside it — either a bulkTRUNCATE answer_cacheafter a corpus update, or acorpus_versioncolumn on bothanswer_cacheand the source documents, so a lookup can filter on "answers cached against the current corpus version only".
Risks
-
Stale or wrong hits. A cached answer that was correct when written can go silently wrong once the underlying facts change, and nothing in a plain similarity lookup detects that — this is exactly what TTL and corpus-version invalidation exist to bound.
-
Near-miss false positives. Two questions can sit just above the similarity threshold while actually wanting different answers ("how do I delete an index" vs. "how do I disable an index"), so the cache returns a confidently wrong answer instead of a slightly-slower correct one.
-
Per-user data leakage. If
answer_cacheis shared across users or tenants, one user’s question and answer can be surfaced to a different user whose semantically similar question happens to match — the same scoping requirement as long-term conversation memory above. A cache holding personalized answers needs auser_id/tenant_idcolumn folded into both the lookup filter and the uniqueness of what counts as "the same question", or it needs to be restricted to answers that are safe to share across every user in the first place.
Forgetting and retention policies
Deliberately forgetting is as much a design decision as caching in the first place: a retention policy
states, for each table on this page, how long a row is kept and what triggers its removal — answer_cache rows expiring on a TTL or a corpus-version bump above, messages /
message_embeddings rows removed with their parent conversations row on a user-deletion or
data-retention request, and any extracted agent_facts-style semantic memory re-derived (or discarded)
whenever the conversations it was extracted from are deleted. Treating forgetting as a first-class
operation, rather than an afterthought once storage grows, keeps both memory tables and the semantic cache
from becoming an unbounded, unaudited copy of everything ever said.
In LangChain and Spring AI
LangChain
LangChain’s memory story for a create_agent-built agent is the same short-term/long-term split as above:
thread-scoped state (comparable to Spring AI’s ChatMemory window) and a separate, similarity-searched store
for anything meant to persist across threads, both provided through LangGraph’s checkpointer and store, since
LangChain 1.x agents run on a LangGraph graph underneath. A tool reads the store through its injected
ToolRuntime, so the agent itself decides when to recall a long-term memory:
from langchain.tools import tool, ToolRuntime
@tool
def recall_user_facts(query: str, runtime: ToolRuntime) -> list[str]:
"""Recall stored facts about the current user that are relevant to `query`."""
user_id = runtime.config["configurable"]["user_id"]
items = runtime.store.search((user_id, "facts"), query=query, limit=3)
return [item.value["text"] for item in items]
Passed in create_agent(…, tools=[search_docs, recall_user_facts], checkpointer=checkpointer, store=store)
and invoked with {"configurable": {"thread_id": …, "user_id": …}}, this tool gives the agent on-demand
long-term memory alongside the checkpointer’s automatic short-term replay. See
LangChain: Long-term memory.
Spring AI
Spring AI’s MessageChatMemoryAdvisor and VectorStoreChatMemoryAdvisor, both shown above, are this
concept’s framework abstraction: the same conversationId selects a short-term window or a long-term
similarity search depending on which advisor (or both) is registered on the ChatClient. See
Integrating with Spring AI:
chat memory.
Persisting conversations per authenticated user in LangGraph, Spring AI and LangChain4j is covered in RAG Systems in Production: Conversational RAG & Memory, and cache tiers with event-driven invalidation in Caching, Latency & Cost.
Related pages
-
Similarity Metrics & Semantic Search — the cosine distance/similarity conversion and threshold heuristics this page’s cache lookup builds on.
-
PostgreSQL & pgvector — the pgvector schema and HNSW indexing conventions reused for
messages,message_embeddingsandanswer_cache. -
Generating Embeddings in Practice — incremental embedding, caching by content hash, and re-embedding on a corpus change.
-
Agentic RAG — the agent loop that reads this page’s memory and cache on every turn.
-
Short-Term Memory & Checkpointers — LangGraph checkpointers, the framework-side counterpart of this page’s
messagestable. -
Long-Term Memory Stores — LangGraph stores, the framework-side counterpart of cross-conversation memory.
-
Agents with create_agent — the
create_agentloop that consumes memory and cache. -
Redis: semantic cache and agent memory — the same pattern over Redis’s
FT.SEARCH/VSIMvector search. -
Redis Spring Boot: chat memory and semantic cache — a
RedisChatMemoryRepositoryandRedisVectorStorewired the same way as this page’s PostgreSQL examples.