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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/retrieval-results.jsonPrompt
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 reranker —
rankis the pipeline's verdict,scoreis 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.
Scheduled Tasks
A list of scheduled agent automations — a human cadence next to a live countdown to the next run, the last run's outcome inline, a pause switch, a one-shot manual trigger, the failure printed on the row that broke, and four data states.
Knowledge Picker
A grounding-scope selector for collections, files and links — tri-state collections that cascade only over what is actually selectable, per-source document and chunk counts with index freshness, an accounting bar that prices the selection in chunks, and four data states.