Skip to content

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).

CapabilityWhat it doesBiological metaphor
SalienceFrequently accessed and well-connected entities rank higherEmotional valence — important things are remembered better
Temporal DecayEntities not accessed recently gradually decrease in rankForgetting — unused knowledge fades
Co-occurrenceEntities that frequently appear together reinforce each otherAssociation — things that fire together, wire together
Co-occurrence Temporal DecayRecent co-occurrences count more than old onesRecent 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.

MCP Memory v2 automatically selects the best scoring strategy based on the query characteristics. This is called query routing.

StrategyCosine WeightLimbic WeightBest For
COSINE_HEAVY70%30%Factual queries: definitions, “what is”, exact terms
LIMBIC_HEAVY30%70%Exploratory queries: “explain everything”, broad context
HYBRID_BALANCED50%50%Mixed queries: relationships, comparisons

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

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.

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.

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:

ComponentRangeRole
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) = 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.

importance(e) = [log₂(1 + access_count) / log₂(1 + max_access)] × (1 + β_deg × min(degree, D_max) / D_max)

The importance score combines two structural signals:

The first factor normalizes how often an entity has been accessed relative to the most-accessed entity in the candidate set:

access_factor = 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_factor = 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.

The second factor rewards entities with many relationships in the knowledge graph:

degree_factor = (1 + β_deg × min(degree, D_max) / D_max)

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.

An entity with 10 accesses (max 20), and 8 relations (D_MAX = 15):

importance = [log₂(11) / log₂(21)] × (1 + 0.15 × 8/15)
= 0.787 × (1 + 0.08)
= 0.787 × 1.08
≈ 0.850

The degree signal adds a modest 8% boost — intentional, since degree is a secondary signal compared to access frequency.

temporal_factor(e) = max(TEMPORAL_FLOOR, exp(-LAMBDA_HOURLY × Δt_hours))

Where:

  • Δt_hours = hours since the entity was last accessed (via search_semantic or open_nodes). If the entity has never been accessed, created_at is used instead.
  • LAMBDA_HOURLY = 0.0001 — the exponential decay rate per hour
  • TEMPORAL_FLOOR = 0.1 — the minimum value the factor can reach

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.

Time since last accessΔt (hours)temporal_factor
1 hour10.9999
1 day240.9976
1 week1680.9833
30 days7200.9305
90 days21600.8053
180 days43200.6485
1 year87660.4172
2 years175320.1740
3+ years26000+→ 0.1 (floor)
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_countlog₂(1 + co_count)Raw multiplier
11.00
52.58
103.4610×
505.6750×
1006.66100×

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-occurrenceDecay factor
1 hour0.9999
1 day0.9976
1 week0.9833
30 days0.9305
90 days0.8053
1 year0.4172
2+ years→ 0.1 (floor)

All constants live at the module level in src/mcp_memory/scoring.py and are directly editable.

ConstantValuePurposeEffect of increasing
BETA_SAL0.5Weight of salience boost on the composite scoreHigher → importance matters more; an entity with importance=1.0 gets a 1.5× boost
BETA_DEG0.15Weight of graph degree within importanceHigher → well-connected entities rank higher; low by design since degree is secondary
D_MAX15Cap on relation count for degree normalizationHigher → entities with many relations keep getting boosted; 15 is a reasonable hub threshold
LAMBDA_HOURLY0.0001Temporal decay rate per hourHigher → faster forgetting; current half-life ≈ 290 days
GAMMA0.01Weight of co-occurrence boost on the composite scoreHigher → co-occurring entities rank higher; reduced from 0.1 which dominated scoring 24×
RRF_K60Smoothing constant for Reciprocal Rank FusionHigher → rank position matters less; 60 is the standard value from the original RRF paper
EXPANSION_FACTOR3KNN over-retrieval multiplierHigher → more candidates for re-ranking at the cost of computation; limit=10 → 30 candidates
TEMPORAL_FLOOR0.1Minimum value for temporal decayHigher → old entities retain more signal; 0.1 means knowledge degrades but is never destroyed

The Limbic System relies on three signals recorded during normal API usage:

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) and open_nodes (for opened entities).
  • What’s stored: access_count (incremented on each access) and last_access (updated to current timestamp).
  • Behavior: best-effort — recording happens after the response is built and does not affect the current result.
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 after open_nodes (when 2+ entities are opened together).
  • What’s stored: co_count (incremented per pair) and last_co (updated timestamp). Pairs are stored in canonical order: entity_a_id < entity_b_id always, preventing duplicate pairs like (A, B) and (B, A).
  • Behavior: best-effort, post-response, non-blocking.

Auto-Tuning: GAMMA and BETA_SAL Optimization

Section titled “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.

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.

  1. Collect data: Shadow mode logs every search_semantic query with both baseline and limbic rankings
  2. Compute metrics: ab_metrics.py calculates NDCG@K using implicit feedback (re-access events)
  3. Grid search: auto_tuner.py --tune explores GAMMA × BETA_SAL combinations
  4. Apply smoothly: New values are blended via exponential moving average (10% new, 90% old)
Terminal window
# 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"
MetricDescription
NDCG@KNormalized Discounted Cumulative Gain at K — how well the ranking matches implicit relevance
Lift@KProportion of relevant items in top-K vs overall —越高越好
GainNDCG improvement over current parameters
  • Minimum 50 events with treatment=1 (A/B test treatment group)
  • Implicit feedback data (re-access events via record_access)
  • Sufficient parameter space coverage
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:

  1. Encode query — the query string is converted to a 384-dimensional vector using the embedding model with task="query" prefix.
  2. KNN 3× — the vector is compared against all entity embeddings via sqlite-vec KNN, retrieving limit × EXPANSION_FACTOR candidates (default: 3× the requested limit).
  3. Fetch metadata — for each candidate, the system loads access counts, last access times, graph degrees, and co-occurrence data from the tracking tables.
  4. 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.
  5. Build output — results are formatted with all fields: original data plus limbic_score, scoring breakdown, and distance.
  6. 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.

The Limbic System extends the search_semantic API in a backward-compatible way.

search_semantic(query, limit)

No new parameters. The scoring happens automatically.

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
}
}]
}
FieldTypeDescription
distancefloatOriginal cosine distance from KNN — not the limbic score
limbic_scorefloatComposite score that determines result ordering
scoringobjectBreakdown of the three limbic components
scoring.importancefloatSalience score (access frequency + graph degree)
scoring.temporal_factorfloatTemporal decay factor (1.0 = just accessed, → 0.1 floor)
scoring.cooc_boostfloatCo-occurrence boost from appearing with related entities
rrf_scorefloat?Present only in Hybrid Search mode

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.

Co-occurrence tracking fires in two places:

  • search_semantic — for all entity pairs in the returned top-K set
  • open_nodes — when 2+ entities are opened together in the same call

See the Tools Reference for the full API specification of both tools.

The scoring engine lives in src/mcp_memory/scoring.py (~351 lines). It exposes the following public functions:

FunctionSignaturePurpose
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) → floatCalculate importance(e) from raw signals
compute_temporal_factor()(last_access, created_at) → floatCalculate temporal_factor(e) from timestamps
compute_cooc_boost()(entity_id, co_occurrences, result_ids) → floatCalculate 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.

# 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
)

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).

  • 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_semantic and open_nodes
  • Architecture — system overview including the scoring module’s place in the data flow