Skip to content

Add langchain_text_splitters - #1947

Merged
bgruening merged 24 commits into
bgruening:masterfrom
IvoLeist:add-langchain_text_splitters
Sep 3, 2026
Merged

Add langchain_text_splitters#1947
bgruening merged 24 commits into
bgruening:masterfrom
IvoLeist:add-langchain_text_splitters

Conversation

@IvoLeist

@IvoLeist IvoLeist commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

This PR starts the addition of langchain-text-splitters, a script collection for breaking large text into smaller chunks for downstream LLM tools. In the GalaxyEU context these could be e.g. LLM Hub or RAG Retriever.

Leveraged Methods of langchain-text-splitters for this initial PR

  • Character-based splitting: Splits text at character-based separators. The target chunk size can be measured in characters or tokens.
    • RecursiveCharacterTextSplitter: Recommended for most use cases as it attempts to preserve larger text units by trying an ordered list of separators, from coarser boundaries such as paragraph breaks to finer boundaries such as line breaks, or spaces, down to individual characters.
    • CharacterTextSplitter: Splits text using one defined separator or character sequence, such as a paragraph break, line break, space, or custom separator.
  • Token-based splitting: The TokenTextSplitter ignores any potential text structure and simply creates chunks of a specified token length.
  • Sentence-based splitting: Detects sentence boundaries first and then fills the chunks with whole sentences, so a chunk never ends in the middle of a sentence. Pick this for prose. A single sentence that is already longer than the target chunk size becomes an oversized chunk of its own, because the sentence is never cut.
    • NLTKTextSplitter: Uses NLTK's Punkt sentence tokenizer, which is trained per language and therefore handles language-specific abbreviations such as Dr. in English or z.B. in German. Select the language of the input.
    • SpacyTextSplitter: Uses spaCy. The rule-based sentencizer is fast and needs no model, but it applies English tokenization rules. The en_core_web_sm model is more accurate on English text, and is roughly five times slower and needs about ten times more memory.

Library for Token Counting

OpenAI's fast Byte-Pair Encoding (BPE) tokenizer tiktoken library is used to determine the number of tokens in a chunk for this proof of concept. *langchain-text-splitters is also allowing Hugging Face tokenizer to count length, consequently this is on the roadmap, but not (yet) part of this PR.

FOR CONTRIBUTOR:

  • I have read the CONTRIBUTING.md document and this tool is appropriate for the tools-iuc repo.
  • License permits unrestricted use (educational + commercial)
  • This PR adds a new tool or tool collection
  • This PR updates an existing tool or tool collection
  • This PR does something else (explain below)

There are two labels that allow to ignore specific (false positive) tool linter errors:

  • skip-version-check: Use it if only a subset of the tools has been updated in a suite.
  • skip-url-check: Use it if github CI sees 403 errors, but the URLs work.

@bgruening bgruening left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Remove the dev_utils?

Comment thread tools/langchain_text_splitters/split_text.py
Comment thread tools/langchain_text_splitters/macros_for_testing.xml Outdated
Comment thread tools/langchain_text_splitters/macros.xml
Comment thread tools/langchain_text_splitters/test-data/.gitignore Outdated
- Remove the folder dev_utils and its content
- Remove .gitignore inside the test-data folder
- Merge macros_for_testing.xml into macros.xml
- Remove macro for setting the TIKTOKEN_CACHE_DIR env var
- Add "AI4Social+" to the creators
- Addresss flake8 warnings
@IvoLeist

IvoLeist commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

@bgruening thanks for the quick first review :)

Here are the changes:

@arash77

arash77 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Thank you @IvoLeist, nice tool! A few issues to fix before merge, found by AI review (verified by running the script with the pinned deps).

Must fix (silent wrong output)

  1. Line endings get changed. read_text() turns \r\n and \r into \n. Windows or old Mac text files are silently altered, and chunk positions no longer match the input. Use read_bytes().decode("utf-8") instead. (split_text.py:294)

  2. TSV content is broken. Tabs and newlines in a chunk are replaced with the literal text \t and \n, so reading the TSV back gives wrong content. A real tab and the text \t look the same, so it can't be undone. Let csv.writer do the quoting and remove the .replace() calls. (split_text.py:278)

