THURSDAY, SEPTEMBER 17, 2026|No. 15320
Artificial Intelligence · Databases

AI Model Achieves Significant Speedup in Database Query Optimization

A new experiment demonstrates that a 4-billion parameter language model, through fine-tuning and reinforcement learning, can generate database query plans that are substantially faster than those produced by traditional systems like PostgreSQL.

A visual representation of data flowing through a complex network, symbolizing database optimization.
A visual representation of data flowing through a complex network, symbolizing database optimization. · Photo by GuerrillaBuzz on Unsplash
1 sources
Pipeline ingest
3 reads
Positive / Neutral / Negative
0 countries
Related coverage

A reinforcement-learning policy update

Four RL rollouts commence for a single query. Qwen produces a candidate strategy per rollout and sends it off to Postgres for measurement against its own default plan. Scalar rewards are assigned to each rollout, which flow backwards to update Qwen's weights.

Weights nudged toward the faster plans.

SELECT count(*) FROM title t JOIN movie_companies mc ON mc.movie_id = t.id JOIN company_name cn ON cn.id = mc.company_id WHERE cn.name = 'Toho' /*+ HashJoin(mc cn) */ 96 ms 118 ms

default +0.21 /*+ Leading((cn mc) t) */ 74 ms 118 ms

default +0.47 /*+ NestLoop(t mc) */ 163 ms 118 ms

default -0.32 /*+ IndexScan(t) / 131 ms 118 ms default -0.10 SELECT count() FROM title t JOIN movie_companies mc ON mc.movie_id = t.id JOIN company_name cn ON cn.id = mc.company_id WHERE cn.name = 'Toho' /*+ HashJoin(mc cn) */ 96 ms 118 ms +0.21 /*+ Leading((cn mc) t) */ 74 ms 118 ms +0.47 /*+ NestLoop(t mc) */ 163 ms 118 ms -0.32 /*+ IndexScan(t) */ 131 ms 118 ms -0.10

How good are query optimizers, really?

Leis et al. asked this exact question in 2015. Then, they asked it again 10 years later.

Despite an enormous body of research spanning a decade since their original exploration, they found that query optimizers continue to leave much to be desired.

I was surprised when I first learned about this. A Postgres database should know everything about the stuff that lives in its tables, no? How hard can it be?

As it turns out: enormously hard. In fact, one particular task a query optimizer needs to do, join ordering, is known to be NP-hard.

So query optimizers are hard. What’s not as hard is verifying whether a query plan an optimizer picks is good or not. Put simply, a good query optimizer produces plans that run fast, and a bad one produces slow plans. Language models are particularly good at learning how to do tasks with easily verifiable outputs. Because there’s a single axis to optimize for—execution time of a query—the problem beautifully reduces to reinforcing the behaviors that guide a model to produce faster query plans.

What follows is a breakdown of an experiment I ran to explore the question: can a small, open-weights model be post-trained via supervised fine-tuning (SFT) and agentic reinforcement learning (RL) to produce Postgres query plans that beat Postgres’s default plans?

The answer to our question is a resounding yes. Highlights include:

  • Attaining a 44.7% latency reduction across 113 join-heavy queries from a 4B model initially unable to produce a query plan for 99 of them
  • Constructing a Postgres measurement rig that minimizes Linux page cache contention noise across concurrent containers
  • Designing a custom GRPO variant for scoring RL rollouts in an inherently noisy environment
  • Splitting RL across two machines: vLLM and the trainer on a rented 2x H100 node and four Postgres containers running on my desk
  • Running off-policy distillation across half a thousand GPT-6 Astra agent trajectories

Let’s start from the beginning.

Inside a query optimizer

Consider the following slice of the IMDb dataset:

-- An IMDb title (movie, series, episode, etc.) [~1M rows]
title (
 id integer PRIMARY KEY,
 title text,
 production_year integer,
 kind_id integer -- FK -> kind_type
)

-- Movie company junction table [~2M rows]
movie_companies (
 id integer PRIMARY KEY,
 movie_id integer, -- FK -> title.id
 company_id integer, -- FK -> company_name.id
 company_type_id integer, -- FK -> company_type.id
 note text
)

-- A company's name, origin, etc. [~100k rows]
company_name (
 id integer PRIMARY KEY,
 name text,
 country_code text -- '[us]', '[jp]', ...
)

-- Lookup table of company roles for a title [4 rows]
company_type (
 id integer PRIMARY KEY,
 kind text -- 'production companies', 'distributors', ...
)

-- Lookup table for what a title _is_ [7 rows]
kind_type (
 id integer PRIMARY KEY,
 kind text -- 'movie', 'tv series', 'episode', ...
)

Let’s say I’m trying to answer the question: “Which Japanese companies put out the most titles in the 2000s?” We might write the following query:

SELECT cn.name,
 COUNT(*) AS titles
FROM title AS t,
 movie_companies AS mc,
 company_name AS cn
WHERE t.id = mc.movie_id
 AND mc.company_id = cn.id
 AND cn.country_code = '[jp]'
 AND t.production_year BETWEEN 2000 AND 2009
GROUP BY cn.name
ORDER BY titles DESC
LIMIT 10;

Running this query outputs 10 Japanese companies with the number of titles they were associated with between 2000 and 2009, sorted from highest to lowest.

But how did Postgres get these results?

The path Postgres took to get this data for us is not a foregone conclusion, and it has everything to do with what we call selective predicates (i.e. the filtering conditions in a WHERE clause).

To illustrate this, let’s imagine our same query without the Japanese company filter or the date range filter:

SELECT cn.name,
 COUNT(*) AS titles
FROM title AS t,
 movie_companies AS mc,
 company_name AS cn
WHERE t.id = mc.movie_id
 AND mc.company_id = cn.id
GROUP BY cn.name
ORDER BY titles DESC
LIMIT 10;

mc can only join with cn via mc.company_id = cn.id, and t can only join with mc via t.id = mc.movie_id.

These constraints produce twoRead annotation: There are technically eight join trees if we take commutativity into account. In this case, we don’t because it doesn’t affect the size of the relations resulting from the joins.Back to annotation reference There are technically eight join trees if we take commutativity into account. In this case, we don’t because it doesn’t affect the size of the relations resulting from the joins. valid join trees:

⋈ Join of (company_name ⋈ movie_companies) with title ⋈ Join of company_name with movie_companies t title relation cn company_name relation mc movie_companies relation (cn ⋈ mc) ⋈ t ⋈ Join of (title ⋈ movie_companies) with company_name ⋈ Join of title with movie_companies cn company_name relation t title relation mc movie_companies relation (t ⋈ mc) ⋈ cn

The two join trees for our query. The lower join runs first; the result is an input into the root join.

The cardinality of a table or query result is the number of rows it contains. Assume the relevant tables have the following cardinalities:

  1. cn=100kcn = 100\text{k}cn=100k
  2. mc=2mmc = 2\text{m}mc=2m
  3. t=1mt = 1\text{m}t=1m

Taking into account our joins, we get the following cardinalities:

(cn⋈mc)=2m, then ⋈t=2m(cn \bowtie mc) = 2\text{m}, \text{ then } \bowtie t = 2\text{m}(cn⋈mc)=2m, then ⋈t=2m(t⋈mc)=2m, then ⋈cn=2m(t \bowtie mc) = 2\text{m}, \text{ then } \bowtie cn = 2\text{m}(t⋈mc)=2m, then ⋈cn=2m

