SemiLayerDocs

Drill-down rows

Every bucket in an AnalyzeResult carries a signed bucketKey. Round-trip that key to analyze.<name>.rows(bucketKey) and you get the contributing rows, paginated, RBAC-filtered, mapping-applied — through the exact same pipeline query uses. Drill-down is query, already narrowed to the bucket's predicate.

Three primitives layer on top of that base:

  • Search inside the bucket — auto routes to semantic for lenses with searchable: true fields, otherwise falls through to substring across text fields.
  • Sort by any mapped field — the platform appends a stable primary-key tiebreaker server-side so cursor pagination doesn't shuffle.
  • Cursor pagination — opaque, deep-scroll-safe, drains end-to-end (and is what the streaming export consumes — see Exports).
// 1. Run the analysis
const result = await beam.products.analyze.byCategory({})

// 2. Pick a bucket and drill
const footwear = result.buckets.find((b) => b.dims.category === 'footwear')!

const page = await beam.products.analyze.byCategory.rows({
  bucketKey: footwear.bucketKey,
  search:    'lightweight',     // free-text search inside the bucket
  orderBy:   { field: 'price', dir: 'asc' },
  limit:     25,
})

// page.rows[i]          ← Record<string, unknown> — same shape as query()
// page.cursor           ← string | undefined — pass back for next page
// page.total            ← number | undefined — when known up-front
// page.crossSource      ← true when the bucket spanned multiple bridges
// page.meta?.searchMode ← 'simple' | 'semantic' | 'hybrid' | 'hybrid:simple-only' | 'hybrid:semantic-only'
// page.meta?.orderBy    ← echo of the resolved sort (with PK tiebreaker appended)

The bucketKey signing model

bucketKey is not a row id and not a UI handle. It's an HMAC-SHA256-signed token (24h TTL) that encodes:

  • The lens identity (org + env + lensId)
  • The bucket's exact predicate (e.g. { category: 'footwear', in_stock: true })
  • The original analyze's candidates.where (so drill inherits the same pre-filter)
  • The similarTo ID set when vector narrowing was active — so drilling a semantic-cluster bucket replays the same vector-narrowed pool

