<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://www.bayis.co.uk/feed.xml" rel="self" type="application/atom+xml" /><link href="https://www.bayis.co.uk/" rel="alternate" type="text/html" /><updated>2026-10-16T05:02:21+00:00</updated><id>https://www.bayis.co.uk/feed.xml</id><title type="html">Bay Information Systems</title><subtitle>tech-talks</subtitle><author><name>Edward Grundy</name></author><entry><title type="html">Evaluating Agents</title><link href="https://www.bayis.co.uk/library/105-agent-evaluation.html" rel="alternate" type="text/html" title="Evaluating Agents" /><published>2026-10-16T00:00:00+00:00</published><updated>2026-10-16T00:00:00+00:00</updated><id>https://www.bayis.co.uk/library/105-agent-evaluation</id><content type="html" xml:base="https://www.bayis.co.uk/library/105-agent-evaluation.html"><![CDATA[<h1 id="evaluating-agents">Evaluating Agents</h1>

<p>A transcript of an agent that completed a task and one that completed it correctly can look the same, in the way a fluent RAG answer can be ungrounded (<a href="/library/100-rag-evaluation.html">Evaluating RAG</a>).
An agent’s output is a sequence of actions, and each can be right or wrong independently of the final answer.
Evaluation therefore follows the loop: each stage measured on its own, in the order the agent runs them, on tasks taken from your own work, and repeated.</p>

<p>We can set out the method in five parts: the task set, the trace, three measurements, repetition, and when to run it again.</p>

<h2 id="the-task-set">The task set</h2>

<p>Public benchmarks measure general capability on someone else’s tools.
The evaluation that predicts behaviour in production uses your tasks, your tools and your failure cases.
A few dozen tasks will find the large faults, and the set grows as production supplies new cases.</p>

<p>Each task needs three things written down before the agent runs it: the request as a user would phrase it, the end state that counts as success, and any rules the agent must respect on the way.
The end state carries the most weight.
tau-bench, a benchmark for customer-service agents, judges a run by comparing the database after the conversation with an annotated goal state, which tests what the agent did and ignores how well it described it.
The same check works on a private set: the order is cancelled, the ticket carries the right label, the patch fixes the failing test and the existing tests still pass.</p>

<p>Include tasks the agent should refuse or escalate (a request that breaks a stated policy, a change it has no permission to make) and tasks it has got wrong before.
These test the governance problems in <a href="/library/103-agent-risks-costs.html">Agent Risks, Costs, and Governance</a>, which a set of ordinary requests never reaches.</p>

<h2 id="the-trace">The trace</h2>

<p>Every measurement reads from a record of the run: each tool call with its arguments and result, each model call with its token count, the order they happened in, and the final state.
<a href="/library/109-instrumentation.html">Instrumentation Is the Deliverable</a> argues that this record is worth building for its own sake.
A durable execution layer produces most of it as a side effect.
DBOS, for example, stores each workflow’s inputs and every step’s output as rows in Postgres (<a href="/library/112-dbos.html">What Is DBOS?</a>), so the evaluation queries the same tables the recovery mechanism uses.
(If you are building this record for an agent already in production, <a href="/contact.html">get in touch</a>.)</p>

<h2 id="three-measurements-in-order">Three measurements, in order</h2>

<p><strong>Tool-call correctness.</strong>
For each call, check that the agent chose the right tool and passed valid arguments.
Most of this is mechanical: arguments validate against the tool’s schema, identifiers refer to records that exist, and values fall within documented ranges.
Where a task has one correct sequence of calls, compare the trace with it; where several sequences are acceptable, check each call against rules instead.
The Berkeley Function-Calling Leaderboard measures this stage across models, which helps choose a model before building and says nothing about your own tools.</p>

<p><strong>Planning efficiency.</strong>
For completed tasks, count steps and cost, and compare each with a budget set per task.
A task that should take three steps and takes thirty shows up here as a number before it shows up on an invoice.
Read these beside the success rate, since a higher success rate bought with ten times the steps is a different result.</p>

<p><strong>Outcome.</strong>
Compare the final state with the expected end state, and check the rules separately: an agent that completes a refund by skipping a required approval has failed the task.
Use code to check anything code can check.
Use a model as judge only for outputs it cannot (the quality of a written summary, for instance), and test the judge against a sample of human labels before relying on it (<a href="/library/61-eval-datasets.html">How to Evaluate an AI Pipeline</a>).</p>

<p>The order serves diagnosis.
A fall in outcome while tool calls stay correct points at planning or at the model’s final step.
A fall in tool-call correctness most often means something changed in a tool (its schema, its error codes, its data), and the later measurements inherit the fault.</p>

<h2 id="repetition">Repetition</h2>

<p>An agent given the same task twice can take different paths to different results, so one run per task overstates reliability.
Run each task several times and report two numbers: the share of individual runs that succeed, and the share of tasks that succeed on every run.
tau-bench introduced the second as pass^k, for k runs.
The gap between the two numbers measures inconsistency, and a production system lives with that gap, because every real request is another draw.
(If runs were independent, a 90 per cent success rate per run would give 0.9 to the power 4, about 66 per cent, for four successes in a row.)</p>

<p>Repetition also separates change from noise.
A difference between two model versions smaller than the spread between runs of one version is not yet a finding.</p>

<h2 id="when-to-run-it-again">When to run it again</h2>

<p>Run the full set whenever anything in the loop changes: the model, a tool’s API, the harness or framework version, the system prompt, or the set of tools exposed to the agent.
Tool and harness changes cause drops in reliability without any change to the model, and they arrive more often than model releases.
Between changes, a scheduled run on a fixed sample catches external tools that changed without notice.
The task set, the trace and the three measurements stay the same each time, which is what makes one run comparable with the last.</p>]]></content><author><name>Edward Grundy</name></author><summary type="html"><![CDATA[How to evaluate an AI agent on your own tasks: tool-call correctness, planning efficiency and outcome, measured separately and in that order, then repeated to check it succeeds the same way twice.]]></summary></entry><entry><title type="html">Durable Execution vs Workflow Orchestration: Two Questions That Separate the Tools</title><link href="https://www.bayis.co.uk/library/113-durable-execution.html" rel="alternate" type="text/html" title="Durable Execution vs Workflow Orchestration: Two Questions That Separate the Tools" /><published>2026-10-15T00:00:00+00:00</published><updated>2026-10-15T00:00:00+00:00</updated><id>https://www.bayis.co.uk/library/113-durable-execution</id><content type="html" xml:base="https://www.bayis.co.uk/library/113-durable-execution.html"><![CDATA[<h1 id="durable-execution-vs-workflow-orchestration-two-questions-that-separate-the-tools">Durable Execution vs Workflow Orchestration: Two Questions That Separate the Tools</h1>

<p>Workflow tools are usually compared feature by feature: language support, user interface, pricing, the length of the integrations page.
Those lists grow with every release and say little about which tool suits a given job.
We can sort the tools more usefully with two questions.
When is the shape of the work fixed?
And where, and how, is the record of progress kept?
The answers decide the rules your code must follow, how a failed run recovers, how you repair one, and which problems the tool fits.</p>

<p>We apply both questions to eight tools: Apache Airflow, Dagster, Prefect, AWS Step Functions, Temporal, DBOS, LangGraph and <a href="/library/54-runfox.html">runfox</a>.
(runfox is our own library. It is included because it sits at an unusual point on both axes).
From the answers we build a procedure for choosing between them.</p>

<h2 id="durable-execution-and-workflow-orchestration">Durable execution and workflow orchestration</h2>

<p>We can trace the two terms to different communities, and they overlap heavily.
Workflow orchestration grew up around data pipelines: a scheduler runs a graph of tasks on a timetable, retries the ones that fail, and shows the history in a user interface.
Durable execution grew up around application code: a function records the result of each step as it runs, so a crash or a deployment interrupts it without losing its place.
(Our article on <a href="/library/112-dbos.html">DBOS</a> covers that mechanism).
Every tool here does some of both, and the two questions locate the real differences more precisely than either label.</p>

<h2 id="question-one-when-is-the-shape-of-the-work-fixed">Question one: when is the shape of the work fixed?</h2>

<p>Some tools need the whole graph before anything runs.
Airflow parses a DAG file into tasks and dependencies; Dagster assembles a job from its ops or assets; Step Functions takes a state machine written in the Amazon States Language; runfox takes a YAML document.
Each allows some runtime variation inside the declared frame.
Airflow’s dynamic task mapping creates a variable number of task instances from an upstream task’s output, and Dagster’s dynamic outputs do the same.
Step Functions has Choice and Map states.
runfox evaluates branch conditions against step outputs and expresses loops by resetting a step.
None of them lets the running work add a kind of step the definition did not declare, and an Airflow DAG cannot contain a cycle at all.</p>

<p>Other tools discover the shape by running ordinary code.
A Temporal workflow, a DBOS workflow and a Prefect flow are functions, and whatever steps they call, in whatever order and however many times, is the workflow.
Inngest, Restate and Hatchet’s durable tasks follow the same model.
Hatchet offers both models and gives a plain rule for choosing: if the structure is not known before the workflow starts, or it contains loops, a DAG cannot express it.</p>

<p>LangGraph sits between the two.
A graph’s nodes and edges are declared before it runs, but conditional edges choose the next node at runtime and cycles are allowed, which is what an agent loop needs.
Its functional API drops the declared graph in favour of ordinary functions.</p>

<p>A declared shape is information held before execution.
You can review a definition without running it, list the models or services it will call, estimate its cost, and store and version it as data.
A discovered shape is expressiveness: anything the language can express, including loops whose length depends on what a model says.
Agents fall almost entirely on the discovered side.
This is the distinction between <a href="/library/44-static-vs-dynamic-planning.html">static and dynamic planning</a>, seen from the infrastructure end.</p>

<h2 id="question-two-where-and-how-is-progress-recorded">Question two: where, and how, is progress recorded?</h2>

<p>The second question has three parts: who owns the store, what a record contains, and how much the tool asks of the store.</p>

<p><strong>A service owns the record.</strong>
Temporal’s history service writes an ordered event history for every workflow to its own database (Cassandra, MySQL, PostgreSQL or SQLite), and workers run code on its instruction.
Step Functions keeps execution history inside AWS for Standard workflows; Express workflows keep none.
Airflow’s scheduler records task state in its metadata database.
Dagster records runs in its run storage, which can be Postgres, and hands step outputs between steps through IO managers.
Prefect reports run state to its API and keeps task results only when result persistence is switched on, which by default it is not.
In each case there is a service to operate or rent, and the record is reached through it.</p>

<p><strong>A library writes the record into your database.</strong>
DBOS writes workflow inputs, every step output and the final result into tables in a Postgres or SQLite database you own, from inside your own process.
LangGraph’s checkpointers save snapshots of the graph’s state to Postgres, SQLite or Redis, at a frequency set by its durability mode: on exit only, asynchronously while the next step runs, or synchronously before it starts.
runfox keeps one record per run through a Store, with in-memory, SQLite and DynamoDB implementations (the DynamoDB store is built on our <a href="https://pypi.org/project/dynawrap/">dynawrap</a> library, which offers the same interface over Postgres).</p>

<p>Within the second group, the richness of the persistence interface varies most.
DBOS asks a great deal of its database: SQL, transactions, and a schema it migrates itself.
That buys exactly-once database writes (a step’s outcome is recorded in the same transaction as the application’s write) and a run history you can query and join directly, and it limits the database to Postgres or SQLite.
runfox asks almost nothing.
Its Store has two operations, load a run’s record and write it back, so anything that can store a document by key can back it, and the same workflow definition runs in-process, against SQLite, or across DynamoDB and SQS without changes.
Features that need more from the store (queries across runs, transactions spanning a step and the application’s own data) are absent from the core.
The design leaves room for them all the same.
An application that commits to one store can build them over the same record format with that store’s native queries.
Capability grows by constraining the choice of store for one deployment, and the core stays portable for every other.</p>

<h2 id="how-a-failed-run-recovers">How a failed run recovers</h2>

<p>Recovery is where the two answers combine.</p>

