Skip to content

Repository files navigation

Lookaside

A ~28M-parameter transformer that runs fully offline, paired with a kNN retrieval memory it can lean on while writing. The tokenizer, model, training loop, sampler, memory, and inference server are all written from scratch; the only runtime dependencies are PyTorch and NumPy. Everything trains and runs on a laptop, on CPU or an Apple-Silicon GPU via MPS.

Measuring the memory carefully changed what this project ships. Almost everything a retrieval memory is worth here is a single tuned number — about 84% of it. The standard way of trying to do better, kNN-LM's gate on the model's own uncertainty, adds +0.10% on top of that constant on one of the two held-out books (interval reaching +0.13%), and on the other its confidence interval includes zero. Gating on the retrieved neighbours instead is worth +0.52% and +1.89%, significant on both.

New to any of this? Lookaside, explained is a from-scratch walkthrough of the whole system and the experiment, assuming no machine-learning background: tokenizers, transformers, retrieval, the finding, the method, and everything wrong with it.

CI license

Compare mode: the same seed streaming with memory off and memory on, forking where the memory first changes a sampled token

Compare mode races the same prompt and the same random seed with the memory off and on. The two runs sample identical tokens until the memory first changes a sampled token; the orange mark is that fork. Words the memory supplied are highlighted, the side panel shows the corpus passage each one was recalled from, and the gauges track the model's entropy H and the blend weight λ as it writes.

What the gate should watch

kNN-LM interpolates the model's distribution with a distribution over retrieved neighbours under a weight λ. Its adaptive variant sets λ from the model's own uncertainty, λ = λ_max · σ(a·H + b), with H the next-token entropy and a, b fitted to held-out likelihood. Lookaside implements that, and fitting it kept producing a gate that barely moved.

Two never-trained Doyle books make it possible to check why. Every gate below is fitted on one of them and scored on the other, so nothing is measured on text it was tuned on:

the gate conditions on The Valley of Fear His Last Bow
nothing — no memory at all 42.53 38.94
a constant λ, fitted 38.59 37.54
entropy H — kNN-LM's signal 38.59 37.50
the gate this project used to ship 38.49 37.51
d1 + d_spread — the neighbours 37.86 37.34
a per-token oracle (cheating) 31.31 31.14

Perplexity on the complete held-out halves of two books the model never trained on. d1 is the squared L2 distance to the nearest stored key; d_spread is how much closer that key is than a typical one among the 64 retrieved.

The first thing to take from that table is not the gate at all. A single fitted number — one λ, applied to every token — captures 9.26% of the 10.97% total, about 84% of everything the memory is worth here. Every gating scheme, including this project's own, is competing over the remaining sliver. So the honest way to compare them is by what they add on top of that constant, with error bars, block-bootstrapped over 256-token blocks because neighbouring tokens are nowhere near independent:

improvement over a fitted constant λ The Valley of Fear His Last Bow
entropy H — kNN-LM's signal +0.009% [−0.061, +0.080] +0.104% [+0.078, +0.130]
d1 alone +0.362% [+0.282, +0.444] +0.055% [−0.035, +0.141]
d_spread alone +1.703% [+1.496, +1.905] +0.426% [+0.250, +0.593]
d1 + d_spread +1.889% [+1.659, +2.115] +0.519% [+0.337, +0.696]
all five neighbour signals +1.897% [+1.661, +2.130] +0.522% [+0.336, +0.699]

The entropy gate's interval includes zero on one book and reaches +0.13% on the other. Neighbour distance and margin are worth 0.52% and 1.89%, significant in both directions, and two signals get essentially all of what five do. The effect is small in absolute terms and varies more than three-fold between two books by one author, so treat the size as unestablished; the ordering survives resampling comfortably.

Bar chart: a constant blend weight recovers 9.3% of perplexity, the entropy gate 9.3%, a gate on distance and margin 11.0%, and a per-token oracle 26.4%