Should fix (crashes)

  1. Non-UTF-8 input crashes with a raw error trace. Catch it and show a clear message. (split_text.py:294)

  2. Character + token mode + a special token + "reject special tokens" crashes with a raw error trace. Only the pure-token path is tested for this. Show a clear message instead. (split_text.py)

  3. Offline use needs the tiktoken cache. tiktoken downloads encoding files from the internet on first use and the conda package does not include them. I saw the plan to set TIKTOKEN_CACHE_DIR via TPV on EU — that covers EU, but the code comment still points to the deleted dev_utils script, and other deployers won't know about the cache requirement. Please update the comment and document it in the tool help. (split_text.py:108)

  4. start_index changes type. Following up on the invalid start-index upstream bug you describe in the PR text: the tool currently replaces the number with a long string, so the JSON has integers for some chunks and a string for others. Downstream code that does math on it will crash. Use null instead, and keep the warning separate. (split_text.py:340)

Nice to fix

  1. Empty or whitespace-only input gives 0 chunks, a 0-byte TSV, and an empty collection. No warning. Add a guard. (split_text.py)

  2. Token-mode output doesn't say whitespace is kept. As you note in the PR text, strip_whitespace is never applied by the token splitter. But the JSON metadata simply omits the field in token mode, so users can't tell from the output. Add strip_whitespace: false to the token-mode metadata, or document it. (split_text.py:376)

  3. JSON chunk key differs by mode (character_count vs token_count). Downstream readers must branch on length_unit. Use one length key plus length_unit. (split_text.py:348)

  4. Minor cleanups: the hand-rolled token counter copies library logic and may drift on upgrade; the tiktoken_options dict is shared by two callers that handle it differently; a dead else None branch in build_splitter; the whole input is token-counted again after splitting; bad custom-separator escapes crash (\UFFFFFFFF) or silently pass through (\u300); --encoding-name and --model-name are not mutually exclusive. The two similar tiktoken blocks in the XML could also be pulled into a shared macro so they don't drift apart later.

Tests to add

Empty input, whitespace-only input, non-UTF-8 input, CRLF/CR input, character+token mode with a disallowed special token, and a TSV round-trip check (the current tsv_escape test only compares bytes to the broken expected file).

@arash77

arash77 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

I fixed the points from my review locally and ran the tests. 16/16 passed before, 20/20 after. Two more real bugs came up while testing.

Correction: do not use csv.writer for item 2

I was wrong there. Galaxy's tabular format does not understand CSV quotes, so a chunk with a newline would become several rows and break the table.

Keep the escaping, but make it undoable: escape \ first, then \t, \r, \n. Then write the row with a plain "\t".join(...).

Note also that after the line ending fix (item 1) a chunk can hold a real \r, which most TSV readers treat as a new row. So \r needs escaping too.

Two new bugs

csv.writer breaks any chunk containing a ". This is the one I would fix first. csv.writer adds quotes by itself, so He said "hi" ok is written as "He said ""hi"" ok". Normal text is full of quotation marks, so the content column is wrong for many real documents, and no unescaping brings it back. Dropping csv (see above) fixes this too.

A separator starting with - breaks the tool. The separator is passed as --separator '$value'. A user splitting a markdown file on --- gets error: expected one argument and exit code 2, with nothing useful shown. Writing it as --separator='$value' fixes it.

Minor

  • The bad start index is not limited to the case in the PR text. After changing it to null, two old tests failed: recursive+gpt2 and token+gpt-5, both with overlap 1. Chunk 2 also gets -1. The old not_has_text check only passed because the code wrote a long string there. So it hits any token split with overlap. Worth adding to the upstream issue.
  • Outputs are written with the system locale encoding while the input is read as UTF-8. Python normally turns a C locale into UTF-8, so this only breaks on a machine set to something like ISO-8859-1. Unlikely on our instances, but encoding="utf-8" on the writes is a one word fix.
  • Item 1 cannot be tested in Galaxy: upload turns \r\n into \n by default. Same for non-UTF-8 input. Both still matter for files made by other tools, but can only be tested on the script.
  • Item 7 changes behaviour: input with only spaces used to run and give empty output, now it stops.
  • Items 6, 8 and 9 change the output format. Feel free to take only the bug fixes.
  • The predefined: separator inside the repeat has no test. All repeat tests use a custom one.

Input handling:
- Decode the input as UTF-8 instead of Path.read_text(), which silently
  rewrote CRLF/CR line endings and shifted the reported start indices.
- Report invalid UTF-8 input with a clear message instead of a traceback.
- Reject input that is empty or whitespace only instead of writing empty
  outputs.

TSV output:
- Escape backslash, tab, carriage return and newline so that the chunk text
  stays on one row and the escaping can be reversed. The previous escaping
  could not be undone, and csv.writer additionally quoted any chunk
  containing a double quote.

