<?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>Retrieval on MongrelDB</title><link>https://www.mongreldb.com/articles/tags/retrieval/</link><description>Recent content in Retrieval 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, 09 Nov 2026 09:00:00 -0600</lastBuildDate><atom:link href="https://www.mongreldb.com/articles/tags/retrieval/index.xml" rel="self" type="application/rss+xml"/><item><title>AI-Native Retrieval in One Engine</title><link>https://www.mongreldb.com/articles/2026/11/ai-native-retrieval-in-one-engine/</link><pubDate>Mon, 09 Nov 2026 09:00:00 -0600</pubDate><guid>https://www.mongreldb.com/articles/2026/11/ai-native-retrieval-in-one-engine/</guid><description>The default AI retrieval stack is five systems and a bag of glue code, and every boundary between them is a consistency bug; here is what changes when the row, the vector, the text, and the metadata live in one engine and fuse in one query.</description><content:encoded><![CDATA[<p>Somewhere around the second AI feature that makes it to production, a team looks at its architecture diagram and realizes it is operating a small zoo: Postgres for the rows, a vector database for the embeddings, Elasticsearch or OpenSearch for the text, Redis because the other three are slow in different ways, and a service in the middle whose only job is to fetch three ranked lists and merge them into one answer before the request times out. Each piece of that stack was a reasonable decision at the time, and I have made most of those decisions myself, but the composite has a property nobody chose: every boundary between systems is a place where consistency goes to die, because the document exists in Postgres, the embedding exists in Qdrant, the text exists in Elasticsearch, and there is no transaction on Earth that spans all three, so the answer to &ldquo;did my delete propagate&rdquo; is always &ldquo;eventually, probably, if the sync job is healthy.&rdquo; The reason the stack looks like this is historical rather than technical, since each of those systems was built when its signal was the only one that mattered, and the fusion problem got solved by a service because no single store could hold all the signals at once. That premise is what MongrelDB was built against, and this post is about what AI retrieval looks like when the premise stops being true.</p>
<h2 id="the-five-stack-tax">The five-stack tax</h2>
<p>The tax is not the hardware, although running five systems to answer one query is its own kind of extravagance; the tax is the semantics. When a user deletes a document, your application issues five deletes across five systems with five different durability guarantees, and the embedding service usually finds out last, which is how a RAG pipeline ends up confidently retrieving content the customer was promised was gone. When a write fails halfway, you now own a reconciliation problem that no single system&rsquo;s tooling can see, because each store is internally consistent and the inconsistency lives in the gaps. When you want a new filter, say &ldquo;only documents this tenant is allowed to see,&rdquo; you get to implement it once per system and then again in the fusion layer, and each implementation has subtly different idea of what &ldquo;allowed&rdquo; means. None of this is exotic; it is the ordinary Tuesday of any team running retrieval over multiple stores, and it is why so many AI features quietly ship with correctness bugs that only surface when someone audits them.</p>
<h2 id="one-row-six-indexes">One row, six indexes</h2>
<p>The alternative shape is the one MongrelDB implements: the operational data and the model-derived representations live in the same transactional row, and each signal gets a specialized index that resolves through one shared row-id space, so a single commit lands the document, its embedding, its sparse terms, its set fingerprint, and its metadata together, or none of them land at all. The index menu covers the signals that actually show up in AI workloads, and each one exists because a real retrieval pattern needs it:</p>
<table>
	<thead>
			<tr>
					<th>Signal</th>
					<th>Index</th>
					<th>Typical role</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Dense embedding</td>
					<td>HNSW</td>
					<td>Semantic candidate generation</td>
			</tr>
			<tr>
					<td>Sparse vector</td>
					<td>Inverted sparse index</td>
					<td>Rare-term and learned lexical relevance</td>
			</tr>
			<tr>
					<td>Text</td>
					<td>FM-index</td>
					<td>Exact substring constraints</td>
			</tr>
			<tr>
					<td>Token/entity/shingle set</td>
					<td>MinHash/LSH</td>
					<td>Near-duplicate and set-overlap candidates</td>
			</tr>
			<tr>
					<td>Tenant/category/status</td>
					<td>Roaring bitmap</td>
					<td>Access control and metadata filters</td>
			</tr>
			<tr>
					<td>Time/score</td>
					<td>PGM learned range</td>
					<td>Recency and numeric constraints</td>
			</tr>
	</tbody>
</table>
<p>This is the part that changes the daily work rather than the benchmark chart: agent memory retrieval by meaning, entities, recency, tenant, and memory type is one table with the right indexes, not a pipeline; ingestion-time near-duplicate detection is a MinHash lookup with exact Jaccard verification on the candidates, not a batch job over an export; prompt caching and training-data dedup stop being separate infrastructure because the fingerprints sit next to the rows they describe. The delete propagates in the same transaction as the delete, which sounds trivial until you have spent a quarter chasing the version where it does not.</p>
<h2 id="fusion-is-a-function-not-a-service">Fusion is a function, not a service</h2>
<p>The query that used to be a microservice is a <code>SearchRequest</code>: hard filters apply first, named retrievers run against their own indexes, the survivors union, and the ranks fuse with deterministic reciprocal rank fusion, so the result carries a fused score plus a component breakdown showing each retriever&rsquo;s rank, raw score, and contribution to the final ordering. In SQL that whole request is one call, with the dense and sparse retrievers side by side and the tenant filter applied as a hard condition before any scoring happens:</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, search_rank, fused_score, components
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> hybrid_search_scored(
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#39;docs&#39;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#39;{&#34;must&#34;:[{&#34;bitmap_eq&#34;:{&#34;column&#34;:&#34;tenant&#34;,&#34;value&#34;:&#34;acme&#34;}}],
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#34;retrievers&#34;:[
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      {&#34;name&#34;:&#34;dense&#34;,&#34;weight&#34;:1.0,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">       &#34;ann&#34;:{&#34;column&#34;:&#34;embedding&#34;,&#34;query&#34;:[0.12,-0.44,0.83],&#34;k&#34;:50}},
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      {&#34;name&#34;:&#34;sparse&#34;,&#34;weight&#34;:1.0,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">       &#34;sparse&#34;:{&#34;column&#34;:&#34;sparse_terms&#34;,&#34;query&#34;:[[1042,2.1],[877,1.4]],&#34;k&#34;:50}}
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    ],
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#34;rrf_constant&#34;:60,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#34;limit&#34;:10}&#39;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#39;id,title&#39;</span>
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> search_rank;
</span></span></code></pre></div><p>Two details in there matter more than they look. The hard filter is not a post-filter bolted onto the fused list, because the engine applies it during candidate generation and adaptively over-fetches so a selective policy still fills the window honestly, and it will return fewer than <code>k</code> rows rather than leak a row the tenant cannot see. And when approximate candidates need float-precision refinement, an optional post-fusion exact-vector stage reranks a bounded candidate window from the stored full-precision vectors, keeping the original fused score, the exact score, and the final rank visible side by side, so you can defend the ordering in a code review instead of waving at it. The fusion step itself is deterministic, which is a property you only learn to demand after debugging a ranker whose output depends on which worker answered first.</p>
<h2 id="the-guardrails-are-the-feature">The guardrails are the feature</h2>
<p>Retrieval that can run away from its budget is a production incident with a queue, so every scored surface in the engine is bounded by default and the bounds are part of the API rather than an operational wish: final hits cap at 10,000, candidates per retriever at 100,000, retrievers per request at 32, hard conditions at 256, sparse terms or set members at 65,536, and hybrid unions stop at 250,000 candidates while materializing only the ranked result window. Every scored endpoint accepts <code>deadline_ms</code> and <code>max_work</code>, runs off the async workers with cooperative cancellation, and shares a bounded concurrency semaphore, and the server-level ceilings are yours to tighten with <code>MONGRELDB_AI_MAX_FUSED_CANDIDATES</code> and <code>MONGRELDB_AI_MAX_CONCURRENT</code> when you would rather shed load than serve stale rankings late. This is the difference between a database and a demo, because the demo answers the query and the database answers it while thirty other tenants are asking questions of their own.</p>
<h2 id="when-you-still-want-the-zoo">When you still want the zoo</h2>
<p>Honesty requires the other side, and there is one: if your corpus is web-scale, if you employ people whose full-time job is tuning ranking functions, or if you already operate Elasticsearch well and your AI feature is a tenant of it rather than its reason to exist, the dedicated stack earns its keep, and a single embedded engine is not going to replace a search platform that a team has spent years shaping. The case for one engine is the common case rather than the extreme one: your data already fits the operational database&rsquo;s world, your AI features need the same five signals fused per request, your correctness requirements are &ldquo;the delete actually deletes&rdquo; and &ldquo;tenant A never sees tenant B,&rdquo; and your team would rather ship the feature than staff the pipeline. We ran the five-stack version of this in the 2010s with MySQL, Sphinx, and a memcached layer held together by cron, and the modern equivalent is Postgres plus pgvector plus OpenSearch plus a fusion service held together by hope; the tools got better and the seams did not, which is the entire argument for putting the seams inside one transaction and being done with it.</p>
]]></content:encoded></item></channel></rss>