Charts

Retrieval Recall@k

A four-state RAG retrieval card: recall@k over precision@k against a log k axis, one line per retriever, with the shipped operating k marked, an MRR@k readout and a coverage bar whose percentages always total 100.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartRetrievalRecallData,
  ChartRetrievalRecallPoint,
  ChartRetrievalRecallRetriever,
} from "./chart-retrieval-recall.contract"

export interface ChartRetrievalRecallProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartRetrievalRecallData {

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartRetrievalRecall" card in plain SVG
with zod. It sweeps k — how many documents a ranker hands the generator — not a
score threshold. That single difference decides the whole layout: the x axis is
a count of documents on a LOG scale, both metrics are read at the same k, and
the chosen k is a decision the card has to price in tokens.

Contract
- One zod schema is the source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    retrievers: { id: string; label: string;
                  points: { k: int >= 1; recall: 0..1; precision: 0..1;
                            mrr?: 0..1 }[] }[];
    operatingK: int >= 1; chunkTokens?: int >= 1; queryCount?: int >= 1 }.
- operatingK is REQUIRED: a k sweep with no chosen k is a shape with no
  decision in it. mrr is OPTIONAL and a missing MRR prints "not reported" —
  never 0, which would claim the first hit is never in the list.
- superRefine: retriever ids unique; a ready card needs at least one retriever
  with at least one point; guard every access so a ragged payload yields an
  issue rather than a TypeError out of safeParse.
- Props = z.infer plus panelHeight (one panel, default 96, clamped 64..220),
  onRetry, onOperatingKChange, className and the div's native props;
  forwardRef to the card, spread the rest on the root.
- Export the maths next to the component so a test can print the numbers the
  picture is made of: buildRetrievalRecallModel(), cutAt(), nearestStop(),
  leaderAt(), thinTicks(), precisionCeiling(), largestRemainderPercents().

Behavior
- TWO STACKED PANELS, ONE X AXIS: recall@k on top, precision@k below, same
  log-k scale, one colour + dash pattern per retriever across both. They are
  the two halves of one trade-off, so they are never a view toggle.
- Retrievers need NOT share a k grid. The axis and the picker are the UNION of
  every measured k; a retriever with no point at some k reads "not measured at
  k = N". Never interpolate: "recall at k = 12.5" is not a number anybody ran.
- INTERACTION IS A RADIOGROUP over the measured k, not a slider drag: k is a
  handful of discrete cuts. Arrows move and select, Home/End jump, roving
  tabIndex, aria-checked, and the chip for operatingK carries a marker so the
  shipped cut stays identifiable while another one is inspected. The pick is
  internal state seeded from operatingK and dropped whenever it stops being a
  measured stop; onOperatingKChange reports it outward.
- ONE MARK, ONE NUMBER: the vertical rule sits at toX(activeK) and each dot at
  toY of the very recall/precision the row below prints. The selected k keeps
  its axis label in foreground weight even when neighbouring labels are thinned
  away for space.
- Derived reads, all recomputable by hand from the payload: chunks that earned
  their tokens (precision x k), context spend (k x chunkTokens), the delta and
  token cost of widening to the next measured k, the smallest k within 5% of
  that retriever's own recall ceiling, and which retriever holds the highest
  recall at how many of the measured k (a strict tie crowns nobody).
- COVERAGE BAR per retriever: of every relevant chunk, the share inside the
  current k, the share reachable only by widening to that retriever's ceiling
  k, and the share still missing there. Three parts of one whole, so they are
  apportioned by LARGEST REMAINDER — floor each, hand the leftover units to the
  biggest fractional parts — and the segments are sized by those very integers,
  so the bar and the caption can never disagree and the row always totals 100.
- Degenerate input is named, never swallowed: k below 1, a rate outside 0..1, a
  non-finite value or a repeat of a k already taken is dropped and counted; a
  retriever with no points keeps its row reading "no k measured"; recall@k that
  FALLS as k grows is impossible, so the steps are counted and called a
  mis-scored evaluation; an operatingK nobody measured snaps to the nearest
  measured cut and says so.
- Four first-class branches of one card. status "ready" with nothing drawable
  renders the empty branch, whose copy explains what a k sweep needs instead of
  reading like a failed fetch.
