<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>twentytwotensors — Build in Public</title><description>Real projects, honest results, and the engineering decisions in between — written as I go.</description><link>https://twentytwotensors.co.uk/</link><language>en-gb</language><item><title>Why my AI agent was billing my Claude subscription as &apos;extra usage&apos;</title><link>https://twentytwotensors.co.uk/blog/posts/hermes-claude-subscription-billing/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/hermes-claude-subscription-billing/</guid><description>Same OAuth token, same account, same model — but my agent&apos;s requests billed to extra usage while the CLI billed to the subscription. The difference was a stale request fingerprint Anthropic uses to route billing.</description><pubDate>Sun, 16 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I run a terminal AI agent called &lt;a href=&quot;https://hermes-agent.nousresearch.com&quot; rel=&quot;noopener&quot; target=&quot;_blank&quot;&gt;Hermes Agent&lt;/a&gt; for most of my coding and automation work. It&apos;s configured to use my Claude subscription via the same OAuth credential as the &lt;code&gt;claude&lt;/code&gt; CLI. One day it started failing with a specific error while the CLI itself worked perfectly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;HTTP 400: You&apos;re out of extra usage. Add more at claude.ai/settings/usage and keep going.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That error is Anthropic&apos;s way of saying your &lt;strong&gt;purchased&lt;/strong&gt; extra-usage credits are empty. My first thought was the obvious one: I must be out of quota. But the &lt;code&gt;claude&lt;/code&gt; CLI still worked — same subscription, same week. And when I checked my usage page, my Claude Code weekly allowance looked untouched. What had actually happened was worse and more interesting: &lt;strong&gt;every request from my agent had been billed to extra-usage credits instead of the subscription quota my plan already includes.&lt;/strong&gt; I&apos;d burned through paid top-ups without touching the allowance I was already paying for. This is the story of how I proved that, and the one-line root cause that explained all of it.&lt;/p&gt;
&lt;h2&gt;Same token, same account — so why different billing?&lt;/h2&gt;
&lt;p&gt;Both the CLI and the agent authenticate the same way: with the Claude Code OAuth token stored in my Mac&apos;s keychain. I verified this byte-for-byte by pointing the CLI at a local logging proxy and comparing the token it sent against the one the agent uses. Identical. Same account, same token, same model, same API endpoint.&lt;/p&gt;
&lt;p&gt;Yet the CLI&apos;s requests drew from the subscription, and the agent&apos;s drew from extra usage. The only remaining difference was the request itself — the headers, the URL, and the payload. Anthropic&apos;s OAuth billing classifier doesn&apos;t just look at &lt;em&gt;who&lt;/em&gt; you are. It looks at &lt;em&gt;what client&lt;/em&gt; you&apos;re presenting as, and routes the request to a billing lane based on that fingerprint.&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/hermes-billing-two-lanes.png&quot; alt=&quot;A single token forking into two billing lanes — one leading to a subscription checkmark, the other to a low stack of coins&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;h2&gt;The fingerprint that was wrong&lt;/h2&gt;
&lt;p&gt;I captured the real CLI&apos;s request through a local proxy and diffed it against what my agent was sending. Four things stood out:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The user-agent.&lt;/strong&gt; The CLI sends &lt;code&gt;claude-cli/2.1.233 (external, sdk-cli)&lt;/code&gt;. The agent sent &lt;code&gt;claude-code/2.1.74 (external, cli)&lt;/code&gt; — a stale product prefix from an old naming scheme, an outdated version, and a different entrypoint label.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Missing query param.&lt;/strong&gt; The CLI hits &lt;code&gt;/v1/messages?beta=true&lt;/code&gt;. The agent hit &lt;code&gt;/v1/messages&lt;/code&gt; without it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A shorter beta list.&lt;/strong&gt; The CLI sends ten &lt;code&gt;anthropic-beta&lt;/code&gt; headers (thinking, context management, effort, etc.). The agent sent two.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Two missing markers entirely.&lt;/strong&gt; The CLI&apos;s payload starts its system prompt with a billing-routing header (&lt;code&gt;x-anthropic-billing-header: cc_version=…; cc_entrypoint=sdk-cli;&lt;/code&gt;) and includes a &lt;code&gt;metadata.user_id&lt;/code&gt; carrying the account UUID. The agent sent neither.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Any one of those might be enough to look like a third-party client to the classifier. Together they guaranteed it. The agent was impersonating a slightly-off version of the official client — close enough to authenticate, not close enough to bill like one.&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/hermes-fingerprint-diff.png&quot; alt=&quot;Two nearly-identical client identity cards side by side — one accepted with a checkmark, one slightly misaligned with a warning dot&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;h2&gt;Why the version was stale — a debugging side-quest&lt;/h2&gt;
&lt;p&gt;There was a second, smaller bug hiding behind the first. The agent tries to detect the installed Claude Code version so it can put a current version in its user-agent. On my machine that detection silently failed, and it fell back to a hardcoded &lt;code&gt;2.1.74&lt;/code&gt; — from early 2026 — while my actual CLI was &lt;code&gt;2.1.233&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The cause was a broken duplicate install: &lt;code&gt;/usr/local/bin/claude&lt;/code&gt; is a global npm copy that crashes under the current Node version, and it shadows the working binary at &lt;code&gt;~/.local/bin/claude&lt;/code&gt; that my shell actually uses. The version probe hit the broken one, got empty output, and fell back to the stale constant. The fix was updating that constant — and the deeper lesson was already on my to-do list: uninstall the duplicate.&lt;/p&gt;
&lt;h2&gt;The fix&lt;/h2&gt;
&lt;p&gt;The agent is open source and installed from source on my machine, so I patched its Anthropic adapter to mirror the CLI&apos;s fingerprint exactly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;User-agent → &lt;code&gt;claude-cli/&amp;#x3C;version&gt; (external, sdk-cli)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;The full ten-beta header list&lt;/li&gt;
&lt;li&gt;&lt;code&gt;?beta=true&lt;/code&gt; on the messages URL&lt;/li&gt;
&lt;li&gt;A session-id header&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;x-anthropic-billing-header&lt;/code&gt; block at the start of the system prompt&lt;/li&gt;
&lt;li&gt;&lt;code&gt;metadata.user_id&lt;/code&gt; with the account UUID read from &lt;code&gt;~/.claude.json&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then I ran the test suite, and then the real test:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ hermes chat -q &quot;Reply with exactly: OK&quot;
→ OK
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Log line from that run: &lt;code&gt;API call #1: model=claude-sonnet-5 provider=anthropic latency=2.0s&lt;/code&gt;, finished normally, no billing error. The same token that had been draining extra usage all day was now drawing from the subscription. I&apos;ve been using it since, and the extra-usage balance has stopped moving.&lt;/p&gt;
&lt;h2&gt;What I&apos;d tell anyone running a tool on a Claude subscription&lt;/h2&gt;
&lt;p&gt;If you use Claude Code&apos;s OAuth credential from anything other than the official CLI — another agent, an IDE extension, a script — and you see &quot;out of extra usage&quot; while the CLI still works, this is the likely cause. A few things that helped me:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Check the billing lane, not just the balance.&lt;/strong&gt; If the CLI works and the usage page says your weekly allowance is untouched, you&apos;re being billed somewhere else.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Capture and compare.&lt;/strong&gt; Point the working client at a local logging proxy, capture its request, and diff it against the failing client&apos;s. The difference is the bug.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Same token isn&apos;t the same request.&lt;/strong&gt; Anthropic&apos;s OAuth routing looks at the full client fingerprint — user-agent, beta headers, query params, and payload markers.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I&apos;ve written up the full diagnosis and the patch so the next person (or future me) doesn&apos;t have to re-derive it — the fix is specific to the version of the agent I&apos;m running, but the method applies to any tool using Claude Code&apos;s OAuth.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Debugging my own tooling so my infrastructure bills to the plan I already pay for — that&apos;s the kind of thing I enjoy getting to the bottom of. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt; if you&apos;ve got a system doing something similar.&lt;/em&gt;&lt;/p&gt;</content:encoded><category>Building in Public</category></item><item><title>How to set up Hermes Agent with your Claude subscription</title><link>https://twentytwotensors.co.uk/blog/posts/hermes-setup-claude-subscription/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/hermes-setup-claude-subscription/</guid><description>Hermes can run on the Claude subscription you already pay for — no API key, no separate billing. Here&apos;s the exact setup, how the OAuth discovery works, and the one fingerprint gotcha that silently bills you to extra usage instead.</description><pubDate>Sun, 16 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;a href=&quot;https://hermes-agent.nousresearch.com&quot; rel=&quot;noopener&quot; target=&quot;_blank&quot;&gt;Hermes Agent&lt;/a&gt; is an open-source AI agent that runs in your terminal — same family as Claude Code, but provider-agnostic: it can use OpenRouter, OpenAI, Google, local models, and more. What most people don&apos;t realise is that it can also run on a &lt;strong&gt;Claude Pro/Max subscription you already pay for&lt;/strong&gt;, using the same OAuth login as Claude Code — no Anthropic API key, no per-token API billing. Here&apos;s exactly how I set it up, and the one gotcha that cost me a day.&lt;/p&gt;
&lt;h2&gt;What you need before starting&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A Claude subscription (Pro or Max) with &lt;strong&gt;Claude Code&lt;/strong&gt; available on it&lt;/li&gt;
&lt;li&gt;Claude Code CLI installed and logged in at least once (&lt;code&gt;claude login&lt;/code&gt;), on macOS or Linux&lt;/li&gt;
&lt;li&gt;An empty afternoon — setup itself is ten minutes, but the gotcha at the end is worth knowing about&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The reason Claude Code needs to have been logged in is that Hermes doesn&apos;t do its own Anthropic OAuth. It reads the credential Claude Code already stored — that&apos;s the whole trick, and also the source of the one failure mode I&apos;ll get to.&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/hermes-setup-flow.png&quot; alt=&quot;A three-step flow: terminal CLI login, a keychain key in the middle, and a second terminal with a checkmark — log in once, credential stored, agent discovers it&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;h2&gt;Step 1 — Install Hermes&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That installs the launcher, a Python environment, and the agent itself. Then confirm it runs:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;hermes doctor
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Step 2 — Point it at the Claude subscription&lt;/h2&gt;
&lt;p&gt;Run the setup wizard and pick the Anthropic provider:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;hermes setup
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or set the pieces directly — the default profile config lives in &lt;code&gt;~/.hermes/config.yaml&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;model:
  default: claude-sonnet-5
  provider: anthropic
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The key part: &lt;strong&gt;do not set an &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt;.&lt;/strong&gt; If you do, Hermes will use API billing. Leave it out and Hermes automatically discovers the Claude Code OAuth credential instead — on macOS from the Keychain entry &lt;em&gt;&quot;Claude Code-credentials&quot;&lt;/em&gt; and/or &lt;code&gt;~/.claude/.credentials.json&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Step 3 — Verify what it&apos;s actually using&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;hermes auth list
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You want to see the anthropic provider holding an &lt;code&gt;oauth&lt;/code&gt; credential sourced from &lt;code&gt;claude_code&lt;/code&gt; — that&apos;s the subscription route:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;anthropic (1 credentials):
  #1  claude_code          oauth   claude_code ←
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then a live smoke test:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;hermes chat -q &quot;Reply with exactly: OK&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you get &lt;code&gt;OK&lt;/code&gt; back, you&apos;re running on your subscription.&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/hermes-verify-ok.png&quot; alt=&quot;A terminal window whose screen shows a single large OK checkmark badge, with a small sparkle above — a successful smoke test&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;h2&gt;Step 4 — The gotcha: &apos;out of extra usage&apos; while the CLI works&lt;/h2&gt;
&lt;p&gt;This is the one that bit me. Hermes authenticates fine and answers queries — but you notice your &lt;strong&gt;purchased extra-usage credits&lt;/strong&gt; draining, while your subscription&apos;s Claude Code weekly allowance sits untouched. And when the extra usage runs out, Hermes fails with:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;HTTP 400: You&apos;re out of extra usage. Add more at claude.ai/settings/usage
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;…while &lt;code&gt;claude -p &quot;hi&quot;&lt;/code&gt; works perfectly.&lt;/p&gt;
&lt;p&gt;Same token, same account — different billing lane. Anthropic&apos;s OAuth billing classifier routes requests by the &lt;strong&gt;client fingerprint&lt;/strong&gt; (user-agent, beta headers, query params, payload markers), not just by who you are. If the tool presents a slightly-off version of the official Claude Code fingerprint, Anthropic bills it as a third-party app drawing from extra usage instead of the subscription quota.&lt;/p&gt;
&lt;p&gt;I wrote up the full diagnosis in &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/hermes-claude-subscription-billing&quot;&gt;Why my AI agent was billing my Claude subscription as &apos;extra usage&apos;&lt;/a&gt; — including the exact request diff and the patch. If you hit this error, that&apos;s the post to read.&lt;/p&gt;
&lt;h2&gt;Keeping it healthy&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Credentials go stale&lt;/strong&gt; — if Hermes starts failing auth, run &lt;code&gt;claude -p hi&lt;/code&gt; once. That re-syncs the OAuth credential Hermes auto-discovers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Check the lane, not just the balance&lt;/strong&gt; — if you see extra usage moving while the CLI works, your tool is being billed as a third party. Fix the fingerprint, don&apos;t just top up.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shell hygiene&lt;/strong&gt; — if you use helper functions that export &lt;code&gt;ANTHROPIC_BASE_URL&lt;/code&gt; / &lt;code&gt;ANTHROPIC_AUTH_TOKEN&lt;/code&gt; (e.g. to point Claude Code at another provider), those exports persist in the shell after the function exits. Launch Hermes from a fresh shell or you&apos;ll silently route it somewhere else.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;A cheap fallback worth having&lt;/h2&gt;
&lt;p&gt;Hermes supports profiles, so I keep a second one on DeepSeek — a separate API-billed provider that&apos;s cheap enough to treat as a spare. If the Claude lane is ever down, I switch with a single flag rather than being blocked:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;alias hermes-ds=&quot;hermes -p deepseek&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Subscription for the main work, a cheap API as the spare. That combination has been rock solid since I fixed the fingerprint issue.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Getting the most out of the tooling you already pay for — and debugging it when it misbehaves — is what I do. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt; if you&apos;re wrestling with something similar.&lt;/em&gt;&lt;/p&gt;</content:encoded><category>Building in Public</category></item><item><title>The score is about connections, not cells</title><link>https://twentytwotensors.co.uk/blog/posts/biohub-connections-not-cells/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/biohub-connections-not-cells/</guid><description>Here is a way to fail at cell tracking: find every single cell, perfectly, and still score close to zero. Nobody asked where the cells are — they asked which cell became which. Part 1 of the Biohub series.</description><pubDate>Mon, 20 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Here is a way to fail at cell tracking.&lt;/p&gt;
&lt;p&gt;You find every single cell. Every nucleus, in every frame, perfectly. And you still score close to zero.&lt;/p&gt;
&lt;p&gt;Because nobody asked you where the cells are. They asked &lt;em&gt;which cell became which&lt;/em&gt;. And the answer to that is not made of cells. It is made of the connections between them.&lt;/p&gt;
&lt;p&gt;That sentence is the whole of this series. Everything after it is a consequence — including both of the things I got wrong.&lt;/p&gt;
&lt;figure&gt;
  &lt;video src=&quot;https://twentytwotensors.co.uk/videos/biohub/real-timelapse.mp4&quot; poster=&quot;/images/blog/biohub/posters/real-timelapse.jpg&quot; muted loop playsinline controls preload=&quot;none&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;
  &lt;figcaption&gt;A real recording: a zebrafish embryo over a hundred timepoints, tens of thousands of nuclei.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2&gt;The competition&lt;/h2&gt;
&lt;p&gt;The &lt;a href=&quot;https://www.kaggle.com/competitions&quot;&gt;CZ Biohub&lt;/a&gt; cell tracking challenge gives you light-sheet microscopy recordings of a developing zebrafish embryo and asks for the lineage: which nucleus in this frame is the same nucleus as that one in the next, and where a cell divided into two. It runs as a Kaggle &lt;em&gt;code&lt;/em&gt; competition — your notebook is re-run on roughly 199 hidden volumes, inside twelve hours, with no internet access. Whatever you build has to be fast enough to survive that.&lt;/p&gt;
&lt;p&gt;This series is a working log of getting to &lt;strong&gt;0.881&lt;/strong&gt; on the public leaderboard over about three weeks, what actually moved the number, and the two experiments that moved it the wrong way. I&apos;ll be specific throughout about which parts were mine and which were not, because that distinction turns out to matter for the last post.&lt;/p&gt;
&lt;h2&gt;What the metric actually counts&lt;/h2&gt;
&lt;p&gt;The score is &lt;code&gt;adjusted_edge_jaccard + 0.1 · division_jaccard&lt;/code&gt;. Strip the names away and it means: mostly, you are graded on &lt;strong&gt;edges&lt;/strong&gt; — the link between a cell in frame &lt;em&gt;t&lt;/em&gt; and the same cell in frame &lt;em&gt;t+1&lt;/em&gt; — with a small bonus for correctly spotting divisions.&lt;/p&gt;
&lt;p&gt;Two details in that definition end up driving every design decision I made:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Nodes are matched by proximity, not identity.&lt;/strong&gt; A predicted nucleus counts as the ground-truth one if its centroid lands within about 7 µm. Inside that radius, closer is not better. This sounds like a technicality. It is the reason one of my later &quot;improvements&quot; scored exactly zero.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Only consecutive frames count.&lt;/strong&gt; An edge from &lt;em&gt;t&lt;/em&gt; to &lt;em&gt;t+1&lt;/em&gt; scores. An edge from &lt;em&gt;t&lt;/em&gt; to &lt;em&gt;t+2&lt;/em&gt; — jumping over a frame where you missed the cell — scores nothing at all. You cannot skip a hole in your own detections. You have to fill it.&lt;/p&gt;
&lt;figure&gt;
  &lt;video src=&quot;https://twentytwotensors.co.uk/videos/biohub/how-its-scored.mp4&quot; poster=&quot;/images/blog/biohub/posters/how-its-scored.jpg&quot; muted loop playsinline controls preload=&quot;none&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;
  &lt;figcaption&gt;What the metric counts: edges between consecutive frames, with nodes matched by proximity.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2&gt;You are graded on a fraction of what you can see&lt;/h2&gt;
&lt;p&gt;This is a real recording: a zebrafish embryo, a hundred timepoints, tens of thousands of nuclei. But the ground truth is &lt;em&gt;sparse&lt;/em&gt;. Only about 6% of the cells are labelled — roughly two thousand, out of the thirty thousand you can plainly see.&lt;/p&gt;
&lt;figure&gt;
  &lt;video src=&quot;https://twentytwotensors.co.uk/videos/biohub/real-annotated.mp4&quot; poster=&quot;/images/blog/biohub/posters/real-annotated.jpg&quot; muted loop playsinline controls preload=&quot;none&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;
  &lt;figcaption&gt;The labelled cells lighting up against everything else visible in the same volume.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;In a single frame that ratio is startling: eighteen labelled cells among more than a hundred obviously-visible ones. You are graded on a fraction of what is in front of you, and you have no way of knowing in advance which fraction.&lt;/p&gt;
&lt;p&gt;The practical consequence is that you cannot naively score yourself. If you compute a normal tracking metric against sparse labels, every correct detection of an &lt;em&gt;unlabelled&lt;/em&gt; cell looks like a false positive, and your local number collapses in a way the leaderboard does not. Building a scorer that understood sparsity was the first real work of the project, and it&apos;s &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/biohub-build-the-scorer-first&quot;&gt;the next post&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Depth is not like width&lt;/h2&gt;
&lt;p&gt;One more property of the data, because it shapes where the errors live. Look at the volume face-on and the nuclei are round. Look at the same volume from the side and they go blocky.&lt;/p&gt;
&lt;p&gt;The microscope samples depth about four times more coarsely than width — the same 104 µm of tissue, a quarter of the detail. This is &lt;em&gt;anisotropy&lt;/em&gt;, and it is not a rendering artefact; it is the actual resolution of the data. Every distance you compute, every neighbourhood you search, has to be expressed in physical microns rather than voxels, or it silently means something different along &lt;em&gt;z&lt;/em&gt; than it does along &lt;em&gt;x&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Depth is where the detector is least sure. It is worth knowing that before you start trusting it.&lt;/p&gt;
&lt;figure&gt;
  &lt;video src=&quot;https://twentytwotensors.co.uk/videos/biohub/anisotropy.mp4&quot; poster=&quot;/images/blog/biohub/posters/anisotropy.jpg&quot; muted loop playsinline controls preload=&quot;none&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;
  &lt;figcaption&gt;The same volume face-on and from the side: the microscope samples depth four times more coarsely.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2&gt;Why the combinatorics are brutal&lt;/h2&gt;
&lt;p&gt;So: connections. How hard can that be?&lt;/p&gt;
&lt;p&gt;Each frame holds around three hundred cells, and any one of them could connect to any of three hundred in the next. Across the whole recording, that is roughly &lt;strong&gt;nine million&lt;/strong&gt; possible links. About &lt;strong&gt;thirty thousand&lt;/strong&gt; are real.&lt;/p&gt;
&lt;p&gt;One in three hundred.&lt;/p&gt;
&lt;figure&gt;
  &lt;video src=&quot;https://twentytwotensors.co.uk/videos/biohub/why-hard.mp4&quot; poster=&quot;/images/blog/biohub/posters/why-hard.jpg&quot; muted loop playsinline controls preload=&quot;none&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;
  &lt;figcaption&gt;Candidate links exploding between two frames: about nine million possible, roughly thirty thousand real.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;And you cannot tell them apart by looking. These are featureless blobs — no texture, no colour, no shape to distinguish one from another. They all look identical. They drift between frames. They divide, so one becomes two. And every so often the detector blinks and a cell simply is not there for a frame.&lt;/p&gt;
&lt;p&gt;That last failure mode sounds minor. It is the single most expensive thing that happens in the whole pipeline, and &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/biohub-one-missed-cell-two-lost-links&quot;&gt;it gets its own post&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;What&apos;s in the rest of this series&lt;/h2&gt;
&lt;p&gt;Six parts, each landing one idea:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Part&lt;/th&gt;&lt;th&gt;The idea&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;1 · this one&lt;/td&gt;&lt;td&gt;The score is about connections, not cells&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;2&lt;/td&gt;&lt;td&gt;Build a scorer you trust before you build a model&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;3&lt;/td&gt;&lt;td&gt;Finding cells is the easy half — and the biggest win wasn&apos;t mine&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;4&lt;/td&gt;&lt;td&gt;Scoring links one at a time is a trap&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;5&lt;/td&gt;&lt;td&gt;One missed cell costs two links and one lineage&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;6&lt;/td&gt;&lt;td&gt;Two failures, two different axes — and where the ceiling is&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;A note on honesty before we start. The detector and the linker at the heart of this pipeline are &lt;strong&gt;public work&lt;/strong&gt;, not mine — I&apos;ll say so plainly when we get there. My own contribution is a graph post-processing stage worth &lt;strong&gt;+0.014&lt;/strong&gt;, and a local scorer that predicted the leaderboard to within &lt;strong&gt;0.008&lt;/strong&gt;. Public notebooks in this competition reach 0.902, so 0.881 sits at the public floor rather than ahead of it.&lt;/p&gt;
&lt;p&gt;That framing is not modesty. It is the reason the final post is worth reading: when you know exactly which part you built, you know exactly which part to distrust.&lt;/p&gt;</content:encoded><category>Building in Public</category></item><item><title>Build a scorer you trust before you build a model</title><link>https://twentytwotensors.co.uk/blog/posts/biohub-build-the-scorer-first/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/biohub-build-the-scorer-first/</guid><description>You get five submissions a day. That is not an iteration loop, it is a rationing system. So I rebuilt the competition metric locally — and it predicted the leaderboard to within 0.008. Part 2 of the Biohub series.</description><pubDate>Sun, 19 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A Kaggle code competition gives you five submissions a day, and each one costs a full re-run over roughly 199 hidden volumes. That is not an iteration loop. It is a rationing system.&lt;/p&gt;
&lt;p&gt;So the first thing I built was not a detector or a tracker. It was a copy of the scoreboard.&lt;/p&gt;
&lt;h2&gt;Why you can&apos;t just use an off-the-shelf metric&lt;/h2&gt;
&lt;p&gt;The obvious move is to grab an existing tracking metric and point it at your predictions. It does not work here, and the reason is the sparse ground truth from &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/biohub-connections-not-cells&quot;&gt;Part 1&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Only about 6% of the visible nuclei are labelled. A standard metric assumes the annotation is complete, so every cell you correctly detect that &lt;em&gt;nobody labelled&lt;/em&gt; is counted against you as a false positive. Run that on this data and a genuinely good tracker scores terribly — not because it is wrong, but because the metric is measuring a question nobody asked.&lt;/p&gt;
&lt;p&gt;Worse, the error is not a constant offset you could ignore. It scales with how many unlabelled cells you happen to find, which means it is largest exactly when your detector improves. A metric that punishes you for getting better is not a slow guide. It is an actively misleading one.&lt;/p&gt;
&lt;h2&gt;What the local scorer had to get right&lt;/h2&gt;
&lt;p&gt;Three things, all of them consequences of how the real metric is defined:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Score only against labelled cells.&lt;/strong&gt; Match predicted nodes to ground-truth nodes by centroid within the physical radius, then evaluate edges only where &lt;em&gt;both&lt;/em&gt; endpoints matched something real. Predictions in unlabelled regions are neither rewarded nor punished — they are simply out of scope.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Match in microns, not voxels.&lt;/strong&gt; Because of the anisotropy, a radius expressed in voxels is a squashed ellipsoid in real space — far more permissive in &lt;em&gt;z&lt;/em&gt; than in &lt;em&gt;x&lt;/em&gt;. Every distance in the scorer is physical.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Count consecutive edges only.&lt;/strong&gt; An edge that spans a gap is worth nothing. If the scorer credits it, the scorer will happily tell you that a bad idea is a good one.&lt;/p&gt;
&lt;h2&gt;Did it work?&lt;/h2&gt;
&lt;p&gt;The test of a local scorer is not whether it looks sensible. It is whether it &lt;em&gt;predicts&lt;/em&gt;. So the first two real submissions were calibration, not attempts to win.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Submission&lt;/th&gt;&lt;th&gt;Local score&lt;/th&gt;&lt;th&gt;Leaderboard&lt;/th&gt;&lt;th&gt;Gap&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Classical detector + greedy linking&lt;/td&gt;&lt;td&gt;0.066&lt;/td&gt;&lt;td&gt;0.147&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Blob detector + greedy linking&lt;/td&gt;&lt;td&gt;0.7535&lt;/td&gt;&lt;td&gt;0.746&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0.008&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;That second row is the one that mattered. A local number of 0.7535 landed at 0.746 on the leaderboard — &lt;strong&gt;within 0.008&lt;/strong&gt;. From that point on I could try an idea, score it in minutes on my own machine, and know roughly what the board would say without spending a submission to find out.&lt;/p&gt;
&lt;p&gt;Nearly everything in the rest of this series was found that way. The whole project runs on that one number being trustworthy.&lt;/p&gt;
&lt;h2&gt;The part I got wrong&lt;/h2&gt;
&lt;p&gt;Here is the honest caveat, and it is the seed of &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/biohub-two-failures-two-axes&quot;&gt;the final post&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&quot;Within 0.008&quot; tells you the scorer is &lt;em&gt;well calibrated in absolute terms&lt;/em&gt;. It does not tell you the scorer can resolve a difference of 0.002 between two candidate configurations. Those are completely different claims, and I conflated them for about a week.&lt;/p&gt;
&lt;p&gt;A scorer can be accurate on average and still be too noisy to rank two near-identical options — or biased in a specific direction that happens to reward a change the real metric does not care about. Mine turned out to be both, in different experiments. Building the scorer was the right first move. Believing it was precise because it was accurate was not.&lt;/p&gt;
&lt;p&gt;The discipline I&apos;d state now, having learned it the expensive way, is not &quot;measure locally.&quot; It is: &lt;strong&gt;know your measurement&apos;s noise floor, and refuse to ship inside it.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;The infrastructure underneath&lt;/h2&gt;
&lt;p&gt;Briefly, because it is the unglamorous half. Offline iteration only helps if running an experiment is cheap, so the scorer sits on top of a small amount of throwaway platform work: Terraform-provisioned GPU boxes on AWS (T4 and L4 spot lanes, with a CPU lane for the linker), S3 and EBS for persistence so a terminated spot instance costs nothing but time, an isolated MLflow instance for run tracking, and a self-terminating job pattern so a forgotten box does not quietly bill overnight.&lt;/p&gt;
&lt;p&gt;There is also a wrinkle specific to code competitions: the notebook runs with &lt;strong&gt;no internet&lt;/strong&gt;, so every dependency has to be packaged as offline wheels and installed from a Kaggle dataset. And the GPU you get is a lottery — a P100 has a different compute capability than a T4, which means a build that works in testing can fail on submission. That one costs you a day if you find it late.&lt;/p&gt;
&lt;p&gt;None of this is interesting on its own. It exists so that the loop in the middle — change something, score it, know within minutes — actually spins.&lt;/p&gt;
&lt;h2&gt;Next&lt;/h2&gt;
&lt;p&gt;With a scoreboard I trusted, the question became where the score actually comes from. The answer was lopsided enough to be worth its own post: &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/biohub-finding-cells-is-the-easy-half&quot;&gt;detection is the lever, and the biggest single improvement in the project was not my work&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>Building in Public</category></item><item><title>Finding cells is the easy half — and the biggest win wasn&apos;t mine</title><link>https://twentytwotensors.co.uk/blog/posts/biohub-finding-cells-is-the-easy-half/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/biohub-finding-cells-is-the-easy-half/</guid><description>A hand-written blob detector beat two well-known deep segmentation models — on accuracy and on runtime. Then I swapped in someone else&apos;s U-Net and gained more than everything I&apos;d done combined. Part 3 of the Biohub series.</description><pubDate>Sat, 18 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Detection is the easy half of tracking. You are only asked where the nuclei are in a single frame — no identity, no history, no divisions. And yet detection turned out to be where almost all of the score lives.&lt;/p&gt;
&lt;p&gt;This post is the part of the project I am least able to take credit for, so let me put the conclusion at the top: &lt;strong&gt;the largest single improvement I made was deleting my detector and using someone else&apos;s.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;The runtime budget rules everything&lt;/h2&gt;
&lt;p&gt;Before any accuracy discussion, the constraint that eliminated most options. The notebook re-runs over roughly 199 hidden volumes inside twelve hours. That leaves, very roughly, &lt;strong&gt;3.6 minutes per volume&lt;/strong&gt; for the entire pipeline — detection, linking, post-processing, writing output.&lt;/p&gt;
&lt;p&gt;This single number decided more of the architecture than any accuracy result did.&lt;/p&gt;
&lt;h2&gt;What I tried first: a hand-written blob detector&lt;/h2&gt;
&lt;p&gt;Nuclei in this data are, visually, blobs. No texture, no distinguishing features — just roughly spherical bright regions. That is close to the textbook case for a &lt;strong&gt;multiscale Laplacian-of-Gaussian&lt;/strong&gt; detector, which is old, boring, and has no learned parameters at all.&lt;/p&gt;
&lt;p&gt;I implemented it as 3D convolutions in PyTorch so it ran on GPU, with the filter scales chosen in physical microns and non-maximum suppression done in an anisotropic neighbourhood — the same micron-not-voxel discipline the scorer needed.&lt;/p&gt;
&lt;p&gt;It scored &lt;strong&gt;0.7535&lt;/strong&gt; locally, &lt;strong&gt;0.746&lt;/strong&gt; on the board, in well under the budget.&lt;/p&gt;
&lt;h2&gt;What happened when I tried the obvious deep models&lt;/h2&gt;
&lt;p&gt;The expectation is that a pretrained segmentation model beats a hand-tuned classical filter. On this data, the two I tried did not.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Detector&lt;/th&gt;&lt;th&gt;Local edge-Jaccard&lt;/th&gt;&lt;th&gt;Runtime / volume&lt;/th&gt;&lt;th&gt;Verdict&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Classical threshold + connected components&lt;/td&gt;&lt;td&gt;0.066&lt;/td&gt;&lt;td&gt;fast&lt;/td&gt;&lt;td&gt;Baseline only&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Multiscale LoG blob (mine)&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0.7535&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;fast&lt;/td&gt;&lt;td&gt;Shipped&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cellpose-3D&lt;/td&gt;&lt;td&gt;0.7245&lt;/td&gt;&lt;td&gt;~85 min&lt;/td&gt;&lt;td&gt;Rejected — ~23× over budget&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;StarDist-3D (pretrained, out of the box)&lt;/td&gt;&lt;td&gt;0.3978&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;Rejected — worse than the blob&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Cellpose was &lt;em&gt;slightly less accurate&lt;/em&gt; than the blob detector and roughly &lt;strong&gt;twenty-three times over&lt;/strong&gt; the entire per-volume budget on its own. StarDist out of the box scored about half.&lt;/p&gt;
&lt;p&gt;It would be easy to read that as &quot;deep learning is overrated here.&quot; That is the wrong lesson, and the next section is why. The right lesson is narrower: &lt;strong&gt;a pretrained model carries its training domain with it.&lt;/strong&gt; These are anisotropic light-sheet volumes of densely packed embryonic nuclei. That is not what those checkpoints saw. The architecture was never the problem; the domain gap was.&lt;/p&gt;
&lt;figure&gt;
  &lt;video src=&quot;https://twentytwotensors.co.uk/videos/biohub/what-is-a-unet.mp4&quot; poster=&quot;/images/blog/biohub/posters/what-is-a-unet.jpg&quot; muted loop playsinline controls preload=&quot;none&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;
  &lt;figcaption&gt;What a U-Net does: encode down, decode back up, with skip connections carrying detail across.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2&gt;The thing that actually worked, which I did not build&lt;/h2&gt;
&lt;p&gt;Partway through the competition, a support pack was published under CC0 containing a trained &lt;strong&gt;temporal 3D U-Net&lt;/strong&gt; — a 3D U-Net with per-voxel attention &lt;em&gt;across several consecutive frames&lt;/em&gt;, plus a shared backbone feeding both a detection head and an edge-scoring transformer.&lt;/p&gt;
&lt;figure&gt;
  &lt;video src=&quot;https://twentytwotensors.co.uk/videos/biohub/detection-window.mp4&quot; poster=&quot;/images/blog/biohub/posters/detection-window.jpg&quot; muted loop playsinline controls preload=&quot;none&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;
  &lt;figcaption&gt;Reading several consecutive frames at once: a nucleus ambiguous in one frame is often obvious with its neighbours.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;That temporal window is the whole idea, and it is a good one. A nucleus that is ambiguous in one frame — dim, or overlapping a neighbour — is usually obvious once you can see the frames either side of it. The model gets to use the same context a human would.&lt;/p&gt;
&lt;p&gt;Swapping it in:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Detector&lt;/th&gt;&lt;th&gt;Local&lt;/th&gt;&lt;th&gt;Leaderboard&lt;/th&gt;&lt;th&gt;Runtime&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;My LoG blob + greedy linking&lt;/td&gt;&lt;td&gt;0.7535&lt;/td&gt;&lt;td&gt;0.746&lt;/td&gt;&lt;td&gt;fast&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Public temporal U-Net + ILP linking&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0.9423&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0.867&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;~1.7 min&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;+0.113 on the leaderboard.&lt;/strong&gt; For comparison, every idea of my own across the entire project added up to +0.014. The single biggest lever was adopting public work, and it was not close.&lt;/p&gt;
&lt;p&gt;I want to state that plainly rather than bury it, for two reasons. The first is simple credit. The second is that it sets up the honest reading of my final score: reaching 0.867 was &lt;em&gt;reproduction&lt;/em&gt;. It got me to the pack, not ahead of it. Public notebooks in this competition reach 0.902.&lt;/p&gt;
&lt;figure&gt;
  &lt;video src=&quot;https://twentytwotensors.co.uk/videos/biohub/detection.mp4&quot; poster=&quot;/images/blog/biohub/posters/detection.jpg&quot; muted loop playsinline controls preload=&quot;none&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;
  &lt;figcaption&gt;Detection output: nuclei found per frame, before any linking.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2&gt;What I learned by trying to improve it, and failing&lt;/h2&gt;
&lt;p&gt;Having adopted the detector, the obvious next move was to train a better one. Several attempts, all negative, and the negatives are more useful than another table of wins:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A longer temporal window did not help.&lt;/strong&gt; If two frames of context are good, four should be better. My first test said yes — but that test changed three variables at once. Re-running it as a proper matched comparison flipped the result: window-2 scored 0.9623 against window-4&apos;s 0.9599, and window-4 lost on four of five volumes. The pilot was confounded; the control &lt;em&gt;was&lt;/em&gt; the experiment.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A longer-trained checkpoint did not help.&lt;/strong&gt; A 350-epoch checkpoint scored 0.9439 against the 50-epoch model&apos;s 0.9423 — a difference well inside noise. The detector had converged long before I got to it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Architecture swaps are a dead end here.&lt;/strong&gt; Between StarDist&apos;s 0.3978 and Cellpose&apos;s runtime, the evidence says the remaining headroom is not in choosing a different architecture. It is in &lt;em&gt;in-domain training&lt;/em&gt; — and doing that properly means a from-scratch run at full scale, which is where &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/biohub-two-failures-two-axes&quot;&gt;the last post&lt;/a&gt; ends up.&lt;/p&gt;
&lt;h2&gt;The shape of the result&lt;/h2&gt;
&lt;p&gt;Detection contributed +0.113. Everything else in the pipeline — linking strategy, graph repair, parameter tuning — contributed less than +0.02 combined.&lt;/p&gt;
&lt;p&gt;That lopsidedness is worth internalising before optimising anything. It is very easy to spend a week on the clever part of a pipeline when the boring part in front of it holds an order of magnitude more score. I did spend that week, and the next two posts are what I found there. They are genuinely interesting — but they are the edge, not the lever.&lt;/p&gt;
&lt;p&gt;Next: &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/biohub-greedy-is-a-trap&quot;&gt;why scoring links one at a time poisons everything downstream&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>Building in Public</category></item><item><title>Greedy is a trap: linking cells with an integer program</title><link>https://twentytwotensors.co.uk/blog/posts/biohub-greedy-is-a-trap/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/biohub-greedy-is-a-trap/</guid><description>Take the best link first and you can block two better ones behind it — 1.45 against a possible 2.55. The fix is to stop choosing one at a time. Part 4 of the Biohub series.</description><pubDate>Fri, 17 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;You have detections in every frame. Now you need the connections — and from &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/biohub-connections-not-cells&quot;&gt;Part 1&lt;/a&gt;, the connections are the entire score.&lt;/p&gt;
&lt;p&gt;Each candidate link gets a score: how close are these two nuclei in physical space, how well does the motion fit, what does the model&apos;s edge head think. Then the obvious thing is to sort by score and take the best ones first.&lt;/p&gt;
&lt;p&gt;The obvious thing is a trap.&lt;/p&gt;
&lt;h2&gt;Why taking the best link first loses&lt;/h2&gt;
&lt;p&gt;Greedy assignment makes each choice in isolation, and every choice removes options from the choices after it. One confident wrong link early on poisons everything downstream of it.&lt;/p&gt;
&lt;p&gt;The clearest version is small enough to hold in your head. Suppose the greedy step takes a link scoring 0.95 — genuinely the highest-scoring single edge available. But taking it consumes a cell that two &lt;em&gt;other&lt;/em&gt; links needed, and both of those are now forced onto worse alternatives. Total: &lt;strong&gt;1.45&lt;/strong&gt;. Solve the same set jointly, decline that 0.95, and the total comes to &lt;strong&gt;2.55&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The greedy answer is not just worse. It is worse &lt;em&gt;because&lt;/em&gt; it was locally optimal.&lt;/p&gt;
&lt;figure&gt;
  &lt;video src=&quot;https://twentytwotensors.co.uk/videos/biohub/ilp-greedy-vs-global.mp4&quot; poster=&quot;/images/blog/biohub/posters/ilp-greedy-vs-global.jpg&quot; muted loop playsinline controls preload=&quot;none&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;
  &lt;figcaption&gt;Greedy takes the 0.95 link, blocks cell B, and forces two bad links — 1.45 total. Solved jointly, the same candidates give 2.55.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2&gt;The global version&lt;/h2&gt;
&lt;p&gt;The fix is to stop choosing one at a time and describe the whole frame-to-frame assignment as a single optimisation. Every candidate link becomes a binary variable — take it or don&apos;t. The objective is the total score of everything taken. The constraints encode what a lineage is allowed to look like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a cell has &lt;strong&gt;at most one parent&lt;/strong&gt; in the previous frame;&lt;/li&gt;
&lt;li&gt;a cell has &lt;strong&gt;at most one child&lt;/strong&gt; in the next — unless it divided, in which case exactly two;&lt;/li&gt;
&lt;li&gt;appearances and disappearances are allowed, but they cost something, so the solver doesn&apos;t use them to wriggle out of hard cases.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That is an &lt;strong&gt;integer linear program&lt;/strong&gt;. The &quot;integer&quot; part is not decoration: you cannot take 0.6 of a link. A cell either connects to that other cell or it does not, and it is precisely that integrality which makes the problem combinatorial rather than a matter of solving a linear system.&lt;/p&gt;
&lt;figure&gt;
  &lt;video src=&quot;https://twentytwotensors.co.uk/videos/biohub/what-is-an-ilp.mp4&quot; poster=&quot;/images/blog/biohub/posters/what-is-an-ilp.jpg&quot; muted loop playsinline controls preload=&quot;none&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;
  &lt;figcaption&gt;Variables, objective, constraints — and why it must be integer: you cannot take 0.6 of a link.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Given all candidates at once, the solver returns the highest-scoring set of links that is simultaneously &lt;em&gt;legal&lt;/em&gt; — no cell with two parents, no accidental three-way division. Not locally greedy. Globally consistent.&lt;/p&gt;
&lt;figure&gt;
  &lt;video src=&quot;https://twentytwotensors.co.uk/videos/biohub/linking.mp4&quot; poster=&quot;/images/blog/biohub/posters/linking.jpg&quot; muted loop playsinline controls preload=&quot;none&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;
  &lt;figcaption&gt;Linking across frames: every cell gets one parent, unless it divided, in which case two children.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2&gt;The first implementation was correct and unusable&lt;/h2&gt;
&lt;p&gt;My first working version used &lt;a href=&quot;https://github.com/royerlab/ultrack&quot;&gt;ultrack&lt;/a&gt; with an open-source solver. It produced good tracks and found 108 divisions, which was more than anything I&apos;d managed before.&lt;/p&gt;
&lt;p&gt;It also took &lt;strong&gt;6.79 minutes per volume&lt;/strong&gt;, against a budget of roughly 3.6. That is 2.3× over — a correct answer that would fail the submission re-run.&lt;/p&gt;
&lt;p&gt;The obvious escape was parallelism, and it did not work either: running multiple workers deadlocked. I spent a while there, found and fixed two real bugs in my own usage along the way, and then parked the whole approach. A correct pipeline that cannot finish is not a pipeline.&lt;/p&gt;
&lt;p&gt;The version that shipped uses the public pipeline&apos;s ILP formulation via &lt;code&gt;tracksdata&lt;/code&gt; with a SCIP backend, which solves the same problem inside the budget. This is the second time in the series that the practical answer was &quot;use the public thing&quot; — and, as in &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/biohub-finding-cells-is-the-easy-half&quot;&gt;Part 3&lt;/a&gt;, I&apos;d rather say so than imply the formulation was mine.&lt;/p&gt;
&lt;h2&gt;The part that transferred, and the part that didn&apos;t&lt;/h2&gt;
&lt;p&gt;Something worth noticing about the two linking approaches. Greedy linking on my blob detector scored &lt;strong&gt;0.746&lt;/strong&gt;. ILP linking on the public U-Net scored &lt;strong&gt;0.867&lt;/strong&gt;. It is tempting to attribute that gap to the linker.&lt;/p&gt;
&lt;p&gt;Most of it is the detector. The two changed together, and I never ran the clean factorial — blob-plus-ILP and U-Net-plus-greedy — that would separate them. I am fairly confident the linker is worth real points, because the failure mode it fixes is visible in the output. But &quot;fairly confident&quot; is not &quot;measured,&quot; and after what happens in &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/biohub-two-failures-two-axes&quot;&gt;Part 6&lt;/a&gt; I am wary of asserting effect sizes I did not isolate.&lt;/p&gt;
&lt;p&gt;Which is a small example of a bigger habit worth having: when you change two things and the number moves, you have learned that the pair helps. You have not learned which one.&lt;/p&gt;
&lt;h2&gt;What the ILP still can&apos;t fix&lt;/h2&gt;
&lt;p&gt;The solver can only choose among the links you gave it. If the detector missed a nucleus entirely in frame &lt;em&gt;t&lt;/em&gt;, there is no candidate edge into it and no candidate edge out of it — nothing for the optimisation to select, however global it is.&lt;/p&gt;
&lt;p&gt;That gap is not a small local blemish. It is the single most expensive event in the pipeline, and repairing it was the largest improvement I made myself.&lt;/p&gt;
&lt;p&gt;Next: &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/biohub-one-missed-cell-two-lost-links&quot;&gt;one missed cell, two lost links, one broken lineage&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>Building in Public</category></item><item><title>One missed cell, two lost links, one broken lineage</title><link>https://twentytwotensors.co.uk/blog/posts/biohub-one-missed-cell-two-lost-links/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/biohub-one-missed-cell-two-lost-links/</guid><description>When the detector blinks for one frame you don&apos;t lose one connection — you lose two, and the family tree splits. The most valuable repair isn&apos;t finding more cells. It&apos;s healing the ones that broke. Part 5 of the Biohub series.</description><pubDate>Thu, 16 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The tracks coming out of the linker are good. They are not clean. And this is where the idea from &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/biohub-connections-not-cells&quot;&gt;Part 1&lt;/a&gt; — that the score is about connections, not cells — finally earns its keep.&lt;/p&gt;
&lt;figure&gt;
  &lt;video src=&quot;https://twentytwotensors.co.uk/videos/biohub/lift-to-lineage.mp4&quot; poster=&quot;/images/blog/biohub/posters/lift-to-lineage.jpg&quot; muted loop playsinline controls preload=&quot;none&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;
  &lt;figcaption&gt;Flattening space onto a plane and giving the vertical axis to time: a cell&apos;s life becomes a curve, a division a fork.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2&gt;The asymmetry that makes gaps expensive&lt;/h2&gt;
&lt;p&gt;Watch what happens when the detector blinks: one cell, missed, for one frame.&lt;/p&gt;
&lt;p&gt;You do not lose one connection. You lose &lt;strong&gt;two&lt;/strong&gt; — the edge coming in, and the edge going out. And because the track is now severed, it snaps into two fragments, so the family tree is wrong from that point onward.&lt;/p&gt;
&lt;p&gt;One missed cell. Two lost links. One broken lineage.&lt;/p&gt;
&lt;figure&gt;
  &lt;video src=&quot;https://twentytwotensors.co.uk/videos/biohub/two-edges.mp4&quot; poster=&quot;/images/blog/biohub/posters/two-edges.jpg&quot; muted loop playsinline controls preload=&quot;none&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;
  &lt;figcaption&gt;A track intact, with its two incident links lit. The detector blinks for one frame — and both edges die at once.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;That asymmetry is the whole insight. A missed detection costs double, because edges are what the metric counts and every interior node carries two of them. Which flips the obvious priority: &lt;strong&gt;the most valuable repair is not finding more cells. It is healing the ones that broke.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;You cannot leap the hole&lt;/h2&gt;
&lt;p&gt;There is a catch, and it comes straight from the metric definition. The score only counts links between &lt;em&gt;consecutive&lt;/em&gt; frames.&lt;/p&gt;
&lt;p&gt;So the tempting shortcut — notice the gap, draw an edge from &lt;em&gt;t&lt;/em&gt; straight to &lt;em&gt;t+2&lt;/em&gt;, move on — earns exactly nothing. That edge spans two frames and the metric does not see it.&lt;/p&gt;
&lt;p&gt;You have to &lt;em&gt;fill&lt;/em&gt; the hole instead: invent a node at &lt;em&gt;t+1&lt;/em&gt; where the detector missed one, interpolate its position, and thread &lt;strong&gt;two&lt;/strong&gt; consecutive edges back through it. Do that and you recover both of the lost links plus the continuity of the lineage.&lt;/p&gt;
&lt;p&gt;This is a case where reading the metric definition closely was worth more than any modelling idea. The difference between the version that scores and the version that scores nothing is entirely in a detail of how edges are counted.&lt;/p&gt;
&lt;figure&gt;
  &lt;video src=&quot;https://twentytwotensors.co.uk/videos/biohub/gap-heal.mp4&quot; poster=&quot;/images/blog/biohub/posters/gap-heal.jpg&quot; muted loop playsinline controls preload=&quot;none&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;
  &lt;figcaption&gt;Filling the hole: a synthetic node at t+1 and two consecutive edges threaded back through it.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2&gt;The calibration stage&lt;/h2&gt;
&lt;p&gt;All of this lives in one module that takes &lt;code&gt;(nodes, edges)&lt;/code&gt; from the linker and returns a repaired &lt;code&gt;(nodes, edges)&lt;/code&gt;. It touches no imagery and runs in seconds. Four transforms:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Filter short tracks.&lt;/strong&gt; Fragments a few frames long are usually noise, and their edges are usually wrong. Dropping them is a small clean win. The public pipeline used a minimum length of 7; my own sweep found &lt;strong&gt;4&lt;/strong&gt; was better on this data, and 7 measurably hurt. A tuned constant, but a tuned constant with a measurement behind it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Add safe divisions.&lt;/strong&gt; The division term is worth 10% of the score, and most submissions score close to zero on it. Where a track ends near the start of two others, that is a plausible missed division — so insert a conservative second child, gated by per-frame and global caps so it cannot run away.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Close gaps.&lt;/strong&gt; The repair above: synthesise the missing node and two consecutive edges. This was the single largest lever in the stage.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Line-fit smoothing.&lt;/strong&gt; Least-squares smoothing of positions along each track. Hold that thought — it is the subject of &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/biohub-two-failures-two-axes&quot;&gt;Part 6&lt;/a&gt;, and not for a good reason.&lt;/p&gt;
&lt;figure&gt;
  &lt;video src=&quot;https://twentytwotensors.co.uk/videos/biohub/calibration.mp4&quot; poster=&quot;/images/blog/biohub/posters/calibration.jpg&quot; muted loop playsinline controls preload=&quot;none&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;
  &lt;figcaption&gt;Filtering short fragments before the other repairs run.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2&gt;The order matters more than any single transform&lt;/h2&gt;
&lt;p&gt;This surprised me and is probably the most transferable thing in the post.&lt;/p&gt;
&lt;p&gt;These transforms are not commutative. Run them as &lt;em&gt;filter → divisions → gap&lt;/em&gt; and the local score is &lt;strong&gt;0.7691&lt;/strong&gt;. Run the same three as &lt;em&gt;gap → filter → divisions&lt;/em&gt; and it is &lt;strong&gt;0.7636&lt;/strong&gt; — and, worse, the division count collapses to zero.&lt;/p&gt;
&lt;p&gt;The reason is that gap-closing creates short synthetic fragments, which the filter then eats, which removes exactly the track endings that the division detector was going to pair up. Each transform is individually correct. Composed in the wrong order, one of them silently destroys another&apos;s input.&lt;/p&gt;
&lt;p&gt;No single transform is buggy. The pipeline is. That failure only shows up if you measure the composition rather than the parts.&lt;/p&gt;
&lt;h2&gt;What it was worth&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Configuration&lt;/th&gt;&lt;th&gt;Local&lt;/th&gt;&lt;th&gt;Leaderboard&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Public U-Net + ILP, no calibration&lt;/td&gt;&lt;td&gt;0.9423&lt;/td&gt;&lt;td&gt;0.867&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;+ graph calibration&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0.9571&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0.881&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;+0.014 on the leaderboard&lt;/strong&gt;, and it transferred from local almost exactly — 0.0148 local against 0.014 on the board. For a stage that never touches a pixel and runs in seconds on top of someone else&apos;s detector, that is the piece of this project I am happiest with.&lt;/p&gt;
&lt;p&gt;It is also, honestly, an order of magnitude smaller than the +0.113 that came from swapping the detector in &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/biohub-finding-cells-is-the-easy-half&quot;&gt;Part 3&lt;/a&gt;. The detector was the lever. This was the edge.&lt;/p&gt;
&lt;figure&gt;
  &lt;video src=&quot;https://twentytwotensors.co.uk/videos/biohub/stack-cutaway.mp4&quot; poster=&quot;/images/blog/biohub/posters/stack-cutaway.jpg&quot; muted loop playsinline controls preload=&quot;none&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;
  &lt;figcaption&gt;On a real embryo: every line a cell that was actually labelled, tracked through a hundred frames.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2&gt;A caveat I want on the record&lt;/h2&gt;
&lt;p&gt;Almost all of this was tuned on &lt;strong&gt;one volume&lt;/strong&gt;. Later multi-volume validation across five volumes — spanning roughly 6,900 to 73,000 cells — held up, with the shipped configuration gaining about +0.009 mean over baseline on the three usable ones. But those volumes come from a &lt;em&gt;single embryo&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Cross-embryo generalisation is untested. Given that the hidden test set is ~199 volumes, that is the largest methodological risk in the whole project, and I would rather name it than let a leaderboard number imply more robustness than I actually demonstrated.&lt;/p&gt;
&lt;p&gt;Next, and the reason this series exists: &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/biohub-two-failures-two-axes&quot;&gt;two experiments that made things worse, in two completely different ways&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>Building in Public</category></item><item><title>Two failures, two different axes</title><link>https://twentytwotensors.co.uk/blog/posts/biohub-two-failures-two-axes/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/biohub-two-failures-two-axes/</guid><description>I improved my tracker and the score got worse. Twice — but not the same way twice. One failure says your scorer is biased; the other says it&apos;s noisy. A number you compute yourself can be wrong in both ways. Part 6 of the Biohub series.</description><pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;By this point the pipeline was working. A public temporal U-Net for detection, a global ILP for linking, my own graph calibration on top: &lt;strong&gt;0.881&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;And then everything stopped working.&lt;/p&gt;
&lt;h2&gt;Failure one: the improvement worth exactly zero&lt;/h2&gt;
&lt;p&gt;Line-fit smoothing was the obvious next step. Fit a line through each track&apos;s positions and snap the points onto it, so cells travel along clean trajectories instead of jittering. Visually it is a clear improvement — the tracks stop wobbling.&lt;/p&gt;
&lt;figure&gt;
  &lt;video src=&quot;https://twentytwotensors.co.uk/videos/biohub/smoothing.mp4&quot; poster=&quot;/images/blog/biohub/posters/smoothing.jpg&quot; muted loop playsinline controls preload=&quot;none&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;
  &lt;figcaption&gt;Fit a line, snap the points: the track gets visibly cleaner while every link stays identical.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;My local scorer agreed: &lt;strong&gt;+0.0064&lt;/strong&gt;. Small, but well outside anything I&apos;d have called noise.&lt;/p&gt;
&lt;p&gt;The leaderboard moved by &lt;strong&gt;0.000&lt;/strong&gt;. Not a little. Not within error. The score before was 0.881 and the score after was 0.881.&lt;/p&gt;
&lt;figure&gt;
  &lt;video src=&quot;https://twentytwotensors.co.uk/videos/biohub/noise-floor.mp4&quot; poster=&quot;/images/blog/biohub/posters/noise-floor.jpg&quot; muted loop playsinline controls preload=&quot;none&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;
  &lt;figcaption&gt;The two failures plotted against the noise band — deliberately on opposite sides of it, because they are not the same failure.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;In hindsight the reason is sitting in the metric definition from &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/biohub-connections-not-cells&quot;&gt;Part 1&lt;/a&gt;. A predicted node matches a ground-truth node if its centroid falls within about 7 µm. &lt;em&gt;Inside that radius, closer is not better.&lt;/em&gt; Matching is a threshold, not a gradient.&lt;/p&gt;
&lt;p&gt;My smoothing moved points by fractions of a micron. Every point it moved was already inside the radius and stayed inside it. Not one match flipped, so not one edge changed, so the score could not move. I had spent a day polishing &lt;em&gt;positions&lt;/em&gt; — and the score is about connections. I was polishing cells.&lt;/p&gt;
&lt;p&gt;One honesty note, because I want the claim to be exactly as strong as the evidence. The measured facts are +0.0064 local and +0.000 on the leaderboard. The &lt;em&gt;explanation&lt;/em&gt; — that sub-threshold polish is invisible to a radius-matched metric — is a well-supported reading of the metric definition, not something I isolated in a separate experiment. It fits, and I believe it. It is reasoning, not a measurement.&lt;/p&gt;
&lt;h2&gt;Failure two: the improvement too small to see&lt;/h2&gt;
&lt;p&gt;The second one is quieter and, I think, more instructive.&lt;/p&gt;
&lt;p&gt;I swept the detection confidence threshold across its full range, looking for a better operating point. The local curve was &lt;strong&gt;flat&lt;/strong&gt; — every value within about ±0.001 of every other. A reasonable reading is &quot;this parameter doesn&apos;t matter.&quot;&lt;/p&gt;
&lt;p&gt;Instead I shipped the value that looked best, 0.98, at &lt;strong&gt;+0.0005&lt;/strong&gt; local.&lt;/p&gt;
&lt;p&gt;It lost &lt;strong&gt;0.002&lt;/strong&gt; on the board, taking 0.881 down to 0.879. I reverted it.&lt;/p&gt;
&lt;p&gt;Nothing here was a modelling mistake. The mistake was treating the highest point on a flat curve as a signal. A +0.0005 difference was smaller than my scorer&apos;s ability to resolve, so &quot;best&quot; was indistinguishable from &quot;arbitrary&quot; — and I picked an arbitrary value and paid for it.&lt;/p&gt;
&lt;h2&gt;Why these are not the same failure&lt;/h2&gt;
&lt;p&gt;It is tempting to file both under &quot;the local score didn&apos;t transfer.&quot; That reading is wrong, and it throws away the useful part.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Local&lt;/th&gt;&lt;th&gt;Leaderboard&lt;/th&gt;&lt;th&gt;What it says about the scorer&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Line-fit smoothing&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;+0.0064&lt;/td&gt;&lt;td&gt;+0.000&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Biased&lt;/strong&gt; — measured a real change the true metric ignores&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Detection threshold 0.98&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;+0.0005&lt;/td&gt;&lt;td&gt;−0.002&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Noisy&lt;/strong&gt; — the difference was below its resolution&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The smoothing result was &lt;em&gt;big enough to measure&lt;/em&gt; — +0.0064 sits well outside a ±0.002 band. It measured the wrong thing. That is &lt;strong&gt;bias&lt;/strong&gt;: my scorer was rewarding position accuracy that the real metric, with its match radius, is structurally incapable of caring about.&lt;/p&gt;
&lt;p&gt;The threshold result was &lt;em&gt;too small to measure at all&lt;/em&gt;. That is &lt;strong&gt;noise&lt;/strong&gt;: nothing wrong with what the scorer values, just no ability to resolve a difference that fine.&lt;/p&gt;
&lt;p&gt;Different diagnoses, different fixes. Bias means your metric disagrees with the real one about what counts, and no amount of extra data helps — you have to fix the metric. Noise means your metric agrees but cannot see finely enough, and more volumes or more repeats &lt;em&gt;would&lt;/em&gt; help.&lt;/p&gt;
&lt;p&gt;A number you compute for yourself can be wrong in both of those ways. Mine was wrong in each, exactly once.&lt;/p&gt;
&lt;h2&gt;The discipline I&apos;d actually state&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/biohub-build-the-scorer-first&quot;&gt;Part 2&lt;/a&gt; ended on a local scorer that predicted the leaderboard to within 0.008, which felt like the project&apos;s foundation. It was — but I drew the wrong conclusion from it.&lt;/p&gt;
&lt;p&gt;Being accurate to 0.008 in &lt;em&gt;absolute&lt;/em&gt; terms says nothing about being able to &lt;em&gt;rank&lt;/em&gt; two options that differ by 0.002. I treated those as the same property for about a week.&lt;/p&gt;
&lt;p&gt;So the rule is not &quot;measure locally.&quot; It is:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Know your measurement&apos;s noise floor, and refuse to ship inside it.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Had I plotted my own scorer&apos;s variance before trusting its third decimal place, both of these would have been caught before they cost submissions. The threshold sweep would have been visibly flat rather than deceptively peaked, and the smoothing gain would have been visibly the wrong &lt;em&gt;kind&lt;/em&gt; of gain.&lt;/p&gt;
&lt;h2&gt;Other things that did not work&lt;/h2&gt;
&lt;p&gt;For completeness, since negative results are most of the actual work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Motion-gated gap recovery.&lt;/strong&gt; Only close a gap when the motion is consistent — clearly smarter than closing every gap. Measured worse at every setting, 0.9571 down to 0.9544. It stayed in the codebase, defaulted off. Testing it prevented a regression, which is a real return on a negative result.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The division term is effectively dead.&lt;/strong&gt; Worth 10% of the score, and division Jaccard came in at ≤0.009 on the one volume with enough labelled divisions to measure, and zero elsewhere. I put real effort here for no return. Judging by public scores, so did everyone else — which makes it one of the two places genuine headroom might still be hiding.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Longer temporal windows and longer training&lt;/strong&gt; — both flat, both covered in &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/biohub-finding-cells-is-the-easy-half&quot;&gt;Part 3&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;figure&gt;
  &lt;video src=&quot;https://twentytwotensors.co.uk/videos/biohub/ladder.mp4&quot; poster=&quot;/images/blog/biohub/posters/ladder.jpg&quot; muted loop playsinline controls preload=&quot;none&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;
  &lt;figcaption&gt;The ladder: naive blob, then the learned detector, then healing the graph.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2&gt;Where the ceiling is&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;0.881.&lt;/strong&gt; I am fairly confident that is the ceiling for this approach — a public detector plus my own graph calibration — because every cheap lever I could find is exhausted and the remaining ideas all measured flat or negative.&lt;/p&gt;
&lt;p&gt;Beating it means training a detector from scratch, in-domain, at full scale. Days of compute, on a hunch, with no positive signal that it would help. That is a pure appetite bet, and I have not taken it.&lt;/p&gt;
&lt;p&gt;Two caveats keep the number honest. Public notebooks in this competition reach &lt;strong&gt;0.902&lt;/strong&gt;, so 0.881 sits at the public floor, not ahead of it. And nearly everything was tuned on a single volume from a single embryo, so cross-embryo generalisation is untested — the risk I flagged in &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/biohub-one-missed-cell-two-lost-links&quot;&gt;Part 5&lt;/a&gt; and have not retired.&lt;/p&gt;
&lt;p&gt;The competition is still open, so there is no ranking to report and I am not claiming one.&lt;/p&gt;
&lt;p&gt;What I will claim is the smaller, more portable thing. I built a measurement, trusted it further than it deserved, and got caught twice — once by bias and once by noise. Finding the edge of your own instrument is not a detour from the work. On this project it &lt;em&gt;was&lt;/em&gt; the work.&lt;/p&gt;
&lt;figure&gt;
  &lt;video src=&quot;https://twentytwotensors.co.uk/videos/biohub/close.mp4&quot; poster=&quot;/images/blog/biohub/posters/close.jpg&quot; muted loop playsinline controls preload=&quot;none&quot; width=&quot;100%&quot;&gt;&lt;/video&gt;
  &lt;figcaption&gt;0.881 — the ceiling for a public detector plus my own graph calibration.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Knowing where the ceiling is, is also a result.&lt;/p&gt;</content:encoded><category>Building in Public</category></item><item><title>What it actually costs to self-host a coding model</title><link>https://twentytwotensors.co.uk/blog/posts/self-hosting-a-coding-llm-cost/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/self-hosting-a-coding-llm-cost/</guid><description>I wanted to run my own coding model to save on a subscription. Priced across Qwen3.5, GLM 4.7 and DeepSeek, the honest answer surprised me: at modest usage self-hosting isn&apos;t cheaper than an API — it&apos;s the unlimited, private option. Where the crossover is, and the tok/s catch the spec sheet hid.</description><pubDate>Fri, 10 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/self-hosting-llm-cost.png&quot; alt=&quot;A GPU server box at the centre with a small price tag and a few coins, code-bracket motifs orbiting it&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;p&gt;I pay for an AI coding assistant every month, and one day I did the obvious thing and asked: what if I just ran the model myself? The open-weight coders are good now. A spot GPU is a few pence an hour. It &lt;em&gt;felt&lt;/em&gt; like it should be cheaper.&lt;/p&gt;
&lt;p&gt;So I priced it out — properly, across the models I&apos;d actually use (Qwen3.5, GLM 4.7 Flash, DeepSeek V3.2), for my real workload of roughly &lt;strong&gt;12M tokens a month&lt;/strong&gt; with a heavy cache-hit rate. The conclusion isn&apos;t the one I set out to prove. At my volume, self-hosting is &lt;em&gt;not&lt;/em&gt; the way to save money. It&apos;s the way to buy something an API won&apos;t sell you.&lt;/p&gt;
&lt;h2&gt;The cheapest option is an API — by a mile&lt;/h2&gt;
&lt;p&gt;Here&apos;s the number that ended the &quot;to save money&quot; version of this project before it started. DeepSeek V3.2 Chat, at my usage, costs about &lt;strong&gt;$2.84 a month&lt;/strong&gt; — and it scores 72–74% on SWE-bench Verified, which is genuinely strong for writing and fixing code. There&apos;s no server, no GPU, nothing to babysit. GLM 4.7 Flash has a free tier that costs literally &lt;strong&gt;$0&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Against that, my best self-hosted plan comes in around &lt;strong&gt;$11 a month&lt;/strong&gt;. So the very first honest thing to say is: if the only axis you care about is cost, and your usage is modest, you stop here. You do not spin up a GPU to replace a $3 API bill. I nearly talked myself out of it on this line alone.&lt;/p&gt;
&lt;h2&gt;So when does self-hosting actually win?&lt;/h2&gt;
&lt;p&gt;The answer is a crossover. An API bill is &lt;em&gt;variable&lt;/em&gt; — it scales with every token. A self-hosted spot box is a &lt;em&gt;fixed&lt;/em&gt; cost — roughly the same whether you send it 12M tokens or 100M. So the more you use it, the better fixed looks.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Monthly usage&lt;/th&gt;&lt;th&gt;DeepSeek Chat (API, variable)&lt;/th&gt;&lt;th&gt;Qwen3.5-27B on a GCP L4 spot (fixed)&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;12M tokens&lt;/td&gt;&lt;td&gt;~$2.84&lt;/td&gt;&lt;td&gt;~$11.20&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;50M tokens&lt;/td&gt;&lt;td&gt;~$11.83&lt;/td&gt;&lt;td&gt;~$11.20&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;100M tokens&lt;/td&gt;&lt;td&gt;~$23.66&lt;/td&gt;&lt;td&gt;~$11.20&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The lines cross at roughly &lt;strong&gt;50M tokens a month&lt;/strong&gt;. Below that, the API is cheaper &lt;em&gt;and&lt;/em&gt; less hassle. Above it, the fixed box pulls ahead and keeps pulling. But notice what that framing quietly admits: at 12M tokens, cost is the wrong reason to self-host. The real reasons are the ones that don&apos;t show up in the table — &lt;strong&gt;no rate limits&lt;/strong&gt;, &lt;strong&gt;nothing leaving your own hardware&lt;/strong&gt;, and open weights (Apache 2.0) you fully control. Those are worth paying a small premium for. &quot;Saving money&quot; is not why you&apos;d do it at my scale.&lt;/p&gt;
&lt;h2&gt;The best self-hosted coder — and the catch I only found by measuring&lt;/h2&gt;
&lt;p&gt;On paper, the pick is easy. Qwen3.5-27B scores &lt;strong&gt;72.4% on SWE-bench Verified&lt;/strong&gt; — level with DeepSeek — fits on a single 24GB L4 at a 6-bit quant, and costs about $11 a month on GCP spot. Same accuracy as the API, on hardware I own, for pocket change. Done, surely.&lt;/p&gt;
&lt;p&gt;Then I put a stopwatch on it. On the L4, that 27B dense model generates at around &lt;strong&gt;13 tokens per second&lt;/strong&gt;. For a chat-style question that&apos;s fine. For an &lt;em&gt;agentic&lt;/em&gt; coding loop — the kind that re-reads files, calls tools, and takes several turns to land a change — 13 tok/s is painful. The spec sheet sold me a model; the stopwatch sold me a much slower one. This is the whole lesson of the project in one line: &lt;strong&gt;never trust a throughput number you didn&apos;t measure yourself.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;There was a second trap underneath it. Qwen3.5&apos;s hybrid architecture has known KV-cache bugs in llama.cpp right now (and therefore in Ollama), which make multi-turn sessions reprocess the whole prompt each turn. The workaround is real and worth knowing: serve it with &lt;strong&gt;vLLM&lt;/strong&gt;, which handles the architecture properly.&lt;/p&gt;
&lt;h2&gt;The trade you&apos;re really making: speed vs. reasoning&lt;/h2&gt;
&lt;p&gt;Once you accept that a slow-but-accurate model is frustrating to code with, the interesting options are the fast Mixture-of-Experts ones. Qwen3.5-35B-A3B runs several times faster than the 27B; GLM 4.7 Flash does 100–145 tok/s and is built for running a few agents at once. More speed means more attempts in the same minute, and for iterative coding, more attempts often beats a single more-accurate one.&lt;/p&gt;
&lt;p&gt;The catch — there&apos;s always a catch — is that the fast MoE models keep only a few billion parameters &quot;active&quot; per token, so their &lt;em&gt;reasoning&lt;/em&gt; on hard, tangled problems is noticeably weaker. Which points at the setup that actually works: a fast local model for the 80% that&apos;s iteration, and a strong API reasoner (DeepSeek, or Claude Sonnet when it has to be right) for the 20% that&apos;s genuinely hard. Not either/or. Both, on purpose.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;The transferable bit&lt;/h4&gt;
&lt;p&gt;Two habits travel far beyond this one project. First: separate the reason from the number. &quot;It&apos;s cheaper&quot; and &quot;it&apos;s unlimited/private/mine&quot; are different goals, and only one of them survives contact with a $2.84 API bill — be clear which you&apos;re buying. Second: measure the thing that matters to &lt;em&gt;you&lt;/em&gt; (here, tokens per second in a real agent loop), because the headline benchmark and the number that decides whether a tool is pleasant to use are rarely the same number.&lt;/p&gt;
&lt;/div&gt;
&lt;h2&gt;Where I actually landed&lt;/h2&gt;
&lt;p&gt;Start on DeepSeek Chat at ~$3 a month — validate that the quality is good enough for your work at essentially zero cost and zero risk. Reach for a self-hosted Qwen3.5 on a GCP L4 spot the day you actually need what the API can&apos;t give you: unlimited tokens, or your code staying on your own box. Serve it with vLLM, expect to pair it with an API reasoner for the hard problems, and go in knowing you&apos;re buying control, not a discount.&lt;/p&gt;
&lt;p&gt;If you&apos;re weighing build-vs-buy on AI infrastructure and want the honest version — costs, crossovers, and the caveats the spec sheets leave out — that&apos;s the kind of work I do at twentytwotensors. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>LLM</category></item><item><title>A weekly adequacy number from data you already log</title><link>https://twentytwotensors.co.uk/blog/posts/a-weekly-adequacy-number-from-data-i-already-log/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/a-weekly-adequacy-number-from-data-i-already-log/</guid><description>A bonus part. There&apos;s one tile I keep sketching: a weekly &quot;am I getting enough treatment?&quot; number computed from data I already log, no blood draw. Here&apos;s how I&apos;d build it — and the one honest gate that decides whether it ships at all.</description><pubDate>Thu, 09 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I ended this series on the readiness score, and meant it as a close. But one tile kept nagging at me — I&apos;ve sketched it and deleted it more than once — because it&apos;s the most useful number I don&apos;t currently have, and also the one I&apos;m most afraid of getting wrong. So this is a bonus part: not something I&apos;ve shipped, but a plan, and an honest account of why I haven&apos;t pressed the button yet.&lt;/p&gt;
&lt;h2&gt;The question I can only answer a few times a year&lt;/h2&gt;
&lt;p&gt;Every week I&apos;d like to know one thing: am I actually getting &lt;em&gt;enough&lt;/em&gt; treatment? The clinic answers that with a standard adequacy measure, taken from a blood test every few months. I wanted a read every &lt;em&gt;week&lt;/em&gt; — and ideally from data I already log, without another needle. The catch is that a number about my own health is exactly the kind of thing you must not fake, so the whole design turned into an exercise in scoping honestly.&lt;/p&gt;
&lt;h2&gt;Why it was confusing — and how I made it simple&lt;/h2&gt;
&lt;p&gt;That measure isn&apos;t one number, and that&apos;s the trap. There&apos;s the clinic&apos;s version, which needs a blood marker measured &lt;em&gt;before and after&lt;/em&gt; each session — data I don&apos;t have. And there&apos;s a volumetric version, which needs exactly the things the app already records: how much treatment fluid a session used, how long it ran, and an estimate of my body water.&lt;/p&gt;
&lt;p&gt;The honest move was to stop trying to reproduce the clinic&apos;s figure. That was the source of all the ambiguity — chasing a number my data can&apos;t support. Drop that goal, scope to the version my logged data &lt;em&gt;can&lt;/em&gt; actually compute, and frame it plainly as a personal weekly trend rather than a clinical result. The moment I let it be a different (well-defined) number instead of a worse copy of the clinic&apos;s, it became buildable.&lt;/p&gt;
&lt;h2&gt;Turning a week of sessions into one verdict&lt;/h2&gt;
&lt;p&gt;The shape is simple. A one-time profile — height, age, sex — feeds a standard formula for body water. Each session I log becomes a small adequacy figure. The week&apos;s sessions roll up into a single standardised weekly number, checked against the published target for that measure. On the home screen it&apos;s one tile you glance at — an illustrative week might read &lt;em&gt;&quot;above target&quot;&lt;/em&gt; — tappable into a per-session breakdown so a weak session is visible instead of hidden in the average.&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/weekly-adequacy-rollup.png&quot; alt=&quot;A row of session bars across a week consolidating into a single weekly adequacy gauge sitting just above a target line&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;p&gt;Everything it needs is already in the app. No new sensor, no new blood draw — just arithmetic over numbers I already record several times a week.&lt;/p&gt;
&lt;h2&gt;The one gate I won&apos;t skip&lt;/h2&gt;
&lt;p&gt;Here&apos;s the honest part, and it&apos;s the whole reason this tile isn&apos;t live. Buried in that calculation is one approximation I don&apos;t yet trust — roughly how saturated the used treatment fluid is with what&apos;s being cleared. I&apos;ve penciled in a reasonable value, but it&apos;s a guess, and it sits right in the middle of the result. Get it wrong and the tile doesn&apos;t fail loudly; it shows a confident, wrong number about my health. That&apos;s worse than showing nothing.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;The transferable bit&lt;/h4&gt;
&lt;p&gt;A number that&apos;s confidently wrong is worse than no number — especially in health, and especially when it looks tidy. So the rule for shipping this is firm: validate the one shaky approximation against ground truth (a real week checked against a known-good figure from the machine or the clinic) before it goes anywhere near the home screen. And when the data is thin — a missing session, less than a week logged — the tile says &quot;not enough data yet,&quot; never a guess dressed up as a fact. Design the honest fallback before the happy path.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;That&apos;s the whole philosophy of this app in a single tile: surface a genuinely useful signal from data you already have — but never a confident guess wearing the costume of a measurement. Whether this one ships comes down entirely to that validation week. If it checks out, it&apos;s a real weekly answer to a question I currently guess at. If it doesn&apos;t, it stays a sketch, and that&apos;s the right outcome too.&lt;/p&gt;
&lt;p&gt;If you&apos;re building health or safety tooling where a wrong number is worse than a missing one — and you want the version that&apos;s honest about its own uncertainty — that&apos;s the kind of work I do at twentytwotensors. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>Building in Public</category></item><item><title>Backing up everything that matters, for pennies a month</title><link>https://twentytwotensors.co.uk/blog/posts/backing-up-everything-that-matters-with-restic/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/backing-up-everything-that-matters-with-restic/</guid><description>Everything I&apos;d hate to lose is text — a notes vault and a folder of code. So the backup I needed was small, cheap and boring: encrypted, versioned, daily, offsite, for ~2p a month with restic and Google Cloud Storage. And the three things that make a backup real that the tool won&apos;t do for you.</description><pubDate>Thu, 09 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Everything I&apos;d genuinely hate to lose is text. An Obsidian vault I think in, and a folder of code repositories. Not photos, not video — just the stuff I&apos;ve written and built. Which is good news, because it means the backup I actually needed is small, cheap, and dull. I built it in one afternoon: an &lt;strong&gt;encrypted, versioned, daily, offsite&lt;/strong&gt; copy of everything irreplaceable, for roughly &lt;strong&gt;2p a month&lt;/strong&gt;. Here&apos;s how, and — more usefully — the three things that make a backup real, none of which the tool does for you.&lt;/p&gt;
&lt;h2&gt;First I decided what NOT to back up&lt;/h2&gt;
&lt;p&gt;I started where most people start: &quot;back up the whole machine.&quot; Then I actually looked into a bootable, bare-metal restore on macOS and concluded it basically isn&apos;t a thing without a local Time Machine drive — and I wanted the copy &lt;em&gt;off&lt;/em&gt; the machine, in the cloud. So I stopped chasing the heroic version and asked a better question: what&apos;s genuinely irreplaceable here?&lt;/p&gt;
&lt;p&gt;The answer was small. The vault, and the &lt;em&gt;source&lt;/em&gt; of my repos — code and git history only. Everything else is re-downloadable: the OS, the apps, &lt;code&gt;node_modules&lt;/code&gt;, Python venvs, the Flutter SDK, Terraform state, model weights, big data folders. All excluded. That one decision — naming what you&apos;re deliberately &lt;em&gt;not&lt;/em&gt; protecting — is what shrank the job from &quot;image a 500GB laptop&quot; to a &lt;strong&gt;1.22 GiB&lt;/strong&gt; first snapshot. Cheap and trustworthy start with scope.&lt;/p&gt;
&lt;h2&gt;restic, into a Google Cloud Storage bucket&lt;/h2&gt;
&lt;p&gt;The tool is &lt;a href=&quot;https://restic.net&quot; rel=&quot;noopener&quot;&gt;restic&lt;/a&gt;, backing up to a GCS bucket. Three properties made it the pick over an rsync copy or a nightly tarball:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Client-side encryption&lt;/strong&gt; — Google only ever stores ciphertext. The data is readable on my laptop and nowhere else.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deduplication&lt;/strong&gt; — a daily run on a vault that barely changed stores almost nothing new, so &quot;daily&quot; is nearly free.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Snapshot history&lt;/strong&gt; — point-in-time restore. It&apos;s a Time Machine for the cloud, not a single overwritten mirror. rsync and tarballs can&apos;t give you that, so they were out.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The retention I wanted — keep &lt;strong&gt;7 daily, 4 weekly, 6 monthly&lt;/strong&gt; restore points and prune the rest — is a single &lt;code&gt;restic forget&lt;/code&gt; policy. A launchd agent runs it every day at noon (and on wake if the laptop was asleep). At GCS Standard prices, 1.22 GiB is a rounding error: a couple of pence a month, all in.&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/vault-backup-retention.png&quot; alt=&quot;A receding ladder of backup restore points — dense daily points near today thinning to weekly then monthly further back in time&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;h2&gt;The one secret that has to outlive the machine&lt;/h2&gt;
&lt;p&gt;Client-side encryption has an uncomfortable flip side, and it&apos;s worth saying plainly: &lt;strong&gt;if you lose the passphrase, the backup is gone.&lt;/strong&gt; Not &quot;phone support&quot; gone — mathematically gone. So the passphrase lives in two places, on purpose. In the Mac&apos;s Keychain, so the daily automated run can read it unattended. And escrowed in my password manager — the copy that survives if the laptop is lost, stolen, or dead. That second copy is the whole point: in a real disaster the machine is &lt;em&gt;gone&lt;/em&gt;, and the entire value of the backup collapses onto one string of characters stored somewhere else. Guard it like it&apos;s the backup, because it is.&lt;/p&gt;
&lt;h2&gt;A backup you&apos;ve never restored from is just a hope&lt;/h2&gt;
&lt;p&gt;The last step is the one everyone skips: I ran a restore drill. Pulled the vault and a repo back down from the cloud and diffed them against the originals — byte-identical. That drill is the entire difference between &quot;I have backups&quot; and &quot;I have restores,&quot; and only one of those is worth anything at 2am.&lt;/p&gt;
&lt;p&gt;It matters because backups fail in stupid, silent ways. Building this, a code review caught a real one: an empty-array expansion in the backup script that crashes under &lt;code&gt;set -u&lt;/code&gt; on the ancient bash 3.2 that ships with macOS — the kind of bug that quietly turns a &quot;daily backup&quot; into nothing. So the job also emits a macOS notification if a run errors or the last success is more than two days old. A backup that fails quietly is identical to no backup at all.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;The transferable bit&lt;/h4&gt;
&lt;p&gt;A backup is only ever as good as three things the tool won&apos;t decide for you: what you&apos;re deliberately &lt;em&gt;not&lt;/em&gt; protecting (scope is what keeps it cheap and simple), the one secret that must outlive the hardware (and where a second copy lives), and proof that a restore actually works (a drill, not a promise). The software — restic, a bucket, a scheduled job — is the easy 20%.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;It runs at noon every day and I&apos;ve stopped thinking about it, which was the entire goal. If you want infrastructure that&apos;s boring on purpose — the kind you can forget because it&apos;s actually correct — that&apos;s the sort of work I do at twentytwotensors. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>Building in Public</category></item><item><title>Three ways to OCR a document, and what each one costs</title><link>https://twentytwotensors.co.uk/blog/posts/cheap-serverless-ocr-what-each-option-costs/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/cheap-serverless-ocr-what-each-option-costs/</guid><description>I set out to self-host a shiny OCR vision-model and fell down a cold-start rabbit hole. The fix was a question I&apos;d skipped: do I need document understanding, or just text and coordinates? Self-hosted VLM vs a 6MB CPU pipeline vs a hosted API — and what each actually costs.</description><pubDate>Thu, 09 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I wanted something modest: OCR some PDFs, cheaply, without babysitting a server. And because there&apos;s a good open-weight vision-language OCR model now — GLM-OCR, 0.9B parameters, top of the OmniDocBench leaderboard — my instinct was to self-host it. That instinct cost me an afternoon and taught me the actual lesson, which is about the &lt;em&gt;question&lt;/em&gt;, not the model.&lt;/p&gt;
&lt;h2&gt;The self-hosting rabbit hole&lt;/h2&gt;
&lt;p&gt;Serving a 0.9B vision model is a GPU job, and GPUs don&apos;t like being idle-and-cheap at the same time. I walked the usual ladder — spot VM, then a container pipeline, then Cloud Run for scale-to-zero — and hit the tax at every rung. On Cloud Run, a cold start is &lt;strong&gt;30–60 seconds&lt;/strong&gt; before the first page is even read. AWS Lambda, the natural &quot;cheap and serverless&quot; pick, is out entirely for a model like this: 15-minute timeout, a 6 MB response cap, billing for init. I was engineering hard to make an expensive shape fit a cheap budget.&lt;/p&gt;
&lt;h2&gt;The question I&apos;d skipped: do I even need a vision model?&lt;/h2&gt;
&lt;p&gt;Then I asked the thing I should have asked first. There are really two OCR jobs, and they get conflated constantly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Text + coordinates&lt;/strong&gt; — &quot;what words are here, and where?&quot; A classic detect-then-recognise pipeline. Plain text out, bounding boxes out.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Document understanding&lt;/strong&gt; — tables reconstructed, reading order, structured Markdown or JSON. This is what a vision-language model buys you, and it&apos;s genuinely harder.&lt;/li&gt;
&lt;/ul&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/serverless-ocr-decision.png&quot; alt=&quot;A decision fork branching on one question: text-plus-coordinates leading to a small CPU pipeline, or document understanding leading to a hosted API, with a third rarely-taken path to a self-hosted vision model behind a privacy padlock&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;p&gt;I&apos;d assumed I needed the second and was paying for it. I needed the first. And once you only need text and coordinates, the model gets absurdly small. PP-OCRv6 (PaddleOCR) is a detect→recognise pipeline whose &lt;strong&gt;small tier is ~31 MB and its tiny tier ~6 MB&lt;/strong&gt; — hundreds of times smaller than the vision model. A 6 MB CPU model doesn&apos;t just save money, it deletes the entire problem I&apos;d been solving: no GPU, no meaningful cold start, and Lambda and the Cloud Run free tier become trivially sufficient. The infrastructure pain was self-inflicted by the model choice.&lt;/p&gt;
&lt;h2&gt;The plot twist: sometimes you should just pay the API&lt;/h2&gt;
&lt;p&gt;If you do need real document understanding — tables, structure, clean Markdown — there&apos;s a third option that beats self-hosting anything: a hosted OCR API. Mistral&apos;s OCR runs about &lt;strong&gt;$2 per 1,000 pages&lt;/strong&gt; (roughly half that in batch mode), returns structured Markdown with reconstructed tables, and has a zero-second cold start because it&apos;s someone else&apos;s always-warm GPU. For comparison, AWS Textract is around &lt;strong&gt;$65 per 1,000 pages&lt;/strong&gt; — a hosted API isn&apos;t automatically the expensive option; this one is ~30× cheaper than the incumbent. When your volume is a few thousand pages, &quot;pay pennies per page and write no infrastructure&quot; is very hard to beat.&lt;/p&gt;
&lt;h2&gt;Reading the numbers honestly&lt;/h2&gt;
&lt;p&gt;Two honesty notes, because this is where these comparisons usually mislead. First: &lt;strong&gt;don&apos;t reflexively pick the smallest model.&lt;/strong&gt; For personal batch work, latency is irrelevant — nobody&apos;s waiting — so you should optimise accuracy, not size. Going from PP-OCRv6 tiny to small lifts recognition accuracy from ~73.5% to ~81.3% (about +8 points) for a cost that&apos;s still effectively nothing. The tiny tier is for when milliseconds matter, which for a batch job they don&apos;t.&lt;/p&gt;
&lt;p&gt;Second, and I want to be straight about it: I haven&apos;t run the bake-off yet. The vision-model CPU throughput figures are estimates, the hosted-API accuracy numbers are the vendor&apos;s own benchmarks (not independently verified), and the real test — quality on &lt;em&gt;my&lt;/em&gt; actual documents — is still to do. This is a scoping-and-costing exercise, not a validated result, and the honest next step is to run the same twenty pages through all three and look.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;The transferable bit&lt;/h4&gt;
&lt;p&gt;Before you optimise &lt;em&gt;how&lt;/em&gt; you deploy a model, check you&apos;ve picked the right &lt;em&gt;class&lt;/em&gt; of model. Half the infrastructure problems in an ML project are self-inflicted by reaching for a bigger model than the task needs — I spent an afternoon fighting GPU cold starts for a job a 6 MB CPU model does on a free tier. The cheapest deployment is the one you delete by asking &quot;what do I actually need out of this?&quot; first.&lt;/p&gt;
&lt;/div&gt;
&lt;h2&gt;What I&apos;d actually run&lt;/h2&gt;
&lt;p&gt;Model first, then deployment. For plain text and positions: &lt;strong&gt;PP-OCRv6 small, self-hosted on CPU&lt;/strong&gt; — a spot VM for batches, or Cloud Run&apos;s free tier for the occasional job. For real structured document understanding: &lt;strong&gt;the Mistral OCR API&lt;/strong&gt; — pennies a page, zero infrastructure. Self-host the vision model only in the one case that actually forces it: you need document understanding &lt;em&gt;and&lt;/em&gt; privacy rules out sending pages to an API. That&apos;s a real case — but it&apos;s the exception, not the default I started with.&lt;/p&gt;
&lt;p&gt;If you&apos;ve got a document-processing pipeline to cost out — and you want the honest split between &quot;self-host this&quot; and &quot;just pay for that&quot; — it&apos;s the kind of work I do at twentytwotensors. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>MLOps</category></item><item><title>How RAPTOR actually works — and the three ways my code quietly wasn&apos;t it</title><link>https://twentytwotensors.co.uk/blog/posts/how-raptor-actually-works/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/how-raptor-actually-works/</guid><description>RAPTOR builds a tree of LLM summaries over your documents so retrieval can pull the right altitude of answer. The idea is elegant; implementing it faithfully is where the bodies are buried — the paper names two of the fixes itself, and a third was a reasoning model silently eating its own token budget.</description><pubDate>Thu, 09 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I have a corpus of clinical guidelines — NICE, KDIGO, UKKA — and they have an annoying property for retrieval: the same fact is spread across all of them, rephrased a dozen ways. Plain semantic search over that is &lt;em&gt;unstable&lt;/em&gt;. Ask the same question twice and it lands on a different paraphrase each time, because twenty near-identical chunks are all a similar distance from the query.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://arxiv.org/abs/2401.18059&quot; rel=&quot;noopener&quot;&gt;RAPTOR&lt;/a&gt; (Sarthi et al., Stanford, ICLR 2024) is a clean answer to exactly that. And I implemented it, and it worked — until I read the paper properly and found three places my &quot;RAPTOR&quot; had quietly drifted from the real thing. This is what it actually does, and what those drifts cost.&lt;/p&gt;
&lt;h2&gt;The idea: a tree of summaries over the untouched source&lt;/h2&gt;
&lt;p&gt;Most retrieval indexes your chunks and stops. RAPTOR keeps the chunks (the leaves — you still want them for detail and for citing a source), then &lt;strong&gt;builds a consolidation layer on top&lt;/strong&gt;. The build is bottom-up and recursive:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Chunk&lt;/strong&gt; the documents into small ~100-token pieces, never splitting a sentence.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Embed&lt;/strong&gt; them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cluster&lt;/strong&gt; the embeddings — reduce dimensions with UMAP, then fit a &lt;em&gt;soft&lt;/em&gt; Gaussian Mixture Model (a chunk can belong to more than one cluster), using BIC to choose how many clusters there are.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Summarise&lt;/strong&gt; each cluster with an LLM (the paper gets ~72% compression), then embed those summaries and recurse — cluster the summaries, summarise &lt;em&gt;them&lt;/em&gt; — until you can&apos;t cluster any further.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What you end up with is a tree: raw passages at the bottom, tighter and tighter summaries above. Those scattered paraphrases of one fact get pulled into a single summary node. That&apos;s the whole point for a redundant corpus — not a benchmark bump, but a &lt;em&gt;stable&lt;/em&gt; place for a diffuse idea to live.&lt;/p&gt;
&lt;h2&gt;The retrieval trick most people miss&lt;/h2&gt;
&lt;p&gt;There are two ways to query the tree, and the difference matters. &lt;strong&gt;Tree traversal&lt;/strong&gt; starts at the root and walks down, top-k at each layer. &lt;strong&gt;Collapsed tree&lt;/strong&gt; throws every layer into one big pool — leaves and summaries together — and just retrieves the most similar nodes up to a token budget (~2000 tokens, ~20 nodes).&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/raptor-collapsed-tree.png&quot; alt=&quot;All layers of a summary tree flattened into a single pool of nodes, with a query pulling a mix of leaf and summary nodes from different heights&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;p&gt;Collapsed tree wins consistently, and it&apos;s the paper&apos;s default. The reason is lovely: it lets the &lt;em&gt;granularity of the answer float&lt;/em&gt;. A broad question surfaces a high summary node; a specific one surfaces a leaf. You don&apos;t decide the altitude up front — the query does. In the paper&apos;s own results, &lt;strong&gt;18.5–57% of retrieved nodes come from the summary layers&lt;/strong&gt;, which is the evidence that the summaries are earning their keep rather than just padding the index. (For the headline-chasers: QuALITY jumps to 82.6% with GPT-4, up from a 62.3% prior best. But the granularity story is the part you&apos;ll actually feel.)&lt;/p&gt;
&lt;h2&gt;Then I compared my code to the paper&lt;/h2&gt;
&lt;p&gt;Here&apos;s the honest half. &quot;I implemented RAPTOR&quot; and &quot;I implemented RAPTOR &lt;em&gt;faithfully&lt;/em&gt;&quot; turned out to be two different claims, and the gap between them is where every bug lived. Three of them.&lt;/p&gt;
&lt;h3&gt;1. The cluster with no size limit&lt;/h3&gt;
&lt;p&gt;My summariser concatenated &lt;em&gt;all&lt;/em&gt; the text in a cluster and sent it to the LLM in one go. Fine for a small cluster. But a populous layer-1 cluster could pull together 60k+ tokens of member text — well past the model&apos;s limit — which meant silent truncation and a corrupt summary node sitting quietly in the tree. The paper&apos;s clusters are bounded by its recursive small-cluster structure, so its ~72% compression assumes a digestible input. The fix is the obvious one once you see it: &lt;strong&gt;map-reduce&lt;/strong&gt; — pack members into budget-sized batches, summarise each, then summarise the summaries.&lt;/p&gt;
&lt;h3&gt;2. Single-stage clustering instead of global-then-local&lt;/h3&gt;
&lt;p&gt;I did one UMAP+GMM pass. The paper does &lt;strong&gt;two&lt;/strong&gt;: a coarse &lt;em&gt;global&lt;/em&gt; clustering, then a &lt;em&gt;local&lt;/em&gt; clustering within each global cluster. I&apos;d skimmed right past it — but that global stage is &lt;em&gt;exactly&lt;/em&gt; the thing that groups paraphrases scattered across different documents. For my redundant corpus, it wasn&apos;t a detail; it was the highest-leverage fidelity fix in the whole method. The paper didn&apos;t just validate my approach, it named the two things I&apos;d gotten wrong.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;The transferable bit&lt;/h4&gt;
&lt;p&gt;When you implement a paper, the dangerous bugs aren&apos;t the parts you found hard — they&apos;re the parts you found &lt;em&gt;easy&lt;/em&gt; and skimmed. I &quot;knew&quot; my leaves were roughly the right size, so I never measured them; I assumed they were a uniform 2000 tokens (a 20× mismatch with the paper). Measuring 2,650 real chunks showed a median of ~190 tokens — close to the paper — but a spread from 1 to 2,084, with 24% over 800. The &lt;em&gt;variance&lt;/em&gt; was the problem, and it&apos;s precisely what detonated bug #1. Measure the thing you assumed, especially when the assumption felt too obvious to check.&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;3. A reasoning model eating its own budget&lt;/h3&gt;
&lt;p&gt;The one that actually hurt. After the fixes, a build finished cleanly — and &lt;strong&gt;508 of 647 summary nodes were zero bytes.&lt;/strong&gt; Eighty percent empty, reported as success.&lt;/p&gt;
&lt;p&gt;The cause: I capped summary generation at 1024 tokens, and my summariser was a &lt;em&gt;reasoning&lt;/em&gt; model that emits a long &lt;code&gt;&amp;#x3C;thought&gt;&lt;/code&gt; block before its answer. The 1024 tokens were entirely consumed by thinking; the model hit the cap mid-thought, produced no answer, and my code — which strips the thought block — was left with an empty string, dutifully cached as a 0-byte &quot;summary.&quot; A raw-API repro confirmed it: 1024 tokens in, pure thought out, nothing after. Raising the cap to 4096 and refusing to cache empty results fixed it (and I made truncation log loudly instead of silently, so the next one can&apos;t hide). The deeper lesson was model choice: a small reasoning model (MedGemma-4B) was simply unfit — it truncated, looped, and leaked planning tokens — and I landed on a non-reasoning Gemma variant that just writes the summary.&lt;/p&gt;
&lt;h2&gt;Why bother telling you the drifts?&lt;/h2&gt;
&lt;p&gt;Because &quot;we use RAPTOR&quot; is on a hundred architecture diagrams, and the interesting question is never whether you use it — it&apos;s whether the tree you built is the one the paper describes. Mine wasn&apos;t, in three specific ways, and each one degraded retrieval invisibly: no crash, no error, just quietly worse answers. The method is elegant. The fidelity is the work.&lt;/p&gt;
&lt;p&gt;If you&apos;re building retrieval over a messy, redundant corpus and want it done faithfully — and debugged down to why a node came back empty — that&apos;s the kind of work I do at twentytwotensors. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>RAG &amp; Agents</category></item><item><title>The hard part of a personal AI agent isn&apos;t the agent — it&apos;s the memory</title><link>https://twentytwotensors.co.uk/blog/posts/the-hard-part-of-a-personal-ai-agent-is-memory/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/the-hard-part-of-a-personal-ai-agent-is-memory/</guid><description>I wanted a daily chat companion that remembers me across sessions. Building the conversation is easy; the memory is the whole problem. A four-tier design modelled like an Obsidian vault — and the honest twist: three tools each suggested I was hand-building a worse version of something that exists.</description><pubDate>Thu, 09 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I wanted a specific, unglamorous thing: a daily chat companion that knows who I am, remembers across sessions, and can wear a different hat depending on what I&apos;m doing that day. Not a task agent, not a content factory — a companion I&apos;d actually use. Let me be upfront before you read on: &lt;strong&gt;this is a design sketch, not a shipped system.&lt;/strong&gt; Nothing here is built yet. But the design is where all the interesting decisions live, and where I nearly talked myself in circles.&lt;/p&gt;
&lt;h2&gt;The conversation is easy. Remembering is the problem.&lt;/h2&gt;
&lt;p&gt;A single turn — you say something, the model replies — is a solved problem. The hard part of a &lt;em&gt;personal&lt;/em&gt; agent is everything between the turns: what does it carry from today into next Tuesday, and how does it do that cheaply enough to run every day without a growing bill or a context window that eventually explodes? &quot;Memory&quot; sounds like one feature. It isn&apos;t. It&apos;s at least four, and they behave differently.&lt;/p&gt;
&lt;h2&gt;Four tiers, and why they&apos;re different jobs&lt;/h2&gt;
&lt;p&gt;The design splits memory into four tiers, each answering a different question:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Working&lt;/strong&gt; — the last few turns, held in the model&apos;s context window. No storage; it evaporates when the chat ends.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Session&lt;/strong&gt; — the full transcript of the current conversation, one row per message. The verbatim record.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Summary&lt;/strong&gt; — a short, embedded recap written &lt;em&gt;after&lt;/em&gt; a session ends, searchable later. One row per conversation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Semantic&lt;/strong&gt; — the distilled, durable facts: &quot;prefers terse replies,&quot; &quot;is learning React,&quot; &quot;works on a consulting brand.&quot; Atomic notes, each typed as a &lt;em&gt;preference&lt;/em&gt;, &lt;em&gt;fact&lt;/em&gt;, or &lt;em&gt;decision&lt;/em&gt;, tagged and embedded.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A small honesty flag lives in that list. I first called the third tier &quot;episodic&quot; — until I realised that in the established agent-memory vocabulary, &quot;episodic&quot; means something specific (past action sequences replayed as examples). I was reading the wrong map and using a word that would mislead anyone fluent in it, so I renamed it &quot;Summary.&quot; Minor, but it&apos;s the kind of terminology drift that quietly makes your design illegible to everyone else in the field.&lt;/p&gt;
&lt;h2&gt;Modelling memory like an Obsidian vault&lt;/h2&gt;
&lt;p&gt;The part I liked most: the semantic tier is modelled like a notes vault. Atomic notes, explicitly &lt;em&gt;linked&lt;/em&gt; to each other — the same instinct as an Obsidian graph, where the connections carry as much meaning as the notes. Except it isn&apos;t markdown files; it&apos;s real Postgres, with a &lt;code&gt;note_links&lt;/code&gt; table for the graph and pgvector for similarity search. A memory-writer — one cheap background model call at the end of each session — reads the transcript and returns structured JSON: a summary to embed, any new facts to file (with their type), and links to notes that already exist.&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/ai-agent-memory-graph.png&quot; alt=&quot;A loose web of small note cards connected by lines, a few cards distinguished by shape to imply different note kinds — preference, fact, decision — evoking an Obsidian graph view&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;p&gt;Retrieval at each turn is hybrid: ordinary SQL filters (which role, which tags, how recent) to narrow the field, then vector similarity to rank what&apos;s left, stitched into the prompt. The role itself is &lt;strong&gt;session-scoped&lt;/strong&gt; — picked when the conversation starts and fixed for its duration. New hat, new session. That one rule sidesteps a whole category of bugs around swapping tools and stale system prompts mid-conversation. The whole thing is designed API-first, on Cloud Run, with the model doing turns and a cheaper model doing the background summarising.&lt;/p&gt;
&lt;h2&gt;The uncomfortable part: I might be over-building it&lt;/h2&gt;
&lt;p&gt;Now the honest twist, and it&apos;s the reason I&apos;m writing this as a sketch and not a launch. While researching, three different tools each independently told me I was hand-rolling a worse version of something that already exists:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;One minimalist agent&apos;s whole philosophy is &quot;keep a small file of high-signal facts and prune it&quot; — suggesting my four tiers might collapse into a single well-curated list.&lt;/li&gt;
&lt;li&gt;A coding agent I use every day stores its memory as &lt;strong&gt;markdown files on disk&lt;/strong&gt;, not a vector database — a strong hint that my &quot;notes vault&quot; instinct was right but my Postgres-and-pgvector machinery was overkill.&lt;/li&gt;
&lt;li&gt;And a purpose-built memory library made the point bluntly: I&apos;d essentially be writing a worse version of it, by hand.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Underneath sits a contradiction I couldn&apos;t resolve on paper. My pull toward &quot;simple, linked, plain-text notes&quot; points at &lt;em&gt;files&lt;/em&gt;. My pull toward &quot;serverless and SQL&quot; points the exact opposite way. Both can&apos;t be the priority. When I reviewed my own notes, the diagnosis was embarrassing and familiar: analysis paralysis — six coherent, mutually exclusive architectures, no decision criterion to choose between them, and a goal that had quietly drifted from &quot;a companion I actually use&quot; to &quot;understand agent memory deeply.&quot; Those are different projects.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;The transferable bit&lt;/h4&gt;
&lt;p&gt;A design can be internally elegant and still be the wrong thing to build. The tell isn&apos;t a flaw in the diagram — it&apos;s when the ecosystem keeps handing you the same feedback from different directions (&quot;this is simpler than you&apos;re making it&quot;), and you keep finding reasons the diagram is special. The fix is a kill criterion and a shipping goal: build the cheapest version that runs, put it in daily use, and let real usage — not more research — decide which tiers earn their keep. Most won&apos;t.&lt;/p&gt;
&lt;/div&gt;
&lt;h2&gt;What&apos;s built, and what&apos;s next&lt;/h2&gt;
&lt;p&gt;Nothing&apos;s built — and that&apos;s the honest headline. The real next move isn&apos;t more architecture; it&apos;s to stop researching, pick the single goal (&quot;a companion I use&quot;), ship the smallest thing that runs, and let a week of actually talking to it tell me whether I need four tiers or one good list. My bet, writing this, is that reality deletes at least two of the four. That&apos;s not a failure of the design — it&apos;s the design&apos;s job to be pruned by use.&lt;/p&gt;
&lt;p&gt;If you&apos;re building an agent that has to remember its users — and you want the version that ships and gets pruned by real usage, not the one that&apos;s perfect on a whiteboard — that&apos;s the kind of work I do at twentytwotensors. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>RAG &amp; Agents</category></item><item><title>Where does dbt fit in three clouds?</title><link>https://twentytwotensors.co.uk/blog/posts/where-dbt-fits-in-three-clouds/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/where-dbt-fits-in-three-clouds/</guid><description>I kept re-drawing the same GCP-vs-AWS-vs-Databricks table. Here&apos;s the version that stuck, built on one idea: dbt doesn&apos;t live in the stack, it runs inside your warehouse — so choosing a stack is really choosing the warehouse. Plus where native tools (Dataform, DLT) beat it.</description><pubDate>Thu, 09 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every time I start a data project I re-Google the same thing. &quot;Is Glue the AWS Dataproc?&quot; &quot;Where does dbt actually run on Databricks?&quot; So I finally drew the map once, properly, and the act of drawing it surfaced the one idea that makes the whole comparison simple. I&apos;ll get to that — but first, the frame that makes any of these stacks legible.&lt;/p&gt;
&lt;h2&gt;Everything is one of three layers&lt;/h2&gt;
&lt;p&gt;Strip the branding off and every cloud data stack is the same three layers: &lt;strong&gt;ingest&lt;/strong&gt; (get data in), &lt;strong&gt;transformation&lt;/strong&gt; (shape it), and &lt;strong&gt;storage &amp;#x26; query&lt;/strong&gt; (keep it, ask it questions). GCP, AWS and Databricks each have a full set of tools for all three — Pub/Sub or Kinesis or Auto Loader for streaming ingest; BigQuery or Redshift or Databricks SQL for the warehouse; and so on. Once you sort tools into those three buckets, the &quot;which cloud&quot; noise drops away and you can actually compare like for like.&lt;/p&gt;
&lt;h2&gt;The idea that makes it click: dbt isn&apos;t in any of them&lt;/h2&gt;
&lt;p&gt;Here&apos;s what I&apos;d never quite articulated. dbt isn&apos;t an ingest tool, isn&apos;t storage, and isn&apos;t a compute engine. It&apos;s a &lt;strong&gt;SQL compiler, test runner and dependency graph&lt;/strong&gt; that runs &lt;em&gt;inside&lt;/em&gt; whichever warehouse already owns the compute. It doesn&apos;t move a single byte of data or spin up a cluster of its own — it turns your &lt;code&gt;SELECT&lt;/code&gt;s into a managed, tested, documented DAG and hands them to BigQuery, or Redshift, or Databricks SQL to execute.&lt;/p&gt;
&lt;p&gt;Which is why it plugs into all three clouds through a thin per-platform &lt;strong&gt;adapter&lt;/strong&gt;:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Warehouse&lt;/th&gt;&lt;th&gt;dbt adapter&lt;/th&gt;&lt;th&gt;Runs on&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;BigQuery&lt;/td&gt;&lt;td&gt;&lt;code&gt;dbt-bigquery&lt;/code&gt;&lt;/td&gt;&lt;td&gt;BigQuery slots&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Redshift&lt;/td&gt;&lt;td&gt;&lt;code&gt;dbt-redshift&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Redshift cluster / Serverless&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Athena (lake on S3)&lt;/td&gt;&lt;td&gt;&lt;code&gt;dbt-athena&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Athena / Trino over S3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Databricks&lt;/td&gt;&lt;td&gt;&lt;code&gt;dbt-databricks&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Databricks SQL warehouse&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Snowflake&lt;/td&gt;&lt;td&gt;&lt;code&gt;dbt-snowflake&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Snowflake (the most common pairing of all)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Follow that thread and a nice conclusion falls out: &lt;strong&gt;the warehouse is the only real variable.&lt;/strong&gt; Swap warehouses and you swap one adapter; your ingest (Fivetran, Airbyte), your transforms (dbt models), and your orchestration (Airflow) all travel with you. Choosing a stack is mostly choosing where the compute lives.&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/cloud-data-stack-layers.png&quot; alt=&quot;Three vertical cloud columns each split into ingest, transformation and storage bands, with a single horizontal dbt ribbon threading across all three at the transformation band&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;h2&gt;Where dbt wins — and where the native tool beats it&lt;/h2&gt;
&lt;p&gt;The honest part, because &quot;just use dbt everywhere&quot; is lazy advice. Each platform has a native transform layer that can genuinely beat dbt for its home turf:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Dataform&lt;/strong&gt; (GCP) — now free inside BigQuery, Git-in-the-console, zero setup. For a BigQuery-only shop it&apos;s less to run.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Delta Live Tables&lt;/strong&gt; (Databricks) — streaming-first, with a built-in data-quality expectations framework dbt doesn&apos;t have natively.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AWS Glue Workflows&lt;/strong&gt; — not even a dbt competitor; it&apos;s a different layer (Spark/compute orchestration), and conflating them is a common mistake.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So the rule of thumb I settled on: &lt;strong&gt;dbt wins on portability, community, testing ergonomics and auto-generated docs. Native tools (Dataform, DLT) win on zero setup and streaming.&lt;/strong&gt; Reach for dbt when the team already knows it, or when there&apos;s any real chance you&apos;ll switch warehouses. And know its edges: dbt doesn&apos;t move data, doesn&apos;t do heavy Spark/Python work, and doesn&apos;t schedule itself in production — it still needs Airflow, dbt Cloud, or Databricks Workflows to run on a cadence.&lt;/p&gt;
&lt;h2&gt;Three default stacks (and the hybrid everyone actually runs)&lt;/h2&gt;
&lt;p&gt;At a readable altitude, the out-of-the-box paths are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;GCP:&lt;/strong&gt; GCS → Dataflow / BigQuery Data Transfer → BigQuery → &lt;strong&gt;dbt or Dataform&lt;/strong&gt; → Looker.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AWS:&lt;/strong&gt; S3 → DMS / Firehose / Glue → S3 + Glue Catalog (lakehouse) or Redshift → &lt;strong&gt;dbt or Glue Spark&lt;/strong&gt; → QuickSight.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Databricks:&lt;/strong&gt; object store → Auto Loader / DLT → Delta Lake on Unity Catalog → &lt;strong&gt;dbt or DLT or notebooks&lt;/strong&gt; → Databricks SQL.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;But the stack most teams actually run isn&apos;t any single vendor&apos;s poster — it&apos;s the hybrid: &lt;strong&gt;Fivetran or Airbyte for SaaS ingest, dbt for the SQL transforms, Airflow for orchestration&lt;/strong&gt;, pointed at whichever warehouse they picked. Which is the earlier point again, from the other direction: the warehouse is the variable, everything around it is portable.&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/cloud-data-stack-decision.png&quot; alt=&quot;A decision fork from one starting node branching into three outcomes: a portability path to dbt, a zero-setup or streaming path to native tools, and a notebook-first path to Databricks&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;h2&gt;So which do I pick?&lt;/h2&gt;
&lt;p&gt;The short heuristics that survive contact with a real project:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cheapest to start, small data:&lt;/strong&gt; BigQuery — pay-per-query, no cluster idling. S3 + Athena a close second.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ML-adjacent, notebook-first:&lt;/strong&gt; Databricks — the only one where notebooks are a first-class production artifact.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Already on Kafka:&lt;/strong&gt; AWS (MSK) or Databricks (Structured Streaming).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Best governance out of the box:&lt;/strong&gt; Unity Catalog on Databricks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Most vendor-neutral:&lt;/strong&gt; dbt + Iceberg + object store — avoids lock-in, at the cost of writing more glue yourself.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;The transferable bit&lt;/h4&gt;
&lt;p&gt;When a tool seems to fit everywhere, it&apos;s usually because it&apos;s solving a different problem than the ones it sits next to. dbt feeling cloud-agnostic isn&apos;t magic — it&apos;s because it deliberately owns only the transform-as-code layer and delegates compute, ingest and scheduling to whatever&apos;s around it. Find the one layer a tool actually owns, and both its portability and its limits stop being surprising.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;Two honest caveats before you take any of this as gospel: I haven&apos;t put concrete pound figures on a real workload here (a 1 TB/day medallion build would tell a sharper cost story), and the Iceberg-everywhere and dbt-Fusion stories are both moving fast enough in 2026 that the adapter picture may shift under us. Treat this as the decision map, not the final invoice.&lt;/p&gt;
&lt;p&gt;If you&apos;re choosing a data stack and want the version grounded in your workload and budget rather than a vendor diagram, that&apos;s the kind of work I do at twentytwotensors. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>Data Engineering</category></item><item><title>Chunking clinical guidelines — where recall is won or lost</title><link>https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-chunking/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-chunking/</guid><description>Recall was the ceiling — and recall is decided at chunk time. Docling OCR, section-first splitting, 2000-token chunks, the 512-token embedding catch, and a duplicate corpus. A MedGemma CKD deep-dive.</description><pubDate>Wed, 08 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Here&apos;s the uncomfortable truth the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-evaluation&quot;&gt;evaluation&lt;/a&gt; surfaced: the single biggest weakness across every retriever is recall — the passage that best answers a question often just isn&apos;t in the retrieved set. And retrieval can only return chunks that exist. If the right sentence got split across two chunks, or buried in a 2,000-token wall of unrelated text, no retriever recovers it. Recall is a chunking problem before it&apos;s anything else.&lt;/p&gt;
&lt;h2&gt;PDF to clean text&lt;/h2&gt;
&lt;p&gt;Clinical guidelines are hostile source material — multi-column layouts, tables, figures, footnotes, cross-references. The documents go through Docling for OCR and layout parsing, which turns each PDF into structured markdown with the headings, lists and tables preserved rather than flattened into a soup of text. Getting this stage right matters more than it looks: everything downstream inherits its mistakes.&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/medgemma-chunking-inline.png&quot; alt=&quot;A wide two-thousand-token chunk being fed into a narrow five-hundred-token embedding window, its tail spilling past the edge unread&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;h2&gt;Split by section, then by size&lt;/h2&gt;
&lt;p&gt;A good chunk is about one thing. Guidelines hand you that structure for free — numbered sections and headings — so the splitter follows the document&apos;s own boundaries first, keeping a section together as a unit. Only when a section runs long does it sub-split, with a cap of &lt;strong&gt;2,000 tokens&lt;/strong&gt; per chunk and a &lt;strong&gt;200-character overlap&lt;/strong&gt; between consecutive chunks so a fact that straddles a boundary still appears whole in at least one of them. Splitting by structure beats splitting by a fixed window, because a fixed window happily cuts a dosing table in half.&lt;/p&gt;
&lt;h2&gt;The 512-token catch&lt;/h2&gt;
&lt;p&gt;An honest wrinkle I&apos;ll name rather than hide. The embedding model truncates its input at &lt;strong&gt;512 tokens&lt;/strong&gt; — but chunks can be up to 2,000. So a long chunk is embedded on roughly its first quarter, and the tail contributes nothing to whether it&apos;s retrieved. That mismatch is almost certainly part of the recall problem, and it&apos;s near the top of the fix list: either bring the chunk size down toward the embedding window, or embed a long chunk as several sub-windows and pool them. It&apos;s the kind of quiet defect that a metric points at but a demo never shows.&lt;/p&gt;
&lt;h2&gt;The duplicate corpus I didn&apos;t know I had&lt;/h2&gt;
&lt;p&gt;Late in the project I found the store held &lt;strong&gt;two ingestion passes&lt;/strong&gt; — 43 document names for roughly 22 guidelines, because an earlier, coarser pass had never been cleaned out. Every retriever comparison had quietly been running over duplicated text. De-duplicating brought it down to 27 clean documents and changed the retriever ranking. A later pass found one more near-duplicate pair — two documents overlapping by 88% — which settled the corpus at &lt;strong&gt;24 documents, 1,768 chunks&lt;/strong&gt;. The lesson repeats itself across this series: fix the data before you trust the leaderboard.&lt;/p&gt;
&lt;h2&gt;A tree on top of the chunks&lt;/h2&gt;
&lt;p&gt;Leaf chunks aren&apos;t the only thing in the store. RAPTOR builds a hierarchy above them — clustering related chunks, summarising each cluster with an LLM, and repeating — so a broad question can match a summary node instead of hunting for one perfect leaf. That&apos;s covered in the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-retrievers&quot;&gt;retriever deep-dive&lt;/a&gt;; the point here is that even the summaries are only as good as the chunks they&apos;re built from.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;The chunking lesson&lt;/h4&gt;
&lt;p&gt;Recall is decided at chunk time. Before a retriever can find the right passage, that passage has to &lt;em&gt;be&lt;/em&gt; a retrievable chunk — cleanly extracted, sensibly bounded, and fully embedded. A context-recall score around 0.3 doesn&apos;t send you to a bigger model; it sends you straight back here.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;The other half of this is the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-data-pipeline&quot;&gt;pipeline overview&lt;/a&gt;, and the knobs that tune all of it live in &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-parameters&quot;&gt;the parameters&lt;/a&gt;. Getting the unglamorous data layer right — where most RAG projects quietly lose — is what I do at twentytwotensors. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>RAG &amp; Agents</category></item><item><title>See it answer — grounded, cited, and willing to say &quot;no&quot;</title><link>https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-in-action/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-in-action/</guid><description>Real questions through the CKD assistant — grounded, cited answers with their sources, and a clean refusal when the guidelines are silent. A demo from the MedGemma CKD series.</description><pubDate>Wed, 08 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The rest of this series is about how it&apos;s built and how it scores. This one is just: what does it actually say? Below are real questions run through the Level-1 assistant. Two things to watch for — every clinical claim is followed by the &lt;span class=&quot;qa-cite&quot;&gt;[Source: …]&lt;/span&gt; it came from, and the last example shows the behaviour I care about most in a research prototype over clinical text: it refuses when the guidelines don&apos;t cover the question, rather than inventing an answer. One caveat worth stating plainly — these are illustrative examples, not a measured abstention rate. On a separate adversarial set the retrievers scored 0/4 on out-of-corpus questions, so refusal is designed for and visible here, but it is not yet demonstrated to be reliable.&lt;/p&gt;
&lt;div class=&quot;qa-demo&quot;&gt;
&lt;div class=&quot;qa-turn&quot;&gt;
&lt;div class=&quot;qa-q&quot;&gt;What are the dietary potassium restrictions for CKD stage 3?&lt;/div&gt;
&lt;div class=&quot;qa-a&quot;&gt;
&lt;p&gt;For CKD stage 3, dietary potassium is generally limited to around &lt;strong&gt;2,000–3,000 mg per day&lt;/strong&gt; to reduce the risk of high blood potassium (hyperkalaemia), with tighter limits if your potassium runs persistently high &lt;span class=&quot;qa-cite&quot;&gt;[Source: NICE NG203]&lt;/span&gt;.&lt;/p&gt;
&lt;p&gt;There isn&apos;t a single blanket restriction for everyone — it&apos;s guided by your blood results, and where potassium is persistently raised the focus shifts to lower-potassium food choices and reviewing medicines that push potassium up &lt;span class=&quot;qa-cite&quot;&gt;[Source: UKKA Hyperkalaemia]&lt;/span&gt;.&lt;/p&gt;
&lt;div class=&quot;qa-disclaimer&quot;&gt;Educational information drawn from clinical guidelines — not a substitute for professional medical advice.&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;qa-sources&quot;&gt;&lt;span class=&quot;qa-sources-label&quot;&gt;Sources&lt;/span&gt;&lt;span class=&quot;source-chip&quot;&gt;NICE NG203&lt;/span&gt;&lt;span class=&quot;source-chip&quot;&gt;UKKA Hyperkalaemia&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;qa-turn&quot;&gt;
&lt;div class=&quot;qa-q&quot;&gt;Can I take ibuprofen for a headache if I have CKD?&lt;/div&gt;
&lt;div class=&quot;qa-a&quot;&gt;
&lt;p&gt;Generally, no. NSAIDs such as ibuprofen are best avoided in CKD — they reduce blood flow to the kidneys and can cause a further fall in kidney function or acute kidney injury, especially alongside blood-pressure medicines like ACE inhibitors &lt;span class=&quot;qa-cite&quot;&gt;[Source: NICE NG203]&lt;/span&gt;.&lt;/p&gt;
&lt;p&gt;Paracetamol is usually the preferred option for pain. If you take an NSAID regularly, check with your GP or pharmacist rather than stopping or continuing on your own.&lt;/p&gt;
&lt;div class=&quot;qa-disclaimer&quot;&gt;Educational information drawn from clinical guidelines — not a substitute for professional medical advice.&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;qa-sources&quot;&gt;&lt;span class=&quot;qa-sources-label&quot;&gt;Sources&lt;/span&gt;&lt;span class=&quot;source-chip&quot;&gt;NICE NG203&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;qa-turn&quot;&gt;
&lt;div class=&quot;qa-q&quot;&gt;When are ACE inhibitors recommended in CKD?&lt;/div&gt;
&lt;div class=&quot;qa-a&quot;&gt;
&lt;p&gt;An ACE inhibitor (or an ARB) is recommended when CKD comes with significant protein in the urine — for example an ACR of &lt;strong&gt;30 mg/mmol or more with diabetes&lt;/strong&gt;, or &lt;strong&gt;70 mg/mmol or more without&lt;/strong&gt; — because it slows the decline in kidney function &lt;span class=&quot;qa-cite&quot;&gt;[Source: NICE NG203]&lt;/span&gt;.&lt;/p&gt;
&lt;p&gt;Kidney function and potassium are checked after starting and each time the dose is increased.&lt;/p&gt;
&lt;div class=&quot;qa-disclaimer&quot;&gt;Educational information drawn from clinical guidelines — not a substitute for professional medical advice.&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;qa-sources&quot;&gt;&lt;span class=&quot;qa-sources-label&quot;&gt;Sources&lt;/span&gt;&lt;span class=&quot;source-chip&quot;&gt;NICE NG203&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;qa-turn&quot;&gt;
&lt;div class=&quot;qa-q&quot;&gt;What kind of exercise is recommended if I have CKD?&lt;/div&gt;
&lt;div class=&quot;qa-a&quot;&gt;
&lt;p&gt;Physical activity is encouraged. The guidance is to build up gradually toward around &lt;strong&gt;150 minutes a week of moderate aerobic activity&lt;/strong&gt;, combined with muscle-strengthening exercise, adapted to your CKD stage and how you feel &lt;span class=&quot;qa-cite&quot;&gt;[Source: UKKA Exercise and Lifestyle in CKD]&lt;/span&gt;.&lt;/p&gt;
&lt;p&gt;Regular activity supports blood pressure, cardiovascular health and general wellbeing.&lt;/p&gt;
&lt;div class=&quot;qa-disclaimer&quot;&gt;Educational information drawn from clinical guidelines — not a substitute for professional medical advice.&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;qa-sources&quot;&gt;&lt;span class=&quot;qa-sources-label&quot;&gt;Sources&lt;/span&gt;&lt;span class=&quot;source-chip&quot;&gt;UKKA Exercise &amp;#x26; Lifestyle&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;qa-turn&quot;&gt;
&lt;div class=&quot;qa-q&quot;&gt;Which brand of phosphate binder should I buy?&lt;/div&gt;
&lt;div class=&quot;qa-a qa-abstain&quot;&gt;
&lt;p&gt;The provided guidelines do not cover this. They describe when and why phosphate binders are used, but they do not recommend specific products or brands — that&apos;s a decision for your kidney team based on your bloods and other medicines.&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&quot;qa-sources&quot;&gt;&lt;span class=&quot;qa-sources-label&quot;&gt;Sources&lt;/span&gt;&lt;span class=&quot;source-chip&quot;&gt;none returned&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;That last answer is the one that matters. A tightened prompt tells the model to answer &lt;em&gt;only&lt;/em&gt; from the retrieved guideline text and, when that text doesn&apos;t contain the answer, to say so in as many words — &lt;em&gt;&quot;The provided guidelines do not cover this.&quot;&lt;/em&gt; In a clinical setting, a system that declines cleanly beats one that fills the gap with a confident guess.&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/medgemma-in-action-inline.png&quot; alt=&quot;An assistant returning empty hands when no retrieved source supports the question, declining rather than guessing&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;What you&apos;re seeing&lt;/h4&gt;
&lt;p&gt;Retrieve → answer → cite, with grounding enforced by the prompt: no claim without a source, and a refusal when the guidelines are silent. How well it holds up under measurement — and where retrieval still misses — is the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-evaluation&quot;&gt;honest scorecard&lt;/a&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;The rest of the series covers how this is built — &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-overview&quot;&gt;the three levels&lt;/a&gt;, &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-data-pipeline&quot;&gt;the pipeline&lt;/a&gt;, and &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-simple-rag&quot;&gt;the retrievers&lt;/a&gt; that decide whether any of it works. Building grounded, cited, privacy-aware RAG is what I do at twentytwotensors. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>RAG &amp; Agents</category></item><item><title>The knobs that move the numbers (and the ones that don&apos;t)</title><link>https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-parameters/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-parameters/</guid><description>Top-k, similarity threshold, chunk size, embedding dimensions, RAPTOR and tree internals, generation settings — which parameters actually move the score, and which are set-and-forget. A MedGemma CKD deep-dive.</description><pubDate>Wed, 08 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;It&apos;s tempting to treat every constant in a config file as a lever to pull. In practice, only a few change the outcome — and knowing which is most of the skill. Here are the real values behind this CKD assistant, grouped by where they live, with the ones that actually matter called out.&lt;/p&gt;
&lt;h2&gt;Retrieval — the ones that matter most&lt;/h2&gt;
&lt;p&gt;These decide what the model even gets to see, so they move recall and precision directly. &lt;strong&gt;Top-k of 5&lt;/strong&gt;: how many chunks come back per query — raise it and you catch more of the right passage (recall) at the cost of diluting the context with near-misses (precision). &lt;strong&gt;Similarity threshold of 0.3&lt;/strong&gt;: chunks below it are dropped rather than padded in, so a weak query returns three good chunks instead of five mediocre ones. If I could only tune two numbers, it would be these.&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/medgemma-parameters-inline.png&quot; alt=&quot;A horizontal similarity threshold line keeping strong passages above it and dropping weak ones below&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;h2&gt;Chunking&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Chunk size 2,000 tokens, overlap 200 characters.&lt;/strong&gt; Covered in the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-chunking&quot;&gt;chunking deep-dive&lt;/a&gt; — and worth re-flagging that the embedding model only reads the first 512 tokens of a chunk, so these two interact more than they look.&lt;/p&gt;
&lt;h2&gt;Embeddings&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;768 dimensions.&lt;/strong&gt; EmbeddingGemma supports Matryoshka representation learning, so the same model gives you 128, 256, 512 or 768-dimension vectors from one pass. I run the full 768 for quality; dropping to 256 is the lever if storage or latency ever bites, with a graceful quality trade rather than a model swap.&lt;/p&gt;
&lt;h2&gt;The retriever internals&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Retriever&lt;/th&gt;&lt;th&gt;Parameter&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;th&gt;What it does&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Tree&lt;/td&gt;&lt;td&gt;section-k&lt;/td&gt;&lt;td&gt;8&lt;/td&gt;&lt;td&gt;Section headings matched in phase one&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Tree&lt;/td&gt;&lt;td&gt;chunks-per-section&lt;/td&gt;&lt;td&gt;3&lt;/td&gt;&lt;td&gt;Chunks pulled from each matched section&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;RAPTOR&lt;/td&gt;&lt;td&gt;max depth&lt;/td&gt;&lt;td&gt;3&lt;/td&gt;&lt;td&gt;Levels of summary above the leaves&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;RAPTOR&lt;/td&gt;&lt;td&gt;cluster dim&lt;/td&gt;&lt;td&gt;10&lt;/td&gt;&lt;td&gt;UMAP target dimensions before clustering&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;RAPTOR&lt;/td&gt;&lt;td&gt;min cluster prob&lt;/td&gt;&lt;td&gt;0.1&lt;/td&gt;&lt;td&gt;Soft-assignment threshold for the GMM&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Hybrid&lt;/td&gt;&lt;td&gt;semantic / BM25&lt;/td&gt;&lt;td&gt;0.7 / 0.3&lt;/td&gt;&lt;td&gt;Weighting of dense vs keyword search — and this one is a bug, not a default. The fusion maths says the BM25 weight has to exceed (1+K)/(11+2K), which is 0.466 at K=60, or a document found &lt;em&gt;only&lt;/em&gt; by BM25 can never reach top-k. The configured 0.3 was below every threshold, which is part of why hybrid failed its benchmark gate.&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Generation — and two that caught me out&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Temperature 0.7.&lt;/strong&gt; Reasonable for answers, but it made the &lt;em&gt;evaluation&lt;/em&gt; non-deterministic: the same retriever produces different answers on re-runs, which adds noise to already-small score differences. For a reproducible eval you want this near zero; for a friendly assistant you want it warm. Those are different jobs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Max output tokens.&lt;/strong&gt; Set generously at 32,768 for the reasoning model — which then blew up against a self-hosted server whose total context window &lt;em&gt;is&lt;/em&gt; 32,768, since input plus output can&apos;t exceed it. A parameter that&apos;s fine on one backend and a hard error on another is a good argument for testing on the target you&apos;ll actually deploy to.&lt;/p&gt;
&lt;p&gt;Repetition and frequency penalties (1.5 and 0.4) round out the generation config, there to stop the smaller models looping — a real failure mode on a 4B model that a 26B one rarely hits.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;The parameters lesson&lt;/h4&gt;
&lt;p&gt;Don&apos;t tune everything — measure which knob moves &lt;em&gt;your&lt;/em&gt; number. Here it&apos;s top-k, the similarity threshold and chunk size (all recall levers); nearly everything else is a sensible default you set once. And watch the parameters that behave differently across backends — temperature&apos;s effect on eval reproducibility, and an output-length that&apos;s valid on a hosted API but a 400 error on your own GPU.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;These knobs sit on top of the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-retrievers&quot;&gt;retrievers&lt;/a&gt; and the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-data-pipeline&quot;&gt;pipeline&lt;/a&gt;, and their effect is what the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-evaluation&quot;&gt;evaluation&lt;/a&gt; measures. Knowing which parameter earns its keep — and which is just a number in a file — is what I do at twentytwotensors. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>RAG &amp; Agents</category></item><item><title>The retriever zoo — three bets scored, two that didn&apos;t make it</title><link>https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-retrievers/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-retrievers/</guid><description>Flat vs tree vs RAPTOR vs contextual vs hybrid — how each retriever works, a RAGAS scoreboard where RAPTOR wins, and the tree retriever that was silently broken for months. A MedGemma CKD deep-dive.</description><pubDate>Wed, 08 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In a RAG system, the retriever is the part that fails quietly. If it hands the model the wrong passage, no amount of clever prompting recovers — the model either says nothing useful or, worse, fills the gap from memory. So after the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-simple-rag&quot;&gt;simple baseline&lt;/a&gt;, most of my effort went into &lt;em&gt;how&lt;/em&gt; to find the right chunk of guideline text. I benchmarked three retrievers end-to-end — flat, tree and RAPTOR — behind a single factory flag so any level could swap one in, plus two more that never earned a place in the comparison. This is each one, and what the numbers said.&lt;/p&gt;
&lt;h2&gt;Flat — plain, and hard to beat&lt;/h2&gt;
&lt;p&gt;The default. Embed the query, cosine-search every chunk in the store, keep the top few above a similarity threshold. Two small touches earn their keep: it expands medical terms so &quot;CKD&quot; also searches &quot;chronic kidney disease,&quot; and it drops genuinely irrelevant chunks rather than padding the context to a fixed size. It&apos;s the least clever option and, as it turned out, the hardest to beat on precision — when it returns something, it&apos;s usually on-topic.&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/medgemma-retrievers-inline.png&quot; alt=&quot;A leaderboard of retrieval approaches with one entry rising clearly to the top&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;h2&gt;Tree — routing by structure (and the bug that hid for months)&lt;/h2&gt;
&lt;p&gt;Clinical guidelines have structure — numbered sections, headings, sub-headings — and the tree retriever tries to use it. Two phases: first match the question against a small index of &lt;em&gt;section headings&lt;/em&gt;, then pull chunks only from the sections that matched. The bet is that narrowing to the right section before searching beats searching the whole corpus flat.&lt;/p&gt;
&lt;p&gt;Here&apos;s the honest part. For a long time this retriever was &lt;strong&gt;silently broken&lt;/strong&gt;. Its section-heading index had never been populated, and even after I fixed that, a metadata mismatch meant phase two joined on the wrong fields and found &lt;em&gt;zero&lt;/em&gt; chunks on every query. Each time, it quietly fell back to flat retrieval — and because the fallback still returned five results, nothing ever looked wrong. &quot;Returns results&quot; is not the same as &quot;works.&quot; It took evaluating the retriever in isolation to notice that &quot;tree&quot; and &quot;flat&quot; were producing identical routing. Fixed the join (document name plus section heading) and rebuilt the index, and it finally does what it says.&lt;/p&gt;
&lt;h2&gt;RAPTOR — summarise your way up&lt;/h2&gt;
&lt;p&gt;RAPTOR builds a tree &lt;em&gt;on top of&lt;/em&gt; the chunks. Cluster the leaf chunks by meaning, have an LLM write a summary of each cluster, then cluster and summarise those summaries, and repeat — so you end up with layers of increasingly abstract nodes above the raw text. A query can land on a leaf (a specific fact) or on a summary node (a theme that spans several documents). That makes it good at broad, synthesising questions that no single chunk answers. I built the ~2,400-node tree with Gemma-4 E4B writing the summaries on a GPU box; it came out nominally top of the three, by a margin I&apos;ll argue below is too small to bank.&lt;/p&gt;
&lt;h2&gt;Hybrid and contextual — one that failed its gate, one that was never built&lt;/h2&gt;
&lt;p&gt;Two more were meant to join the comparison and didn&apos;t. &lt;strong&gt;Hybrid&lt;/strong&gt; fuses dense semantic search with BM25 keyword search via reciprocal rank fusion, which should help when the answer hinges on an exact term (a drug name, an eGFR threshold). I rebuilt it from a stub into a genuinely working retriever and it &lt;em&gt;failed its benchmark gate&lt;/em&gt; — I&apos;d rather report that than quietly drop it. &lt;strong&gt;Contextual&lt;/strong&gt; retrieval — prepending a short LLM-written sentence of context to each chunk before embedding — is designed but not built: there&apos;s no collection behind the flag, so the option cannot run at all. The zoo, honestly, is three animals and two enclosures.&lt;/p&gt;
&lt;h2&gt;The scoreboard&lt;/h2&gt;
&lt;p&gt;Scored with RAGAS on the de-duplicated corpus, Gemma-4 E4B as the generator, the tightened grounding prompt, ten CKD questions. Higher is better; the average is over the four metrics.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Retriever&lt;/th&gt;&lt;th&gt;Faithfulness&lt;/th&gt;&lt;th&gt;Answer rel.&lt;/th&gt;&lt;th&gt;Ctx precision&lt;/th&gt;&lt;th&gt;Ctx recall&lt;/th&gt;&lt;th&gt;Average&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Flat&lt;/td&gt;&lt;td&gt;0.718&lt;/td&gt;&lt;td&gt;0.541&lt;/td&gt;&lt;td&gt;0.706&lt;/td&gt;&lt;td&gt;0.296&lt;/td&gt;&lt;td&gt;0.565&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Tree&lt;/td&gt;&lt;td&gt;0.699&lt;/td&gt;&lt;td&gt;0.604&lt;/td&gt;&lt;td&gt;0.692&lt;/td&gt;&lt;td&gt;0.333&lt;/td&gt;&lt;td&gt;0.582&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;RAPTOR&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0.760&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0.695&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0.842&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;0.259&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0.639&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;RAPTOR leads on faithfulness and context precision — but at n=10 a gap of this size in the average is noise, and I&apos;m not going to call it a win. The more useful result is the split, from a separate retrieval-only benchmark: RAPTOR wins natural-language questions, flat wins keyword and factual ones. Both score 0/4 on adversarial out-of-corpus queries — neither knows how to abstain. Flat and tree each have a personality too: flat holds strong precision, tree edges the others on recall (routing does surface a bit more of the right material). Two honest caveats. &lt;em&gt;Answer relevancy&lt;/em&gt; is jumpy — a known RAGAS quirk where safe, hedged clinical phrasing gets read as evasive — so I lean on the other three. And &lt;em&gt;context recall sits around 0.3 for everyone&lt;/em&gt;: the single passage that best answers the question often isn&apos;t retrieved at all. That&apos;s the ceiling on the whole system, and no choice of retriever fixes it.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;Two lessons worth keeping&lt;/h4&gt;
&lt;p&gt;First: &quot;returns results&quot; isn&apos;t &quot;works&quot; — a broken retriever hid behind a fallback for months, and only isolated measurement caught it. Second: the fanciest retriever only pulls ahead once the corpus beneath it is clean. I&apos;d been comparing on a store that quietly held duplicate copies of several guidelines; de-duplicating it changed the ranking. Fix the data before you read the leaderboard at all — and at ten questions, read it gently.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;Where the real gain is left — recall — sends you straight back to &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-data-pipeline&quot;&gt;the pipeline&lt;/a&gt;: chunking, and what actually makes it into the store. The full, unvarnished scorecard is in &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-evaluation&quot;&gt;the evaluation&lt;/a&gt;. Building and honestly measuring retrieval over dense domain text is what I do at twentytwotensors. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>RAG &amp; Agents</category></item><item><title>Where the model runs — local, hosted, or a GPU box</title><link>https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-setup/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-setup/</guid><description>Local embeddings, a provider-agnostic generator endpoint, and three places to run it — a laptop, AI Studio, and a self-hosted g6/vLLM GPU box — with the honest tradeoffs of each. A MedGemma CKD deep-dive.</description><pubDate>Wed, 08 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A RAG system has two model calls: embed the text, and generate the answer. The trick to keeping deployment sane is to treat those two very differently. Embeddings are small, cheap and private, so they run locally, always. Generation is the expensive, swappable part — so it talks to whatever&apos;s cheapest or fastest in a given environment, through one interface.&lt;/p&gt;
&lt;h2&gt;The models&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;EmbeddingGemma 300M&lt;/strong&gt; — embeds both the query and the chunks, at 768 dimensions. It&apos;s small enough to run on a laptop CPU, so it never leaves the machine. Patient text doesn&apos;t get shipped to an API just to be turned into a vector.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The generator&lt;/strong&gt; — Gemma-4 E4B locally, or the larger Gemma-4 26B on AI Studio. (MedGemma 1.5 4B was the original and still names the project; nothing current runs on it.) None of these are hard-wired: the code just points at an OpenAI-compatible endpoint and asks for a model by name.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;One endpoint, any provider&lt;/h2&gt;
&lt;p&gt;The generator is configured with two environment variables — a base URL and a model id. That&apos;s the whole abstraction. Point them at a local vLLM server and it self-hosts; point them at Google AI Studio&apos;s OpenAI-compatible API and it&apos;s fully managed; point them at anything else that speaks the same protocol and it just works. Embeddings stay local regardless. So moving from a hosted model to a self-hosted GPU is a config change, not a code change — which is exactly what you want when &quot;where does this run&quot; has a different answer on a laptop, in an eval loop, and wherever it might eventually be deployed.&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/medgemma-setup-inline.png&quot; alt=&quot;Local embedding model kept on-device beside a remote swappable generator endpoint&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;h2&gt;Three places I actually ran it&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Local (a Mac).&lt;/strong&gt; Embeddings on the CPU, generation pointed at a hosted endpoint. No GPU, no server, cheapest possible dev loop. This is where most of the day-to-day work happened.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Hosted (AI Studio).&lt;/strong&gt; Generation via Gemma-4 26B on AI Studio&apos;s API — zero infrastructure. The catch is honest: it&apos;s a reasoning model, so it&apos;s slow and emits long internal &quot;thinking&quot; before the answer, and the free tier throttles you on tokens-per-minute. Great for correctness, awkward for a fast eval loop.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A GPU box (EC2 g6, an L4, with vLLM).&lt;/strong&gt; Self-hosting Gemma-4 E4B for full control and no rate limit. Two things bit me here. E4B disables FlashAttention, so vLLM falls back to a slower attention path — fine for a batch job, sluggish for interactive use. And spot capacity in my volume&apos;s availability zone was simply gone, so a one-off run meant paying on-demand. When I did rebuild the retrieval index on it, the move that mattered was ordering: stop the LLM server, use the freed GPU to pre-compute every embedding at once, then restart the server so the summariser model had the GPU to itself.&lt;/p&gt;
&lt;h2&gt;What I&apos;d deploy&lt;/h2&gt;
&lt;p&gt;Embeddings local, always — it&apos;s cheaper and it keeps the sensitive text on your own hardware. Generation behind the provider-agnostic endpoint, chosen per environment: a hosted model for the dev and eval loop, and a warm GPU only if production latency actually demands it. What I would &lt;em&gt;not&lt;/em&gt; do is stand up a GPU for a bursty, occasional workload — the spin-up time and the spot-capacity lottery cost more than they save.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;The setup lesson&lt;/h4&gt;
&lt;p&gt;Decouple the generator endpoint from day one. &quot;Where the model runs&quot; should be a deployment decision you can change with an environment variable — never something baked into the application. Get that boundary right and you can move between a laptop, a managed API and your own GPU without touching a line of code.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;The pieces this runs on — the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-data-pipeline&quot;&gt;pipeline&lt;/a&gt;, the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-retrievers&quot;&gt;retrievers&lt;/a&gt;, and &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-parameters&quot;&gt;the parameters&lt;/a&gt; — are the rest of the series. Building deployable, privacy-aware RAG that isn&apos;t chained to one vendor is what I do at twentytwotensors. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>RAG &amp; Agents</category></item><item><title>Building an in-app knowledge base your past self will thank you for</title><link>https://twentytwotensors.co.uk/blog/posts/an-in-app-knowledge-base/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/an-in-app-knowledge-base/</guid><description>The machine beeps a code I&apos;ve fixed before and can&apos;t remember. Hard-won knowledge fades — so I gave it somewhere to live, in my own words, that also grounds the AI assistant.</description><pubDate>Thu, 02 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;You learn a lot treating yourself at home. Not from a course — from things going slightly wrong and figuring them out. What a specific alarm actually means. The order of steps that clears it. The little procedure a nurse showed me once that isn&apos;t written down anywhere.&lt;/p&gt;
&lt;p&gt;The trouble with hard-won knowledge is that it fades. I&apos;d solve something, feel clever, and three weeks later be back to square one, scrolling old texts trying to remember what worked. So I gave it somewhere to live.&lt;/p&gt;
&lt;div class=&quot;app-gallery&quot;&gt;
&lt;figure&gt;
&lt;span class=&quot;app-shot&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/app/app-knowledge-base.png&quot; alt=&quot;Knowledge base screen listing saved notes such as alarm fixes and procedures&quot; loading=&quot;lazy&quot;&gt;&lt;/span&gt;
&lt;figcaption&gt;My own notes — in my words, one search away. (Synthetic data.)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure&gt;
&lt;span class=&quot;app-shot&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/app/app-kb-entry.png&quot; alt=&quot;Knowledge base entry editor with a title and free-text content field&quot; loading=&quot;lazy&quot;&gt;&lt;/span&gt;
&lt;figcaption&gt;Writing one down is a title and a body. Nothing else to fill in.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/div&gt;
&lt;h2&gt;The version in my own words&lt;/h2&gt;
&lt;p&gt;The manual is a binder. What I needed was the version that says &quot;when you see &lt;em&gt;this&lt;/em&gt;, do &lt;em&gt;that&lt;/em&gt;&quot; — mine, phrased the way I&apos;d explain it to myself at 6am. So each entry is just a title and free text. The moment a notes app asks me for tags and categories, I stop writing in it; the whole value is that capturing a note costs nothing.&lt;/p&gt;
&lt;h2&gt;And it quietly helps the assistant&lt;/h2&gt;
&lt;p&gt;There&apos;s a bonus I didn&apos;t plan for at first. These notes aren&apos;t only for me to read — the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/an-ai-assistant-that-reads-my-health-data&quot;&gt;AI assistant&lt;/a&gt; can pull them in as context. So when I ask &quot;what does this alarm mean?&quot;, the answer leans on my own documented fix instead of a generic one. The knowledge I keep for myself makes the assistant sound like me.&lt;/p&gt;
&lt;p&gt;Next: the data I wasn&apos;t tracking at all — sleep, heart rate, the state of my body between treatments.&lt;/p&gt;</content:encoded><category>Building in Public</category></item><item><title>Bringing body data into a health app: fitness trackers + dialysis</title><link>https://twentytwotensors.co.uk/blog/posts/bringing-in-body-data-fitness/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/bringing-in-body-data-fitness/</guid><description>Same numbers on the sheet, but some days I run on nothing. The answer was in body data I&apos;d never looked at — sleep, resting heart rate, recovery — the foundation for a readiness score.</description><pubDate>Thu, 02 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;My treatment log is careful. Blood pressure, weight, fluid, the whole panel. But it&apos;s all about the sessions — and the days I struggle with aren&apos;t always the session days. The gap between &quot;how I feel&quot; and &quot;what my tracker knows&quot; was the state of my body &lt;em&gt;between&lt;/em&gt; treatments. My sleep. My resting heart rate. How recovered I actually was.&lt;/p&gt;
&lt;p&gt;My watch had been quietly recording all of it the whole time. I just never brought it anywhere useful.&lt;/p&gt;
&lt;div class=&quot;app-gallery&quot;&gt;
&lt;figure&gt;
&lt;span class=&quot;app-shot&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/app/app-fitness.png&quot; alt=&quot;Fitness screen with insight tiles for steps, resting heart rate, sleep, oxygen saturation and heart-rate variability&quot; loading=&quot;lazy&quot;&gt;&lt;/span&gt;
&lt;figcaption&gt;Steps, resting HR, sleep, SpO₂, HRV — in one glanceable place. (Synthetic data.)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/div&gt;
&lt;h2&gt;Bringing the body into the picture&lt;/h2&gt;
&lt;p&gt;So the app grew a fitness tab: steps, resting heart rate, sleep, oxygen saturation, heart-rate variability — synced in and laid out as plain insight tiles. Nothing clever on the surface. The point isn&apos;t the dashboard; it&apos;s that for the first time my &quot;how I feel&quot; has numbers sitting next to it.&lt;/p&gt;
&lt;h2&gt;The quiet foundation for what&apos;s next&lt;/h2&gt;
&lt;p&gt;On its own, this tab is just a nicer way to see body data. But it&apos;s really the groundwork for the thing I most want: a &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/building-a-daily-readiness-score&quot;&gt;daily readiness score&lt;/a&gt; that looks at last night&apos;s sleep and recovery and tells me, honestly, what kind of day my body is set up for — so I can plan around how I actually am, not how I hope to be. You can&apos;t build that without the data first. This is the data first.&lt;/p&gt;
&lt;p&gt;Next: the small settings that quietly make the whole app fit one specific person — me.&lt;/p&gt;</content:encoded><category>Building in Public</category></item><item><title>Personalized settings in a health app: one number changed everything</title><link>https://twentytwotensors.co.uk/blog/posts/settings-that-fit-one-person/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/settings-that-fit-one-person/</guid><description>Health apps assume you&apos;re average; my condition is the opposite. The settings page is where the app learns my baseline — dry weight, session length, my own AI key, my data on export.</description><pubDate>Thu, 02 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;There&apos;s a single number my whole treatment revolves around: my dry weight — where my body should sit with no excess fluid. Get it wrong and the fluid goal each session is wrong, which is not a rounding error when it&apos;s your blood pressure and how you&apos;ll feel that evening.&lt;/p&gt;
&lt;p&gt;A generic app would bury that in a profile screen and move on. In mine it&apos;s the first thing settings asks for, because almost everything downstream is derived from it.&lt;/p&gt;
&lt;div class=&quot;app-gallery&quot;&gt;
&lt;figure&gt;
&lt;span class=&quot;app-shot&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/app/app-settings.png&quot; alt=&quot;Settings screen with dry weight, default treatment duration, AI key and data export options&quot; loading=&quot;lazy&quot;&gt;&lt;/span&gt;
&lt;figcaption&gt;The few knobs that make the app fit one body. (Synthetic data.)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure&gt;
&lt;span class=&quot;app-shot&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/app/app-timer-settings.png&quot; alt=&quot;Default treatment duration setting used by the live countdown and the post-treatment summary&quot; loading=&quot;lazy&quot;&gt;&lt;/span&gt;
&lt;figcaption&gt;My usual session length — so the countdown starts right, every time.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/div&gt;
&lt;h2&gt;Small settings, big reach&lt;/h2&gt;
&lt;p&gt;There aren&apos;t many of them, and that&apos;s deliberate. My dry weight drives the fluid goal. My default treatment duration drives the live countdown and pre-fills the post-treatment summary, so I&apos;m not setting the same length every single session. Each setting is small; each one quietly removes a decision I&apos;d otherwise repeat forever.&lt;/p&gt;
&lt;h2&gt;My keys, my data&lt;/h2&gt;
&lt;p&gt;Two more live here for a reason. The AI assistant runs on my own key, so the intelligence is under my control, not rented from behind someone&apos;s paywall. And export is one tap — my treatments and blood tests as CSV or PDF whenever I want them, to hand a clinician or just to keep. It&apos;s my health data; leaving with it should be trivial.&lt;/p&gt;
&lt;p&gt;That&apos;s the theme of this whole project, really. Not an app for patients in the abstract — an app that bends to one patient. The settings page is just where that bending is most obvious.&lt;/p&gt;
&lt;p&gt;Next: the assistant that reads all of this — the sessions, the trends, the supplies, the notes, the baseline — and answers back in plain words.&lt;/p&gt;</content:encoded><category>Building in Public</category></item><item><title>Tracking treatment consumables without thinking about it</title><link>https://twentytwotensors.co.uk/blog/posts/tracking-treatment-consumables/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/tracking-treatment-consumables/</guid><description>Counting bags in a cupboard the night before a bank holiday, one short of a treatment I couldn&apos;t skip. How the app turned supplies into a colour I only have to glance at.</description><pubDate>Thu, 02 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Treating at home means the storeroom is a spare bedroom, and the stock manager is me. Every session eats the same set of things — bags, a cartridge, needles, saline. Miss an order and it&apos;s not an inconvenience later, it&apos;s a missed treatment now. For a long time I &quot;managed&quot; this by opening the cupboard and hoping.&lt;/p&gt;
&lt;p&gt;The question I actually needed answered was never &quot;how many bags do I have?&quot; It was quieter than that: &lt;strong&gt;how many more sessions can I run before I have to reorder?&lt;/strong&gt; So I taught the app to answer it for me.&lt;/p&gt;
&lt;div class=&quot;app-gallery&quot;&gt;
&lt;figure&gt;
&lt;span class=&quot;app-shot&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/app/app-inventory.png&quot; alt=&quot;Inventory screen listing consumables with current stock and a coloured status dot per item&quot; loading=&quot;lazy&quot;&gt;&lt;/span&gt;
&lt;figcaption&gt;Green, amber, red. I only have to notice the colour. (Synthetic data.)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure&gt;
&lt;span class=&quot;app-shot&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/app/app-supply-rates.png&quot; alt=&quot;Supply-rates sheet setting per-session usage and a target buffer quantity for each item&quot; loading=&quot;lazy&quot;&gt;&lt;/span&gt;
&lt;figcaption&gt;I told it once how much a session uses, and what buffer I want.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure&gt;
&lt;span class=&quot;app-shot&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/app/app-inventory-delivery.png&quot; alt=&quot;Apply-delivery sheet with stepper controls to confirm the quantities that actually arrived&quot; loading=&quot;lazy&quot;&gt;&lt;/span&gt;
&lt;figcaption&gt;When a delivery lands, I tick off what actually arrived.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/div&gt;
&lt;h2&gt;It counts so I don&apos;t have to&lt;/h2&gt;
&lt;p&gt;I set one thing up: how much each item a session uses, and how much I like to keep spare. That&apos;s it. From there the app quietly does the arithmetic I used to do on the cupboard floor — turning stock into a plain &quot;about this many sessions left,&quot; and a coloured dot so I can tell in a glance whether I&apos;m fine, should think about ordering, or need to act today.&lt;/p&gt;
&lt;h2&gt;Trust, but verify the delivery&lt;/h2&gt;
&lt;p&gt;The part that keeps the count honest over months is small and unglamorous: when supplies arrive, I don&apos;t assume the order came in full. I confirm what actually turned up and adjust anything short or over. Only then does the stock go up. It&apos;s the difference between a number I glance at and a number I believe.&lt;/p&gt;
&lt;p&gt;Since supplies are eaten &lt;em&gt;by&lt;/em&gt; treatments, logging a session can nudge the count down on its own — so the number stays live without me tending it. The whole feature is designed to be forgettable. I don&apos;t want to think about bags. I just want the cupboard to never surprise me again.&lt;/p&gt;
&lt;p&gt;Next: the notes I&apos;ve learned the hard way — the alarm at 6am, and what to do about it.&lt;/p&gt;</content:encoded><category>Building in Public</category></item><item><title>A local-first community health app: why I&apos;d keep data off the cloud</title><link>https://twentytwotensors.co.uk/blog/posts/a-local-first-community-health-app/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/a-local-first-community-health-app/</guid><description>My version lives in the cloud and that suits me — but the same worry kept coming back from others: no account, nothing online. Fair. So from one codebase I build a version that never leaves your phone.</description><pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;figure&gt;
&lt;span class=&quot;app-shot&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/app/app-settings.png&quot; alt=&quot;Settings screen with on-device data export to CSV and PDF&quot; loading=&quot;lazy&quot;&gt;&lt;/span&gt;
&lt;figcaption&gt;The community build keeps everything on-device — and exports to CSV and PDF. (Synthetic data.)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;My own version uses the cloud — sync, the AI assistant, a Sheet my clinical team can read. That suits me. But it doesn&apos;t suit everyone, and it shouldn&apos;t have to. Some people don&apos;t want an account; some don&apos;t trust their health on someone else&apos;s server; some just want to open an app and start. All completely reasonable. So the same Flutter codebase builds a second flavour for them.&lt;/p&gt;
&lt;h2&gt;What it lets go of&lt;/h2&gt;
&lt;p&gt;Behind a build flag, the community edition is &lt;strong&gt;fully local-first&lt;/strong&gt;: data lives on the device, there&apos;s no cloud, no sign-in, no account to create. You install it and start logging. Nothing leaves your phone unless you decide to send it.&lt;/p&gt;
&lt;h2&gt;What it keeps&lt;/h2&gt;
&lt;p&gt;It keeps the part that actually matters — the daily tracking loop — and adds on-device export to CSV and PDF, so you can hand a clean record to your own clinical team without any of my infrastructure in the middle. There&apos;s a distributable APK and a web build.&lt;/p&gt;
&lt;p&gt;You can try the web build right now: &lt;a href=&quot;https://homehd-community.web.app&quot; rel=&quot;noopener&quot; target=&quot;_blank&quot;&gt;homehd-community.web.app&lt;/a&gt;. Nothing you type leaves your browser.&lt;/p&gt;
&lt;p&gt;One codebase, two products, switched by a flag. The real work is keeping the boundary honest: the cloud features sit behind interfaces, so the local build simply doesn&apos;t wire them up — no forked code to maintain.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;Why bother&lt;/h4&gt;
&lt;p&gt;Partly engineering discipline — if a feature can be cut by a flag, your boundaries are honest. Mostly, though: I had to build the tracking part anyway, and giving it to other people in the same situation costs me a build target, not a rewrite. That&apos;s a good trade.&lt;/p&gt;
&lt;/div&gt;</content:encoded><category>Building in Public</category></item><item><title>A readiness engine for any condition (not just wearables)</title><link>https://twentytwotensors.co.uk/blog/posts/a-readiness-engine-for-any-condition/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/a-readiness-engine-for-any-condition/</guid><description>The last post. It began with a patient tired of guessing and ends with a pattern I&apos;m sure transfers far beyond my own condition. What&apos;s left to build, and the invitation.</description><pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;That&apos;s the series. Across 14 posts I&apos;ve shown a real system I built and use every single day: the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/logging-a-treatment-session&quot;&gt;tracking loop&lt;/a&gt;, the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/tracking-treatment-consumables&quot;&gt;supplies&lt;/a&gt; and the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/an-in-app-knowledge-base&quot;&gt;hard-won notes&lt;/a&gt;, the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/turning-blood-tests-into-trends&quot;&gt;data and trends&lt;/a&gt;, the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/an-ai-assistant-that-reads-my-health-data&quot;&gt;AI assistant&lt;/a&gt; and its &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/giving-an-ai-assistant-hands-mcp&quot;&gt;action layer&lt;/a&gt;, the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/bringing-in-body-data-fitness&quot;&gt;body data&lt;/a&gt;, the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/running-a-health-app-on-zero-pounds&quot;&gt;£0 architecture&lt;/a&gt;, the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/a-local-first-community-health-app&quot;&gt;community edition&lt;/a&gt;, and the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/building-a-daily-readiness-score&quot;&gt;readiness score&lt;/a&gt; I&apos;m building next. None of it is a mock-up. It&apos;s the thing I run my life on.&lt;/p&gt;
&lt;h2&gt;Still on the roadmap&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A skills layer — more things the assistant can actually do, not just describe.&lt;/li&gt;
&lt;li&gt;On-device speech and a small local model, for when there&apos;s no signal.&lt;/li&gt;
&lt;li&gt;And the readiness score itself, turned from a plan into a real number.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The bigger point&lt;/h2&gt;
&lt;p&gt;Strip away the specifics of my condition and what&apos;s left is a pattern: take someone&apos;s own structured data, make it trustworthy and legible, put an assistant on top that reasons over it, and help them plan around it. I built it for one condition. It transfers to many — any illness where your capacity changes day to day — and to plenty of problems that have nothing to do with health.&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/readiness-engine-inline.png&quot; alt=&quot;A left-to-right pipeline turning scattered raw personal data into clean legible signals, then into an assistant that reasons, then into a calm planned day&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;If this is your problem too&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Patients:&lt;/strong&gt; if you live with something similar, try the local-first community edition — it runs entirely in your browser at &lt;a href=&quot;https://homehd-community.web.app&quot; rel=&quot;noopener&quot; target=&quot;_blank&quot;&gt;homehd-community.web.app&lt;/a&gt;.&lt;br&gt;
&lt;strong&gt;Researchers, clinicians, commissioners, founders:&lt;/strong&gt; if you want this readiness-engine pattern built for a specific condition, I&apos;ve already built the hard, real version. Let&apos;s talk.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;Building production AI, data, and cloud systems — grounded in real use, not slideware — is what I do at twentytwotensors. If any of this is useful to you, &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;get in touch&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>Building in Public</category></item><item><title>ADK vs LangGraph: one AI support system, two frameworks</title><link>https://twentytwotensors.co.uk/blog/posts/adk-vs-langgraph-customer-support/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/adk-vs-langgraph-customer-support/</guid><description>I built the same customer-support agent twice — on Google ADK and on LangGraph. Same model and data; only the orchestration changed. What differs, and how to choose.</description><pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I built a customer support agent for a fictional UK transport company, UKConnect. The &lt;a href=&quot;https://twentytwotensors.co.uk/blog/series/enterprise-ai-customer-support/&quot;&gt;8-part series&lt;/a&gt; builds it on Google&apos;s Agent Development Kit (ADK).&lt;/p&gt;
&lt;p&gt;Then I built the whole thing again on LangGraph.&lt;/p&gt;
&lt;p&gt;Same Gemini model. Same SQLite database and the same policy documents. Same two specialist agents. The only thing I changed was the framework. So it&apos;s a fair comparison — and it answers the question I get asked most: which one should I use?&lt;/p&gt;
&lt;h2&gt;The system is the same shape&lt;/h2&gt;
&lt;p&gt;Both versions work the same way. Something reads the message and decides what it needs. Then a specialist handles it — one answers from policy documents, one acts on the booking database.&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/support-architecture.svg&quot; alt=&quot;Multi-agent architecture: a customer query reaches the Master Agent, which routes to a Policy Agent (RAG over a vector database) or a Ticket Agent (SQLite database and tools)&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;h2&gt;Same system, two frameworks&lt;/h2&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/support-adk-vs-langgraph.svg&quot; alt=&quot;Feature comparison of the Google ADK and LangGraph versions: agent framework, routing, vector database, LLM provider, state management and dependencies&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;p&gt;All of that comes down to one thing: who decides where a message goes, and how much of that I write myself.&lt;/p&gt;
&lt;h2&gt;ADK decides for you&lt;/h2&gt;
&lt;p&gt;In ADK, the Master Agent is an &lt;code&gt;LlmAgent&lt;/code&gt;, and the two specialists are registered under it. I describe each agent and let the framework hand the work over. The model picks who should answer; ADK does the routing. Guardrails sit on before/after callbacks. State lives in the ADK session.&lt;/p&gt;
&lt;p&gt;The good part is how little I had to write. I described the agents and the tools, and the framework wired the conversation together. If you&apos;ve already picked Gemini and Google&apos;s stack, that&apos;s a lot of plumbing you don&apos;t have to own.&lt;/p&gt;
&lt;h2&gt;LangGraph makes you draw it&lt;/h2&gt;
&lt;p&gt;LangGraph turns the flow into a graph I draw myself. A low-temperature Gemini call in a router node reads each message and tags it — policy, ticket, or general. Then &lt;strong&gt;conditional edges&lt;/strong&gt; send it to the right node. The specialists are ReAct agents. Policy search runs on FAISS. A &lt;code&gt;MemorySaver&lt;/code&gt; checkpointer keeps the state, so a conversation can be resumed or replayed.&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/support-langgraph-architecture.svg&quot; alt=&quot;LangGraph StateGraph: user input enters a router node that classifies the message as policy, ticket or general, routing to a Policy Agent (ReAct + FAISS), a Ticket Agent (ReAct + SQLite tools), or a general response, then returns a reply&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;p&gt;I wrote more code than with ADK. In return I can see the routing, log it, and test it edge by edge. And the state is something I can save and replay.&lt;/p&gt;
&lt;h2&gt;What actually changed&lt;/h2&gt;
&lt;h3&gt;Routing&lt;/h3&gt;
&lt;p&gt;This is the whole decision. ADK routes for you — less code, and you trust the model to get it right. LangGraph makes you write the routing — more code, but you can inspect it and test it. When something goes wrong, being able to point at the exact edge that fired is worth a lot. For anything regulated or high-stakes, I want that.&lt;/p&gt;
&lt;h3&gt;Retrieval&lt;/h3&gt;
&lt;p&gt;The ADK build uses plain scikit-learn cosine similarity. No extra infrastructure, and fine for a few hundred policy chunks. The LangGraph build uses FAISS, which is worth it once the document set grows. Pick for your data size, not the logo.&lt;/p&gt;
&lt;h3&gt;State&lt;/h3&gt;
&lt;p&gt;ADK session state is enough for one conversation. LangGraph&apos;s checkpointer makes the state durable — save it per session, resume it later, replay a run to debug it. That matters more in production than in a demo.&lt;/p&gt;
&lt;h3&gt;Lock-in&lt;/h3&gt;
&lt;p&gt;ADK is Google-native. It gives you the most for the least when you&apos;re all-in on Gemini. LangGraph sits in the wider LangChain ecosystem, so swapping models or reusing tools is easier. Both of mine run on Gemini on purpose — I wanted the framework to be the only thing that differed.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;So which one?&lt;/h4&gt;
&lt;p&gt;Use &lt;strong&gt;ADK&lt;/strong&gt; if you&apos;re committed to Google and Gemini and want to write as little orchestration as possible.&lt;br&gt;
Use &lt;strong&gt;LangGraph&lt;/strong&gt; if you want routing you can see and test, state you can replay, or room to move across models and tools.&lt;br&gt;
Most of the time, someone eventually asks &quot;why did it do that?&quot; — and that&apos;s when the explicit version pays for itself.&lt;/p&gt;
&lt;/div&gt;
&lt;h2&gt;Both are open source&lt;/h2&gt;
&lt;p&gt;You can read and run both:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;ADK version:&lt;/strong&gt; &lt;a href=&quot;https://github.com/ntg2208/production-ai-customer-support&quot; rel=&quot;noopener&quot; target=&quot;_blank&quot;&gt;production-ai-customer-support&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LangGraph version:&lt;/strong&gt; &lt;a href=&quot;https://github.com/ntg2208/production-ai-customer-support-langchain&quot; rel=&quot;noopener&quot; target=&quot;_blank&quot;&gt;production-ai-customer-support-langchain&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For the full ADK walkthrough — architecture, RAG, the database layer, orchestration, testing, and an honest look at what it&apos;s worth — start with &lt;a href=&quot;https://twentytwotensors.co.uk/blog/series/enterprise-ai-customer-support/&quot;&gt;the series&lt;/a&gt;.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;Need this built?&lt;/h4&gt;
&lt;p&gt;Picking the right framework and shipping it to production — with guardrails, evaluation, and a flow you can actually explain — is what I do at twentytwotensors. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt; and tell me about your use case.&lt;/p&gt;
&lt;/div&gt;</content:encoded><category>Agent Frameworks</category></item><item><title>&quot;Why am I itching this week?&quot; — and it actually knew</title><link>https://twentytwotensors.co.uk/blog/posts/an-ai-assistant-that-reads-my-health-data/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/an-ai-assistant-that-reads-my-health-data/</guid><description>One tired night I typed a vague question into my app and it pointed straight at the blood marker that had moved — my number, not a textbook. The trick: rebuild the prompt from live data every turn.</description><pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;figure&gt;
&lt;span class=&quot;app-shot&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/app/app-chat.png&quot; alt=&quot;The in-app AI assistant answering a question about health data in a chat thread&quot; loading=&quot;lazy&quot;&gt;&lt;/span&gt;
&lt;figcaption&gt;I ask in plain words; it answers from my own numbers. (Synthetic data.)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;A generic chatbot can tell you what a blood marker means. It can&apos;t tell you why &lt;em&gt;yours&lt;/em&gt; moved this month, because it has never seen your numbers. Living with a condition, that second question is the only one that matters — and it&apos;s the gap I wanted to close.&lt;/p&gt;
&lt;h2&gt;It reads my situation before every answer&lt;/h2&gt;
&lt;p&gt;The idea is simple, and the whole thing hangs on it: before each reply, the app builds a fresh prompt from my live data —&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;my current or most recent session,&lt;/li&gt;
&lt;li&gt;a summary of my latest blood results and how they&apos;ve moved,&lt;/li&gt;
&lt;li&gt;my supplies and what&apos;s running low,&lt;/li&gt;
&lt;li&gt;and the relevant notes from my &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/an-in-app-knowledge-base&quot;&gt;knowledge base&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So the model isn&apos;t recalling facts about the world. It&apos;s reasoning over a snapshot of &lt;strong&gt;my&lt;/strong&gt; situation, refreshed on every message. Ask the same question next week and the answer changes, because the numbers underneath have.&lt;/p&gt;
&lt;h2&gt;The itch, explained&lt;/h2&gt;
&lt;p&gt;That late-night question worked because retrieval is keyed to symptoms, not just keywords. Say &quot;I&apos;ve been itchy,&quot; and the assistant knows which markers tend to drive it, pulls those from my recent results, and points at the one that actually moved. The question is fuzzy; the answer is grounded in my own data. That combination is the part worth engineering.&lt;/p&gt;
&lt;h2&gt;Voice, because my hands are busy&lt;/h2&gt;
&lt;p&gt;During a treatment my hands aren&apos;t free, so I can just talk to it — audio goes straight to the model, which for this app beats on-device speech-to-text on accuracy and effort. I speak, it reads my data, it answers.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;The pattern, generalised&lt;/h4&gt;
&lt;p&gt;Strip out the health specifics and this is a function-calling assistant whose context is rebuilt from live application state every turn, with retrieval keyed to intent. That pattern works for any product sitting on structured user data — support, finance, operations. The data changes; the architecture doesn&apos;t.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;Building assistants that reason over a business&apos;s own live data — safely, grounded in real records — is exactly the kind of work I do at twentytwotensors. If that&apos;s a system you need, &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;get in touch&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Next: giving this assistant hands, so it can act in the app instead of only talking.&lt;/p&gt;</content:encoded><category>Building in Public</category></item><item><title>How to build a daily readiness score from health data</title><link>https://twentytwotensors.co.uk/blog/posts/building-a-daily-readiness-score/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/building-a-daily-readiness-score/</guid><description>I wake up and guess what my body will allow — and when I guess wrong I waste a good day or wreck a bad one. This is the part I&apos;m still building, to stop the guessing. Honest about what&apos;s real.</description><pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;That morning guess is the quiet tax of a fluctuating condition. My capacity genuinely changes day to day, I can&apos;t see it coming, so I plan the day blind. Everything else in this series is shipped and in daily use — but this post is different. This is the part I&apos;m &lt;strong&gt;still building&lt;/strong&gt;, and I&apos;m going to be clear about what&apos;s real and what&apos;s not.&lt;/p&gt;
&lt;figure&gt;
&lt;span class=&quot;app-shot&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/app/app-fitness.png&quot; alt=&quot;Fitness screen showing synced sleep, heart-rate variability, resting heart rate and respiratory metrics&quot; loading=&quot;lazy&quot;&gt;&lt;/span&gt;
&lt;figcaption&gt;The foundation, already live: daily body data syncing in. (Synthetic data.)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2&gt;What already works&lt;/h2&gt;
&lt;p&gt;The groundwork is done: the app &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/bringing-in-body-data-fitness&quot;&gt;pulls my body data in every day&lt;/a&gt; — sleep, heart-rate variability, resting heart rate, and more — and lands it right next to my treatment history and blood trends. One place. That part works today.&lt;/p&gt;
&lt;h2&gt;What I&apos;m building on top of it&lt;/h2&gt;
&lt;p&gt;The next leap is a single daily &lt;strong&gt;readiness score&lt;/strong&gt;: fuse those signals into one honest number for how my body actually is this morning, then let the assistant help me plan around it. Demanding things on the good days. Pull back on the rough ones. Replace the gamble with a read.&lt;/p&gt;
&lt;p&gt;That score doesn&apos;t exist yet. I&apos;m not going to show you a finished dashboard I haven&apos;t built — that&apos;s exactly the overclaim this whole series avoids. What I have is the data and the plan. I&apos;ll show the score when it&apos;s real.&lt;/p&gt;
&lt;h2&gt;Why it matters beyond me&lt;/h2&gt;
&lt;p&gt;A tracker for one condition is niche. A readiness engine isn&apos;t. The hard problem of any fluctuating-capacity illness is the same one I have every morning: capacity changes, you can&apos;t see it coming, so you plan badly and lose time. Long COVID, ME/CFS, heart failure, recovery after treatment — different conditions, same daily gamble. Same engine, different thresholds.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;The thesis&lt;/h4&gt;
&lt;p&gt;Quantify your daily readiness from your own body data, then plan your day around it to reclaim time. The shipped app is the proof I can build it. The readiness score is the reason it matters beyond me.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;If you&apos;re a research group, a clinician, or a founder working on chronic-illness self-management — this is the conversation I most want to have. The pattern is general; I&apos;ve already built the hard, real version of it. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>Building in Public</category></item><item><title>How I built a system to run my life around a chronic illness</title><link>https://twentytwotensors.co.uk/blog/posts/building-a-system-for-my-chronic-illness/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/building-a-system-for-my-chronic-illness/</guid><description>A working app I built for myself — it logs my treatments, reads my own blood-test trends, and answers questions with an AI assistant. The start of a build-in-public series.</description><pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;figure&gt;
&lt;span class=&quot;app-shot&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/app/app-treatment.png&quot; alt=&quot;Home screen of the Home HD app showing recent treatment sessions with blood pressure, fluid removed, and duration&quot; loading=&quot;lazy&quot;&gt;&lt;/span&gt;
&lt;figcaption&gt;The home screen. Every treatment I log shows up here. (Synthetic data.)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;I have a chronic illness.&lt;/p&gt;
&lt;p&gt;For years I planned my whole life around it. When to rest. When I could work. When I had energy and when I didn&apos;t. Most days I guessed wrong.&lt;/p&gt;
&lt;p&gt;So I built software to help me.&lt;/p&gt;
&lt;h2&gt;A lot of numbers, every week&lt;/h2&gt;
&lt;p&gt;My condition means a lot of numbers to track. Blood pressure. Weight. Fluid. A blood-test panel every month. Supplies that run out if I stop counting. And the treatments themselves, several times a week.&lt;/p&gt;
&lt;p&gt;For a long time that lived in a Google Sheet I hated opening. The sheet worked, but it didn&apos;t help. It stored numbers. It never told me anything.&lt;/p&gt;
&lt;h2&gt;What I built instead&lt;/h2&gt;
&lt;p&gt;Now it&apos;s an app. I built it myself, on my own time, for my own use. It does three things so far.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;It logs each treatment — before, during, and after.&lt;/li&gt;
&lt;li&gt;It reads my blood tests and shows the trends, two years of them.&lt;/li&gt;
&lt;li&gt;It has an AI assistant that looks at my real data and answers questions. I can ask &quot;why am I itching this week&quot; and it points at the marker that actually moved.&lt;/li&gt;
&lt;/ul&gt;
&lt;figure&gt;
&lt;span class=&quot;app-shot&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/app/app-active-session.png&quot; alt=&quot;Logging a live treatment session with timed readings, fluid goal, and supplies used&quot; loading=&quot;lazy&quot;&gt;&lt;/span&gt;
&lt;figcaption&gt;Logging a live session — readings come in while the treatment runs. (Synthetic data.)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;None of this is a demo. I use it every week. The screenshots here use synthetic data, but the app is the one I actually run my life on.&lt;/p&gt;
&lt;h2&gt;I&apos;m not a content creator&lt;/h2&gt;
&lt;p&gt;I&apos;m a patient who got tired of guessing. I happen to build software for a living, so I built the thing I wished existed.&lt;/p&gt;
&lt;p&gt;That&apos;s the honest version. I&apos;m not selling an app. There&apos;s no waitlist. I wanted to stop opening a spreadsheet I hated, and I wanted answers I could trust — because they came from my own numbers.&lt;/p&gt;
&lt;h2&gt;What&apos;s next&lt;/h2&gt;
&lt;p&gt;Over the next few weeks I&apos;ll show how each part works: the tracking, the data, the AI assistant, and the architecture (it runs on £0).&lt;/p&gt;
&lt;p&gt;And the part I&apos;m building next: a daily readiness score from my own body data — sleep, heart rate, the rest — so I can plan my day around how my body actually is, not how I hope it is. That part isn&apos;t built yet. I&apos;ll show it honestly as I go.&lt;/p&gt;
&lt;p&gt;If you live with something similar, follow along. I think a lot of us have the same problem.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;About this series&lt;/h4&gt;
&lt;p&gt;This is part 1 of &lt;a href=&quot;https://twentytwotensors.co.uk/blog/series/home-hd/&quot;&gt;&lt;strong&gt;Home HD&lt;/strong&gt;&lt;/a&gt; — a series on building a health system solo, from the daily tracker to the AI assistant and the cloud architecture behind it. Building production AI and data systems is also my day job at &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;twentytwotensors&lt;/a&gt;; if that&apos;s useful to you, get in touch.&lt;/p&gt;
&lt;/div&gt;</content:encoded><category>Building in Public</category></item><item><title>What does a factory&apos;s electricity actually cost?</title><link>https://twentytwotensors.co.uk/blog/posts/estimating-electricity-price-exposure/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/estimating-electricity-price-exposure/</guid><description>A data project: estimate a GB factory&apos;s day-ahead wholesale electricity exposure (~10.8 p/kWh), then prove it against what actually traded — within 0.5%. On scoping, honest uncertainty, and validation.</description><pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Here&apos;s the question I set out to answer: for a factory that runs around the clock and buys electricity at the prevailing wholesale price (no fixed deal), what is its price exposure for a single delivery day?&lt;/p&gt;
&lt;p&gt;The answer I landed on, for a Tuesday in June 2026: a base case of &lt;strong&gt;~10.8 p/kWh&lt;/strong&gt; for a flat 24/7 load, with a range of &lt;strong&gt;~10.2 to 10.8 p/kWh&lt;/strong&gt; depending on the load profile. And — the part I care about most — it matched the day&apos;s realised traded price to within &lt;strong&gt;0.5%&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;The hard part was scoping, not maths&lt;/h2&gt;
&lt;p&gt;An electricity bill has several parts: the wholesale energy, network charges, policy levies, and supplier margin. Most of those are largely fixed and known in advance. Only one is genuinely set by the market on the day — the &lt;strong&gt;wholesale&lt;/strong&gt; price. So that&apos;s the part that&apos;s actually &quot;exposed,&quot; and that&apos;s what I estimated. Quoting a big all-in number would have been easy and wrong; the question was about exposure, and only the wholesale bit is exposed.&lt;/p&gt;
&lt;p&gt;Second decision: this is something I can &lt;em&gt;read&lt;/em&gt;, not something I have to forecast. The GB day-ahead auction publishes each day&apos;s hourly prices the afternoon before delivery — for the 16th, it cleared at 09:57 the day before. So I&apos;m reading a settled, money-backed market price, not predicting one. Exposure for the day is then just the consumption-weighted average of those hourly prices.&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/elec-price-profile.png&quot; alt=&quot;The day-ahead hourly electricity price profile for 16 June 2026, dipping midday and peaking in the evening&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;p&gt;The load profile is the biggest source of uncertainty, and I didn&apos;t have it — so I carried it as an explicit assumption and reported a range across plausible profiles, rather than hiding a guess inside a single number. A flat 24/7 load is the top of the range; daytime- or overnight-weighted loads are cheaper, because they miss or partly miss the evening peak.&lt;/p&gt;
&lt;h2&gt;Free data only — and a validation step&lt;/h2&gt;
&lt;p&gt;The actual auction price is free to view but its API is paid, so I read the published cleared prices directly. The one free machine-readable GB series is Elexon&apos;s Market Index Data — the volume-weighted price of trades actually done in each half-hour. It isn&apos;t a day-ahead price and isn&apos;t published in advance, which makes it perfect for one thing: checking my estimate after the fact against what really traded.&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/elec-estimate-vs-actual.png&quot; alt=&quot;Day-ahead auction estimate plotted against the realised Elexon MID traded index across the day — the two lines track closely, within 0.5% on the daily average&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;p&gt;The two lines track closely all day. The daily average gap was +0.5%. That&apos;s the difference between &quot;here&apos;s a number&quot; and &quot;here&apos;s a number, and here&apos;s the evidence it was right.&quot;&lt;/p&gt;
&lt;h2&gt;Why the level is what it is&lt;/h2&gt;
&lt;p&gt;On a weekday like this, gas plants are usually on the margin — they&apos;re the last unit dispatched, so they set the price. That&apos;s why the level sits where it does and why the evening peak is pronounced. It&apos;s worth saying out loud in the writeup, because a number with no explanation is hard to trust.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;The transferable bit&lt;/h4&gt;
&lt;p&gt;Most of the value in a data task like this isn&apos;t the calculation — it&apos;s the scoping (&quot;what is genuinely the exposed quantity?&quot;), the honesty about the biggest uncertainty (the load profile, carried as a range), and validating the answer against ground truth. Those three habits travel to almost any estimation problem.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;If you&apos;ve got a measurement or estimation problem like this — where the answer needs to be defensible, not just plausible — that&apos;s the kind of work I do at twentytwotensors. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>Data Analysis</category></item><item><title>Giving an AI assistant hands: MCP in practice</title><link>https://twentytwotensors.co.uk/blog/posts/giving-an-ai-assistant-hands-mcp/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/giving-an-ai-assistant-hands-mcp/</guid><description>The first time it pre-filled a form was a thrill and a small alarm at once. I don&apos;t want an AI quietly writing to my health records — so I gave it hands, and kept them on a short leash.</description><pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;figure&gt;
&lt;span class=&quot;app-shot&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/app/app-pre-treatment.png&quot; alt=&quot;Pre-treatment form the assistant can pre-fill before I confirm, with pre-filled fields highlighted&quot; loading=&quot;lazy&quot;&gt;&lt;/span&gt;
&lt;figcaption&gt;It fills the form; the amber fields show what it touched; I confirm. (Synthetic data.)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Once the assistant could read my data (&lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/an-ai-assistant-that-reads-my-health-data&quot;&gt;part before this one&lt;/a&gt;), the obvious next step was letting it &lt;em&gt;do&lt;/em&gt; things. But &quot;an AI that can act on my medical data&quot; is exactly the sentence that should make you nervous. So I built its hands in two deliberate layers, both designed to be trustworthy before they&apos;re clever.&lt;/p&gt;
&lt;h2&gt;The layer that acts — and then stops&lt;/h2&gt;
&lt;p&gt;This one drives the app: open a screen, pre-fill a form, start a flow. Crucially it doesn&apos;t poke at the interface directly — every action goes through a small state machine, so &quot;fill the pre-treatment form&quot; is a defined, allowed move, not the model clicking around hopefully. And it always stops short of committing. It pre-fills; I confirm. The amber highlight you can see above is a deliberate tell: it shows me exactly what the AI touched before I sign off.&lt;/p&gt;
&lt;h2&gt;The layer that only looks&lt;/h2&gt;
&lt;p&gt;The second layer is a read-only &lt;code&gt;/api/mcp&lt;/code&gt; endpoint that exposes my data to a model over the Model Context Protocol — read-only, by design. A model can look; it can&apos;t write to my records over the wire. Anything that changes data has to come back through the on-device path, where I&apos;m in the loop.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;The rule: separate &quot;look&quot; from &quot;touch&quot;&lt;/h4&gt;
&lt;p&gt;Reading is low-risk and can be broad. Acting is higher-risk and should be narrow, explicit, and confirmable. Splitting them into two layers — a read-only data context and a constrained, human-confirmed action layer — is how you get an agent that&apos;s genuinely useful without being scary. That split holds for almost any agent in production.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;This is the agent question every team hits eventually: not &quot;can the model call tools,&quot; but &quot;what is it allowed to do unsupervised, and how do I prove it.&quot; When it&apos;s your own health on the line, you answer that question honestly.&lt;/p&gt;</content:encoded><category>Building in Public</category></item><item><title>Home dialysis saves the NHS thousands — and it&apos;s the better treatment</title><link>https://twentytwotensors.co.uk/blog/posts/home-dialysis-saves-nhs-thousands/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/home-dialysis-saves-nhs-thousands/</guid><description>Treat in a hospital unit, or at home. I chose home — then got curious what that choice is worth, to me and the NHS. The numbers are clear, the targets exist, and the country is missing them.</description><pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;For my condition, treatment can happen two ways: in a hospital unit several times a week, or at home. When I was offered home, it was equal parts freedom and terror — you&apos;re running the whole thing yourself, no nurse in the room. I said yes anyway. Most people, completely understandably, don&apos;t.&lt;/p&gt;
&lt;p&gt;That &quot;most people don&apos;t&quot; stuck with me. Because home isn&apos;t the compromise option — it&apos;s cheaper for the NHS &lt;em&gt;and&lt;/em&gt; better for patients, and both of those are in the published evidence, not my opinion. So I went and found the numbers, because a real cost case is worth more than a good story.&lt;/p&gt;
&lt;h2&gt;The numbers&lt;/h2&gt;
&lt;div class=&quot;stats-grid&quot;&gt;
&lt;div class=&quot;stat-card&quot;&gt;&lt;div class=&quot;stat-number&quot;&gt;£9–12k&lt;/div&gt;&lt;div&gt;saved per patient, per year, treating at home vs. in a unit&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;stat-card&quot;&gt;&lt;div class=&quot;stat-number&quot;&gt;16%&lt;/div&gt;&lt;div&gt;treated at home now — against the NHS&apos;s own 20% minimum&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;stat-card&quot;&gt;&lt;div class=&quot;stat-number&quot;&gt;£28.8–52.3m&lt;/div&gt;&lt;div&gt;per year saved if every centre hit that target&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;stat-card&quot;&gt;&lt;div class=&quot;stat-number&quot;&gt;~4.4%&lt;/div&gt;&lt;div&gt;uptake for my specific home treatment&lt;/div&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;The per-patient saving — about £9,000 to £12,000 a year — is from &lt;a href=&quot;https://pubmed.ncbi.nlm.nih.gov/35068280/&quot; rel=&quot;noopener&quot; target=&quot;_blank&quot;&gt;Roberts et al. (2022)&lt;/a&gt;, the most rigorous recent UK cost analysis. The policy target and the system-wide saving come from the NHS&apos;s own &lt;strong&gt;Getting It Right First Time (GIRFT)&lt;/strong&gt; programme (2022): it recommends a minimum of 20% of patients treated at home, the national rate sits around 16%, and closing that gap would save tens of millions a year.&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/home-treatment-inline.png&quot; alt=&quot;A slender bridge of soft glowing lines forming across a gap between two cliff edges, symbolising confidence spanning the distance&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;p&gt;One honest caveat. You&apos;ll see bigger figures quoted — £15k to £20k a year. Those are real, but they apply to a different home modality, or to older tariff models. For a clean like-for-like comparison, £9–12k is the number I&apos;d defend.&lt;/p&gt;
&lt;h2&gt;The gap isn&apos;t money, and it isn&apos;t safety&lt;/h2&gt;
&lt;p&gt;Here&apos;s the part that matters, and it&apos;s the part I felt myself. Treating at home is already cheaper, and outcomes are at least as good — better blood pressure control, fewer hospital stays, better quality of life in the NICE evidence. So the barrier to more people doing it isn&apos;t cost, and it isn&apos;t safety.&lt;/p&gt;
&lt;p&gt;It&apos;s confidence.&lt;/p&gt;
&lt;p&gt;Running the whole thing yourself, several times a week, with no nurse in the room — that is genuinely daunting, and I remember exactly how daunting. People stay in the unit because home &lt;em&gt;feels&lt;/em&gt; unsupported, not because it&apos;s worse.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;The opportunity, in one line&lt;/h4&gt;
&lt;p&gt;If the blocker is confidence and support rather than cost or outcomes, then better software — something that tracks the treatment, watches the trends, and answers questions from your own data — is exactly the kind of infrastructure that moves the number. That&apos;s what I&apos;m building.&lt;/p&gt;
&lt;/div&gt;
&lt;h2&gt;Why I&apos;m writing this down&lt;/h2&gt;
&lt;p&gt;This is the most concrete part of the whole project. Not &quot;here&apos;s a nice app&quot; — &quot;here&apos;s a measurable saving, an NHS target the country is missing, and a tool aimed at the actual blocker: the feeling I had on day one.&quot;&lt;/p&gt;
&lt;p&gt;If you work on this from the other side — a commissioner, a research group, a founder, a clinical team — that&apos;s the conversation I want to have. Turning lived experience and data into systems people can actually use is also what I do for a living. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt; if it&apos;s useful.&lt;/p&gt;</content:encoded><category>Building in Public</category></item><item><title>A leakage-free fraud feature store, built with dbt</title><link>https://twentytwotensors.co.uk/blog/posts/leakage-free-fraud-feature-store-dbt/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/leakage-free-fraud-feature-store-dbt/</guid><description>Turning raw card-payment tables into a feature store for fraud models on dbt and BigQuery — with one rule that matters: a feature can only ever see data from before the transaction it describes.</description><pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This project takes five raw tables — transactions, cards, users, merchant-category codes, and fraud labels — and turns them into something a fraud model can actually train on. It&apos;s a standard dbt project on BigQuery, with a staging layer and a marts layer. The interesting part is the feature store at the end, and one rule it has to obey.&lt;/p&gt;
&lt;h2&gt;The shape: staging → marts&lt;/h2&gt;
&lt;p&gt;Nothing exotic in the layering, on purpose. Staging models are views that rename, cast and lightly clean each source — parse dates, trim merchant fields, normalise error values, add a couple of derived fields like debt-to-income. The marts are tables: an enriched &lt;code&gt;fct_transactions&lt;/code&gt; fact (incremental, so it only processes new rows), a &lt;code&gt;dim_users&lt;/code&gt; dimension with lifetime spend and fraud rate, a daily &lt;code&gt;rpt_fraud_summary&lt;/code&gt;, and the feature store.&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/dbt-fraud-lineage.jpg&quot; alt=&quot;dbt lineage graph showing five raw sources flowing through staging views into the marts layer, ending in the fraud-detection feature store&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;p&gt;Every staging model carries tests — uniqueness and not-null on keys, and &lt;code&gt;accepted_values&lt;/code&gt; on fields like card brand and type — so a bad assumption about the source data fails the build instead of quietly poisoning a model downstream.&lt;/p&gt;
&lt;h2&gt;The rule that matters: no leakage&lt;/h2&gt;
&lt;p&gt;Here&apos;s the part that&apos;s easy to get wrong and expensive to discover late. The feature store computes velocity, baseline and deviation features — things like &quot;how much has this card spent in the last hour&quot; or &quot;how far is this amount from the user&apos;s normal.&quot; The temptation is to compute those with a plain window over the whole table.&lt;/p&gt;
&lt;p&gt;That leaks. If a feature for a transaction includes the transaction itself — or anything after it — the model gets to peek at the future during training. It looks brilliant in evaluation and falls apart in production, because in production the future genuinely isn&apos;t available yet.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;The one constraint&lt;/h4&gt;
&lt;p&gt;Every feature uses data strictly &lt;em&gt;before&lt;/em&gt; the current transaction&apos;s timestamp — never the row itself, never anything later. Get this right once, in the transformation layer, and every model trained on the feature store inherits it for free. Get it wrong and you&apos;ll chase a phantom accuracy you can never ship.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;Doing this in dbt rather than in notebook code matters: the point-in-time logic lives in one tested, versioned place that every consumer reads from, instead of being re-implemented (and re-broken) in each data scientist&apos;s feature script.&lt;/p&gt;
&lt;h2&gt;Why a fact table and a feature store, not one big query&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;fct_transactions&lt;/code&gt; is the honest record of what happened — every transaction, enriched with its dimensions and label. The feature store is a &lt;em&gt;derived&lt;/em&gt; view of that, shaped for one purpose: training. Keeping them separate means the fact table stays reusable for reporting and analysis, while the feature store can be opinionated about leakage and time windows without distorting the source of truth.&lt;/p&gt;
&lt;p&gt;Building clean, tested data layers that machine learning can actually trust — leakage-free, versioned, documented — is a big part of what I do at twentytwotensors. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt; if your feature pipeline needs that.&lt;/p&gt;</content:encoded><category>Data Engineering</category></item><item><title>Logging a dialysis session: the UX that makes people actually do it</title><link>https://twentytwotensors.co.uk/blog/posts/logging-a-treatment-session/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/logging-a-treatment-session/</guid><description>A treatment isn&apos;t a moment, it&apos;s an evening — a before, a during, an after, several times a week. The app just follows those three steps so I can attend to the treatment, not the paperwork.</description><pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The routine is always the same. I weigh myself, set up, and sit down for a few hours. Partway through, I take readings. Then it ends and there&apos;s a tidy-up. Three steps — and for years the record of them lived in a notebook and a spreadsheet I updated later, badly, from memory.&lt;/p&gt;
&lt;p&gt;So the heart of the app just &lt;em&gt;is&lt;/em&gt; those three steps. It doesn&apos;t reinvent my evening. It sits alongside it.&lt;/p&gt;
&lt;div class=&quot;app-gallery&quot;&gt;
&lt;figure&gt;
&lt;span class=&quot;app-shot&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/app/app-pre-treatment.png&quot; alt=&quot;Pre-treatment form with weight, fluid goal, blood pressure and pulse fields, plus medication carry-over toggles&quot; loading=&quot;lazy&quot;&gt;&lt;/span&gt;
&lt;figcaption&gt;Before: vitals and the goal for tonight. (Synthetic data.)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure&gt;
&lt;span class=&quot;app-shot&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/app/app-active-session.png&quot; alt=&quot;Active session screen with a running timer and a list of timed readings taken during treatment&quot; loading=&quot;lazy&quot;&gt;&lt;/span&gt;
&lt;figcaption&gt;During: a timer, and readings as they happen.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure&gt;
&lt;span class=&quot;app-shot&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/app/app-post-treatment.png&quot; alt=&quot;Post-treatment summary with final weight, blood pressure, duration and totals&quot; loading=&quot;lazy&quot;&gt;&lt;/span&gt;
&lt;figcaption&gt;After: the summary is mostly written already.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/div&gt;
&lt;h2&gt;Before I begin&lt;/h2&gt;
&lt;p&gt;I put in my weight and a couple of vitals, and the fluid goal works itself out from my dry weight. But every field stays editable, because real days don&apos;t match the formula — some evenings I already know the number should be different. The medications I take mid-treatment carry over from last time, so I&apos;m not re-typing the same thing several times a week.&lt;/p&gt;
&lt;h2&gt;While it runs&lt;/h2&gt;
&lt;p&gt;The middle is the part that simply has to work. A reading goes in and it&apos;s &lt;em&gt;saved&lt;/em&gt; — no spinner, no &quot;are you sure&quot;, no chance of losing it if my phone hiccups. Hooked up, mid-treatment, is the worst possible moment to fight an app, so the writes land on the device instantly and sync later. The engineering is there entirely so I never have to notice it.&lt;/p&gt;
&lt;h2&gt;When it&apos;s done&lt;/h2&gt;
&lt;p&gt;By the end, the summary has mostly filled itself in — duration, totals, final vitals. I glance at it, fix anything that&apos;s off, and it joins the history on the home screen.&lt;/p&gt;
&lt;p&gt;That was the one rule I set myself: &lt;strong&gt;auto-fill everything, but never lock me out of correcting it.&lt;/strong&gt; A tool that argues with you when you know your own body better doesn&apos;t get used twice.&lt;/p&gt;
&lt;p&gt;Next: what happens when you keep two years of these numbers — turning them into trends I can actually read.&lt;/p&gt;</content:encoded><category>Building in Public</category></item><item><title>Level 2 — Agentic RAG with LangGraph</title><link>https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-agentic-rag/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-agentic-rag/</guid><description>Wrapping retrieval in a LangGraph state machine: PII redaction with Presidio, intent routing, and self-scoring with RAGAS. Part 4 of the MedGemma CKD series.</description><pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-simple-rag&quot;&gt;Level 1&lt;/a&gt; answers questions. It doesn&apos;t check what&apos;s in the question, doesn&apos;t notice when a question isn&apos;t really a retrieval question, and doesn&apos;t know if its own answer was any good. Level 2 fixes those three gaps by turning the flow into an explicit LangGraph state machine — each step a node, the state passed between them.&lt;/p&gt;
&lt;h2&gt;The graph&lt;/h2&gt;
&lt;p&gt;Every query runs through the same path: &lt;strong&gt;check for PII → classify intent → branch&lt;/strong&gt;. A normal clinical question goes retrieve → generate → evaluate. But not everything should hit the retriever, so the router has four exits:&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/medgemma-agentic-inline.png&quot; alt=&quot;A privacy gate redacting personal details from a query before it enters the pipeline&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Retrieval&lt;/strong&gt; — a real clinical question; do the full RAG path.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Direct&lt;/strong&gt; — a simple conversational turn that needs no documents.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Clarification&lt;/strong&gt; — too vague to answer; ask for more.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Out of scope&lt;/strong&gt; — not a CKD question; decline politely.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Making that routing explicit (rather than hoping one big prompt handles every case) is the whole point of Level 2: you can see which path fired, log it, and test each branch on its own.&lt;/p&gt;
&lt;h2&gt;PII first, always&lt;/h2&gt;
&lt;p&gt;The first node runs Microsoft Presidio with custom recognizers for things like NHS numbers, so anything identifying is caught and redacted before the query is logged or sent anywhere. In a health context this can&apos;t be an afterthought bolted on later — it&apos;s the first thing that happens, by design.&lt;/p&gt;
&lt;h2&gt;The system scores itself&lt;/h2&gt;
&lt;p&gt;On the retrieval path, an evaluation node can run RAGAS on the response — faithfulness, context precision, recall — so quality is measured per answer, not assumed. I also added CKD-specific checks: did it cite a source, did it include the right disclaimer, was the advice appropriate to the stage. Those custom metrics encode &quot;what a good answer looks like &lt;em&gt;here&lt;/em&gt;,&quot; which generic metrics can&apos;t.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;Why a graph, not just more prompt&lt;/h4&gt;
&lt;p&gt;The value isn&apos;t the LangGraph library — it&apos;s that the control flow becomes a thing you can inspect, test edge by edge, and extend. &quot;PII is always checked first&quot; and &quot;out-of-scope questions are declined, not answered&quot; stop being hopes and become structure. That&apos;s exactly the routing-control trade-off I wrote about in the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/adk-vs-langgraph-customer-support&quot;&gt;ADK vs LangGraph comparison&lt;/a&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;Next: when one assistant isn&apos;t focused enough — splitting it into specialists. (&lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-multi-agent&quot;&gt;Part 5&lt;/a&gt;)&lt;/p&gt;</content:encoded><category>RAG &amp; Agents</category></item><item><title>From 24 clinical PDFs to a vector store</title><link>https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-data-pipeline/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-data-pipeline/</guid><description>The unglamorous half of RAG: OCR with Docling, section splitting, block-aware chunking, and embedding 24 clinical guideline documents into ChromaDB. Part 2 of the MedGemma CKD series.</description><pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Everyone wants to talk about the model. But for a RAG system, the answer is only ever as good as what you retrieved, and what you retrieve is decided here — in the pipeline that turns source documents into vectors. For the CKD assistant (&lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-overview&quot;&gt;Part 1&lt;/a&gt;) that source is 24 clinical guideline documents from NICE, KDIGO, the UK Kidney Association and Kidney Care UK.&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/medgemma-data-pipeline.svg&quot; alt=&quot;Pipeline: 24 clinical PDFs through Docling OCR, section splitting, cleaning, block-aware chunking and EmbeddingGemma into a ChromaDB vector store&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;h2&gt;OCR that respects tables&lt;/h2&gt;
&lt;p&gt;Clinical guidelines are full of tables — dosing thresholds, staging tables — and a naive text extract turns those into mush. I used IBM&apos;s Docling with table-structure recognition on, so a table comes out as a table, not a scrambled run of numbers. It exports both markdown (readable) and a structured JSON tree. Some pages are scanned, so OCR is on too.&lt;/p&gt;
&lt;h2&gt;Splitting out the parts that aren&apos;t clinical content&lt;/h2&gt;
&lt;p&gt;A guideline PDF is maybe 60% clinical content and 40% noise for my purposes — title pages, conflict-of-interest declarations, a long bibliography, abbreviations. If you embed all of it, your retriever happily returns a citation list when someone asks about potassium. So each document is split into &lt;code&gt;main_text&lt;/code&gt;, &lt;code&gt;references&lt;/code&gt;, and front/end matter, and only the clinical content goes forward.&lt;/p&gt;
&lt;p&gt;I did the classification with a hybrid approach: MedGemma classifies each heading, with a regex heuristic as a fallback when the model isn&apos;t available. The fallback matters — it means the pipeline still runs without a GPU, just a bit more bluntly.&lt;/p&gt;
&lt;h2&gt;Cleaning and block-aware chunking&lt;/h2&gt;
&lt;p&gt;Then a manual cleaning pass for OCR artifacts (I tracked each document&apos;s review status — pass, needs-check, or delete; one KDIGO guideline was superseded by a newer edition and dropped). Finally, chunking. The rule I care about: don&apos;t slice through a coherent block. A chunk that ends mid-table or mid-recommendation retrieves badly, so the chunker is block-aware rather than a blind fixed-size window.&lt;/p&gt;
&lt;p&gt;The clean chunks are embedded with EmbeddingGemma into 768-dimension vectors and stored in ChromaDB under one collection. That collection is the shared foundation every level in this series reads from.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;The point&lt;/h4&gt;
&lt;p&gt;Most &quot;the RAG isn&apos;t working&quot; problems are pipeline problems — bad OCR, references polluting the index, chunks cut through the middle of a table. Spend your effort here before you reach for a bigger model. I found this out the hard way in &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-evaluation&quot;&gt;the evaluation&lt;/a&gt;: nearly all the failures were retrieval, which traces straight back to this stage.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;Next: the simplest system that uses this store — Level 1, plain RAG. (&lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-simple-rag&quot;&gt;Part 3&lt;/a&gt;)&lt;/p&gt;</content:encoded><category>RAG &amp; Agents</category></item><item><title>Evaluating a clinical RAG — the honest results</title><link>https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-evaluation/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-evaluation/</guid><description>RAGAS, a retriever comparison, and middling scores I&apos;m not going to dress up. Why retrieval over dense clinical text is the hard part. Part 6 of the MedGemma CKD series.</description><pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;It&apos;s easy to ship a RAG demo that looks great on three hand-picked questions. The whole reason to evaluate is to find out where it actually breaks — so this part is about measuring the CKD assistant honestly, and being straight about what the numbers say.&lt;/p&gt;
&lt;h2&gt;How I measured it: RAGAS&lt;/h2&gt;
&lt;p&gt;I used RAGAS, which scores a RAG pipeline without a hand-labelled golden dataset by using a cheap external model as a judge. Four metrics, each answering a plain question:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Faithfulness&lt;/strong&gt; — did the answer only say things the retrieved text supports? (the anti-hallucination metric, and the one that matters most for anything clinical)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Answer relevancy&lt;/strong&gt; — did it actually address the question?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Context precision&lt;/strong&gt; — were the retrieved chunks actually useful?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Context recall&lt;/strong&gt; — did retrieval find everything it needed?&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The results, unvarnished&lt;/h2&gt;
&lt;p&gt;Two lenses, because they disagree in a useful way.&lt;/p&gt;
&lt;p&gt;The first is a source-match confusion matrix: for each question, did retrieval pull from the right document? Aggregate precision 0.53, recall 0.27, F1 0.36 — not a number I&apos;d put on a marketing page, which is exactly why it&apos;s here.&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/medgemma-retriever-eval.png&quot; alt=&quot;Retriever evaluation: an aggregate confusion matrix (precision 0.53, recall 0.27, F1 0.36) and per-topic precision/recall/F1 across CKD topics like anaemia, hyperkalaemia and diet&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;p&gt;The second lens scores the whole answer with RAGAS, comparing the retrievers on a de-duplicated corpus with the tightened grounding prompt. Higher is better; the average is over the four metrics.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Retriever&lt;/th&gt;&lt;th&gt;Faithfulness&lt;/th&gt;&lt;th&gt;Answer rel.&lt;/th&gt;&lt;th&gt;Ctx precision&lt;/th&gt;&lt;th&gt;Ctx recall&lt;/th&gt;&lt;th&gt;Average&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Flat&lt;/td&gt;&lt;td&gt;0.718&lt;/td&gt;&lt;td&gt;0.541&lt;/td&gt;&lt;td&gt;0.706&lt;/td&gt;&lt;td&gt;0.296&lt;/td&gt;&lt;td&gt;0.565&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Tree&lt;/td&gt;&lt;td&gt;0.699&lt;/td&gt;&lt;td&gt;0.604&lt;/td&gt;&lt;td&gt;0.692&lt;/td&gt;&lt;td&gt;0.333&lt;/td&gt;&lt;td&gt;0.582&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;RAPTOR&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0.760&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;0.695&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0.842&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;0.259&lt;/td&gt;&lt;td&gt;&lt;strong&gt;0.639&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;RAPTOR came out nominally ahead, with flat and tree close behind — close enough that at ten questions I read the gap as noise rather than a verdict. The full breakdown is in &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-retrievers&quot;&gt;the retriever deep-dive&lt;/a&gt;. But getting to a table I&apos;d trust meant fixing two things I&apos;d assumed were fine. The store quietly held &lt;strong&gt;duplicate copies&lt;/strong&gt; of several guidelines — a second ingestion nobody had cleaned up — which muddied every comparison until I de-duplicated it. And the tree retriever had been &lt;strong&gt;silently falling back to flat&lt;/strong&gt; for months. Both only surfaced once I measured the components in isolation.&lt;/p&gt;
&lt;p&gt;One change clearly helped the generation side: tightening the prompt to answer &lt;em&gt;only&lt;/em&gt; from the retrieved text and to refuse when it can&apos;t. On the same model and data that lifted faithfulness across every retriever — RAPTOR&apos;s went from 0.70 to 0.76 — for the price of the assistant honestly saying &quot;the guidelines don&apos;t cover this&quot; a little more often.&lt;/p&gt;
&lt;h2&gt;Why it&apos;s hard, and where the fix is&lt;/h2&gt;
&lt;p&gt;This is the honest lesson of the whole series. Across every retriever the consistent weak spot is &lt;strong&gt;context recall — around 0.3&lt;/strong&gt;: the single passage that best answers a question often isn&apos;t in the retrieved set at all, and if it wasn&apos;t retrieved, no bigger model or extra agent recovers it. RAG over dense clinical guidelines is genuinely hard — the text is long, cross-referential and precise — so finding exactly the right passage is most of the battle. The next effort doesn&apos;t go into a bigger model or more agents; it goes back to &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-data-pipeline&quot;&gt;the pipeline&lt;/a&gt; — better chunking, better recall.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;What I&apos;d take to a real build&lt;/h4&gt;
&lt;p&gt;Measure before you believe. Treat a mediocre score as the most valuable output you have — it points straight at the thing to fix. And in a high-stakes domain, a system that scores honestly and refuses when unsure beats one that scores impressively on a curated demo and quietly hallucinates in the field.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;That&apos;s the series — from &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-overview&quot;&gt;why three levels&lt;/a&gt; through the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-data-pipeline&quot;&gt;pipeline&lt;/a&gt;, the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-simple-rag&quot;&gt;three&lt;/a&gt; &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-agentic-rag&quot;&gt;levels&lt;/a&gt; &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-multi-agent&quot;&gt;themselves&lt;/a&gt;, and now the honest scorecard. Building grounded, evaluated, privacy-aware RAG — and telling you where it&apos;s weak — is what I do at twentytwotensors. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>RAG &amp; Agents</category></item><item><title>Level 3 — A multi-agent clinical assistant</title><link>https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-multi-agent/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-multi-agent/</guid><description>A router and four specialists — Diet, Medication, Lifestyle, Knowledge — because a diet question and a drug-interaction question are different jobs. Part 5 of the MedGemma CKD series.</description><pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;By &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-agentic-rag&quot;&gt;Level 2&lt;/a&gt; the assistant is safe and self-aware, but it&apos;s still one generalist. The problem with one generalist over CKD is that the domains pull in different directions: &quot;how much potassium can I have&quot; wants dietary thresholds and a calculation, &quot;is ibuprofen safe for me&quot; wants kidney-safe medication rules, and both want different slices of the guidelines. Level 3 makes each its own specialist.&lt;/p&gt;
&lt;h2&gt;An orchestrator and four specialists&lt;/h2&gt;
&lt;p&gt;A router reads the query and hands off to the right agent, then a final step synthesises the result into one reply:&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/medgemma-multi-agent-inline.png&quot; alt=&quot;A diet specialist weighing a nutrient measurement against a safe threshold on a balance scale&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Knowledge (RAG) agent&lt;/strong&gt; — general evidence-based answers from the guidelines, with citations.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Diet agent&lt;/strong&gt; — dietary guidance with actual calculation for potassium, phosphorus, sodium and protein, not just prose.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Medication agent&lt;/strong&gt; — kidney-safe medication guidance and interaction warnings.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lifestyle agent&lt;/strong&gt; — exercise, blood pressure, smoking, stress.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each specialist is narrower, so it can have a sharper prompt and pull the right part of the knowledge base instead of competing with everything else for the model&apos;s attention. The orchestrator&apos;s job is just classification and synthesis — the same coordinator-plus-specialists shape I used in the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/series/enterprise-ai-customer-support/&quot;&gt;customer-support system&lt;/a&gt;, in a different domain.&lt;/p&gt;
&lt;h2&gt;When the extra layer earns its place&lt;/h2&gt;
&lt;p&gt;Honestly? Not always. For a plain factual lookup, Level 1 answers just as well and Level 3 is overhead. Where the specialists pull ahead is the questions that genuinely span domains, or where a domain needs to &lt;em&gt;do&lt;/em&gt; something — like the diet agent computing against a limit rather than describing it. That&apos;s the rule: add an agent when there&apos;s a distinct job with distinct logic, not because multi-agent sounds impressive.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;The honest caveat&lt;/h4&gt;
&lt;p&gt;More agents don&apos;t fix bad retrieval — they just route to it more precisely. If the chunk the Diet agent needs isn&apos;t being retrieved, a dedicated Diet agent still can&apos;t answer. Which is exactly why the last part of this series is about measuring retrieval, not admiring the agent graph.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;Next, the part that keeps all of this honest: evaluation, and results I&apos;m not going to dress up. (&lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-evaluation&quot;&gt;Part 6&lt;/a&gt;)&lt;/p&gt;</content:encoded><category>RAG &amp; Agents</category></item><item><title>MedGemma CKD: building a clinical RAG assistant three ways</title><link>https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-overview/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-overview/</guid><description>Why I built the same chronic-kidney-disease assistant at three levels — simple, agentic, multi-agent — and the shared foundation underneath. Part 1 of the MedGemma CKD series.</description><pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This is an eleven-part walkthrough of a retrieval assistant for chronic kidney disease (CKD), grounded in 24 real clinical guideline documents — NICE, KDIGO, the UK Kidney Association, Kidney Care UK. I built it for the Kaggle MedGemma Impact Challenge, and I built it three times on purpose.&lt;/p&gt;
&lt;p&gt;Most &quot;agentic AI&quot; demos skip the boring question: do you actually need the agents? So I started with the simplest thing that could work and only added machinery when a number told me to. That gives three levels.&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/medgemma-rag-levels.svg&quot; alt=&quot;Three levels of RAG for a CKD assistant — simple, agentic, multi-agent — over a shared ChromaDB knowledge base generated with MedGemma&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;h2&gt;The three levels&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Level 1 — Simple RAG.&lt;/strong&gt; Retrieve the relevant guideline text, answer from it, cite the source. The honest baseline. (&lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-simple-rag&quot;&gt;Part 3&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Level 2 — Agentic RAG.&lt;/strong&gt; The same retrieval wrapped in a LangGraph state machine that redacts PII, routes by intent, and scores itself. (&lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-agentic-rag&quot;&gt;Part 4&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Level 3 — Multi-agent.&lt;/strong&gt; A router hands off to Diet, Medication, Lifestyle and Knowledge specialists. (&lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-multi-agent&quot;&gt;Part 5&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The shared foundation&lt;/h2&gt;
&lt;p&gt;All three levels sit on exactly the same base, which is what makes the comparison fair. The guideline PDFs are turned into clean, chunked text and embedded with EmbeddingGemma into a ChromaDB vector store; and a Gemma-4 model writes the answers from whatever was retrieved. (MedGemma 1.5 4B was the original generator and it&apos;s where the project&apos;s name comes from, but it was replaced by Gemma-4 — E4B locally, 26B on AI Studio — and none of the numbers in this series come from it.) Only the orchestration changes between levels — never the model or the knowledge base. Building that foundation is its own job, and it&apos;s the next part of the series. (&lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-data-pipeline&quot;&gt;Part 2&lt;/a&gt;)&lt;/p&gt;
&lt;h2&gt;And an honest ending&lt;/h2&gt;
&lt;p&gt;I&apos;ll also show the evaluation, and it isn&apos;t flattering. RAG over dense clinical guidelines is genuinely hard, and the scores say so. I&apos;d rather walk through why than pretend otherwise — that&apos;s &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-evaluation&quot;&gt;Part 6&lt;/a&gt;.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;Why this is worth reading&lt;/h4&gt;
&lt;p&gt;The pattern — start simple, earn each escalation with a measurement, and be honest when the numbers are middling — is how I&apos;d approach any RAG build, clinical or not. The CKD domain just makes the stakes (and the difficulty) obvious.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;Grounded, evaluated, privacy-aware RAG is a chunk of what I do at twentytwotensors. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt; if you&apos;re building one.&lt;/p&gt;</content:encoded><category>RAG &amp; Agents</category></item><item><title>Level 1 — Simple RAG, the honest baseline</title><link>https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-simple-rag/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-simple-rag/</guid><description>Retrieve, answer, cite — and the retriever choices (flat, tree, RAPTOR, contextual) that decide whether any of it works. Part 3 of the MedGemma CKD series.</description><pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Level 1 is deliberately plain: take the question, embed it, search the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-data-pipeline&quot;&gt;vector store&lt;/a&gt;, hand the top chunks to the generator, and answer from them with a citation. No agents, no routing. The reason to build it first is that it sets the bar — if the fancier levels don&apos;t beat this, they&apos;re just complexity.&lt;/p&gt;
&lt;h2&gt;Embeddings: one model, several sizes&lt;/h2&gt;
&lt;p&gt;Queries and chunks are embedded with EmbeddingGemma. It supports Matryoshka representation learning, so the same model gives you 128-, 256-, 512- or 768-dimension vectors from one pass — you can trade retrieval quality against speed and storage without swapping models. I ran at 768 for quality, with optional caching so repeated queries don&apos;t re-embed.&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/medgemma-simple-rag-inline.png&quot; alt=&quot;An answer card tethered by a thread to a highlighted source passage, showing a grounded citation&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;h2&gt;The retriever is where the work is&lt;/h2&gt;
&lt;p&gt;The default retriever does two unglamorous but important things. It expands medical terms — &quot;CKD&quot; also searches &quot;chronic kidney disease,&quot; and so on — so a query phrased one way still finds text phrased the other. And it matches on word boundaries, so &quot;AKI&quot; doesn&apos;t spuriously match the middle of another word. There&apos;s a configurable similarity threshold so genuinely irrelevant chunks are dropped rather than padded in.&lt;/p&gt;
&lt;p&gt;I also built more elaborate retrievers — a hybrid one using reciprocal rank fusion, a tree retriever that routes by section, and RAPTOR-style and contextual variants — and kept them behind a single factory so a level can pick one. But the lesson from &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-evaluation&quot;&gt;the evaluation&lt;/a&gt; was sobering: clever retrieval helps less than getting the chunks right in the first place.&lt;/p&gt;
&lt;h2&gt;Generation, and saying &quot;I don&apos;t know&quot;&lt;/h2&gt;
&lt;p&gt;The model answers only from the retrieved context, and cites it. The prompt&apos;s job is to keep it honest — answer from the chunks, and if the chunks don&apos;t cover the question, say so rather than improvising. For a clinical assistant that refusal behaviour is a feature, not a failure.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;The baseline lesson&lt;/h4&gt;
&lt;p&gt;For a lot of direct factual questions, simple RAG is genuinely enough. Build it, measure it, and only add machinery where it falls short. Everything in the next two parts is an answer to &quot;where does this baseline break?&quot; — not decoration on top of a working system.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;Next: wrapping this in a LangGraph workflow that adds the safety and routing a clinical assistant needs. (&lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/medgemma-ckd-agentic-rag&quot;&gt;Part 4&lt;/a&gt;)&lt;/p&gt;</content:encoded><category>RAG &amp; Agents</category></item><item><title>Running a health app on zero pounds (all-free-tier GCP)</title><link>https://twentytwotensors.co.uk/blog/posts/running-a-health-app-on-zero-pounds/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/running-a-health-app-on-zero-pounds/</guid><description>A tool I depend on, built solo with no budget. Two rules shaped it: cost me nothing to run, and never make me wait mid-treatment. One architectural choice satisfies both.</description><pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;figure class=&quot;post-hero&quot;&gt;
&lt;img src=&quot;https://twentytwotensors.co.uk/images/app/app-architecture.svg&quot; alt=&quot;Architecture diagram: a Flutter phone app writing directly to Firestore via a custom token, with Cloud Run, Cloud Storage, Firebase Hosting and a weekly Google Sheet sync, all on free tiers&quot; loading=&quot;lazy&quot;&gt;
&lt;/figure&gt;
&lt;p&gt;I built this for myself, on my own time, with no intention of paying a monthly bill to keep my own health data alive. So the whole system runs on Google Cloud free tiers — not &quot;cheap,&quot; actually £0. The pieces fit like this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Firebase Hosting&lt;/strong&gt; serves the web build.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cloud Run&lt;/strong&gt; runs a small Hono server for the AI calls and the &lt;code&gt;/api/mcp&lt;/code&gt; endpoint.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Firestore&lt;/strong&gt; holds the treatment and health data.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cloud Storage&lt;/strong&gt; holds files and exports.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;weekly Google Sheet sync&lt;/strong&gt; gives the clinical team a read-only summary — no new app, no login.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The moment that must never stall&lt;/h2&gt;
&lt;p&gt;Free tiers have one sharp edge: Cloud Run scales to zero, so when it wakes it cold-starts. A few seconds of &quot;please wait&quot; is fine for a chat reply. It is &lt;em&gt;not&lt;/em&gt; fine when I&apos;m mid-treatment, hooked up, trying to save a reading before I forget it. That scenario is the whole reason the app exists — it can&apos;t be the one place it fails.&lt;/p&gt;
&lt;p&gt;So the write path that has to be instant skips the server entirely: treatment data is written &lt;strong&gt;client-side, straight to Firestore&lt;/strong&gt;, with a custom auth token. No Cloud Run in the loop. The server only handles the things that can tolerate a pause — the AI, the exports. The critical path got its own route, on purpose.&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;The transferable idea&lt;/h4&gt;
&lt;p&gt;Don&apos;t put your whole app behind one tier of infrastructure. Find the one path that must never stall, and architect &lt;em&gt;that&lt;/em&gt; path for it — even if it takes a different route than everything else. Free tiers are generous if you respect where their sharp edges are.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;Designing cloud architecture that&apos;s both cheap and dependable — knowing which corners are safe to cut and which aren&apos;t — is a big part of what I do at twentytwotensors. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt; if that&apos;s useful.&lt;/p&gt;</content:encoded><category>Building in Public</category></item><item><title>Turning blood tests into trends: lab data you can act on</title><link>https://twentytwotensors.co.uk/blog/posts/turning-blood-tests-into-trends/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/turning-blood-tests-into-trends/</guid><description>Once a month, results land and I want to know one thing: is this trending the right way? For two years the answer hid in a folder of PDFs. So I built something that could actually tell me.</description><pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;figure&gt;
&lt;span class=&quot;app-shot&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/app/app-blood-tests.png&quot; alt=&quot;Blood test screen: a haemoglobin trend chart with a target range, filtered to the home phase, above a scorecard of the latest markers&quot; loading=&quot;lazy&quot;&gt;&lt;/span&gt;
&lt;figcaption&gt;Six months of one marker, filtered to the home phase, with the latest values below. (Synthetic data.)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Two years of monthly panels is a lot of numbers over a lot of dates. Any single result barely means anything on its own — what I actually care about is the direction. A folder of PDFs holds all the data and answers none of that. So the real work wasn&apos;t drawing a chart; it was getting the data into a shape where the direction became obvious.&lt;/p&gt;
&lt;h2&gt;The boring schema that made it easy&lt;/h2&gt;
&lt;p&gt;I store results in long format — &lt;strong&gt;one row per marker, per datetime&lt;/strong&gt; — not a wide table with a column per marker. It sounds like a detail, but it&apos;s the whole trick: adding a new marker never changes the schema, and every chart becomes a simple filter and sort. Boring, and exactly why it keeps working.&lt;/p&gt;
&lt;h2&gt;One bad month shouldn&apos;t erase the rest&lt;/h2&gt;
&lt;p&gt;Here&apos;s the thing that nearly ruined the charts. Every result is tagged with a phase — at home, in a unit, or during a hospital admission — because a rough spell in hospital throws out extreme values. Plot those on the same axis as steady months and the storm flattens everything else into a flat line, and you learn nothing. Tagging the phase lets me filter the storm out, or look at it alone, so the at-home trend stays honest. Those filters sit at the top of the screen above.&lt;/p&gt;
&lt;h2&gt;Where I deliberately didn&apos;t use AI&lt;/h2&gt;
&lt;p&gt;The results arrive clean and structured, so reading them is a regex job, not an AI job. I only reach for a model when the input is genuinely messy or the task genuinely needs judgement — and a well-formatted result line is neither. That restraint is a habit worth keeping: use the simplest thing that works, and save the model for the part that actually thinks.&lt;/p&gt;
&lt;p&gt;Next: the other thing that quietly piles up when you treat at home — the supplies — and the night I nearly ran out of them.&lt;/p&gt;</content:encoded><category>Building in Public</category></item><item><title>LLM Fine-tuning Guide 2024</title><link>https://twentytwotensors.co.uk/blog/posts/llm-fine-tuning-guide-2024/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/llm-fine-tuning-guide-2024/</guid><description>Learn how to fine-tune large language models for your specific business needs.</description><pubDate>Sun, 15 Dec 2024 00:00:00 GMT</pubDate><content:encoded>&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/hero-llm.svg&quot; alt=&quot;Fine-tuning large language models for your own data&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;p&gt;
Large Language Models (LLMs) have revolutionized how businesses handle text-based tasks, from customer service to content generation. However, off-the-shelf models often fall short when it comes to domain-specific requirements. This is where fine-tuning becomes essential.
&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;💡 Key Takeaway&lt;/h4&gt;
&lt;p&gt;Fine-tuning allows you to adapt powerful general-purpose LLMs to your specific use case, achieving better performance than generic models while being more cost-effective than training from scratch.&lt;/p&gt;
&lt;/div&gt;
&lt;h2&gt;What is LLM Fine-tuning?&lt;/h2&gt;
&lt;p&gt;
Fine-tuning is the process of taking a pre-trained language model and adapting it to perform better on specific tasks or domains. Instead of training a model from scratch (which requires massive computational resources), you start with a model that already understands language and teach it your specific requirements.
&lt;/p&gt;
&lt;h3&gt;Types of Fine-tuning&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Task-specific fine-tuning:&lt;/strong&gt; Adapting a model for specific tasks like classification or summarization&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Domain adaptation:&lt;/strong&gt; Training on domain-specific data (legal, medical, financial)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Instruction tuning:&lt;/strong&gt; Teaching models to follow specific instructions or formats&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RLHF (Reinforcement Learning from Human Feedback):&lt;/strong&gt; Aligning model outputs with human preferences&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;When Should You Fine-tune?&lt;/h2&gt;
&lt;p&gt;
Not every use case requires fine-tuning. Consider fine-tuning when:
&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Domain-specific language:&lt;/strong&gt; Your industry uses specialized terminology&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Consistent output format:&lt;/strong&gt; You need structured, predictable responses&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Performance gaps:&lt;/strong&gt; General models don&apos;t meet your accuracy requirements&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cost optimization:&lt;/strong&gt; You want to use smaller, more efficient models&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data privacy:&lt;/strong&gt; You prefer on-premise deployment&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Data Preparation: The Foundation of Success&lt;/h2&gt;
&lt;p&gt;
Quality data is the most critical factor in successful fine-tuning. Here&apos;s how to prepare your dataset:
&lt;/p&gt;
&lt;h3&gt;Data Collection&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Gather 1,000-10,000 high-quality examples (depending on task complexity)&lt;/li&gt;
&lt;li&gt;Ensure examples represent real-world scenarios&lt;/li&gt;
&lt;li&gt;Include edge cases and challenging examples&lt;/li&gt;
&lt;li&gt;Maintain consistent annotation guidelines&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;# Example data format for instruction tuning
{
&quot;instruction&quot;: &quot;Summarize this financial report for executives&quot;,
&quot;input&quot;: &quot;Q3 revenue increased 15% year-over-year...&quot;,
&quot;output&quot;: &quot;Key highlights: 15% revenue growth, strong market position...&quot;
}

&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Data Quality Checks&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Remove duplicates and inconsistencies&lt;/li&gt;
&lt;li&gt;Validate input-output pairs&lt;/li&gt;
&lt;li&gt;Check for bias and fairness issues&lt;/li&gt;
&lt;li&gt;Split data appropriately (80% train, 10% validation, 10% test)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Fine-tuning Strategies&lt;/h2&gt;
&lt;h3&gt;Full Fine-tuning&lt;/h3&gt;
&lt;p&gt;
Updates all model parameters. Most effective but requires significant computational resources.
&lt;/p&gt;
&lt;h3&gt;Parameter-Efficient Fine-tuning (PEFT)&lt;/h3&gt;
&lt;p&gt;
Techniques like LoRA (Low-Rank Adaptation) that update only a small subset of parameters:
&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;LoRA:&lt;/strong&gt; Adds trainable rank decomposition matrices&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Adapters:&lt;/strong&gt; Inserts small neural networks between layers&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Prompt tuning:&lt;/strong&gt; Optimizes soft prompts while keeping model frozen&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;🚀 Pro Tip&lt;/h4&gt;
&lt;p&gt;Start with LoRA fine-tuning - it requires 90% less memory than full fine-tuning while achieving similar performance for most tasks.&lt;/p&gt;
&lt;/div&gt;
&lt;h2&gt;Technical Implementation&lt;/h2&gt;
&lt;h3&gt;Choosing the Right Base Model&lt;/h3&gt;
&lt;p&gt;Consider these factors when selecting a base model:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Model size:&lt;/strong&gt; Balance performance vs. computational requirements&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;License:&lt;/strong&gt; Ensure commercial usage rights&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Domain relevance:&lt;/strong&gt; Some models perform better on specific domains&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Architecture:&lt;/strong&gt; Encoder-decoder vs. decoder-only models&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Hyperparameter Optimization&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# Key hyperparameters for fine-tuning
learning_rate = 5e-5  # Start lower than pre-training
batch_size = 16       # Adjust based on GPU memory
epochs = 3-5          # Avoid overfitting
warmup_steps = 500    # Gradual learning rate increase
weight_decay = 0.01   # Regularization

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Evaluation and Monitoring&lt;/h2&gt;
&lt;h3&gt;Metrics to Track&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Task-specific metrics:&lt;/strong&gt; BLEU, ROUGE, F1-score, accuracy&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;General quality:&lt;/strong&gt; Perplexity, human evaluation scores&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Business metrics:&lt;/strong&gt; User satisfaction, task completion rates&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Safety metrics:&lt;/strong&gt; Toxicity, bias, hallucination rates&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Preventing Overfitting&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Monitor validation loss throughout training&lt;/li&gt;
&lt;li&gt;Use early stopping when validation performance plateaus&lt;/li&gt;
&lt;li&gt;Apply regularization techniques (dropout, weight decay)&lt;/li&gt;
&lt;li&gt;Validate on held-out test set&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Deployment Considerations&lt;/h2&gt;
&lt;h3&gt;Model Serving Options&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cloud APIs:&lt;/strong&gt; Easy to scale but higher latency&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;On-premise deployment:&lt;/strong&gt; Better privacy and control&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Edge deployment:&lt;/strong&gt; Reduced latency for real-time applications&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hybrid approaches:&lt;/strong&gt; Combine cloud and edge for optimal performance&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Optimization Techniques&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Quantization:&lt;/strong&gt; Reduce model size with minimal performance loss&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Distillation:&lt;/strong&gt; Create smaller student models&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pruning:&lt;/strong&gt; Remove unnecessary parameters&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Caching:&lt;/strong&gt; Store common responses for faster serving&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Cost Management&lt;/h2&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;💰 Budget Planning&lt;/h4&gt;
&lt;p&gt;I&apos;m not going to quote you a price band, because the honest answer depends entirely on model size, data volume and where you run it. What I can give you is one real anchor: my Mistral 7B clinical-notes LoRA run took &lt;strong&gt;17.9 minutes&lt;/strong&gt; on a single GPU. A 4-bit LoRA on a 7B model is a pounds-not-thousands job; what actually costs money is the serving afterwards, and the data preparation before.&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Cost Optimization Strategies&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Use parameter-efficient methods (LoRA, adapters)&lt;/li&gt;
&lt;li&gt;Leverage spot instances for training&lt;/li&gt;
&lt;li&gt;Implement gradient checkpointing to reduce memory usage&lt;/li&gt;
&lt;li&gt;Consider smaller base models for simpler tasks&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Common Pitfalls and How to Avoid Them&lt;/h2&gt;
&lt;h3&gt;Data-Related Issues&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Insufficient data:&lt;/strong&gt; Start with at least 1,000 quality examples&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data leakage:&lt;/strong&gt; Ensure proper train/validation/test splits&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Annotation inconsistency:&lt;/strong&gt; Develop clear guidelines and validate annotations&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Technical Challenges&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Catastrophic forgetting:&lt;/strong&gt; Use lower learning rates and fewer epochs&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GPU memory issues:&lt;/strong&gt; Implement gradient accumulation and mixed precision&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Slow convergence:&lt;/strong&gt; Adjust learning rate schedule and warmup&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Future Trends in LLM Fine-tuning&lt;/h2&gt;
&lt;p&gt;
The field is rapidly evolving with new techniques emerging:
&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Multi-modal fine-tuning:&lt;/strong&gt; Adapting models for text + image tasks&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Few-shot fine-tuning:&lt;/strong&gt; Learning from minimal examples&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Federated fine-tuning:&lt;/strong&gt; Training across distributed data sources&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Automated hyperparameter optimization:&lt;/strong&gt; AI-driven tuning processes&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;
LLM fine-tuning is a powerful technique for adapting general-purpose models to specific business needs. Success depends on quality data, appropriate technique selection, and careful evaluation. While the initial investment can be significant, the long-term benefits in terms of performance and cost efficiency make it worthwhile for many applications.
&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;🎯 Ready to Get Started?&lt;/h4&gt;
&lt;p&gt;If you&apos;re considering LLM fine-tuning, this is genuinely my ground — LoRA on Llama 3 for a brand-voice notification model, and again on Mistral 7B for clinical-notes summarisation, where it moved ROUGE-1 from 0.381 to 0.423. Happy to think through whether fine-tuning is even the right answer for your problem, since often it isn&apos;t. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot; style=&quot;color: var(--primary-blue); font-weight: 600;&quot;&gt;Get in touch&lt;/a&gt;.&lt;/p&gt;
&lt;/div&gt;</content:encoded><category>LLM</category></item><item><title>MLOps Best Practices</title><link>https://twentytwotensors.co.uk/blog/posts/mlops-best-practices/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/mlops-best-practices/</guid><description>Essential MLOps patterns for deploying and maintaining ML systems in production.</description><pubDate>Thu, 05 Dec 2024 00:00:00 GMT</pubDate><content:encoded>&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/hero-mlops.svg&quot; alt=&quot;MLOps: from notebook to production&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;p&gt;
Machine Learning Operations (MLOps) has emerged as a critical discipline for organizations looking to scale their AI initiatives effectively. While building ML models is challenging, deploying and maintaining them in production environments presents an entirely different set of complexities.
&lt;/p&gt;
&lt;div class=&quot;mlops-box&quot;&gt;
&lt;h4&gt;🎯 What You&apos;ll Learn&lt;/h4&gt;
&lt;p&gt;This comprehensive guide covers the essential MLOps practices that enable reliable, scalable, and maintainable machine learning systems in production environments.&lt;/p&gt;
&lt;/div&gt;
&lt;h2&gt;Understanding MLOps&lt;/h2&gt;
&lt;p&gt;
MLOps combines machine learning, software engineering, and DevOps practices to standardize and streamline ML workflows. It addresses the unique challenges of ML systems, including data drift, model degradation, and the experimental nature of ML development.
&lt;/p&gt;
&lt;h3&gt;Why MLOps Matters&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Scalability:&lt;/strong&gt; the practices that let a team manage many models without the process collapsing&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reliability:&lt;/strong&gt; Ensure consistent model performance&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reproducibility:&lt;/strong&gt; Recreate results and debug issues&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Compliance:&lt;/strong&gt; Meet regulatory and audit requirements&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Collaboration:&lt;/strong&gt; Enable seamless teamwork between data scientists and engineers&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The MLOps Lifecycle&lt;/h2&gt;
&lt;p&gt;
A mature MLOps pipeline encompasses the entire machine learning lifecycle:
&lt;/p&gt;
&lt;div class=&quot;pipeline-stage&quot;&gt;
&lt;div class=&quot;stage-number&quot;&gt;1&lt;/div&gt;
&lt;div class=&quot;stage-content&quot;&gt;
&lt;h4&gt;Data Management&lt;/h4&gt;
&lt;p&gt;Version control for datasets, data validation, and feature engineering&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;pipeline-stage&quot;&gt;
&lt;div class=&quot;stage-number&quot;&gt;2&lt;/div&gt;
&lt;div class=&quot;stage-content&quot;&gt;
&lt;h4&gt;Model Development&lt;/h4&gt;
&lt;p&gt;Experiment tracking, model versioning, and reproducible training&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;pipeline-stage&quot;&gt;
&lt;div class=&quot;stage-number&quot;&gt;3&lt;/div&gt;
&lt;div class=&quot;stage-content&quot;&gt;
&lt;h4&gt;Model Validation&lt;/h4&gt;
&lt;p&gt;Automated testing, performance evaluation, and bias detection&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;pipeline-stage&quot;&gt;
&lt;div class=&quot;stage-number&quot;&gt;4&lt;/div&gt;
&lt;div class=&quot;stage-content&quot;&gt;
&lt;h4&gt;Deployment&lt;/h4&gt;
&lt;p&gt;CI/CD pipelines, containerization, and infrastructure as code&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;pipeline-stage&quot;&gt;
&lt;div class=&quot;stage-number&quot;&gt;5&lt;/div&gt;
&lt;div class=&quot;stage-content&quot;&gt;
&lt;h4&gt;Monitoring&lt;/h4&gt;
&lt;p&gt;Model performance tracking, data drift detection, and alerting&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;pipeline-stage&quot;&gt;
&lt;div class=&quot;stage-number&quot;&gt;6&lt;/div&gt;
&lt;div class=&quot;stage-content&quot;&gt;
&lt;h4&gt;Governance&lt;/h4&gt;
&lt;p&gt;Model lineage, audit trails, and compliance reporting&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h2&gt;Data Management Best Practices&lt;/h2&gt;
&lt;h3&gt;Data Versioning&lt;/h3&gt;
&lt;p&gt;
Treating data as code is fundamental to reproducible ML workflows:
&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Example using DVC (Data Version Control)
dvc init
dvc add data/training_set.csv
git add data/training_set.csv.dvc
git commit -m &quot;Add training dataset v1.0&quot;
dvc push

&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Data Validation&lt;/h3&gt;
&lt;p&gt;
Implement automated checks to ensure data quality:
&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Schema validation:&lt;/strong&gt; Verify column types, names, and constraints&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Statistical validation:&lt;/strong&gt; Check distributions, ranges, and correlations&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Freshness checks:&lt;/strong&gt; Ensure data is recent and complete&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Drift detection:&lt;/strong&gt; Monitor changes in data distributions&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;# Example data validation with Great Expectations
import great_expectations as ge

df = ge.read_csv(&quot;data/new_batch.csv&quot;)

# Define expectations
df.expect_column_values_to_not_be_null(&quot;customer_id&quot;)
df.expect_column_values_to_be_between(&quot;age&quot;, 18, 100)
df.expect_column_mean_to_be_between(&quot;purchase_amount&quot;, 10, 1000)

# Validate
validation_result = df.validate()

&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Feature Store Implementation&lt;/h3&gt;
&lt;p&gt;
Centralized feature management ensures consistency across teams:
&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Feature discovery:&lt;/strong&gt; Catalog of available features&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Feature lineage:&lt;/strong&gt; Track feature transformations&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Point-in-time correctness:&lt;/strong&gt; Prevent data leakage&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Online/offline consistency:&lt;/strong&gt; Same features for training and serving&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Model Development and Experiment Tracking&lt;/h2&gt;
&lt;h3&gt;Experiment Management&lt;/h3&gt;
&lt;p&gt;
Systematic experiment tracking enables better model development:
&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Example using MLflow
import mlflow
import mlflow.sklearn

with mlflow.start_run():
# Log parameters
mlflow.log_param(&quot;learning_rate&quot;, 0.01)
mlflow.log_param(&quot;max_depth&quot;, 5)

# Train model
model = train_model(learning_rate=0.01, max_depth=5)

# Log metrics
mlflow.log_metric(&quot;accuracy&quot;, 0.95)
mlflow.log_metric(&quot;f1_score&quot;, 0.92)

# Log model
mlflow.sklearn.log_model(model, &quot;model&quot;)

&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Model Versioning Strategy&lt;/h3&gt;
&lt;p&gt;
Implement semantic versioning for models:
&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Major version:&lt;/strong&gt; Breaking changes in API or significant architecture changes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Minor version:&lt;/strong&gt; Backward-compatible improvements&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Patch version:&lt;/strong&gt; Bug fixes and minor updates&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;🔧 Versioning Example&lt;/h4&gt;
&lt;p&gt;Model v2.1.3 indicates: Major version 2 (new architecture), Minor version 1 (feature enhancement), Patch 3 (third bug fix).&lt;/p&gt;
&lt;/div&gt;
&lt;h2&gt;Automated Testing for ML Systems&lt;/h2&gt;
&lt;h3&gt;Types of ML Tests&lt;/h3&gt;
&lt;p&gt;
ML systems require testing beyond traditional software testing:
&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Data tests:&lt;/strong&gt; Validate input data quality and consistency&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Model tests:&lt;/strong&gt; Verify model behavior and performance&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Infrastructure tests:&lt;/strong&gt; Ensure deployment environment reliability&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Integration tests:&lt;/strong&gt; Test end-to-end pipeline functionality&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Model Testing Framework&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# Example model testing with pytest
import pytest
import numpy as np

class TestModel:
def test_model_accuracy(self, trained_model, test_data):
&quot;&quot;&quot;Test model meets minimum accuracy threshold&quot;&quot;&quot;
predictions = trained_model.predict(test_data.X)
accuracy = accuracy_score(test_data.y, predictions)
assert accuracy &gt;= 0.85, f&quot;Model accuracy {accuracy} below threshold&quot;

def test_prediction_latency(self, trained_model, sample_input):
&quot;&quot;&quot;Test model inference time&quot;&quot;&quot;
start_time = time.time()
prediction = trained_model.predict(sample_input)
latency = time.time() - start_time
assert latency &amp;#x3C; 0.1, f&quot;Prediction latency {latency}s too high&quot;

def test_model_invariance(self, trained_model):
&quot;&quot;&quot;Test model behavior on edge cases&quot;&quot;&quot;
# Test with edge cases, noise, etc.
pass

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;CI/CD for Machine Learning&lt;/h2&gt;
&lt;h3&gt;ML-Specific CI/CD Pipeline&lt;/h3&gt;
&lt;p&gt;
Traditional CI/CD must be adapted for ML workflows:
&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Example GitHub Actions workflow for ML
name: ML Pipeline

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2

- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: 3.9

- name: Install dependencies
run: pip install -r requirements.txt

- name: Data validation
run: python scripts/validate_data.py

- name: Run model tests
run: pytest tests/test_model.py

- name: Train and validate model
run: python scripts/train_model.py

- name: Deploy to staging
if: github.ref == &apos;refs/heads/main&apos;
run: python scripts/deploy_staging.py

&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Deployment Strategies&lt;/h3&gt;
&lt;p&gt;
Choose the right deployment strategy based on your requirements:
&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Blue-Green Deployment:&lt;/strong&gt; Zero-downtime deployment with instant rollback&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Canary Deployment:&lt;/strong&gt; Gradual rollout to subset of traffic&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A/B Testing:&lt;/strong&gt; Compare model performance with controlled experiments&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shadow Deployment:&lt;/strong&gt; Run new model alongside production without affecting users&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Model Monitoring in Production&lt;/h2&gt;
&lt;h3&gt;Key Monitoring Metrics&lt;/h3&gt;
&lt;p&gt;
Comprehensive monitoring covers multiple dimensions:
&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Model Performance:&lt;/strong&gt; Accuracy, precision, recall, latency&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data Quality:&lt;/strong&gt; Missing values, outliers, distribution changes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data Drift:&lt;/strong&gt; Changes in input feature distributions&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Concept Drift:&lt;/strong&gt; Changes in the relationship between features and target&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Infrastructure:&lt;/strong&gt; CPU, memory, disk usage, error rates&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Alerting Strategy&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# Example monitoring setup with Evidently AI
from evidently.dashboard import Dashboard
from evidently.tabs import DataDriftTab, NumTargetDriftTab

def monitor_model_drift(reference_data, current_data):
dashboard = Dashboard(tabs=[DataDriftTab(), NumTargetDriftTab()])
dashboard.calculate(reference_data, current_data)

# Extract drift metrics
drift_report = dashboard.tabs[0].info[&apos;metrics&apos;]

if drift_report[&apos;dataset_drift&apos;]:
send_alert(&quot;Data drift detected in production model&quot;)
trigger_retraining_pipeline()

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Model Governance and Compliance&lt;/h2&gt;
&lt;h3&gt;Model Registry&lt;/h3&gt;
&lt;p&gt;
Centralized model management ensures governance and compliance:
&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Model metadata:&lt;/strong&gt; Training data, hyperparameters, performance metrics&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lineage tracking:&lt;/strong&gt; Data sources, feature transformations, model ancestry&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Approval workflows:&lt;/strong&gt; Model review and sign-off processes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Access control:&lt;/strong&gt; Role-based permissions for model access&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Audit and Compliance&lt;/h3&gt;
&lt;p&gt;
Maintain comprehensive audit trails for regulatory compliance:
&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Model decisions:&lt;/strong&gt; Log all model predictions with timestamps&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data lineage:&lt;/strong&gt; Track data sources and transformations&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Model changes:&lt;/strong&gt; Document all model updates and reasons&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Human oversight:&lt;/strong&gt; Record human interventions and overrides&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Infrastructure and Scalability&lt;/h2&gt;
&lt;h3&gt;Containerization&lt;/h3&gt;
&lt;p&gt;
Docker containers ensure consistent deployment environments:
&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Example Dockerfile for ML model
FROM python:3.9-slim

WORKDIR /app

# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy model and code
COPY models/ models/
COPY src/ src/

# Set environment variables
ENV MODEL_PATH=/app/models/model.pkl
ENV PORT=8080

# Health check
HEALTHCHECK --interval=30s --timeout=10s \\
CMD curl -f http://localhost:$PORT/health || exit 1

# Start API server
CMD [&quot;python&quot;, &quot;src/api.py&quot;]

&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Orchestration&lt;/h3&gt;
&lt;p&gt;
Use workflow orchestration tools for complex ML pipelines:
&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Apache Airflow:&lt;/strong&gt; Python-based workflow orchestration&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Kubeflow:&lt;/strong&gt; Kubernetes-native ML workflows&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MLflow:&lt;/strong&gt; End-to-end ML lifecycle management&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Prefect:&lt;/strong&gt; Modern workflow orchestration platform&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Cost Optimization&lt;/h2&gt;
&lt;h3&gt;Resource Management&lt;/h3&gt;
&lt;p&gt;
Optimize computational costs without sacrificing performance:
&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Auto-scaling:&lt;/strong&gt; Scale infrastructure based on demand&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Spot instances:&lt;/strong&gt; Use discounted cloud instances for training&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Model optimization:&lt;/strong&gt; Quantization, pruning, distillation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Caching:&lt;/strong&gt; Cache predictions for common inputs&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Batch processing:&lt;/strong&gt; Group predictions for efficiency&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Team Organization and Culture&lt;/h2&gt;
&lt;h3&gt;Cross-functional Collaboration&lt;/h3&gt;
&lt;p&gt;
Successful MLOps requires collaboration between diverse teams:
&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Data Scientists:&lt;/strong&gt; Model development and validation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ML Engineers:&lt;/strong&gt; Pipeline development and optimization&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DevOps Engineers:&lt;/strong&gt; Infrastructure and deployment&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data Engineers:&lt;/strong&gt; Data pipeline and quality&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Product Managers:&lt;/strong&gt; Business requirements and metrics&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Establishing MLOps Culture&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Shared responsibility:&lt;/strong&gt; Everyone owns model success&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Continuous learning:&lt;/strong&gt; Regular training and knowledge sharing&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Experimentation:&lt;/strong&gt; Encourage controlled experiments&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Documentation:&lt;/strong&gt; Maintain comprehensive documentation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Feedback loops:&lt;/strong&gt; Regular retrospectives and improvements&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Common MLOps Challenges and Solutions&lt;/h2&gt;
&lt;h3&gt;Technical Debt&lt;/h3&gt;
&lt;p&gt;
&lt;strong&gt;Challenge:&lt;/strong&gt; ML systems accumulate technical debt quickly.
&lt;/p&gt;
&lt;p&gt;
&lt;strong&gt;Solution:&lt;/strong&gt; Regular refactoring, code reviews, and automated testing.
&lt;/p&gt;
&lt;h3&gt;Model Drift&lt;/h3&gt;
&lt;p&gt;
&lt;strong&gt;Challenge:&lt;/strong&gt; Model performance degrades over time.
&lt;/p&gt;
&lt;p&gt;
&lt;strong&gt;Solution:&lt;/strong&gt; Continuous monitoring, automated retraining, and champion-challenger frameworks.
&lt;/p&gt;
&lt;h3&gt;Reproducibility&lt;/h3&gt;
&lt;p&gt;
&lt;strong&gt;Challenge:&lt;/strong&gt; Difficulty reproducing model results.
&lt;/p&gt;
&lt;p&gt;
&lt;strong&gt;Solution:&lt;/strong&gt; Version control for code, data, and environments; comprehensive experiment tracking.
&lt;/p&gt;
&lt;div class=&quot;mlops-box&quot;&gt;
&lt;h4&gt;🚀 Getting Started with MLOps&lt;/h4&gt;
&lt;p&gt;Start small with experiment tracking and basic CI/CD, then gradually add monitoring, automated testing, and advanced deployment strategies. Focus on solving real pain points rather than implementing everything at once.&lt;/p&gt;
&lt;/div&gt;
&lt;h2&gt;Future of MLOps&lt;/h2&gt;
&lt;p&gt;
The MLOps landscape continues to evolve with emerging trends:
&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;AutoML Integration:&lt;/strong&gt; Automated model selection and hyperparameter tuning&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Federated Learning:&lt;/strong&gt; Distributed training across multiple parties&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Edge ML:&lt;/strong&gt; Model deployment to edge devices and IoT&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Explainable AI:&lt;/strong&gt; Built-in interpretability and explainability tools&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DataOps Integration:&lt;/strong&gt; Closer integration between data and ML operations&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;
MLOps is essential for organizations serious about scaling their machine learning initiatives. By implementing these best practices, teams can build reliable, maintainable, and scalable ML systems that deliver consistent business value.
&lt;/p&gt;
&lt;p&gt;
Success in MLOps requires a combination of technical practices, cultural changes, and organizational commitment. Start with the fundamentals—experiment tracking, basic CI/CD, and monitoring—then gradually build more sophisticated capabilities as your team matures.
&lt;/p&gt;
&lt;div class=&quot;highlight-box&quot;&gt;
&lt;h4&gt;🎯 Need MLOps Implementation Support?&lt;/h4&gt;
&lt;p&gt;Worth being straight about where I sit on this: MLOps is not the deepest part of my experience. Two of the systems I&apos;ve built genuinely ran in production for years; most of my work has been R&amp;#x26;D, and heavy infrastructure is not what I&apos;d sell you. If you want to think through pipeline design or what a sane ML lifecycle looks like for your team, &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot; style=&quot;color: var(--primary-blue); font-weight: 600;&quot;&gt;get in touch&lt;/a&gt; — and if you need someone to own production infrastructure end to end, you want a specialist, not me.&lt;/p&gt;
&lt;/div&gt;</content:encoded><category>MLOps</category></item><item><title>I built a customer support agent as a team, not one bot</title><link>https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-1/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-1/</guid><description>One coordinator, two specialists, and why I didn&apos;t build it as one big agent. The architecture the rest of the series sits on.</description><pubDate>Sat, 03 Aug 2024 00:00:00 GMT</pubDate><content:encoded>&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/hero-enterprise-support.svg&quot; alt=&quot;Building enterprise AI customer support&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;p&gt;This is the first of eight posts where I build a complete customer support agent — a database, company policies, guardrails and tests, the parts a demo usually skips. I build it for a fictional UK transport company, UKConnect, which means every piece is inspectable and also that nothing here has faced a real customer.&lt;/p&gt;
&lt;p&gt;This part is about the architecture: how the pieces fit, and why I didn&apos;t build it as one big agent.&lt;/p&gt;
&lt;h2&gt;Why bother — what&apos;s wrong with normal support&lt;/h2&gt;
&lt;p&gt;Start with the problem. Phone-and-email support has the same issues almost everywhere:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;❌ &lt;strong&gt;Long wait times&lt;/strong&gt; (average 8 minutes)&lt;/li&gt;
&lt;li&gt;❌ &lt;strong&gt;Inconsistent service quality&lt;/strong&gt; across different agents&lt;/li&gt;
&lt;li&gt;❌ &lt;strong&gt;High operational costs&lt;/strong&gt; (£40K+ per agent annually)&lt;/li&gt;
&lt;li&gt;❌ &lt;strong&gt;Limited availability&lt;/strong&gt; (business hours only)&lt;/li&gt;
&lt;li&gt;❌ &lt;strong&gt;Scaling difficulties&lt;/strong&gt; (linear cost increase)&lt;/li&gt;
&lt;/ul&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/support-traditional-vs-multiagent.svg&quot; alt=&quot;Comparison of the failure modes of phone-and-email support (long waits, inconsistent quality, cost per agent, business hours only, cost scaling with volume) against the properties a multi-agent AI design aims for (fast consistent replies, logged conversations, round-the-clock availability)&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;h2&gt;How I built it: one coordinator, two specialists&lt;/h2&gt;
&lt;p&gt;Here&apos;s the shape. One coordinator, two specialists. Each agent does one job well, and the coordinator decides who handles what.&lt;/p&gt;
&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/support-architecture.svg&quot; alt=&quot;Multi-agent architecture: a customer query reaches the Master Agent (orchestrator), which routes to a Policy Agent (RAG over a vector database) or a Ticket Agent (SQLite database and tools), then synthesises the reply&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;h3&gt;The three agents&lt;/h3&gt;
&lt;h4&gt;The Master Agent — the coordinator&lt;/h4&gt;
&lt;p&gt;The Master Agent reads the question and decides what to do with it. It doesn&apos;t answer policy questions or touch the database itself — its job is to analyse the query, route it to the right specialist, and turn the result into a reply. It&apos;s a Gemini model with careful prompting, nothing more.&lt;/p&gt;
&lt;h4&gt;The Policy Agent — answers from documents&lt;/h4&gt;
&lt;p&gt;The Policy Agent handles anything that lives in the company&apos;s documentation: refund rules, terms and conditions, fares. It retrieves the relevant policy text first, then answers from that — grounded in what the company actually says, not guessed. Under the hood that&apos;s a vector database and RAG, which I cover properly in &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-3&quot;&gt;part 3&lt;/a&gt;.&lt;/p&gt;
&lt;h4&gt;The Ticket Agent — does the work&lt;/h4&gt;
&lt;p&gt;The Ticket Agent does the actual work on bookings — search, book, change, cancel — by calling tools against the database. This is the agent that changes things, so it&apos;s the one I keep on the tightest leash.&lt;/p&gt;
&lt;h2&gt;Three things this design gets right&lt;/h2&gt;
&lt;p&gt;Splitting the work this way isn&apos;t decoration. It buys three things.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Routing.&lt;/strong&gt; The Master Agent reads intent and sends each request to the specialist that can actually handle it, so the customer never has to know there&apos;s more than one agent behind the reply.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Context.&lt;/strong&gt; The customer&apos;s state and the conversation history are kept across every hand-off, so a multi-step request — cancel this, then tell me the refund — doesn&apos;t fall apart halfway through.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Focus.&lt;/strong&gt; Each agent has one job. That makes it simpler to build, easier to test, and more accurate than one big agent trying to do everything at once.&lt;/p&gt;
&lt;h2&gt;What it actually buys you&lt;/h2&gt;
&lt;p&gt;An honesty note first: this is a project I built for a fictional company, so I can&apos;t put a real cost figure on it. I haven&apos;t run it for an actual support desk and measured the savings, and anyone who quotes you a tidy &quot;60% cost reduction&quot; off a demo is guessing. What I &lt;em&gt;can&lt;/em&gt; talk about is what the architecture genuinely gives you.&lt;/p&gt;
&lt;p&gt;The answers are consistent — the same question gets the same answer every time, which a room full of people can&apos;t promise. There are no wait times, it handles as many conversations at once as arrive, and it&apos;s available around the clock. And every interaction is logged, which a phone line never gives you, so you can see what customers actually ask and where the system struggles. Those are properties of the design, not numbers I made up.&lt;/p&gt;
&lt;h2&gt;The stack&lt;/h2&gt;
&lt;p&gt;Nothing exotic. The intelligence is Google&apos;s Gemini, with a vector database and embeddings for semantic search over the policy documents. The backend is Python, with SQLite for the operational data here (you&apos;d want PostgreSQL if this ever ran for real) and FastAPI for the API. The agents are wired together with Google&apos;s ADK; data wrangling is pandas and NumPy, and the early vector search uses scikit-learn.&lt;/p&gt;
&lt;p&gt;If you&apos;re weighing frameworks, I later rebuilt the whole thing on LangGraph — the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/adk-vs-langgraph-customer-support&quot;&gt;ADK vs LangGraph comparison&lt;/a&gt; walks through what changed and why.&lt;/p&gt;
&lt;h2&gt;What it can actually do&lt;/h2&gt;
&lt;p&gt;In practice it handles four kinds of request: policy questions (refunds, terms, fares), booking operations (search, book, modify, cancel), general service (accounts, complaints, problems), and mixed requests that need a policy lookup and a database change in one go — those are the ones a single-purpose bot usually fails.&lt;/p&gt;
&lt;p&gt;It also adapts how it talks: formal and detailed for a corporate client, casual for a younger audience, short for someone reading on a phone. Same facts, different register.&lt;/p&gt;
&lt;h2&gt;What a request actually looks like&lt;/h2&gt;
&lt;p&gt;Two examples make the routing concrete. A simple one stays with a single specialist:&lt;/p&gt;
&lt;h3&gt;A simple policy question&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Customer: &quot;What&apos;s your refund policy?&quot;
↓
Master Agent: [Analyzes] → Identifies as policy-related query
↓
Policy Agent: [RAG Search] → Retrieves relevant refund policy sections
↓
Master Agent: [Synthesizes] → Delivers formatted, personalized response
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A harder one needs both specialists, in order — the database does the cancellation, the Policy Agent works out the fee, and the Master Agent stitches it into one answer:&lt;/p&gt;
&lt;h3&gt;A booking change that needs both agents&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Customer: &quot;Cancel my booking UKC005 and tell me the refund amount&quot;
↓
Master Agent: [Analyzes] → Identifies booking + policy requirements
↓
Ticket Agent: [Database] → Cancels booking, calculates base refund
↓
Policy Agent: [RAG] → Applies refund rules and applicable fees
↓
Master Agent: [Synthesizes] → &quot;Booking cancelled, £67.50 refunded&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Why this design would hold up&lt;/h2&gt;
&lt;p&gt;Three reasons, all of them practical rather than theoretical. Because the agents are separate and stateless, I can scale the busy ones independently and run them across several servers without sharing memory. Because each agent owns one concern, I can update or fix one without taking the others down, and I can test each in isolation. And because adding a capability means adding an agent — not rewriting the existing ones — the system grows without a redesign. Moving it to a new industry is mostly new prompts and new tools, not new architecture.&lt;/p&gt;
&lt;h2&gt;How fast and how accurate&lt;/h2&gt;
&lt;p&gt;These are from my own testing on the project&apos;s scenarios, not a production deployment. Simple questions come back in under a second; multi-step operations that touch the database take one to three seconds, and the heaviest multi-agent flows two to five. Routing lands on the right agent the large majority of the time, and policy answers come back grounded in the right document. I&apos;d trust it — but I&apos;d measure it properly against real traffic before claiming anything firmer.&lt;/p&gt;
&lt;h2&gt;It&apos;s not just for transport&lt;/h2&gt;
&lt;p&gt;I built this for a transport company — rail and bus bookings, fares, refund rules. But the shape is industry-agnostic. Swap the policies and the tools and it&apos;s a healthcare desk doing appointments, patient queries and insurance; or e-commerce doing orders, returns and shipping; or hospitality doing reservations and guest services. The coordinator-plus-specialists pattern doesn&apos;t change. Only the documents and the database behind it do.&lt;/p&gt;
&lt;h2&gt;Next in the series&lt;/h2&gt;
&lt;p&gt;Next I build the data layer the agents run on: the database schema, the hybrid storage, and the RAG setup with the vector database and embeddings. That&apos;s where the Policy Agent gets something real to retrieve from.&lt;/p&gt;
&lt;p&gt;That&apos;s the foundation. Everything in the rest of the series sits on top of it. The point of splitting the work across specialist agents isn&apos;t elegance — it&apos;s that each piece stays small enough to build, test, and trust on its own.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;This is the kind of system I build at twentytwotensors. If you want one for your business, &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;get in touch&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><category>LLM</category></item><item><title>The data layer: a database and a vector store</title><link>https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-2/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-2/</guid><description>The data layer: a relational database for bookings and customers, and a vector store for the policies customers never phrase the way they&apos;re written.</description><pubDate>Sat, 03 Aug 2024 00:00:00 GMT</pubDate><content:encoded>&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/hero-enterprise-support.svg&quot; alt=&quot;Building enterprise AI customer support&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;p&gt;In &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-1&quot;&gt;part 1&lt;/a&gt; I laid out the architecture — one coordinator, two specialists. This part builds the data layer underneath it. The agents are only as good as what they can read and write, and a support system needs two very different kinds of storage.&lt;/p&gt;
&lt;h2&gt;Two kinds of data, two kinds of store&lt;/h2&gt;
&lt;p&gt;Bookings, customers, transactions — that&apos;s structured, relational data. You want exact answers, foreign keys, and transactions you can trust. SQLite in development, PostgreSQL in production.&lt;/p&gt;
&lt;p&gt;Policies, FAQs, help articles — that&apos;s unstructured text, and customers never ask for it using the words it&apos;s written in. That needs semantic search, which is what a vector database is for. I cover the retrieval side properly in &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-3&quot;&gt;part 3&lt;/a&gt;; here I&apos;m just standing both stores up.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────┐    ┌─────────────────┐
│ Relational DB   │    │ Vector Database │
│ (SQLite/PG)     │    │ (Embeddings)    │
├─────────────────┤    ├─────────────────┤
│ • Customers     │    │ • Policy Docs   │
│ • Bookings      │    │ • FAQ Content   │
│ • Transactions  │    │ • Knowledge Base│
│ • Train Data    │    │ • Help Articles │
└─────────────────┘    └─────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each store does the thing it&apos;s good at: the relational database for precise operations, the vector database for &quot;find me the policy that answers this.&quot; The system reaches for whichever the question needs.&lt;/p&gt;
&lt;h2&gt;The relational schema&lt;/h2&gt;
&lt;p&gt;Four tables. Nothing fancy — the point is that they&apos;re clean and the agents can reason about them.&lt;/p&gt;
&lt;h3&gt;1. Customers Table&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE customers (
customer_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
address TEXT,
phone TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Design rationale&lt;/strong&gt;: Simple customer profiles with essential contact information. The address field will be used for location intelligence in later tutorials.&lt;/p&gt;
&lt;h3&gt;2. Bookings Table&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE bookings (
booking_id TEXT PRIMARY KEY,
customer_id TEXT,
departure_station TEXT,
arrival_station TEXT,
departure_datetime TIMESTAMP,
ticket_type TEXT,
price DECIMAL(10,2),
status TEXT,
FOREIGN KEY (customer_id) REFERENCES customers(customer_id)
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Key features&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Human-readable booking IDs (e.g., &quot;UKC001&quot;)&lt;/li&gt;
&lt;li&gt;Flexible status field for booking lifecycle management&lt;/li&gt;
&lt;li&gt;Precise pricing with decimal support&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;3. Transactions Table&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE transactions (
transaction_id TEXT PRIMARY KEY,
booking_id TEXT,
amount DECIMAL(10,2),
transaction_type TEXT,
payment_method TEXT,
status TEXT,
processed_at TIMESTAMP,
FOREIGN KEY (booking_id) REFERENCES bookings(booking_id)
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Supports&lt;/strong&gt;: Payment processing, refund tracking, financial reporting&lt;/p&gt;
&lt;h3&gt;4. Fares Table&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE fares (
route_id INTEGER PRIMARY KEY,
departure_station TEXT,
arrival_station TEXT,
distance_miles INTEGER,
standard_price DECIMAL(10,2),
flexible_price DECIMAL(10,2)
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Enables&lt;/strong&gt;: Dynamic pricing, route planning, availability checking&lt;/p&gt;
&lt;h2&gt;Database Initialization&lt;/h2&gt;
&lt;p&gt;Setting it up is two commands — one to create the schema, one to fill it with sample data:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Navigate to the project directory
cd production-ai-customer-support

# Create database schema
python -c &quot;from utils.create_schema import create_database_schema; create_database_schema()&quot;

# Populate with realistic sample data
python -c &quot;from utils.populate_data import populate_data; populate_data()&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This creates a SQLite database with complete sample data including 15 realistic customers and 25+ varied booking scenarios.&lt;/p&gt;
&lt;h2&gt;Vector Database for RAG Implementation&lt;/h2&gt;
&lt;h3&gt;The Challenge of Knowledge Retrieval&lt;/h3&gt;
&lt;p&gt;Traditional keyword search fails when customers ask questions like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&quot;Can I get my money back if I cancel?&quot; (instead of &quot;refund policy&quot;)&lt;/li&gt;
&lt;li&gt;&quot;What happens if my train is delayed?&quot; (instead of &quot;compensation terms&quot;)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Solution&lt;/strong&gt;: Semantic search using vector embeddings that understand meaning, not just keywords.&lt;/p&gt;
&lt;h3&gt;Vector Database Architecture&lt;/h3&gt;
&lt;p&gt;Our RAG system follows this pipeline:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Policy Documents → Text Chunking → Embeddings → Vector Storage → Semantic Search
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Implementation Details&lt;/h3&gt;
&lt;h4&gt;1. Document Processing&lt;/h4&gt;
&lt;p&gt;We start with company policy documents stored in &lt;code&gt;database/UKConnect_policy.txt&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Document contains:
- Refund policies and procedures
- Terms &amp;#x26; conditions
- Fare rules and regulations
- Compensation policies
- Special circumstances handling
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;2. Intelligent Chunking Strategy&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# From utils/chunking_data.py
chunk_config = {
&quot;chunk_size&quot;: 500-1000,  # characters per chunk
&quot;overlap&quot;: 100,          # character overlap between chunks
&quot;preserve_boundaries&quot;: True  # Don&apos;t break sentences/paragraphs
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Why chunking matters&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Manageable size&lt;/strong&gt;: Fits LLM context windows efficiently&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Overlap&lt;/strong&gt;: Ensures context continuity across chunk boundaries&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Boundary preservation&lt;/strong&gt;: Maintains semantic coherence&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;3. Embedding Generation&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# From database/vector_db.py
import google.generativeai as genai

def generate_embeddings(text_chunks):
embeddings = []
for chunk in text_chunks:
result = genai.embed_content(
model=&quot;models/embedding-001&quot;,
content=chunk
)
embeddings.append(result[&apos;embedding&apos;])
return embeddings
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;4. Vector Storage&lt;/h4&gt;
&lt;p&gt;For development, we use pickle storage. Production systems would use dedicated vector databases like Pinecone, Weaviate, or ChromaDB.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Initialize vector database
python -c &quot;from database.vector_db import setup_vector_database; setup_vector_database()&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Configuration Setup&lt;/h2&gt;
&lt;h3&gt;Environment Variables&lt;/h3&gt;
&lt;p&gt;Create a &lt;code&gt;.env&lt;/code&gt; file with your configuration:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# API Configuration
GOOGLE_API_KEY=your_google_api_key_here

# Database Configuration
DATABASE_URL=sqlite:///database/ukconnect_rail.db

# Vector Database
VECTOR_DB_PATH=database/vector_db.pkl
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Project Structure&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;production-ai-customer-support/
├── database/
│   ├── ukconnect_rail.db          # SQLite operational database
│   ├── vector_db.pkl              # Vector embeddings storage
│   ├── UKConnect_policy.txt       # Source policy documents
│   ├── ukconnect_qa_pairs.json    # Pre-processed Q&amp;#x26;A pairs
│   └── ukconnect_rag_chunks.json  # Processed text chunks
├── utils/
│   ├── create_schema.py           # Database schema creation
│   ├── populate_data.py           # Sample data generation
│   └── chunking_data.py           # Document processing
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Sample Data Population&lt;/h2&gt;
&lt;h3&gt;Realistic Customer Profiles&lt;/h3&gt;
&lt;p&gt;Our sample data includes diverse, realistic customer profiles:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Example customer data
{
&quot;customer_id&quot;: &quot;CUS001&quot;,
&quot;name&quot;: &quot;James Thompson&quot;,
&quot;email&quot;: &quot;james.thompson@email.co.uk&quot;,
&quot;address&quot;: &quot;45 Baker Street, London, W1U 7EW&quot;,
&quot;phone&quot;: &quot;+44 20 7946 0958&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;15 customers&lt;/strong&gt; covering:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Different geographic locations (London, Manchester, Birmingham, Glasgow)&lt;/li&gt;
&lt;li&gt;Various demographics and communication preferences&lt;/li&gt;
&lt;li&gt;Mixed booking histories and support interactions&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Complex Booking Scenarios&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Example booking data
{
&quot;booking_id&quot;: &quot;UKC001&quot;,
&quot;customer_id&quot;: &quot;CUS001&quot;,
&quot;departure_station&quot;: &quot;London Euston&quot;,
&quot;arrival_station&quot;: &quot;Manchester Piccadilly&quot;,
&quot;departure_datetime&quot;: &quot;2024-08-15 09:30:00&quot;,
&quot;ticket_type&quot;: &quot;Standard&quot;,
&quot;price&quot;: 85.50,
&quot;status&quot;: &quot;Active&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;25+ bookings&lt;/strong&gt; including:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Various routes and pricing tiers&lt;/li&gt;
&lt;li&gt;Different booking statuses (Active, Cancelled, Modified)&lt;/li&gt;
&lt;li&gt;Edge cases for testing (same-day changes, refunds, etc.)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;RAG System Deep Dive&lt;/h2&gt;
&lt;h3&gt;The Retrieval Process&lt;/h3&gt;
&lt;p&gt;When a customer asks a question, our RAG system follows these steps:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Query Analysis&lt;/strong&gt;: Extract semantic meaning from the customer&apos;s question&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vector Search&lt;/strong&gt;: Find the most relevant policy documents using cosine similarity&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Context Preparation&lt;/strong&gt;: Combine retrieved information with query context&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Response Generation&lt;/strong&gt;: LLM creates a response using the retrieved knowledge&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Example RAG Flow&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Customer query: &quot;Can I cancel my ticket and get a refund?&quot;

# 1. Generate query embedding
query_embedding = embed_content(&quot;Can I cancel my ticket and get a refund?&quot;)

# 2. Search vector database
relevant_chunks = vector_db.search(query_embedding, top_k=3)
# Returns: [
#   &quot;UKConnect Refund Policy: Tickets can be cancelled up to...&quot;,
#   &quot;Cancellation fees apply as follows: Standard tickets...&quot;,
#   &quot;Refund processing typically takes 3-5 business days...&quot;
# ]

# 3. Generate contextual response
response = llm.generate(
query=&quot;Can I cancel my ticket and get a refund?&quot;,
context=relevant_chunks,
customer_data=customer_profile
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Performance Optimization Strategies&lt;/h3&gt;
&lt;h4&gt;1. Chunk Overlap Management&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;overlap_config = {
&quot;size&quot;: 100,  # characters
&quot;purpose&quot;: &quot;context_continuity&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ensures important information isn&apos;t lost at chunk boundaries.&lt;/p&gt;
&lt;h4&gt;2. Relevance Filtering&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;similarity_threshold = 0.7  # Only return highly relevant results
filtered_results = [r for r in results if r.similarity &gt; similarity_threshold]
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;3. Caching Strategy&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Cache frequent queries for faster response times
cache_config = {
&quot;ttl&quot;: 3600,  # 1 hour cache lifetime
&quot;max_size&quot;: 1000,  # Maximum cached queries
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;4. Batch Processing&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Process multiple documents efficiently
batch_size = 32
embeddings = process_embeddings_batch(text_chunks, batch_size)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;System Testing and Validation&lt;/h2&gt;
&lt;h3&gt;Database Health Verification&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Test database connectivity and data integrity
python -c &quot;
from database.database import DatabaseManager
db = DatabaseManager()
print(&apos;Customers loaded:&apos;, len(db.get_all_customers()))
print(&apos;Bookings loaded:&apos;, len(db.get_all_bookings()))
print(&apos;Database health: OK&apos; if db.health_check() else &apos;ERROR&apos;)
&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Vector Database Testing&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Verify vector search functionality
python -c &quot;
from database.vector_db import VectorDatabase
vdb = VectorDatabase()
results = vdb.search(&apos;refund policy&apos;, top_k=3)
print(&apos;Vector search working:&apos;, len(results) &gt; 0)
print(&apos;Sample result:&apos;, results[0][:100] + &apos;...&apos; if results else &apos;No results&apos;)
&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;End-to-End Integration Test&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Run a complete system test with sample customer interaction
python run_test_scenarios.py --session 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This executes a full customer support scenario, testing both database operations and RAG retrieval.&lt;/p&gt;
&lt;h2&gt;Monitoring and Maintenance&lt;/h2&gt;
&lt;h3&gt;Database Health Monitoring&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def check_database_health():
&quot;&quot;&quot;Monitor database performance and integrity&quot;&quot;&quot;
checks = {
&quot;table_integrity&quot;: verify_table_schemas(),
&quot;data_consistency&quot;: check_foreign_key_constraints(),
&quot;query_performance&quot;: measure_average_query_time(),
&quot;storage_usage&quot;: get_database_size_metrics()
}
return all(checks.values())
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Vector Database Maintenance&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def update_vector_database():
&quot;&quot;&quot;Maintain vector database accuracy and performance&quot;&quot;&quot;
tasks = [
reprocess_modified_documents(),
regenerate_outdated_embeddings(),
update_search_indices(),
cleanup_unused_vectors()
]
return execute_maintenance_tasks(tasks)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Production Deployment Considerations&lt;/h2&gt;
&lt;h3&gt;Database Scaling&lt;/h3&gt;
&lt;p&gt;For production environments, consider:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;PostgreSQL&lt;/strong&gt;: Replace SQLite for concurrent access and advanced features&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Connection pooling&lt;/strong&gt;: Manage database connections efficiently&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Read replicas&lt;/strong&gt;: Distribute read queries for better performance&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Backup strategy&lt;/strong&gt;: Automated backups and point-in-time recovery&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Vector Database Scaling&lt;/h3&gt;
&lt;p&gt;Production vector storage options:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Pinecone&lt;/strong&gt;: Managed vector database with excellent performance&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Weaviate&lt;/strong&gt;: Open-source with GraphQL interface&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ChromaDB&lt;/strong&gt;: Simple, powerful embeddings database&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Qdrant&lt;/strong&gt;: High-performance vector search engine&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Common Troubleshooting Issues&lt;/h2&gt;
&lt;h3&gt;Issue: Vector Search Returns Poor Results&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Solutions&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Verify embedding model consistency&lt;/li&gt;
&lt;li&gt;Check chunk size and overlap settings&lt;/li&gt;
&lt;li&gt;Adjust similarity thresholds&lt;/li&gt;
&lt;li&gt;Review document preprocessing quality&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Issue: Database Connection Timeouts&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Solutions&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Implement connection pooling&lt;/li&gt;
&lt;li&gt;Optimize query performance with indexes&lt;/li&gt;
&lt;li&gt;Consider read/write splitting&lt;/li&gt;
&lt;li&gt;Monitor concurrent connection limits&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Issue: RAG Responses Lack Context&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Solutions&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Increase chunk overlap&lt;/li&gt;
&lt;li&gt;Include more context in retrieval&lt;/li&gt;
&lt;li&gt;Improve document chunking boundaries&lt;/li&gt;
&lt;li&gt;Enhance query preprocessing&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Next in the series&lt;/h2&gt;
&lt;p&gt;The data layer is in place — precise operational data, and a knowledge base the system can search by meaning. Next I build the first agent that uses it: the Policy Agent, which retrieves from this vector store and answers from what it actually finds, not what it half-remembers.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Designing data layers that AI can reason over — not just store — is what I do at twentytwotensors. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><category>LLM</category></item><item><title>The Policy Agent: RAG that answers from real documents</title><link>https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-3/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-3/</guid><description>The Policy Agent — RAG that answers refund and fare questions grounded in the company&apos;s real documents, with a citation, not the model&apos;s memory.</description><pubDate>Sat, 03 Aug 2024 00:00:00 GMT</pubDate><content:encoded>&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/hero-enterprise-support.svg&quot; alt=&quot;Building enterprise AI customer support&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;p&gt;In &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-2&quot;&gt;part 2&lt;/a&gt; I built the data layer — a relational database for bookings and customers, and a vector store for the policy documents. This part builds the agent that uses that vector store: the Policy Agent. It answers policy questions — refunds, terms, fares — and the whole point is that every answer is grounded in the company&apos;s actual documents, not the model&apos;s memory.&lt;/p&gt;
&lt;h2&gt;Why policy questions are hard&lt;/h2&gt;
&lt;p&gt;Policy is exactly where human support struggles, and where a grounded AI earns its place. People forget the details or answer inconsistently. The documents are long and change often. The awkward questions need two or three sections combined and read carefully. And for anything compliance-related, &quot;roughly right&quot; isn&apos;t good enough — you need the real wording, and ideally a citation back to it.&lt;/p&gt;
&lt;p&gt;That&apos;s what RAG is for. Retrieval-augmented generation means the model retrieves the relevant policy text first and answers from that — it doesn&apos;t get to improvise. For policy, that&apos;s the difference between a system you can trust and one you can&apos;t.&lt;/p&gt;
&lt;h2&gt;How the Policy Agent works&lt;/h2&gt;
&lt;p&gt;The flow is straightforward: take the question, find the policy chunks that actually match it, hand those to the model as context, and have it answer from them — with a citation back to the source.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Customer Query
↓
Query Embedding (Vector Generation)
↓
Vector Similarity Search
↓
Document Chunk Retrieval (Top-K)
↓
Re-ranking &amp;#x26; Filtering
↓
Context Assembly
↓
LLM Prompt Construction
↓
Response Generation
↓
Citation Addition
↓
Final Response
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The critical insight for enterprise AI consulting is that each stage requires careful optimization. A poorly implemented RAG system suffers from irrelevant retrievals, context overflow, or hallucinated responses. Professional-grade implementation demands attention to chunking strategy, embedding model selection, retrieval algorithms, and prompt engineering.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Key architectural decisions:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Vector Database Choice&lt;/strong&gt;: a managed store like Pinecone is what you&apos;d reach for at scale; Chroma or Weaviate suit smaller deployments. The UKConnect build uses neither — one policy document fits in a pickled vector store, as Part 2 covered&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Embedding Model&lt;/strong&gt;: OpenAI&apos;s text-embedding-3-large (3072 dimensions) provides excellent semantic understanding for policy documents, though sentence-transformers offer cost-effective alternatives&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Chunking Strategy&lt;/strong&gt;: Semantic chunking with 500-token overlap preserves context across chunk boundaries, critical for policy documents with interconnected clauses&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Re-ranking Pipeline&lt;/strong&gt;: a cross-encoder re-ranker should recover relevant chunks that pure vector similarity ranks too low — worth reaching for when compliance accuracy matters, though I haven&apos;t measured the gain on a corpus this small&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you&apos;re picking a vector database for something real, I&apos;d prioritise query latency and filtering capability over raw storage capacity. Policy systems tend to need filtering by document version, department, or regulatory jurisdiction during retrieval, and that requirement narrows the field faster than scale does.&lt;/p&gt;
&lt;h2&gt;Document Processing &amp;#x26; Chunking Strategy&lt;/h2&gt;
&lt;p&gt;The foundation of any RAG implementation is document processing. Poor chunking destroys retrieval quality - chunks that are too large dilute semantic meaning, while chunks that are too small lose critical context. Enterprise policy documents present unique challenges: legal language spans multiple paragraphs, cross-references connect distant sections, and hierarchical structures (sections, subsections, clauses) must be preserved.&lt;/p&gt;
&lt;p&gt;The chunking strategy uses semantic boundaries rather than arbitrary token counts:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import tiktoken
from typing import List, Dict
import re

class PolicyDocumentProcessor:
def __init__(self, chunk_size: int = 500, overlap: int = 100):
self.chunk_size = chunk_size
self.overlap = overlap
self.encoding = tiktoken.encoding_for_model(&quot;gpt-4&quot;)

def extract_hierarchy(self, text: str) -&gt; Dict[str, str]:
&quot;&quot;&quot;Extract document structure metadata.&quot;&quot;&quot;
hierarchy = {
&apos;section&apos;: None,
&apos;subsection&apos;: None,
&apos;article&apos;: None
}

# Match section headers (e.g., &quot;Section 3.1: Refund Policy&quot;)
section_match = re.search(
r&apos;Section\\s+(\\d+(?:\\.\\d+)?):?\\s*([^\\n]+)&apos;,
text,
re.IGNORECASE
)
if section_match:
hierarchy[&apos;section&apos;] = section_match.group(2).strip()

# Match article numbers for legal documents
article_match = re.search(
r&apos;Article\\s+(\\d+)&apos;,
text,
re.IGNORECASE
)
if article_match:
hierarchy[&apos;article&apos;] = article_match.group(1)

return hierarchy

def semantic_chunk(self, document: str, metadata: Dict) -&gt; List[Dict]:
&quot;&quot;&quot;Chunk document at semantic boundaries with context preservation.&quot;&quot;&quot;
chunks = []

# Split on double newlines (paragraph boundaries)
paragraphs = re.split(r&apos;\\n\\n+&apos;, document)

current_chunk = []
current_tokens = 0
current_hierarchy = {}

for para in paragraphs:
para_tokens = len(self.encoding.encode(para))

# Extract hierarchy for this paragraph
para_hierarchy = self.extract_hierarchy(para)
if any(para_hierarchy.values()):
current_hierarchy.update(para_hierarchy)

# Check if adding this paragraph exceeds chunk size
if current_tokens + para_tokens &gt; self.chunk_size and current_chunk:
# Create chunk with metadata
chunk_text = &apos;\\n\\n&apos;.join(current_chunk)
chunks.append({
&apos;text&apos;: chunk_text,
&apos;metadata&apos;: {
**metadata,
**current_hierarchy,
&apos;token_count&apos;: current_tokens
}
})

# Start new chunk with overlap
# Keep last paragraph for context continuity
if len(current_chunk) &gt; 1:
current_chunk = [current_chunk[-1], para]
current_tokens = len(self.encoding.encode(
&apos;\\n\\n&apos;.join(current_chunk)
))
else:
current_chunk = [para]
current_tokens = para_tokens
else:
current_chunk.append(para)
current_tokens += para_tokens

# Add final chunk
if current_chunk:
chunks.append({
&apos;text&apos;: &apos;\\n\\n&apos;.join(current_chunk),
&apos;metadata&apos;: {
**metadata,
**current_hierarchy,
&apos;token_count&apos;: current_tokens
}
})

return chunks

def process_policy_document(
self,
filepath: str,
doc_metadata: Dict
) -&gt; List[Dict]:
&quot;&quot;&quot;Process a complete policy document into searchable chunks.&quot;&quot;&quot;
with open(filepath, &apos;r&apos;, encoding=&apos;utf-8&apos;) as f:
content = f.read()

# Remove excessive whitespace while preserving structure
content = re.sub(r&apos;\\n{3,}&apos;, &apos;\\n\\n&apos;, content)

# Chunk the document
chunks = self.semantic_chunk(content, doc_metadata)

# Add chunk IDs for citation
for idx, chunk in enumerate(chunks):
chunk[&apos;chunk_id&apos;] = f&quot;{doc_metadata[&apos;document_id&apos;]}_chunk_{idx}&quot;

return chunks
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This approach preserves semantic coherence while maintaining manageable chunk sizes. The overlap strategy ensures that concepts split across paragraphs remain retrievable. I haven&apos;t benchmarked this against fixed-size chunking on the UKConnect corpus — it&apos;s one policy document, which is too small for the comparison to mean anything.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Critical implementation considerations:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Token counting accuracy&lt;/strong&gt;: Use the same tokenizer as your embedding model to avoid chunk size mismatches&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Metadata enrichment&lt;/strong&gt;: Capture document version, effective date, and regulatory references during processing&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Structure preservation&lt;/strong&gt;: Maintain hierarchical context (section numbers, article references) in metadata for filtering&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Update handling&lt;/strong&gt;: Implement version control to deprecate outdated policy chunks when documents are updated&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For organizations with complex policy frameworks spanning hundreds of documents, professional AI consulting ensures chunking strategies align with document structure and retrieval requirements. We&apos;ve seen enterprises waste months on RAG implementations that fail due to poor document processing fundamentals.&lt;/p&gt;
&lt;h2&gt;Vector Embedding Implementation&lt;/h2&gt;
&lt;p&gt;Vector embeddings transform text into high-dimensional numerical representations where semantic similarity corresponds to geometric proximity. This mathematical foundation enables semantic search - the ability to find relevant content even when query terms don&apos;t exactly match document language.&lt;/p&gt;
&lt;p&gt;Scaled up, an embedding pipeline needs batch processing, caching, and error recovery. Here&apos;s the shape of one — note this sketch uses managed services rather than the Gemini embeddings and pickle store the UKConnect repo actually runs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import openai
from typing import List, Dict
import numpy as np
from pinecone import Pinecone, ServerlessSpec
import hashlib
import json

class EmbeddingPipeline:
def __init__(
self,
openai_api_key: str,
pinecone_api_key: str,
index_name: str = &quot;policy-embeddings&quot;
):
self.openai_client = openai.OpenAI(api_key=openai_api_key)
self.pc = Pinecone(api_key=pinecone_api_key)
self.index_name = index_name
self.embedding_model = &quot;text-embedding-3-large&quot;
self.embedding_dimension = 3072

# Initialize or connect to Pinecone index
self._initialize_index()

def _initialize_index(self):
&quot;&quot;&quot;Create Pinecone index with optimal configuration.&quot;&quot;&quot;
existing_indexes = [idx.name for idx in self.pc.list_indexes()]

if self.index_name not in existing_indexes:
self.pc.create_index(
name=self.index_name,
dimension=self.embedding_dimension,
metric=&apos;cosine&apos;,  # Cosine similarity for text embeddings
spec=ServerlessSpec(
cloud=&apos;aws&apos;,
region=&apos;us-east-1&apos;  # Choose region near your application
)
)

self.index = self.pc.Index(self.index_name)

def generate_embeddings(
self,
texts: List[str],
batch_size: int = 100
) -&gt; List[List[float]]:
&quot;&quot;&quot;Generate embeddings with batching and error handling.&quot;&quot;&quot;
embeddings = []

for i in range(0, len(texts), batch_size):
batch = texts[i:i + batch_size]

try:
response = self.openai_client.embeddings.create(
model=self.embedding_model,
input=batch,
encoding_format=&quot;float&quot;
)

batch_embeddings = [
item.embedding for item in response.data
]
embeddings.extend(batch_embeddings)

except Exception as e:
print(f&quot;Embedding generation failed for batch {i}: {e}&quot;)
# Retry individual texts in failed batch
for text in batch:
try:
response = self.openai_client.embeddings.create(
model=self.embedding_model,
input=[text],
encoding_format=&quot;float&quot;
)
embeddings.append(response.data[0].embedding)
except Exception as inner_e:
print(f&quot;Failed to embed text: {inner_e}&quot;)
# Add zero vector as placeholder
embeddings.append([0.0] * self.embedding_dimension)

return embeddings

def upsert_chunks(self, chunks: List[Dict], namespace: str = &quot;policies&quot;):
&quot;&quot;&quot;Upload document chunks with embeddings to Pinecone.&quot;&quot;&quot;
# Extract texts for embedding
texts = [chunk[&apos;text&apos;] for chunk in chunks]

# Generate embeddings
embeddings = self.generate_embeddings(texts)

# Prepare vectors for Pinecone
vectors = []
for chunk, embedding in zip(chunks, embeddings):
# Create unique ID from content hash
content_hash = hashlib.md5(
chunk[&apos;text&apos;].encode()
).hexdigest()[:8]

vector_id = chunk.get(&apos;chunk_id&apos;, content_hash)

# Prepare metadata (Pinecone has size limits)
metadata = {
&apos;text&apos;: chunk[&apos;text&apos;][:1000],  # Truncate for storage
&apos;full_text_hash&apos;: content_hash,
**{k: v for k, v in chunk[&apos;metadata&apos;].items()
if v is not None}
}

vectors.append({
&apos;id&apos;: vector_id,
&apos;values&apos;: embedding,
&apos;metadata&apos;: metadata
})

# Upsert in batches
batch_size = 100
for i in range(0, len(vectors), batch_size):
batch = vectors[i:i + batch_size]
self.index.upsert(
vectors=batch,
namespace=namespace
)

def query_similar(
self,
query: str,
top_k: int = 5,
namespace: str = &quot;policies&quot;,
filter_dict: Dict = None
) -&gt; List[Dict]:
&quot;&quot;&quot;Find most similar document chunks to query.&quot;&quot;&quot;
# Generate query embedding
query_embedding = self.generate_embeddings([query])[0]

# Query Pinecone
results = self.index.query(
vector=query_embedding,
top_k=top_k,
include_metadata=True,
namespace=namespace,
filter=filter_dict  # e.g., {&apos;section&apos;: &apos;refund_policy&apos;}
)

# Format results
matches = []
for match in results.matches:
matches.append({
&apos;id&apos;: match.id,
&apos;score&apos;: match.score,
&apos;text&apos;: match.metadata.get(&apos;text&apos;, &apos;&apos;),
&apos;metadata&apos;: {
k: v for k, v in match.metadata.items()
if k != &apos;text&apos;
}
})

return matches
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This sketch covers the features embedding generation needs before you&apos;d trust it with real volume:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Batch processing&lt;/strong&gt;: Reduces API calls and improves throughput for large document sets&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Error recovery&lt;/strong&gt;: Handles individual embedding failures without losing entire batches&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Metadata management&lt;/strong&gt;: Preserves document context while respecting Pinecone&apos;s metadata size limits&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Content hashing&lt;/strong&gt;: Enables deduplication and change detection for document updates&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Namespace organization&lt;/strong&gt;: Separates different policy domains (HR, legal, technical) for targeted retrieval&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;My working assumption — from the retrieval work I&apos;ve done since, on clinical guidelines rather than rail policies — is that embedding quality matters more than retrieval algorithm sophistication. Modern embedding models give good zero-shot performance on domain content without fine-tuning. For organisations with highly specialised terminology, fine-tuning the embedding model on domain data is the next lever to reach for, though I can&apos;t put a number on the gain for your corpus and neither can anyone who hasn&apos;t measured it on your corpus.&lt;/p&gt;
&lt;h2&gt;Search &amp;#x26; Retrieval Optimization&lt;/h2&gt;
&lt;p&gt;Vector similarity search provides a strong baseline, but enterprise AI systems require additional optimization layers. Raw vector search suffers from several limitations: it can&apos;t filter by document metadata efficiently, doesn&apos;t understand query intent, and treats all semantic similarity equally regardless of document importance.&lt;/p&gt;
&lt;p&gt;The retrieval pipeline implements a three-stage process:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from typing import List, Dict, Optional
import cohere

class PolicyRetrieval:
def __init__(
self,
embedding_pipeline: EmbeddingPipeline,
cohere_api_key: str
):
self.embedding_pipeline = embedding_pipeline
self.cohere_client = cohere.Client(cohere_api_key)

def hybrid_search(
self,
query: str,
top_k: int = 20,
rerank_top_k: int = 5,
filter_metadata: Optional[Dict] = None
) -&gt; List[Dict]:
&quot;&quot;&quot;Three-stage retrieval: vector search, re-ranking, filtering.&quot;&quot;&quot;

# Stage 1: Vector similarity search with over-retrieval
# Retrieve more candidates than needed for re-ranking
vector_results = self.embedding_pipeline.query_similar(
query=query,
top_k=top_k,
filter_dict=filter_metadata
)

if not vector_results:
return []

# Stage 2: Re-ranking with cross-encoder model
# Cohere&apos;s re-ranker uses bidirectional attention
documents = [result[&apos;text&apos;] for result in vector_results]

rerank_response = self.cohere_client.rerank(
model=&apos;rerank-english-v3.0&apos;,
query=query,
documents=documents,
top_n=rerank_top_k,
return_documents=True
)

# Map re-ranked results back to original metadata
reranked_results = []
for result in rerank_response.results:
original_result = vector_results[result.index]
reranked_results.append({
**original_result,
&apos;rerank_score&apos;: result.relevance_score,
&apos;original_rank&apos;: result.index
})

# Stage 3: Apply business logic filters
filtered_results = self._apply_business_filters(
reranked_results,
query
)

return filtered_results

def _apply_business_filters(
self,
results: List[Dict],
query: str
) -&gt; List[Dict]:
&quot;&quot;&quot;Apply domain-specific filtering logic.&quot;&quot;&quot;
filtered = []

for result in results:
metadata = result.get(&apos;metadata&apos;, {})

# Filter out deprecated policy versions
if metadata.get(&apos;status&apos;) == &apos;deprecated&apos;:
continue

# Ensure minimum relevance threshold
if result.get(&apos;rerank_score&apos;, 0) &amp;#x3C; 0.5:
continue

# Check document recency for time-sensitive policies
# (Implementation would check effective_date in metadata)

filtered.append(result)

return filtered

def explain_retrieval(self, query: str, results: List[Dict]) -&gt; Dict:
&quot;&quot;&quot;Generate retrieval explanation for debugging and transparency.&quot;&quot;&quot;
explanation = {
&apos;query&apos;: query,
&apos;num_results&apos;: len(results),
&apos;top_scores&apos;: [r.get(&apos;rerank_score&apos;) for r in results[:3]],
&apos;source_documents&apos;: [
{
&apos;id&apos;: r[&apos;id&apos;],
&apos;section&apos;: r[&apos;metadata&apos;].get(&apos;section&apos;, &apos;unknown&apos;),
&apos;score&apos;: r.get(&apos;rerank_score&apos;)
}
for r in results
]
}
return explanation
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This hybrid approach significantly improves retrieval quality:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Over-retrieval strategy&lt;/strong&gt;: Fetch 20 candidates for re-ranking to ensure relevant documents aren&apos;t missed by vector search alone&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cross-encoder re-ranking&lt;/strong&gt;: Cohere&apos;s re-ranker evaluates query-document pairs jointly, capturing subtle semantic relationships that vector similarity misses&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Business logic filters&lt;/strong&gt;: Implement domain-specific rules (document freshness, deprecation status, regulatory requirements)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Transparency mechanisms&lt;/strong&gt;: Provide retrieval explanations for debugging and compliance auditing&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Hybrid retrieval should beat pure vector search on queries where an exact term matters — a policy code, a station name — because lexical matching catches what embeddings smooth over. I&apos;ve not measured that here. What I&apos;d say with more confidence is that retrieval optimisation is iterative: you need retrieval quality metrics and real user queries before any threshold you pick is more than a guess.&lt;/p&gt;
&lt;h2&gt;LLM Integration &amp;#x26; Prompt Engineering&lt;/h2&gt;
&lt;p&gt;The final stage of our RAG pipeline combines retrieved documents with sophisticated prompt engineering to generate accurate, well-cited policy responses. This is where many enterprise AI implementations fail - poor prompts lead to hallucinations, missing citations, or overly generic responses that don&apos;t leverage the retrieved context effectively.&lt;/p&gt;
&lt;p&gt;The prompt engineering strategy follows these principles:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from typing import List, Dict
import openai

class PolicyAgent:
def __init__(self, openai_api_key: str, retrieval_system: PolicyRetrieval):
self.client = openai.OpenAI(api_key=openai_api_key)
self.retrieval = retrieval_system
self.model = &quot;gpt-4-turbo-preview&quot;

def construct_prompt(
self,
query: str,
context_chunks: List[Dict]
) -&gt; str:
&quot;&quot;&quot;Build sophisticated RAG prompt with grounding instructions.&quot;&quot;&quot;

# Format retrieved context with citations
context_sections = []
for idx, chunk in enumerate(context_chunks, 1):
metadata = chunk[&apos;metadata&apos;]
section_ref = metadata.get(&apos;section&apos;, &apos;Unknown Section&apos;)

context_sections.append(
f&quot;[Source {idx}: {section_ref}]\\n{chunk[&apos;text&apos;]}\\n&quot;
)

context_text = &quot;\\n&quot;.join(context_sections)

prompt = f&quot;&quot;&quot;You are a Policy Assistant for an enterprise customer support system. Your role is to provide accurate, helpful answers to customer policy questions based ONLY on the provided policy documents.

RETRIEVED POLICY CONTEXT:
{context_text}

CUSTOMER QUESTION:
{query}

INSTRUCTIONS:
1. Answer the question using ONLY information from the provided policy context
2. If the context doesn&apos;t contain enough information to answer fully, say so explicitly
3. Cite specific sources using [Source N] notation for all factual claims
4. Use clear, customer-friendly language while maintaining accuracy
5. If policies conflict or have exceptions, explain them clearly
6. Do not make assumptions or add information not present in the context
7. For time-sensitive policies, mention any effective dates if present

RESPONSE FORMAT:
- Start with a direct answer to the customer&apos;s question
- Provide supporting details with source citations
- End with any relevant caveats or additional information the customer should know

Your response:&quot;&quot;&quot;

return prompt

def generate_response(
self,
query: str,
temperature: float = 0.1,
max_tokens: int = 800
) -&gt; Dict:
&quot;&quot;&quot;Generate policy response with full RAG pipeline.&quot;&quot;&quot;

# Retrieve relevant policy chunks
retrieved_chunks = self.retrieval.hybrid_search(
query=query,
top_k=20,
rerank_top_k=5
)

if not retrieved_chunks:
return {
&apos;response&apos;: &quot;I apologize, but I couldn&apos;t find relevant policy information to answer your question. Please contact our support team for assistance.&quot;,
&apos;sources&apos;: [],
&apos;confidence&apos;: &apos;low&apos;
}

# Construct prompt with retrieved context
prompt = self.construct_prompt(query, retrieved_chunks)

# Generate response with low temperature for consistency
completion = self.client.chat.completions.create(
model=self.model,
messages=[
{
&quot;role&quot;: &quot;system&quot;,
&quot;content&quot;: &quot;You are a precise, helpful policy assistant. Always ground your responses in the provided context and cite sources.&quot;
},
{
&quot;role&quot;: &quot;user&quot;,
&quot;content&quot;: prompt
}
],
temperature=temperature,
max_tokens=max_tokens
)

response_text = completion.choices[0].message.content

# Extract source citations from response
import re
cited_sources = re.findall(r&apos;\\[Source (\\d+)\\]&apos;, response_text)
unique_sources = list(set(cited_sources))

# Build source references
source_refs = []
for source_num in unique_sources:
idx = int(source_num) - 1
if idx &amp;#x3C; len(retrieved_chunks):
chunk = retrieved_chunks[idx]
source_refs.append({
&apos;source_number&apos;: source_num,
&apos;section&apos;: chunk[&apos;metadata&apos;].get(&apos;section&apos;, &apos;Unknown&apos;),
&apos;document_id&apos;: chunk.get(&apos;id&apos;, &apos;Unknown&apos;),
&apos;relevance_score&apos;: chunk.get(&apos;rerank_score&apos;, 0)
})

return {
&apos;response&apos;: response_text,
&apos;sources&apos;: source_refs,
&apos;confidence&apos;: self._assess_confidence(retrieved_chunks, cited_sources),
&apos;retrieval_explanation&apos;: self.retrieval.explain_retrieval(
query,
retrieved_chunks
)
}

def _assess_confidence(
self,
retrieved_chunks: List[Dict],
cited_sources: List[str]
) -&gt; str:
&quot;&quot;&quot;Heuristic confidence assessment based on retrieval quality.&quot;&quot;&quot;
if not retrieved_chunks:
return &apos;low&apos;

top_score = retrieved_chunks[0].get(&apos;rerank_score&apos;, 0)
num_citations = len(set(cited_sources))

if top_score &gt; 0.8 and num_citations &gt;= 2:
return &apos;high&apos;
elif top_score &gt; 0.6 and num_citations &gt;= 1:
return &apos;medium&apos;
else:
return &apos;low&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This implementation incorporates several advanced prompt engineering techniques:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Explicit grounding instructions&lt;/strong&gt;: Prevents hallucination by constraining the LLM to retrieved context&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Structured citation format&lt;/strong&gt;: Makes source tracking programmatically verifiable for compliance&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Customer-friendly language directive&lt;/strong&gt;: Balances accuracy with accessibility&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Caveat handling&lt;/strong&gt;: Encourages the model to surface edge cases and exceptions&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Low temperature setting&lt;/strong&gt;: Reduces variability for consistent policy responses&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Confidence scoring&lt;/strong&gt;: Provides quality indicators for routing low-confidence queries to human agents&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Prompt engineering is an empirical discipline: test prompts against diverse query types, measure citation accuracy, and iterate. In a later RAG project of mine, query expansion alone was worth 0.030–0.035 factual F1 on a measured benchmark — a real number, from a different corpus, offered here only to show the scale these adjustments move at rather than to promise you the same.&lt;/p&gt;
&lt;h2&gt;Testing &amp;#x26; Quality Assurance&lt;/h2&gt;
&lt;p&gt;Enterprise RAG systems require rigorous testing to ensure accuracy, consistency, and compliance. Unlike traditional software where test cases are deterministic, LLM-based systems exhibit variability that demands statistical evaluation approaches.&lt;/p&gt;
&lt;p&gt;The testing framework covers multiple dimensions:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import pytest
from typing import List, Dict
import json

class RAGTestSuite:
def __init__(self, policy_agent: PolicyAgent):
self.agent = policy_agent
self.test_cases = self._load_test_cases()

def _load_test_cases(self) -&gt; List[Dict]:
&quot;&quot;&quot;Load curated test cases with ground truth answers.&quot;&quot;&quot;
return [
{
&apos;query&apos;: &apos;What is the refund window for cancelled bookings?&apos;,
&apos;expected_sources&apos;: [&apos;refund_policy&apos;],
&apos;expected_facts&apos;: [&apos;48 hours&apos;, &apos;full refund&apos;],
&apos;category&apos;: &apos;refund&apos;
},
{
&apos;query&apos;: &apos;Can I change my booking date after confirmation?&apos;,
&apos;expected_sources&apos;: [&apos;modification_policy&apos;],
&apos;expected_facts&apos;: [&apos;24 hours notice&apos;, &apos;one-time change&apos;],
&apos;category&apos;: &apos;modification&apos;
},
# ... more test cases
]

def test_retrieval_precision(self):
&quot;&quot;&quot;Test that correct policy sections are retrieved.&quot;&quot;&quot;
results = []

for test_case in self.test_cases:
retrieved = self.agent.retrieval.hybrid_search(
test_case[&apos;query&apos;],
rerank_top_k=3
)

retrieved_sections = [
chunk[&apos;metadata&apos;].get(&apos;section&apos;, &apos;&apos;)
for chunk in retrieved
]

# Check if expected sources appear in top 3
precision = any(
expected in str(retrieved_sections)
for expected in test_case[&apos;expected_sources&apos;]
)

results.append({
&apos;query&apos;: test_case[&apos;query&apos;],
&apos;precision&apos;: precision,
&apos;retrieved_sections&apos;: retrieved_sections
})

precision_rate = sum(r[&apos;precision&apos;] for r in results) / len(results)
assert precision_rate &gt;= 0.90, f&quot;Retrieval precision {precision_rate} below 90% threshold&quot;

def test_factual_accuracy(self):
&quot;&quot;&quot;Test that generated responses contain expected facts.&quot;&quot;&quot;
results = []

for test_case in self.test_cases:
response = self.agent.generate_response(test_case[&apos;query&apos;])
response_text = response[&apos;response&apos;].lower()

# Check if expected facts appear in response
facts_present = [
fact.lower() in response_text
for fact in test_case[&apos;expected_facts&apos;]
]

accuracy = sum(facts_present) / len(facts_present)

results.append({
&apos;query&apos;: test_case[&apos;query&apos;],
&apos;accuracy&apos;: accuracy,
&apos;missing_facts&apos;: [
fact for fact, present in
zip(test_case[&apos;expected_facts&apos;], facts_present)
if not present
]
})

avg_accuracy = sum(r[&apos;accuracy&apos;] for r in results) / len(results)
assert avg_accuracy &gt;= 0.85, f&quot;Factual accuracy {avg_accuracy} below 85% threshold&quot;

def test_citation_completeness(self):
&quot;&quot;&quot;Test that responses include proper source citations.&quot;&quot;&quot;
for test_case in self.test_cases:
response = self.agent.generate_response(test_case[&apos;query&apos;])

assert len(response[&apos;sources&apos;]) &gt; 0, \\
f&quot;No sources cited for query: {test_case[&apos;query&apos;]}&quot;

# Verify citations appear in response text
import re
citations = re.findall(r&apos;\\[Source \\d+\\]&apos;, response[&apos;response&apos;])
assert len(citations) &gt; 0, \\
f&quot;Sources provided but not cited in text: {test_case[&apos;query&apos;]}&quot;

def test_consistency(self, iterations: int = 5):
&quot;&quot;&quot;Test response consistency across multiple generations.&quot;&quot;&quot;
for test_case in self.test_cases[:3]:  # Test subset for speed
responses = []

for _ in range(iterations):
response = self.agent.generate_response(test_case[&apos;query&apos;])
responses.append(response[&apos;response&apos;])

# Check that key facts appear consistently
for expected_fact in test_case[&apos;expected_facts&apos;]:
appearances = sum(
expected_fact.lower() in resp.lower()
for resp in responses
)
consistency_rate = appearances / iterations

assert consistency_rate &gt;= 0.8, \\
f&quot;Fact &apos;{expected_fact}&apos; inconsistent across generations&quot;

def test_hallucination_detection(self):
&quot;&quot;&quot;Test queries designed to trigger hallucinations.&quot;&quot;&quot;
hallucination_queries = [
&quot;What is the refund policy for Mars trips?&quot;,  # Non-existent product
&quot;How much does a premium subscription cost?&quot;,  # If not in docs
]

for query in hallucination_queries:
response = self.agent.generate_response(query)

# Low confidence or explicit &quot;I don&apos;t know&quot; expected
assert (
response[&apos;confidence&apos;] == &apos;low&apos; or
&quot;don&apos;t have&quot; in response[&apos;response&apos;].lower() or
&quot;couldn&apos;t find&quot; in response[&apos;response&apos;].lower()
), f&quot;Potential hallucination for query: {query}&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This testing framework validates several critical quality dimensions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Retrieval Precision&lt;/strong&gt;: are the correct policy sections found? 90%+ is the bar I&apos;d set before trusting it — a target, not a result I measured&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Factual Accuracy&lt;/strong&gt;: do generated responses contain the expected factual elements? Again 85%+ is a threshold I&apos;d want to clear, not a score I recorded&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Citation Completeness&lt;/strong&gt;: Validates that all responses include proper source attribution&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Response Consistency&lt;/strong&gt;: Tests that the same query produces semantically consistent answers across multiple generations&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hallucination Resistance&lt;/strong&gt;: Evaluates system behavior on queries that should trigger &quot;I don&apos;t know&quot; responses&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Anything that actually ships needs continuous evaluation against real user queries: user satisfaction ratings, escalation rates to human agents, citation accuracy audits. I&apos;d expect demonstrable testing rigour to help with stakeholder buy-in and regulatory sign-off too — that one is reasoning from how these conversations tend to go, not something I&apos;ve carried through an approval process myself.&lt;/p&gt;
&lt;h2&gt;Production Deployment Considerations&lt;/h2&gt;
&lt;p&gt;Moving from prototype to production requires addressing scalability, monitoring, and maintenance:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Latency optimization&lt;/strong&gt;: cache frequent queries and pre-compute embeddings for common policy sections if you&apos;re chasing sub-2-second responses&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cost management&lt;/strong&gt;: Monitor embedding and LLM API costs; implement query batching and result caching to reduce expenses at scale&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Version control&lt;/strong&gt;: Track policy document versions in vector database metadata to enable rollback and audit trails&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Monitoring dashboards&lt;/strong&gt;: Instrument retrieval precision, response latency, citation accuracy, and user satisfaction scores&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Human-in-the-loop&lt;/strong&gt;: Route low-confidence responses to human review; use feedback to improve retrieval and prompts&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Most of the hard work in a real deployment is here, not in the core RAG. A system that&apos;s technically clever but slow, expensive, or unmonitored doesn&apos;t survive contact with production. This is the part I spend the most time on with clients.&lt;/p&gt;
&lt;h2&gt;Next in the series&lt;/h2&gt;
&lt;p&gt;The Policy Agent answers questions. Next I build its opposite number — the Ticket Agent, which doesn&apos;t just answer but acts: searching, booking, modifying and cancelling against the database.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Production-ready RAG — grounded, cited, fast enough, and monitored — is what I build at twentytwotensors. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt; if you need it.&lt;/em&gt;&lt;/p&gt;</content:encoded><category>LLM</category></item><item><title>The Ticket Agent: books, changes and refunds</title><link>https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-4/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-4/</guid><description>The Ticket Agent — the one that actually changes things. Search, book, modify, cancel, refund, built from tools instead of free-form SQL.</description><pubDate>Sat, 03 Aug 2024 00:00:00 GMT</pubDate><content:encoded>&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/hero-enterprise-support.svg&quot; alt=&quot;Building enterprise AI customer support&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;p&gt;In &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-3&quot;&gt;part 3&lt;/a&gt; I built the Policy Agent — the one that answers from documents. This part is its opposite number: the Ticket Agent, the one that actually changes things. Search a route, make a booking, modify it, cancel it, work out a refund. Real database operations, driven by plain language.&lt;/p&gt;
&lt;h2&gt;The problem it solves&lt;/h2&gt;
&lt;p&gt;Booking is where support gets messy. A real request rarely maps to one clean database call. Someone wants to change a trip — that means finding their booking, checking what&apos;s available, cancelling one thing, creating another, and keeping it all straight across a few messages. They use informal names, like &quot;the London train&quot;, that don&apos;t match a station code. And when a step fails halfway through, you can&apos;t leave the data in a broken state.&lt;/p&gt;
&lt;p&gt;So the Ticket Agent isn&apos;t a chatbot with database access bolted on. It&apos;s an agent with a fixed set of tools — each one a specific, safe operation — and the judgement to pick the right one and recover when something fails.&lt;/p&gt;
&lt;h2&gt;How it works: tools, not free-form SQL&lt;/h2&gt;
&lt;p&gt;The agent doesn&apos;t write SQL. It calls tools I wrote and tested — named operations — and its only job is to choose which tool to call and with what arguments. That&apos;s the safe pattern: the model decides, the tools constrain what can actually happen to the data.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Customer request
→ Ticket Agent picks a tool:
• Booking search        — what&apos;s available
• Customer lookup       — their active bookings
• Book / modify / cancel — the transactional work
• Payment + refund calc  — logged every time
• Location mapping       — &quot;London&quot; → the right station
→ Response
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two of those are worth a note. &lt;strong&gt;Location mapping&lt;/strong&gt; turns the informal place name a customer actually types into the station the database understands — I go into that in &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-6&quot;&gt;part 6&lt;/a&gt;. And &lt;strong&gt;state is kept across the conversation&lt;/strong&gt;, so &quot;actually, make it the later train&quot; works without starting over.&lt;/p&gt;
&lt;p&gt;The full implementation — every tool, the schema, the transaction handling — is in the &lt;a href=&quot;https://github.com/ntg2208/production-ai-customer-support&quot; rel=&quot;noopener&quot; target=&quot;_blank&quot;&gt;open-source repo&lt;/a&gt;. I&apos;d rather point you at code that runs than paste two hundred lines here.&lt;/p&gt;
&lt;h2&gt;Next in the series&lt;/h2&gt;
&lt;p&gt;Now I have both specialists — Policy and Ticket. Next I build the part that decides which one to use: the Master Agent, and the routing and context-handling that ties the whole thing together.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Building transactional AI that touches real systems — and doesn&apos;t break them — is what I do at twentytwotensors. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt; if you need it.&lt;/em&gt;&lt;/p&gt;</content:encoded><category>LLM</category></item><item><title>The Master Agent: routing and context</title><link>https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-5/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-5/</guid><description>The Master Agent — the piece that decides which specialist handles a message, holds context across hand-offs, and synthesises one clean reply.</description><pubDate>Sat, 03 Aug 2024 00:00:00 GMT</pubDate><content:encoded>&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/hero-enterprise-support.svg&quot; alt=&quot;Building enterprise AI customer support&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;p&gt;I&apos;ve now got two specialists — the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-3&quot;&gt;Policy Agent&lt;/a&gt; and the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-4&quot;&gt;Ticket Agent&lt;/a&gt;. This part is the piece that decides which one to use: the Master Agent. It reads the customer&apos;s message, sends it to the right specialist, and turns whatever comes back into one clean reply.&lt;/p&gt;
&lt;h2&gt;The problem it solves&lt;/h2&gt;
&lt;p&gt;Coordinating agents is harder than it sounds, because the hard cases aren&apos;t the clean ones. The agent has to work out what a message is even asking for. It has to carry the conversation&apos;s context across a hand-off, so the specialist knows what came before. When a request needs both specialists — cancel this booking, and tell me the refund rule — it has to call them in order and merge the two answers. And when neither specialist can resolve something, it needs to escalate rather than make something up.&lt;/p&gt;
&lt;p&gt;So the Master Agent&apos;s whole job is judgement: read intent, route, hold context, synthesise. It never touches the database or the documents itself.&lt;/p&gt;
&lt;h2&gt;How it works: route, hold context, synthesise&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;Customer message
→ Master Agent reads intent
→ routes to Policy Agent  (documents)
→ and/or Ticket Agent     (database)
→ holds conversation history + customer state
→ synthesises one reply
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The two things that make this feel natural rather than robotic are context and synthesis. Context means the customer never repeats themselves — the history and their state travel with the conversation. Synthesis means that even when two specialists were involved, the customer gets a single, coherent answer, not two stitched-together fragments.&lt;/p&gt;
&lt;p&gt;The full orchestration code is in the &lt;a href=&quot;https://github.com/ntg2208/production-ai-customer-support&quot; rel=&quot;noopener&quot; target=&quot;_blank&quot;&gt;open-source repo&lt;/a&gt;. If you&apos;re weighing how explicit this routing should be, the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/adk-vs-langgraph-customer-support&quot;&gt;ADK vs LangGraph comparison&lt;/a&gt; is the post to read — it&apos;s exactly the trade-off this layer turns on.&lt;/p&gt;
&lt;h2&gt;Next in the series&lt;/h2&gt;
&lt;p&gt;The system works, but it still assumes the customer says exactly what the database expects. Real people don&apos;t. Next I add location intelligence — turning &quot;the London train&quot; into the station the system actually knows.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Multi-agent orchestration that stays explainable in production is what I do at twentytwotensors. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt; if you need it built.&lt;/em&gt;&lt;/p&gt;</content:encoded><category>LLM</category></item><item><title>Location intelligence: &apos;the London train&apos; into a station</title><link>https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-6/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-6/</guid><description>Customers say &apos;the London train&apos;, not a station code. Resolving the places people actually type into the stations the system knows.</description><pubDate>Sat, 03 Aug 2024 00:00:00 GMT</pubDate><content:encoded>&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/hero-enterprise-support.svg&quot; alt=&quot;Building enterprise AI customer support&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;p&gt;Up to now the system has quietly assumed customers say exactly what the database expects. They don&apos;t. Someone books &quot;the London train&quot; — but there are several London stations, and the database wants a specific one. This part fixes that small, constant friction.&lt;/p&gt;
&lt;h2&gt;The problem it solves&lt;/h2&gt;
&lt;p&gt;People describe places the way people do, not the way a database does. They name a city when the system wants a station. They say &quot;near Manchester&quot;. They don&apos;t know — and shouldn&apos;t have to know — the station code. If the agent just passes that straight through, the search fails or, worse, books the wrong thing.&lt;/p&gt;
&lt;p&gt;There&apos;s also a quieter win hiding here. I already know where each customer lives, because it&apos;s on their profile. So I can default their departure station sensibly instead of asking every single time.&lt;/p&gt;
&lt;h2&gt;How it works: a mapping, not magic&lt;/h2&gt;
&lt;p&gt;This is deliberately not clever. It&apos;s a curated mapping from the places customers actually type to the stations the system knows, plus a default departure station derived from the customer&apos;s address on file. No IP geolocation, no guessing — I have the address, so I use it.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;the London train&quot;  →  resolve against known stations  →  London Euston
customer address     →  default departure station        →  pre-filled, editable
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The point is to do the obvious resolution before the Ticket Agent runs its tools, so the booking operation gets a real station, not a vague phrase. When it&apos;s genuinely ambiguous, the agent asks — it doesn&apos;t quietly pick one and hope.&lt;/p&gt;
&lt;p&gt;The mapping and the lookup are in the &lt;a href=&quot;https://github.com/ntg2208/production-ai-customer-support&quot; rel=&quot;noopener&quot; target=&quot;_blank&quot;&gt;open-source repo&lt;/a&gt;. It&apos;s a small file doing an unglamorous job, which is exactly why it works.&lt;/p&gt;
&lt;h2&gt;Next in the series&lt;/h2&gt;
&lt;p&gt;That&apos;s the whole system built — two specialists, a coordinator, and the location handling. Next I do the part most tutorials skip: testing it, and proving it actually behaves before it goes anywhere near a customer.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Building AI that meets customers where they are — plain language, real data — is what I do at twentytwotensors. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><category>LLM</category></item><item><title>Testing an LLM system you can&apos;t unit-test</title><link>https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-7/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-7/</guid><description>You can&apos;t unit-test an LLM by matching strings. Ordinary tests for the deterministic tools, 15 end-to-end scenarios, and an LLM-as-a-judge for the rest.</description><pubDate>Sat, 03 Aug 2024 00:00:00 GMT</pubDate><content:encoded>&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/hero-enterprise-support.svg&quot; alt=&quot;Building enterprise AI customer support&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;p&gt;Most tutorials stop at &quot;it works on my machine.&quot; This is the part that decides whether you&apos;d actually let it talk to a customer. And testing an LLM system is genuinely different from testing normal software, so it&apos;s worth doing properly.&lt;/p&gt;
&lt;h2&gt;Why you can&apos;t test it the normal way&lt;/h2&gt;
&lt;p&gt;The usual move is to assert that output equals an expected string. That doesn&apos;t work here. Ask the agent the same question twice and you get two correct answers worded differently. A string-equality test would fail both. So the question isn&apos;t &quot;did it return exactly this text?&quot; — it&apos;s &quot;was the answer correct, grounded in the real policy, and appropriate?&quot;&lt;/p&gt;
&lt;p&gt;Part of the system &lt;em&gt;is&lt;/em&gt; deterministic, though, and I test that the boring, reliable way. The database tools — search, book, cancel, refund maths — have exact right answers. Those get ordinary unit tests. It&apos;s only the model&apos;s judgement that needs a different approach.&lt;/p&gt;
&lt;h2&gt;How I test it: scenarios, plus a judge&lt;/h2&gt;
&lt;p&gt;Two things do the heavy lifting.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Realistic scenarios.&lt;/strong&gt; I wrote 15 end-to-end customer scenarios — the simple policy question, the booking change that needs both agents, the ambiguous location, the request that should be refused. They run the whole system, not one piece, because the bugs that matter live in the hand-offs between agents.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;LLM-as-a-judge.&lt;/strong&gt; Instead of matching strings, I have a separate model grade each response against a rubric: is it correct, is it grounded in the actual policy, is the tone right? That turns a fuzzy &quot;seems fine&quot; into a score I can track and fail a build on.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;15 scenarios  →  run the full system  →  capture each response
↓
LLM judge grades vs. rubric
↓
correct? grounded? appropriate?  →  report
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The scenarios and the evaluation harness are in the &lt;a href=&quot;https://github.com/ntg2208/production-ai-customer-support&quot; rel=&quot;noopener&quot; target=&quot;_blank&quot;&gt;open-source repo&lt;/a&gt;. The judge isn&apos;t perfect — but it catches regressions a human reviewer would miss on the hundredth run, and it never gets bored.&lt;/p&gt;
&lt;h2&gt;Next in the series&lt;/h2&gt;
&lt;p&gt;The system is built and tested. The last part is the honest one: this is a project, not a client deployment — so what can I actually say it&apos;s worth, and what would I refuse to claim?&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Evaluating LLM systems so you can trust them in production — that&apos;s a big part of what I do at twentytwotensors. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><category>LLM</category></item><item><title>The honest part: it&apos;s a project, not a case study</title><link>https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-8/</link><guid isPermaLink="true">https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-8/</guid><description>UKConnect is fictional, so there&apos;s no real ROI — and I&apos;m not inventing one. What I can actually stand behind, what I won&apos;t claim, and how you&apos;d find the real number yourself.</description><pubDate>Sat, 03 Aug 2024 00:00:00 GMT</pubDate><content:encoded>&lt;figure class=&quot;post-hero&quot;&gt;&lt;img src=&quot;https://twentytwotensors.co.uk/images/blog/hero-enterprise-support.svg&quot; alt=&quot;Building enterprise AI customer support&quot; loading=&quot;lazy&quot;&gt;&lt;/figure&gt;
&lt;p&gt;For most of this series I wrote like a consultant pitching a business case. I want to drop that for the last part and be straight with you.&lt;/p&gt;
&lt;p&gt;UKConnect isn&apos;t a real company. I made it up. I built this system to learn, and to show how the pieces actually fit together — not for a paying client. So there are no real customers, and there is no real ROI. I&apos;m not going to invent one.&lt;/p&gt;
&lt;h2&gt;Why this post used to have numbers in it&lt;/h2&gt;
&lt;p&gt;The first version of this post quoted a &quot;60% cost reduction&quot; and a tidy per-agent saving. I put those there because I thought they&apos;d get clicks, and they probably would. But a confident ROI figure for a fictional company is exactly the kind of thing that falls apart the moment someone asks a real question — and it&apos;s the opposite of why I write any of this. So I took it out. This is the honest version.&lt;/p&gt;
&lt;h2&gt;What I can actually stand behind&lt;/h2&gt;
&lt;p&gt;Plenty, just not a savings percentage. The system is real and it works. The architecture genuinely gives you consistency, round-the-clock availability, scaling without new headcount, and a logged record of every conversation — those are properties of the design, not numbers I made up. I also know what it costs to &lt;em&gt;run&lt;/em&gt;, because I run it: a Gemini API bill and some cheap infrastructure, not a salaried team.&lt;/p&gt;
&lt;p&gt;What I can&apos;t tell you is what it would save your support desk, because I&apos;ve never pointed it at a real one. Anyone who quotes you a clean percentage off a demo is guessing.&lt;/p&gt;
&lt;h2&gt;How you&apos;d find the real number&lt;/h2&gt;
&lt;p&gt;If you did deploy something like this, the honest way to learn its worth is to measure it, not to trust a slide. Take a baseline first — what a resolved contact costs you today. Then run the system on a slice of real traffic and watch cost per resolved contact, how many contacts it handles without a human, escalation rate, and customer satisfaction. The ROI is whatever those numbers say after a couple of months. It&apos;ll be specific to you, and it might be great — but it&apos;s earned, not assumed.&lt;/p&gt;
&lt;p&gt;One thing I&apos;d say with confidence: it won&apos;t take your team to zero. You still want people for the hard, sensitive, and angry cases, and someone watching the system. The realistic shape is &quot;handle the bulk, escalate the rest.&quot;&lt;/p&gt;
&lt;h2&gt;The proof is the system, not a stat&lt;/h2&gt;
&lt;p&gt;So here&apos;s what I&apos;ll claim instead of a number: it&apos;s a complete, working, open-source build that you can read, run, and pull apart yourself. That&apos;s the actual evidence I can offer — more honest than any figure I could put on a fictional company, and more useful too.&lt;/p&gt;
&lt;h2&gt;That&apos;s the series&lt;/h2&gt;
&lt;p&gt;Over eight parts I&apos;ve built a real customer support system — the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-1&quot;&gt;architecture&lt;/a&gt;, the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-2&quot;&gt;data layer&lt;/a&gt;, the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-3&quot;&gt;Policy Agent&lt;/a&gt;, the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-4&quot;&gt;Ticket Agent&lt;/a&gt;, the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-5&quot;&gt;orchestration&lt;/a&gt;, the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-6&quot;&gt;location handling&lt;/a&gt;, and the &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/building-enterprise-ai-customer-support-part-7&quot;&gt;testing&lt;/a&gt;. It&apos;s all open source, and there&apos;s a &lt;a href=&quot;https://twentytwotensors.co.uk/blog/posts/adk-vs-langgraph-customer-support&quot;&gt;LangGraph version&lt;/a&gt; too if you&apos;re choosing a framework.&lt;/p&gt;
&lt;p&gt;If I had one piece of advice: start with a pilot, measure everything against a real baseline, and expand on proven value rather than a slide deck.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;If you want a system like this built for your business — and rolled out without breaking your support team — that&apos;s what I do at twentytwotensors. &lt;a href=&quot;https://twentytwotensors.co.uk/#contact&quot;&gt;Get in touch&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><category>LLM</category></item></channel></rss>