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

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.

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

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

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:

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)

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_MAX
  • consolidation = 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 (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

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

:::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_countlog₂(1 + co_count)Raw multiplier
11.00
52.58
103.4610×
505.6750×
1006.66100×

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

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

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

:::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:

StatusDeboostEffect
activo1.0× (none)Default, no penalty
pausado0.85×Slight penalty
completado0.70×Moderate penalty
archivado0.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) 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.

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

:::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

  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)

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

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

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:

  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.

:::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
      }
    }
  ]
}
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

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

Module: scoring.py

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.

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

  • 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