<p>Tools that discover the shape recover by <strong>replay</strong>.
Temporal re-executes the workflow function against its event history, returning recorded results for completed steps and raising a non-determinism error if the code takes a different path.
DBOS does the same against its tables, and Inngest re-invokes the function with the memoized results of completed steps.
Replay is the reason these tools require deterministic workflow code: the record only means something if re-running the code makes the same calls in the same order.</p>

<p>Tools that declare the shape recover by <strong>resuming from recorded state</strong>.
There is no code to replay, so there is no determinism rule.
Step Functions can redrive a failed Standard execution from the failed state within fourteen days, keeping the results of the steps that succeeded.
Dagster re-executes a run from failure, skipping completed steps and loading their outputs through the IO manager.
Airflow retries failed tasks and lets an operator clear a task to run it again.
runfox rehydrates the run’s record and advances from it, so a restarted process carries on from the last write.</p>

<p>Two tools fall between.
Prefect re-runs a flow and skips the tasks whose results it can load from its cache, which works only with persistence switched on.
LangGraph resumes from the start of the node that was interrupted, so the node’s work is repeated unless its side effects sit inside tasks, and its documentation asks for deterministic, idempotent workflows for that reason.</p>

<p>Granularity matters as much as mechanism.
Replay and per-step state protect each step.
A per-node checkpoint protects each node, and a node that makes three model calls and fails on the third repeats all three.</p>

<p>Every tool also needs something to notice that a worker has died.
The services handle this centrally.
DBOS recovers a process’s own workflows when the process restarts, and across several machines it relies on its Conductor service or on recovery calls you make yourself.
runfox, as of version 0.0.7, leaves this to the runner: a worker that dies without writing a result leaves its run waiting, so executors are expected to catch their own errors and return them as data.</p>

<h2 id="how-a-broken-run-is-repaired">How a broken run is repaired</h2>

<p>When the fault is a bug in the workflow itself, the two answers decide the repair.</p>

<p>In a code-defined workflow the bug can sit anywhere in the code, including the glue between steps.
The fix is a deployment followed by a re-run from the faulty step on the new version (Temporal’s reset, DBOS’s fork and rewind).
Recorded steps before that point are kept, so expensive work is not repeated, but every in-flight run that reaches the bug fails until the deployment lands.</p>

<p>In a declared workflow, a mistake in wiring or branching is a mistake in data, fixed by editing the definition without redeploying the executor.
The executor is still code, and a bug there needs a deployment like any other.
Declared definitions can also fail more quietly.
In expression languages such as JSON Logic, a reference to a missing field yields null, and a mistyped path can carry that null through to a wrong result with nothing raised along the way.</p>

<h2 id="eight-tools-on-two-axes">Eight tools on two axes</h2>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>When the shape is fixed</th>
      <th>Where progress is recorded</th>
      <th>Recovery</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Airflow</td>
      <td>Parse time; runtime fan-out via task mapping; no cycles</td>
      <td>Scheduler’s metadata database</td>
      <td>Task retries; clear and re-run</td>
    </tr>
    <tr>
      <td>Dagster</td>
      <td>Definition time; runtime fan-out via dynamic outputs</td>
      <td>Run storage; outputs via IO managers</td>
      <td>Re-execute from failure</td>
    </tr>
    <tr>
      <td>Prefect</td>
      <td>Runtime, as Python</td>
      <td>Prefect API; task results only if persistence is on</td>
      <td>Re-run, skipping cached tasks</td>
    </tr>
    <tr>
      <td>Step Functions</td>
      <td>State machine definition</td>
      <td>AWS-managed history (Standard workflows)</td>
      <td>Redrive from the failed state</td>
    </tr>
    <tr>
      <td>Temporal</td>
      <td>Runtime, as code</td>
      <td>Event history in Temporal’s database</td>
      <td>Replay</td>
    </tr>
    <tr>
      <td>DBOS</td>
      <td>Runtime, as code</td>
      <td>Tables in your Postgres or SQLite</td>
      <td>Replay</td>
    </tr>
    <tr>
      <td>LangGraph</td>
      <td>Declared nodes; runtime routing and cycles</td>
      <td>Checkpoints in your Postgres, SQLite or Redis</td>
      <td>Resume at the start of the interrupted node</td>
    </tr>
    <tr>
      <td>runfox</td>
      <td>YAML definition; branches and loops</td>
      <td>One record per run via a Store (memory, SQLite, DynamoDB)</td>
      <td>Rehydrate the record and advance</td>
    </tr>
  </tbody>
</table>

<h2 id="how-to-choose">How to choose</h2>

<p>We can build a selection procedure from the two questions.</p>

<p>If the shape of the work is known before it starts and the work runs on a schedule over data, the declared orchestrators fit: Airflow for task pipelines with its large operator ecosystem, Dagster where the unit of thought is a data asset.
Step Functions fits the same declared shape when the steps are AWS services and someone else should run the orchestrator.
runfox fits it when the definition should be a portable document, executed by a library inside your own process against whichever store the deployment already has.</p>

<p>If the shape is decided while the work runs (agents, business processes that wait on people, anything with loops of unknown length), the replay family fits, and the second question chooses within it.
DBOS suits teams that already run Postgres and want no further service.
Temporal suits teams willing to operate or rent a service in return for multi-language workers and a long production record.
LangGraph’s checkpointer suits agents already written in LangGraph, provided side effects are wrapped in tasks so that node-level resumption does not repeat them.
Prefect sits across the line: flows are ordinary code with a service alongside, and its durability is a result cache that has to be switched on.</p>

<p>The tools differ in many further ways, and those differences matter at the margins.
The important ones (determinism rules, recovery granularity, the shape of a repair, what must be operated) follow from where a tool stands on the two questions.</p>

<p>If you are making this choice for an AI system, <a href="/contact">we can help</a>.</p>]]></content><author><name>Edward Grundy</name></author><summary type="html"><![CDATA[What is the difference between Temporal, DBOS, Airflow, Dagster, Prefect, Step Functions, LangGraph and runfox? Two questions sort them: when the shape of the work is fixed, and where progress is recorded. The answers decide recovery, repair and fit.]]></summary></entry><entry><title type="html">What Is DBOS? Durable Python Workflows Stored in Postgres</title><link href="https://www.bayis.co.uk/library/112-dbos.html" rel="alternate" type="text/html" title="What Is DBOS? Durable Python Workflows Stored in Postgres" /><published>2026-10-14T00:00:00+00:00</published><updated>2026-10-14T00:00:00+00:00</updated><id>https://www.bayis.co.uk/library/112-dbos</id><content type="html" xml:base="https://www.bayis.co.uk/library/112-dbos.html"><![CDATA[<h1 id="what-is-dbos-durable-python-workflows-stored-in-postgres">What Is DBOS? Durable Python Workflows Stored in Postgres</h1>

<p><a href="https://docs.dbos.dev/">DBOS</a> is an open-source library for durable workflows.
You annotate ordinary functions in a Python application (TypeScript, Go and Java are also supported), and DBOS records their progress in a database as they run.
If the process dies, the work resumes from the last completed step when the process starts again.
DBOS needs a Postgres database and nothing else.</p>

<p>That makes it unusually easy to describe.
The state of a running program is a set of ordinary rows in Postgres, and recovery, queues, schedules and human-in-the-loop waits are all reads and writes against those rows.
What follows covers what a durable workflow is, what DBOS is, how to use it in Python, how an AI agent fits the model, and what it costs.</p>

<h2 id="what-is-a-durable-workflow">What is a durable workflow?</h2>

<p>A durable workflow is a function whose progress is recorded as it runs, so that a crash, a restart or a deployment interrupts it without losing its place.
The function is divided into steps.
Each completed step’s result is saved, and after a failure the workflow resumes from the first step with no saved result.
Finished work is never repeated; interrupted work is retried.</p>

<p>The problem it addresses is ordinary.
Any job that spans several calls to other systems (take a payment, reserve stock, send an email; or ask a model, call a tool, ask the model again) can fail between two of them and leave the job half done.
Without a workflow runtime, the usual remedies are hand-written status columns, retry queues and clean-up scripts, rebuilt for each job.
A durable workflow moves that bookkeeping into the runtime.
Two further properties follow from the record.
A workflow can wait for hours or days (on a timer, or for a human decision) without holding a process open, and its history is an audit trail of what ran and what each step returned.</p>

<p>Implementations differ mainly in where the record lives and what drives execution.
Temporal and AWS Step Functions run a separate service that holds the history and dispatches work to your code.
Azure Durable Functions and DBOS keep the history in storage the application writes to directly (Azure Storage by default in one case, Postgres in the other) and replay the function against it.</p>

<h2 id="what-is-dbos">What is DBOS?</h2>

<p>DBOS has two primitives.
A workflow is a function decorated with <code class="language-plaintext highlighter-rouge">@DBOS.workflow()</code>.
A step is a function decorated with <code class="language-plaintext highlighter-rouge">@DBOS.step()</code> and called from inside a workflow.
When a workflow starts, DBOS writes its inputs to the database; each time a step completes, DBOS writes the step’s output; when the workflow returns, DBOS writes the result.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">dbos</span> <span class="kn">import</span> <span class="n">DBOS</span>

<span class="o">@</span><span class="n">DBOS</span><span class="p">.</span><span class="n">step</span><span class="p">()</span>
<span class="k">def</span> <span class="nf">charge_card</span><span class="p">(</span><span class="n">order_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
    <span class="p">...</span>  <span class="c1"># call the payment provider, return a charge ID
</span>
<span class="o">@</span><span class="n">DBOS</span><span class="p">.</span><span class="n">step</span><span class="p">()</span>
<span class="k">def</span> <span class="nf">send_receipt</span><span class="p">(</span><span class="n">order_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">charge_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
    <span class="p">...</span>  <span class="c1"># send an email
</span>
<span class="o">@</span><span class="n">DBOS</span><span class="p">.</span><span class="n">workflow</span><span class="p">()</span>
<span class="k">def</span> <span class="nf">checkout</span><span class="p">(</span><span class="n">order_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
    <span class="n">charge_id</span> <span class="o">=</span> <span class="n">charge_card</span><span class="p">(</span><span class="n">order_id</span><span class="p">)</span>
    <span class="n">send_receipt</span><span class="p">(</span><span class="n">order_id</span><span class="p">,</span> <span class="n">charge_id</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">charge_id</span>
</code></pre></div></div>

<p>Those writes land in a schema called <code class="language-plaintext highlighter-rouge">dbos</code> in what DBOS calls the system database.
Three tables carry most of the weight.
<code class="language-plaintext highlighter-rouge">workflow_status</code> holds one row per execution: its ID, the function name, a status (<code class="language-plaintext highlighter-rouge">PENDING</code>, <code class="language-plaintext highlighter-rouge">ENQUEUED</code>, <code class="language-plaintext highlighter-rouge">SUCCESS</code>, <code class="language-plaintext highlighter-rouge">ERROR</code> and a few others), the executor running it, its queue and its timings.
<code class="language-plaintext highlighter-rouge">workflow_input</code> holds the serialised arguments.
<code class="language-plaintext highlighter-rouge">operation_outputs</code> holds one row per completed step, keyed by the workflow ID and a <code class="language-plaintext highlighter-rouge">function_id</code> that numbers steps in the order they ran (from 1 in Python), with the step’s serialised output or error.
The full schema is in the <a href="https://docs.dbos.dev/explanations/system-tables">system database reference</a>.</p>

<p>A <code class="language-plaintext highlighter-rouge">checkout</code> that dies after charging the card leaves a <code class="language-plaintext highlighter-rouge">PENDING</code> row in <code class="language-plaintext highlighter-rouge">workflow_status</code>, its argument in <code class="language-plaintext highlighter-rouge">workflow_input</code>, and one row in <code class="language-plaintext highlighter-rouge">operation_outputs</code> for <code class="language-plaintext highlighter-rouge">charge_card</code>.
Nothing records a receipt.
That is everything DBOS needs to finish the job.</p>

<p>Recovery reads those rows.
On startup DBOS finds workflows still marked <code class="language-plaintext highlighter-rouge">PENDING</code> and calls each one again with its stored inputs.
As the function re-executes, each step call first looks for its row in <code class="language-plaintext highlighter-rouge">operation_outputs</code>; if the row exists, the step returns the stored output without running.
The first step with no row is where the original run stopped, and execution carries on normally from there.
So <code class="language-plaintext highlighter-rouge">charge_card</code> returns the recorded charge ID, the customer is not charged twice, and <code class="language-plaintext highlighter-rouge">send_receipt</code> runs for the first time.</p>

<p>The other features use the same tables.
A queued workflow is a <code class="language-plaintext highlighter-rouge">workflow_status</code> row with status <code class="language-plaintext highlighter-rouge">ENQUEUED</code>, which worker processes poll for under configurable concurrency and rate limits.
A message sent to a running workflow is a row in <code class="language-plaintext highlighter-rouge">dbos.notifications</code>, and a value a workflow publishes is a row in <code class="language-plaintext highlighter-rouge">dbos.workflow_events</code>.
Schedules have a table of their own.
Since it is all SQL, a workflow’s history is one query away:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">function_id</span><span class="p">,</span>
       <span class="n">function_name</span><span class="p">,</span>
       <span class="n">error</span> <span class="k">IS</span> <span class="k">NOT</span> <span class="k">NULL</span> <span class="k">AS</span> <span class="n">failed</span><span class="p">,</span>
       <span class="n">completed_at_epoch_ms</span> <span class="o">-</span> <span class="n">started_at_epoch_ms</span> <span class="k">AS</span> <span class="n">duration_ms</span>
<span class="k">FROM</span> <span class="n">dbos</span><span class="p">.</span><span class="n">operation_outputs</span>
<span class="k">WHERE</span> <span class="n">workflow_uuid</span> <span class="o">=</span> <span class="s1">'checkout-order-1042'</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">function_id</span><span class="p">;</span>
</code></pre></div></div>

<p>(The <code class="language-plaintext highlighter-rouge">output</code> column holds Python values pickled by default, so it is readable from Python rather than from SQL. A portable JSON format exists for applications that mix languages.)</p>

<h2 id="how-do-i-use-dbos">How do I use DBOS?</h2>

<p>Install it with <code class="language-plaintext highlighter-rouge">pip install dbos</code>, then configure and launch it when the application starts:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">os</span>
<span class="kn">from</span> <span class="nn">dbos</span> <span class="kn">import</span> <span class="n">DBOS</span><span class="p">,</span> <span class="n">DBOSConfig</span>

<span class="n">config</span><span class="p">:</span> <span class="n">DBOSConfig</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s">"name"</span><span class="p">:</span> <span class="s">"orders"</span><span class="p">,</span>
    <span class="s">"system_database_url"</span><span class="p">:</span> <span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"DBOS_SYSTEM_DATABASE_URL"</span><span class="p">),</span>
<span class="p">}</span>
<span class="n">DBOS</span><span class="p">(</span><span class="n">config</span><span class="o">=</span><span class="n">config</span><span class="p">)</span>

<span class="k">if</span> <span class="n">__name__</span> <span class="o">==</span> <span class="s">"__main__"</span><span class="p">:</span>
    <span class="n">DBOS</span><span class="p">.</span><span class="n">launch</span><span class="p">()</span>
    <span class="n">checkout</span><span class="p">(</span><span class="s">"order-1042"</span><span class="p">)</span>
</code></pre></div></div>

<p>With no database URL DBOS uses a local SQLite file, which is enough for development.
Anything with more than one process needs Postgres, and the URL is an ordinary <code class="language-plaintext highlighter-rouge">postgresql://</code> connection string.</p>

<p>Replay puts two obligations on your code.
The first is that a workflow function must be deterministic: given the same inputs and the same step outputs, it must call the same steps in the same order.
Anything that could differ between runs (the time, a random number, a network call, a database read) belongs in a step, where its result is recorded once and returned from the record on every replay.
Branching on a step’s output is safe for the same reason.
DBOS raises <code class="language-plaintext highlighter-rouge">DBOSStepNondeterminismError</code> when it detects a replay that diverges.</p>

<p>The second is that steps should be safe to repeat.
A step that completes is never run again, but a step interrupted part-way through will be retried, so DBOS guarantees each step runs at least once.
Steps that call unreliable services can retry automatically with <code class="language-plaintext highlighter-rouge">@DBOS.step(retries_allowed=True, max_attempts=5)</code>.</p>

<p>Replay also means the workflow function itself runs from the top on recovery.
Code between steps (logging, building a prompt, assembling a list) executes again, which is harmless as long as it only depends on inputs and step outputs.</p>

<p>Every execution has a workflow ID, which is the key for all of its rows.
By default DBOS generates a random UUID per execution (the ID is not derived from the inputs, so two calls with the same arguments are two executions).
You can supply your own with <code class="language-plaintext highlighter-rouge">SetWorkflowID</code>, or with the <code class="language-plaintext highlighter-rouge">workflow_id</code> argument when enqueueing, as long as it is unique within the application.
A supplied ID doubles as an idempotency key: run a workflow under an ID that already exists and DBOS returns the existing execution’s result instead of starting a second one.
The useful IDs are built from something the job already has, such as an order number or an inbound request ID, so a retried request or a duplicated message lands on the same workflow:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">dbos</span> <span class="kn">import</span> <span class="n">SetWorkflowID</span>

<span class="k">with</span> <span class="n">SetWorkflowID</span><span class="p">(</span><span class="s">"checkout-order-1042"</span><span class="p">):</span>
    <span class="n">checkout</span><span class="p">(</span><span class="s">"order-1042"</span><span class="p">)</span>
</code></pre></div></div>

<p>(Pass <code class="language-plaintext highlighter-rouge">workflow_id_reuse_policy="reject"</code> to <code class="language-plaintext highlighter-rouge">SetWorkflowID</code> to raise an error on a duplicate instead.)</p>

<p>To re-run a recorded step, you re-execute the workflow from that step onward: the steps before it return their recorded outputs, and that step and everything after it run afresh.
Re-running a single step in the middle while keeping the later results would hand those later steps inputs they never saw, so the rule follows from the replay model.
There are two ways to do it.
<code class="language-plaintext highlighter-rouge">DBOS.fork_workflow(workflow_id, start_step)</code> copies the inputs and the earlier steps into a new workflow with a new ID and runs it from <code class="language-plaintext highlighter-rouge">start_step</code>.
<code class="language-plaintext highlighter-rouge">DBOS.rewind_workflow(workflow_id, start_step)</code> deletes the original workflow’s records from <code class="language-plaintext highlighter-rouge">start_step</code> onward and re-runs it under the same ID, which keeps messages, events and child workflows attached (it only works on a workflow that has finished, failed or been cancelled).
Step numbers come from <code class="language-plaintext highlighter-rouge">DBOS.list_workflow_steps(workflow_id)</code> or <code class="language-plaintext highlighter-rouge">dbos workflow steps</code> on the command line.
Both can target a newer application version, so a workflow that failed on a bug can be re-run from the faulty step once the fix is deployed.
For an agent, the same mechanism re-runs a conversation from a given model call after a prompt change, without repeating the tool calls that came before it.</p>

<p>For work that should run in the background, <code class="language-plaintext highlighter-rouge">DBOS.start_workflow</code> returns a handle you can poll or wait on, and a <code class="language-plaintext highlighter-rouge">Queue</code> adds flow control:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">dbos</span> <span class="kn">import</span> <span class="n">Queue</span>

<span class="n">refunds</span> <span class="o">=</span> <span class="n">Queue</span><span class="p">(</span><span class="s">"refunds"</span><span class="p">,</span> <span class="n">worker_concurrency</span><span class="o">=</span><span class="mi">2</span><span class="p">)</span>
<span class="n">handle</span> <span class="o">=</span> <span class="n">refunds</span><span class="p">.</span><span class="n">enqueue</span><span class="p">(</span><span class="n">checkout</span><span class="p">,</span> <span class="s">"order-1042"</span><span class="p">)</span>
<span class="n">result</span> <span class="o">=</span> <span class="n">handle</span><span class="p">.</span><span class="n">get_result</span><span class="p">()</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">DBOS.sleep</code> pauses a workflow durably: the wake-up time is stored, so a workflow told to sleep for a week still wakes on schedule through any number of restarts.</p>

<h2 id="example-an-ai-agent-with-dbos">Example: an AI agent with DBOS</h2>

<p>An agent maps onto DBOS directly.
The agent loop is a workflow; each model call and each tool call is a step.
The loop branches on whatever the model said, which looks like the kind of non-determinism the rules forbid.
It is safe, because on replay the model’s reply comes back from <code class="language-plaintext highlighter-rouge">operation_outputs</code> and the model is not asked again.
A crashed agent therefore resumes without paying twice for the calls it had already made.</p>

<p>Here is a refund agent with two tools, one of which waits for a human to approve it:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">json</span>
<span class="kn">from</span> <span class="nn">dbos</span> <span class="kn">import</span> <span class="n">DBOS</span>

<span class="o">@</span><span class="n">DBOS</span><span class="p">.</span><span class="n">step</span><span class="p">(</span><span class="n">retries_allowed</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">max_attempts</span><span class="o">=</span><span class="mi">3</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">think</span><span class="p">(</span><span class="n">history</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">dict</span><span class="p">:</span>
    <span class="n">reply</span> <span class="o">=</span> <span class="n">llm</span><span class="p">.</span><span class="n">chat</span><span class="p">.</span><span class="n">completions</span><span class="p">.</span><span class="n">create</span><span class="p">(</span><span class="n">model</span><span class="o">=</span><span class="n">MODEL</span><span class="p">,</span> <span class="n">messages</span><span class="o">=</span><span class="n">history</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">parse_action</span><span class="p">(</span><span class="n">reply</span><span class="p">.</span><span class="n">choices</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="n">message</span><span class="p">.</span><span class="n">content</span><span class="p">)</span>  <span class="c1"># raises on malformed output
</span>
<span class="o">@</span><span class="n">DBOS</span><span class="p">.</span><span class="n">step</span><span class="p">()</span>
<span class="k">def</span> <span class="nf">lookup_order</span><span class="p">(</span><span class="n">order_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">dict</span><span class="p">:</span>
    <span class="p">...</span>

<span class="o">@</span><span class="n">DBOS</span><span class="p">.</span><span class="n">step</span><span class="p">()</span>
<span class="k">def</span> <span class="nf">issue_refund</span><span class="p">(</span><span class="n">order_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">amount</span><span class="p">:</span> <span class="nb">float</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">dict</span><span class="p">:</span>
    <span class="p">...</span>  <span class="c1"># keyed on order_id, so a retry overwrites the same record
</span>
<span class="o">@</span><span class="n">DBOS</span><span class="p">.</span><span class="n">workflow</span><span class="p">()</span>
<span class="k">def</span> <span class="nf">refund_agent</span><span class="p">(</span><span class="n">request</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
    <span class="n">history</span> <span class="o">=</span> <span class="p">[</span><span class="n">SYSTEM_PROMPT</span><span class="p">,</span> <span class="p">{</span><span class="s">"role"</span><span class="p">:</span> <span class="s">"user"</span><span class="p">,</span> <span class="s">"content"</span><span class="p">:</span> <span class="n">request</span><span class="p">}]</span>
    <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">10</span><span class="p">):</span>
        <span class="n">action</span> <span class="o">=</span> <span class="n">think</span><span class="p">(</span><span class="n">history</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">action</span><span class="p">[</span><span class="s">"action"</span><span class="p">]</span> <span class="o">==</span> <span class="s">"final_answer"</span><span class="p">:</span>
            <span class="k">return</span> <span class="n">action</span><span class="p">[</span><span class="s">"args"</span><span class="p">][</span><span class="s">"text"</span><span class="p">]</span>
        <span class="k">if</span> <span class="n">action</span><span class="p">[</span><span class="s">"action"</span><span class="p">]</span> <span class="o">==</span> <span class="s">"issue_refund"</span><span class="p">:</span>
            <span class="n">DBOS</span><span class="p">.</span><span class="n">set_event</span><span class="p">(</span><span class="s">"status"</span><span class="p">,</span> <span class="p">{</span><span class="s">"awaiting_approval"</span><span class="p">:</span> <span class="n">action</span><span class="p">[</span><span class="s">"args"</span><span class="p">]})</span>
            <span class="n">decision</span> <span class="o">=</span> <span class="n">DBOS</span><span class="p">.</span><span class="n">recv</span><span class="p">(</span><span class="s">"approval"</span><span class="p">,</span> <span class="n">timeout_seconds</span><span class="o">=</span><span class="mi">24</span> <span class="o">*</span> <span class="mi">3600</span><span class="p">)</span>
            <span class="k">if</span> <span class="n">decision</span> <span class="o">==</span> <span class="s">"approve"</span><span class="p">:</span>
                <span class="n">result</span> <span class="o">=</span> <span class="n">issue_refund</span><span class="p">(</span><span class="o">**</span><span class="n">action</span><span class="p">[</span><span class="s">"args"</span><span class="p">])</span>
            <span class="k">else</span><span class="p">:</span>
                <span class="n">result</span> <span class="o">=</span> <span class="p">{</span><span class="s">"error"</span><span class="p">:</span> <span class="s">"refund rejected by reviewer"</span><span class="p">}</span>
        <span class="k">else</span><span class="p">:</span>
            <span class="n">result</span> <span class="o">=</span> <span class="n">lookup_order</span><span class="p">(</span><span class="o">**</span><span class="n">action</span><span class="p">[</span><span class="s">"args"</span><span class="p">])</span>
        <span class="n">history</span><span class="p">.</span><span class="n">append</span><span class="p">({</span><span class="s">"role"</span><span class="p">:</span> <span class="s">"assistant"</span><span class="p">,</span> <span class="s">"content"</span><span class="p">:</span> <span class="n">json</span><span class="p">.</span><span class="n">dumps</span><span class="p">(</span><span class="n">action</span><span class="p">)})</span>
        <span class="n">history</span><span class="p">.</span><span class="n">append</span><span class="p">({</span><span class="s">"role"</span><span class="p">:</span> <span class="s">"user"</span><span class="p">,</span> <span class="s">"content"</span><span class="p">:</span> <span class="n">json</span><span class="p">.</span><span class="n">dumps</span><span class="p">(</span><span class="n">result</span><span class="p">)})</span>
    <span class="k">return</span> <span class="s">"step limit reached"</span>
</code></pre></div></div>

<p>Parsing and validating the model’s reply inside <code class="language-plaintext highlighter-rouge">think</code> matters.
A malformed reply raises, DBOS retries the step, and only a valid action is ever recorded.
A bad reply that reached the workflow would be replayed faithfully on every recovery.</p>

<p>The approval is where durability earns its keep.
<code class="language-plaintext highlighter-rouge">DBOS.set_event</code> publishes the agent’s state for a dashboard or an inbox to read.
<code class="language-plaintext highlighter-rouge">DBOS.recv</code> blocks until a message arrives on the <code class="language-plaintext highlighter-rouge">approval</code> topic or the deadline passes, in which case it returns <code class="language-plaintext highlighter-rouge">None</code> and the refund is refused.
The deadline and the eventual message are both stored in Postgres, so the process can be restarted or redeployed while a reviewer takes a day to decide, and the workflow carries on waiting.
The reviewer’s decision arrives from any other process with <code class="language-plaintext highlighter-rouge">DBOS.send(workflow_id, "approve", topic="approval")</code>, or through <code class="language-plaintext highlighter-rouge">DBOSClient</code> from a service that is not itself a DBOS application.
It becomes a row in <code class="language-plaintext highlighter-rouge">dbos.notifications</code>.</p>

<p>The same pattern is packaged for several agent frameworks: DBOS has <a href="https://docs.dbos.dev/ai/ai-quickstart">integrations</a> for Pydantic AI, the OpenAI Agents SDK, LlamaIndex and Google ADK in Python, and the Vercel AI SDK in TypeScript, each of which turns model requests and tool calls into steps.</p>

<p>(If durable agents on your own infrastructure are a live problem for you, <a href="/contact.html">get in touch</a>.)</p>

<h2 id="what-dbos-costs">What DBOS costs</h2>

<p><strong>Writes.</strong>
DBOS adds one database write per step and two per workflow.
DBOS’s own <a href="https://www.dbos.dev/blog/benchmarking-workflow-execution-scalability-on-postgres">benchmarks</a> put a single Postgres instance at over 40,000 workflows or steps per second, so for most applications the write count is a non-issue.
Write size is the variable to watch, because it follows the size of what your steps return.
A step that returns a generated image stores the image; the documented advice is to put large artefacts in object storage and return a reference.</p>

<p><strong>Code changes.</strong>
Recovery replays new code against old records.
A deployment that changes which steps a workflow calls, or their order, can strand workflows that were in flight under the old code.
DBOS handles this with patch markers in the code or with application versioning, so that old workflows only recover on processes running compatible code.
Either way, deploying long-running workflows needs some forethought.</p>

<p><strong>SQLAlchemy.</strong>
DBOS is Postgres-native but not driver-neutral.
Its own tables are reached through SQLAlchemy, and it migrates them itself on launch (or ahead of time with <code class="language-plaintext highlighter-rouge">dbos migrate</code>, for database roles without DDL rights).
The same holds for application data, with larger consequences: exactly-once writes are only available through DBOS <a href="https://docs.dbos.dev/python/tutorials/transaction-tutorial">datasources</a>, which hand you a SQLAlchemy session and add a tracking table to your database.
Any other client, including a plain psycopg2 connection, runs as an ordinary step.
It is retried on failure, so its idempotency is your problem.</p>

<p><strong>Recovery across machines.</strong>
A single process recovers its own workflows when it starts.
With several workers, something has to notice that one has died and hand its workflows to another.
DBOS provides this through Conductor, its control plane, or through recovery calls you make yourself.
Conductor stays off the execution path (applications keep running if it is unreachable), but it is a separate service to account for.</p>

<p>What DBOS asks, then, is a small set of rules: deterministic workflow functions, steps that tolerate a retry, and some care when changing workflow code.
In return, the state of every running program lives in a database you probably already operate, and you can inspect it, join it and repair it with SQL.</p>]]></content><author><name>Edward Grundy</name></author><summary type="html"><![CDATA[What a durable workflow is, and how DBOS implements one by storing program state as ordinary rows in Postgres. How to use DBOS in Python, how an AI agent with a human approval step fits the model, and what it costs.]]></summary></entry><entry><title type="html">Are You a Data Controller or a Processor?</title><link href="https://www.bayis.co.uk/library/110-data-processor-or-controller.html" rel="alternate" type="text/html" title="Are You a Data Controller or a Processor?" /><published>2026-09-06T00:00:00+00:00</published><updated>2026-09-06T00:00:00+00:00</updated><id>https://www.bayis.co.uk/library/110-data-processor-or-controller</id><content type="html" xml:base="https://www.bayis.co.uk/library/110-data-processor-or-controller.html"><![CDATA[<h1 id="are-you-a-data-controller-or-a-processor">Are You a Data Controller or a Processor?</h1>

<p>Machine learning work is data work, so the legal position on storing, moving and processing that data belongs in the engineering rather than in a review at the end. This piece covers one narrow category: request data of the kind that already sits in server logs and analytics events. It is not legal advice, and other categories raise different questions. (If your situation involves those, <a href="/contact.html">get in touch</a> or ask your legal representative.)</p>

<p>Two regimes apply to that data and they test different things. Most discussion collapses them into one, which is why so many teams reach the wrong answer confidently. One regime asks whether you put anything on the user’s device. The other asks whether what you hold can be traced to a person. A system can pass the second and fail the first, and failing the first is the common case.</p>

<h2 id="the-device-question-comes-first">The device question comes first</h2>

<p>In the UK, Regulation 6 of the Privacy and Electronic Communications Regulations governs storing information on, or reading information from, a user’s terminal equipment. The EU equivalent is Article 5(3) of the ePrivacy Directive. Consent is required unless the storage is strictly necessary to provide a service the user has explicitly requested.</p>

<p>Three properties of that rule are worth stating plainly, because each of them catches somebody.</p>

<p>It is technology-neutral. The word “cookie” appears in the popular name for the rule and not in the mechanism it describes. The ICO applies it to local storage, session storage, and device fingerprinting on the same basis. Replacing a cookie with <code class="language-plaintext highlighter-rouge">localStorage</code> changes nothing.</p>

<p>It does not ask what the stored value means. A random identifier containing no personal data at all still engages the rule, because the regulated act is the storage rather than the content. This is where the intuition fails: teams reason that their identifier is meaningless, conclude they hold no personal data, and never notice they are being tested on a different question.</p>

<p>And analytics does not qualify as strictly necessary. The ICO’s position is that the exemption covers what the user asked for, and nobody visits a website in order to be measured. First-party analytics is still analytics.</p>

<p>So a system that writes anything to the device needs consent, and the GDPR analysis never gets started. The only way past is to store nothing there.</p>

<p>That is achievable, and it constrains the design in one specific way. A session identifier generated fresh on each page load, transmitted with the event and never written back, touches nothing. What you give up is continuity: the server sees a sequence of page loads rather than a visit, and reconstructing a journey across pages requires either device storage or server-side stitching from other signals. That is a real cost and it is the cost of the exemption.</p>

<h2 id="the-identity-question-is-now-relative">The identity question is now relative</h2>

<p>Assume the device question is settled. The remaining question is whether the data you hold is personal data, and the answer to that changed on 4 September 2025.</p>

<p>In EDPS v SRB (C-413/23 P), the Court of Justice ruled that pseudonymised data is not automatically personal data in every case and for every party. What matters is whether the party in question can realistically re-identify the individual. It was the first judgment to state explicitly that the same data can be personal data for the original controller, which holds the key, and not for a recipient who cannot reverse the pseudonymisation and has no other route to identification. The Court confirmed the interpretation applies under the GDPR and not only under the regulation governing EU institutions.</p>

<p>The practical effect is that identifiability is a property of a relationship rather than of a field. The same string can be personal data in one organisation’s hands and anonymous in another’s.</p>

<p>The European Data Protection Board has moved to match. Its draft Guidelines 02/2026 on Anonymisation, published on 7 July 2026, adopt the relative approach the Court endorsed. That is a reversal: the EDPB’s January 2025 draft on pseudonymisation had stated that pseudonymised data counts as personal data even where the recipient lacks the additional information needed to re-identify anyone.</p>

<p>Which is the reason to treat this as current rather than settled. Two drafts in eighteen months took opposite positions, the anonymisation guidelines remain in draft, and the judgment has been referred back to the General Court on the remaining procedural points. The substantive findings are authoritative. The guidance built on them is not finished.</p>

<h2 id="the-click-identifier-case">The click identifier case</h2>

<p>The clearest application is an identifier that is meaningful only to the party that issued it.</p>

<p>A Google click identifier arrives in the landing page URL when someone clicks an advert. It resolves to a specific click, and through that to a user, in Google’s systems and nowhere else. An intermediary that receives it, stores it alongside a page path and a timestamp, and later sends it back to Google with a conversion, has at no point held anything it could resolve to a person. Google learns that a click it already knew about produced a conversion. It learns nothing new about who.</p>

<p>Under the relative test that intermediary is not processing personal data, while Google plainly is. The judgment is what makes this statable rather than merely arguable.</p>

<p>Two qualifications, both of which matter more than the headline.</p>

<p>The test looks at the recipient’s total means rather than at the field in isolation. An identifier alongside a user agent, a timestamp, a request sequence and an IP address is a more distinctive combination than any of them alone, and a regulator assesses the combination. Storing the autonomous system number rather than the address removes the field with the clearest route to a subscriber, and the honest claim is about what you have no lawful means to do rather than about what you happen not to hold.</p>

<p>And the obligation to inform does not move. The Court was explicit that the duty to tell people what happens to their data is assessed prospectively, at the point of collection, from the controller’s perspective, and cannot rest on whether a downstream recipient might identify anyone later. The site operator remains the controller. Their privacy notice still has to say that a click identifier is collected and forwarded. A vendor being outside scope is not the customer being outside scope, and any vendor implying otherwise is selling you a problem.</p>

<p>There is also a contractual layer underneath the statutory one. Google’s own EU user consent policy requires advertisers to obtain consent for ad measurement from users in the EEA. Satisfying the GDPR does not satisfy that, and it is enforced by contract rather than by a regulator.</p>

<h2 id="what-this-implies-for-how-you-build">What this implies for how you build</h2>

<p>The rules that fall out are all design decisions rather than compliance decisions, and each is cheaper at build time than afterwards.</p>

<p>Write nothing to the device. Generate the session identifier per request and let it live in the payload. Hold no field you could resolve to a person by any lawful route, which in practice means the autonomous system number rather than the address, and no free-text field that a user could type a name into. Make forwarding opt-in per destination and per field, so the decision to send an identifier somewhere is an explicit act with a record. Carry the consent state as a field, so a customer who does need consent can pass their signal through rather than working around you.</p>

<p>The last one is the point most easily missed. A system that cannot represent consent forces every customer into the same posture, and the customer who wants advert measurement and the customer who wants no third-party processing are asking for incompatible things from the same product.</p>

<p>None of this is difficult. It is difficult to retrofit, because the identifier scheme and the storage model are decided early and everything downstream assumes them. The regimes above are best read as constraints on the first week of a project rather than as a review in the last.</p>

<p><a href="https://www.clientlog.bayis.co.uk">Clientlog</a> is built to these constraints: no device storage, no address retention, forwarding opt-in per destination. The implementation notes are on its own site.</p>

<p>(If you are working out where your analytics or model pipeline sits against this, <a href="/contact.html">get in touch</a>.)</p>]]></content><author><name>Edward Grundy</name></author><summary type="html"><![CDATA[Two regimes govern analytics data and they test different things. PECR asks whether you stored anything on the device. GDPR asks whether you can identify anyone. A 2025 CJEU judgment changed the answer to the second, and the first is still where most systems fail.]]></summary></entry><entry><title type="html">When a Graph Earns Its Place in RAG</title><link href="https://www.bayis.co.uk/library/111-graph-rag.html" rel="alternate" type="text/html" title="When a Graph Earns Its Place in RAG" /><published>2026-09-06T00:00:00+00:00</published><updated>2026-09-06T00:00:00+00:00</updated><id>https://www.bayis.co.uk/library/111-graph-rag</id><content type="html" xml:base="https://www.bayis.co.uk/library/111-graph-rag.html"><![CDATA[<h1 id="when-a-graph-earns-its-place-in-rag">When a Graph Earns Its Place in RAG</h1>

<p>Benchmark work through 2025 and 2026 keeps returning the same awkward result: GraphRAG underperforms ordinary vector RAG on a large share of real tasks, while clearly beating it on a narrow set. That pattern is more useful than either the enthusiasm or the dismissal, because it turns the question from whether graphs help retrieval into which questions they help with, and whether your corpus contains enough of them to justify the cost.</p>

<p>This piece covers what the graph adds, how it gets built, the two query shapes where it pays, what it costs, and how to decide without building one first.</p>

<h2 id="what-the-graph-actually-is">What the graph actually is</h2>

<p>A graph is a set of nodes (things) connected by edges (the stated relations between them). The same structure sits behind a family tree or an org chart; here it is built from text rather than drawn by hand.</p>

<p>Standard RAG treats a corpus as independent chunks. Embed each chunk, embed the query, return the nearest few. The retrieval unit is a passage, and the relationships between passages are invisible to the system.</p>

<p>GraphRAG, in the form Microsoft Research published in 2024, runs an extraction pass over the corpus, pulling out entities and the relations between them, and assembles the result into a knowledge graph. It clusters the graph into communities and generates a summary of each one ahead of any query arriving. At query time it retrieves graph fragments (nodes, triples, paths, subgraphs) or community summaries rather than raw passages, and generates from those.</p>

<p>Two things change. The retrieval unit becomes structured, so the model receives “A supplies B, B acquired C in 2019” instead of three paragraphs that each mention one of those facts. And the system holds a representation of the whole corpus, in the community summaries, which plain RAG has no equivalent of.</p>

<h2 id="building-the-graph-is-a-separable-choice">Building the graph is a separable choice</h2>

<p>Microsoft’s original pipeline extracts entities and relations with an LLM prompt, and most write-ups treat that as the method. It is one option among several, and the choice affects cost and coverage more than it affects the resulting graph’s shape.</p>

<p>Classical named-entity recognition paired with a relation-extraction model (or an OpenIE-style triple extractor) produces the same nodes and edges at a fraction of the token cost, without an LLM call per chunk. The trade-off is the one that affects any NER pipeline: a model trained on general text misses the relation types specific to a narrow domain, and a contracts corpus or a clinical record set will need extraction tuned to it rather than the off-the-shelf kind.</p>

<p>Entailment models offer a different edge type again. Instead of “supplies” or “acquired”, the edge becomes “confirms” or “contradicts” between two claims, which suits a corpus built from assertions that agree or disagree with each other (audit findings, conflicting witness statements, versions of a policy) rather than one built from entities acting on each other.</p>

<p>Where the relations already exist in structured form (an ownership register, a citation index, a CRM’s account hierarchy) no extraction step is needed at all. The graph is built directly from data already held, which is the case covered from the embedding side in <a href="/library/60-embedding-networks.html">Embedding-Based Relationship Discovery</a>.</p>

<h2 id="the-two-shapes-where-it-pays">The two shapes where it pays</h2>

<p><strong>Multi-hop questions.</strong> “Which of our suppliers are exposed to the sanctions affecting our second-tier vendors” requires joining facts that appear in different documents and are never stated together. Vector retrieval returns chunks similar to the question, and no chunk resembles a question whose answer is a chain. The graph traverses the chain because the chain is what it stores.</p>

<p><strong>Whole-corpus questions.</strong> “What are the recurring themes in three years of incident reports” has no answer in any single passage. Top-k retrieval returns five reports and the model summarises those five. Community summaries were built for exactly this case: the system answers from a hierarchical view of the entire corpus rather than from whatever the nearest-neighbour search surfaced.</p>

<p>Everything else is the majority case. When the answer sits in one passage, and the question resembles that passage, plain vector retrieval finds it, and the graph adds cost without adding accuracy. Ordinary document question-answering, policy lookup, support deflection, and most internal search fall here.</p>

<h2 id="where-the-embeddings-go">Where the embeddings go</h2>

<p>The graph does not replace vector search; it usually sits downstream of it. In Microsoft’s design, entities and community summaries are themselves embedded, and a query’s nearest neighbours among those embeddings decide where in the graph to start: which node, which community. Traversal happens after that, across explicit edges, to pull in connected facts the query embedding alone would not have surfaced. Vector search finds the foothold; graph structure walks outward from it.</p>

<h2 id="what-it-costs">What it costs</h2>

<p>Three costs, and the third is the one that catches deployments after they ship.</p>

<p><strong>Indexing.</strong> Extraction runs over every chunk to pull entities and relations, then again to generate community summaries. On a large corpus this is a substantial one-off token bill and a slow build, and none of it answers a user’s question directly.</p>

<p><strong>Query latency.</strong> Traversing the graph and summarising what comes back adds work at inference time. Reported figures put end-to-end latency at roughly two to three times a comparable vector pipeline, which matters for anything user-facing and matters less for batch analysis.</p>

<p><strong>Incremental updates.</strong> The graph index and its summaries grow super-linearly with corpus size, and adding documents can shift community boundaries, invalidating the summaries built from them. A corpus that changes daily either gets rebuilt repeatedly or drifts out of date. This is the cost that decides most deployments, and the one least visible in a proof of concept run against a frozen document set.</p>

<h2 id="traversal-or-folding-relations-into-the-index">Traversal, or folding relations into the index</h2>

<p>The research response to the traversal cost has been to keep the relational signal and drop the walk. LightRAG folds entities and relations directly into a standard dense index and retrieves in two rounds, a fine-grained pass over specific entities and a coarser pass over thematic keys. There is no explicit path-walking step; “ROCm” and the frameworks it supports sit near each other in the same embedding space, found by nearest-neighbour search rather than by following an edge. This removes the traversal cost, allows the index to be patched incrementally, cuts indexing token cost by roughly 60% and roughly halves median query latency against full GraphRAG, without a clear quality penalty on multi-hop benchmarks.</p>

<p>If the reason for wanting a graph is multi-hop retrieval rather than whole-corpus summarisation, the folded-relations approach is the sensible starting point. Community summaries are the expensive component, and they exist to serve the global-question case specifically.</p>

<h2 id="deciding-without-building-one">Deciding without building one</h2>

<p>Take a sample of fifty real questions from whoever will use the system, before any implementation exists.
Classify each one: does the answer sit in a single passage, does it require joining facts across documents, or does it require a view of the whole corpus.
If the first category holds forty of the fifty, build vector RAG and spend the saved effort on chunking and reranking, which will move accuracy further than a graph would.</p>

<p>If the second and third categories carry real weight, the follow-up question is how often the corpus changes. A static corpus (regulations, published research, completed case files) absorbs the indexing cost once. A corpus with daily additions pays it repeatedly, and that recurring cost is what the decision turns on.</p>

<p>The general point holds beyond this choice: the retrieval architecture is a consequence of the question distribution, and the question distribution is knowable before anything is built. Most RAG projects skip that step and discover the distribution afterwards, from complaints.</p>

<h2 id="a-worked-example">A worked example</h2>

<p>Using our <a href="/library/78-gpu-market-2026.html">article</a> on GPU selection: a paragraph stating that the RX 7900 XTX runs on ROCm, and a separate table, further down the document, listing which frameworks ROCm supports. “Which frameworks run on the RX 7900 XTX” has no single-passage answer; a flat vector index retrieves whichever chunk is closest to “RX 7900 XTX” and stops there.</p>

<p>The script below extracts a handful of triples from that article by hand (standing in for an LLM or NER pass), builds the graph, picks an entry node with a toy embedding, and walks the graph to answer the question.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">re</span>
<span class="kn">from</span> <span class="nn">collections</span> <span class="kn">import</span> <span class="n">defaultdict</span>
<span class="kn">from</span> <span class="nn">math</span> <span class="kn">import</span> <span class="n">sqrt</span>

<span class="kn">import</span> <span class="nn">networkx</span> <span class="k">as</span> <span class="n">nx</span>

<span class="c1"># --- 1. Extraction --------------------------------------------------------
# Normally an LLM pass or a NER + relation-extraction model over the
# source article. Written out by hand here so the graph stays legible.
</span>
<span class="n">triples</span> <span class="o">=</span> <span class="p">[</span>
    <span class="p">(</span><span class="s">"RTX 5090"</span><span class="p">,</span>        <span class="s">"uses_driver"</span><span class="p">,</span> <span class="s">"CUDA"</span><span class="p">),</span>
    <span class="p">(</span><span class="s">"RTX 4090"</span><span class="p">,</span>        <span class="s">"uses_driver"</span><span class="p">,</span> <span class="s">"CUDA"</span><span class="p">),</span>
    <span class="p">(</span><span class="s">"RTX 3090"</span><span class="p">,</span>        <span class="s">"uses_driver"</span><span class="p">,</span> <span class="s">"CUDA"</span><span class="p">),</span>
    <span class="p">(</span><span class="s">"Jetson AGX Thor"</span><span class="p">,</span>  <span class="s">"uses_driver"</span><span class="p">,</span> <span class="s">"CUDA"</span><span class="p">),</span>
    <span class="p">(</span><span class="s">"DGX Spark"</span><span class="p">,</span>        <span class="s">"uses_driver"</span><span class="p">,</span> <span class="s">"CUDA"</span><span class="p">),</span>
    <span class="p">(</span><span class="s">"RX 7900 XTX"</span><span class="p">,</span>      <span class="s">"uses_driver"</span><span class="p">,</span> <span class="s">"ROCm"</span><span class="p">),</span>
    <span class="p">(</span><span class="s">"W7900"</span><span class="p">,</span>            <span class="s">"uses_driver"</span><span class="p">,</span> <span class="s">"ROCm"</span><span class="p">),</span>
    <span class="p">(</span><span class="s">"MI300X"</span><span class="p">,</span>           <span class="s">"uses_driver"</span><span class="p">,</span> <span class="s">"ROCm"</span><span class="p">),</span>
    <span class="p">(</span><span class="s">"M3 Ultra"</span><span class="p">,</span>         <span class="s">"uses_driver"</span><span class="p">,</span> <span class="s">"Metal"</span><span class="p">),</span>
    <span class="p">(</span><span class="s">"M4 Max"</span><span class="p">,</span>           <span class="s">"uses_driver"</span><span class="p">,</span> <span class="s">"Metal"</span><span class="p">),</span>
    <span class="p">(</span><span class="s">"CUDA"</span><span class="p">,</span>  <span class="s">"supports"</span><span class="p">,</span> <span class="s">"PyTorch"</span><span class="p">),</span>
    <span class="p">(</span><span class="s">"CUDA"</span><span class="p">,</span>  <span class="s">"supports"</span><span class="p">,</span> <span class="s">"vLLM"</span><span class="p">),</span>
    <span class="p">(</span><span class="s">"CUDA"</span><span class="p">,</span>  <span class="s">"supports"</span><span class="p">,</span> <span class="s">"Ollama"</span><span class="p">),</span>
    <span class="p">(</span><span class="s">"CUDA"</span><span class="p">,</span>  <span class="s">"supports"</span><span class="p">,</span> <span class="s">"llama.cpp"</span><span class="p">),</span>
    <span class="p">(</span><span class="s">"CUDA"</span><span class="p">,</span>  <span class="s">"supports"</span><span class="p">,</span> <span class="s">"ComfyUI"</span><span class="p">),</span>
    <span class="p">(</span><span class="s">"ROCm"</span><span class="p">,</span>  <span class="s">"supports"</span><span class="p">,</span> <span class="s">"PyTorch"</span><span class="p">),</span>
    <span class="p">(</span><span class="s">"ROCm"</span><span class="p">,</span>  <span class="s">"supports"</span><span class="p">,</span> <span class="s">"vLLM"</span><span class="p">),</span>
    <span class="p">(</span><span class="s">"ROCm"</span><span class="p">,</span>  <span class="s">"supports"</span><span class="p">,</span> <span class="s">"Ollama"</span><span class="p">),</span>
    <span class="p">(</span><span class="s">"ROCm"</span><span class="p">,</span>  <span class="s">"supports"</span><span class="p">,</span> <span class="s">"llama.cpp"</span><span class="p">),</span>
    <span class="p">(</span><span class="s">"ROCm"</span><span class="p">,</span>  <span class="s">"supports"</span><span class="p">,</span> <span class="s">"ComfyUI"</span><span class="p">),</span>
    <span class="p">(</span><span class="s">"Metal"</span><span class="p">,</span> <span class="s">"supports"</span><span class="p">,</span> <span class="s">"Ollama"</span><span class="p">),</span>
    <span class="p">(</span><span class="s">"Metal"</span><span class="p">,</span> <span class="s">"supports"</span><span class="p">,</span> <span class="s">"llama.cpp"</span><span class="p">),</span>
    <span class="p">(</span><span class="s">"Metal"</span><span class="p">,</span> <span class="s">"supports"</span><span class="p">,</span> <span class="s">"ComfyUI"</span><span class="p">),</span>
<span class="p">]</span>

<span class="n">G</span> <span class="o">=</span> <span class="n">nx</span><span class="p">.</span><span class="n">DiGraph</span><span class="p">()</span>
<span class="k">for</span> <span class="n">subject</span><span class="p">,</span> <span class="n">relation</span><span class="p">,</span> <span class="n">obj</span> <span class="ow">in</span> <span class="n">triples</span><span class="p">:</span>
    <span class="n">G</span><span class="p">.</span><span class="n">add_edge</span><span class="p">(</span><span class="n">subject</span><span class="p">,</span> <span class="n">obj</span><span class="p">,</span> <span class="n">relation</span><span class="o">=</span><span class="n">relation</span><span class="p">)</span>

<span class="c1"># --- 2. A toy embedding, standing in for a real one ------------------------
# Bag-of-words cosine similarity over each node's name plus its immediate
# neighbours: enough signal to pick an entry point, no LLM call required.
</span>
<span class="k">def</span> <span class="nf">bow</span><span class="p">(</span><span class="n">text</span><span class="p">):</span>
    <span class="k">return</span> <span class="n">re</span><span class="p">.</span><span class="n">findall</span><span class="p">(</span><span class="sa">r</span><span class="s">"[a-z0-9.]+"</span><span class="p">,</span> <span class="n">text</span><span class="p">.</span><span class="n">lower</span><span class="p">())</span>

<span class="k">def</span> <span class="nf">node_text</span><span class="p">(</span><span class="n">node</span><span class="p">):</span>
    <span class="n">neighbours</span> <span class="o">=</span> <span class="nb">list</span><span class="p">(</span><span class="n">G</span><span class="p">.</span><span class="n">successors</span><span class="p">(</span><span class="n">node</span><span class="p">))</span> <span class="o">+</span> <span class="nb">list</span><span class="p">(</span><span class="n">G</span><span class="p">.</span><span class="n">predecessors</span><span class="p">(</span><span class="n">node</span><span class="p">))</span>
    <span class="k">return</span> <span class="s">" "</span><span class="p">.</span><span class="n">join</span><span class="p">([</span><span class="n">node</span><span class="p">]</span> <span class="o">+</span> <span class="n">neighbours</span><span class="p">)</span>

<span class="k">def</span> <span class="nf">cosine</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">):</span>
    <span class="n">va</span><span class="p">,</span> <span class="n">vb</span> <span class="o">=</span> <span class="n">defaultdict</span><span class="p">(</span><span class="nb">int</span><span class="p">),</span> <span class="n">defaultdict</span><span class="p">(</span><span class="nb">int</span><span class="p">)</span>
    <span class="k">for</span> <span class="n">w</span> <span class="ow">in</span> <span class="n">a</span><span class="p">:</span> <span class="n">va</span><span class="p">[</span><span class="n">w</span><span class="p">]</span> <span class="o">+=</span> <span class="mi">1</span>
    <span class="k">for</span> <span class="n">w</span> <span class="ow">in</span> <span class="n">b</span><span class="p">:</span> <span class="n">vb</span><span class="p">[</span><span class="n">w</span><span class="p">]</span> <span class="o">+=</span> <span class="mi">1</span>
    <span class="n">dot</span> <span class="o">=</span> <span class="nb">sum</span><span class="p">(</span><span class="n">va</span><span class="p">[</span><span class="n">w</span><span class="p">]</span> <span class="o">*</span> <span class="n">vb</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="n">w</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span> <span class="k">for</span> <span class="n">w</span> <span class="ow">in</span> <span class="n">va</span><span class="p">)</span>
    <span class="n">norm_a</span> <span class="o">=</span> <span class="n">sqrt</span><span class="p">(</span><span class="nb">sum</span><span class="p">(</span><span class="n">v</span> <span class="o">*</span> <span class="n">v</span> <span class="k">for</span> <span class="n">v</span> <span class="ow">in</span> <span class="n">va</span><span class="p">.</span><span class="n">values</span><span class="p">()))</span>
    <span class="n">norm_b</span> <span class="o">=</span> <span class="n">sqrt</span><span class="p">(</span><span class="nb">sum</span><span class="p">(</span><span class="n">v</span> <span class="o">*</span> <span class="n">v</span> <span class="k">for</span> <span class="n">v</span> <span class="ow">in</span> <span class="n">vb</span><span class="p">.</span><span class="n">values</span><span class="p">()))</span>
    <span class="k">return</span> <span class="n">dot</span> <span class="o">/</span> <span class="p">(</span><span class="n">norm_a</span> <span class="o">*</span> <span class="n">norm_b</span><span class="p">)</span> <span class="k">if</span> <span class="n">norm_a</span> <span class="ow">and</span> <span class="n">norm_b</span> <span class="k">else</span> <span class="mf">0.0</span>

<span class="n">node_vectors</span> <span class="o">=</span> <span class="p">{</span><span class="n">n</span><span class="p">:</span> <span class="n">bow</span><span class="p">(</span><span class="n">node_text</span><span class="p">(</span><span class="n">n</span><span class="p">))</span> <span class="k">for</span> <span class="n">n</span> <span class="ow">in</span> <span class="n">G</span><span class="p">.</span><span class="n">nodes</span><span class="p">}</span>

<span class="k">def</span> <span class="nf">entry_point</span><span class="p">(</span><span class="n">query</span><span class="p">):</span>
    <span class="n">query_vec</span> <span class="o">=</span> <span class="n">bow</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
    <span class="n">scored</span> <span class="o">=</span> <span class="p">[(</span><span class="n">cosine</span><span class="p">(</span><span class="n">query_vec</span><span class="p">,</span> <span class="n">vec</span><span class="p">),</span> <span class="n">n</span><span class="p">)</span> <span class="k">for</span> <span class="n">n</span><span class="p">,</span> <span class="n">vec</span> <span class="ow">in</span> <span class="n">node_vectors</span><span class="p">.</span><span class="n">items</span><span class="p">()]</span>
    <span class="k">return</span> <span class="nb">max</span><span class="p">(</span><span class="n">scored</span><span class="p">)[</span><span class="mi">1</span><span class="p">]</span>

<span class="c1"># --- 3. Traversal: the step a flat vector index cannot do ------------------
</span>
<span class="k">def</span> <span class="nf">frameworks_for</span><span class="p">(</span><span class="n">device</span><span class="p">):</span>
    <span class="n">driver</span> <span class="o">=</span> <span class="nb">next</span><span class="p">(</span><span class="nb">iter</span><span class="p">(</span><span class="n">G</span><span class="p">.</span><span class="n">successors</span><span class="p">(</span><span class="n">device</span><span class="p">)))</span>
    <span class="n">frameworks</span> <span class="o">=</span> <span class="p">[</span><span class="n">n</span> <span class="k">for</span> <span class="n">n</span> <span class="ow">in</span> <span class="n">G</span><span class="p">.</span><span class="n">successors</span><span class="p">(</span><span class="n">driver</span><span class="p">)</span> <span class="k">if</span> <span class="n">G</span><span class="p">[</span><span class="n">driver</span><span class="p">][</span><span class="n">n</span><span class="p">][</span><span class="s">"relation"</span><span class="p">]</span> <span class="o">==</span> <span class="s">"supports"</span><span class="p">]</span>
    <span class="k">return</span> <span class="n">driver</span><span class="p">,</span> <span class="n">frameworks</span>

<span class="n">query</span> <span class="o">=</span> <span class="s">"Which frameworks run on the RX 7900 XTX?"</span>
<span class="n">device</span> <span class="o">=</span> <span class="n">entry_point</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
<span class="n">driver</span><span class="p">,</span> <span class="n">frameworks</span> <span class="o">=</span> <span class="n">frameworks_for</span><span class="p">(</span><span class="n">device</span><span class="p">)</span>

<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Entry point: </span><span class="si">{</span><span class="n">device</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Driver stack: </span><span class="si">{</span><span class="n">driver</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Supported frameworks: </span><span class="si">{</span><span class="n">frameworks</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
</code></pre></div></div>

<p>This prints:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Entry point: RX 7900 XTX
Driver stack: ROCm
Supported frameworks: ['PyTorch', 'vLLM', 'Ollama', 'llama.cpp', 'ComfyUI']
</code></pre></div></div>

<p>The query names only the card, yet the answer joins a fact stated in one paragraph (device to driver) with a fact stated in a separate table (driver to frameworks), which is what “multi-hop” means in practice. Replace the hand-written triples with an LLM or NER extraction pass, replace the bag-of-words vector with a real sentence embedding, and the shape of the pipeline does not change. Fold the <code class="language-plaintext highlighter-rouge">supports</code> edges directly into <code class="language-plaintext highlighter-rouge">node_vectors</code> instead of walking them at query time, and this becomes the LightRAG.</p>

<p>(If you are working out whether your question set justifies a graph, <a href="/contact.html">get in touch</a>.)</p>]]></content><author><name>Edward Grundy</name></author><summary type="html"><![CDATA[GraphRAG builds an entity graph over your documents and retrieves subgraphs in place of passages. It helps with multi-hop and whole-corpus questions, at two to three times the query latency and a large one-off indexing cost.]]></summary></entry><entry><title type="html">Instrumentation Is the Deliverable</title><link href="https://www.bayis.co.uk/library/109-instrumentation.html" rel="alternate" type="text/html" title="Instrumentation Is the Deliverable" /><published>2026-09-04T00:00:00+00:00</published><updated>2026-09-04T00:00:00+00:00</updated><id>https://www.bayis.co.uk/library/109-instrumentation</id><content type="html" xml:base="https://www.bayis.co.uk/library/109-instrumentation.html"><![CDATA[<h1 id="instrumentation-is-the-deliverable">Instrumentation Is the Deliverable</h1>

<p><a href="https://popstory.co.uk">PopStory</a> writes illustrated short stories in several
languages, each graded to a reading level. Ten or so model calls stand between a
chosen theme and a finished story, and when one of them goes wrong the story
arrives wrong without an error. So every stage writes a record:
which model ran, under what instruction, on what input, how long it took, what it
cost, and whether it failed.</p>

<p>Rendered, those records became a page. Each story now carries public production
notes showing the cast, the setting, the five-part plan behind the text, the
reference drawings, the models that ran and the bill for the run. The standing
objection to generated writing is that it shows no working. The page shows the
working.</p>

<p>The instruments that make a model pipeline debuggable are the same artefacts a
business process needs and rarely has.</p>

<h2 id="the-questions-a-pipeline-forces">The questions a pipeline forces</h2>

<p>To make a chain of model calls work you have to answer a short list of questions:
What are the steps? What does each one consume and produce? What does good look
like? Can we check? What does each step cost? What happens when a step fails? and how do you know it failed rather than “looked plausible”?</p>

<p>Ask those five questions of a process in your own organisation. Onboarding, a compliance
review, the route from a raised ticket to production code. Most organisations
cannot answer them.</p>

<p>This is because human processes degrade gracefully. Where there is a gap, somebody fills it: they
ask a colleague, they remember the exception, they find a document is out of date and work round it and so on.
The gap rarely gets written down because it doesn’t cause enough of a problem.
Or at least, the cost of absorbing it is less than the cost of writing it down. (Humans are very good at that type of judgement)
Automation removes the absorber, and the gap surfaces immediately as a defect.
This is why so many automation projects are a major discovery process.</p>

<h2 id="the-mapping">The mapping</h2>

<p><strong>Provenance</strong> on every output, meaning the model, the exact instruction, the inputs
and the timestamp, is an audit trail. In regulated work it decides whether a
system can be procured at all, independently of how good the output is.</p>

<p>Cost per call, attributed to the step that incurred it, is <strong>unit economics</strong> at the
resolution of the work rather than the department. We know what a story costs,
that image generation dominates the bill, and what text generation failure costs.</p>

<p>Stage records across many runs are process mining. Duration by stage across a
batch shows where the time goes, and it is the <strong>analysis</strong> you would run over a
workflow tool’s event log if anyone had kept one.</p>

<p>Evaluations are acceptance criteria that someone who did not do the work can
check. Ours is level compliance: a story graded to JLPT N4 either stays inside N4
and N5 kanji or it does not, and a script settles it. “Well written” hands the
standard back to the reviewer to supply.</p>

<p>Content addressing, where an output is keyed by a hash of the input that produced
it, is impact analysis. Change one clause of a source document and the keys tell
you which downstream outputs are now stale and which are untouched. Very few
organisations can provide that answer.</p>

<h2 id="the-bug-that-makes-the-point">The bug that makes the point</h2>

<p>Our stage records wrote a cost on success only. A call that spent money and then
returned something unusable was recorded as free.</p>

<p>We found it when a degenerate loop cost roughly a hundred times a normal call and
the records showed nothing at all. The fix was small: the error carries the cost.</p>

<p>The same bug runs through most reported numbers. Rework, abandoned drafts, the
review that bounced three times, the ticket reopened twice, the document nobody
could use so they rewrote it themselves. All of it is spend, and none of it
reaches the figures that get reported upwards. The organisation knows the cost of
time, but not the cost of inefficient work.</p>

<p>No AI is needed to find that. Instrumentation is enough.</p>

<h2 id="what-follows">What follows</h2>

<p>Build an instrumentation habit. Ask who else would want to see it.
The data already exists, rendering it costs close to nothing, and the debug view
is often the thing a customer, an auditor or a reviewer will ask about.</p>

<p>Before automating a process, instrument the human version of it using a machine schema.
Record the steps, the inputs and outputs, the duration, the cost, and the failures, including the
ones somebody absorbed. People will be reluctant to do this because it is bureaucratic and tedious; exactly what machines love!</p>

<p>Automate, and you hold a specification of what and a baseline to measure against.</p>

<p>At this point, you hold a written description of a process, itemised costs, a specification for automation and a description of the risks and costs.</p>

<p>The failed AI pilots we have observed mostly fail because there was no coherent goal.
Instrumenting first moves that finding forward, from a write-off to the deliverable.</p>]]></content><author><name>Edward Grundy</name></author><summary type="html"><![CDATA[The records written to debug a generation pipeline describe the process the pipeline replaced. That description is the artefact most organisations lack, and it can be produced before any model is bought.]]></summary></entry><entry><title type="html">Engineering in the Exponential Age: MCP as a Case Study</title><link href="https://www.bayis.co.uk/library/108-exponential-engineering-mcp.html" rel="alternate" type="text/html" title="Engineering in the Exponential Age: MCP as a Case Study" /><published>2026-08-29T00:00:00+00:00</published><updated>2026-08-29T00:00:00+00:00</updated><id>https://www.bayis.co.uk/library/108-exponential-engineering-mcp</id><content type="html" xml:base="https://www.bayis.co.uk/library/108-exponential-engineering-mcp.html"><![CDATA[<h1 id="engineering-in-the-exponential-age-mcp-as-a-case-study">Engineering in the Exponential Age: MCP as a Case Study</h1>

<p>At a recent <a href="https://aiwales.org/events/">AI Wales</a> meetup, several developers said some version of: “if a service doesn’t offer MCP, I will continue looking.”.</p>

<p><a href="https://modelcontextprotocol.io/">MCP</a>, the Model Context Protocol, is a standard interface for connecting an AI agent to a tool or a data source.
An agent using MCP discovers what a service can do and calls it directly, without a developer writing bespoke integration code for each new agent.</p>

<p>A REST API used to be a differentiator.
A product that exposed one signalled other systems could be built against it.
It’s now assumed, and a product without a REST API cannot be taken seriously as infrastructure.</p>

<p>What MCP does in practice is let an agent find a service and use it, the way a person finds and uses a website: by name, by search, by discovery, without builing the specific connection in advance.
A product behind an MCP interface becomes reachable by AI agents, not just developers.</p>

<p>And none of this required much building.
FastMCP and FastAPI turn the protocol and the REST layer beneath it into a few decorators around existing functions, not a system designed from scratch.
The agentic workers calling the service, the actual AI systems, were built by someone else entirely: Anthropic, OpenAI, whoever ships the agent a person happens to be using that day.
In many cases the service itself already existed as a product before any of this started.
MCP is bolted onto something already finished.</p>

<p>The return on the work is not proportional.
A product that speaks MCP reaches a new audience: agents and their users, discovering and using a service the same way they discover anything else in an agentic workflow.
It surfaces use cases the original team never designed for, because the interface is generic enough that someone else’s agent can compose it into a task nobody at the company thought to build.
And building the interface forces a clearer answer to what the product actually does, since MCP’s tool definitions have to describe the service in terms an agent can act on directly.</p>

<p>The developers in that room are the case in miniature. Each of them is now a potential audience for a product built this way, and so is every agent they set running on their own behalf afterwards.
One integration reaching a person, and everything that person automates through it, is the exponential, network-shaped growth an integration is meant to buy.</p>]]></content><author><name>Edward Grundy</name></author><summary type="html"><![CDATA[MCP moved from a genuine differentiator to an assumed interface faster than REST did. A worked example of what that shift actually requires an engineering team to build, and what it returns.]]></summary></entry><entry><title type="html">Marigold: A Self-Hosted Inference API for Open-Weight Models</title><link href="https://www.bayis.co.uk/library/64-marigold.html" rel="alternate" type="text/html" title="Marigold: A Self-Hosted Inference API for Open-Weight Models" /><published>2026-08-29T00:00:00+00:00</published><updated>2026-08-29T00:00:00+00:00</updated><id>https://www.bayis.co.uk/library/64-marigold</id><content type="html" xml:base="https://www.bayis.co.uk/library/64-marigold.html"><![CDATA[<h1 id="marigold-a-self-hosted-inference-api-for-open-weight-models">Marigold: A Self-Hosted Inference API for Open-Weight Models</h1>

<p>The major AI providers bundle two things that should be separate: the model
and the infrastructure. When you call the OpenAI or Anthropic API, your data
travels to their servers, runs against their model, and returns a result.
The provider sits between you and the model, whatever their policy says
about logging, retention, or training.</p>

<p>Open-weight models remove that party. Llama, Mistral, Qwen, and others
publish their weights publicly, and the model can run anywhere. Marigold
is that anywhere: an OpenAI-compatible API, a model-serving worker, and a
chat interface, all running on hardware you control. No request reaches
OpenAI, Anthropic, or any other external provider. Once your models are
cached, it runs fully airgapped.</p>

<h2 id="install-and-run">Install and run</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip install bayis-marigold
git clone https://github.com/bayinfosys/marigold-examples
marigold deployment start marigold-examples/chat
</code></pre></div></div>

<p>The first command installs the <code class="language-plaintext highlighter-rouge">marigold</code> CLI. The second pulls a set of
pre-written application packages, each one a <code class="language-plaintext highlighter-rouge">models.yaml</code> (which models
to load) and a <code class="language-plaintext highlighter-rouge">marigold.toml</code> (how to run it). The third brings up the
full stack for that package, Postgres, a cache-init step that downloads
the declared models, the worker, the API, and (for the <code class="language-plaintext highlighter-rouge">chat</code> package)
Open WebUI. The model download is the only point in the process that
needs an internet connection; everything after that runs offline.</p>

<p>The full walkthrough, including how to confirm it’s working and where
the model cache lives, is in the <a href="https://marigold.run/tutorials/setup.html">setup guide</a>.</p>

<h2 id="a-drop-in-replacement-for-the-major-ai-apis">A drop-in replacement for the major AI APIs</h2>

<p>Marigold exposes the same interface as OpenAI and Anthropic. Existing
application code that calls those APIs can point at Marigold instead
without changes. The models underneath are open-weight equivalents,
running on your own hardware rather than a shared cloud server.</p>

<p>(For the broader architectural case, see
<a href="/library/40-private-inference.html">Private Inference: Running AI Inside Your Own Infrastructure</a>.)</p>

<h2 id="models-as-the-stable-layer">Models as the stable layer</h2>

<p>The model cache is shared across every package on the machine,
independent of which one is currently running. Download a model once
and every application package that needs it reuses the same copy,
rather than each package pulling its own.</p>

<p>Application packages sit on top as the thin, disposable layer: a
<code class="language-plaintext highlighter-rouge">models.yaml</code>, a <code class="language-plaintext highlighter-rouge">marigold.toml</code>, and application code. Marigold runs
the package; it never runs the package’s own code directly, by design.
This is the same split described in
<a href="/library/45-fat-protocols-for-ai.html">Models as a Fat Protocol</a> –
the model as the durable, shared layer, applications as thin clients
built on top of it, the shape that made early internet protocols last.</p>

<h2 id="workflows-and-pipelines">Workflows and pipelines</h2>

<p>Single model calls handle straightforward tasks. More complex automation
requires composing multiple steps: embed a document, classify its
content, generate a structured summary, evaluate the output. Marigold
supports this through a typed workflow layer, where each step conditions
on the outputs of the previous one and the whole pipeline is declared
rather than hand-coded.</p>

<p>This is the same pattern described in the
<a href="/library/54-runfox.html">runfox</a> workflow engine, which Marigold uses
directly as its execution substrate.</p>

<h2 id="what-its-used-for">What it’s used for</h2>

<p>The model types cover more than chat: text and image embedding,
instruction-following, image generation, image-to-text, image
segmentation, depth estimation, text-to-speech, and evaluation models
for scoring text, image, and text-image alignment. In combination,
these support workflows like quality-gated image generation
(generate, score, regenerate until a threshold is met), visual
conformance checking (compare an observation against a reference,
diff the discrepancy, describe it in natural language), and batch
tabular classification against a small labelled set, with low-confidence
predictions routed to an instruct model for explanation.</p>

<h2 id="getting-started">Getting started</h2>

<p>Marigold is open source, on <a href="https://github.com/bayinfosys/marigold">GitHub</a>
and <a href="https://pypi.org/project/bayis-marigold">PyPI</a>. The
<a href="https://marigold.run/tutorials.html">tutorials</a> cover setup, local
document search, and adding a new model to an existing package; the
<a href="https://marigold.run/examples.html">examples</a> are pre-built application
packages to clone and run directly. Sizing your own hardware for this is
its own decision: see our <a href="/library/78-gpu-market-2026.html">GPU options guide</a>
for what VRAM and which vendors actually support self-hosted inference
in practice.</p>

<p>Marigold publishes benchmark data and implementation notes on its own
<a href="https://marigold.run/blog.html">Build Log</a>.</p>]]></content><author><name>Edward Grundy</name></author><summary type="html"><![CDATA[An OpenAI-compatible API for open-weight models, running entirely on your own hardware. Open source, installed with pip, airgapped once your models are cached.]]></summary></entry><entry><title type="html">The Unfinshed Notebook</title><link href="https://www.bayis.co.uk/library/107-notebooks.html" rel="alternate" type="text/html" title="The Unfinshed Notebook" /><published>2026-08-07T00:00:00+00:00</published><updated>2026-08-07T00:00:00+00:00</updated><id>https://www.bayis.co.uk/library/107-notebooks</id><content type="html" xml:base="https://www.bayis.co.uk/library/107-notebooks.html"><![CDATA[<h1 id="the-unfinished-notebook">The Unfinished Notebook</h1>

