AI

Retrieval Results

The RAG retrieval step opened up — ranked chunk cards with source and page, a similarity bar carrying the pipeline cutoff, whole-word query emphasis, a used-in-answer badge wired to its citation number, and a threshold divider that refuses to lie.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  AlertCircle,
  ArrowDownWideNarrow,
  Check,
  ChevronDown,
  ExternalLink,
  FileText,
  ListOrdered,
  type LucideIcon,
  RefreshCcw,
  SearchX,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/retrieval-results.json

Prompt

Build a React + TypeScript + Tailwind "RetrievalResults" component with zod and
lucide-react — the retrieval half of a RAG answer, rendered for a human who is
trying to work out why the model said what it said.

Contract
- A zod schema (`retrievalResultsItemSchema` in a sibling contract file) is the
  single source of truth for ONE chunk: { id, rank, score, source, page?,
  snippet, usedInAnswer, citation?, url? }.
- `rank` is authored upstream (1-based, the order the pipeline — usually a
  reranker — handed the chunks over) and is NEVER the array index: filtering or
  re-sorting the list must not renumber a chunk, because that number is what you
  quote back to whoever owns the retriever.
- `score` lives on whatever scale `scoreMax` says (default 1 for cosine, pass 100
  for a percentage reranker). The component only ever divides by `scoreMax`; it
  never assumes a range and never rescales the data.
- `usedInAnswer` + `citation` come from the generation step, not the retrieval
  step. Keeping them on the same record is the whole point: retrieval and
  citation are different events, and the gap between them is the finding.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
  panel's own state. `empty` means the retriever ran and matched nothing;
  `error` means the retrieval call itself failed. Collapsing those two into one
  "no results" screen is how a broken index gets misread as a thin knowledge
  base for a week.
- Props: status; items: readonly Item[]; query?; terms?; stopWords?; threshold?;
  scoreMax? (1); defaultSort? ("rank"); sort?; onSortChange?;
  defaultShowExcluded? (true); snippetChars? (260, clamped >= 60, Infinity
  disables truncation); onSelect?(item, event); onCitationSelect?(citation,
  item); onRetry?; formatScore?; target? ("_blank"); heading?; emptyState?;
  errorMessage?; label?; loadingCount? (3); className plus the rest of the
  element props, ref forwarded to the <section>.

Behavior
- TWO ORDERS, ONE ARRAY. The sort toggle switches between `rank` (the pipeline's
  verdict) and `score` (raw similarity, descending). Sorting is a memoised VIEW
  over `items`, never a mutation, and each comparator falls back to the other
  field so the order is total and cannot shuffle between two renders of the same
  data. The toggle is uncontrolled by default and becomes controlled when `sort`
  is passed; `onSortChange` fires either way. The reason both orders exist: a
  reranker earns its keep by disagreeing with cosine distance, and flipping the
  toggle is the only way to SEE the disagreement.
- THE CUTOFF DIVIDER IS CONDITIONAL ON THE DATA. `threshold` splits the list
  into "went into the prompt" and "retrieved and thrown away". The divider row
  is drawn only when the current order actually partitions the list — every item
  from the first excluded one on is also excluded. Under `score` that is always
  true; under `rank` it is not (fused/RRF ranks can put a low-similarity chunk
  at position 2), and drawing a line there would claim an ordering the data does
  not have. When it cannot draw, it draws nothing and every excluded card keeps
  its own dashed edge and "below cutoff" wording, so the information never
  depends on the divider existing.
- Excluded chunks are VISIBLE BY DEFAULT. The divider doubles as a disclosure
  button (aria-expanded + aria-controls on the list) that folds them away, and
  folding UNMOUNTS them rather than hiding them with CSS — a zero-height collapse
  leaves every button inside reachable by Tab. Default `defaultShowExcluded`
  true: the chunk that answered the question and lost by 0.008 is the reason
  this panel exists, and you cannot report a bug you cannot see.
- THREE TIERS, NOT TWO. Cited → a "Used (n)" badge carrying the citation number
  from the data. Sent to the model and not cited → a quiet "Sent · not cited"
  badge, which is what a padded top-k looks like from the outside. Below the
  cutoff → no usage badge at all, because it never reached the model. The badge
  becomes a real button only when `onCitationSelect` is passed; otherwise it is
  a span, because a badge that looks clickable and does nothing is a lie.
- QUERY EMPHASIS IS STRUCTURAL. Terms are derived from `query` (split on
  non-word characters, deduplicated case-insensitively, a short stop list
  dropped, one-character ASCII fragments dropped, single CJK characters KEPT),
  compiled into ONE case-insensitive alternation ordered longest-first so a
  phrase wins over its own first word, and rendered as real <mark> elements
  around real text nodes. Never dangerouslySetInnerHTML: a chunk is text you did
  not write, and building HTML out of it turns a knowledge base into an
  injection surface. A pattern that fails to compile degrades to "no emphasis",
  never to a crash.
- Matching is boundary-aware for ASCII terms ONLY: "API" must not light up
  inside "APIs", while CJK has no spaces to hang a boundary on and would never
  match under a \b rule. The check reads the neighbouring characters out of the
  source string rather than using look-behind. Consequence to document: emphasis
  is whole-word, so "keys" does not mark "keychain" AND does not mark "key" —
  pass the retriever's expanded terms through `terms` when you want stems and
  synonyms marked.
- The matcher is memoised on a derived STRING (terms joined on a separator that
  cannot occur inside a term), not on the `terms` array: an inline array literal
  changes identity every render, and rebuilding the matcher would re-mount every
  <mark> on the page.
- SNIPPET TRUNCATION IS ANNOUNCED. A chunk longer than `snippetChars` is cut and
  the card states "Showing 260 of 1,412 characters"; matches are scanned once on
  the FULL text and partitioned, so the note can also say "3 more matches below"
  — a match hiding past the cut must never masquerade as a chunk that did not
  match. A match straddling the cut counts as hidden; half a highlighted word is
  a lie. Expansion is per chunk, keyed by id, and lives in the parent so
  re-sorting keeps it.
- Identity changes reset the panel (adjust state during render, no effect): a
  new set of chunk ids collapses every expanded snippet and restores the
  excluded group's default, so a recycled panel can never show the previous
  query's disclosure state for one frame.
- Every numeric input is defended: `scoreMax <= 0` or NaN falls back to 1, a
  non-finite `threshold` is treated as absent, a NaN score renders an em dash
  instead of "NaN", and the bar ratio is clamped to 0..1 so an out-of-range score
  cannot overflow its track.
- The component owns no timers, no observers and no clock, so there is nothing to
  clean up on unmount and the server, the browser and a screenshot fixture all
  render the same string.

Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
  var(--chart-1) for an included score bar and var(--chart-3) (at reduced
  opacity) for an excluded one, bg-muted for the track, bg-foreground/60 for the
  threshold tick, bg-primary + text-primary-foreground for the active sort
  segment, border-primary/40 + bg-primary/10 for the used badge, bg-primary/15
  for the <mark>, border-destructive/40 + bg-destructive/5 for the error branch.
  No hard-coded colours anywhere.
- The score bar encodes magnitude as WIDTH and inclusion as COLOUR, with the
  cutoff drawn as a tick on the same axis — so "0.612 against a 0.62 threshold"
  is a picture instead of arithmetic. It is aria-hidden: the number beside it and
  the "below cutoff" wording carry the same facts in text, so nothing depends on
  colour or on seeing the bar.
- Excluded cards are dashed and dimmed; the state is ALSO in words and in a
  data-excluded attribute, never in colour alone.
- Root is a <section aria-label="Retrieved chunks"> holding one persistent
  sr-only role="status" line that spells out the counts and the current ordering,
  so re-sorting and folding — both of which change the screen without moving
  focus — are announced from an element the reader is already watching.
- Chunk text renders with whitespace-pre-wrap (how the chunker split a table or a
  code block is half of what you came to see) plus wrap-anywhere + min-w-0, so a
  200-character URI inside a chunk wraps instead of widening the panel.
- The locator is a real <a> when the chunk carries a `url` (modified clicks —
  ⌘/Ctrl/Shift/Alt, middle click — are left to the browser even when `onSelect`
  intercepts the plain one), a <button> when only `onSelect` is passed, and plain
  text when neither is: no affordance without behaviour.
- Sort segments are <button aria-pressed>, the divider is a <button
  aria-expanded aria-controls>, every control has a focus-visible ring, and every
  transition is motion-reduce-guarded (the bar's width transition included).
- cn() merges the consumer's className into the root, which also spreads the
  remaining element props and forwards its ref.

Customization levers
- Scale: `scoreMax` moves the whole bar from cosine (1) to a reranker's 0-100;
  `formatScore` swaps the wording entirely (percentages, "distance 0.38", four
  decimals). The default trims trailing zeros rather than padding them.
- Policy: `threshold` is what turns the panel from a list into a verdict — omit
  it and every chunk reads as included, no divider, no dimming.
  `defaultShowExcluded={false}` starts the rejected group folded for a
  customer-facing trace; leave it true for a debugging surface.
- Emphasis: `terms` replaces the derived terms wholesale (feed it your query
  expansion, or `[]` to switch marking off), `stopWords` replaces the built-in
  list (pass `[]` to mark everything, or add your domain's noise words).
- Density: `snippetChars` decides how much chunk is drawn before the reveal —
  raise it for short chunks, pass Infinity for a full-text audit view, but never
  make the cut silent; the count is part of the reading. `loadingCount` matches
  the skeleton to your top-k so the panel does not resize when data lands.
- Wiring: `onCitationSelect` turns the used badge into a jump into your answer,
  `onSelect` opens the source document in your own reader, `target` decides where
  a plain link goes, `heading`/`label`/`emptyState`/`errorMessage`/`onRetry` own
  the chrome and the two failure branches.

Concepts

  • Retrieved, sent, cited are three different things — a chunk can come back from the index, clear the threshold, ride into the prompt and still be ignored by the answer. Flattening that into one "relevant" flag hides the two findings that matter: a padded top-k burning context, and a near-miss chunk that lost the prompt by a thousandth.
  • The threshold is a tick on the bar, not a number in a corner — magnitude is the width, inclusion is the colour, and the cutoff is drawn on the same axis every card shares, so "0.612 against 0.62" reads as a picture. The number stays printed beside it, because a bar alone is unreadable to a screen reader and unquotable in a bug report.
  • A divider that cannot be honest refuses to draw — the line between included and excluded only exists when the current order actually partitions the list. Fused (RRF) ranks routinely put a low-similarity chunk near the top; drawing a line there would assert an ordering the data does not have, so the line disappears and each card carries its own mark instead.
  • Two orders expose the rerankerrank is the pipeline's verdict, score is the raw similarity. Watching a chunk move when you flip between them is the cheapest way to see whether the reranker is earning its latency or quietly inverting a good retrieval.
  • Emphasis is structural and whole-word — marks are real <mark> nodes around real text nodes, never an HTML string built out of retrieved text, and "API" refuses to light up inside "APIs". The strictness is deliberate: pass the retriever's expanded terms when you want stems and synonyms marked, so the highlight always reflects what was actually searched for.
  • Truncation reports what it took — a cut chunk states the characters withheld AND how many term matches went with them, because a match hiding past the cut would otherwise read as a chunk that never matched, and that is exactly the wrong conclusion to draw in a retrieval bug hunt.

On This Page