Project 2: NanoMem

CS145: Stanford University, Fall 2026. Systems Track.

Technical Memo: Build a Memory Engine for AI Agents

Dear engineers, your AI agent has goldfish memory.

Every coding agent on your laptop (Claude Code, Cursor, Copilot) starts each session blind. It forgets yesterday's design decisions, hallucinates function names that do not exist, and re-derives bug fixes you already shipped. You read about this in Case Study 0.2: claude-mem and QMD are 2026 tools that fix it by indexing your codebase and your decisions into a local SQL database, so the agent can look things up before it makes things up.

In Project 1 you queried other people's databases. In Project 2 you build the engine the next generation of AI tools is built on.

Mission: build NanoMem, a search engine that gives an AI coding agent persistent memory across sessions. Run it on two contrasting datasets, 2 million code functions and 8.8 million web passages. Race it against the production tools already shipping in a billion phones.

If Tobi Lütke (CEO of Shopify) shipped the first version of QMD in a weekend, you can build a deeper version in five weeks.

Project Scope

Estimated Work-Back Schedule

Proposal due Oct 30. Final due Nov 20. Five weeks of work, about 8 hours a week. Jot AI surprises as they happen so you can write them up later.

Week 1 · Get a baseline working

Oct 17 to Oct 23 · 8 to 10 hours · Phases 0 + 1 (start)

Load both 100 MB datasets into SQLite with FTS5 and sqlite-vec. Run your first BM25 query and your first vector query on both datasets. You have a two-dataset search engine in 30 lines. Then start hand-rolling the inverted index on the toy dataset.

Week 2 · Hand-roll BM25 and ship the proposal

Oct 24 to Oct 30 · 8 to 10 hours · Phase 1 + proposal

Finish your inverted index and BM25 scoring. Pass the parity test against FTS5 on the 100 MB code dataset. Submit the proposal at the end of this week with Phase 0 already working: FTS5 and sqlite-vec query output from both datasets, a paragraph on what surprised you, then your plans (target tier, custom fusion, partner divvy).

Week 3 · Hand-roll LSH

Oct 31 to Nov 6 · 8 to 10 hours · Phase 2

Random projections, banding, candidate ranking by hit count, exact cosine re-rank. Pass the parity test against sqlite-vec. If your recall craters, revisit the FAQ on candidate selection.

Week 4 · See the dataset asymmetry

Nov 7 to Nov 13 · 7 to 8 hours · Phases 3 + 4

Build hybrid retrieval, your custom fusion variant, and the query planner. Run the two-dataset benchmark. This is where you watch the same engine produce opposite winners on code vs knowledge. The lesson is not which algorithm wins, it is which workload favors which. Spend a paragraph on it in your writeup notes.

Week 5 · Race the production tools

Nov 14 to Nov 20 · 5 to 7 hours · Phases 5 + 6

Run the production scoreboard: your numbers vs FTS5 and sqlite-vec, same dataset, same eval, same metrics. By the end you know exactly how close your hand-rolled engine got to industry baselines, where production wins, and why. Record the 5-minute demo (2 min of NanoMem running, 3 min walking through your code). Submit.

Expected total: about 34 to 60 hours of work, depending on tier (~7 to 12 hours a week solo). A team of 2 splits it: fewer hours each, or the same hours aimed a tier higher.

Tiers at a glance

Pick one row before you write the proposal. You can climb later, but the proposal asks you which tier you are aiming for so partners (and we) know how to plan the work. Tier 87 is the spec target; Tier 93 and Tier 100 are where memory starts to bite back.

