Limbic System
What Is the Limbic System?
Section titled “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).
The Four Capabilities
Section titled “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
Section titled “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
Section titled “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
Section titled “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_BALANCEDBlending Formula
Section titled “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_normLIMBIC_HEAVY: final = 0.3 × cosine + 0.7 × limbic_normHYBRID_BALANCED: final = 0.5 × cosine + 0.5 × limbic_normWhere limbic_norm is min-max normalized across the candidate set.
API Output
Section titled “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
Section titled “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)
Section titled “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.
Sub-formula: importance(e)
Section titled “Sub-formula: importance(e)”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:
Access frequency
Section titled “Access frequency”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.787Not half (0.5), but ~0.79 — the logarithm preserves more signal at lower counts.
Graph degree
Section titled “Graph degree”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.
Combined example
Section titled “Combined example”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.850The degree signal adds a modest 8% boost — intentional, since degree is a secondary signal compared to access frequency.
Sub-formula: temporal_factor(e)
Section titled “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
Section titled “Decay characteristics”With LAMBDA_HOURLY = 0.0001, the half-life of the decay is:
half-life = ln(2) / 0.0001 ≈ 6931 hours ≈ 289 daysThis 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
Section titled “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) |
Sub-formula: cooc_boost(e, R)
Section titled “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
Section titled “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) |
Tuneable Constants
Section titled “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 |
Signal Tracking
Section titled “Signal Tracking”The Limbic System relies on three signals recorded during normal API usage:
entity_access table
Section titled “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
Section titled “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.
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.
Why Tune?
Section titled “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
Section titled “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)
# Analyze current performancepython scripts/auto_tuner.py --analyze
# Find optimal params and applypython scripts/auto_tuner.py --tune
# Force specific values with smooth blendpython scripts/auto_tuner.py --set-gamma 0.05 --set-beta 0.75
# Custom rangespython 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
Section titled “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
Section titled “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
Complete Pipeline Flow
Section titled “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.
API Transparency
Section titled “API Transparency”The Limbic System extends the search_semantic API in a backward-compatible way.
Input: unchanged
Section titled “Input: unchanged”search_semantic(query, limit)No new parameters. The scoring happens automatically.
Output: extended fields
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “Usage in the pipeline”# Pure semantic moderanked = 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)Worked Example
Section titled “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.65Step 2 — Importance:
access_factor = log₂(11) / log₂(21) = 3.459 / 4.392 ≈ 0.787degree_factor = 1 + 0.15 × (8/15) = 1 + 0.08 = 1.08importance = 0.787 × 1.08 ≈ 0.850salience_boost = 1 + 0.5 × 0.850 = 1.425Step 3 — Temporal factor:
Δt = 30 days × 24 = 720 hourstemporal_factor = exp(-0.0001 × 720) = exp(-0.072) ≈ 0.931Step 4 — Co-occurrence boost:
cooc_boost = log₂(6) + log₂(3) + log₂(2) = 2.585 + 1.585 + 1.000 = 5.170cooc_factor = 1 + 0.01 × 5.170 = 1.052Step 5 — Composite limbic score:
limbic_score = 0.65 × 1.425 × 0.931 × 1.052 = 0.65 × 1.425 × 0.931 × 1.052 ≈ 0.908The 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
Section titled “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