When the server receives a bucketKey it verifies the signature, re-evaluates the lens's access rules against the current caller's identity (RBAC isn't captured in the key — drift between the analyze run and the drill is fine), and runs query against the union predicate. A bucketKey from one user does not work for another user — RBAC re-evaluates server-side. A bucketKey from a Free tier pk_ key works in sk_ mode too — the key encodes the predicate, not the requester.

Tokens are valid for 24 hours. After that you need to re-run the analysis to get fresh bucketKeys; we trade convenience for security-margin on the HMAC window.

Search — auto, simple, semantic, hybrid

Drill-down accepts a search?: string and a searchMode?: 'auto' | 'simple' | 'semantic' | 'hybrid'. Empty string = no filter (falls through to the cursor walk). The router picks an engine based on the lens config:

search?: string                                              // the query
searchMode?: 'auto' | 'simple' | 'semantic' | 'hybrid'      // routing override (default 'auto')
ModeWhat runsWhen to pick it
auto (default)Semantic if the lens has any searchable: true field; otherwise substring across text fieldsThe right answer for ~95% of UIs
simpleSubstring $ilike OR'd across every text field on the lens. No embedding costLens has no embeddings, you want literal text matching, or you want predictable highlighting
semanticEmbed the query, rerank candidates by cosine similarityLens has searchable fields, you want "summer dresses" to match "linen maxi"
hybridSubstring narrows to a candidate pool (10× the page size); semantic reranks withinBig buckets where pure semantic over the whole pool would over-fetch

The mode the engine actually ran echoes back at meta.searchMode (never 'auto'). Two degenerate-fallback labels surface when one half of hybrid no-ops:

  • 'hybrid:simple-only' — lens has no searchable: true field; ran simple, no semantic rerank.
  • 'hybrid:semantic-only' — substring would have matched ≥90% of the bucket; substring was a no-op, fell through to pure semantic.
const page = await beam.recipes.analyze.byCuisine.rows({
  bucketKey,
  search:     'spicy comfort food',
  searchMode: 'auto',
})

if (page.meta?.searchMode === 'simple') {
  // Showed substring matches — UI: "filtered by text"
} else if (page.meta?.searchMode === 'semantic') {
  // Re-ranked by meaning — UI: "ranked by relevance"
}
⚠️

Wildcard escaping. simple and hybrid modes auto-escape %, _, and \\ in the query — searching for "100%" matches the literal three characters, not "anything containing 100." The substring path uses the bridge's $ilike operator under the hood.

Sort — and the PK tiebreaker

orderBy accepts a single rule or an array (applied in order):

// Single
orderBy: { field: 'price', dir: 'asc' }

// Multiple
orderBy: [
  { field: 'price',    dir: 'asc' },
  { field: 'name',     dir: 'asc' },
]

Default direction is 'asc'. Default ordering when orderBy is omitted is the lens's primary key. The platform always appends the primary key as a final tiebreaker server-side, so cursor-based pagination never shuffles rows on ties — you don't need to remember to add it yourself. The resolved chain echoes back at meta.orderBy:

// Request:  orderBy: { field: 'price', dir: 'asc' }
// Echoed:   meta.orderBy: [
//             { field: 'price', dir: 'asc' },
//             { field: 'id',    dir: 'asc' },   // PK appended
//           ]

Validating an orderBy field that isn't on the lens returns 400 with unknown_field.

Search and sort layered together

When both search (semantic / hybrid) and orderBy are set, semantic narrows the pool, then explicit orderBy sorts the narrowed result. The similarity-score order is dropped in favor of the caller's chosen sort. meta.searchMode still reflects the engine that picked the candidates; meta.orderBy reflects the final ordering applied.

Cursor pagination

Drill-down rows always return a cursor when more pages remain:

// First page
const first = await beam.products.analyze.byCategory.rows({
  bucketKey,
  limit: 25,
})

// Next page — pass the cursor from the previous response
const next = await beam.products.analyze.byCategory.rows({
  bucketKey,
  cursor: first.cursor,
  limit:  25,
})

// Terminate when cursor is undefined

The cursor is an offset under the hood. Drill on a static bucket is read-only, so write-races against the source DB aren't a concern here. For write-stable cursors over query() directly, see Cursors & streaming.

Cross-source drill-down

When the analyze ran through a declared relation (e.g. parent products joined to child reviews), the drill-down replays the join. crossSource: true flags those pages so UIs can label them ("Showing reviews for the products in this bucket"). The bucketKey carries the join-side predicate; the drill fetches parent ids, then child rows scoped to those ids.

Putting it together

The pattern most apps land on:

// React — the canonical drill-down UI
const { result }       = useAnalyze(beam.products.analyze.byCategory, {})
const [bucketKey, set] = useState<string | null>(null)
const { rows, fetchMore, total } = useAnalyzeRows(
  beam.products.analyze.byCategory,
  { bucketKey, pageSize: 25 },
)

return (
  <>
    <BarChart
      data={result?.buckets ?? []}
      onBucketClick={(b) => set(b.bucketKey)}
    />
    {bucketKey && (
      <DrillPanel
        rows={rows}
        total={total}
        onLoadMore={fetchMore}
        onClose={() => set(null)}
      />
    )}
  </>
)

useAnalyzeRows resets on bucketKey: null, refetches on bucketKey change, and exposes cursor for "load more" / IntersectionObserver wiring. Search + sort are one prop each — { search, orderBy } thread through to the same /rows endpoint.

Errors

StatusCodeWhen
400bucket_key_requiredBody missing bucketKey.
400bucket_key_invalidSignature failed, scope mismatch, or token expired (24h).
400unknown_fieldorderBy.field isn't declared on the lens.
400unsupported_search_modesearchMode: 'simple' against a lens with no text fields.
403forbiddenThe analysis's grants.analyze.<name> rule denied — re-evaluated at drill time, not at the original analyze run.
404lens_not_found / analysis_not_foundThe lens or named analysis doesn't exist on this env.

Next: Exports — drain the same cursor end-to-end as streaming NDJSON or CSV.