<?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: RecallRun</title>
    <description>The latest articles on DEV Community by RecallRun (@recallrundev).</description>
    <link>https://dev.to/recallrundev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4160628%2F626bba7e-f376-4429-beb0-6b0aa0331e52.webp</url>
      <title>DEV Community: RecallRun</title>
      <link>https://dev.to/recallrundev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/recallrundev"/>
    <language>en</language>
    <item>
      <title>Make your RAG say 'I don't know': a score threshold, measured</title>
      <dc:creator>RecallRun</dc:creator>
      <pubDate>Sun, 25 Oct 2026 11:06:23 +0000</pubDate>
      <link>https://dev.to/recallrundev/make-your-rag-say-i-dont-know-a-score-threshold-measured-1k6c</link>
      <guid>https://dev.to/recallrundev/make-your-rag-say-i-dont-know-a-score-threshold-measured-1k6c</guid>
      <description>&lt;p&gt;A RAG system always gets something back from the index. Ask it about cooking and it returns the five nearest chunks of API docs, and the LLM then tries to be helpful. So: can a simple score threshold on retrieval tell "the docs cover this" from "they don't"?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; yes, mostly. On the FastAPI docs, the top-1 cosine score separated the two groups well (AUC 0.986), and a threshold of 0.40 refused 24 of 30 unanswerable questions while answering 29 of 30 answerable ones. The leftovers are near-domain questions, which no threshold fully fixes. Raw BM25 scores did worse, and BM25 almost never returns an empty result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Corpus:&lt;/strong&gt; 152 files of the FastAPI docs, split into 2,808 heading-aware chunks with the heading path prefixed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrievers:&lt;/strong&gt; MiniLM-L6-v2 cosine similarity, and BM25 over the same chunks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;30 answerable queries:&lt;/strong&gt; hand-written questions, each matching one docs page (for example "How do I set a cookie on the response?").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;30 unanswerable queries:&lt;/strong&gt; 15 near-domain (pagination, rate limiting, gRPC, Terraform, Prometheus, Selenium...) and 15 unrelated (egg boiling, football, tides...). I checked by keyword search that the near-domain topics are not documented pages, though a few terms are mentioned in passing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decision score:&lt;/strong&gt; the top-1 chunk's score. Refuse when it is below the threshold.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;Top-1 cosine score by group:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Group&lt;/th&gt;
&lt;th&gt;Median&lt;/th&gt;
&lt;th&gt;Min&lt;/th&gt;
&lt;th&gt;Max&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Answerable (30)&lt;/td&gt;
&lt;td&gt;0.619&lt;/td&gt;
&lt;td&gt;0.393&lt;/td&gt;
&lt;td&gt;0.818&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unanswerable, near-domain (15)&lt;/td&gt;
&lt;td&gt;0.385&lt;/td&gt;
&lt;td&gt;0.256&lt;/td&gt;
&lt;td&gt;0.508&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unanswerable, unrelated (15)&lt;/td&gt;
&lt;td&gt;0.218&lt;/td&gt;
&lt;td&gt;0.175&lt;/td&gt;
&lt;td&gt;0.298&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;How well each score separates answerable from unanswerable (AUC, 1.0 is perfect, 0.5 is chance):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;AUC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cosine top-1&lt;/td&gt;
&lt;td&gt;0.986&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BM25 top-1, stopwords removed&lt;/td&gt;
&lt;td&gt;0.927&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BM25 top-1, raw&lt;/td&gt;
&lt;td&gt;0.910&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cosine gap between top-1 and top-2&lt;/td&gt;
&lt;td&gt;0.649&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cosine threshold sweep:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Threshold&lt;/th&gt;
&lt;th&gt;Answerable kept&lt;/th&gt;
&lt;th&gt;Near-domain refused&lt;/th&gt;
&lt;th&gt;Unrelated refused&lt;/th&gt;
&lt;th&gt;All unanswerable refused&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.30&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;13%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;57%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.35&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;20%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;60%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.40&lt;/td&gt;
&lt;td&gt;97% (29/30)&lt;/td&gt;
&lt;td&gt;60%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;80% (24/30)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.45&lt;/td&gt;
&lt;td&gt;93% (28/30)&lt;/td&gt;
&lt;td&gt;73%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;87% (26/30)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.50&lt;/td&gt;
&lt;td&gt;90% (27/30)&lt;/td&gt;
&lt;td&gt;93%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;97% (29/30)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.55&lt;/td&gt;
&lt;td&gt;73%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&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;I also split each group in half, picked the threshold on one half and scored the other. Requiring every answerable calibration query to be kept gave a threshold of 0.393. On the held-out half it kept 15 of 15 answerable queries and refused 11 of 14 unanswerable ones (79%).&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers mean
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unrelated questions are easy.&lt;/strong&gt; Their best score (0.298) is below the worst answerable score (0.393). Any threshold between 0.30 and 0.39 catches all of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Near-domain questions are the real problem.&lt;/strong&gt; Their scores run up to 0.508, which overlaps the lowest answerable queries. The highest-scoring one was "How do I expose Prometheus metrics from my service?" at 0.508, matched to the OpenTelemetry page. Arguably that page is a partial answer, so this one is a labeling grey zone as much as a miss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The cost of tightening is visible.&lt;/strong&gt; Going from 0.40 to 0.50 refuses 5 more unanswerable queries but also loses 2 more answerable ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BM25 is a weaker gate.&lt;/strong&gt; Its raw scores depend on query length and term rarity, so one cutoff does not transfer. It also never returned an empty candidate list for any of the 60 queries, because words like "how" and "do" match something. With stopwords removed, only 5 of the 30 unanswerable queries came back empty. "Check for empty results" alone is not a refusal strategy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The top-1 vs top-2 gap is nearly useless here&lt;/strong&gt; (AUC 0.649).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Caveats
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;60 queries is small. One query moves a percentage by about 3 points.&lt;/li&gt;
&lt;li&gt;I wrote both query sets myself, and the unanswerable ones are cleaner than real traffic.&lt;/li&gt;
&lt;li&gt;Thresholds are specific to this model and corpus. Recalibrate whenever you change the embedding model or re-chunk.&lt;/li&gt;
&lt;li&gt;This tests retrieval only. A model can still hallucinate when the retrieved chunks are on topic but do not contain the answer; that needs an answer-level check such as citation validation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Copyable code
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;should_refuse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query_vec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk_matrix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.40&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Vectors must be L2-normalised. Returns (refuse, top_score, top_index).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;sims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chunk_matrix&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt; &lt;span class="n"&gt;query_vec&lt;/span&gt;
    &lt;span class="n"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sims&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt;

&lt;span class="n"&gt;REFUSAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I couldn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t find this in the documentation.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pick the threshold from your own labeled set: take the highest value that still answers at least 95-100% of your known-answerable queries, then check what it refuses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule of thumb
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Build a set of at least 50 answerable and 50 unanswerable queries from your own logs, including near-domain ones.&lt;/li&gt;
&lt;li&gt;Use the top-1 cosine score, not BM25, as the gate, and set the threshold just under your lowest answerable score.&lt;/li&gt;
&lt;li&gt;Put the threshold in a test, so a model or chunking change that moves the score scale fails CI.&lt;/li&gt;
&lt;li&gt;Expect to refuse most, not all, of the near-domain questions. Treat the rest with answer-level checks.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The script and raw output are on the &lt;a href="https://recallrun.dev/posts/empty-retrieval-refusal/?utm_source=devto&amp;amp;utm_campaign=rag-idk" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;I run experiments like this every week at RecallRun. If you want to build RAG systems end to end, the AI Engineering course starts free: &lt;a href="https://recallrun.dev/learn/ai-engineering/?utm_source=devto&amp;amp;utm_campaign=rag-idk" rel="noopener noreferrer"&gt;https://recallrun.dev/learn/ai-engineering/?utm_source=devto&amp;amp;utm_campaign=rag-idk&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rag</category>
      <category>python</category>
      <category>llm</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Metadata filtering in vector search: post-filter loses results, measured</title>
      <dc:creator>RecallRun</dc:creator>
      <pubDate>Thu, 15 Oct 2026 19:19:58 +0000</pubDate>
      <link>https://dev.to/recallrundev/metadata-filtering-in-vector-search-post-filter-loses-results-measured-4gm2</link>
      <guid>https://dev.to/recallrundev/metadata-filtering-in-vector-search-post-filter-loses-results-measured-4gm2</guid>
      <description>&lt;p&gt;Multi-tenant RAG needs a filter: “only search documents this user may see.” Vector indexes are built for nearest neighbours, not for “nearest neighbours among these 1,000.” So where does the filter go: after the search, inside it, or before it?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; post-filtering silently returns fewer results as the filter gets more selective. In my test, a filter that keeps 1% of the data returned 1.9 results out of 10 when I fetched the top 100 and filtered. In-graph filtering fixed recall but got slow. For selective filters, exact search over the filtered subset was the fastest and always correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;100,000 vectors, each with a tenant id. I ran two layouts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;random&lt;/strong&gt;: the tenant is independent of content. Think “documents from many customers about the same product.”&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;topical&lt;/strong&gt;: each tenant owns whole topic clusters, and its queries land in its own region.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Selectivity is the fraction of the corpus the filter keeps: about 50%, 10%, 1% and 0.1%. Ground truth is exact search restricted to the tenant’s vectors, so recall@10 here means “how many of the true filtered top 10 did the method return.”&lt;/p&gt;

&lt;p&gt;Methods:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;post-filter&lt;/strong&gt;: ask HNSW for the top &lt;em&gt;k’&lt;/em&gt; (10, 100 or 1000), drop results that fail the filter, keep up to 10.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;in-graph filter&lt;/strong&gt;: hnswlib’s &lt;code&gt;filter&lt;/code&gt; argument, evaluated while the graph is traversed (&lt;a href="https://github.com/nmslib/hnswlib" rel="noopener noreferrer"&gt;hnswlib&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pre-filter exact&lt;/strong&gt;: select the allowed rows, then do a brute-force dot product. I timed two versions: build the subset per query (mask + gather) and keep a per-tenant matrix prebuilt (partitioned).
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;post_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fetch&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;knn_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;]][:&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;in_graph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;knn_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pre_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X_tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ids_tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;  &lt;span class="c1"&gt;# X_tenant = X[ids_tenant], built once
&lt;/span&gt;    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;X_tenant&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
    &lt;span class="n"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argpartition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids_tenant&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argsort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;])]])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Results: random layout
&lt;/h2&gt;

&lt;p&gt;Recall@10 / average results returned (max 10) / median latency.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Selectivity&lt;/th&gt;
&lt;th&gt;post k=10&lt;/th&gt;
&lt;th&gt;post k=100&lt;/th&gt;
&lt;th&gt;post k=1000 (ef=1000)&lt;/th&gt;
&lt;th&gt;in-graph filter&lt;/th&gt;
&lt;th&gt;pre-filter (partitioned)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;50%&lt;/td&gt;
&lt;td&gt;0.553 / 5.5 / 0.21 ms&lt;/td&gt;
&lt;td&gt;1.000 / 10 / 0.24 ms&lt;/td&gt;
&lt;td&gt;1.000 / 10 / 1.94 ms&lt;/td&gt;
&lt;td&gt;1.000 / 10 / 0.44 ms&lt;/td&gt;
&lt;td&gt;1.000 / 10 / 1.38 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;0.181 / 1.8 / 0.20 ms&lt;/td&gt;
&lt;td&gt;0.908 / 9.1 / 0.22 ms&lt;/td&gt;
&lt;td&gt;1.000 / 10 / 1.83 ms&lt;/td&gt;
&lt;td&gt;1.000 / 10 / 2.19 ms&lt;/td&gt;
&lt;td&gt;1.000 / 10 / 0.16 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1%&lt;/td&gt;
&lt;td&gt;0.110 / 1.1 / 0.20 ms&lt;/td&gt;
&lt;td&gt;0.191 / 1.9 / 0.22 ms&lt;/td&gt;
&lt;td&gt;0.884 / 9.2 / 1.88 ms&lt;/td&gt;
&lt;td&gt;1.000 / 10 / 17.03 ms&lt;/td&gt;
&lt;td&gt;1.000 / 10 / 0.03 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.1%&lt;/td&gt;
&lt;td&gt;0.100 / 1.0 / 0.14 ms&lt;/td&gt;
&lt;td&gt;0.105 / 1.0 / 0.16 ms&lt;/td&gt;
&lt;td&gt;0.214 / 2.1 / 1.79 ms&lt;/td&gt;
&lt;td&gt;1.000 / 10 / 74.22 ms&lt;/td&gt;
&lt;td&gt;1.000 / 10 / 0.01 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Post-filtering behaves like a coin flip with the selectivity as the odds: fetch 10, keep about &lt;code&gt;selectivity × 10&lt;/code&gt;. The query is never told that most of its neighbours are off-limits. Even fetching 1,000 candidates returned only 2.1 results per query at 0.1%.&lt;/p&gt;

&lt;p&gt;In-graph filtering never lost recall, but its cost grows as the filter tightens, from 0.44 ms at 50% to 74.22 ms at 0.1%. The traversal has to wander through many forbidden nodes to find allowed ones. At 0.1% it took 74.22 ms, while the exact scan of the 100-vector subset took 0.01 ms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results: topical layout
&lt;/h2&gt;

&lt;p&gt;When tenants own their own regions of the space, the problem mostly disappears.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Selectivity&lt;/th&gt;
&lt;th&gt;post k=10&lt;/th&gt;
&lt;th&gt;in-graph filter&lt;/th&gt;
&lt;th&gt;pre-filter (partitioned)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;50%&lt;/td&gt;
&lt;td&gt;1.000 / 0.20 ms&lt;/td&gt;
&lt;td&gt;1.000 / 0.22 ms&lt;/td&gt;
&lt;td&gt;1.000 / 1.34 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;1.000 / 0.20 ms&lt;/td&gt;
&lt;td&gt;1.000 / 0.29 ms&lt;/td&gt;
&lt;td&gt;1.000 / 0.16 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1%&lt;/td&gt;
&lt;td&gt;1.000 / 0.12 ms&lt;/td&gt;
&lt;td&gt;1.000 / 0.61 ms&lt;/td&gt;
&lt;td&gt;1.000 / 0.03 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.1%&lt;/td&gt;
&lt;td&gt;1.000 / 0.07 ms&lt;/td&gt;
&lt;td&gt;1.000 / 0.16 ms&lt;/td&gt;
&lt;td&gt;1.000 / 0.01 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Recall is 1.000 for every method. The query’s nearest neighbours are already inside the allowed set, so the filter rejects almost nothing. This is the trap: post-filtering looks fine in a demo where a user asks about their own topic, then fails when a filter is independent of the query. Examples are a date range, a document type, or a tenant whose content overlaps with everyone else’s. Real tenants are somewhere between my two layouts, so test with your own data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers mean
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Post-filter is only safe when the filter is loose or correlated with the query.&lt;/strong&gt; Below about 10% selectivity on independent filters it breaks, and it breaks quietly: fewer results, not an error. The 0.1% row returned a single result on average.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over-fetching helps, but needs a lot.&lt;/strong&gt; To reach 0.884 recall at 1% selectivity I had to fetch 1,000 and raise &lt;code&gt;ef&lt;/code&gt; to 1,000, which cost 1.88 ms versus 0.22 ms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In-graph filtering is the default to reach for between ~10% and 100%&lt;/strong&gt;, where it stayed between 0.22 and 2.19 ms (and at 10% the exact partitioned scan was faster, 0.16 ms).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Below ~1%, scan the subset.&lt;/strong&gt; At 1% the subset has about 1,000 vectors, so a dot product takes 0.03 ms. This is the same reasoning as in &lt;a href="//../do-you-need-a-vector-database/"&gt;Do you need a vector database?&lt;/a&gt;: exact search is cheap at small sizes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Caveats
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Synthetic Gaussian clusters, 128 dimensions, one index configuration. Real embeddings will have different structure. Treat the shape of the curves as the result, not the exact milliseconds.&lt;/li&gt;
&lt;li&gt;One tenant per setting, 200 queries each. Latency is a median on a shared 2 vCPU machine.&lt;/li&gt;
&lt;li&gt;The partitioned pre-filter times exclude building the per-tenant matrices. It needs memory for a second copy of the vectors, or an index per tenant.&lt;/li&gt;
&lt;li&gt;hnswlib’s filter is a Python callback here. A native filter in a database engine will have different costs. The qualitative trend (slower as selectivity falls) is reported for hnswlib in the &lt;a href="https://arxiv.org/abs/2507.11907" rel="noopener noreferrer"&gt;SIEVE paper&lt;/a&gt;, which also notes that hnswlib applies the filter during search.&lt;/li&gt;
&lt;li&gt;My first run gave a misleading 32 ms for exact search over 50k vectors. The cause was a float64 query array: &lt;code&gt;np.sqrt(D)&lt;/code&gt; returns a float64 scalar, and under NumPy 2 this upcast the query, so each dot product converted the whole float32 matrix. I fixed it by casting queries to float32 and reran everything. Check dtypes if your brute-force numbers look too slow.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Rule of thumb
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Estimate selectivity per query: allowed rows divided by total rows.&lt;/li&gt;
&lt;li&gt;Above ~50%, post-filter with a modest over-fetch is fine.&lt;/li&gt;
&lt;li&gt;Between ~10% and 50%, use the index’s in-graph filter.&lt;/li&gt;
&lt;li&gt;Below ~1%, or when the filter is a hard permission boundary, search the filtered subset exactly (or give each tenant its own index).&lt;/li&gt;
&lt;li&gt;Always log how many results survive the filter. A silent drop from 10 to 1 is the failure to catch. A test that asserts &lt;code&gt;len(results) == k&lt;/code&gt; on an independent filter would have caught it here.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For the basics of embeddings and indexes, see the free lesson &lt;a href="//../../learn/ai-engineering/06-vector-search.html"&gt;Embeddings &amp;amp; vector databases&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://recallrun.dev/posts/rag-metadata-filtering/" rel="noopener noreferrer"&gt;RecallRun&lt;/a&gt;.&lt;/em&gt; I run experiments like this every week at RecallRun. If you want to build RAG systems end to end, the AI Engineering course starts free: &lt;a href="https://recallrun.dev/learn/ai-engineering/?utm_source=devto&amp;amp;utm_campaign=metadata-filter" rel="noopener noreferrer"&gt;https://recallrun.dev/learn/ai-engineering/?utm_source=devto&amp;amp;utm_campaign=metadata-filter&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rag</category>
      <category>vectordatabase</category>
      <category>python</category>
      <category>performance</category>
    </item>
    <item>
      <title>Do you need a vector database? Brute-force search benchmarked from 10k to 1M vectors</title>
      <dc:creator>RecallRun</dc:creator>
      <pubDate>Wed, 07 Oct 2026 11:01:14 +0000</pubDate>
      <link>https://dev.to/recallrundev/do-you-need-a-vector-database-brute-force-search-benchmarked-from-10k-to-1m-vectors-6fk</link>
      <guid>https://dev.to/recallrundev/do-you-need-a-vector-database-brute-force-search-benchmarked-from-10k-to-1m-vectors-6fk</guid>
      <description>&lt;p&gt;Most RAG tutorials start by installing a vector database. For a lot of projects, that is a dependency you don't need yet. I measured how long plain exact search takes as the corpus grows, and what an HNSW index buys you in return.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; below about 100,000 chunks, a numpy matrix multiply is fast enough for almost any RAG app. Between 100k and 1M it depends on your traffic. Past that, use an index.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Every embedding is normalized, so cosine similarity is a dot product. Exact search is one matrix-vector product and a partial sort:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ndarray&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ndarray&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ndarray&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;X: (n, d) normalized float32 matrix. q: (d,) normalized query.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;X&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
    &lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argpartition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argsort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;])]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I timed this against &lt;code&gt;faiss.IndexFlatIP&lt;/code&gt; (also exact) for three common embedding sizes: 384 (MiniLM-class models), 768 (BERT-base-class) and 1536 (OpenAI &lt;code&gt;text-embedding-3-small&lt;/code&gt; default). The machine is deliberately small: 2 vCPUs, the size of a cheap cloud instance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exact search latency
&lt;/h2&gt;

&lt;p&gt;Single-query latency, median of 50 queries:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dim&lt;/th&gt;
&lt;th&gt;Vectors&lt;/th&gt;
&lt;th&gt;RAM for vectors&lt;/th&gt;
&lt;th&gt;numpy&lt;/th&gt;
&lt;th&gt;faiss Flat&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;384&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;15 MB&lt;/td&gt;
&lt;td&gt;0.35 ms&lt;/td&gt;
&lt;td&gt;0.78 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;384&lt;/td&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;154 MB&lt;/td&gt;
&lt;td&gt;3.5 ms&lt;/td&gt;
&lt;td&gt;8.3 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;384&lt;/td&gt;
&lt;td&gt;500,000&lt;/td&gt;
&lt;td&gt;768 MB&lt;/td&gt;
&lt;td&gt;31 ms&lt;/td&gt;
&lt;td&gt;66 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;384&lt;/td&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;td&gt;1.5 GB&lt;/td&gt;
&lt;td&gt;62 ms&lt;/td&gt;
&lt;td&gt;122 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;768&lt;/td&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;307 MB&lt;/td&gt;
&lt;td&gt;10.6 ms&lt;/td&gt;
&lt;td&gt;22.6 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;768&lt;/td&gt;
&lt;td&gt;500,000&lt;/td&gt;
&lt;td&gt;1.5 GB&lt;/td&gt;
&lt;td&gt;57 ms&lt;/td&gt;
&lt;td&gt;120 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;768&lt;/td&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;td&gt;3.1 GB&lt;/td&gt;
&lt;td&gt;114 ms&lt;/td&gt;
&lt;td&gt;240 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1536&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;61 MB&lt;/td&gt;
&lt;td&gt;1.2 ms&lt;/td&gt;
&lt;td&gt;3.1 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1536&lt;/td&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;614 MB&lt;/td&gt;
&lt;td&gt;20 ms&lt;/td&gt;
&lt;td&gt;51 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1536&lt;/td&gt;
&lt;td&gt;500,000&lt;/td&gt;
&lt;td&gt;3.1 GB&lt;/td&gt;
&lt;td&gt;107 ms&lt;/td&gt;
&lt;td&gt;248 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things stand out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Latency scales linearly with &lt;code&gt;n × d&lt;/code&gt;.&lt;/strong&gt; It is a memory-bandwidth problem. Doubling either the corpus or the dimension doubles the time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory is the real limit, not speed.&lt;/strong&gt; 1M vectors at 1536 dims is 6 GB of float32 before any metadata. I skipped that row: it won't fit comfortably on a 7 GB machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plain numpy beat faiss Flat for single queries here&lt;/strong&gt;, by about 2×. Faiss is built for batches; one query at a time pays overhead that numpy's direct matrix-vector call doesn't. If you batch queries, test again before assuming this holds.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What an HNSW index buys you
&lt;/h2&gt;

&lt;p&gt;Next, I built &lt;code&gt;hnswlib&lt;/code&gt; indexes (M=16, ef_construction=200) and measured query time and &lt;strong&gt;recall@10&lt;/strong&gt;: the fraction of the true top 10 the index actually returns.&lt;/p&gt;

&lt;p&gt;I used two synthetic datasets, because recall depends heavily on how your data is shaped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Uniform:&lt;/strong&gt; random Gaussian directions. This is the worst case for any approximate index: no structure to exploit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clustered:&lt;/strong&gt; 2,000 tight clusters. Real embeddings sit between these two, usually much closer to clustered, since documents about the same topic land near each other.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Data&lt;/th&gt;
&lt;th&gt;Dim&lt;/th&gt;
&lt;th&gt;Vectors&lt;/th&gt;
&lt;th&gt;Build time&lt;/th&gt;
&lt;th&gt;ef=32&lt;/th&gt;
&lt;th&gt;ef=64&lt;/th&gt;
&lt;th&gt;ef=128&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;clustered&lt;/td&gt;
&lt;td&gt;384&lt;/td&gt;
&lt;td&gt;100k&lt;/td&gt;
&lt;td&gt;23 s&lt;/td&gt;
&lt;td&gt;0.10 ms, recall 0.954&lt;/td&gt;
&lt;td&gt;0.17 ms, 0.995&lt;/td&gt;
&lt;td&gt;0.34 ms, 1.000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;clustered&lt;/td&gt;
&lt;td&gt;768&lt;/td&gt;
&lt;td&gt;100k&lt;/td&gt;
&lt;td&gt;48 s&lt;/td&gt;
&lt;td&gt;0.22 ms, 0.878&lt;/td&gt;
&lt;td&gt;0.36 ms, 0.985&lt;/td&gt;
&lt;td&gt;0.68 ms, 1.000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;uniform&lt;/td&gt;
&lt;td&gt;384&lt;/td&gt;
&lt;td&gt;100k&lt;/td&gt;
&lt;td&gt;37 s&lt;/td&gt;
&lt;td&gt;0.16 ms, 0.129&lt;/td&gt;
&lt;td&gt;0.31 ms, 0.206&lt;/td&gt;
&lt;td&gt;0.55 ms, 0.281&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;uniform&lt;/td&gt;
&lt;td&gt;768&lt;/td&gt;
&lt;td&gt;100k&lt;/td&gt;
&lt;td&gt;69 s&lt;/td&gt;
&lt;td&gt;0.34 ms, 0.058&lt;/td&gt;
&lt;td&gt;0.63 ms, 0.105&lt;/td&gt;
&lt;td&gt;1.07 ms, 0.172&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;uniform&lt;/td&gt;
&lt;td&gt;384&lt;/td&gt;
&lt;td&gt;500k&lt;/td&gt;
&lt;td&gt;279 s&lt;/td&gt;
&lt;td&gt;0.19 ms, 0.034&lt;/td&gt;
&lt;td&gt;0.35 ms, 0.057&lt;/td&gt;
&lt;td&gt;0.67 ms, 0.099&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;HNSW queries are &lt;strong&gt;10 to 100 times faster&lt;/strong&gt; than exact search at these sizes. The cost:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build time.&lt;/strong&gt; 500k vectors took over 4.5 minutes on 2 cores. You pay this again on every full re-index, such as after switching embedding models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recall is not guaranteed.&lt;/strong&gt; On structured data, &lt;code&gt;ef=64&lt;/code&gt; gave near-perfect recall. On structureless data, the same settings returned less than a quarter of the true neighbors. Your data will be somewhere in between, so &lt;strong&gt;measure recall on your own embeddings&lt;/strong&gt; before trusting the defaults.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where the time actually goes in a RAG request
&lt;/h2&gt;

