Back to blog
Performance

Why OpenSearch k-NN with a filter returns fewer results than k, and how to fix it

A filter outside the knn clause runs after the vector search, so a 1% filter keeps about 1% of k. We ran every filter method at six selectivities on OpenSearch 3.9.0 and logged the hit counts.

Abhilash B12 min read
#opensearch#vector search#k-nn#filtering#faiss

An OpenSearch knn filter returns fewer than k results when it runs after the vector search: the knn clause picks the k nearest documents first, and a post_filter or a bool filter beside it then throws away every neighbor that does not match. The fix is to move the filter inside the knn clause, on the Faiss or Lucene engine, which the docs call efficient k-NN filtering. It returns k results whenever at least k documents match.

Everything below ran on OpenSearch 3.9.0 (Lucene 10.5.1, k-NN plugin 3.9.0.0), the current release as of 31 October 2026, on a single-node Docker container with security off. The corpus is illustrative: 50,000 vectors of 128 dimensions drawn around 200 random cluster centers, with an integer bucket field from 0 to 999 assigned at random, independent of the vector. A filter of bucket < N therefore matches about N/10 percent of the index. Each index has one shard, force-merged to one segment. Every number in the tables is the average over the same 50 query vectors, with k: 10. The corpus is too small to say anything about latency, so we report hit counts and recall only.

The three filter placements

The filtering docs describe efficient filtering as applying the filter "during the vector search", and warn that post-filtering "may return significantly fewer than k results for a restrictive filter". These are the request shapes we ran:

// 1. Efficient filtering: filter inside the knn clause
{ "size": 10, "query": { "knn": { "emb": {
    "vector": [0.41, -1.23, ...], "k": 10,
    "filter": { "range": { "bucket": { "lt": 10 } } } } } } }

// 2a. Post-filter with the post_filter parameter
{ "size": 10, "query": { "knn": { "emb": { "vector": [...], "k": 10 } } },
  "post_filter": { "bool": { "filter": [ { "range": { "bucket": { "lt": 10 } } } ] } } }

// 2b. Post-filter with a bool filter beside the knn clause
{ "size": 10, "query": { "bool": {
    "must":   [ { "knn": { "emb": { "vector": [...], "k": 10 } } } ],
    "filter": [ { "range": { "bucket": { "lt": 10 } } } ] } } }

// 3. Exact search over the filtered subset
{ "size": 10, "query": { "script_score": {
    "query": { "bool": { "filter": [ { "range": { "bucket": { "lt": 10 } } } ] } },
    "script": { "source": "knn_score", "lang": "knn",
      "params": { "field": "emb", "query_value": [...], "space_type": "l2" } } } } }

The post_filter range is wrapped in bool on purpose. A bare range there hits a bug on 3.x, covered further down.

MethodFilter runsSearch typeEngines (3.9 docs)
Efficient filteringDuring the searchApproximate, with exact fallbacksLucene HNSW (2.4+), Faiss HNSW (2.9+) and IVF (2.10+)
post_filterAfter the searchApproximateLucene, Faiss, nmslib
bool filterAfter the searchApproximateLucene, Faiss, nmslib
script_scoreBefore the searchExactAny knn_vector field

nmslib supports only the post-filter rows. Putting a filter inside the knn clause on an nmslib field fails with Engine [NMSLIB] does not support filters, which is one of the reasons we gave for migrating in the nmslib to Faiss migration guide.

Hit counts at six filter selectivities

Faiss HNSW with default settings (m 16, ef_construction 100, ef_search 100), averaged over 50 queries:

FilterMatching docsEfficientpost_filterbool filterpost_filter, k: 100script_score
bucket < 50024,950 (49.9%)10.004.864.8610.0010.00
bucket < 40019,891 (39.8%)10.003.783.7810.0010.00
bucket < 30014,957 (29.9%)10.002.802.8010.0010.00
bucket < 1005,080 (10.2%)10.001.041.048.7810.00
bucket < 10517 (1.0%)10.000.140.141.1610.00
bucket < 144 (0.1%)10.000.000.000.1010.00

Recall@10 against the script_score results was 1.000 for efficient filtering at every row. For the post-filter columns, recall equals hits divided by 10: every hit they did return was a true neighbor, they just returned too few. The Lucene engine gave the same counts for every column, and so did a Faiss index with index.knn.memory_optimized_search: true.

xychart-beta
  title "Average hits for k = 10, Faiss HNSW, OpenSearch 3.9.0"
  x-axis ["49.9%", "39.8%", "29.9%", "10.2%", "1.0%", "0.1%"]
  y-axis "Hits returned" 0 --> 10
  bar [10, 10, 10, 10, 10, 10]
  line [4.86, 3.78, 2.80, 1.04, 0.14, 0.00]

Blue bars are efficient filtering, grey line is post_filter, both from the table above.

The post-filter numbers follow simple arithmetic. The knn clause returns its 10 nearest neighbors per shard, and the filter keeps the fraction that matches, so the expected count is k × selectivity: 10 × 0.499 = 4.99, measured 4.86; 10 × 0.010 = 0.10, measured 0.14. Raising k to 100 with size: 10 buys headroom only while 100 × selectivity stays above 10. At 10.2% the expected count is 10.2 and we measured 8.78; at 1% it is about 1. Oversampling is a patch for mild filters, and every extra unit of k is more graph traversal on every shard. Elasticsearch users will recognize the same trade-off from tuning num_candidates and oversampling; the Elasticsearch fix for filters is the same move, into knn.filter.

How the OpenSearch knn filter chooses exact or approximate search

Efficient filtering is a decision made per segment, and on Faiss you can watch it. Add "explain": true to the search and the k-NN plugin writes the decision into the explanation of each hit. From our runs:

bucket < 500, 24,950 matches:
  the type of knn search executed at leaf was Approximate-NN

bucket < 10, 517 matches:
  the type of knn search executed at leaf was Exact since max distance computation = 2048000
  is greater than or equal to estimated distance computations = 66176

bucket < 1, 44 matches, k = 100:
  the type of knn search executed at leaf was Exact since filteredIds = 44 is less than or
  equal to K = 100

The rules, read from KNNWeight.java in the k-NN 3.9.0.0 source and matching the variables in the efficient filtering docs, with P as the number of filtered documents in the segment:

  1. If P is at or below k, run exact search over the filtered IDs.
  2. If index.knn.advanced.filtered_exact_search_threshold is set, run exact search when P is at or below it, otherwise go approximate. The next rule is skipped.
  3. If the threshold is not set, run exact search when P × dimension is at or below 2,048,000, the constant the docs call MDC (max distance computations), which "cannot be changed".
  4. Otherwise run filtered HNSW. If it returns fewer than k results while P is above k, run exact search over the filtered IDs, unless index.knn.faiss.efficient_filter.disable_exact_search is true.
flowchart TD
  A["knn query with filter,<br/>per segment"] --> B{"P at or below k?"}
  B -- yes --> X["Exact search over filtered IDs"]
  B -- no --> C{"filtered_exact_search_threshold<br/>set?"}
  C -- yes --> D{"P at or below threshold?"}
  D -- yes --> X
  D -- no --> H["Filtered HNSW"]
  C -- no --> E{"P x dimension at or<br/>below 2,048,000?"}
  E -- yes --> X
  E -- no --> H
  H --> F{"Fewer than k results?"}
  F -- no --> R["Return ANN results"]
  F -- yes --> G{"disable_exact_search true?"}
  G -- no --> X
  G -- yes --> S["Return fewer than k"]

Rule 3 makes the cut-off depend on dimension. At 128 dimensions it is 2,048,000 / 128 = 16,000 matching documents per segment, which is why our 14,957-document filter went exact and the 19,891-document filter went approximate. The same division for common embedding sizes:

DimensionExact search when matches per segment are at or below
12816,000
3845,333
7682,666
1,5361,333
3,072666

Two consequences follow from "per segment". The same query can go exact on a small segment and approximate on a large one, so explain output can differ between hits. And an index with many small segments takes the exact path more often than the same data force-merged.

The settings involved, from the vector search settings page and checked with GET <index>/_settings?include_defaults=true on 3.9.0:

SettingScopeDefaultEffect
index.knn.advanced.filtered_exact_search_thresholdIndex, dynamic-1 (unset)Replaces the MDC rule with a fixed per-segment count
index.knn.faiss.efficient_filter.disable_exact_searchIndex, dynamicfalse3.5+. Skips the exact fallback after a short ANN result
index.knn.algo_param.ef_searchIndex, dynamic100Candidate list size; per query via method_parameters.ef_search
index.knn.memory_optimized_searchIndex, staticfalse3.1+. Memory-maps the Faiss index; filtered search can use ACORN

Forcing the approximate path

To see what the exact fallbacks protect you from, we set filtered_exact_search_threshold to 1. With the threshold set, rule 3 no longer applies, so every filter with more than 10 matches goes to filtered HNSW:

PUT docs-faiss/_settings
{ "index.knn.advanced.filtered_exact_search_threshold": 1 }
FilterDefault settingsThreshold 1Threshold 1, disable_exact_search: true
10.2%10.00 (exact)10.00, recall 0.998 (ANN)10.00, recall 0.998
1.0%10.00 (exact)10.00 (48 of 50 queries fell back to exact)4.10, recall 0.362
0.1%10.00 (exact)10.00 (50 of 50 fell back)0.30, recall 0.028

The fallback shows up in explain as Exact since the number of documents returned are less than K = 10 and there are more than K filtered Ids = 517. With the fallback disabled, it reads Approximate-NN, it is not falling back to exact search after Approximate-NN search since exact search is disabled, and the 1% filter returned 3 hits for our first query. So efficient filtering can return fewer than k too, once you turn off the safety net. The docs offer disable_exact_search "for latency-sensitive workloads in which fewer than k results are acceptable"; for a RAG retriever or a product listing, fewer than k usually is not acceptable.