Why entropy fails

A gate exists to predict the blend weight a token wants, so measure that directly: split the 89,541 held-out tokens into fifths by each signal and find the single λ that is optimal inside each fifth.

Two panels of optimal blend weight per fifth. Sorted by neighbour distance it falls monotonically: 0.163, 0.107, 0.071, 0.062, 0.049. Sorted by model entropy it is 0.157 then roughly flat: 0.080, 0.077, 0.084, 0.066.

By neighbour distance the optimal λ falls monotonically, 0.163 down to 0.049, a 3.3× spread and exactly the shape σ(w·d + b) can track. By entropy, four of the five fifths want the same weight, 0.066 to 0.084. Only the most confident fifth departs from it, at 0.157, and those tokens sit at perplexity 3.7 — already predicted so well that almost no total likelihood rides on their λ.

The reason is a distinction the entropy gate quietly gets wrong. Whether the memory wins a token and how much that win is worth are different quantities, and the two signals predict opposite ones:

signal correlation with the memory winning the token correlation with nats actually saved
entropy H −0.358 +0.031
p_LM top-1 +0.375 −0.019
d1 −0.186 −0.025
d_spread +0.069 +0.176

Entropy is the best available predictor of which tokens the memory will win, and almost useless for how many nats those wins are worth. The margin is the reverse. A gate fitted to likelihood is optimising the second column, so it is reading the one thing entropy cannot tell it: not how bad p_LM is, but whether p_kNN is better.

Two controls, because "your fit just failed" is the obvious objection:

control perplexity recovered
a constant λ 9.259%
entropy gate, fitted by Adam 9.267%
entropy gate, best of a 19,481-point grid over (a, b) 9.267%
entropy gate, allowed a quadratic in H — non-monotone 9.263%

An exhaustive grid finds what the optimiser found to three decimals, so this is not an optimisation failure; and letting λ be a non-monotone function of H does not help either, so it is not the sigmoid's shape. On the trained-text set the grid's own optimum is a = +0.00 — a literal constant, and the entropy gate's interval there is [−0.000, +0.000]. On the reverse book split the grid does beat the optimiser, by 0.015 points, which puts the entropy gate's honest value there at 0.115 points over a constant rather than 0.100.

What it costs on familiar text

A gate that opens further can pay for it on text the weights already handle. The gate fitted on a never-trained book was applied, unchanged, to withheld slices of books the model did train on (30,200 tokens from five of the six; the sixth was the fit set):

gate perplexity vs no memory
no memory 21.93
the gate this project used to ship 21.88 0.24%
entropy H, refitted here 21.71 1.04%
a constant λ 21.71 1.04%
d1 + d_spread, fitted on a never-trained book 21.68 1.15%

It pays no measurable penalty. The +0.109% it gains over the constant here has interval [−0.017, +0.241] and should be read as "no worse", not as a win. The refitted entropy gate ties the constant to four decimals, which is the same finding again on different text.

That is enough to make it safe to ship, and checkpoints/lookaside-demo/ now defaults to gate_mode="confidence". Generating with it, λ ranges over 0.028 to 0.279 (mean 0.065) against the shipped 750k-entry memory, where the entropy gate spans only 0.082 to 0.114 (runs/demo_receipt.log), so the selectivity survives outside the fitting set. The full write-up, with the caveats, is in What should the gate watch?.

Results

The experiment holds two Doyle books (The Valley of Fear, His Last Bow) out of training entirely and puts them in the memory instead, alongside the six Doyle books the model did train on. Each held-out book is split in half: one half goes into the datastore, the other becomes an evaluation set. Withheld tail slices of the six trained books form a second evaluation set. Nothing evaluated is in the datastore — guaranteed by the split itself and by a test that pins the self-retrieval invariant, not by inspection.

On books the model never read, the memory recovers 4.1% and 11.0% of perplexity depending on which book is measured, knowledge added without touching a single weight. That two-books spread is wider than every gate difference above, which is why both are always reported.

How dense does the memory have to be?

A 500k-entry datastore spread over the full 5.7M-token classics corpus once produced no gain, while 600k entries over the 725,788-token Doyle corpus produced a clear one. Those runs differ in density and domain, so they cannot separate the causes. Holding the model, corpus and evaluation positions fixed and varying only the entry cap does:

Two curves against datastore coverage. A blend weight pinned at 0.1 starts at −4.6%, worse than no memory, and crosses zero near 11% coverage; a per-level fitted constant never goes negative, starting at +0.04%. Both climb to about +7% at 83% coverage.

Below roughly 11% coverage a λ pinned at 0.1 is worse than having no memory at all — at 1% coverage it costs 4.6% perplexity, because the nearest stored key is not actually a near match and its vote is noise. Fitting λ at each level avoids that hole by shrinking itself: 0.006 at 1% coverage, rising to 0.079 at 83%. Both climb to 7.2–7.3% and are still rising there. Note that both curves are plain constants, not gates — this sweep is about density, and its single fitted parameter is the one place a random position split is used rather than a book-level one.

Where the gain lands

Bucketing held-out tokens by how often the model saw them in the six Doyle books it trained on:

times seen in training tokens gain from the memory best λ
never 116 −1.5% 0.011
1–10 2,752 8.5% 0.054
11–100 17,389 15.0% 0.086
101–1,000 24,647 9.2% 0.092
more than 1,000 44,637 1.3% 0.069

The payoff concentrates in the middle of the frequency range: words the model has met but not often enough to pin down. Common words gain almost nothing. Tokens it never saw are the honest failure case — only 116 of them, the memory makes them slightly worse, and the λ they want is 0.011, essentially closed, because a token missing from the training corpus is usually missing from the datastore too.

A sample

Same prompt and seed, memory off then on, from runs/demo_receipt.log. Words the memory drove are bold:

Prompt: The tragedy at Birlstone came about in this way.

LM-only: …One of the adventurers and the other are the work of the hospitals. The address was committed by Sir Henry, who has not been sent to Baker Street by the death of Stapletons. "Anything that you know?" asked Holmes…

+ memory: …One of the adventurers and the other are the facts of the cabman. The address is a second man, but he has not known the death of his murdered friend and of any one who could find it. "When I met him I found myself at once upon my own wits' gossip…

The memory drove 5 of 120 tokens here, and the log records where each came from: facts and of from The Memoirs of Sherlock Holmes, the and fellow from The Hound of the Baskervilles, plenty from The Return of Sherlock Holmes. All five come from books the model trained on, not the held-out ones, so a single 120-token sample demonstrates the provenance machinery rather than evidencing held-out knowledge. The perplexity tables above are that evidence. Prose quality is what ~28M parameters and a few laptop-hours buy: fluent Doyle-flavoured pastiche, not literature.

Earlier experiments — including a regime where the fitted gate correctly closes the memory entirely, and one where a weaker 14M model does learn to open on uncertainty — are in When does retrieval actually help?.

How it works

flowchart LR
    P[prompt] --> T[byte-level BPE]
    T --> M[decoder transformer<br/>RoPE · RMSNorm · SwiGLU · GQA · KV-cache]
    M -->|logits| L[p_LM]
    M -->|hidden state h| D[(kNN datastore<br/>keys=h, values=next token)]
    D -->|k nearest| K[p_kNN + distances]
    K -->|distance, margin| G{"gate<br/>λ = λmax·σ(w·z+b)"}
    K --> B[(blend)]
    L --> B
    G -->|λ| B
    B --> S[sample: temp · top-p · rep-penalty]
    S --> O[token + provenance]
Loading

