autobm25 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- autobm25-0.1.0/.gitignore +49 -0
- autobm25-0.1.0/LICENSE +21 -0
- autobm25-0.1.0/MANIFEST.in +7 -0
- autobm25-0.1.0/PKG-INFO +325 -0
- autobm25-0.1.0/README.md +295 -0
- autobm25-0.1.0/README_CN.md +243 -0
- autobm25-0.1.0/autobm25/__init__.py +187 -0
- autobm25-0.1.0/autobm25/__main__.py +7 -0
- autobm25-0.1.0/autobm25/_accel.py +113 -0
- autobm25-0.1.0/autobm25/bm25_engine.py +282 -0
- autobm25-0.1.0/autobm25/cli.py +191 -0
- autobm25-0.1.0/autobm25/data/config.yaml +57 -0
- autobm25-0.1.0/autobm25/data/param_dictionary.json +2037 -0
- autobm25-0.1.0/autobm25/data/stopwords.txt +175 -0
- autobm25-0.1.0/autobm25/demo.py +190 -0
- autobm25-0.1.0/autobm25/dictionary.py +170 -0
- autobm25-0.1.0/autobm25/feature_extractor.py +228 -0
- autobm25-0.1.0/autobm25/loader.py +168 -0
- autobm25-0.1.0/autobm25/metrics.py +68 -0
- autobm25-0.1.0/autobm25/rule_predictor.py +111 -0
- autobm25-0.1.0/autobm25.egg-info/PKG-INFO +325 -0
- autobm25-0.1.0/autobm25.egg-info/SOURCES.txt +49 -0
- autobm25-0.1.0/autobm25.egg-info/dependency_links.txt +1 -0
- autobm25-0.1.0/autobm25.egg-info/entry_points.txt +2 -0
- autobm25-0.1.0/autobm25.egg-info/requires.txt +8 -0
- autobm25-0.1.0/autobm25.egg-info/top_level.txt +1 -0
- autobm25-0.1.0/config.yaml +56 -0
- autobm25-0.1.0/logo/logo.png +0 -0
- autobm25-0.1.0/pyproject.toml +72 -0
- autobm25-0.1.0/requirements.txt +4 -0
- autobm25-0.1.0/rust/Cargo.lock +180 -0
- autobm25-0.1.0/rust/Cargo.toml +39 -0
- autobm25-0.1.0/rust/README.md +62 -0
- autobm25-0.1.0/rust/build_pyext.py +129 -0
- autobm25-0.1.0/rust/src/corpus.rs +219 -0
- autobm25-0.1.0/rust/src/engine.rs +201 -0
- autobm25-0.1.0/rust/src/features.rs +240 -0
- autobm25-0.1.0/rust/src/fx.rs +69 -0
- autobm25-0.1.0/rust/src/grid.rs +307 -0
- autobm25-0.1.0/rust/src/json.rs +614 -0
- autobm25-0.1.0/rust/src/lib.rs +28 -0
- autobm25-0.1.0/rust/src/loader.rs +364 -0
- autobm25-0.1.0/rust/src/main.rs +707 -0
- autobm25-0.1.0/rust/src/metrics.rs +202 -0
- autobm25-0.1.0/rust/src/params.rs +742 -0
- autobm25-0.1.0/rust/src/pyext.rs +422 -0
- autobm25-0.1.0/rust/src/stats.rs +171 -0
- autobm25-0.1.0/rust/src/text.rs +146 -0
- autobm25-0.1.0/rust/tests/core.rs +139 -0
- autobm25-0.1.0/setup.cfg +4 -0
- autobm25-0.1.0/setup.py +194 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# ---------------------------------------------------------------------------
|
|
2
|
+
# Local-only material. Everything below stays on the machine that produced it:
|
|
3
|
+
# the corpora, the experiments behind the shipped dictionary, and the scratch
|
|
4
|
+
# tooling. Only the package itself (autobm25/, rust/, setup.py, READMEs) is
|
|
5
|
+
# meant to reach a clone or a wheel.
|
|
6
|
+
# ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
# Datasets (BEIR downloads, subsamples, language splits) - fetched, never shipped
|
|
9
|
+
dataset/
|
|
10
|
+
dataset_en/
|
|
11
|
+
dataset_cn/
|
|
12
|
+
datasets/
|
|
13
|
+
|
|
14
|
+
# Research pipeline output: benchmark tables, grid searches, mixes, documents
|
|
15
|
+
results/
|
|
16
|
+
research/
|
|
17
|
+
document/
|
|
18
|
+
|
|
19
|
+
# Local tooling and scratch directories (bench harnesses, downloaded installers)
|
|
20
|
+
_bench/
|
|
21
|
+
_tools/
|
|
22
|
+
_scratch/
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Python build artefacts
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
__pycache__/
|
|
28
|
+
*.py[cod]
|
|
29
|
+
*.egg-info/
|
|
30
|
+
build/
|
|
31
|
+
dist/
|
|
32
|
+
release/
|
|
33
|
+
.venv/
|
|
34
|
+
venv/
|
|
35
|
+
.pytest_cache/
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Rust build output and the compiled Python extension.
|
|
39
|
+
#
|
|
40
|
+
# `rust/` itself is deliberately *tracked*: the sources are what let a user
|
|
41
|
+
# compile the accelerator on their own machine (`python rust/build_pyext.py`).
|
|
42
|
+
# Only the artefacts are ignored - `rust/target/` is cargo's scratch directory
|
|
43
|
+
# and the compiled extension is rebuilt by `pip install .` / `pip wheel .`
|
|
44
|
+
# (see setup.py), so a stale binary can never be committed by accident.
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
rust/target/
|
|
47
|
+
*.pyd
|
|
48
|
+
*.so
|
|
49
|
+
*.dylib
|
autobm25-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tzy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Ship the Rust sources in the sdist so `pip install autobm25` from an sdist can
|
|
2
|
+
# still compile the extension when a toolchain is available. Built artifacts and
|
|
3
|
+
# the cargo target directory stay out of the archive.
|
|
4
|
+
include LICENSE README.md README_CN.md
|
|
5
|
+
recursive-include rust *.rs Cargo.toml Cargo.lock build_pyext.py README.md
|
|
6
|
+
prune rust/target
|
|
7
|
+
global-exclude *.pyd *.so *.dylib
|
autobm25-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: autobm25
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Zero-label BM25 hyperparameter adaptation: O(1) statistical-feature dictionary lookup with heuristic fallback
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/SaturnAura/AutoBM25
|
|
7
|
+
Project-URL: Repository, https://github.com/SaturnAura/AutoBM25
|
|
8
|
+
Project-URL: Issues, https://github.com/SaturnAura/AutoBM25/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/SaturnAura/AutoBM25/releases
|
|
10
|
+
Keywords: bm25,information-retrieval,hyperparameter,zero-shot,ir
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Rust
|
|
24
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
25
|
+
Classifier: Topic :: Text Processing :: Indexing
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
Provides-Extra: test
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
|
|
31
|
+
# AutoBM25 — Zero-Label, Zero-Tuning BM25 Hyperparameter Adaptation
|
|
32
|
+
|
|
33
|
+
[](https://pypi.org/project/autobm25/)
|
|
34
|
+
[](https://pypi.org/project/autobm25/)
|
|
35
|
+
[](https://github.com/SaturnAura/AutoBM25/blob/main/LICENSE)
|
|
36
|
+
[](https://github.com/SaturnAura/AutoBM25/blob/main/rust/Cargo.toml)
|
|
37
|
+
[](https://github.com/SaturnAura/AutoBM25/actions/workflows/ci.yml)
|
|
38
|
+
|
|
39
|
+
> [Chinese README](https://github.com/SaturnAura/AutoBM25/blob/main/README_CN.md)
|
|
40
|
+
|
|
41
|
+

|
|
42
|
+
|
|
43
|
+
> Feed the corpus's **statistical features** into an **O(1) parameter dictionary** and get the optimal BM25 hyperparameters (k1 / b / k3 / δ / IDF) directly; fall back to interpretable heuristics when nothing matches. Verified on 16 BEIR datasets: **16/16 never worse than the BM25 defaults, NDCG@10 +18.0% median / +30.5% mean, 92.0% leave-one-corpus-out dictionary hit rate**.
|
|
44
|
+
|
|
45
|
+
> 😄 Maybe we should call it **BM26**? — if the hyperparameters can adapt, so can the version number (just kidding, BM25 is BM25).
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Why
|
|
50
|
+
|
|
51
|
+
BM25 is the most widely used ranking model in information retrieval, but its **optimal hyperparameters vary dramatically across corpora** — short texts, long texts, code, legal documents, Q&A communities each need different k1/b values. In practice, everyone uses the fixed defaults `k1=1.2, b=0.75`, because tuning requires a labeled validation set, and **new corpora rarely have labels**.
|
|
52
|
+
|
|
53
|
+
AutoBM25 takes a different path: **optimal parameters are a function of the corpus's statistical structure, and statistical structure can be measured without any labels.** Measure 21 normalized dictionary features, look up the parameters — zero labels, zero tuning, constant time.
|
|
54
|
+
|
|
55
|
+
## Highlights
|
|
56
|
+
|
|
57
|
+
- 🗂️ **21 dictionary features** (15 corpus + 6 query-side) across length distribution, term-frequency redundancy, vocabulary growth, and query statistics — each with an explicit mechanism hypothesis
|
|
58
|
+
- ⚡ **O(1) parameter dictionary**: nearest-neighbor lookup from feature vectors to optimal parameters, with **out-of-distribution (OOD) guard** and **heuristic fallback**; 48 entries ship with the repo (16 benchmark corpora + 32 cross-domain blends), each validated to be no worse than the defaults on the corpus it came from
|
|
59
|
+
- 🔧 **k3 query-term-frequency saturation**: ablation-verified, up to **+19.9%** on long-query scenarios
|
|
60
|
+
- 🧩 **Corpus mixing**: combine existing corpora into cross-domain blends to fill feature-space gaps; dictionary hit rate **70.6% → 92.0%**
|
|
61
|
+
- 🚀 **Scales to 8.84M documents** (msmarco); self-built numpy-vectorized BM25/BM25+ engine with streaming stratified subsampling that keeps all relevant annotations
|
|
62
|
+
- 🦀 **Rust/PyO3 acceleration**: the tokenize-and-index stage is available as a compiled extension, **5.2–12.4× faster one-pass end-to-end** on the benchmark below, with identical scores (optional; pure Python still works)
|
|
63
|
+
|
|
64
|
+
## Results (16 BEIR datasets)
|
|
65
|
+
|
|
66
|
+
**BM25 defaults vs AutoBM25 (adaptive) — MRR@10 / NDCG@10 / Recall@100**
|
|
67
|
+
|
|
68
|
+
Each cell shows `BM25 defaults → AutoBM25` on the same corpus, queries and relevance
|
|
69
|
+
judgements (defaults: k1=1.2, b=0.75, k3=0, δ=0, rsj IDF). All 16 datasets improve;
|
|
70
|
+
none is worse than the default it is compared with.
|
|
71
|
+
|
|
72
|
+
| Dataset | MRR@10 | NDCG@10 | Recall@100 |
|
|
73
|
+
| ---------------- | ------------------: | ------------------: | ------------------: |
|
|
74
|
+
| arguana | 0.2036 → 0.2411 | 0.3158 → 0.3654 | 0.9090 → 0.9573 |
|
|
75
|
+
| climate-fever | 0.2480 → 0.3049 | 0.1778 → 0.2241 | 0.4551 → 0.5306 |
|
|
76
|
+
| dbpedia-entity | 0.6674 → 0.6919 | 0.3863 → 0.4158 | 0.4779 → 0.5152 |
|
|
77
|
+
| fever | 0.5983 → 0.8467 | 0.6105 → 0.8322 | 0.9007 → 0.9536 |
|
|
78
|
+
| fiqa | 0.1727 → 0.2050 | 0.1387 → 0.1624 | 0.3429 → 0.3799 |
|
|
79
|
+
| hotpotqa | 0.8131 → 0.8376 | 0.6478 → 0.6803 | 0.7700 → 0.7830 |
|
|
80
|
+
| msmarco | 0.4024 → 0.4162 | 0.4400 → 0.4548 | 0.7490 → 0.7530 |
|
|
81
|
+
| nfcorpus | 0.4600 → 0.4740 | 0.2633 → 0.2738 | 0.1983 → 0.2027 |
|
|
82
|
+
| nq | 0.1594 → 0.1957 | 0.1774 → 0.2266 | 0.5937 → 0.6587 |
|
|
83
|
+
| scidocs | 0.2228 → 0.2596 | 0.1215 → 0.1444 | 0.2936 → 0.3260 |
|
|
84
|
+
| scifact | 0.5033 → 0.5574 | 0.5388 → 0.5945 | 0.8116 → 0.8334 |
|
|
85
|
+
| trec-covid | 0.5936 → 0.7832 | 0.3322 → 0.5011 | 0.0573 → 0.0764 |
|
|
86
|
+
| trec-covid-beir | 0.6107 → 0.7982 | 0.3394 → 0.5113 | 0.0575 → 0.0761 |
|
|
87
|
+
| trec-covid-v2 | 0.4079 → 0.7941 | 0.2127 → 0.5041 | 0.0495 → 0.0933 |
|
|
88
|
+
| vihealthqa | 0.3824 → 0.3921 | 0.3676 → 0.3758 | 0.6247 → 0.6289 |
|
|
89
|
+
| webis-touche2020 | 0.4931 → 0.7170 | 0.2226 → 0.3901 | 0.4336 → 0.5250 |
|
|
90
|
+
| **Average** | **0.4337 → 0.5322** | **0.3308 → 0.4160** | **0.4828 → 0.5183** |
|
|
91
|
+
|
|
92
|
+
Relative NDCG@10 gain over the 16 corpora: **+18.0% median, +30.5% mean** (worst
|
|
93
|
+
+2.2%, best +137.0%).
|
|
94
|
+
|
|
95
|
+
How those numbers are produced, because it matters when reading them:
|
|
96
|
+
|
|
97
|
+
- Each dictionary entry is selected by evaluating a candidate pool on the **full**
|
|
98
|
+
query set of its source corpus, and the BM25 defaults are always in that pool. An
|
|
99
|
+
entry therefore cannot be worse than the defaults on the corpus it was built from,
|
|
100
|
+
and the table measures the shipped prediction path end to end (dictionary lookup,
|
|
101
|
+
heuristic fallback on a miss).
|
|
102
|
+
- Five corpora (dbpedia-entity, climate-fever, fever, hotpotqa, msmarco) are
|
|
103
|
+
subsampled to 200k–300k documents. The sampler keeps **every** annotated relevant
|
|
104
|
+
document, so absolute values are not comparable with full-corpus BEIR leaderboards;
|
|
105
|
+
both numbers in a cell come from the same sample, queries and judgements.
|
|
106
|
+
- For a corpus that is *not* in the dictionary, parameters come from the nearest
|
|
107
|
+
entry or the heuristics. Leaving the target's own entry out, the dictionary still
|
|
108
|
+
answers **92.0%** of the 50 targets (the 16 corpora plus 34 cross-domain blends).
|
|
109
|
+
|
|
110
|
+
## Quick Start
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
pip install autobm25
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
For a source checkout, use `pip install .`. If Cargo is available, the installer
|
|
117
|
+
tries to build the optional Rust extension; otherwise it installs the fully
|
|
118
|
+
functional pure-Python backend.
|
|
119
|
+
|
|
120
|
+
Then hand it a corpus - a plain list of strings is all it takes, there is no file format to learn:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from autobm25 import AutoBM25
|
|
124
|
+
|
|
125
|
+
corpus = [
|
|
126
|
+
"BM25 is a probabilistic ranking function used in information retrieval.",
|
|
127
|
+
"Automatic parameter tuning improves retrieval effectiveness.",
|
|
128
|
+
]
|
|
129
|
+
retriever = AutoBM25()
|
|
130
|
+
retriever.index(corpus) # a plain list of strings is enough — no files needed
|
|
131
|
+
print(retriever.params) # hyperparameters chosen for this corpus
|
|
132
|
+
retriever.search("parameter tuning", top_k=5)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`corpus` can be anything list-shaped: strings, `{"id", "text"}` records, a column of
|
|
136
|
+
a dataframe, documents you fetched from an API. Nothing is written to disk and no
|
|
137
|
+
directory layout is required. Pass `queries=[...]` to `index()` as well and the
|
|
138
|
+
query-side statistics (query length, query/term overlap, idf) join the same feature
|
|
139
|
+
vector.
|
|
140
|
+
|
|
141
|
+
Documents already in files? `autobm25 --dataset dataset/XXX` runs the same pipeline
|
|
142
|
+
over a directory, and `--interactive` turns it into a prompt - the CLI is a
|
|
143
|
+
convenience wrapper over this API, not a requirement. `autobm25 --demo` needs no files
|
|
144
|
+
at all.
|
|
145
|
+
|
|
146
|
+
The CLI prints the hyperparameters chosen for the dataset (for example,
|
|
147
|
+
`k1=0.5 b=0.495 k3=0.7 δ=0.97 idf=smoothed`).
|
|
148
|
+
|
|
149
|
+
The parameter dictionary is bundled inside the package (`autobm25/data/param_dictionary.json`), so retrieval works out of the box after installation — no need to run any experiments first.
|
|
150
|
+
|
|
151
|
+
Want to see it run before wiring up your own data? `python -m autobm25 --demo` (or the
|
|
152
|
+
`autobm25 --demo` console script) indexes a small built-in corpus - ten invented
|
|
153
|
+
documents about brewing, bicycles and retrieval - and prints the hyperparameters it
|
|
154
|
+
chose for that corpus together with the ranking for a handful of queries, with no
|
|
155
|
+
dataset directory involved. `autobm25 --demo --interactive` then drops you into a
|
|
156
|
+
prompt where you can type your own queries against the same corpus, and
|
|
157
|
+
`examples/quickstart.py` is the same thing as a copy-paste script over a made-up
|
|
158
|
+
support-desk corpus.
|
|
159
|
+
|
|
160
|
+
## Rust acceleration (PyO3)
|
|
161
|
+
|
|
162
|
+
The retrieval core is also implemented in Rust and exposed as a **PyO3** extension
|
|
163
|
+
module, so the hot loops run as compiled code while the API, the feature
|
|
164
|
+
definitions and the rankings stay exactly as documented above.
|
|
165
|
+
|
|
166
|
+
The extension is optional. Published platform wheels include it, while a source
|
|
167
|
+
install compiles it when Cargo is available and falls back to the pure-Python
|
|
168
|
+
implementation when it is not. `autobm25.BACKEND` reports which one is live, and
|
|
169
|
+
`AUTOBM25_NO_RUST=1` forces the Python path.
|
|
170
|
+
|
|
171
|
+
### Which stages are covered
|
|
172
|
+
|
|
173
|
+
The whole pipeline is: load -> tokenize + index -> features -> predict -> search.
|
|
174
|
+
The extension covers the three middle stages plus scoring; the two Python stages are
|
|
175
|
+
the ones where Python is simply the right tool.
|
|
176
|
+
|
|
177
|
+
| Stage | Implementation | Cost |
|
|
178
|
+
| ----------------------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
179
|
+
| loading documents and queries | Python | I/O + `json` parsing, seconds to tens of seconds on multi-GB files; this is why the loader is *not* in Rust |
|
|
180
|
+
| tokenize + inverted index (`build_index`) | **Rust** | one pass over the corpus; also collects the feature statistics on the way |
|
|
181
|
+
| feature vector (`extract_features`) | **Rust** | read off that same pass instead of re-tokenizing |
|
|
182
|
+
| parameter prediction (dictionary lookup + heuristics) | Python | O(1): 48 entries x 21 features, **0.1-0.5 ms per corpus**, cached config/dictionary |
|
|
183
|
+
| `search` / `search_many` | **Rust** | posting-list accumulation, GIL released, one scorer per worker thread |
|
|
184
|
+
| evaluation metrics (CLI batch mode) | Python | numpy over the returned rankings, milliseconds |
|
|
185
|
+
|
|
186
|
+
The loader stays in Python because it is I/O plus JSON parsing, and on the multi-GB
|
|
187
|
+
corpora it is the dominant wall-clock cost - a Rust rewrite would not change the
|
|
188
|
+
disk reads. Prediction stays in Python for a different reason: it is a constant-time
|
|
189
|
+
lookup (0.1-0.5 ms against a 3 s index build), so moving it would buy nothing.
|
|
190
|
+
|
|
191
|
+
### What the Rust extension replaces
|
|
192
|
+
|
|
193
|
+
The bottleneck is not scoring — that part is already vectorized numpy. It is a Python
|
|
194
|
+
loop over every token in the corpus with a dictionary operation per token, **run
|
|
195
|
+
twice**: once to collect the feature statistics and once to build the inverted index.
|
|
196
|
+
|
|
197
|
+
| Stage | Python | Rust |
|
|
198
|
+
| ------------------------ | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
199
|
+
| tokenize + term counting | `str.lower().split()` plus a dict operation per token | one pass over the corpus; terms live in a flat table, so only the first occurrence of a term hashes its string |
|
|
200
|
+
| feature statistics | re-tokenizes every document, and keeps a per-document dict for **all** of them in memory | read off the index build — the corpus is tokenized **once**, not twice |
|
|
201
|
+
| inverted index | one `np.asarray` per distinct term over Python-built posting lists | postings are built during that same pass |
|
|
202
|
+
| BM25 scoring | vectorized, but `np.add.at` scatter-add plus a full-array sort per query | posting-list accumulation, no per-query allocation, GIL released, one scorer per worker thread |
|
|
203
|
+
|
|
204
|
+
`index()` therefore tokenizes the corpus once instead of twice, which is the single
|
|
205
|
+
largest change. The pure-Python `BM25Engine` is unchanged and still available.
|
|
206
|
+
|
|
207
|
+
### Do end users need Rust? No.
|
|
208
|
+
|
|
209
|
+
PyO3 is a **build-time** tool: the Rust code is compiled once into an ordinary
|
|
210
|
+
Python extension module, and the compiled result is what ships. Anyone who
|
|
211
|
+
installs the package just imports it — there is no compiler, no toolchain and no
|
|
212
|
+
extra step on their side:
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
import autobm25 # picks up the compiled extension if present
|
|
216
|
+
|
|
217
|
+
retriever = autobm25.AutoBM25() # Rust-backed
|
|
218
|
+
retriever.index(corpus) # one tokenizing pass
|
|
219
|
+
retriever.search("parameter tuning", top_k=5)
|
|
220
|
+
|
|
221
|
+
print(autobm25.BACKEND) # "rust"
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
That is what a wheel gives you: `pip install <wheel>` (or `pip install autobm25` from
|
|
225
|
+
an index, where pip picks the wheel matching the OS/CPU) and nothing else, because the
|
|
226
|
+
extension is already inside. `abi3-py39` is what makes the matrix manageable - one
|
|
227
|
+
binary per **platform** serves CPython 3.9 and every later version, so wheels are
|
|
228
|
+
built per OS/architecture (`win_amd64`, `manylinux_x86_64`, `macosx_arm64`, …) rather
|
|
229
|
+
than per Python version. A single universal wheel is not possible: a compiled
|
|
230
|
+
extension is platform-specific by definition, which is also why a source checkout
|
|
231
|
+
with no compiled extension present builds a `py3-none-any` pure-Python wheel.
|
|
232
|
+
|
|
233
|
+
Nothing here is ever fatal. `autobm25/_accel.py` imports the extension when it is
|
|
234
|
+
there and falls back to the Python engine when it is not, so `import autobm25`
|
|
235
|
+
cannot fail because of a missing or mismatched binary — it only runs ~7× slower.
|
|
236
|
+
`AUTOBM25_NO_RUST=1` forces the Python backend at runtime,
|
|
237
|
+
`AUTOBM25_SKIP_RUST_BUILD=1` skips the compile step during an install, and
|
|
238
|
+
`autobm25.backend_info()` explains which backend is live.
|
|
239
|
+
|
|
240
|
+
From a source checkout it comes down to `cargo`: with a toolchain on PATH,
|
|
241
|
+
`pip install .` compiles the extension for you, and `python rust/build_pyext.py`
|
|
242
|
+
does the same thing by hand (`--check` also verifies that it imports). Without a
|
|
243
|
+
toolchain you still get a fully working pure-Python package. Set
|
|
244
|
+
`AUTOBM25_REQUIRE_RUST=1` for release builds that must fail rather than silently
|
|
245
|
+
omit the accelerator. Toolchains are tried in order - the default first, then any
|
|
246
|
+
installed `*-windows-gnu` one - and `--toolchain <name>` pins one explicitly.
|
|
247
|
+
|
|
248
|
+
### Measured speedup
|
|
249
|
+
|
|
250
|
+
Measured on 2026-11-09 on Windows with 12 logical cores, CPython 3.11.5,
|
|
251
|
+
numpy 1.26.4 and scipy 1.12.0. Same machine, same data and process,
|
|
252
|
+
`top_k=100`; the Python column is the same package with `AUTOBM25_NO_RUST=1`.
|
|
253
|
+
|
|
254
|
+
| Dataset | Docs | Tokens | Queries | Build index + features | Search (all queries) | End-to-end |
|
|
255
|
+
| ------------- | ------: | -----: | ------: | --------------------------: | --------------------------: | --------------------------: |
|
|
256
|
+
| arguana | 8,674 | 1.45M | 1,406 | 2.27 s → 0.24 s (**9.6×**) | 3.87 s → 0.22 s (**17.6×**) | 6.38 s → 0.52 s (**12.4×**) |
|
|
257
|
+
| scifact | 5,183 | 1.11M | 1,109 | 1.16 s → 0.13 s (**8.8×**) | 0.39 s → 0.06 s (**6.2×**) | 1.59 s → 0.23 s (**6.8×**) |
|
|
258
|
+
| nfcorpus | 3,633 | 0.85M | 3,237 | 0.97 s → 0.11 s (**9.3×**) | 0.35 s → 0.07 s (**4.9×**) | 1.50 s → 0.29 s (**5.2×**) |
|
|
259
|
+
| trec-covid-v2 | 129,179 | 27.2M | 50 | 41.27 s → 4.22 s (**9.8×**) | 0.37 s → 0.05 s (**7.1×**) | 42.88 s → 5.26 s (**8.1×**) |
|
|
260
|
+
|
|
261
|
+
*End-to-end* = load + parameter prediction + index + features + search. The split
|
|
262
|
+
path (`extract_features` and `build_index` called separately, each tokenizing the
|
|
263
|
+
corpus) is **3.7–8.9×** faster.
|
|
264
|
+
|
|
265
|
+
Reading the table: the gap generally widens with corpus size because Python's
|
|
266
|
+
per-token dictionary work is linear in tokens. Scoring gains most on long-query
|
|
267
|
+
workloads (arguana, 17.6×) where the old `np.add.at` scatter-add is slowest, and
|
|
268
|
+
the batch path uses every core with the GIL released.
|
|
269
|
+
|
|
270
|
+
### Parity
|
|
271
|
+
|
|
272
|
+
Running both backends over the same corpus and diffing the feature vector, the chosen
|
|
273
|
+
hyperparameters, the full ranked list of every query and MRR@10 / NDCG@10 /
|
|
274
|
+
Recall@100 gives the same answer either way. On scifact, nfcorpus, arguana and
|
|
275
|
+
trec-covid-v2:
|
|
276
|
+
|
|
277
|
+
- feature values agree to about `1e-11` — they come from different floating-point summation
|
|
278
|
+
orders over identical numbers — and the chosen hyperparameters are identical;
|
|
279
|
+
- every ranked list is bitwise identical, except where documents **tie** at the
|
|
280
|
+
`top_k` cutoff: `np.argpartition` returns an arbitrary member of a tie group,
|
|
281
|
+
while Rust applies the tie-break the Python code documents (lowest document index
|
|
282
|
+
wins). Scores stay bitwise equal, MRR@10 and NDCG@10 are unaffected, and
|
|
283
|
+
Recall@100 can move in the 4th decimal when the tied document at rank 100 is
|
|
284
|
+
judged relevant in one list but not the other.
|
|
285
|
+
|
|
286
|
+
## Limitations
|
|
287
|
+
|
|
288
|
+
- **Out-of-distribution (OOD)**: an entry is validated on the corpus it was built
|
|
289
|
+
from, not on yours. With the target's own entry removed, the dictionary still
|
|
290
|
+
answers 75% of the 16 corpora above — but it is then predicting from a *different*
|
|
291
|
+
corpus, and that is not free: in this leave-one-corpus-out setting 12/16 improve
|
|
292
|
+
(mean +17.0%, median +7.7%) while 4 get worse than the defaults (-8% to -23%:
|
|
293
|
+
fiqa, arguana, climate-fever, vihealthqa — three of which miss the dictionary
|
|
294
|
+
entirely and land on the heuristics). The OOD guard and the heuristic fallback
|
|
295
|
+
limit the damage; they do not remove it. If your corpus is not represented in the
|
|
296
|
+
dictionary, validate the returned parameters before trusting them.
|
|
297
|
+
- **Non-English corpora**: a few features are language-dependent (English stopword
|
|
298
|
+
density, stopword-filtered term statistics). Vietnamese vihealthqa is in the
|
|
299
|
+
dictionary and lands at +2.2%, but for a language the dictionary has never seen,
|
|
300
|
+
validate the returned parameters before trusting them.
|
|
301
|
+
|
|
302
|
+
## Project Structure
|
|
303
|
+
|
|
304
|
+
```
|
|
305
|
+
autobm25/
|
|
306
|
+
├── autobm25/ # the pip package: retrieval API (engine, features, dictionary, CLI) + data/
|
|
307
|
+
├── rust/ # Rust core + PyO3 bindings (python rust/build_pyext.py)
|
|
308
|
+
├── tests/ # pytest API, loader, metrics, dictionary and parity tests
|
|
309
|
+
├── .github/workflows/ # three-platform CI and Trusted Publishing
|
|
310
|
+
├── examples/ # runnable library examples (quickstart.py, ...)
|
|
311
|
+
├── logo/
|
|
312
|
+
├── config.yaml # heuristic coefficients
|
|
313
|
+
├── setup.py # compiles the extension into the wheel (metadata is in pyproject.toml)
|
|
314
|
+
├── pyproject.toml # pip packaging
|
|
315
|
+
├── requirements.txt
|
|
316
|
+
└── README.md / README_CN.md
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
## License
|
|
320
|
+
|
|
321
|
+
[MIT License](LICENSE): the code can be freely **copied, modified, and used commercially**; the only requirement is to **retain the copyright notice and this license text** in copies (i.e., attribution). Linking the repository when using the project satisfies this requirement.
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
[Chinese README](https://github.com/SaturnAura/AutoBM25/blob/main/README_CN.md)
|