<p>We presented a benchmark at AI Wales this week: 57 open-weight language models, 380 prompts each, power and memory telemetry captured per request with <a href="https://marigold.run">Marigold</a>. The dataset and notebook are both available on <a href="https://github.com/AI-Wales/build-llm-power-sampling">github</a> . Both were built to be simple rather than complete, to help develop a narrative and direction, rather than a goal.</p>

<h2 id="what-the-notebook-does">What the notebook does</h2>

<p>The notebook follows the four classic steps of a data science task: Load the data; Check some values and ranges; Perform Exploratory Data Analysis via distributions and a correlation matrix; and, build one basic model: coefficient of variation. Computing this per model and ranking the models by the consistency of power draw.</p>

<p>The final outcome produces a result which is also a baseline for improvement.
The most consistent model in the set is a clear number anyone can check against the raw CSV.
It is also, on inspection, incomplete answer due to zero values in the data.
So now, we have identified our first task: data cleaning. Which can then be performed, new results gathered, inspected; a new improvement identified and so on.
This is the iterative loop which we can access once the project is established.</p>

<p>This is a deliberate scaffold.
It is too easy and tempting to continue the analysis and improvement, and delaying delivery of the work.</p>

<p>It is important to separate the setup of a project, and the goals of setup, with the delivery of the project.
A key goal of setup is to encourage collaboration within a team.
A notebook with obvious places for improvement encourages engagement.</p>

<h2 id="a-notebook-as-a-narrative-not-a-pipeline">A notebook as a narrative, not a pipeline</h2>

<p>A notebook is a particular kind of artefact which creates a narrative through a problem.
Data collection, parsing, cleaning, exploration, model selection, measurement and metrics and so on, can all be identified as steps in the notebook.
In exploratory tasks, this sequence is important and clear. Missing a step is an obvious omission.</p>

<p>That narrative is not required in production where deployment requires integration, testing, and monitoring.
A notebook is not a rough draft of a production system.
It is the right form for presenting analysis so that a team, or a room of strangers at a meetup, can understand the process, ask questions, engage, and pick the thread up themselves.</p>

<h2 id="what-people-actually-asked">What people actually asked</h2>

<p>Two questions came up in the room: Had we measured response accuracy? and, had we accounted for batch size and parallel decoding in the timing figures?</p>

<p>Accuracy evaluation and batching are the two things anyone with production LLM experience ask.
Both questions can be seen as a way of demonstrating knowledge and suggesting next steps, and “feeling out” what space is left in the project.
The useful answer was the same: no, but feel free to contribute!</p>

<p>This turned the demonstration into an invitation. People could leave the room comfortable that they could contribute to the project.</p>

<h2 id="engagement-as-a-property-of-the-work">Engagement as a property of the work</h2>

<p>The usual approach to team culture treats engagement as a separate activity: an away day, a team lunch, a Friday demo.
These are fine on their own terms, but sit outside the actual work, which means the habits they build – showing up, being sociable, sitting through a presentation – do not transfer back into work getting done.</p>

<p>Build the invitation into the work.
Design for it to be continued and make it accessible enough that collaboration is the next natural step.</p>

<p>(If you’re building a data team’s engagement into its actual output rather than around it, <a href="/contact.html">get in touch</a>.)</p>]]></content><author><name>Edward Grundy</name></author><summary type="html"><![CDATA[A benchmark of 57 open-weight models, presented at AI Wales, was built to be simple rather than complete. What that design choice did for engagement in the room, and why community building works better inside the work than around it.]]></summary></entry><entry><title type="html">Evaluating RAG</title><link href="https://www.bayis.co.uk/library/100-rag-evaluation.html" rel="alternate" type="text/html" title="Evaluating RAG" /><published>2026-07-26T00:00:00+00:00</published><updated>2026-07-26T00:00:00+00:00</updated><id>https://www.bayis.co.uk/library/100-rag-evaluation</id><content type="html" xml:base="https://www.bayis.co.uk/library/100-rag-evaluation.html"><![CDATA[<h1 id="evaluating-rag">Evaluating RAG</h1>

