Feeds — Ranking
The rank block composes four optional scorers plus a list of boost rules.
Every scorer produces a value in roughly [0, 1]. The final score is:
diversify reorders the ranked list post-sort — it doesn't change scores.
similarity
Cosine distance between the candidate's stored vector and a target vector derived from your context.
against — three shapes
| Shape | When to use |
|---|---|
'liked_titles' | Bare string → context.liked_titles. Text (embedded on demand + cached) or a number[] vector. |
{ from: 'context.user.pref' } | Explicit context path. Same semantics as the bare string. |
{ from: 'context.seedRecordId', mode: 'recordVector' } | from resolves to a row id; server reads that row's stored embedding. Zero embedding-API call. See Related items. |
Math
Scorer is clamped defensively to [0, 1].
recency
Exponential half-life decay on a date field.
Parameters
| Field | Type | Default | Notes |
|---|---|---|---|
weight | number | — | Required. |
halfLife | '1h' | '6h' | '1d' | '7d' | '30d' | — | Required. Fixed-token whitelist — no arbitrary strings. |
field | string | lens's changeTrackingColumn | Mapped field name (e.g. 'updated_at', 'published_at'). Must be a date or number (ms). |
Math
Rows with a missing or unparseable timestamp score 0.
engagement
Aggregate rows from a sibling lens (e.g. recipe_likes) keyed by the
candidate row. Use for popularity, click-through, like counts — any
behavioral signal you can express as "rows in another lens that reference
this row."
Join
Two ways:
relation(preferred): name a declaredrelationsentry on the owning lens whose target isconfig.lens. Server derives join fromrelations[name].on.join: { local, foreign }: explicit column mapping. Required if you didn't declare the relation.
Aggregates
| Value | Meaning |
|---|---|
'count' | Number of matching rows in the sibling lens. |
'recent_count' | Count of matching rows in the last window (requires window). |
'sum' | Sum of column across matching rows (requires column). |
Windows: '1h' | '24h' | '7d' | '30d' — fixed token whitelist.
Decay
Softens runaway signals so a single 10-million-like post doesn't eat the feed.
| Value | Formula |
|---|---|
'none' | raw (no decay) |
'linear' | raw (normalized to page max) |
'log' | log(1 + raw) (normalized to page max) |
All three normalize per-page: each candidate's value is divided by the page's max. So "most engaged" always scores ~1.0, "least" scores ~0.0.
Engagement lives in a lens, not a raw table. That's by design — the
sibling lens's access rules apply to pk_ callers, and airgap/runner
routing is automatic. See Signals for why.
diversify
Post-sort reordering to prevent a single group (cuisine, author, brand) dominating the page.
Parameters
| Field | Type | Default | Notes |
|---|---|---|---|
by | string | — | Mapped field name. Must be present on the candidate's metadata. |
maxPerGroup | number | — | Hard cap. |
strategy | 'cap' | 'mmr' | 'cap' | cap = simple round-robin skip. mmr = maximal marginal relevance interleave. |
cap is fast and predictable. mmr is smarter for high-duplication pools
(e.g. every fourth item is the same creator) but slightly more expensive.
boost
Static additive scores based on candidate metadata.
when is an exact-match predicate on mapped fields. Array values match any
($in). All matching boosts stack — order doesn't matter. Boosts are applied
after scorer composition and before diversify.
Validating your weights
A few heuristics that hold up:
- Weights sum to ~1.0 for the headline scorers (similarity + recency + engagement). Boosts are icing, not main course.
- Similarity rarely wants
< 0.3— if it does, you don't want a semantic feed. - Recency
halfLifematches the content rhythm —'1h'for news,'7d'for articles,'30d'for evergreen. - Engagement
weightis usually the smallest, not the largest. Engagement tends to compound; left unchecked it creates filter bubbles.
To check what actually happened at query time, use the
explain endpoint — it returns the exact per-scorer math
for any record.