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.
Installation
npx shadcn@latest add https://craftbits.dev/r/hybrid-search-viz.jsonUsage
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
- 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.
- Phase-driven reveal. In
exploremode, 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 todoneafter ~2.5s so the formula stays legible. - Per-contribution math. Each fused row breaks its score into two
ContribBarwidgets — one per retriever — sized to the doc's1/(k+rank)slice of the maximum possible contribution1/(k+1). Their sum is rendered below, matching the formula above. - Predict slots. The third column flips to a slot grid in
predictmode. 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. - 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
| Prop | Type | Default | Description |
|---|---|---|---|
docs | readonly HybridSearchVizDoc[] | 12-doc default corpus | Document corpus referenced by every ranked list. |
queries | readonly HybridSearchVizQuery[] | 3 default queries | Preset queries surfaced as pills in explore and predict. |
challengeRounds | readonly HybridSearchVizChallengeRound[] | 4 rounds | MCQ rounds in challenge mode. |
rrfK | number | 60 | RRF dampening constant. Larger values compress rank differences. |
topK | number | 5 | Fused top-K shown and the number of predict slots. |
defaultMode | HybridSearchVizMode | "explore" | Mode visible on first render. |
transition | Transition | SPRINGS.snap | Override the spring for ranked-row entrances and AnimatePresence transitions. |
onModeChange | (mode) => void | — | Fires when the active mode changes. |
onModeComplete | (score) => void | — | Fires when the learner finishes a predict or challenge run. |
className | string | — | Merged onto the root via cn(). |
Accessibility
- The mode strip is a
role="group"witharia-pressedon 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-pressedfor the selected state and anaria-labelthat 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-labeldescribes 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 theWidgetchrome (eyebrow + formula + undo/redo), drove its phase machine throughuseWidgetHistory, and consumedModeStrip/ChallengeBtn/FeedbackBadge/ScoreDotsfrom the lesson'sConstructionPrimitivesmodule. The viz extract drops the Widget chrome (the eyebrow + undo/redo + formula bar are the lesson's framing, not the viz), inlines a token-styledModeButton/PrimaryButton/SecondaryButton/FeedbackBadge/ScoreDotsso the component has no project-specific dependencies, and re-keys every spring (SPRINGS.snappy/SPRINGS.gentle) to the canonicalSPRINGS.snap/SPRINGS.smoothfrom@craft-bits/core/motion. Colours are remapped tovar(--cb-warning)/var(--cb-accent)/var(--cb-success)/var(--cb-error)/var(--cb-fg-*)so consumer themes repaint freely.STAGGER.tight/STAGGER.normalreferences are replaced by the canonicalSTAGGERconstant. The previously hard-coded corpus + queries + challenge rounds are exposed asdocs/queries/challengeRoundsprops withrrfKandtopKfor the fusion math.