<p>A fluent answer and a correct one look identical from the outside. A model handed the wrong passage writes just as confidently as one handed the right one. So evaluation has to check each stage of the pipeline in order, since a fault at any stage produces the same symptom downstream: a well-formed answer with nothing underneath it.</p>

<h2 id="first-the-embedding-model">First, the embedding model</h2>

<p>Before measuring retrieval, check that the embedding model separates your concepts at all. Build a small set of query-passage pairs from your own domain, ones you already know should sit close together and ones that shouldn’t, and measure cosine similarity between them directly. A general-purpose embedding model trained on web text can genuinely fail to distinguish domain-specific concepts that look similar on the surface (two clauses that use the same words but mean opposite things contractually, say). If the embedding model can’t separate these in a raw similarity test, nothing downstream will fix it. This is a one-off check, not something you re-run continuously, and it’s worth doing before any of the following steps.</p>

<h2 id="then-the-chunking">Then, the chunking</h2>

<p>Hold the embedding model fixed and vary chunk size or strategy against the same corpus. Context precision and context recall are the relevant pair here: precision asks whether the chunk returned actually contains the answer, recall asks whether the answer exists somewhere in the corpus but got split across a boundary and lost. A chunking strategy that scores well on precision but poorly on recall is cutting documents in the wrong places. This is measured by holding retrieval and generation constant and only changing chunk boundaries, so any change in score is attributable to chunking alone.</p>

