Long-Term Memory: the Store and Semantic Recall (LangGraph in Production, Module 10)

Module 10 of 16 18 min read Lab code ↗

Long-Term Memory: the Store and Semantic Recall (LangGraph in Production, Module 10)

This is Module 10 of LangGraph in Production, a free 16-module course that takes you from your first StateGraph to a deployed, durable autonomous coding agent on LangGraph v1.x. Start at Module 1 or browse the full syllabus.


The amnesia problem

Here is what Forge’s Module 9 run looked like on the issue “Bug: report.py crashes with KeyError when --year is used without --month.”

The Planner explored the Tally codebase, produced a plan targeting report.py, and the Editor wrote the fix. Three test-fix iterations later, the tests passed. Expensive — but it worked. The PR was opened and approved.

One week later, a similar issue came in: “Bug: report.py crashes with KeyError for empty category.” Forge ran the same pattern again. Three more fix iterations. Same root cause, same fix shape — totals.get(cat, 0.0) instead of totals[cat] — but Forge had absolutely no memory that it had solved this before. It also didn’t know that Tally follows strict import ordering, or that storage.py is effectively read-only in tests. It relearned the codebase from scratch.

The checkpointer saved the state of each run. But the moment a new thread_id was used, that history became invisible. The checkpointer is per-thread by design. The memory that must survive across all runs — across all threads, all issues, all restarts — lives somewhere else.

That somewhere else is the Store.

After this module, the Planner checks the Store for project conventions and past similar fixes before producing its Plan. Every successful run writes back to the Store. The more runs Forge does, the more precise its planning gets.


In this module

You’ll learn:

  • Distinguish the Store (cross-thread, persistent memory) from the checkpointer (per-thread, per-run state) and know when to reach for each
  • Provision an InMemoryStore (dev) and attach it to the graph via compile(store=store); understand the path to PostgresStore (production, theory)
  • Organize data with namespaces (Python tuples) and address entries cross-thread via (namespace, key)
  • Read and write the Store from a node using store.put(), store.get(), and store.search()
  • Enable semantic search by configuring InMemoryStore(index={"embed": ..., "dims": ...}) with a free local embedder
  • Access the Store from inside a node via the (state, *, store: BaseStore) signature — LangGraph injects it automatically

You’ll build: A long-term memory layer for Forge: forge/memory.py wraps the Store with helpers for Tally conventions and past fixes; a new recall node pulls semantic matches before planning; every successful run writes back to the Store.

Concepts covered: CA10 — Long-term memory (core). This module BUILDs CA10.1–CA10.6 of the LangGraph theory inventory.

Prerequisites: Modules 1–9, forge/ cumulative package (Planner, ForgeState with all 11 M2–M9 fields, SqliteSaver checkpointer from M6, RetryPolicy/CachePolicy from M9). A .env with ANTHROPIC_API_KEY, or use the Ollama fallback for $0. The local embedder used in this module is entirely free — no account, no key required.


Where we are

M1  ✅  Setup, StateGraph basics, Forge & Tally
M2  ✅  ForgeState, intake node, reducers
M3  ✅  Router, test-fix loop, sandbox, TestResult
M4  ✅  Tools, create_agent, Planner, Plan
M5  ✅  Send API, fan-out Editor×N, fan-in, defer=True
M6  ✅  SqliteSaver, threads, get_state, survive kill
M7  ✅  interrupt(), Command(resume=), plan/PR approval
M8  ✅  get_state_history, replay, fork via update_state
M9  ✅  durability=, RetryPolicy, CachePolicy, sandbox hardening
M10 👉  the Store, namespaces, semantic recall, memory-aware Planner
M11 ⬜  Streaming: get_stream_writer, astream_events, token-stream
M12–M16 ⬜

Forge before M10: resilient and durable, but amnesiac between runs. The Planner starts from zero every time.

Forge after M10: the Planner consults cross-thread memory — project conventions and past similar fixes — before producing its Plan. The memory survives across threads and restarts.

M11 will add streaming, so you can watch the Store being queried in real time. One sentence of teaser — that’s all you get until then.


The Checkpointer Is Not Enough: Store vs Checkpointer

This is the foundational concept of the module. Before looking at any code, you need this distinction locked down.

The checkpointer saves the state of a running graph. Every superstep — every node execution within a thread_id — gets a checkpoint. If Forge crashes mid-run, it resumes from the last checkpoint. If you want to time-travel to inspect a past state, you replay from a specific checkpoint_id. The checkpointer is the memory of a single execution.

The Store is different in kind, not just in scope. It is a shared key-value store that exists outside any single thread. When the Planner in thread issue-101 writes a fix summary, the Planner in thread issue-107 can read it. Restarts don’t affect it. It accumulates knowledge across every run Forge ever does.

