Skip to content

File-Based vs. Vector Memory in Practice

Search a set of Edinburgh venues by meaning alone (a vector search, matching on what words are about rather than what they literally say) for "cozy venue with vegan options," and The Bow Bar shows up in the top 5 results — despite seating only 80 people and serving no vegan food. The search matched on the word "cozy" and had no way to enforce "vegan options" as a hard requirement rather than a vague mood. Add one filter, capacity >= 160 AND vegan = True, on top of the exact same search, and The Bow Bar disappears immediately. That's this lesson's whole argument in one before-and-after: the filter, not the embedding, was always doing the real work of enforcing what the query actually required.

Before you start

Prerequisite: Lesson 23, "Choosing an embedding model, and chunking strategies for retrieval quality" — you should know why this course defaults to Qwen3-Embedding-8B and what embeddings are structurally bad at (numbers, negation, exact match, ranges). After this lesson, you can: build a working file-based memory that survives a session restart with zero chat history, and explain — with your own retrieval comparison — why the metadata filter, not the embedding, does most of the work in a production vector query.

Both labs below, including the Edinburgh venue scenario that runs through them, are Rod Rivera's own design: two complete, runnable proofs on the same toy dataset. One shows a markdown file carrying memory across a session boundary with nothing else helping it — that's Lab 2, first. The other is the filtering result above, in full — that's Lab 3.

Lab 2: a markdown file with zero chat history passed

The claim under test: not every persistence problem needs a database. Sometimes a file is the correct answer, and this lab is built to prove it rather than assert it.

python
class CLAUDEStyleMemory:
    def __init__(self, session_dir: str):
        self.file = Path(session_dir) / "CLAUDE.md"
        if not self.file.exists():
            self.file.write_text(
                "# Edinburgh Events Agent — Project Memory\n\n"
                "You are Rod's Edinburgh events assistant. You remember user "
                "preferences and past venue experiences across sessions.\n\n"
                "## User profile\n\n(empty)\n\n"
                "## Known venues\n\n(empty)\n\n"
                "## Past failures\n\n(empty)\n"
            )

    def load(self) -> str:
        return self.file.read_text()

    def append_to_section(self, section: str, line: str):
        text = self.file.read_text()
        marker = f"## {section}"
        idx = text.find(marker)
        # ... find section end, strip "(empty)" placeholder, append line
        self.file.write_text(text)

agent_turn() loads the file as the system prompt on every call — there is no retrieval step, the whole file is simply in the prompt — while a small extractor pulls user-stated facts out of each message and appends them to the right section.

Session 1 tells the agent three things across two turns: "I'm Rod, planning an event for 160 people, vegan options needed, budget £800," and "I tried The Bow Bar last month — they only fit 80." Both get written to CLAUDE.md: a user-profile fact, a known-venue fact, and — critically — a past-failure fact.

Session 2 is the actual test. A brand-new CLAUDEStyleMemory object points at the same directory. No chat history is passed — this is a fresh conversation in every sense a chat API would recognize. The only question asked: "suggest a venue for tonight." The agent addresses Rod by name, cites the 160-person and vegan constraints, and does not suggest The Bow Bar.

Files changed
CLAUDE.md
Terminal
$
python lab2_file_memory.py

None of that recall came from conversation memory, because there wasn't any — Session 2's object never saw Session 1's messages. It came entirely from the file, loaded fresh as the system prompt. That is the whole proof: Path.write_text() is the entire transaction layer, there is no database, no server, no vector index, and the agent's complete knowledge state is one cat CLAUDE.md away from being readable by a human.

The one piece of real complexity in the build is the append-to-section logic — editing markdown sections programmatically is fiddlier than it looks. Production systems settle on one of three patterns: parse and rewrite the whole file, wrap a structured format (YAML, TOML, JSON) inside a markdown shell, or split into separate per-topic files — profile.md, venues.md, failures.md — so each write only touches one small file instead of re-parsing a growing document. This pattern doesn't scale past a few dozen or hundred facts; when it stops being enough, a vector store gets added next to the files, not instead of them, which is exactly what Lab 3 builds next.

Two architectures, same Edinburgh data: Lab 2 vs. Lab 3

Lab 3: vector memory, and what the filter is actually doing

Files work well for a few dozen facts and start to hurt at hundreds or thousands. At that scale, three things are needed: similarity search, metadata filters, and persistent storage — all three, built here with Chroma (in-process, no server) and the Qwen3-Embedding-8B endpoint from the previous lesson, against six Edinburgh venues carrying real structured metadata.

python
VENUES = [
    {"id": "v1", "name": "The Banshee Labyrinth", "capacity": 250,
     "vegan": True, "area": "Old Town", "price_gbp": 1200},
    {"id": "v2", "name": "The Bow Bar", "capacity": 80,
     "vegan": False, "area": "Old Town", "price_gbp": 400},
    {"id": "v3", "name": "The Albanach", "capacity": 180,
     "vegan": True, "area": "Old Town", "price_gbp": 900},
    {"id": "v4", "name": "Hemma", "capacity": 150,
     "vegan": True, "area": "Holyrood", "price_gbp": 750},
    # v5, v6: The Haymarket Vaults, The Dome
]

collection.add(
    ids=[v["id"] for v in VENUES],
    documents=[v["notes"] for v in VENUES],
    metadatas=[{k: v[k] for k in ("name", "capacity", "vegan", "area",
                "last_verified", "price_gbp")} for v in VENUES],
)

Three queries against the same six venues, escalating in how many constraints they carry:

Q1, no filter. This is the Bow Bar result from the top of this lesson: "cozy venue with vegan options" returns it in the top 5 — capacity 80, not vegan. The embedding matched on the word "cozy" and has no mechanism to enforce that "vegan options" is a hard requirement rather than a vague vibe.

Q2, one filter. The same query, with capacity >= 160 AND vegan = True added — the fix from the top of this lesson. The Bow Bar disappears immediately, along with every other venue that fails the constraint. What's left is ranked by semantic relevance among candidates that are all actually viable — which is what "semantic ranking" should have been doing the whole time.

Q3, four filters stacked. Capacity, vegan, price under £1000, and Old Town specifically. Only one or two venues survive. At this point the semantic ranking is close to irrelevant — once hard constraints have narrowed the field, which survivor is "most semantically similar" is a tiebreaker, not the actual selection mechanism.

python
collection.query(
    query_texts=["cozy venue with vegan options"], n_results=5,
    where={"$and": [{"capacity": {"$gte": 160}}, {"vegan": True}]},
)
Terminal
$
python lab3_vector_filters.py

The metadata filter is doing 90% of the work in that query, and this holds in most production RAG, not just this toy example. The embedding handles the fuzzy semantic part; the filter handles everything embeddings are structurally bad at — numbers, booleans, dates. Neither alone is sufficient, and this is not an argument against vectors: without the embedding step, "cozy" would never have surfaced anything at all. It's an argument that the filter is doing the load-bearing work of enforcing what the query actually required.

One more thing the metadata unlocks for free: staleness. With a last_verified field on every venue, filtering to "verified in the last 30 days" — or boosting recent ones in scoring — becomes a one-line addition. Without structured metadata, the only option is embedding the date into the prose and hoping the model notices, which it won't reliably. And the entire Lab 3 exercise, across all three queries, cost under $0.0001 in Nebius embedding calls — the same conclusion lesson 23 already drew about embedding cost never being the bottleneck, now with a real number attached rather than an assertion.

Quick check — Q1 in Lab 3 returns The Bow Bar (capacity 80, non-vegan) in the top 5 for the query 'cozy venue with vegan options.' What does Q2's result show about why?

What to carry into the next lesson

Two labs, two proofs, on toy data. Everything from here forward moves from Edinburgh-scale demonstration to something closer to production: PyNanoClaw's actual memory subsystem, its module structure, and the formal protocol that lets file, vector, and hybrid backends all satisfy the same interface.

Continue to Lesson 25

Reverse-engineering NanoClaw's architecture, the seven patterns PyNanoClaw keeps and the ones it drops, and the formal Memory protocol the rest of the course builds against.

Have a question about this lesson?

Reply here and it goes straight to Rod. Same as replying to one of his emails.