Files
Nicolò Boschi f747d96c38 feat(recall): fuzzy tag matching on tag_groups leaves (#4026) (#4028)
* feat(recall): fuzzy tag matching on tag_groups leaves (#4026)

Tags increasingly hold user-facing names, and tag filtering is exact array
containment. A caller filtering by what a query mentioned passes `typsecript`,
and the memory tagged `typescript` is dropped before ranking runs — so the
recall returns empty even though ranking would have found it. Better ranking
cannot fix that; the match itself has to tolerate the misspelling.

A `tag_groups` leaf gains one optional field, `resolve`, defaulting to `exact`
(today's behaviour). Set to `fuzzy`, its tags are matched against the bank's
tags by similarity instead of literally. That is the whole API change: no new
config, no new response field, no new TagsMatch values.

Matching is trigram similarity at 0.45 via `entity_resolver._trigram_similarity`,
already verified byte-identical to Postgres `similarity()` (#3107), so Postgres,
Oracle and store-owned backends behave the same. Resolves: typescropt/typescript
0.57, kubernets/kubernetes 0.62, user:alcie/user:alice 0.47. Does not: mango/mongo
0.33, k9s/k8s 0.14.

Known limit, pinned by a test: similarity is length-sensitive. A short tag has
few trigrams and one edit destroys three of them, so kakfa/kafka scores 0.20 and
does not resolve. Fuzzy matching is effective on descriptive tags and close to
inert on very short ones — and that same property is what keeps different short
words apart.

Resolution runs above the SQL layer, rewriting the leaf into ordinary exact
leaves so only those reach the query builders. The ~20 SQL call sites, the
Python mirrors used on the graph path, the GIN(tags) index, the store protocol
and the Oracle dialect are untouched. Per mode, for tokens t1..tn resolving to
E1..En: any/any_strict becomes one leaf over the union; all/all_strict becomes an
AND of one OR-leaf per token, so a memory must carry some spelling of each;
exact becomes an OR over the cross product, one tag per token, bounded at 32
branches and checked before enumeration, with combinations carrying fewer
distinct tags than tokens dropped.

Failing closed: a tag that resolves to nothing stays in the filter as itself,
leaving the leaf unsatisfiable. Returning an empty list would read as "no tag
filtering" in the builders and hand back the whole bank.

The vocabulary comes from the existing `list_tags` store method, so there is no
schema change. A bank holding more than 5000 distinct tags is rejected with a
422 rather than resolved against a truncated vocabulary.

Claude-Session: https://claude.ai/code/session_011KDT484YujNcBxHzbfzNbk

* fix(clients): make tag_groups reachable through the wrapper SDKs

`Hindsight.recall(tag_groups=...)` and `.reflect(tag_groups=...)` raised
ModuleNotFoundError for every caller. The wrapper imported
`hindsight_client_api.models.recall_request_tag_groups_inner`, which the
generator does not emit: it produces one union model per tag_groups shape and
names it after the first schema that used it, so the class is
`MentalModelTriggerInputTagGroupsInner`. Nothing caught it because the wrapper's
tests never passed tag_groups and the import sits inside the `if tag_groups is
not None` branch, so it only fires when the feature is used.

Fixed at both call sites, with mirrored regression tests on the Python and
TypeScript wrappers asserting a tag group reaches the request body with the
leaf's `resolve` intact — the pair the review checklist asks for, since a
capability that exists in one wrapper and not the other is invisible to
client-coverage-check (it validates request-body fields, not wrapper surface).

Also thread tag_groups through the control-plane recall and reflect proxy routes
and their client types. Both accepted every other tag filter and silently
dropped this one, so no control-plane caller could use compound tag filtering at
all — fuzzy or exact.

Two follow-ups from reviewing #4026:

- Reject `resolve="fuzzy"` in a mental-model trigger's tag_groups. A trigger's
  scope is read by two paths that resolve differently: the refresh runs through
  reflect, which resolves fuzzy leaves, while the staleness check and the scope
  watermark build SQL straight from the stored groups and do not. A stored fuzzy
  leaf would build content from the resolved tags while never being marked stale
  by them, and would drift as the bank's tag vocabulary changes.

- Promote `entity_resolver._trigram_similarity` to `trigram_similarity`. Two
  subsystems now share it — entity resolution and fuzzy tag matching — so the
  leading underscore misrepresented a real contract between them.

Claude-Session: https://claude.ai/code/session_011KDT484YujNcBxHzbfzNbk
2026-09-02 16:50:53 +02:00

540 lines
29 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
sidebar_position: 2
---
# Recall Memories
Retrieve memories from a bank using multi-strategy recall.
When you **recall**, Hindsight runs four retrieval strategies in parallel — semantic similarity, keyword (BM25), graph traversal, and temporal — then fuses and reranks the results into a single ranked list. The response contains structured facts, not raw documents.
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import CodeSnippet from '@site/src/components/CodeSnippet';
{/* Import raw source files */}
import recallPy from '!!raw-loader!@site/examples/api/recall.py';
import recallMjs from '!!raw-loader!@site/examples/api/recall.mjs';
import recallSh from '!!raw-loader!@site/examples/api/recall.sh';
import recallGo from '!!raw-loader!@site/examples/api/recall.go';
:::info How Recall Works
Learn about the four retrieval strategies (semantic, keyword, graph, temporal) and RRF fusion in the [Recall Architecture](/developer/retrieval) guide.
:::
:::tip Prerequisites
Make sure you've completed the [Quick Start](./quickstart) to install the client and start the server.
:::
## Basic Recall
<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-basic" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-basic" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-basic" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-basic" language="go" />
</TabItem>
</Tabs>
---
## Parameters
### query
The natural language question or statement to search for. This is the only required field. The query drives all four retrieval strategies simultaneously: it is embedded for semantic search, tokenized for BM25 keyword search, used to seed graph traversal, and parsed for temporal expressions. After retrieval, the raw query text is also passed to the cross-encoder reranker to re-score every candidate. Queries exceeding 500 tokens are rejected.
### types
Controls which categories of memory facts are searched. Accepted values are `world` (objective facts), `experience` (events and conversations), and `observation` (deduplicated, evidence-grounded beliefs consolidated from multiple memories). When omitted, all three types are searched.
Each type runs the full four-strategy retrieval pipeline independently, so narrowing `types` reduces both the result set and query cost.
<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-world-only" language="python" />
<CodeSnippet code={recallPy} section="recall-experience-only" language="python" />
<CodeSnippet code={recallPy} section="recall-observations-only" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-world-only" language="javascript" />
<CodeSnippet code={recallMjs} section="recall-experience-only" language="javascript" />
<CodeSnippet code={recallMjs} section="recall-observations-only" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-fact-type" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-world-only" language="go" />
<CodeSnippet code={recallGo} section="recall-experience-only" language="go" />
<CodeSnippet code={recallGo} section="recall-observations-only" language="go" />
</TabItem>
</Tabs>
:::tip About Observations
Observations are deduplicated, evidence-grounded beliefs consolidated from multiple facts — preferences, recurring patterns, and durable learnings the memory bank has built up. Each observation references its supporting memories (with exact quotes), and is refined rather than overwritten when new evidence arrives. They are created and maintained automatically in the background after retain operations.
:::
### prefer_observations
Because observations are consolidated from raw facts, recalling `observation` alongside `world` and `experience` can return the same information twice — once as the raw fact and once folded into an observation. With `prefer_observations` you get the best of both: you still recall every type, but whenever an observation in the results was built from a raw fact, that raw fact is dropped so the observation supersedes it. The freed slots are backfilled with the next-best results, so you don't lose coverage.
This lets you ask for everything without choosing between "raw facts only" (no consolidation) and "observations only" (which may lag behind the latest retains while consolidation catches up). **Disabled by default** — set it to `true` to opt in. It has no effect unless both `observation` and at least one of `world`/`experience` are included in `types`.
### budget
Controls retrieval depth and breadth. Accepted values are `low`, `mid` (default), and `high`. Use `low` for fast simple lookups, `mid` for balanced everyday queries, and `high` when you need to find indirect connections or exhaustive coverage.
<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-budget-levels" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-budget-levels" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-budget-levels" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-budget-levels" language="go" />
</TabItem>
</Tabs>
### max_tokens
The maximum number of tokens the returned facts can collectively occupy. Defaults to `4096`. Only the `text` field of each fact is counted toward this budget — metadata, tags, entities, and other fields are not included. After reranking, facts are included in relevance order until this budget is exhausted — so you always get the most relevant memories that fit. A fact too long for the remaining budget is skipped rather than ending the selection, so shorter facts ranked behind it still come back. Hindsight is designed for agents, which think in tokens rather than result counts: set `max_tokens` to however much of your context window you want to allocate to memories.
:::note
A query that matched something never comes back empty: if not even the top fact fits the budget, it is returned whole and over budget rather than clipped mid-sentence, because an empty result list would read as "this bank has no such memory" and a clipped fact would be a claim the memory never made. The one exception is `max_tokens=0`, which means "no facts" on purpose — it is how you ask for chunks alone.
:::
<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-token-budget" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-token-budget" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-token-budget" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-token-budget" language="go" />
</TabItem>
</Tabs>
### query_timestamp
An ISO 8601 datetime representing when the query is being asked, from the user's perspective. When provided, it is used as the anchor for resolving relative temporal expressions in the query and for recency scoring — for example, if the query says "last month" and `query_timestamp` is `2023-05-30`, the temporal search window becomes approximately April 2023, and recency boosts are calculated as of May 30, 2023. Without it, the server's current time is used as the anchor. This field matters most for replaying historical conversations or building agents that need time-anchored recall.
### temporal_window
An explicit `{ "start": ..., "end": ... }` pair of ISO 8601 datetimes for the temporal part of the search. Supply it when you already know the period you mean — a date picker in your UI, or an agent that has already worked out what "last quarter" resolves to — and Hindsight uses those bounds directly instead of reading dates out of the query text.
```json
{ "query": "what did we decide about pricing", "temporal_window": { "start": "2023-04-01T00:00:00Z", "end": "2023-06-30T23:59:59Z" } }
```
**This ranks, it does not filter.** Hindsight searches several ways at once, and the window steers only the time-aware part of that search: memories dated inside it are surfaced and ranked higher, while everything else keeps being searched normally. Results dated outside the window are still returned, so this is not a way to restrict an answer to a period. Note also that the dates being compared are the *memory's own* dates — when the memory says something happened — not when it was stored.
Two smaller things worth knowing: bounds are inclusive and a naive datetime (one with no timezone) is read as UTC; and the window is ignored on banks that have time-aware search turned off. `temporal_window` replaces date extraction only — [`query_timestamp`](#query_timestamp) still anchors recency scoring, so it remains useful alongside it.
### include
An optional object controlling supplementary data returned alongside the main facts.
#### chunks
When enabled, the response includes the raw source text chunks from which each fact was extracted. Chunks are fetched before the `max_tokens` filter, so setting `max_tokens=0` returns no facts but can still return chunks. The `max_tokens` sub-option (default `8192`) controls the total chunk token budget independently of the main fact budget. This is useful when agents need surrounding context beyond the extracted fact text.
:::note
When `include_chunks` is enabled, chunks are fetched based on the top-scored reranked results before token filtering. The last chunk is truncated (not dropped) to fit exactly within the budget, and each chunk carries a `truncated` flag indicating whether it was cut.
:::
#### source_facts
When enabled and `types` includes `observation`, each observation result is accompanied by the original contributing facts it was synthesized from. Source facts are returned in a top-level `source_facts` dict keyed by fact ID, and each observation result carries a `source_fact_ids` list for cross-referencing. Facts are deduplicated across observations. The `max_tokens` sub-option (default `4096`) limits the total token budget for source facts.
:::note
The budget is spent in result order, so when it runs out it is the lowest-ranked results that lose their source facts — the top results always keep theirs. `source_fact_ids` always lists every source, so an ID may have no entry in `source_facts`; the response sets `source_facts_truncated: true` when that is the budget's doing rather than a missing fact. Raise `max_tokens` (or set it to `-1`) if you need every source resolved.
:::
<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-source-facts" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-source-facts" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-source-facts" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-source-facts" language="go" />
</TabItem>
</Tabs>
#### entities
Enabled by default. When active, each returned fact includes the canonical names of entities associated with it. Set to `null` to skip the entity JOIN query and reduce response size. The `max_tokens` sub-option (default `500`) is a future-facing guard for entity data.
### tags
Filters recall to memories in the requested tag scope. `tags` defaults to `null` and
`tags_match` defaults to `any`.
The `tags_match` parameter controls the filtering logic:
| Mode | Untagged memories | Match condition |
|------|-------------------|-----------------|
| `any` (default) | Included | Memory has **at least one** of the specified tags |
| `any_strict` | Excluded | Memory has **at least one** of the specified tags |
| `all` | Included | Memory has **all** of the specified tags |
| `all_strict` | Excluded | Memory has **all** of the specified tags |
| `exact` | Excluded | Memory has **exactly** the specified tag set |
The defaults and empty-filter behavior are important:
| `tags` | `tags_match` | Eligible memories |
|--------|--------------|-------------------|
| Omitted, `null`, or `[]` | Omitted (`any`) | All tagged and untagged memories |
| Omitted, `null`, or `[]` | `any`, `all`, `any_strict`, or `all_strict` | All tagged and untagged memories; an empty tag list means no filter |
| Omitted, `null`, or `[]` | `exact` | Only untagged/global memories |
| Non-empty | `any` or `all` | Matching tagged memories plus untagged/global memories |
| Non-empty | `any_strict` or `all_strict` | Matching tagged memories only |
| Non-empty | `exact` | Memories whose complete tag set exactly equals `tags` |
:::note MCP empty-scope behavior
For the MCP `recall` tool, `tags_match` is forwarded only when `tags` is present.
To select the untagged/global scope through MCP, pass both `tags: []` and
`tags_match: "exact"` rather than omitting `tags`.
:::
#### Scenario setup
Consider a bank with these four memories:
| Memory | Tags |
|--------|------|
| "Alice prefers async communication" | `["user:alice"]` |
| "Bob dislikes long meetings" | `["user:bob"]` |
| "Team uses Slack for announcements" | `["user:alice", "team"]` |
| "Company policy: no meetings on Fridays" | *(untagged)* |
#### `any` — OR matching, includes untagged (default)
Returns memories that have **at least one** matching tag, plus untagged memories.
<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-tags-any" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-tags-any" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-tags-any" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-with-tags" language="go" />
</TabItem>
</Tabs>
Use this for **shared global knowledge + user-specific** patterns, where untagged memories represent information everyone should see.
#### `any_strict` — OR matching, excludes untagged
Same as `any` but untagged memories are excluded.
<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-tags-any-strict" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-tags-any-strict" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-tags-any-strict" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-tags-strict" language="go" />
</TabItem>
</Tabs>
Use this when memories are **fully partitioned by tags** and untagged memories should never be visible.
#### `all` — AND matching, includes untagged
Returns memories that have **every** specified tag, plus untagged memories.
<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-tags-all-mode" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-tags-all-mode" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-tags-all-mode" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-tags-all-mode" language="go" />
</TabItem>
</Tabs>
Use this when memories must belong to a **specific intersection** of scopes (e.g., only memories relevant to both a user and a project), while still surfacing shared global knowledge.
#### `all_strict` — AND matching, excludes untagged
Returns memories that have **every** specified tag, and excludes untagged memories.
<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-tags-all-strict" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-tags-all-strict" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-tags-all-strict" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-tags-all" language="go" />
</TabItem>
</Tabs>
Use this for strict scope enforcement where a memory must explicitly belong to **all** specified contexts.
:::tip Extra tags are fine
A memory with tags `["user:alice", "team", "project:x"]` will still match a filter of `["user:alice", "team"]` under `all_strict` — extra tags on the memory are not a problem. The filter only requires the memory to contain **at least** the specified tags.
:::
#### `exact` — set equality, excludes untagged
Returns memories whose tag set is exactly equal to the specified tags, regardless of tag order. Unlike `all_strict`, memories with extra tags do not match.
Use this when filtering a precise observation scope returned by `GET /v1/default/banks/{bank_id}/observations/scopes`, where `["user:alice"]` should not also match observations scoped to `["user:alice", "project:x"]`.
:::tip Filter to global (untagged) observations only
The empty scope is a real scope — it's where `observation_scopes: "shared"` consolidation writes. Set `tags_match: "exact"` with **no tags** (omit `tags`, or pass `[]`) to recall **only** untagged/global memories and exclude every tagged one:
```json
{ "query": "...", "tags": [], "tags_match": "exact" }
```
With any other `tags_match` mode, absent or empty `tags` means "no tag filter" (all memories are eligible). Only under `exact` do absent/empty tags select "the global scope". This is the way to read back just the global observations after you've started using more specific scopes.
:::
### tag_groups
`tag_groups` is a list of compound boolean tag filters. The groups in the list are AND-ed together at the top level. Each group is a recursive boolean expression: a **leaf** node `{tags, match}`, or a **compound** node `{and: [...]}`, `{or: [...]}`, or `{not: ...}`.
`tag_groups` defaults to `null`. The public REST and MCP request models treat
`tag_groups` and `tags` as mutually exclusive: if both are present, the request is
rejected. Use `tag_groups` by itself for compound filtering and normally leave the
top-level `tags_match` at its default, `any`. Each `tag_groups` leaf has its own
`match` value. The exception is top-level `tags_match: "exact"`: because exact
matching gives absent flat tags a meaning, it adds a global-only flat constraint
that is AND-ed with the compound expression.
#### Leaf node
```json
{ "tags": ["step:5", "step:8"], "match": "any_strict" }
```
`match` accepts the same values as `tags_match`: `any`, `all`, `any_strict`, `all_strict`, `exact`. Defaults to `any_strict`.
#### Fuzzy leaves
A leaf may set `resolve: "fuzzy"` (default `"exact"`) to match its tags against the bank's tags by trigram similarity instead of literally, so a filter on `typsecript` still reaches memories tagged `typescript`:
```json
{ "tags": ["typsecript"], "match": "any_strict", "resolve": "fuzzy" }
```
Each tag resolves to the bank tags scoring at least 0.45, and the leaf then matches those exactly — so `resolve` composes with every `match` mode. Similarity is length-sensitive: `kubernets` reaches `kubernetes`, but a short tag has too few trigrams to survive an edit (`kakfa` does not reach `kafka`). A tag that resolves to nothing matches nothing; the filter is never dropped. A 422 is returned if the bank has more than 5000 distinct tags, or if a `resolve: "fuzzy"` leaf with `match: "exact"` expands past 32 candidate scopes.
#### Compound nodes
```json
{ "and": [ <TagGroup>, <TagGroup>, ... ] }
{ "or": [ <TagGroup>, <TagGroup>, ... ] }
{ "not": <TagGroup> }
```
#### Examples
**Step filter AND user scope** — two top-level groups AND-ed:
```json
{
"tag_groups": [
{ "tags": ["step:5", "step:8", "step:12"], "match": "any_strict" },
{ "tags": ["user:ep_42"], "match": "all_strict" }
]
}
```
**Nested OR inside AND** — user must match, plus either step OR priority:
```json
{
"tag_groups": [
{ "tags": ["user:alice"], "match": "all_strict" },
{ "or": [
{ "tags": ["step:5"], "match": "any_strict" },
{ "tags": ["priority:high"], "match": "all_strict" }
]}
]
}
```
**Exclusion** — user must match, but archived memories are excluded:
```json
{
"tag_groups": [
{ "tags": ["user:alice"], "match": "all_strict" },
{ "not": { "tags": ["archived"], "match": "any_strict" } }
]
}
```
### trace
When set to `true`, the response includes a detailed debug trace covering the query embedding, entry points, per-strategy retrieval results, RRF fusion candidates, reranked results, temporal constraints detected, and per-phase timings. Has no effect on the retrieval logic itself. Useful for understanding why specific memories were or were not returned.
### min_scores
An optional object of per-stage score floors, each compared **inclusively** (`>=`). Any field you leave unset imposes no floor; omitting `min_scores` entirely (the default) applies no score filtering at all. The four fields operate at **two different levels of the pipeline**, and the level decides what a returned result is guaranteed to satisfy:
| field | level | effect | guaranteed by every result? |
|---|---|---|---|
| `semantic` | retrieval | minimum vector similarity, pushed into the **semantic arm's** SQL — prunes weak vector matches **before** fusion (overrides the global similarity minimum for this request) | no |
| `keyword` | retrieval | minimum keyword/full-text (BM25) score, pushed into the **keyword arm's** SQL — prunes weak keyword matches before fusion | no |
| `reranker` | post-query | minimum normalized cross-encoder score, applied to the ranked results | yes |
| `final` | post-query | minimum final ranking score, applied to the ranked results | yes |
```json
{ "query": "...", "min_scores": { "reranker": 0.5 } }
```
#### Retrieval floors constrain one arm, not the result
Recall runs [four retrieval arms](#results) — semantic, keyword, graph and temporal — and a memory reaches the response if **any** of them surfaced it. `semantic` and `keyword` prune inside the arm they name, so they change *which candidates are considered*, and with them the final ordering. They are **not predicates over each returned result**:
- a result surfaced only semantically reports `"keyword": null`, whatever `min_scores.keyword` you set;
- a result surfaced only by keyword reports `"semantic": null`, whatever `min_scores.semantic` you set;
- a result reached through the graph or temporal arm reports **neither**, and is unaffected by both floors.
Setting `semantic` and `keyword` together therefore does not restrict the response to results that clear both. That is deliberate: an intersection would discard exactly the strong single-arm matches hybrid retrieval exists to find — a paraphrase with no lexical overlap in common with the query, or an exact identifier like `amber-17` that the embedding scores poorly.
#### For abstention, use `reranker` or `final`
The post-query floors are applied to every scored result after fusion and reranking, so a returned result always clears them — and a query where nothing clears them returns no results. That is the floor to reach for when you want recall to abstain on a low-confidence or nonsense query. Note they gate a *combined* signal: `final` blends RRF rank, cross-encoder relevance, recency/temporal and strategy boosts, and `reranker` depends on the cross-encoder's calibration, so neither is a drop-in equivalent of a retrieval-stage cutoff.
Because freed slots are **not** backfilled, any floor can return fewer results than the budget allows.
**Use floors with care.** The reranker's scores are reliable for *ordering* but not as *absolute* values — a clearly-relevant memory can score `~0.001` on one query and `~1.0` on another, so a fixed cutoff risks silently dropping good results. Calibrate any threshold against the scores you actually observe (recall with no `min_scores` first and inspect the [`scores`](#scores) object). See the note under [`scores`](#scores) on why the scale is relative, not absolute, before relying on a fixed threshold.
---
## Response
### results
The main list of recalled facts, ordered by relevance. Relevance is computed by running four retrieval strategies in parallel — semantic similarity, BM25 keyword, graph traversal, and temporal — fusing their rankings with Reciprocal Rank Fusion (RRF), then re-scoring the merged candidates with a cross-encoder reranker against the original query.
Each result carries a [`scores`](#scores) object (see below). Treat these as **relative** signals: they reflect the ranking within a single query, not an absolute, cross-query confidence — a `0.8` from one query is not comparable to a `0.8` from another. For most agents the right approach is to consume memories in order and let `max_tokens` determine how many fit, rather than filtering by score. The `scores` object (and the [`min_scores`](#min_scores) parameter) exist for callers that want to inspect the ranking or drop a low-confidence tail; calibrate any threshold against the scores you see on an unfiltered query.
Each item in `results` has the following fields:
#### id
The unique identifier of this fact. Use it to cross-reference with `source_facts` or for application-level deduplication.
#### text
The extracted fact text as stored in the memory bank.
#### type
The fact category: `world` for objective information, `experience` for events and conversations, or `observation` for consolidated knowledge synthesized over time.
#### context
The context label provided when the fact was retained (e.g., `"team meeting"`, `"slack"`). `null` if none was set.
#### metadata
The key-value string pairs attached when the fact was retained. `null` if none were set.
#### tags
The visibility-scoping tags attached to this fact.
#### entities
A list of canonical entity name strings linked to this fact. Only populated when `include.entities` is enabled (the default). `null` otherwise.
#### occurred_start / occurred_end
ISO 8601 datetimes representing when the described event started and ended. Extracted by the LLM from the content during retain. `null` if the content had no temporal information.
#### mentioned_at
ISO 8601 datetime of when this fact was retained into the bank.
#### document_id
The document ID this fact belongs to, as set during retain.
#### chunk_id
The ID of the source text chunk this fact was extracted from. Used to cross-reference with `chunks` in the response when `include.chunks` is enabled.
#### source_fact_ids
For `observation`-type results only: the IDs of the original facts this observation was synthesized from. Cross-references with `source_facts` in the response. `null` for other types or when `include.source_facts` is not enabled.
#### scores
An object of the per-stage scores for this result. `null` for `source_facts` entries, which are attached by provenance rather than ranked. Fields:
- **`final`** — the score this fact was ranked by (cross-encoder relevance × recency/temporal/evidence boosts). `results` is ordered by it descending. A relative signal, not a calibrated probability (see the note above).
- **`reranker`** — the cross-encoder's normalized relevance (`0`–`1`). `null` when the deployment uses a passthrough reranker (RRF/interleave modes).
- **`semantic`** — the raw vector cosine similarity (`0`–`1`). `null` if this result was not surfaced by semantic search.
- **`keyword`** — the raw keyword/full-text (BM25) score (`≥ 0`, unbounded). `null` if this result was not surfaced by keyword search.
Each field is also a valid [`min_scores`](#min_scores) floor — but `semantic` and `keyword` gate their own retrieval arm rather than the returned result, so a `null` here is expected even when you set that floor. A non-null value always clears it. See [`min_scores`](#min_scores).
---
### source_facts
A dict keyed by fact ID containing full `RecallResult` objects for the source facts that contributed to observation results. Only present when `include.source_facts` is enabled. Facts are deduplicated — if two observations share a source fact, it appears once.
### source_facts_truncated
Whether the token budget cut the `source_facts` map short. When `true`, some IDs in `results[].source_fact_ids` have no entry in `source_facts` because the budget ran out — the references are not dangling. Only present when `include.source_facts` is enabled.
### chunks
A dict keyed by chunk ID containing the raw source text chunks associated with the returned facts. Only present when `include.chunks` is enabled. Each chunk has `id`, `text`, `chunk_index`, and `truncated` (whether the text was cut to fit the token budget).
### entities
A dict keyed by canonical entity name containing entity state objects. Only present when `include.entities` is enabled. Each entry has `entity_id`, `canonical_name`, and `observations`.
### trace
A debug object present only when `trace: true` was set in the request. Contains per-phase timings, retrieval breakdowns, and RRF fusion details.