You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@lucene.apache.org by gs...@apache.org on 2022/01/10 21:58:01 UTC

[lucene] branch branch_9x updated: LUCENE-10245: Addition of MultiDoubleValues(Source) and MultiLongValues(Source) along with faceting capabilities (#543)

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

gsmiller pushed a commit to branch branch_9x
in repository https://gitbox.apache.org/repos/asf/lucene.git


The following commit(s) were added to refs/heads/branch_9x by this push:
     new 60b499b  LUCENE-10245: Addition of MultiDoubleValues(Source) and MultiLongValues(Source) along with faceting capabilities (#543)
60b499b is described below

commit 60b499b907d380ebec9f7cfc3a7e58f64515f2ec
Author: Greg Miller <gs...@gmail.com>
AuthorDate: Mon Jan 10 13:48:36 2022 -0800

    LUCENE-10245: Addition of MultiDoubleValues(Source) and MultiLongValues(Source) along with faceting capabilities (#543)
---
 lucene/CHANGES.txt                                 |   8 +-
 .../lucene/demo/facet/DistanceFacetsExample.java   |   2 +
 .../apache/lucene/facet/LongValueFacetCounts.java  | 104 +++++++-
 .../org/apache/lucene/facet/MultiDoubleValues.java |  50 ++++
 .../lucene/facet/MultiDoubleValuesSource.java      | 278 +++++++++++++++++++++
 .../org/apache/lucene/facet/MultiLongValues.java   |  50 ++++
 .../apache/lucene/facet/MultiLongValuesSource.java | 260 +++++++++++++++++++
 .../org/apache/lucene/facet/range/DoubleRange.java | 129 ++++++++++
 .../lucene/facet/range/DoubleRangeFacetCounts.java | 106 +++++++-
 .../org/apache/lucene/facet/range/LongRange.java   | 129 ++++++++++
 .../lucene/facet/range/LongRangeFacetCounts.java   |  96 ++++++-
 .../lucene/facet/MultiValuesSourceTestCase.java    | 199 +++++++++++++++
 .../lucene/facet/TestLongValueFacetCounts.java     |  67 ++++-
 .../lucene/facet/TestMultiDoubleValuesSource.java  | 169 +++++++++++++
 .../lucene/facet/TestMultiLongValuesSource.java    | 148 +++++++++++
 .../lucene/facet/range/TestRangeFacetCounts.java   | 146 +++++++++--
 16 files changed, 1895 insertions(+), 46 deletions(-)

diff --git a/lucene/CHANGES.txt b/lucene/CHANGES.txt
index 17006fb..5a45659 100644
--- a/lucene/CHANGES.txt
+++ b/lucene/CHANGES.txt
@@ -39,7 +39,7 @@ New Features
   JDK modules: jdk.unsupported (unmapping) and jdk.management (OOP size for RAM usage calculatons).
   It is also recommended to install JUL logging adapters to feed the log events into your app's
   logging system.  (Uwe Schindler, Dawid Weiss, Tomoko Uchida, Robert Muir)
-  
+
 * LUCENE-10330: Make MMapDirectory tests fail by default, if unmapping does not work.
   (Uwe Schindler, Dawid Weiss)
 
@@ -66,6 +66,12 @@ New Features
 * LUCENE-10335: Add ModuleResourceLoader as complement to ClasspathResourceLoader.
   (Uwe Schindler)
 
+* LUCENE-10245: MultiDoubleValues(Source) and MultiLongValues(Source) were added as multi-valued
+  versions of DoubleValues(Source) and LongValues(Source) to the facets module. LongValueFacetCounts,
+  LongRangeFacetCounts and DoubleRangeFacetCounts were augmented to support these new multi-valued
+  abstractions. DoubleRange and LongRange also support creating queries from these multi-valued
+  sources. (Greg Miller)
+
 * LUCENE-10250: Add support for arbitrary length hierarchical SSDV facets. (Marc D'mello)
 
 Improvements
diff --git a/lucene/demo/src/java/org/apache/lucene/demo/facet/DistanceFacetsExample.java b/lucene/demo/src/java/org/apache/lucene/demo/facet/DistanceFacetsExample.java
index fefec65..a8db7c7 100644
--- a/lucene/demo/src/java/org/apache/lucene/demo/facet/DistanceFacetsExample.java
+++ b/lucene/demo/src/java/org/apache/lucene/demo/facet/DistanceFacetsExample.java
@@ -118,6 +118,8 @@ public class DistanceFacetsExample implements Closeable {
     writer.close();
   }
 
+  // TODO: Would be nice to augment this example with documents containing multiple "locations",
+  // adding the ability to compute distance facets for the multi-valued case (see LUCENE-10245)
   private DoubleValuesSource getDistanceValueSource() {
     Expression distance;
     try {
diff --git a/lucene/facet/src/java/org/apache/lucene/facet/LongValueFacetCounts.java b/lucene/facet/src/java/org/apache/lucene/facet/LongValueFacetCounts.java
index 4a4590c..defef44 100644
--- a/lucene/facet/src/java/org/apache/lucene/facet/LongValueFacetCounts.java
+++ b/lucene/facet/src/java/org/apache/lucene/facet/LongValueFacetCounts.java
@@ -70,7 +70,7 @@ public class LongValueFacetCounts extends Facets {
    * been indexed).
    */
   public LongValueFacetCounts(String field, FacetsCollector hits) throws IOException {
-    this(field, null, hits);
+    this(field, (LongValuesSource) null, hits);
   }
 
   /**
@@ -88,11 +88,31 @@ public class LongValueFacetCounts extends Facets {
   }
 
   /**
+   * Create {@code LongValueFacetCounts}, using the provided {@link MultiLongValuesSource} if
+   * non-null. If {@code valuesSource} is null, doc values from the provided {@code field} will be
+   * used.
+   */
+  public LongValueFacetCounts(
+      String field, MultiLongValuesSource valuesSource, FacetsCollector hits) throws IOException {
+    this.field = field;
+    if (valuesSource != null) {
+      LongValuesSource singleValues = MultiLongValuesSource.unwrapSingleton(valuesSource);
+      if (singleValues != null) {
+        count(singleValues, hits.getMatchingDocs());
+      } else {
+        count(valuesSource, hits.getMatchingDocs());
+      }
+    } else {
+      count(field, hits.getMatchingDocs());
+    }
+  }
+
+  /**
    * Counts all facet values for this reader. This produces the same result as computing facets on a
    * {@link org.apache.lucene.search.MatchAllDocsQuery}, but is more efficient.
    */
   public LongValueFacetCounts(String field, IndexReader reader) throws IOException {
-    this(field, null, reader);
+    this(field, (LongValuesSource) null, reader);
   }
 
   /**
@@ -111,6 +131,27 @@ public class LongValueFacetCounts extends Facets {
     }
   }
 
+  /**
+   * Counts all facet values for the provided {@link MultiLongValuesSource} if non-null. If {@code
+   * valueSource} is null, doc values from the provided {@code field} will be used. This produces
+   * the same result as computing facets on a {@link org.apache.lucene.search.MatchAllDocsQuery},
+   * but is more efficient.
+   */
+  public LongValueFacetCounts(String field, MultiLongValuesSource valuesSource, IndexReader reader)
+      throws IOException {
+    this.field = field;
+    if (valuesSource != null) {
+      LongValuesSource singleValued = MultiLongValuesSource.unwrapSingleton(valuesSource);
+      if (singleValued != null) {
+        countAll(reader, singleValued);
+      } else {
+        countAll(reader, valuesSource);
+      }
+    } else {
+      countAll(reader, field);
+    }
+  }
+
   /** Counts from the provided valueSource. */
   private void count(LongValuesSource valueSource, List<MatchingDocs> matchingDocs)
       throws IOException {
@@ -137,6 +178,37 @@ public class LongValueFacetCounts extends Facets {
     }
   }
 
+  /** Counts from the provided valuesSource. */
+  private void count(MultiLongValuesSource valuesSource, List<MatchingDocs> matchingDocs)
+      throws IOException {
+    for (MatchingDocs hits : matchingDocs) {
+
+      MultiLongValues multiValues = valuesSource.getValues(hits.context);
+
+      DocIdSetIterator docs = hits.bits.iterator();
+      for (int doc = docs.nextDoc(); doc != DocIdSetIterator.NO_MORE_DOCS; ) {
+        // Skip missing docs:
+        if (multiValues.advanceExact(doc)) {
+          long limit = multiValues.getValueCount();
+          if (limit > 0) {
+            totCount++;
+          }
+          long previousValue = 0;
+          for (int i = 0; i < limit; i++) {
+            long value = multiValues.nextValue();
+            // do not increment the count for duplicate values
+            if (i == 0 || value != previousValue) {
+              increment(value);
+              previousValue = value;
+            }
+          }
+        }
+
+        doc = docs.nextDoc();
+      }
+    }
+  }
+
   /** Counts from the field's indexed doc values. */
   private void count(String field, List<MatchingDocs> matchingDocs) throws IOException {
     for (MatchingDocs hits : matchingDocs) {
@@ -194,6 +266,34 @@ public class LongValueFacetCounts extends Facets {
     }
   }
 
+  /** Count everything in the provided valueSource. */
+  private void countAll(IndexReader reader, MultiLongValuesSource valueSource) throws IOException {
+
+    for (LeafReaderContext context : reader.leaves()) {
+      MultiLongValues multiValues = valueSource.getValues(context);
+      int maxDoc = context.reader().maxDoc();
+
+      for (int doc = 0; doc < maxDoc; doc++) {
+        // Skip missing docs:
+        if (multiValues.advanceExact(doc)) {
+          long limit = multiValues.getValueCount();
+          if (limit > 0) {
+            totCount++;
+          }
+          long previousValue = 0;
+          for (int i = 0; i < limit; i++) {
+            long value = multiValues.nextValue();
+            // do not increment the count for duplicate values
+            if (i == 0 || value != previousValue) {
+              increment(value);
+              previousValue = value;
+            }
+          }
+        }
+      }
+    }
+  }
+
   /** Count everything in the specified field. */
   private void countAll(IndexReader reader, String field) throws IOException {
 
diff --git a/lucene/facet/src/java/org/apache/lucene/facet/MultiDoubleValues.java b/lucene/facet/src/java/org/apache/lucene/facet/MultiDoubleValues.java
new file mode 100644
index 0000000..b97a9fa
--- /dev/null
+++ b/lucene/facet/src/java/org/apache/lucene/facet/MultiDoubleValues.java
@@ -0,0 +1,50 @@
+/*
+ * 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.lucene.facet;
+
+import java.io.IOException;
+import org.apache.lucene.search.DoubleValues;
+
+/**
+ * Per-segment, per-document double values, which can be calculated at search-time. Documents may
+ * produce multiple values. See also {@link DoubleValues} for a single-valued version.
+ *
+ * <p>Currently meant only for use within the faceting module. Could be further generalized and made
+ * available for more use-cases outside faceting if there is a desire to do so.
+ *
+ * @lucene.experimental
+ */
+public abstract class MultiDoubleValues {
+
+  /** Instantiates a new MultiDoubleValues */
+  public MultiDoubleValues() {}
+
+  /**
+   * Retrieves the number of values for the current document. This must always be greater than zero.
+   * It is illegal to call this method after {@link #advanceExact(int)} returned {@code false}.
+   */
+  public abstract long getValueCount();
+
+  /**
+   * Iterates to the next value in the current document. Do not call this more than {@link
+   * #getValueCount} times for the document.
+   */
+  public abstract double nextValue() throws IOException;
+
+  /** Advance to exactly {@code doc} and return whether {@code doc} has a value. */
+  public abstract boolean advanceExact(int doc) throws IOException;
+}
diff --git a/lucene/facet/src/java/org/apache/lucene/facet/MultiDoubleValuesSource.java b/lucene/facet/src/java/org/apache/lucene/facet/MultiDoubleValuesSource.java
new file mode 100644
index 0000000..4442796
--- /dev/null
+++ b/lucene/facet/src/java/org/apache/lucene/facet/MultiDoubleValuesSource.java
@@ -0,0 +1,278 @@
+/*
+ * 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.lucene.facet;
+
+import java.io.IOException;
+import java.util.Objects;
+import java.util.function.LongToDoubleFunction;
+import org.apache.lucene.index.DocValues;
+import org.apache.lucene.index.LeafReaderContext;
+import org.apache.lucene.index.SortedNumericDocValues;
+import org.apache.lucene.search.DoubleValues;
+import org.apache.lucene.search.DoubleValuesSource;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.SegmentCacheable;
+
+/**
+ * Base class for producing {@link MultiDoubleValues}. See also {@link DoubleValuesSource} for a
+ * single-valued version.
+ *
+ * <p>MultiDoubleValuesSource objects for NumericDocValues/SortedNumericDocValues fields can be
+ * obtained by calling {@link #fromFloatField(String)}, {@link #fromDoubleField(String)}, {@link
+ * #fromIntField(String)}, or {@link #fromLongField(String)}. If custom long-to-double logic is
+ * required, {@link #fromField(String, LongToDoubleFunction)} can be used. This is valid for both
+ * multi-valued and single-valued fields.
+ *
+ * <p>To obtain a MultiDoubleValuesSource from an existing {@link DoubleValuesSource}, see {@link
+ * #fromSingleValued(DoubleValuesSource)}. Instances created in this way can be "unwrapped" using
+ * {@link #unwrapSingleton(MultiDoubleValuesSource)} if necessary. Note that scores are never
+ * provided to the underlying {@code DoubleValuesSource}. {@link
+ * DoubleValuesSource#rewrite(IndexSearcher)} will also never be called. The user should be aware of
+ * this if using a {@code DoubleValuesSource} that relies on rewriting or scores. The faceting
+ * use-cases don't call rewrite or provide scores, which is why this simplification was made.
+ *
+ * <p>Currently meant only for use within the faceting module. Could be further generalized and made
+ * available for more use-cases outside faceting if there is a desire to do so.
+ *
+ * @lucene.experimental
+ */
+public abstract class MultiDoubleValuesSource implements SegmentCacheable {
+
+  /** Instantiates a new MultiDoubleValuesSource */
+  public MultiDoubleValuesSource() {}
+
+  /** Returns a {@link MultiDoubleValues} instance for the passed-in LeafReaderContext */
+  public abstract MultiDoubleValues getValues(LeafReaderContext ctx) throws IOException;
+
+  @Override
+  public abstract int hashCode();
+
+  @Override
+  public abstract boolean equals(Object o);
+
+  @Override
+  public abstract String toString();
+
+  /**
+   * Creates a MultiDoubleValuesSource that wraps a generic NumericDocValues/SortedNumericDocValues
+   * field. Uses the long-to-double decoding logic specified in {@code decoder} for converting the
+   * stored value to a double.
+   */
+  public static MultiDoubleValuesSource fromField(String field, LongToDoubleFunction decoder) {
+    return new FieldMultiValuedSource(field, decoder);
+  }
+
+  /** Creates a MultiDoubleValuesSource that wraps a double-valued field */
+  public static MultiDoubleValuesSource fromDoubleField(String field) {
+    return fromField(field, Double::longBitsToDouble);
+  }
+
+  /** Creates a MultiDoubleValuesSource that wraps a float-valued field */
+  public static MultiDoubleValuesSource fromFloatField(String field) {
+    return fromField(field, v -> (double) Float.intBitsToFloat((int) v));
+  }
+
+  /** Creates a MultiDoubleValuesSource that wraps a long-valued field */
+  public static MultiDoubleValuesSource fromLongField(String field) {
+    return fromField(field, v -> (double) v);
+  }
+
+  /** Creates a MultiDoubleValuesSource that wraps an int-valued field */
+  public static MultiDoubleValuesSource fromIntField(String field) {
+    return fromLongField(field);
+  }
+
+  /** Creates a MultiDoubleValuesSource that wraps a single-valued {@code DoubleValuesSource} */
+  public static MultiDoubleValuesSource fromSingleValued(DoubleValuesSource singleValued) {
+    return new SingleValuedAsMultiValued(singleValued);
+  }
+
+  /**
+   * Returns a single-valued view of the {@code MultiDoubleValuesSource} if it was previously
+   * wrapped with {@link #fromSingleValued(DoubleValuesSource)}, or null.
+   */
+  public static DoubleValuesSource unwrapSingleton(MultiDoubleValuesSource in) {
+    if (in instanceof SingleValuedAsMultiValued) {
+      return ((SingleValuedAsMultiValued) in).in;
+    } else {
+      return null;
+    }
+  }
+
+  /** Convert to a MultiLongValuesSource by casting the double values to longs */
+  public final MultiLongValuesSource toMultiLongValuesSource() {
+    return new LongDoubleValuesSource(this);
+  }
+
+  private static class FieldMultiValuedSource extends MultiDoubleValuesSource {
+    private final String field;
+    private final LongToDoubleFunction decoder;
+
+    FieldMultiValuedSource(String field, LongToDoubleFunction decoder) {
+      this.field = field;
+      this.decoder = decoder;
+    }
+
+    @Override
+    public MultiDoubleValues getValues(LeafReaderContext ctx) throws IOException {
+      final SortedNumericDocValues docValues = DocValues.getSortedNumeric(ctx.reader(), field);
+      return new MultiDoubleValues() {
+        @Override
+        public long getValueCount() {
+          return docValues.docValueCount();
+        }
+
+        @Override
+        public double nextValue() throws IOException {
+          return decoder.applyAsDouble(docValues.nextValue());
+        }
+
+        @Override
+        public boolean advanceExact(int doc) throws IOException {
+          return docValues.advanceExact(doc);
+        }
+      };
+    }
+
+    @Override
+    public boolean isCacheable(LeafReaderContext ctx) {
+      return DocValues.isCacheable(ctx, field);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(field, decoder);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o == this) return true;
+      if (o == null || getClass() != o.getClass()) return false;
+      FieldMultiValuedSource that = (FieldMultiValuedSource) o;
+      return Objects.equals(field, that.field) && Objects.equals(decoder, that.decoder);
+    }
+
+    @Override
+    public String toString() {
+      return "multi-double(" + field + ")";
+    }
+  }
+
+  private static class SingleValuedAsMultiValued extends MultiDoubleValuesSource {
+    private final DoubleValuesSource in;
+
+    SingleValuedAsMultiValued(DoubleValuesSource in) {
+      this.in = in;
+    }
+
+    @Override
+    public MultiDoubleValues getValues(LeafReaderContext ctx) throws IOException {
+      final DoubleValues singleValues = in.getValues(ctx, null);
+      return new MultiDoubleValues() {
+        @Override
+        public long getValueCount() {
+          return 1;
+        }
+
+        @Override
+        public double nextValue() throws IOException {
+          return singleValues.doubleValue();
+        }
+
+        @Override
+        public boolean advanceExact(int doc) throws IOException {
+          return singleValues.advanceExact(doc);
+        }
+      };
+    }
+
+    @Override
+    public boolean isCacheable(LeafReaderContext ctx) {
+      return in.isCacheable(ctx);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(in);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o == this) return true;
+      if (o == null || getClass() != o.getClass()) return false;
+      SingleValuedAsMultiValued that = (SingleValuedAsMultiValued) o;
+      return Objects.equals(in, that.in);
+    }
+
+    @Override
+    public String toString() {
+      return "multi-double(" + in + ")";
+    }
+  }
+
+  private static class LongDoubleValuesSource extends MultiLongValuesSource {
+    private final MultiDoubleValuesSource in;
+
+    LongDoubleValuesSource(MultiDoubleValuesSource in) {
+      this.in = in;
+    }
+
+    @Override
+    public MultiLongValues getValues(LeafReaderContext ctx) throws IOException {
+      final MultiDoubleValues doubleValues = in.getValues(ctx);
+      return new MultiLongValues() {
+        @Override
+        public long getValueCount() {
+          return doubleValues.getValueCount();
+        }
+
+        @Override
+        public long nextValue() throws IOException {
+          return (long) doubleValues.nextValue();
+        }
+
+        @Override
+        public boolean advanceExact(int doc) throws IOException {
+          return doubleValues.advanceExact(doc);
+        }
+      };
+    }
+
+    @Override
+    public boolean isCacheable(LeafReaderContext ctx) {
+      return in.isCacheable(ctx);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(in);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o == this) return true;
+      if (o == null || getClass() != o.getClass()) return false;
+      LongDoubleValuesSource that = (LongDoubleValuesSource) o;
+      return Objects.equals(in, that.in);
+    }
+
+    @Override
+    public String toString() {
+      return "multi-double(" + in + ")";
+    }
+  }
+}
diff --git a/lucene/facet/src/java/org/apache/lucene/facet/MultiLongValues.java b/lucene/facet/src/java/org/apache/lucene/facet/MultiLongValues.java
new file mode 100644
index 0000000..4299762
--- /dev/null
+++ b/lucene/facet/src/java/org/apache/lucene/facet/MultiLongValues.java
@@ -0,0 +1,50 @@
+/*
+ * 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.lucene.facet;
+
+import java.io.IOException;
+import org.apache.lucene.search.LongValues;
+
+/**
+ * Per-segment, per-document long values, which can be calculated at search-time. Documents may
+ * produce multiple values. See also {@link LongValues} for a single-valued version.
+ *
+ * <p>Currently meant only for use within the faceting module. Could be further generalized and made
+ * available for more use-cases outside faceting if there is a desire to do so.
+ *
+ * @lucene.experimental
+ */
+public abstract class MultiLongValues {
+
+  /** Instantiates a new MultiLongValues */
+  public MultiLongValues() {}
+
+  /**
+   * Retrieves the number of values for the current document. This must always be greater than zero.
+   * It is illegal to call this method after {@link #advanceExact(int)} returned {@code false}.
+   */
+  public abstract long getValueCount();
+
+  /**
+   * Iterates to the next value in the current document. Do not call this more than {@link
+   * #getValueCount} times for the document.
+   */
+  public abstract long nextValue() throws IOException;
+
+  /** Advance to exactly {@code target} and return whether {@code target} has a value. */
+  public abstract boolean advanceExact(int doc) throws IOException;
+}
diff --git a/lucene/facet/src/java/org/apache/lucene/facet/MultiLongValuesSource.java b/lucene/facet/src/java/org/apache/lucene/facet/MultiLongValuesSource.java
new file mode 100644
index 0000000..227994d
--- /dev/null
+++ b/lucene/facet/src/java/org/apache/lucene/facet/MultiLongValuesSource.java
@@ -0,0 +1,260 @@
+/*
+ * 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.lucene.facet;
+
+import java.io.IOException;
+import java.util.Objects;
+import org.apache.lucene.index.DocValues;
+import org.apache.lucene.index.LeafReaderContext;
+import org.apache.lucene.index.SortedNumericDocValues;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.LongValues;
+import org.apache.lucene.search.LongValuesSource;
+import org.apache.lucene.search.SegmentCacheable;
+
+/**
+ * Base class for producing {@link MultiLongValues}. See also {@link LongValuesSource} for a
+ * single-valued version.
+ *
+ * <p>MultiLongValuesSource objects for long and int-valued NumericDocValues/SortedNumericDocValues
+ * fields can be obtained by calling {@link #fromLongField(String)} and {@link
+ * #fromIntField(String)}. This is valid for both multi-valued and single-valued fields.
+ *
+ * <p>To obtain a MultiLongValuesSource from a float or double-valued
+ * NumericDocValues/SortedNumericDocValues field, use {@link
+ * MultiDoubleValuesSource#fromFloatField(String)} or {@link
+ * MultiDoubleValuesSource#fromDoubleField(String)} and then call {@link
+ * MultiDoubleValuesSource#toMultiLongValuesSource()}.
+ *
+ * <p>To obtain a MultiLongValuesSource from an existing {@link LongValuesSource}, see {@link
+ * #fromSingleValued(LongValuesSource)}. Instances created in this way can be "unwrapped" using
+ * {@link #unwrapSingleton(MultiLongValuesSource)} if necessary. Note that scores are never provided
+ * to the underlying {@code LongValuesSource}. {@link LongValuesSource#rewrite(IndexSearcher)} will
+ * also never be called. The user should be aware of this if using a {@code LongValuesSource} that
+ * relies on rewriting or scores. The faceting use-cases don't call rewrite or provide scores, which
+ * is why this simplification was made.
+ *
+ * <p>Currently meant only for use within the faceting module. Could be further generalized and made
+ * available for more use-cases outside faceting if there is a desire to do so.
+ *
+ * @lucene.experimental
+ */
+public abstract class MultiLongValuesSource implements SegmentCacheable {
+
+  /** Instantiates a new MultiLongValuesSource */
+  public MultiLongValuesSource() {}
+
+  /** Returns a {@link MultiLongValues} instance for the passed-in LeafReaderContext */
+  public abstract MultiLongValues getValues(LeafReaderContext ctx) throws IOException;
+
+  @Override
+  public abstract int hashCode();
+
+  @Override
+  public abstract boolean equals(Object o);
+
+  @Override
+  public abstract String toString();
+
+  /** Creates a MultiLongValuesSource that wraps a long-valued field */
+  public static MultiLongValuesSource fromLongField(String field) {
+    return new FieldMultiValueSource(field);
+  }
+
+  /** Creates a MultiLongValuesSource that wraps an int-valued field */
+  public static MultiLongValuesSource fromIntField(String field) {
+    return fromLongField(field);
+  }
+
+  /** Creates a MultiLongValuesSource that wraps a single-valued {@code LongValuesSource} */
+  public static MultiLongValuesSource fromSingleValued(LongValuesSource singleValued) {
+    return new SingleValuedAsMultiValued(singleValued);
+  }
+
+  /**
+   * Returns a single-valued view of the {@code MultiLongValuesSource} if it was previously wrapped
+   * with {@link #fromSingleValued(LongValuesSource)}, or null.
+   */
+  public static LongValuesSource unwrapSingleton(MultiLongValuesSource in) {
+    if (in instanceof SingleValuedAsMultiValued) {
+      return ((SingleValuedAsMultiValued) in).in;
+    } else {
+      return null;
+    }
+  }
+
+  /** Convert to a MultiDoubleValuesSource by casting long values to doubles */
+  public final MultiDoubleValuesSource toMultiDoubleValuesSource() {
+    return new DoubleLongValuesSources(this);
+  }
+
+  private static class FieldMultiValueSource extends MultiLongValuesSource {
+    private final String field;
+
+    FieldMultiValueSource(String field) {
+      this.field = field;
+    }
+
+    @Override
+    public MultiLongValues getValues(LeafReaderContext ctx) throws IOException {
+      final SortedNumericDocValues docValues = DocValues.getSortedNumeric(ctx.reader(), field);
+      return new MultiLongValues() {
+        @Override
+        public long getValueCount() {
+          return docValues.docValueCount();
+        }
+
+        @Override
+        public long nextValue() throws IOException {
+          return docValues.nextValue();
+        }
+
+        @Override
+        public boolean advanceExact(int doc) throws IOException {
+          return docValues.advanceExact(doc);
+        }
+      };
+    }
+
+    @Override
+    public boolean isCacheable(LeafReaderContext ctx) {
+      return DocValues.isCacheable(ctx, field);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(field);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o == this) return true;
+      if (o == null || getClass() != o.getClass()) return false;
+      FieldMultiValueSource that = (FieldMultiValueSource) o;
+      return Objects.equals(field, that.field);
+    }
+
+    @Override
+    public String toString() {
+      return "multi-long(" + field + ")";
+    }
+  }
+
+  private static class SingleValuedAsMultiValued extends MultiLongValuesSource {
+    private final LongValuesSource in;
+
+    SingleValuedAsMultiValued(LongValuesSource in) {
+      this.in = in;
+    }
+
+    @Override
+    public MultiLongValues getValues(LeafReaderContext ctx) throws IOException {
+      final LongValues singleValued = in.getValues(ctx, null);
+      return new MultiLongValues() {
+        @Override
+        public long getValueCount() {
+          return 1;
+        }
+
+        @Override
+        public long nextValue() throws IOException {
+          return singleValued.longValue();
+        }
+
+        @Override
+        public boolean advanceExact(int doc) throws IOException {
+          return singleValued.advanceExact(doc);
+        }
+      };
+    }
+
+    @Override
+    public boolean isCacheable(LeafReaderContext ctx) {
+      return in.isCacheable(ctx);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(in);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o == this) return true;
+      if (o == null || getClass() != o.getClass()) return false;
+      SingleValuedAsMultiValued that = (SingleValuedAsMultiValued) o;
+      return Objects.equals(in, that.in);
+    }
+
+    @Override
+    public String toString() {
+      return "multi-long(" + in + ")";
+    }
+  }
+
+  private static class DoubleLongValuesSources extends MultiDoubleValuesSource {
+    private final MultiLongValuesSource in;
+
+    DoubleLongValuesSources(MultiLongValuesSource in) {
+      this.in = in;
+    }
+
+    @Override
+    public MultiDoubleValues getValues(LeafReaderContext ctx) throws IOException {
+      final MultiLongValues longValues = in.getValues(ctx);
+      return new MultiDoubleValues() {
+        @Override
+        public long getValueCount() {
+          return longValues.getValueCount();
+        }
+
+        @Override
+        public double nextValue() throws IOException {
+          return (double) longValues.nextValue();
+        }
+
+        @Override
+        public boolean advanceExact(int doc) throws IOException {
+          return longValues.advanceExact(doc);
+        }
+      };
+    }
+
+    @Override
+    public boolean isCacheable(LeafReaderContext ctx) {
+      return in.isCacheable(ctx);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(in);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o == this) return true;
+      if (o == null || getClass() != o.getClass()) return false;
+      DoubleLongValuesSources that = (DoubleLongValuesSources) o;
+      return Objects.equals(in, that.in);
+    }
+
+    @Override
+    public String toString() {
+      return "multi-long(" + in + ")";
+    }
+  }
+}
diff --git a/lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRange.java b/lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRange.java
index f1d86cb..bf188a9 100644
--- a/lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRange.java
+++ b/lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRange.java
@@ -18,6 +18,8 @@ package org.apache.lucene.facet.range;
 
 import java.io.IOException;
 import java.util.Objects;
+import org.apache.lucene.facet.MultiDoubleValues;
+import org.apache.lucene.facet.MultiDoubleValuesSource;
 import org.apache.lucene.index.IndexReader;
 import org.apache.lucene.index.LeafReaderContext;
 import org.apache.lucene.search.ConstantScoreScorer;
@@ -211,6 +213,112 @@ public final class DoubleRange extends Range {
     }
   }
 
+  private static class MultiValueSourceQuery extends Query {
+    private final DoubleRange range;
+    private final Query fastMatchQuery;
+    private final MultiDoubleValuesSource valueSource;
+
+    MultiValueSourceQuery(
+        DoubleRange range, Query fastMatchQuery, MultiDoubleValuesSource valueSource) {
+      this.range = range;
+      this.fastMatchQuery = fastMatchQuery;
+      this.valueSource = valueSource;
+    }
+
+    @Override
+    public boolean equals(Object other) {
+      return sameClassAs(other) && equalsTo(getClass().cast(other));
+    }
+
+    private boolean equalsTo(MultiValueSourceQuery other) {
+      return range.equals(other.range)
+          && Objects.equals(fastMatchQuery, other.fastMatchQuery)
+          && valueSource.equals(other.valueSource);
+    }
+
+    @Override
+    public int hashCode() {
+      return classHash() + 31 * Objects.hash(range, fastMatchQuery, valueSource);
+    }
+
+    @Override
+    public String toString(String field) {
+      return "Filter(" + range.toString() + ")";
+    }
+
+    @Override
+    public void visit(QueryVisitor visitor) {
+      visitor.visitLeaf(this);
+    }
+
+    @Override
+    public Query rewrite(IndexReader reader) throws IOException {
+      if (fastMatchQuery != null) {
+        final Query fastMatchRewritten = fastMatchQuery.rewrite(reader);
+        if (fastMatchRewritten != fastMatchQuery) {
+          return new MultiValueSourceQuery(range, fastMatchRewritten, valueSource);
+        }
+      }
+      return super.rewrite(reader);
+    }
+
+    @Override
+    public Weight createWeight(IndexSearcher searcher, ScoreMode scoreMode, float boost)
+        throws IOException {
+      final Weight fastMatchWeight =
+          fastMatchQuery == null
+              ? null
+              : searcher.createWeight(fastMatchQuery, ScoreMode.COMPLETE_NO_SCORES, 1f);
+
+      return new ConstantScoreWeight(this, boost) {
+        @Override
+        public Scorer scorer(LeafReaderContext context) throws IOException {
+          final int maxDoc = context.reader().maxDoc();
+
+          final DocIdSetIterator approximation;
+          if (fastMatchWeight == null) {
+            approximation = DocIdSetIterator.all(maxDoc);
+          } else {
+            Scorer s = fastMatchWeight.scorer(context);
+            if (s == null) {
+              return null;
+            }
+            approximation = s.iterator();
+          }
+
+          final MultiDoubleValues values = valueSource.getValues(context);
+          final TwoPhaseIterator twoPhase =
+              new TwoPhaseIterator(approximation) {
+                @Override
+                public boolean matches() throws IOException {
+                  if (values.advanceExact(approximation.docID()) == false) {
+                    return false;
+                  }
+
+                  for (int i = 0; i < values.getValueCount(); i++) {
+                    if (range.accept(values.nextValue())) {
+                      return true;
+                    }
+                  }
+                  return false;
+                }
+
+                @Override
+                public float matchCost() {
+                  return 100; // TODO: use cost of range.accept()
+                }
+              };
+          return new ConstantScoreScorer(this, score(), scoreMode, twoPhase);
+        }
+
+        @Override
+        public boolean isCacheable(LeafReaderContext ctx) {
+          return valueSource.isCacheable(ctx);
+        }
+      };
+    }
+  }
+
   /**
    * Create a Query that matches documents in this range
    *
@@ -226,4 +334,25 @@ public final class DoubleRange extends Range {
   public Query getQuery(Query fastMatchQuery, DoubleValuesSource valueSource) {
     return new ValueSourceQuery(this, fastMatchQuery, valueSource);
   }
+
+  /**
+   * Create a Query that matches documents in this range
+   *
+   * <p>The query will check all documents that match the provided match query, or every document in
+   * the index if the match query is null.
+   *
+   * <p>If the value source is static, eg an indexed numeric field, it may be faster to use {@link
+   * org.apache.lucene.search.PointRangeQuery}
+   *
+   * @param fastMatchQuery a query to use as a filter
+   * @param valueSource the source of values for the range check
+   */
+  public Query getQuery(Query fastMatchQuery, MultiDoubleValuesSource valueSource) {
+    DoubleValuesSource singleValues = MultiDoubleValuesSource.unwrapSingleton(valueSource);
+    if (singleValues != null) {
+      return new ValueSourceQuery(this, fastMatchQuery, singleValues);
+    } else {
+      return new MultiValueSourceQuery(this, fastMatchQuery, valueSource);
+    }
+  }
 }
