What Is the Limbic System?
The Limbic System is a dynamic scoring layer that runs on top of KNN results from search_semantic, re-ranking candidates based on how the knowledge graph has been used over time. It turns a purely similarity-based search into one that also considers what matters, what’s recent, and what belongs together.
The name is a deliberate biological metaphor. In the human brain, the limbic system handles three functions that this module replicates computationally:
- Emotional valence — important memories are reinforced. Here, frequently accessed and well-connected entities rank higher.
- Forgetting — unused memories fade gradually. Here, entities not accessed recently decay in score.
- Association — things that fire together wire together. Here, entities that co-occur in search results reinforce each other.
The search_semantic API extends its output to include the computed scores. Beyond the original {name, entityType, observations, distance}, each result now carries limbic_score (the composite score) and scoring (a breakdown of each component).
:::note The Limbic System is not a separate search engine — it’s a post-retrieval re-ranker. It operates on candidates already returned by KNN (or KNN+FTS5 in Hybrid Search mode). Without candidates to re-rank, it does nothing. :::
The Four Capabilities
| Capability | What it does | Biological metaphor |
|---|---|---|
| Salience | Frequently accessed and well-connected entities rank higher | Emotional valence — important things are remembered better |
| Temporal Decay | Entities not accessed recently gradually decrease in rank | Forgetting — unused knowledge fades |
| Co-occurrence | Entities that frequently appear together reinforce each other | Association — things that fire together, wire together |
| Co-occurrence Temporal Decay | Recent co-occurrences count more than old ones | Recent associations are stronger than old ones |
Each capability maps to a factor in the scoring formula. They are multiplicative — a strong score requires all three to contribute.
Query Routing: Dynamic Strategy Selection
MCP Memory v2 automatically selects the best scoring strategy based on the query characteristics. This is called query routing.
The Three Strategies
| Strategy | Cosine Weight | Limbic Weight | Best For |
|---|---|---|---|
COSINE_HEAVY | 70% | 30% | Factual queries: definitions, “what is”, exact terms |
LIMBIC_HEAVY | 30% | 70% | Exploratory queries: “explain everything”, broad context |
HYBRID_BALANCED | 50% | 50% | Mixed queries: relationships, comparisons |
Detection Algorithm
The detect_query_type() function analyzes linguistic features:
Scoring rules:
- Factual keywords ("qué es", "definición", "cómo funciona", "es un/una") → +2
- Intermediate keywords ("relación", "diferencia", "ejemplos", "comparar") → +1
- Exploratory keywords ("explícame", "relación entre", "dime todo", "qué piensas") → -2
- Query length ≤3 words → +1 (factual)
- Query length ≥10 words → -1 (exploratory)
- K limit ≤3 → +1 (precise/factual)
- K limit ≥10 → -1 (exploratory)
Final routing:
- Score ≥ 2 → COSINE_HEAVY
- Score ≤ -2 → LIMBIC_HEAVY
- Otherwise → HYBRID_BALANCED
Blending Formula
When routing selects COSINE_HEAVY or LIMBIC_HEAVY, the final score blends cosine similarity with normalized limbic score:
COSINE_HEAVY: final = 0.7 × cosine + 0.3 × limbic_norm
LIMBIC_HEAVY: final = 0.3 × cosine + 0.7 × limbic_norm
HYBRID_BALANCED: final = 0.5 × cosine + 0.5 × limbic_norm
Where limbic_norm is min-max normalized across the candidate set.
API Output
The routing_strategy field is included in every search_semantic result:
{
"results": [{
"name": "FastMCP",
"routing_strategy": "cosine_heavy",
"limbic_score": 0.67,
...
}]
}
This lets you understand why a particular result ranked where it did.
Scoring Formula
The composite limbic score is calculated as:
score(e, q) = cosine_sim(q, e) × (1 + β_sal × importance(e)) × temporal_factor(e) × (1 + γ × cooc_boost(e, R))
Where:
| Component | Range | Role |
|---|---|---|
cosine_sim(q, e) | [0, 1] | Base relevance — pure embedding similarity from KNN |
(1 + β_sal × importance(e)) | [1, 1.5] | Salience boost — how important the entity is |
temporal_factor(e) | [0.1, 1.0] | Temporal decay — how recently the entity was accessed |
(1 + γ × cooc_boost(e, R)) | [1, ~1.05] | Co-occurrence boost — reinforcement from appearing with related entities |
The formula is multiplicative by design: a candidate that is similar, important, recent, and co-occurring will score significantly higher than one that only matches on similarity. Conversely, a decayed entity can still rank if it’s highly important and relevant.
cosine_sim(q, e)
cosine_sim(q, e) = max(0, 1 - distance)
The base similarity comes directly from the KNN search. distance is the cosine distance stored in the embeddings table. The max(0, ...) clamp prevents negative values for distant vectors.
:::tip
In Hybrid Search mode, entities found only by FTS5 (not KNN) have distance = None. Their base relevance is estimated from the normalized RRF score instead: 0.2 + 0.6 × norm_rrf, mapping to the range [0.2, 0.8].
:::
Sub-formula: importance(e)
importance(e) = access_norm × (1 + β_deg × degree_norm) × (1 + α_cons × consolidation)
Where:
access_norm = log₂(1 + access_count) / log₂(1 + max_access)degree_norm = min(degree, D_MAX) / D_MAXconsolidation = log₂(1 + access_days) / log₂(1 + max_access_days)α_cons = 0.2(ALPHA_CONS)
The importance score combines three structural signals:
Access frequency
The first factor normalizes how often an entity has been accessed relative to the most-accessed entity in the candidate set:
access_norm = log₂(1 + access_count) / log₂(1 + max_access)
The logarithm compresses the scale so that the difference between 1 and 10 accesses matters more than between 100 and 110. This prevents entities that were accidentally accessed many times from permanently dominating.
Numerical example: an entity with 10 accesses when the maximum in the set is 20:
access_norm = log₂(11) / log₂(21) = 3.459 / 4.392 ≈ 0.787
Not half (0.5), but ~0.79 — the logarithm preserves more signal at lower counts.
Graph degree
The second factor rewards entities with many relationships in the knowledge graph:
degree_factor = (1 + β_deg × degree_norm)
An entity at the center of a knowledge graph (many → and ← relations) is likely more important than an isolated one. The degree is capped at D_MAX (15) to prevent hub entities from dominating.
Multi-day consolidation
The third factor captures whether an entity has been accessed on multiple distinct days, indicating sustained relevance rather than a one-time spike:
consolidation = log₂(1 + access_days) / log₂(1 + max_access_days)
Where access_days is the number of distinct calendar days on which the entity has been accessed (recorded in the entity_access table). This prevents an entity accessed 100 times in one hour from outranking an entity accessed once per day for a month. The logarithmic scale keeps the factor bounded — with α_cons = 0.2, the maximum boost from consolidation is 1.2×.
Combined example
An entity with 10 accesses (max 20), 8 relations (D_MAX = 15), and accessed on 5 distinct days (max 10):
access_norm = log₂(11) / log₂(21) ≈ 0.787
degree_norm = min(8, 15) / 15 = 0.533
consolidation = log₂(6) / log₂(11) = 2.585 / 3.459 ≈ 0.747
importance = 0.787 × (1 + 0.15 × 0.533) × (1 + 0.2 × 0.747)
= 0.787 × 1.080 × 1.149
≈ 0.977
The degree signal adds a modest 8% boost, and consolidation adds a ~15% boost — both secondary to access frequency, which remains the primary signal.
Sub-formula: temporal_factor(e)
temporal_factor(e) = max(TEMPORAL_FLOOR, exp(-LAMBDA_HOURLY × Δt_hours))
Where:
Δt_hours= hours since the entity was last accessed (viasearch_semanticoropen_nodes). If the entity has never been accessed,created_atis used instead.LAMBDA_HOURLY = 0.0001— the exponential decay rate per hourTEMPORAL_FLOOR = 0.1— the minimum value the factor can reach
Decay characteristics
With LAMBDA_HOURLY = 0.0001, the half-life of the decay is:
half-life = ln(2) / 0.0001 ≈ 6931 hours ≈ 289 days
This is a slow decay by design. The knowledge graph is a long-lived resource — entities shouldn’t disappear from results just because they haven’t been needed for a few weeks. But over months and years, unused entities gradually recede.
Numerical examples
| Time since last access | Δt (hours) | temporal_factor |
|---|---|---|
| 1 hour | 1 | 0.9999 |
| 1 day | 24 | 0.9976 |
| 1 week | 168 | 0.9833 |
| 30 days | 720 | 0.9305 |
| 90 days | 2160 | 0.8053 |
| 180 days | 4320 | 0.6485 |
| 1 year | 8766 | 0.4172 |
| 2 years | 17532 | 0.1740 |
| 3+ years | 26000+ | → 0.1 (floor) |
:::caution The floor of 0.1 means that no entity is ever fully forgotten. Even a 5-year-old entity retains 10% of its temporal signal. If you need true expiration (e.g., for time-sensitive data), you must implement it at the application layer. :::
Sub-formula: cooc_boost(e, R)
cooc_boost(e, R) = Σ_{r ∈ R, r ≠ e} log₂(1 + co_count(e, r)) × decay(last_co(e, r))
This sums the co-occurrence counts between entity e and every other entity r in the result set R, weighted by temporal decay. The logarithm smooths the contribution:
| co_count | log₂(1 + co_count) | Raw multiplier |
|---|---|---|
| 1 | 1.00 | 1× |
| 5 | 2.58 | 5× |
| 10 | 3.46 | 10× |
| 50 | 5.67 | 50× |
| 100 | 6.66 | 100× |
Co-occurrence Temporal Decay
The decay(last_co) factor uses the same half-life (~290 days) as compute_temporal_factor:
decay(last_co) = max(COOC_TEMPORAL_FLOOR, exp(-LAMBDA_HOURLY × Δt_hours))
This means recent co-occurrences boost more than stale ones. An entity that appeared together with another entity yesterday contributes more to the co-occurrence boost than one that co-occurred 6 months ago.
| Time since co-occurrence | Decay factor |
|---|---|
| 1 hour | 0.9999 |
| 1 day | 0.9976 |
| 1 week | 0.9833 |
| 30 days | 0.9305 |
| 90 days | 0.8053 |
| 1 year | 0.4172 |
| 2+ years | → 0.1 (floor) |
:::tip
The γ (GAMMA) constant is deliberately small (0.01). Even a co-occurrence boost of 5.0 only adds 1 + 0.01 × 5.0 = 1.05 — a 5% improvement. This prevents co-occurrence from dominating the scoring, keeping it as a tiebreaker rather than a primary signal.
:::
Tuneable Constants
All constants live at the module level in src/mcp_memory/scoring.py and are directly editable.
| Constant | Value | Purpose | Effect of increasing |
|---|---|---|---|
BETA_SAL | 0.5 | Weight of salience boost on the composite score | Higher → importance matters more; an entity with importance=1.0 gets a 1.5× boost |
BETA_DEG | 0.15 | Weight of graph degree within importance | Higher → well-connected entities rank higher; low by design since degree is secondary |
D_MAX | 15 | Cap on relation count for degree normalization | Higher → entities with many relations keep getting boosted; 15 is a reasonable hub threshold |
LAMBDA_HOURLY | 0.0001 | Temporal decay rate per hour | Higher → faster forgetting; current half-life ≈ 290 days |
GAMMA | 0.01 | Weight of co-occurrence boost on the composite score | Higher → co-occurring entities rank higher; reduced from 0.1 which dominated scoring 24× |
RRF_K | 60 | Smoothing constant for Reciprocal Rank Fusion | Higher → rank position matters less; 60 is the standard value from the original RRF paper |
EXPANSION_FACTOR | 3 | KNN over-retrieval multiplier | Higher → more candidates for re-ranking at the cost of computation; limit=10 → 30 candidates |
TEMPORAL_FLOOR | 0.1 | Minimum value for temporal decay | Higher → old entities retain more signal; 0.1 means knowledge degrades but is never destroyed |
:::caution
The constants are tuned to work together. Changing one without considering the others can produce unexpected results. For example, setting GAMMA=0.1 without reducing BETA_SAL will cause co-occurrence to dominate salience, effectively ignoring access patterns.
:::
Status Deboosts
After limbic scoring, results are adjusted based on entity status and observation composition:
| Status | Deboost | Effect |
|---|---|---|
activo | 1.0× (none) | Default, no penalty |
pausado | 0.85× | Slight penalty |
completado | 0.70× | Moderate penalty |
archivado | 0.50× | Strong penalty |
Metadata Penalty
If more than 50% of an entity’s observations have kind="metadata", its limbic_score is multiplied by 0.7. This prevents metadata-heavy entities (file listings, config dumps) from dominating search results.
Signal Tracking
The Limbic System relies on three signals recorded during normal API usage:
entity_access table
CREATE TABLE entity_access (
entity_id INTEGER PRIMARY KEY REFERENCES entities(id) ON DELETE CASCADE,
access_count INTEGER NOT NULL DEFAULT 1,
last_access TEXT NOT NULL DEFAULT (datetime('now'))
);
- When recorded: every call to
search_semantic(for top-K results) andopen_nodes(for opened entities). - What’s stored:
access_count(incremented on each access) andlast_access(updated to current timestamp). - Behavior: best-effort — recording happens after the response is built and does not affect the current result.
co_occurrences table
CREATE TABLE co_occurrences (
entity_a_id INTEGER NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
entity_b_id INTEGER NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
co_count INTEGER NOT NULL DEFAULT 1,
last_co TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (entity_a_id, entity_b_id)
);
- When recorded: after
search_semantic(for all pairs in the top-K set) and afteropen_nodes(when 2+ entities are opened together). - What’s stored:
co_count(incremented per pair) andlast_co(updated timestamp). Pairs are stored in canonical order:entity_a_id < entity_b_idalways, preventing duplicate pairs like (A, B) and (B, A). - Behavior: best-effort, post-response, non-blocking.
:::note Signals are recorded for entities returned in the top-K results (the final output after re-ranking), not for the expanded candidate set. This means only entities the user actually sees generate tracking data. :::
Auto-Tuning: GAMMA and BETA_SAL Optimization
The limbic scoring constants (GAMMA and BETA_SAL) can be automatically tuned via offline grid search using data collected from shadow mode A/B testing.
Why Tune?
The default values (GAMMA=0.01, BETA_SAL=0.5) work well as starting points, but the optimal balance depends on your usage patterns. Auto-tuning finds the sweet spot for your specific knowledge graph.
How It Works
- Collect data: Shadow mode logs every
search_semanticquery with both baseline and limbic rankings - Compute metrics:
ab_metrics.pycalculates NDCG@K using implicit feedback (re-access events) - Grid search:
auto_tuner.py --tuneexplores GAMMA × BETA_SAL combinations - Apply smoothly: New values are blended via exponential moving average (10% new, 90% old)
Usage
# Analyze current performance
python scripts/auto_tuner.py --analyze
# Find optimal params and apply
python scripts/auto_tuner.py --tune
# Force specific values with smooth blend
python scripts/auto_tuner.py --set-gamma 0.05 --set-beta 0.75
# Custom ranges
python scripts/auto_tuner.py --tune --gamma-range "0.001,0.01,0.05,0.1" --beta-sal-range "0.1,0.5,1.0"
Metrics
| Metric | Description |
|---|---|
| NDCG@K | Normalized Discounted Cumulative Gain at K — how well the ranking matches implicit relevance |
| Lift@K | Proportion of relevant items in top-K vs overall —越高越好 |
| Gain | NDCG improvement over current parameters |
Requirements
- Minimum 50 events with treatment=1 (A/B test treatment group)
- Implicit feedback data (re-access events via
record_access) - Sufficient parameter space coverage
:::caution
Auto-tuning requires accumulated shadow mode data. Without sufficient events, the --tune command will skip with a message like “Only 23 events, minimum 50 required”.
:::
Complete Pipeline Flow
graph TD
Q["Query string"] --> Encode["1. Encode<br/>engine.encode(query)<br/>→ float[384]"]
Encode --> KNN["2. KNN 3×<br/>search_embeddings(query, limit × 3)<br/>→ [{entity_id, distance}]"]
KNN --> Fetch["3. Fetch metadata<br/>access_data · degrees · co_occurrences"]
Fetch --> Rerank["4. Re-rank<br/>rank_candidates()<br/>compute limbic_score per candidate<br/>→ sort descending → top-K"]
Rerank --> Build["5. Build output<br/>{name, entityType, observations,<br/>distance, limbic_score, scoring}"]
Build --> Record["6. Record signals<br/>record_access(top-K ids)<br/>record_co_occurrences(top-K ids)<br/>best-effort, post-response"]
Step by step:
- Encode query — the query string is converted to a 384-dimensional vector using the embedding model with
task="query"prefix. - KNN 3× — the vector is compared against all entity embeddings via sqlite-vec KNN, retrieving
limit × EXPANSION_FACTORcandidates (default: 3× the requested limit). - Fetch metadata — for each candidate, the system loads access counts, last access times, graph degrees, and co-occurrence data from the tracking tables.
- Re-rank — the
rank_candidates()function computes the limbic score for each candidate using the formula above, sorts by score descending, and returns the top-K. - Build output — results are formatted with all fields: original data plus
limbic_score,scoringbreakdown, anddistance. - Record signals — access counts and co-occurrences for the returned entities are updated. This happens after the response is built, is best-effort, and does not affect the current result.
:::tip
The EXPANSION_FACTOR of 3× is what makes re-ranking effective. If you request 10 results, the system retrieves 30 candidates, re-ranks all 30, and returns the best 10. This gives the Limbic System room to surface entities that rank lower on pure similarity but higher on importance and recency.
:::
API Transparency
The Limbic System extends the search_semantic API in a backward-compatible way.
Input: unchanged
search_semantic(query, limit)
No new parameters. The scoring happens automatically.
Output: extended fields
Each result includes new fields alongside the originals:
{
"results": [
{
"name": "SofIA - Sistema Multiagente",
"entityType": "Proyecto",
"observations": ["Sistema multiagente con roles especializados"],
"distance": 0.42,
"limbic_score": 0.67,
"scoring": {
"importance": 0.85,
"temporal_factor": 0.99,
"cooc_boost": 1.23
}
}
]
}
| Field | Type | Description |
|---|---|---|
distance | float | Original cosine distance from KNN — not the limbic score |
limbic_score | float | Composite score that determines result ordering |
scoring | object | Breakdown of the three limbic components |
scoring.importance | float | Salience score (access frequency + graph degree) |
scoring.temporal_factor | float | Temporal decay factor (1.0 = just accessed, → 0.1 floor) |
scoring.cooc_boost | float | Co-occurrence boost from appearing with related entities |
rrf_score | float? | Present only in Hybrid Search mode |
Ordering
Results are ordered by limbic_score descending (highest first), not by cosine distance ascending. This is the key behavioral change: the most relevant-and-important entity appears first, even if a slightly more similar but unimportant entity exists.
Tracking scope
Co-occurrence tracking fires in two places:
search_semantic— for all entity pairs in the returned top-K setopen_nodes— when 2+ entities are opened together in the same call
See the Tools Reference for the full API specification of both tools.
Module: scoring.py
The scoring engine lives in src/mcp_memory/scoring.py (~351 lines). It exposes the following public functions:
| Function | Signature | Purpose |
|---|---|---|
rank_candidates() | (candidates, access_data, degrees, co_occurrences, limit) → list[dict] | Re-rank KNN results with limbic scoring — pure semantic mode |
rank_hybrid_candidates() | (candidates, access_data, degrees, co_occurrences, limit) → list[dict] | Re-rank RRF-merged results with limbic scoring — hybrid mode |
reciprocal_rank_fusion() | (semantic_results, fts_results, k) → list[dict] | Merge KNN and FTS5 rankings using RRF |
compute_importance() | (access_count, max_access, degree) → float | Calculate importance(e) from raw signals |
compute_temporal_factor() | (last_access, created_at) → float | Calculate temporal_factor(e) from timestamps |
compute_cooc_boost() | (entity_id, co_occurrences, result_ids) → float | Calculate cooc_boost(e, R) from co-occurrence map |
All constants (BETA_SAL, BETA_DEG, D_MAX, LAMBDA_HOURLY, GAMMA, RRF_K, EXPANSION_FACTOR, TEMPORAL_FLOOR) are module-level variables and can be overridden at import time or patched for testing.
Usage in the pipeline
# Pure semantic mode
ranked = rank_candidates(
candidates=knn_results, # [{entity_id, distance}]
access_data=access_data, # {entity_id: {access_count, last_access}}
degrees=degrees, # {entity_id: int}
co_occurrences=co_occurrences, # {(a_id, b_id): co_count}
limit=10
)
# Hybrid mode (KNN + FTS5)
merged = reciprocal_rank_fusion(knn_results, fts_results, k=RRF_K)
ranked = rank_hybrid_candidates(
candidates=merged,
access_data=access_data,
degrees=degrees,
co_occurrences=co_occurrences,
limit=10
)
:::note
The rank_hybrid_candidates() function handles the special case where FTS5-only entities have distance = None. It estimates their base relevance from the normalized RRF score (range [0.2, 0.8]) instead of using cosine similarity.
:::
Worked Example
Walk through a complete scoring calculation for a concrete entity.
Entity: “FastMCP” (a framework entity)
- Access count: 10 (max in candidate set: 20)
- Graph degree: 8 relations
- Last accessed: 30 days ago
- Co-occurrence with 3 other results: counts of [5, 2, 1]
- Cosine distance from query: 0.35
Step 1 — Base similarity:
cosine_sim = max(0, 1 - 0.35) = 0.65
Step 2 — Importance:
access_factor = log₂(11) / log₂(21) = 3.459 / 4.392 ≈ 0.787
degree_factor = 1 + 0.15 × (8/15) = 1 + 0.08 = 1.08
importance = 0.787 × 1.08 ≈ 0.850
salience_boost = 1 + 0.5 × 0.850 = 1.425
Step 3 — Temporal factor:
Δt = 30 days × 24 = 720 hours
temporal_factor = exp(-0.0001 × 720) = exp(-0.072) ≈ 0.931
Step 4 — Co-occurrence boost:
cooc_boost = log₂(6) + log₂(3) + log₂(2) = 2.585 + 1.585 + 1.000 = 5.170
cooc_factor = 1 + 0.01 × 5.170 = 1.052
Step 5 — Composite limbic score:
limbic_score = 0.65 × 1.425 × 0.931 × 1.052
= 0.65 × 1.425 × 0.931 × 1.052
≈ 0.908
The Limbic System boosted this entity from a raw similarity of 0.65 to a composite score of 0.908 — a 40% increase driven by importance (primary), temporal recency (secondary), and co-occurrence (tertiary).
Related
- Hybrid Search — how FTS5 and KNN merge via RRF before limbic re-ranking
- Semantic Search — the KNN pipeline that produces the initial candidates
- Tools Reference — full API specification for
search_semanticandopen_nodes - Architecture — system overview including the scoring module’s place in the data flow