The StoreCheckpointer
ScopeCross-thread — shared by all runs, all thread IDsPer-thread — scoped to a single thread_id
Addresses entries by(namespace, key) — a tuple + a stringthread_id + checkpoint_id
Survives restartsYes (with a durable backend like PostgresStore)Yes (with SqliteSaver or PostgresStore)
Typical use caseProject conventions, past fix patterns, user preferences, accumulated knowledgeState of an in-progress run, HITL pause/resume, time-travel debugging
Main APIstore.put(), store.get(), store.search()graph.get_state(), graph.update_state(), graph.get_state_history()
Attach to graphbuilder.compile(store=store)builder.compile(checkpointer=ckpt)
Created byYou, outside the graphYou, outside the graph

They are not alternatives. They are complementary. A production Forge uses both: the checkpointer to persist each run’s execution state and support HITL and time-travel, and the Store to accumulate knowledge that outlives any individual run.

One more clarification before moving on: CachePolicy (introduced in M9) is neither the Store nor the checkpointer. It is a node-level in-process cache — it avoids re-running an expensive node within the same process, but it evaporates on restart and is not shared across threads. If you want the analysis result to be available to a completely separate run six hours later, you need the Store.

Here is the architectural picture of the two planes:

graph TD
    subgraph "Thread A — issue-101"
        CA1[intake] --> CA2[triage] --> CA3[recall] --> CA4[planner]
        CA1 -. checkpoint .-> CKA[(SqliteSaver\nthread: issue-101)]
        CA2 -. checkpoint .-> CKA
        CA3 -. checkpoint .-> CKA
        CA4 -. checkpoint .-> CKA
    end

    subgraph "Thread B — issue-107"
        CB1[intake] --> CB2[triage] --> CB3[recall] --> CB4[planner]
        CB1 -. checkpoint .-> CKB[(SqliteSaver\nthread: issue-107)]
        CB2 -. checkpoint .-> CKB
        CB3 -. checkpoint .-> CKB
        CB4 -. checkpoint .-> CKB
    end

    subgraph "The Store — cross-thread"
        NS1["(forge, conventions)\nproject-rules, style-guide..."]
        NS2["(forge, fixes)\nissue-101: summary+files\nissue-103: summary+files..."]
    end

    CA3 -- "store.search()\nstore.get()" --> NS1
    CA3 -- "store.search(query=issue)" --> NS2
    CB3 -- "store.search()\nstore.get()" --> NS1
    CB3 -- "store.search(query=issue)" --> NS2
    CA4 -. "record_fix after run" .-> NS2
    CB4 -. "record_fix after run" .-> NS2

Thread A and Thread B each have completely separate checkpoint histories. But they both read from — and write to — the same Store. That is the whole point.


What the Store is

InMemoryStore lives at langgraph.store.memory. It is the development-friendly backend: no external service, no configuration, runs in the same process. Its production counterpart is PostgresStore (from langgraph.store.postgres), backed by a shared Postgres database — all worker instances read and write the same Store. We cover PostgresStore in the “In production” section; the lab uses InMemoryStore.

BaseStore (from langgraph.store.base) is the abstract interface both backends implement. Your helper functions should type-hint against BaseStore so they work with any backend.

Namespaces: always a tuple

A namespace is a Python tuple — ("forge", "conventions"), ("forge", "fixes"). Never a bare string. This is one of the most common mistakes new users make. The Store will happily accept a string "forge_fixes", but it creates a completely separate namespace from ("forge", "fixes"). You will write to one and read from the other and get nothing. We’ll look at this trap in the Mastery corner.

Namespaces exist to give you hierarchical organization across the Store. In a multi-tenant system, you would segment by (user_id, "fixes"). For Forge on Tally, we segment by knowledge type:

# forge/memory.py
CONVENTIONS_NS = ("forge", "conventions")
FIXES_NS       = ("forge", "fixes")

Defining namespaces as module-level constants eliminates the typo trap entirely. If any node imports FIXES_NS, they all use the same tuple.

The three core operations

Here is the complete Store API that you will use in Forge:

from langgraph.store.base import BaseStore
from langgraph.store.memory import InMemoryStore

store: BaseStore = InMemoryStore(...)   # more on construction below

# Write an entry
store.put(("forge", "fixes"), key="issue-42", value={"summary": "use totals.get(cat, 0.0)"})

# Read a specific entry by key
item = store.get(("forge", "fixes"), key="issue-42")
# item is a SearchItem or None
# item.value  -> {"summary": "use totals.get(cat, 0.0)"}
# item.key    -> "issue-42"
# item.namespace -> ("forge", "fixes")

# Search entries (exact or semantic depending on whether index= was configured)
results = store.search(("forge", "fixes"), query="report KeyError", limit=3)
# results -> list[SearchItem]; item.score is float if semantic, None if exact

All three operations have async counterparts — await store.aput(...), await store.aget(...), await store.asearch(...) — for use in async nodes. The lab uses the sync versions since Forge’s graph is synchronous.

A SearchItem carries these fields: .key, .value, .namespace, .created_at, .updated_at, and .score (a float when semantic search is active, None for exact search).

The helpers in forge/memory.py

The actual lab code wraps these three primitives in thin, named helpers:

