<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Learned-Index on MongrelDB</title><link>https://www.mongreldb.com/articles/tags/learned-index/</link><description>Recent content in Learned-Index on MongrelDB</description><image><title>MongrelDB</title><url>https://www.mongreldb.com/logo.png</url><link>https://www.mongreldb.com/logo.png</link></image><generator>Hugo</generator><language>en-US</language><lastBuildDate>Sat, 11 Jul 2026 11:22:00 -0500</lastBuildDate><atom:link href="https://www.mongreldb.com/articles/tags/learned-index/index.xml" rel="self" type="application/rss+xml"/><item><title>Why We Shipped Learned-Range Indexes Before We Shipped B-Trees</title><link>https://www.mongreldb.com/articles/2026/07/why-we-shipped-learned-range-indexes-before-we-shipped-b-trees/</link><pubDate>Sat, 11 Jul 2026 11:22:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/07/why-we-shipped-learned-range-indexes-before-we-shipped-b-trees/</guid><description>Learned-range indexes beat B-trees on cold sorted range scans up to 3-4x on p99, but only when the workload is recency-heavy; here is when the tradeoff is right and when the B-tree still wins.</description><content:encoded><![CDATA[<p>Most range queries on modern OLTP data look like <code>WHERE created_at BETWEEN ? AND ?</code>, and for the first decade of a database&rsquo;s life the only answer that ever shipped was a B-tree, because B-trees were the structure everyone understood and they got within a small constant factor of optimal for sorted range scans on a workload that fit in cache. We shipped a learned-range index first, because the workload that had been hurting on B-trees was the one we kept seeing in customer traces, and that workload is not the one B-trees were tuned for.</p>
<h2 id="what-a-learned-range-index-is-briefly">What a learned-range index is, briefly</h2>
<p>A learned-range index is a small neural network, typically an MLP of one or two hidden layers, that learns the cumulative distribution function (CDF) of sorted keys and predicts the leaf-page offset of any given key from that CDF. The reference is Kraska et al. (2017), &ldquo;The Case for Learned Index Structures,&rdquo; and the headline result, measured on specific in-memory integer-key benchmarks with the paper&rsquo;s own caveats, was up to an order of magnitude faster than a B-tree on point lookups and up to 70% smaller in memory footprint; later work (Ferragina et al., 2020) showed those gains were workload-sensitive and that the win was real but smaller for general-purpose data, so we treat the headline numbers as upper bounds rather than the floor. The model replaced the tree with two or three multiply-adds per lookup; the B-tree existed only as a fallback when the model&rsquo;s error term exceeded the leaf size.</p>
<p>In MongrelDB the learned-range index is implemented as a recency-weighted CDF: incremental updates nudge the model on hot inserts, full retraining happens at segment and compaction boundaries rather than per row, and the loss is weighted toward recent keys so the high-traffic part of the keyspace stays accurately modelled even as the tail grows. The shape is the same MLP, the loss and the update cadence are what changed.</p>
<h2 id="what-it-bought-us-on-the-path-we-cared-about">What it bought us on the path we cared about</h2>
<p>The path that mattered was sorted range scans on data larger than memory, and the query shape that kept appearing in our traces was <code>WHERE event_at BETWEEN ? AND ?</code> against an events table that had outgrown the host&rsquo;s RAM. A B-tree over a 200 GB cold column on a 64 GB host pays roughly <code>log(N) / log(fanout)</code> random reads per query, where <code>N</code> is the leaf-page count, and that random-read pattern is what dominates p99 on the workloads we kept getting bug reports for, where the column was the company&rsquo;s events table and the query was &ldquo;everything between Tuesday and Wednesday last week.&rdquo; A learned-range index over the same data collapses most of the random reads into sequential reads, because the model&rsquo;s prediction lands the seek in the right column block on the first try more often than not, and the cold read degrades to &ldquo;load the leaf and scan&rdquo; rather than &ldquo;walk the tree and load the leaf.&rdquo;</p>
<p>We measured this on a customer trace with the kind of <code>WHERE event_at BETWEEN ? AND ?</code> query above against an 180 GB events table on a 64 GB host at roughly a 3-4x speedup on p99 for the cached-leaf case and a 1.5-2x speedup on p99 for the cold-leaf case; those numbers are honest, they hold over a multi-day window, and a benchmark fixture in the kit/tests directory reproduces them so anyone can re-run them on their own hardware without trusting ours.</p>
<p>The second thing the learned-range index bought us was a smaller working set, and that part mattered more than the latency number in the traces we cared about: a learned index holds each key distribution in a few kilobytes of model weights, where a B-tree holds the same key distribution in a few megabytes of pointers and page metadata, and the difference in cache pressure showed up as a higher hit rate on the rest of the workload, because there was more cache left over for the leaf pages the model was pointing at.</p>
<h2 id="what-we-had-to-give-up-to-get-there">What we had to give up to get there</h2>
<p>Unconditional correctness at the index boundary. A B-tree places every key at a known offset, so <code>WHERE k BETWEEN 100 AND 200</code> returns exactly the keys whose value is in <code>[100, 200]</code>, every time, with no error term to reason about. A learned-range index has a model error that defines a &ldquo;delta band&rdquo; around each prediction, and a query that lands in that band has to fall back to a leaf scan; on data with sharp CDF cliffs (compressed keys, monotonic timestamps) the delta band is narrow and the scan is short, but on data with heavy tail skew (event sources, sparse UUIDs) the delta band is wider and the scan turns into the worst case the model was supposed to prevent. The default <code>IndexBuildPolicy::Learned</code> configures the model for the common recency-heavy case; on data that is not recency-heavy, you fall back to <code>IndexBuildPolicy::BTree</code> and the B-tree beats the learned index. We chose that on purpose rather than papering over it.</p>
<p>Engineering cost. Training a model per index is not a one-line addition to a B-tree codepath; it is a separate subsystem with its own CI fixtures, its own drift detection (model error as measured against held-out data is a real signal we monitor in the catalog), its own rebuild policy on schema change, and its own failure mode when the loss function does not converge on a degenerate distribution (uniform UUIDs, for example). That cost is real, and it is the kind of cost that makes a database team ask whether the speedup justifies the cognitive load; we think it does, but we are not going to pretend that a B-tree was simpler.</p>
<h2 id="where-it-shows-up-in-the-wire-format">Where it shows up in the wire format</h2>
<p><code>WHERE k BETWEEN ? AND ?</code> against a typed numeric, decimal, timestamp, or date column dispatches through <code>index_plan = LearnedRange(model_id = X, delta = 8KB)</code> to <code>/kit/query</code>, and the fallback path is <code>index_plan = BTree</code>. The choice is per-index at build time, not per-query at runtime, which means a single table can hold a B-tree on its primary key and a learned-range index on its events column, and you pick the right tool per column rather than per query. The wire format is the same either way:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">TABLE</span> events (
</span></span><span style="display:flex;"><span>  id            INT64,
</span></span><span style="display:flex;"><span>  event_at      <span style="color:#66d9ef">TIMESTAMP</span>,
</span></span><span style="display:flex;"><span>  payload       JSON,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">INDEX</span> event_at_idx <span style="color:#66d9ef">USING</span> LEARNED <span style="color:#66d9ef">ON</span> event_at,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">INDEX</span> id_pk     <span style="color:#66d9ef">USING</span> BTREE   <span style="color:#66d9ef">ON</span> id
</span></span><span style="display:flex;"><span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">count</span>(<span style="color:#f92672">*</span>), <span style="color:#66d9ef">avg</span>(latency_ms)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> events
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> event_at <span style="color:#66d9ef">BETWEEN</span> <span style="color:#e6db74">&#39;2026-07-01 00:00:00&#39;</span> <span style="color:#66d9ef">AND</span> <span style="color:#e6db74">&#39;2026-07-08 00:00:00&#39;</span>;
</span></span></code></pre></div><p>Same <code>WHERE</code> clause you would write for a B-tree, same planner, same transactional commit at the end of the statement, same WAL coverage for both index and row update on any <code>INSERT INTO events</code>. The learned-range index is the right shape for <code>event_at</code>, the B-tree is the right shape for <code>id</code>, and we ship both because they are the right tool for each column rather than because we had an academic result to justify.</p>
<h2 id="when-this-is-the-wrong-tradeoff">When this is the wrong tradeoff</h2>
<p>If your column is small enough to fit in cache, the B-tree wins anyway, because the random-read cost of the B-tree is small relative to the cache-hit latency of the learned-range index. If your column is wider than memory and your queries are equality lookups (<code>WHERE k = ?</code>) rather than range scans, the B-tree wins by a wider margin, because the learned-range index&rsquo;s prediction error buys you nothing when you are not sweeping across a key range. If your data has no recency structure at all (a uniformly-distributed synthetic key, an auto-increment surrogate id), the learned-range model&rsquo;s loss never improves past the prior, and the B-tree is the right answer with no hesitation. The learned-range index is a tool for one specific shape of query on one specific shape of data; the B-tree is the universal tool that covers the rest, and we ship both as first-class index options in the same engine, on the same wire format, with the same transaction model.</p>
]]></content:encoded></item></channel></rss>