Follow

kickstartDS Concierge

An ML-backed chat agent that answers design-system questions

The Concierge answers design-system questions — what a component does, which token to use, how something is meant to be composed — without a human in the loop.

It ran as a hosted service on kickstartds.com, with the release written up on the blog. Most of the interesting work was not the model but the context: chunking component documentation, tokens and examples so the answers stayed grounded in the actual system.


FactDetail
Hosted atkickstartds.com/concierge
ApproachRetrieval-grounded chat over component docs
StackJupyter/Python service
StartedMarch 2023

What's in it

Answers, not guesses

Grounded in the component documentation, the token catalog and real usage examples.

Written up on release

The blog post covers the architecture and the design decisions behind it.

In detail

What is actually inside it

Retrieval first

Questions are embedded and matched against a knowledge base built from component documentation, tokens and examples. Grounding came from the corpus, not from prompt wording.

Quality measured, not assumed

quality_analysis.ipynb and quality_calculation.ipynb exist next to the ingestion notebooks — the system was evaluated as a data problem before it was tuned as a chat problem.

Fly.io and a dashboard

fly.toml plus a separate dashboard/ app: the service ran somewhere small and observable, with a UI to look at what it was doing.

DetailValue
Architectureembed → retrieve → answer (Flask + sentence-transformers)
Modelmsmarco-distilbert-cos-v5
Data pipeline8 notebooks incl. BERTopic and quality metrics
Deployed toFly.io (`fly.toml`)
StartedMarch 2023

Technical notes

The decisions behind it

Why a bi-encoder instead of calling a language model directly?
Cost and latency, but mostly grounding: if the answer must be traceable to our own documentation, the retrieval step is the part that has to work. Embedding is cheap; inventing is not.
What did BERTopic do here?
It found which clusters actually existed in the documentation corpus — which is how we noticed topics that were under-documented instead of assuming the model was wrong.

What actually runs, and where

The boundary between the model and the index

The Concierge was three processes with one HTTP hop between them, and most of the design decisions show up at that hop.

The browser talks to a Supabase Edge Function written in TypeScript. That function owns the conversation: it moderates the question through OpenAI’s moderation endpoint, asks for an embedding, runs the vector search, assembles the prompt and streams the completion back as Server-Sent Events. It holds no model of its own.

Embedding is the one thing that could not live in the Edge Function, because Supabase functions run on Deno and a PyTorch sentence-transformer does not. So it moved into a separate Flask service (app.py) on Fly.io, deployed as question-embedding in ams. The container loads msmarco-distilbert-cos-v5 once at import, caps it at max_seq_length = 256, and exposes a single route: POST / with {"question": "..."} in, {"embedding": [768 floats]} out. No auth, no batching, CORS(app) wide open — the file still carries the TODO to restrict it.

On the database side there is nothing but Postgres and pgvector: a sections table with embedding vector(768) and an ivfflat index using vector_cosine_ops, plus a match_sections() SQL function that returns rows ordered by 1 - (embedding <=> query_embedding). Retrieval is that one RPC with similarity_threshold: 0.4 and match_count: 15. The notebooks carry a cross-encoder re-ranker that was used to inspect results offline; it is not in the deployed path — all fifteen vector hits go straight into the prompt.

The prompt is then trimmed with a GPT-3 tokenizer against a hard 2100-token budget, and generation runs text-davinci-003 at temperature: 0 and max_tokens: 1200. The first SSE event the client receives is not text but the retrieved sections themselves, so the source list can render before the answer finishes. Every completed answer is written back to Postgres: questions keeps the question, the full prompt, its token length, the answer and the question embedding, while question_answer_sections keeps which sections were used and how similar they scored. The Concierge is also a record of what people ask.

180783
Passages embedded
Sections extracted from the mirrored corpus in the committed knowledge-base run, across every curated domain.
52
Curated domains
A hand-picked whitelist, not a web crawl — the full list is published on the live page.
5978
Sections kept
What survived the corpus filter: > 250 tokens, 4–15 newlines, > 120 spaces, exact duplicates dropped.
768
Dimensions
msmarco-distilbert-cos-v5 vectors, stored as vector(768) in Postgres.
15
Sections per question
Top cosine matches above 0.4, packed into a 2100-token context window.
PieceDetail
Embedding serviceFlask + `sentence-transformers` on Fly.io (`question-embedding`, `ams`)
Endpoint contract`POST /` · `{ question }` in → `{ embedding: 768 floats }` out
Encoder`msmarco-distilbert-cos-v5`, `max_seq_length = 256`
Vector storeSupabase Postgres + `pgvector`, `ivfflat` / `vector_cosine_ops`
Retrieval`match_sections()` RPC — threshold `0.4`, `match_count 15`
Context budget≤ 2100 GPT-3 tokens of retrieved sections per prompt
Generation`text-davinci-003` · `temperature 0` · `max_tokens 1200` · SSE stream
IngestionPlaywright/Chromium + trafilatura; split on Markdown headings, 300-token chunks / 50 overlap

Judging the corpus before generating anything

Three audits, none of them a gate

The release post promises that hallucinations are minimised because all context is fed from our own corpus. That promise only holds if the corpus is worth retrieving from, so four of the eight notebooks exist to decide what belongs in it.

The first pass is mechanical: keep sections with more than 250 tokens, 4–15 newlines and more than 120 spaces, and drop exact duplicates. On the committed run that cut 180,783 extracted passages down to roughly 6,000 — the crawl is full of navigation, cookie banners and API tables that are not answers to anything.

The remaining notebooks then treat quality as something to measure, not assume. All three lean on the NLTK Brown corpus as a stand-in for ordinary English prose.

Similarity to ordinary prose

quality_analysis.ipynb embeds 300-token chunks of the Brown corpus, scores every section by its mean cosine similarity to all of them, and splits at the 10th percentile. Sections that read nothing like prose — nav dumps, attribute tables, transcripts — land at the bottom; the committed run put that threshold at 0.0973.

Bigram entropy

quality_calculation.ipynb trains a Kneser-Ney bigram language model on Brown and averages each section’s bigram entropy under it. Text that a general English model finds predictable is generic boilerplate; infinite entropy from out-of-vocabulary words is clamped to 100 so one unseen token cannot dominate the score.

Out-of-distribution sections

cleanup_data.ipynb runs cleanlab’s OutOfDistribution over the section embeddings, splits at the 2.5th percentile, then re-clusters the outlier, rest and anomaly buckets with UMAP + HDBSCAN and colours them by domain — which shows which sources produce content that does not fit the rest.

None of this is a gate. Nothing in the pipeline reads these scores to drop a section — they were the instruments for deciding whether a domain needed re-crawling, or whether the token floor should move.

Where it broke, and what it could not do

Limits, as observed in the code and today

Could it answer any design-system question?
No, by construction. If no section clears similarity_threshold 0.4, the edge function throws instead of calling the model, and the prompt forbids answering outside the retrieved context — the fallback text is literally “Sorry, I don’t know how to help with that.” The blog frames this as the point: if there is no relevant content in the database, no answer is generated. The intended failure mode is a refusal or a bad pointer, not an invention.
What is broken in the committed code?
Three things a reader will hit. The offline and online paths disagree about the model: create_knowledge_base.ipynb builds paraphrase-multilingual-MiniLM-L12-v2 vectors (384 dimensions) while the table is vector(768) and the deployed encoder is msmarco-distilbert-cos-v5 (768) — a model switch that was left half-finished in the notebooks. The edge function assembles its request body by string interpolation ({"question": "${sanitizedQuery}"}), so a question containing a double quote emits invalid JSON and the embedding call fails before retrieval. And dashboard/src/.env ships a live database password, where the adjacent .env.example is empty.
What is still alive today?
The embedding service. A POST to https://question-embedding.fly.dev still returns a 768-float vector in about half a second (checked October 2026). Almost everything around it is gone: the Supabase project the code hard-codes (pzdzoelitkqizxopmwfg.supabase.co) no longer resolves, and OpenAI retired text-davinci-003, so the answer path as committed cannot run. The cheap, self-hosted half outlived the generation half.
What did the crawler miss?
Anything behind anchor-based navigation, and more. The crawl only follows same-domain links and silently drops any URL containing #, ? or /tag/, so tabbed content and tag archives were never reached; the ingestion notes call out the Volkswagen Frontify docs as partly lost for exactly this reason. Each domain also carries a manual exclusion list — /octicons/* on primer.style, /blog/tag/* on bradfrost.com, /components/examples/ on duetds.com — so the corpus reflects one person’s judgement about what a section should look like.