# forge/memory.py (full file — 60 lines including docstring and HashEmbeddings)

def add_convention(store: BaseStore, key: str, text: str) -> None:
    store.put(CONVENTIONS_NS, key, {"text": text})

def conventions(store: BaseStore, limit: int = 10) -> list:
    return store.search(CONVENTIONS_NS, limit=limit)

def remember_fix(store: BaseStore, key: str, issue: str, summary: str) -> None:
    store.put(FIXES_NS, key, {"issue": issue, "summary": summary})

def recall_fixes(store: BaseStore, query: str, limit: int = 3) -> list:
    return store.search(FIXES_NS, query=query, limit=limit)

Nothing clever here — the point is to give every node a stable, named abstraction instead of inline namespace tuples scattered across the codebase. When you add a PostgresStore in production, none of these helpers change.


Semantic Search: index= and init_embeddings

Without an index= configuration, store.search() does exact lookups — it matches on value fields or metadata, not on natural language similarity. That is useful for precise retrieval by key, but it does not help you find “the past fix most similar to this new issue.”

For that you need semantic search: entries are embedded (as vectors) at write time; queries are embedded at search time; cosine similarity selects the limit closest results. The score field on each SearchItem carries the similarity value.

You activate semantic search by passing an index= argument to the Store constructor:

from langgraph.store.memory import InMemoryStore
from langchain_core.embeddings import Embeddings

store = InMemoryStore(index={"embed": embeddings_instance, "dims": 384})

embed is any Embeddings object (from langchain_core.embeddings.Embeddings). dims must match the vector dimension your embedder produces.

Choosing an embedder

For the lab, we use a deterministic local embedder (HashEmbeddings) that costs nothing, requires no account or network, and is bundled directly in forge/memory.py:

# forge/memory.py — HashEmbeddings: $0, no API, fully offline
class HashEmbeddings(Embeddings):
    """Deterministic local embedder. Enough to exercise the Store offline."""

    def __init__(self, size: int = 64) -> None:
        self.size = size

    def _vec(self, text: str) -> list[float]:
        h = hashlib.sha256(text.encode()).digest()
        return [h[i % len(h)] / 255.0 for i in range(self.size)]

    def embed_query(self, text: str) -> list[float]:
        return self._vec(text)

    def embed_documents(self, texts: list[str]) -> list[list[float]]:
        return [self._vec(t) for t in texts]

The factory function that assembles the Store:

# forge/memory.py
def make_store(embeddings: Embeddings | None = None, dims: int = 64) -> InMemoryStore:
    embeddings = embeddings or HashEmbeddings(dims)
    return InMemoryStore(index={"embed": embeddings, "dims": dims})

In production, swap HashEmbeddings for a real embedder in a single line:

from langchain.embeddings import init_embeddings

# High-quality local embedder ($0 after download, ~80MB model):
embeddings = init_embeddings("sentence_transformers:all-MiniLM-L6-v2")  # dims=384

# Or the best fully offline option with no PyTorch dependency:
# embeddings = init_embeddings("fastembed:BAAI/bge-small-en-v1.5")      # dims=384

# Or OpenAI (paid, best quality, dims=1536):
# embeddings = init_embeddings("openai:text-embedding-3-small")

The init_embeddings function is from langchain.embeddings (v1.x). It resolves provider prefixes the same way init_chat_model does for language models — same idiom, same agnosticism.

The course uses HashEmbeddings as the offline default so the entire lab runs at $0 and requires no additional dependencies beyond what you already have. The sentence_transformers and fastembed alternatives are documented in memory.py as one-line swaps for when you want real semantic quality.

Why semantic recall matters

Exact lookup (store.search(ns) without a query) gives you the N most recently added items. That is fine for conventions (you want all of them), but it misses the point for past fixes. You don’t want the most recent fix — you want the fix most similar to the current issue. If the current issue is about a KeyError in report.py, you want the earlier fix where you caught the same pattern, not the last chore commit about updating a README.

With index={"embed": ..., "dims": ...} configured, calling store.search(FIXES_NS, query=state["issue"], limit=3) gives you the three past fixes most semantically similar to the current issue text. The Planner can then include those in its context before generating a Plan.


Store in a Node: the recall Node

The Store is injected into any node that declares it as a keyword argument in its signature. This is the cleanest, most explicit way to access it.

Three ways to access the Store from a node

Option 1 — store keyword argument (recommended):

from langgraph.store.base import BaseStore

def recall(state: ForgeState, *, store: BaseStore) -> dict:
    hits = store.search(FIXES_NS, query=state["issue"], limit=3)
    # ... process hits ...

LangGraph inspects the node’s signature at graph-compile time. If it finds a store keyword argument, it injects the Store object that was passed to compile(store=store). You do not call anything special — the injection is automatic.

Option 2 — get_store from langgraph.config:

from langgraph.config import get_store

def some_helper() -> BaseStore:
    return get_store()

This works inside any helper function called during a node’s execution, without threading store through every function signature. Useful for deep call stacks.