diff --git a/lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRangeFacetCounts.java b/lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRangeFacetCounts.java
index 2253183..ae5ac42 100644
--- a/lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRangeFacetCounts.java
+++ b/lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRangeFacetCounts.java
@@ -22,6 +22,8 @@ import org.apache.lucene.document.FloatDocValuesField;
 import org.apache.lucene.facet.Facets;
 import org.apache.lucene.facet.FacetsCollector;
 import org.apache.lucene.facet.FacetsCollector.MatchingDocs;
+import org.apache.lucene.facet.MultiDoubleValues;
+import org.apache.lucene.facet.MultiDoubleValuesSource;
 import org.apache.lucene.index.NumericDocValues;
 import org.apache.lucene.index.SortedNumericDocValues;
 import org.apache.lucene.search.DocIdSetIterator;
@@ -50,13 +52,14 @@ public class DoubleRangeFacetCounts extends RangeFacetCounts {
    *
    * <p>N.B This assumes that the field was indexed with {@link
    * org.apache.lucene.document.DoubleDocValuesField}. For float-valued fields, use {@link
-   * #DoubleRangeFacetCounts(String, DoubleValuesSource, FacetsCollector, DoubleRange...)}
+   * #DoubleRangeFacetCounts(String, DoubleValuesSource, FacetsCollector, DoubleRange...)} or {@link
+   * #DoubleRangeFacetCounts(String, MultiDoubleValuesSource, FacetsCollector, DoubleRange...)}
    *
    * <p>TODO: Extend multi-valued support to fields that have been indexed as float values
    */
   public DoubleRangeFacetCounts(String field, FacetsCollector hits, DoubleRange... ranges)
       throws IOException {
-    this(field, null, hits, ranges);
+    this(field, (DoubleValuesSource) null, hits, ranges);
   }
 
   /**
@@ -74,6 +77,24 @@ public class DoubleRangeFacetCounts extends RangeFacetCounts {
   }
 
   /**
+   * Create {@code RangeFacetCounts}, using the provided {@link MultiDoubleValuesSource} if
+   * non-null. If {@code valuesSource} is null, doc values from the provided {@code field} will be
+   * used.
+   *
+   * <p>N.B If relying on the provided {@code field}, see javadoc notes associated with {@link
+   * #DoubleRangeFacetCounts(String, FacetsCollector, DoubleRange...)} for assumptions on how the
+   * field is indexed.
+   */
+  public DoubleRangeFacetCounts(
+      String field,
+      MultiDoubleValuesSource valuesSource,
+      FacetsCollector hits,
+      DoubleRange... ranges)
+      throws IOException {
+    this(field, valuesSource, hits, null, ranges);
+  }
+
+  /**
    * Create {@code RangeFacetCounts}, using the provided {@link DoubleValuesSource} if non-null. If
    * {@code valueSource} is null, doc values from the provided {@code field} will be used. Use the
    * provided {@code Query} as a fastmatch: only documents matching the query are checked for the
@@ -100,6 +121,38 @@ public class DoubleRangeFacetCounts extends RangeFacetCounts {
     }
   }
 
+  /**
+   * Create {@code RangeFacetCounts}, using the provided {@link MultiDoubleValuesSource} if
+   * non-null. If {@code valuesSource} is null, doc values from the provided {@code field} will be
+   * used. Use the provided {@code Query} as a fastmatch: only documents matching the query are
+   * checked for the matching ranges.
+   *
+   * <p>N.B If relying on the provided {@code field}, see javadoc notes associated with {@link
+   * #DoubleRangeFacetCounts(String, FacetsCollector, DoubleRange...)} for assumptions on how the
+   * field is indexed.
+   */
+  public DoubleRangeFacetCounts(
+      String field,
+      MultiDoubleValuesSource valuesSource,
+      FacetsCollector hits,
+      Query fastMatchQuery,
+      DoubleRange... ranges)
+      throws IOException {
+    super(field, ranges, fastMatchQuery);
+    // use the provided valueSource if non-null, otherwise use the doc values associated with the
+    // field
+    if (valuesSource != null) {
+      DoubleValuesSource singleValues = MultiDoubleValuesSource.unwrapSingleton(valuesSource);
+      if (singleValues != null) {
+        count(singleValues, hits.getMatchingDocs());
+      } else {
+        count(valuesSource, hits.getMatchingDocs());
+      }
+    } else {
+      count(field, hits.getMatchingDocs());
+    }
+  }
+
   /** Counts from the provided valueSource. */
   private void count(DoubleValuesSource valueSource, List<MatchingDocs> matchingDocs)
       throws IOException {
@@ -134,6 +187,55 @@ public class DoubleRangeFacetCounts extends RangeFacetCounts {
     totCount -= missingCount;
   }
 
+  /** Counts from the provided valueSource. */
+  private void count(MultiDoubleValuesSource valueSource, List<MatchingDocs> matchingDocs)
+      throws IOException {
+
+    LongRange[] longRanges = getLongRanges();
+
+    LongRangeCounter counter = LongRangeCounter.create(longRanges, counts);
+
+    int missingCount = 0;
+    for (MatchingDocs hits : matchingDocs) {
+      MultiDoubleValues multiValues = valueSource.getValues(hits.context);
+
+      final DocIdSetIterator it = createIterator(hits);
+      if (it == null) {
+        continue;
+      }
+
+      for (int doc = it.nextDoc(); doc != DocIdSetIterator.NO_MORE_DOCS; ) {
+        // Skip missing docs:
+        if (multiValues.advanceExact(doc)) {
+          long limit = multiValues.getValueCount();
+          // optimize single-valued case:
+          if (limit == 1) {
+            counter.addSingleValued(NumericUtils.doubleToSortableLong(multiValues.nextValue()));
+            totCount++;
+          } else {
+            counter.startMultiValuedDoc();
+            long previous = 0;
+            for (int i = 0; i < limit; i++) {
+              long val = NumericUtils.doubleToSortableLong(multiValues.nextValue());
+              if (i == 0 || val != previous) {
+                counter.addMultiValued(val);
+                previous = val;
+              }
+            }
+            if (counter.endMultiValuedDoc()) {
+              totCount++;
+            }
+          }
+        }
+
+        doc = it.nextDoc();
+      }
+    }
+
+    missingCount += counter.finish();
+    totCount -= missingCount;
+  }
+
   /** Create long ranges from the double ranges. */
   @Override
   protected LongRange[] getLongRanges() {
diff --git a/lucene/facet/src/java/org/apache/lucene/facet/range/LongRange.java b/lucene/facet/src/java/org/apache/lucene/facet/range/LongRange.java
index 614cec8..63e9919 100644
--- a/lucene/facet/src/java/org/apache/lucene/facet/range/LongRange.java
+++ b/lucene/facet/src/java/org/apache/lucene/facet/range/LongRange.java
@@ -18,6 +18,8 @@ package org.apache.lucene.facet.range;
 
 import java.io.IOException;
 import java.util.Objects;
+import org.apache.lucene.facet.MultiLongValues;
+import org.apache.lucene.facet.MultiLongValuesSource;
 import org.apache.lucene.index.IndexReader;
 import org.apache.lucene.index.LeafReaderContext;
 import org.apache.lucene.search.ConstantScoreScorer;
@@ -198,6 +200,112 @@ public final class LongRange extends Range {
     }
   }
 
+  private static class MultiValueSourceQuery extends Query {
+    private final LongRange range;
+    private final Query fastMatchQuery;
+    private final MultiLongValuesSource valuesSource;
+
+    MultiValueSourceQuery(
+        LongRange range, Query fastMatchQuery, MultiLongValuesSource valuesSource) {
+      this.range = range;
+      this.fastMatchQuery = fastMatchQuery;
+      this.valuesSource = valuesSource;
+    }
+
+    @Override
+    public boolean equals(Object other) {
+      return sameClassAs(other) && equalsTo(getClass().cast(other));
+    }
+
+    private boolean equalsTo(MultiValueSourceQuery other) {
+      return range.equals(other.range)
+          && Objects.equals(fastMatchQuery, other.fastMatchQuery)
+          && valuesSource.equals(other.valuesSource);
+    }
+
+    @Override
+    public int hashCode() {
+      return classHash() + 31 * Objects.hash(range, fastMatchQuery, valuesSource);
+    }
+
+    @Override
+    public String toString(String field) {
+      return "Filter(" + range.toString() + ")";
+    }
+
+    @Override
+    public void visit(QueryVisitor visitor) {
+      visitor.visitLeaf(this);
+    }
+
+    @Override
+    public Query rewrite(IndexReader reader) throws IOException {
+      if (fastMatchQuery != null) {
+        final Query fastMatchRewritten = fastMatchQuery.rewrite(reader);
+        if (fastMatchRewritten != fastMatchQuery) {
+          return new MultiValueSourceQuery(range, fastMatchRewritten, valuesSource);
+        }
+      }
+      return super.rewrite(reader);
+    }
+
+    @Override
+    public Weight createWeight(IndexSearcher searcher, ScoreMode scoreMode, float boost)
+        throws IOException {
+      final Weight fastMatchWeight =
+          fastMatchQuery == null
+              ? null
+              : searcher.createWeight(fastMatchQuery, ScoreMode.COMPLETE_NO_SCORES, 1f);
+
+      return new ConstantScoreWeight(this, boost) {
+        @Override
+        public Scorer scorer(LeafReaderContext context) throws IOException {
+          final int maxDoc = context.reader().maxDoc();
+
+          final DocIdSetIterator approximation;
+          if (fastMatchWeight == null) {
+            approximation = DocIdSetIterator.all(maxDoc);
+          } else {
+            Scorer s = fastMatchWeight.scorer(context);
+            if (s == null) {
+              return null;
+            }
+            approximation = s.iterator();
+          }
+
+          final MultiLongValues values = valuesSource.getValues(context);
+          final TwoPhaseIterator twoPhase =
+              new TwoPhaseIterator(approximation) {
+                @Override
+                public boolean matches() throws IOException {
+                  if (values.advanceExact(approximation.docID()) == false) {
+                    return false;
+                  }
+
+                  for (int i = 0; i < values.getValueCount(); i++) {
+                    if (range.accept(values.nextValue())) {
+                      return true;
+                    }
+                  }
+                  return false;
+                }
+
+                @Override
+                public float matchCost() {
+                  return 100; // TODO: use cost of range.accept()
+                }
+              };
+          return new ConstantScoreScorer(this, score(), scoreMode, twoPhase);
+        }
+
+        @Override
+        public boolean isCacheable(LeafReaderContext ctx) {
+          return valuesSource.isCacheable(ctx);
+        }
+      };
+    }
+  }
+
   /**
    * Create a Query that matches documents in this range
    *
@@ -213,4 +321,25 @@ public final class LongRange extends Range {
   public Query getQuery(Query fastMatchQuery, LongValuesSource valueSource) {
     return new ValueSourceQuery(this, fastMatchQuery, valueSource);
   }
+
+  /**
+   * Create a Query that matches documents in this range
+   *
+   * <p>The query will check all documents that match the provided match query, or every document in
+   * the index if the match query is null.
+   *
+   * <p>If the value source is static, eg an indexed numeric field, it may be faster to use {@link
+   * org.apache.lucene.search.PointRangeQuery}
+   *
+   * @param fastMatchQuery a query to use as a filter
+   * @param valuesSource the source of values for the range check
+   */
+  public Query getQuery(Query fastMatchQuery, MultiLongValuesSource valuesSource) {
+    LongValuesSource singleValues = MultiLongValuesSource.unwrapSingleton(valuesSource);
+    if (singleValues != null) {
+      return new ValueSourceQuery(this, fastMatchQuery, singleValues);
+    } else {
+      return new MultiValueSourceQuery(this, fastMatchQuery, valuesSource);
+    }
+  }
 }
diff --git a/lucene/facet/src/java/org/apache/lucene/facet/range/LongRangeFacetCounts.java b/lucene/facet/src/java/org/apache/lucene/facet/range/LongRangeFacetCounts.java
index 50c9dd6..42a2e4c 100644
--- a/lucene/facet/src/java/org/apache/lucene/facet/range/LongRangeFacetCounts.java
+++ b/lucene/facet/src/java/org/apache/lucene/facet/range/LongRangeFacetCounts.java
@@ -21,6 +21,8 @@ import java.util.List;
 import org.apache.lucene.facet.Facets;
 import org.apache.lucene.facet.FacetsCollector;
 import org.apache.lucene.facet.FacetsCollector.MatchingDocs;
+import org.apache.lucene.facet.MultiLongValues;
+import org.apache.lucene.facet.MultiLongValuesSource;
 import org.apache.lucene.index.NumericDocValues;
 import org.apache.lucene.index.SortedNumericDocValues;
 import org.apache.lucene.search.DocIdSetIterator;
@@ -45,7 +47,7 @@ public class LongRangeFacetCounts extends RangeFacetCounts {
    */
   public LongRangeFacetCounts(String field, FacetsCollector hits, LongRange... ranges)
       throws IOException {
-    this(field, null, hits, ranges);
+    this(field, (LongValuesSource) null, hits, ranges);
   }
 
   /**
@@ -59,6 +61,17 @@ public class LongRangeFacetCounts extends RangeFacetCounts {
   }
 
   /**
+   * Create {@code LongRangeFacetCounts}, using the provided {@link MultiLongValuesSource} if
+   * non-null. If {@code valuesSource} is null, doc values from the provided {@code field} will be
+   * used.
+   */
+  public LongRangeFacetCounts(
+      String field, MultiLongValuesSource valuesSource, FacetsCollector hits, LongRange... ranges)
+      throws IOException {
+    this(field, valuesSource, hits, null, ranges);
+  }
+
+  /**
    * Create {@code LongRangeFacetCounts}, using the provided {@link LongValuesSource} if non-null.
    * If {@code valueSource} is null, doc values from the provided {@code field} will be used. Use
    * the provided {@code Query} as a fastmatch: only documents passing the filter are checked for
@@ -83,12 +96,35 @@ public class LongRangeFacetCounts extends RangeFacetCounts {
   }
 
   /**
-   * Counts from the provided valueSource.
-   *
-   * <p>TODO: Seems like we could extract this into RangeFacetCounts and make the logic common
-   * between this class and DoubleRangeFacetCounts somehow. The blocker right now is that this
-   * implementation expects LongValueSource and DoubleRangeFacetCounts expects DoubleValueSource.
+   * Create {@code LongRangeFacetCounts}, using the provided {@link MultiLongValuesSource} if
+   * non-null. If {@code valuesSource} is null, doc values from the provided {@code field} will be
+   * used. Use the provided {@code Query} as a fastmatch: only documents passing the filter are
+   * checked for the matching ranges, which is helpful when the provided {@link LongValuesSource} is
+   * costly per-document, such as a geo distance.
    */
+  public LongRangeFacetCounts(
+      String field,
+      MultiLongValuesSource valuesSource,
+      FacetsCollector hits,
+      Query fastMatchQuery,
+      LongRange... ranges)
+      throws IOException {
+    super(field, ranges, fastMatchQuery);
+    // use the provided valueSource if non-null, otherwise use the doc values associated with the
+    // field
+    if (valuesSource != null) {
+      LongValuesSource singleValues = MultiLongValuesSource.unwrapSingleton(valuesSource);
+      if (singleValues != null) {
+        count(singleValues, hits.getMatchingDocs());
+      } else {
+        count(valuesSource, hits.getMatchingDocs());
+      }
+    } else {
+      count(field, hits.getMatchingDocs());
+    }
+  }
+
+  /** Counts from the provided valueSource. */
   private void count(LongValuesSource valueSource, List<MatchingDocs> matchingDocs)
       throws IOException {
 
@@ -123,6 +159,54 @@ public class LongRangeFacetCounts extends RangeFacetCounts {
     totCount -= missingCount;
   }
 
+  /** Counts from the provided valueSource. */
+  private void count(MultiLongValuesSource valueSource, List<MatchingDocs> matchingDocs)
+      throws IOException {
+
+    LongRange[] ranges = getLongRanges();
+
+    LongRangeCounter counter = LongRangeCounter.create(ranges, counts);
+
+    for (MatchingDocs hits : matchingDocs) {
+      MultiLongValues multiValues = valueSource.getValues(hits.context);
+
+      final DocIdSetIterator it = createIterator(hits);
+      if (it == null) {
+        continue;
+      }
+
+      for (int doc = it.nextDoc(); doc != DocIdSetIterator.NO_MORE_DOCS; ) {
+        // Skip missing docs:
+        if (multiValues.advanceExact(doc)) {
+          long limit = multiValues.getValueCount();
+          // optimize single-valued case:
+          if (limit == 1) {
+            counter.addSingleValued(multiValues.nextValue());
+            totCount++;
+          } else {
+            counter.startMultiValuedDoc();
+            long previous = 0;
+            for (int i = 0; i < limit; i++) {
+              long val = multiValues.nextValue();
+              if (i == 0 || val != previous) {
+                counter.addMultiValued(val);
+                previous = val;
+              }
+            }
+            if (counter.endMultiValuedDoc()) {
+              totCount++;
+            }
+          }
+        }
+
+        doc = it.nextDoc();
+      }
+    }
+
+    int missingCount = counter.finish();
+    totCount -= missingCount;
+  }
+
   @Override
   protected LongRange[] getLongRanges() {
     return (LongRange[]) this.ranges;
diff --git a/lucene/facet/src/test/org/apache/lucene/facet/MultiValuesSourceTestCase.java b/lucene/facet/src/test/org/apache/lucene/facet/MultiValuesSourceTestCase.java
new file mode 100644
index 0000000..6ebe232
--- /dev/null
+++ b/lucene/facet/src/test/org/apache/lucene/facet/MultiValuesSourceTestCase.java
@@ -0,0 +1,199 @@
+/*
+ * 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.lucene.facet;
+
+import com.carrotsearch.randomizedtesting.generators.RandomNumbers;
+import java.io.IOException;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.DoubleDocValuesField;
+import org.apache.lucene.document.FloatDocValuesField;
+import org.apache.lucene.document.NumericDocValuesField;
+import org.apache.lucene.document.SortedNumericDocValuesField;
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.index.NumericDocValues;
+import org.apache.lucene.index.SortedNumericDocValues;
+import org.apache.lucene.search.DoubleValues;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.LongValues;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.tests.index.RandomIndexWriter;
+import org.apache.lucene.tests.util.LuceneTestCase;
+
+public abstract class MultiValuesSourceTestCase extends LuceneTestCase {
+  protected Directory dir;
+  protected IndexReader reader;
+  protected IndexSearcher searcher;
+
+  @Override
+  public void setUp() throws Exception {
+    super.setUp();
+
+    dir = newDirectory();
+    RandomIndexWriter iw = new RandomIndexWriter(random(), dir);
+
+    int numDocs = RandomNumbers.randomIntBetween(random(), 100, 1000);
+    for (int i = 0; i < numDocs; i++) {
+      Document doc = new Document();
+
+      if (random().nextInt(10) < 8) {
+        doc.add(new NumericDocValuesField("single_int", random().nextInt()));
+      }
+      if (random().nextInt(10) < 8) {
+        doc.add(new NumericDocValuesField("single_long", random().nextLong()));
+      }
+      if (random().nextInt(10) < 8) {
+        doc.add(new FloatDocValuesField("single_float", random().nextFloat()));
+      }
+      if (random().nextInt(10) < 8) {
+        doc.add(new DoubleDocValuesField("single_double", random().nextDouble()));
+      }
+
+      int limit = RandomNumbers.randomIntBetween(random(), 0, 100);
+      for (int j = 0; j < limit; j++) {
+        doc.add(new SortedNumericDocValuesField("multi_int", random().nextInt()));
+      }
+      limit = RandomNumbers.randomIntBetween(random(), 0, 100);
+      for (int j = 0; j < limit; j++) {
+        doc.add(new SortedNumericDocValuesField("multi_long", random().nextLong()));
+      }
+      limit = RandomNumbers.randomIntBetween(random(), 0, 100);
+      for (int j = 0; j < limit; j++) {
+        doc.add(
+            new SortedNumericDocValuesField(
+                "multi_float", Float.floatToRawIntBits(random().nextFloat())));
+      }
+      limit = RandomNumbers.randomIntBetween(random(), 0, 100);
+      for (int j = 0; j < limit; j++) {
+        doc.add(
+            new SortedNumericDocValuesField(
+                "multi_double", Double.doubleToRawLongBits(random().nextDouble())));
+      }
+
+      iw.addDocument(doc);
+
+      if (i % 100 == 0 && random().nextBoolean()) {
+        iw.commit();
+      }
+    }
+
+    reader = iw.getReader();
+    iw.close();
+    searcher = newSearcher(reader);
+  }
+
+  @Override
+  public void tearDown() throws Exception {
+    reader.close();
+    dir.close();
+    super.tearDown();
+  }
+
+  protected void validateFieldBasedSource(NumericDocValues docValues, LongValues values, int maxDoc)
+      throws IOException {
+    for (int doc = 0; doc < maxDoc; doc++) {
+      boolean hasValues = docValues.advanceExact(doc);
+      assertEquals(hasValues, values.advanceExact(doc));
+      if (hasValues) {
+        assertEquals(docValues.longValue(), values.longValue());
+      }
+    }
+  }
+
+  protected void validateFieldBasedSource(
+      SortedNumericDocValues docValues, MultiLongValues values, int maxDoc) throws IOException {
+    for (int doc = 0; doc < maxDoc; doc++) {
+      boolean hasValues = docValues.advanceExact(doc);
+      assertEquals(hasValues, values.advanceExact(doc));
+      if (hasValues) {
+        int valueCount = docValues.docValueCount();
+        assertEquals(valueCount, values.getValueCount());
+        for (int i = 0; i < valueCount; i++) {
+          assertEquals(docValues.nextValue(), values.nextValue());
+        }
+      }
+    }
+  }
+
+  protected void validateFieldBasedSource(
+      NumericDocValues docValues, DoubleValues values, int maxDoc) throws IOException {
+    for (int doc = 0; doc < maxDoc; doc++) {
+      boolean hasValues = docValues.advanceExact(doc);
+      assertEquals(hasValues, values.advanceExact(doc));
+      if (hasValues) {
+        assertEquals((double) docValues.longValue(), values.doubleValue(), 0.00001);
+      }
+    }
+  }
+
+  protected void validateFieldBasedSource(
+      SortedNumericDocValues docValues, MultiDoubleValues values, int maxDoc) throws IOException {
+    for (int doc = 0; doc < maxDoc; doc++) {
+      boolean hasValues = docValues.advanceExact(doc);
+      assertEquals(hasValues, values.advanceExact(doc));
+      if (hasValues) {
+        int valueCount = docValues.docValueCount();
+        assertEquals(valueCount, values.getValueCount());
+        for (int i = 0; i < valueCount; i++) {
+          assertEquals((double) docValues.nextValue(), values.nextValue(), 0.00001);
+        }
+      }
+    }
+  }
+
+  protected void validateFieldBasedSource(
+      NumericDocValues docValues, DoubleValues values, int maxDoc, boolean useDoublePrecision)
+      throws IOException {
+    for (int doc = 0; doc < maxDoc; doc++) {
+      boolean hasValues = docValues.advanceExact(doc);
+      assertEquals(hasValues, values.advanceExact(doc));
+      if (hasValues) {
+        if (useDoublePrecision) {
+          long asLong = Double.doubleToRawLongBits(values.doubleValue());
+          assertEquals(docValues.longValue(), asLong);
+        } else {
+          int asInt = Float.floatToRawIntBits((float) values.doubleValue());
+          assertEquals((int) docValues.longValue(), asInt);
+        }
+      }
+    }
+  }
+
+  protected void validateFieldBasedSource(
+      SortedNumericDocValues docValues,
+      MultiDoubleValues values,
+      int maxDoc,
+      boolean useDoublePrecision)
+      throws IOException {
+    for (int doc = 0; doc < maxDoc; doc++) {
+      boolean hasValues = docValues.advanceExact(doc);
+      assertEquals(hasValues, values.advanceExact(doc));
+      if (hasValues) {
+        int valueCount = docValues.docValueCount();
+        assertEquals(valueCount, values.getValueCount());
+        for (int i = 0; i < valueCount; i++) {
+          if (useDoublePrecision) {
+            long asLong = Double.doubleToRawLongBits(values.nextValue());
+            assertEquals(docValues.nextValue(), asLong);
+          } else {
+            int asInt = Float.floatToRawIntBits((float) values.nextValue());
+            assertEquals(docValues.nextValue(), asInt);
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/lucene/facet/src/test/org/apache/lucene/facet/TestLongValueFacetCounts.java b/lucene/facet/src/test/org/apache/lucene/facet/TestLongValueFacetCounts.java
index af1b74f..b61da63 100644
--- a/lucene/facet/src/test/org/apache/lucene/facet/TestLongValueFacetCounts.java
+++ b/lucene/facet/src/test/org/apache/lucene/facet/TestLongValueFacetCounts.java
@@ -236,8 +236,19 @@ public class TestLongValueFacetCounts extends LuceneTestCase {
           if (VERBOSE) {
             System.out.println("  use value source");
           }
-          facetCounts =
-              new LongValueFacetCounts("field", LongValuesSource.fromLongField("field"), fc);
+          if (random().nextBoolean()) {
+            facetCounts =
+                new LongValueFacetCounts("field", LongValuesSource.fromLongField("field"), fc);
+          } else if (random().nextBoolean()) {
+            facetCounts =
+                new LongValueFacetCounts("field", MultiLongValuesSource.fromLongField("field"), fc);
+          } else {
+            facetCounts =
+                new LongValueFacetCounts(
+                    "field",
+                    MultiLongValuesSource.fromSingleValued(LongValuesSource.fromLongField("field")),
+                    fc);
+          }
         } else {
           if (VERBOSE) {
             System.out.println("  use doc values");
@@ -250,8 +261,19 @@ public class TestLongValueFacetCounts extends LuceneTestCase {
           if (VERBOSE) {
             System.out.println("  count all value source");
           }
-          facetCounts =
-              new LongValueFacetCounts("field", LongValuesSource.fromLongField("field"), r);
+          if (random().nextBoolean()) {
+            facetCounts =
+                new LongValueFacetCounts("field", LongValuesSource.fromLongField("field"), r);
+          } else if (random().nextBoolean()) {
+            facetCounts =
+                new LongValueFacetCounts("field", MultiLongValuesSource.fromLongField("field"), r);
+          } else {
+            facetCounts =
+                new LongValueFacetCounts(
+                    "field",
+                    MultiLongValuesSource.fromSingleValued(LongValuesSource.fromLongField("field")),
+                    r);
+          }
         } else {
           if (VERBOSE) {
             System.out.println("  count all doc values");
@@ -320,8 +342,19 @@ public class TestLongValueFacetCounts extends LuceneTestCase {
         if (VERBOSE) {
           System.out.println("  use value source");
         }
-        facetCounts =
-            new LongValueFacetCounts("field", LongValuesSource.fromLongField("field"), fc);
+        if (random().nextBoolean()) {
+          facetCounts =
+              new LongValueFacetCounts("field", LongValuesSource.fromLongField("field"), fc);
+        } else if (random().nextBoolean()) {
+          facetCounts =
+              new LongValueFacetCounts("field", MultiLongValuesSource.fromLongField("field"), fc);
+        } else {
+          facetCounts =
+              new LongValueFacetCounts(
+                  "field",
+                  MultiLongValuesSource.fromSingleValued(LongValuesSource.fromLongField("field")),
+                  fc);
+        }
       }
 
       expected = new HashMap<>();
@@ -476,13 +509,23 @@ public class TestLongValueFacetCounts extends LuceneTestCase {
         if (VERBOSE) {
           System.out.println("  use doc values");
         }
-        facetCounts = new LongValueFacetCounts("field", fc);
+        if (random().nextBoolean()) {
+          facetCounts = new LongValueFacetCounts("field", fc);
+        } else {
+          facetCounts =
+              new LongValueFacetCounts("field", MultiLongValuesSource.fromLongField("field"), fc);
+        }
       } else {
         // optimized count all:
         if (VERBOSE) {
           System.out.println("  count all doc values");
         }
-        facetCounts = new LongValueFacetCounts("field", r);
+        if (random().nextBoolean()) {
+          facetCounts = new LongValueFacetCounts("field", r);
+        } else {
+          facetCounts =
+              new LongValueFacetCounts("field", MultiLongValuesSource.fromLongField("field"), r);
+        }
       }
 
       FacetResult actual = facetCounts.getAllChildrenSortByValue();
@@ -536,8 +579,12 @@ public class TestLongValueFacetCounts extends LuceneTestCase {
 
       fc = new FacetsCollector();
       s.search(IntPoint.newRangeQuery("id", minId, maxId), fc);
-      // cannot use value source here because we are multi valued
-      facetCounts = new LongValueFacetCounts("field", fc);
+      if (random().nextBoolean()) {
+        facetCounts = new LongValueFacetCounts("field", fc);
+      } else {
+        facetCounts =
+            new LongValueFacetCounts("field", MultiLongValuesSource.fromLongField("field"), fc);
+      }
 
       expected = new HashMap<>();
       expectedTotalCount = 0;
diff --git a/lucene/facet/src/test/org/apache/lucene/facet/TestMultiDoubleValuesSource.java b/lucene/facet/src/test/org/apache/lucene/facet/TestMultiDoubleValuesSource.java
new file mode 100644
index 0000000..e7ccaaf
--- /dev/null
+++ b/lucene/facet/src/test/org/apache/lucene/facet/TestMultiDoubleValuesSource.java
@@ -0,0 +1,169 @@
+/*
+ * 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.lucene.facet;
+
+import org.apache.lucene.index.DocValues;
+import org.apache.lucene.index.LeafReaderContext;
+import org.apache.lucene.index.NumericDocValues;
+import org.apache.lucene.index.SortedNumericDocValues;
+import org.apache.lucene.search.DoubleValues;
+import org.apache.lucene.search.DoubleValuesSource;
+
+public class TestMultiDoubleValuesSource extends MultiValuesSourceTestCase {
+
+  public void testRandom() throws Exception {
+    MultiDoubleValuesSource valuesSource;
+
+    valuesSource = MultiDoubleValuesSource.fromIntField("single_int");
+    assertNotNull(valuesSource);
+    for (LeafReaderContext ctx : reader.leaves()) {
+      SortedNumericDocValues docValues = DocValues.getSortedNumeric(ctx.reader(), "single_int");
+      MultiDoubleValues values = valuesSource.getValues(ctx);
+      validateFieldBasedSource(docValues, values, ctx.reader().maxDoc());
+    }
+
+    valuesSource = MultiDoubleValuesSource.fromLongField("single_long");
+    assertNotNull(valuesSource);
+    for (LeafReaderContext ctx : reader.leaves()) {
+      SortedNumericDocValues docValues = DocValues.getSortedNumeric(ctx.reader(), "single_long");
+      MultiDoubleValues values = valuesSource.getValues(ctx);
+      validateFieldBasedSource(docValues, values, ctx.reader().maxDoc());
+    }
+
+    valuesSource = MultiDoubleValuesSource.fromIntField("multi_int");
+    for (LeafReaderContext ctx : reader.leaves()) {
+      SortedNumericDocValues docValues = DocValues.getSortedNumeric(ctx.reader(), "multi_int");
+      MultiDoubleValues values = valuesSource.getValues(ctx);
+      validateFieldBasedSource(docValues, values, ctx.reader().maxDoc());
+    }
+
+    valuesSource = MultiDoubleValuesSource.fromLongField("multi_long");
+    for (LeafReaderContext ctx : reader.leaves()) {
+      SortedNumericDocValues docValues = DocValues.getSortedNumeric(ctx.reader(), "multi_long");
+      MultiDoubleValues values = valuesSource.getValues(ctx);
+      validateFieldBasedSource(docValues, values, ctx.reader().maxDoc());
+    }
+
+    valuesSource = MultiDoubleValuesSource.fromFloatField("single_float");
+    assertNotNull(valuesSource);
+    for (LeafReaderContext ctx : reader.leaves()) {
+      SortedNumericDocValues docValues = DocValues.getSortedNumeric(ctx.reader(), "single_float");
+      MultiDoubleValues values = valuesSource.getValues(ctx);
+      validateFieldBasedSource(docValues, values, ctx.reader().maxDoc(), false);
+    }
+
+    valuesSource = MultiDoubleValuesSource.fromDoubleField("single_double");
+    assertNotNull(valuesSource);
+    for (LeafReaderContext ctx : reader.leaves()) {
+      SortedNumericDocValues docValues = DocValues.getSortedNumeric(ctx.reader(), "single_double");
+      MultiDoubleValues values = valuesSource.getValues(ctx);
+      validateFieldBasedSource(docValues, values, ctx.reader().maxDoc(), true);
+    }
+
+    valuesSource = MultiDoubleValuesSource.fromFloatField("multi_float");
+    for (LeafReaderContext ctx : reader.leaves()) {
+      SortedNumericDocValues docValues = DocValues.getSortedNumeric(ctx.reader(), "multi_float");
+      MultiDoubleValues values = valuesSource.getValues(ctx);
+      validateFieldBasedSource(docValues, values, ctx.reader().maxDoc(), false);
+    }
+
+    valuesSource = MultiDoubleValuesSource.fromDoubleField("multi_double");
+    for (LeafReaderContext ctx : reader.leaves()) {
+      SortedNumericDocValues docValues = DocValues.getSortedNumeric(ctx.reader(), "multi_double");
+      MultiDoubleValues values = valuesSource.getValues(ctx);
+      validateFieldBasedSource(docValues, values, ctx.reader().maxDoc(), true);
+    }
+  }
+
+  public void testFromSingleValued() throws Exception {
+    MultiDoubleValuesSource valuesSource;
+    DoubleValuesSource singleton;
+
+    valuesSource =
+        MultiDoubleValuesSource.fromSingleValued(DoubleValuesSource.fromFloatField("single_float"));
+    singleton = MultiDoubleValuesSource.unwrapSingleton(valuesSource);
+    assertNotNull(singleton);
+    for (LeafReaderContext ctx : reader.leaves()) {
+      SortedNumericDocValues docValues = DocValues.getSortedNumeric(ctx.reader(), "single_float");
+      MultiDoubleValues values = valuesSource.getValues(ctx);
+      validateFieldBasedSource(docValues, values, ctx.reader().maxDoc(), false);
+
+      NumericDocValues singletonDv = DocValues.getNumeric(ctx.reader(), "single_float");
+      DoubleValues singletonVals = singleton.getValues(ctx, null);
+      validateFieldBasedSource(singletonDv, singletonVals, ctx.reader().maxDoc(), false);
+    }
+
+    valuesSource =
+        MultiDoubleValuesSource.fromSingleValued(
+            DoubleValuesSource.fromDoubleField("single_double"));
+    singleton = MultiDoubleValuesSource.unwrapSingleton(valuesSource);
+    assertNotNull(singleton);
+    for (LeafReaderContext ctx : reader.leaves()) {
+      SortedNumericDocValues docValues = DocValues.getSortedNumeric(ctx.reader(), "single_double");
+      MultiDoubleValues values = valuesSource.getValues(ctx);
+      validateFieldBasedSource(docValues, values, ctx.reader().maxDoc(), true);
+
+      NumericDocValues singletonDv = DocValues.getNumeric(ctx.reader(), "single_double");
+      DoubleValues singletonVals = singleton.getValues(ctx, null);
+      validateFieldBasedSource(singletonDv, singletonVals, ctx.reader().maxDoc(), true);
+    }
+  }
+
+  public void testCacheable() {
+    MultiDoubleValuesSource valuesSource = MultiDoubleValuesSource.fromDoubleField("multi_double");
+    for (LeafReaderContext ctx : searcher.getIndexReader().leaves()) {
+      assertEquals(DocValues.isCacheable(ctx, "multi_double"), valuesSource.isCacheable(ctx));
+    }
+  }
+
+  public void testEqualsAndHashcode() {
+    MultiDoubleValuesSource valuesSource1 = MultiDoubleValuesSource.fromLongField("multi_long");
+    MultiDoubleValuesSource valuesSource2 = MultiDoubleValuesSource.fromLongField("multi_long");
+    MultiDoubleValuesSource valuesSource3 = MultiDoubleValuesSource.fromLongField("multi_int");
+    assertEquals(valuesSource1, valuesSource2);
+    assertNotEquals(valuesSource1, valuesSource3);
+    assertEquals(valuesSource1.hashCode(), valuesSource2.hashCode());
+    assertNotEquals(valuesSource1.hashCode(), valuesSource3.hashCode());
+
+    valuesSource1 =
+        MultiDoubleValuesSource.fromSingleValued(DoubleValuesSource.fromLongField("single_long"));
+    valuesSource2 =
+        MultiDoubleValuesSource.fromSingleValued(DoubleValuesSource.fromLongField("single_long"));
+    valuesSource3 =
+        MultiDoubleValuesSource.fromSingleValued(DoubleValuesSource.fromLongField("single_int"));
+    assertEquals(valuesSource1, valuesSource2);
+    assertNotEquals(valuesSource1, valuesSource3);
+    assertEquals(valuesSource1.hashCode(), valuesSource2.hashCode());
+    assertNotEquals(valuesSource1.hashCode(), valuesSource3.hashCode());
+
+    DoubleValuesSource singleton1 = MultiDoubleValuesSource.unwrapSingleton(valuesSource1);
+    DoubleValuesSource singleton2 = MultiDoubleValuesSource.unwrapSingleton(valuesSource2);
+    DoubleValuesSource singleton3 = MultiDoubleValuesSource.unwrapSingleton(valuesSource3);
+    assertEquals(singleton1, singleton2);
+    assertNotEquals(singleton1, singleton3);
+    assertEquals(singleton1.hashCode(), singleton2.hashCode());
+    assertNotEquals(singleton1.hashCode(), singleton3.hashCode());
+
+    valuesSource1 = MultiDoubleValuesSource.fromField("single_long", Long::valueOf);
+    valuesSource2 = MultiDoubleValuesSource.fromField("single_long", v -> -1 * v);
+    valuesSource3 = MultiDoubleValuesSource.fromField("single_int", Long::valueOf);
+    assertNotEquals(valuesSource1, valuesSource2);
+    assertNotEquals(valuesSource1, valuesSource3);
+    assertNotEquals(valuesSource1.hashCode(), valuesSource2.hashCode());
+    assertNotEquals(valuesSource1.hashCode(), valuesSource3.hashCode());
+  }
+}
diff --git a/lucene/facet/src/test/org/apache/lucene/facet/TestMultiLongValuesSource.java b/lucene/facet/src/test/org/apache/lucene/facet/TestMultiLongValuesSource.java
new file mode 100644
index 0000000..c73e7d4
--- /dev/null
+++ b/lucene/facet/src/test/org/apache/lucene/facet/TestMultiLongValuesSource.java
@@ -0,0 +1,148 @@
+/*
+ * 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.lucene.facet;
+
+import org.apache.lucene.index.DocValues;
+import org.apache.lucene.index.LeafReaderContext;
+import org.apache.lucene.index.NumericDocValues;
+import org.apache.lucene.index.SortedNumericDocValues;
+import org.apache.lucene.search.LongValues;
+import org.apache.lucene.search.LongValuesSource;
+
+public class TestMultiLongValuesSource extends MultiValuesSourceTestCase {
+
+  public void testRandom() throws Exception {
+    MultiLongValuesSource valuesSource;
+
+    valuesSource = MultiLongValuesSource.fromIntField("single_int");
+    assertNotNull(valuesSource);
+    for (LeafReaderContext ctx : reader.leaves()) {
+      SortedNumericDocValues docValues = DocValues.getSortedNumeric(ctx.reader(), "single_int");
+      MultiLongValues values = valuesSource.getValues(ctx);
+      validateFieldBasedSource(docValues, values, ctx.reader().maxDoc());
+    }
+
+    valuesSource = MultiLongValuesSource.fromLongField("single_long");
+    assertNotNull(valuesSource);
+    for (LeafReaderContext ctx : reader.leaves()) {
+      SortedNumericDocValues docValues = DocValues.getSortedNumeric(ctx.reader(), "single_long");
+      MultiLongValues values = valuesSource.getValues(ctx);
+      validateFieldBasedSource(docValues, values, ctx.reader().maxDoc());
+    }
+
+    valuesSource = MultiLongValuesSource.fromIntField("multi_int");
+    for (LeafReaderContext ctx : reader.leaves()) {
+      SortedNumericDocValues docValues = DocValues.getSortedNumeric(ctx.reader(), "multi_int");
+      MultiLongValues values = valuesSource.getValues(ctx);
+      validateFieldBasedSource(docValues, values, ctx.reader().maxDoc());
+    }
+
+    valuesSource = MultiLongValuesSource.fromLongField("multi_long");
+    for (LeafReaderContext ctx : reader.leaves()) {
+      SortedNumericDocValues docValues = DocValues.getSortedNumeric(ctx.reader(), "multi_long");
+      MultiLongValues values = valuesSource.getValues(ctx);
+      validateFieldBasedSource(docValues, values, ctx.reader().maxDoc());
+    }
+  }
+
+  public void testFromSingleValued() throws Exception {
+    MultiLongValuesSource valuesSource;
+    LongValuesSource singleton;
+
+    valuesSource =
+        MultiLongValuesSource.fromSingleValued(LongValuesSource.fromIntField("single_int"));
+    singleton = MultiLongValuesSource.unwrapSingleton(valuesSource);
+    assertNotNull(singleton);
+    for (LeafReaderContext ctx : reader.leaves()) {
+      SortedNumericDocValues docValues = DocValues.getSortedNumeric(ctx.reader(), "single_int");
+      MultiLongValues values = valuesSource.getValues(ctx);
+      validateFieldBasedSource(docValues, values, ctx.reader().maxDoc());
+
+      NumericDocValues singletonDv = DocValues.getNumeric(ctx.reader(), "single_int");
+      LongValues singletonVals = singleton.getValues(ctx, null);
+      validateFieldBasedSource(singletonDv, singletonVals, ctx.reader().maxDoc());
+    }
+
+    valuesSource =
+        MultiLongValuesSource.fromSingleValued(LongValuesSource.fromLongField("single_long"));
+    singleton = MultiLongValuesSource.unwrapSingleton(valuesSource);
+    assertNotNull(singleton);
+    for (LeafReaderContext ctx : reader.leaves()) {
+      SortedNumericDocValues docValues = DocValues.getSortedNumeric(ctx.reader(), "single_long");
+      MultiLongValues values = valuesSource.getValues(ctx);
+      validateFieldBasedSource(docValues, values, ctx.reader().maxDoc());
+
+      NumericDocValues singletonDv = DocValues.getNumeric(ctx.reader(), "single_long");
+      LongValues singletonVals = singleton.getValues(ctx, null);
+      validateFieldBasedSource(singletonDv, singletonVals, ctx.reader().maxDoc());
+    }
+  }
+
+  public void testToDouble() throws Exception {
+    MultiDoubleValuesSource valuesSource =
+        MultiLongValuesSource.fromLongField("multi_long").toMultiDoubleValuesSource();
+    for (LeafReaderContext ctx : reader.leaves()) {
+      SortedNumericDocValues docValues = DocValues.getSortedNumeric(ctx.reader(), "multi_long");
+      MultiDoubleValues values = valuesSource.getValues(ctx);
+      validateFieldBasedSource(docValues, values, ctx.reader().maxDoc());
+    }
+  }
+
+  public void testCacheable() {
+    MultiLongValuesSource valuesSource = MultiLongValuesSource.fromLongField("multi_long");
+    for (LeafReaderContext ctx : searcher.getIndexReader().leaves()) {
+      assertEquals(DocValues.isCacheable(ctx, "multi_long"), valuesSource.isCacheable(ctx));
+    }
+  }
+
+  public void testEqualsAndHashcode() {
+    MultiLongValuesSource valuesSource1 = MultiLongValuesSource.fromLongField("multi_long");
+    MultiLongValuesSource valuesSource2 = MultiLongValuesSource.fromLongField("multi_long");
+    MultiLongValuesSource valuesSource3 = MultiLongValuesSource.fromLongField("multi_int");
+    assertEquals(valuesSource1, valuesSource2);
+    assertNotEquals(valuesSource1, valuesSource3);
+    assertEquals(valuesSource1.hashCode(), valuesSource2.hashCode());
+    assertNotEquals(valuesSource1.hashCode(), valuesSource3.hashCode());
+
+    valuesSource1 =
+        MultiLongValuesSource.fromSingleValued(LongValuesSource.fromLongField("single_long"));
+    valuesSource2 =
+        MultiLongValuesSource.fromSingleValued(LongValuesSource.fromLongField("single_long"));
+    valuesSource3 =
+        MultiLongValuesSource.fromSingleValued(LongValuesSource.fromLongField("single_int"));
+    assertEquals(valuesSource1, valuesSource2);
+    assertNotEquals(valuesSource1, valuesSource3);
+    assertEquals(valuesSource1.hashCode(), valuesSource2.hashCode());
+    assertNotEquals(valuesSource1.hashCode(), valuesSource3.hashCode());
+
+    LongValuesSource singleton1 = MultiLongValuesSource.unwrapSingleton(valuesSource1);
+    LongValuesSource singleton2 = MultiLongValuesSource.unwrapSingleton(valuesSource2);
+    LongValuesSource singleton3 = MultiLongValuesSource.unwrapSingleton(valuesSource3);
+    assertEquals(singleton1, singleton2);
+    assertNotEquals(singleton1, singleton3);
+    assertEquals(singleton1.hashCode(), singleton2.hashCode());
+    assertNotEquals(singleton1.hashCode(), singleton3.hashCode());
+
+    MultiDoubleValuesSource doubleValuesSource1 = valuesSource1.toMultiDoubleValuesSource();
+    MultiDoubleValuesSource doubleValuesSource2 = valuesSource2.toMultiDoubleValuesSource();
+    MultiDoubleValuesSource doubleValuesSource3 = valuesSource3.toMultiDoubleValuesSource();
+    assertEquals(doubleValuesSource1, doubleValuesSource2);
+    assertNotEquals(doubleValuesSource1, doubleValuesSource3);
+    assertEquals(doubleValuesSource1.hashCode(), doubleValuesSource2.hashCode());
+    assertNotEquals(doubleValuesSource1.hashCode(), doubleValuesSource3.hashCode());
+  }
+}
diff --git a/lucene/facet/src/test/org/apache/lucene/facet/range/TestRangeFacetCounts.java b/lucene/facet/src/test/org/apache/lucene/facet/range/TestRangeFacetCounts.java
index 1ca5007..758ca3c 100644
--- a/lucene/facet/src/test/org/apache/lucene/facet/range/TestRangeFacetCounts.java
+++ b/lucene/facet/src/test/org/apache/lucene/facet/range/TestRangeFacetCounts.java
@@ -38,7 +38,9 @@ import org.apache.lucene.facet.Facets;
 import org.apache.lucene.facet.FacetsCollector;
 import org.apache.lucene.facet.FacetsConfig;
 import org.apache.lucene.facet.LabelAndValue;
+import org.apache.lucene.facet.MultiDoubleValuesSource;
 import org.apache.lucene.facet.MultiFacets;
+import org.apache.lucene.facet.MultiLongValuesSource;
 import org.apache.lucene.facet.taxonomy.TaxonomyReader;
 import org.apache.lucene.facet.taxonomy.directory.DirectoryTaxonomyReader;
 import org.apache.lucene.facet.taxonomy.directory.DirectoryTaxonomyWriter;
@@ -766,28 +768,67 @@ public class TestRangeFacetCounts extends FacetTestCase {
       } else {
         fastMatchQuery = null;
       }
-      LongValuesSource vs = LongValuesSource.fromLongField("field");
-      Facets facets = new LongRangeFacetCounts("field", vs, sfc, fastMatchQuery, ranges);
-      FacetResult result = facets.getTopChildren(10, "field");
-      assertEquals(numRange, result.labelValues.length);
-      for (int rangeID = 0; rangeID < numRange; rangeID++) {
-        if (VERBOSE) {
-          System.out.println("  range " + rangeID + " expectedCount=" + expectedCounts[rangeID]);
-        }
-        LabelAndValue subNode = result.labelValues[rangeID];
-        assertEquals("r" + rangeID, subNode.label);
-        assertEquals(expectedCounts[rangeID], subNode.value.intValue());
-
-        LongRange range = ranges[rangeID];
 
-        // Test drill-down:
-        DrillDownQuery ddq = new DrillDownQuery(config);
+      if (random().nextBoolean()) {
+        LongValuesSource vs = LongValuesSource.fromLongField("field");
+        MultiLongValuesSource mvs = MultiLongValuesSource.fromLongField("field");
+        Facets facets;
         if (random().nextBoolean()) {
-          ddq.add("field", LongPoint.newRangeQuery("field", range.min, range.max));
+          facets = new LongRangeFacetCounts("field", vs, sfc, fastMatchQuery, ranges);
+        } else if (random().nextBoolean()) {
+          facets = new LongRangeFacetCounts("field", mvs, sfc, fastMatchQuery, ranges);
         } else {
-          ddq.add("field", range.getQuery(fastMatchQuery, vs));
+          facets =
+              new LongRangeFacetCounts(
+                  "field", MultiLongValuesSource.fromSingleValued(vs), sfc, fastMatchQuery, ranges);
+        }
+        FacetResult result = facets.getTopChildren(10, "field");
+        assertEquals(numRange, result.labelValues.length);
+        for (int rangeID = 0; rangeID < numRange; rangeID++) {
+          if (VERBOSE) {
+            System.out.println("  range " + rangeID + " expectedCount=" + expectedCounts[rangeID]);
+          }
+          LabelAndValue subNode = result.labelValues[rangeID];
+          assertEquals("r" + rangeID, subNode.label);
+          assertEquals(expectedCounts[rangeID], subNode.value.intValue());
+
+          LongRange range = ranges[rangeID];
+
+          // Test drill-down:
+          DrillDownQuery ddq = new DrillDownQuery(config);
+          if (random().nextBoolean()) {
+            ddq.add("field", LongPoint.newRangeQuery("field", range.min, range.max));
+          } else if (random().nextBoolean()) {
+            ddq.add("field", range.getQuery(fastMatchQuery, mvs));
+          } else {
+            ddq.add("field", range.getQuery(fastMatchQuery, vs));
+          }
+          assertEquals(expectedCounts[rangeID], s.count(ddq));
+        }
+      } else {
+        MultiLongValuesSource vs = MultiLongValuesSource.fromLongField("field");
+        Facets facets = new LongRangeFacetCounts("field", vs, sfc, fastMatchQuery, ranges);
+        FacetResult result = facets.getTopChildren(10, "field");
+        assertEquals(numRange, result.labelValues.length);
+        for (int rangeID = 0; rangeID < numRange; rangeID++) {
+          if (VERBOSE) {
+            System.out.println("  range " + rangeID + " expectedCount=" + expectedCounts[rangeID]);
+          }
+          LabelAndValue subNode = result.labelValues[rangeID];
+          assertEquals("r" + rangeID, subNode.label);
+          assertEquals(expectedCounts[rangeID], subNode.value.intValue());
+
+          LongRange range = ranges[rangeID];
+
+          // Test drill-down:
+          DrillDownQuery ddq = new DrillDownQuery(config);
+          if (random().nextBoolean()) {
+            ddq.add("field", LongPoint.newRangeQuery("field", range.min, range.max));
+          } else {
+            ddq.add("field", range.getQuery(fastMatchQuery, vs));
+          }
+          assertEquals(expectedCounts[rangeID], s.count(ddq));
         }
-        assertEquals(expectedCounts[rangeID], s.count(ddq));
       }
     }
 
@@ -924,7 +965,16 @@ public class TestRangeFacetCounts extends FacetTestCase {
       } else {
         fastMatchQuery = null;
       }
-      Facets facets = new LongRangeFacetCounts("field", null, sfc, fastMatchQuery, ranges);
+      Facets facets;
+      if (random().nextBoolean()) {
+        facets =
+            new LongRangeFacetCounts(
+                "field", MultiLongValuesSource.fromLongField("field"), sfc, fastMatchQuery, ranges);
+      } else {
+        facets =
+            new LongRangeFacetCounts(
+                "field", (MultiLongValuesSource) null, sfc, fastMatchQuery, ranges);
+      }
       FacetResult result = facets.getTopChildren(10, "field");
       assertEquals(numRange, result.labelValues.length);
       for (int rangeID = 0; rangeID < numRange; rangeID++) {
@@ -1069,7 +1119,21 @@ public class TestRangeFacetCounts extends FacetTestCase {
         fastMatchFilter = null;
       }
       DoubleValuesSource vs = DoubleValuesSource.fromDoubleField("field");
-      Facets facets = new DoubleRangeFacetCounts("field", vs, sfc, fastMatchFilter, ranges);
+      MultiDoubleValuesSource mvs = MultiDoubleValuesSource.fromDoubleField("field");
+      Facets facets;
+      if (random().nextBoolean()) {
+        facets = new DoubleRangeFacetCounts("field", vs, sfc, fastMatchFilter, ranges);
+      } else if (random().nextBoolean()) {
+        facets =
+            new DoubleRangeFacetCounts(
+                "field",
+                MultiDoubleValuesSource.fromSingleValued(vs),
+                sfc,
+                fastMatchFilter,
+                ranges);
+      } else {
+        facets = new DoubleRangeFacetCounts("field", mvs, sfc, fastMatchFilter, ranges);
+      }
       FacetResult result = facets.getTopChildren(10, "field");
       assertEquals(numRange, result.labelValues.length);
       for (int rangeID = 0; rangeID < numRange; rangeID++) {
@@ -1086,8 +1150,10 @@ public class TestRangeFacetCounts extends FacetTestCase {
         DrillDownQuery ddq = new DrillDownQuery(config);
         if (random().nextBoolean()) {
           ddq.add("field", DoublePoint.newRangeQuery("field", range.min, range.max));
-        } else {
+        } else if (random().nextBoolean()) {
           ddq.add("field", range.getQuery(fastMatchFilter, vs));
+        } else {
+          ddq.add("field", range.getQuery(fastMatchFilter, mvs));
         }
 
         assertEquals(expectedCounts[rangeID], s.count(ddq));
@@ -1222,7 +1288,20 @@ public class TestRangeFacetCounts extends FacetTestCase {
       } else {
         fastMatchFilter = null;
       }
-      Facets facets = new DoubleRangeFacetCounts("field", null, sfc, fastMatchFilter, ranges);
+      Facets facets;
+      if (random().nextBoolean()) {
+        facets =
+            new DoubleRangeFacetCounts(
+                "field",
+                MultiDoubleValuesSource.fromDoubleField("field"),
+                sfc,
+                fastMatchFilter,
+                ranges);
+      } else {
+        facets =
+            new DoubleRangeFacetCounts(
+                "field", (MultiDoubleValuesSource) null, sfc, fastMatchFilter, ranges);
+      }
       FacetResult result = facets.getTopChildren(10, "field");
       assertEquals(numRange, result.labelValues.length);
       for (int rangeID = 0; rangeID < numRange; rangeID++) {
@@ -1496,7 +1575,14 @@ public class TestRangeFacetCounts extends FacetTestCase {
       System.out.println("TEST: fastMatchFilter=" + fastMatchFilter);
     }
 
-    Facets facets = new DoubleRangeFacetCounts("field", vs, fc, fastMatchFilter, ranges);
+    Facets facets;
+    if (random().nextBoolean()) {
+      facets = new DoubleRangeFacetCounts("field", vs, fc, fastMatchFilter, ranges);
+    } else {
+      facets =
+          new DoubleRangeFacetCounts(
+              "field", MultiDoubleValuesSource.fromSingleValued(vs), fc, fastMatchFilter, ranges);
+    }
 
     assertEquals(
         "dim=field path=[] value=3 childCount=6\n  < 1 (0)\n  < 2 (1)\n  < 5 (3)\n  < 10 (3)\n  < 20 (3)\n  < 50 (3)\n",
@@ -1504,7 +1590,13 @@ public class TestRangeFacetCounts extends FacetTestCase {
     assertTrue(fastMatchFilter == null || filterWasUsed.get());
 
     DrillDownQuery ddq = new DrillDownQuery(config);
-    ddq.add("field", ranges[1].getQuery(fastMatchFilter, vs));
+    if (random().nextBoolean()) {
+      ddq.add("field", ranges[1].getQuery(fastMatchFilter, vs));
+    } else {
+      ddq.add(
+          "field",
+          ranges[1].getQuery(fastMatchFilter, MultiDoubleValuesSource.fromSingleValued(vs)));
+    }
 
     // Test simple drill-down:
     assertEquals(1, s.search(ddq, 10).totalHits.value);
@@ -1521,7 +1613,11 @@ public class TestRangeFacetCounts extends FacetTestCase {
               throws IOException {
             assert drillSideways.length == 1;
             return new DoubleRangeFacetCounts(
-                "field", vs, drillSideways[0], fastMatchFilter, ranges);
+                "field",
+                MultiDoubleValuesSource.fromSingleValued(vs),
+                drillSideways[0],
+                fastMatchFilter,
+                ranges);
           }
 
           @Override