Skip to content

Convert RecursiveChunker to iterative and cap separator list size - #158589

Merged
john-wagster merged 5 commits into
elastic:mainfrom
john-wagster:improve-recursive-chunker-iterative
Sep 11, 2026
Merged

john-wagster merged 5 commits into
elastic:mainfrom
john-wagster:improve-recursive-chunker-iterative

Conversation

@john-wagster

Copy link
Copy Markdown
Contributor

This change improves the robustness of the recursive chunking implementation in two ways:

Iterative worklist replaces recursion in RecursiveChunker

The private chunk() method previously used recursion indexed by the separator list position. This is replaced with an iterative loop backed by an explicit ArrayDeque<PendingChunk> worklist. The JVM call-stack depth is now constant regardless of the number of separators or input size. Chunks that already fit within maxChunkSize are emitted immediately on pop; only oversized chunks are pushed back for further splitting with the next separator. PendingChunk carries the word count alongside the offset so fitness checks avoid redundant re-counting and bypass the backup chunker for chunks that already fit.

Separator list size validation in RecursiveChunkingSettings

A MAX_SEPARATOR_COUNT = 50 limit is added and enforced in fromMap(), validate(), and the StreamInput deserialization constructor. This provides a reasonable upper bound on custom separator lists — the largest built-in SeparatorGroup (MARKDOWN) has 8 entries, so 50 is generous for any real-world use case. The validation logic is extracted into a private static validateFields() method shared by the instance validate() and the deserialization path to avoid the this-escape compiler warning.

Tests

  • RecursiveChunkerTests: added tests for many non-matching separators completing normally, and for document-order preservation when chunks resolve at different separator depths.
  • RecursiveChunkingSettingsTests: added tests for fromMap() rejection, boundary acceptance at the limit, validate() rejection, and StreamInput deserialization rejection of over-limit separator lists.

Replace the recursive chunk() method in RecursiveChunker with an
iterative worklist-based approach using an explicit ArrayDeque. This
makes the JVM call-stack depth constant regardless of separator count.

Add a MAX_SEPARATOR_COUNT (50) validation to RecursiveChunkingSettings
enforced in fromMap(), validate(), and the StreamInput constructor.
The validation logic is extracted into a private static validateFields()
method shared by the instance validate() and the deserialization path.

PendingChunk carries ChunkOffsetAndCount so that fit chunks are emitted
directly on pop without unnecessary backup chunker invocations.
@elasticsearchmachine elasticsearchmachine added v9.6.0 needs:triage Requires assignment of a team area label labels Sep 4, 2026
@john-wagster
john-wagster requested a review from ah89 September 4, 2026 22:20
@elasticsearchmachine elasticsearchmachine added Team:Analytics Meta label for analytical engine team (ESQL/Aggs/Geo) and removed needs:triage Requires assignment of a team area label labels Sep 4, 2026
@elasticsearchmachine

Copy link
Copy Markdown
Collaborator

Pinging @elastic/es-analytical-engine (Team:Analytics)

@john-wagster john-wagster added auto-backport Automatically create backport pull requests when merged >bug labels Sep 4, 2026
@elasticsearchmachine

Copy link
Copy Markdown
Collaborator

Hi @john-wagster, I've created a changelog YAML for you.

@github-actions

github-actions Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

🔍 Preview links for changed docs

⏳ Building and deploying preview... View progress

This comment will be updated with preview links when the build is complete.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

ℹ️ Important: Docs version tagging

👋 Thanks for updating the docs! Just a friendly reminder that our docs are now cumulative. This means all 9.x versions are documented on the same page and published off of the main branch, instead of creating separate pages for each minor version.

We use applies_to tags to mark version-specific features and changes.

Expand for a quick overview

When to use applies_to tags:

✅ At the page level to indicate which products/deployments the content applies to (mandatory)
✅ When features change state (e.g. preview, ga) in a specific version
✅ When availability differs across deployments and environments

What NOT to do:

❌ Don't remove or replace information that applies to an older version
❌ Don't add new information that applies to a specific version without an applies_to tag
❌ Don't forget that applies_to tags can be used at the page, section, and inline level

🤔 Need help?

@john-wagster

Copy link
Copy Markdown
Contributor Author

@buildkite test this

@kderusso kderusso left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hey thanks for looping me in on this change.

One question - if we're enforcing a cap now, I think this may technically constitute a breaking change, do we need to go through that formal process here?

