<?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>Substring-Search on MongrelDB</title><link>https://www.mongreldb.com/articles/tags/substring-search/</link><description>Recent content in Substring-Search on MongrelDB</description><image><title>MongrelDB</title><url>https://www.mongreldb.com/assets/og-mongreldb.png</url><link>https://www.mongreldb.com/assets/og-mongreldb.png</link></image><generator>Hugo</generator><language>en-US</language><lastBuildDate>Sun, 02 Aug 2026 12:00:00 -0500</lastBuildDate><atom:link href="https://www.mongreldb.com/articles/tags/substring-search/index.xml" rel="self" type="application/rss+xml"/><item><title>FM-Index Substring Search in a Transactional Database</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 uses a Burrows-Wheeler transform and wavelet-tree FM-index for exact substring candidates that combine with SQL, vectors, ranges, and equality filters.</description><content:encoded><![CDATA[<p>Database search discussions usually jump from a B-tree to a tokenized inverted index, which leaves an awkward middle case for applications that need exact byte containment rather than words, stemming, language analysis, or BM25; <code>WHERE body LIKE '%error:42%'</code> is not a semantic query and it is not a prefix query, it is a substring question, and MongrelDB answers that question with an FM-index built from a Burrows-Wheeler transform and wavelet-tree rank structure.</p>
<p>Calling this general full-text search is convenient but imprecise. The FM-index finds containment candidates for a literal pattern. MongrelDB also has a separate FTS document surface and ranking function, while sparse retrieval handles learned weighted terms. Those paths can work together, but an FM-index does not turn a substring into a tokenized relevance model by itself.</p>
<h2 id="why-ordinary-indexes-miss-needle">Why ordinary indexes miss <code>%needle%</code></h2>
<p>A sorted B-tree can seek a known prefix because values beginning with <code>user:</code> occupy one ordered interval. It cannot seek an arbitrary middle fragment without another representation, because strings containing <code>needle</code> may appear anywhere in lexical order.</p>
<p>A full scan solves the problem by checking every value. That is correct and often good enough for a small table, but cost grows with the bytes examined. The FM-index stores a transformed view of the corpus that supports backward search over the pattern, narrowing the suffix interval one symbol at a time through rank operations.</p>
<p>The search work depends primarily on pattern length plus the cost of locating and materializing matches. Output still matters: a two-byte pattern matching half the corpus cannot return half the corpus for free.</p>
<h2 id="what-the-burrows-wheeler-transform-contributes">What the Burrows-Wheeler transform contributes</h2>
<p>The Burrows-Wheeler transform rearranges text so repeated contexts cluster together. A wavelet tree supplies compact rank queries over that transformed sequence, and the FM-index combines those pieces with sampled location metadata so a matching interval can resolve back to candidate rows.</p>
<p>That structure is valuable when the application needs literal containment over stored text and does not want a separate search service. It is not automatically the best answer for hot write-heavy token search, fuzzy matching, typo tolerance, language stemming, or relevance-ranked document retrieval; those are different query models with different indexes.</p>
<h2 id="how-it-appears-in-mongreldb">How it appears in MongrelDB</h2>
<p>The schema declares an FM secondary index on a bytes/text column:</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-rust" data-lang="rust"><span style="display:flex;"><span>IndexDef {
</span></span><span style="display:flex;"><span>    name: <span style="color:#e6db74">&#34;body_fm&#34;</span>.into(),
</span></span><span style="display:flex;"><span>    column_id: <span style="color:#ae81ff">4</span>,
</span></span><span style="display:flex;"><span>    kind: <span style="color:#a6e22e">IndexKind</span>::FmIndex,
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>A native condition requests containment:</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-rust" data-lang="rust"><span style="display:flex;"><span>Condition::FmContains {
</span></span><span style="display:flex;"><span>    column_id: <span style="color:#ae81ff">4</span>,
</span></span><span style="display:flex;"><span>    pattern: <span style="color:#a6e22e">b</span><span style="color:#e6db74">&#34;error:42&#34;</span>.to_vec(),
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>SQL can push down a recognized containment shape:</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, body
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> logs
</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;%error:42%&#39;</span>;
</span></span></code></pre></div><p>The FM path produces candidate RowIds. DataFusion rechecks SQL pattern semantics where the index result is a superset, which keeps pushdown an optimization rather than a change in query truth. Native <code>FmContains</code> expresses the exact literal containment condition directly.</p>
<p>For anchored prefixes such as <code>LIKE 'user:%'</code>, a Bitmap index on a bytes column can enumerate distinct keys and union matching prefixes exactly, which is usually a tighter path than asking the FM-index to solve a query whose anchor already gives the ordered key domain enough information.</p>
<h2 id="why-the-shared-rowid-matters">Why the shared RowId matters</h2>
<p>The substring result can intersect with a tenant Bitmap, a learned time range, an ANN candidate set, or any other hard condition before rows and requested columns are decoded. This is the useful part of keeping text search in the operational engine: the text match does not become a detached document identifier that the application must join back to a row stored somewhere else.</p>
<p>A hybrid query can ask for semantic neighbours whose body contains an exact code and whose timestamp falls inside an incident window. HNSW, FM, and PGM each do the work they were built for, then the engine combines their RowIds under one snapshot.</p>
<h2 id="index-maintenance-is-derived-state">Index maintenance is derived state</h2>
<p>The authoritative data is the committed row version. Secondary index generations can be checkpointed and rebuilt from runs plus mutable state. Online create, replace, and drop operations build or publish generations without rewriting the table, with a short barrier at publication.</p>
<p>That distinction matters for crash reasoning. The WAL protects committed changes to source data; the FM structure is an acceleration path that can be reconstructed, not a second authoritative database whose divergence the application must reconcile.</p>
<p>Maintenance cost still exists. Building a transformed corpus is more work than appending one posting to a small in-memory map, and frequent updates to large indexed text deserve a representative benchmark. MongrelDB exposes REINDEX and rebuild paths because no immutable text structure remains cheap under every update pattern.</p>
<h2 id="when-to-choose-another-search-path">When to choose another search path</h2>
<p>Use a tokenized inverted index when the product needs word analysis, stemming, fuzzy terms, facets, mature BM25 tuning, or a search-only workload large enough to justify a dedicated engine. Use sparse retrieval when learned lexical expansion and weighted terms are the signal. Use ANN when semantic similarity matters. Use a Bitmap prefix path when the query is anchored and the distinct-key set is manageable.</p>
<p>Use the FM-index when exact substring containment is the requirement, the data belongs transactionally with the rest of the row, and combining that containment with ordinary database filters is worth more than operating a separate text-search service.</p>
<p>The current index behavior is documented in <a href="https://github.com/visorcraft/MongrelDB/blob/master/docs/06-indexes.md"><code>docs/06-indexes.md</code></a>, and the broader <a href="https://www.mongreldb.com/embedded-vector-database/">embedded vector database guide</a> shows how the text path combines with dense and sparse retrieval.</p>
]]></content:encoded></item></channel></rss>