A demo built for my application to Great Question — a mini version of the platform's core value proposition: making sense of qualitative interview data at scale.
What it does: Load customer research transcripts → search them semantically → stream AI-synthesized insights via Claude.
This directly mirrors GQ's "semantic search across tens of thousands of interview hours" challenge.
# 1. Clone and install
pip install -r requirements.txt
# 2. Set API keys
cp .env.example .env
# Edit .env — add ANTHROPIC_API_KEY (required) and VOYAGE_API_KEY (optional but recommended)
# 3. Pre-compute the vector index (already done — index.pkl is committed)
# Only re-run if you change transcripts.json or switch embedding mode:
python build_index.py
# 4. Run
uvicorn app.main:app --reload
# Open http://localhost:8000Docker:
cp .env.example .env # fill in keys
docker compose upDeploy to Railway/Render: Connect repo, set ANTHROPIC_API_KEY and VOYAGE_API_KEY as env vars. The committed index.pkl means no embedding API call is needed at startup — cold start is under 3 seconds.
transcripts.json
│
▼
Turn-level chunker ──▶ Voyage AI (voyage-3-lite, 512-dim)
│ │
│ numpy matrix (pre-normalized)
│ │
▼ ▼
index.pkl ◀──────────────── cosine search (single matmul)
│
Query ──embed──▶ Top-K chunks
│
▼
Claude (claude-sonnet-4-6)
streaming synthesis
│
▼
SSE → browser
sentence-transformers requires PyTorch (~2GB), CUDA drivers, and a model download on first run — all three are demo-killers. Voyage AI (voyageai package: ~50KB pip install) is Anthropic-acquired and purpose-built for retrieval. voyage-3-lite is state-of-the-art at this scale.
If VOYAGE_API_KEY is not set, the app falls back to TF-IDF with hash projection to 512 dimensions. The /health endpoint tells you which mode is active. The eval script (evals/eval_search.py) measures the quality gap.
At ~100 chunks, a vectorized dot product on a pre-normalized matrix completes in under 1ms. FAISS pays off at 100K+ vectors; ChromaDB adds a server process and persistence complexity. The numpy approach is honest about scale: if this were production GQ with 50M interview minutes, you'd switch to Qdrant.
Participant turns are natural semantic units in interview transcripts. Sliding windows would split quotes mid-sentence and mix context from different topics. Interviewer turns are excluded from the index — they're context, not insight.
Long synthesis responses feel stuck without it. The marked.js renderer handles incremental markdown gracefully. The frontend uses the native EventSource-style reader (not the EventSource API, since this is a POST request) to consume the stream token by token.
POST /search returns a typed Pydantic SearchResponse — machine-to-machine, consumed by the frontend's result renderer. POST /insights streams markdown prose — human-facing, consumed by a researcher's eyes. Right tool for each job.
Passing curated JSON with relevance scores and participant metadata produces better synthesis than pasting raw text. The system prompt has explicit grounding rules that prevent hallucination: every claim must be traceable to a specific quote. Context quality drives output quality.
python evals/eval_search.pyReports precision@1, @3, @5 over 8 labeled queries. TF-IDF baseline: MAP@1=0.25, MAP@3=0.38. Run again after setting VOYAGE_API_KEY to see the semantic improvement.
- Persistent vector DB (Qdrant) with async batch re-indexing as new sessions come in
- Speaker diarization pipeline — Whisper → diarized transcript → automatic ingest
- RAGAS eval framework for retrieval + generation quality at scale
- Webhook system — trigger Slack summaries or project tracker updates when a study completes
- Auth layer (JWT) with per-user transcript isolation
- MCP tools exposing search and synthesis as Claude-callable functions, so researchers can query their insight library from any Claude interface
| Method | Path | Description |
|---|---|---|
| GET | / |
Frontend |
| GET | /health |
Status, index size, embedding mode |
| GET | /transcripts |
List transcript metadata |
| GET | /transcripts/{id} |
Full transcript with turns |
| POST | /search |
{ query, top_k } → ranked chunks |
| POST | /insights |
{ query, chunk_ids } → SSE stream |
| POST | /upload |
Multipart form → ingest + re-index |
- Backend: Python 3.13, FastAPI, Uvicorn
- Embeddings: Voyage AI
voyage-3-lite(TF-IDF fallback) - Vector search: numpy cosine similarity
- LLM: Anthropic
claude-sonnet-4-6(streaming) - Frontend: Vanilla HTML/CSS/JS,
marked.jsfor markdown rendering - Deploy: Docker / Railway / Render