The same forced-ANN test on the memory-optimized index, where the docs say ACORN applies when the filter keeps 60% or fewer of the documents, returned 4.60 hits at 1% and 0.00 at 0.1%. On this corpus ACORN did not remove the need for the fallback.

Lucene engine fields follow Lucene's own logic in AbstractKnnVectorQuery (10.5.1 source). It goes exact when the filter matches no more than the per-segment k; otherwise it runs HNSW with a visit limit of the filter count plus one and drops to exact search if that limit is reached. The docs describe the threshold settings only for Faiss, and on Lucene fields explain does not report which path ran.

A post_filter bug on 3.x that makes the shortfall worse

Our first run used a bare range as the post_filter, and the counts made no sense: 1.90 hits at 49.9%, 39.8% and 29.9% alike. Wrapping the same range in bool gave the expected numbers. One query, three requests:

// knn only, k = 10: buckets of the 10 hits
[665, 249, 406, 561, 164, 80, 420, 999, 846, 418]   // 6 are below 500

// "post_filter": { "range": { "bucket": { "lt": 500 } } }
{ "total": { "value": 2, "relation": "eq" } }        // buckets 164, 80

// "post_filter": { "bool": { "filter": [ { "range": { "bucket": { "lt": 500 } } } ] } }
{ "total": { "value": 6, "relation": "eq" } }        // all six

Even "lt": 1000, which matches every document, kept only 4 of 10. This is OpenSearch issue #23303, open as of 31 October 2026: a top-level numeric range in post_filter is approximated as if it were the main query, and stops after about max(from + size, track_total_hits) + 1 points per segment, about 10,000 by default. The issue lists 3.0.0 to 3.9.0 as affected and 2.19.5 as unaffected. In our index the 10,000 lowest bucket values end near 200, which is why 164 and 80 survived and 249 did not. The total still says "relation": "eq".

Until a fix ships, wrap any numeric or date range in bool.filter when it sits in post_filter, or send "track_total_hits": true. Both returned 6 of 6 for us. A range inside the knn filter or inside a bool query is not affected, so moving to efficient filtering removes this bug along with the shortfall.

Which filter method to use

The docs' filtered search optimization table covers 1M and 10M documents with k: 100. For filters keeping 38% or 80% of documents it recommends efficient filtering for both recall and latency. For a filter keeping 2.5%, it lists efficient filtering or the scoring script for recall and the scoring script for lower latency.

That 2.5% row needs a note on current Faiss. With the MDC rule, efficient filtering already runs an exact search over the filtered IDs when P × dimension is small enough, so on Faiss the two methods often end up doing the same work. script_score is still the right tool when you want exact results regardless of segment layout, or on an nmslib index that cannot filter inside knn. The docs warn that it "may have high latency and does not scale when filtered subsets are large".

flowchart TD
  A["Filtered vector query"] --> B{"Engine?"}
  B -- nmslib --> N["Migrate to Faiss or Lucene.<br/>Meanwhile: script_score, or<br/>post-filter with a larger k"]
  B -- "Faiss or Lucene" --> C["Filter inside the knn clause"]
  C --> D{"Fewer than k hits<br/>and at least k matches?"}
  D -- no --> E["Done. Keep default settings"]
  D -- yes --> F{"disable_exact_search<br/>set to true?"}
  F -- yes --> G["Unset it, or accept<br/>fewer than k"]
  F -- no --> H["Check for a filter outside knn<br/>and for issue 23303"]

Our recommendation is efficient filtering with the default settings, on Faiss HNSW unless you have a reason to pick Lucene. Leave filtered_exact_search_threshold unset unless you have measured the dimension-based rule making the wrong choice for your data, because setting it switches that rule off entirely. Leave disable_exact_search at false. Keep post_filter for facets and refinements that must not change what the vector search considers, and wrap its ranges in bool.

To check an existing application, grep the query builders for knn clauses that sit inside a bool with a sibling filter, or next to a post_filter. Then run a sample of production queries with explain: true on a Faiss index and count which leaf decision each one hits. A filtered retriever that silently returns 1 document out of 10 is a common reason an agent is only as good as its retriever. If you are mapping Elasticsearch vector fields over at the same time, the knn_vector mapping guide covers the field side, and the Elasticsearch vs OpenSearch comparison covers the wider choice.

Where we can help

We audit OpenSearch vector search for teams with filtered retrieval in production. We find the queries that post-filter, then test recall before and after moving each filter into the knn clause. The search health check is a quick place to start. Our OpenSearch work for GrabJobs delivered 5ms queries and 40% lower cost across 10M+ listings, and the same team handles k-NN filtering through our OpenSearch consulting service.

Advisor, Query Quotient

Abhilash B is an advisor at Query Quotient and an information retrieval and AI expert who holds patents in the field. Abhilash brings that retrieval depth to the search, RAG and agent systems Query Quotient builds for clients.

Keep reading

Building something similar?

Tell us what you are working on. In a free 30-minute call we will give you an honest view of the approach, the effort and how to measure success.