Skip to content

Ranking

Every retrieved memory carries a score. This page is the whole formula.

The formula

score = (0.40 * similarity      # cosine, query vs memory embedding
       + 0.20 * recency         # exp decay, 14-day half-life
       + 0.25 * importance      # supplied at write time, never decays
       + 0.15 * frequency)      # log-scaled access_count

Weights are DEFAULT_WEIGHTS in venkai/api/memory_scoring.py. They are global and not configurable per request. Since all four terms are in [0, 1], score is too.

pie showData
  title Score composition
  "similarity 0.40" : 40
  "importance 0.25" : 25
  "recency 0.20" : 20
  "frequency 0.15" : 15

Term by term

relevance — 0.40

Cosine similarity between the query embedding and the memory's. 0.0 when the vectors are incompatible or either is empty; 1.0 when no query was supplied, which lets a query-less call fall back cleanly to the other three terms.

This is the term most likely to under-deliver. With the default hashing provider it measures token overlap dressed as a vector, so a paraphrase scores near zero. See Embeddings.

recency — 0.20

recency = 0.5 ** (age_days / 14.0)
Age Contribution to score
now 0.200
1 week 0.141
2 weeks 0.100
1 month 0.045
3 months 0.005

After about two months, recency is effectively zero and a memory survives on importance and similarity alone. This is why importance matters: it is the only term that does not fade.

importance — 0.25

Exactly the float you sent at write time. No decay, no normalisation, no learning. It is the largest single lever you have over ranking, and it is entirely yours.

The second-largest term in the formula is one you set by hand. Set it thoughtfully or you have given up a quarter of the ranking.

frequency — 0.15

Log-scaled access_count. In practice this term is near zero for almost every memory, because of the following.

Why retrieval does not count as an access

access_count is not incremented when a memory is retrieved. This is deliberate and load-bearing.

flowchart LR
  A["memory retrieved"] --> B["access_count++"]
  B --> C["frequency score ↑"]
  C --> D["ranks higher next query"]
  D --> A
  style A fill:#7f1d1d,color:#fff
  style D fill:#7f1d1d,color:#fff

Counting retrievals as accesses creates a self-reinforcing loop: whatever surfaced first keeps surfacing, regardless of whether it was useful. The top-N locks in and never rotates.

The code documents the measurement that killed it: recall@5 fell from 0.54 to 0.12 after roughly 50 queries with the loop in place. That is the internal finding recorded in the source, on an internal fixture — it is a design rationale, not a product benchmark, and it is not a number to quote. See Evaluation.

So today access_count moves only through explicit feedback paths, which means the frequency term contributes ~0 for most memories and the effective ranking is closer to:

score ≈ 0.40 · similarity + 0.20 · recency + 0.25 · importance

Planned — a separate validated_access_count, incremented only on confirmed usefulness, so the frequency term can be earned rather than assumed.

Worked example

Two memories, query "which database did we pick", hashing provider:

Decision (Postgres) Preference (commit messages)
similarity 0.135 0.000
recency (fresh) 1.000 1.000
importance 0.900 0.200
frequency 0.000 0.000
score 0.40·0.135 + 0.20·1 + 0.25·0.9 = 0.479 0 + 0.20 + 0.25·0.2 = 0.250

The right memory wins — but note it wins mostly on importance, not on relevance. With a weak similarity signal, ranking is being carried by the metadata you supplied. That is worth knowing before you conclude that retrieval "works".

Tuning what you can

You cannot change the weights per request. You can change the inputs:

Want Do
Foundational rules to always surface importance ≥ 0.9
Better paraphrase matching VENKAI_EMBEDDING_PROVIDER=semantic
Less noise from old chatter Write ephemera at importance ≤ 0.2 — recency drops it out on its own
Deterministic subsets Use the list endpoint with type=, not /relevant