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]";

Reply via email to