How this site works
The chat window is not a person. Here is exactly what is behind it, what happens to what you type, and why the search works the way it does.
The short version
- You type a question.
- The browser sends it to a small Next.js proxy, which forwards it to a Python backend on Railway. Your message never reaches a model provider directly from your browser.
- The backend searches a local knowledge base of markdown files I maintain by hand, and hands the best few passages to a language model along with your question.
- The model writes the reply, streams it back word by word, and can call small tools, opening a window, switching the theme, drafting an email for you.
Which model answers you
There is no single model. The backend keeps a pool of endpoints across two providers, all on their free tiers, and works down the list until one answers. That is not an architectural flourish, it is a budget: Google's free tier allows roughly 20 requests per day per model, so a single model would take one curious afternoon to exhaust. Stacking several models across several providers turns that into something that survives being posted somewhere.
The order is deliberate, and measured rather than assumed:
| # | Provider | Free headroom | Why here |
|---|---|---|---|
| 1 | Google Gemini | ~20 requests/day, per model | Best answers for this persona. Five flash models = five separate daily buckets. |
| 2 | Mistral | 25,000 tokens/min, 50 req/min | The workhorse. Enough headroom for real traffic, and processed inside the EU. |
The interesting constraint is not requests per day, it is tokens per minute. One question that makes the assistant use a tool costs two model calls and around 7,000 tokens, so a provider advertising a huge daily request allowance can still be unable to finish a single turn. Groq was tried and dropped for exactly that reason: 14,400 requests a day sounds generous until you notice the 8,000 tokens-per-minute ceiling underneath it. Mistral's 25,000/min fits about three tool turns a minute, which is what actually matters.
The rest of the rules:
- Bench on failure. An endpoint that rate-limits is set aside for a cooldown read from its own retry hint — a few seconds for a token-bucket refill, an hour for an exhausted daily quota — and the request moves down the list.
- A tool call does not commit the turn. If a model opens a window and then runs out of budget before writing anything, the next model takes over and finishes. You see one answer, not an error, and not the same window twice.
- Text does commit it. Once prose has started streaming, that model owns the turn. Switching halfway would mean rewriting something you have already read.
The consequence is that the assistant's voice shifts a little between answers, because different model families phrase things differently. The facts should not, because they come from the same knowledge base either way. Every reply is labelled with the model and provider that produced it, and the live pool state is in the System Status window on the desktop.
Why the search uses BM25 and not embeddings
The usual way to build this is a vector database: embed every passage, embed the question, compare them by cosine similarity. This site does the opposite. It uses Okapi BM25, a keyword ranking function from the 1990s, over a few dozen markdown files.
The reason embeddings are usually reached for is vocabulary mismatch: you ask about “customer feedback analysis”, the document says “After Sales NLP pipeline”, and a keyword search finds nothing. Embeddings solve that by comparing meaning instead of words.
But there is a second way to close that gap, and it is older and cheaper: fix it at indexing time instead of at query time. Once, offline, a language model reads each passage and writes down the words and questions someone might use to look for it, synonyms, abbreviations, German equivalents, the phrasings the passage itself never uses. Those get appended to the text that BM25 indexes. The passage about Mercedes-Benz After Sales now literally contains the phrase “customer feedback analysis”, so a keyword search finds it.
This is a well-travelled idea rather than a new one:
- Document Expansion by Query Prediction (Nogueira et al., 2019), the original “doc2query”: generate the queries a document answers, append them, index as normal. Later refined as docTTTTTquery.
- Contextual Retrieval (Anthropic, 2024), prepend LLM-written context to each chunk before indexing. Reported a 49% drop in retrieval failures when combined with BM25, and notably kept BM25 in the loop rather than replacing it.
- BM25 Wins at Scale (2026), a scaling study across 28 corpus sizes finding BM25 overtaking dense retrieval past roughly 10M tokens, by a margin approaching 20 points at full scale.
For a corpus this small the practical arguments matter more than the benchmarks. Exact matching is genuinely better for what people actually ask a portfolio: proper nouns, years, acronyms, company names, the tokens embeddings tend to blur. Search runs in under a millisecond as a pure in-memory calculation, with no API call, no vector store and no network hop, so a cold container is answering immediately instead of rebuilding an index. And the whole thing is inspectable: the index is markdown plus a JSON file of generated keywords, both committed to git, both readable by a human who wants to know why a particular answer came out.
The honest trade-off: BM25 cannot match a question that shares no vocabulary with the expansion. A truly novel phrasing will miss where an embedding might have caught it. On a corpus of a few dozen curated files that is a rare and acceptable failure, on a corpus of a million documents it would not be.
Where your messages actually go
Because the rotation picks a different endpoint each turn, there is no single answer to “who processed my message” — so here is every provider that could. Only the ones with an API key configured are ever contacted, and each reply is labelled with the one that produced it.
| Provider | Where | Transfer basis |
|---|---|---|
| Mistral AI SAS, Paris | Frankreich | Processed in the EU — no third-country transfer |
| Google LLC / Google Ireland Ltd. | USA / Irland | EU-US Data Privacy Framework |
Mistral is the odd one out and deliberately so: it is a French company processing inside the EU, so nothing leaves European jurisdiction when it answers. The full legal detail — roles, data categories, processing agreements — is in the Datenschutzerklärung.
What happens to what you type
Your messages go to the model providers listed in the Datenschutzerklärung, all US companies. Please do not type anything sensitive into a chat box on a stranger's portfolio, not here and not anywhere else.
The site sets no cookies, runs no analytics and loads nothing from a third party in your browser. Fonts are served from this domain. The only things stored on your device are the ones needed to make the chat work: a random session id, your chosen theme, your display name and your conversation. Clearing your browser's site data removes all of it.
The code
The whole thing is open source at github.com/createdbymichel/mh-website. The retrieval design is written up in docs/RETRIEVAL.md, and the rotation logic is in backend/agent/michel_agent.py if you want to see how ugly the fallback handling really is.