Skip to content

new: support pluggable stemmer and inline stopwords for Bm25 - #655

Open
Mohith26 wants to merge 2 commits into
qdrant:mainfrom
Mohith26:feat/bm25-pluggable-stemmer
Open

new: support pluggable stemmer and inline stopwords for Bm25#655
Mohith26 wants to merge 2 commits into
qdrant:mainfrom
Mohith26:feat/bm25-pluggable-stemmer

Conversation

@Mohith26

Copy link
Copy Markdown

Closes #654, requested by @pjastrzebskiwelyo.

Adds a stemmer parameter to Bm25: any object with a stem_word(word: str) -> str method (a small Stemmer protocol, satisfied by py_rust_stemmers.SnowballStemmer among others). When a custom stemmer is provided, the language check is relaxed, which enables languages without a Snowball algorithm such as Polish. An optional stopwords parameter lets callers pass inline stopwords instead of the file shipped with the model, which those languages need too.

No new dependencies, and defaults are unchanged: without the new parameters, behavior is byte-for-byte the same (there is a test pinning the default Snowball path).

New tests cover a custom stemmer being used, an unsupported language working with one, inline stopwords overriding the file-based set, and the default path staying unchanged. They fail without the change; the sparse embedding suite passes with it.

Mohith26 added 2 commits July 28, 2026 13:17
Allow passing a custom stemmer (any object with a stem_word(word) -> str
method, per the new Stemmer protocol) to Bm25, overriding the default
SnowballStemmer. When a custom stemmer is provided, the supported-languages
check is skipped, enabling languages without a Snowball algorithm such as
Polish, Czech, Ukrainian, Slovak, Bulgarian, or Vietnamese.

Also allow passing stopwords inline, overriding the per-language stopwords
file shipped with the model. Both parameters are forwarded to parallel
workers. Default behavior is unchanged when neither parameter is given.

Fixes qdrant#654
- custom stemmer callable is applied to tokens
- default Snowball path is unchanged
- unsupported language (Polish) works with a custom stemmer and still
  raises without one
- inline stopwords override the file-based stopwords
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

BM25 now exposes a typed custom Stemmer protocol and accepts optional stemmer and stopwords constructor arguments. Initialization prioritizes these overrides while preserving default Snowball stemming and model stopword loading. Custom configuration is propagated to parallel workers. Tests cover custom and default stemming, unsupported languages, and inline stopword overrides.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: joein

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: pluggable stemming and inline stopwords for Bm25.
Description check ✅ Passed The description matches the code changes and is clearly related to the Bm25 stemming and stopword updates.
Linked Issues check ✅ Passed The PR adds a custom stemmer protocol, skips language checks with a custom stemmer, and supports inline stopwords as requested.
Out of Scope Changes check ✅ Passed The changes stay focused on Bm25 stemmer and stopword support, with no obvious unrelated additions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@fastembed/sparse/bm25.py`:
- Around line 130-131: Update the stopword initialization in the BM25
constructor to lowercase each supplied stopword before storing it in
_custom_stopwords, matching the normalization performed by _stem(); preserve
None handling and add a mixed-case stopword test.
- Around line 125-126: Update the constructor’s stopwords-loading logic so
provided stemmers cannot bypass language validation when fallback files are
used. Only open model_dir / f"{language}.txt" for supported languages; when
language is unsupported and stopwords are not inline, use an empty stopwords set
instead, while preserving inline stopwords behavior.
- Around line 222-223: Make the custom stemmer path safe for parallel execution:
document on Bm25’s stemmer configuration that values passed through
Bm25Worker.start must be pickleable and importable, and add a parallel test
using a module-level stemmer. Alternatively, replace direct stemmer instances
with an importable factory or configuration payload that Bm25 can reconstruct in
worker processes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f7486b40-d261-444f-834a-b33549119cd5

📥 Commits

Reviewing files that changed from the base of the PR and between 0892291 and 9489011.

📒 Files selected for processing (2)
  • fastembed/sparse/bm25.py
  • tests/test_sparse_embeddings.py

Comment thread fastembed/sparse/bm25.py
Comment on lines +125 to 126
if stemmer is None and language not in supported_languages:
raise ValueError(f"{language} language is not supported")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file outline =="
ast-grep outline fastembed/sparse/bm25.py --view expanded || true

echo "== relevant source =="
sed -n '1,220p' fastembed/sparse/bm25.py | cat -n

echo "== occurrences of load_stopwords and language validation =="
rg -n "_load_stopwords|supported_languages|stopwords|language" fastembed/sparse/bm25.py

Repository: qdrant/fastembed

Length of output: 12445


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Path traversal probe in Python =="
python3 - <<'PY'
from pathlib import Path
tmp = Path("/tmp")
for language in ["english", "../../../etc/"], ["custom", tmp / ".." / "tmp"]:
    # Avoid creating files; only inspect normalized paths and whether a fake model dir root can contain the result.
    path = tmp / f"{language}.txt" if isinstance(language, str) else tmp / f"{language}.txt"
    print(language, "=>", path, "=>", path.resolve(), "inside_tmp", path.resolve().is_relative_to(tmp))
PY

echo "== Worker pass-through =="
sed -n '366,430p' fastembed/sparse/bm25.py | cat -n

echo "== search for custom stemmer usage / tests =="
rg -n "Bm25\\(|stemmer=|disable_stemmer=|stopwords=" --glob '*.py' . | head -200

Repository: qdrant/fastembed

Length of output: 2748


Prevent unsupported language values from being used as stopwords file names.

When stemmer is provided, the constructor skips supported_languages, but fallback stopwords still open model_dir / f"{language}.txt" and path traversal values can read arbitrary readable files outside the model directory. Load stopwords only for supported languages or when stopwords is inline, otherwise default to an empty set.

[low_effort and high_reward]

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fastembed/sparse/bm25.py` around lines 125 - 126, Update the constructor’s
stopwords-loading logic so provided stemmers cannot bypass language validation
when fallback files are used. Only open model_dir / f"{language}.txt" for
supported languages; when language is unsupported and stopwords are not inline,
use an empty stopwords set instead, while preserving inline stopwords behavior.

Comment thread fastembed/sparse/bm25.py
Comment on lines +130 to +131
self._custom_stemmer = stemmer
self._custom_stopwords = set(stopwords) if stopwords is not None else None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Normalize inline stopwords to match token normalization.

_stem() lowercases tokens, but set(stopwords) preserves caller casing; stopwords=["The"] will not filter "the". Store lowercase stopwords and add a mixed-case test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fastembed/sparse/bm25.py` around lines 130 - 131, Update the stopword
initialization in the BM25 constructor to lowercase each supplied stopword
before storing it in _custom_stopwords, matching the normalization performed by
_stem(); preserve None handling and add a mixed-case stopword test.

Comment thread fastembed/sparse/bm25.py
Comment on lines +222 to +223
"stemmer": self._custom_stemmer,
"stopwords": self._custom_stopwords,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate bm25.py =="
fd -a 'bm25.py$' . || true

echo "== git status/stat =="
git status --short || true
git diff --stat || true

echo "== relevant bm25.py excerpt =="
file="$(fd 'bm25.py$' . | head -n 1)"
if [ -n "${file:-}" ]; then
  wc -l "$file"
  sed -n '1,280p' "$file" | cat -n
fi

echo "== search parallel/forkserver/spawn/pool/embed signatures =="
rg -n "parallel|forkserver|spawn|multiprocessing|Pool|Parallel|embed\(" -S . --glob '!build/**' --glob '!dist/**' --glob '!node_modules/**' || true

echo "== tests referencing bm25/custom stemmer =="
rg -n "_FakePolishStemmer|custom_stemmer|custom stop|BM25|bm25|parallel=" -S . --glob '!build/**' --glob '!dist/**' --glob '!node_modules/**' || true

Repository: qdrant/fastembed

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== inspect parallel_processor.py =="
wc -l fastembed/parallel_processor.py
sed -n '1,180p' fastembed/parallel_processor.py | cat -n

echo "== inspect bm25 worker/reconstructor and raw_embed tail =="
sed -n '280,390p' fastembed/sparse/bm25.py | cat -n

echo "== inspect tests for bm25 custom stemrer =="
rg -n "_FakePolishStemmer|custom stemmer|custom_stemmer|stemmer=|parallel=" tests -S --glob '!build/**' --glob '!dist/**' || true
fd -i 'bm25*test*.py|test*bm25*.py' tests | while read -r f; do echo "--- $f"; wc -l "$f"; sed -n '1,260p' "$f" | cat -n; done

Repository: qdrant/fastembed

Length of output: 18573


🌐 Web query:

Python multiprocessing Process pickle worker arguments forkserver spawn source documentation pickable arguments

💡 Result:

In Python's multiprocessing module, all arguments passed to a Process (specifically via the args and kwargs parameters) must be picklable [1][2][3]. This requirement exists because, regardless of the start method—'spawn', 'forkserver', or 'fork'—the multiprocessing module must be able to serialize these arguments to transmit them to the child process [1][4]. Start Method Overview: 1. 'spawn': The parent process starts a fresh Python interpreter [1][2]. Because it is a clean process, it cannot access the parent's memory, necessitating the pickling of target functions and all arguments [1][5]. 2. 'forkserver': A server process is started at the beginning of the program [1][5]. When a new process is requested, the parent communicates with this server to fork a new process [6][5]. Since the server is a separate, single-threaded process, arguments must also be picklable to be transferred [1][4]. 3. 'fork': The parent process forks the interpreter using os.fork [5]. While the child technically inherits the parent's memory space, consistency across different platforms and the potential for complex behavior often makes it best practice to treat arguments as needing to be picklable even when using 'fork' [1][4]. Key Practical Considerations: - Main Protection: You must wrap your process creation code in an if name == 'main': block [1][2]. This prevents the child process from recursively attempting to spawn new processes when it imports the main module [7]. - Picklability: If you attempt to pass non-picklable objects (such as locally defined functions, lambdas, or certain system handles like Locks directly) as arguments, you will encounter pickling errors [1][3]. - Recommendation: For robust, cross-platform code, always design your target functions and arguments to be serializable and define target functions at the module level rather than inside other functions [1][7].

Citations:


🌐 Web query:

Python standard library snowballstemmer PyRustStemmer pickle

💡 Result:

The snowballstemmer library and related tools like PyRustStemmer are not part of the Python standard library. The Python standard library does not include built-in support for linguistic stemming algorithms. Here is a breakdown of the components you mentioned: 1. snowballstemmer: This is a third-party, pure-Python library that provides Snowball stemming algorithms [1][2]. It is available on PyPI and must be installed separately (e.g., via pip) [1][3]. It acts as a wrapper that can automatically detect and use the faster C-based PyStemmer library if it is installed [1][4]. 2. PyRustStemmer (py-rust-stemmers): This is also a third-party library [5]. It provides a high-performance Python wrapper around the Rust-based snowball-stemming algorithms [5]. It is designed for faster execution, including support for parallel processing, and is managed as an independent project [5][6]. 3. Pickle: Unlike the other items mentioned, the pickle module is part of the Python standard library. It is used for serializing and de-serializing Python object structures, allowing you to convert complex objects into a byte stream for storage or transmission and then reconstruct them later. For stemming tasks in Python, you typically use third-party libraries such as snowballstemmer, PyStemmer, or NLTK (which includes its own implementation of Snowball stemmers) [1][7][8].

Citations:


Make custom BM25 stemmers safe for parallel execution.

When parallel is enabled, Bm25Worker.start(...) re-constructs Bm25 from the constructor arguments, so stemmer is passed through multiprocessing process arguments. Local stemmers such as the test’s class _FakePolishStemmer are not pickleable and will fail parallel calls; document that stemmer must be pickleable/importable and add a parallel test, or replace it with an importable factory/configuration payload.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fastembed/sparse/bm25.py` around lines 222 - 223, Make the custom stemmer
path safe for parallel execution: document on Bm25’s stemmer configuration that
values passed through Bm25Worker.start must be pickleable and importable, and
add a parallel test using a module-level stemmer. Alternatively, replace direct
stemmer instances with an importable factory or configuration payload that Bm25
can reconstruct in worker processes.

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.

[Feature]: Support pluggable stemmer for BM25 (enable Polish and other non‑Snowball languages)

1 participant