Errors and metadata:
- Report a disallowed special token with a clear message on the character
  splitter as well, not only on the token splitter.
- Report an invalid start index as null instead of replacing the number with
  a string, so the JSON field keeps a single type. The warning on stdout is
  unchanged.
- Use a single "length" key per chunk together with the existing
  "length_unit" instead of character_count/token_count.
- Report strip_whitespace as false in token mode, where the splitter never
  applies it.
- Write all outputs as UTF-8 instead of the system locale encoding.

Command line and separators:
- Pass a custom separator as --separator=VALUE so that a separator starting
  with a dash is not read as an option.
- Reject unsupported and out-of-range custom separator escapes with a clear
  message instead of crashing or passing them through silently.
- Make --encoding-name and --model-name mutually exclusive.

Tests and docs:
- Add tests for whitespace-only input, a disallowed special token on the
  character splitter, a dash-prefixed custom separator, and a predefined
  separator inside the repeat.
- Expect null start indices in the two overlapping token tests, which hit
  the same upstream bug as the dedicated repro test.
- Document the tiktoken encoding cache, the TSV escaping and the JSON
  fields in the tool help.
- Deduplicate the two tiktoken command line blocks into a macro.
@IvoLeist

IvoLeist commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author
  • The bad start index is not limited to the case in the PR text. After changing it to null, two old tests failed: recursive+gpt2 and token+gpt-5, both with overlap 1. Chunk 2 also gets -1. The old not_has_text check only passed because the code wrote a long string there. So it hits any token split with overlap. Worth adding to the upstream issue.

Very interesting finding 🤓 I went through some langchain issues/stopped PRs regarding it (see e.g. langchain-ai/langchain#29884) and apparently this is not easy to fix. So 🤞 that they succeed at one point ! Since this start_index information is nice to have (from the users perspective) but in my opinion not critical I would (for now) comment out the failing asserts including a reference to the upstream bug issue. So we/potential future maintainers could reactivate these assert easily ;D

Comment thread tools/langchain_text_splitters/test-data/tsv_escape.txt
Comment thread tools/langchain_text_splitters/test-data/tsv_escape_expected.tsv
- Add NLTKTextSplitter and SpacyTextSplitter
- Add Arash as creator

*Note this is so far still a proof of concept. There are no spaCy or NLTK tests yet. So not merge ready.
@IvoLeist

IvoLeist commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Latest update:

*Note this is so far only a proof of concept. There are no spaCy or NLTK tests yet. So not merge ready.

@IvoLeist
IvoLeist force-pushed the add-langchain_text_splitters branch from 05f283c to d80e5c7 Compare August 6, 2026 10:07
arash77 and others added 4 commits August 6, 2026 14:39
Tests 11, 12 and 14 could not be fixed by adjusting the assertions, each
had a different cause.

Test 12 asserted "\nASecond sentence ..." with a stray A, and its third
element missed the trailing newline. planemo stops at the first failing
element, so only one of the two showed up.

Test 11 is unreachable at chunk size 23. The sentences are 10, 10, 13 and
12 characters long and langchain drops the carried over sentence until
the incoming one fits, so the third chunk only keeps the overlap at a
chunk size of 25 or more. Retuned to 25 / 13.

Test 14 ended in a fourth chunk holding a single newline. Galaxy's upload
appends a trailing newline to the input, and the sentencizer reports it
as a sentence of its own. Since the sentence splitters keep the
whitespace between the sentences and never strip a chunk, that newline
survived into the outputs. Chunks that hold nothing but whitespace are
now left out and reported in a warning, so the test is back to three
chunks.

Fixes found while testing the new sentence splitters:

- The LookupError handler for the missing NLTK Punkt data was
  unreachable. langchain loads the tokenizer in the constructor, so the
  error is raised in build_splitter() and not in create_documents().
  KeyError and IndexError are excluded so that an unrelated lookup
  failure is not reported as missing Punkt data.
- --spacy-max-length was ignored for the sentencizer, which langchain
  builds from English() without forwarding max_length. Any input above
  one million characters failed with a raw spaCy traceback.
- The maximum input length was unbounded. en_core_web_sm keeps the
  dependency parser and needs roughly 3.5 kB per input character, so a
  large value could exhaust the memory of a shared compute node.
- The input length is now checked before the tiktoken encoding is
  loaded, so a job that cannot run does not pay for it first.
- The NLTK splitter drops the text after the last detected sentence, so
  its chunks do not add up to the input. This is now reported in a
  warning and documented. The spaCy splitter keeps everything.
- The help section still held a TODO instead of the sentence splitter
  documentation, and .shed.yml did not mention sentence splitting.

Note that punkt_tab is still not covered by a requirement. The
conda-forge nltk_data package ships punkt and not punkt_tab, so the NLTK
splitter keeps failing where the resource is not provided by the
instance. The error message now says so instead of showing a traceback.
…fixes

Arash fixed the failing sentence splitter tests :)
…he separator input

