Skip to content

Full-text search: a filter on another property is applied after the limit (openCypher) or loads nearly every match when it is restrictive (SQL) #11069

Closed

Description

@shaunpatterson

Follow-up of #9895, #9909 and #10022. The openCypher side is the full-text twin of #10685, which #10808 resolved for vector search.

Problem

A search across tenants, or any access-controlled search, combines a full-text match with a filter on another property:

SELECT @rid, $score FROM Doc
WHERE SEARCH_INDEX('Doc[content]', :q) = true
  AND tenant = :t
ORDER BY $score DESC LIMIT 10

Neither query lane can decide the filter inside the full-text search.

Reproduction

FilteredFullTextSearchReproTest (full source at the end, engine module, com.arcadedb.function.sql.fulltext). Corpus: 2,000 vertices that all match common. Tenant a owns 1,980 and scores highest. Tenant b owns 20 and scores lowest. Run with mvn -o -pl engine test -Dtest=FilteredFullTextSearchReproTest.

Measured on current main:

Query Result
SQL, tenant = 'a' (the best matches pass the filter), LIMIT 10 10 records loaded for 10 rows
SQL, tenant = 'b' (restrictive filter), LIMIT 10 1,990 records loaded for 10 rows
openCypher, queryNodes(..., {limit: 10}) YIELD node WHERE node.tenant = 'b' 0 rows, although 20 tenant-b documents match
openCypher, queryNodes(..., {limit: 10, where: {tenant: 'b'}}) unsupported option 'where'. Supported options: skip, limit

A permissive filter is already cheap, because the records are loaded lazily until the filter has let n rows through (#9895). The cost appears when the filter rejects the best matches, which is the common case for a tenant or access filter on a large shared corpus.

SQL: why

  • SelectExecutionPlanner.indexedFunctionRowLimit returns -1 when the WHERE holds any condition other than the function itself (SelectExecutionPlanner.java:4544-4546). Its javadoc says why: "a filter could reject a row the limit counted" (4521-4525).
  • With no limit the search scores and sorts every match, and the extra predicates are checked on each loaded record. The plan shows no (best n needed).
  • The only conditions decided before a record loads are @rid comparisons whose right side is a bound parameter or a global LET variable (RIDFilter.isPushable requires getRightStatement() == null, Push @rid range conditions into SEARCH_INDEX before records are loaded, so keyset pagination over a full-text result stops loading every match #9909).

openCypher: why

db.index.fulltext.queryNodes accepts only skip and limit (FullTextQueryOptions.SUPPORTED_KEYS). A WHERE after the CALL runs on the procedure's own top-limit rows, the same shape as #10685.

Workaround today and its cost

In SQL, materialize the allowed RIDs in a global LET and pass @rid IN $ok, over a BY ITEM index for a list property. This builds the whole allowed set first, so it is costly for a broad filter. It also depends on BY ITEM semantics (see #10784).

Proposal

The pieces exist but are not connected. FullTextSearch.search takes a RIDFilter and drops the matches it rejects before they are scored and before the limit picks the best (FullTextSearch.java:149-173). RIDFilter is final, has a private constructor and is built only from @rid conditions (RIDFilter.resolve), so it cannot take a candidate set from a property predicate today. RecordPredicateRidSet (function/sql/vector) already turns a where map into a candidate set of RIDs, listing candidates through an existing property index (listCandidates, #10921). The ask is to let the full-text search consume such a set: a RIDFilter factory that wraps a RID set, or a second filter parameter on FullTextSearch.search.

  1. openCypher, small and self-contained: accept a where key in the options of db.index.fulltext.queryNodes and queryRelationships (FullTextQueryOptions.SUPPORTED_KEYS), as feat(#10685): db.index.vector.queryNodes takes an optional {where, filter} pre-filter (#10808) [skip ci] #10808 did for db.index.vector.queryNodes. Resolve it with RecordPredicateRidSet.of(...) and pass the set to the full-text search as the filter above, so limit and skip apply to the filtered set. For full-text the candidate set is complete, so top-n after the filter is exact. Existing forms keep their behaviour.
  2. SQL: two ways to let the planner keep the limit, with different trade-offs.
    • A where option on SEARCH_INDEX / SEARCH_FIELDS, as vector.neighbors has (perf: vector.neighbors 'where' predicate so a property restriction does not build the set of every RID in scope #10169). If the user moves the predicate into the option, the residual WHERE is empty and indexedFunctionRowLimit keeps the limit. It mirrors an API maintainers already shipped. The cost is that the user must put the predicate in the option instead of the WHERE, and a predicate left in the WHERE still drops the limit. SQLFunctionSearchIndex ignores a third argument today (:133-141), so it needs real handling.
    • Planner pushdown: when a residual conjunct can be decided exactly by an existing index (an equality on an indexed property, with the listing marked exact), decide it in the fetch, as pushableRidConditions does for @rid, and drop it from the residual. Existing queries benefit without being rewritten. The cost is a riskier planner change.
  3. Later, optional: extend the Cypher 25 SEARCH clause beyond VECTOR INDEX (Cypher25Parser.g4:178-183), and look at stored values so a projection of those columns and $score never loads the record. Both are out of scope here.

Risks to check in review: the top-k of the filtered set must still equal the union of the per-bucket top-k, BM25 scores must be identical with and without the filter (statistics stay type-wide), and dropping a SQL conjunct is sound only when the index listing is exact (case-insensitive indexes, date truncation and BY ITEM need care, RecordPredicateRidSet.java:357).

If the SQL planner change is too large for one step, item 1 alone fixes the wrong answer in openCypher and avoids loading rejected records when the filtered property is indexed. The SQL pushdown can follow as its own issue.

Acceptance

  • With ORDER BY $score DESC LIMIT n and a restrictive filter, the filter is decided before any record is loaded: readRecord stays within a small slack of n and no rejected match is loaded. Today's lazy loop already stops once n rows pass, so "bounded by n plus the rejected rows" would be met by it and is not the criterion (the repro's sqlRestrictiveFilterLoadsNearlyEveryMatch, which reports 1,990 loads today).
  • A restrictive filter still returns n rows in openCypher (cypherWhereOptionPreFiltersLikeTheVectorProcedure).
  • EXPLAIN shows which predicates were decided before the load.
  • BM25 scores of the filtered rows equal their scores in the unfiltered search (statistics stay type-wide).

Not in scope

FilteredFullTextSearchReproTest.java
/*
 * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com)
 *
 * Licensed 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.
 *
 * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com)
 * SPDX-License-Identifier: Apache-2.0
 */
package com.arcadedb.function.sql.fulltext;

import com.arcadedb.TestHelper;
import com.arcadedb.database.DatabaseInternal;
import com.arcadedb.query.sql.executor.ResultSet;
import org.junit.jupiter.api.Test;

import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;

/**
 * Reproduction: a full-text search combined with a filter on another property.
 * <p>
 * Corpus: {@value #DOCUMENTS} vertices that all match the query {@code common}. Tenant {@code a} owns most of them and scores
 * high (short text, repeated term). Tenant {@code b} owns {@value #TENANT_B_DOCUMENTS} of them and they score LOWEST (one
 * occurrence in a long text), which is the worst case for a filter that rejects the best matches first.
 * <p>
 * Tests marked "DESIRED" fail today and describe the behaviour the issue asks for. The others pass today and pin the current
 * behaviour the issue describes.
 */
class FilteredFullTextSearchReproTest extends TestHelper {
  private static final int    DOCUMENTS           = 2_000;
  private static final int    TENANT_B_DOCUMENTS  = 20;
  private static final int    WANTED              = 10;
  /** Loads tolerated beyond WANTED: none are needed when the filter is decided before the load. */
  private static final int    SLACK               = 5;
  private static final String SEARCH              = "SEARCH_INDEX('Doc[content]', 'common') = true";

  @Test
  void sqlPermissiveFilterIsAlreadyCheap() {
    createCorpus();
    // Control: the best matches pass the filter, so the lazy load stops after about WANTED records. Not the problem.
    final long loaded = recordsLoadedBy("SELECT FROM Doc WHERE " + SEARCH + " AND tenant = 'a' LIMIT " + WANTED);
    assertThat(loaded).as("records loaded when the best matches pass the filter").isLessThanOrEqualTo(WANTED + SLACK);
  }

  @Test
  void sqlRestrictiveFilterLoadsNearlyEveryMatch() {
    createCorpus();
    final String query = "SELECT FROM Doc WHERE " + SEARCH + " AND tenant = 'b' LIMIT " + WANTED;

    final long loaded = recordsLoadedBy(query);

    // DESIRED: the filter is decided before any record is loaded, so every rejected match is skipped without a load and the
    // count stays close to WANTED. Today the lazy loop loads records until WANTED of them pass the filter, and the rejected
    // ones are loaded too (here about DOCUMENTS - TENANT_B_DOCUMENTS of them): the planner returns a row limit of -1
    // (SelectExecutionPlanner.indexedFunctionRowLimit) and the filter is checked on every loaded record.
    assertThat(loaded).as("records loaded for LIMIT " + WANTED + " of " + DOCUMENTS + " matches, " + TENANT_B_DOCUMENTS
        + " of them pass the filter; plan: " + plan(query).replace('\n', ' ')).isLessThanOrEqualTo(WANTED + SLACK);
  }

  @Test
  void cypherFilterAfterTheProcedureLimitReturnsTooFewRows() {
    createCorpus();
    // CURRENT BEHAVIOUR: the procedure takes its own top-WANTED, then WHERE runs on those rows only.
    final int rows = count("opencypher",
        "CALL db.index.fulltext.queryNodes('Doc[content]', 'common', {limit: " + WANTED + "}) YIELD node "
            + "WHERE node.tenant = 'b' RETURN node", Map.of());
    assertThat(rows).as("rows left after filtering the procedure's top " + WANTED + ", although " + TENANT_B_DOCUMENTS
        + " tenant-b documents match").isEqualTo(0);
  }

  @Test
  void cypherWhereOptionPreFiltersLikeTheVectorProcedure() {
    createCorpus();
    // DESIRED: same shape as db.index.vector.queryNodes(index, k, vector, {where: {...}}) (#10808)
    final int rows = count("opencypher",
        "CALL db.index.fulltext.queryNodes('Doc[content]', 'common', {limit: " + WANTED + ", where: {tenant: 'b'}}) YIELD node "
            + "RETURN node", Map.of());
    assertThat(rows).isEqualTo(WANTED);
  }

  private void createCorpus() {
    database.transaction(() -> {
      database.command("sql", "CREATE VERTEX TYPE Doc BUCKETS 1");
      database.command("sql", "CREATE PROPERTY Doc.content STRING");
      database.command("sql", "CREATE PROPERTY Doc.tenant STRING");
      database.command("sql", "CREATE INDEX ON Doc (content) FULL_TEXT METADATA {\"similarity\": \"BM25\"}");
      for (int i = 0; i < DOCUMENTS; i++) {
        final boolean tenantB = i % (DOCUMENTS / TENANT_B_DOCUMENTS) == 0;
        database.newVertex("Doc").set("tenant", tenantB ? "b" : "a")
            // tenant b: one occurrence in a long text (lowest BM25); tenant a: repeated term in a short text (highest)
            .set("content", tenantB ? "common " + "filler ".repeat(30) : "common common common common").save();
      }
    });
    // The first search computes the index's corpus statistics with a scan of the type, which counts as reads: not the subject
    count("sql", "SELECT FROM Doc WHERE " + SEARCH + " LIMIT 1", Map.of());
  }

  private long recordsLoadedBy(final String query) {
    final long before = readRecords();
    try (final ResultSet rs = database.query("sql", query)) {
      int rows = 0;
      while (rs.hasNext()) {
        rs.next();
        ++rows;
      }
      assertThat(rows).as(query).isEqualTo(WANTED);
    }
    return readRecords() - before;
  }

  private long readRecords() {
    return ((Number) ((DatabaseInternal) database).getStats().get("readRecord")).longValue();
  }

  private String plan(final String query) {
    try (final ResultSet rs = database.query("sql", query)) {
      return rs.getExecutionPlan().orElseThrow().prettyPrint(0, 2);
    }
  }

  private int count(final String language, final String query, final Map<String, Object> params) {
    int rows = 0;
    try (final ResultSet rs = database.query(language, query, params)) {
      while (rs.hasNext()) {
        rs.next();
        ++rows;
      }
    }
    return rows;
  }
}

Activity

  1. added this to the 26.11.1 milestone on Nov 1, 2026
  2. added
    in progressThe team is actively working on this issue
    on Nov 1, 2026
  3. self-assigned this
    on Nov 1, 2026
  4. added 2 commits that reference this issue on Nov 1, 2026
  5. lvca commented on Nov 1, 2026

    @lvca
    Member

    Fixed in #11111.

    SQL: a condition on another property next to SEARCH_INDEX() / SEARCH_FIELDS() is now checked against that property's regular index before the records are loaded. With LIMIT 10, the example in this issue now loads 10 records instead of ~2,000. The filtered property needs a regular index for this; without one the result is the same as before, just slower. EXPLAIN shows the pre-filter.

    SEARCH_INDEX() and SEARCH_FIELDS() now reject a third argument, which was silently ignored before.

    Cypher: a WHERE after CALL db.index.fulltext.queryNodes(...) filters the rows the procedure already returned, which is how Neo4j works too, so it can return fewer rows than limit. To filter inside the search, use the new where (or filter) option, like the vector procedures:

    CALL db.index.fulltext.queryNodes('Article[body]', 'database', {limit: 10, where: {category: 'tech'}})
    YIELD node, score RETURN node, score

    ❤️ Thanks for helping make ArcadeDB better! ArcadeDB is free and open source, sustained by its community. If it's useful to you or your company, please consider becoming a sponsor to keep development going.

  6. removed
    in progressThe team is actively working on this issue
    on Nov 1, 2026
  7. added a commit that references this issue on Nov 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

engineCore engine: document/schema APIenhancementNew feature or requestopencypherperformanceseverity:minorCosmetic, documentation, edge case, or a workaround existssql

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions