ferrex is a local-first MCP server that provides intelligent long-term memory for AI agents. It combines vector search and BM25 keyword matching into a hybrid retrieval system — exposed through memory-typed tools that agents interact with naturally.
The goal is not another vector store with an MCP wrapper. It is a memory system that understands temporal facts, resolves contradictions, manages its own lifecycle, and retrieves context through complementary signal paths fused into one result.
- Minimal ops — one Rust binary + Qdrant sidecar. No Python, no Docker compose stacks, no cloud accounts.
- Memory semantics, not storage semantics — the API speaks in episodic/semantic/procedural terms, not vectors and indexes.
- Retrieval quality over storage volume — hybrid search with reranking by default. Better to return 3 excellent results than 10 mediocre ones.
- Temporal awareness — every fact has a validity timeline. Contradictions are detected and resolved, not silently accumulated.
- Staleness-aware — stale memories are detected, flagged, and decayed. The system never silently returns outdated facts without signaling freshness.
- Local-first — embeddings generated locally (ONNX), data stored locally, no network calls required for core operation.
The landscape (as of April 2026):
| System | Approach | ferrex differentiator |
|---|---|---|
| mem0 | LLM-based extraction → vector store | No LLM dependency; preserves original detail |
| Hindsight | 4-network retrieval, Python/FastAPI, Postgres. SOTA on BEAM 10M (64.1%) | Single Rust binary, no Python runtime |
| Honcho | Continual learning with custom reasoning models (Neuromancer). LLM-heavy, cloud-first. 90.4% LongMemEval-S, 40.6% BEAM 10M | LLM-free, local-first. ferrex keeps reasoning in the calling agent, not the server |
| Cognee | KG + vector, Python, 30+ connectors | Lightweight, embeddable, MCP-native |
| Zep/Graphiti | Temporal KG, Neo4j. 71.2% LongMemEval-S | No Neo4j; SQLite + Qdrant sidecar |
| Letta/MemGPT | Self-editing memory, agent controls recall | Hybrid search (vector + BM25), temporal validity |
| Supermemory | MCP-native, ~85% LongMemEval-S (gpt-4o) | Local-first, no cloud dependency, temporal validity |
| A-Mem | Zettelkasten-style linked notes (NeurIPS 2025) | Hybrid search, temporal awareness, staleness safeguards |
| Memobase.ai | MCP-native memory-as-a-service | Local-first, no cloud dependency |
| System | Approach | Why ferrex is complementary, not competing |
|---|---|---|
| Mastra OM | Two background LLM agents compress conversation into dense observation log. No retrieval. 84.2% LongMemEval-S (gpt-4o), 94.9% (gpt-5-mini) | OM manages intra-session context windows; ferrex provides persistent cross-session queryable memory. OM can't recall facts from a different project or last month — it compresses what's in the current window. The two are complementary |
| System | Approach | What ferrex adds |
|---|---|---|
| memory-mcp-rs | Rust + SQLite KG, FTS5 search | Vector search, BM25, reranking, temporal validity, memory types |
| memory-mcp | Markdown files in git + fastembed | Hybrid search, entity resolution, staleness detection, conflict resolution |
| rusty-mcp | Qdrant + Ollama embeddings | No Ollama dependency, BM25, memory type semantics, temporal awareness |
| sqlite-mcp-rs | SQLite + optional vector/fastembed | Memory-typed API, entity resolution, lifecycle management |
These are thin wrappers — vector store or KG with MCP glue. None combine memory type semantics, temporal validity, staleness detection, entity resolution, conflict detection, hybrid search with server-side RRF, and cross-encoder reranking.
LongMemEval-S (~115k tokens) is approaching obsolescence as context windows grow beyond 128k. BEAM at 10M tokens is the emerging stress test where context stuffing is impossible and only real memory architectures survive. Current BEAM 10M leaders: Hindsight (64.1%), Honcho (40.6%).
ferrex's position: Rust-native + Qdrant (sidecar or external) + hybrid search (vector + BM25) + temporal validity + staleness safeguards + LLM-free + MCP-native. No published benchmark scores yet — BEAM 10M and LongMemEval evaluation planned post-v1.
┌─────────────────────────────────────────────────────┐
│ MCP Transport │
│ (rmcp, stdio) │
└────────────────────────┬────────────────────────────┘
│
┌────────────────────────▼────────────────────────────┐
│ Tool Router │
│ store / recall / forget / reflect / stats │
└────────────────────────┬────────────────────────────┘
│
┌────────────────────────▼────────────────────────────┐
│ Memory Router │
│ classifies queries by complexity, routes to │
│ appropriate retrieval strategy │
└───────┬────────────────┬────────────────┬───────────┘
│ │ │
┌───────▼───────┐ ┌──────▼──────┐ ┌───────▼───────┐
│ Ingestion │ │ Retrieval │ │ Lifecycle │
│ Pipeline │ │ Engine │ │ Manager │
└───────┬───────┘ └──────┬──────┘ └───────┬───────┘
│ │ │
┌───────▼────────────────▼────────────────▼───────────┐
│ Storage Layer │
│ ┌──────────────────────────────┐ │
│ │ Qdrant (sidecar process) │ │
│ │ • Dense vector index (HNSW) │ │
│ │ • Sparse/BM25 index │ │
│ │ • Payload filtering │ │
│ └──────────────────────────────┘ │
│ ┌──────────────────────────────┐ │
│ │ SQLite (in-process) │ │
│ │ • Entity/relation tables │ │
│ │ • Temporal validity tracking │ │
│ │ • Metadata, access counts │ │
│ │ • Staleness scores │ │
│ └──────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
│
┌───────▼─────────────────────────────────────────────┐
│ Embedding Engine │
│ (fastembed, ONNX local) │
│ embedding: configurable (see Model Tiers) │
│ reranking: configurable (see Model Tiers) │
└─────────────────────────────────────────────────────┘
ferrex communicates with Qdrant via gRPC using qdrant-client. Two modes:
- Sidecar (default) — ferrex manages a local Qdrant subprocess. Starts on launch, stops on shutdown. Invisible to the user. Data stored at
~/.ferrex/qdrant-data/. - External (
--qdrant-url <url>) — ferrex connects to a user-provided Qdrant instance. No sidecar process, no local data directory. Use this for team deployments, existing infrastructure, or when you prefer to manage Qdrant yourself. Fail-fast: one connection attempt with 3-second timeout. On failure:"error: cannot connect to Qdrant at {url} — is it running?"and exit. No retry — the user manages the instance.
When --qdrant-url is set, sidecar management is skipped entirely. The same qdrant-client code handles both modes — the only difference is the connection target.
This trades pure single-binary for access to Qdrant's full feature set: HNSW with fused payload filtering, sparse vectors for BM25 (server-side tokenization + IDF), named vectors for hybrid search, and the Query API for server-side RRF fusion in a single request.
All stored facts use a self-contained format: each memory must be independently meaningful without surrounding context. The recommended structure is "what | when | where | who | why" — ensuring that a retrieved memory makes sense even without its neighbors. This is critical for retrieval quality (learned from Hindsight's approach).
Records of specific events and interactions. Timestamped, contextual, append-only.
{
"type": "episodic",
"content": "user debugged a deadlock in the connection pool by switching to tokio::sync::Semaphore | 2026-04-03 | api-server project | outcome: success",
"context": { "task": "bug-fix", "project": "api-server", "outcome": "success" },
"timestamp": "2026-04-03T10:30:00Z",
"entities": ["connection-pool", "tokio::sync::Semaphore", "api-server"]
}
- Storage: vector embedding + BM25 index + metadata
- Retrieval: temporal + similarity search
- Lifecycle: decays after configurable TTL unless accessed. Candidates for consolidation into semantic memory via
reflect.
Stable facts, concepts, entity relationships. The knowledge graph lives here.
{
"type": "semantic",
"subject": "api-server",
"predicate": "uses",
"object": "tokio 1.38",
"confidence": 0.95,
"source": "episodic:abc123",
"t_valid": "2026-04-01T00:00:00Z",
"t_invalid": null
}
- Storage: vector embedding + BM25 index + entity metadata + SQLite tables
- Retrieval: exact match + semantic search + entity filtering
- Lifecycle: never auto-decays. Updated via upsert with conflict resolution. Old values archived with
t_invalidtimestamp set. - Temporal validity: every semantic fact has
t_valid(when it became true) andt_invalid(when it stopped being true, null if current). Queries default to current facts only. Historical queries can specify a time range to include invalidated facts. (Adopted from Zep/Graphiti's bi-temporal model, which scored 94.8% on DMR benchmark.)
Workflows, heuristics, learned strategies. Versioned.
{
"type": "procedural",
"name": "deploy-to-staging",
"conditions": ["branch is main", "tests pass"],
"steps": ["build release", "push to registry", "apply k8s manifest"],
"version": 3
}
- Storage: vector embedding + BM25 index + metadata
- Retrieval: pattern matching on conditions + similarity
- Lifecycle: versioned. Old versions kept for rollback.
Tool count is kept low (5 tools) to minimize context window tax. Research shows MCP tools can consume 16%+ of context — fewer, richer tools are better.
MCP tool descriptions are loaded into the agent's context at session start. They are the primary mechanism for guiding agent behavior — no hooks, no system prompt injection needed. The descriptions below are carefully crafted to serve as both API documentation and implicit behavioral instructions.
MCP description (what the agent sees):
Store a memory for long-term recall. Call this whenever you learn something worth remembering: new facts about the user or project, decisions made, problems solved, workflows discovered, or corrections to previous knowledge. You can specify type explicitly ("episodic" for events, "semantic" for stable facts, "procedural" for workflows) or omit it and the system will auto-detect from the fields you provide. Write self-contained memories — each should make sense on its own without surrounding context. Include relevant entities for filtering. If this updates a previously known fact, the system detects and resolves the contradiction automatically. Near-duplicate memories are rejected automatically.
| Parameter | Type | Required | Description |
|---|---|---|---|
type |
string | no | "episodic", "semantic", or "procedural". Auto-detected when omitted: semantic if subject+predicate+object provided, episodic otherwise. Procedural requires explicit type. |
content |
string | yes* | What happened (episodic) or the procedure steps (procedural). Self-contained format recommended: "what | when | where | who | why" |
subject |
string | yes* | The entity this fact is about (semantic only) |
predicate |
string | yes* | The relationship or property (semantic only) |
object |
string | yes* | The value or target entity (semantic only) |
confidence |
float | no | 0.0-1.0, defaults to 1.0 |
source |
string | no | Provenance (memory ID, URL, etc.) |
entities |
string[] | no | Entity names for filtering and future knowledge graph expansion |
context |
object | no | Structured context (task, project, outcome, etc.) |
supersedes |
string | no | Memory ID to explicitly replace (skips similarity check) |
*Required fields depend on type (explicit or auto-detected): episodic/procedural require content; semantic requires subject+predicate+object.
On store, the ingestion pipeline:
- Type resolution: if
typeomitted, auto-detect from provided fields - Deduplication check: search existing same-type memories by embedding similarity. If cosine > 0.95 → reject with
"similar memory already exists: {id}". Prevents agents from storing the same fact repeatedly with slight rewording. - Chunking (if needed): apply type-aware chunking (see Chunking Strategy)
- Embed via fastembed → write dense vector to Qdrant. BM25 sparse vectors are computed server-side by Qdrant (send raw text, Qdrant handles tokenization + IDF via
Modifier::IDFonSparseVectorParams) - Entity resolution: resolve entity names via layered pipeline (normalize → fuzzy → embedding → alias), store as Qdrant payload metadata + SQLite entity table (see Entity Resolution)
- Write metadata to SQLite (timestamps, access counts, staleness fields)
- For semantic type: run conflict detection (see Conflict Resolution)
- For procedural type: create new version if name already exists
MCP description (what the agent sees):
Search long-term memory. Call this whenever you need to remember something: past discussions, known facts, established workflows, or entity relationships. Use
statsat the start of a conversation for a quick overview — userecallwhen you have a specific question. Returns results ranked by relevance with freshness metadata — check the staleness field to gauge how current each memory is.
| Parameter | Type | Required | Description |
|---|---|---|---|
query |
string | yes | What to search for |
types |
string[] | no | Filter by memory type: ["episodic", "semantic", "procedural"] |
limit |
int | no | Max results, default 5 |
time_range |
object | no | {after: "...", before: "..."} for temporal filtering |
entities |
string[] | no | Filter to memories involving these entities |
include_stale |
bool | no | Include memories flagged as potentially stale, default false |
include_invalidated |
bool | no | Include semantic facts with t_invalid set (historical queries), default false |
Retrieval pipeline (see Retrieval Pipeline Detail for full walkthrough):
- Embed query via fastembed
- Single Qdrant Query API call: prefetch dense + sparse, fuse via server-side RRF (k=60)
- Cross-encoder reranking (top-20 candidates) with multiplicative recency boost
- Return top-N with scores and metadata
- (Phase 4 adds: staleness filter, staleness annotation, freshness metadata)
Each result includes metadata (Phase 2: basic, Phase 4: full freshness):
Phase 2 response shape:
{
"id": "mem_12",
"content": "...",
"score": 0.94,
"age_days": 45
}Phase 4 adds freshness metadata:
{
"id": "mem_12",
"content": "...",
"score": 0.94,
"freshness": {
"age_days": 45,
"last_accessed": "2026-02-17T...",
"last_validated": "2026-03-01T...",
"access_count": 7,
"staleness": "fresh"
}
}staleness field values (Phase 4): "fresh", "aging" (approaching staleness threshold), "stale" (exceeded threshold, returned only if include_stale=true), "superseded" (a newer fact exists for the same subject+predicate).
MCP description (what the agent sees):
Delete or invalidate memories that are no longer accurate or relevant. Use this when you discover a memory is wrong, outdated, or the user asks you to forget something. First use recall to find the memory IDs, then pass them here.
| Parameter | Type | Required | Description |
|---|---|---|---|
ids |
string[] | yes | Specific memory IDs to delete |
cascade |
bool | no | Also remove entity-memory links for entities only referenced by forgotten memories |
MCP description (what the agent sees):
Audit memory health. Call this periodically (e.g., end of a long session or weekly) to: surface stale memories that need review, detect contradictions between active facts, and identify memories that haven't been validated recently. Review the results and use store/forget to address issues.
| Parameter | Type | Required | Description |
|---|---|---|---|
namespace |
string | yes | Namespace to reflect over |
limit |
int | no | Max items to return per category |
include_contradictions |
bool | no | Include contradiction alerts, default true |
include_stale |
bool | no | Include stale memory audit, default true |
Returns:
- List of stale/unvalidated memories that need review
- Contradiction alerts (multiple active facts for same subject+predicate)
- Memories with lowest access counts (candidates for forget)
MCP description (what the agent sees):
Memory system overview. Call this at the START of every conversation for a quick status, or with
detailed=truefor full diagnostics. Default (brief) mode returns just what needs attention and recent context — enough to orient without wasting tokens. Detailed mode adds counts, staleness distribution, and storage info.
| Parameter | Type | Required | Description |
|---|---|---|---|
namespace |
string | yes | Namespace to query stats for |
detailed |
bool | no | Default false (brief mode). Set true for full diagnostics. |
Brief mode (default) — optimized for conversation-start. Minimal token cost:
{
"total": 241,
"recent": [
{ "id": "mem_241", "type": "episodic", "summary": "debugged connection pool deadlock", "age_days": 0 },
{ "id": "mem_240", "type": "semantic", "summary": "api-server uses tokio 1.38", "age_days": 2 }
],
"needs_attention": {
"stale_count": 12,
"conflict_count": 3,
"unvalidated_count": 8
}
}Detailed mode (detailed=true) — full diagnostics for health checks:
{
"counts": { "episodic": 142, "semantic": 87, "procedural": 12 },
"staleness": { "fresh": 198, "aging": 31, "stale": 12 },
"conflicts": 3,
"entities": 64,
"storage_mb": 42.1,
"recent": [
{ "id": "mem_241", "type": "episodic", "summary": "debugged connection pool deadlock", "age_days": 0 },
{ "id": "mem_240", "type": "semantic", "summary": "api-server uses tokio 1.38", "age_days": 2 }
],
"needs_attention": {
"stale_count": 12,
"conflict_count": 3,
"unvalidated_count": 8
}
}Entities mentioned in memories are stored in SQLite and as Qdrant payload metadata for filtering. This provides the foundation for future knowledge graph expansion (see future-improvements.md).
struct Entity {
id: EntityId,
name: String, // "tokio", "api-server", "deadlock bug #42"
entity_type: String, // "library", "project", "event"
created_at: DateTime,
updated_at: DateTime,
}- Entity extraction: when memories are stored with
entitiesparam, look up or create entity rows in SQLite - Payload filtering: entities stored as Qdrant payload on each memory point, enabling filtered vector search (
recallwithentitiesparam) - Memory-entity links: SQLite junction table tracks which memories mention which entities
Agents provide inconsistent entity names ("tokio" vs "Tokio" vs "tokio runtime"). Without resolution, filtering fragments across variant names.
Layered resolution pipeline:
- Normalize — lowercase, trim whitespace, collapse separators.
"Tokio"→"tokio". Check for exact match against existing entities → merge silently. - Fuzzy match — Jaro-Winkler similarity (strsim) > 0.85 against existing entity names and aliases → merge. Catches "postgres" ↔ "postgresql".
- Embedding similarity — cosine > 0.92 → merge, store as alias. Catches semantically equivalent but lexically different names.
- No match → create as new entity.
Each entity has a canonical name + list of aliases. All lookups check aliases first.
Memories are embedded as plain text only — no metadata prefixes. Type, namespace, and date are stored as Qdrant payload fields and filtered at query time using Qdrant's payload filtering.
Why not metadata prefixes? BGE-base-en-v1.5 expects plain text on the document side (it was trained with a specific query instruction prefix, not arbitrary metadata). Structured prefixes like [type | namespace | date] are out-of-distribution and likely degrade embedding quality. Anthropic's Contextual Retrieval (which reported 67% improvement) uses LLM-generated natural language prose, not structured metadata concatenation — a fundamentally different technique.
Embed text by type:
- Episodic: embed the
contentfield directly - Semantic: embed
{subject} {predicate} {object}as a natural sentence - Procedural: embed the
contentfield directly
Query-time filtering via Qdrant payload:
typefilter: restrict to specific memory typesnamespacefilter: scope to project/workspaceentitiesfilter: restrict to memories mentioning specific entities- Temporal filters:
t_valid/t_invalidfor semantic facts, timestamp ranges for episodic
ferrex stores memories, not documents. Most memories are short and should never be chunked — chunking short text destroys more context than it preserves (benchmarks show 54% accuracy for fragmented chunks vs 69% for intact content).
Episodic (never chunk): Events should be self-contained and short. If content exceeds the embedding model's context window, reject with an error: "Episodic memory too long. Break into separate events." This enforces the self-contained fact format at the system level.
Semantic (never chunk): Triples (subject + predicate + object) are always short. No chunking path exists for semantic memories.
Procedural (chunk on step boundaries when needed):
Procedural memories are structured as steps. When content exceeds the model's context window, split on step boundaries — not token counts. Each step becomes its own embedding vector, all linked to the parent memory ID with a step_index.
on store(memory):
embed_text = match memory.type:
"semantic" => "{subject} {predicate} {object}"
"episodic" | "procedural" => memory.content
match memory.type:
"semantic" =>
# Triples are always short — single embedding
embed(embed_text) → 1 vector
"episodic" =>
if tokens(embed_text) > model.max_context:
return error("Episodic memory too long. Break into separate events.")
embed(embed_text) → 1 vector
"procedural" =>
if tokens(embed_text) <= model.max_context:
embed(embed_text) → 1 vector
else:
# Split on step boundaries (steps are already structured)
for (i, step) in split_steps(memory.content):
embed(step) → 1 vector (same memory_id, step_index=i)
At retrieval time: Qdrant returns the best-matching chunk. ferrex deduplicates by memory_id (keeps highest-scoring chunk per memory), returns the full memory content from SQLite.
| Strategy | Why not for ferrex |
|---|---|
| Sliding window | Memories aren't documents. Step boundaries are the natural split points for procedural memories. |
| Semantic chunking | Designed for long documents. On short text it over-fragments (43-token average chunks, poor accuracy). |
| Late chunking | Requires a long-context model for full-document embedding first. Memories are already self-contained — no cross-reference problem to solve. |
| Propositional chunking | Requires an LLM. Our memories are already near-atomic by design (self-contained fact format). |
| Agentic chunking | High computational overhead, consensus is it's not worth the cost (dropped from ACL 2025 benchmarks). |
| RAG fusion | Increases raw recall but gains vanish after reranking (confirmed by arXiv:2603.02153, March 2026). We already have reranking. |
v1 uses two retrieval channels (vector + BM25) fused via RRF, followed by cross-encoder reranking. Adaptive query routing and additional channels (kNN links, graph expansion) are deferred to v2 pending retrieval quality measurements (see future-improvements.md).
Query: "how did we fix the connection pool issue?"
Step 1: Embed query → [0.12, -0.34, 0.56, ...]
Step 2: Hybrid retrieval via Qdrant Query API (single request)
prefetch: [
{ query: dense_vector, using: "dense", limit: 30 },
{ query: sparse_vector, using: "sparse", limit: 30 }
]
query: Fusion::RRF (k=60, server-side)
→ [mem_12, mem_7, mem_3, mem_8, mem_19, ...]
Qdrant fuses dense + BM25 results server-side using RRF in one round-trip.
No client-side fusion code needed. BM25 sparse vectors are also computed
server-side (Qdrant handles tokenization + IDF since v1.15).
Step 3: Reranking (fastembed cross-encoder)
Score top-20 candidates with cross-encoder(query, memory_content)
Apply multiplicative recency boost (not additive — keeps secondary signal proportional):
final_score = rerank_score × recency_boost
Where:
recency_boost (type-specific, half-life decay, floor at 1.0):
episodic: 1.0 + 0.3 × 2^(-age_days/30) // half-life 30d, range 1.0-1.3
semantic: 1.0 + 0.15 × 2^(-age_days/180) // half-life 180d, range 1.0-1.15
procedural: 1.0 // no boost
→ [mem_12: 0.94, mem_7: 0.91, mem_3: 0.87, mem_8: 0.72, mem_19: 0.68]
Step 4: Return top-5
Results include memory content, scores, and basic metadata.
Note: Staleness filtering and freshness annotations are added in Phase 4
when the staleness scoring machinery exists. Phase 2 returns all matched
results without staleness metadata.
When store (type: semantic) is called, conflict detection looks for existing facts that match on (subject, predicate) — with predicate normalization to catch semantic equivalents.
Agents use inconsistent predicates ("uses" vs "depends-on" vs "requires"). Without normalization, conflict detection misses obvious contradictions.
Resolution pipeline (applied before conflict matching):
- Normalize — lowercase, trim, collapse separators, strip hyphens/underscores.
"depends-on"→"dependson". - Synonym map — static canonical mapping for common predicate families:
uses,depends_on,requires,needs→ canonicaldepends_onwritten_in,implemented_in,built_with→ canonicalbuilt_withversion,runs_version,at_version→ canonicalversionowned_by,maintained_by,managed_by→ canonicalowned_by
- Fuzzy match — Jaro-Winkler similarity (strsim) > 0.85 against existing predicates for the same subject → treat as same predicate.
The synonym map is extensible via config. Unrecognized predicates pass through as-is — fuzzy matching catches most remaining equivalences.
Existing: ("api-server", "uses", "tokio 1.36", confidence: 0.9, t_valid: 2026-01-15, t_invalid: null)
Incoming: ("api-server", "depends-on", "tokio 1.38", confidence: 0.95, t_valid: 2026-04-01)
↓ predicate normalization: "depends-on" → "depends_on", "uses" → "depends_on" → match!
Resolution:
- Match existing facts by (resolved_subject, normalized_predicate)
- Compute similarity between object values ("tokio 1.36" vs "tokio 1.38")
- Similarity < 0.95 → these are different values (not duplicates)
- Invalidate old fact: set
t_invalid = 2026-04-01(the incoming fact'st_valid) - Insert new fact with
t_valid = 2026-04-01,t_invalid = null - Log the transition for auditability
The old fact is NOT deleted — it remains queryable for historical queries ("what did we use before tokio 1.38?") via include_invalidated=true on recall.
Edge cases:
- Same confidence, different dates: prefer more recent
- Ambiguous (e.g., subject has multiple valid values for a predicate): store both, tag as multi-valued
- Explicit supersede: if the agent calls
storewith asupersedesparam pointing to an existing memory ID, skip similarity check and invalidate directly - Duplicate detection: similarity >= 0.95 → deduplicate (keep higher confidence, bump
last_validated) - Unresolved predicates: if predicates don't match via normalization, synonym map, or fuzzy match, treat as distinct predicates (no conflict)
Stale memory is the silent killer of RAG systems. mem0's biggest failure mode is silently returning outdated facts. ferrex treats staleness as a first-class concern with multiple defense layers.
Every semantic fact has t_valid and t_invalid timestamps. When a fact is superseded via conflict resolution, the old fact gets t_invalid set — it's never silently returned as current. Queries default to t_invalid IS NULL (current facts only).
Every memory has a staleness level computed from multiple signals:
staleness_score = f(age, last_accessed, last_validated, access_count, type)
| Signal | Weight | Description |
|---|---|---|
age |
High | Days since creation or last update |
last_accessed |
Low | Days since last retrieval (popularity signal, not correctness signal) |
last_validated |
High | Days since an agent explicitly confirmed this fact (see Layer 3) |
access_count |
Low | Total retrievals (frequently used facts are more likely current) |
Staleness levels:
- fresh: within expected lifetime, recently validated
- aging: approaching staleness threshold, still returned but annotated
- stale: exceeded threshold, excluded from results by default
Thresholds (configurable per memory type, overridable per namespace):
- Episodic: fresh < 30d, aging < 90d, stale >= 90d
- Semantic: fresh < 90d since last validation, aging < 180d, stale >= 180d
- Procedural: fresh < 180d, aging < 365d, stale >= 365d
Per-namespace overrides allow projects to tune thresholds to their domain. A fast-moving CI/CD project might set procedural staleness to 30d; a reference knowledge base might set semantic staleness to 365d. Configured via ferrex.toml or --staleness-config:
[staleness.defaults]
episodic = { fresh = 30, aging = 90, stale = 90 }
semantic = { fresh = 90, aging = 180, stale = 180 }
procedural = { fresh = 180, aging = 365, stale = 365 }
[staleness.namespaces."ci-infra"]
procedural = { fresh = 7, aging = 14, stale = 30 }
[staleness.namespaces."company-reference"]
semantic = { fresh = 365, aging = 730, stale = 730 }Retrieval and validation are distinct signals with different meanings:
last_accessed— bumped on everyrecallhit. Used for decay scoring (frequently retrieved memories decay slower). This is a popularity signal, not a correctness signal.last_validated— bumped only by explicit agent actions that confirm the memory is still accurate:store(supersedes: id)— the agent updates a fact, implicitly confirming the subject areareflectconfirmation — the agent reviews a stale memory and confirms itstore(source: "memory:id")— the agent creates a new memory citing this one as source
Retrieval alone does NOT bump last_validated. A popular-but-wrong memory that keeps appearing in results will still age toward staleness based on its validation timestamp. This prevents the positive feedback loop where frequently-retrieved stale facts perpetually appear fresh.
When recall returns results, the system checks if multiple active semantic facts exist for the same (subject, predicate) pair with different objects. If so, both are returned with a contradiction: true flag and the agent can resolve it.
The reflect tool (in addition to episodic consolidation) surfaces:
- Semantic facts that haven't been validated in > N days
- Facts with decaying confidence scores
- Entity nodes with no recent memory references
The agent can then confirm, update, or forget flagged memories.
Every recall result includes freshness metadata. The agent always knows how old and how validated a memory is. This prevents the "silently return stale data" failure mode — even if a stale memory sneaks through, the agent sees "staleness": "aging" and can judge accordingly.
Every memory has a relevance_score computed from:
- Recency: exponential decay from creation/last-update time (configurable half-life per type)
- Access frequency: memories retrieved more often decay slower
- Validation recency: recently validated memories decay slower
- Explicit boost: agent can pin important memories
Defaults:
- Episodic: half-life 30 days
- Semantic: no time-based decay, but staleness scoring based on last-validated timestamp
- Procedural: no decay
Deferred to v2 (see future-improvements.md). Requires episodic clustering and semantic promotion via reflect.
Three fates for stale memory, depending on type:
Episodic (evict aggressively):
Once an episodic memory reaches computed stale staleness level (based on age + access frequency + validation recency, not raw age alone), it becomes an eviction candidate. Before deleting, check if it is referenced as source by any semantic fact — if so, the episodic memory has already been distilled into durable knowledge and can safely go. Unreferenced stale episodic memories are evicted first when storage budget is exceeded.
Semantic (never auto-evict active facts):
A semantic fact with t_invalid = null (still current) is never auto-evicted, even if its staleness score is high. "Stale" for an active semantic fact means "unvalidated for a while" — it might still be true. The reflect tool surfaces these for the agent to confirm, update, or explicitly invalidate.
Semantic facts with t_invalid set (superseded) are evicted after a configurable retention window (default: 180 days after invalidation). They serve historical queries ("what did we use before?") but don't need to live forever.
Procedural (never auto-evict):
Procedures may be rarely used but critical when needed. Only explicitly deleted via forget.
Eviction priority (when storage exceeds budget):
- Superseded semantic facts past retention window
- Stale episodic memories (unreferenced by semantic facts first)
- Stale episodic memories (referenced — source field preserved in semantic fact)
- Aging episodic memories with lowest relevance_score
- Never: active semantic facts, procedural memories
ferrex/
├── crates/
│ ├── ferrex-server/ # MCP server binary
│ │ ├── main.rs # entry point, transport setup, Qdrant sidecar lifecycle
│ │ └── tools.rs # MCP tool definitions (rmcp #[tool] macros)
│ │
│ ├── ferrex-core/ # memory system logic
│ │ ├── memory.rs # memory types, store/recall/forget
│ │ ├── retrieval.rs # retrieval pipeline orchestration, recency boosts, final scoring (RRF is server-side in Qdrant)
│ │ ├── conflict.rs # contradiction detection and temporal validity
│ │ ├── lifecycle.rs # decay, staleness scoring, compaction, eviction
│ │ └── staleness.rs # staleness safeguards, validation tracking
│ │
│ ├── ferrex-embed/ # embedding engine
│ │ ├── embed.rs # fastembed wrapper
│ │ └── rerank.rs # fastembed TextRerank wrapper (raw relevance scores only)
│ │
│ └── ferrex-store/ # storage backends
│ ├── qdrant.rs # Qdrant client (sidecar gRPC or remote URL)
│ ├── sidecar.rs # Qdrant sidecar process management
│ ├── db.rs # SQLite: entity tables, metadata, staleness
│ └── schema.rs # SQLite migrations and table definitions
│
├── Cargo.toml
└── flake.nix
| Component | Choice | Why | Service scaling path |
|---|---|---|---|
| MCP SDK | rmcp |
Official Rust SDK, #[tool] macros, stdio transport |
Add Streamable HTTP transport |
| Vector + BM25 | Qdrant (sidecar or external) | Built-in hybrid search via Query API (prefetch dense + sparse, server-side RRF fusion in one request). BM25 tokenization + IDF computed server-side since v1.15. Rich payload filtering. Sidecar for local, --qdrant-url for external. |
Already a service — replication, sharding, multi-client |
| Metadata + entities | SQLite (rusqlite) |
Debuggable (sqlite3 CLI), one file, one connection pool. Entity tables, temporal validity, staleness metadata. |
Same schema moves to Postgres trivially |
| Embedding | fastembed |
44 embedding models + 6 reranker models, ONNX quantized, local, no API keys. Maintained by Qdrant team. | Wrap in gRPC service if needed |
| Entity extraction | Caller provides via tool params (v1) | MCP clients (Claude) already know the entities. Zero latency, clean API contract. | Add optional NER model in v2 |
All models run locally via fastembed ONNX runtime. Configurable at startup via CLI flag or config file.
| Tier | Model | Dimensions | Size | MTEB | Context | License | fastembed enum |
|---|---|---|---|---|---|---|---|
| small | all-MiniLM-L6-v2 |
384 | 90MB | 56.3 | 256 tok | Apache-2.0 | AllMiniLML6V2 / AllMiniLML6V2Q |
| mid | BGE-small-en-v1.5 |
384 | 67MB | ~59 | 512 tok | MIT | BGESmallENV15 / BGESmallENV15Q |
| best | BGE-base-en-v1.5 |
768 | 210MB | ~63 | 512 tok | MIT | BGEBaseENV15 / BGEBaseENV15Q |
Extended options for specific needs:
| Tier | Model | Dimensions | Size | Notes | fastembed enum |
|---|---|---|---|---|---|
| best-large | BGE-large-en-v1.5 |
1024 | 1.2GB | Highest accuracy in BGE family | BGELargeENV15 / BGELargeENV15Q |
| multilingual | BGE-M3 |
1024 | ~1.5GB | 100+ languages, 8192 tok context. Outputs dense + sparse + ColBERT from a single model — could unify the hybrid pipeline. | BGEM3 |
Quantized variants (suffix Q) are ~50% smaller with ~2% accuracy loss.
Default: BGE-base-en-v1.5 (best tier). 210MB is acceptable for a local tool, 768 dimensions gives significantly better retrieval than 384d models.
2025-2026 contenders (investigate fastembed-rs support during implementation):
| Model | Params | Notes |
|---|---|---|
Qwen3-Embedding-0.6B |
0.6B | New MTEB leader at small size. Apache-2.0. fastembed-rs has behind feature flag. |
EmbeddingGemma-300M |
300M | Google, 100+ languages, on-device optimized. |
nomic-embed-text |
137M | Ultra-lightweight, good for resource-constrained setups. |
Cross-encoder rerankers re-score retrieval candidates for final ranking. Always enabled. Configurable at startup. ferrex-embed exposes raw reranker scores only. Recency boosts and final scoring live in ferrex-core/retrieval.rs where memory type and timestamp data is available.
| Tier | Model | Size | BEIR nDCG@10 | License | fastembed enum |
|---|---|---|---|---|---|
| default | BAAI/bge-reranker-base |
278MB | ~52 | MIT | BGERerankerBase |
| multilingual | jinaai/jina-reranker-v2-base-multilingual |
~560MB | ~55 | — | JINARerankerV2BaseMultilingual |
Other fastembed built-in rerankers:
rozgo/bge-reranker-v2-m3(multilingual,BGERerankerV2M3)jinaai/jina-reranker-v1-turbo-en(English,JINARerankerV1TurboEn)
2025-2026 contenders (not in fastembed built-ins — require UserDefinedRerankingModel with ONNX files from HuggingFace, or direct ort crate loading):
Other notable rerankers:
mxbai-rerank-large-v2(1.5B, BEIR 61.44) — close secondbge-reranker-v2-m3(0.6B, BEIR 56.51) — multilingual optionQwen3-Reranker-4B(4B, BEIR 61.16) — too large for local default
[workspace.dependencies]
# MCP
rmcp = { version = "1", features = ["server", "transport-io", "macros"] } # verify feature flags against 1.x migration guide
# Async runtime
tokio = { version = "1", features = ["full"] }
# Embedding + reranking
fastembed = "5"
# Vector + BM25 store
qdrant-client = "1"
# Metadata + entity persistence
rusqlite = { version = "0.32", features = ["bundled"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# MCP tool schemas
schemars = "1"
# Error handling
anyhow = "1"
thiserror = "2"
# Logging (stderr only — stdout is MCP JSON-RPC protocol)
tracing = "0.1"
tracing-subscriber = "0.3"
# Time
chrono = { version = "0.4", features = ["serde"] }
# IDs
uuid = { version = "1", features = ["v7", "serde"] }-
Reranker model selection: fastembed built-in rerankers are
bge-reranker-base(default),bge-reranker-v2-m3,jina-reranker-v1-turbo-en, andjina-reranker-v2-base-multilingual. Jina Reranker v3 and ms-marco-MiniLM are NOT built-in — they requireUserDefinedRerankingModelwith ONNX files or directortcrate loading. Benchmarkbge-reranker-baseas v1 default; evaluate jina-v3 via custom loading if quality insufficient. -
Qdrant sidecar packaging: how to distribute the Qdrant binary alongside ferrex? Options: (a) expect user to install Qdrant separately, (b) download on first run, (c) bundle in Nix flake. Nix makes (c) straightforward.
-
BGE-M3 as unified model: BGE-M3 outputs dense + sparse + ColBERT from a single forward pass. This could replace the separate BM25 index entirely (Qdrant's sparse vector support can ingest the sparse output directly). Worth benchmarking vs separate BGE-base + Qdrant server-side BM25.
- Multi-user / multi-tenant support
- Cloud sync or remote storage
- Knowledge graph traversal as retrieval channel (v2 — see
future-improvements.md) - kNN link graphs (v2 — see
future-improvements.md) - Adaptive query routing (v2 — see
future-improvements.md) - Community detection / Leiden algorithm (v2)
- Automatic entity extraction from free text (require explicit entities in v1)
- Multimodal embeddings (text only in v1)
- Streamable HTTP transport (stdio only in v1)
- LLM-based memory extraction (mem0's approach — intentionally avoided due to detail loss)
Tests are written alongside each implementation phase, not deferred to the end. Each subsystem has unit tests; integration tests require a real Qdrant instance.
Staleness scoring (ferrex-core/staleness.rs):
- Compute staleness levels for each memory type at boundary conditions (29d, 30d, 31d for episodic fresh/aging)
- Verify
last_accesseddoes NOT bumplast_validated - Verify explicit validation actions (supersede, reflect confirm, source citation) DO bump
last_validated - Per-namespace threshold overrides apply correctly
- Decay formula produces expected scores (half-life math)
Conflict resolution (ferrex-core/conflict.rs):
- Exact (subject, predicate) match triggers conflict detection
- Predicate normalization: "uses" and "depends-on" match via synonym map
- Predicate fuzzy matching: "depends_on" and "dependson" match at ratio > 0.85
- Unrelated predicates don't trigger false conflicts
- Object similarity stages: exact dedup (>0.95), clear different (<0.5), middle-ground fallback to embedding
t_invalidset correctly on superseded factssupersedesparam bypasses similarity check- Multi-valued predicates handled (both stored, tagged)
Entity resolution (ferrex-core/entity.rs):
- Normalization: "Tokio" → "tokio", " api server " → "api server"
- Fuzzy match: "postgres" ↔ "postgresql" merges above 0.85
- Alias lookup: querying by alias returns canonical entity
- Embedding similarity > 0.92: merged, stored as alias
- Below threshold: new entity created
Deduplication (ferrex-core/memory.rs):
- Cosine > threshold rejects with existing ID
- Cosine <= threshold allows store
supersedesparam bypasses dedup check- Different memory types don't cross-deduplicate
Chunking (ferrex-core/chunking.rs):
- Episodic: rejects content exceeding model context window
- Semantic: triples always produce single vector
- Procedural: splits on step boundaries when exceeding context
- Procedural: short content produces single vector
- Step chunks share memory_id with correct step_index
Memory type auto-detection (ferrex-core/memory.rs):
- subject+predicate+object → semantic
- steps or conditions → procedural
- everything else → episodic
- Explicit type overrides auto-detection
Recency boost formulas (ferrex-core/retrieval.rs):
- Episodic at age 0: boost = 1.3, at age 30d: boost = 1.15, at age 300d: boost ≈ 1.0
- Semantic at age 0: boost = 1.15, at age 180d: boost = 1.075
- Procedural: always 1.0
- Boosts are multiplicative with rerank score
- Ranges large enough to shift rankings (cross-encoder scores vary by 0.2-0.5 between candidates)
Run against a real Qdrant instance (sidecar mode — test starts/stops its own Qdrant). Use #[ignore] attribute for CI environments without Qdrant, or gate behind a integration feature flag.
Store → Recall round-trip:
- Store episodic memory, recall by semantic query, verify returned
- Store semantic triple, recall by subject name, verify returned
- Store procedural memory, recall by condition description, verify returned
Hybrid retrieval quality:
- Store 20 memories with known content. Query with terms that should match via BM25 (exact keywords) and via vector (semantic similarity). Verify both channels contribute — a result found only by BM25 and a result found only by vector search both appear in final results.
Deduplication end-to-end:
- Store a memory, attempt to store a near-identical rewording, verify rejection
- Store with
supersedesparam, verify original invalidated
Conflict resolution end-to-end:
- Store ("X", "uses", "v1"), then ("X", "depends-on", "v2"). Verify first fact gets
t_invalidset. Recall withinclude_invalidated=true, verify both returned. Recall without, verify only v2 returned.
Entity resolution end-to-end:
- Store memory with entity "Tokio", store another with "tokio runtime". Recall filtering by entity "tokio" returns both.
Staleness lifecycle:
- Store a memory, artificially age it (set timestamps in the past). Verify staleness level transitions. Verify stale memories excluded from default recall, included with
include_stale=true.
Sidecar lifecycle:
- Start ferrex, verify Qdrant sidecar starts (PID file created)
- Start second ferrex instance, verify it reuses existing sidecar
- Stop first instance (the one that started sidecar), verify sidecar stops
- Stop second instance, verify clean shutdown
Stats brief vs detailed:
- Store a mix of memory types. Call
stats()(brief), verify minimal response shape. Callstats(detail=true), verify full response with counts, staleness distribution, storage size.
A small hand-crafted test set (~20 memories + ~10 queries with expected results) to measure baseline retrieval quality and catch regressions. Not a full benchmark suite — just enough to verify that hybrid retrieval + reranking returns sensible results.
golden_set/
├── memories.json # 20 memories of mixed types
├── queries.json # 10 queries with expected memory IDs (top-3)
└── run_golden.rs # stores memories, runs queries, reports recall@3
Target: recall@3 >= 0.7 on the golden set. If a code change drops below this, investigate before merging.
- Verify all 5 tools register correctly via MCP Inspector
- Verify tool parameter validation (required fields, type checks)
- Verify error responses have correct MCP error format
- Verify stdio transport round-trip (JSON-RPC request → response)
- Scaffold workspace and crates
- Qdrant sidecar lifecycle management (start/stop/health check) +
--qdrant-urlexternal mode - fastembed wrapper (embed + rerank, configurable model tiers)
- Qdrant client (vector + BM25 write/search via gRPC)
- SQLite schema (entity table, metadata tables, temporal validity columns)
- Basic
store(all types) andrecall(vector-only) MCP tools - stdio transport via rmcp
- Cross-encoder reranking (top-20 candidates) with multiplicative recency boosts (ferrex-embed: raw scores, ferrex-core: boost computation)
- BM25 via Qdrant built-in sparse index (server-side tokenization + IDF)
- Hybrid retrieval via Qdrant Query API: prefetch dense + sparse, server-side RRF (k=60) in one request
storesemantic type: conflict detection with temporal validity (t_valid/t_invalid)- Contradiction detection at query time
- Entity resolution: full layered pipeline (normalize → fuzzy → embedding similarity → alias table)
- Entity storage (SQLite + Qdrant payload) with resolution
- Entity-based filtering on
recall - Deduplication on write
forget,stats,reflect(staleness audit + contradiction alerts) tools- Staleness scoring (age + access + validation recency)
- Staleness levels (fresh/aging/stale) with configurable thresholds
- Access-time validation refresh
- Freshness metadata annotation on recall results
- Decay scoring, eviction policy
- Semantic caching on recall (LRU, embed-hash keyed)
- Nix build with ONNX runtime + Qdrant binary
- Integration tests
- MCP Inspector validation
- Retrieval quality instrumentation (for measuring v2 improvements)