Tier What you ship Dataset size Effort
Tier 80 Hand-rolled engine matches FTS5 + sqlite-vec on parity. End-to-end demo of all five retrievers running on both datasets. 100 MB (25K docs per dataset) 34 to 42 hours. ~350 LOC. Any laptop.
Tier 87
spec target
Same code at 10× the data. Where most students land. Workload asymmetry between code and knowledge becomes obvious. 1 GB (250K docs per dataset) 36 to 45 hours. ~350 LOC (same as Tier 80). Any laptop or free Colab CPU.
Tier 93 HashPartition with single-shard residency. Memory turns into the constraint that drives the design. Module 3 partitioning applied to retrieval. 5 GB (1.25M docs, at least one dataset) 41 to 52 hours. +80 LOC for shard build and query merge (~430 total). Free Colab CPU.
Tier 100 Streaming retrieval over Wikipedia at a 1 GB working set. The algorithm is the same; what changes is the discipline. 22 GB Wikipedia (6.7M articles → 25M passages) 48 to 60 hours. +80 more LOC for streaming embed and bounded vocab (~510 total). Colab T4 GPU for the embedding pass.
+5 bonus Top 3 quality-per-millisecond on the Tier 93 leaderboard. Independent of where you land on the ceiling. Tier 93 dataset Not LOC; profile and tune.

Final score = min(quality_score, tier_ceiling) + bonus. Quality is the bottleneck more often than scale; a polished Tier 87 project earns 87, a sloppy Tier 93 project earns 72. Push both.

Choosing between the two Project 2 options? In this project tiers are a scale ceiling: the same code, bigger data raises the cap. The Data Science project (BigQuery Part Deux) grades tiers the other way, adding a new section per tier.

Going further (optional, not graded). Fighting the memory ceiling at Tier 93 or Tier 100? One lever we deliberately did not require: stop storing float32 embeddings and quantize them instead. pip install turbovec, about five lines, and 384-dim MiniLM vectors compress roughly 8× at bit_width=4 with little recall loss. Measure Recall@k against peak memory and put the trade-off in your writeup. Why this works, when it does not, and the classic-PQ-versus-TurboQuant story: Case Study 2.4: Compressing a Billion Embeddings.

Why This Project Matters

Grading

Your final grade has two axes: a quality score out of 100 (judged from the 7-phase rubric below) and a tier ceiling (which row of Tiers at a glance you reach). Push both.

Metrics you will report at every scale

R@1, R@5, R@10
Recall at K. Did the right answer appear in your top 1, top 5, top 10? Averaged over all eval queries. Higher is better; 1.0 is perfect.
MRR
Mean reciprocal rank. 1 / (rank of the first correct answer), averaged. Captures "how high did the right answer rank?" not just "did it make the cut?"
p50, p95 ms
Latency. Median and 95th percentile query time in milliseconds.
Peak memory and working set
Two related numbers. Peak RSS (resident set size, via psutil) is the high-water mark your process actually held. Working set is what's live in memory at any one moment, which is what we grade against the 2 GB and 1 GB targets at Tier 93 and Tier 100. At Tier 80 and Tier 87 you just report both numbers, no cap. See Tier 93 FAQ for what to keep visible in your notebook so we can verify residency without re-running.

The per-tier ceilings and effort are in Tiers at a glance above. This section is the quality side: the 7-phase rubric that scores your 0-to-100 quality number, regardless of which tier you land.

Quality rubric (100 points)

Each phase carries its rubric, its tests, its parity bar, and a "what good looks like" inline in nanomem-template.py. Read the section header before you start coding. The point totals:

PhaseSectionPoints
0Production setup (SQLite + FTS5 + sqlite-vec, both datasets). The end-of-week-1 proposal IS this phase. Submit by then to bank the 10 points; miss the deadline and they forfeit.10
1Inverted index + BM25 from scratch (parity vs FTS5)20
2LSH for vector search from scratch (parity vs sqlite-vec)20
3Hybrid retrieval + query planner (RRF + your custom variant)15
4Two-dataset benchmark (all 5 retrievers, both datasets)10
5Production scoreboard (your numbers vs FTS5 and sqlite-vec)10
6Writeup + 5-min demo + AI takeaways15
Total100

The specific test functions, parity bars (e.g., "within X% of FTS5 on R@5"), and pedagogical goals for each phase are documented in the template's section headers. They are the canonical source; this table is the macro summary.

Reference baselines (informational, not graded)

What the staff reference produces, end-to-end, on real CodeSearchNet (Mac CPU, no GPU). Use these as a sanity check for your own numbers. They are not a target. If yours land in the same neighborhood you are probably correct; if you are far off, that is worth explaining in your writeup.

Aiming for Tier 93 or Tier 100? See Tier 93 FAQ for the sharded-recall heads-up, the iterate-on-50 / report-on-500 tip, and what "single-shard residency" means for grading.

TierBuildEval p50R@5Working setNotes
Tier 80 (100 MB, unsharded)< 1 s~30 ms~0.98~1.2 GBrecall ceiling, no constraints
Tier 80 (100 MB, sharded N = 4)~1 s~360 ms~0.72~1.1 GBsharded retrieval tradeoff visible already
Tier 87 (1 GB, unsharded)~5 s~250 ms~0.88~1.5 GBspec target. No sharding needed at this tier.
Tier 93 (5 GB, sharded N = 4)~20 s~3.6 s~0.72~1.7 GBHashPartition required to stay under 2 GB

Sharded R@5 is typically lower than unsharded. That is a real tradeoff to discuss in your writeup, not a bug. The "working set" column is the size of what's live at any one moment, which is what we grade against the 2 GB target; psutil's peak RSS will read higher because Python doesn't return freed pages to the OS. See Tier 93 FAQ for the details.

Library Policy and Honor Code

Core rule: the algorithms in your final submission must be your own. You may use production tools as your baseline (Phase 0) and as your parity reference (Phases 1, 2, 5). You may not use them as your retrieval algorithm.

Use AI freely to learn the concepts. Ask Claude to explain LSH three different ways. Watch a 3Blue1Brown video on hashing. Paste the original BM25 paper and have AI walk you through the math. The intuition layer is exactly what AI is good at, and you should use it.

Write the algorithm yourself. The inverted index, the LSH bands, the BM25 scoring loop, the hybrid combiner. Write your own code. Not because typing matters, but because writing them yourself is what tells you whether you actually understood the intuition you just absorbed. The line, in one sentence: if you could not explain the code you submitted to a TA in plain English, with the chat window closed, you crossed it.

You CAN use
  • NumPy for math, dot products, random projections.
  • PyArrow for Parquet I/O.
  • Python stdlib including sqlite3 for metadata storage.
  • Python dict, set, Counter, heapq as your data structures.
  • sentence-transformers to call a frozen embedding model. One line: model.encode(text).
  • FTS5, sqlite-vec, FAISS as correctness references only. Compare your output against them; do not let them be your retriever.
You CANNOT use
  • FTS5 as your primary BM25. You implement the inverted index.
  • sqlite-vec as your primary vector search. You implement LSH.
  • FAISS, ChromaDB, Pinecone, Weaviate, Milvus. Any pre-built vector database.
  • Whoosh, Lucene, Tantivy. Any pre-built search engine.
  • rank_bm25 or any other pre-built BM25 implementation.
  • AI-written algorithms. AI for intuition is fine; AI generating your inverted index code is not.

Honor Code

You must follow the Stanford Honor Code. Original engineering is required.

Frequently Asked Questions

Getting Started

Q: What does the proposal look like?

The proposal IS Phase 0, worth 10 points, due Oct 30 (end of week 2). Submit it on time with Phase 0 actually working and the 10 points are yours; miss the deadline and Phase 0 forfeits. One markdown cell at the top of your notebook (with the Phase 0 code cells run above it), covering:

  • Proof of Phase 0. Paste the printed output of one FTS5 query and one sqlite-vec query against each of the two 100 MB datasets. Four query results total.
  • What surprised you. One short paragraph on what you saw in those early queries. Which queries did each retriever nail? Which did it miss? Anything unexpected?
  • Plans. Which tier you are targeting (Tier 80, 87, 93, or 100) and how you plan to apply HashPartition at Tier 93. What your custom fusion variant will be (Phase 3). If partnering: who owns which phase.

The point of the on-time milestone is pace. Ship the proposal on time and the back half of the project has room to breathe instead of a final-week scramble.

Q: Where are the rubric details?

Inline in nanomem-template.py. Each phase has its rubric, its tests, and its parity bar in the section header. The summary on this page is the macro picture; the per-section details live in the code.

Q: Do I have to use SQLite?

For Phase 0 (production baseline) and Phase 5 (production scoreboard), yes. SQLite + FTS5 is the BM25 reference; sqlite-vec is the vector KNN reference. Both ship with Python. For the rest of your engine, NO. Build it however you want.

Phase 1: Inverted Index + BM25 (all tiers)

Q: What is a posting list?

For each term, a list of (doc_id, term_frequency) pairs. Optionally also positions if you want phrase queries. When a query comes in, you look up the posting list for each query term and combine them. A dict[str, list[tuple]] works fine for the in-memory representation.

Q: My BM25 R@5 is way below FTS5's. What is wrong?

Three things to check, in order. First: are you tokenizing the query the same way you tokenized the dataset? If you stripped underscores in one and not the other, your match rate will drop. Second: is your IDF formula correct? The + 1 inside the log keeps IDF non-negative for very common terms. Without it, common terms can get a NEGATIVE score and pull the whole document down. Third: is your length normalization computing |d| / avgdl, not |d| - avgdl?

Q: Can I use the template's tokenizer?

Yes. The tokenizer is substrate, not the algorithm. Modify if you want, but you do not have to. Note that the template tokenizer is identifier-aware (keeps verify_jwt as one token) while FTS5's default tokenizer is not (splits on underscores). This will produce measurable differences in your parity test. Both are defensible; document what you chose and why.

Phase 2: LSH (all tiers)

The most common LSH bug (recall craters between Tier 80 and Tier 87) is documented in the Phase 2 section header of nanomem-template.py. Read that header before you start coding LSH.

Q: How should I tune n_bands and bits_per_band?

Start with n_bands=60, bits_per_band=3. That gives 8 buckets per band, expected bucket size N/8. Across 60 bands, the candidate set with rank-by-hits will surface the true nearest neighbors at the top. The recall/latency curve as you vary one parameter is a great thing to plot in your writeup; do not try to find the global optimum from scratch.

Q: What is the candidate budget for?

After bucketing, you might have 10K candidates. Re-ranking all of them with exact cosine defeats the point of LSH. Cap candidates at some K (e.g., 1000) and re-rank only those. The cap trades recall for latency; document the tradeoff.

Phase 3: Hybrid + Planner (all tiers)

Q: My hybrid is worse than pure BM25 on code. Is that wrong?

No, that is exactly the finding to write up. Naive RRF gives equal weight to a strong and a weak signal; on a dataset where one signal dominates (BM25 on code, LSH on knowledge), RRF drags down the dominant signal's right answers with the weak signal's wrong ones. This is why you build a custom fusion in Phase 3. to do better than naive RRF.

Q: What is a "good" custom fusion?

Anything meaningfully different from RRF that you can defend. Examples:

  • Weighted score fusion. Normalize scores within each ranked list (divide by top-1 score), then combine as 0.7 * bm25 + 0.3 * lsh (or whatever weights).
  • Confidence fall-through. Run BM25 first; if its top score is above a threshold, return BM25 results; else also run LSH and fuse.
  • Agreement boost. Standard RRF, but if both signals' top-1 is the same doc, boost it 2x.

Document what you did and why, in a comment at the top of your custom_fusion function.

Tier 80 + 87: Benchmarking and setup

Q: What metrics should I report?

R@1, R@5, R@10, MRR, p50 and p95 latency, peak memory (RSS). See the Metrics card at the top of Grading for definitions.

Q: My Colab session keeps dying and I lose my embedding cache.

Mount Google Drive and write the cache there. Drive persists across sessions; Colab's local disk does not. The standard pattern:

from google.colab import drive
drive.mount('/content/drive')
CACHE_DIR = Path("/content/drive/MyDrive/nanomem_cache")
CACHE_DIR.mkdir(exist_ok=True)
# Then point cached_embed_batch() at this directory.

For Tier 100 (Wikipedia, ~25M passages), consider running the embedding pass on a separate machine with a beefier GPU and uploading the resulting Parquet to Drive. You only need to do it once.

Tier 93: 5 GB scaling

Skip this subsection if you are aiming for Tier 87. The two callouts below are where most Tier 93 confusion starts, so read them before debugging.

Sharded recall is lower than unsharded. At Tier 93, sharded R@5 lands around 0.70; unsharded at Tier 80 hits 0.98. That gap is RRF doing its job: the final top-5 carries roughly one winner per shard rather than 5 winners from a single global ranking. If your sharded R@5 is in the 0.65 to 0.75 range you are correct, not broken. Discuss the tradeoff in your writeup.
Iterate on 50 queries, report on 500. A 50-query eval at Tier 93 takes about 3 minutes; the full 500-query eval takes about 30. Iterate on the small one. Report final numbers on the full one.

Where memory goes (and why HashPartition becomes necessary)

Component-by-component breakdown across the three memory-constrained tiers. The substrate's memory_breakdown(...) prints this same table for your own indexes, so you can compare against the reference numbers.

Component Tier 87 (1 GB, 250K) Tier 93 (5 GB, 1.25M) Tier 100 (20 GB, 25M)
Embedding matrix (float32)~380 MB~1.9 GB~38 GB
Inverted index postings (numpy)~400 MB~2.0 GB~10 GB
LSH bands (60 × bucket dicts)~300 MB~1.5 GB~12 GB
Doc texts in memory~250 MB~1.25 GB~25 GB
Vocabulary keys~30 MB~150 MB~500 MB
Model + misc~100 MB~100 MB~100 MB
Total unsharded ~1.4 GB ~7 GB ~85 GB
RAM budget unlimited 2 GB 1 GB
Single-shard working set n/a (no sharding) ~1.7 GB (N = 4) ~700 MB (N = 128). Scale N up; that's the primary lever.

Tier 87 fits unsharded. Tier 93's total (~7 GB) blows past the 2 GB budget, so HashPartition with N = 4 brings the single-shard working set under the cap. Tier 100 needs the same idea with a larger N. One algorithm at every tier; the only knob is N.

Q: What does "single-shard residency" actually mean for grading?

Only one shard's data structures (inverted index, LSH bands, posting lists) should be referenced in Python at any moment. After you query shard i, drop it before loading shard i+1. Your working set (the size of what's live) should stay under 2 GB; that's what we grade against. psutil's peak RSS will often read higher than your working set because Python doesn't return freed heap pages to the OS, so we treat the raw RSS number as a soft signal, not a hard cap. Print your peak RSS in the notebook, explain it in one line in your writeup, and you are fine.

How to make this gradable. Leave the following outputs visible in your submitted notebook so we can verify residency by reading, not by re-running:

  • The output of memory_breakdown(...) for at least one shard build. This is the per-component table; it should show the live shard well under 2 GB.
  • The per-query [mem] prints from MemoryBudget (working set and peak RSS) for the eval run.
  • The final R@1, R@5, R@10, MRR, p50, p95, peak_mb summary cell.

Q: My Tier 93 run is killing free Colab. Help.

