Skip to content

Interactive Portfolio Agent

A retrieval agent that answers questions about my work with this website as its only source, re-indexed from the site's own corpus on every deploy.

Try it on Hugging Face Spaces, or open the chat from the button in the bottom right corner of this site.

Most portfolio assistants answer from a copy of a CV that stopped being true months ago. This one keeps no copy. Its corpus is generated by this website, and the site hands it the new text every time it deploys.

The retrieval side builds on the RAG pipeline I evaluated in my Natural Language Processing project, where the same retrieve-then-generate approach was measured against a plain language model.

The website is the only source

The site publishes everything it holds as one JSON document: posts, projects, pages, tools, publications and CV entries. The agent validates that file against a schema before it uses it, and the schema is strict about the failures that would otherwise be invisible. A duplicate id, a prose document with an empty body, a declared total that does not match the number of documents: each of those fails the refresh, instead of producing an index full of empty documents that still answers confidently.

From that file the agent builds two things: a vector index, and a plain-text catalogue of every id, title and description. Both are built when the container image is built, never at boot. The Space has an ephemeral disk and restarts often, so anything computed on wake-up is paid for again on every wake-up.

A deploy of this site notifies the agent’s repository, which refreshes, re-indexes and republishes on its own. A daily job does the same, so the content is never more than a day stale even if the notification is missed. One detail took a second attempt: a deploy reports success before the CDN stops serving the previous build, so the refresh now waits until the site actually serves the content hash it was told to expect. Without that it compared the old corpus against itself, found no change, and reported success.

Answering before searching

The catalogue is part of the system prompt. Questions like “what has he written about Kubernetes” or “which projects use Docker” are answered from it with no tool call at all, which on a free box is the difference between two seconds and twenty.

When the text itself is needed there are two ways in. If the catalogue already names the item, the agent opens that item directly. Only when the right item is unknown does it search, and the search is hybrid: dense retrieval over the vector index, merged with a BM25 keyword pass so an exact term the embeddings miss still surfaces. The embedding model is small and runs inside the container, baked into an image layer, so neither indexing nor querying costs an API call.

The prompt is assembled once and stays byte-identical between requests, which keeps it eligible for prompt caching. History is trimmed to the last few messages, and never cut between a tool call and its result.

One measurement worth keeping: lowering the model’s reasoning effort to the minimum cut latency noticeably, and made it skip tool calls on roughly two questions in three. It was answering from the prompt when it should have opened the page. The effort is back at its default and the seconds are spent.

Under each answer the site renders cards for the pages it cited. Those are not asked of the model as structured output. They are extracted from the links the answer already wrote and matched against the corpus, so a link to a document that does not exist matches nothing and is dropped. That is the property that makes it safe to render a model’s links as buttons.

The same rule runs through the prompt: ids are internal and never printed, and no URL may appear that did not come from the catalogue or from a tool result.

Running on free hosting

The Space sleeps, has a daily token budget and answers in tens of seconds. What those constraints cost, and what they buy, is written up in The price of a chatbot on a site with no server.

Two things make the wait bearable. The API streams tokens as they are produced and announces which tool it reached for, so the widget can say “Searching the site” instead of showing three dots for twenty seconds. And when the Space is asleep or paused, the website answers from a short brief of its own, with links to the pages that would have been cited, rather than leaving the visitor at a dead end.

Per-address rate limits and a daily token ceiling stop one visitor from spending the budget for everyone. When the ceiling is reached the agent says so in plain words instead of failing.

Stack

  • LangGraph for the graph: one reasoning node, one tool node, looping until the model stops asking for tools.
  • A small OpenAI reasoning model for generation, with usage recorded per turn against the daily budget.
  • ChromaDB for the vector index, with a BM25 keyword pass merged into the same result set.
  • Local embeddings through FastEmbed, cached in the image so retrieval needs no embedding API.
  • FastAPI for the streaming and non-streaming endpoints, with per-address rate limiting.
  • Gradio for the interface on the Space itself. On this site the same API is called by a React widget.
  • Docker, which is where the index is built and the embedding model is cached.
  • GitHub Actions for the corpus refresh and for publishing to the Space.
  • Hugging Face Spaces for hosting, on the free tier.

What it deliberately does not know

A growing number of sites now end every page with a row of buttons: one for llms.txt, then “get an AI summary of this page” beside a logo for each popular model. It looks capable. What it does is send the reader to a model that knows less about the page than mine does, with no guarantee it will fetch the URL at all, and whatever it invents arrives with my name on it.

What this site does instead is smaller. Open the chat from a blog post or a project and the first suggested question becomes “Summarise this post”. The offer appears on those two kinds of page only, because they are the only routes whose whole content is a single item the agent can open in full. Everywhere else the chat behaves exactly as it did before.

Even there, the agent is told which page you are reading only in that one message. Your location is not attached to every question you ask. Sending it always would buy a tool call on questions that never needed one, and would leave a stale “this” in the conversation for the model to anchor on later.