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.
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.
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.
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.
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].
Best hit in the set → 1.0. All tied → 1.0 for everyone.
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
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.
| Boost | Fires when | Multiplier |
|---|---|---|
| Identifier | The 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 token | A distinctive plain word (4+ chars, not a stopword) appears in the chunk | 1.15× |
| Branch | The chunk was written on the git branch you are on right now | 1.1× |
The same query, finished.
get_effective_contract_price contains _ → it is code-shaped → 1 match → ×1.3A · 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.
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.
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.
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.
| legacy | v2 (default) | |
|---|---|---|
| Fusion | reciprocal rank fusion, k=60 | weighted, 0.7 / 0.3 |
| Identifier boost | on | on |
| Rare-token boost | off | on, 1.15× |
| Recency blend | off | on, 21d half-life, floor 0.5 |
| Superseded hits | unchanged | 0.5× demote |
| Candidates per leg | 25 | 25 |
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.