public RecursiveChunkingSettings(StreamInput in) throws IOException {
maxChunkSize = in.readInt();
separators = in.readCollectionAsList(StreamInput::readString);
validateFields(maxChunkSize, separators);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We need to do a version check here, or it will break BWC

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I've removed validation from existing setups. Validation now will only happen when user invokes an api so we don't need to deal with someone having added a bad separators list and nodes not talking to each other. Dealing with this seemed like a bigger headache than it was worth.

Comment thread docs/changelog/158589.yaml Outdated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So this is going to have some downstream impacts, because ChunkingSettingsBuilder.fromMap() will call this.

Can we please make sure we have tested and have coverage for the following use cases here that violate the new cap:

  • SenderService#parsePersistedConfig on GET _inference/<id> and inference in general
  • ShardBulkInferenceActionFilter indexing into an existing semantic_text field
  • SemanticTextField - reading _source that's already been indexed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

all of these paths are no longer impacted; figured it was less painful to only do user input validation instead here

@john-wagster john-wagster added :ml Machine learning and removed :Analytics/ES|QL AKA ESQL labels Sep 9, 2026
@elasticsearchmachine elasticsearchmachine added Team:ML Meta label for the ML team and removed Team:Analytics Meta label for analytical engine team (ESQL/Aggs/Geo) labels Sep 9, 2026
@elasticsearchmachine

Copy link
Copy Markdown
Collaborator

Pinging @elastic/ml-core (Team:ML)

@john-wagster

Copy link
Copy Markdown
Contributor Author

One question - if we're enforcing a cap now, I think this may technically constitute a breaking change, do we need to go through that formal process here?

@kderusso I think there's a belief that this is an overly generous max and no one would be using it currently. We've been treating similar issues like this (as there's been a lot of them lately) rather than doing the breaking change process for all of them (something that when it's that large it breaks the system). Having said that if you think it's possible for someone to hit that max of 50 separators and it be a valid usecase I can either consider increasing it or take it back to the team to validate further. Thoughts?

@kderusso

kderusso commented Sep 9, 2026

Copy link
Copy Markdown
Member

@john-wagster Happy to defer to your team's decision on breaking changes 🙂

@john-wagster

Copy link
Copy Markdown
Contributor Author

@john-wagster Happy to defer to your team's decision on breaking changes 🙂

@kderusso 👍 your opinion is always valued

Replace the recursive chunk() method in RecursiveChunker with an
iterative worklist-based approach using an explicit ArrayDeque. This
makes the JVM call-stack depth constant regardless of separator count.

Add a MAX_SEPARATOR_COUNT (50) limit to RecursiveChunkingSettings,
enforced only on user-facing request paths (ES|QL CHUNK function,
text_similarity_reranker chunk_rescorer, PUT/UPDATE _inference) via
a new enforceRequestLimits parameter threaded through fromMap().
Persistence-read paths (parsePersistedConfig, ShardBulkInferenceAction
Filter, SemanticTextField source parsing) do not enforce the limit,
so existing endpoints with more than 50 separators continue to work.

PendingChunk carries ChunkOffsetAndCount so that fit chunks are emitted
directly on pop without unnecessary backup chunker invocations.
@john-wagster
john-wagster force-pushed the improve-recursive-chunker-iterative branch from b635019 to a26a226 Compare September 9, 2026 17:31
@john-wagster

Copy link
Copy Markdown
Contributor Author

Apologies I did not mean to force push there (agents sigh).

@kderusso kderusso left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, it would be good to get a 👀 from the Inference team too

Comment thread docs/changelog/158589.yaml Outdated
@john-wagster
john-wagster merged commit ea8498c into elastic:main Sep 11, 2026
39 checks passed
@elasticsearchmachine

Copy link
Copy Markdown
Collaborator

💔 Backport failed

Status Branch Result
❌ 9.4 Commit could not be cherrypicked due to conflicts
✅ 9.5

You can use sqren/backport to manually backport by running backport --upstream elastic/elasticsearch --pr 158589

elasticsearchmachine pushed a commit that referenced this pull request Sep 11, 2026
…58589) (#159084)

* Convert RecursiveChunker to iterative and cap separator list size

Replace the recursive chunk() method in RecursiveChunker with an
iterative worklist-based approach using an explicit ArrayDeque. This
makes the JVM call-stack depth constant regardless of separator count.

Add a MAX_SEPARATOR_COUNT (50) validation to RecursiveChunkingSettings
enforced in fromMap(), validate(), and the StreamInput constructor.
The validation logic is extracted into a private static validateFields()
method shared by the instance validate() and the deserialization path.

PendingChunk carries ChunkOffsetAndCount so that fit chunks are emitted
directly on pop without unnecessary backup chunker invocations.

* Update docs/changelog/158589.yaml

* Update 158589.yaml
@john-wagster

Copy link
Copy Markdown
Contributor Author

💚 All backports created successfully

Status Branch Result
✅ 9.4

Questions ?

Please refer to the Backport tool documentation

elasticsearchmachine pushed a commit that referenced this pull request Sep 11, 2026
…58589) (#159096)

* Convert RecursiveChunker to iterative and cap separator list size

Replace the recursive chunk() method in RecursiveChunker with an
iterative worklist-based approach using an explicit ArrayDeque. This
makes the JVM call-stack depth constant regardless of separator count.

Add a MAX_SEPARATOR_COUNT (50) validation to RecursiveChunkingSettings
enforced in fromMap(), validate(), and the StreamInput constructor.
The validation logic is extracted into a private static validateFields()
method shared by the instance validate() and the deserialization path.

PendingChunk carries ChunkOffsetAndCount so that fit chunks are emitted
directly on pop without unnecessary backup chunker invocations.

* Update docs/changelog/158589.yaml

* Update 158589.yaml

(cherry picked from commit ea8498c)

# Conflicts:
#	x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/action/TransportUpdateInferenceModelAction.java
#	x-pack/plugin/inference/src/main/java/org/elasticsearch/xpack/inference/services/SenderService.java
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto-backport Automatically create backport pull requests when merged backport pending >bug :ml Machine learning Team:ML Meta label for the ML team v9.4.8 v9.5.5 v9.6.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants