Captain Memo

How the right memory reaches you

Recall

Storing everything is the easy half. The hard half is that every prompt asks a slightly different kind of question — and the ranking that answers one kind well is the ranking that fails the other. Captain Memo takes two independent readings and crosses them, the way you fix a position at sea.

A sea chart with a sextant and a bearing compass, a line of sight from each crossing at a single glowing point — two readings, one fix

The problem

Two kinds of question, one ranking.

Every search you make is really one of two questions, and they want opposite things from a ranker.

"What did we decide about rounding?"

You remember the gist, not the words. The note you want might say "precision", "decimal places", or "don't round intermediates" — none of which is in your query. Only meaning-based search finds it. Keyword search returns nothing useful.

"get_effective_contract_price"

You remember the exact string and want every line that contains it. Here meaning-based search is actively unhelpful: it happily returns twelve notes that are about pricing and buries the one that names the function.

Picking one is picking which half of your queries to be bad at

This is why "just use embeddings" is the wrong instinct for a developer's memory. A corpus of engineering notes is full of exact strings that matter — function names, table columns, branch names, error codes — and a vector index treats those as approximately-similar mush. Equally, keyword-only search cannot answer a question you can only phrase from memory. Both readings are needed, and neither is trusted alone.

The pipeline

Six stages, and nothing is thrown away early.

Two searches run in parallel, get fused into one ranking, then adjusted by what the query actually looks like — and only the very last step cuts the list down.

The six stages of a Captain Memo recall A query fans out into a vector search and a keyword search running in parallel, each returning 25 candidates. The two lists are fused into one score, then adjusted by boosts, then by a recency factor, then re-ranked by Tide, and only then truncated to the requested number of results. query vector · meaning top 25 keyword · BM25 top 25 fuse 0.7 / 0.3 boosts id · rare · branch recency + Tide truncate to topK — last, never earlier Each half fails independently: if one dies you get a degraded ranking and a log line, never a silent empty result.
The order matters more than any single stage — see why truncation is last.

Stage two

Crossing the two readings.

Both halves return a score, but on incompatible scales — one is a cosine, the other is BM25, which is negative and unbounded. They have to be made comparable before they can be blended.

The vector leg

Embeddings are unit-normalised, so the L2 distance the index returns converts to a similarity in [0,1] exactly, with no tuning constant.

sim = 1 − d² / 4

Identical → 1.0. Orthogonal → 0.5. Opposite → 0.

The keyword leg

SQLite's FTS5 bm25() returns a negative rank where more-negative is better. It is min-max normalised across the returned set into [0,1].

kw = (rank − worst) / (best − worst)

Best hit in the set → 1.0. All tied → 1.0 for everyone.

score = 0.7·sim + 0.3·kw // when the document appears in BOTH legs

The weighting leans on meaning, because meaning generalises and exact matching does not. But note what happens when a document is found by only one leg: rather than multiplying its single score by that leg's weight — which would penalise it for the other index's blind spot — it keeps its score at full weight. A document is not worse because only one instrument could see it.

Worked query — get_effective_contract_price rounding

A · the note that names the function    sim 0.62, kw 1.00    0.7·0.62 + 0.3·1.00 = 0.734
B · a note about rounding, function never named    sim 0.81, no keyword hit    0.810
After fusion alone, B outranks A — the conceptual match beats the exact one.

Which is wrong, and fusion cannot fix it, because fusion has no idea that get_effective_contract_price is a name rather than a phrase. That is what the next stage is for.

Stage three

Knowing when you meant it literally.

Three multipliers, applied only when the query itself gives evidence they should be. Each records what it did, so a surprising result can always be explained.

BoostFires whenMultiplier
IdentifierThe query contains a code-shaped token — one with _, . or / in it, or an internal camelCase hump — and the chunk contains that token+0.3 per match, capped at 2.0×
Rare tokenA distinctive plain word (4+ chars, not a stopword) appears in the chunk1.15×
BranchThe chunk was written on the git branch you are on right now1.1×

The same query, finished.

get_effective_contract_price contains _ → it is code-shaped → 1 match → ×1.3
A · 0.734 × 1.3 = 0.954  ✓ first
B · 0.810 × 1 = 0.810  — still returned, just second

Note that B is not suppressed. If you wanted the conceptual note, it is right there. The boost changes the order, not the set.

What is deliberately not boosted

Plain words and ALL-CAPS tokens are skipped by the identifier rule on purpose. The comment in the source says it plainly: "those are exactly the queries where semantic ranking should win uncontested." A query of ordinary English is a query about meaning; boosting literal matches there would drag the ranking back towards keyword search and undo the entire point of fusing.