The gate sits downstream of the search, because what it watches is the search result. A gate reading only the model's own output could in principle skip retrieval before paying for it, and this one cannot — but in practice that costs nothing, because a fitted sigmoid never returns exactly zero, so the entropy gate searches at every step too. Measured against the 750k-entry memory: 44.2 tok/s for the confidence gate and 44.4 for the entropy gate, within run-to-run variance, against 110.0 tok/s with the memory off. The search is what retrieval costs, not the gate that reads it.

The shipped model is 27.8M parameters: d_model 512, 8 layers, 8 heads with 4 KV heads (grouped-query attention), context 256, byte-level BPE vocab of 8192.

lookaside/model.py is a decoder-only transformer: rotary position embeddings, RMSNorm pre-norm, SwiGLU feed-forwards, grouped-query attention, tied input/output embeddings, and an incremental KV-cache with a sliding window so generation runs past the training context.

lookaside/tokenizer.py is a byte-level BPE: a reversible byte↔unicode mapping (nothing falls out of vocabulary), a regex pre-tokenizer, and a heap-backed incremental merge trainer.

lookaside/retrieval.py is the memory. For every corpus position it stores the hidden state the LM head consumes (the key) and the true next token (the value). At inference it finds the nearest keys to the current hidden state, converts their distances into a distribution, and blends that in under the gate. Search is pure-torch brute force by default, with an optional faiss HNSW index. Every retrieved token keeps a pointer back to the corpus position it came from, so the UI can show its source.

Quickstart

pip install -r requirements.txt

# Option A: run the shipped demo checkpoint — fully offline, no download needed.
# The first run builds the retrieval memory from the bundled Doyle corpus
# (about a minute, one time) and caches it; startup is instant after that.
python -m lookaside serve            # open http://127.0.0.1:8000

# Option B: retrain everything from scratch, into a directory of your own.
# (--out matters: the default is the committed checkpoint, and these steps
#  overwrite model.pt and retrieval_config.json in place.)
python scripts/download_corpus.py --set classics   # 32 books (needs internet once)
python scripts/train_demo.py --out checkpoints/my-run \
  --exclude the_valley_of_fear his_last_bow \
  --d-model 512 --layers 8 --heads 8 --kv-heads 4 \
  --steps 2600                       # ~2.5 h on Apple-Silicon MPS
python scripts/finalize_demo.py --ckpt checkpoints/my-run   # memory, entropy gate, ablation
python scripts/gate_study.py --eval-set holdout \
  --write-config checkpoints/my-run/retrieval_config.json   # the gate the demo ships

CLI

lookaside generate --prompt "Sherlock Holmes turned to me and said, " --show-sources
lookaside generate --no-knn                  # parametric model only (the A/B baseline)
lookaside generate --gate-mode entropy       # the kNN-LM-style gate, for comparison
lookaside generate --gate-mode fixed --lam 0.3
lookaside serve --port 8000                  # streaming web UI
lookaside build-datastore                    # (re)build the retrieval memory
lookaside fit-gate                           # fit the *entropy* gate; overwrites the config
lookaside ablation                           # perplexity across this checkpoint's gate modes
lookaside train --steps 3000                 # train end to end

With no --gate-mode, --lam or --top-k-knn, the checkpoint's own fitted gate is used exactly as fitted — a confidence gate's gate_mu/gate_sd are statistics of neighbour distances at a particular k, so changing --top-k-knn invalidates it.

fit-gate and ablation take their held-out tail from the same files the memory indexes, so their numbers include self-retrieval; both say so when they run. The book-level protocol behind the tables above is scripts/gate_study.py.

lookaside is installed via pip install -e ., or use python -m lookaside.

Reproducing the experiments

# the gate tables and the fitted gate the demo ships
python3 scripts/gate_study.py --eval-set holdout --write-config runs/confidence_gate.json
python3 scripts/gate_study.py --eval-set holdout --fit-book the_valley_of_fear
# what that gate costs on text the model did train on
python3 scripts/gate_study.py --eval-set trained \
  --gate-from checkpoints/lookaside-demo/retrieval_config.json
python3 scripts/coverage_sweep.py     # the density curve
python3 scripts/demo_receipt.py       # the sample, lambda ranges and throughput
python3 scripts/trace_token.py        # one prediction through every stage, with numbers
python3 scripts/make_figures.py       # redraw the three study figures from that JSON

The first run builds a datastore and caches per-token features, so the rest are quick. runs/gate_study.log, runs/coverage_sweep.log and runs/demo_receipt.log are the captured output behind the numbers above, and runs/*.json are the payloads make_figures.py draws from — committed so the figures can be re-derived from a clone, not just trusted. The older runs/exp*.log and runs/*classics*.log back the earlier experiments in docs/when-does-retrieval-help.md; those predate the Mnemosyne → Lookaside rename and still name checkpoints/mnemosyne-* paths, which is left as-is because a dated record should not be edited after the fact.

Project layout

lookaside/
  config.py       dataclass configs (model / train / sampling / retrieval)
  tokenizer.py    byte-level BPE tokenizer
  model.py        the decoder-only transformer
  data.py         Gutenberg cleaning, tokenisation, batching
  train.py        AdamW + cosine schedule, grad-accum, best-on-val checkpointing
  sample.py       temperature / top-k / top-p / repetition / no-repeat-ngram
  generate.py     streaming generation with a sliding-window KV-cache
  retrieval.py    datastore, kNN search, the gates, provenance, ablation
  bundle.py       load model + tokenizer + memory (builds datastore on first run)
  server.py       dependency-free http.server + Server-Sent Events
  cli.py          the `lookaside` command
web/index.html    self-contained streaming UI (no CDNs, works offline)
scripts/
  download_corpus · train_demo · finalize_demo          the model
  gate_study · coverage_sweep · demo_receipt            the experiments
  trace_token                                           one prediction, stage by stage
  make_figures · svgchart                               the figures
tests/            139 tests

Testing

pip install pytest && pytest

The suite (139 tests, ~4 s) covers the parts most likely to fail silently: lossless tokenizer round-trips, KV-cache output matching a full forward pass, causal masking, the datastore key/value off-by-one, the self-retrieval invariant, and — because a fitted gate would otherwise mean something different at inference than it did at fit time — the per-step gate signals checked against a batched reference. CI runs it on pushes to main and on pull requests, on Linux (Python 3.9 and 3.12) and macOS (Python 3.12).

Prior art

The transformer components are standard building blocks. The retrieval core is kNN-LM (Khandelwal, Levy, Jurafsky, Zettlemoyer & Lewis, ICLR 2020), and its adaptive-gate variant — λ from the model's entropy — is the baseline measured against here.

Conditioning retrieval on properties of the search itself is not new either. Zheng et al., Adaptive Nearest Neighbor Machine Translation (ACL 2021), learn to weight the k neighbours from their distances; He, Neubig & Berg-Kirkpatrick, Efficient Nearest Neighbor Language Models (EMNLP 2021), and Drozdov et al., You can't pick your neighbors, or can you? (EMNLP Findings 2022), both adapt retrieval on learned features of it. Anyone weighing the result above should read those first.

What I have not found reported is the controlled head-to-head: the same gate form, the same fitting objective, the same held-out data, entropy against neighbour distance — together with the finding that entropy's contribution is indistinguishable from zero on one held-out book and worth +0.10% on the other, with an interval reaching +0.13%, and the reason why (it predicts whether the memory wins a token, not how much that win is worth). The token-level provenance UI and packaging the whole stack to train and run offline on a laptop are also mine.

License

MIT © 2026 Sef

About

A 28M-parameter transformer trained from scratch on a laptop, paired with a kNN retrieval memory it can lean on while writing — and a learned gate that decides when to trust it. PyTorch and NumPy only.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages