Skip to content
Back to skills

Bm25 Tuning

ASecurity

BM25 deep tuning. k1 and b parameters with defaults per collection, field boosts, stopwords, language-specific analyzers (Italian, French, German, non-English), stemming vs lemmatization, tokenization gotchas, Elasticsearch vs Lucene vs rank_bm25. When BM25 alone beats vectors. USE WHEN: user mentions "BM25", "BM25 tuning", "k1 b parameter", "Elasticsearch analyzer", "stemming", "lemmatization", "rank_bm25", "TF-IDF", "lexical search" DO NOT USE FOR: learned sparse - use `retrieval/splade-d...

  • 31 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 8, 2026
ai-agentspythonrustgojavanodeapiperformance

Works with

  • cli
  • api

Security analysis

A96/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned September 8, 2026

npx -y skills add claude-dev-suite/claude-dev-suite --skill bm25-tuning --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Bm25 Tuning?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Bm25 Tuning
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/claude-dev-suite-bm25-tuning/badge)](https://www.skillsdirectory.com/skills/claude-dev-suite-bm25-tuning)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: bm25-tuning
description: |
  BM25 deep tuning. k1 and b parameters with defaults per collection, field boosts,
  stopwords, language-specific analyzers (Italian, French, German, non-English),
  stemming vs lemmatization, tokenization gotchas, Elasticsearch vs Lucene vs
  rank_bm25. When BM25 alone beats vectors.

  USE WHEN: user mentions "BM25", "BM25 tuning", "k1 b parameter", "Elasticsearch
  analyzer", "stemming", "lemmatization", "rank_bm25", "TF-IDF", "lexical search"

  DO NOT USE FOR: learned sparse - use `retrieval/splade-deep`; hybrid fusion - use
  `rag/hybrid-search`; dense retrieval - use `vector-stores/*`
allowed-tools: Read, Grep, Glob, Write, Edit
---
# BM25 Tuning

## The Formula

```
score(q, d) = sum over q_terms t of
    IDF(t) * ( f(t, d) * (k1 + 1) ) / ( f(t, d) + k1 * (1 - b + b * |d| / avgdl) )
```

- `f(t, d)`: term frequency in document d
- `|d|`: document length in tokens
- `avgdl`: average document length across the corpus
- `k1`: term-frequency saturation (how fast extra occurrences stop helping)
- `b`: length normalization (how much longer documents are penalized)
- `IDF(t)`: inverse document frequency of t

## k1 and b Defaults

| Collection type | k1 | b | Why |
|---|---|---|---|
| Lucene default | 1.2 | 0.75 | Safe general-purpose |
| Short homogeneous docs (titles, tweets) | 1.0-1.2 | 0.3-0.5 | Length already similar; less penalization |
| Long heterogeneous docs (web, manuals) | 1.2-1.5 | 0.75-0.85 | Long docs over-reward frequent terms otherwise |
| Code / logs with rare tokens | 1.5-2.0 | 0.5-0.75 | k1 high so repeated identifiers still accumulate |
| Q&A short passages | 0.8-1.2 | 0.4-0.6 | Passages are near-uniform length |

Tune in a sweep: grid k1 in {0.8, 1.0, 1.2, 1.5, 2.0}, b in {0.3, 0.5, 0.75, 0.9}. Measure NDCG@10 or recall@20 on a gold set.

## Elasticsearch Similarity Config

```json
PUT /docs
{
  "settings": {
    "index": {
      "similarity": {
        "bm25_long": {
          "type": "BM25",
          "k1": 1.5,
          "b": 0.85
        }
      },
      "analysis": {
        "analyzer": {
          "en_custom": {
            "type": "custom",
            "tokenizer": "standard",
            "filter": ["lowercase", "asciifolding", "english_stop", "english_stemmer"]
          }
        },
        "filter": {
          "english_stop": {"type": "stop", "stopwords": "_english_"},
          "english_stemmer": {"type": "stemmer", "language": "light_english"}
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "title": {"type": "text", "analyzer": "en_custom", "boost": 3.0,
                "similarity": "bm25_long"},
      "body":  {"type": "text", "analyzer": "en_custom",
                "similarity": "bm25_long"}
    }
  }
}
```

## Field Boosts

Boost title over body, headings over paragraphs:

```json
{
  "query": {
    "multi_match": {
      "query": "oauth token refresh 403",
      "type": "best_fields",
      "fields": ["title^3", "headings^2", "body^1"],
      "tie_breaker": 0.3
    }
  }
}
```

`best_fields` returns the highest single field score; `most_fields` sums. `tie_breaker` blends the two. For question-answer ranking, `cross_fields` with a shared analyzer usually beats `best_fields`.

## Language-Specific Analyzers

Non-English corpora fail silently with the default analyzer. Always pick a language-specific one.

### Italian

```json
"it_custom": {
  "type": "custom",
  "tokenizer": "standard",
  "filter": [
    "lowercase",
    "asciifolding",
    "italian_elision",
    "italian_stop",
    "italian_stemmer"
  ]
},
"filter": {
  "italian_elision": {
    "type": "elision",
    "articles_case": true,
    "articles": ["c", "l", "all", "dall", "dell", "nell", "sull", "coll", "pell",
                 "gl", "agl", "dagl", "degl", "negl", "sugl", "un", "m", "t", "s",
                 "v", "d"]
  },
  "italian_stop": {"type": "stop", "stopwords": "_italian_"},
  "italian_stemmer": {"type": "stemmer", "language": "light_italian"}
}
```

Italian needs elision handling (`l'amore` -> `amore`), otherwise queries for "amore" miss documents with the apostrophe form.

### French

```json
"fr_custom": {
  "type": "custom",
  "tokenizer": "standard",
  "filter": [
    "lowercase",
    "asciifolding",
    "french_elision",
    "french_stop",
    "french_stemmer"
  ]
},
"filter": {
  "french_elision": {
    "type": "elision",
    "articles_case": true,
    "articles": ["l", "m", "t", "qu", "n", "s", "j", "d", "c", "jusqu",
                 "quoiqu", "lorsqu", "puisqu"]
  },
  "french_stop": {"type": "stop", "stopwords": "_french_"},
  "french_stemmer": {"type": "stemmer", "language": "light_french"}
}
```

Use `light_french` over `french`; aggressive stemming conflates unrelated roots.

### German

```json
"de_custom": {
  "type": "custom",
  "tokenizer": "standard",
  "filter": [
    "lowercase",
    "german_normalization",
    "german_stop",
    "german_stemmer"
  ]
},
"filter": {
  "german_stop": {"type": "stop", "stopwords": "_german_"},
  "german_stemmer": {"type": "stemmer", "language": "light_german"}
}
```

German compounds (`Bundesausbildungsförderungsgesetz`) need `decompound_token_filter` via an external dictionary — plain stemmers do not split them. Consider `hyphenation_decompounder` with the OpenOffice hyphenation files.

## Stemming vs Lemmatization

| Aspect | Stemming | Lemmatization |
|---|---|---|
| Approach | Strip suffixes by rules | Reduce to dictionary lemma |
| Tool | Snowball / Porter / Lovins | spaCy, Stanza |
| Speed | Microseconds | Milliseconds |
| Accuracy | Crude (runner/running -> run, but also universe/university -> univers) | Correct (better -> good) |
| Storage | Same vocab | Same vocab |
| Production default | Elasticsearch `light_*` stemmers | Only when stem conflation hurts recall |

Most BM25 pipelines stick with stemmers. Lemmatize when you have strong morphology (Finnish, Turkish, Russian) and precision matters.

## Stopword Handling

Default stopword lists remove `the`, `a`, `of`, etc. Two gotchas:

1. Query like "to be or not to be" becomes empty after stopword removal. Use `stop` filter with `remove_trailing=false` or skip stopwords on short queries.
2. Domain-specific words may act as stopwords (`system`, `user` in a software manual). Measure IDF, remove terms with IDF below a threshold as a custom stop set.

```json
"custom_stop": {
  "type": "stop",
  "stopwords": ["system", "user", "module", "click"]
}
```

## Tokenization Gotchas

- `standard` tokenizer splits on Unicode word boundaries — it breaks `error_code_403` into `error`, `code`, `403`. Use `whitespace` tokenizer + `word_delimiter_graph` when identifiers matter.
- URLs / emails: use `uax_url_email` tokenizer if they are keys to your queries.
- Camel-case code tokens: add `word_delimiter_graph` with `generate_word_parts=true`.
- Numbers: BM25 treats `403` and `404` as equally distant from `Forbidden`. Keep them as tokens; rely on exact-term ranking via `constant_score` clauses for known IDs.

```json
"code_analyzer": {
  "type": "custom",
  "tokenizer": "whitespace",
  "filter": [
    "lowercase",
    {
      "type": "word_delimiter_graph",
      "preserve_original": true,
      "split_on_numerics": false,
      "catenate_words": true
    }
  ]
}
```

## Python: rank_bm25 for Prototyping

```python
# pip install rank_bm25 nltk
from rank_bm25 import BM25Okapi, BM25Plus, BM25L
import re

def tokenize(text: str) -> list[str]:
    return re.findall(r"\w+", text.lower())

docs = ["OAuth 2.0 uses refresh tokens.", "PKCE protects public clients.", ...]
tokenized = [tokenize(d) for d in docs]

bm = BM25Okapi(tokenized, k1=1.2, b=0.75)
scores = bm.get_scores(tokenize("token refresh"))
top = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:10]
```

`BM25Plus` (2014 variant) slightly helps on short documents; `BM25L` helps on long ones. All three accept `k1` and `b`.

## Lucene vs Elasticsearch vs rank_bm25

| Engine | Best for | Downsides |
|---|---|---|
| Elasticsearch / OpenSearch | Production, scale, rich analyzers | Cluster ops overhead |
| Tantivy (Rust) / Meilisearch | Single-node high-performance | Smaller analyzer ecosystem |
| Lucene direct (Java) | Embedded JVM apps | Write your own analyzer plumbing |
| rank_bm25 (Python) | Research, tests, single-host | No analyzer pipeline; bring your own |
| Whoosh | Pure Python, offline | Slow and unmaintained |
| Pyserini | BEIR reproduction, anserini-style | Heavy deps |

## When BM25 Alone Beats Vectors

- Legal, medical, financial — exact-term matching on codes / names dominates.
- Short queries with rare identifiers (error codes, SKUs, version numbers).
- Small corpora (< 5k documents) where training a domain embedding is impractical.
- Languages with weak pretrained embeddings (Welsh, Basque, Amharic).
- Freshness-critical content: BM25 indexes update in milliseconds; embeddings need recompute.

## Tuning Playbook

```python
from itertools import product

def grid_search_bm25(eval_set, tokenized_docs, k1_grid, b_grid, k=10):
    best = None
    for k1, b in product(k1_grid, b_grid):
        bm = BM25Okapi(tokenized_docs, k1=k1, b=b)
        recall = []
        for q, relevant in eval_set:
            scores = bm.get_scores(tokenize(q))
            top = {i for i in sorted(range(len(scores)),
                                     key=lambda i: scores[i], reverse=True)[:k]}
            recall.append(len(top & set(relevant)) / max(len(relevant), 1))
        score = sum(recall) / len(recall)
        if best is None or score > best[0]:
            best = (score, k1, b)
    return best

# usage
_, k1, b = grid_search_bm25(gold, tokenized, [0.8,1.0,1.2,1.5,2.0], [0.3,0.5,0.75,0.9])
```

Run the sweep after any major corpus change (doc length distribution shift).

## Anti-Patterns

| Anti-Pattern | Fix |
|---|---|
| Default `standard` analyzer for non-English | Use language-specific analyzer and stemmer |
| Leaving k1=1.2, b=0.75 forever | Sweep on your gold set; expect 5-15% recall lift |
| Removing stopwords from short queries | Skip stop filter on queries with <= 3 tokens |
| `match` query on a non-analyzed `keyword` field | Use a `text` field or mix `text` + `keyword` multi-fields |
| Boosting title field only, ignoring headings | Index headings separately with their own analyzer |
| Recomputing IDF per request | Persist the index; IDF is precomputed |
| Ignoring document-length distribution | Long tail skew needs higher `b`; measure avgdl |
| Using English stemmer on Italian content | Stemmer must match language; detect if mixed-language corpus |

## Production Checklist

- [ ] Language-appropriate analyzer per field
- [ ] `k1` and `b` tuned on a held-out gold set
- [ ] Field boosts set per document structure
- [ ] Stopwords list reviewed (general + domain-specific)
- [ ] Tokenization verified on a sample of edge-case queries (codes, URLs, camelCase)
- [ ] Index refresh interval tuned for the freshness SLA
- [ ] Synonyms file maintained (domain terms, acronyms)
- [ ] Query latency p95 monitored
- [ ] Periodic recall@k measured against gold set
- [ ] Upgrade path documented for Lucene / Elasticsearch version bumps

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…