Spaces:
Sleeping
A newer version of the Streamlit SDK is available: 1.59.1
Apollo MIS β LLM Internship Assessment
Candidate: Kshamaa Suresh Date: March 2026 Time Spent: ~5 hours
| Task | Time |
|---|---|
| Data exploration + DB setup | 30 min |
| Retrieval (BM25) | 45 min |
| Re-ranking (Claude API) | 1 hr |
| Streamlit frontend | 1 hr |
| System design write-up | 1 hr |
| Onboarding proposal | 45 min |
1. Data Setup
What's in the dataset
60 exercises with the following fields: id, title, description, tags,
body_part, difficulty, equipment, injury_focus, intensity.
One trailing empty column (Unnamed: 9) was dropped. One bad value in
difficulty ("body") was noted β treated as-is since it only affects
one row and doesn't break any logic.
Why SQLite instead of PostgreSQL
The assessment suggests PostgreSQL, but SQLite is a better fit here for a few practical reasons:
- No server to spin up β the database is a single file, which means the app works the same locally and on HuggingFace Spaces without any additional setup
- 60 rows is well within SQLite's range; it handles hundreds of thousands of rows without issue
- All queries are standard SQL, so migrating to PostgreSQL later would just be a connection string change
Schema decision
I added a search_text column that pre-concatenates all the text fields
used in retrieval (title, description, tags, body_part, equipment,
injury_focus, intensity, difficulty). This avoids rebuilding the string at
query time and makes the BM25 index straightforward to build.
# setup_db.py β key logic
search_text = " ".join([title, description, tags, body_part,
equipment, injury_focus, intensity, difficulty]).lower()
2. Query β Recommendations Pipeline
The pipeline has two clearly separated stages:
User query
β
βΌ
Stage 1: BM25 Retrieval
β Tokenises the query and scores all 60 exercises
β Returns top 15 candidates with score > 0
β (falls back to top 15 regardless if fewer than 3 match)
βΌ
Stage 2: Claude Re-ranking
β Sends query + 15 candidates to Claude
β Claude scores and orders by relevance, returns JSON
β Returns top 5 with a one-sentence reason each
βΌ
Top 3β5 recommendations shown in UI
Why two stages?
Sending all 60 exercises to Claude on every query would work at this size, but it's the wrong pattern:
- At 100k+ exercises, sending everything to an LLM is not feasible
- BM25 quickly filters to a relevant subset β Claude only needs to reason over 15 candidates, not the full corpus
- This separation also makes each stage independently testable
Stage 1 β BM25 Retrieval
BM25 (Best Match 25) is a standard keyword ranking algorithm. It scores each exercise by how well its text matches the query tokens, accounting for term frequency and document length.
Why BM25 and not embeddings?
- No model to download or host
- Works well for domain terms like "knee rehab", "plyometric", "no weights"
- Fast β scores all 60 docs in milliseconds
- At larger scale, a hybrid approach (BM25 + vector search) would be better
# retrieval.py β core logic
corpus = [tokenize(ex["search_text"]) for ex in exercises]
bm25 = BM25Okapi(corpus)
scores = bm25.get_scores(tokenize(query))
Stage 2 β LLM Re-ranking (Mistral-7B via HuggingFace free Inference API)
The LLM receives the query and the 15 BM25 candidates and is asked to return a ranked JSON array of the top 5 with a one-sentence reason for each.
Why re-rank with an LLM?
- BM25 is purely lexical β it can miss a great match if the words don't overlap. An LLM understands intent: "low-impact" implies avoiding jumps and heavy loading even if those words don't appear in an exercise title.
- The LLM can reason about constraints: "no weights" should filter out barbell and dumbbell exercises even if they ranked highly on BM25.
Why Mistral-7B via HuggingFace Inference API?
- Completely free β requires only a free HuggingFace account token
- Strong instruction-following, handles JSON output reliably
- No local GPU or model download needed β the model runs on HF's servers
- Works natively on HuggingFace Spaces (the token is available as a
built-in secret
HF_TOKEN)
The prompt uses Mistral's [INST] format and asks for a JSON array only,
which makes parsing reliable. A regex fallback extracts the JSON if the
model adds any preamble text.
3. Backend Design
How the backend handles each step
1. User submits query via Streamlit text input
2. retrieve(query, top_k=15) called β BM25 scores exercises β returns list of dicts
3. rerank(query, candidates, top_n=5) called β Claude API call β parses JSON β returns ranked list
4. Streamlit renders results with title, description, difficulty, reason
All of this runs in a single Python process in the Streamlit app. For a production backend, this would be a FastAPI endpoint:
@app.post("/recommend")
async def recommend(query: str):
candidates = retrieve(query, top_k=15)
results = rerank(query, candidates, top_n=5)
return {"results": results}
How this scales to 100k+ exercises
| Problem | Solution |
|---|---|
| BM25 over 100k docs is slow in memory | Move to Elasticsearch or a vector store (FAISS/pgvector) with an index |
| Claude can't receive 100k candidates | BM25/vector search still returns only 15β20 candidates for re-ranking β this step doesn't change |
| Multiple simultaneous users | FastAPI handles async requests natively; Claude API calls are already I/O-bound so they work well with async/await |
| DB connection pooling | Use SQLAlchemy with a connection pool instead of raw sqlite3 |
| Cold start on DB index | Pre-build the BM25 index at startup and cache it in memory instead of rebuilding on every request |
4. Frontend
Built with Streamlit. The user can:
- Type a free-text query
- Click one of 4 example queries to pre-fill the input
- See the top 5 results with title, description, difficulty, body part, equipment, intensity, injury focus, and Claude's reason for ranking it
Hosting on HuggingFace Spaces (fully free):
- Create a new Space with the Streamlit SDK
- Push all project files
HF_TOKENis automatically available in Spaces as a built-in secret β no manual configuration needed for the re-ranking step- HuggingFace handles the rest β free tier is sufficient
Cost breakdown: $0
- SQLite β free, no server
- BM25 β free, runs in memory
- Mistral-7B via HF Inference API β free tier
- Streamlit on HuggingFace Spaces β free tier
5. Onboarding Proposal β Personalisation
What data to collect
During onboarding I'd ask users 4β5 quick questions:
| Question | Options | Why it matters |
|---|---|---|
| What's your main goal? | Rehab, strength, endurance, sport performance | Determines which exercises to prioritise |
| Any injuries or areas to avoid? | Knee, back, shoulder, groin, none | Hard filters retrieval β don't recommend knee exercises to someone with knee pain |
| What equipment do you have? | None, bands, dumbbells, barbell, full gym | Filters by equipment field |
| How would you rate your fitness level? | Beginner, intermediate, advanced | Maps to difficulty field |
| How intense do you want sessions to be? | Low, medium, high | Maps to intensity field |
How it influences retrieval and ranking
At retrieval time: apply hard filters in the SQL query before BM25. For example, a user who said "no equipment" gets a WHERE clause:
WHERE equipment IN ('none', 'bodyweight', 'band')
This reduces the candidate pool before BM25 even runs.
At re-ranking time: inject the user's profile into the Claude prompt:
User profile: rehab goal, knee injury, no equipment, beginner, low intensity
Query: "strengthen my legs"
Claude can then deprioritise any high-intensity or equipment-dependent exercises even if they scored well on BM25.
Inspiration from Spotify / Netflix
Spotify's Discover Weekly builds a taste profile from listening history and uses it to weight recommendations. The equivalent here is tracking which exercises a user completes or skips β over time, the system learns their preferences without them having to re-answer onboarding questions.
Netflix's contextual recommendations change based on time of day and recently watched content. A coaching app could similarly adapt β if a user just logged a hard leg session, the next session recommendation should shift toward upper body or recovery work.
Cold start problem: Both Spotify and Netflix use onboarding to handle new users who have no history. The 4β5 question onboarding above serves the same purpose β it gives the system enough signal to make useful recommendations from day one.