Skip to content

Fix FULL-upsert vector candidate generation - #19287

Open
xiangfu0 wants to merge 2 commits into
apache:masterfrom
xiangfu0:xiangfu0/codex/full-upsert-vector-candidates
Open

Fix FULL-upsert vector candidate generation#19287
xiangfu0 wants to merge 2 commits into
apache:masterfrom
xiangfu0:xiangfu0/codex/full-upsert-vector-candidates

Conversation

@xiangfu0

Copy link
Copy Markdown
Contributor

Summary

Fix FULL-upsert vector queries so obsolete physical versions cannot consume the per-segment top-K candidate budget before Pinot applies the query-scoped upsert snapshot.

  • Treat the upsert snapshot as a mandatory candidate-generation scope while retaining the final outer bitmap AND as defense in depth.
  • Keep required upsert/publication scopes separate from optimizer-selected metadata filters.
  • Use filtered ANN when the reader supports it; otherwise scan exact distances only over allowed document IDs and fail clearly when no safe path exists.
  • Apply the same allowed-document contract to no-index exact search, threshold search, and vector radius predicates.
  • Make mutable HNSW filter-aware with explicit Pinot document IDs, same-generation NRT lookup/filtering, immutable async inputs, and a captured publication boundary.
  • Preserve adaptive metadata behavior for ordinary non-upsert queries and avoid vector bitmap allocation for non-vector plans.
  • Add explain attributes for candidate-filter cardinality, execution mode, skipped generation, and fallback reason.

Root cause

FilterPlanNode constructed and executed VECTOR_SIMILARITY before adding SegmentContext.getDocIdsSnapshot() as an outer AND. Obsolete versions could therefore occupy ANN top-K slots and be removed only afterward, producing fewer than K rows or omitting nearer current rows.

Validation

All commands ran with JDK 25 and GITHUB_ACTIONS=true.

  • Core vector/filter suite: 101 tests passed.
  • Mutable and immutable HNSW suite: 16 tests passed.
  • VectorUpsertTableTest: 2 query-engine invocations passed, covering both a single consuming segment and sealed-plus-consuming segments.
  • Fresh affected-reactor clean test-compile with -Xlint:all passed.
  • Spotless, Checkstyle, license format/check, and git diff --check passed.

The integration test verifies six physical records/four current records, exact K and entity membership, scalar-distance equivalence, both query engines, two replicas, and a skipUpsert=true control proving obsolete rows are physically nearest.

Performance and compatibility

ANN approximation semantics are unchanged. Readers that cannot honor the mandatory bitmap use a correctness-first exact scan whose cost is proportional to the allowed-document count times vector dimension. Mutable HNSW currently opens an NRT reader per search; reusable reader management can be optimized separately.

@xiangfu0 xiangfu0 added bug Something is not working as expected upsert Related to upsert functionality vector Related to vector similarity search query Related to query processing index Related to indexing (general) data-integrity Related to correctness of data or query results ready-for-review PR is ready for maintainer review labels Aug 18, 2026
@codecov-commenter

codecov-commenter commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.57350% with 89 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.20%. Comparing base (b6cd43b) to head (6a12924).

Files with missing lines Patch % Lines
...local/realtime/impl/vector/MutableVectorIndex.java 68.57% 18 Missing and 4 partials ⚠️
...operator/filter/ExactVectorScanFilterOperator.java 81.72% 6 Missing and 11 partials ⚠️
...perator/filter/VectorSimilarityFilterOperator.java 87.68% 5 Missing and 12 partials ⚠️
...re/operator/filter/VectorRadiusFilterOperator.java 76.92% 13 Missing and 2 partials ⚠️
...ava/org/apache/pinot/core/plan/FilterPlanNode.java 84.84% 5 Missing and 10 partials ⚠️
...pinot/core/operator/filter/BaseFilterOperator.java 81.81% 1 Missing and 1 partial ⚠️
...not/core/operator/filter/VectorSearchStrategy.java 50.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19287      +/-   ##
============================================
+ Coverage     67.09%   67.20%   +0.11%     
  Complexity     1424     1424              