- CLEANUP: one ResizeObserver measuring the plot column so SVG user units are
  CSS pixels, disconnected on unmount and whenever the node changes. No timers,
  no rAF, no pointer listeners.

Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground panel, border for
  gridlines, muted for skeletons and the bar track, muted-foreground for axis
  text, foreground for the inspected rule, foreground/40 for the "only by
  widening" segment (a token that keeps its contrast in BOTH themes, unlike a
  low-alpha chart colour), primary for the selected chip and the leader badge,
  and var(--chart-1..5) cycling per retriever. Never a hex.
- COLOUR IS NEVER THE ONLY CHANNEL: each retriever also owns a dash pattern
  (solid, 7 4, 2 3, 11 3 2 3, 1 3) repeated in its readout swatch.
- Log x axis, because k = 1, 3, 5, 10 would otherwise share the leftmost tenth
  of the frame — and that head is where recall is bought cheapest. Labels are
  thinned to a minimum pixel gap, with the inspected and shipped k pinned so
  they survive the thinning.
- Recall's axis runs a fixed 0..1 (the ceiling is meaningful); precision's runs
  0..the busiest cut rounded up to a tenth, so a curve that decays to 0.02 is
  still legible. Explicit "en-US" Intl formats: three decimals on rates, two on
  axis ticks, one on "chunks relevant", grouped integers on tokens.
- ACCESSIBILITY: role="img" on the svg with a one-sentence aria-label (nothing
  inside the plot is focusable — the picker is real HTML below it); the
  radiogroup and its radios carry aria-label / aria-checked; each coverage bar
  is a role="img" with its three percentages spelled out; an sr-only WRAPPER
  DIV (never sr-only on the table itself) holds up to 12 rank-spaced cuts per
  retriever with k, recall, precision and MRR.
- Motion: the only animation is the skeleton pulse and the chip colour
  transition, both carrying motion-reduce variants.

Customization levers
- panelHeight: 64 for a dashboard tile, 160 for a report page. The two panels
  always match — differing heights would make the flatter metric look steadier.
- Metrics: swap precision@k for nDCG@k or hit-rate@k by renaming the lower
  panel's accessor; keep the shared x axis and the single k rule, which is what
  makes the two panels one reading.
- Token maths: chunkTokens turns k into context spend. Multiply by your input
  price per million tokens for a per-query cost line, or divide the context
  window by (k x chunkTokens) to show how many turns of history survive.
- Saturation: SATURATION_FRACTION (0.95) sets when the card calls recall done.
  State it wherever it is printed — an unstated knee is an opinion.
- Density: drop the coverage bars for a compact card, or keep only the leader's
  row; drop the footnote paragraph if the audience already speaks recall@k.
- Palette: re-point CURVE_INK at brand tokens but keep the dash ladder — it is
  the channel that survives a greyscale print.
- More retrievers: past five the token cycle repeats, so pair the extra rows
  with distinct dashes or split the card by index family instead.

Concepts

  • A k sweep is not a threshold sweep — a ranker has no cutoff to move; it returns an ordering, and the only knob left is where to cut it. That is why the independent variable here is a count of documents, why the axis is logarithmic, and why every cut is a discrete measured stop rather than a point you can slide between. A precision-recall curve answers a different question with the same two words.
  • Both metrics at one k, or neither — recall@k can only rise as k grows and precision@k almost always falls, so quoting either alone is free. The two panels share one x axis and one vertical rule so the reader cannot accidentally compare a recall at k = 50 with a precision at k = 5.
  • The chosen k is priced, not just markedk × chunkTokens is the context every query spends, and (1 − precision@k) × k of those chunks are padding. Widening k is a purchase: the card prints the recall it buys and the tokens it costs at the very next measured cut, which is the decision an ML engineer is actually making.
  • MRR@k truncates, and says so — mean reciprocal rank counts a query as zero when its first relevant chunk falls past k, which is why MRR@k climbs with k and then flattens. A retriever that never reported it prints "not reported"; a fabricated zero would claim its first hit is never in the list.
  • Percentages that partition a whole are apportioned, not rounded — the coverage bar splits every relevant chunk into found inside k, found only by widening, and still missing, then hands out whole percentages by largest remainder. The segments are sized by those same integers, so the bar always totals exactly 100 at every k the reader inspects — never the 99 or 101 that per-slice rounding prints.

On This Page