Spaces:
Sleeping
Sleeping
| # 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. | |
| ```python | |
| # 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 | |
| ```python | |
| # 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: | |
| ```python | |
| @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):** | |
| 1. Create a new Space with the Streamlit SDK | |
| 2. Push all project files | |
| 3. `HF_TOKEN` is automatically available in Spaces as a built-in secret β | |
| no manual configuration needed for the re-ranking step | |
| 4. 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: | |
| ```sql | |
| 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. | |