Every boost is also individually switchable — CAPTAIN_MEMO_IDENTIFIER_BOOST=0, …_RARE_TOKEN_BOOST=0, …_BRANCH_BOOST=0 — because a heuristic you cannot turn off is a heuristic you cannot debug.


Stage four

Recency that reorders, never buries.

"What's the current deploy version" and "why did we choose Postgres" want completely different treatment of age. So the recency blend only runs when the query asks for it.

It has to be asked for

The factor is a no-op unless the query matches a temporal intent — last, latest, newest, current, most recent, recent, up to date, now. Ask an ordinary question and age is not considered at all.

It is bounded, not a sort

Age becomes a multiplier with a floor, applied to the top 10 only. A maximally stale note keeps at least half its relevance, so freshness settles near-ties and never overturns a much better older answer.

factor = 0.5 + (1 − 0.5)·e^(−ln2 · ageDays / 21)
written today    factor 1.00
21 days old (one half-life)    factor 0.75
63 days old (three half-lives)    factor 0.56
Even at three half-lives a note keeps 56% — a far more relevant old answer still wins.

Only observations decay. Curated memory and skills are exempt and always score at factor 1. A deliberate note you wrote is not made less true by being three months old, and it must never be pushed below a fresh, casually-captured observation. A note with no date, or a timestamp in the future, also scores 1 — the blend can lower a score for being old, but never for being unknown.

Stage five and six

Cut the list last.

The least visible decision on this page, and the one most likely to be got wrong.

Tide — the lifecycle scoring that tracks which memories are strengthening or going dormant — re-ranks the full post-boost pool, before anything is discarded. It would have been simpler to slice to the requested number first and re-rank the survivors. That version has a specific failure: a near-dormant memory that is exactly what you asked for gets cut at the slice, and Tide never gets the chance to rescue it. The row you needed is eliminated one step before the stage designed to save it.

So the order is: fuse everything, boost everything, apply recency, let Tide see the whole pool, and only then take the top N. Every stage that could rescue a result runs while that result still exists.

Half a search is better than none, and it says so

The vector and keyword halves run concurrently and each catches its own error. If the vector index is rebuilding, you get keyword-only results plus a log line naming the half that failed. Before that split, both halves could fail and the caller simply received an empty list — indistinguishable from "nothing matched". A memory system that silently returns nothing has told you a lie about your own corpus.


If you want to change it

Two profiles, and every constant is an env var.

v2 is the default and is everything described above. legacy is the older ranking, kept selectable and frozen so a regression can always be bisected against it.

legacyv2  (default)
Fusionreciprocal rank fusion, k=60weighted, 0.7 / 0.3
Identifier boostonon
Rare-token boostoffon, 1.15×
Recency blendoffon, 21d half-life, floor 0.5
Superseded hitsunchanged0.5× demote
Candidates per leg2525

Set CAPTAIN_MEMO_RANK_PROFILE=legacy to switch, or override any single constant — CAPTAIN_MEMO_RRF_K, CAPTAIN_MEMO_FUSION_MODE, and the per-boost switches above — without leaving the profile.

Questions

The ones that decide whether you trust the results.

Why not just use embeddings? Everyone else does.

Because engineering notes are full of exact strings that matter — function names, columns, branches, error codes — and a vector index treats those as approximately-similar mush. Searching get_effective_contract_price in an embeddings-only system returns a dozen notes that are broadly about pricing. The worked example above shows meaning-based ranking putting the wrong note first, and exactly which stage corrects it.

Can a boost bury the result I actually wanted?

It can reorder it, not remove it. Boosts are multipliers on a shared candidate pool — nothing is filtered out, and the identifier boost is capped at 2× so no single match can dominate. Every boost that fires also records the multiplier it applied, so a surprising ranking can be traced rather than guessed at.

Will old notes stop showing up?

No. Age is only considered when your query asks for recency, it applies to the top 10 only, and it is a bounded multiplier with a floor of 0.5 — a note keeps at least half its relevance no matter how old. Curated memory and skills never decay at all.

What happens if half the search breaks?

You get the other half plus a log line naming which one failed. The two legs run concurrently and each catches its own error, so a rebuilding vector index degrades the ranking instead of silently returning nothing — which would be indistinguishable from an empty corpus.

Is any of this sent anywhere?

No. Both indexes are local SQLite — sqlite-vec for vectors, FTS5 for keywords — and every stage described here is arithmetic on your own machine. The only outbound call in the whole system is embedding your query, and even that can point at Ollama or any local /v1/embeddings endpoint.