&lt;p&gt;A typical RAG request spends 1 to 5 seconds waiting for the LLM to generate. Next to that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;3.5 ms (100k × 384, exact) is invisible.&lt;/li&gt;
&lt;li&gt;60 to 110 ms (1M exact) is noticeable only if you care about p99, or serve many queries per second on the same box.&lt;/li&gt;
&lt;li&gt;Exact search gives perfect recall for free, which removes one variable when you debug bad answers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A rough sizing rule: one PDF page is usually 2 to 3 chunks. &lt;strong&gt;100k chunks is roughly 30,000 to 50,000 pages.&lt;/strong&gt; Many internal "chat with our docs" projects never get there.&lt;/p&gt;

&lt;h2&gt;
  
  
  My rule of thumb
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your corpus&lt;/th&gt;
&lt;th&gt;What I'd use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;under 100k chunks&lt;/td&gt;
&lt;td&gt;numpy (or pgvector with no index). Store vectors in a &lt;code&gt;.npy&lt;/code&gt; file or Postgres.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100k to 1M chunks&lt;/td&gt;
&lt;td&gt;exact search is still fine at low QPS. Add HNSW when p95 latency or QPS demands it, and measure recall.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;over 1M chunks, or many tenants&lt;/td&gt;
&lt;td&gt;a proper ANN index (pgvector HNSW, Qdrant, Milvus, OpenSearch), plus filtering and sharding.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most importantly, &lt;strong&gt;keep the vector store behind one interface&lt;/strong&gt; so you can swap numpy for pgvector later without touching the rest of the pipeline. That one seam is worth more than picking the "right" database on day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;p&gt;The exact-search benchmark:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;faiss&lt;/span&gt;
&lt;span class="n"&gt;faiss&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;omp_set_num_threads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;rng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;default_rng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;norm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;linalg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keepdims&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;384&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;768&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1536&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1_000_000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;3.2e9&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;          &lt;span class="c1"&gt;# skip what won't fit in RAM
&lt;/span&gt;        &lt;span class="n"&gt;X&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;standard_normal&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;float32&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;Q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;standard_normal&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;float32&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;Q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;sc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;X&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
            &lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argpartition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="mf"&gt;1e3&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For HNSW, build with &lt;code&gt;hnswlib.Index(space="ip", dim=d)&lt;/code&gt;, &lt;code&gt;init_index(max_elements=n, ef_construction=200, M=16)&lt;/code&gt;, and compare &lt;code&gt;knn_query&lt;/code&gt; results against the exact top 10 to get recall.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caveat:&lt;/strong&gt; these are synthetic vectors on one small machine. Latency for exact search doesn't depend on what the vectors mean, so those numbers transfer well. HNSW recall does depend on your data, which is exactly why you should measure it yourself.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Test setup: 2 vCPU Intel Xeon @ 2.1 GHz, 7 GB RAM, Python 3.11, numpy 2.4.4, faiss-cpu 1.15.1, hnswlib. Synthetic normalized float32 vectors. Median of 50 (exact) or 200 (HNSW) single queries.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I run experiments like this every week at RecallRun. If you want to build RAG systems end to end, the AI Engineering course starts free: &lt;a href="https://recallrun.dev/learn/ai-engineering/?utm_source=devto&amp;amp;utm_campaign=vector-db" rel="noopener noreferrer"&gt;https://recallrun.dev/learn/ai-engineering/?utm_source=devto&amp;amp;utm_campaign=vector-db&lt;/a&gt;&lt;/p&gt;

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