Regardless of the order in which these three tables are joined, the same 2m rows are always passed into the second join.

Now let’s add back our selective predicates:

  1. cn′=5kcn' = 5\text{k}cn′=5k (assuming 5% of our 100k companies are Japanese)
  2. mc=2mmc = 2\text{m}mc=2m (does not change)
  3. t′=200kt' = 200\text{k}t′=200k (assuming 20% of our 1m titles were made in the 2000s)

(cn′⋈mc)≈100k, then ⋈t′≈20k(cn' \bowtie mc) \approx 100\text{k}, \text{ then } \bowtie ' \approx 20\text{k}(cn′⋈mc)≈100k, then ⋈t′≈20k(t′⋈mc)≈400k, then ⋈cn′≈20k(t' \bowtie mc) \approx 400\text{k}, \text{ then } \bowtie cn' \approx 20\text{k}(t′⋈mc)≈400k, then ⋈cn′≈20k

The first join ordering filters the 2m movie_companies entries down to the 5% slice of companies that are Japanese. Assuming uniform distribution (we’ll discuss later why we assume this), this join results in approximately 100k rows. Joining the result with the filtered title table keeps only the 20% of those rows from the 2000s.

The second join ordering filters the 2m movie_companies entries down to the 20% slice of titles that were made in the 2000s. The same uniformity assumption holds, so the first join results in 400k rows, meaning we’re passing 400k rows into the second join.

We do 4x the work if we picked the second join ordering.

Unfortunately, it doesn’t stop there.

A combinatorial explosion

Each join can use any of:

  1. Hash join
  2. Merge join
  3. Nested-loop join

Factoring commutativity back in nowRead annotation: While commutativity doesn’t change the number of rows produced, it must be considered now because it does affect performance regarding the join algorithm used.Back to annotation reference While commutativity doesn’t change the number of rows produced, it must be considered now because it does affect performance regarding the join algorithm used., there are 4 different outer/inner join orientations, resulting in 8 possible combinations:

(cn⋈mc)⋈t(cn \bowtie mc) \bowtie t(cn⋈mc)⋈t

t⋈(cn⋈mc)t \bowtie (cn \bowtie mc)t⋈(cn⋈mc)

(mc⋈cn)⋈t(mc \bowtie cn) \bowtie t(mc⋈cn)⋈t

t⋈(mc⋈cn)t \bowtie (mc \bowtie cn)t⋈(mc⋈cn)

(t⋈mc)⋈cn(t \bowtie mc) \bowtie cn(t⋈mc)⋈cn

cn⋈(t⋈mc)cn \bowtie (t \bowtie mc)cn⋈(t⋈mc)

(mc⋈t)⋈cn(mc \bowtie t) \bowtie cn(mc⋈t)⋈cn

cn⋈(mc⋈t)cn \bowtie (mc \bowtie t)cn⋈(mc⋈t)

Lastly, each table can be scanned in different ways. Considering just four types of scans:

  1. Sequential
  2. Index
  3. Index-only
  4. Bitmap

2 Join trees: which pair of tables joins first. ×22 Orientations: each of the 2 joins can swap which input is outer and which is inner. ×32 Algorithms: each of the 2 joins picks hash, merge, or nested loop. ×43 Scans: each of the 3 tables is either read sequentially or via index, index-only or bitmap scans. =4,608

There are 4,608 different ways to run this queryRead annotation: This is actually an undercount. Plans can run in parallel, aggregates can be hashed or sorted, etc.It’s also worth noting that Postgres doesn’t evaluate all of these plans. It uses dynamic programming (and a genetic algorithm for queries involving 12+ joins) to prune the search space.Back to annotation reference This is actually an undercount. Plans can run in parallel, aggregates can be hashed or sorted, etc.

It’s also worth noting that Postgres doesn’t evaluate all of these plans. It uses dynamic programming (and a genetic algorithm for queries involving 12+ joins) to prune the search space.!

To make matters worse, every join combinatorially explodes the search space:

SELECT cn.name,
 COUNT(*) AS titles
FROM movie_companies AS mc,
 company_name AS cn
WHERE mc.company_id = cn.id
 AND cn.country_code = '[jp]'
GROUP BY cn.name
ORDER BY titles DESC
LIMIT 10;

1 Join trees: with two tables there is only one way to join them. ×21 Orientation: 1 join means there are only 2 orientations. ×31 Algorithm: the join algorithm can be a hash join, merge join or nested loop. ×42 Scans: each of the 2 tables is either read sequentially or via index, index-only or bitmap scans. =96

SELECT cn.name,
 COUNT(*) AS titles
FROM title AS t,
 movie_companies AS mc,
 company_name AS cn
WHERE t.id = mc.movie_id
 AND mc.company_id = cn.id
 AND cn.country_code = '[jp]'
 AND t.production_year BETWEEN 2000 AND 2009
GROUP BY cn.name
ORDER BY titles DESC
LIMIT 10;

2 Join trees: the ways 3 tables can be joined up, before any swapping of inputs. ×22 Orientations: each of the 2 joins can swap which input is outer and which is inner. ×32 Algorithms: each of the 2 joins picks hash, merge, or nested loop. ×43 Scans: each of the 3 tables is either read sequentially or via index, index-only or bitmap scans. =4,608

SELECT MIN(t.title) AS movie_title
FROM keyword AS k,
 movie_info AS mi,
 movie_keyword AS mk,
 title AS t
WHERE k.keyword LIKE '%sequel%'
 AND mi.info IN ('Bulgaria')
 AND t.production_year > 2010
 AND t.id = mi.movie_id
 AND t.id = mk.movie_id
 AND mk.movie_id = mi.movie_id
 AND k.id = mk.keyword_id;

8 Join trees: the ways 4 tables can be joined up, before any swapping of inputs. ×23 Orientations: each of the 3 joins can swap which input is outer and which is inner. ×33 Algorithms: each of the 3 joins picks hash, merge, or nested loop. ×44 Scans: each of the 4 tables is either read sequentially or via index, index-only or bitmap scans. =442,368

SELECT MIN(t.title) AS movie_title
FROM company_name AS cn,
 keyword AS k,
 movie_companies AS mc,
 movie_keyword AS mk,
 title AS t
WHERE cn.country_code ='[de]'
 AND k.keyword ='character-name-in-title'
 AND cn.id = mc.company_id
 AND mc.movie_id = t.id
 AND t.id = mk.movie_id
 AND mk.keyword_id = k.id
 AND mc.movie_id = mk.movie_id;

25 Join trees: the ways 5 tables can be joined up, before any swapping of inputs. ×24 Orientations: each of the 4 joins can swap which input is outer and which is inner. ×34 Algorithms: each of the 4 joins picks hash, merge, or nested loop. ×45 Scans: each of the 5 tables is either read sequentially or via index, index-only or bitmap scans. =33,177,600

SELECT MIN(lt.link) AS link_type,
 MIN(t1.title) AS first_movie,
 MIN(t2.title) AS second_movie
FROM keyword AS k,
 link_type AS lt,
 movie_keyword AS mk,
 movie_link AS ml,
 title AS t1,
 title AS t2
WHERE k.keyword ='10,000-mile-club'
 AND mk.keyword_id = k.id
 AND t1.id = mk.movie_id
 AND ml.movie_id = t1.id
 AND ml.linked_movie_id = t2.id
 AND lt.id = ml.link_type_id
 AND mk.movie_id = t1.id;

56 Join trees: the ways 6 tables can be joined up, before any swapping of inputs. ×25 Orientations: each of the 5 joins can swap which input is outer and which is inner. ×35 Algorithms: each of the 5 joins picks hash, merge, or nested loop. ×46 Scans: each of the 6 tables is either read sequentially or via index, index-only or bitmap scans. =1,783,627,776

SELECT MIN(a1.name) AS writer_pseudo_name,
 MIN(t.title) AS movie_title
FROM aka_name AS a1,
 cast_info AS ci,
 company_name AS cn,
 movie_companies AS mc,
 name AS n1,
 role_type AS rt,
 title AS t
WHERE cn.country_code ='[us]'
 AND rt.role ='writer'
 AND a1.person_id = n1.id
 AND n1.id = ci.person_id
 AND ci.movie_id = t.id
 AND t.id = mc.movie_id
 AND mc.company_id = cn.id
 AND ci.role_id = rt.id
 AND a1.person_id = ci.person_id
 AND ci.movie_id = mc.movie_id;

696 Join trees: the ways 7 tables can be joined up, before any swapping of inputs. ×26 Orientations: each of the 6 joins can swap which input is outer and which is inner. ×36 Algorithms: each of the 6 joins picks hash, merge, or nested loop. ×47 Scans: each of the 7 tables is either read sequentially or via index, index-only or bitmap scans. =532,030,685,184

SELECT MIN(an.name) AS cool_actor_pseudonym,
 MIN(t.title) AS series_named_after_char
FROM aka_name AS an,
 cast_info AS ci,
 company_name AS cn,
 keyword AS k,
 movie_companies AS mc,
 movie_keyword AS mk,
 name AS n,
 title AS t
WHERE cn.country_code ='[us]'
 AND k.keyword ='character-name-in-title'
 AND an.person_id = n.id
 AND n.id = ci.person_id
 AND ci.movie_id = t.id
 AND t.id = mk.movie_id
 AND mk.keyword_id = k.id
 AND t.id = mc.movie_id
 AND mc.company_id = cn.id
 AND an.person_id = ci.person_id
 AND ci.movie_id = mc.movie_id
 AND ci.movie_id = mk.movie_id
 AND mc.movie_id = mk.movie_id;

4,698 Join trees: the ways 8 tables can be joined up, before any swapping of inputs. ×27 Orientations: each of the 7 joins can swap which input is outer and which is inner. ×37 Algorithms: each of the 7 joins picks hash, merge, or nested loop. ×48 Scans: each of the 8 tables is either read sequentially or via index, index-only or bitmap scans. =86,188,970,999,808

SELECT MIN(cn.name) AS producing_company,
 MIN(miidx.info) AS rating,
 MIN(t.title) AS movie
FROM company_name AS cn,
 company_type AS ct,
 info_type AS it,
 info_type AS it2,
 kind_type AS kt,
 movie_companies AS mc,
 movie_info AS mi,
 movie_info_idx AS miidx,
 title AS t
WHERE cn.country_code ='[us]'
 AND ct.kind ='production companies'
 AND it.info ='rating'
 AND it2.info ='release dates'
 AND kt.kind ='movie'
 AND mi.movie_id = t.id
 AND it2.id = mi.info_type_id
 AND kt.id = t.kind_id
 AND mc.movie_id = t.id
 AND cn.id = mc.company_id
 AND ct.id = mc.company_type_id
 AND miidx.movie_id = t.id
 AND it.id = miidx.info_type_id
 AND mi.movie_id = miidx.movie_id
 AND mi.movie_id = mc.movie_id
 AND miidx.movie_id = mc.movie_id;

20,340 Join trees: the ways 9 tables can be joined up, before any swapping of inputs. ×28 Orientations: each of the 8 joins can swap which input is outer and which is inner. ×38 Algorithms: each of the 8 joins picks hash, merge, or nested loop. ×49 Scans: each of the 9 tables is either read sequentially or via index, index-only or bitmap scans. =8,955,727,561,359,360

SELECT MIN(n.name) AS voicing_actress,
 MIN(t.title) AS jap_engl_voiced_movie
FROM aka_name AS an,
 char_name AS chn,
 cast_info AS ci,
 company_name AS cn,
 info_type AS it,
 movie_companies AS mc,
 movie_info AS mi,
 name AS n,
 role_type AS rt,
 title AS t
WHERE ci.note IN ('(voice)',
 '(voice: Japanese version)',
 '(voice) (uncredited)',
 '(voice: English version)')
 AND cn.country_code ='[us]'
 AND it.info = 'release dates'
 AND n.gender ='f'
 AND rt.role ='actress'
 AND t.production_year > 2000
 AND t.id = mi.movie_id
 AND t.id = mc.movie_id
 AND t.id = ci.movie_id
 AND mc.movie_id = ci.movie_id
 AND mc.movie_id = mi.movie_id
 AND mi.movie_id = ci.movie_id
 AND cn.id = mc.company_id
 AND it.id = mi.info_type_id
 AND n.id = ci.person_id
 AND rt.id = ci.role_id
 AND n.id = an.person_id
 AND ci.person_id = an.person_id
 AND chn.id = ci.person_role_id;

242,160 Join trees: the ways 10 tables can be joined up, before any swapping of inputs. ×29 Orientations: each of the 9 joins can swap which input is outer and which is inner. ×39 Algorithms: each of the 9 joins picks hash, merge, or nested loop. ×410 Scans: each of the 10 tables is either read sequentially or via index, index-only or bitmap scans. =2,558,960,455,762,575,360

SELECT MIN(kt.kind) AS movie_kind,
 MIN(t.title) AS complete_us_internet_movie
FROM complete_cast AS cc,
 comp_cast_type AS cct1,
 company_name AS cn,
 company_type AS ct,
 info_type AS it1,
 keyword AS k,
 kind_type AS kt,
 movie_companies AS mc,
 movie_info AS mi,
 movie_keyword AS mk,
 title AS t
WHERE cct1.kind = 'complete+verified'
 AND cn.country_code = '[us]'
 AND it1.info = 'release dates'
 AND kt.kind IN ('movie')
 AND mi.note LIKE '%internet%'
 AND mi.info IS NOT NULL
 AND (mi.info LIKE 'USA:% 199%'
 OR mi.info LIKE 'USA:% 200%')
 AND t.production_year > 2000
 AND kt.id = t.kind_id
 AND t.id = mi.movie_id
 AND t.id = mk.movie_id
 AND t.id = mc.movie_id
 AND t.id = cc.movie_id
 AND mk.movie_id = mi.movie_id
 AND mk.movie_id = mc.movie_id
 AND mk.movie_id = cc.movie_id
 AND mi.movie_id = mc.movie_id
 AND mi.movie_id = cc.movie_id
 AND mc.movie_id = cc.movie_id
 AND k.id = mk.keyword_id
 AND it1.id = mi.info_type_id
 AND cn.id = mc.company_id
 AND ct.id = mc.company_type_id
 AND cct1.id = cc.status_id;

1,490,850 Join trees: the ways 11 tables can be joined up, before any swapping of inputs. ×210 Orientations: each of the 10 joins can swap which input is outer and which is inner. ×310 Algorithms: each of the 10 joins picks hash, merge, or nested loop. ×411 Scans: each of the 11 tables is either read sequentially or via index, index-only or bitmap scans. =378,099,722,048,923,238,400

SELECT MIN(chn.name) AS character_name,
 MIN(mi_idx.info) AS rating,
 MIN(t.title) AS complete_hero_movie
FROM complete_cast AS cc,
 comp_cast_type AS cct1,
 comp_cast_type AS cct2,
 char_name AS chn,
 cast_info AS ci,
 info_type AS it2,
 keyword AS k,
 kind_type AS kt,
 movie_info_idx AS mi_idx,
 movie_keyword AS mk,
 name AS n,
 title AS t
WHERE cct1.kind = 'cast'
 AND cct2.kind LIKE '%complete%'
 AND chn.name IS NOT NULL
 AND (chn.name LIKE '%man%'
 OR chn.name LIKE '%Man%')
 AND it2.info = 'rating'
 AND k.keyword IN ('superhero',
 'marvel-comics',
 'based-on-comic',
 'fight')
 AND kt.kind = 'movie'
 AND mi_idx.info > '8.0'
 AND t.production_year > 2005
 AND kt.id = t.kind_id
 AND t.id = mk.movie_id
 AND t.id = ci.movie_id
 AND t.id = cc.movie_id
 AND t.id = mi_idx.movie_id
 AND mk.movie_id = ci.movie_id
 AND mk.movie_id = cc.movie_id
 AND mk.movie_id = mi_idx.movie_id
 AND ci.movie_id = cc.movie_id
 AND ci.movie_id = mi_idx.movie_id
 AND cc.movie_id = mi_idx.movie_id
 AND chn.id = ci.person_role_id
 AND n.id = ci.person_id
 AND k.id = mk.keyword_id
 AND cct1.id = cc.subject_id
 AND cct2.id = cc.status_id
 AND it2.id = mi_idx.info_type_id;

11,932,560 Join trees: the ways 12 tables can be joined up, before any swapping of inputs. ×211 Orientations: each of the 11 joins can swap which input is outer and which is inner. ×311 Algorithms: each of the 11 joins picks hash, merge, or nested loop. ×412 Scans: each of the 12 tables is either read sequentially or via index, index-only or bitmap scans. =72,630,206,166,931,876,085,760 lots!

2 3 4 5 6 7 8 9 10 11 12

Click to select the number of tables being joined together. From four tables onwards, queries on the left are from JOB. On the right is a rough estimate of the size of the search space.

Estimating, not counting

Postgres is in a tough spot here. It would be reasonable to think it could simply count cardinalities and pick the plan that minimizes the number of rows passed through to successive joins.

But this would imply Postgres can count cardinalities during query planning. It can’t. In order to know this, it would need to actually run each join and count the resulting rows. This defeats the whole point of a fast query optimizer. A query optimizer does not aim to be exact in its cost minimization… it aims to be good enough across many types of queries.

Instead, Postgres uses statistics to estimate cardinalities. The planner queries the pg_statistic table, getting back common values for each column and their frequencies, and a histogram for the rest. Things get a bit more complicated when you tack on joins. Postgres doesn’t know how the rows in one table are distributed over the other. To get around this, it assumes that the frequency of a given value in the first table can simply be applied over the second table. This is the uniform distribution assumption I mentioned earlier.

Assuming a uniform distribution is fine as a heuristic, but when it fails, it fails hard. Looking back at an earlier join ordering (cn′⋈mc)≈100k, then ⋈t′≈20k(cn' \bowtie mc) \approx 100\text{k}, \text{ then } \bowtie ' \approx 20\text{k}(cn′⋈mc)≈100k, then ⋈t′≈20k, we filtered 2m movie_companies entries on the assumption that 5% of them were from Japanese companies. But what if the 5% of companies that are Japanese were actually responsible for 50% of the movies? The first join would produce 1m rows! The cost model says pick the first join ordering; in reality, the second one is actually better since it only sends 400k rows through to the second join.

Postgres assumption- 100k rowsActual- 100k rowsLess than the 400k rows in ordering #2 ✓ - :)

⋈ 20k ⋈ 100k t′ 200k cn′ 5k mc 2m (cn′ ⋈ mc) ⋈ t′ ⋈ 20k ⋈ 400k cn′ 5k t′ 200k mc 2m (t′ ⋈ mc) ⋈ cn′

Share of movie_companies rows that belong to Japanese companies: 5%(uniform)

Drag the slider to make Japanese companies more productive. Notice how Postgres’s estimate is static while actual row counts get affected.

One bad estimate in an early join can cascade through the rest of the join tree, corrupting all other estimates.

How to steer an elephant

Postgres always picks the plan with the lowest cost, and we can’t change its cost model without modifying its source code, so how can we actually steer it to pick different plans that have higher costs?

Enter pg_hint_plan.

pg_hint_plan is a beautifully simple third-party extension: just by adding structured “hints” as comments above SQL statements, you can nudge Postgres towards plans that use the instructions provided in the hint. For example:

/*+The hint block. It is an ordinary SQL comment with a leading +, so Postgres ignores it and pg_hint_plan reads it. HashJoin(a b)Join a and b using a hash join. SeqScan(a)Read table a with a sequential scan rather than an index.*/EXPLAINEXPLAIN prints the plan Postgres would use instead of running the query. SELECT * FROM pgbench_branches b JOIN pgbench_accounts a ON b.bid = a.bid ORDER BY a.aid;
 QUERY PLAN--------------------------------------------------------------------------------- SortThe root of the plan. Rows flow upward, so this runs last: it orders the joined rows by a.aid. (cost=31465.84..31715.84 rows=100000 width=197)Postgres’s estimates for this node: startup cost..total cost, estimated rows out, and average row width in bytes. Sort Key: a.aid -> Hash JoinThe join method the HashJoin(a b) hint asked for. (cost=1.02..4016.02 rows=100000 width=197) Hash Cond: (a.bid = b.bid)The join condition, taken from the ON clause. -> Seq Scan on pgbench_accounts aThe scan method the SeqScan(a) hint asked for. This is the probe side: each row looks up a match in the hash table. (cost=0.00..2640.00 rows=100000 width=97) -> HashThe build side. The small table is read first and loaded into an in-memory hash table keyed on bid. (cost=1.01..1.01 rows=1 width=100) -> Seq Scan on pgbench_branches b (cost=0.00..1.01 rows=1 width=100)(7 rows)

Example from pg_hint_plan’s documentation.

The hint mandates usage of a HashJoin for joining pgbench_accounts and pgbench_branches, and doing a sequential scan of the pgbench_accounts table; the actual query plan follows suit nicely.

Formulating our problem

Given that we can influence Postgres to pick different—and potentially better—query plans using pg_hint_plan hints, the question we’re starting with is:

Can a language model learn to produce hints that result in better query plans?

Useful research

What might make this a worthwhile problem to solve?

My first idea was to give the model the query and the exact same set of information Postgres’s planner has. This amounts to seeing if we could build a better cardinality estimator. I came to the conclusion this is not a worthwhile avenue to explore; we would be fighting decades of cardinality estimation research. Furthermore, the inference latency alone would far outweigh any learned usefulness compared to Postgres’s ultra-fast query optimizer.

The second idea—and what I believe is the correct formulation—lies in a specific database usage pattern: heavy analytic workloads. If queries are getting run thousands of times using sub-optimal default Postgres plans, efficiency gains are being left on the table. Instead, a model could be trained to find a better way to run a specific query. The training process might require execution of that query tens to hundreds of times upfront, but the amortized cost across all runs of the query would be drastically lower.

The goal isn’t to try and beat Postgres on the time/efficiency Pareto frontier for one-off queries, but we may be able to beat it on queries that run over and over again.

A model and its harness

I decided to start with a small 4B model because it would be easiest to train/inference myself on the 2x RTX 3090 rig (affectionately named FLOPper) I have at home.

Around the time I started this project, the Qwen 3.8 family of models was released, unfortunately without a 4B variant. However, I came across a Qwen 3.8 4B distillation from a small lab in Germany called Empero and was intrigued. They used Qwen 3.8’s 2.4T model as a teacher model to distill learnings into Qwen 3.5 4B, producing empero-ai/Qwen3.8-4B-Distill. This distilled model is not outright better than its base 3.5 model; it performs better on MMLU tasks and slightly worse on GSM8K tasks. In other words, this distillation performs better when evaluated on breadth of general knowledge, and slightly worse on multi-step mathematical reasoning. As to which is better for our task, I do not know; I decided to stick with the distilled model either way.

With the model locked in, I built a lightweight agent harness, qo-agent, that would orchestrate hint production. It was given the following six tools:

  1. inspect_relation — Lists a table’s columns with types and nullability, index definitions and estimated rows and bytes
  2. get_column_stats — Gets Postgres planner statistics for 1-8 columns of a relation
  3. get_plan — Gets the default plan’s estimates or a submitted candidate’s stored plan
  4. evaluate_candidate — Validates a proposed plan action and then executes it for timing/plan diagnostics
  5. keep_default — Returns Postgres’s default plan itself as the candidate and ends the search
  6. finish — Takes as input a submitted candidate ID or the default plan and ends the search

To take advantage of structured outputs, the agent was instructed to produce PlanAction JSON objects. Calls to evaluate_candidate then compiled PlanAction objects into hints and prepended them to the original query.

A sample agent trajectory:

Agent →A tool call made by the model. get_plan("default")Ask the harness for the plan Postgres picks on its own. This returns the planner’s estimates only; nothing is executed.Tool ←The harness’s reply, fed back into the model’s context. Default plan: (t ⋈ mc) ⋈ cn, hash joins, estimated rows per node Agent → evaluate_candidate({Propose one PlanAction. The harness validates it with a plain EXPLAIN, compiles it to hints, and, with execution feedback on, runs the query once after a warmup and reports the timing. "leading": { "left": { "left": "cn", "right": "mc" }, "right": "t" },The join tree: join cn with mc first, then join the result with t. This is the first tree from the figure earlier. "joins": [{ "relations": ["cn", "mc"], "force": "hash" }]Force a hash join for the cn ⋈ mc step. })Tool ← c1The id the harness assigns to this candidate. get_plan and finish refer to candidates by id. · valid · novel plan · 118 ms · 0.91× defaultExecution feedback: the candidate’s measured time and its preliminary ratio to the default’s median. Below 1 means slower than Postgres. · 2 attempts leftEvery submission, valid or not, consumes one of the rollout’s candidate attempts. Agent → evaluate_candidate({ "leading": { "left": { "left": "t", "right": "mc" }, "right": "cn" },Try the other tree: t with mc first, then cn. "joins": [{ "relations": ["t", "mc"], "force": "nestloop" }],A nested loop for the first join, so the filtered t rows probe mc one at a time. "scans": [{ "relation": "mc", "force": "index" }]Make the inner side of that loop an index scan on mc. })Tool ← c2 · valid · novel plan · 87 ms · 1.24× defaultFaster than the default: a ratio above 1 is a speedup. · 1 attempt left Agent → evaluate_candidate({ "leading": { "left": { "left": "t", "right": "mc" }, "right": "cn" }, "joins": [{ "relations": ["t", "mc"], "force": "hash" }]Same tree as c2, but a hash join for the first step, to see whether the nested loop was the win. })Tool ← c3 · valid · novel plan · 100 ms · 1.08× default · 0 attempts leftThe candidate budget is spent. From here the only tool the model is offered is finish. Agent → finish({ "selected_candidate_id": "c2" })End the search by naming a candidate, including an earlier one. The harness then measures it against the default with the final paired protocol.Tool ← Finished · selected c2

A sample trajectory where the agent is permitted to submit up to three candidates.

Benchmarks

An agent is useless without something to benchmark its performance against. Fortunately for us, the hard work of creating these benchmarks was already done.

The Join Order Benchmark

Leis et al. introduced the Join Order Benchmark (JOB) in How Good Are Query Optimizers, Really?. They used it to evaluate cardinality estimation and join-order optimization using our familiar IMDb dataset.

It consists of 113 queries spread across 33 query templates. Query templates differ via their relational skeleton. They reference different tables and connect them with different join predicates. You can think about them as a structural family of questions that can be answered. Queries derived from templates preserve the tables used and the join graph topology but change selection predicates.

Looking at an example:

SELECT MIN(t.title) AS movie_title
FROM company_name AS cn
JOIN movie_companies AS mc ON mc.company_id = cn.id
JOIN title AS t ON t.id = mc.movie_id
JOIN movie_keyword AS mk ON mk.movie_id = t.id
JOIN keyword AS k ON k.id = mk.keyword_id
WHERE cn.country_code = :country_code
 AND k.keyword = 'character-name-in-title';

Query template 2 — “What is the alphabetically first title of a movie associated with a company from country X and tagged with the keyword character-name-in-title?”

…and here are two real queries from JOB derived from this template:

SELECT MIN(t.title) AS movie_title
FROM company_name AS cn,
 keyword AS k,
 movie_companies AS mc,
 movie_keyword AS mk,
 title AS t
WHERE cn.country_code = '[de]'
 AND k.keyword = 'character-name-in-title'
 AND cn.id = mc.company_id
 AND mc.movie_id = t.id
 AND t.id = mk.movie_id
 AND mk.keyword_id = k.id
 AND mc.movie_id = mk.movie_id;

Query 2a — “What is the alphabetically first such movie title associated with a German company?”

SELECT MIN(t.title) AS movie_title
FROM company_name AS cn,
 keyword AS k,
 movie_companies AS mc,
 movie_keyword AS mk,
 title AS t
WHERE cn.country_code = '[us]'
 AND k.keyword = 'character-name-in-title'
 AND cn.id = mc.company_id
 AND mc.movie_id = t.id
 AND t.id = mk.movie_id
 AND mk.keyword_id = k.id
 AND mc.movie_id = mk.movie_id;

Query 2d — “What is the alphabetically first such movie title associated with a U.S. company?”

The Cardinality Estimation Benchmark

Another relevant benchmark is the Cardinality Estimation Benchmark (CEB), introduced in Flow-loss: Learning Cardinality Estimates That Matter. It uses the same IMDb database and is a much larger benchmark consisting of ~13.6k synthetically generated queries organized across 16 query templatesRead annotation: CEB’s definition of a template is looser than JOB's. Two CEB templates can share the same join graph, differing only in their selectivity predicates. In JOB, every template's join graph is unique.Back to annotation reference CEB’s definition of a template is looser than JOB's. Two CEB templates can share the same join graph, differing only in their selectivity predicates. In JOB, every template's join graph is unique..

Train time, test time

Due to its size, CEB was a good fit for training the model. JOB would be used to validate the model’s performance.

You might be wondering if it makes sense to both train and test on IMDb. If it works well, hasn’t the model just learned this specific database well?

I would argue this is precisely the point. We want our model to learn IMDb well. Given our problem formulation, if this agent is continually getting used for a company’s analytic workloads across its specific databases, we need not generalize to all databases.

The real issue is making sure we’re not overfitting to JOB query templates during training over CEB. The model should learn IMDb in a way where given any query, even for structural query families it hasn’t seen before, it’s still capable of producing a good plan. In practice, this means we need to prune CEB queries that have the same shape as any of the JOB queries.

Query topology mapping

Let’s define a query’s “topology” as its structural join-graph (de-aliased table names as nodes and joins as edges). The join graph excludes all selectivity predicates; we’re only interested in joins here.

CEB queries sharing a topology with a JOB query would be removed from the training set. I wrote a small script to convert all JOB and CEB queries to their topologies and checked if there was any overlap. There wasn’t, so no filtering was required.

JOB: 113 queries, 33 templates, 33 topologies

  • JOB 1 · 4 queries · 5 tables 1
  • JOB 2 · 4 queries · 5 tables 2
  • JOB 3 · 3 queries · 4 tables 3
  • JOB 4 · 3 queries · 5 tables 4
  • JOB 5 · 3 queries · 5 tables 5
  • JOB 6 · 6 queries · 5 tables 6
  • JOB 7 · 3 queries · 8 tables 7
  • JOB 8 · 4 queries · 7 tables 8
  • JOB 9 · 4 queries · 8 tables 9
  • JOB 10 · 3 queries · 7 tables 10
  • JOB 11 · 4 queries · 8 tables 11
  • JOB 12 · 3 queries · 8 tables 12
  • JOB 13 · 4 queries · 9 tables 13
  • JOB 14 · 3 queries · 8 tables 14
  • JOB 15 · 4 queries · 9 tables 15
  • JOB 16 · 4 queries · 8 tables 16
  • JOB 17 · 6 queries · 7 tables 17
  • JOB 18 · 3 queries · 7 tables 18
  • JOB 19 · 4 queries · 10 tables 19
  • JOB 20 · 3 queries · 10 tables 20
  • JOB 21 · 3 queries · 9 tables 21
  • JOB 22 · 4 queries · 11 tables 22
  • JOB 23 · 3 queries · 11 tables 23
  • JOB 24 · 2 queries · 12 tables 24
  • JOB 25 · 3 queries · 9 tables 25
  • JOB 26 · 3 queries · 12 tables 26
  • JOB 27 · 3 queries · 12 tables 27
  • JOB 28 · 3 queries · 14 tables 28
  • JOB 29 · 3 queries · 17 tables 29
  • JOB 30 · 3 queries · 12 tables 30
  • JOB 31 · 3 queries · 11 tables 31
  • JOB 32 · 2 queries · 6 tables 32
  • JOB 33 · 3 queries · 14 tables 33

CEB: 13,646 queries, 16 templates, 12 topologies

  • CEB 1a · 3,000 queries · 9 tables 1a
  • CEB 2a · 888 queries · 11 tables 2a
  • CEB 2b · 500 queries · 11 tables 2b
  • CEB 2c · 298 queries · 9 tables 2c
  • CEB 3a · 1,383 queries · 10 tables 3a
  • CEB 3b · 256 queries · 10 tables 3b
  • CEB 4a · 516 queries · 6 tables 4a
  • CEB 5a · 1,014 queries · 10 tables 5a
  • CEB 6a · 465 queries · 14 tables 6a
  • CEB 7a · 167 queries · 16 tables 7a
  • CEB 8a · 515 queries · 12 tables 8a
  • CEB 9a · 2,247 queries · 9 tables 9a
  • CEB 9b · 537 queries · 9 tables 9b
  • CEB 10a · 1,019 queries · 7 tables 10a
  • CEB 11a · 491 queries · 10 tables 11a
  • CEB 11b · 350 queries · 12 tables 11b

JOB and CEB templates displayed as an identicon of their topologies.

How to muffle an elephant

Before getting into benchmarking the agent and doing training runs, we have to talk about how Postgres was actually run, because it directly impacts the training process.

First, some facts:

  • FLOPper has a CPU with 16 physical cores, 64 GB of RAM and a 2 TB NVMe SSD
  • The slice of IMDb we’re using is 8.5 GB on disk
  • Postgres caches pages of data retrieved during query execution into a buffer
  • The operating system has its own filesystem cache doing the same thing one level down

If we run the exact same query on Postgres 20 times in a row, it won’t take the same amount of time each run. In day-to-day work, this isn’t a big deal. But the whole thesis, and the training process itself, relies on measuring whether one way of running a query is faster than the Postgres default. This means we need to do everything in our power to de-noise Postgres.

First, I needed to understand just how noisy Postgres query executions are.

I started by building a “calibration” capability into my experimentation workflow. The calibration process was simple: run NNN Docker containers built from a Postgres image, each given a fixed slice of CPU cores and RAM to use. I set N=4N = 4N=4 to begin; anything lower might make future training far too slow, and anything higher might lead to more CPU contention, which means more noise. Each container was given 4 cores to use and capped at 8 GB of memory. On startup, each container initialized Postgres with identical settings and loaded the IMDb data. Calibration then opened a thread pool of size four and pushed all 113 queries onto a shared queue. Whenever a container finished measuring a query, it pulled the next one off the queue.

The actual measurement process had two phases:

  1. Run the query a few times to “warm it up”
  2. Then run the query 20 more times and record each execution time

queue

job-01ajob-01cjob-01djob-01bjob-02ajob-02cjob-02bjob-02d

+105 more

  1. container 0—warmupmeasureidle
  2. container 1—warmupmeasureidle
  3. container 2—warmupmeasureidle
  4. container 3—warmupmeasureidle

0 / 113 queries measured

Four containers pull JOB queries off a shared queue, warm each one up until its buffer counters settle, then run it 20 times.

So what does it mean to warm a query up? We need to bust out some OS fundamentals to understand.

Whenever Postgres executes a query, it asks the operating system (in our case, Linux) for pages of data. Linux first checks its own filesystem cache, the page cache. If the pages are present, Linux sends them over; else it reads them from disk, stores them in its cache and then sends them over. Postgres, in turn, keeps received pages in its own shared_buffers cache for easy reuse. When shared_buffers begins to overflow, Postgres evicts pages. If it needs those pages again, it must ask Linux once more.

Every time there’s a cache hit in shared_buffers for a page, Postgres increments a counter called “shared hit blocks” (SHBs). If it has to ask Linux, it increments “shared read blocks” (SRBs).

Postgres conveniently reports both counters if we run EXPLAIN with the BUFFERS option. For example, running EXPLAIN (ANALYZE, TIMING OFF, BUFFERS, FORMAT JSON) outputs something like:

{
 "Plan": {
 "Node Type": "Aggregate",
 "Shared Hit Blocks": 1800786,
 "Shared Read Blocks": 52990,
 ...
 },
 "Execution Time": 189.2,
 ...
}

These counters give us some notion of the “warmness” of a query. After each warmup run, we compared its hit and read counts to the previous run’s. If both were within 2% of each other (and the plan hadn’t changed), we called the query warm and started measuring. A query needed at least two warmups to have something to compare, and was cut off at five regardless. The idea was that if the counters stopped moving, the data could be considered settled and cache churn would be minimized during the 20 measurements.

Query A runs

shared_buffers Postgres

page cache Linux

disk

Query A Query BShared hit blocks 0Shared read blocks 0

Query A fills shared_buffers via Linux calls. Query B requires different pages, evicting Query A pages in shared_buffers along the way. When A runs again, the evicted pages count as reads.

I set shared_buffers to a conservative 128 MB and ran the first calibration:

0% 25% 50% 75% 100%

2 warmups 50 queries

48 of 50 still reading

3 warmups 46 queries

26 of 46 still reading

4 warmups 4 queries

2 of 4 still reading

5, capped 13 queries

13 of 13 still reading

Still reading from Linux after warmup Fully resident in shared_buffers

All 113 JOB queries in the first calibration grouped by how many warmups they needed and placed by the share of their pages still read from Linux on every run afterwards.

Half the queries were declared warm after only two runs. Not bad… at least until I dug deeper. The SRB counts weren’t dropping to zero; rather, they were hovering steady at some large number. With only 128 MB of shared_buffers against an 8.5 GB database, Postgres was consistently missing its own cache on every execution and asking Linux for more pages. “Stable” did not mean “resident.”

Linux’s page cache is fast, so this isn’t the end of the world. Unfortunately, a new problem emerged when I actually looked at the 20 measurements taken for various queries. Let’s look at one query in particular, job-13b:

job-13b128 MB shared_buffers

run 1 run 5 run 10 run 15 run 20 14 runs · 186–204 ms 6 runs · 227–253 ms

180 200 220 240 260 ms

In the order they ranSorted

job-13b's 20 measured runs at 128 MB shared_buffers. Each dot is one run. Toggle between the two buttons to see the runs first in the order they ran, and then dropped onto the x-axis, where they pile into two clumps.

14 of the 20 landed between 186 and 204 ms. The other 6 landed between 227 and 253 ms, somewhere between 14% and 26% slower. The query wasn’t even uniformly noisy, it just had two different speeds at different times, and a third of the time it ran at the slower speed.

I initially wanted to quantify noise using the coefficient of variation:

CV=The mean of the 20 runs, in ms.xˉThe standard deviation of the 20 runs: how far a typical run sits from the mean, in ms.s​×100%

The CV tells us the “wobble” of a measurement. If a query takes 100 ms and has a CV of 5%, we could say it wobbles by about 5 ms. For job-13b, the CV was 10.3%. It wasn’t great. CV is also not a great measurement to use here. Because it’s built on the mean, it’s easily influenced by a few outlier runs.

We don’t actually care as much about how spread out the 20 runs are. We do care about how often this causes our measurement criteria during training runs to get fooled.

To fool an agent

Bear with me here as I skip ahead a little bit in order to provide more color on what exactly we needed to measure.

To de-noise during actual agent runs, I couldn’t just run the agent’s proposed plan a single time. Instead, I ran three interleaved (candidate, default) pairs sequentially. Three was picked somewhat arbitrarily to provide some measure of variability while being small enough to prevent agent evaluation runs from spending most of their time in Postgres. Once the three candidate/default execution time tuples were obtained, the medians of both the three candidates and the three defaults were taken and expressed as a ratio of each other to determine the final speedup or slowdown. If the two medians differed by less than an arbitrarily declared 5%, it was a tie. Outside of that tie zone, a candidate could be declared as a speedup or a slowdown.

Now let’s go back to our earlier job-13b example. We had 14 executions in one clump, and 6 in another slower clump. The median of three strategy sounds good until you realize that if, in theory, at least two of the three measurements landed in that “slower” clump, the median would bias towards the less frequent slower clump.

Imagine a candidate plan that executes identically to the default. No real difference exists, so the correct reward is zero. Draw three timings for the “candidate” and three for the “default” out of the 20 we observed. There are (203)=1,140\binom{20}{3} = 1{,}140(320​)=1,140 ways to draw three from 20; for job-13b, 230 of them contain at least two slow runs, so one side’s median lands in the slow clump ~20% of the time.

That’s a totally phantom 14-26% speedup or slowdown that we would show to our model as signal ~20% of the time. Dangerous!

job-13b128 MB shared_buffers · a no-op candidate (i.e. one that is identical to the default)

20 runs

candidate

default

180 200 220 240 260 ms

—drawing…

0 rounds · ties 0 · phantom wins 0 · phantom losses 0 · fooled 0%

A no-op candidate measured against itself. Every round draws three of job-13b's 20 runs for the candidate and three for the default, takes each side's median, and applies the 5% tie zone. Over every possible draw, the reward is fooled ~40% of the time.

So we can’t just rely on CV as the golden number to minimize, as two queries with the exact same CV can fool the measurement reward at different rates depending on whether the spreads are a uniform blur or two clumps sitting more than 5% apart. The actual number to minimize is this fooling rate itself.

I wrote a small script to compute the fooling rate directly from raw calibration data. It worked by sliding a window of six sequential runs across the 20. For each window, we took interleaved pairs of size two to represent an interleaved (candidate, default) pair. A window of size six gives us pairings like: (t1, t2), (t3, t4), (t5, t6). In any given pair, tnt_ntn​ and tn+1t +1​tn+1​ can alternate roles of being the candidate query, or the default query. That means for each pair, there are two possibilities, and therefore for each window of three tuples, there are 2×2×2=82 \times 2 \times 2 = 82×2×2=8 possibilities. 20 measurements means we’ll slide this window 15 times, so we have 15×8=12015 \times 8 = 12015×8=120 total possibilitiesRead annotation: Each possibility is a binary value indicating whether or not that specific, simulated formulation of candidate/default pairs resulted in a ratio of medians between the two greater than the 5% tie-zone.Back to annotation reference Each possibility is a binary value indicating whether or not that specific, simulated formulation of candidate/default pairs resulted in a ratio of medians between the two greater than the 5% tie-zone. for a given query.

We derive two metrics from these raw numbers. First, we calculate the no-op error rate for a given query as the ratio of the 120 simulated possibilities that do differ by more than 5% against the number that don’t. We sum these percentages up across all 113 JOB queries and then divide by 113. This number, which we’ll call the “mean no-op error rate,” gives us the percentage likelihood that the reward may get fooled for any JOB query when doing our three paired measurements strategy. Second, we sort the no-op error rates for all 113 queries, lowest to highest. The number that is 90% of the way to the end of this sorted list is reported as the “p90 query,” and gives us a measure of the fooling rate for the worst-offending queries.

At 128 MB for shared_buffers and four concurrent containers, the “fool rate” script produced the following mean no-op error rates and p90 query numbersRead annotation: I ran the calibration twice per config to provide a sense of how much two runs may disagree with each other.Back to annotation reference I ran the calibration twice per config to provide a sense of how much two runs may disagree with each other.:

RunMean no-op error ratep90 queryMedian CV
15.0%13%2.3%
25.4%20%2.4%

The numbers aren’t good. One in twenty no-op plans get rewarded, and one in ~10 queries gets fooled more than 13% of the time.

We can do better.

Tuning Postgres

I focused on two memory-related settings Postgres exposes:

  1. shared_buffers decides how much of the database Postgres can keep in its own cache
  2. work_mem decides how much memory a single sort/hash operation can get before spilling to disk

I ran four calibrations:

shared_bufferswork_memNo-op error rate (run 1 / 2)p90 queryMedian CVTotal runtime
128 MB4 MB5.0% / 5.4%13% / 20%2.3%95 s
2 GB4 MB1.8% / 1.2%1.3% / 0%1.1%60 s
128 MB32 MB7.0% / 6.6%20% / 23%2.6%94 s
2 GB32 MB1.7% / 1.3%0% / 0%1.2%60 s

Surprisingly, work_mem had no effect on noise at all, and shared_buffers carried all of the weight!

With 2 GB of shared_buffers, the median query ended warmup with its SRB counter at exactly zero: its working set was fully resident in Postgres’s own cache. The no-op error rate dropped by roughly 4x, and the 90th percentile query went from being fooled 13%–20% of the time to almost never. Our two-clump query, job-13b, went from a CV of 10.3% to 0.9%, with all 20 runs landing within 7 ms of each other.

One neat benefit emerged that I wasn’t initially chasing: the default plans themselves got faster. The summed runtime of all 113 JOB queries fell from 95 seconds to 60 seconds, just from cache residency. In other words, actually taking our measurements for both candidates and defaults would now be significantly faster, meaning the training process would take less time.

I locked in 2 GB shared_buffers and 4 MB work_mem for the rest of the project.

Baselines and metrics

I used two metrics for benchmarking agent performance.

Geometric mean speedup

The geometric mean speedupSgeo​=(i=1∏N​The candidate plan's execution time for some query ici​The default plan's execution time for some query ibi​​)The Nth root of the product. With this, a 2x and a 0.5x speedup cancel out to 1x.1/N

The geometric mean speedup gives all queries equal weight. For example, in a two-query sample, if query 1 runs 2x faster than its baseline, and query 2 runs 0.5x faster than its baseline, then Sgeo=1.00xS _{geo} = 1.00\text{x}Sgeo​=1.00x. It doesn’t matter if query 1’s baseline took 5 minutes and our candidate took 2.5 minutes, but query 2 only regressed from 25s to 50s, as they are equally weighted.

Total workload speedup

The total workload speedupSworkload​=∑i=1N​The candidate plan's execution time for some query ici​∑i=1N​The default plan's execution time for some query ibi​​

Total workload speedup treats the entire query set as one batch. We simply add all the baseline times and divide by the sum of the candidate times. In our above example, Sworkload=1.4xS _{workload} = 1.4\text{x}Sworkload​=1.4x.

Both metrics tell different stories. The total workload speedup is a measure of practicality. A data analyst building out a suite of analytics queries wants to decrease the overall runtime across the batch. But from a model training standpoint, the total workload speedup could be entirely influenced by a single query plan the agent chanced upon; the rest of the batch could be degenerate. This implies the model hasn’t actually learned anything interesting; it just got lucky. Because the geometric mean speedup cares not for absolutes, it gives us a measure of actual learning across the batch: values above 1x imply that the average query is executing faster.

A frontier intelligence control

Before running the untrained 4B model through the qo-agent harness, I wanted to validate this problem was actually solveable by today’s frontier models. If a model like GPT-6 Astra or Qwen 3.8 2.4T couldn’t improve upon the default Postgres query plan, I couldn’t really expect the 4B model to either.

I took a small sample of 10 JOB queries and benchmarked them on both Astra and Qwen 3.8 2.4T running through the qo-agent harness:

ModelCandidatesTasks scoredSgeo​ Geometric mean speedupSworkload​ Total workload speedupRegressions
Astra [m] Run at medium reasoning19/100.85x1.00x3
Astra [m] Run at medium reasoning510/102.54x2.12x0
Astra [m, r] Run at medium reasoning, with reasoning summaries on510/102.39x1.57x1
Qwen 3.8 2.4T [m] Run at medium reasoning17/102.02x1.30x1
Qwen 3.8 2.4T [m] Run at medium reasoning510/102.26x1.35x1

Evaluations of Astra and Qwen 3.8 2.4T run on the same slice of 10 JOB queries. The frontier models were benchmarked at different candidate numbers (i.e. how many candidates they were allowed to generate during a complete trajectory; either a single candidate or 5) and for Astra, whether reasoning summariesRead annotation: I was a little surprised to see Astra performance worsen with reasoning summaries on compared to the 5-candidate evaluation done right before it, but these evaluations were only run a single time on a small 10-query slice of JOB, so I chalked up the worse results to random variance.Back to annotation reference I was a little surprised to see Astra performance worsen with reasoning summaries on compared to the 5-candidate evaluation done right before it, but these evaluations were only run a single time on a small 10-query slice of JOB, so I chalked up the worse results to random variance. were enabled or not. Astra was inferenced through OpenAI’s API, and Qwen 3.8 2.4T through Modal via OpenRouter.

Given the difference between the single-candidate scores and the 5-candidate scores, the agent was clearly capable of doing in-context learning across sequential executions of its candidates. This gave me the confidence to stick with an agentic multi-turn approach rather than try and train the 4B model to get really good at one-shotting a plan.

During a run of the agent, each candidate was warmed once and then measured once. After exhausting the candidate attempts budget, the model was only presented with a single tool to call, finish, and the model was told to select the best scoring candidate (or keep the default plan). After the candidate was selected, three interleaved (candidate, default) pairs were run and passed through a clipper:

Si=clip⁡(median⁡(Di)median⁡(Ci),0.1,10)S_i = \operatorname{clip}\left( \frac{\operatorname{median}(D_i)}{\operatorname{median}(C_i)},\ 0.1, 10 \right)Si​=clip(median(Ci​)median(Di​)​,0.1,10)

The clipper constrained the result of the division between the two medians to be between [0.1,10][0.1, 10][0.1,10]. These clipper values were picked somewhat arbitrarily; I found they prevented the geometric mean speedup from getting overly influenced by an extreme speedup or an extreme regression.

Conclusion: frontier intelligence is capable of agentically doing query optimization.

The vanilla 4B baseline

We’re now ready to evaluate the untrained 4B model on JOB and see how it does!

The same 5-candidate plan budget per agent trajectory configuration was employed. The results were dismal:

How the trajectory endedQueries
No valid candidate81
Selection failed16
Timed out1
Candidate duplicated the default plan7
Kept the default2
Candidate measured against the default6

Only the last three rows co

PAN's pipeline reviewed approximately 1 open sources for this article. No human editor reviewed this article before publication.

Related Reads

Show on timeline →