# S01: Multi-Field Composite Search

**Goal:** Backend search tokenizes input by whitespace, matches each token independently against all indexed fields with AND logic. Both keyword fallback and semantic path support cross-field queries. Graceful no-results fallback.
**Demo:** After this: Type 'keota snare' in search box → results show only content matching both tokens across creator/title/tags/body fields

## Tasks
- [x] **T01: Refactored keyword_search to multi-token AND with cross-field matching and partial_matches fallback** — Refactor `SearchService.keyword_search()` in `backend/search_service.py`:
1. Tokenize query by whitespace: `tokens = query.split()`
2. For each token, build an OR condition across: TechniquePage.title, TechniquePage.summary, TechniquePage.topic_category, and a joined Creator.name. Also check if token appears in topic_tags array (use `func.array_to_string(TechniquePage.topic_tags, ' ').ilike(pattern)`).
3. AND all per-token conditions together so every token must match at least one field.
4. Same pattern for KeyMoment search: join Creator via SourceVideo, check title/summary/creator.name.
5. For Creator search: each token must match name or genres.
6. Add `partial_matches` logic: if zero AND results, re-query with individual tokens, score results by how many tokens they match, return top 5 as partial_matches.
7. Extend SearchResponse schema with `partial_matches: list[SearchResultItem]` (default empty).
  - Estimate: 45min
  - Files: backend/search_service.py, backend/schemas.py, backend/routers/search.py
  - Verify: Run targeted curl: `curl 'http://localhost:8001/api/v1/search?q=keota+snare'` returns results matching both tokens. `curl 'http://localhost:8001/api/v1/search?q=xyznonexistent+snare'` returns empty items but partial_matches with snare-only results.
- [x] **T02: Enriched Qdrant embedding text with creator_name/tags and added reindex-all endpoint** — In `backend/pipeline/stages.py` stage6_embed_and_index:
1. For technique pages, change embedding text from `'{title} {summary} {topic_category}'` to `'{creator_name} {title} {topic_category} {tags_joined} {summary}'` where creator_name comes from the joined Creator row and tags_joined is `' '.join(topic_tags)`.
2. For key moments, change from `'{title} {summary}'` to `'{creator_name} {title} {summary}'`.
3. In `qdrant_client.py`, add `creator_name` to key_moment payload dict.
4. In stage 6, fetch creator name for moment enrichment (creator available via source_video.creator join).
5. Add an admin endpoint `POST /admin/pipeline/reindex-all` that triggers stage 6 re-run for all videos with processing_status='complete'. This allows re-indexing after embedding text changes.
  - Estimate: 30min
  - Files: backend/pipeline/stages.py, backend/pipeline/qdrant_client.py, backend/routers/pipeline.py
  - Verify: Inspect stage 6 code to confirm enriched text. Hit reindex endpoint and verify Qdrant points contain creator_name in payload.
- [x] **T03: Added partial_matches fallback UI to search results — shows muted suggestions when no exact AND match exists** — In `frontend/src/pages/SearchResults.tsx`:
1. Update SearchResponse type in public-client.ts to include `partial_matches: SearchResultItem[]`.
2. When `results.length === 0 && partial_matches.length > 0`, show a 'No exact matches for all terms' banner followed by 'Results matching some of your terms:' with the partial_matches rendered as SearchResultCards.
3. When both are empty, keep the existing 'No results found' state.
4. Style the partial-matches section with a muted header to distinguish it from exact results.
  - Estimate: 20min
  - Files: frontend/src/api/public-client.ts, frontend/src/pages/SearchResults.tsx
  - Verify: Browser verification: search for a multi-token query that has no exact match but has partial matches — verify the fallback UI renders correctly.
