How I Built This

A hybrid stats + scouting RAG system for the 2025-26 NBA season, built around a single interesting problem: deciding whether a question wants a SQL answer, a prose answer, or both.

TL;DR

The Ball Knowledge Oracle covers the entire 2025-26 NBA season in three tiers: full season stats for every active player, deep scouting analysis on the top 30, and live-updating playoff data through the Finals. A Claude Sonnet 4.5 query router decides whether a question wants numbers, prose, or both, dispatches the retrieval, and a Claude Opus 4.6 synthesis model answers with cited paragraphs and the exact SQL that ran.

Below: the architecture, the three tiers of scope, the order I built things in, what turned out to be interesting (and what turned out to be the unsexy gold), and the tech stack.

Architecture
Data LayerBuilt offline · stats refreshed daily, playoff updates live
Stats pipelineplayer_id-keyed numerics
  • nba_api + basketball-ref
  • Postgres — players / games / pbp
  • Top 30 deep dive · authored summaries
Prose pipelinevector index · pgvector
  • Articles · Reddit · scouting
  • Postgres + pgvector (voyage-3-large)
  • Contextual retrieval prefix
Entity Normalizer· 1,525 aliases · 'the Chef' → 201939
per question
Query LayerOnline · one DAG per user question
User Question
received from the chat input
Query Router
Claude Sonnet 4.5 · classifies stats / prose / hybrid
Stats Path
Text-to-SQL → Postgres
ex: Jokić clutch TS%
Prose Path
Vector search + Cohere Rerank
ex: how scouts grade Wemby's defense
Hybrid Path
SQL filter → Vector
ex: guards >40% 3PT, praised off-ball
Synthesis
Claude Opus 4.6 · grounds answer in retrieved sources
Answer + Sources
cited paragraphs · SQL query · linked chunks

Every question enters at the top. The router picks one of three paths, the chosen retrieval feeds the synthesis model, and the answer comes out with its cited sources. The trace sidebar shows each step in real time.

Three tiers of depth
Tier 1≈ 500

All active players

Full season stats, game logs, biographical data in Postgres. Nothing fancy, just complete coverage so a stats question about any active player has data to chew on.

Tier 230 players

Top 30 deep dive

Advanced splits — clutch, on/off, lineup data, shot charts — plus hand-picked scouting articles and a Claude-authored 500-word scouting summary per player.

Tier 3daily refresh

Playoffs (live)

The playoffs are happening as the demo runs. A daily refresh pulls new game data and articles; time-aware retrieval boosts recent content so a question asked today sees today's evidence.

Build order
  1. Stats ingestion

    src/ingest_stats ↗

    nba_api is the Python wrapper around the official stats.nba.com endpoints — player game logs, advanced stats, play-by-play, lineup data. A daily APScheduler job inside the FastAPI server refreshes the live scoreboard and the next 14 days of upcoming games so the demo stays current through the Finals.

  2. Stats schema in Postgres

    schema.sql ↗

    Players, games, player_game_stats, and play_by_play (top 30 only, because pbp explodes in row count). The interesting field is is_clutch_data — a flag that lets clutch-specific questions narrow to the clutch sub-table without scanning the whole season.

  3. Prose ingestion

    src/ingest_prose ↗

    Articles and Reddit threads scraped politely (robots.txt, no OAuth — Reddit's public .json endpoints are enough). Aim was ~500-1000 chunks tagged with date, mentioned players, and source. Today's corpus has 197 Reddit threads (257 chunks) covering r/nba and team subs.

  4. Entity normalization

    unsexy goldentities.py ↗

    Every name variant resolves to a canonical player_id: "Steph", "Curry", "the Chef", "Wardell" all map to 201939. The map has 1,525 aliases today; 90%+ coverage of the active roster, 100% on the top 30. Every scraped chunk runs through this normalizer before embedding so a player filter at retrieval time is a single integer match instead of a fuzzy string game.

    Why "unsexy gold": entity linking is a real production problem every sports data company solves. Pointing at this in interviews signals "I've shipped data systems", not "I followed a RAG tutorial".

  5. Embed + index

    embedder.py ↗

    Chunks are 400 tokens with 15% overlap. Voyage voyage-3-large (1024-dim) embeddings, stored in the same Postgres DB via pgvector. Contextual-retrieval prefix ("Article from {source}, {date}, about {players}: …") is prepended before embedding for a 35-50% recall lift on hard queries.

  6. Top 30 deep dive

    authored_summaries.py ↗

    For each top-30 player, a one-time enrichment job pulls every chunk mentioning them and asks Claude Opus to write a 500-word scouting summary covering strengths, weaknesses, playoff performance, and trends. Stored as a single chunk with article_type = 'authored_summary' and a higher retrieval prior so the synthesis layer gets a strong baseline on Tier-2 players.

  7. Query router

    the heartrouter/classifier.py ↗

    The heart of the project. A Sonnet 4.5 classifier with few-shot examples reads the question and returns {route, reasoning} . stats → text-to-SQL on Postgres. prose → vector search with Cohere Rerank. hybrid → SQL filter to narrow the player set, then vector search inside that set. The reasoning string surfaces in the trace sidebar so the decision is auditable.

    A follow-up turn passes a short conversation history into the hybrid SQL prompt so pronouns like "him" can resolve against the previous answer.

  8. Synthesis with visible tool use

    src/synthesize ↗

    Retrieved stats and chunks go to Claude Opus 4.6. The key UX move: the UI shows exactly what happened — the SQL that ran, the chunks that came back, the router's reasoning, and the model's cited paragraphs. Visible tool use is what makes the demo legible.

  9. Eval set with Braintrust

    src/eval ↗

    30 test queries, stratified: 10 stats, 10 prose, 10 hybrid. Each case has an expected route, expected SQL or chunk ids, and a model-graded answer rubric. Router accuracy, SQL correctness, and retrieval recall are tracked separately so a regression in one path doesn't hide behind another's wins.

  10. The UI

    ui/ ↗

    Next.js 15 App Router, Tailwind, framer-motion, deployed locally for the demo. The visible-tool-use sidebar shows router decision, SQL panel, retrieved chunks, citations. A live RAG pipeline banner animates per stage on every turn, and copy buttons land question / answer / trace on the clipboard for follow-up workflows.

What's interesting

Query routing as the heart

Most RAG demos handle one side of a split: structured numbers OR unstructured prose. The interesting move is letting the model decide which side each question belongs on — and then handling the questions that want both. The router's reasoning is surfaced in the trace so the decision is auditable.

Entity normalization, the unsexy gold

Every sports data team writes this code eventually. 1,525 aliases mapping 'the Chef' and 'Wardell' to 201939 means a player filter is a single integer match — pgvector's GIN index on player_ids[] turns 'guards praised for off-ball' into one fast query.

Visible tool use

The trace sidebar shows the SQL that ran, the chunks that came back, the router's reasoning, and the synthesis model's citations. That visibility is the difference between 'AI black box' and 'system you can debug'.

The conversation context fix

Follow-ups with pronouns ('compare him to...') used to fail because the SQL filter saw only the current question. Now the UI passes the last two completed turns as history, the model resolves the pronoun against the prior answer, and a fallback path runs unfiltered prose retrieval if the SQL still fails.

Tech stack
  • Python3.11+ with uv for dependency management
  • DatabasePostgres 16 + pgvector (local docker compose)
  • Statsnba_api · basketball-reference scraping fallback
  • ProseReddit public .json + scraped articles · respect robots.txt
  • EmbeddingsVoyage voyage-3-large (1024-dim)
  • RerankCohere Rerank 3.5
  • ModelsSonnet 4.5 (router, text-to-SQL) · Opus 4.6 (synthesis) · Haiku 4.5 (pre-checks)
  • BackendFastAPI · APScheduler · psycopg
  • ObservabilityBraintrust traces + eval
  • UINext.js 15 (App Router) · Tailwind · framer-motion

Note: docker-compose.yml ships with POSTGRES_PASSWORD=nbarag as a local-only dev placeholder that matches .env.example. The container is bound to localhost and isn't exposed beyond the developer machine. Real credentials live in .env, which is gitignored and never enters the repo.

  • Expand the eval set to 100 cases across all three routes so the regression signal is stronger before each model upgrade.
  • Add a "compare to last season" hybrid path so questions about year-over-year trends get a dedicated SQL template instead of a one-off prompt.
  • Stream the synthesis answer token-by-token in the UI instead of rendering it at the end so the pipeline banner and the response feel like one continuous animation.
Read the code on GitHub↗