SemiLayerDocs

Feeds — Related items

The "more like this" pattern. Given a clicked record, return the N nearest neighbors ranked however you want — similarity + recency, similarity + engagement, or pure similarity with diversify.

The trick: use mode: 'recordVector' and the seed's existing stored embedding as the target. Zero embedding-API call at request time.

feedrecipes
recipes: {
  // ... fields, relations
  feeds: {
    relatedTo: {
      candidates: { from: 'embeddings', limit: 200 },
      rank: {
        similarity: {
          weight: 0.75,
          against: { from: 'context.seedRecordId', mode: 'recordVector' },
        },
        engagement: {
          weight: 0.2,
          lens: 'recipe_likes',
          relation: 'likes',
          aggregate: 'count',
        },
        diversify: { by: 'cuisine', maxPerGroup: 2 },
      },
      pagination: {
        pageSize: 6,
        dedup: { by: 'sourceRowId' },
        excludeIds: 'context.seedRecordId',   // don't return the seed itself
      },
    },
  },
  grants: { feed: { relatedTo: 'public' } },
}

Why this is cheap

Semantic "more like this" is usually expensive: you take the current document's text, embed it, then do a vector search. That's one embedding API call per click.

With mode: 'recordVector', the server skips the embed call entirely:

  1. Client sends context: { seedRecordId: 'r_104' }.
  2. Server reads the stored vector for r_104 (indexed read).
  3. Server uses that vector as the similarity target directly.

Zero embedding API calls at request time. The seed's vector already exists — the request is an indexed read plus an ANN search, nothing more. This is what makes "more like this" cheap enough to ship on every detail page.

React hook

import { useFeed } from '@semilayer/react'
import { beam } from '@/lib/beam'

const related = beam.recipes.feed.relatedTo

export function RelatedPanel({ recipeId }: { recipeId: string }) {
  const { items, isLoading } = useFeed(related, {
    context:  { seedRecordId: recipeId },
    pageSize: 6,
  })

  if (isLoading) return <Skeleton />
  return (
    <aside>
      <h4>More like this</h4>
      {items.map((it) => (
        <a key={it.id} href={`/recipes/${it.sourceRowId}`}>
          {it.metadata.title}
        </a>
      ))}
    </aside>
  )
}

Because seedRecordId is part of the hook's dependencies, navigating to a new recipe re-runs the feed with the new seed. No manual cache busting.

Blending with popularity

Pure similarity often surfaces obscure near-duplicates. Adding a small engagement weight pulls "popular related items" forward:

rank: {
  similarity: {
    weight: 0.75,
    against: { from: 'context.seedRecordId', mode: 'recordVector' },
  },
  engagement: {
    weight: 0.2,
    lens: 'recipe_likes',
    relation: 'likes',
    aggregate: 'count',
  },
},

Rule of thumb: similarity 0.7–0.8, engagement 0.15–0.25, everything else pure icing.

excludeIds — don't return the seed

Without pagination.excludeIds, the highest-similarity result to r_104 is usually r_104 itself (distance zero). Always set:

pagination: {
  excludeIds: 'context.seedRecordId',
}

The value is a context path that resolves to a row id (or an array of ids). The server filters those out after ranking.

Limits

  • Only records with stored embeddings work as seeds. If the row hasn't been ingested yet (or has searchable: false on every field), there's no vector to use. The server returns 400 seed_record_not_found.
  • One seed per request. Pass more than one id in seedRecordId and you'll get a validation error. For multi-seed blends, run N requests client-side and union. Or pre-compute an average vector on your side and pass it as a plain number[].
  • The seed's own engagement isn't contextualized. If you want "related items by users similar to me," you'll need a user-context component on top — the engagement scorer alone aggregates over the whole engagement lens, not per-user.

Next: Live evolution — push updates when new matching content arrives.