<?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>Udf on MongrelDB</title><link>https://www.mongreldb.com/articles/tags/udf/</link><description>Recent content in Udf 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>Mon, 07 Sep 2026 09:00:00 -0500</lastBuildDate><atom:link href="https://www.mongreldb.com/articles/tags/udf/index.xml" rel="self" type="application/rss+xml"/><item><title>The mongreldb_fts_rank UDF and Why We Wrote Our Own</title><link>https://www.mongreldb.com/articles/2026/09/the-mongreldb-fts-rank-udf-and-why-we-wrote-our-own/</link><pubDate>Mon, 07 Sep 2026 09:00:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/09/the-mongreldb-fts-rank-udf-and-why-we-wrote-our-own/</guid><description>MongrelDB ships full-text relevance as an ordinary SQL function that scores whole Arrow batches per call, composes with WHERE and ORDER BY and window functions, and asks for no index up front, with the honest tradeoff that global IDF lives in the fts_docs virtual table instead.</description><content:encoded><![CDATA[<p>Ranking search results is the feature that turns a filter into a search engine, and it is also the feature most databases make you leave the database to get, because the usual answer is &ldquo;the FTS index has a rank column if you set it up our way, otherwise stand up Elasticsearch,&rdquo; which is how a PHP app that needed to sort four hundred rows by relevance ends up with a JVM, a cluster, and a sync job. We wanted relevance scoring to behave like <code>LENGTH()</code> or <code>COALESCE()</code>: an expression you drop into a <code>SELECT</code>, usable in <code>ORDER BY</code> and <code>WHERE</code> and <code>HAVING</code>, composable with everything else the planner already knows, and requiring nothing to exist before you call it. That is what <code>mongreldb_fts_rank(text, query)</code> is, a scalar UDF registered directly into the query engine, and the interesting part is not the formula inside it but the decision to make ranking an ordinary function at all, because that decision is what lets it score things no index will ever see.</p>
<h2 id="what-the-function-actually-does">What the function actually does</h2>
<p>The engine side of the query layer is DataFusion over Arrow record batches, so a UDF in MongrelDB is not a row-at-a-time callback the way <code>sqlite3_create_function</code> was back when we all registered PHP closures into SQLite and wondered why the query took nine seconds; <code>FtsRankUdf</code> implements <code>ScalarUDFImpl</code>, receives the whole batch as columnar Arrow arrays, tokenizes the query once, and returns a <code>Float64</code> array with one score per row, which means the per-row cost is a couple of vector passes over memory the engine already had in flight. The scoring is BM25-inspired term saturation, keeping the classic <code>k1 = 1.2</code> and <code>b = 0.75</code> constants in the code because every BM25 implementation since the nineties has started from them, though the length-normalization term is collapsed out, the document length over average document length ratio is fixed at one, which makes <code>b</code> cancel arithmetically, and what survives is the term-frequency saturation curve <code>tf * (k1 + 1) / (tf + k1)</code>:</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>       mongreldb_fts_rank(content, <span style="color:#e6db74">&#39;database performance&#39;</span>) <span style="color:#66d9ef">AS</span> score
</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> mongreldb_fts_rank(content, <span style="color:#e6db74">&#39;database performance&#39;</span>) <span style="color:#f92672">&gt;</span> <span style="color:#ae81ff">0</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> score <span style="color:#66d9ef">DESC</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">LIMIT</span> <span style="color:#ae81ff">10</span>;
</span></span></code></pre></div><p>The tokenizer splits on any non-alphanumeric character and lowercases, Unicode-aware because Rust&rsquo;s <code>char::is_alphanumeric</code> is, so <code>'日本語'</code> and <code>'naïve'</code> tokenize the way you would hope rather than the way a 2003-era C tokenizer would have mangled them. The signature accepts <code>Utf8</code>, <code>LargeUtf8</code>, <code>Utf8View</code>, and the binary string types, and the function is marked <code>Immutable</code>, which matters more than it looks, because an immutable marker is what lets the planner constant-fold the query argument, cache results, and reorder the expression around joins without second-guessing whether the score will change between calls.</p>
<h2 id="why-a-function-and-not-an-index-flag">Why a function and not an index flag</h2>
<p>The reason to write our own instead of wiring ranking to the full-text index is that an index-coupled rank can only ever rank what the index indexed, and the queries that actually hurt are the ones where the text comes from somewhere the index does not cover: a computed column, a <code>json_extract</code> of a nested field, a concatenation of two columns, a subquery result. A scalar function does not care where its input came from, it ranks whatever string the expression tree hands it, so the same <code>mongreldb_fts_rank</code> call that scores a plain <code>content</code> column also scores <code>json_extract(meta, '$.summary')</code> or <code>title || ' ' || coalesce(subtitle, '')</code> with zero setup, and it shows up inside CTEs and window functions because to the planner it is just another expression. Retrieval and scoring being separate layers is the whole design: the FM-index and the <code>fts_docs</code> virtual table answer &ldquo;which documents might match&rdquo; at scale, and the UDF answers &ldquo;how well does this specific string match&rdquo; for everything else, including the long tail of tables that will never justify an index at all.</p>
<p>From the Kit side it is one line of ordinary SQL through the same <code>sqlRows</code> surface everything else uses:</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-ts" data-lang="ts"><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">rows</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">db</span>.<span style="color:#a6e22e">sqlRows</span>(
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#34;SELECT id, title, mongreldb_fts_rank(content, &#39;database performance&#39;) AS score &#34;</span> <span style="color:#f92672">+</span>
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#34;FROM articles ORDER BY score DESC LIMIT 10&#34;</span>
</span></span><span style="display:flex;"><span>);
</span></span></code></pre></div><p>No new client method, no new wire shape, no version negotiation, because a function the server already knows is the cheapest feature to ship across three SDKs and a PHP client: if the engine can parse it, every client can call it, which is the same trick we pulled with the rest of the extended SQL surface and the reason the conformance fixtures can pin ranking behavior byte-for-byte across TypeScript, Rust, and Python.</p>
<h2 id="the-honest-tradeoff">The honest tradeoff</h2>
<p>The simplification worth being upfront about is IDF: real BM25 weights terms by how rare they are across the whole corpus, and a scalar UDF that sees one document at a time has no corpus, so the function omits inverse document frequency entirely and every query term weighs the same, which is a deliberate scope cut rather than an approximation, since there is nothing to approximate from. For the workload this UDF is aimed at, ranking a few hundred or few thousand candidate rows that a <code>WHERE</code> clause already narrowed down, term saturation plus term frequency gets you essentially the same ordering, and nobody has ever noticed IDF missing on a four-hundred-row result set. When you do need corpus-accurate relevance, the escape hatch is already in the engine: the <code>fts_docs</code> virtual table module maintains a real inverted index with global statistics, and that is where accurate BM25 lives, with the UDF remaining the right tool for ad-hoc scoring on regular tables. That split is the modern equivalent of a lesson from the MySQL years: <code>MATCH ... AGAINST</code> was fine until you wanted to rank anything it had not indexed, at which point you were stuck, and the fix was never a better index flag but a scoring primitive that stood on its own.</p>
]]></content:encoded></item></channel></rss>