Option 3 — Runtime.store via context_schema:

If you are using context_schema and Runtime[Context] (the v1.x pattern for typed configuration), the Store is also accessible as runtime.store. This is an advanced pattern covered more in M12 — not needed here.

For Forge, option 1 is right: explicit, testable (you pass store directly in tests), and the signature documents the node’s dependency clearly.

The recall node

The module introduces a dedicated recall node that sits between triage and planner in the graph. Rather than modifying the Planner node itself, a separate recall node pulls memory into the message history, making it available as context for everything downstream:

# forge/nodes.py — Module 10 addition
def recall(state: ForgeState, *, store=None) -> dict:
    """Pull repo conventions + relevant past fixes into context.

    Store injected by runtime when compiled with store=...; without it, a no-op.
    """
    if store is None:
        return {}
    notes: list[str] = []
    for c in memory.conventions(store):
        notes.append(f"Convention: {c.value.get('text', '')}")
    for f in memory.recall_fixes(store, state.get("issue", "")):
        notes.append(f"Past fix: {f.value.get('summary', '')}")
    if not notes:
        return {}
    return {"messages": [AIMessage(content="Relevant memory:\n" + "\n".join(notes))]}

This design has three properties worth noting:

  1. Graceful degradation. If store is None (the graph was compiled without a Store), recall returns an empty dict — a transparent no-op. The graph topology does not change; memory is additive.

  2. Uses the message channel. The memory context flows into ForgeState["messages"], the same Annotated[list[AnyMessage], add_messages] channel that carries all agent scratchpad content. The Planner reads state["messages"] as context, so it automatically sees what recall injected without any additional wiring.

  3. Separation of concerns. The planner node (and the agents.plan_change function it calls) does not need to know about the Store at all. The recall node handles memory retrieval; the planner node handles planning. This is cleaner than folding Store access into the planner function, which already has enough to do.

Wiring recall into the graph

Here is the relevant section of forge/graph.py:

# forge/graph.py — Module 10 additions
from forge.memory import make_store

def build_graph(checkpointer=None, store=None):
    builder = StateGraph(ForgeState)
    # ... other nodes ...
    builder.add_node("recall", nodes.recall)
    builder.add_node(
        "planner", nodes.planner,
        cache_policy=CachePolicy(key_func=lambda s: s.get("issue", ""), ttl=300),
    )
    # Triage now routes to recall, which chains to planner
    builder.add_conditional_edges(
        "triage", nodes.route_by_type,
        {"bug": "recall", "feature": "recall", "chore": "recall"},
    )
    builder.add_edge("recall", "planner")
    # ... rest of graph unchanged from M9 ...
    return builder.compile(checkpointer=checkpointer, store=store, cache=InMemoryCache())

Three things to notice:

  • compile(store=store) sits alongside compile(checkpointer=checkpointer, cache=InMemoryCache()) from M6 and M9. No existing parameter was removed — this is purely additive.
  • The recall node takes store=None in its signature. When build_graph is called with store=make_store(), LangGraph injects that Store object into every node that declares store as a kwarg.
  • The CachePolicy on the planner node is preserved from M9. The cache and the Store co-exist without conflict: CachePolicy is an in-process cache for repeated identical issues within a session; the Store is the cross-thread long-term memory.

The flow looks like this:

flowchart TD
    A[intake] --> B[triage]
    B -->|route| C[recall]
    C -->|store.search FIXES_NS| D1[(Store\nfixes)]
    C -->|store.search CONVENTIONS_NS| D2[(Store\nconventions)]
    D1 -->|SearchItems| C
    D2 -->|SearchItems| C
    C -->|AIMessage: 'Relevant memory: ...'| E[planner]
    E -->|Plan| F[plan_approval]
    F -->|approved| G[editor × N]
    G --> H[apply]
    H --> I[tester]
    I -->|passed| J[pr_approval]
    J -->|"remember_fix(store, ...)"| D1

The Planner sees the conventions and past-fix summaries as part of its message context. On a successful run, calling memory.remember_fix(store, issue_id, ...) writes the fix summary back to the Store so the next Planner invocation can benefit from it.


⚠️ Common misconception: “the Store is just the checkpointer with a different API”

This is the most damaging confusion in this module, because it leads to using the wrong tool entirely.

The checkpointer is scoped to a thread_id. It knows everything about a specific run: every node that executed, every state value at every superstep, every human-approval event. That history is invisible to any other thread_id. This is by design — it enables per-run HITL, time-travel, and crash recovery without one run’s state contaminating another.

The Store has no concept of thread_id. It is flat across all threads. When you call store.put(ns, key, value), that value is immediately visible to every other thread, every other process, every future invocation of the graph — forever, until explicitly deleted or expired.

A second confusion: many developers configure an InMemoryStore without index={"embed": ..., "dims": ...}, then call store.search(ns, query="some text") expecting vector similarity matching. Without index=, the query parameter is treated as an exact filter — not a semantic query. The returned SearchItem objects will have score=None. The Store does not silently embed your query and pretend semantic search is happening. If you see score=None, check your Store constructor.

Finally, do not call the Store “the vector DB.” The canonical term is the Store (per 06-FIL-ROUGE-SPEC.md §5.6). It optionally supports vector-based recall via index=, but that is a feature of the Store, not its identity.


Hands-on lab: Build It

What you will build

You will add long-term memory to Forge by introducing forge/memory.py, wiring a recall node into the graph between triage and planner, and attaching a Store to compile(). At the end of the lab, running two consecutive issues will show the second Planner benefiting from the first run’s recorded fix.

Lab cost: This lab’s live run (with FORGE_LIVE_TESTS=1) costs approximately $0.15–0.20 at June 2026 Anthropic pricing. The offline tests described below cost $0.00 — no API key required. The Ollama fallback (set FORGE_MODEL_PROVIDER=ollama) also costs $0.00.

Step 1: forge/memory.py (new file)

Create forge/memory.py with the namespace constants, the local HashEmbeddings class, the make_store() factory, and the four helper functions:

# forge/memory.py
from __future__ import annotations
import hashlib
from langchain_core.embeddings import Embeddings
from langgraph.store.base import BaseStore
from langgraph.store.memory import InMemoryStore

CONVENTIONS_NS = ("forge", "conventions")
FIXES_NS       = ("forge", "fixes")


class HashEmbeddings(Embeddings):
    """Deterministic $0 embedder. Exercises the Store API with no API calls."""
    def __init__(self, size: int = 64) -> None:
        self.size = size
    def _vec(self, text: str) -> list[float]:
        h = hashlib.sha256(text.encode()).digest()
        return [h[i % len(h)] / 255.0 for i in range(self.size)]
    def embed_query(self, text: str) -> list[float]:
        return self._vec(text)
    def embed_documents(self, texts: list[str]) -> list[list[float]]:
        return [self._vec(t) for t in texts]


def make_store(embeddings: Embeddings | None = None, dims: int = 64) -> InMemoryStore:
    embeddings = embeddings or HashEmbeddings(dims)
    return InMemoryStore(index={"embed": embeddings, "dims": dims})

Then the helpers:

# forge/memory.py (continued)
def add_convention(store: BaseStore, key: str, text: str) -> None:
    store.put(CONVENTIONS_NS, key, {"text": text})

def conventions(store: BaseStore, limit: int = 10) -> list:
    return store.search(CONVENTIONS_NS, limit=limit)

def remember_fix(store: BaseStore, key: str, issue: str, summary: str) -> None:
    store.put(FIXES_NS, key, {"issue": issue, "summary": summary})

def recall_fixes(store: BaseStore, query: str, limit: int = 3) -> list:
    return store.search(FIXES_NS, query=query, limit=limit)

This is the complete forge/memory.py. The BaseStore import (from langgraph.store.base import BaseStore) is the correct v1.x path.

Step 2: add the recall node to forge/nodes.py

Add the import of memory at the top (it is already in the lab’s nodes.py via from forge import agents, config, memory, sandbox). Then add the recall function:

# forge/nodes.py — new node, Module 10
from langchain_core.messages import AIMessage
from forge import memory

def recall(state: ForgeState, *, store=None) -> dict:
    """Pull conventions + relevant past fixes from the Store into messages."""
    if store is None:
        return {}
    notes: list[str] = []
    for c in memory.conventions(store):
        notes.append(f"Convention: {c.value.get('text', '')}")
    for f in memory.recall_fixes(store, state.get("issue", "")):
        notes.append(f"Past fix: {f.value.get('summary', '')}")
    if not notes:
        return {}
    return {"messages": [AIMessage(content="Relevant memory:\n" + "\n".join(notes))]}

The store=None default is intentional: it makes the node testable without a Store, and makes the graph forward-compatible — you can compile without a Store and the node is a harmless no-op.

Step 3: wire recall into forge/graph.py

The changes to graph.py are purely additive — no existing node, edge, or compile parameter is removed:

# forge/graph.py — Module 10 changes (additions only)
from forge.memory import make_store           # new import

def build_graph(checkpointer=None, store=None):
    builder = StateGraph(ForgeState)
    # ... existing nodes (intake, triage, planner, plan_approval, ...) ...
    builder.add_node("recall", nodes.recall)  # new node

    # triage now routes to recall (previously routed to planner)
    builder.add_conditional_edges(
        "triage", nodes.route_by_type,
        {"bug": "recall", "feature": "recall", "chore": "recall"},
    )
    builder.add_edge("recall", "planner")     # recall → planner
    # ... rest of edges unchanged ...

    return builder.compile(
        checkpointer=checkpointer,
        store=store,               # new parameter — None is valid (graceful degradation)
        cache=InMemoryCache(),     # M9 cache preserved
    )

To run with the Store active, instantiate it before calling build_graph:

from forge.memory import make_store
from forge.graph import build_graph

