feat(agent): optional keyword stemming, measured and left off by default

The keyword stage had no stemming, so "training" and "trains" were unrelated
terms. bm25::TokenFilter::Stemmed strips common English inflections (plurals,
-ing/-ed, with consonant un-doubling) from documents and queries alike;
BM25Index::build_with and HDF5Memory::set_token_filter select it, and the
index records which filter built it so a stale one is rebuilt rather than
mixed.

Measured over the full LongMemEval haystack (500 questions, real MiniLM
embeddings) rather than adopted on principle — and it is a trade, not a win:

  BM25 only         Hit@1 53.8%  Hit@5 75.0%  Hit@10 81.6%  MRR 0.6320
  BM25 stemmed      Hit@1 52.0%  Hit@5 77.8%  Hit@10 84.0%  MRR 0.6320
  Hybrid 0.4/0.6    Hit@1 51.6%  Hit@5 81.4%  Hit@10 87.8%  MRR 0.6430
  Hybrid stemmed    Hit@1 50.2%  Hit@5 81.4%  Hit@10 88.2%  MRR 0.6394

Conflation buys depth and costs the top rank: on BM25 alone MRR is unchanged
to four decimal places, the deeper gains exactly offsetting the rank-1 loss.
On the shipping hybrid configuration the vector stage already supplies most of
that recall, so the trade is narrower and slightly negative. Default stays
Plain; Stemmed is there for callers who want Hit@5/@10 over rank-1 precision.

The stemmer is deliberately conservative — it only strips inflections, and
only when the stem stays long enough to be meaningful, since an aggressive one
also conflates unrelated words. Tests pin both the pairs that must meet and
the pairs that must not.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-19 18:20:19 -07:00
co-authored by Claude Opus 5
parent 55ed87d2e8
commit 84a39ef3c5
5 changed files with 259 additions and 11 deletions
+28
View File
@@ -625,6 +625,34 @@ labelled data to tune against. Here there is, so the weighted sum is kept as
the default. `Fusion::Rrf` remains available for callers whose stages are more
evenly matched.
### Keyword tokenizer — stemming, full haystack, n=500
The keyword stage lowercases and splits on non-alphanumerics, with no stemming,
so "training" and "trains" are unrelated terms. `TokenFilter::Stemmed` strips
common English inflections (plurals, `-ing`/`-ed`, with consonant un-doubling)
from documents and queries alike. Turn-level:
| Mode | Hit@1 | Hit@5 | Hit@10 | MRR | session Hit@1 |
|---|---|---|---|---|---|
| BM25 only | **53.8%** | 75.0% | 81.6% | 0.6320 | 86.2% |
| BM25 only, stemmed | 52.0% | 77.8% | 84.0% | 0.6320 | 88.0% |
| Hybrid 0.4/0.6 | 51.6% | **81.4%** | 87.8% | **0.6430** | 91.0% |
| Hybrid 0.4/0.6, stemmed | 50.2% | **81.4%** | **88.2%** | 0.6394 | **91.4%** |
**Stemming is a trade, not a win, and the default stays off.** It reliably buys
depth and costs the top rank: on BM25 alone, +2.8pp Hit@5 and +2.4pp Hit@10 for
1.8pp Hit@1, with MRR unchanged to four decimal places — the gains deeper down
exactly offset the loss at rank 1. That is what conflation does: merging
"train"/"training"/"trains" surfaces documents an exact-match query would never
reach, and also lets a near-miss outrank the exact hit.
On the configuration that actually ships (hybrid 0.4/0.6) the trade is
narrower still — Hit@5 identical, Hit@10 +0.4pp, Hit@1 1.4pp, MRR 0.004 —
because the vector stage already supplies much of the recall stemming would
add. There is no case here for changing the default; `TokenFilter::Stemmed`
is available via `HDF5Memory::set_token_filter` for callers who want Hit@5/@10
over rank-1 precision.
### Weight sweep — full haystack, n=500
`0.7/0.3` was a documented default, never a searched one. Sweeping