Skip to content

SEARCH_INDEX loads every matching record before LIMIT applies #9895

Closed

Description

@lvca

Problem

SELECT ... WHERE SEARCH_INDEX('idx', 'query') = true LIMIT 200 returns at most 200 rows, but it still matches, sorts and loads every matching record first. SQLFunctionSearchIndex.searchFromTarget(...) builds the full match map, copies it into a list, sorts it by score and calls lookupByRID on every entry before the iterator is handed to FetchFromIndexedFunctionStep. On a broad query this is a lot of heap and I/O for rows that are thrown away.

FullTextSearch.search(typeIndex, query, limit) already accepts a limit, but the SQL path passes -1.

Proposed enhancement

  1. Lazy loading: keep the sorted (RID, score) entries and resolve each record in the iterator, so LIMIT stops the record loading. This is always safe.
  2. Limit pushdown (top-k by score instead of a full sort), only when it cannot change the result:
    • the condition is a single AND block (no OR split into parallel branches),
    • there is no residual filter after the indexed function,
    • and there is either no ORDER BY or ORDER BY $score DESC.

Tests: same results as today with and without LIMIT, ORDER BY $score, OR-ed conditions and residual filters; the number of records loaded is bounded by the limit when the pushdown applies.

Activity

  1. lvca commented on Oct 13, 2026

    @lvca
    MemberAuthor

    Field evidence from a 3-node cluster under a full-text heavy workload: in one day, about two thirds of the logged OutOfMemoryErrors were raised inside SEARCH_INDEX before LIMIT applied:

    OOM site rows
    SQLFunctionSearchIndex.searchFromTarget -> LocalDatabase.lookupByRID (loading every match) 2,617
    LSMTreeFullTextIndex.getPostings (collecting postings) 866
    LSMTreeFullTextIndex.computeBM25Scores 559

    Every node restarted several times that day. So this is a stability issue as much as a latency one: raising the priority.

    Two notes from the same workload:

    • The typical query has an OR of two SEARCH_INDEX conditions plus a residual filter (deleted_at IS NULL), so the limit pushdown in point 2 would not apply. Lazy record loading (point 1) still bounds the record loads by the rows the filter lets through.
    • Soft-deleted records keep their full-text entries, so a broad term can score and load many records that the residual filter then rejects. Lazy loading makes the cost proportional to how deep the first LIMIT eligible rows sit in the score order, not to the total number of matches.
  2. added
    high_priorityIssue opened by a GitHub Sponsor of ArcadeData
    on Oct 13, 2026
  3. added this to the 26.11.1 milestone on Oct 13, 2026
  4. added
    in progressThe team is actively working on this issue
    on Oct 13, 2026
  5. self-assigned this
    on Oct 13, 2026
  6. added 5 commits that reference this issue on Oct 13, 2026
  7. lvca commented on Oct 13, 2026

    @lvca
    MemberAuthor

    Fixed in #9906 (milestone 26.11.1).

    SEARCH_INDEX(...) ... LIMIT n used to load every matching record before applying the limit. Now:

    • Records are loaded only as rows are returned, best score first. A LIMIT (or a filter that stops early) also stops the loading. This applies to every query shape, including SEARCH_INDEX(...) OR SEARCH_INDEX(...) AND <filter> LIMIT n.
    • Matches with equal scores now come back in a stable order (by RID), so repeated runs return the same rows.
    • For simple queries the search keeps only the best matches. That means a single SEARCH_INDEX condition with no other filter, no ORDER BY or exactly ORDER BY $score DESC, and no GROUP BY/DISTINCT/aggregates. Here the search keeps only the best SKIP + LIMIT matches and skips the sort. EXPLAIN shows (best N needed) on the fetch step.

    Results are the same as before; only the work done to get them is smaller. The documentation's full-text page describes this.


    ❤️ 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.

  8. removed
    in progressThe team is actively working on this issue
    on Oct 13, 2026
  9. added a commit that references this issue on Oct 15, 2026
  10. added 5 commits that reference this issue on Oct 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or requesthigh_priorityIssue opened by a GitHub Sponsor of ArcadeDataperformanceseverity: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