The bottleneck is usually one of three things. Check in this order:

  • Embedding matrix. Computing 1.25M embeddings into one big NumPy array uses ~2 GB. Compute in chunks, save each chunk to Parquet, and load only what you need at query time.
  • Inverted index postings. 1.25M code docs is ~3M unique terms; common terms have huge posting lists. Pickle and reload from disk between phases.
  • Embedding model overhead. ~300 MB just to have the model loaded. del model; gc.collect() when you are not embedding.

Q: Could a Bloom filter speed up my queries?

Maybe. Worth trying. You met Bloom filters in M2 as one of the standard storage tricks; the question is whether they buy you anything here.

The natural place to try one is per-shard: build a small Bloom of "every token that appears in any doc in this shard," and at query time skip loading shards whose Bloom says "definitely none of my query tokens." Implement it, measure how often the Bloom actually skips a load, and report what you find. The answer is partitioning-dependent in an interesting way, and a clean Bloom-vs-no-Bloom microbenchmark is exactly the kind of analysis that strengthens your writeup and, if it speeds your queries, your standing on the Tier 93 leaderboard.

Tier 100: 20 GB Wikipedia

Only attempt Tier 100 after Tier 93 is working. The algorithm is the same; what changes is the size of N and the discipline of the residency.

Q: Which dataset?

wikimedia/wikipedia (English subset, 6.7M articles, ~22 GB compressed / ~75 GB uncompressed, roughly every fact on the open web). Chunk into ~25M passages, embed yourself (~3 to 5 hours on Colab T4 GPU or ~30+ hours on CPU; consider running the embedding pass on a separate machine with a beefier GPU and uploading the resulting Parquet to Google Drive). Then index and retrieve against it. Tier 100 is bonus precisely because the engineering is hard.

Q: How do I get from Tier 93 to Tier 100?

Two things stack. First the data: scale your HashPartition build to the full ~22 GB English Wikipedia dataset (~6.7M articles chunked into ~25M passages). Second the constraint: keep the working set under 1 GB (single-shard residency at N = 128). The memory budget is what makes this tier hard; the algorithmic answer is more shards, not new machinery.

What that forces you to do: lazy-load shards one at a time, stream posting-list scans (don't materialize the full posting in Python), bound LSH band caches, and aggressively delete intermediate state between query phases. The biggest one-time cost is still the embedding pass at 3 to 5 hours on a Colab T4 GPU. Cache embeddings to Drive so you don't lose them on session restart.

AI Takeaways + Honor Code

Q: How much AI is too much?

Use AI freely for the conceptual layer. Asking Claude to explain LSH from the original paper. Asking it to walk you through the BM25 formula. Asking it to debug a NumPy shape mismatch. These are exactly what AI is good at, and using it well is a skill we want you to develop.

Not OK: AI-written inverted indexes, LSH implementations, or BM25 scorers. The algorithms were covered in lecture and the FAQ has the formulas. Implement them yourself. The bar is whether you can talk through your inverted index, your LSH, and your hybrid combiner in plain English without pulling up a chat window.

Q: What should the AI Takeaways section look like?

Short and informal. Half a page, not a report. 2 to 3 anecdotes (a few sentences each) plus one plain-English walkthrough of a piece of your engine. Good anecdotes capture a moment:

  • "AI suggested using a single giant hash table for LSH. That would have blown memory on 1 GB. I switched to per-band buckets after looking at my memory profile."
  • "AI wrote a benchmark harness that measured wall time. I added a cold-cache warm-up because my first few runs were 3x faster than the later ones."
  • "AI recommended a candidate budget of 100. At 250K docs my LSH recall was 0.05. The fix was ranking candidates by hit count, not the budget itself."

Submission

Both deliverables go to Gradescope. Dates and what each one contains are above; this is the file checklist.

Project intro (~4 min)

The mission, the schedule, the tier ladder, what gets graded, and why this matters. Watch this first if you have not read the page yet.

Template walkthrough (~5 min)

A tour of nanomem-template.py: what is given, what you write, and the trap that bites every cohort.