Repository navigation
Description
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 10Neither 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.indexedFunctionRowLimitreturns -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
@ridcomparisons whose right side is a bound parameter or a globalLETvariable (RIDFilter.isPushablerequiresgetRightStatement() == 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.
- openCypher, small and self-contained: accept a
wherekey in the options ofdb.index.fulltext.queryNodesandqueryRelationships(FullTextQueryOptions.SUPPORTED_KEYS), as feat(#10685): db.index.vector.queryNodes takes an optional {where, filter} pre-filter (#10808) [skip ci] #10808 did fordb.index.vector.queryNodes. Resolve it withRecordPredicateRidSet.of(...)and pass the set to the full-text search as the filter above, solimitandskipapply 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. - SQL: two ways to let the planner keep the limit, with different trade-offs.
- A
whereoption onSEARCH_INDEX/SEARCH_FIELDS, asvector.neighborshas (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 andindexedFunctionRowLimitkeeps 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.SQLFunctionSearchIndexignores 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, aspushableRidConditionsdoes for@rid, and drop it from the residual. Existing queries benefit without being rewritten. The cost is a riskier planner change.
- A
- Later, optional: extend the Cypher 25
SEARCHclause beyondVECTOR INDEX(Cypher25Parser.g4:178-183), and look at stored values so a projection of those columns and$scorenever 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 nand a restrictive filter, the filter is decided before any record is loaded:readRecordstays within a small slack ofnand no rejected match is loaded. Today's lazy loop already stops oncenrows pass, so "bounded bynplus the rejected rows" would be met by it and is not the criterion (the repro'ssqlRestrictiveFilterLoadsNearlyEveryMatch, which reports 1,990 loads today). - A restrictive filter still returns
nrows in openCypher (cypherWhereOptionPreFiltersLikeTheVectorProcedure). EXPLAINshows 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
- Cross-type logical index names in SQL (SQL: resolve the logical name of a multi-label Cypher VECTOR/FULLTEXT index #11014).
- Changing
=andINsemantics on list properties (SQL=andINon a list property answer by ITEM over a BY ITEM index and by WHOLE VALUE without it #10784).
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
test(#11069): a condition on a per-record LET variable is not pushed …
fix(#11098, #11069, #11047): unbound SQL parameters refused, full-tex…
lvca commentedon Nov 1, 2026
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. WithLIMIT 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.EXPLAINshows the pre-filter.SEARCH_INDEX()andSEARCH_FIELDS()now reject a third argument, which was silently ignored before.Cypher: a
WHEREafterCALL db.index.fulltext.queryNodes(...)filters the rows the procedure already returned, which is how Neo4j works too, so it can return fewer rows thanlimit. To filter inside the search, use the newwhere(orfilter) option, like the vector procedures:❤️ 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.
fix(#11098, #11069, #11047): unbound SQL parameters refused, full-tex…