This is an automated email from the ASF dual-hosted git repository. hossman pushed a commit to branch jira/SOLR-16858 in repository https://gitbox.apache.org/repos/asf/solr.git
commit 68d2c743f30f834572155cd32b06f67440715a4b Author: Chris Hostetter <[email protected]> AuthorDate: Fri Dec 15 14:39:42 2023 -0700 SOLR-16858: new localparams for knn to override pre-filtering behavior: fq, includeTags, & excludeTags --- .../org/apache/solr/search/neural/KnnQParser.java | 143 +++++++- .../org/apache/solr/search/QueryEqualityTest.java | 76 ++++- .../apache/solr/search/neural/KnnQParserTest.java | 372 ++++++++++++++++++++- 3 files changed, 555 insertions(+), 36 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/neural/KnnQParser.java b/solr/core/src/java/org/apache/solr/search/neural/KnnQParser.java index 3d8cd64a5e2..5599fd8dbe2 100644 --- a/solr/core/src/java/org/apache/solr/search/neural/KnnQParser.java +++ b/solr/core/src/java/org/apache/solr/search/neural/KnnQParser.java @@ -17,11 +17,14 @@ package org.apache.solr.search.neural; import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import org.apache.lucene.search.Query; import org.apache.solr.common.SolrException; import org.apache.solr.common.params.CommonParams; import org.apache.solr.common.params.SolrParams; +import org.apache.solr.common.util.StrUtils; import org.apache.solr.request.SolrQueryRequest; import org.apache.solr.schema.DenseVectorField; import org.apache.solr.schema.FieldType; @@ -29,11 +32,13 @@ import org.apache.solr.schema.SchemaField; import org.apache.solr.search.QParser; import org.apache.solr.search.QueryParsing; import org.apache.solr.search.QueryUtils; -import org.apache.solr.search.SolrIndexSearcher; import org.apache.solr.search.SyntaxError; public class KnnQParser extends QParser { + static final String EXCLUDE_TAGS = "excludeTags"; + static final String INCLUDE_TAGS = "includeTags"; + // retrieve the top K results based on the distance similarity function static final String TOP_K = "topK"; static final int DEFAULT_TOP_K = 10; @@ -82,20 +87,132 @@ public class KnnQParser extends QParser { } private Query getFilterQuery() throws SolrException, SyntaxError { - boolean isSubQuery = recurseCount != 0; - if (!isFilter() && !isSubQuery) { - String[] filterQueries = req.getParams().getParams(CommonParams.FQ); - if (filterQueries != null && filterQueries.length != 0) { - try { - List<Query> filters = QueryUtils.parseFilterQueries(req); - SolrIndexSearcher.ProcessedFilter processedFilter = - req.getSearcher().getProcessedFilter(filters); - return processedFilter.filter; - } catch (IOException e) { - throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, e); + + // Default behavior of FQ wrapping, and suitability of some local params + // depends on wether we are a sub-query or not + final boolean isSubQuery = recurseCount != 0; + + // include/exclude tags for global fqs to wrap; + // Check these up front for error handling if combined with `fq` local param. + final List<String> includedGlobalFQTags = getLocalParamTags(INCLUDE_TAGS); + final List<String> excludedGlobalFQTags = getLocalParamTags(EXCLUDE_TAGS); + final boolean haveGlobalFQTags = + !(includedGlobalFQTags.isEmpty() && excludedGlobalFQTags.isEmpty()); + + if (haveGlobalFQTags) { + // Some early error handling of incompatible options... + + if (isFilter()) { // this knn query is itself a filter query + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Knn Query Parser used as a filter does not support " + + INCLUDE_TAGS + + " or " + + EXCLUDE_TAGS + + " localparams"); + } + + if (isSubQuery) { // this knn query is a sub-query of a broader query (possibly disjunction) + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Knn Query Parser used as a sub-query does not support " + + INCLUDE_TAGS + + " or " + + EXCLUDE_TAGS + + " localparams"); + } + } + + // Explicit fq local params specifying the filter(s) to wrap + final String[] localFQs = getLocalParams().getParams(CommonParams.FQ); + if (null != localFQs) { + + // We don't particularly care if localFQs is empty, the usage below will still work, + // but SolrParams API says it should be null not empty... + assert 0 != localFQs.length : "SolrParams.getParams should return null, never zero len array"; + + if (haveGlobalFQTags) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Knn Query Parser does not support combining " + + CommonParams.FQ + + " localparam with either " + + INCLUDE_TAGS + + " or " + + EXCLUDE_TAGS + + " localparams"); + } + + final List<Query> localParamFilters = new ArrayList<>(localFQs.length); + for (String fq : localFQs) { + final QParser parser = subQuery(fq, null); + parser.setIsFilter(true); + + // maybe null, ie: `fq=""` + final Query filter = parser.getQuery(); + if (null != filter) { + localParamFilters.add(filter); } } + try { + return req.getSearcher().getProcessedFilter(localParamFilters).filter; + } catch (IOException e) { + throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, e); + } + } + + // No explicit `fq` localparams specifying what we should filter on. + // + // So now, if we're either a filter or a subquery, we have to default to + // not wrapping anything... + if (isFilter() || isSubQuery) { + return null; + } + + // At this point we now are a (regular) query and can wrap global `fq` filters... + try { + // Start by assuming we wrap all global filters, + // then adjust our list based on include/exclude tag params + List<Query> globalFQs = QueryUtils.parseFilterQueries(req); + + // Adjust our globalFQs based on any include/exclude we may have + if (!includedGlobalFQTags.isEmpty()) { + // NOTE: Even if no FQs match the specified tag(s) the fact that tags were specified + // means we should replace globalFQs (even with a possibly empty list) + globalFQs = new ArrayList<>(QueryUtils.getTaggedQueries(req, includedGlobalFQTags)); + } + if (null != excludedGlobalFQTags) { + globalFQs.removeAll(QueryUtils.getTaggedQueries(req, excludedGlobalFQTags)); + } + + return req.getSearcher().getProcessedFilter(globalFQs).filter; + + } catch (IOException e) { + throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, e); + } + } + + /** + * @return set (possibly empty) of tags specified in the given local param + * @see StrUtils#splitSmart + * @see QueryUtils#getTaggedQueries + * @see #localParams + */ + private List<String> getLocalParamTags(final String param) { + final String[] strVals = localParams.getParams(param); + if (null == strVals) { + return Collections.emptyList(); + } + final List<String> tags = new ArrayList<>(strVals.length * 2); + for (String val : strVals) { + // This ensures parity w/how QParser constructor builds tagMap, + // and that empty strings will make it into our List (for "include nothing") + if (0 < val.indexOf(',')) { + tags.addAll(StrUtils.splitSmart(val, ',')); + } else { + tags.add(val); + } } - return null; + return tags; } } diff --git a/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java b/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java index 0a03521c87b..a72de3efd72 100644 --- a/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java +++ b/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java @@ -1350,9 +1350,51 @@ public class QueryEqualityTest extends SolrTestCaseJ4 { assertU(adoc(doc)); assertU(commit()); - try { - assertQueryEquals( - "knn", "{!knn f=vector}[1.0,2.0,3.0,4.0]", "{!knn f=vector v=[1.0,2.0,3.0,4.0]}"); + try (SolrQueryRequest req0 = req()) { + final String qvec = "[1.0,2.0,3.0,4.0]"; + // no filters + final Query fqNull = + assertQueryEqualsAndReturn( + "knn", + req0, + "{!knn f=vector}" + qvec, + "{!knn f=vector fq=''}" + qvec, + "{!knn f=vector v=" + qvec + "}"); + + try (SolrQueryRequest req1 = req("fq", "{!tag=t1}id:1", "xxx", "id:1")) { + // either global fq, or (same) fq as localparam + final Query fqOne = + assertQueryEqualsAndReturn( + "knn", + req1, + "{!knn f=vector}" + qvec, + "{!knn f=vector includeTags=t1}" + qvec, + "{!knn f=vector fq='id:1'}" + qvec, + "{!knn f=vector fq=$xxx}" + qvec, + "{!knn f=vector v=" + qvec + "}"); + QueryUtils.checkUnequal(fqNull, fqOne); + + try (SolrQueryRequest req2 = req("fq", "{!tag=t2}id:2", "xxx", "id:1", "yyy", "")) { + // override global fq with local param to use different filter + final Query fqOneOverride = + assertQueryEqualsAndReturn( + "knn", + req2, + "{!knn f=vector fq='id:1'}" + qvec, + "{!knn f=vector fq=$xxx}" + qvec); + QueryUtils.checkEqual(fqOne, fqOneOverride); + + // override global fq with local param to use no filters + final Query fqNullOverride = + assertQueryEqualsAndReturn( + "knn", + req2, + "{!knn f=vector fq=''}" + qvec, + "{!knn f=vector excludeTags=t2}" + qvec, + "{!knn f=vector fq=$yyy}" + qvec); + QueryUtils.checkEqual(fqNull, fqNullOverride); + } + } } finally { delQ("id:0"); assertU(commit()); @@ -1364,12 +1406,12 @@ public class QueryEqualityTest extends SolrTestCaseJ4 { * for coverage sanity checking * * @see #testParserCoverage - * @see #assertQueryEquals + * @see #assertQueryEqualsAndReturn */ protected void assertQueryEquals(final String defType, final String... inputs) throws Exception { SolrQueryRequest req = req(new String[] {"df", "text"}); try { - assertQueryEquals(defType, req, inputs); + assertQueryEqualsAndReturn(defType, req, inputs); } finally { req.close(); } @@ -1379,13 +1421,34 @@ public class QueryEqualityTest extends SolrTestCaseJ4 { * NOTE: defType is not only used to pick the parser, but, if non-null it is also to record the * parser being tested for coverage sanity checking * + * @see #testParserCoverage + * @see #assertQueryEqualsAndReturn + */ + protected void assertQueryEquals( + final String defType, final SolrQueryRequest req, final String... inputs) throws Exception { + assertQueryEqualsAndReturn(defType, req, inputs); + } + + /** + * Parses a set of input strings in the context of a request, making various assertions about the + * resulting Query objects, including that they must all be equals. + * + * <p>Returns one of the (all equal) Query objects so it may be used in other comparisons with + * other Query objects, possibly parsed in the context of different requests. + * + * <p>NOTE: defType is not only used to pick the parser, but, if non-null it is also to record the + * parser being tested for coverage sanity checking. + * * @see QueryUtils#check * @see QueryUtils#checkEqual * @see #testParserCoverage */ - protected void assertQueryEquals( + protected Query assertQueryEqualsAndReturn( final String defType, final SolrQueryRequest req, final String... inputs) throws Exception { + assertTrue( + "At least one input string for parsing must be passed to this method", 0 < inputs.length); + if (null != defType) qParsersTested.add(defType); final Query[] queries = new Query[inputs.length]; @@ -1409,6 +1472,7 @@ public class QueryEqualityTest extends SolrTestCaseJ4 { QueryUtils.checkEqual(query1, query2); } } + return queries[0]; } /** diff --git a/solr/core/src/test/org/apache/solr/search/neural/KnnQParserTest.java b/solr/core/src/test/org/apache/solr/search/neural/KnnQParserTest.java index 5cf2fd41f97..197540b2c7b 100644 --- a/solr/core/src/test/org/apache/solr/search/neural/KnnQParserTest.java +++ b/solr/core/src/test/org/apache/solr/search/neural/KnnQParserTest.java @@ -26,6 +26,8 @@ import org.apache.solr.SolrTestCaseJ4; import org.apache.solr.common.SolrException; import org.apache.solr.common.SolrInputDocument; import org.apache.solr.common.params.CommonParams; +import org.apache.solr.common.params.SolrParams; +import org.apache.solr.request.SolrQueryRequest; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -453,6 +455,8 @@ public class KnnQParserTest extends SolrTestCaseJ4 { @Test public void knnQueryUsedInFilters_shouldFilterResultsBeforeTheQueryExecution() { String vectorToSearch = "[1.0, 2.0, 3.0, 4.0]"; + + // topK=4 -> 1,4,2,10 assertQ( req( CommonParams.Q, @@ -460,45 +464,379 @@ public class KnnQParserTest extends SolrTestCaseJ4 { "fq", "{!knn f=vector topK=4}" + vectorToSearch, "fq", - "id:(4 20)", + "id:(4 20 9)", "fl", "id"), "//result[@numFound='1']", "//result/doc[1]/str[@name='id'][.='4']"); - } - - @Test - public void knnQueryWithFilterQuery_shouldPerformKnnSearchInPreFilteredResults() { - String vectorToSearch = "[1.0, 2.0, 3.0, 4.0]"; + // topK=4 w/localparam fq -> 1,4,7,9 assertQ( req( CommonParams.Q, - "{!knn f=vector topK=10}" + vectorToSearch, + "id:(3 4 9 2)", + "fq", + "{!knn f=vector topK=4 fq='id:(1 4 7 8 9)'}" + vectorToSearch, "fq", - "id:(1 2 7 20)", + "id:(4 20 9)", "fl", "id"), + "//result[@numFound='2']", + "//result/doc[1]/str[@name='id'][.='4']", + "//result/doc[2]/str[@name='id'][.='9']"); + + for (String fq : + Arrays.asList( + "{!knn f=vector topK=5 includeTags=xxx}" + vectorToSearch, + "{!knn f=vector topK=5 excludeTags=xxx}" + vectorToSearch)) { + assertQEx( + "fq={!knn...} incompatible with include/exclude localparams", + "used as a filter does not support", + req("q", "*:*", "fq", fq), + SolrException.ErrorCode.BAD_REQUEST); + } + } + + @Test + public void knnQueryAsSubQuery() { + final SolrParams common = params("fl", "id", "vec", "[1.0, 2.0, 3.0, 4.0]"); + final String filt = "id:(2 4 7 9 8 20 3)"; + + // When knn parser is a subquery, it should not pre-filter on any global fq params + // topK -> 1,4,2,10,3 -> fq -> 4,2,3 + assertQ( + req(common, "fq", filt, "q", "*:* AND {!knn f=vector topK=5 v=$vec}"), "//result[@numFound='3']", - "//result/doc[1]/str[@name='id'][.='1']", + "//result/doc[1]/str[@name='id'][.='4']", + "//result/doc[2]/str[@name='id'][.='2']", + "//result/doc[3]/str[@name='id'][.='3']"); + // topK -> 1,4,2,10,3 + '8' -> fq -> 4,2,3,8 + assertQ( + req(common, "fq", filt, "q", "id:8^=0.01 OR {!knn f=vector topK=5 v=$vec}"), + "//result[@numFound='4']", + "//result/doc[1]/str[@name='id'][.='4']", "//result/doc[2]/str[@name='id'][.='2']", - "//result/doc[3]/str[@name='id'][.='7']"); + "//result/doc[3]/str[@name='id'][.='3']", + "//result/doc[4]/str[@name='id'][.='8']"); + // knn subquery should still accept `fq` local param + // filt -> topK -> 4,2,3,7,9 + assertQ( + req(common, "q", "*:* AND {!knn f=vector topK=5 fq='" + filt + "' v=$vec}"), + "//result[@numFound='5']", + "//result/doc[1]/str[@name='id'][.='4']", + "//result/doc[2]/str[@name='id'][.='2']", + "//result/doc[3]/str[@name='id'][.='3']", + "//result/doc[4]/str[@name='id'][.='7']", + "//result/doc[5]/str[@name='id'][.='9']"); + + // knn subquery should still accept `fq` local param, and not pre-filter on any global fq params + // filt -> topK -> 4,2,3,7,9 -> fq -> 3,9 assertQ( req( - CommonParams.Q, - "{!knn f=vector topK=4}" + vectorToSearch, + common, "fq", - "id:(3 4 9 2)", - "fl", - "id"), + "id:(1 9 20 3 5 6 8)", + "q", + "*:* AND {!knn f=vector topK=5 fq='" + filt + "' v=$vec}"), + "//result[@numFound='2']", + "//result/doc[1]/str[@name='id'][.='3']", + "//result/doc[2]/str[@name='id'][.='9']"); + // filt -> topK -> 4,2,3,7,9 + '8' -> fq -> 8,3,9 + assertQ( + req( + common, + "fq", + "id:(1 9 20 3 5 6 8)", + "q", + "id:8^=100 OR {!knn f=vector topK=5 fq='" + filt + "' v=$vec}"), + "//result[@numFound='3']", + "//result/doc[1]/str[@name='id'][.='8']", + "//result/doc[2]/str[@name='id'][.='3']", + "//result/doc[3]/str[@name='id'][.='9']"); + + for (String knn : + Arrays.asList( + "{!knn f=vector topK=5 includeTags=xxx v=$vec}", + "{!knn f=vector topK=5 excludeTags=xxx v=$vec}")) { + assertQEx( + "knn as subquery incompatible with include/exclude localparams", + "used as a sub-query does not support", + req(common, "q", "*:* OR " + knn), + SolrException.ErrorCode.BAD_REQUEST); + } + } + + @Test + public void knnQueryWithFilterQuery_shouldPerformKnnSearchInPreFilteredResults() { + final String vectorToSearch = "[1.0, 2.0, 3.0, 4.0]"; + final SolrParams common = params("fl", "id"); + + { // these requests should be equivilent + final String filt = "id:(1 2 7 20)"; + for (SolrQueryRequest req : + Arrays.asList( + req(common, "q", "{!knn f=vector topK=10}" + vectorToSearch, "fq", filt), + req(common, "q", "{!knn f=vector fq=\"" + filt + "\" topK=10}" + vectorToSearch), + req( + common, + "q", + "{!knn f=vector fq=$my_filt topK=10}" + vectorToSearch, + "my_filt", + filt))) { + assertQ( + req, + "//result[@numFound='3']", + "//result/doc[1]/str[@name='id'][.='1']", + "//result/doc[2]/str[@name='id'][.='2']", + "//result/doc[3]/str[@name='id'][.='7']"); + } + } + + { // these requests should be equivilent + final String fx = "id:(3 4 9 2 1 )"; // 1 & 10 dropped from intersection + final String fy = "id:(3 4 9 2 10)"; + for (SolrQueryRequest req : + Arrays.asList( + req(common, "q", "{!knn f=vector topK=4}" + vectorToSearch, "fq", fx, "fq", fy), + req( + common, + "q", + "{!knn f=vector fq=\"" + fx + "\" fq=\"" + fy + "\" topK=4}" + vectorToSearch), + req( + common, + "q", + "{!knn f=vector fq=$fx fq=$fy topK=4}" + vectorToSearch, + "fx", + fx, + "fy", + fy), + req( + common, + "q", + "{!knn f=vector fq=$multi_filt topK=4}" + vectorToSearch, + "multi_filt", + fx, + "multi_filt", + fy))) { + assertQ( + req, + "//result[@numFound='4']", + "//result/doc[1]/str[@name='id'][.='4']", + "//result/doc[2]/str[@name='id'][.='2']", + "//result/doc[3]/str[@name='id'][.='3']", + "//result/doc[4]/str[@name='id'][.='9']"); + } + } + + assertQEx( + "knn fq localparm incompatible with include/exclude localparams", + "does not support combining fq localparam with either", + // shouldn't matter if global fq w/tag even exists, usage is an error + req("q", "{!knn f=vector fq='id:1' includeTags=xxx}" + vectorToSearch), + SolrException.ErrorCode.BAD_REQUEST); + assertQEx( + "knn fq localparm incompatible with include/exclude localparams", + "does not support combining fq localparam with either", + // shouldn't matter if global fq w/tag even exists, usage is an error + req("q", "{!knn f=vector fq='id:1' excludeTags=xxx}" + vectorToSearch), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void knnQueryWithFilterQuery_localParamOverridesGlobalFilters() { + final String vectorToSearch = "[1.0, 2.0, 3.0, 4.0]"; + + // trivial case: empty fq localparam means no pre-filtering + assertQ( + req( + "q", "{!knn f=vector fq='' topK=5}" + vectorToSearch, + "fq", "-id:4", + "fl", "id"), "//result[@numFound='4']", - "//result/doc[1]/str[@name='id'][.='4']", + "//result/doc[1]/str[@name='id'][.='1']", "//result/doc[2]/str[@name='id'][.='2']", - "//result/doc[3]/str[@name='id'][.='3']", + "//result/doc[3]/str[@name='id'][.='10']", + "//result/doc[4]/str[@name='id'][.='3']"); + + // localparam prefiltering, global fqs applied independently + assertQ( + req( + "q", "{!knn f=vector fq='id:(3 4 9 2 7 8)' topK=5}" + vectorToSearch, + "fq", "-id:4", + "fl", "id"), + "//result[@numFound='4']", + "//result/doc[1]/str[@name='id'][.='2']", + "//result/doc[2]/str[@name='id'][.='3']", + "//result/doc[3]/str[@name='id'][.='7']", "//result/doc[4]/str[@name='id'][.='9']"); } + @Test + public void knnQueryWithFilterQuery_localParamIncludeExcludeTags() { + final String vectorToSearch = "[1.0, 2.0, 3.0, 4.0]"; + final SolrParams common = + params( + "fl", "id", + "fq", "{!tag=xx,aa}id:(5 6 7 8 9 10)", + "fq", "{!tag=yy,aa}id:(1 2 3 4 5 6 7)"); + + // These req's are equivilent: pre-filter everything + // So only 7,6,5 are viable for topK=5 + for (SolrQueryRequest req : + Arrays.asList( + // default behavior is all fq's pre-filter, + req(common, "q", "{!knn f=vector topK=5}" + vectorToSearch), + // diff ways of explicitly requesting both fq params + req(common, "q", "{!knn f=vector includeTags=aa topK=5}" + vectorToSearch), + req( + common, + "q", + "{!knn f=vector includeTags=aa excludeTags='' topK=5}" + vectorToSearch), + req( + common, + "q", + "{!knn f=vector includeTags=aa excludeTags=bogus topK=5}" + vectorToSearch), + req( + common, + "q", + "{!knn f=vector includeTags=xx includeTags=yy topK=5}" + vectorToSearch), + req(common, "q", "{!knn f=vector includeTags=xx,yy,bogus topK=5}" + vectorToSearch))) { + assertQ( + req, + "//result[@numFound='3']", + "//result/doc[1]/str[@name='id'][.='7']", + "//result/doc[2]/str[@name='id'][.='5']", + "//result/doc[3]/str[@name='id'][.='6']"); + } + + // These req's are equivilent: pre-filter nothing + // So 1,4,2,10,3,7 are the topK=6 + // Only 7 matches both of the the regular fq params + for (SolrQueryRequest req : + Arrays.asList( + // explicit local empty fq + req(common, "q", "{!knn f=vector fq='' topK=6}" + vectorToSearch), + // diff ways of explicitly including none of the global fq params + req(common, "q", "{!knn f=vector includeTags='' topK=6}" + vectorToSearch), + req(common, "q", "{!knn f=vector includeTags=bogus topK=6}" + vectorToSearch), + // diff ways of explicitly excluding all of the global fq params + req(common, "q", "{!knn f=vector excludeTags=aa topK=6}" + vectorToSearch), + req( + common, + "q", + "{!knn f=vector includeTags=aa excludeTags=aa topK=6}" + vectorToSearch), + req( + common, + "q", + "{!knn f=vector includeTags=aa excludeTags=xx,yy topK=6}" + vectorToSearch), + req( + common, + "q", + "{!knn f=vector includeTags=xx,yy excludeTags=aa topK=6}" + vectorToSearch), + req(common, "q", "{!knn f=vector excludeTags=xx,yy topK=6}" + vectorToSearch), + req(common, "q", "{!knn f=vector excludeTags=aa topK=6}" + vectorToSearch), + req( + common, + "q", + "{!knn f=vector excludeTags=xx excludeTags=yy topK=6}" + vectorToSearch), + req( + common, + "q", + "{!knn f=vector excludeTags=xx excludeTags=yy,bogus topK=6}" + vectorToSearch), + req(common, "q", "{!knn f=vector excludeTags=xx,yy,bogus topK=6}" + vectorToSearch))) { + assertQ(req, "//result[@numFound='1']", "//result/doc[1]/str[@name='id'][.='7']"); + } + + // These req's are equivilent: prefilter only the 'yy' fq + // So 1,4,2,3,7 are in the topK=5. + // Only 7 matches the regular 'xx' fq param + for (SolrQueryRequest req : + Arrays.asList( + // diff ways of only using the 'yy' filter + req(common, "q", "{!knn f=vector includeTags=yy,bogus topK=5}" + vectorToSearch), + req( + common, + "q", + "{!knn f=vector includeTags=yy excludeTags='' topK=5}" + vectorToSearch), + req(common, "q", "{!knn f=vector excludeTags=xx,bogus topK=5}" + vectorToSearch), + req( + common, + "q", + "{!knn f=vector includeTags=yy excludeTags=xx topK=5}" + vectorToSearch), + req( + common, + "q", + "{!knn f=vector includeTags=aa excludeTags=xx topK=5}" + vectorToSearch))) { + assertQ(req, "//result[@numFound='1']", "//result/doc[1]/str[@name='id'][.='7']"); + } + } + + @Test + public void knnQueryWithMultiSelectFaceting_excludeTags() { + // NOTE: faceting on id is not very realistic, + // but it confirms what we care about re:filters w/o needing extra fields. + final String facet_xpath = "//lst[@name='facet_fields']/lst[@name='id']/int"; + final String vectorToSearch = "[1.0, 2.0, 3.0, 4.0]"; + + final SolrParams common = + params( + "fl", "id", + "indent", "true", + "q", "{!knn f=vector topK=5 excludeTags=facet_click v=$vec}", + "vec", vectorToSearch, + // mimicing "inStock:true" + "fq", "-id:(2 3)", + "facet", "true", + "facet.mincount", "1", + "facet.field", "{!ex=facet_click}id"); + + // initial query, with basic pre-filter and facet counts + assertQ( + req(common), + "//result[@numFound='5']", + "//result/doc[1]/str[@name='id'][.='1']", + "//result/doc[2]/str[@name='id'][.='4']", + "//result/doc[3]/str[@name='id'][.='10']", + "//result/doc[4]/str[@name='id'][.='7']", + "//result/doc[5]/str[@name='id'][.='5']", + "*[count(" + facet_xpath + ")=5]", + facet_xpath + "[@name='1'][.='1']", + facet_xpath + "[@name='4'][.='1']", + facet_xpath + "[@name='10'][.='1']", + facet_xpath + "[@name='7'][.='1']", + facet_xpath + "[@name='5'][.='1']"); + + // drill down on a single facet constraint + // multi-select means facet counts shouldn't change + // (this proves the knn isn't pre-filtering on the 'facet_click' fq) + assertQ( + req(common, "fq", "{!tag=facet_click}id:(4)"), + "//result[@numFound='1']", + "//result/doc[1]/str[@name='id'][.='4']", + "*[count(" + facet_xpath + ")=5]", + facet_xpath + "[@name='1'][.='1']", + facet_xpath + "[@name='4'][.='1']", + facet_xpath + "[@name='10'][.='1']", + facet_xpath + "[@name='7'][.='1']", + facet_xpath + "[@name='5'][.='1']"); + + // drill down on an additional facet constraint + // multi-select means facet counts shouldn't change + // (this proves the knn isn't pre-filtering on the 'facet_click' fq) + assertQ( + req(common, "fq", "{!tag=facet_click}id:(4 5)"), + "//result[@numFound='2']", + "//result/doc[1]/str[@name='id'][.='4']", + "//result/doc[2]/str[@name='id'][.='5']", + "*[count(" + facet_xpath + ")=5]", + facet_xpath + "[@name='1'][.='1']", + facet_xpath + "[@name='4'][.='1']", + facet_xpath + "[@name='10'][.='1']", + facet_xpath + "[@name='7'][.='1']", + facet_xpath + "[@name='5'][.='1']"); + } + @Test public void knnQueryWithCostlyFq_shouldPerformKnnSearchWithPostFilter() { String vectorToSearch = "[1.0, 2.0, 3.0, 4.0]";