Chunk diagnostics:
- Detect a start index that is invalid without being negative. Once the
  splitter returns one negative index, the positions it derives from it stay
  positive but point at an earlier part of the input, which was reported as if
  it were correct. The position is now checked for order and for content.
- Report text that the splitter altered separately from a wrong position.
  Splitting between tokens can cut a character that is encoded in several
  bytes, which replaces it with the Unicode replacement character. Those chunks
  used to be reported as an invalid start index, pointing the user at the wrong
  cause.

Custom separator:
- Widen the sanitizer so that characters such as | ; ~ % & $ @ < > " [ ] { }
  reach the script instead of being silently replaced by an X, which split the
  text at the wrong places.
- Reject a separator that still contains a character Galaxy cannot pass on
  unchanged, with a message pointing at the escape syntax.

Other:
- Write the JSON output as UTF-8 as well, instead of the system locale
  encoding.
- Let special_token_error() return the error to raise, so the special token
  handling has a single contract instead of an unreachable re-raise at both
  call sites.

Tests:
- Add a test for a separator that the sanitizer used to mangle.
Arash separated the chunk diagnostics and hardened the separator input
@IvoLeist
IvoLeist force-pushed the add-langchain_text_splitters branch from c7b7c6c to f97dd61 Compare August 6, 2026 16:19
@IvoLeist

IvoLeist commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Note, before Arash's PR conda-forge/nltk_data-feedstock#10 is not merged the NLTK TextSplitter can only be tested locally:

