Ask anything about React and get answers grounded in the official docs, with citations.
A GenAI chat app built on a RAG pipeline over the react.dev documentation. Answers stream in token by token, and every claim traces back to the page it came from.
🎯 Grounded answers. The model only ever sees the retrieved extracts. When those don't cover the question it replies I don't know based on the documents. rather than filling the gap.
🔗 Citations come from metadata. The sources event is built from the retrieval hits and sent before the first token, and the prompt tells the model not to cite in prose. Attribution can't drift from what was actually retrieved.
🧩 Headings go inside the embedding. Every chunk carries Title > Section > Subsection in its own text, since embeddings never see vector-store metadata.
🧼 The MDX cleaner is fence-aware. react.dev isn't plain markdown. Cleaning strips the doc-site chrome and leaves the 297 JSX examples inside code fences alone.
⚡ Streaming the whole way through. SSE from OpenRouter, through FastAPI, into a typed parser written by hand in the browser.
Prerequisites: Python 3.13 with uv, Node.js 20+, and an OpenRouter API key.
echo "OPENROUTER_API_KEY=sk-or-..." > api/.env
make setup # fetch the docs, embed them, install the hook (~1.5¢ in embedding calls)
make dev # API on :8000, web on :3000Open http://localhost:3000 and ask something.
Note
make setup costs money because it calls an embeddings API. Re-run it only when the corpus changes, or when clean.py / chunker.py change how text is prepared.
| Command | What it does |
|---|---|
make dev |
Runs the API (uvicorn, reload) and the web dev server together |
make fetch |
Re-downloads react.dev's docs into api/corpus/ |
make ingest |
Cleans, chunks and embeds api/corpus/ into api/chroma/ |
make hooks |
Points core.hooksPath at .githooks/ (see Tests) |
make setup |
fetch + ingest + hooks, for a fresh clone |
make test |
pytest + ruff in api/, vitest in web/ |
Tip
Ingestion is idempotent: chunk ids are <corpus-relative-path>:<index>, written with upsert. Shrinking a source file does leave its orphaned tail chunks behind, so delete api/chroma/ when you want a guaranteed-clean rebuild.
INGEST ─────────────────────────────────────────────────────────────
scripts/fetch-corpus.sh react.dev/src/content ──▶ api/corpus/*.md
│
rag/clean.py MDX ──▶ plain markdown
│ frontmatter titles · chrome components
│ heading anchors · relative links
│
rag/chunker.py split on headings, then ~400 chars
│ heading prepended into the chunk text
│
rag/embeddings.py openai/text-embedding-3-small
│
rag/store.py upsert ──▶ Chroma "docs" (cosine) @ api/chroma/
QUERY ──────────────────────────────────────────────────────────────
POST /chat/stream rag.store.search ──▶ top-k chunks
│ rag.answer.build_prompt ──▶ <context> block
│ claude-haiku-4.5, streamed
▼
SSE sources ──▶ token* ──▶ done │ error
│
web/lib/chat-stream.ts typed parser ──▶ use-chat.ts ──▶ app/page.tsx
📁 Project structure
api/ FastAPI service + ingestion pipeline (Python 3.13, uv)
rag/
main.py API: /health and the /chat/stream SSE endpoint
answer.py system prompt + <context> prompt builder
ingest.py CLI entrypoint: walk a folder, ingest every .md
store.py Chroma collection, ingest() and search()
chunker.py header-aware + recursive splitting
clean.py react.dev MDX -> plain markdown
embeddings.py OpenRouter embeddings client
scripts/
fetch-corpus.sh downloads react.dev's src/content
corpus/ fetched markdown (gitignored, ~210 files)
chroma/ persisted vector store (gitignored)
tests/ pytest, covering cleaning and chunking behaviour
web/ Next.js 16 + React 19 + Tailwind v4 chat UI
app/page.tsx the chat page
lib/chat-stream.ts SSE parser, typed events
lib/use-chat.ts streaming chat state (send / retry / stop / reset)
lib/react-dev-url.ts corpus path -> react.dev URL
components/ shadcn/ui primitives + ai-elements chat components
docs/ demo recording + poster frame
.githooks/ pre-commit: lints and tests what the commit touched
Makefile dev / fetch / ingest / hooks / setup / test
Base URL http://localhost:8000. CORS is open to http://localhost:3000 only.
{ "ok": true, "chunks": 10444 }Responds with text/event-stream:
| Event | Data | When |
|---|---|---|
sources |
Array of hits: text, source, position, distance |
Once, before any token |
token |
One delta of the answer | Repeatedly |
error |
A safe message; the real error is logged server-side | On stream failure |
done |
[DONE] |
Always last |
curl -N http://localhost:8000/chat/stream \
-H 'Content-Type: application/json' \
-d '{"question": "What is a key in a list?", "k": 4}'Important
The catch-all around the LLM stream in rag/main.py is deliberate. This is the stream boundary, and anything that escapes it truncates the SSE response with no error event, so the client just sees the stream stop.
| Variable | Where | Purpose |
|---|---|---|
OPENROUTER_API_KEY |
api/.env (gitignored) |
Required. Used for embeddings and for chat completions |
NEXT_PUBLIC_API_BASE_URL |
web/.env.local |
API origin; defaults to http://localhost:8000 |
Models and retrieval knobs live in code, all of them in rag/constants.py:
| Setting | Value | Constant |
|---|---|---|
| Embedding model | openai/text-embedding-3-small |
EMBED_MODEL |
| Chat model | anthropic/claude-haiku-4.5 |
CHAT_MODEL |
| Max output tokens | 800 |
MAX_OUTPUT_TOKENS |
| Chunk size / overlap | 400 / 50 chars |
CHUNK_SIZE, CHUNK_OVERLAP |
Default k |
4 (API), 3 (search()) |
CHAT_K, DEFAULT_K |
| Distance metric | cosine | DISTANCE_SPACE |
react.dev is MDX, not markdown. Ingesting it naively degrades retrieval in ways that are easy to miss, which is what most of rag/clean.py exists to prevent.
Titles live in frontmatter, not # headings
Only 2 of 222 files have an h1. Unless the frontmatter title is promoted into one, the splitter's title slot comes back empty and chunks lose the single word most queries are about. DOM component pages key on the tag name (meta: "<meta>") instead of title, so there's a first-key fallback.
Doc-site components are stripped, code is not
297 distinct capitalised tags appear inside code fences as React examples. Only a closed list of chrome components (<Note>, <Pitfall>, <Sandpack>, …) gets removed, and only outside fences. The tag is dropped but the body it wraps stays, because that body is usually the caveat people are asking about.
Relative links are stripped
[useState](/reference/react/useState) resolves against react.dev, not against this app. The label is kept and the target dropped. Attribution comes from the sources panel instead, which is built from stored metadata rather than generated text.
Hidden and presentational fences are dropped
Sandpack scaffolding files and Sandpack CSS are hidden from react.dev's own readers, so they stay out of the index too. Fence matching is length-aware, since the four-backtick fence in the React 19 blog post desyncs a naive three-backtick matcher.
Two smaller choices in the same area:
fetch-corpus.shdropscommunity/anderrors/before anything is indexed. One is team bios and meetup listings, the other is three copies of the same boilerplate, and neither is reference material.- Chunk ids use the corpus-relative path rather than the basename, because react.dev has nine
index.mdfiles plus five other duplicated basenames. On bare filenames they would collide and silently overwrite each other.
make test # both suites: pytest + ruff in api/, vitest in web/Neither suite needs an API key or the network, and together they run in a few seconds.
api/tests/test_clean.py covers the cleaning rules above: frontmatter titles, anchor stripping, chrome removal, and the fence handling that keeps JSX examples intact. web/ uses vitest + Testing Library over the SSE parser and the chat components.
make hooks (included in make setup) points core.hooksPath at .githooks/. The hook checks only the project the commit actually touched — an api/-only commit never starts npm, a web/-only commit never starts Python:
| Staged | Runs |
|---|---|
api/** |
ruff check + ruff format --check on staged files, then pytest |
web/** |
eslint on staged files, then tsc --noEmit and vitest |
Linters run on the staged files only; the suites are whole-project by nature, since a shared change breaks tests in files it never touched.
Warning
The checks read the working tree, not the index. A partially staged commit (git add -p) is checked as the files currently read on disk, so a hook pass is not a guarantee about what actually lands. It's fast local feedback, not a gate — it's opt-in per clone and --no-verify walks past it.
Things left undone on purpose, and why.
Retrieval is untuned. No reranking, no hybrid search, no tuned k or chunk size. Not an oversight: there is no eval set here, so I had no way to know whether a change helped. Tuning on the strength of a few eyeballed answers is guessing with extra steps. An eval set comes first, and then the knobs are worth turning.
One replica only. Chroma's PersistentClient writes to a local directory, so the API cannot be scaled horizontally as it stands. Fine for a single-box demo; a hosted vector store is the swap if it ever needs more.
No auth on /chat/stream. The endpoint spends money on every call and anyone who can reach it can spend it. It is bound to localhost with CORS locked to :3000, which is enough for local use and not close to enough for a public deployment. Auth plus a per-caller rate limit is the minimum before it goes anywhere.
No multi-turn memory. Each question is retrieved and answered on its own, so follow-ups like "why?" or "what about in a class component?" lose the thread. Fixing it properly means rewriting the follow-up into a standalone query before retrieval, which is its own retrieval problem.
Ingestion has no delete pass. upsert keyed on <path>:<index> means a source file that shrinks leaves its orphaned tail chunks in the index. Documented as "delete api/chroma/" rather than solved.
This project's code is MIT licensed.
The corpus under api/corpus/ is fetched from reactjs/react.dev and is not part of this repository. React's documentation is licensed CC BY 4.0, and any answer this app produces is derived from it.
{ "question": "How do I reset state when a prop changes?", "k": 4 }