Source: opensearch_knn_filter_pushdown Β·
opensearch_knn_filter_pushdown.mdΒ· updated 2026-08-07 Β· π secret gistSynced verbatim from gist.github.com/bl9.
Common Filter Push-Down in OpenSearch β RFC, Design, and the k-NN filter() Implementation
Deep dive on:
- RFC: neural-search#1135 β Common Filter Support for Hybrid Query Sub-Queries
- Implementation: k-NN#2599 β Add filter function to KNNQueryBuilder
- Prerequisite: OpenSearch#17409 β Add filter function for AbstractQueryBuilder, BoolQueryBuilder, ConstantScoreQueryBuilder
- Consumer: neural-search#1206 β Add filter function for NeuralQueryBuilder and HybridQueryBuilder
Shipped in OpenSearch 3.0.
Table of Contents
- TL;DR
- Background concepts you need first
- The RFC (neural-search#1135)
- Architecture: three repos, one contract
- k-NN PR #2599, line by line
- End-to-end request trace
- Semantics, edge cases, and real bugs
- Build it yourself
- Testing
- Limitations and follow-ups
- Reference index
1. TL;DR
- User-facing feature:
hybridquery gets a top-levelfilterfield. One filter, applied to every sub-query. - Mechanism: a new method
QueryBuilder#filter(QueryBuilder)on the core query-builder interface. It means "combine this filter into me and give me back the resulting builder." - Default behaviour:
AbstractQueryBuilderwrapsthisin abool { must: this, filter: <f> }. Query types that natively understand filters override it to do something smarter. - k-NN's job (PR #2599): override
filter()onKNNQueryBuilderso the filter lands inside the k-NN query rather than around it. That's the whole point β a filter inside a k-NN query participates in ANN traversal; a filter outside it is post-filtering and silently destroys recall. - Size: 40 lines of production code, 105 lines of tests, 1 CHANGELOG line. The design work is 95% of the value.
2. Background concepts you need first
2.1 The OpenSearch query lifecycle
A _search request goes through roughly this pipeline:
- Route β REST layer resolves the handler, request lands on a coordinating node.
- Parse β the JSON query DSL is parsed into a tree of
QueryBuilderobjects (fromXContent). - Search request processors β if a search pipeline defines any, they mutate the
SearchRequesthere. - Rewrite β
QueryBuilder#rewrite()runs, possibly several rounds, possibly async (this is wheretermslookups,neuralβknnmodel inference, etc. resolve). - Serialize + fan out β the rewritten builder tree is written to
StreamOutputand shipped to every data node holding a shard. toQuery()β on each shard,QueryBuilder#toQuery(QueryShardContext)produces a LuceneQuery.- Execute β Lucene collects hits per segment.
- Post-process β search phase results processors (e.g. normalization/combination for hybrid), then response.
Steps 2 and 3 are the only two places where you can still restructure the query as a builder. That matters enormously, and is exactly the argument the RFC makes.
2.2 QueryBuilder vs Lucene Query
Two different objects, often confused:
QueryBuilder | Lucene Query | |
|---|---|---|
| Layer | OpenSearch DSL | Lucene |
| Lifetime | Coordinator β wire β shard | Shard-local, per-search |
| Serializable | Yes (Writeable) | No |
| Mutable | Nominally yes, conventionally no | No |
| Knows about mappings | No (until toQuery) | N/A |
KNNQueryBuilder holds a QueryBuilder filter field. At toQuery() time that filter is handed to KNNQueryFactory, which converts it to a Lucene Query, runs it to get a filtered doc-ID bitset, and feeds that bitset into the ANN search. The filter must be inside the builder before toQuery() runs, or the engine never sees it.
2.3 Hybrid search and why it exists
hybrid (from the neural-search plugin) runs N sub-queries in parallel over the same shards, keeps their score lists separate, then a search-phase processor normalizes each list (min-max, L2, z-score) and combines them (arithmetic/geometric/harmonic mean, RRF). This is how you fuse BM25 lexical relevance with dense-vector semantic relevance without one score scale swamping the other.
Canonical shape:
{
"hybrid": {
"queries": [
{ "match": { "text": "wireless earbuds" } },
{ "neural": { "embedding": { "query_text": "wireless earbuds", "model_id": "abc", "k": 50 } } }
]
}
}
Because sub-queries are independent, a filter has to be applied to each of them independently.
2.4 Filtering in vector search: pre, post, and efficient
Three strategies, with very different cost/recall profiles:
- Pre-filtering (scoring script / exact): compute the filtered doc set first, then brute-force distance over exactly those docs. Perfect recall, O(P) distance computations. Great when P is tiny, terrible when P is large.
- Post-filtering (bool filter or
post_filter): run ANN over the whole graph, get k results, then drop the ones that fail the filter. Cheap, but if the filter is at all restrictive you get back far fewer than k results β sometimes zero. - Efficient / "during-search" filtering: hand the filter's doc-ID bitset to the ANN algorithm so it only accepts in-filter candidates while traversing. This is what
knn.filterdoes.
Engine support, per the k-NN docs: efficient k-NN filtering applies filtering during the vector search, which ensures k results are returned if at least k results exist in total; it is supported by the Lucene engine with HNSW (OpenSearch 2.4+) and the Faiss engine with HNSW (2.9+) or IVF (2.10+). From OpenSearch 3.1, Faiss + HNSW additionally uses the Lucene ACORN filtering optimization during graph traversal when memory-optimized search is enabled.
Both engines adaptively pick a strategy. The algorithm weighs N (docs in the index), P (docs surviving the filter), k, R (results returned by filtered ANN), FT (the knn.advanced.filtered_exact_search_threshold index setting), and MDC (max distance computations allowed in exact search when FT is unset). Practically: if the filtered doc-ID list is at or below k, it does exact search, because returning k neighbors requires at least k distance computations anyway.
2.5 Why "tail relevant results" get dropped
This phrase appears throughout the RFC. Concretely:
- Index has 1M docs. Filter
category = "medical"matches 2,000 of them. - Filter outside (
bool { must: knn(k=10), filter: category=medical }): ANN returns the global top 10 by vector distance. If none of those 10 are medical, you get 0 results. If 2 are, you get 2. - Filter inside (
knn { ..., filter: category=medical }): the engine restricts traversal (or switches to exact search over the 2,000) and returns the top 10 medical docs. You get 10.
The "tail" is the relevant-but-not-globally-top documents. Post-filtering throws them away before you ever see them. This is the single technical reason the RFC rejected the simpler designs.
3. The RFC (neural-search#1135)
Authored by bzhangam, opened 22 Jan 2025, labelled v3.0.0, now closed/implemented.
3.1 Problem statement
To apply one filter across a hybrid query today, you repeat it in every sub-query. The neural/knn sub-queries take a filter field; the rest need wrapping in a bool. Verbose, error-prone, and the wrapping form is semantically different from the inline form.
Before:
{
"hybrid": {
"queries": [
{ "neural": { "embedding": { "query_text": "...", "model_id": "m1", "k": 50,
"filter": { "term": { "status": "active" } } } } },
{ "bool": { "must": [ { "match": { "title": "..." } } ],
"filter": [ { "term": { "status": "active" } } ] } },
{ "bool": { "must": [ { "match": { "body": "..." } } ],
"filter": [ { "term": { "status": "active" } } ] } }
]
}
}
After:
{
"hybrid": {
"queries": [
{ "neural": { "embedding": { "query_text": "...", "model_id": "m1", "k": 50 } } },
{ "match": { "title": "..." } },
{ "match": { "body": "..." } }
],
"filter": { "term": { "status": "active" } }
}
}
3.2 Scope and non-goals
Explicitly out of scope in v1:
- Metrics β no per-feature metric API; deferred to a separate project.
- Configurability β one filter, AND-combined, always. No per-sub-query opt-out.
- No change to filter semantics β push-down only relocates the filter; how
neural/knn/booleach interpret a filter is untouched. - No recursive push-down β the filter reaches the top-level sub-query and stops. It does not descend into a sub-query's own inner queries. Deliberate: keeps the transform predictable and cheap.
3.3 High-level design: two options
Option 1 (chosen): push down during parsing. Add a filter field to the hybrid query; apply the push-down in HybridQueryBuilder.fromXContent.
Option 2 (deferred): a new SearchRequestProcessor. Model it on core's FilterQueryRequestProcessor, register it in the search pipeline alongside the normalization processor.
| Option 1 | Option 2 | |
|---|---|---|
| Effort | Modify one existing class | New processor class + same push-down logic |
| Ergonomics | Filter travels with the query | Filter lives in pipeline config |
| Reuse | Repeat per request | Define once; becomes an index default if set as the default search pipeline |
| Verdict | Recommended | Future work |
The deciding constraint: KNNQueryBuilder optimizes its filter before Lucene Query conversion, so push-down must happen at step 2 or 3 of the lifecycle. Both options satisfy that; Option 1 is cheaper and friendlier.
3.4 Low-level design: three options
Option 1 (chosen): copy the filter into sub-queries when the type supports it. Types that natively hold a filter (neural, knn, bool, constant_score, hybrid) get special handling; everything else gets bool-wrapped.
Option 2: always bool-wrap. Trivial to implement, but for neural/knn it degrades to post-filtering and loses the tail. Rejected.
Option 3: use the existing post_filter. Zero effort, but it runs after hybrid normalization and combination, and it is post-filtering by definition. Rejected.
| Pros | Cons | |
|---|---|---|
| Option 1 | Best recall (filter is pushed into ANN); fewer wrapper queries β slightly faster | Most implementation complexity |
| Option 2 | Simple | Loses tail results for vector sub-queries |
| Option 3 | Free | Loses tail results; applies after normalization |
3.5 Per-query-type push-down semantics
| Sub-query type | Behaviour |
|---|---|
knn / neural | Set/merge the builder's own filter field β participates in ANN traversal |
bool | Append to filterClauses β no extra wrapper needed, since bool filters are already applied to the whole clause set |
constant_score | Recurse into its wrapped filter query and apply filter() there |
hybrid | Nested hybrid is not a supported shape; error out |
| everything else | Wrap: bool { must: <sub-query>, filter: <f> } |
Worked example β bool sub-query:
// input
{ "hybrid": {
"queries": [
{ "bool": { "must": [ <q1> ], "filter": [ <f1> ] } },
<q2>
],
"filter": <F> } }
// after parsing
{ "hybrid": {
"queries": [
{ "bool": { "must": [ <q1> ], "filter": [ <f1>, <F> ] } },
{ "bool": { "must": [ <q2> ], "filter": [ <F> ] } }
] } }
Worked example β constant_score wrapping a neural:
// input
{ "hybrid": {
"queries": [
{ "constant_score": { "filter": { "neural": { ... } }, "boost": 1.2 } },
<q1>
],
"filter": <F> } }
// after parsing β F goes INSIDE the neural query, not around the constant_score
{ "hybrid": {
"queries": [
{ "constant_score": { "filter": { "neural": { ..., "filter": <F> } }, "boost": 1.2 } },
{ "bool": { "must": [ <q1> ], "filter": [ <F> ] } }
] } }
3.6 The FilterCombinationMode that didn't ship
The RFC proposed a two-argument signature:
public QueryBuilder filter(QueryBuilder filter, FilterCombinationMode mode);
public enum FilterCombinationMode {
AND, // v1: the only mode
IGNORE_IF_EXISTS, // future: skip if the sub-query already has a filter
OR // future
}
What actually merged in core is the one-argument form, AND hard-coded. Reasonable call β the enum was speculative, and adding a parameter later is an additive change. Worth knowing if you read the RFC and then the code and wonder where the mode went.
3.7 Score filters are blocked
Hybrid normalizes scores after sub-queries run. A user writing a score-based filter at the hybrid level would reasonably expect it to apply to normalized scores β but push-down applies it pre-normalization, per sub-query, on raw scores. The numbers would be meaningless. The RFC's answer: reject score-based filters at parse time rather than document a footgun.
4. Architecture: three repos, one contract
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β OpenSearch core (PR #17409) β
β β
β interface QueryBuilder β
β QueryBuilder filter(QueryBuilder filter); β contractβ
β β
β AbstractQueryBuilder β bool{must:this, filter:f} β
β BoolQueryBuilder β filterClauses.add(f) β
β ConstantScoreQueryBuilder β recurse into wrapped filter β
βββββββββββββββββ¬ββββββββββββββββββββββββββ¬ββββββββββββββββββββ
β extends β extends
βββββββββββββΌβββββββββββ ββββββββββββΌβββββββββββββββββββ
β k-NN (PR #2599) β β neural-search (PR #1206) β
β KNNQueryBuilder β β NeuralQueryBuilder β
β β rebuild with β β β set/merge queryfilter β
β merged filter β β HybridQueryBuilder β
ββββββββββββββββββββββββ β β fan out to sub-queries β
βββββββββββββββββββββββββββββββ
Merge order was forced: core (3 Mar 2025) β k-NN (14 Mar 2025) β neural-search. neural-search's HybridQueryBuilder.filter() calls query.filter(f) polymorphically, so KNNQueryBuilder must already implement it or you get the base-class bool wrapping and the whole point is lost.
4.1 The contract in core
server/src/main/java/org/opensearch/index/query/QueryBuilder.java:
QueryBuilder filter(QueryBuilder filter);
Documented semantics: combine the filter with this builder. If the builder already carries a filter, merge and return the builder itself. Otherwise use a bool query to combine builder and filter, and return the bool. A null filter is a no-op.
Note this is an interface method with no default β every implementor must supply one. That's why AbstractQueryBuilder provides the fallback: all ~60 core query types extend it, so they all inherit sane behaviour for free.
4.2 Core implementations
AbstractQueryBuilder β the universal fallback:
public static boolean validateFilterParams(QueryBuilder filter) {
return filter != null;
}
public QueryBuilder filter(QueryBuilder filter) {
if (validateFilterParams(filter) == false) {
return this;
}
final BoolQueryBuilder modifiedQB = new BoolQueryBuilder();
modifiedQB.must(this);
modifiedQB.filter(filter);
return modifiedQB;
}
validateFilterParams is public static on purpose: plugin code in other JARs calls it. It is the shared null-guard for every implementation across all three repos.
BoolQueryBuilder β no wrapper needed:
public BoolQueryBuilder filter(QueryBuilder filter) {
if (validateFilterParams(filter) == false) {
return this;
}
filterClauses.add(filter);
return this;
}
This is a pre-existing method, not a new one β BoolQueryBuilder.filter(QueryBuilder) has always meant "add a filter clause." PR #17409 only added the null-guard so the same method could satisfy the new interface contract. The signature covariance (BoolQueryBuilder return type) is legal and keeps the existing fluent API source-compatible. Nice bit of API design: zero breakage, new semantics for free.
ConstantScoreQueryBuilder β recurse:
public ConstantScoreQueryBuilder filter(QueryBuilder filter) {
if (validateFilterParams(filter) == false) {
return this;
}
QueryBuilder filteredFilterBuilder = filterBuilder.filter(filter);
if (filteredFilterBuilder != filterBuilder) {
return new ConstantScoreQueryBuilder(filteredFilterBuilder);
}
return this;
}
The != identity check is the interesting line: if the inner builder mutated itself in place (bool, neural), return this and allocate nothing; if it returned a different object (knn, or a bool-wrapped leaf), rebuild the wrapper around the new object. This "returns-self-or-new" convention runs through the entire feature.
4.3 Plugin implementations
HybridQueryBuilder (neural-search) β the fan-out:
public QueryBuilder filter(QueryBuilder filter) {
if (validateFilterParams(filter) == false) {
return this;
}
ListIterator<QueryBuilder> iterator = queries.listIterator();
while (iterator.hasNext()) {
QueryBuilder query = iterator.next();
// set the query again because query.filter(filter) can return new query.
iterator.set(query.filter(filter));
}
return this;
}
The ListIterator.set() is required, not stylistic. query.filter(f) may return a different object; a plain for-each would compute the new builder and throw it away.
And in fromXContent, the parse-time application:
} else if (FILTER_FIELD.match(currentFieldName, parser.getDeprecationHandler())) {
filter = parseInnerQueryBuilder(parser);
}
...
for (QueryBuilder query : queries) {
if (filter == null) {
compoundQueryBuilder.add(query);
} else {
compoundQueryBuilder.add(query.filter(filter));
}
...
}
Two details worth noting in current main:
filteris only accepted as aSTART_OBJECT. An array ("filter": [...]) or a scalar hitsthrowUnsupportedFilterParsingExceptionβ "[hybrid] query's [filter] field must be a query object". To combine several filters, wrap them in aboolyourself.- Usage is instrumented:
EventStatsManager.increment(EventStatName.HYBRID_QUERY_FILTER_REQUESTS). The RFC listed metrics as out of scope; they were added later.
NeuralQueryBuilder (neural-search) β mutate in place:
public QueryBuilder filter(QueryBuilder filterToBeAdded) {
if (validateFilterParams(filterToBeAdded) == false) {
return this;
}
if (this.queryfilter == null) {
this.queryfilter = filterToBeAdded;
} else {
this.queryfilter = this.queryfilter.filter(filterToBeAdded);
}
return this;
}
Contrast this with the k-NN version below β same problem, opposite style. Same feature, two plugins, two conventions.
4.4 Why the plugins duplicate instead of share
NeuralQueryBuilder and KNNQueryBuilder want nearly identical logic, but they live in different plugins with no shared dependency. The RFC's call: accept the duplication rather than invent a shared library for ~15 lines. Pragmatic, and correct for the size.
5. k-NN PR #2599, line by line
Author: chloewqg Β· Merged by: junqiu-lei, 14 Mar 2025 Β· Commit: 89b6883 (single commit, branch chloewqg:local2)
5.1 Files changed
| File | Ξ |
|---|---|
CHANGELOG.md | +1 |
src/main/java/org/opensearch/knn/index/query/KNNQueryBuilder.java | +40 |
src/test/java/org/opensearch/knn/index/FaissIT.java | +50 |
src/test/java/org/opensearch/knn/index/query/KNNQueryBuilderTests.java | +55 |
5.2 CHANGELOG.md
One line under ## [Unreleased 3.0] β ### Features. Mandatory in this repo; CI enforces it. Note the merged line is missing its leading * bullet marker β a cosmetic slip that got through review.
5.3 KNNQueryBuilder.filter() β the 40 lines
Inserted right after doXContent, at what was line 381:
/**
* Add a filter to Neural Query Builder // (a)
* @param filterToBeAdded fiXlter to be added // (b)
* @return return itself with underlying filter combined with passed in filter
*/
@Override // (c)
public QueryBuilder filter(QueryBuilder filterToBeAdded) { // (d)
if (validateFilterParams(filterToBeAdded) == false) { // (e)
return this;
}
if (this.filter == null) { // (f)
return KNNQueryBuilder.builder() // (g)
.fieldName(fieldName)
.vector(vector)
.k(k)
.maxDistance(maxDistance)
.minScore(minScore)
.methodParameters(methodParameters)
.filter(filterToBeAdded) // (h)
.ignoreUnmapped(ignoreUnmapped)
.rescoreContext(rescoreContext)
.expandNested(expandNested)
.build(); // (i)
}
return KNNQueryBuilder.builder() // (j)
.fieldName(fieldName)
.vector(vector)
.k(k)
.maxDistance(maxDistance)
.minScore(minScore)
.methodParameters(methodParameters)
.filter(filter.filter(filterToBeAdded)) // (k) β the whole trick
.ignoreUnmapped(ignoreUnmapped)
.rescoreContext(rescoreContext)
.expandNested(expandNested)
.build();
}
Annotations:
-
(a) Javadoc says "Neural Query Builder" β copy-paste from
NeuralQueryBuilder. bzhangam flagged it in review; chloewqg said a follow-up PR would fix it. It is still wrong inmaintoday. -
(b)
fiXlterβ typo, also still present. Two cosmetic defects that survived review and a year ofmain. Instructive about what code review actually catches. -
(c)
@Overrideis what binds this to the new core interface method. Without core PR #17409 merged first, this line is a compile error. This is the hard dependency between the two repos. -
(d) Parameter deliberately named
filterToBeAdded, notfilter, becausefilteris already a field on the class. Avoidsthis.filtershadowing noise in the body. -
(e) The shared null-guard from
AbstractQueryBuilder. Note the== falsestyle β OpenSearch convention, avoids the easily-missed!. Returningthis(notnull, not a copy) makesfilter(null)a true no-op and lets callers likeConstantScoreQueryBuilderuse identity comparison to detect "nothing changed." -
(f) Branch on whether a filter already exists.
-
(g) No existing filter β rebuild the whole builder rather than assigning to the field. See Β§5.6.
-
(h) The new filter becomes the filter, verbatim. No wrapping. This is what makes it a real push-down:
KNNQueryBuilder.filteris handed toKNNQueryFactoryattoQuery()time and drives efficient filtering. -
(i)
build()runsBuilder.validate()β checks fieldName non-empty, vector non-null/non-empty, exactly one ofk/minScore/maxDistance,0 < k <= K_MAX,minScore > 0. This validation-on-construction is the stated justification for the rebuild. -
(j) Existing filter β same rebuild, one field different.
-
(k) The recursive delegation.
filter.filter(filterToBeAdded)dispatches polymorphically on the existing filter's type:- existing filter is a
BoolQueryBuilderβ appends to itsfilterClauses, returns the same instance. Result:bool { ..., filter: [old..., new] }. Flat, no extra nesting. - existing filter is a
TermQueryBuilder(or any leaf) βAbstractQueryBuilder.filterwraps it:bool { must: term, filter: newFilter }. - existing filter is a
ConstantScoreQueryBuilderβ recurses into its inner query.
Fifteen lines get correct AND-composition for every query type in the system, present and future. That's the payoff of putting the contract in core.
- existing filter is a
What this method does NOT copy: boost and queryName. The Builder has both fields (queryName(String), boost(float), default DEFAULT_BOOST), and build() applies them via .boost(boost).queryName(queryName). filter() sets neither, so a pushed-down filter silently resets boost to 1.0 and drops the _name. Since AbstractQueryBuilder.equals compares both, this is observable. The same omission exists in doRewrite(), which predates this PR β so it's a pre-existing pattern that was copied, not a new mistake. Still a real bug. See Β§7.
5.4 The unit test
KNNQueryBuilderTests.testFilter(), +55 lines, three cases:
@SneakyThrows
public void testFilter() {
// Case 1 β null is a no-op
KNNQueryBuilder knnQueryBuilder = new KNNQueryBuilder(FIELD_NAME, QUERY_VECTOR, K);
KNNQueryBuilder updatedKnnQueryBuilder = (KNNQueryBuilder) knnQueryBuilder.filter(null);
assertEquals(knnQueryBuilder, updatedKnnQueryBuilder);
// Case 2 β existing filter, AND-combined
knnQueryBuilder = KNNQueryBuilder.builder()
.fieldName(FIELD_NAME).vector(QUERY_VECTOR).filter(TERM_QUERY).k(K).build();
updatedKnnQueryBuilder = (KNNQueryBuilder) knnQueryBuilder.filter(TERM_QUERY);
BoolQueryBuilder expectedUpdatedQueryFilter = new BoolQueryBuilder();
expectedUpdatedQueryFilter.must(TERM_QUERY);
expectedUpdatedQueryFilter.filter(TERM_QUERY);
assertEquals(knnQueryBuilder.fieldName(), updatedKnnQueryBuilder.fieldName());
assertEquals(knnQueryBuilder.vector(), updatedKnnQueryBuilder.vector());
assertEquals(knnQueryBuilder.getK(), updatedKnnQueryBuilder.getK());
assertEquals(knnQueryBuilder.getMaxDistance(), updatedKnnQueryBuilder.getMaxDistance());
assertEquals(knnQueryBuilder.getMinScore(), updatedKnnQueryBuilder.getMinScore());
assertEquals(knnQueryBuilder.getMethodParameters(), updatedKnnQueryBuilder.getMethodParameters());
assertEquals(knnQueryBuilder.isIgnoreUnmapped(), updatedKnnQueryBuilder.isIgnoreUnmapped());
assertEquals(knnQueryBuilder.getRescoreContext(), updatedKnnQueryBuilder.getRescoreContext());
assertEquals(knnQueryBuilder.getExpandNested(), updatedKnnQueryBuilder.getExpandNested());
assertEquals(expectedUpdatedQueryFilter, updatedKnnQueryBuilder.getFilter());
// Case 3 β no existing filter, direct assignment
knnQueryBuilder = KNNQueryBuilder.builder()
.fieldName(FIELD_NAME).vector(QUERY_VECTOR).k(K).build();
updatedKnnQueryBuilder = (KNNQueryBuilder) knnQueryBuilder.filter(TERM_QUERY);
// ... same field-by-field assertions ...
assertEquals(TERM_QUERY, updatedKnnQueryBuilder.getFilter());
}
Reading it:
- Case 1 passes precisely because
filter(null)returnsthisβ reference equality would also pass, butassertEqualsdocuments intent. - Case 2 is the important one.
TERM_QUERYis a leaf, sofilter.filter(TERM_QUERY)hitsAbstractQueryBuilder.filterand producesbool { must: TERM_QUERY, filter: TERM_QUERY }. The test asserts that exact structure. It also confirms every other field survives the rebuild β which is the real risk of the rebuild-vs-mutate approach. - Case 3 confirms the
filter == nullbranch assigns without wrapping. - Gap: because the assertions are field-by-field rather than
assertEquals(expectedBuilder, actual), the droppedboost/queryNamenever gets caught. A whole-object comparison on a builder with a non-default boost would fail today. - The
(KNNQueryBuilder)casts are needed because the interface returnsQueryBuilder. Safe here only because the implementation is known to return aKNNQueryBuilder; in general callers must not assume this (the base implementation returns aBoolQueryBuilder). - The PR also left a commented-out copy of the production code inside the test as an explanatory note. Dead comment; would not survive a stricter review.
New import: org.opensearch.index.query.BoolQueryBuilder.
5.5 The integration test
FaissIT.testQueryWithFilterFunctionAppliedMultipleShards(), +50 lines. New imports: MatchNoneQueryBuilder, RangeQueryBuilder.
@SneakyThrows
public void testQueryWithFilterFunctionAppliedMultipleShards() {
XContentBuilder builder = XContentFactory.jsonBuilder()
.startObject()
.startObject(PROPERTIES_FIELD_NAME)
.startObject(FIELD_NAME)
.field(TYPE_FIELD_NAME, KNN_VECTOR_TYPE)
.field(DIMENSION_FIELD_NAME, "3")
.startObject(KNNConstants.KNN_METHOD)
.field(KNNConstants.NAME, METHOD_HNSW)
.field(KNNConstants.METHOD_PARAMETER_SPACE_TYPE, SpaceType.L2.getValue())
.field(KNNConstants.KNN_ENGINE, KNNEngine.FAISS.getName())
.endObject()
.endObject()
.startObject(INTEGER_FIELD_NAME)
.field(TYPE_FIELD_NAME, FILED_TYPE_INTEGER)
.endObject()
.endObject()
.endObject();
String mapping = builder.toString();
createIndex(INDEX_NAME, Settings.builder()
.put("number_of_shards", 10)
.put("number_of_replicas", 0)
.put("index.knn", true).build());
putMappingRequest(INDEX_NAME, mapping);
addKnnDocWithAttributes("doc1", new float[] { 7.0f, 7.0f, 3.0f },
ImmutableMap.of("dateReceived", "2024-10-01"));
refreshIndex(INDEX_NAME);
final float[] searchVector = { 6.0f, 7.0f, 3.0f };
// (1) matching filter β 1 hit
RangeQueryBuilder rangeQueryBuilder = QueryBuilders.rangeQuery("dateReceived").gte("2023-11-01");
KNNQueryBuilder knnQueryBuilder = new KNNQueryBuilder(FIELD_NAME, searchVector, 1, null);
KNNQueryBuilder updatedKnnQueryBuilder = (KNNQueryBuilder) knnQueryBuilder.filter(rangeQueryBuilder);
Response response = searchKNNIndex(INDEX_NAME, updatedKnnQueryBuilder, 10);
List<KNNResult> knnResults = parseSearchResponse(EntityUtils.toString(response.getEntity()), FIELD_NAME);
assertEquals(1, knnResults.size());
// (2) impossible filter β 0 hits
updatedKnnQueryBuilder = (KNNQueryBuilder) knnQueryBuilder.filter(new MatchNoneQueryBuilder());
response = searchKNNIndex(INDEX_NAME, updatedKnnQueryBuilder, 10);
knnResults = parseSearchResponse(EntityUtils.toString(response.getEntity()), FIELD_NAME);
assertEquals(0, knnResults.size());
}
Reading it:
- Faiss + HNSW + L2,
index.knn: trueβ chosen because Faiss is the engine with the richest efficient-filtering path. The test lives inFaissIT, which extendsKNNRestTestCaseand drives a real cluster over REST. - 10 shards, 1 document. Deliberate: the doc lands on one shard, the other nine have empty segments. This exercises the serializeβfan-outβ
toQuerypath on shards where the filter matches nothing, catching NPEs and empty-segment bugs. That's why "MultipleShards" is in the name. new KNNQueryBuilder(FIELD_NAME, searchVector, 1, null)β the four-arg constructor with an explicitnullfilter, so the test starts on thefilter == nullbranch.knnQueryBuilder.filter(...)twice on the same object β the second call at (2) reuses the original builder, not the updated one. This only works becausefilter()returns a new instance and leaves the receiver untouched. A mutating implementation would have made the second queryrange AND match_none. The immutability contract is load-bearing here, and this test would catch a regression to mutation.k = 1in the query,size = 10in the search β separates ANNkfrom result size.MatchNoneQueryBuilderis the cleanest way to prove the filter is actually reaching the engine: the doc is a perfect vector match, so a nonzero result would mean the filter was ignored.- Weakness: with one document, this proves the filter is applied, not that it is applied efficiently. It cannot distinguish push-down from post-filtering. A recall test β 10k docs, restrictive filter, assert k results come back β would be the real proof, and isn't here.
5.6 The review debate: mutate or rebuild?
Worth reading in full on the PR; it's the most substantive part.
bzhangam: why construct a new KNNQueryBuilder instead of assigning this.filter and returning this?
heemin32: no behavioural difference today, but validation lives in build(). If a future filter-related validation is added there, direct field assignment bypasses it.
bzhangam: that's speculative β building for a use case that doesn't exist. If the concern is bypass, mark the field final and force mutation through the builder. Two-way door.
heemin32: final on a QueryBuilder reference still permits mutating the referenced object; it only prevents swapping the instance. If we'd adopt the pattern later anyway, adopt it now.
chloewqg closed it by citing a review on k-NN#2585: query builders are intended to be effectively immutable, with all parameters validated together at construction rather than individually.
Rebuild won. And as Β§5.5 shows, the integration test then depends on that choice. Note the inconsistency though: NeuralQueryBuilder.filter() in the sibling plugin mutates in place and returns this. Both satisfy the interface β the contract only says "return the resulting builder" β but a caller that assumes one style breaks against the other. HybridQueryBuilder handles this correctly with iterator.set(...); hand-written client code often won't.
6. End-to-end request trace
Request:
POST /products/_search?search_pipeline=nlp-pipeline
{
"query": {
"hybrid": {
"queries": [
{ "match": { "title": "running shoes" } },
{ "knn": { "embedding": { "vector": [0.1, 0.2, 0.3], "k": 50 } } }
],
"filter": { "term": { "in_stock": true } }
}
}
}
- REST β parse.
HybridQueryBuilder.fromXContentwalks the JSON.queriesβ two builders.filterβ aTermQueryBuilder. - Push-down loop. For each sub-query,
query.filter(termQuery):MatchQueryBuilderhas no override βAbstractQueryBuilder.filterβ returnsbool { must: match, filter: term }.KNNQueryBuilderoverrides βthis.filter == nullβ returns a newKNNQueryBuilderwithfilter = term.- Both results are added to the new
HybridQueryBuilder. The top-levelfilterfield is not retained β it has been fully distributed.
- Stats.
HYBRID_QUERY_FILTER_REQUESTSincremented. - Rewrite.
KNNQueryBuilder.doRewriterewrites the inner filter; if the rewrite produced a different object it rebuilds the k-NN builder around it. (Same missingboost/queryNamecopy asfilter().) - Serialize + fan out. No wire-format change was needed anywhere in this feature:
KNNQueryBuilderalready serialized itsfilterfield. That's why there's no BWC work in PR #2599. toQueryper shard. InKNNQueryBuilder.doToQuery:- Guard: if the engine creates custom segment files and doesn't support filters (nmslib), and
filter != nullβIllegalArgumentException("Engine [x] does not support filters"). KNNQueryFactory.CreateQueryRequest.builder()...filter(this.filter)...build()βKNNQueryFactory.create(...).
- Guard: if the engine creates custom segment files and doesn't support filters (nmslib), and
- Engine execution. The factory converts the filter builder to a Lucene
Query, runs it to get a doc-ID bitset, and picks pre-filter exact search vs filtered ANN based on N/P/k/FT/MDC. Thematchsub-query'sboolruns conventionally. - Normalization + combination. The hybrid search-phase processor normalizes each sub-query's score list and combines them.
- Response.
The key structural fact: the transform happens once, at parse time, on the coordinating node. By the time anything is serialized or executed, the query is indistinguishable from one where the user typed the filters out longhand. That's the RFC's performance argument, and it holds β there's no per-shard or per-segment cost to the feature itself.
7. Semantics, edge cases, and real bugs
Combination is always AND, always. No opt-out. If a sub-query already filters category=books and you push in_stock=true, you get both. If you want OR or per-sub-query behaviour, don't use the common filter β write the filters inline.
Push-down is one level deep. hybrid.filter reaches each sub-query. It does not descend into a sub-query's inner queries β except via the specific overrides (constant_score recursing into its wrapped query, bool appending to its own filter clauses).
boost and queryName are lost. KNNQueryBuilder.filter() and doRewrite() rebuild via KNNQueryBuilder.builder() without .boost(...) or .queryName(...). A knn sub-query with "boost": 2.0 or "_name": "vec" silently loses both when a hybrid filter is pushed into it. AbstractQueryBuilder.equals compares both, so this is observable and testable. Reproduce: build a KNNQueryBuilder with a non-default boost, call filter(termQuery), assert getBoost(). This is a good first-contribution PR if you want one β the fix is two chained calls plus a test.
Filter type matters for the resulting shape.
Existing knn.filter | After pushing F |
|---|---|
null | F |
bool { filter: [A] } | bool { filter: [A, F] } β flat |
term { x: 1 } | bool { must: term, filter: F } β one level of nesting |
constant_score { filter: Q } | constant_score { filter: Q.filter(F) } |
Repeated push-downs on a leaf filter nest linearly. Not a correctness problem β Lucene flattens most of this at rewrite β but deeply nested bools show up in _validate/query?explain output and can be confusing when debugging.
nmslib rejects filters entirely. A hybrid filter pushed into a knn sub-query on an nmslib index throws at toQuery time, per-shard, not at parse time. The error surfaces as a shard failure, not a 400. Faiss and Lucene engines are fine.
Score-based filters are rejected at parse time by design (Β§3.7) β the pre/post normalization mismatch would produce nonsense.
Nested hybrid is not supported; pushing a filter into a nested hybrid errors.
filter must be an object. "filter": [ {...}, {...} ] is rejected. Combine with bool instead.
Mutation-style vs rebuild-style implementations coexist. NeuralQueryBuilder mutates and returns this; KNNQueryBuilder rebuilds and returns a new object; BoolQueryBuilder mutates; AbstractQueryBuilder returns a different type entirely. Always use the return value. Never call q.filter(f) for side effects.
8. Build it yourself
8.1 Dev environment
The k-NN plugin has native (JNI) dependencies, so first build is slow.
# JDK 21 is what current main targets; check build.gradle for the exact version
java -version
git clone https://github.com/opensearch-project/k-NN.git
cd k-NN
git submodule update --init -- jni/external/nmslib
git submodule update --init -- jni/external/faiss
# full build, including native libs β expect 20-40 min cold
./gradlew build
# start a single-node cluster with the plugin installed
./gradlew run
# β http://localhost:9200
If you only need the Java side compiled (no native libs, no ITs), ./gradlew compileJava compileTestJava is much faster for iterating.
For the hybrid-query half you also need the neural-search plugin, which depends on ml-commons. Easiest path for end-to-end manual testing is a Docker OpenSearch 3.x image, which bundles all three.
8.2 Implementing the change from scratch
Assume you're on a main that predates the feature.
Step 1 β verify the core contract exists.
grep -n "QueryBuilder filter(QueryBuilder" \
~/OpenSearch/server/src/main/java/org/opensearch/index/query/QueryBuilder.java
If it's absent, your k-NN @Override won't compile and you need OpenSearch β₯ 3.0 (or a locally published snapshot) as your dependency. This is the ordering constraint that forced core β k-NN β neural-search.
Step 2 β add the override to src/main/java/org/opensearch/knn/index/query/KNNQueryBuilder.java, after doXContent. Use the code in Β§5.3 β but fix the javadoc, and add the two lines the original missed:
/**
* Combine a filter into this KNN query builder.
* @param filterToBeAdded filter to be added
* @return a builder whose filter is the AND-combination of the existing filter and the new one
*/
@Override
public QueryBuilder filter(QueryBuilder filterToBeAdded) {
if (validateFilterParams(filterToBeAdded) == false) {
return this;
}
final QueryBuilder combined = (this.filter == null)
? filterToBeAdded
: this.filter.filter(filterToBeAdded);
return KNNQueryBuilder.builder()
.fieldName(fieldName)
.vector(vector)
.k(k)
.maxDistance(maxDistance)
.minScore(minScore)
.methodParameters(methodParameters)
.filter(combined)
.ignoreUnmapped(ignoreUnmapped)
.rescoreContext(rescoreContext)
.expandNested(expandNested)
.boost(boost) // β not in the merged PR
.queryName(queryName) // β not in the merged PR
.build();
}
Collapsing the two branches into one combined expression also removes the duplicated 11-line builder chain. Behaviourally identical to the merged version except for the boost/name fix.
Step 3 β imports. org.opensearch.index.query.QueryBuilder is almost certainly already imported. validateFilterParams is inherited from AbstractQueryBuilder, no import needed.
Step 4 β CHANGELOG. One bullet under ### Features in the unreleased section. CI fails without it.
Step 5 β spotless. ./gradlew spotlessApply before committing; the repo enforces formatting.
Step 6 β sign your commits. DCO is enforced: git commit --signoff.
8.3 The same feature for a query type that doesn't have it
If you're adding filter() to some other query builder, the checklist is:
- Does the builder have a field that already means "filter" (or an inner query you should recurse into)? If not, don't override β the
AbstractQueryBuilderbool-wrap is the right answer and you get it for free. - If it does, decide mutate-vs-rebuild and be consistent with the rest of your plugin.
- Start with
if (validateFilterParams(f) == false) return this;β the identity return is relied on byConstantScoreQueryBuilder's!=check. - Combine an existing filter via
existing.filter(f), never by hand-rolling aBoolQueryBuilder. Delegation is what makes the feature compose. - If you rebuild, copy every field including
boostandqueryName. - Test: null case, no-existing-filter case, existing-filter case, and a whole-object
assertEqualsagainst an expected builder with non-default boost/name.
9. Testing
9.1 Unit tests
# just this test
./gradlew test --tests "org.opensearch.knn.index.query.KNNQueryBuilderTests.testFilter"
# whole class
./gradlew test --tests "*KNNQueryBuilderTests"
Cases to cover, beyond what the PR shipped:
public void testFilter_whenBoostAndNameSet_thenPreserved() {
KNNQueryBuilder qb = KNNQueryBuilder.builder()
.fieldName(FIELD_NAME).vector(QUERY_VECTOR).k(K)
.boost(2.0f).queryName("vec").build();
KNNQueryBuilder updated = (KNNQueryBuilder) qb.filter(TERM_QUERY);
assertEquals(2.0f, updated.boost(), 0.0f); // fails on merged code
assertEquals("vec", updated.queryName()); // fails on merged code
}
public void testFilter_whenExistingFilterIsBool_thenFlatCombination() {
BoolQueryBuilder existing = new BoolQueryBuilder().filter(TERM_QUERY);
KNNQueryBuilder qb = KNNQueryBuilder.builder()
.fieldName(FIELD_NAME).vector(QUERY_VECTOR).k(K).filter(existing).build();
KNNQueryBuilder updated = (KNNQueryBuilder) qb.filter(RANGE_QUERY);
BoolQueryBuilder actual = (BoolQueryBuilder) updated.getFilter();
assertEquals(2, actual.filter().size()); // flat, not nested
}
public void testFilter_whenCalledTwiceOnSameBuilder_thenOriginalUnchanged() {
KNNQueryBuilder qb = KNNQueryBuilder.builder()
.fieldName(FIELD_NAME).vector(QUERY_VECTOR).k(K).build();
qb.filter(TERM_QUERY);
assertNull(qb.getFilter()); // immutability contract
}
9.2 Integration tests
# single IT
./gradlew integTest --tests "org.opensearch.knn.index.FaissIT.testQueryWithFilterFunctionAppliedMultipleShards"
# whole IT class
./gradlew integTest --tests "*FaissIT"
ITs need the native libs built, so run a full ./gradlew build at least once first.
Worth adding β a recall test that actually proves push-down rather than mere application:
// index 10_000 docs; only ~50 have category="rare"
// query with k=10 and filter category=rare
// assert 10 results, NOT ~0
// post-filtering would return close to zero; efficient filtering returns 10
That is the assertion that distinguishes this feature from the naive implementation, and it isn't in the repo.
9.3 Manual REST testing on a live cluster
# 1. index with faiss/hnsw
curl -XPUT "localhost:9200/products" -H 'Content-Type: application/json' -d'
{
"settings": { "index": { "knn": true, "number_of_shards": 2, "number_of_replicas": 0 } },
"mappings": { "properties": {
"embedding": { "type": "knn_vector", "dimension": 3,
"method": { "name": "hnsw", "space_type": "l2", "engine": "faiss" } },
"title": { "type": "text" },
"in_stock": { "type": "boolean" }
} }
}'
# 2. docs
curl -XPOST "localhost:9200/products/_bulk?refresh" -H 'Content-Type: application/x-ndjson' -d'
{"index":{"_id":"1"}}
{"embedding":[1.0,1.0,1.0],"title":"running shoes","in_stock":true}
{"index":{"_id":"2"}}
{"embedding":[1.1,1.0,1.0],"title":"running socks","in_stock":false}
{"index":{"_id":"3"}}
{"embedding":[9.0,9.0,9.0],"title":"hiking boots","in_stock":true}
'
# 3. plain knn with an inline filter β the pre-existing capability
curl -XPOST "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{ "query": { "knn": { "embedding": {
"vector": [1.0,1.0,1.0], "k": 2,
"filter": { "term": { "in_stock": true } } } } } }'
# expect docs 1 and 3 (doc 2 is nearer than 3 but out of stock)
# 4. search pipeline for hybrid
curl -XPUT "localhost:9200/_search/pipeline/nlp-pipeline" -H 'Content-Type: application/json' -d'
{
"description": "hybrid normalization",
"phase_results_processors": [
{ "normalization-processor": {
"normalization": { "technique": "min_max" },
"combination": { "technique": "arithmetic_mean", "parameters": { "weights": [0.3, 0.7] } } } }
]
}'
# 5. THE FEATURE β one top-level filter, both sub-queries
curl -XPOST "localhost:9200/products/_search?search_pipeline=nlp-pipeline" \
-H 'Content-Type: application/json' -d'
{
"query": {
"hybrid": {
"queries": [
{ "match": { "title": "running" } },
{ "knn": { "embedding": { "vector": [1.0,1.0,1.0], "k": 2 } } }
],
"filter": { "term": { "in_stock": true } }
}
}
}'
# doc 2 ("running socks") matches both sub-queries but is out of stock β excluded
9.4 Verifying push-down actually happened
Three ways, in increasing order of reliability:
1. _validate/query?explain β shows the Lucene query the builders produced.
curl -XPOST "localhost:9200/products/_validate/query?explain=true" \
-H 'Content-Type: application/json' -d'
{ "query": { "hybrid": { "queries": [ { "match": { "title": "running" } } ],
"filter": { "term": { "in_stock": true } } } } }'
You should see the match clause wrapped in a boolean with a filter clause β proof the push-down ran at parse time.
2. Profile the query with "profile": true and look for the k-NN query implementation in the breakdown. A KnnFloatVectorQuery/Faiss query carrying a filter looks different from a BooleanQuery containing an unfiltered k-NN clause.
3. The recall test. Index enough documents that a restrictive filter would starve post-filtering, then count results. If you get k back, the filter went in. If you get near zero, it didn't. This is the only test that can't be fooled.
10. Limitations and follow-ups
Still open or unresolved as of the current main:
- Javadoc typos in
KNNQueryBuilder.filter()β "Neural Query Builder" and "fiXlter". Flagged in review, promised as a follow-up, never fixed. boost/queryNamedropped on rebuild, in bothfilter()anddoRewrite().- No
FilterCombinationMode. AND only. OR and IGNORE_IF_EXISTS remain speculative. - No
SearchRequestProcessorvariant (RFC high-level Option 2), so you can't set an index-default common filter via a default search pipeline. - No deep push-down into a sub-query's inner queries.
- No performance/recall benchmark in the repo demonstrating the push-down advantage β the ITs prove correctness, not efficiency.
- Two conflicting conventions for
filter()across plugins (mutate vs rebuild), with nothing in the interface javadoc that forces one. - Duplicated logic between
NeuralQueryBuilderandKNNQueryBuilder, accepted deliberately.
11. Reference index
Pull requests and issues
| Ref | Repo | What |
|---|---|---|
| #1135 | neural-search | The RFC |
| #17409 | OpenSearch | filter() on QueryBuilder, AbstractQueryBuilder, BoolQueryBuilder, ConstantScoreQueryBuilder |
| #2599 | k-NN | filter() on KNNQueryBuilder β this document's subject |
| #1206 | neural-search | filter() on NeuralQueryBuilder and HybridQueryBuilder |
| #2585 | k-NN | The immutability review that settled the rebuild-vs-mutate debate |
| #1108 | neural-search | Validation for nested hybrid queries |
| #903 | k-NN | META: efficient filtering for Faiss |
| #1049 | k-NN | Restrictive-filter enhancements, exact-search fallback |
Source files
| File | Repo |
|---|---|
server/src/main/java/org/opensearch/index/query/QueryBuilder.java | OpenSearch |
server/src/main/java/org/opensearch/index/query/AbstractQueryBuilder.java | OpenSearch |
server/src/main/java/org/opensearch/index/query/BoolQueryBuilder.java | OpenSearch |
server/src/main/java/org/opensearch/index/query/ConstantScoreQueryBuilder.java | OpenSearch |
src/main/java/org/opensearch/knn/index/query/KNNQueryBuilder.java | k-NN |
src/main/java/org/opensearch/knn/index/query/KNNQueryFactory.java | k-NN |
src/main/java/org/opensearch/neuralsearch/query/HybridQueryBuilder.java | neural-search |
src/main/java/org/opensearch/neuralsearch/query/NeuralQueryBuilder.java | neural-search |
Docs