A tiny, zero-dependency BM25-lite in-memory text search index.
Project description
zerosearch
A tiny, zero-dependency BM25-lite in-memory text search index — standard
library only, a single small module, and good enough to power retrieval for a
RAG pipeline. Designed to run anywhere Python runs, including constrained
environments like Cloudflare Python Workers (Pyodide) where pulling in
scikit-learn/numpy is not an option.
It is a spiritual cousin of minsearch:
same Index / .fit() / .search() API, but reimplemented from scratch with no
third-party dependencies.
Drop-in replacement
zerosearch mirrors the minsearch
API — Index(text_fields, keyword_fields), index.fit(docs), and
index.search(query, filter_dict, boost_dict, num_results) — so you can swap it in
without changing your call sites. It is used exactly this way in
DataTalksClub/faq-assistant as the
retrieval engine.
Note on ranking vs minsearch: zerosearch uses BM25-lite scoring, not minsearch's
TF-IDF + cosine similarity — different algorithms, so the rankings are not
bit-for-bit identical. Retrieval quality is on par, though: on the faq-assistant
benchmark zerosearch matches minsearch's recall (it surfaces the same relevant
documents in the top results), it just orders them differently. It is 100% identical
to the in-repo BM25-lite engine it replaced.
Install
pip install zerosearch
Usage
from zerosearch import Index
docs = [
{"id": "1", "title": "Docker compose basics", "text": "how to start services", "course": "de"},
{"id": "2", "title": "Kafka consumers", "text": "consumer groups explained", "course": "de"},
]
index = Index(
text_fields=["title", "text"],
keyword_fields=["id", "course"],
)
index.fit(docs)
results = index.search(
"how do I start docker compose",
filter_dict={"course": "de"}, # keyword filter (scalar = exact, list = IN)
boost_dict={"title": 3.0, "text": 1.0}, # per-field boosts
num_results=5,
)
for result in results:
print(result["score"], result["title"])
Each result is a shallow copy of the original document dict with an added
"score" key.
Filtering
A filter_dict value can be a scalar (exact match) or a list/tuple/set
(match any of the values — IN / OR within that field). Filters on different
fields are combined with AND.
index.search("docker", filter_dict={"course": "de"}) # course == "de"
index.search("docker", filter_dict={"course": ["de", ""]}) # course == "de" OR course == ""
index.search("docker", filter_dict={"course": ["de", "mlops"], "kind": "faq"}) # (de OR mlops) AND faq
Saving & loading a prebuilt index
fit() does all the tokenization work up front. For latency-sensitive or
cold-start-sensitive deployments (serverless functions, CLIs) you can build the
index once — e.g. in CI — and ship the prebuilt artifact, so the process loads in
milliseconds instead of re-tokenizing the whole corpus on startup.
# build step (CI / offline)
Index(text_fields=["title", "text"], keyword_fields=["id", "course"]).fit(docs).save("index.zsx")
# runtime (loads in ~ms, no re-tokenization)
index = Index.load("index.zsx")
results = index.search("docker compose")
dumps() / loads() are the in-memory equivalents (return/accept bytes). The
artifact is a marshal blob, so
documents must hold only marshal-able values (the JSON-like types a search corpus
normally contains). Loading verifies a format tag and the platform's array item
sizes and raises ValueError on a mismatch — rebuild from source if the format
version or Python/platform changed. If you built with a custom tokenizer, pass
it back in: Index.load("index.zsx", tokenizer=my_tokenizer) (the default
tokenizer plus its stop words restore automatically).
How it works
-
Packed runtime state —
fit()builds withCounterscaffolding, then compacts the index into flatarraybuffers (a CSR-style postings list). That packed form is whatsearch()reads and whatsave()/load()round-trip, so a prebuilt index loads without rebuilding any Python objects per document. -
Tokenizer — lowercased word/number tokens; keeps
+ . # _ -inside a token soc++,node.js,f-stringsurvive (a token must start with a letter/digit). Drops 1-character tokens and a small English stop-word list (both overridable). -
Inverted index — built once in
fit(). A query only scores documents that actually contain a query term, so search is fast even on large corpora. -
Ranking — BM25-lite: each query term contributes
boost * idf * (term_frequency / sqrt(field_length))per field. IDF and document frequencies are computed over the filtered candidate set.
Customizing
index = Index(
text_fields=["title", "text"],
stop_words={"the", "a", "an"}, # replace the default stop words
tokenizer=lambda s: s.lower().split(), # or plug in your own tokenizer
)
index.fit(docs)
License
WTFPL.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file zerosearch-0.3.0.tar.gz.
File metadata
- Download URL: zerosearch-0.3.0.tar.gz
- Upload date:
- Size: 62.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
67026b01562edbebb44bc6ad788d967572ad6eec7120041beec122fd1e3b85c6
|
|
| MD5 |
f2edd2ae946159938f7253d6019d0151
|
|
| BLAKE2b-256 |
b9ab964a7e2bd5e88b5a0230bb19234223feabfe020d2953792919e3c21f4d0b
|
File details
Details for the file zerosearch-0.3.0-py3-none-any.whl.
File metadata
- Download URL: zerosearch-0.3.0-py3-none-any.whl
- Upload date:
- Size: 9.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5b9a3dc4acbbd4578ee2c6f1d35cb51e34b7bb6891b9084f11523767ea7781d2
|
|
| MD5 |
0466ab459adc72dcee3c86c40bb27781
|
|
| BLAKE2b-256 |
d66109464c703e88d56e2c8b6ff0c810c4e612b411d93431ae4ec930befb449d
|