Hybrid Search Viz

An interactive visualisation of Reciprocal Rank Fusion — the algorithm that combines a keyword retriever (BM25) and a semantic retriever (Dense embeddings) into a single ranked list. The RRF formula is unusually simple:

RRF(d) = Σ 1 / (k + rank_i(d))     with k = 60

— but the consequences (consensus wins, single-source docs lose, rank deltas compress) are not obvious. Three modes drive the lesson: Explore walks the merge phase by phase — pick a preset query, watch BM25 retrieve, advance to Dense, then watch RRF score each doc with a per-contribution bar. Predict asks the learner to order the fused top-K by placing candidate chips into numbered slots, then reveals the actual ranking with a per-slot breakdown. Challenge runs MCQ rounds that test rank-fusion intuition (consensus on #1, harmonic middle wins, single-source #1 fails, rank compression).

Select a query to compare BM25, dense, and hybrid retrieval.
query
BM25 (keyword)

Select a query to compare BM25, dense, and hybrid retrieval.

Customize
Fusion math
60
5
Mode
explore

Installation

npx shadcn@latest add https://craftbits.dev/r/hybrid-search-viz.json

Usage

import { HybridSearchViz } from "@craft-bits/viz/hybrid-search-viz";
 
<HybridSearchViz />

Override the RRF dampening constant for a more or less rank-compressed score curve:

<HybridSearchViz rrfK={30} />

Subscribe to round results:

<HybridSearchViz
  onModeComplete={({ mode, correct, total }) => {
    /* lift the score into a sibling progress bar */
  }}
/>

Understanding the component

  1. Three columns, three retrievers. The grid lays out BM25, Dense, and Fused (RRF) side-by-side. Each ranked row shows the doc name, its rank badge, its score, and (for BM25/Dense) a two-dot indicator when the doc also appears in the other list.
  2. Phase-driven reveal. In explore mode, the BM25 column fills first, then the Dense column header becomes the advance affordance, and finally the Fused column animates each row in with a per-doc contribution bar. The fusing phase auto-advances to done after ~2.5s so the formula stays legible.
  3. Per-contribution math. Each fused row breaks its score into two ContribBar widgets — one per retriever — sized to the doc's 1/(k+rank) slice of the maximum possible contribution 1/(k+1). Their sum is rendered below, matching the formula above.
  4. Predict slots. The third column flips to a slot grid in predict mode. Tapping a candidate chip then a slot fills the slot. The Check button is gated until every slot is filled. Reveal animates the per-slot breakdown in four stages: position-correctness border, actual rank annotation, source pills + contribution bars, then the final score banner.
  5. Reduced motion. Under prefers-reduced-motion: reduce, every entrance and the fusing auto-advance collapse to a single snap, the chip tap-scale disables, and the per-stage reveal delays compress to 50ms increments.

Props

PropTypeDefaultDescription
docsreadonly HybridSearchVizDoc[]12-doc default corpusDocument corpus referenced by every ranked list.
queriesreadonly HybridSearchVizQuery[]3 default queriesPreset queries surfaced as pills in explore and predict.
challengeRoundsreadonly HybridSearchVizChallengeRound[]4 roundsMCQ rounds in challenge mode.
rrfKnumber60RRF dampening constant. Larger values compress rank differences.
topKnumber5Fused top-K shown and the number of predict slots.
defaultModeHybridSearchVizMode"explore"Mode visible on first render.
transitionTransitionSPRINGS.snapOverride the spring for ranked-row entrances and AnimatePresence transitions.
onModeChange(mode) => voidFires when the active mode changes.
onModeComplete(score) => voidFires when the learner finishes a predict or challenge run.
classNamestringMerged onto the root via cn().

Accessibility

  • The mode strip is a role="group" with aria-pressed on each mode button, so screen-reader users can announce and switch modes via keyboard.
  • A polite live region carries the narration so assistive-tech users hear which phase the explore walk is in, which round of predict / challenge they're on, and the per-doc explanation.
  • Candidate chips expose aria-pressed for the selected state and an aria-label that announces "in both BM25 and Dense" / "BM25 only" / "Dense only" so the dual-dot colour signal is never the only cue.
  • Each slot's aria-label describes its rank and (after reveal) whether the placement was correct, the actual rank, and the doc name.
  • Challenge options use aria-pressed, gain a coloured border + background after answering, and pair the correct/wrong shading with a feedback badge so colour is never the only signal.
  • Every interactive control has a ≥ 36×36px hit area.
  • Motion respects prefers-reduced-motion: reduce — the pulsing column-header animation, the chip tap-scale, the slot pulse on placement, the per-stage predict reveal staircase, and the fusing auto-advance all collapse or disable.

Credits

  • Extracted from: craftingattention (app/src/lessons/primitives/systems/HybridSearchViz.tsx). The source was a lesson primitive wrapped in the Widget chrome (eyebrow + formula + undo/redo), drove its phase machine through useWidgetHistory, and consumed ModeStrip / ChallengeBtn / FeedbackBadge / ScoreDots from the lesson's ConstructionPrimitives module. The viz extract drops the Widget chrome (the eyebrow + undo/redo + formula bar are the lesson's framing, not the viz), inlines a token-styled ModeButton / PrimaryButton / SecondaryButton / FeedbackBadge / ScoreDots so the component has no project-specific dependencies, and re-keys every spring (SPRINGS.snappy / SPRINGS.gentle) to the canonical SPRINGS.snap / SPRINGS.smooth from @craft-bits/core/motion. Colours are remapped to var(--cb-warning) / var(--cb-accent) / var(--cb-success) / var(--cb-error) / var(--cb-fg-*) so consumer themes repaint freely. STAGGER.tight / STAGGER.normal references are replaced by the canonical STAGGER constant. The previously hard-coded corpus + queries + challenge rounds are exposed as docs / queries / challengeRounds props with rrfK and topK for the fusion math.