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.
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.
- nba_api + basketball-ref
- Postgres — players / games / pbp
- Top 30 deep dive · authored summaries
- Articles · Reddit · scouting
- Postgres + pgvector (voyage-3-large)
- Contextual retrieval prefix
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.
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.
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.
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.
Stats ingestion
src/ingest_stats ↗nba_apiis 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.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.Prose ingestion
src/ingest_prose ↗Articles and Reddit threads scraped politely (robots.txt, no OAuth — Reddit's public
.jsonendpoints 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.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".
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.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.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.
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.
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.
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.
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.
- 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.