<?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>Full-Text-Search on MongrelDB</title><link>https://www.mongreldb.com/articles/tags/full-text-search/</link><description>Recent content in Full-Text-Search 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>Thu, 09 Jul 2026 12:30:00 -0500</lastBuildDate><atom:link href="https://www.mongreldb.com/articles/tags/full-text-search/index.xml" rel="self" type="application/rss+xml"/><item><title>FM-Index Full-Text Search in a Transactional Engine</title><link>https://www.mongreldb.com/articles/2026/07/fm-index-full-text-search-in-a-transactional-engine/</link><pubDate>Thu, 09 Jul 2026 12:30:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/07/fm-index-full-text-search-in-a-transactional-engine/</guid><description>MongrelDB&amp;#39;s full-text search uses an FM-index, not the inverted index you usually see, because the FM-index lives in the same WAL as the rows it covers.</description><content:encoded><![CDATA[<p>Inverted indexes and transactional writes do not coexist easily, and that seam is what pushes most database vendors toward eventually-consistent search sidecars, search daemons, or CDC pipelines that resync the index out of band. We wanted to skip the seam entirely, which meant picking a full-text index structure that the same WAL could cover at the same fsync as the rows it covered, and the FM-index (Ferragina-Manzini, 2000) was the data structure that made the cut.</p>
<h2 id="what-an-fm-index-is-briefly">What an FM-index is, briefly</h2>
<p>A Burrows-Wheeler transform turns text into something that compresses as well as the same text runs through gzip, because the BWT groups repeated substrings the same way. The FM-index adds enough suffix-sampling metadata on top of the BWT that you can test whether a pattern is a substring by walking the BWT&rsquo;s first-column matrix one character at a time, without scanning the original text at all. The asymptotic shape is <code>O(|pattern|)</code> per query against a corpus that is highly compressed on disk.</p>
<p>Inverted indexes, by contrast, store the original tokens uncompressed, hash each token to a postings list, and answer a query by intersecting those postings lists in constant time per term. They are the right choice when your data is largely read-only, your queries are repeated, and you can afford to rebuild the index out of band after writes. They are not the right choice for a transactional engine that wants the index to participate in the same <code>commit</code> as the rows.</p>
<h2 id="what-the-fm-index-bought-us-at-the-storage-layer">What the FM-index bought us at the storage layer</h2>
<p>Two things specifically.</p>
<p>The first is write-path simplicity. An inverted index wants to be immutable between commits, which is why transactional inverted indexes need separate WAL coordination, deferred rebuilds, or row-level deltas stitched onto an immutable postings structure. An FM-index in MongrelDB is materialized as a column-level rewrite, batched with the rows it covers, fsync&rsquo;d through the same WAL, and rolled back by not rewriting it; the index has no separate consistency model because there is no separate index state to coordinate.</p>
<p>The second is the readability of the data on disk. The same BWT that makes the FM-index queryable is the same representation that compresses text well, and MongrelDB&rsquo;s storage layer already pays for compressed column layouts in the rest of the engine; the search side gets that compression benefit alongside the rest of the engine&rsquo;s columnar storage without a separate index cost.</p>
<p>That is the trade we wanted: one source of truth, one consistency model, one wire format. The FM-index was the data structure that gave us all three; the inverted index would have given us one and a half and required a separate subsystem for the half.</p>
<h2 id="what-we-had-to-give-up-to-get-there">What we had to give up to get there</h2>
<p>Build cost. Inverted indexes get built incrementally as documents are inserted, and the index is correct after every <code>INSERT</code>. The FM-index is meaningful only over a corpus; rebuilding it across a single row is wasteful. MongrelDB batches FM-index rewrites across table-rewrite checkpoints, with the same <code>IndexBuildPolicy::Deferred</code> default the SQL indexes use; this means write amplification on full-text-heavy workloads is higher than on inverted-index engines, and first-query-after-batch latency can spike when a checkpoint has not been built yet. The benchmark numbers are honest about this; the architecture is honest about why.</p>
<p>Pure-pattern ranking. The FM-index gives you containment (does the pattern appear?), not relevance (how much does it matter?). BM25 ranking is available via the <code>mongreldb_fts_rank(text, query)</code> UDF, which computes simplified BM25 from per-row term frequency and approximated corpus statistics at query time; accurate BM25-with-IDF lives in the separate <code>fts_docs</code> virtual table, which is the inverted-index path. That is a hybrid shape, not pure FM-index purity, and we note it because a search-engine purist would.</p>
<h2 id="where-it-shows-up-in-the-wire-format">Where it shows up in the wire format</h2>
<p><code>WHERE body LIKE '%phrase%'</code> in raw SQL, <code>-&gt;whereFts(...)</code> in the Kit query builder, and <code>where('fm', ...)</code> in the PHP fluent builder all dispatch to the same FM-index path through <code>/kit/query</code>. There is no separate search endpoint to point your app at, no separate consistency model to reason about, and no eventual-index lag to defend against in application code. A search query joined against the same <code>WHERE</code> clauses you would write for a regular typed column looks like:</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">SELECT</span> id, title
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> articles
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> body <span style="color:#66d9ef">LIKE</span> <span style="color:#e6db74">&#39;%rust bwt fts%&#39;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">AND</span> published_at <span style="color:#f92672">&gt;</span> <span style="color:#e6db74">&#39;2026-01-01&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> mongreldb_fts_rank(body, <span style="color:#e6db74">&#39;rust bwt fts&#39;</span>) <span style="color:#66d9ef">DESC</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">LIMIT</span> <span style="color:#ae81ff">20</span>;
</span></span></code></pre></div><p>Same transactional commit at the end of the statement, same WAL coverage for the index update on any <code>INSERT</code> to <code>articles</code>, and the same single-engine consistency model for both the row and the search result.</p>
<h2 id="when-this-is-the-wrong-tradeoff">When this is the wrong tradeoff</h2>
<p>If you are running a pure search-engine workload with no transactional semantics on the indexed data, with hot-write patterns the FM-index chokes on, and with strict sub-millisecond p99 on very short queries, an inverted-index engine tuned for that workload is still faster than what an FM-index on a transactional engine gives you; we did not pretend otherwise when we built it. What we wanted was transactional full-text search on the same engine as the rest of your data, on the same wire format, and the FM-index was the right way to spend the storage budget to get it.</p>
]]></content:encoded></item></channel></rss>