Episodic Memory — Selective Recall with Similarity Search¶
This notebook demonstrates the similarity_memory() factory, which gives an
LLMAgent episodic memory that retrieves past episodes by semantic relevance
rather than recency.
Two properties are illustrated:
- Selective recall. After each task the agent records an episode in a Qdrant
in-memory vector store. When a new task arrives,
similarity_memory()searches for the most semantically similar past episodes, not the most recent. A question about Southeast Asia retrieves Southeast Asian country episodes; a question about European economies retrieves European ones, regardless of when they were recorded. - Contrast with recency. The notebook explicitly shows what
recency_memory()would have returned for the same queries: the same three most-recent episodes for both, illustrating exactly when similarity search adds value over a simpler recency strategy.
This notebook uses the World Bank API, which is free and requires no authentication.
Setting up the LLM¶
I recommend running these notebooks on Ollama Cloud. It serves much larger models than most laptops can run. The default cloud model is picked by benchmarking candidates on these notebooks, and an automated run checks every notebook against it each week. The notebook status page shows the current model and results.
- Create an API key at https://ollama.com/settings/keys.
- Export it before launching Jupyter:
export OLLAMA_API_KEY=<your key>. - That's all. When a notebook builds its LLM, it detects the key and uses Ollama Cloud.
Prefer to run locally? Install Ollama from https://ollama.com/download
and pull the model with ollama pull qwen3:14b. If no key is set, the
notebook starts the Ollama service for you, so there is no need to run
ollama serve. Local models are smaller, so your outputs will differ from
the recorded ones.
Prefer OpenAI or Anthropic?
- Install the extra:
uv sync --extra openaioruv sync --extra anthropic. - Export
OPENAI_API_KEYorANTHROPIC_API_KEYbefore launching Jupyter. If you forget, you will be prompted for it, which keeps the key out of the saved notebook. - Set
LLM_PROVIDER=openai(oranthropic), or passprovider="openai"tomake_llm(). A key on its own never switches providers, so one exported for unrelated work cannot reroute you.
The switch applies wherever a notebook builds its LLM with make_llm().
A notebook that constructs OllamaLLM directly stays on Ollama. Output on
gpt-5 or Claude will differ from the recorded outputs, and prompt-sensitive
examples (the ch09 evaluator pattern, ch08 supervised trajectories) may
behave noticeably differently.
# Uncomment the line below to install `llm-agents-from-scratch` from PyPI
# !pip install llm-agents-from-scratch
Setup¶
from llm_agents_from_scratch.notebook_utils import make_llm
import json
import logging
import tempfile
import urllib.request
from pathlib import Path
from tqdm.asyncio import tqdm
from llm_agents_from_scratch import LLMAgent
from llm_agents_from_scratch.data_structures import Task
from llm_agents_from_scratch.logger import enable_console_logging
from llm_agents_from_scratch.memory.recipes import (
recency_memory,
similarity_memory,
)
from llm_agents_from_scratch.tools.simple_function import SimpleFunctionTool
Defining the Tool¶
The World Bank API returns a country's region, capital, income level, population and area, with no API key required. The tool looks the country up by name, so "Vietnam" and "Viet Nam" both work.
WORLD_BANK = "https://api.worldbank.org/v2"
def _world_bank(path: str) -> list:
req = urllib.request.Request(
f"{WORLD_BANK}/{path}",
headers={"User-Agent": "llm-agents-from-scratch/1.0"},
)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
def _latest(iso3: str, indicator: str) -> float | None:
rows = _world_bank(
f"country/{iso3}/indicator/{indicator}?format=json&mrnev=1",
)
return rows[1][0]["value"] if len(rows) > 1 and rows[1] else None
def _key(name: str) -> str:
return "".join(ch for ch in name.lower() if ch.isalpha())
def get_country(name: str) -> str:
"""Look up a country by name and return key facts.
Uses the World Bank API (https://api.worldbank.org/v2). No auth required.
"""
countries = _world_bank("country?format=json&per_page=400")[1]
match = next(
(
c
for c in countries
if c["region"]["value"] != "Aggregates"
and _key(c["name"]) == _key(name)
),
None,
)
if match is None:
raise ValueError(
f"Country '{name}' not found. Check the spelling and try again.",
)
return json.dumps(
{
"name": match["name"],
"region": match["region"]["value"].strip(),
"capital": match["capitalCity"],
"income_level": match["incomeLevel"]["value"],
"population": _latest(match["id"], "SP.POP.TOTL"),
"area_km2": _latest(match["id"], "AG.SRF.TOTL.K2"),
},
)
get_country_tool = SimpleFunctionTool(func=get_country)
Creating the Agent with Memory¶
similarity_memory() creates a Memory backed by an in-process Qdrant vector store.
Episodes are embedded at write time using FastEmbed (local ONNX, no external service
needed). The max_results parameter controls how many semantically similar episodes
are recalled per task. The agent holds the memory in its memories list; TaskHandler
calls recall at task start and record at task end automatically.
The agent also carries a recency_memory() during the lookups in Part 1. It
records the same episodes into a JSONL file in a temporary directory, so the
recency baseline below can show what a recency strategy would recall. It is
detached before the synthesis tasks, so only similarity recall is in play from
Part 2 on.
memory = similarity_memory(max_results=3)
recency = recency_memory(path=Path(tempfile.mkdtemp()), max_results=3)
llm = make_llm()
agent = LLMAgent(
llm=llm,
tools=[get_country_tool],
memories=[memory, recency],
)
✓ Using Ollama Cloud (kimi-k2.7-code:cloud)
Part 1 — Building Up Episodic Memory¶
Seven country lookups spanning four distinct regions: Southeast Asia (Thailand,
Vietnam, Indonesia), Western Europe (France, Germany), East Africa (Kenya), and
South America (Brazil). Each task calls get_country once and records an episode
at the end.
The first lookup runs with full logging so you can see the tool call and recording
in action. The remaining six run concurrently with logging silenced to keep the
output readable. After all lookups, memory.summary() shows the store state.
enable_console_logging(logging.INFO)
task1 = Task(
instruction=(
"What are Thailand's key geographic and demographic facts? "
"Use the get_country tool. Do not rely on prior knowledge."
),
)
result1 = await agent.run(task1)
print(result1)
INFO (llm_agents_fs.LLMAgent) : 🚀 Starting task: What are Thailand's key geographic and demographic facts? Use the get_country tool. Do not rely on prior knowledge.
INFO (llm_agents_fs.TaskHandler) : ⚙️ Processing Step: What are Thailand's key geographic and demographic facts? Use the get_country tool. Do not rely on prior knowledge.
INFO (llm_agents_fs.TaskHandler) : 🛠️ Executing Tool Call: get_country
INFO (llm_agents_fs.TaskHandler) : ✅ Successful Tool Call: {"name": "Thailand", "region": "East Asia & Pacific", "capital": "Bangkok", "income_level": "Upper middle income", "population...[TRUNCATED]
INFO (llm_agents_fs.TaskHandler) : ✅ Step Result: Based on the result from the `get_country` tool, here are Thailand's key geographic and demographic facts:
- **Name:** Thailand
- **Re...[TRUNCATED]
INFO (llm_agents_fs.TaskHandler) : No new step required.
INFO (llm_agents_fs.LLMAgent) : 🏁 Task completed: Based on the result from the `get_country` tool, here are Thailand's key geographic and demographic facts:
- **Name:** Thailand
- *...[TRUNCATED]
Based on the result from the `get_country` tool, here are Thailand's key geographic and demographic facts:
- **Name:** Thailand
- **Region:** East Asia & Pacific
- **Capital city:** Bangkok
- **Income level:** Upper middle income
- **Population:** 71,619,863 people
- **Total land area:** 513,115.02 km²
These figures come directly from the tool output, without relying on prior knowledge.
logging.disable(logging.INFO)
countries = ["Vietnam", "Indonesia", "France", "Germany", "Kenya", "Brazil"]
lookup_tasks = [
Task(
instruction=(
f"What are {c}'s key geographic and demographic facts? "
"Use the get_country tool. Do not rely on prior knowledge."
),
)
for c in countries
]
results = await tqdm.gather(
*[agent.run(t) for t in lookup_tasks],
desc="Looking up countries",
)
for country, result in zip(countries, results, strict=False):
print(f"**{country}**\n{result}\n")
Looking up countries: 0%| | 0/6 [00:00<?, ?it/s] Looking up countries: 17%|█▋ | 1/6 [00:08<00:43, 8.74s/it] Looking up countries: 67%|██████▋ | 4/6 [00:09<00:03, 1.87s/it] Looking up countries: 100%|██████████| 6/6 [00:10<00:00, 1.17s/it] Looking up countries: 100%|██████████| 6/6 [00:10<00:00, 1.68s/it]
**Vietnam** Based on the result from the `get_country` tool, here are Vietnam's key geographic and demographic facts: - **Name:** Viet Nam - **Region:** East Asia & Pacific - **Capital city:** Hanoi - **Income level:** Upper middle income - **Population:** 101,598,527 people - **Total land area:** 331,338.31 km² These figures come directly from the tool output, without relying on prior knowledge. **Indonesia** Based on the result from the `get_country` tool, here are Indonesia's key geographic and demographic facts: - **Name:** Indonesia - **Region:** East Asia & Pacific - **Capital city:** Jakarta - **Income level:** Upper middle income - **Population:** 285,721,236 people - **Total land area:** 1,916,906.77 km² These figures come directly from the tool output, without relying on prior knowledge. **France** Based on the result from the `get_country` tool, here are France's key geographic and demographic facts: - **Name:** France - **Region:** Europe & Central Asia - **Capital city:** Paris - **Income level:** High income - **Population:** 68,720,337 people - **Total land area:** 606,410 km² These figures come directly from the tool output, without relying on prior knowledge. **Germany** Based on the result from the `get_country` tool, here are Germany's key geographic and demographic facts: - **Name:** Germany - **Region:** Europe & Central Asia - **Capital city:** Berlin - **Income level:** High income - **Population:** 83,491,249 people - **Total land area:** 357,680 km² These figures come directly from the tool output, without relying on prior knowledge. **Kenya** Based on the result from the `get_country` tool, here are Kenya's key geographic and demographic facts: - **Name:** Kenya - **Region:** Sub-Saharan Africa - **Capital city:** Nairobi - **Income level:** Lower middle income - **Population:** 57,532,493 people - **Total land area:** 591,969 km² These figures come directly from the tool output, without relying on prior knowledge. **Brazil** Based on the result from the `get_country` tool, here are Brazil's key geographic and demographic facts: - **Name:** Brazil - **Region:** Latin America & Caribbean - **Capital city:** Brasilia - **Income level:** Upper middle income - **Population:** 212,812,405 people - **Total land area:** 8,510,420 km² These figures come directly from the tool output, without relying on prior knowledge.
logging.disable(logging.NOTSET)
print(await memory.summary())
QdrantMemoryStore: 7 episodes | collection=episodes newest: 2026-09-30 00:53:08 | What are Brazil's key geographic and demographic facts? Use oldest: 2026-09-30 00:52:58 | What are Thailand's key geographic and demographic facts? Us metadata_fns: none
The Recency Baseline¶
Before running any synthesis tasks, it is worth checking what the
recency_memory() attached in Part 1 recalls. Its recall() ignores the query,
so this is what it returns for any query at this point, regardless of topic. Because the six lookups
ran concurrently, the recording order depends on wall-clock completion time rather than
submission order. Whatever three episodes happened to finish last appear below. A
recency strategy would inject these same three episodes into the system prompt for
both the Southeast Asia query and the Western Europe query that follow, regardless
of whether they are relevant.
The cell then detaches it, leaving similarity_memory() as the agent's only
memory for the synthesis tasks.
print("Most recent 3 episodes (recency_memory() baseline for any query):\n")
most_recent = await recency.store.recall("any query")
for ep in most_recent:
print(f" - {ep.task.instruction[:65]}")
agent.memories = [memory]
Most recent 3 episodes (recency_memory() baseline for any query): - What are Brazil's key geographic and demographic facts? Use the g - What are Indonesia's key geographic and demographic facts? Use th - What are Kenya's key geographic and demographic facts? Use the ge
Part 2 — Selective Recall: Southeast Asia¶
The synthesis task asks about Southeast Asian countries. The cell below
defines the query and shows which episodes are most semantically similar
to it. These are exactly what similarity_memory() injects into the system
prompt when the task runs. Compare this to the recency baseline above.
task8 = Task(
instruction=(
"Which of the countries you have researched are in Southeast Asia? "
"Summarise their key facts and compare their populations. "
"Answer from the country information you have already gathered. "
"No tools are needed."
),
)
print("Episodes that will be recalled for this query:\n")
recalled_sea = await memory.store.recall(task8.instruction)
for ep in recalled_sea:
print(str(ep))
print()
Episodes that will be recalled for this query:
<episode>
<task>What are Vietnam's key geographic and demographic facts? Use the get_country tool. Do not rely on prior knowledge.</task>
<result>Based on the result from the `get_country` tool, here are Vietnam's key geographic and demographic facts:
- **Name:** Viet Nam
- **Region:** East Asia & Pacific
- **Capital city:** Hanoi
- **Income level:** Upper middle income
- **Population:** 101,598,527 people
- **Total land area:** 331,338.31 km²
These figures come directly from the tool output, without relying on prior knowledge.</result>
<completed_at>2026-09-30 00:53:06</completed_at>
</episode>
<episode>
<task>What are Indonesia's key geographic and demographic facts? Use the get_country tool. Do not rely on prior knowledge.</task>
<result>Based on the result from the `get_country` tool, here are Indonesia's key geographic and demographic facts:
- **Name:** Indonesia
- **Region:** East Asia & Pacific
- **Capital city:** Jakarta
- **Income level:** Upper middle income
- **Population:** 285,721,236 people
- **Total land area:** 1,916,906.77 km²
These figures come directly from the tool output, without relying on prior knowledge.</result>
<completed_at>2026-09-30 00:53:07</completed_at>
</episode>
<episode>
<task>What are Thailand's key geographic and demographic facts? Use the get_country tool. Do not rely on prior knowledge.</task>
<result>Based on the result from the `get_country` tool, here are Thailand's key geographic and demographic facts:
- **Name:** Thailand
- **Region:** East Asia & Pacific
- **Capital city:** Bangkok
- **Income level:** Upper middle income
- **Population:** 71,619,863 people
- **Total land area:** 513,115.02 km²
These figures come directly from the tool output, without relying on prior knowledge.</result>
<completed_at>2026-09-30 00:52:58</completed_at>
</episode>
Now run the task. The agent receives the recalled episodes above in its
system prompt. Watch the logs: you should see no 🛠️ Executing Tool Call
lines. The agent answers entirely from recalled episodes.
result8 = await agent.run(task8)
print(result8)
INFO (llm_agents_fs.LLMAgent) : 🚀 Starting task: Which of the countries you have researched are in Southeast Asia? Summarise their key facts and compare their populations. Answer fro...[TRUNCATED] INFO (llm_agents_fs.TaskHandler) : ⚙️ Processing Step: Which of the countries you have researched are in Southeast Asia? Summarise their key facts and compare their populations. Answer ...[TRUNCATED] INFO (llm_agents_fs.TaskHandler) : ✅ Step Result: From the country information I have already gathered, the following countries are in Southeast Asia: - **Vietnam** - **Indonesia** - *...[TRUNCATED] INFO (llm_agents_fs.TaskHandler) : No new step required. INFO (llm_agents_fs.LLMAgent) : 🏁 Task completed: From the country information I have already gathered, the following countries are in Southeast Asia: - **Vietnam** - **Indonesia** ...[TRUNCATED] From the country information I have already gathered, the following countries are in Southeast Asia: - **Vietnam** - **Indonesia** - **Thailand** ### Key facts summary | Country | Capital | Region | Income level | Population | Land area | |---------|---------|--------|--------------|------------|-----------| | Vietnam | Hanoi | East Asia & Pacific | Upper middle income | 101,598,527 | 331,338.31 km² | | Indonesia | Jakarta | East Asia & Pacific | Upper middle income | 285,721,236 | 1,916,906.77 km² | | Thailand | Bangkok | East Asia & Pacific | Upper middle income | 71,619,863 | 513,115.02 km² | ### Population comparison - **Indonesia** is by far the largest, with about **285.7 million** people. - **Vietnam** is second, with about **101.6 million** people. - **Thailand** is the smallest of the three, with about **71.6 million** people. Indonesia’s population is roughly 2.8 times that of Vietnam and about 4 times that of Thailand.
Part 3 — A Different Query, A Different Subset: European Economies¶
A second synthesis task asks about European economies. The query is semantically distant from Southeast Asia, so a completely different subset of episodes should surface: France and Germany. Even though those episodes were not the most recently recorded, the similarity search surfaces them because they are the most relevant to the query.
task9 = Task(
instruction=(
"Which of the countries you have researched are in Western Europe? "
"How do they compare in population and area? "
"Answer from the country information you have already gathered. "
"No tools are needed."
),
)
print("Episodes that will be recalled for this query:\n")
recalled_eu = await memory.store.recall(task9.instruction)
for ep in recalled_eu:
print(str(ep))
print()
Episodes that will be recalled for this query:
<episode>
<task>What are Germany's key geographic and demographic facts? Use the get_country tool. Do not rely on prior knowledge.</task>
<result>Based on the result from the `get_country` tool, here are Germany's key geographic and demographic facts:
- **Name:** Germany
- **Region:** Europe & Central Asia
- **Capital city:** Berlin
- **Income level:** High income
- **Population:** 83,491,249 people
- **Total land area:** 357,680 km²
These figures come directly from the tool output, without relying on prior knowledge.</result>
<completed_at>2026-09-30 00:53:07</completed_at>
</episode>
<episode>
<task>What are France's key geographic and demographic facts? Use the get_country tool. Do not rely on prior knowledge.</task>
<result>Based on the result from the `get_country` tool, here are France's key geographic and demographic facts:
- **Name:** France
- **Region:** Europe & Central Asia
- **Capital city:** Paris
- **Income level:** High income
- **Population:** 68,720,337 people
- **Total land area:** 606,410 km²
These figures come directly from the tool output, without relying on prior knowledge.</result>
<completed_at>2026-09-30 00:53:07</completed_at>
</episode>
<episode>
<task>Which of the countries you have researched are in Southeast Asia? Summarise their key facts and compare their populations. Answer from the country information you have already gathered. No tools are needed.</task>
<result>From the country information I have already gathered, the following countries are in Southeast Asia:
- **Vietnam**
- **Indonesia**
- **Thailand**
### Key facts summary
| Country | Capital | Region | Income level | Population | Land area |
|---------|---------|--------|--------------|------------|-----------|
| Vietnam | Hanoi | East Asia & Pacific | Upper middle income | 101,598,527 | 331,338.31 km² |
| Indonesia | Jakarta | East Asia & Pacific | Upper middle income | 285,721,236 | 1,916,906.77 km² |
| Thailand | Bangkok | East Asia & Pacific | Upper middle income | 71,619,863 | 513,115.02 km² |
### Population comparison
- **Indonesia** is by far the largest, with about **285.7 million** people.
- **Vietnam** is second, with about **101.6 million** people.
- **Thailand** is the smallest of the three, with about **71.6 million** people.
Indonesia’s population is roughly 2.8 times that of Vietnam and about 4 times that of Thailand.</result>
<completed_at>2026-09-30 00:53:13</completed_at>
</episode>
Now run the task and confirm the agent answers from the recalled European episodes.
result9 = await agent.run(task9)
print(result9)
INFO (llm_agents_fs.LLMAgent) : 🚀 Starting task: Which of the countries you have researched are in Western Europe? How do they compare in population and area? Answer from the country...[TRUNCATED] INFO (llm_agents_fs.TaskHandler) : ⚙️ Processing Step: Which of the countries you have researched are in Western Europe? How do they compare in population and area? Answer from the coun...[TRUNCATED] INFO (llm_agents_fs.TaskHandler) : ✅ Step Result: From the country information I have already gathered, the following countries are in Western Europe: - **Germany** - **France** ### K...[TRUNCATED] INFO (llm_agents_fs.TaskHandler) : No new step required. INFO (llm_agents_fs.LLMAgent) : 🏁 Task completed: From the country information I have already gathered, the following countries are in Western Europe: - **Germany** - **France** ##...[TRUNCATED] From the country information I have already gathered, the following countries are in Western Europe: - **Germany** - **France** ### Key facts summary | Country | Capital | Region | Income level | Population | Land area | |---------|---------|--------|--------------|------------|-----------| | Germany | Berlin | Europe & Central Asia | High income | 83,491,249 | 357,680 km² | | France | Paris | Europe & Central Asia | High income | 68,720,337 | 606,410 km² | ### Population comparison - **Germany** has the larger population, with about **83.5 million** people. - **France** has about **68.7 million** people. - Germany's population is roughly **14.8 million** larger than France's, or about **1.2 times** the size. ### Area comparison - **France** is much larger in land area, with about **606,410 km²**. - **Germany** has about **357,680 km²**. - France's land area is roughly **1.7 times** that of Germany.
Key Takeaway¶
similarity_memory() gives the agent something recency-based memory cannot:
context that is relevant to the current task, not merely recent.
The two queries issued in Parts 2 and 3 touched completely different regions. A recency strategy would have injected whichever three episodes happened to finish last into both queries. Those episodes are unlikely to be relevant to both Southeast Asia and Western Europe simultaneously. Similarity search retrieved the right subset for each query regardless of when those episodes were recorded.
The factory takes two parameters: collection (the Qdrant collection name, defaults
to "episodes") and max_results (how many similar episodes to recall, defaults to
5). Swapping in recency_memory() or reflective_memory() requires changing only
the one line that constructs the memory object — the agent and the rest of the
notebook are unchanged.