First you would need to build the nltk_data conda package by cloning the PR`s branch:
https://github.com/arash77/nltk_data-feedstock/tree/update-nltk-data-2026.07.01

Then you could build the package with:

cd nltk_data-feedstock
python build-locally.py

Note

You need some patience building it can take a while....
If you encounter a WARNING: Number of parsed outputs does not match detected raw metadata blocks no worries the package has most likely been built correctly

You can run the tests as follows:

planemo test \
    --conda_dependency_resolution \
    --conda_auto_install \
    --conda_ensure_channels \
    "file:///home/<user>/<path_to_feedstock>/build_artifacts,conda-forge,bioconda" 

It should return:

All 26 test(s) executed passed.

Or test it in your local galaxy instance by setting the following in your config/galaxy.yml

conda_ensure_channels: file:///home/<user>/<path_to_feedstock>/build_artifacts,conda-forge,bioconda
conda_auto_install: true

before running it with:
planemo serve --galaxy_root /home/<user>/<path_to_your_local_galaxy_instance>

@IvoLeist

IvoLeist commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

To keep everyone in the loop:
In the meantime Arash's conda-forge/nltk_data-feedstock#10 PR got merged and @bgruening re-triggered the due to GithubAction outage skipped Linux built: conda-forge/nltk_data-feedstock/actions/runs/31182366681/job/92878488138 🚀

- Add strip_whitespace option for SpacyTextSpliiter; refer to it in the tool help and change the params of one test case to strip_whitespace=True
- Wrap strip_whitespace in macro
- Add explanation why strip_whitespace is fixed to false for the NLTK text splitter
- Populate the NLTK language options with all the in punkt_tab available 19 languages
- Set the chunk_overlap default to "0"
- Add a TODO related to galaxy adding by default a "\n" to each upload when there is no terminal new line and made one text fail on purpose so we remember to address this before the merge
@IvoLeist

IvoLeist commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Latest changes:

  • Add strip_whitespace option for SpacyTextSpliiter;
    • Refer to it in the tool help
    • Change the params of one test case to strip_whitespace=True
  • Wrap strip_whitespace in macro
  • Add explanation why strip_whitespace is fixed to false for the NLTK text splitter
  • Populate the NLTK language options with all the in punkt_tab available 19 languages
  • Set the chunk_overlap default to "0"
  • Add a TODO related to galaxy adding by default a "\n" to each upload when there is no terminal new line
    • Made one test (Number: 9) fail on purpose so we remember to address this before the merge

Regarding the still open TODO this is what I learned so far.
As I have understood this here below is adding the "\n" during galaxy file upload

if last_block and last_block[-1] != NEWLINE_BYTE:
  converted_newlines = True
  i += 1
  fp.write(b"\n")

https://github.com/galaxyproject/galaxy/blob/release_26.1/lib/galaxy/datatypes/sniff.py#L166-L169

A simple: return text.removesuffix("\n") leads to 6 six failing tests (6,12,14,15,16,17)

However, just not removing the "\n" potentially messes with the splitting as indicated by our

Warning: The NLTK sentence splitter dropped the 1 character(s) after the last sentence

To reproduce use sentence_nltk_english.txt of our test-data, with target chunk size in characters: 30
Or run planemo test --test_index 9

@bgruening

Copy link
Copy Markdown
Owner

Does your test data contain windows line breaks?

@IvoLeist

IvoLeist commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Does your test data contain windows line breaks?

No, I just double checked:

cd test-data/
file *    

custom_separator.txt: ASCII text
dash_separator.txt: ASCII text
langchain_docs_sample.txt: Unicode text, UTF-8 text
overlap_words.txt: ASCII text
pipe_separator.txt: ASCII text
recursive_default.txt: ASCII text
sentence_nltk_english.txt: ASCII text
sentence_nltk_german.txt: ASCII text
sentence_overlap.txt: ASCII text
sentence_spacy.txt: ASCII text
sentence_tokens.txt: ASCII text

grep -rl $'\r' -- test-data/ | wc -l
0
grep -rl $'\r$' -- test-data/ | wc -l
0

…itters

Two bugs silently changed the user's text.

spaCy with "strip whitespace" deleted the space between sentences.
langchain strips every sentence before joining them and we join with an
empty separator, so "mat. The" became "mat.The". The chunk then no longer
occurred in the input, so its start index was reported as null. spaCy is
now built with strip_whitespace=False and the stripping is done on the
finished chunk, which is what the option promises and keeps the chunk a
slice of the input.

Whitespace-only chunks were dropped for every splitter. A run of blank
lines is content for the token and character splitters, and dropping it
renumbered the chunks that followed, so 64 characters of a chapter break
disappeared. They are now only dropped when stripping was asked for and
nothing is left.

Also:
- the spaCy input limit is set per pipeline; one limit of 10 million
  characters allowed about 44 GB with the English model
- the "text does not occur in the input" warning no longer claims token
  splitting is the only cause
- the NLTK dropped-text check uses the chunk's start index instead of
  rfind(), which found the last occurrence and so reported no loss when
  the dropped text repeated the final chunk
- strip_whitespace is reported once from the argument instead of being
  hardcoded per splitter
- the punkt_tab notes match the nltk_data requirement
- test 9 pins the NLTK warning instead of failing on purpose
- new test on space separated prose; every other sentence fixture
  separates with a line break, which is why this was never caught
…e-fixes

Fix whitespace handling in the sentence splitters
Comment thread tools/langchain_text_splitters/langchain_text_splitters.xml Outdated
- add disclaimer that strip_whitespace is not carried by langchain but by the main function in our python  wrapper
arash77 and others added 9 commits September 1, 2026 10:35
…two misleading warnings

- fail early when the settings would produce more chunks than the
  instance's max_discovered_files, instead of splitting everything and
  then letting Galaxy fail the job on the file count
- name the cause that fits the run in the altered-text warning instead
  of always blaming multi-byte tokens
- stop reporting a null start index for overlapping chunks that
  legitimately begin at the same offset
- set strip_whitespace once for every splitter instead of per branch
- correct the help on whitespace at a chunk boundary, and let three
  tests assert the warning instead of pinning the upstream -1
Another round of contributions from Arash
The function picks between two causes for the same symptom, but nothing
tested that it picks the right one.

- multibyte_persian.txt with the token splitter: gpt2 splits Persian into
  very small tokens, so a cut lands inside a multi-byte character and the
  chunk comes back with the replacement character.
- repeated_separator.txt with a discarded comma: the run of separators is
  rebuilt as a single one, so the chunk text no longer occurs in the input.

Each test asserts the cause it expects and that the other one is absent,
so swapping the two would fail.
…chain's strip whitespace option but our own logic
@bgruening
bgruening merged commit 5655731 into bgruening:master Sep 3, 2026
10 checks passed
@Sch-Da

Sch-Da commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

really cool, thanks so much @IvoLeist @arash77 @anuprulez and @bgruening !

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants