Skip to content

feat(bm25): Porter stemming on the BM25F lane (#154) - #428

Merged
robotrocketscience merged 1 commit into
mainfrom
feat/issue-154-bm25f-stemming
May 5, 2026
Merged

feat(bm25): Porter stemming on the BM25F lane (#154)#428
robotrocketscience merged 1 commit into
mainfrom
feat/issue-154-bm25f-stemming

Conversation

@yoshi280

@yoshi280 yoshi280 commented May 5, 2026

Copy link
Copy Markdown
Collaborator

Closes the stemming-gap blocker on #154's default-on flip. Surfaced at #154 comment 4380887104.

What ships

  • snowballstemmer>=2.2 added as a runtime dep. Pure-Python, ~150 KB, deterministic for the English Porter algorithm.
  • tokenize_stemmed() helper in aelfrice.bm25 — lowercase + Unicode-word splitting + Porter stemming. BM25Index.build and BM25Index.score switched to it so BM25F has FTS5-equivalent matching.
  • tokenize() (existing) stays unstemmedaelfrice.relationship_detector and any other caller that depends on word-form-preserving tokens (e.g. matching "always" / "never" / "rarely" against unstemmed QUANT_AXIS keys) keeps the old contract.

Why this is needed

Without stemming, BM25F missed natural-language queries that FTS5's Porter stemmer caught for free:

q="banana", BM25F (default-on prototype): []        ← regression
q="banana", FTS5 (legacy):                ['F1']     ← matched "bananas"

Switching the L1 lane to BM25F by default would silently break user queries until the stemmer was added. With this PR, both lanes match.

Bench evidence

Re-ran python -m tests.retrieve_uplift_runner against the v0.1 lab corpus under stemming:

flag n NDCG_off NDCG_on uplift (pre-stem) uplift (post-stem)
use_bm25f_anchors 30 0.2499 0.9149 +0.6010 +0.6650
use_signed_laplacian 30 0.2499 0.2499 +0.0000 +0.0000
use_heat_kernel 30 0.2499 0.2499 +0.0000 +0.0000
use_posterior_ranking 30 0.2499 0.2499 +0.0000 +0.0000
use_hrr_structural 30 0.2499 0.2499 +0.0000 +0.0000

use_bm25f_anchors uplift improved by +0.064 NDCG@k (more rows match because stem-divergent queries now hit). The other four flags are unchanged — placeholder flags or fixture-too-small per the prior #154 comment 4380842909.

Out of scope (subsequent #154 tasks)

  • The default-on flip itself: separate PR. Requires the regression-test contract updates documented in the prior addendum (now defensible because stemming closes the gap).
  • Stem-divergent rows in v0.1 corpus: optional additional rows to harden the bench evidence. Current 30 rows already validate the new tokenizer behavior since use_bm25f_anchors uplift went UP, not DOWN.
  • README v1.7 row update: PR docs(readme): v1.7 row → shipped (BM25F default-on; others opt-in) per #154 #426 already proposes the honest "shipped (opt-in)" framing; can be amended after this lands to reflect that the flip is now actually unblocked.

Test plan

  • uv run pytest --ignore=tests/bench_gate -q — 2456 passed, 23 skipped.
  • AELFRICE_CORPUS_ROOT=... uv run pytest tests/bench_gate/test_retrieve_uplift.py — PASS, no per-flag regressions.
  • Reproduction of the previously-failing case: q="banana" against content "bananas" now returns ['F1'] under BM25F (was []).
  • relationship_detector quantifier-axis tests still pass — confirmed tokenize() retains unstemmed behavior for non-BM25 callers.

Summary by Sourcery

Add Porter-stemmed tokenization to the BM25F path for parity with FTS5 and improved recall.

New Features:

  • Introduce a Porter-stemmed tokenizer used by BM25Index build and score so BM25F queries and documents share stemmed tokens.

Enhancements:

  • Clarify the existing tokenize() contract to remain unstemmed for callers that require word-form-preserving tokens.

Build:

  • Add snowballstemmer as a runtime dependency to provide the Porter stemming implementation used by BM25F.

Adds `snowballstemmer` runtime dep + `tokenize_stemmed()` helper.
`BM25Index.build` and `BM25Index.score` now stem at index/query time
so the BM25F lane has FTS5-equivalent matching behavior. Without
this, switching the L1 lane to BM25F by default would silently
regress queries like q="banana" against content "bananas" — FTS5's
Porter stemming caught those for free; stemless BM25F missed them.

`tokenize()` (without stem) stays for callers like
`relationship_detector` that depend on word-form-preserving tokens
to match against unstemmed quantifier vocabulary like "always" /
"rarely". Splitting the helpers keeps each call site
semantically correct.

Bench evidence: re-running the per-flag NDCG@k harness on the
v0.1 retrieve_uplift fixture under stemming raises
use_bm25f_anchors uplift from +0.6010 to +0.6650 (other four flags
unchanged at 0.0000). Stemming unblocks the v1.7 default-on flip
for use_bm25f_anchors per #154.
@sourcery-ai

sourcery-ai Bot commented May 5, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds Porter stemming to the BM25F lane via a new tokenize_stemmed() helper and a snowballstemmer runtime dependency, and switches BM25Index.build/score to use it while preserving the existing unstemmed tokenize() behavior for non-BM25 callers.

Sequence diagram for BM25F query scoring with Porter stemming

sequenceDiagram
    actor User
    participant BM25Index
    participant bm25_module
    participant tokenize_stemmed
    participant PorterStemmer

    User->>BM25Index: score(query)
    BM25Index->>bm25_module: tokenize_stemmed(query)
    bm25_module->>tokenize_stemmed: call with text
    tokenize_stemmed->>tokenize_stemmed: lowercase and find tokens
    loop for each token
        tokenize_stemmed->>PorterStemmer: stemWord(token)
        PorterStemmer-->>tokenize_stemmed: stemmed_token
    end
    tokenize_stemmed-->>bm25_module: list of stemmed tokens
    bm25_module-->>BM25Index: q_tokens
    BM25Index->>BM25Index: compute idf weighted tf for q_tokens
    BM25Index-->>User: ranked_results
Loading

Updated class diagram for BM25 tokenization and BM25Index build/score

classDiagram
    class bm25_module {
        +regex _TOKEN_PATTERN
        +stemmer _PORTER_STEMMER
        +list~str~ tokenize(text)
        +list~str~ tokenize_stemmed(text)
    }

    class PorterStemmer {
        +list~str~ stemWord(word)
    }

    class BM25Index {
        +sp.csr_matrix tf
        +np.ndarray idf
        +int n_docs
        +float k1
        +float b
        +score(query) list~tuple~
    }

    class BM25_build_function {
        +BM25Index build(beliefs, contents, incoming, anchor_weight)
    }

    bm25_module --> PorterStemmer : uses
    bm25_module --> BM25_build_function : defines
    bm25_module --> BM25Index : defines

    BM25_build_function --> BM25Index : constructs
    BM25_build_function --> bm25_module : calls tokenize_stemmed

    BM25Index --> bm25_module : score calls tokenize_stemmed

    bm25_module ..> bm25_module : tokenize used by non_BM25_callers
Loading

File-Level Changes

Change Details Files
Introduce Porter-stemmed tokenization for BM25F while keeping existing unstemmed tokenization for other callers.
  • Document tokenize() as lowercase + Unicode-word tokenization without stemming and clarify it is for word-form-preserving consumers like relationship_detector.
  • Add tokenize_stemmed() that lowercases, Unicode-tokenizes, and applies a shared Porter stemmer instance.
  • Instantiate a module-level snowballstemmer Porter stemmer to reuse across tokenize_stemmed() calls.
src/aelfrice/bm25.py
Wire stemmed tokenization into BM25Index indexing and querying paths to match SQLite FTS5 Porter behavior.
  • Change BM25Index.build to tokenize document contents with tokenize_stemmed().
  • Change BM25Index.build to tokenize incoming anchor texts with tokenize_stemmed() before applying anchor weighting.
  • Change BM25Index.score to tokenize queries with tokenize_stemmed() and early-return on empty token lists as before.
src/aelfrice/bm25.py
Add snowballstemmer as a runtime dependency for BM25F stemming parity and lockfile update.
  • Declare snowballstemmer>=2.2 in project dependencies with rationale comments about FTS5 Porter parity and size characteristics.
  • Update uv.lock to record the new snowballstemmer dependency and its resolved version(s).
pyproject.toml
uv.lock

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai

coderabbitai Bot commented May 5, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@yoshi280 has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 49 minutes and 30 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 345efe9d-80b0-417d-a3db-1814609d910b

📥 Commits

Reviewing files that changed from the base of the PR and between 6dc4411 and a805e2c.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock, !**/*.lock
📒 Files selected for processing (2)
  • pyproject.toml
  • src/aelfrice/bm25.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/issue-154-bm25f-stemming

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 and usage tips.

@sourcery-ai sourcery-ai 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.

Hey - I've found 1 issue, and left some high level feedback:

  • Consider updating the BM25Index-related docstrings/comments that describe the canonical tokenisation to explicitly mention tokenize_stemmed() (and the stemming behavior) so future readers don’t assume BM25 still uses the unstemmed tokenize() path.
  • To avoid the tokenisation behavior drifting over time, you could have tokenize_stemmed() call tokenize() and apply the Porter stemmer to its output, instead of duplicating the _TOKEN_PATTERN + lower() logic in two places.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Consider updating the `BM25Index`-related docstrings/comments that describe the canonical tokenisation to explicitly mention `tokenize_stemmed()` (and the stemming behavior) so future readers don’t assume BM25 still uses the unstemmed `tokenize()` path.
- To avoid the tokenisation behavior drifting over time, you could have `tokenize_stemmed()` call `tokenize()` and apply the Porter stemmer to its output, instead of duplicating the `_TOKEN_PATTERN` + `lower()` logic in two places.

## Individual Comments

### Comment 1
<location path="src/aelfrice/bm25.py" line_range="109-128" />
<code_context>
     return [m.group(0).lower() for m in _TOKEN_PATTERN.finditer(text)]


+def tokenize_stemmed(text: str) -> list[str]:
+    """Lowercase + Unicode-word tokenisation + Porter stemming.
+
+    Used by `BM25Index.build` and `BM25Index.score` so the BM25F
+    lane has FTS5-equivalent stemming. SQLite FTS5 uses Porter by
+    default; without stemming on the BM25F path,
+    `q="banana"` against content `"bananas"` would miss matches that
+    the legacy FTS5 lane catches. Added at v1.7.0 (#154) when the
+    default-on flip was prepared.
+
+    Non-BM25 callers (relationship_detector, scoring helpers, etc.)
+    that depend on word-form-preserving tokens should keep using
+    `tokenize()`; stemming is BM25-specific.
+    """
+    if not text:
+        return []
+    return [
+        _PORTER_STEMMER.stemWord(m.group(0).lower())
+        for m in _TOKEN_PATTERN.finditer(text)
+    ]
</code_context>
<issue_to_address>
**suggestion:** Avoid duplicating tokenisation logic by reusing `tokenize()` inside `tokenize_stemmed()`.

Both functions reapply `_TOKEN_PATTERN` and `.lower()` independently. To keep behavior aligned and avoid divergence, have `tokenize_stemmed()` call `tokenize()` and stem the resulting tokens:

```python
base_tokens = tokenize(text)
return [_PORTER_STEMMER.stemWord(tok) for tok in base_tokens]
```

This ensures any future tokenization changes automatically apply to the stemmed path as well.

```suggestion
def tokenize_stemmed(text: str) -> list[str]:
    """Lowercase + Unicode-word tokenisation + Porter stemming.

    Used by `BM25Index.build` and `BM25Index.score` so the BM25F
    lane has FTS5-equivalent stemming. SQLite FTS5 uses Porter by
    default; without stemming on the BM25F path,
    `q="banana"` against content `"bananas"` would miss matches that
    the legacy FTS5 lane catches. Added at v1.7.0 (#154) when the
    default-on flip was prepared.

    Non-BM25 callers (relationship_detector, scoring helpers, etc.)
    that depend on word-form-preserving tokens should keep using
    `tokenize()`; stemming is BM25-specific.
    """
    base_tokens = tokenize(text)
    return [_PORTER_STEMMER.stemWord(tok) for tok in base_tokens]
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/aelfrice/bm25.py
Comment on lines +109 to +128
def tokenize_stemmed(text: str) -> list[str]:
"""Lowercase + Unicode-word tokenisation + Porter stemming.

Used by `BM25Index.build` and `BM25Index.score` so the BM25F
lane has FTS5-equivalent stemming. SQLite FTS5 uses Porter by
default; without stemming on the BM25F path,
`q="banana"` against content `"bananas"` would miss matches that
the legacy FTS5 lane catches. Added at v1.7.0 (#154) when the
default-on flip was prepared.

Non-BM25 callers (relationship_detector, scoring helpers, etc.)
that depend on word-form-preserving tokens should keep using
`tokenize()`; stemming is BM25-specific.
"""
if not text:
return []
return [
_PORTER_STEMMER.stemWord(m.group(0).lower())
for m in _TOKEN_PATTERN.finditer(text)
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: Avoid duplicating tokenisation logic by reusing tokenize() inside tokenize_stemmed().

Both functions reapply _TOKEN_PATTERN and .lower() independently. To keep behavior aligned and avoid divergence, have tokenize_stemmed() call tokenize() and stem the resulting tokens:

base_tokens = tokenize(text)
return [_PORTER_STEMMER.stemWord(tok) for tok in base_tokens]

This ensures any future tokenization changes automatically apply to the stemmed path as well.

Suggested change
def tokenize_stemmed(text: str) -> list[str]:
"""Lowercase + Unicode-word tokenisation + Porter stemming.
Used by `BM25Index.build` and `BM25Index.score` so the BM25F
lane has FTS5-equivalent stemming. SQLite FTS5 uses Porter by
default; without stemming on the BM25F path,
`q="banana"` against content `"bananas"` would miss matches that
the legacy FTS5 lane catches. Added at v1.7.0 (#154) when the
default-on flip was prepared.
Non-BM25 callers (relationship_detector, scoring helpers, etc.)
that depend on word-form-preserving tokens should keep using
`tokenize()`; stemming is BM25-specific.
"""
if not text:
return []
return [
_PORTER_STEMMER.stemWord(m.group(0).lower())
for m in _TOKEN_PATTERN.finditer(text)
]
def tokenize_stemmed(text: str) -> list[str]:
"""Lowercase + Unicode-word tokenisation + Porter stemming.
Used by `BM25Index.build` and `BM25Index.score` so the BM25F
lane has FTS5-equivalent stemming. SQLite FTS5 uses Porter by
default; without stemming on the BM25F path,
`q="banana"` against content `"bananas"` would miss matches that
the legacy FTS5 lane catches. Added at v1.7.0 (#154) when the
default-on flip was prepared.
Non-BM25 callers (relationship_detector, scoring helpers, etc.)
that depend on word-form-preserving tokens should keep using
`tokenize()`; stemming is BM25-specific.
"""
base_tokens = tokenize(text)
return [_PORTER_STEMMER.stemWord(tok) for tok in base_tokens]

robotrocketscience added a commit that referenced this pull request May 5, 2026
Updates the v1.7 row to reflect the post-stemming bench result and
the actual default-on flip:

- BM25F anchor-text retrieval (#148) default-on at v1.7.0 per #154
  bench evidence: +0.6650 NDCG@k uplift on the v0.1 retrieve_uplift
  fixture under Porter stemming. PR #428 added the stemmer; PR #430
  flipped the default; bench-gate test_retrieve_per_flag_no_regression
  PASS.
- Other v1.7 components (use_signed_laplacian, use_heat_kernel,
  use_hrr_structural) remain opt-in — placeholder lanes pending
  wiring into retrieve(). The v1.7 wave is shipped; the
  remaining-flags flip waits on those lanes landing.
- v2.0 row drops the "default-on flip is a prereq" note since
  v1.7 is now shipped.

Replaces the prior intermediate framing ("shipped (opt-in)";
deferred default-on flip).
@yoshi280

yoshi280 commented May 5, 2026

Copy link
Copy Markdown
Collaborator Author

[claim:review:Kulili:2026-05-05T16:17:22Z]

@robotrocketscience
robotrocketscience merged commit a805e2c into main May 5, 2026
18 of 36 checks passed
@robotrocketscience
robotrocketscience deleted the feat/issue-154-bm25f-stemming branch May 5, 2026 16:33
robotrocketscience added a commit that referenced this pull request May 5, 2026
Updates the v1.7 row to reflect the post-stemming bench result and
the actual default-on flip:

- BM25F anchor-text retrieval (#148) default-on at v1.7.0 per #154
  bench evidence: +0.6650 NDCG@k uplift on the v0.1 retrieve_uplift
  fixture under Porter stemming. PR #428 added the stemmer; PR #430
  flipped the default; bench-gate test_retrieve_per_flag_no_regression
  PASS.
- Other v1.7 components (use_signed_laplacian, use_heat_kernel,
  use_hrr_structural) remain opt-in — placeholder lanes pending
  wiring into retrieve(). The v1.7 wave is shipped; the
  remaining-flags flip waits on those lanes landing.
- v2.0 row drops the "default-on flip is a prereq" note since
  v1.7 is now shipped.

Replaces the prior intermediate framing ("shipped (opt-in)";
deferred default-on flip).
robotrocketscience added a commit that referenced this pull request May 5, 2026
Updates the v1.7 row to reflect the post-stemming bench result and
the actual default-on flip:

- BM25F anchor-text retrieval (#148) default-on at v1.7.0 per #154
  bench evidence: +0.6650 NDCG@k uplift on the v0.1 retrieve_uplift
  fixture under Porter stemming. PR #428 added the stemmer; PR #430
  flipped the default; bench-gate test_retrieve_per_flag_no_regression
  PASS.
- Other v1.7 components (use_signed_laplacian, use_heat_kernel,
  use_hrr_structural) remain opt-in — placeholder lanes pending
  wiring into retrieve(). The v1.7 wave is shipped; the
  remaining-flags flip waits on those lanes landing.
- v2.0 row drops the "default-on flip is a prereq" note since
  v1.7 is now shipped.

Replaces the prior intermediate framing ("shipped (opt-in)";
deferred default-on flip).
robotrocketscience added a commit that referenced this pull request May 5, 2026
Updates the v1.7 row to reflect the post-stemming bench result and
the actual default-on flip:

- BM25F anchor-text retrieval (#148) default-on at v1.7.0 per #154
  bench evidence: +0.6650 NDCG@k uplift on the v0.1 retrieve_uplift
  fixture under Porter stemming. PR #428 added the stemmer; PR #430
  flipped the default; bench-gate test_retrieve_per_flag_no_regression
  PASS.
- Other v1.7 components (use_signed_laplacian, use_heat_kernel,
  use_hrr_structural) remain opt-in — placeholder lanes pending
  wiring into retrieve(). The v1.7 wave is shipped; the
  remaining-flags flip waits on those lanes landing.
- v2.0 row drops the "default-on flip is a prereq" note since
  v1.7 is now shipped.

Replaces the prior intermediate framing ("shipped (opt-in)";
deferred default-on flip).
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.

2 participants