store = make_store()
# Seed initial conventions — idempotent (no-op if already present)
memory.add_convention(store, "empty-categories", "Always use totals.get(cat, 0.0)")
memory.add_convention(store, "storage-py", "Do not modify storage.py — tests mock the file")

graph = build_graph(checkpointer=ckpt, store=store)

After a successful run, record the fix:

# After tests pass — record so the next Planner benefits
memory.remember_fix(
    store,
    key="issue-101",
    issue="report crashes on empty category",
    summary="replaced totals[cat] with totals.get(cat, 0.0) in report.py",
)

Step 4: verify with the offline tests

The tests/test_module_10.py file exercises every piece of forge/memory.py without touching an API:

# tests/test_module_10.py
from forge import memory, nodes

def test_store_put_search_conventions_and_fixes():
    store = memory.make_store()
    memory.add_convention(store, "c1", "Use totals.get for safe dict access")
    memory.remember_fix(store, "f1", "report KeyError", "used totals.get(cat, 0.0)")
    assert len(memory.conventions(store)) == 1
    fixes = memory.recall_fixes(store, "report crashes on empty category")
    assert len(fixes) == 1 and "totals.get" in fixes[0].value["summary"]

def test_recall_node_without_store_is_noop():
    assert nodes.recall({"issue": "x"}) == {}

def test_recall_node_injects_memory_with_store():
    store = memory.make_store()
    memory.add_convention(store, "c1", "Always run pytest before opening a PR")
    out = nodes.recall({"issue": "report crashes"}, store=store)
    content = out["messages"][-1].content
    assert "Relevant memory" in content and "pytest" in content

And tests/test_pipeline.py adds an integration test that exercises the full graph with a Store:

# tests/test_pipeline.py (excerpt)
def test_store_recall_feeds_the_planner(monkeypatch, make_routed_model):
    repo = _setup(monkeypatch, make_routed_model)
    store = memory.make_store()
    memory.add_convention(store, "c1", "report.py must handle empty categories")
    graph = build_graph(checkpointer=checkpoint.in_memory(), store=store)
    cfg = {"configurable": {"thread_id": "mem"}, "recursion_limit": 50}

    r1 = graph.invoke({"issue": "report crashes on empty category", "repo_path": repo}, cfg)
    # recall ran and injected the convention before the planner executed
    msgs = " ".join(m.content for m in r1["messages"] if isinstance(getattr(m, "content", None), str))
    assert "Relevant memory" in msgs and "empty categories" in msgs

Run all offline tests:

cd module-10
uv run pytest tests/ -v

Expected output: all tests pass, including the pytest of Tally’s own test suite (which verifies the sandbox fix works — deterministic, no API).

Try it yourself

  1. Two consecutive runs: seed the Store with a convention and a first fix, then run a second similar issue. Check that the second run’s message history contains "Relevant memory:" with relevant content. Compare plan quality between the two runs.

  2. Disable semantic search: construct InMemoryStore() without index= and call store.search(FIXES_NS, query="report crash"). Observe that the results have score=None. Then re-enable index= and observe non-null scores.

  3. Swap the embedder: replace HashEmbeddings with init_embeddings("sentence_transformers:all-MiniLM-L6-v2") (install sentence-transformers from PyPI first). Run the same two-run scenario and compare recall quality.


In production

Use PostgresStore for durability

InMemoryStore evaporates when the process restarts. For real production, you need PostgresStore:

# Theory — not built in this lab
from langgraph.store.postgres import PostgresStore

store = PostgresStore.from_conn_string("postgresql://user:pw@host:5432/forge_db")

PostgresStore uses the same BaseStore interface — your helper functions in forge/memory.py work unchanged. All worker instances share the same Postgres database, so the Store is truly cross-process, not just cross-thread.

Note: PostgresStore is not a required dependency in this module’s pyproject.toml. Add langgraph-checkpoint-postgres (which bundles both the checkpointer and the Store backend) as an optional dependency, or a separate [extras] group, to avoid pulling in Postgres drivers for developers who don’t need them.

Define namespaces as constants — always

In production, a namespace mismatch is a silent partition. One instance writes to ("Forge", "Fixes"), another reads ("forge", "fixes") — completely different namespaces. The Store does not validate or normalize casing. Define every namespace as a module-level constant in forge/memory.py and import those constants everywhere. Never write namespace tuples inline in node code.

Think about TTL

A fix recorded in June 2024 might be stale if the code has been refactored since. PostgresStore supports a native TTL on entries (specify ttl=timedelta(days=90) on store.put()). InMemoryStore does not support TTL — entries live until the process restarts or you explicitly delete them. In production, archive or expire old entries. Otherwise the Store grows without bound, and semantic search degrades as the signal-to-noise ratio of recalled results drops.

Embedder costs at scale

With HashEmbeddings (the lab default), store.search() calls are pure CPU — no network, no cost. With sentence_transformers or fastembed, it is still CPU only, but it loads a small local model (~80MB) at startup. With openai:text-embedding-3-small, every store.search() call is an API call (about $0.02 per million tokens — trivial per call, but it adds up at scale). Plan your embedder choice with an eye on the call frequency.

