SemiLayerDocs

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:

score = Σ(scorer.weight × scorer.value) + Σ(matching boosts)

diversify reorders the ranked list post-sort — it doesn't change scores.

feedrecipes
// A fully-loaded rank block — every scorer + boost + diversify
discover: {
  candidates: { from: 'embeddings', limit: 500 },
  rank: {
    similarity: { weight: 0.5, against: 'liked_titles' },
    recency:    { weight: 0.2, halfLife: '7d', field: 'updated_at' },
    engagement: {
      weight: 0.3,
      lens: 'recipe_likes',
      relation: 'likes',        // derives join from relations.likes.on
      aggregate: 'recent_count',
      window: '24h',
      decay: 'log',
    },
    diversify: { by: 'cuisine', maxPerGroup: 3, strategy: 'mmr' },
    boost: [
      { when: { featured: true }, add: 0.15 },
      { when: { cuisine: ['thai', 'vietnamese'] }, add: 0.05 },
    ],
  },
  pagination: { pageSize: 12 },
}

similarity

Cosine distance between the candidate's stored vector and a target vector derived from your context.

rank: {
  similarity: { weight: 0.6, against: 'liked_titles' },
}

against — three shapes

ShapeWhen 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

value = (1 + cosine(candidate, target)) / 2    // mapped from [-1,1] → [0,1]

Scorer is clamped defensively to [0, 1].

recency

Exponential half-life decay on a date field.

rank: {
  recency: { weight: 0.2, halfLife: '7d' },
}

Parameters

FieldTypeDefaultNotes
weightnumberRequired.
halfLife'1h' | '6h' | '1d' | '7d' | '30d'Required. Fixed-token whitelist — no arbitrary strings.
fieldstringlens's changeTrackingColumnMapped field name (e.g. 'updated_at', 'published_at'). Must be a date or number (ms).

Math

age       = now − metadata[field]                    // seconds
halfSec   = { '1h': 3600, '6h': 21600, '1d': 86400, '7d': 604800, '30d': 2592000 }[halfLife]
value     = 0.5 ^ (age / halfSec)                    // in (0, 1]

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."

rank: {
  engagement: {
    weight: 0.3,
    lens: 'recipe_likes',
    relation: 'likes',            // or use explicit `join: { local, foreign }`
    aggregate: 'recent_count',
    window: '24h',
    column: 'weight',             // only required for sum / recent_count-with-column
    decay: 'log',
  },
}

Join

Two ways:

  • relation (preferred): name a declared relations entry on the owning lens whose target is config.lens. Server derives join from relations[name].on.
  • join: { local, foreign }: explicit column mapping. Required if you didn't declare the relation.

Aggregates

ValueMeaning
'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.

ValueFormula
'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.

rank: {
  diversify: { by: 'cuisine', maxPerGroup: 3, strategy: 'cap' },
}

Parameters

FieldTypeDefaultNotes
bystringMapped field name. Must be present on the candidate's metadata.
maxPerGroupnumberHard 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.

rank: {
  boost: [
    { when: { featured: true },                  add: 0.15 },
    { when: { cuisine: ['thai', 'vietnamese'] }, add: 0.05 },
  ],
}

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 halfLife matches the content rhythm'1h' for news, '7d' for articles, '30d' for evergreen.
  • Engagement weight is 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.