<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Varun Sharma</title>
    <description>The latest articles on DEV Community by Varun Sharma (@varunsls).</description>
    <link>https://dev.to/varunsls</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4038851%2Fc88589e6-4d3a-4f32-acab-c787d0b8052f.png</url>
      <title>DEV Community: Varun Sharma</title>
      <link>https://dev.to/varunsls</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/varunsls"/>
    <language>en</language>
    <item>
      <title>Filtered vector search in pgvector can silently lose recall</title>
      <dc:creator>Varun Sharma</dc:creator>
      <pubDate>Mon, 20 Jul 2026 22:19:23 +0000</pubDate>
      <link>https://dev.to/varunsls/filtered-vector-search-in-pgvector-can-silently-lose-recall-1nim</link>
      <guid>https://dev.to/varunsls/filtered-vector-search-in-pgvector-can-silently-lose-recall-1nim</guid>
      <description>&lt;p&gt;&lt;em&gt;Why &lt;code&gt;WHERE tenant_id = 42 ORDER BY embedding &amp;lt;-&amp;gt; $query LIMIT 10&lt;/code&gt; can return fewer correct neighbors than you think - and how to know when it's happening to you.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Almost every RAG app eventually writes a query like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Give me the ten closest chunks to this query vector - but only for tenant 42. It looks harmless. Postgres returns rows, the app moves on.&lt;/p&gt;

&lt;p&gt;Here's the problem: those rows might not be the ten nearest filtered neighbors. Sometimes you may not even get ten rows back. And nothing - not an error, not a warning, not the query plan - tells you that recall collapsed.&lt;/p&gt;

&lt;p&gt;I spent the last few weeks measuring exactly when this happens and built a small tool to catch it. This is what I found.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually runs
&lt;/h2&gt;

&lt;p&gt;When Postgres chooses an HNSW index scan for &lt;code&gt;ORDER BY embedding &amp;lt;-&amp;gt; $query LIMIT k&lt;/code&gt;, pgvector searches the vector index &lt;em&gt;first&lt;/em&gt; and applies your &lt;code&gt;WHERE&lt;/code&gt; clause &lt;em&gt;afterward&lt;/em&gt;. That ordering matters more than it sounds.&lt;/p&gt;

&lt;p&gt;HNSW is an approximate index. It walks a graph and keeps an unfiltered search frontier - by default, &lt;code&gt;hnsw.ef_search = 40&lt;/code&gt;. That frontier is built before the SQL filter is applied. Then Postgres throws away the candidates that don't match &lt;code&gt;tenant_id = 42&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the real question isn't "how rare is tenant 42 in the whole table?" It's "how many candidates near this query vector belong to tenant 42?" If too few matching rows survive that frontier, you simply can't get 10 good filtered neighbors from the fixed-frontier scan. pgvector returns what survived the filter, and recall quietly drops.&lt;/p&gt;

&lt;p&gt;Why doesn't the planner warn you? Because PostgreSQL's planner is costing execution work, not semantic recall. It can prefer a plan that looks cheap while returning too few correct filtered neighbors, because recall is not part of the cost model.&lt;/p&gt;

&lt;h2&gt;
  
  
  I measured it at a million rows
&lt;/h2&gt;

&lt;p&gt;To make this concrete, I ran a benchmark on one million real SIFT vectors in Postgres 17 / pgvector 0.8.4. The filter matches 5% of the table globally, but is arranged so that for each query, &lt;em&gt;none&lt;/em&gt; of its exact top-40 nearest neighbors pass the filter. That's the adversarial case - a filter that's common overall but absent right where you're searching. (If you've ever had a tenant whose documents live in a different region of embedding space than the query, you've met it.)&lt;/p&gt;

&lt;p&gt;Same query, four ways of running it:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F35y8wjakl906oekpciy7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F35y8wjakl906oekpciy7.png" alt="recall@10 and returns-k rate by strategy on 1M filtered SIFT vectors" width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;recall@10&lt;/th&gt;
&lt;th&gt;returned all 10 rows?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;post-filter (the default)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.34&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;only 25% of queries&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;exact scan&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iterative scan&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;partial index&lt;/td&gt;
&lt;td&gt;0.86&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The default plan returned about a third of the correct results, and for three out of four queries it couldn't even find ten matching rows. Every alternative improved quality: an exact filtered scan and pgvector's iterative scan (added in 0.8.0) both recovered full recall, and a partial HNSW index built for this filter was actually the &lt;em&gt;fastest&lt;/em&gt; of the four while still hitting 0.86.&lt;/p&gt;

&lt;p&gt;The correct plans weren't some heavy tax you pay for safety - the default was simply the wrong pick here, and the information to know that was right there. The planner just doesn't use it.&lt;/p&gt;

&lt;p&gt;To be clear, this is a deliberately hard case, chosen to make the failure visible. With a friendly filter - one that's dense near your queries - post-filtering is fast and perfectly fine. And that's the whole point: &lt;strong&gt;whether the default is great or terrible depends on something Postgres never looks at.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Global selectivity is the wrong number
&lt;/h2&gt;

&lt;p&gt;The thing Postgres never looks at is what I've come to call &lt;em&gt;local&lt;/em&gt; (or frontier) selectivity: the fraction of your query's actual near-neighbors that pass the filter - not the fraction of the whole table.&lt;/p&gt;

&lt;p&gt;Those two numbers can be wildly different. A tenant might be 0.1% of your rows but 100% of the neighbors for their own queries, because their documents cluster together. Or 5% of your rows and 0% of the neighbors for a query that lands elsewhere. Global selectivity - the number Postgres estimates - tells you almost nothing about which case you're in. What decides whether post-filtering holds up is the &lt;em&gt;correlation&lt;/em&gt; between your filter and your vector space, and that's exactly the signal the cost model is missing.&lt;/p&gt;

&lt;p&gt;Once you see it that way, the fix isn't "post-filtering is bad." It's "the right strategy depends on local selectivity, so someone should actually measure it."&lt;/p&gt;

&lt;h2&gt;
  
  
  VecAdvisor
&lt;/h2&gt;

&lt;p&gt;So I built &lt;a href="https://github.com/vecadvisor/vecadvisor" rel="noopener noreferrer"&gt;VecAdvisor&lt;/a&gt; - a small, read-only CLI that does the measuring.&lt;/p&gt;

&lt;p&gt;You point it at your database and a query, and it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;reads your table statistics and reproduces Postgres's own global selectivity estimate,&lt;/li&gt;
&lt;li&gt;runs one cheap probe - a single &lt;code&gt;count(*) FILTER (...)&lt;/code&gt; over your query's nearest-neighbor frontier - to measure &lt;em&gt;local&lt;/em&gt; selectivity where the query actually lands,&lt;/li&gt;
&lt;li&gt;costs every strategy (exact, post-filter, iterative, partial index, partitioning) with a calibrated model, and&lt;/li&gt;
&lt;li&gt;tells you which one is recall-safe, and why.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It never touches your planner or your data. It's an advisor, not a patch. A run reads like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EXPLAIN VECTOR  documents (1M rows, HNSW)
  filter: tenant_id = 42
  s(global) = 0.05    s(local) = 0.00   &amp;lt;- filter is sparse near this query

  &amp;gt; EXACT / iterative     recall ~1.00   returns full k? yes   RECOMMENDED
    post-filter (ef=40)   recall ~0.34   returns full k? no    &amp;lt;- the planner's default

  WHY: only ~0 of the nearest-neighbor frontier matches the filter, so post-filter
       cannot return 10 good rows. Use iterative scan, a filtered index, or tune ef_search.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I want to be honest about where it stands. In my measured sweep, VecAdvisor recommended a plan that met the recall target in &lt;strong&gt;every&lt;/strong&gt; case where the default post-filter failed - 9 out of 9. It picked the &lt;em&gt;exact fastest&lt;/em&gt; safe plan in 4 of those 9; in the rest it chose a safe plan that was a little slower than the absolute optimum.&lt;/p&gt;

&lt;p&gt;"Never quietly lose recall" is the promise for this first version; "always pick the latency-optimal plan" is calibration work I'm still doing. I'd rather ship something that's honestly &lt;em&gt;always safe, usually fast&lt;/em&gt; than pretend it's perfect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;vecadvisor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's alpha, Apache-2.0, and not affiliated with the pgvector project - it just works alongside it. The &lt;a href="https://github.com/vecadvisor/vecadvisor" rel="noopener noreferrer"&gt;repo&lt;/a&gt; has the full benchmark (reproducible from a fresh checkout), the cost model, and the design notes.&lt;/p&gt;

&lt;p&gt;If you run filtered vector search in Postgres, I'd genuinely like to know whether this catches something real in your workload. Stars and issues both help - and honest "this got it wrong for me" reports help most of all.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>ai</category>
      <category>rag</category>
    </item>
  </channel>
</rss>