The Store is not a substitute for the checkpointer

If you need HITL resumption, time-travel debugging, or crash recovery within a single run — use the checkpointer. The Store has no concept of supersteps, checkpoint_id, or run-level state snapshots. Using them together is not redundant; it is the intended architecture.

A note on M14: when you deploy Forge to LangGraph Platform, langgraph.json supports a store: field for configuring the Store backend declaratively. That is a one-module teaser — we cover it properly in Module 14.


Mastery corner

What to really understand here

The Store is not a smarter checkpointer. It is a completely separate abstraction that operates at a different time scale. The checkpointer captures transient execution state within a single thread; the Store accumulates persistent knowledge across every thread, every run, every restart. They are complementary — production Forge uses both.

Namespaces being tuples is not a minor implementation detail. The Store allows any hashable as a namespace key, and a tuple and a string hash to different values. If your put and search calls use inconsistent namespace types, you will silently read from an empty namespace.

Semantic search is opt-in. You activate it by configuring index={"embed": ..., "dims": ...}. Without it, store.search(ns, query=...) does exact matching. The distinction is not in the function signature — it is in the Store constructor.

The three access patterns — store kwarg injection, get_store(), and Runtime.store — are all valid. Prefer the kwarg: it makes the node’s dependency on the Store explicit, which makes the node testable in isolation.


Questions

Question 1. Your team wants Forge to remember Tally’s coding conventions across all runs. A developer proposes writing the conventions into ForgeState and relying on the checkpointer to persist them. What is the problem with this approach, and what is the correct solution?

A. Nothing is wrong with this approach — the checkpointer persists state across all threads. B. ForgeState does not support dictionary values, so conventions must be stored externally. C. The checkpointer saves state per thread_id; conventions stored in ForgeState are invisible to any other thread. The correct solution is to write them to the Store using a shared namespace. D. The checkpointer only persists state for completed runs, so conventions would be lost mid-run.


Question 2. A node calls store.put("forge_fixes", "k1", {"summary": "fix"}). Later, a different node calls store.search(("forge", "fixes"), query="fix") and gets no results. What happened?

A. The query parameter requires index= to be configured — the search returns nothing without it. B. store.put requires the value to include a "text" field for search to work. C. "forge_fixes" (a string) and ("forge", "fixes") (a tuple) are different namespaces. The entry was written to one namespace and searched in another. D. store.search returns results asynchronously and must be awaited.


Question 3. A developer creates store = InMemoryStore() without index= and calls store.search(ns, query="ZeroDivisionError in report.py", limit=3). The call returns an empty list even though several fix entries exist in ns. Then they add index={"embed": HashEmbeddings(64), "dims": 64} to the constructor and re-run. Now results appear. What changed?

A. InMemoryStore without index= does not support the search method at all. B. Without index=, store.search performs exact key matching — since no key equals the query string, nothing is returned. With index=, the query is embedded and cosine similarity is used. C. The limit=3 parameter is ignored without index= — it always returns all entries or none. D. Adding index= initializes the Store’s storage backend; without it, put calls are silently discarded.


Question 4. You want the recall node to access the Store without changing its signature or threading a store argument through every helper. Which approach works?

A. Pass the Store as part of the initial graph.invoke() input dictionary. B. Declare store as a keyword argument in the node function signature — LangGraph injects it automatically from compile(store=store). C. Call from langgraph.config import get_store inside the helper function — it retrieves the Store from the current execution context. D. Both B and C are valid, and they work independently; only B makes the dependency explicit in the signature.


Question 5. The analyze_repo node in Forge has a CachePolicy(ttl=300) from Module 9. A developer proposes replacing this with a Store entry so the analysis persists across restarts. Is this a valid substitution?

A. Yes — the Store persists across restarts while CachePolicy does not, so the Store is strictly better. B. No — CachePolicy is a node-level in-process cache that avoids re-running a node within a session. The Store is cross-thread long-term memory. They solve different problems and co-exist in the same graph; replacing one with the other loses either the in-process speed-up or the cross-restart durability. C. No — the Store does not support storing complex analysis results, only short text strings. D. Yes, but only if you use PostgresStore, since InMemoryStore has the same TTL as CachePolicy.


Answers

Q1: C. The checkpointer is thread_id-scoped. Any state written into ForgeState by one thread is completely invisible to another thread. Conventions that should be shared across all runs belong in the Store, under a shared namespace like ("forge", "conventions").

Q2: C. The Store treats "forge_fixes" (a bare string) and ("forge", "fixes") (a tuple) as entirely separate namespaces. The entry was written to one and searched in the other. This is the namespace string-vs-tuple trap. Always define namespaces as tuple constants in a single module and import them wherever you reference the Store.

