Grounded RAG troubleshooting for any bike. Pick a bike, drop in its Owner's Manual / Service Manual / User Guide (or auto-fetch from the OEM), then chat with citations, voice (Sarvam Saaras + Bulbul), and image diagnosis (Gemini Flash). UI inspired by CRED Garage — a rotating 3D model anchors a glassmorphic chat surface.
Disclaimer: Not affiliated with any manufacturer. For informational use only — consult an authorised service centre for repairs. PDFs are downloaded at ingest-time and never redistributed by this project.
- Multi-bike corpus keyed by
bike_id— single Qdrant collection with per-bike payload filtering. Seeded with the top-selling Indian bikes (Himalayan, Classic 350, Pulsar 150, Apache RTR, Activa 6G, Splendor Plus, R15, FZ, Duke 200, Chetak) — extend inbackend/app/bikes/registry.py. - Two onboarding paths per bike:
- Drag-and-drop PDF upload, one slot per doc type.
- Auto-fetch from the OEM URL list, with HEAD-derived size + reachability previews before ingest.
- CRED-style frontend — dark canvas, animated aurora gradient, frosted-
glass panels, rotating 3D bike model via Google
<model-viewer>(or a static hero image fallback). - PDF.js citation deep-link — every citation chip opens a modal scrolled to the right page with a best-effort bbox highlight.
- Hinglish code-switch — STT returns
language_code, threaded into both the chat call and the next TTS call so the Bulbul speaker auto-matches. - Ragas eval harness — 25 hand-authored Q/A pairs, faithfulness gate at
0.85.
make eval.
flowchart TB
subgraph CLIENT["Client · Vercel / Next.js"]
UI[Garage UI<br/>chat · upload · mic · image]
STORE[(localStorage<br/>activeBike · langPref · messagesByBike)]
UI <--> STORE
end
subgraph API["API · FastAPI on uvicorn"]
ROUTES["/bikes /chat /chat/stream<br/>/voice/* · /pdf/* · /bikes/{id}/upload"]
DETECT["Lang detect<br/>Devanagari + Roman-Hindi cues"]
GUARD["Guardrails<br/>verbatim spec check · severity floor<br/>retrieval gate 0.35"]
ROUTES --> DETECT --> GUARD
end
subgraph INGEST["Ingest pipeline (one-time + on upload)"]
PDF[OEM PDF / upload] --> DOCLING[Docling<br/>page index + markdown tree]
DOCLING --> CHUNK[Header-aware chunker<br/>800/120 + parent/child]
CHUNK --> EMB1[fastembed bge-small<br/>384 dim, local ONNX]
EMB1 --> QD[(Qdrant<br/>single collection<br/>payload-indexed on bike_id)]
end
subgraph RETRIEVE["Retrieval"]
Q[Query] --> BM25[BM25<br/>per-bike cache]
Q --> EMB2[fastembed embed]
EMB2 --> DENSE[Qdrant dense search]
BM25 --> RRF[RRF k=60]
DENSE --> RRF
RRF --> RERANK{Cohere<br/>key set?}
RERANK -- yes --> COHERE[Rerank top-5]
RERANK -- no --> TOP5[RRF top-5]
COHERE --> PROMOTE[Promote parent chunks]
TOP5 --> PROMOTE
end
subgraph LLM["LLM"]
SARVAM["Sarvam-M<br/>response_format=json_object<br/>★ Hinglish primary"]
OLLAMA[Ollama qwen2.5:3b<br/>format=json · offline fallback]
SARVAM -. on HTTPError .-> OLLAMA
end
subgraph SARVAM_VOICE["Sarvam voice"]
STT[Saaras v3 STT<br/>★ returns language_code]
TTS[Bulbul v3 TTS<br/>★ speaker auto-picked by lang]
end
GEMINI[Gemini Flash<br/>image diagnosis JSON]
UI -- HTTPS --> ROUTES
UI -- mic blob --> STT
STT -- transcript + lang --> UI
UI -- image base64 --> ROUTES
ROUTES -. when image .-> GEMINI
GUARD --> RETRIEVE
PROMOTE --> LLM
LLM --> ROUTES
ROUTES -- text --> UI
UI -- text + lang --> TTS
TTS -- audio --> UI
INGEST --> RETRIEVE
classDef sarvam fill:#1a1d33,stroke:#a78bfa,color:#fff
class SARVAM,STT,TTS,SARVAM_VOICE sarvam
★ marks the three Sarvam touch-points. See Why Sarvam below for the reasoning.
- Frontend: Next.js 16, Tailwind v4, Framer Motion, Zustand (
persist→ localStorage), react-pdf, Google<model-viewer>, react-dropzone. - Backend: FastAPI. Endpoints under
/bikes/*for registry, status, upload, OEM auto-fetch./chat,/chat/stream,/voice/*threadbike_idandlanguage_codeend-to-end. - Ingest: Docling → per-item page index → header-aware markdown chunker (800/120 + breadcrumb + parent/child) → fastembed (
bge-small-en-v1.5, 384 dim, local ONNX) → Qdrant with payload indexes onbike_id,doc_type. - Retrieval: BM25 (per-bike cached
BM25Okapi) + dense → RRF k=60 → top-5, parent chunk promoted to LLM context. Optional Cohere Rerank whenCOHERE_API_KEYis set. - LLM: Sarvam-M primary via
response_format=json_object; Ollamaqwen2.5:3bas offline fallback (no key needed for local dev). - Guardrails: structured JSON schema, retrieval gate at 0.35, verbatim regex validation of part numbers / torque against retrieved chunks, deterministic severity floor (
STOP_RIDINGkeywords can't be downgraded),bike_id-scoped empty-corpus refusal.
Sarvam shows up in three places, each chosen because the alternative we tried failed an explicit demo requirement:
Decision: call api.sarvam.ai/v1/chat/completions with model="sarvam-m", response_format=json_object.
What we tested against it. We ran the same Hinglish prompts through qwen2.5:3b (Ollama, local) and GPT-4o-mini. With a directive "reply in Hinglish if the user wrote Hinglish" — even when restated as the last line of the user message — qwen2.5:3b consistently replied in English. GPT-4o-mini replied in transliterated Hindi but stripped technical English terms ("torque" → "घूर्णन-बल") which a mechanic can't act on. Sarvam-M was the only model that mirrored Romanised Hinglish and kept the English technical vocabulary the manual uses.
Where the boundary is. When the network is down or SARVAM_API_KEY is unset, we fall through to Ollama. The demo still works — it just speaks English. See backend/app/rag/llm.py.
Decision: call api.sarvam.ai/v1/speech-to-text with language_code="unknown" so Saaras detects automatically.
What's special. Saaras returns the detected language_code alongside the transcript. Off-the-shelf Whisper returns ISO-639-1 codes that don't distinguish hi-IN (Hindi) from a Hinglish utterance — and Whisper-large is the smallest local variant that even attempts code-mixed Indic. Saaras's language_code is what we thread forward into the LLM and TTS.
Decision: call api.sarvam.ai/v1/text-to-speech with target_language_code taken directly from Saaras's detection.
Why this completes the loop. The detected language_code flows: mic → Saaras (returns lang) → /chat (system prompt picks Hinglish style) → /voice/synthesize → Bulbul (picks a matching speaker). The user speaks Hinglish, gets a Hinglish reply, hears it back in a Hinglish voice. No manual language toggle required. This end-to-end code-switch is the design centrepiece — see effectiveLang in frontend/src/components/chat.tsx.
The pieces below are either non-obvious or hard to find in public RAG demos:
- Language-aware closed loop, automatically. Saaras returns
language_code→ it threads into the chat request → server prompt picks Hinglish style → Bulbul TTS speaker is auto-matched. No UI lang toggle is required for the demo path. For typed input where Saaras hasn't run, the server sniffs Devanagari script + Roman-Hindi cue words (kya,kaise,hai,chahiye, …) and synthesises the samelanguage_code. - Single Qdrant collection, multi-bike payload-filtered. Instead of N collections, one collection has payload-indexed
bike_id/doc_type/manual_id. Adding a bike means appending a row toregistry.py— no schema migration, no index creation. Filter cost is O(1) after the payload index is built. - Parent-child chunking with parent promotion. Children (≤400 tok) drive retrieval signal; the parent block (≤800 tok) is what actually lands in the LLM prompt. The model gets H1→H4 breadcrumbs and surrounding context that a child chunk alone would miss.
- Verbatim spec guardrail. A regex pass over the LLM output extracts part numbers, torque values, fluid grades. If they don't appear verbatim in the retrieved chunks, confidence is dropped to
lowand a "specs could not be verified" warning is appended. Stops the model from confidently making up a torque value. - Deterministic severity floor. The model can escalate severity but never downgrade safety-critical issues. A keyword set (
brake fluid leak,oil pressure,steering wobble, …) forcesSTOP_RIDINGregardless of model judgment. - Citation deep-link with bbox. Every citation chip opens a
react-pdfmodal scrolled to the cited page with a best-effort bbox highlight. The user can verify the manual passage in two clicks. bike_id-scoped refusal, not brand keyword matching. "I don't have this manual" is driven bycount_chunks(bike_id, ...) == 0, not by hardcoded brand strings. Add Suzuki Hayabusa to the registry without touching prompts.- Persistent UX state, volatile server state. Active bike + language toggle + per-bike chat history live in
localStoragevia zustandpersist. Bike list + chunk counts are deliberately not persisted — they re-fetch on mount so registry edits and new ingests reflect immediately. See State persistence on/about. - Always-on Documents tab. Bikes that already have manuals can accept more uploads without rebuilding. The handler short-circuits when every requested
doc_typeis already indexed andforce=false, so the second click on "Add all to corpus" returns in <100ms instead of re-running Docling.
The default path is fully OSS: local Qdrant, local fastembed, local Ollama. Sarvam (voice) and Gemini (vision) keys are optional — without them, those endpoints return 503 but chat works end-to-end.
| Need | Why |
|---|---|
| Python 3.11+ | backend |
| Node 20+ | frontend |
| Docker Desktop | Qdrant container |
| Homebrew (macOS) | Ollama install |
| ~3 GB free disk | qwen2.5:3b model (~2 GB) + fastembed (~150 MB) |
git clone https://github.com/Debanitrkl/bike-analyzer.git
cd bike-analyzer
cp .env.example .env
cp frontend/.env.local.example frontend/.env.localEdit .env only if you want voice (SARVAM_API_KEY) or vision (GOOGLE_API_KEY). Everything else has working local defaults.
brew install ollama
brew services start ollama # daemon on localhost:11434
ollama pull qwen2.5:3b # ~2 GBLinux: curl -fsSL https://ollama.com/install.sh | sh then ollama serve &.
open -a Docker # if not already running
make qdrant # docker compose up -d qdrantSanity: curl localhost:6333/healthz → healthz check passed.
cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000In another shell, sanity: curl localhost:8000/health → {"status":"ok"}.
# from repo root, .venv still active
make ingestPulls OEM PDFs for the bikes that have URLs in registry.py (RE Himalayan + Classic 350), runs Docling → header-aware chunker → fastembed → Qdrant. ~3–5 min the first run; PDFs are cached so re-runs are fast.
Sanity: curl localhost:8000/bikes/royal-enfield-himalayan/status → ready: true, total_chunks: ~480.
cd frontend
npm install
npm run devOpen http://localhost:3000, pick Royal Enfield Himalayan, ask "What is the recommended front tyre pressure?" — should cite page 7 with 25 PSI / 1.75 kg/cm².
First chat per session waits ~25 s while Ollama loads the model into memory. Subsequent chats are sub-second.
make qdrant # ensure Qdrant up
brew services restart ollama # ensure Ollama up
cd backend && source .venv/bin/activate && uvicorn app.main:app --reload --port 8000 &
cd frontend && npm run dev| Symptom | Fix |
|---|---|
Ollama unreachable in chat |
brew services restart ollama and curl localhost:11434/api/version |
Index required but not found from Qdrant |
recreate the collection: python -c "from app.rag.store import ensure_collection; ensure_collection(recreate=True)" then re-ingest |
Failed to fetch in browser |
check CORS_ORIGINS in .env matches http://localhost:3000 |
| First chat returns "no relevant context" | confirm ingest ran: curl localhost:8000/bikes/<bike_id>/status |
| Empty bike picker | check Network tab — /bikes should return 10 entries; if 0, backend env didn't load |
make eval # full Ragas (calls the LLM judge — needs OPENAI_API_KEY)
make eval-fast # skip Ragas, just retrieval+gen sanity- Append to
BIKESinbackend/app/bikes/registry.py. Fill in OEM URLs insources=(...)if any are publicly hosted. - (Optional) Drop a Draco-compressed GLB at
frontend/public/models/<bike_id>.glband a hero image atfrontend/public/images/<bike_id>.webp(seefrontend/public/attribution.mdfor sources). - Restart the backend. New bike shows up in the picker.
| Endpoint | Description |
|---|---|
GET /bikes |
List registry bikes |
GET /bikes/{id}/status |
Per-doc-type chunk counts + readiness |
GET /bikes/{id}/sources |
OEM URL previews (HEAD-checked) |
POST /bikes/{id}/ingest-from-urls |
Download + ingest OEM docs |
POST /bikes/{id}/upload |
Upload PDF (multipart, doc_type form field) |
GET /pdf/{bike_id}/{doc_type} |
Serve the cached PDF (citation modal) |
POST /chat / POST /chat/stream |
Chat (accepts bike_id) |
POST /voice/transcribe |
Saaras v3 STT |
POST /voice/synthesize |
Bulbul v3 TTS, speaker auto-picked from language_code |
POST /ingest |
Legacy: seed-ingest every bike in the registry |
- Sarvam-M vs GPT-4o-mini: Sarvam-M is primary for Hinglish + brand fit
and now uses
response_format=json_objectso JSON parse failures should be rare. GPT-4o-mini fallback kicks in on parse errors or API failure. - Embeddings: Voyage-3-large when keyed; otherwise
bge-small-en-v1.5via fastembed for local dev without Voyage billing. - BM25: still Python-side (per-bike
BM25Okapicache). Qdrant native sparse vectors are the right v2 upgrade for production scale. - Voice streaming: REST only in v1. WebSocket Saaras + Bulbul is a v2 follow-up for <250 ms partials.
- 3D models: five Tier-1 CC-BY Sketchfab models, dropped manually in
frontend/public/models/. Bikes without a GLB fall back to a static hero image with the same aurora treatment.
See sarvam_bike_assistant_research.md
for the original opinionated architecture writeup.
The default OSS path is local-first — Ollama doesn't run on Render's free tier, so a one-click hosted deploy needs a tweak.
Frontend → Vercel (works as-is):
- Import this repo in Vercel, Root Directory =
frontend. - Set
NEXT_PUBLIC_BACKEND_URLto your tunneled local backend (ngrok / cloudflared) or a hosted backend URL. - Deploy.
Backend — three realistic options:
| Option | Where Ollama lives | Notes |
|---|---|---|
| Local + tunnel | Your laptop | Cheapest for demos. ngrok http 8000 → paste URL into Vercel. |
| Modal / Fly.io | On the same box as FastAPI (GPU optional) | Modal has a 1-click Ollama recipe; pay only when running. |
| Cloud LLM swap | None — swap Ollama back for Sarvam-M / OpenAI in app/rag/llm.py |
Render free tier works. Loses the "free OSS" property. |
render.yaml is left in the repo for the cloud-LLM option. For the
local-first path it isn't used.
- Deploy (Vercel frontend + Render backend)
- Multi-bike corpus + onboarding flow
- PDF.js citation deep-link modal
- Hinglish code-switch end-to-end (STT → LLM → TTS speaker)
- Ragas eval harness (
make eval) - 2-min Loom demo
- 5-slide deck