2026-10-11 16:37 UTC

Rohan Bansal claims SFT and reinforcement learning let a 4B open-weight model reduce execution latency by 44.7% across 113 join-heavy queries versus PostgreSQL's default plans, suggesting small specialized models can improve database query optimization.

state: seedheat: lowuncertainty: highconvergesscott: lowdatabase-optimization small-models ai-systemsRohan Bansal

What is this?

The case attributes to Rohan Bansal an experiment using supervised fine-tuning (SFT) and reinforcement learning to train a 4-billion-parameter open-weight model to produce PostgreSQL query plans, claiming 44.7% lower execution latency across 113 join-heavy queries than PostgreSQL’s default plans. The supplied web snippets concern other post-training research and do not corroborate Bansal’s involvement, the model, or the database results. The evidence title advertises “81% faster” plans, but the supplied material does not establish how that figure relates to the latency claim or provide benchmark methodology, inference overhead, or generalization results.

Why it matters to Scott

The claimed domain-specific post-training approach directionally converges with Scott’s Salesforce fine-tuning data factory, and PostgreSQL is part of his application infrastructure, but the hits establish neither a query-planning bottleneck nor an applicable join-heavy workload. With the results uncorroborated and inference overhead undisclosed, this remains an adjacent specialization example rather than a reason to change his architecture; the radar’s FermiSense catalog-review case tracks a related approach, not this development.
dev:project.redditdev:technology.postgresqlradar:500-dollar-9b-rl-catalog-review
queries asked of Scott's wikis
  • small specialized models versus general-purpose frontier models
  • PostgreSQL query planning database performance projects
  • SFT reinforcement learning execution-feedback optimization
  • agent evaluation harnesses latency measurement benchmark noise
  • local model inference overhead end-to-end cost savings

Measured heat

now 0 pts/hpeak 0 pts/hcomments 0/hpeers p14momentum: steady2 platformsage 597h
points/hour across evidence · reading as of 2026-10-12 02:59:37.977291+11:00 · deterministic, not a model opinion

How the heat travelled

09-16 20:23 (minted)⭐ origin echo-reconstructedReports a 44.7% latency reduction across 113 join-heavy queries using a 4B model post-trained with SFT and agentic RL, alongside a noise-con
Rohan Bansal on blog (echo) · attributed from hn.story.49731285 · published time unknown
—
09-16 18:50first on hacker news · published · lag ?Training a 4B model to produce 81% faster query plans than Postgres
polyphilz
—
09-16 18:50amplified on hacker news 👑hn.story.49731285
polyphilz
peak 702 · 141 comments · 100% of case engagement
09-16 20:21our radar first saw it · lag ?discovery anchor: hn.story.49731285—
pace: p89 vs 1032 stories at the 336h mark (now 597h old) — ahead of anthropic-fable5-pro-quota-restore (1.0x), behind amazon-blocks-meta-muse-shopping (1.0x)

Evidence (2) — ⭐ canonical anchor

sourceobjectauthorscorecomments
🟧 hnTraining a 4B model to produce 81% faster query plans than Postgres
Retrieved article excerpt

Open article · Retrieved 2026-09-16T20:22:40.536254+00:00

## How good are query optimizers, really?

[Leis et al.](https://vldb.org/pvldb/vol9/p204-leis.pdf) asked this exact question in 2015. Then, [they asked it again 10 years later](https://www.vldb.org/pvldb/vol18/p5531-viktor.pdf).

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](https://dl.acm.org/doi/10.1145/1270.1498).

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](https://en.wikipedia.org/wiki/IMDb):

```
-- 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  two   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\ t' \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  now   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  query   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](https://www.postgresql.org/docs/current/geqo-pg-intro.html) 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
  AN
polyphilz702141
🟧 echo.blog ⭐Reports a 44.7% latency reduction across 113 join-heavy queries using a 4B model post-trained with SFT and agentic RL, alongside a noise-conRohan Bansal——

Interpretation history

Decision trace