<h2 id="then-retrieval">Then, retrieval</h2>

<p>With embedding and chunking settled, measure whether the right passages actually surface for a query. Precision at K: of the top K results, how many were relevant. Recall at K: of all relevant passages in the corpus, how many made it into the top K. Mean reciprocal rank: how high the first relevant result ranked, since a relevant passage at position 40 does little good if the model only reads the top ten. These three numbers are what most people mean when they say “RAG evaluation”, but they only mean anything once the two steps above are already accounted for.</p>

<h2 id="then-generation">Then, generation</h2>

<p>Finally, check what the model did with what it was given. Faithfulness measures whether each claim in the answer traces back to the retrieved context, rather than being filled in from the model’s general training. Answer relevancy measures whether the response addresses the question asked, a separate failure from faithfulness: an answer can be fully grounded and still not answer the question. Hallucination, in this specific sense, is a claim with no support in the retrieved context at all, and it is the failure a user is worst placed to catch, since it reads exactly like a correct answer.</p>

<h2 id="how-ragas-actually-measures-this">How RAGAS actually measures this</h2>

<p>RAGAS is a framework, not a library of formulas or a single algorithm: it doesn’t compute a fixed statistic so much as orchestrate a series of judge calls, each one a separate LLM prompt with a narrow task, and aggregate the results per metric. Two stages sit behind that.</p>

<p><strong>Building the dataset.</strong> RAGAS generates synthetic question-and-answer pairs directly from your own source documents, using an LLM to write plausible questions and their correct answers from the actual content. This is what makes evaluation practical at scale; without it, someone has to hand-write hundreds of question-answer pairs before any of the above can be measured. Each row in the resulting dataset carries four fields: the question, the ground-truth answer, the contexts your pipeline actually retrieved, and the answer your pipeline actually generated.</p>

<p><strong>Scoring, metric by metric.</strong> Each metric is computed with a separate judge-model call (a different LLM from the one that generated the answer), and the mechanics differ per metric rather than reducing to a single rating question.</p>

<p>Faithfulness is computed by decomposing the generated answer into individual claims, then checking each claim separately against the retrieved context for support. The score is the proportion of claims that pass. This claim-by-claim decomposition is why faithfulness catches partial hallucination: four true claims and one fabricated one scores 0.8, not a binary pass or fail.</p>

<p>Context precision looks at the ranked list of retrieved chunks and checks, position by position, whether each is relevant, weighting relevant chunks that appear earlier in the ranking more heavily than ones further down.</p>

<p>Context recall works in the other direction. It takes the ground-truth answer, breaks it into its constituent claims, and checks whether each claim can be attributed to something in the retrieved context. Missing context drops recall even when precision looks fine.</p>

<p>Answer relevancy takes the generated answer and asks the judge model to reverse-engineer plausible questions that answer would be responding to, then compares those reconstructed questions against the original question by embedding similarity. A relevant answer produces reconstructed questions close to the real one; an off-topic answer doesn’t.</p>

<p>Each metric returns a score per row, aggregated across the dataset into a number per metric rather than one blended score, which is the design choice that matters most: a drop in one number tells you which stage of the pipeline broke, rather than telling you only that something, somewhere, is wrong.</p>

<p>LLM-as-judge is not a full substitute for human review. It can miss domain-specific errors a specialist would catch immediately (a subtly wrong dosage, a misstated clause, a transposed number that reads as grammatically fine), so treat RAGAS as the tool for continuous, high-volume measurement, with human review sampled in wherever a missed error is expensive.</p>

<h2 id="drift-detection-in-rag">Drift detection in RAG</h2>

<p>None of the four measurements above is a one-time gate. The corpus changes, documents get added, edited, retired, and a chunking or retrieval score measured at launch decays as the source material drifts from what was originally indexed, with no change to the model at all. This is drift specific to the retrieval side of the system, distinct from the model drift a standard ML deployment watches for, and it needs its own detection loop rather than an assumption that generation-side monitoring will catch it.</p>

<p>Re-run the four stages on a fixed schedule against the current corpus, and track retrieval metrics against a rolling sample of real production queries rather than only the original eval set, since real usage drifts away from whatever questions the system was tested against at launch. When a metric drops, it points at the stage responsible: falling context recall means revisit chunking, falling precision at K means revisit retrieval, falling faithfulness means revisit generation. The measurement tells you which part of the pipeline to touch, which is the entire point of measuring each stage separately rather than scoring the system as one number.</p>

<p>(If you’re setting up drift detection for a RAG system already in production, <a href="/contact.html">get in touch</a>.)</p>]]></content><author><name>Edward Grundy</name></author><summary type="html"><![CDATA[How to evaluate a RAG system with four measurements taken in pipeline order. A fault at any stage produces the same symptom (a fluent, wrong answer), so each stage is measured on its own.]]></summary></entry></feed>