============================================
  Files          3459     3459              
  Lines        219789   220166     +377     
  Branches      35007    35110     +103     
============================================
+ Hits         147457   147962     +505     
+ Misses        60543    60377     -166     
- Partials      11789    11827      +38     
Flag Coverage Δ
integration 100.00% <ø> (ø)
integration1 100.00% <ø> (ø)
integration2 ?
java-25 67.20% <81.57%> (+0.11%) ⬆️
lane-a 100.00% <ø> (ø)
lane-b 0.00% <ø> (ø)
temurin 67.20% <81.57%> (+0.11%) ⬆️
unittests 67.20% <81.57%> (+0.11%) ⬆️
unittests1 57.85% <71.63%> (+0.12%) ⬆️
unittests2 39.16% <10.97%> (-0.03%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@xiangfu0
xiangfu0 requested review from Jackie-Jiang and deepthi912 and a balanced review from Copilot August 18, 2026 06:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Fixes FULL-upsert vector candidate generation so per-segment top-K selection cannot be “consumed” by obsolete physical versions before the query-scoped upsert snapshot is applied, with consistent behavior across ANN, exact-scan fallback, radius predicates, and mutable HNSW.

Changes:

  • Make upsert snapshot (and mutable publication boundary) a mandatory candidate-generation scope for vector predicates, with clear fallback/skip behavior and added explain attributes.
  • Add filter-aware behavior to mutable HNSW (Lucene) including explicit Pinot doc IDs and near-real-time reader usage.
  • Expand unit/integration coverage for upsert-scoped vector queries, fallback paths, and boolean-plan interactions.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/creator/HnswVectorIndexCreatorTest.java Adds a filtered-reader test to ensure top-K results respect an allowed-doc bitmap.
pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndexTest.java Adds tests for mutable HNSW Pinot-ID translation, filtered search semantics, NRT visibility, and async bitmap capture.
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndex.java Implements filter-aware mutable HNSW with explicit Pinot doc IDs, NRT reader search, and per-instance temp dirs.
pinot-segment-local/pom.xml Adds provided JetBrains annotations dependency.
pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/VectorUpsertTableTest.java New integration test validating FULL-upsert vector correctness across engine modes and segment states.
pinot-core/src/test/java/org/apache/pinot/core/plan/FilterPlanNodeTest.java Adds extensive plan-level tests for mandatory scopes, vector subtree safety, and fallback behavior.
pinot-core/src/test/java/org/apache/pinot/core/operator/filter/VectorSearchStrategyTest.java Updates strategy tests to allow pre-filter when the reader supports it (even for mutable segments).
pinot-core/src/test/java/org/apache/pinot/core/operator/filter/VectorRadiusFilterOperatorTest.java Adds tests for mandatory-scope exact allowed scans and skip behavior.
pinot-core/src/test/java/org/apache/pinot/core/operator/filter/FilterAwareVectorSearchTest.java Adds tests for required upsert scope behavior, bitmap intersections, explain attributes, and exact fallback.
pinot-core/src/test/java/org/apache/pinot/core/operator/filter/ExactVectorScanFilterOperatorTest.java Adds tests ensuring exact scan respects allowed-doc scope and intersections.
pinot-core/src/test/java/org/apache/pinot/core/operator/filter/BaseFilterOperatorTest.java Adds a test ensuring materialized boolean children aren’t scanned twice and accounting is preserved.
pinot-core/src/main/java/org/apache/pinot/core/plan/FilterPlanNode.java Introduces mandatory vector candidate scope plumbing, safe metadata materialization, and filtered/fallback routing.
pinot-core/src/main/java/org/apache/pinot/core/operator/filter/VectorSimilarityFilterOperator.java Adds mandatory-scope + metadata-scope intersection, exact-scan fallback, skip reasons, and explain attributes.
pinot-core/src/main/java/org/apache/pinot/core/operator/filter/VectorSearchStrategy.java Adjusts decision logic: pre-filter eligibility is capability-based (not mutability-based).
pinot-core/src/main/java/org/apache/pinot/core/operator/filter/VectorRadiusFilterOperator.java Enforces mandatory-scope exact scans for radius predicates; adds explain attributes + skip state.
pinot-core/src/main/java/org/apache/pinot/core/operator/filter/OrFilterOperator.java Switches boolean execution to reuse materialized bitmaps via getTruesForExecution().
pinot-core/src/main/java/org/apache/pinot/core/operator/filter/NotFilterOperator.java Switches false-set derivation to reuse cached materialization via getTruesForExecution().
pinot-core/src/main/java/org/apache/pinot/core/operator/filter/ExactVectorScanFilterOperator.java Adds mandatory scope restriction, metadata-scope intersection, and shared exact-scan scoring utility.
pinot-core/src/main/java/org/apache/pinot/core/operator/filter/BaseFilterOperator.java Introduces getTruesForExecution() to avoid re-scanning children after materialization.
pinot-core/src/main/java/org/apache/pinot/core/operator/filter/AndFilterOperator.java Switches boolean execution to reuse materialized bitmaps via getTruesForExecution().
Suppressed comments (1)

pinot-core/src/main/java/org/apache/pinot/core/plan/FilterPlanNode.java:1

  • retainedChildFilters and childFilterOperators can become misaligned when a child operator is empty. In the isResultEmpty() branch, you add the operator but do not add the corresponding childFilter to retainedChildFilters before breaking, so wirePreFilterForVectorOperators(...) can throw IndexOutOfBoundsException when it assumes parallel lists. Fix by keeping these lists in lockstep (e.g., add childFilter to retainedChildFilters whenever you add an operator, including the empty case), or by passing a single zipped structure instead of two separate lists.
/**

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@xiangfu0
xiangfu0 force-pushed the xiangfu0/codex/full-upsert-vector-candidates branch from 2c163f0 to 6a12924 Compare August 18, 2026 09:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 21 changed files in this pull request and generated no new comments.

Suppressed comments (6)

pinot-core/src/main/java/org/apache/pinot/core/operator/filter/ExactVectorScanFilterOperator.java:273

  • computeExactMatches() can NPE when topK == 0 (or behave incorrectly for non-positive K): the heap is created, but scoreDocument() will eventually call maxHeap.peek() on an empty heap. Since this method is now used as a correctness fallback for mandatory candidate scopes, it should defensively handle topK <= 0 by returning an empty bitmap when distanceThreshold == null (top‑K semantics), and avoid constructing/using the heap in that case.
  @SuppressWarnings({"rawtypes", "unchecked"})
  static ImmutableRoaringBitmap computeExactMatches(ForwardIndexReader<?> forwardIndexReader,
      float[] queryVector, int topK, int numDocs,
      VectorIndexConfig.VectorDistanceFunction distanceFunction, @Nullable Float distanceThreshold,
      @Nullable ImmutableRoaringBitmap allowedDocIds, String column) {
    if (allowedDocIds != null && allowedDocIds.isEmpty()) {
      return new MutableRoaringBitmap();
    }

    PriorityQueue<DocDistance> maxHeap = distanceThreshold == null
        ? new PriorityQueue<>(topK + 1, (a, b) -> Float.compare(b._distance, a._distance)) : null;

pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndex.java:159

  • This stores the Pinot doc id twice (DocValues + StoredField) and then loads a stored Document per hit to translate Lucene doc IDs back to Pinot doc IDs. For high QPS / larger topK, the stored-fields IO/object allocation can add noticeable overhead and the duplicated storage increases index size. Consider translating via NumericDocValues instead (using leaf contexts + advanceExact) so only DocValues are needed, or at least loading only the single stored field via a StoredFieldVisitor/field selector to avoid materializing full Document instances.
    // Store the supplied Pinot document ID in both forms. The numeric doc value drives filtered traversal, while the
    // stored field translates Lucene search hits back to Pinot IDs from the same near-real-time reader generation.
    docToIndex.add(new NumericDocValuesField(VECTOR_INDEX_DOC_ID_COLUMN_NAME, docId));
    docToIndex.add(new StoredField(VECTOR_INDEX_DOC_ID_COLUMN_NAME, docId));

pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndex.java:306

  • This stores the Pinot doc id twice (DocValues + StoredField) and then loads a stored Document per hit to translate Lucene doc IDs back to Pinot doc IDs. For high QPS / larger topK, the stored-fields IO/object allocation can add noticeable overhead and the duplicated storage increases index size. Consider translating via NumericDocValues instead (using leaf contexts + advanceExact) so only DocValues are needed, or at least loading only the single stored field via a StoredFieldVisitor/field selector to avoid materializing full Document instances.
  private static MutableRoaringBitmap translateTopDocs(DirectoryReader directoryReader, TopDocs topDocs)
      throws IOException {
    MutableRoaringBitmap docIds = new MutableRoaringBitmap();
    StoredFields storedFields = directoryReader.storedFields();
    for (ScoreDoc scoreDoc : topDocs.scoreDocs) {
      Document document = storedFields.document(scoreDoc.doc);
      IndexableField docIdField = document.getField(VECTOR_INDEX_DOC_ID_COLUMN_NAME);

pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndexTest.java:211

  • This test assumes the RealtimeLuceneTextIndexSearcherPool executor will serialize tasks such that submitting blocker will prevent the subsequent vector search from running until releaseSearcher is counted down. If the pool has a parallelism > 1, the search may execute on a different thread immediately, making the “queued search uses captured state” guarantee non-deterministic and potentially flaky. A more robust approach is to either (a) configure the pool to a single thread for this test, or (b) saturate the pool with enough blockers to cover its parallelism, or (c) introduce a deterministic hook/latch in the index/search path under test to control when execution begins.
  public void testFilteredSearchCopiesBitmapBeforeAsyncDispatch()
      throws Exception {
    MutableVectorIndex index = createEmptyIndex(createConfig(1, 3_600_000L));
    ExecutorService callerExecutor = Executors.newSingleThreadExecutor();
    CountDownLatch searcherBlocked = new CountDownLatch(1);
    CountDownLatch releaseSearcher = new CountDownLatch(1);
    Future<?> blocker = RealtimeLuceneTextIndexSearcherPool.getInstance().getExecutorService().submit(() -> {
      searcherBlocked.countDown();
      try {
        releaseSearcher.await();
      } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new RuntimeException(e);
      }
    });

pinot-core/src/main/java/org/apache/pinot/core/plan/FilterPlanNode.java:522

  • wirePreFilterForVectorOperators() relies on childFilters and childOperators being perfectly index-aligned. That coupling is subtle (especially because callers now pass a “retained” filter list after pruning match-all operators), and an accidental mismatch would surface as an IndexOutOfBoundsException at runtime. Adding a defensive precondition (e.g., size equality) at method entry will fail fast with a clearer message and reduce future maintenance risk.
  private void wirePreFilterForVectorOperators(List<FilterContext> childFilters,
      List<BaseFilterOperator> childOperators, int numDocs) {
    if (childOperators.size() < 2) {
      return;
    }

pinot-core/src/main/java/org/apache/pinot/core/plan/FilterPlanNode.java:534

  • wirePreFilterForVectorOperators() relies on childFilters and childOperators being perfectly index-aligned. That coupling is subtle (especially because callers now pass a “retained” filter list after pruning match-all operators), and an accidental mismatch would surface as an IndexOutOfBoundsException at runtime. Adding a defensive precondition (e.g., size equality) at method entry will fail fast with a clearer message and reduce future maintenance risk.
    for (int i = 0; i < childOperators.size(); i++) {
      FilterContext childFilter = childFilters.get(i);
      BaseFilterOperator op = childOperators.get(i);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something is not working as expected data-integrity Related to correctness of data or query results index Related to indexing (general) query Related to query processing ready-for-review PR is ready for maintainer review upsert Related to upsert functionality vector Related to vector similarity search

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants