Editing physical plans
You can inspect and edit a physical plan before executing it. This example inserts a barrier between a filter and a join, then shows how to add a Python function through the query builder.
The example runs on the CPU. It plans queries and prints their structure; it does not load model weights or run inference.
The full example is in
demos/plan_walkthrough.py.
Run the example
uv run python demos/plan_walkthrough.pyThe query
The example uses a small version of a FEVER query from QUAIL-B. In FEVER, each claim is a factual statement and each evidence passage is a Wikipedia paragraph. The goal is to find which passages support which claims.
The input contains three claims and three evidence passages. The query first keeps claims about a person. It then checks whether the evidence passages support those claims. A URL equality limits the pairs evaluated by the model.
The script creates a session and registers both tables. It then builds this query with the Python builder API:
from quail import col, prompt
PERSON = "Is the claim in DOCUMENT {0} about a person?"
SUPPORT = (
"Does the passage in DOCUMENT {1} support the claim in "
"DOCUMENT {0}?"
)
query = (
session.docs("claims").alias("c")
.ai_if(prompt(PERSON, col("c.claim")))
.join(
session.docs("evidence").alias("e"),
on=col("c.evidence_wiki_url") == col("e.id"),
)
.ai_if(prompt(SUPPORT, col("c.claim"), col("e.text")))
.select("c.id", "e.id")
)The .join(..., on=...) restricts which combinations of claims and
evidence the model evaluates.
Inspect the plan
Call query.explain() to see the plan without executing it. The
physical plan identifies each operator and its estimated work:
Time estimates assume peak GPU compute rates and memory bandwidth.
They exclude CPU work and kernel launch time. Use explain(analyze=True)
on a GPU to compare estimates with measured execution.
physical: backend=quail, model=qwen3-4b-fp8, workers=1
KV=bf16, chunk budget=110,376 tokens
est. rows est. pass est. time
Project: c.id, e.id
AiJoin: anchor=c 4.29 ms
KV: anchor=streamed from its filter, retain after join=no
join 1 full (c, e) over hash_join:c-e 0.6 20%*
AiFilter: c 0.6 2.39 ms
survivors stream into the join with KV pinned
predicate 1 3 20%*
Scan claims as c
tokens=306, mean_doc_tokens=102
Scan evidence as e
tokens=4,506, mean_doc_tokens=1,502
HashJoin: c.evidence_wiki_url = e.idThe line "survivors stream into the join with KV pinned" means a claim can enter the join as soon as it passes the filter. KV is the model's saved key and value vectors for the claim's tokens. The engine keeps that KV in GPU memory and reuses it when evaluating evidence passages.
Insert a barrier
Insert a Barrier to require the filter to finish over all claims before
the join starts. The barrier collects the passing claim IDs, then supplies
them to the join.
from quail.physical import Barrier
plan = query.plan()
edited = plan.insert(
Barrier(node_id="barrier:c", next_anchor="c", aliases=("c",)),
between=("ai_filter:c", "ai_join:c"),
)The barrier separates the filter and join into two pipelines. The edited
plan sets keep_kv=True on the filter so passing claims' KV can be retained
for the join. Retention is limited by available KV memory:
Barrier: next_anchor=c
AiFilter: c
retain KV for later joinsWe can remove the barrier and get back the original plan:
assert edited.remove("barrier:c") == planOnly Barrier, Foreign, and Exchange nodes can be removed. A
Foreign node holds a Python function, so its removal can change the
results. The removal of an LLM node raises an error:
PlanEditError: 'ai_filter:c' is a AiFilter; removing it would change
what the query meansPlan editing API
Start with query.plan() to inspect the plan without running inference.
If planning returns a Refusal, resolve the reported constraint before
editing it.
| Method | Effect |
|---|---|
plan.insert(node, between=(producer_id, consumer_id)) | Insert a node on an existing edge. Its output must match the edge's value type. |
plan.remove(node_id) | Remove an eligible node and reconnect its consumers to its producer. |
plan.move(node_id, between=(producer_id, consumer_id)) | Move an eligible node to another edge. |
query.run(plan=edited) | Execute the edited plan. |
Each edit returns a new plan, validates port connections and cycles, and
re-estimates the graph. An invalid edit raises PlanEditError.
Only Foreign, Barrier, and Exchange nodes can be removed. Quail
rejects removal of a scan, model operator, or filter. Removing a Foreign
node can change results if its Python function filters rows.
Insert a user function
With .apply(), we can run our own Python function between operators.
The full signature is:
query.apply(fn, columns=(), *, name=None, ids=None, kind="per_batch")fnreceives adict[str, pa.Table]keyed by alias. Each table has the alias's row indices plus the columns you listed incolumns.columnsis a list ofcol(...)references that your function needs to read.- After a
.join(),fnreturns a table with both alias columns (e.g.cande) selecting which combinations the next AI predicate evaluates. - Without a preceding
.join(),fnreturns the ids to keep. kind="per_batch"runs on each batch of rows in a pipeline. Outside a pipeline, it gets all input rows in one call.kind="barrier"waits for all survivors, then runs once.
For example, instead of an ON clause, we can match claims to
evidence with an Arrow join inside our function:
def match_by_url(tables):
"""Match each claim to the evidence page its URL names."""
return tables["c"].join(
tables["e"],
keys=["evidence_wiki_url"],
right_keys=["id"],
join_type="inner",
).select(["c", "e"])
query = (
session.docs("claims").alias("c")
.ai_if(prompt(PERSON, col("c.claim")))
.join(session.docs("evidence").alias("e"))
.apply(match_by_url, columns=[col("c.evidence_wiki_url"),
col("e.id")])
.ai_if(prompt(SUPPORT, col("c.claim"), col("e.text")))
.select("c.id", "e.id")
)The function determines which row pairs reach the model predicate.
Quail uses the returned c and e index columns to identify those pairs;
it does not inspect the function's internal logic.
In the physical plan, match_by_url appears as a Foreign node:
Foreign: match_by_url (per_batch, pairs) on c, e
columns: c.evidence_wiki_url, e.id