# 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.