Q3: B. Without index=, store.search does not perform embedding or similarity ranking. It does a metadata/key-based exact match. Since no entry key matches the full query string, the result is empty. Adding index={"embed": ..., "dims": ...} enables embedding at write time and at search time, activating cosine similarity ranking. Option A is incorrect — the search method exists regardless of index=; it just behaves differently.

Q4: D. Option B (kwarg injection) is the most explicit: LangGraph detects store in the function signature and injects the Store automatically. Option C (get_store()) also works and is useful for helpers that are called deep in a call stack where threading store through every signature is impractical. Both are valid v1.x patterns; B is preferred for nodes because it makes the dependency visible in the signature. Option A is incorrect — you cannot pass the Store through the input state dictionary.

Q5: B. CachePolicy avoids redundant node execution within a single in-process session (it caches the node’s output in memory). It does not survive restarts. The Store persists across restarts and threads. They serve orthogonal purposes: CachePolicy for same-session speed, the Store for durable cross-run knowledge. The M9 cache=InMemoryCache() and M10 store=make_store() are both present in compile() in the same graph — they co-exist and complement each other. Replacing one with the other would sacrifice whatever the removed one provides.


Traps to avoid

Trap 1: Storing cross-thread knowledge in the checkpointer. If you write a convention, a past fix, or any state that must be shared across different thread_id values into ForgeState, the checkpointer will faithfully persist it — but only for that thread. The moment a new issue arrives with a new thread_id, that knowledge is gone. The rule: if the lifetime of the data exceeds a single run, use the Store.

Trap 2: Namespace type inconsistency. The Store accepts a string, a tuple, or any hashable as a namespace. "forge" and ("forge",) are different namespaces. "forge_fixes" and ("forge", "fixes") are different namespaces. You will not get an error when you mix them — you will just get empty search results. Always define namespaces as module-level tuple constants:

FIXES_NS = ("forge", "fixes")   # the constant is the source of truth

Never write ("forge", "fixes") inline in node code. Import FIXES_NS and use it everywhere.

Trap 3 (freshness trap): calling the Store “the vector database.” The canonical name is the Store. Its semantic search capability is activated by index={"embed": ..., "dims": ...}, but it is not a purpose-built vector database. It is a general-purpose key-value store that optionally supports embedding-based retrieval. Similarly: without index=, store.search(ns, query="text") is not doing semantic search — it is an exact match. Do not assume the word search implies a vector operation.


Key takeaways

  • The Store is cross-thread; the checkpointer is per-thread. They are complementary tools, not alternatives. The checkpointer handles execution state for a single run; the Store accumulates knowledge across all runs.
  • A namespace must be a tuple("forge", "fixes"), not "forge_fixes". Define all namespaces as module-level constants and import them everywhere to eliminate type inconsistency bugs.
  • InMemoryStore(index={"embed": embedder, "dims": N}) activates semantic search via store.search(ns, query="..."). Without index=, search is exact and .score is None.
  • Use a free local embedder (HashEmbeddings for offline tests, sentence_transformers for real quality) to keep lab costs at $0. OpenAI embeddings are documented as an alternative — they are never the default.
  • Access the Store from a node via the (state: ForgeState, *, store: BaseStore) signature — LangGraph injects it automatically from compile(store=store). No manual plumbing required.
  • CachePolicy (M9) and the Store (M10) co-exist: CachePolicy avoids re-running a node within the same session; the Store provides durable, cross-restart, cross-thread memory.
  • Long-term memory makes the Planner cumulatively better with each run — past fix summaries and project conventions in the Store let the Planner avoid known pitfalls from the first plan attempt.

What’s next + newsletter

Want the full LangGraph mastery question bank (interview-style) and the next module in your inbox? Subscribe here — it’s free, like everything in this series.

Forge can now remember. But you still can not see what it is thinking in real time. Module 11 adds streaming: watch the Planner query the Store, see the Editor work file by file, and stream the PR body token by token — with get_stream_writer and astream_events(version="v2").


Links:


References

  1. LangGraph Memory Concepts — official documentation on the Store, cross-thread memory, and namespaces. https://langchain-ai.github.io/langgraph/concepts/memory/ (as of June 2026)

  2. InMemoryStore API Referencelanggraph.store.memory. https://langchain-ai.github.io/langgraph/reference/store/ (as of June 2026)

  3. init_embeddings — LangChain Embeddings — the v1.x provider-agnostic embedder factory. https://python.langchain.com/docs/integrations/text_embedding/ (as of June 2026)

  4. sentence-transformers on PyPI — the all-MiniLM-L6-v2 model used as the local embedder alternative. https://pypi.org/project/sentence-transformers/ (as of June 2026)

  5. LangGraph How-to: Use the Store — practical examples of put, get, search, and namespace patterns. https://langchain-ai.github.io/langgraph/how-tos/memory/manage-conversation-history/ (as of June 2026)

  6. Lab code — Module 10forge/memory.py, forge/nodes.py (recall node), forge/graph.py (compile(store=...)), and tests/test_module_10.py. https://github.com/dupuis1212/langgraph-prod-labs/tree/main/module-10