This is an automated email from the ASF dual-hosted git repository.

Jackie-Jiang pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git


The following commit(s) were added to refs/heads/master by this push:
     new 92360684523 Make filter count and bitmap shortcuts three-valued under 
null handling (#19440)
92360684523 is described below

commit 92360684523ab24998d9f104596a3de3f9452528
Author: Xiaotian (Jackie) Jiang <[email protected]>
AuthorDate: Mon Sep 14 22:37:07 2026 -0700

    Make filter count and bitmap shortcuts three-valued under null handling 
(#19440)
---
 .../core/operator/filter/AndFilterOperator.java    |  80 +++++---
 .../operator/filter/BaseColumnFilterOperator.java  |  66 ++++---
 .../core/operator/filter/BaseFilterOperator.java   |  65 +++++--
 .../operator/filter/BitmapBasedFilterOperator.java |  45 ++++-
 .../core/operator/filter/BitmapCollection.java     | 127 +++++++++++--
 .../operator/filter/ExpressionFilterOperator.java  |  25 ++-
 .../core/operator/filter/FilterOperatorUtils.java  |  43 ++++-
 .../filter/InvertedIndexFilterOperator.java        |  24 ++-
 .../core/operator/filter/MapFilterOperator.java    |  10 +
 .../core/operator/filter/NotFilterOperator.java    |  33 +++-
 .../core/operator/filter/OrFilterOperator.java     |  77 +++++---
 .../filter/RangeIndexBasedFilterOperator.java      |  35 +++-
 .../filter/SortedIndexBasedFilterOperator.java     |  19 +-
 .../apache/pinot/core/startree/StarTreeUtils.java  |  46 +++--
 .../operator/filter/AndFilterOperatorTest.java     | 111 +++++++++++
 .../core/operator/filter/BitmapCollectionTest.java |  91 +++++++++
 .../filter/InvertedIndexFilterOperatorTest.java    |  64 ++++++-
 .../operator/filter/NotFilterOperatorTest.java     |  81 +++++++-
 .../core/operator/filter/OrFilterOperatorTest.java |  68 +++++++
 .../pinot/core/startree/v2/BaseStarTreeV2Test.java |   4 +-
 .../queries/NullHandlingEnabledQueriesTest.java    | 210 +++++++++++++++++++++
 .../queries/StarTreeNullHandlingQueriesTest.java   | 210 +++++++++++++++++++++
 .../tests/NullHandlingIntegrationTest.java         |   6 +-
 23 files changed, 1389 insertions(+), 151 deletions(-)

diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/AndFilterOperator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/AndFilterOperator.java
index 53ed6e4b53f..fca2858a5ed 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/AndFilterOperator.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/AndFilterOperator.java
@@ -28,12 +28,11 @@ import org.apache.pinot.core.common.Operator;
 import org.apache.pinot.core.operator.docidsets.AndDocIdSet;
 import org.apache.pinot.core.operator.docidsets.EmptyDocIdSet;
 import org.apache.pinot.core.operator.docidsets.MatchAllDocIdSet;
-import org.apache.pinot.core.operator.docidsets.NotDocIdSet;
-import org.apache.pinot.core.operator.docidsets.OrDocIdSet;
 import org.apache.pinot.core.operator.docidsets.ShortCircuitingDocIdSet;
 import org.apache.pinot.spi.trace.Tracing;
 import org.roaringbitmap.buffer.BufferFastAggregation;
 import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
+import org.roaringbitmap.buffer.MutableRoaringBitmap;
 
 
 public class AndFilterOperator extends BaseFilterOperator {
@@ -72,33 +71,29 @@ public class AndFilterOperator extends BaseFilterOperator {
     return new AndDocIdSet(blockDocIdSets, _queryOptions);
   }
 
+  /// A conjunction is not false where no child is false: the intersection of 
the children's not-false documents.
   @Override
-  protected BlockDocIdSet getFalses() {
-    List<BlockDocIdSet> blockDocIdSets = new 
ArrayList<>(_filterOperators.size());
+  protected BlockDocIdSet getNotFalses() {
+    List<BlockDocIdSet> notFalses = new ArrayList<>(_filterOperators.size());
     for (BaseFilterOperator filterOperator : _filterOperators) {
-      BlockDocIdSet trues = filterOperator.getTrues();
-      if (trues instanceof EmptyDocIdSet) {
-        return new MatchAllDocIdSet(_numDocs);
+      BlockDocIdSet childNotFalses = filterOperator.getNotFalses();
+      if (childNotFalses instanceof EmptyDocIdSet) {
+        return EmptyDocIdSet.getInstance();
       }
-      if (trues instanceof MatchAllDocIdSet) {
+      if (childNotFalses instanceof MatchAllDocIdSet) {
         continue;
       }
-      if (_nullHandlingEnabled) {
-        BlockDocIdSet nulls = filterOperator.getNulls();
-        if (!(nulls instanceof EmptyDocIdSet)) {
-          blockDocIdSets.add(new OrDocIdSet(Arrays.asList(trues, nulls), 
_numDocs));
-          continue;
-        }
-      }
-      blockDocIdSets.add(trues);
-    }
-    if (blockDocIdSets.isEmpty()) {
-      return EmptyDocIdSet.getInstance();
+      notFalses.add(childNotFalses);
     }
-    if (blockDocIdSets.size() == 1) {
-      return new NotDocIdSet(blockDocIdSets.get(0), _numDocs);
+    if (notFalses.isEmpty()) {
+      return new MatchAllDocIdSet(_numDocs);
     }
-    return new NotDocIdSet(new AndDocIdSet(blockDocIdSets, _queryOptions), 
_numDocs);
+    return notFalses.size() == 1 ? notFalses.get(0) : new 
AndDocIdSet(notFalses, _queryOptions);
+  }
+
+  @Override
+  protected BlockDocIdSet getNulls() {
+    return mayHaveNulls() ? deriveNulls(_queryOptions) : 
EmptyDocIdSet.getInstance();
   }
 
   @Override
@@ -129,13 +124,46 @@ public class AndFilterOperator extends BaseFilterOperator 
{
     return true;
   }
 
+  /// The true documents are those true for every child. When a child has 
UNKNOWN documents, so has the result: a
+  /// document is UNKNOWN when no child is false for it and some child is 
UNKNOWN, which is the intersection of the
+  /// children's not-false documents minus the intersection of their true ones.
   @Override
   public BitmapCollection getBitmaps() {
-    ImmutableRoaringBitmap[] bitmaps = new 
ImmutableRoaringBitmap[_filterOperators.size()];
-    for (int i = 0; i < _filterOperators.size(); i++) {
-      bitmaps[i] = _filterOperators.get(i).getBitmaps().reduce();
+    int numChildren = _filterOperators.size();
+    ImmutableRoaringBitmap[] trues = new ImmutableRoaringBitmap[numChildren];
+    ImmutableRoaringBitmap[] notFalses = null;
+    for (int i = 0; i < numChildren; i++) {
+      BitmapCollection childBitmaps = _filterOperators.get(i).getBitmaps();
+      trues[i] = childBitmaps.reduce();
+      ImmutableRoaringBitmap childNulls = childBitmaps.getNullBitmap();
+      if (childNulls != null) {
+        if (notFalses == null) {
+          notFalses = Arrays.copyOf(trues, numChildren);
+        }
+        notFalses[i] = ImmutableRoaringBitmap.or(trues[i], childNulls);
+      } else if (notFalses != null) {
+        notFalses[i] = trues[i];
+      }
+    }
+    MutableRoaringBitmap andTrues = BufferFastAggregation.and(trues);
+    if (notFalses == null) {
+      return new BitmapCollection(_numDocs, false, andTrues);
+    }
+    MutableRoaringBitmap nulls = BufferFastAggregation.and(notFalses);
+    nulls.andNot(andTrues);
+    return new BitmapCollection(_numDocs, false, 
andTrues).excludingNulls(nulls);
+  }
+
+  @Override
+  public boolean mayHaveNulls() {
+    if (_nullHandlingEnabled) {
+      for (BaseFilterOperator filterOperator : _filterOperators) {
+        if (filterOperator.mayHaveNulls()) {
+          return true;
+        }
+      }
     }
-    return new BitmapCollection(_numDocs, false, 
BufferFastAggregation.and(bitmaps));
+    return false;
   }
 
   @Override
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/BaseColumnFilterOperator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/BaseColumnFilterOperator.java
index 7b31b13d445..e74da0edb5c 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/BaseColumnFilterOperator.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/BaseColumnFilterOperator.java
@@ -18,64 +18,86 @@
  */
 package org.apache.pinot.core.operator.filter;
 
-import java.util.Arrays;
+import java.util.List;
 import javax.annotation.Nullable;
 import org.apache.pinot.core.common.BlockDocIdSet;
 import org.apache.pinot.core.operator.docidsets.AndDocIdSet;
 import org.apache.pinot.core.operator.docidsets.BitmapDocIdSet;
 import org.apache.pinot.core.operator.docidsets.EmptyDocIdSet;
+import org.apache.pinot.core.operator.docidsets.OrDocIdSet;
 import org.apache.pinot.core.query.request.context.QueryContext;
 import org.apache.pinot.segment.spi.datasource.DataSource;
-import org.apache.pinot.segment.spi.index.reader.NullValueVectorReader;
 import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
 
 
 public abstract class BaseColumnFilterOperator extends BaseFilterOperator {
   protected final QueryContext _queryContext;
   protected final DataSource _dataSource;
+  @Nullable
+  private final ImmutableRoaringBitmap _nullBitmap;
 
   protected BaseColumnFilterOperator(QueryContext queryContext, DataSource 
dataSource, int numDocs) {
     super(numDocs, queryContext.isNullHandlingEnabled());
     _queryContext = queryContext;
     _dataSource = dataSource;
+    _nullBitmap = _nullHandlingEnabled ? 
FilterOperatorUtils.getNullBitmap(dataSource) : null;
   }
 
   protected abstract BlockDocIdSet getNextBlockWithoutNullHandling();
 
   @Override
   protected BlockDocIdSet getTrues() {
-    if (_nullHandlingEnabled) {
-      ImmutableRoaringBitmap nullBitmap = getNullBitmap();
-      if (nullBitmap != null && !nullBitmap.isEmpty()) {
-        return excludeNulls(getNextBlockWithoutNullHandling(), nullBitmap);
-      }
+    if (_nullBitmap != null) {
+      return excludeNulls(getNextBlockWithoutNullHandling(), _nullBitmap);
     }
     return getNextBlockWithoutNullHandling();
   }
 
   @Override
   protected BlockDocIdSet getNulls() {
-    ImmutableRoaringBitmap nullBitmap = getNullBitmap();
-    if (nullBitmap != null && !nullBitmap.isEmpty()) {
-      return new BitmapDocIdSet(nullBitmap, _numDocs);
-    } else {
-      return EmptyDocIdSet.getInstance();
+    return _nullBitmap != null ? new BitmapDocIdSet(_nullBitmap, _numDocs) : 
EmptyDocIdSet.getInstance();
+  }
+
+  /// The not-false documents are the ones matching the predicate over the 
stored values together with the null ones.
+  /// The default implementation reads [#getTrues], which takes the null rows 
out of the match, only to put them back.
+  @Override
+  protected BlockDocIdSet getNotFalses() {
+    BlockDocIdSet matches = getNextBlockWithoutNullHandling();
+    if (_nullBitmap == null) {
+      return matches;
     }
+    return new OrDocIdSet(List.of(matches, new BitmapDocIdSet(_nullBitmap, 
_numDocs)), _numDocs);
   }
 
-  private BlockDocIdSet excludeNulls(BlockDocIdSet blockDocIdSet, 
ImmutableRoaringBitmap nullBitmap) {
-    return new AndDocIdSet(Arrays.asList(blockDocIdSet,
-        new BitmapDocIdSet(ImmutableRoaringBitmap.flip(nullBitmap, 0, (long) 
_numDocs), _numDocs)),
-        _queryContext.getQueryOptions());
+  @Override
+  public boolean mayHaveNulls() {
+    return _nullBitmap != null;
   }
 
+  /// Returns the documents the predicate is UNKNOWN for, or `null` when there 
is none: null handling is disabled, or
+  /// the column has no null row. The bitmap is read once, at construction, so 
that every view of the operator agrees
+  /// on the same documents even on a consuming segment, whose vector hands 
out a fresh copy on each read. An
+  /// implementation with a count or bitmap shortcut attaches it to its 
[BitmapCollection] so that those leave the null
+  /// documents out too.
   @Nullable
-  private ImmutableRoaringBitmap getNullBitmap() {
-    NullValueVectorReader nullValueVector = _dataSource.getNullValueVector();
-    if (nullValueVector != null) {
-      return nullValueVector.getNullBitmap();
-    } else {
-      return null;
+  protected ImmutableRoaringBitmap getNullBitmap() {
+    return _nullBitmap;
+  }
+
+  /// Returns how many documents are true when the index finds 
`numMatchingDocs` documents matching the predicate's
+  /// values, `numMatchingNulls` of which are null rows and so UNKNOWN rather 
than true. When `exclusive`, the index
+  /// found the documents that do not match, and the true documents are the 
rest minus the null rows among the rest.
+  protected int toNumTrueDocs(int numMatchingDocs, int numMatchingNulls, 
boolean exclusive) {
+    if (exclusive) {
+      int numNulls = _nullBitmap != null ? _nullBitmap.getCardinality() : 0;
+      return _numDocs - numMatchingDocs - (numNulls - numMatchingNulls);
     }
+    return numMatchingDocs - numMatchingNulls;
+  }
+
+  private BlockDocIdSet excludeNulls(BlockDocIdSet blockDocIdSet, 
ImmutableRoaringBitmap nullBitmap) {
+    return new AndDocIdSet(List.of(blockDocIdSet,
+        new BitmapDocIdSet(ImmutableRoaringBitmap.flip(nullBitmap, 0, (long) 
_numDocs), _numDocs)),
+        _queryContext.getQueryOptions());
   }
 }
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/BaseFilterOperator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/BaseFilterOperator.java
index e45c2f47545..0293a003d9a 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/BaseFilterOperator.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/BaseFilterOperator.java
@@ -18,12 +18,14 @@
  */
 package org.apache.pinot.core.operator.filter;
 
-import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
 import javax.annotation.Nullable;
 import org.apache.pinot.core.common.BlockDocIdIterator;
 import org.apache.pinot.core.common.BlockDocIdSet;
 import org.apache.pinot.core.operator.BaseOperator;
 import org.apache.pinot.core.operator.blocks.FilterBlock;
+import org.apache.pinot.core.operator.docidsets.AndDocIdSet;
 import org.apache.pinot.core.operator.docidsets.EmptyDocIdSet;
 import org.apache.pinot.core.operator.docidsets.MatchAllDocIdSet;
 import org.apache.pinot.core.operator.docidsets.NotDocIdSet;
@@ -70,11 +72,20 @@ public abstract class BaseFilterOperator extends 
BaseOperator<FilterBlock> {
     return false;
   }
 
-  /// @return bitmaps of matching docIds
+  /// Returns the true documents as bitmaps. The collection leaves out the 
documents the filter is UNKNOWN for and
+  /// carries them, so that its inversion leaves them out as well.
   public BitmapCollection getBitmaps() {
     throw new UnsupportedOperationException();
   }
 
+  /// Returns whether some documents may be UNKNOWN to this filter, that is 
whether [#getNulls] may be non-empty. The
+  /// answer comes from metadata rather than from evaluating the filter, so it 
may be pessimistic, and it is `false`
+  /// whenever null handling is disabled, since every document then reads as a 
value. A parent that derives its result
+  /// from the two-valued shortcuts alone, such as a negation counting the 
complement, consults this before doing so.
+  public boolean mayHaveNulls() {
+    return _nullHandlingEnabled;
+  }
+
   /// Exact filtered docIds for the operator. `null` indicates match-all.
   public static final class FilteredDocIds {
     @Nullable
@@ -139,22 +150,52 @@ public abstract class BaseFilterOperator extends 
BaseOperator<FilterBlock> {
     return EmptyDocIdSet.getInstance();
   }
 
-  /// @return document IDs in which the predicate evaluates to false.
-  protected BlockDocIdSet getFalses() {
+  /// Returns the document IDs in which the predicate does not evaluate to 
false: the true ones and, with null
+  /// handling enabled, the NULL ones. The result is a [MatchAllDocIdSet] when 
the predicate is true everywhere and an
+  /// [EmptyDocIdSet] when it is false everywhere, so that a parent can 
short-circuit on either.
+  protected BlockDocIdSet getNotFalses() {
     BlockDocIdSet trues = getTrues();
-    if (trues instanceof MatchAllDocIdSet) {
+    if (!_nullHandlingEnabled || trues instanceof MatchAllDocIdSet) {
+      return trues;
+    }
+    BlockDocIdSet nulls = getNulls();
+    if (nulls instanceof EmptyDocIdSet) {
+      return trues;
+    }
+    return trues instanceof EmptyDocIdSet ? nulls : new 
OrDocIdSet(List.of(trues, nulls), _numDocs);
+  }
+
+  /// Returns the document IDs in which the predicate evaluates to NULL, 
derived as the ones that are neither false
+  /// nor true.
+  ///
+  /// Only for operators that override [#getNotFalses]. The default 
[#getNotFalses] reads [#getNulls], so an operator
+  /// that keeps it would recurse; a leaf returns its UNKNOWN documents 
directly instead.
+  protected BlockDocIdSet deriveNulls(@Nullable Map<String, String> 
queryOptions) {
+    BlockDocIdSet notFalses = getNotFalses().getOptimizedDocIdSet();
+    if (notFalses instanceof EmptyDocIdSet) {
       return EmptyDocIdSet.getInstance();
     }
-    if (_nullHandlingEnabled) {
-      BlockDocIdSet nulls = getNulls();
-      if (!(nulls instanceof EmptyDocIdSet)) {
-        return new NotDocIdSet(new OrDocIdSet(Arrays.asList(trues, nulls), 
_numDocs),
-            _numDocs);
-      }
+    BlockDocIdSet trues = getTrues().getOptimizedDocIdSet();
+    if (trues instanceof MatchAllDocIdSet) {
+      return EmptyDocIdSet.getInstance();
     }
     if (trues instanceof EmptyDocIdSet) {
+      return notFalses;
+    }
+    BlockDocIdSet notTrues = new NotDocIdSet(trues, _numDocs);
+    return notFalses instanceof MatchAllDocIdSet ? notTrues
+        : new AndDocIdSet(List.of(notFalses, notTrues), queryOptions);
+  }
+
+  /// @return document IDs in which the predicate evaluates to false.
+  protected BlockDocIdSet getFalses() {
+    BlockDocIdSet notFalses = getNotFalses();
+    if (notFalses instanceof MatchAllDocIdSet) {
+      return EmptyDocIdSet.getInstance();
+    }
+    if (notFalses instanceof EmptyDocIdSet) {
       return new MatchAllDocIdSet(_numDocs);
     }
-    return new NotDocIdSet(trues, _numDocs);
+    return new NotDocIdSet(notFalses, _numDocs);
   }
 }
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/BitmapBasedFilterOperator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/BitmapBasedFilterOperator.java
index da9073eec88..3fbcf1f5944 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/BitmapBasedFilterOperator.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/BitmapBasedFilterOperator.java
@@ -19,22 +19,54 @@
 package org.apache.pinot.core.operator.filter;
 
 import java.util.List;
+import javax.annotation.Nullable;
 import org.apache.pinot.core.common.BlockDocIdSet;
 import org.apache.pinot.core.common.Operator;
 import org.apache.pinot.core.operator.docidsets.BitmapDocIdSet;
+import org.apache.pinot.core.operator.docidsets.EmptyDocIdSet;
 import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
 
 
+/// Filter over a fixed set of documents.
+///
+/// `docIds` are the documents the predicate is true for or, when `exclusive`, 
the documents it is not true for, so
+/// that it is true for every other one. Without a null bitmap the predicate 
is two-valued: the false documents are
+/// the complement of the true ones, and [#getNulls] is empty.
+///
+/// A null bitmap adds the documents the predicate is UNKNOWN for. The true, 
false and null documents must partition
+/// the whole, so the null bitmap has to be disjoint from the true documents: 
contained in `docIds` when `exclusive`,
+/// disjoint from `docIds` otherwise. The caller guarantees it; the operator 
only asserts it. The two bitmaps are
+/// otherwise independent, and [#getNulls] returns the null bitmap as given. 
The count and the bitmaps leave the null
+/// documents out as well, and the bitmaps carry them so that an inversion 
leaves them out too.
 public class BitmapBasedFilterOperator extends BaseFilterOperator {
   private static final String EXPLAIN_NAME = "FILTER_BITMAP";
 
   private final ImmutableRoaringBitmap _docIds;
   private final boolean _exclusive;
+  @Nullable
+  private final ImmutableRoaringBitmap _nullBitmap;
 
   public BitmapBasedFilterOperator(ImmutableRoaringBitmap docIds, boolean 
exclusive, int numDocs) {
-    super(numDocs, false);
+    this(docIds, exclusive, numDocs, null);
+  }
+
+  /// Creates a filter over the given documents that also knows the documents 
the predicate is UNKNOWN for, so that
+  /// its complement leaves them out: NOT of UNKNOWN is UNKNOWN, not true.
+  ///
+  /// `nullBitmap` must be disjoint from the documents the predicate is true 
for, as described on the class. A
+  /// predicate that is true on every non-null document is `(nullBitmap, true, 
numDocs, nullBitmap)`, and one that is
+  /// true on none is `(empty, false, numDocs, nullBitmap)`.
+  public BitmapBasedFilterOperator(ImmutableRoaringBitmap docIds, boolean 
exclusive, int numDocs,
+      @Nullable ImmutableRoaringBitmap nullBitmap) {
+    super(numDocs, nullBitmap != null);
+    assert nullBitmap == null
+        || (exclusive
+            ? ImmutableRoaringBitmap.andNotCardinality(nullBitmap, docIds) == 0
+            : !ImmutableRoaringBitmap.intersects(docIds, nullBitmap))
+        : "The null bitmap must be disjoint from the trues";
     _docIds = docIds;
     _exclusive = exclusive;
+    _nullBitmap = nullBitmap;
   }
 
   @Override
@@ -46,6 +78,11 @@ public class BitmapBasedFilterOperator extends 
BaseFilterOperator {
     }
   }
 
+  @Override
+  protected BlockDocIdSet getNulls() {
+    return _nullBitmap != null ? new BitmapDocIdSet(_nullBitmap, _numDocs) : 
EmptyDocIdSet.getInstance();
+  }
+
   @Override
   public boolean canOptimizeCount() {
     return true;
@@ -53,6 +90,9 @@ public class BitmapBasedFilterOperator extends 
BaseFilterOperator {
 
   @Override
   public int getNumMatchingDocs() {
+    if (_nullBitmap != null) {
+      return getBitmaps().getCardinality();
+    }
     int count = _docIds.getCardinality();
     return _exclusive ? _numDocs - count : count;
   }
@@ -64,10 +104,9 @@ public class BitmapBasedFilterOperator extends 
BaseFilterOperator {
 
   @Override
   public BitmapCollection getBitmaps() {
-    return new BitmapCollection(_numDocs, _exclusive, _docIds);
+    return new BitmapCollection(_numDocs, _exclusive, 
_docIds).excludingNulls(_nullBitmap);
   }
 
-
   @Override
   @SuppressWarnings("rawtypes")
   public List<Operator> getChildOperators() {
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/BitmapCollection.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/BitmapCollection.java
index 7ba6f1e5428..4b59289140a 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/BitmapCollection.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/BitmapCollection.java
@@ -18,6 +18,7 @@
  */
 package org.apache.pinot.core.operator.filter;
 
+import javax.annotation.Nullable;
 import org.roaringbitmap.buffer.BufferFastAggregation;
 import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
 import org.roaringbitmap.buffer.MutableRoaringBitmap;
@@ -26,54 +27,145 @@ import org.roaringbitmap.buffer.MutableRoaringBitmap;
 /// Encapsulates a collection of bitmaps, and allows inversion without 
modifying the bitmaps.
 /// Provides simplified access to efficient cardinality calculation which work 
regardless of
 /// inversion status without computing the complement of the union of the 
bitmaps.
+///
+/// The collection is two-valued by default: the true documents are the union 
of the bitmaps, or its complement when
+/// inverted, and every other document is false. [#excludingNulls] attaches 
the documents the predicate is UNKNOWN
+/// for, which are then neither true nor false: they are left out of the true 
documents whether or not they fall in
+/// the bitmaps, and they stay UNKNOWN under inversion, since NOT of UNKNOWN 
is UNKNOWN. Every cardinality and
+/// reduction honors the null bitmap.
 public class BitmapCollection {
   private final int _numDocs;
   private boolean _inverted;
   private final ImmutableRoaringBitmap[] _bitmaps;
+  @Nullable
+  private final ImmutableRoaringBitmap _nullBitmap;
 
   public BitmapCollection(int numDocs, boolean inverted, 
ImmutableRoaringBitmap... bitmaps) {
+    this(numDocs, inverted, bitmaps, null);
+  }
+
+  private BitmapCollection(int numDocs, boolean inverted, 
ImmutableRoaringBitmap[] bitmaps,
+      @Nullable ImmutableRoaringBitmap nullBitmap) {
     _numDocs = numDocs;
     _inverted = inverted;
     _bitmaps = bitmaps;
+    _nullBitmap = nullBitmap;
+  }
+
+  /// Returns a collection over the same bitmaps that treats the documents in 
`nullBitmap` as UNKNOWN, or this
+  /// collection when `nullBitmap` is `null` or empty.
+  public BitmapCollection excludingNulls(@Nullable ImmutableRoaringBitmap 
nullBitmap) {
+    if (nullBitmap == null || nullBitmap.isEmpty()) {
+      return this;
+    }
+    return new BitmapCollection(_numDocs, _inverted, _bitmaps, nullBitmap);
+  }
+
+  /// Returns the documents the predicate is UNKNOWN for, or `null` when there 
is none.
+  @Nullable
+  public ImmutableRoaringBitmap getNullBitmap() {
+    return _nullBitmap;
   }
 
-  /// Inverts the bitmaps in constant time and space.
+  /// Inverts the bitmaps in constant time and space. The null bitmap is kept: 
NOT of UNKNOWN is UNKNOWN.
   /// @return this bitmap collection inverted.
   public BitmapCollection invert() {
     _inverted = !_inverted;
     return this;
   }
 
+  /// Returns the number of true documents.
+  public int getCardinality() {
+    return getCardinality(reduceInternal());
+  }
+
+  private int getCardinality(ImmutableRoaringBitmap union) {
+    if (_nullBitmap == null) {
+      return _inverted ? _numDocs - union.getCardinality() : 
union.getCardinality();
+    }
+    if (_inverted) {
+      return _numDocs - ImmutableRoaringBitmap.orCardinality(union, 
_nullBitmap);
+    }
+    return union.getCardinality() - 
ImmutableRoaringBitmap.andCardinality(union, _nullBitmap);
+  }
+
   /// Computes the size of the intersection of the bitmaps efficiently 
regardless of negation, without
-  /// needing to invert inputs or materialize an intermediate bitmap.
+  /// needing to invert inputs or materialize an intermediate bitmap. The 
UNKNOWN documents of either collection are
+  /// taken out through bitmaps no larger than the null bitmaps themselves.
   ///
   /// @param bitmaps to intersect with
   /// @return the size of the intersection of the bitmaps in this collection 
and in the other collection
   public int andCardinality(BitmapCollection bitmaps) {
-    ImmutableRoaringBitmap left = reduceInternal();
-    ImmutableRoaringBitmap right = bitmaps.reduceInternal();
+    return andCardinality(reduceInternal(), bitmaps.reduceInternal(), bitmaps);
+  }
+
+  private int andCardinality(ImmutableRoaringBitmap left, 
ImmutableRoaringBitmap right, BitmapCollection bitmaps) {
+    int cardinality;
     if (!_inverted) {
       if (!bitmaps._inverted) {
-        return ImmutableRoaringBitmap.andCardinality(left, right);
+        cardinality = ImmutableRoaringBitmap.andCardinality(left, right);
+      } else {
+        cardinality = ImmutableRoaringBitmap.andNotCardinality(left, right);
       }
-      return ImmutableRoaringBitmap.andNotCardinality(left, right);
     } else {
       if (!bitmaps._inverted) {
-        return ImmutableRoaringBitmap.andNotCardinality(right, left);
+        cardinality = ImmutableRoaringBitmap.andNotCardinality(right, left);
+      } else {
+        cardinality = _numDocs - ImmutableRoaringBitmap.orCardinality(left, 
right);
       }
-      return _numDocs - ImmutableRoaringBitmap.orCardinality(left, right);
     }
+    ImmutableRoaringBitmap nulls = unionOfNulls(bitmaps);
+    if (nulls != null) {
+      // A document UNKNOWN to either side is not true on that side, so it 
leaves the intersection
+      cardinality -= andCardinalityWithin(nulls, left, _inverted, right, 
bitmaps._inverted);
+    }
+    return cardinality;
+  }
+
+  /// Returns the size of the intersection of the two unions, each 
complemented when inverted, restricted to
+  /// `within`. Only bitmaps no larger than `within` are materialized.
+  private static int andCardinalityWithin(ImmutableRoaringBitmap within, 
ImmutableRoaringBitmap left,
+      boolean leftInverted, ImmutableRoaringBitmap right, boolean 
rightInverted) {
+    if (!leftInverted) {
+      MutableRoaringBitmap leftWithin = ImmutableRoaringBitmap.and(left, 
within);
+      return rightInverted
+          ? ImmutableRoaringBitmap.andNotCardinality(leftWithin, right)
+          : ImmutableRoaringBitmap.andCardinality(leftWithin, right);
+    }
+    if (!rightInverted) {
+      return 
ImmutableRoaringBitmap.andNotCardinality(ImmutableRoaringBitmap.and(right, 
within), left);
+    }
+    // Both complemented: what is left of `within` once the documents of 
either union are removed
+    return within.getCardinality() - 
ImmutableRoaringBitmap.orCardinality(ImmutableRoaringBitmap.and(left, within),
+        ImmutableRoaringBitmap.and(right, within));
+  }
+
+  /// Returns the documents UNKNOWN to either collection, or `null` when there 
is none.
+  @Nullable
+  private ImmutableRoaringBitmap unionOfNulls(BitmapCollection bitmaps) {
+    if (_nullBitmap == null) {
+      return bitmaps._nullBitmap;
+    }
+    if (bitmaps._nullBitmap == null) {
+      return _nullBitmap;
+    }
+    return ImmutableRoaringBitmap.or(_nullBitmap, bitmaps._nullBitmap);
   }
 
   /// Computes the size of the union of the bitmaps efficiently regardless of 
negation, without
   /// needing to invert inputs or materialize an intermediate bitmap. If 
either this collection
-  /// or the other collection has more than one bitmap, the union will be 
materialized.
+  /// or the other collection has more than one bitmap, the union will be 
materialized. When either collection has a
+  /// null bitmap, the size follows from the two cardinalities and the 
intersection, so nothing larger than the null
+  /// bitmaps is materialized.
   ///
   /// @param bitmaps to intersect with
   /// @return the size of the union of the bitmaps in this collection and in 
the other collection
   public int orCardinality(BitmapCollection bitmaps) {
     ImmutableRoaringBitmap left = reduceInternal();
     ImmutableRoaringBitmap right = bitmaps.reduceInternal();
+    if (_nullBitmap != null || bitmaps._nullBitmap != null) {
+      return getCardinality(left) + bitmaps.getCardinality(right) - 
andCardinality(left, right, bitmaps);
+    }
     if (!_inverted) {
       if (!bitmaps._inverted) {
         return ImmutableRoaringBitmap.orCardinality(left, right);
@@ -94,17 +186,22 @@ public class BitmapCollection {
     return BufferFastAggregation.or(_bitmaps);
   }
 
-  /// Reduces the bitmaps to a single bitmap. In common cases, when the 
collection
-  /// is not inverted and only has one bitmap, this operation is cheap. 
However,
+  /// Reduces the bitmaps to a single bitmap of the true documents. In common 
cases, when the collection
+  /// is not inverted, only has one bitmap and no null bitmap, this operation 
is cheap. However,
   /// this may be a costly operation: a new bitmap may be allocated, one or 
many
-  /// bitmaps may need to be inverted. Prefer {@see andCardinality} or {@see 
orCardinality}
+  /// bitmaps may need to be inverted, and the null bitmap subtracted. Prefer 
[#andCardinality] or [#orCardinality]
   /// when appropriate.
   /// @return a bitmap
   public ImmutableRoaringBitmap reduce() {
-    if (!_inverted) {
-      return reduceInternal();
+    if (_nullBitmap == null) {
+      return _inverted ? invertedOr() : reduceInternal();
+    }
+    if (_inverted) {
+      MutableRoaringBitmap complement = invertedOr();
+      complement.andNot(_nullBitmap);
+      return complement;
     }
-    return invertedOr();
+    return ImmutableRoaringBitmap.andNot(reduceInternal(), _nullBitmap);
   }
 
   private MutableRoaringBitmap invertedOr() {
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/ExpressionFilterOperator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/ExpressionFilterOperator.java
index cf68c484a49..91715f99080 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/ExpressionFilterOperator.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/ExpressionFilterOperator.java
@@ -32,6 +32,7 @@ import org.apache.pinot.core.common.Operator;
 import org.apache.pinot.core.operator.ColumnContext;
 import org.apache.pinot.core.operator.ExplainAttributeBuilder;
 import 
org.apache.pinot.core.operator.dociditerators.ExpressionScanDocIdIterator;
+import org.apache.pinot.core.operator.docidsets.EmptyDocIdSet;
 import org.apache.pinot.core.operator.docidsets.ExpressionDocIdSet;
 import org.apache.pinot.core.operator.docidsets.NotDocIdSet;
 import org.apache.pinot.core.operator.filter.predicate.PredicateEvaluator;
@@ -81,17 +82,33 @@ public class ExpressionFilterOperator extends 
BaseFilterOperator {
   @Override
   protected BlockDocIdSet getTrues() {
     if (_predicateType == Predicate.Type.IS_NULL) {
-      return getNulls();
+      return getExpressionNulls();
     } else if (_predicateType == Predicate.Type.IS_NOT_NULL) {
-      return new NotDocIdSet(getNulls(), _numDocs);
+      return new NotDocIdSet(getExpressionNulls(), _numDocs);
     } else {
       return new ExpressionDocIdSet(_transformFunction, _predicateEvaluator, 
_dataSourceMap, _numDocs,
           ExpressionScanDocIdIterator.PredicateEvaluationResult.TRUE, 
_queryContext);
     }
   }
 
+  /// `IS NULL` and `IS NOT NULL` are two-valued: a null expression makes them 
true or false, never UNKNOWN. Every other
+  /// predicate is UNKNOWN where the expression is null.
   @Override
   protected BlockDocIdSet getNulls() {
+    return isNullCheck() ? EmptyDocIdSet.getInstance() : getExpressionNulls();
+  }
+
+  @Override
+  public boolean mayHaveNulls() {
+    return _nullHandlingEnabled && !isNullCheck();
+  }
+
+  private boolean isNullCheck() {
+    return _predicateType == Predicate.Type.IS_NULL || _predicateType == 
Predicate.Type.IS_NOT_NULL;
+  }
+
+  /// Returns the documents the expression evaluates to null for.
+  private BlockDocIdSet getExpressionNulls() {
     return new ExpressionDocIdSet(_transformFunction, null, _dataSourceMap, 
_numDocs,
         ExpressionScanDocIdIterator.PredicateEvaluationResult.NULL, 
_queryContext);
   }
@@ -99,9 +116,9 @@ public class ExpressionFilterOperator extends 
BaseFilterOperator {
   @Override
   protected BlockDocIdSet getFalses() {
     if (_predicateType == Predicate.Type.IS_NULL) {
-      return new NotDocIdSet(getNulls(), _numDocs);
+      return new NotDocIdSet(getExpressionNulls(), _numDocs);
     } else if (_predicateType == Predicate.Type.IS_NOT_NULL) {
-      return getNulls();
+      return getExpressionNulls();
     } else {
       return new ExpressionDocIdSet(_transformFunction, _predicateEvaluator, 
_dataSourceMap, _numDocs,
           ExpressionScanDocIdIterator.PredicateEvaluationResult.FALSE, 
_queryContext);
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/FilterOperatorUtils.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/FilterOperatorUtils.java
index bab63d0d0db..29b98784aef 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/FilterOperatorUtils.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/FilterOperatorUtils.java
@@ -22,6 +22,7 @@ import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.List;
 import java.util.OptionalInt;
+import javax.annotation.Nullable;
 import org.apache.pinot.common.request.context.predicate.Predicate;
 import 
org.apache.pinot.core.operator.filter.predicate.BaseDictIdBasedRegexpLikePredicateEvaluator;
 import org.apache.pinot.core.operator.filter.predicate.PredicateEvaluator;
@@ -30,6 +31,7 @@ import org.apache.pinot.segment.spi.datasource.DataSource;
 import org.apache.pinot.segment.spi.index.reader.NullValueVectorReader;
 import org.apache.pinot.spi.config.table.FieldConfig;
 import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
+import org.roaringbitmap.buffer.MutableRoaringBitmap;
 
 
 public class FilterOperatorUtils {
@@ -61,21 +63,46 @@ public class FilterOperatorUtils {
     BaseFilterOperator getNotFilterOperator(QueryContext queryContext, 
BaseFilterOperator filterOperator, int numDocs);
   }
 
+  /// Returns the null rows of the column in this segment, or `null` when 
there is none.
+  ///
+  /// A column reports nulls through its null value vector, which is absent 
when the segment has none for the column
+  /// and can also be present but empty. Under null handling these are the 
rows a predicate on the column is UNKNOWN
+  /// over, and the reason a verdict of always true or always false over the 
column's values does not cover every row.
+  /// A consuming segment hands out a copy of its vector on each read, so 
callers read it once and keep the result.
+  @Nullable
+  public static ImmutableRoaringBitmap getNullBitmap(DataSource dataSource) {
+    NullValueVectorReader nullValueVector = dataSource.getNullValueVector();
+    if (nullValueVector != null) {
+      ImmutableRoaringBitmap nullBitmap = nullValueVector.getNullBitmap();
+      if (!nullBitmap.isEmpty()) {
+        return nullBitmap;
+      }
+    }
+    return null;
+  }
+
+  /// Returns whether the column holds any null value in this segment, see 
[#getNullBitmap].
+  public static boolean hasNulls(DataSource dataSource) {
+    return getNullBitmap(dataSource) != null;
+  }
+
   public static class DefaultImplementation implements Implementation {
     @Override
     public BaseFilterOperator getLeafFilterOperator(QueryContext queryContext, 
PredicateEvaluator predicateEvaluator,
         DataSource dataSource, int numDocs) {
+      // The evaluator's verdicts are over the column's real values. With null 
handling enabled a null row is UNKNOWN
+      // under either verdict, so it is selected by neither the predicate nor 
its negation: the leaf has to carry the
+      // null rows for the negation to leave them out, instead of collapsing 
to a constant that knows nothing of them.
       if (predicateEvaluator.isAlwaysFalse()) {
+        ImmutableRoaringBitmap nullBitmap = 
queryContext.isNullHandlingEnabled() ? getNullBitmap(dataSource) : null;
+        if (nullBitmap != null) {
+          return new BitmapBasedFilterOperator(new MutableRoaringBitmap(), 
false, numDocs, nullBitmap);
+        }
         return EmptyFilterOperator.getInstance();
       } else if (predicateEvaluator.isAlwaysTrue()) {
-        if (queryContext.isNullHandlingEnabled()) {
-          NullValueVectorReader nullValueVectorReader = 
dataSource.getNullValueVector();
-          if (nullValueVectorReader != null) {
-            ImmutableRoaringBitmap nullBitmap = 
nullValueVectorReader.getNullBitmap();
-            if (nullBitmap != null && !nullBitmap.isEmpty()) {
-              return new BitmapBasedFilterOperator(nullBitmap, true, numDocs);
-            }
-          }
+        ImmutableRoaringBitmap nullBitmap = 
queryContext.isNullHandlingEnabled() ? getNullBitmap(dataSource) : null;
+        if (nullBitmap != null) {
+          return new BitmapBasedFilterOperator(nullBitmap, true, numDocs, 
nullBitmap);
         }
         return new MatchAllFilterOperator(numDocs);
       }
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/InvertedIndexFilterOperator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/InvertedIndexFilterOperator.java
index 07a41028b08..af9d817b447 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/InvertedIndexFilterOperator.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/InvertedIndexFilterOperator.java
@@ -103,15 +103,31 @@ public class InvertedIndexFilterOperator extends 
BaseColumnFilterOperator {
 
   @Override
   public int getNumMatchingDocs() {
+    ImmutableRoaringBitmap nullBitmap = getNullBitmap();
+    if (nullBitmap != null && !_isSingleValue) {
+      // Per-dictId bitmaps overlap on a multi-value column, so the null rows 
among the matches can only be counted on
+      // the materialized union
+      return getBitmaps().getCardinality();
+    }
     int[] dictIds = _exclusive ? _predicateEvaluator.getNonMatchingDictIds() : 
_predicateEvaluator.getMatchingDictIds();
     int count;
+    // Null rows among the matches are UNKNOWN rather than true
+    int numNulls = 0;
     if (_isSingleValue) {
       // On a single-value column, per-dictId bitmaps partition the docId 
space (each docId has exactly one
       // dictId), so the union cardinality equals the sum of per-bitmap 
cardinalities. No scratch bitmap is
       // allocated and no OR pass is performed.
       count = 0;
-      for (int dictId : dictIds) {
-        count += _invertedIndexReader.getDocIds(dictId).getCardinality();
+      if (nullBitmap == null) {
+        for (int dictId : dictIds) {
+          count += _invertedIndexReader.getDocIds(dictId).getCardinality();
+        }
+      } else {
+        for (int dictId : dictIds) {
+          ImmutableRoaringBitmap docIds = 
_invertedIndexReader.getDocIds(dictId);
+          count += docIds.getCardinality();
+          numNulls += ImmutableRoaringBitmap.andCardinality(docIds, 
nullBitmap);
+        }
       }
     } else {
       // TODO: For MV column, per-dictId bitmaps may overlap, so we must 
materialize the union to count.
@@ -137,7 +153,7 @@ public class InvertedIndexFilterOperator extends 
BaseColumnFilterOperator {
           break;
       }
     }
-    return _exclusive ? _numDocs - count : count;
+    return toNumTrueDocs(count, numNulls, _exclusive);
   }
 
   @Override
@@ -152,7 +168,7 @@ public class InvertedIndexFilterOperator extends 
BaseColumnFilterOperator {
     for (int i = 0; i < dictIds.length; i++) {
       bitmaps[i] = _invertedIndexReader.getDocIds(dictIds[i]);
     }
-    return new BitmapCollection(_numDocs, _exclusive, bitmaps);
+    return new BitmapCollection(_numDocs, _exclusive, 
bitmaps).excludingNulls(getNullBitmap());
   }
 
   @Override
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/MapFilterOperator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/MapFilterOperator.java
index 9c62e3d2466..eb2d4f8152e 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/MapFilterOperator.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/MapFilterOperator.java
@@ -278,6 +278,11 @@ public class MapFilterOperator extends BaseFilterOperator {
     return _delegate.getFalses();
   }
 
+  @Override
+  protected BlockDocIdSet getNotFalses() {
+    return _delegate.getNotFalses();
+  }
+
   @Override
   public boolean canOptimizeCount() {
     return _delegate.canOptimizeCount();
@@ -298,6 +303,11 @@ public class MapFilterOperator extends BaseFilterOperator {
     return _delegate.getBitmaps();
   }
 
+  @Override
+  public boolean mayHaveNulls() {
+    return _delegate.mayHaveNulls();
+  }
+
   @Override
   public List<Operator> getChildOperators() {
     return List.of();
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/NotFilterOperator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/NotFilterOperator.java
index b0b1cbc2905..56037a91e5a 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/NotFilterOperator.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/NotFilterOperator.java
@@ -23,7 +23,9 @@ import java.util.List;
 import javax.annotation.Nullable;
 import org.apache.pinot.core.common.BlockDocIdSet;
 import org.apache.pinot.core.common.Operator;
+import org.apache.pinot.core.operator.docidsets.EmptyDocIdSet;
 import org.apache.pinot.core.operator.docidsets.MatchAllDocIdSet;
+import org.apache.pinot.core.operator.docidsets.NotDocIdSet;
 
 
 public class NotFilterOperator extends BaseFilterOperator {
@@ -61,13 +63,37 @@ public class NotFilterOperator extends BaseFilterOperator {
     return _filterOperator.getTrues();
   }
 
+  /// NOT of UNKNOWN is UNKNOWN: a negation is UNKNOWN exactly where its child 
is.
+  @Override
+  protected BlockDocIdSet getNulls() {
+    return _filterOperator.getNulls();
+  }
+
+  /// A negation is not false where its child is not true.
+  @Override
+  protected BlockDocIdSet getNotFalses() {
+    BlockDocIdSet childTrues = _filterOperator.getTrues();
+    if (childTrues instanceof MatchAllDocIdSet) {
+      return EmptyDocIdSet.getInstance();
+    }
+    if (childTrues instanceof EmptyDocIdSet) {
+      return new MatchAllDocIdSet(_numDocs);
+    }
+    return new NotDocIdSet(childTrues, _numDocs);
+  }
+
+  /// The complement of the child's count is its false documents only when 
none is UNKNOWN. Otherwise the count comes
+  /// from the child's bitmaps, which know the UNKNOWN documents and keep them 
out of the inversion.
   @Override
   public boolean canOptimizeCount() {
-    return _filterOperator.canOptimizeCount();
+    return _filterOperator.mayHaveNulls() ? 
_filterOperator.canProduceBitmaps() : _filterOperator.canOptimizeCount();
   }
 
   @Override
   public int getNumMatchingDocs() {
+    if (_filterOperator.mayHaveNulls()) {
+      return getBitmaps().getCardinality();
+    }
     return _numDocs - _filterOperator.getNumMatchingDocs();
   }
 
@@ -81,6 +107,11 @@ public class NotFilterOperator extends BaseFilterOperator {
     return _filterOperator.getBitmaps().invert();
   }
 
+  @Override
+  public boolean mayHaveNulls() {
+    return _filterOperator.mayHaveNulls();
+  }
+
   public BaseFilterOperator getChildFilterOperator() {
     return _filterOperator;
   }
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/OrFilterOperator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/OrFilterOperator.java
index e2c92bfd0c0..ed1d0ac75e1 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/OrFilterOperator.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/OrFilterOperator.java
@@ -19,7 +19,6 @@
 package org.apache.pinot.core.operator.filter;
 
 import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
 import javax.annotation.Nullable;
@@ -27,12 +26,12 @@ import org.apache.pinot.core.common.BlockDocIdSet;
 import org.apache.pinot.core.common.Operator;
 import org.apache.pinot.core.operator.docidsets.EmptyDocIdSet;
 import org.apache.pinot.core.operator.docidsets.MatchAllDocIdSet;
-import org.apache.pinot.core.operator.docidsets.NotDocIdSet;
 import org.apache.pinot.core.operator.docidsets.OrDocIdSet;
 import org.apache.pinot.core.operator.docidsets.ShortCircuitingDocIdSet;
 import org.apache.pinot.spi.trace.Tracing;
 import org.roaringbitmap.buffer.BufferFastAggregation;
 import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
+import org.roaringbitmap.buffer.MutableRoaringBitmap;
 
 
 public class OrFilterOperator extends BaseFilterOperator {
@@ -71,33 +70,29 @@ public class OrFilterOperator extends BaseFilterOperator {
     return new OrDocIdSet(blockDocIdSets, _numDocs);
   }
 
+  /// A disjunction is not false where some child is not false: the union of 
the children's not-false documents.
   @Override
-  protected BlockDocIdSet getFalses() {
-    List<BlockDocIdSet> blockDocIdSets = new 
ArrayList<>(_filterOperators.size());
+  protected BlockDocIdSet getNotFalses() {
+    List<BlockDocIdSet> notFalses = new ArrayList<>(_filterOperators.size());
     for (BaseFilterOperator filterOperator : _filterOperators) {
-      BlockDocIdSet trues = filterOperator.getTrues();
-      if (trues instanceof MatchAllDocIdSet) {
-        return EmptyDocIdSet.getInstance();
+      BlockDocIdSet childNotFalses = filterOperator.getNotFalses();
+      if (childNotFalses instanceof MatchAllDocIdSet) {
+        return new MatchAllDocIdSet(_numDocs);
       }
-      if (trues instanceof EmptyDocIdSet) {
+      if (childNotFalses instanceof EmptyDocIdSet) {
         continue;
       }
-      if (_nullHandlingEnabled) {
-        BlockDocIdSet nulls = filterOperator.getNulls();
-        if (!(nulls instanceof EmptyDocIdSet)) {
-          blockDocIdSets.add(new OrDocIdSet(Arrays.asList(trues, nulls), 
_numDocs));
-          continue;
-        }
-      }
-      blockDocIdSets.add(trues);
-    }
-    if (blockDocIdSets.isEmpty()) {
-      return new MatchAllDocIdSet(_numDocs);
+      notFalses.add(childNotFalses);
     }
-    if (blockDocIdSets.size() == 1) {
-      return new NotDocIdSet(blockDocIdSets.get(0), _numDocs);
+    if (notFalses.isEmpty()) {
+      return EmptyDocIdSet.getInstance();
     }
-    return new NotDocIdSet(new OrDocIdSet(blockDocIdSets, _numDocs), _numDocs);
+    return notFalses.size() == 1 ? notFalses.get(0) : new 
OrDocIdSet(notFalses, _numDocs);
+  }
+
+  @Override
+  protected BlockDocIdSet getNulls() {
+    return mayHaveNulls() ? deriveNulls(_queryOptions) : 
EmptyDocIdSet.getInstance();
   }
 
   @Override
@@ -137,12 +132,42 @@ public class OrFilterOperator extends BaseFilterOperator {
     return true;
   }
 
+  /// The true documents are those true for some child. When a child has 
UNKNOWN documents, so may the result: a
+  /// document is UNKNOWN when no child is true for it and some child is 
UNKNOWN, which is the union of the children's
+  /// UNKNOWN documents minus the union of their true ones.
   @Override
   public BitmapCollection getBitmaps() {
-    ImmutableRoaringBitmap[] bitmaps = new 
ImmutableRoaringBitmap[_filterOperators.size()];
-    for (int i = 0; i < _filterOperators.size(); i++) {
-      bitmaps[i] = _filterOperators.get(i).getBitmaps().reduce();
+    int numChildren = _filterOperators.size();
+    ImmutableRoaringBitmap[] trues = new ImmutableRoaringBitmap[numChildren];
+    MutableRoaringBitmap nulls = null;
+    for (int i = 0; i < numChildren; i++) {
+      BitmapCollection childBitmaps = _filterOperators.get(i).getBitmaps();
+      trues[i] = childBitmaps.reduce();
+      ImmutableRoaringBitmap childNulls = childBitmaps.getNullBitmap();
+      if (childNulls != null) {
+        if (nulls == null) {
+          nulls = new MutableRoaringBitmap();
+        }
+        nulls.or(childNulls);
+      }
+    }
+    MutableRoaringBitmap orTrues = BufferFastAggregation.or(trues);
+    if (nulls == null) {
+      return new BitmapCollection(_numDocs, false, orTrues);
+    }
+    nulls.andNot(orTrues);
+    return new BitmapCollection(_numDocs, false, 
orTrues).excludingNulls(nulls);
+  }
+
+  @Override
+  public boolean mayHaveNulls() {
+    if (_nullHandlingEnabled) {
+      for (BaseFilterOperator filterOperator : _filterOperators) {
+        if (filterOperator.mayHaveNulls()) {
+          return true;
+        }
+      }
     }
-    return new BitmapCollection(_numDocs, false, 
BufferFastAggregation.or(bitmaps));
+    return false;
   }
 }
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/RangeIndexBasedFilterOperator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/RangeIndexBasedFilterOperator.java
index 34471ccf9e6..4dda6267e9b 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/RangeIndexBasedFilterOperator.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/RangeIndexBasedFilterOperator.java
@@ -182,8 +182,41 @@ public class RangeIndexBasedFilterOperator extends 
BaseColumnFilterOperator {
     return _rangeIndexReader.isExact();
   }
 
+  /// The index counts the documents whose stored value satisfies the 
predicate. A null row stores the column's default
+  /// null value, so the null rows are either all among those documents or all 
outside them, depending on whether that
+  /// value satisfies the predicate: the index's count is kept, and the null 
rows are subtracted when it does.
   @Override
   public int getNumMatchingDocs() {
+    int numMatchingDocs = getNumMatchingDocsFromIndex();
+    ImmutableRoaringBitmap nullBitmap = getNullBitmap();
+    if (nullBitmap != null && matchesDefaultNullValue()) {
+      numMatchingDocs -= nullBitmap.getCardinality();
+    }
+    return numMatchingDocs;
+  }
+
+  /// Returns whether the predicate holds for the column's default null value, 
the value a null row is stored under.
+  private boolean matchesDefaultNullValue() {
+    Object defaultNullValue = 
_dataSource.getDataSourceMetadata().getFieldSpec().getDefaultNullValue();
+    if (_predicateEvaluator.isDictionaryBased()) {
+      int dictId = 
_dataSource.getDictionary().indexOf(FieldSpec.getStringValue(defaultNullValue));
+      return dictId >= 0 && _predicateEvaluator.applySV(dictId);
+    }
+    switch (_parameterType) {
+      case INT:
+        return _predicateEvaluator.applySV(((Number) 
defaultNullValue).intValue());
+      case LONG:
+        return _predicateEvaluator.applySV(((Number) 
defaultNullValue).longValue());
+      case FLOAT:
+        return _predicateEvaluator.applySV(((Number) 
defaultNullValue).floatValue());
+      case DOUBLE:
+        return _predicateEvaluator.applySV(((Number) 
defaultNullValue).doubleValue());
+      default:
+        throw unsupportedDataType(_parameterType);
+    }
+  }
+
+  private int getNumMatchingDocsFromIndex() {
     switch (_parameterType) {
       case INT:
         if (_predicateEvaluator instanceof IntValue) {
@@ -225,7 +258,7 @@ public class RangeIndexBasedFilterOperator extends 
BaseColumnFilterOperator {
 
   @Override
   public BitmapCollection getBitmaps() {
-    return new BitmapCollection(_numDocs, false, getMatchingDocIds());
+    return new BitmapCollection(_numDocs, false, 
getMatchingDocIds()).excludingNulls(getNullBitmap());
   }
 
   @Override
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/SortedIndexBasedFilterOperator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/SortedIndexBasedFilterOperator.java
index 8991034ad2a..8ecaa207647 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/SortedIndexBasedFilterOperator.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/SortedIndexBasedFilterOperator.java
@@ -22,6 +22,7 @@ import com.google.common.base.CaseFormat;
 import com.google.common.base.Preconditions;
 import java.util.ArrayList;
 import java.util.List;
+import javax.annotation.Nullable;
 import org.apache.pinot.core.common.BlockDocIdSet;
 import org.apache.pinot.core.common.Operator;
 import org.apache.pinot.core.operator.ExplainAttributeBuilder;
@@ -32,6 +33,7 @@ import 
org.apache.pinot.core.query.request.context.QueryContext;
 import org.apache.pinot.segment.spi.datasource.DataSource;
 import org.apache.pinot.segment.spi.index.reader.SortedIndexReader;
 import org.apache.pinot.spi.utils.Pairs.IntPair;
+import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
 import org.roaringbitmap.buffer.MutableRoaringBitmap;
 
 
@@ -137,7 +139,11 @@ public class SortedIndexBasedFilterOperator extends 
BaseColumnFilterOperator {
 
   @Override
   public int getNumMatchingDocs() {
+    ImmutableRoaringBitmap nullBitmap = getNullBitmap();
     int count = 0;
+    // Null rows within the counted ranges are UNKNOWN rather than true; they 
are counted per range so that no bitmap
+    // has to be materialized
+    int numNulls = 0;
     boolean exclusive = _predicateEvaluator.isExclusive();
     if (_predicateEvaluator instanceof 
SortedDictionaryBasedRangePredicateEvaluator) {
       // For RANGE predicate, use start/end document id to construct a new 
document id range
@@ -147,6 +153,7 @@ public class SortedIndexBasedFilterOperator extends 
BaseColumnFilterOperator {
       // NOTE: End dictionary id is exclusive in 
OfflineDictionaryBasedRangePredicateEvaluator.
       int endDocId = 
_sortedIndexReader.getDocIds(rangePredicateEvaluator.getEndDictId() - 
1).getRight();
       count = endDocId - startDocId + 1;
+      numNulls = countNulls(nullBitmap, startDocId, endDocId);
     } else {
       int[] dictIds =
           exclusive ? _predicateEvaluator.getNonMatchingDictIds() : 
_predicateEvaluator.getMatchingDictIds();
@@ -156,6 +163,7 @@ public class SortedIndexBasedFilterOperator extends 
BaseColumnFilterOperator {
       if (numDictIds == 1) {
         IntPair docIdRange = _sortedIndexReader.getDocIds(dictIds[0]);
         count = docIdRange.getRight() - docIdRange.getLeft() + 1;
+        numNulls = countNulls(nullBitmap, docIdRange.getLeft(), 
docIdRange.getRight());
       } else {
         IntPair lastDocIdRange = _sortedIndexReader.getDocIds(dictIds[0]);
         for (int i = 1; i < numDictIds; i++) {
@@ -164,13 +172,20 @@ public class SortedIndexBasedFilterOperator extends 
BaseColumnFilterOperator {
             lastDocIdRange.setRight(docIdRange.getRight());
           } else {
             count += lastDocIdRange.getRight() - lastDocIdRange.getLeft() + 1;
+            numNulls += countNulls(nullBitmap, lastDocIdRange.getLeft(), 
lastDocIdRange.getRight());
             lastDocIdRange = docIdRange;
           }
         }
         count += lastDocIdRange.getRight() - lastDocIdRange.getLeft() + 1;
+        numNulls += countNulls(nullBitmap, lastDocIdRange.getLeft(), 
lastDocIdRange.getRight());
       }
     }
-    return exclusive ? _numDocs - count : count;
+    return toNumTrueDocs(count, numNulls, exclusive);
+  }
+
+  /// Returns how many null rows fall in the inclusive document id range, or 0 
without a null bitmap.
+  private static int countNulls(@Nullable ImmutableRoaringBitmap nullBitmap, 
int startDocId, int endDocId) {
+    return nullBitmap != null ? (int) nullBitmap.rangeCardinality(startDocId, 
endDocId + 1L) : 0;
   }
 
   @Override
@@ -213,7 +228,7 @@ public class SortedIndexBasedFilterOperator extends 
BaseColumnFilterOperator {
         bitmap.add(lastDocIdRange.getLeft(), lastDocIdRange.getRight() + 1L);
       }
     }
-    return new BitmapCollection(_numDocs, exclusive, bitmap);
+    return new BitmapCollection(_numDocs, exclusive, 
bitmap).excludingNulls(getNullBitmap());
   }
 
 
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/startree/StarTreeUtils.java 
b/pinot-core/src/main/java/org/apache/pinot/core/startree/StarTreeUtils.java
index e9352c5799f..25954a97fbb 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/startree/StarTreeUtils.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/startree/StarTreeUtils.java
@@ -34,6 +34,7 @@ import 
org.apache.pinot.common.request.context.ExpressionContext;
 import org.apache.pinot.common.request.context.FilterContext;
 import org.apache.pinot.common.request.context.predicate.Predicate;
 import org.apache.pinot.core.operator.BaseProjectOperator;
+import org.apache.pinot.core.operator.filter.FilterOperatorUtils;
 import org.apache.pinot.core.operator.filter.predicate.PredicateEvaluator;
 import 
org.apache.pinot.core.operator.filter.predicate.PredicateEvaluatorProvider;
 import org.apache.pinot.core.query.aggregation.function.AggregationFunction;
@@ -88,9 +89,14 @@ public class StarTreeUtils {
   /// the list are implicitly ANDed together. Any OR and NOT predicates are 
nested within a CompositePredicate.
   ///
   /// A map from predicates to their evaluators is passed in to accelerate the 
computation.
+  ///
+  /// A predicate that is always true over a column's values is left out of 
the map, unless null handling is enabled
+  /// and the column holds nulls: a null row is UNKNOWN rather than true, so 
the predicate is kept, and the column stays
+  /// visible to the null checks that decide whether a star-tree can serve the 
query.
   @Nullable
   public static Map<String, List<CompositePredicateEvaluator>> 
extractPredicateEvaluatorsMap(IndexSegment indexSegment,
-      @Nullable FilterContext filter, List<Pair<Predicate, 
PredicateEvaluator>> predicateEvaluatorMapping) {
+      @Nullable FilterContext filter, List<Pair<Predicate, 
PredicateEvaluator>> predicateEvaluatorMapping,
+      boolean nullHandlingEnabled) {
     if (filter == null) {
       return Map.of();
     }
@@ -106,7 +112,7 @@ public class StarTreeUtils {
           break;
         case OR:
           Pair<String, CompositePredicateEvaluator> pair =
-              isOrClauseValidForStarTree(indexSegment, filterNode, 
predicateEvaluatorMapping);
+              isOrClauseValidForStarTree(indexSegment, filterNode, 
predicateEvaluatorMapping, nullHandlingEnabled);
           if (pair == null) {
             return null;
           }
@@ -133,7 +139,8 @@ public class StarTreeUtils {
                 return null;
               }
               // Skip adding always true predicate
-              if ((predicateEvaluator.isAlwaysTrue() && !negated) || 
(predicateEvaluator.isAlwaysFalse() && negated)) {
+              if (isAlwaysTrue(predicateEvaluator, negated, indexSegment, 
predicate.getLhs().getIdentifier(),
+                  nullHandlingEnabled)) {
                 break;
               }
               
predicateEvaluatorsMap.computeIfAbsent(predicate.getLhs().getIdentifier(), k -> 
new ArrayList<>())
@@ -157,7 +164,8 @@ public class StarTreeUtils {
           if (predicateEvaluator == null || 
predicateEvaluator.isAlwaysFalse()) {
             return null;
           }
-          if (!predicateEvaluator.isAlwaysTrue()) {
+          if (!isAlwaysTrue(predicateEvaluator, false, indexSegment, 
predicate.getLhs().getIdentifier(),
+              nullHandlingEnabled)) {
             
predicateEvaluatorsMap.computeIfAbsent(predicate.getLhs().getIdentifier(), k -> 
new ArrayList<>())
                 .add(new 
CompositePredicateEvaluator(List.of(ObjectBooleanPair.of(predicateEvaluator, 
false))));
           }
@@ -215,7 +223,8 @@ public class StarTreeUtils {
   ///         clause cannot be solved with star-tree; a pair of nulls if the 
OR clause always evaluates to true.
   @Nullable
   private static Pair<String, CompositePredicateEvaluator> 
isOrClauseValidForStarTree(IndexSegment indexSegment,
-      FilterContext filter, List<Pair<Predicate, PredicateEvaluator>> 
predicateEvaluatorMapping) {
+      FilterContext filter, List<Pair<Predicate, PredicateEvaluator>> 
predicateEvaluatorMapping,
+      boolean nullHandlingEnabled) {
     assert filter.getType() == FilterContext.Type.OR;
 
     List<ObjectBooleanPair<Predicate>> predicates = new ArrayList<>();
@@ -234,7 +243,8 @@ public class StarTreeUtils {
       }
       boolean negated = predicate.rightBoolean();
       // Use a pair of null values to represent always true
-      if ((predicateEvaluator.isAlwaysTrue() && !negated) || 
(predicateEvaluator.isAlwaysFalse() && negated)) {
+      if (isAlwaysTrue(predicateEvaluator, negated, indexSegment, 
predicate.left().getLhs().getIdentifier(),
+          nullHandlingEnabled)) {
         return Pair.of(null, null);
       }
       // Skip the always false predicate
@@ -302,6 +312,18 @@ public class StarTreeUtils {
     return true;
   }
 
+  /// Returns whether the predicate is always true for the query, so that the 
star-tree filter can drop it.
+  ///
+  /// The evaluator's verdict is over the column's real values. With null 
handling enabled a null row is UNKNOWN rather
+  /// than true, so the predicate is only always true when the column holds no 
null. Kept in the map otherwise, the
+  /// column reaches the null checks that decide whether a star-tree can serve 
the query.
+  private static boolean isAlwaysTrue(PredicateEvaluator predicateEvaluator, 
boolean negated, IndexSegment indexSegment,
+      String column, boolean nullHandlingEnabled) {
+    boolean alwaysTrueOverValues = negated ? 
predicateEvaluator.isAlwaysFalse() : predicateEvaluator.isAlwaysTrue();
+    return alwaysTrueOverValues
+        && !(nullHandlingEnabled && 
FilterOperatorUtils.hasNulls(indexSegment.getDataSource(column)));
+  }
+
   /// Returns the predicate evaluator for the given predicate, or `null` if 
the predicate cannot be solved with
   /// star-tree.
   @Nullable
@@ -373,7 +395,7 @@ public class StarTreeUtils {
     }
 
     Map<String, List<CompositePredicateEvaluator>> predicateEvaluatorsMap =
-        extractPredicateEvaluatorsMap(indexSegment, filter, 
predicateEvaluators);
+        extractPredicateEvaluatorsMap(indexSegment, filter, 
predicateEvaluators, queryContext.isNullHandlingEnabled());
     if (predicateEvaluatorsMap == null) {
       return null;
     }
@@ -394,9 +416,7 @@ public class StarTreeUtils {
           if (!inputExpressions.isEmpty()) {
             if (inputExpressions.get(0).getType() == 
ExpressionContext.Type.IDENTIFIER) {
               DataSource dataSource = 
indexSegment.getDataSource(inputExpressions.get(0).getIdentifier());
-              if (dataSource.getNullValueVector() != null && 
!dataSource.getNullValueVector()
-                  .getNullBitmap()
-                  .isEmpty()) {
+              if (FilterOperatorUtils.hasNulls(dataSource)) {
                 return null;
               }
             }
@@ -411,7 +431,7 @@ public class StarTreeUtils {
           LOGGER.debug("Cannot use star-tree index because aggregation column: 
'{}' does not exist", column);
           return null;
         }
-        if (dataSource.getNullValueVector() != null && 
!dataSource.getNullValueVector().getNullBitmap().isEmpty()) {
+        if (FilterOperatorUtils.hasNulls(dataSource)) {
           LOGGER.debug("Cannot use star-tree index because aggregation column: 
'{}' has null values", column);
           return null;
         }
@@ -423,7 +443,7 @@ public class StarTreeUtils {
           LOGGER.debug("Cannot use star-tree index because filter column: '{}' 
does not exist", column);
           return null;
         }
-        if (dataSource.getNullValueVector() != null && 
!dataSource.getNullValueVector().getNullBitmap().isEmpty()) {
+        if (FilterOperatorUtils.hasNulls(dataSource)) {
           LOGGER.debug("Cannot use star-tree index because filter column: '{}' 
has null values", column);
           return null;
         }
@@ -441,7 +461,7 @@ public class StarTreeUtils {
           LOGGER.debug("Cannot use star-tree index because group-by column: 
'{}' does not exist", column);
           return null;
         }
-        if (dataSource.getNullValueVector() != null && 
!dataSource.getNullValueVector().getNullBitmap().isEmpty()) {
+        if (FilterOperatorUtils.hasNulls(dataSource)) {
           LOGGER.debug("Cannot use star-tree index because group-by column: 
'{}' has null values", column);
           return null;
         }
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/AndFilterOperatorTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/AndFilterOperatorTest.java
index a939447a41f..5a975d64664 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/AndFilterOperatorTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/AndFilterOperatorTest.java
@@ -29,6 +29,7 @@ import org.testng.annotations.Test;
 
 import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNull;
 import static org.testng.Assert.assertTrue;
 
 
@@ -325,6 +326,116 @@ public class AndFilterOperatorTest {
     assertEquals(andOperator.getBitmaps().reduce().toArray(), new int[]{3, 6});
   }
 
+  @Test
+  public void testGetBitmapsWithNullChild() {
+    int numDocs = 10;
+    // The second child is UNKNOWN on {4, 5, 6}. The AND is UNKNOWN only where 
no child is false: on 4, where the other
+    // children are true, while 5 and 6 are false through the first child
+    AndFilterOperator andOperator = new 
AndFilterOperator(List.of(bitmapOp(numDocs, false, 2, 3, 4, 7),
+        new BitmapBasedFilterOperator(MutableRoaringBitmap.bitmapOf(0, 1, 2, 
3), false, numDocs,
+            MutableRoaringBitmap.bitmapOf(4, 5, 6)), bitmapOp(numDocs, false, 
2, 4, 8)), null, numDocs, true);
+
+    assertTrue(andOperator.canProduceBitmaps());
+    assertTrue(andOperator.mayHaveNulls());
+    BitmapCollection bitmaps = andOperator.getBitmaps();
+    assertEquals(bitmaps.reduce().toArray(), new int[]{2});
+    assertEquals(bitmaps.getNullBitmap().toArray(), new int[]{4});
+    assertEquals(andOperator.getNumMatchingDocs(), 1);
+
+    NotFilterOperator notOperator = new NotFilterOperator(andOperator, 
numDocs, true);
+    assertTrue(notOperator.canOptimizeCount());
+    assertEquals(notOperator.getNumMatchingDocs(), 8);
+    assertEquals(notOperator.getBitmaps().reduce().toArray(), new int[]{0, 1, 
3, 5, 6, 7, 8, 9});
+    assertEquals(TestUtils.getDocIds(notOperator.getTrues()), List.of(0, 1, 3, 
5, 6, 7, 8, 9));
+  }
+
+  @Test
+  public void testGetBitmapsWithoutNullChild() {
+    int numDocs = 10;
+    AndFilterOperator andOperator = new AndFilterOperator(
+        List.of(bitmapOp(numDocs, false, 2, 3, 4, 7), bitmapOp(numDocs, false, 
2, 4, 8)), null, numDocs, true);
+
+    assertFalse(andOperator.mayHaveNulls());
+    assertNull(andOperator.getBitmaps().getNullBitmap());
+    assertEquals(andOperator.getNumMatchingDocs(), 2);
+  }
+
+  @Test
+  public void testGetNulls() {
+    int numDocs = 10;
+    // The first child is UNKNOWN on {4, 5, 6}; the AND is UNKNOWN only on 4, 
where the second child is true, since 5
+    // and 6 are false through it
+    AndFilterOperator andFilterOperator = new AndFilterOperator(
+        List.of(new TestFilterOperator(new int[]{0, 1, 2, 3}, new int[]{4, 5, 
6}, numDocs),
+            new TestFilterOperator(new int[]{2, 3, 4, 7}, numDocs)), null, 
numDocs, true);
+
+    assertTrue(andFilterOperator.mayHaveNulls());
+    assertEquals(TestUtils.getDocIds(andFilterOperator.getTrues()), List.of(2, 
3));
+    assertEquals(TestUtils.getDocIds(andFilterOperator.getNulls()), 
List.of(4));
+    assertEquals(TestUtils.getDocIds(andFilterOperator.getFalses()), 
List.of(0, 1, 5, 6, 7, 8, 9));
+  }
+
+  @Test
+  public void testChildTrueNowhereButUnknownSomewhere() {
+    int numDocs = 5;
+    // The first child is true nowhere and UNKNOWN on {1, 2}; the AND is 
UNKNOWN on 1, where the second child is
+    // true, and false everywhere else
+    AndFilterOperator andFilterOperator = new AndFilterOperator(
+        List.of(new TestFilterOperator(new int[0], new int[]{1, 2}, numDocs),
+            new TestFilterOperator(new int[]{1, 3}, numDocs)), null, numDocs, 
true);
+
+    assertEquals(TestUtils.getDocIds(andFilterOperator.getTrues()), List.of());
+    assertEquals(TestUtils.getDocIds(andFilterOperator.getNulls()), 
List.of(1));
+    assertEquals(TestUtils.getDocIds(andFilterOperator.getFalses()), 
List.of(0, 2, 3, 4));
+  }
+
+  @Test
+  public void testGetNullsWhenNeverFalse() {
+    int numDocs = 3;
+    // The negated child is true nowhere and UNKNOWN on 1, so its negation is 
true on {0, 2}, UNKNOWN on 1 and false
+    // nowhere. The conjunction with a match-all is then never false either, 
yet still UNKNOWN on 1
+    NotFilterOperator notFilterOperator =
+        new NotFilterOperator(new TestFilterOperator(new int[0], new int[]{1}, 
numDocs), numDocs, true);
+    AndFilterOperator andFilterOperator = new AndFilterOperator(
+        List.of(notFilterOperator, new MatchAllFilterOperator(numDocs)), null, 
numDocs, true);
+
+    assertEquals(TestUtils.getDocIds(andFilterOperator.getTrues()), List.of(0, 
2));
+    assertEquals(TestUtils.getDocIds(andFilterOperator.getNulls()), 
List.of(1));
+    assertEquals(TestUtils.getDocIds(andFilterOperator.getFalses()), 
List.of());
+  }
+
+  @Test
+  public void testGetNullsPropagateThroughNestedOr() {
+    int numDocs = 8;
+    // The OR is UNKNOWN on {1, 2}, where its first child is UNKNOWN and its 
second false; the AND above it inherits
+    // that where its own other child is true, and the outer negation has to 
leave those documents out
+    OrFilterOperator orFilterOperator = new OrFilterOperator(
+        List.of(new TestFilterOperator(new int[]{0}, new int[]{1, 2}, numDocs),
+            new TestFilterOperator(new int[]{3}, numDocs)), null, numDocs, 
true);
+    AndFilterOperator andFilterOperator = new AndFilterOperator(
+        List.of(new TestFilterOperator(new int[]{0, 1, 2, 3, 4}, numDocs), 
orFilterOperator), null, numDocs, true);
+
+    assertEquals(TestUtils.getDocIds(andFilterOperator.getNulls()), List.of(1, 
2));
+    NotFilterOperator notFilterOperator = new 
NotFilterOperator(andFilterOperator, numDocs, true);
+    assertEquals(TestUtils.getDocIds(notFilterOperator.getTrues()), List.of(4, 
5, 6, 7));
+  }
+
+  @Test
+  public void testGetNumMatchingDocsWithNullChild() {
+    int numDocs = 10;
+    // The second child is exclusive, so true on all but {0, 1, 4}; the AND is 
true on {2, 3} and UNKNOWN on {5, 6},
+    // where the first child is UNKNOWN and the second true
+    AndFilterOperator andOperator = new AndFilterOperator(List.of(
+        new BitmapBasedFilterOperator(MutableRoaringBitmap.bitmapOf(0, 1, 2, 
3), false, numDocs,
+            MutableRoaringBitmap.bitmapOf(4, 5, 6)), bitmapOp(numDocs, true, 
0, 1, 4)), null, numDocs, true);
+
+    assertTrue(andOperator.canOptimizeCount());
+    assertEquals(andOperator.getNumMatchingDocs(), 2);
+    BitmapCollection bitmaps = andOperator.getBitmaps();
+    assertEquals(bitmaps.reduce().toArray(), new int[]{2, 3});
+    assertEquals(bitmaps.getNullBitmap().toArray(), new int[]{5, 6});
+  }
+
   private static BitmapBasedFilterOperator bitmapOp(int numDocs, boolean 
exclusive, int... docIds) {
     MutableRoaringBitmap bitmap = new MutableRoaringBitmap();
     bitmap.add(docIds);
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/BitmapCollectionTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/BitmapCollectionTest.java
index 5224b35f9d7..98d2a1f7786 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/BitmapCollectionTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/BitmapCollectionTest.java
@@ -23,6 +23,8 @@ import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
 
 import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertSame;
 
 
 public class BitmapCollectionTest {
@@ -225,6 +227,95 @@ public class BitmapCollectionTest {
         new BitmapCollection(numDocs, rightInverted, split(right))), expected);
   }
 
+  @Test
+  public void testExcludingNulls() {
+    int numDocs = 10;
+    ImmutableRoaringBitmap docIds = ImmutableRoaringBitmap.bitmapOf(0, 5);
+    ImmutableRoaringBitmap nullBitmap = ImmutableRoaringBitmap.bitmapOf(5, 7);
+
+    BitmapCollection bitmaps = new BitmapCollection(numDocs, false, 
docIds).excludingNulls(nullBitmap);
+    assertSame(bitmaps.getNullBitmap(), nullBitmap);
+    assertEquals(bitmaps.getCardinality(), 1);
+    assertEquals(bitmaps.reduce().toArray(), new int[]{0});
+
+    // NOT of UNKNOWN is UNKNOWN: the inversion is the complement of the union 
minus the null documents
+    bitmaps.invert();
+    assertSame(bitmaps.getNullBitmap(), nullBitmap);
+    assertEquals(bitmaps.getCardinality(), 7);
+    assertEquals(bitmaps.reduce().toArray(), new int[]{1, 2, 3, 4, 6, 8, 9});
+
+    BitmapCollection splitBitmaps = new BitmapCollection(numDocs, true, 
split(docIds)).excludingNulls(nullBitmap);
+    assertEquals(splitBitmaps.getCardinality(), 7);
+    assertEquals(splitBitmaps.reduce().toArray(), new int[]{1, 2, 3, 4, 6, 8, 
9});
+  }
+
+  @Test
+  public void testExcludingNoNulls() {
+    BitmapCollection bitmaps = new BitmapCollection(10, false, 
ImmutableRoaringBitmap.bitmapOf(0, 5));
+    assertSame(bitmaps.excludingNulls(null), bitmaps);
+    assertSame(bitmaps.excludingNulls(ImmutableRoaringBitmap.bitmapOf()), 
bitmaps);
+    assertNull(bitmaps.getNullBitmap());
+    assertEquals(bitmaps.getCardinality(), 2);
+    assertEquals(bitmaps.invert().getCardinality(), 8);
+  }
+
+  @Test
+  public void testAndOrCardinalityWithNulls() {
+    int numDocs = 10;
+    // True on {0, 1}
+    BitmapCollection left = new BitmapCollection(numDocs, false, 
ImmutableRoaringBitmap.bitmapOf(0, 1, 5))
+        .excludingNulls(ImmutableRoaringBitmap.bitmapOf(5, 7));
+    // True on {0, 4, 5, 6, 7, 8, 9}
+    BitmapCollection right = new BitmapCollection(numDocs, true, 
ImmutableRoaringBitmap.bitmapOf(1, 2))
+        .excludingNulls(ImmutableRoaringBitmap.bitmapOf(3));
+    // True on {1, 5, 9}
+    BitmapCollection plain = new BitmapCollection(numDocs, false, 
ImmutableRoaringBitmap.bitmapOf(1, 5, 9));
+
+    assertEquals(left.andCardinality(right), 1);
+    assertEquals(left.orCardinality(right), 8);
+    assertEquals(left.andCardinality(plain), 1);
+    assertEquals(plain.andCardinality(left), 1);
+    assertEquals(left.orCardinality(plain), 4);
+    assertEquals(plain.orCardinality(left), 4);
+  }
+
+  /// Checks the cardinalities against the materialized true documents over 
every combination of inversion and null
+  /// bitmaps on either side, with nulls inside and outside the unions and 
shared between the sides.
+  @Test
+  public void testCardinalitiesMatchMaterializedTrues() {
+    int numDocs = 12;
+    ImmutableRoaringBitmap leftDocIds = ImmutableRoaringBitmap.bitmapOf(0, 1, 
5, 6, 9);
+    ImmutableRoaringBitmap rightDocIds = ImmutableRoaringBitmap.bitmapOf(1, 2, 
6, 10);
+    ImmutableRoaringBitmap[] nullBitmaps = {
+        null, ImmutableRoaringBitmap.bitmapOf(1, 5, 7), 
ImmutableRoaringBitmap.bitmapOf(2, 7, 9, 11)
+    };
+    for (boolean leftInverted : new boolean[]{false, true}) {
+      for (boolean rightInverted : new boolean[]{false, true}) {
+        for (ImmutableRoaringBitmap leftNulls : nullBitmaps) {
+          for (ImmutableRoaringBitmap rightNulls : nullBitmaps) {
+            BitmapCollection left =
+                new BitmapCollection(numDocs, leftInverted, 
split(leftDocIds)).excludingNulls(leftNulls);
+            BitmapCollection right =
+                new BitmapCollection(numDocs, rightInverted, 
rightDocIds).excludingNulls(rightNulls);
+            ImmutableRoaringBitmap leftTrues = left.reduce();
+            ImmutableRoaringBitmap rightTrues = right.reduce();
+            String description = String.format("leftInverted=%s 
rightInverted=%s leftNulls=%s rightNulls=%s",
+                leftInverted, rightInverted, leftNulls, rightNulls);
+
+            assertEquals(left.getCardinality(), leftTrues.getCardinality(), 
description);
+            assertEquals(right.getCardinality(), rightTrues.getCardinality(), 
description);
+            int andCardinality = 
ImmutableRoaringBitmap.andCardinality(leftTrues, rightTrues);
+            assertEquals(left.andCardinality(right), andCardinality, 
description);
+            assertEquals(right.andCardinality(left), andCardinality, 
description);
+            int orCardinality = 
ImmutableRoaringBitmap.orCardinality(leftTrues, rightTrues);
+            assertEquals(left.orCardinality(right), orCardinality, 
description);
+            assertEquals(right.orCardinality(left), orCardinality, 
description);
+          }
+        }
+      }
+    }
+  }
+
   private ImmutableRoaringBitmap[] split(ImmutableRoaringBitmap bitmap) {
     if (bitmap.isEmpty()) {
       return new ImmutableRoaringBitmap[]{bitmap};
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/InvertedIndexFilterOperatorTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/InvertedIndexFilterOperatorTest.java
index affc3fed8ba..7582b585b7a 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/InvertedIndexFilterOperatorTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/InvertedIndexFilterOperatorTest.java
@@ -18,11 +18,13 @@
  */
 package org.apache.pinot.core.operator.filter;
 
+import javax.annotation.Nullable;
 import org.apache.pinot.core.operator.filter.predicate.PredicateEvaluator;
 import org.apache.pinot.core.query.request.context.QueryContext;
 import org.apache.pinot.segment.spi.datasource.DataSource;
 import org.apache.pinot.segment.spi.datasource.DataSourceMetadata;
 import org.apache.pinot.segment.spi.index.reader.InvertedIndexReader;
+import org.apache.pinot.segment.spi.index.reader.NullValueVectorReader;
 import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
 import org.roaringbitmap.buffer.MutableRoaringBitmap;
 import org.testng.annotations.Test;
@@ -30,10 +32,13 @@ import org.testng.annotations.Test;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
 
 
-/// Targeted tests for [InvertedIndexFilterOperator#getNumMatchingDocs()]. 
Five tests cover every
-/// distinct code branch (SV loop, MV switch arms {0, 2, default}, and 
exclusive arithmetic)
+/// Targeted tests for [InvertedIndexFilterOperator#getNumMatchingDocs()]. The 
two-valued tests cover every
+/// distinct code branch (SV loop, MV switch arms {0, 2, default}, and 
exclusive arithmetic); the null handling ones
+/// check that null rows are left out of the count and the bitmaps.
 public class InvertedIndexFilterOperatorTest {
   private static final int NUM_DOCS = 1000;
 
@@ -89,6 +94,47 @@ public class InvertedIndexFilterOperatorTest {
     assertEquals(operator.getNumMatchingDocs(), NUM_DOCS);
   }
 
+  // Null handling: null rows are UNKNOWN, so they are left out of the count 
and the bitmaps on both sides of exclusive
+  @Test
+  public void testNullRowsAreExcluded() {
+    ImmutableRoaringBitmap b0 = bitmap(0, 1, 2, 3);
+    ImmutableRoaringBitmap b1 = bitmap(4, 5, 6);
+    ImmutableRoaringBitmap nullBitmap = bitmap(3, 100);
+    InvertedIndexFilterOperator operator =
+        newOperator(true, false, new int[]{0, 1}, new 
ImmutableRoaringBitmap[]{b0, b1}, nullBitmap);
+    assertTrue(operator.mayHaveNulls());
+    assertEquals(operator.getNumMatchingDocs(), 6);
+    assertEquals(operator.getBitmaps().reduce().toArray(), new int[]{0, 1, 2, 
4, 5, 6});
+
+    operator = newOperator(true, true, new int[]{0, 1}, new 
ImmutableRoaringBitmap[]{b0, b1}, nullBitmap);
+    assertEquals(operator.getNumMatchingDocs(), NUM_DOCS - 8);
+    assertFalse(operator.getBitmaps().reduce().contains(100));
+  }
+
+  // Multi-value postings overlap, so the null rows among the matches are 
counted on the union
+  @Test
+  public void testNullRowsAreExcludedOnMultiValueColumn() {
+    ImmutableRoaringBitmap b0 = bitmap(0, 1, 2, 3, 4);
+    ImmutableRoaringBitmap b1 = bitmap(3, 4, 5, 6);
+    ImmutableRoaringBitmap nullBitmap = bitmap(3, 100);
+    InvertedIndexFilterOperator operator =
+        newOperator(false, false, new int[]{0, 1}, new 
ImmutableRoaringBitmap[]{b0, b1}, nullBitmap);
+    assertEquals(operator.getNumMatchingDocs(), 6);
+
+    operator = newOperator(false, true, new int[]{0, 1}, new 
ImmutableRoaringBitmap[]{b0, b1}, nullBitmap);
+    assertEquals(operator.getNumMatchingDocs(), NUM_DOCS - 8);
+  }
+
+  // An empty null vector leaves the two-valued shortcuts in place
+  @Test
+  public void testEmptyNullBitmapIsIgnored() {
+    ImmutableRoaringBitmap b0 = bitmap(0, 1, 2, 3);
+    InvertedIndexFilterOperator operator =
+        newOperator(true, false, new int[]{0}, new 
ImmutableRoaringBitmap[]{b0}, bitmap());
+    assertFalse(operator.mayHaveNulls());
+    assertEquals(operator.getNumMatchingDocs(), 4);
+  }
+
   // ----- helpers -----
 
   private static ImmutableRoaringBitmap bitmap(int... docIds) {
@@ -99,11 +145,16 @@ public class InvertedIndexFilterOperatorTest {
     return bitmap;
   }
 
-  @SuppressWarnings({"unchecked", "rawtypes"})
   private static InvertedIndexFilterOperator newOperator(boolean singleValue, 
boolean exclusive, int[] dictIds,
       ImmutableRoaringBitmap[] perDictIdBitmaps) {
+    return newOperator(singleValue, exclusive, dictIds, perDictIdBitmaps, 
null);
+  }
+
+  @SuppressWarnings({"unchecked", "rawtypes"})
+  private static InvertedIndexFilterOperator newOperator(boolean singleValue, 
boolean exclusive, int[] dictIds,
+      ImmutableRoaringBitmap[] perDictIdBitmaps, @Nullable 
ImmutableRoaringBitmap nullBitmap) {
     QueryContext queryContext = mock(QueryContext.class);
-    when(queryContext.isNullHandlingEnabled()).thenReturn(false);
+    when(queryContext.isNullHandlingEnabled()).thenReturn(nullBitmap != null);
 
     DataSourceMetadata metadata = mock(DataSourceMetadata.class);
     when(metadata.isSingleValue()).thenReturn(singleValue);
@@ -116,6 +167,11 @@ public class InvertedIndexFilterOperatorTest {
     DataSource dataSource = mock(DataSource.class);
     when(dataSource.getDataSourceMetadata()).thenReturn(metadata);
     when(dataSource.getInvertedIndex()).thenReturn(reader);
+    if (nullBitmap != null) {
+      NullValueVectorReader nullValueVector = 
mock(NullValueVectorReader.class);
+      when(nullValueVector.getNullBitmap()).thenReturn(nullBitmap);
+      when(dataSource.getNullValueVector()).thenReturn(nullValueVector);
+    }
 
     PredicateEvaluator predicateEvaluator = mock(PredicateEvaluator.class);
     when(predicateEvaluator.isExclusive()).thenReturn(exclusive);
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/NotFilterOperatorTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/NotFilterOperatorTest.java
index d130d603577..f291252efe5 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/NotFilterOperatorTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/NotFilterOperatorTest.java
@@ -23,9 +23,13 @@ import java.util.Iterator;
 import java.util.List;
 import org.apache.pinot.core.common.BlockDocIdIterator;
 import org.apache.pinot.segment.spi.Constants;
-import org.testng.Assert;
+import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
 import org.testng.annotations.Test;
 
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+
 
 public class NotFilterOperatorTest {
 
@@ -38,7 +42,7 @@ public class NotFilterOperatorTest {
     BlockDocIdIterator iterator = 
notFilterOperator.nextBlock().getBlockDocIdSet().iterator();
     int docId;
     while ((docId = iterator.next()) != Constants.EOF) {
-      Assert.assertEquals(docId, expectedIterator.next().intValue());
+      assertEquals(docId, expectedIterator.next().intValue());
     }
   }
 
@@ -51,8 +55,73 @@ public class NotFilterOperatorTest {
     NotFilterOperator notFilterOperator =
         new NotFilterOperator(new TestFilterOperator(docIds, nullDocIds, 
numDocs), numDocs, true);
 
-    Assert.assertEquals(TestUtils.getDocIds(notFilterOperator.getTrues()), 
List.of(7, 8, 9));
-    Assert.assertEquals(TestUtils.getDocIds(notFilterOperator.getFalses()), 
List.of(0, 1, 2, 3));
+    assertEquals(TestUtils.getDocIds(notFilterOperator.getTrues()), List.of(7, 
8, 9));
+    assertEquals(TestUtils.getDocIds(notFilterOperator.getFalses()), 
List.of(0, 1, 2, 3));
+  }
+
+  @Test
+  public void testNotPropagatesNulls() {
+    int numDocs = 6;
+    NotFilterOperator notFilterOperator =
+        new NotFilterOperator(new TestFilterOperator(new int[]{0, 1}, new 
int[]{2, 3}, numDocs), numDocs, true);
+    assertTrue(notFilterOperator.mayHaveNulls());
+    assertEquals(TestUtils.getDocIds(notFilterOperator.getNulls()), List.of(2, 
3));
+
+    // A parent reads the nulls of its children when deriving its falses: doc 
2 is UNKNOWN through the negation while
+    // the other child is true, so it is UNKNOWN for the AND and stays out of 
the outer negation
+    AndFilterOperator andFilterOperator =
+        new AndFilterOperator(List.of(notFilterOperator, new 
TestFilterOperator(new int[]{0, 2, 4}, numDocs)), null,
+            numDocs, true);
+    NotFilterOperator outerNotFilterOperator = new 
NotFilterOperator(andFilterOperator, numDocs, true);
+    assertEquals(TestUtils.getDocIds(outerNotFilterOperator.getTrues()), 
List.of(0, 1, 3, 5));
+  }
+
+  @Test
+  public void testNotCountAndBitmapsWithNull() {
+    int numDocs = 10;
+    ImmutableRoaringBitmap docIds = ImmutableRoaringBitmap.bitmapOf(0, 1, 2, 
3);
+    ImmutableRoaringBitmap nullBitmap = ImmutableRoaringBitmap.bitmapOf(4, 5, 
6);
+
+    BitmapBasedFilterOperator child = new BitmapBasedFilterOperator(docIds, 
false, numDocs, nullBitmap);
+    assertTrue(child.canOptimizeCount());
+    assertEquals(child.getNumMatchingDocs(), 4);
+    assertEquals(child.getBitmaps().reduce().toArray(), new int[]{0, 1, 2, 3});
+
+    NotFilterOperator notFilterOperator = new NotFilterOperator(child, 
numDocs, true);
+    assertTrue(notFilterOperator.mayHaveNulls());
+    assertTrue(notFilterOperator.canOptimizeCount());
+    assertEquals(notFilterOperator.getNumMatchingDocs(), 3);
+    assertTrue(notFilterOperator.canProduceBitmaps());
+    assertEquals(notFilterOperator.getBitmaps().reduce().toArray(), new 
int[]{7, 8, 9});
+    assertEquals(TestUtils.getDocIds(notFilterOperator.getTrues()), List.of(7, 
8, 9));
+  }
+
+  @Test
+  public void testNotCountAndBitmapsWithNullOnExclusiveChild() {
+    int numDocs = 10;
+    // The child is true on every document but {0, 1, 2, 3}, of which {2, 3} 
are UNKNOWN rather than false
+    ImmutableRoaringBitmap docIds = ImmutableRoaringBitmap.bitmapOf(0, 1, 2, 
3);
+    ImmutableRoaringBitmap nullBitmap = ImmutableRoaringBitmap.bitmapOf(2, 3);
+
+    BitmapBasedFilterOperator child = new BitmapBasedFilterOperator(docIds, 
true, numDocs, nullBitmap);
+    assertEquals(child.getNumMatchingDocs(), 6);
+    assertEquals(child.getBitmaps().reduce().toArray(), new int[]{4, 5, 6, 7, 
8, 9});
+
+    NotFilterOperator notFilterOperator = new NotFilterOperator(child, 
numDocs, true);
+    assertEquals(notFilterOperator.getNumMatchingDocs(), 2);
+    assertEquals(notFilterOperator.getBitmaps().reduce().toArray(), new 
int[]{0, 1});
+    assertEquals(TestUtils.getDocIds(notFilterOperator.getTrues()), List.of(0, 
1));
+  }
+
+  @Test
+  public void testNotCountWithoutNull() {
+    int numDocs = 10;
+    NotFilterOperator notFilterOperator = new NotFilterOperator(
+        new BitmapBasedFilterOperator(ImmutableRoaringBitmap.bitmapOf(0, 1, 2, 
3), false, numDocs), numDocs, true);
+
+    assertFalse(notFilterOperator.mayHaveNulls());
+    assertTrue(notFilterOperator.canOptimizeCount());
+    assertEquals(notFilterOperator.getNumMatchingDocs(), 6);
   }
 
   @Test
@@ -61,7 +130,7 @@ public class NotFilterOperatorTest {
 
     NotFilterOperator notFilterOperator = new 
NotFilterOperator(EmptyFilterOperator.getInstance(), numDocs, true);
 
-    Assert.assertEquals(TestUtils.getDocIds(notFilterOperator.getTrues()), 
List.of(0, 1, 2, 3, 4));
-    Assert.assertEquals(TestUtils.getDocIds(notFilterOperator.getFalses()), 
List.of());
+    assertEquals(TestUtils.getDocIds(notFilterOperator.getTrues()), List.of(0, 
1, 2, 3, 4));
+    assertEquals(TestUtils.getDocIds(notFilterOperator.getFalses()), 
List.of());
   }
 }
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/OrFilterOperatorTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/OrFilterOperatorTest.java
index 7c95bd9e683..4a98c60e616 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/OrFilterOperatorTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/OrFilterOperatorTest.java
@@ -276,6 +276,74 @@ public class OrFilterOperatorTest {
     assertEquals(orOperator.getBitmaps().reduce().toArray(), new int[]{1, 2, 
3, 6, 8, 30});
   }
 
+  @Test
+  public void testGetBitmapsWithNullChild() {
+    int numDocs = 8;
+    // The second child is UNKNOWN on {4, 5, 6}. The OR is UNKNOWN only where 
no child is true: 4 is true through the
+    // first child, so only 5 and 6 stay UNKNOWN
+    OrFilterOperator orOperator = new 
OrFilterOperator(List.of(bitmapOp(numDocs, false, 1, 2, 4),
+        new BitmapBasedFilterOperator(MutableRoaringBitmap.bitmapOf(0, 1), 
false, numDocs,
+            MutableRoaringBitmap.bitmapOf(4, 5, 6))), null, numDocs, true);
+
+    assertTrue(orOperator.canProduceBitmaps());
+    assertTrue(orOperator.mayHaveNulls());
+    BitmapCollection bitmaps = orOperator.getBitmaps();
+    assertEquals(bitmaps.reduce().toArray(), new int[]{0, 1, 2, 4});
+    assertEquals(bitmaps.getNullBitmap().toArray(), new int[]{5, 6});
+    assertEquals(orOperator.getNumMatchingDocs(), 4);
+
+    NotFilterOperator notOperator = new NotFilterOperator(orOperator, numDocs, 
true);
+    assertTrue(notOperator.canOptimizeCount());
+    assertEquals(notOperator.getNumMatchingDocs(), 2);
+    assertEquals(notOperator.getBitmaps().reduce().toArray(), new int[]{3, 7});
+    assertEquals(TestUtils.getDocIds(notOperator.getTrues()), List.of(3, 7));
+  }
+
+  @Test
+  public void testGetNulls() {
+    int numDocs = 8;
+    // The second child is UNKNOWN on {4, 5, 6}; the OR is UNKNOWN only on 5 
and 6, since 4 is true through the first
+    // child
+    OrFilterOperator orFilterOperator = new OrFilterOperator(
+        List.of(new TestFilterOperator(new int[]{1, 2, 4}, numDocs),
+            new TestFilterOperator(new int[]{0, 1}, new int[]{4, 5, 6}, 
numDocs)), null, numDocs, true);
+
+    assertTrue(orFilterOperator.mayHaveNulls());
+    assertEquals(TestUtils.getDocIds(orFilterOperator.getTrues()), List.of(0, 
1, 2, 4));
+    assertEquals(TestUtils.getDocIds(orFilterOperator.getNulls()), List.of(5, 
6));
+    assertEquals(TestUtils.getDocIds(orFilterOperator.getFalses()), List.of(3, 
7));
+  }
+
+  @Test
+  public void testChildTrueNowhereButUnknownSomewhere() {
+    int numDocs = 5;
+    // The first child is true nowhere and UNKNOWN on {1, 2}; the OR is 
UNKNOWN on 2, where the second child is
+    // false, and false on {0, 4}
+    OrFilterOperator orFilterOperator = new OrFilterOperator(
+        List.of(new TestFilterOperator(new int[0], new int[]{1, 2}, numDocs),
+            new TestFilterOperator(new int[]{1, 3}, numDocs)), null, numDocs, 
true);
+
+    assertEquals(TestUtils.getDocIds(orFilterOperator.getTrues()), List.of(1, 
3));
+    assertEquals(TestUtils.getDocIds(orFilterOperator.getNulls()), List.of(2));
+    assertEquals(TestUtils.getDocIds(orFilterOperator.getFalses()), List.of(0, 
4));
+  }
+
+  @Test
+  public void testGetNullsPropagateThroughNestedAnd() {
+    int numDocs = 6;
+    // The AND is UNKNOWN on 3, where its first child is UNKNOWN and its 
second true; the OR above it inherits that
+    // where its own other child is false, and the outer negation has to leave 
that document out
+    AndFilterOperator andFilterOperator = new AndFilterOperator(
+        List.of(new TestFilterOperator(new int[]{1, 2}, new int[]{3, 4}, 
numDocs),
+            new TestFilterOperator(new int[]{2, 3, 5}, numDocs)), null, 
numDocs, true);
+    OrFilterOperator orFilterOperator = new OrFilterOperator(
+        List.of(new TestFilterOperator(new int[]{0}, numDocs), 
andFilterOperator), null, numDocs, true);
+
+    assertEquals(TestUtils.getDocIds(orFilterOperator.getNulls()), List.of(3));
+    NotFilterOperator notFilterOperator = new 
NotFilterOperator(orFilterOperator, numDocs, true);
+    assertEquals(TestUtils.getDocIds(notFilterOperator.getTrues()), List.of(1, 
4, 5));
+  }
+
   private static BitmapBasedFilterOperator bitmapOp(int numDocs, boolean 
exclusive, int... docIds) {
     MutableRoaringBitmap bitmap = new MutableRoaringBitmap();
     bitmap.add(docIds);
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/startree/v2/BaseStarTreeV2Test.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/startree/v2/BaseStarTreeV2Test.java
index 4896b6865dd..79ac5a7dd29 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/startree/v2/BaseStarTreeV2Test.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/startree/v2/BaseStarTreeV2Test.java
@@ -249,7 +249,7 @@ abstract class BaseStarTreeV2Test<R, A> {
     filterPlanNode.run();
     Map<String, List<CompositePredicateEvaluator>> predicateEvaluatorsMap =
         StarTreeUtils.extractPredicateEvaluatorsMap(_indexSegment, 
queryContext.getFilter(),
-            filterPlanNode.getPredicateEvaluators());
+            filterPlanNode.getPredicateEvaluators(), 
queryContext.isNullHandlingEnabled());
     assertNull(predicateEvaluatorsMap);
   }
 
@@ -281,7 +281,7 @@ abstract class BaseStarTreeV2Test<R, A> {
     filterPlanNode.run();
     Map<String, List<CompositePredicateEvaluator>> predicateEvaluatorsMap =
         StarTreeUtils.extractPredicateEvaluatorsMap(_indexSegment, 
queryContext.getFilter(),
-            filterPlanNode.getPredicateEvaluators());
+            filterPlanNode.getPredicateEvaluators(), 
queryContext.isNullHandlingEnabled());
     assertNotNull(predicateEvaluatorsMap);
 
     // Extract values with star-tree
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/queries/NullHandlingEnabledQueriesTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/queries/NullHandlingEnabledQueriesTest.java
index 0afe4ef6ad3..cb48a2e910a 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/queries/NullHandlingEnabledQueriesTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/queries/NullHandlingEnabledQueriesTest.java
@@ -23,11 +23,14 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import javax.annotation.Nullable;
 import org.apache.commons.io.FileUtils;
 import org.apache.pinot.common.response.broker.ResultTable;
 import org.apache.pinot.core.plan.DocIdSetPlanNode;
 import 
org.apache.pinot.segment.local.indexsegment.immutable.ImmutableSegmentLoader;
+import org.apache.pinot.segment.local.indexsegment.mutable.MutableSegmentImpl;
+import 
org.apache.pinot.segment.local.indexsegment.mutable.MutableSegmentImplTestUtils;
 import 
org.apache.pinot.segment.local.segment.creator.impl.SegmentIndexCreationDriverImpl;
 import org.apache.pinot.segment.local.segment.readers.GenericRowRecordReader;
 import org.apache.pinot.segment.spi.ImmutableSegment;
@@ -66,6 +69,15 @@ public class NullHandlingEnabledQueriesTest extends 
BaseQueriesTest {
       new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME)
           .setInvertedIndexColumns(List.of(COLUMN1))
           .build();
+  private static final TableConfig TABLE_CONFIG_WITH_RANGE_INDEX_COLUMN =
+      new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME)
+          .setRangeIndexColumns(List.of(COLUMN1))
+          .build();
+  private static final TableConfig TABLE_CONFIG_WITH_RAW_RANGE_INDEX_COLUMN =
+      new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME)
+          .setNoDictionaryColumns(List.of(COLUMN1))
+          .setRangeIndexColumns(List.of(COLUMN1))
+          .build();
   private static final int NUM_OF_SEGMENT_COPIES = 4;
   private static final Map<String, String> QUERY_OPTIONS = 
Map.of("enableNullHandling", "true");
 
@@ -192,6 +204,204 @@ public class NullHandlingEnabledQueriesTest extends 
BaseQueriesTest {
     assertEquals(rows.get(2), new Object[]{null, (long) 3 * 
NUM_OF_SEGMENT_COPIES});
   }
 
+  /// A predicate that no real value fails is true for every non-null row and 
UNKNOWN for a null one, so it selects
+  /// the non-null rows and its negation selects nothing. A column with a null 
therefore has no predicate that is
+  /// always true or always false for the query, whatever the evaluator 
concludes over the dictionary's values.
+  @Test
+  public void testPredicateOverEveryRealValueExcludesNulls()
+      throws Exception {
+    initializeRows();
+    insertRow(1);
+    insertRow(null);
+    insertRow(2);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, 
DataType.INT).build();
+    setUpSegments(TABLE_CONFIG, schema);
+
+    assertEquals(getRows(String.format("SELECT COUNT(*) FROM testTable WHERE 
%s <> 99999", COLUMN1)).get(0)[0],
+        (long) 2 * NUM_OF_SEGMENT_COPIES);
+    assertEquals(getRows(String.format("SELECT COUNT(*) FROM testTable WHERE 
NOT (%s <> 99999)", COLUMN1)).get(0)[0],
+        0L, "NOT of UNKNOWN is UNKNOWN, so the null row must not be selected");
+  }
+
+  /// The mirror image: a predicate that no real value satisfies selects 
nothing, and its negation selects the non-null
+  /// rows only, rather than every row.
+  @Test
+  public void testPredicateOverNoRealValueExcludesNullsWhenNegated()
+      throws Exception {
+    initializeRows();
+    insertRow(1);
+    insertRow(null);
+    insertRow(2);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, 
DataType.INT).build();
+    setUpSegments(TABLE_CONFIG, schema);
+
+    assertEquals(getRows(String.format("SELECT COUNT(*) FROM testTable WHERE 
%s = 99999", COLUMN1)).get(0)[0], 0L);
+    assertEquals(getRows(String.format("SELECT COUNT(*) FROM testTable WHERE 
NOT (%s = 99999)", COLUMN1)).get(0)[0],
+        (long) 2 * NUM_OF_SEGMENT_COPIES, "The null row is UNKNOWN under the 
negation too, and must stay out");
+  }
+
+  @DataProvider(name = "IndexedTableConfigs")
+  public static Object[][] getIndexedTableConfigs() {
+    return new Object[][]{{TABLE_CONFIG_WITH_INVERTED_INDEX_COLUMN}, 
{TABLE_CONFIG_WITH_SORTED_COLUMN}};
+  }
+
+  /// A lone `COUNT(*)` takes its count straight from the index, so an 
index-based filter has to leave the null rows
+  /// out of that count too: when the predicate is exclusive, when it matches 
the default value a null row is stored
+  /// under, and when it is negated.
+  @Test(dataProvider = "IndexedTableConfigs")
+  public void testFastFilteredCountExcludesNulls(TableConfig tableConfig)
+      throws Exception {
+    initializeRows();
+    insertRow(null);
+    insertRow(1);
+    insertRow(2);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, 
DataType.INT).build();
+    setUpSegments(tableConfig, schema);
+
+    assertEquals(getRows(String.format("SELECT COUNT(*) FROM testTable WHERE 
%s <> 1", COLUMN1)).get(0)[0],
+        (long) NUM_OF_SEGMENT_COPIES);
+    assertEquals(getRows(String.format("SELECT COUNT(*) FROM testTable WHERE 
%s = %d", COLUMN1,
+        FieldSpec.DEFAULT_DIMENSION_NULL_VALUE_OF_INT)).get(0)[0], 0L);
+    assertEquals(getRows(String.format("SELECT COUNT(*) FROM testTable WHERE 
NOT (%s = 1)", COLUMN1)).get(0)[0],
+        (long) NUM_OF_SEGMENT_COPIES);
+    assertEquals(getRows(String.format("SELECT COUNT(*) FROM testTable WHERE 
%s IN (1, 2)", COLUMN1)).get(0)[0],
+        (long) 2 * NUM_OF_SEGMENT_COPIES);
+    assertEquals(getRows(String.format("SELECT COUNT(*) FROM testTable WHERE 
%s < 5", COLUMN1)).get(0)[0],
+        (long) 2 * NUM_OF_SEGMENT_COPIES);
+    assertEquals(getRows(String.format("SELECT COUNT(*) FROM testTable WHERE 
NOT (%s > 1)", COLUMN1)).get(0)[0],
+        (long) NUM_OF_SEGMENT_COPIES);
+  }
+
+  /// Both range index configurations crossed with every numeric type, with 
the values 1 and 2 of that type.
+  @DataProvider(name = "RangeIndexedTableConfigsAndNumericTypes")
+  public static Object[][] getRangeIndexedTableConfigsAndNumericTypes() {
+    TableConfig[] tableConfigs = {TABLE_CONFIG_WITH_RANGE_INDEX_COLUMN, 
TABLE_CONFIG_WITH_RAW_RANGE_INDEX_COLUMN};
+    Object[][] typedValues = {
+        {DataType.INT, 1, 2}, {DataType.LONG, 1L, 2L}, {DataType.FLOAT, 1f, 
2f}, {DataType.DOUBLE, 1d, 2d}
+    };
+    List<Object[]> cases = new ArrayList<>();
+    for (TableConfig tableConfig : tableConfigs) {
+      for (Object[] typed : typedValues) {
+        cases.add(new Object[]{tableConfig, typed[0], typed[1], typed[2]});
+      }
+    }
+    return cases.toArray(new Object[0][]);
+  }
+
+  /// The range index stores the null row under the default value like any 
other, so a range that covers the default
+  /// would count it, whether the index is over dictionary ids or raw values. 
Every numeric type's default null value
+  /// lies below 1, so `< 5` covers it and `> 1` does not.
+  @Test(dataProvider = "RangeIndexedTableConfigsAndNumericTypes")
+  public void testFastFilteredCountExcludesNullsWithRangeIndex(TableConfig 
tableConfig, DataType dataType,
+      Object value1, Object value2)
+      throws Exception {
+    initializeRows();
+    insertRow(null);
+    insertRow(value1);
+    insertRow(value2);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, 
dataType).build();
+    setUpSegments(tableConfig, schema);
+
+    assertEquals(getRows(String.format("SELECT COUNT(*) FROM testTable WHERE 
%s < 5", COLUMN1)).get(0)[0],
+        (long) 2 * NUM_OF_SEGMENT_COPIES);
+    assertEquals(getRows(String.format("SELECT COUNT(*) FROM testTable WHERE 
%s > 1", COLUMN1)).get(0)[0],
+        (long) NUM_OF_SEGMENT_COPIES);
+    assertEquals(getRows(String.format("SELECT COUNT(*) FROM testTable WHERE 
NOT (%s > 1)", COLUMN1)).get(0)[0],
+        (long) NUM_OF_SEGMENT_COPIES);
+  }
+
+  /// `IS NULL` and `IS NOT NULL` are two-valued even on an expression that is 
null there: the null makes them true or
+  /// false, never UNKNOWN, so a boolean tree above them must not treat the 
null rows as UNKNOWN.
+  @Test
+  public void testIsNullPredicatesOnExpressionsAreTwoValued()
+      throws Exception {
+    initializeRows();
+    insertRowWithTwoColumns(null, 1);
+    insertRowWithTwoColumns(1, 1);
+    insertRowWithTwoColumns(2, 2);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
+
+    // The null row makes the IS NOT NULL false, so the conjunction is false 
there and its negation selects the row
+    assertEquals(getRows(String.format("SELECT COUNT(*) FROM testTable WHERE 
NOT (add(%s, 0) IS NOT NULL AND %s = 1)",
+        COLUMN1, COLUMN2)).get(0)[0], (long) 2 * NUM_OF_SEGMENT_COPIES);
+    // Likewise through a disjunction whose other branch is false on the null 
row
+    assertEquals(getRows(String.format("SELECT COUNT(*) FROM testTable WHERE 
NOT (add(%s, 0) IS NOT NULL OR %s = 2)",
+        COLUMN1, COLUMN2)).get(0)[0], (long) NUM_OF_SEGMENT_COPIES);
+  }
+
+  /// A multi-value column's null row is stored as a single default value, and 
its posting lists overlap across values,
+  /// so the count is taken over their union.
+  @Test
+  public void testFastFilteredCountExcludesNullsOnMultiValueColumn()
+      throws Exception {
+    initializeRows();
+    insertRow(new Object[]{1, 2});
+    insertRow(null);
+    insertRow(new Object[]{2, 3});
+    Schema schema = schemaBuilder().addMultiValueDimension(COLUMN1, 
DataType.INT).build();
+    setUpSegments(TABLE_CONFIG_WITH_INVERTED_INDEX_COLUMN, schema);
+
+    assertEquals(getRows(String.format("SELECT COUNT(*) FROM testTable WHERE 
%s <> 1", COLUMN1)).get(0)[0],
+        (long) NUM_OF_SEGMENT_COPIES);
+    assertEquals(getRows(String.format("SELECT COUNT(*) FROM testTable WHERE 
%s = %d", COLUMN1,
+        FieldSpec.DEFAULT_DIMENSION_NULL_VALUE_OF_INT)).get(0)[0], 0L);
+    assertEquals(getRows(String.format("SELECT COUNT(*) FROM testTable WHERE 
%s IN (2, 3)", COLUMN1)).get(0)[0],
+        (long) 2 * NUM_OF_SEGMENT_COPIES);
+    assertEquals(getRows(String.format("SELECT COUNT(*) FROM testTable WHERE 
NOT (%s = 2)", COLUMN1)).get(0)[0], 0L);
+  }
+
+  /// A consuming segment reports its null rows through a vector that grows 
with ingestion and hands out a copy on each
+  /// read. The filter takes that copy once, and the index-based count has to 
leave those rows out as on a sealed
+  /// segment.
+  @Test
+  public void testFastFilteredCountExcludesNullsOnMutableSegment()
+      throws Exception {
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, 
DataType.INT).build();
+    MutableSegmentImpl mutableSegment =
+        MutableSegmentImplTestUtils.createMutableSegmentImpl(schema, Set.of(), 
Set.of(), Set.of(COLUMN1), false, true);
+    for (Integer value : new Integer[]{null, 1, 2}) {
+      GenericRow row = new GenericRow();
+      if (value != null) {
+        row.putValue(COLUMN1, value);
+      } else {
+        row.putDefaultNullValue(COLUMN1, 
FieldSpec.DEFAULT_DIMENSION_NULL_VALUE_OF_INT);
+      }
+      mutableSegment.index(row, null);
+    }
+    _indexSegment = mutableSegment;
+    _indexSegments = List.of(mutableSegment, mutableSegment);
+
+    assertEquals(getRows(String.format("SELECT COUNT(*) FROM testTable WHERE 
%s <> 1", COLUMN1)).get(0)[0],
+        (long) NUM_OF_SEGMENT_COPIES);
+    assertEquals(getRows(String.format("SELECT COUNT(*) FROM testTable WHERE 
%s = %d", COLUMN1,
+        FieldSpec.DEFAULT_DIMENSION_NULL_VALUE_OF_INT)).get(0)[0], 0L);
+    assertEquals(getRows(String.format("SELECT COUNT(*) FROM testTable WHERE 
NOT (%s = 1)", COLUMN1)).get(0)[0],
+        (long) NUM_OF_SEGMENT_COPIES);
+  }
+
+  /// The index-based distinct takes the filter's bitmap as its set of rows, 
so the null row has to be gone from the
+  /// bitmap already, or it would surface as a distinct `null`.
+  @Test(dataProvider = "IndexedTableConfigs")
+  public void testIndexBasedDistinctExcludesNulls(TableConfig tableConfig)
+      throws Exception {
+    initializeRows();
+    insertRow(null);
+    insertRow(1);
+    insertRow(2);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, 
DataType.INT).build();
+    setUpSegments(tableConfig, schema);
+    Map<String, String> queryOptions = Map.of("enableNullHandling", "true", 
"useIndexBasedDistinctOperator", "true");
+
+    List<Object[]> rows =
+        getRows(String.format("SELECT DISTINCT %s FROM testTable WHERE %s <> 
1", COLUMN1, COLUMN1), queryOptions);
+    assertEquals(rows.size(), 1);
+    assertEquals(rows.get(0)[0], 2);
+  }
+
   /// A row holding the column's default null value must not join the null 
group.
   ///
   /// Ingestion stores that default in the forward index for a null row, so 
both share a dictionary id and only the
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/queries/StarTreeNullHandlingQueriesTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/queries/StarTreeNullHandlingQueriesTest.java
new file mode 100644
index 00000000000..e28d30ff6a8
--- /dev/null
+++ 
b/pinot-core/src/test/java/org/apache/pinot/queries/StarTreeNullHandlingQueriesTest.java
@@ -0,0 +1,210 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.queries;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import org.apache.commons.io.FileUtils;
+import org.apache.pinot.common.response.broker.BrokerResponseNative;
+import 
org.apache.pinot.segment.local.indexsegment.immutable.ImmutableSegmentLoader;
+import 
org.apache.pinot.segment.local.segment.creator.impl.SegmentIndexCreationDriverImpl;
+import org.apache.pinot.segment.local.segment.readers.GenericRowRecordReader;
+import org.apache.pinot.segment.local.startree.v2.builder.MultipleTreesBuilder;
+import org.apache.pinot.segment.spi.ImmutableSegment;
+import org.apache.pinot.segment.spi.IndexSegment;
+import org.apache.pinot.segment.spi.creator.SegmentGeneratorConfig;
+import org.apache.pinot.spi.config.table.StarTreeIndexConfig;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.data.readers.GenericRow;
+import org.apache.pinot.spi.utils.ReadMode;
+import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+
+
+/// Star-tree usage for queries that enable null handling.
+///
+/// A star-tree folds a null row into the column's default value and counts 
it, which is the answer null handling
+/// disabled asks for and the wrong answer when it is enabled. So a star-tree 
may only serve a null handling query
+/// when nothing the query touches actually holds a null, and the interesting 
cases are the ones where that check
+/// could be skipped.
+public class StarTreeNullHandlingQueriesTest extends BaseQueriesTest {
+  private static final File INDEX_DIR = new File(FileUtils.getTempDirectory(), 
"StarTreeNullHandlingQueriesTest");
+  private static final String RAW_TABLE_NAME = "testTable";
+  private static final String SEGMENT_NAME = "testSegment";
+  /// Holds a null value.
+  private static final String NULLABLE_DIMENSION = "d1";
+  /// Holds no null value.
+  private static final String DIMENSION = "d2";
+  private static final String METRIC = "m";
+
+  private static final Map<String, String> QUERY_OPTIONS = 
Map.of("enableNullHandling", "true");
+
+  /// `_indexSegments` holds two copies and the harness queries two instances, 
so every aggregate is scaled by this.
+  private static final int SEGMENT_COPIES = 4;
+  private static final int NUM_ROWS = 4;
+
+  /// A value no row holds, so a `<>` against it is always true over the real 
values and is dropped from the star-tree
+  /// predicate map. It is not true over a null, where it is UNKNOWN and the 
row is not selected.
+  private static final int ABSENT_VALUE = 99999;
+
+  private static final Integer[] NULLABLE_DIMENSION_VALUES = {1, null, 2, 2};
+  private static final int[] DIMENSION_VALUES = {5, 5, 6, 6};
+  private static final int[] METRIC_VALUES = {10, 20, 30, 40};
+  private static final int SUM_OF_ALL_ROWS = 10 + 20 + 30 + 40;
+  private static final int NUM_ROWS_WITH_A_NON_NULL_DIMENSION = 3;
+  private static final int SUM_OF_ROWS_WITH_A_NON_NULL_DIMENSION = 10 + 30 + 
40;
+
+  private IndexSegment _indexSegment;
+  private List<IndexSegment> _indexSegments;
+
+  @Override
+  protected String getFilter() {
+    return "";
+  }
+
+  @Override
+  protected IndexSegment getIndexSegment() {
+    return _indexSegment;
+  }
+
+  @Override
+  protected List<IndexSegment> getIndexSegments() {
+    return _indexSegments;
+  }
+
+  @BeforeClass
+  public void setUp()
+      throws Exception {
+    FileUtils.deleteDirectory(INDEX_DIR);
+
+    Schema schema = new Schema.SchemaBuilder().setSchemaName(RAW_TABLE_NAME)
+        .addSingleValueDimension(NULLABLE_DIMENSION, DataType.INT)
+        .addSingleValueDimension(DIMENSION, DataType.INT)
+        .addMetric(METRIC, DataType.INT)
+        .build();
+    TableConfig tableConfig = new 
TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
+
+    List<GenericRow> rows = new ArrayList<>(NUM_ROWS);
+    for (int i = 0; i < NUM_ROWS; i++) {
+      GenericRow row = new GenericRow();
+      row.putValue(NULLABLE_DIMENSION, NULLABLE_DIMENSION_VALUES[i]);
+      row.putValue(DIMENSION, DIMENSION_VALUES[i]);
+      row.putValue(METRIC, METRIC_VALUES[i]);
+      rows.add(row);
+    }
+
+    SegmentGeneratorConfig segmentGeneratorConfig = new 
SegmentGeneratorConfig(tableConfig, schema);
+    segmentGeneratorConfig.setSegmentName(SEGMENT_NAME);
+    segmentGeneratorConfig.setDefaultNullHandlingEnabled(true);
+    segmentGeneratorConfig.setOutDir(INDEX_DIR.getPath());
+    SegmentIndexCreationDriverImpl driver = new 
SegmentIndexCreationDriverImpl();
+    driver.init(segmentGeneratorConfig, new GenericRowRecordReader(rows));
+    driver.build();
+
+    File indexDir = new File(INDEX_DIR, SEGMENT_NAME);
+    StarTreeIndexConfig starTreeIndexConfig =
+        new StarTreeIndexConfig(List.of(NULLABLE_DIMENSION, DIMENSION), null, 
List.of("SUM__" + METRIC), null, 1);
+    try (MultipleTreesBuilder builder = new 
MultipleTreesBuilder(List.of(starTreeIndexConfig), false, indexDir,
+        MultipleTreesBuilder.BuildMode.OFF_HEAP)) {
+      builder.build();
+    }
+
+    ImmutableSegment segment = ImmutableSegmentLoader.load(indexDir, 
ReadMode.mmap);
+    _indexSegment = segment;
+    _indexSegments = List.of(segment, segment);
+  }
+
+  @AfterClass
+  public void tearDown()
+      throws IOException {
+    _indexSegment.destroy();
+    FileUtils.deleteDirectory(INDEX_DIR);
+  }
+
+  /// A predicate that is always true over a column's real values is UNKNOWN 
over a null, so with null handling it is
+  /// only truly always true for a column without nulls. Dropped anyway, the 
column would escape the null checks and
+  /// the star-tree would answer with the null row folded into the column's 
default value.
+  @Test
+  public void testAlwaysTruePredicateOnANullableColumnRefusesTheStarTree() {
+    String query = String.format("SELECT SUM(%s) FROM testTable WHERE %s <> 
%d", METRIC, NULLABLE_DIMENSION,
+        ABSENT_VALUE);
+
+    BrokerResponseNative brokerResponse = getBrokerResponse(query, 
QUERY_OPTIONS);
+
+    assertEquals(brokerResponse.getResultTable().getRows().get(0)[0],
+        (double) SUM_OF_ROWS_WITH_A_NON_NULL_DIMENSION * SEGMENT_COPIES,
+        "A null makes the predicate UNKNOWN, so the row must not be 
aggregated");
+    assertEquals(brokerResponse.getNumDocsScanned(), (long) 
NUM_ROWS_WITH_A_NON_NULL_DIMENSION * SEGMENT_COPIES,
+        "The rows the filter selects should be scanned one by one, because the 
star-tree cannot answer this query");
+  }
+
+  /// A negated predicate that is always false over the real values is the 
same verdict reached through the NOT
+  /// branch, and has to follow the same rule.
+  @Test
+  public void 
testNegatedAlwaysFalsePredicateOnANullableColumnRefusesTheStarTree() {
+    String query = String.format("SELECT SUM(%s) FROM testTable WHERE NOT (%s 
= %d)", METRIC, NULLABLE_DIMENSION,
+        ABSENT_VALUE);
+
+    BrokerResponseNative brokerResponse = getBrokerResponse(query, 
QUERY_OPTIONS);
+
+    assertEquals(brokerResponse.getResultTable().getRows().get(0)[0],
+        (double) SUM_OF_ROWS_WITH_A_NON_NULL_DIMENSION * SEGMENT_COPIES);
+    assertEquals(brokerResponse.getNumDocsScanned(), (long) 
NUM_ROWS_WITH_A_NON_NULL_DIMENSION * SEGMENT_COPIES);
+  }
+
+  /// An OR clause is dropped as a whole when one of its branches is always 
true, which is the same decision made on
+  /// a different path, and has to follow the same rule.
+  @Test
+  public void testAlwaysTrueOrClauseOnANullableColumnRefusesTheStarTree() {
+    String query = String.format("SELECT SUM(%s) FROM testTable WHERE %s = 1 
OR %s <> %d", METRIC, NULLABLE_DIMENSION,
+        NULLABLE_DIMENSION, ABSENT_VALUE);
+
+    BrokerResponseNative brokerResponse = getBrokerResponse(query, 
QUERY_OPTIONS);
+
+    assertEquals(brokerResponse.getResultTable().getRows().get(0)[0],
+        (double) SUM_OF_ROWS_WITH_A_NON_NULL_DIMENSION * SEGMENT_COPIES);
+    assertEquals(brokerResponse.getNumDocsScanned(), (long) 
NUM_ROWS_WITH_A_NON_NULL_DIMENSION * SEGMENT_COPIES);
+  }
+
+  /// The same shape over a column that holds no null keeps the star-tree: 
nothing the query touches can turn the
+  /// predicate UNKNOWN, so folding is not a risk and the optimization stands.
+  @Test
+  public void testAlwaysTruePredicateOnANonNullableColumnKeepsTheStarTree() {
+    String query =
+        String.format("SELECT SUM(%s) FROM testTable WHERE %s <> %d", METRIC, 
DIMENSION, ABSENT_VALUE);
+
+    BrokerResponseNative brokerResponse = getBrokerResponse(query, 
QUERY_OPTIONS);
+
+    assertEquals(brokerResponse.getResultTable().getRows().get(0)[0],
+        (double) SUM_OF_ALL_ROWS * SEGMENT_COPIES);
+    assertTrue(brokerResponse.getNumDocsScanned() < (long) NUM_ROWS * 
SEGMENT_COPIES,
+        "Expected the star-tree to answer from pre-aggregated documents, but 
every row was scanned");
+  }
+}
diff --git 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/NullHandlingIntegrationTest.java
 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/NullHandlingIntegrationTest.java
index c158d6913cf..873bc9aa413 100644
--- 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/NullHandlingIntegrationTest.java
+++ 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/NullHandlingIntegrationTest.java
@@ -539,7 +539,9 @@ public class NullHandlingIntegrationTest extends 
BaseClusterIntegrationTestSet
               + "    PinotLogicalAggregate(group=[{0}], agg#0=[COUNT()], 
agg#1=[COUNT() FILTER $1], aggType=[LEAF])\n"
               + "      LogicalProject(city=[$10], $f1=[IS TRUE(=($12, 
_UTF-8'unknown'))])\n"
               + "        PinotLogicalTableScan(table=[[default, mytable]])\n");
-      // IS_TRUE should be trimmed off, then the filter becomes always false 
in the server execution plan
+      // IS_TRUE should be trimmed off. The predicate matches no value, but 
the column has null rows, which are UNKNOWN
+      // under null handling rather than false, so the server plan keeps a 
bitmap filter over them instead of an empty
+      // one: its negation must leave them out too
       explainAskingServers(query,
           "Execution Plan\n"
               + "PinotLogicalAggregate(group=[{0}], agg#0=[COUNT($1)], 
agg#1=[COUNT($2)], aggType=[FINAL])\n"
@@ -550,7 +552,7 @@ public class NullHandlingIntegrationTest extends 
BaseClusterIntegrationTestSet
               + "          GroupByFiltered(groupKeys=[[city]], 
aggregations=[[count(*), count(*)]])\n"
               + "            Project(columns=[[city]])\n"
               + "              DocIdSet(maxDocs=[20000])\n"
-              + "                FilterEmpty\n"
+              + "                FilterBitmap\n"
               + "            Project(columns=[[city]])\n"
               + "              DocIdSet(maxDocs=[20000])\n"
               + "                FilterMatchEntireSegment(numDocs=[100])\n"


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to