Skip to main content
recall() ranks by relevance. A recall filter runs next to that ranking as a hard gate: a deterministic predicate that a chunk either satisfies or doesn’t. Reach for it when “relevant” isn’t enough and you need “relevant and from this source”, “and tagged tier: gold”, or “and dated this quarter”. Pass it as the filter= kwarg. It takes a RecallFilter or a plain dict (the dict is validated for you):
from datetime import datetime, timezone
from khora import RecallFilter

result = await kb.recall(
    "pricing changes",
    namespace=ns_id,
    filter={
        "source_name": "linear",
        "metadata.tier": {"$in": ["gold", "silver"]},
        "occurred_at": {"$gte": datetime(2026, 1, 1, tzinfo=timezone.utc)},
    },
)
The grammar uses familiar $-prefixed operators ($eq, $in, $and, …) but is closed and typed. An unknown key, a malformed operator, or an out-of-place predicate raises RecallFilterValidationError (with a structured errors list) instead of quietly matching nothing.

What you can filter on

Two groups of keys, plus your own metadata. You set all of these at ingest with remember(): see Ingestion. System keys are first-class fields Khora denormalizes onto every chunk:
KeyTypeOperators
occurred_at, created_at, source_timestampdatetime$eq $ne $gt $gte $lt $lte $in $nin $not
source_type, source_name, source_url, external_id, content_type, source, titlestring$eq $ne $in $nin $exists $not
Date keys have no $exists (the axis is always present) and string keys have no range operators. Both restrictions are enforced when the filter is built. Metadata is your free-form per-document dict. Match the whole blob for exact equality ({"metadata": {...}}), or address one field with dot-notation:
filter={"metadata.team.name": {"$eq": "ingest"}}      # nested path
filter={"metadata.score": {"$gte": 0.8}}              # range on a metadata field
Metadata fields accept $eq $ne $gt $gte $lt $lte $in $nin $exists $not. You cannot mix operator keys with plain keys in one object, and an operator nested inside an equality value is rejected. Reach a nested field with a dotted key, not a nested dict.

Operators

$eq $ne $gt $gte $lt $lte $in $nin $exists, the logical operators $and $or $nor $not, and the $date typed literal (below). Logical operators combine whole sub-filters and nest freely:
filter={"$or": [
    {"source_name": "linear"},
    {"$and": [{"source_type": "doc"}, {"metadata.public": True}]},
]}

Bare-value shorthand

  • A scalar is $eq: {"source_name": "linear"} means {"source_name": {"$eq": "linear"}}.
  • A list is exact-array equality, not membership: {"source_type": ["a", "b"]} matches the value ["a", "b"]. For “is one of”, use $in: {"source_type": {"$in": ["a", "b"]}}.
  • null is an active null-or-missing match. To not filter on a key, omit it.

$date in dict form

JSON has no datetime type. In the dict form, wrap a date operand as {"$date": "<ISO-8601>"} so it compares as a date rather than a string. System date keys take datetime objects directly, so $date matters mainly for date-valued metadata:
filter={"metadata.published": {"$gte": {"$date": "2026-01-01T00:00:00Z"}}}

Filtering by time

filter={"occurred_at": {...}} is the supported way to bound recall by time. It enforces the event-time axis exactly, with no fallback to ingest time. The older start_time / end_time kwargs are deprecated. They are a recency window over COALESCE(source_timestamp, created_at), a different axis, and they cannot be combined with filter= (doing so raises ValueError). Prefer:
filter={"occurred_at": {"$gte": start, "$lt": end}}

What gets pushed down

Khora compiles each filter to a native backend query where it can, and re-checks the rest in memory. The result is identical either way. The difference is cost: a pushed-down predicate narrows the index scan instead of filtering after the fact. On the VectorCypher engine:
  • Postgres + Neo4j: the vector and keyword (BM25) channels push the whole filter into the SQL and index scan. The graph channel pushes system keys into the Cypher WHERE and re-checks any residual metadata predicate in memory.
  • Embedded (sqlite_lance): the vector and keyword channels compile to a SQLite WHERE with JSON metadata pushdown. The embedded graph channel re-checks in memory.
Either way, a channel also runs a defensive in-memory re-check over the full predicate as a safety net, so a filtered result never depends on a single backend compile being exactly right. When a filter is present, the typed-entity fast path is skipped and recall takes the full retrieval path, because that fast path cannot enforce the filter.

The pushdown report

Every filtered recall reports honestly what happened, on engine_info["filter"], a FilterPushdownReport:
result.engine_info["filter"]
# {
#   "pushed_down": True,          # fully pushed: nothing re-checked, every leaf in the scan
#   "post_filtered": True,        # an in-memory re-check ran (here, the defensive one)
#   "pushed_keys": ["source_name"],
#   "post_filtered_keys": [],
#   "unenforced_keys": [],        # empty on a correct recall
#   "channels": {                 # per-channel breakdown
#     "vector": {"pushed_keys": ["source_name"], "post_filtered_keys": []},
#   },
# }
pushed_keys, post_filtered_keys, and unenforced_keys partition every constraint leaf into exactly one bucket. unenforced_keys is the one to watch: a non-empty list means a result-producing path returned candidates without enforcing those keys. On a correct recall it is always empty.

Errors

  • RecallFilterValidationError: the filter is malformed (unknown key, bad operator, wrong operand shape, nesting too deep). Raised at the recall() call before any search, with a structured errors list an SDK can map to an HTTP 400.
  • RecallFilterUnsupportedError: a backend cannot honor a predicate on a key it doesn’t back with a real column. It fails loud rather than silently dropping every row.
Both are exported from the top-level khora package.