# Read Pipeline

The read pipeline finds facts relevant to a query using multiple signals in parallel. Every call to `memory.retrieve()` runs five stages: **Plan → Retrieve → Enhance → Rerank → Format**.

```mermaid
flowchart LR
    A["Query"] --> B["Plan"]
    B --> C["Retrieve\n(3 signals)"]
    C --> D["Enhance"]
    D --> E["Rerank"]
    E --> F["RetrieveResult"]
```

## Overview

When you call `memory.retrieve(user_id, query)`, the pipeline:

1. **Plans the retrieval** — An LLM planner analyzes the query and decides the strategy, reformulates the query for semantic search, and identifies relevant entities for graph traversal.
2. **Retrieves candidates** — Three parallel signals (semantic, keyword, graph) find candidate facts.
3. **Enhances results** — Spreading activation expands context from seed facts along entity relationships.
4. **Reranks** — An optional LLM reranker refines the ranking based on query intent.
5. **Formats** — Facts are scored, compressed within a token budget, and formatted into a context string.

---

## Stage 1: Retrieval Agent (Planner)

The retrieval agent is an LLM-powered planner that decides **how** to retrieve, not just **what** to retrieve. It analyzes the query and produces a `RetrievalPlan`.

### What the Planner Decides

| Field | Description | Example |
|-------|-------------|---------|
| `strategy` | Retrieval strategy | `"multi_signal"` (default) or `"skip"` (for greetings) |
| `similarity_query` | Reformulated query for semantic search | "user location city" (from "where do I live?") |
| `entities` | Entity keys for graph signal | `["person:ana", "organization:acme"]` |
| `as_of_range` | Time-travel window (optional) | `{"start": "2024-01-01", "end": "2024-06-30"}` |
| `broad_query` | Whether to expand graph scope | `true` for "tell me everything about..." |
| `reason` | Explanation of the strategy | For debugging and observability |

### Query Reformulation

The planner doesn't just pass the user's query to semantic search. It **reformulates** it to improve vector similarity matching:

- `"where do I live?"` → `"user location city residence"`
- `"what does Ana do for work?"` → `"Ana profession occupation job role"`

This bridges the vocabulary gap between how users ask questions and how facts are stored.

### Schema-Aware Planning

The planner inspects the actual memory schema for this user:

- Which entity types exist (persons, organizations, places...)
- Which entities have the most facts
- What attributes are stored

This grounds the plan in reality — the planner won't search for entities that don't exist.

### Hybrid Entity Resolution

The pipeline uses a **hybrid approach** to identify entities for the graph signal, combining three sources:

1. **Deterministic resolution (primary)** — Matches words in the query against known entity aliases (`MemoryEntityAlias`), display names (`MemoryEntity.display_name`), and entity_key slugs. Fast (< 10ms), reliable, zero LLM cost. For example, "Onde o Carlos mora?" deterministically resolves to `person:carlos` via slug match.

2. **LLM planner (supplementary)** — The retrieval agent extracts entities from its understanding of the query. Catches indirect references the deterministic layer misses (e.g., "my boss" → `person:kevin`).

3. **Query expansion (alias priming)** — `expand_query()` resolves aliases and fetches 1-hop KG neighbors, adding related entities.

All three sources are **unified** before the graph gate. If any source identifies an entity, the graph traversal runs. This makes the graph signal fault-tolerant: if the LLM fails to extract entities (LLM variance), the deterministic layer covers it.

The trace step `"retrieval"` includes an `entities_sources` breakdown showing which entities came from each source (`llm`, `deterministic`, `expansion`).

### Skip Strategy

For greetings and casual messages ("hi", "how are you?"), the planner returns `strategy: "skip"`, short-circuiting the pipeline. No database queries, no LLM calls, instant response.

!!! info "Neuroscience parallel"
    The retrieval agent mirrors **retrieval cues** in cognitive psychology. When you try to remember something, your brain doesn't do an exhaustive search — it uses contextual cues to narrow down the search space. The planner identifies entities and reformulates queries as cues that guide the retrieval signals.

---

## Stage 2: Multi-Signal Retrieval

Three independent signals run **in parallel** via `asyncio.gather()`, each finding candidates from a different angle:

```mermaid
flowchart TD
    P["RetrievalPlan"] --> S["Semantic Search\n(pgvector cosine)"]
    P --> K["Keyword Search\n(SQL ILIKE)"]
    P --> G["Graph Traversal\n(BFS 2-hop)"]
    S --> M["Merge & Rank\n(RRF + weighted scoring)"]
    K --> M
    G --> M
```

### Signal 1: Semantic Search

Uses pgvector cosine similarity to find facts whose embeddings are close to the query embedding.

- Embeds the reformulated query (from the planner)
- Searches the `MemoryFact` table with HNSW index
- Returns top-N candidates above `min_similarity` threshold
- Filters: `user_id`, active facts (`valid_to IS NULL`), confidence ≥ `min_confidence`

This is the primary signal — it finds facts that are **semantically similar** to the query, even if they don't share exact keywords.

### Signal 2: Keyword Search

SQL ILIKE matching on `fact_text` for exact or partial keyword hits.

- Extracts significant words (> 2 characters) from the query
- Matches against fact text (up to 5 keywords)
- Score = fraction of query words found in the fact

This complements semantic search by catching exact matches that embedding similarity might miss (e.g., proper nouns, technical terms, abbreviations).

### Signal 3: Graph Retrieval

Traverses entity relationships to find facts connected to the query entities.

- Starts from entities identified by the planner
- BFS traversal up to 2 hops through `MemoryEntityRelationship`
- Scoring formula: `edge_strength × recency_factor × edge_recency_factor × query_bonus`
- `query_bonus`: 1.5× when the entity name appears in the query text

Graph retrieval excels at finding **contextual** facts. When you ask about a person, it also finds facts about their workplace, their relationships, and their projects.

### Merge & Rank

After all three signals return, results are merged:

1. **Deduplicate** by fact ID (same fact may appear in multiple signals)
2. **Apply recency decay** — Exponential decay with configurable half-life (`recency_half_life_days`, default 14)
3. **Apply confidence decay** — Older facts with lower confidence are penalized
4. **Compute combined score** — Weighted sum:

```python
score = (
    score_weights["semantic"]   * semantic_score +    # default 0.70
    score_weights["recency"]    * recency_score +     # default 0.20
    score_weights["importance"] * importance_score     # default 0.10
)
```

### Complete Score Breakdown

Each `ScoredFact.scores` dict contains **all 6 signals** computed across the pipeline. The weighted formula above produces the base combined score; later stages add signals that may modify the final ranking:

| Key | Source | Range | Description |
|-----|--------|-------|-------------|
| `semantic` | Semantic search | 0.0–1.0 | Cosine similarity between query and fact embeddings. Primary retrieval signal. |
| `keyword` | Keyword search | 0.0–1.0 | Fraction of query words found in the fact text. Complements semantic for exact matches. |
| `recency` | Merge & Rank | 0.0–1.0 | Exponential decay from `created_at`, half-life = `recency_half_life_days` (default 14). |
| `importance` | Dynamic importance | 0.05–3.0 | Computed from retrieval frequency, recency of use, user corrections, and pattern membership. Starts at 0.5 for new facts and evolves as the fact is retrieved and confirmed. Requires the background importance job to produce non-default values. |
| `confidence` | Merge & Rank | 0.0–1.0 | Effective confidence after temporal decay. The base confidence is assigned by the LLM during extraction (typically 0.95 for assertive statements). It decays over time and is used as a filter (`min_confidence`) and scoring signal. |
| `reranker` | Reranking | 0.0, 0.3, 0.5, 0.8, 1.0 | LLM-based relevance score. Only present when `enable_reranker=True`. Discrete values assigned by the reranker LLM. |

Additional signals computed during enhancement (not in `score_weights` but affect final score):

| Key | Source | Description |
|-----|--------|-------------|
| `pattern` | Enhancement | Additive boost for recently confirmed facts (up to +0.10). |
| `graph` | Graph traversal | Score from BFS 2-hop entity relationship traversal. |

### Configuration

| Parameter | Default | Description |
|-----------|---------|-------------|
| `topk_facts` | `20` | Maximum facts to return |
| `topk_events` | `8` | Maximum events to consider |
| `min_similarity` | `0.20` | Minimum cosine similarity for semantic results |
| `min_confidence` | `0.55` | Minimum fact confidence |
| `recency_half_life_days` | `14` | Half-life for recency decay |
| `score_weights` | See above | Weights for each scoring signal |
| `enable_reranker` | `True` | Whether to use LLM reranking |

!!! info "Neuroscience parallel"
    Multi-signal retrieval mirrors **spreading activation** in semantic networks (Collins & Loftus, 1975). When you think of "doctor", activation spreads to related concepts ("hospital", "medicine", "appointment") through associative links. Similarly, graph retrieval spreads from query entities along relationship edges, while semantic search activates facts through embedding proximity.

---

## Stage 3: Enhancement

### Spreading Activation

Starting from the top-K seed facts, the pipeline expands context by following entity relationships:

- For each seed fact, find its entity's relationships
- Traverse relationships for N hops (`spreading_activation_hops`, default 2). Set to `0` to disable spreading entirely.
- Apply decay factor per hop (`spreading_decay_factor`, default 0.50). Hop 1 uses the factor directly; Hop 2 uses the factor squared (compounded decay).
- Return up to `spreading_facts_per_entity` additional facts per entity (default 3), applied in both Hop 1 and Hop 2.

This catches important context that wasn't directly matched. If you ask "what does Rafael do?", spreading activation might surface facts about his workplace, team, and projects.

### Pattern Signal

Facts that have been recently confirmed (NOOP decisions in write update `last_confirmed_at`) get an additive score boost:

- Recently confirmed facts → up to 0.10 extra score
- Captures frequently mentioned, well-established facts

### Configuration

| Parameter | Default | Description |
|-----------|---------|-------------|
| `spreading_activation_hops` | `2` | Maximum hops from seed facts. Set to `0` to disable spreading. |
| `spreading_decay_factor` | `0.50` | Score decay per hop. Hop 1 = factor, Hop 2 = factor² |
| `spreading_facts_per_entity` | `3` | Max facts fetched per entity in both Hop 1 and Hop 2 |
| `spreading_max_related_entities` | `5` | Max KG-related entities to explore in Hop 1 |

---

## Stage 4: Reranking (Optional)

When `enable_reranker=True`, the top candidates are reranked by an LLM that considers query intent:

- Respects the semantic meaning of the query (not just keyword overlap)
- Can promote facts that are indirectly relevant but important
- Graceful degradation: if the reranker fails or exceeds `reranker_timeout_sec` (default 5.0s), the original ranking is preserved
- Timeout is enforced via `asyncio.wait_for` — the LLM call is cancelled if it exceeds the configured timeout

The reranker is the most expensive stage but provides the highest quality improvement for complex queries.

!!! warning "Reranker overrides `score_weights`"
    When `enable_reranker=True` (default), the **final score and ranking are determined by the LLM reranker**, not by `score_weights`. The reranker assigns discrete relevance scores (0, 0.3, 0.5, 0.8, 1.0) that replace the weighted formula score for ranking purposes. The `score_weights` only affect candidate selection and pre-reranking order. To rely on `score_weights` for final ranking, set `enable_reranker=False`.

---

## Stage 5: Formatting

The final stage converts ranked facts into consumable output.

### Context Compression

Facts are divided into three tiers within a token budget (`context_max_tokens`):

!!! note "`context_max_tokens` is a proportional budget, not a hard cap"
    The `context_max_tokens` parameter controls the **relative** size of the output context, but the actual token count may exceed the configured value. The pipeline guarantees a minimum context for core facts and uses the parameter as a proportional budget across tiers. Treat it as a target, not a strict limit. For example, setting `context_max_tokens=100` may produce ~240 tokens due to minimum guarantees for the hot tier.

| Tier | Budget share | Content |
|------|-------------|---------|
| **Hot** | 50% | Most relevant facts (highest scores) |
| **Warm** | 30% | Supporting context |
| **Cold** | 20% | Background facts (lower scores but still relevant) |

### Configuration

| Parameter | Default | Description |
|-----------|---------|-------------|
| `context_budget_tokens` | `3000` | Total token budget for retrieval |
| `context_max_tokens` | `2000` | Maximum tokens in formatted context |
| `hot_tier_ratio` | `0.50` | Share of budget for top facts |
| `warm_tier_ratio` | `0.30` | Share of budget for supporting facts |

### Output Format

The `context` string is formatted for direct injection into LLM prompts:

```
## Known facts about the user:
- Lives in São Paulo (confidence: 0.95)
- Works at Acme Corp as a backend engineer (confidence: 0.90)
- Wife's name is Ana (confidence: 0.92)
```

---

## RetrieveResult

```python
result = await memory.retrieve(user_id="user_123", query="...")

# Pre-formatted context (ready for LLM prompts)
print(result.context)

# Individual facts with scores
for fact in result.facts:
    print(f"[{fact.score:.2f}] {fact.entity_name}: {fact.value}")
    print(f"  Scores: {fact.scores}")  # {"semantic": 0.85, "recency": 0.72, ...}

# Pipeline stats
print(f"Candidates evaluated: {result.total_candidates}")
print(f"Duration: {result.duration_ms:.0f}ms")
```

---

## Pipeline Diagram (Complete)

```mermaid
flowchart TD
    Q["User query"] --> AG["Retrieval Agent\n(LLM planner)"]
    AG -->|skip| SKIP["Return empty\n(greeting/casual)"]
    AG -->|multi_signal| PAR["Parallel retrieval"]
    PAR --> SEM["Semantic Search\n(pgvector cosine)"]
    PAR --> KW["Keyword Search\n(SQL ILIKE)"]
    PAR --> GR["Graph Traversal\n(BFS 2-hop)"]
    SEM --> MERGE["Merge & Rank\n(dedup + weighted scoring)"]
    KW --> MERGE
    GR --> MERGE
    MERGE --> SA["Spreading Activation\n(expand context along edges)"]
    SA --> RR{"Reranker\nenabled?"}
    RR -->|yes| RERANK["LLM Rerank"]
    RR -->|no| FMT["Format & Compress"]
    RERANK --> FMT
    FMT --> RES["RetrieveResult"]
```

!!! info "Neuroscience parallel"
    The tiered compression (hot/warm/cold) mirrors **levels of activation** in working memory. In Cowan's embedded-process model, a small number of items are in the focus of attention (hot tier), surrounded by activated long-term memory (warm tier), with the rest of long-term memory available but not active (cold tier). The token budget acts as the capacity limit of working memory.
