Skip to content

Architecture

GiulioDER edited this page Jul 23, 2026 · 2 revisions

Architecture

Two installable packages:

  • recall/ — the library. No server, no framework, no global state.
  • recall_mcp/ — an MCP server wrapping it, so an agent can call the library as a tool.

recall_mcp depends on recall. Nothing in recall knows the MCP server exists.

The request path

flowchart TB
    Q([query]) --> TS["trust.trusted_search()"]
    TS --> R["retriever.HybridRetriever"]
    R --> E["embeddings.Embedder"]
    R --> SD["store.query_dense · pgvector"]
    R --> SS["store.query_sparse · full-text"]
    SD --> RRF["Reciprocal Rank Fusion"]
    SS --> RRF
    RRF --> RK["rerank.Reranker (optional)"]
    RK --> G["guards: gap_warning · staleness"]
    G --> EV["trust.evaluate()"]
    SUP["store.supersession()"] --> EV
    CAL["calibration.Calibration"] --> EV
    EV --> EN["entailment.apply_entailment (opt-in)"]
    EN --> O([TrustedResult])
Loading

The shape worth noticing: trust.evaluate() is pure. It reads no clock, touches no database, and performs no I/O — now, the supersession map and the calibration are all passed in. That is why the trust semantics can be tested exhaustively without a database, and why the ordering rules are verifiable rather than merely observed.

Module ownership

recall/ — core

Module Owns Depends on
types.py The data contract: Chunk, ScoredChunk, TrustedHit, TrustedResult, Verdict, Validity, Provenance, StalenessReport. All frozen dataclasses. nothing
store.py PostgreSQL + pgvector. Schema, migration, RLS, dense/sparse queries, upsert, prune, the supersession scan and its cache, connection mode (single vs pool), retry, timeouts, DSN security checks. The largest module, and the only one that talks to a database. types
embeddings.py The Embedder protocol and its implementations (hashing, FastEmbed, sentence-transformers, Voyage), plus batching, retry-with-backoff and dimension checking. nothing
retriever.py HybridRetriever — the two legs, RRF fusion, rerank hand-off, and annotation with gap/staleness. store, embeddings, rerank, guards, types
rerank.py The Reranker protocol, a no-op, and a cross-encoder implementation. types
guards.py The two cheap honesty signals: gap_warning (are all candidates below threshold) and staleness (is the index too old). Holds the default gap threshold. types
calibration.py Per-embedder abstention threshold and the cosine→confidence mapping; fitting from labelled samples; safe load/save. observability
frontmatter.py The dependency-free frontmatter parser, validity-date interpretation, and supersedes: reference normalisation. nothing
trust.py Verdict assignment, successor resolution, successor promotion, valid-first ordering, the abstention decision and its reason. store, embeddings, retriever, rerank, calibration, frontmatter, guards, types
entailment.py Opt-in near-miss guard: demotes verdict-ok hits that do not entail an answer. Pure post-processing over a TrustedResult, mirroring trust. trust, types
index.py Indexer — file discovery, chunking (prose and code), NUL sanitisation, index-root confinement, content-hash skip, batched writes, pruning of vanished sources and the prune guard. store, embeddings, frontmatter, cache, lint, observability
cache.py Content-addressed embedding cache (SQLite), keyed by embedder name + dim + text. embeddings
lint.py Static supersession-graph lint: dangling edges, self-supersession, cycles, ambiguous targets, invalid dates. No database. frontmatter
fix.py Extracts prose closure markers and proposes supersedes: edges — under rules that refuse far more often than they act. frontmatter, lint
check.py Write-time gate for a single file being authored: surfaces candidate edges while the author is still present. frontmatter, lint
semantic_lint.py The missing-edge lint: queries the index with a new memo's text to surface closed decisions it should probably reference. trust, store, embeddings
observability.py get_logger (namespaced, never configures handlers), opt-in configure_logging with JSON output, and the METRICS counter/histogram registry. nothing
timing.py Decorators that wrap an Embedder or Reranker to record per-call latency. Used by the eval harness. embeddings, rerank, types
cli.py Argument parsing and the command implementations. most of the above
_env.py Minimal .env loader for local development. Never overrides an already-set variable. nothing
eval/ The evaluation harness: ablation matrix, trust eval, near-miss eval, calibration fitting, labelled-question runner, synthetic corpus generation, scale/latency runner, metrics. most of the above

recall_mcp/ — the MCP server

Module Owns
server.py Builds the FastMCP server, the four tools, the lifespan (store construction, calibration load, auth wiring), scope enforcement per tool, and transport selection.
service.py The tool bodies, independent of MCP: search, index, forget, stats. Pydantic response models. This is where the logic lives, so it is testable without a server.
auth.py Bearer-token registry, principal→tenant mapping, scope definitions, token-file parsing (plaintext or SHA-256 digests), expiry, and the fail-closed configuration check.
limits.py Per-tenant token buckets: call rates per tool class and an aggregate indexing byte budget.
stores.py Per-tenant store construction and lifetime.

Three structural decisions worth knowing

The trust layer is a pure function over a retrieval result. It could have been folded into the retriever's SQL — one query returning already-judged rows would be faster. It is separate because the judgment is the part most likely to be wrong, and a pure function is the only shape in which "a superseded hit with a top cosine loses to its lower-scoring successor" is a test rather than an observation.

Entailment mirrors trust rather than extending it. apply_entailment takes a TrustedResult and returns a TrustedResult. It judges only verdict-ok hits — judging an already-demoted hit would waste a model call and, worse, could resurrect a superseded memory by ranking it back up. The two stages stack; neither replaces the other, and the measurements say so explicitly.

A store is bound to one tenant for its lifetime. Tenancy is not an argument you pass to a query — it is a property of which store object you hold, enforced in the database by a row-level security policy comparing against a per-connection setting. That is what makes a forgotten WHERE clause return nothing rather than another tenant's memories, and it is why the MCP server's authentication has to resolve a tenant before any tool body runs. → Tenancy-and-Auth

Where the pieces are documented

Clone this wiki locally