You wired up search, typed a query, and the result you expected to rank first came in last.
This is the most common complaint about any search feature, and it's almost never a bug — it's
a ranking decision the library made on your behalf. The good news: with Whoosh, the pure-Python
search library, every one of those decisions is a knob you can turn, and you can see the score
that produced the order.
Here's a practical tour of the four levers that actually move results, in the order you should
reach for them.
pip install whoosh3— the actively-maintained fork of Whoosh. Every snippet below runs
against the current release (3.53.1).
A tiny corpus that misbehaves
from whoosh.fields import Schema, TEXT, ID
from whoosh.filedb.filestore import RamStorage
from whoosh.qparser import MultifieldParser
schema = Schema(title=TEXT(stored=True), body=TEXT(stored=True), id=ID(stored=True))
ix = RamStorage().create_index(schema)
w = ix.writer()
docs = [
("1", "python tutorial for beginners", "A friendly walkthrough of variables, loops and functions."),
("2", "cooking with a snake", "python python python python python python python everywhere here."),
("3", "advanced metaclasses", "deep dive python internals, python descriptors, python python."),
]
for i, t, b in docs:
w.add_document(id=i, title=t, body=b)
w.commit()
with ix.searcher() as s:
q = MultifieldParser(["title", "body"], schema=ix.schema).parse("python")
print([(r["id"], round(r.score, 3)) for r in s.search(q, limit=None)])
# [('2', 1.832), ('3', 1.666), ('1', 1.258)]
Doc 1 is the actual python tutorial. It comes dead last, because doc 2 (about snakes) and
doc 3 (metaclasses) simply say "python" more often in their bodies. This is the problem we're
going to fix, one lever at a time.
Lever 1: field boosts (structure beats frequency)
A match in the title should count for more than a match buried in the body. Declare that
once, at schema time, with field_boost:
schema = Schema(
title=TEXT(stored=True, field_boost=2.0), # a title hit is worth double
body=TEXT(stored=True),
id=ID(stored=True),
)
Re-index and search again:
[('2', 1.832), ('1', 1.789), ('3', 1.666)]
Doc 1 climbs from last to second, nudging past the metaclasses post purely because "python"
sits in its title. Field boosts are the single highest-leverage tuning you can do, because they
encode what your data means: titles, tags and headings are stronger signals than body text,
and you almost always know that up front.
Lever 2: length normalization (stop rewarding keyword stuffing)
Doc 2 still leads — because BM25F, Whoosh's default scoring, rewards term frequency. But it
also ships a defence against keyword stuffing called length normalization, governed by the
parameter B (0.0–1.0, default 0.75). Turn it off and long, repetitive documents stop
getting a free pass:
from whoosh import scoring
# on the field-boosted index from Lever 1:
with ix.searcher(weighting=scoring.BM25F(B=0.0)) as s: # ignore document length
...
# [('1', 1.933), ('2', 1.878), ('3', 1.692)]
There it is: the tutorial finally ranks #1. B=0.0 means "document length doesn't matter", so
doc 2 no longer profits from cramming "python" seven times into one line. The companion knob,
K1 (default 1.2), controls how fast repeated terms hit diminishing returns — lower it toward
zero and the 2nd, 3rd, 4th occurrence of a word barely helps. If one loud document keeps
drowning out concise, precise ones, that's your pair of dials: raise B or lower K1.
Lever 3: per-field scoring with MultiWeighting
B=0.0 everywhere is a blunt instrument — you probably want no length penalty on short
titles but a strong one on long, rambling bodies. MultiWeighting lets you set different
rules per field, stacked on top of your field boost:
from whoosh import scoring
from whoosh.query import Term, Or
mw = scoring.MultiWeighting(
scoring.BM25F(B=0.75), # default for any field not named below
title=scoring.BM25F(B=0.0), # titles are short; don't length-normalize
body=scoring.BM25F(B=1.0), # bodies vary wildly; normalize hard
)
with ix.searcher(weighting=mw) as s: # field_boost=2.0 still on the title
q = Or([Term("title", "python"), Term("body", "python")])
print([(r["id"], round(r.score, 3)) for r in s.search(q, limit=None)])
# [('1', 1.933), ('2', 1.818), ('3', 1.658)]
Doc 1 holds first place, and now you're not just doubling a number — you're saying titles and
bodies obey different physics, which is the honest version of "the title matters more".
Lever 4: query-time boosts (context you only know at search time)
The first three levers are baked into the index or the searcher. But sometimes a field's
importance depends on this query — a search box that knows the user is really after a tag,
say. Boost individual query terms at search time:
from whoosh.query import Term, Or
# weight the title match 3x for THIS query only
q = Or([Term("title", "python", boost=3.0), Term("body", "python")])
no boost: [('2', 1.832), ('3', 1.666), ('1', 1.258)]
title boost 3.0: [('1', 3.775), ('2', 1.832), ('3', 1.666)]
The query parser understands this too — the string python^3 tutorial parses to a boosted
term, so you can even hand this dial to power users inside the search box.
How to actually tune, without guessing forever
-
Start with structure, not scoring math. Set
field_booston titles/tags first — it fixes most "wrong best result" complaints on its own. - Only then touch B and K1, and change one at a time.
- Write down 5–10 real queries and the result you want ranked #1. Print scores like the snippets above and watch the whole ordering as you turn a knob — a change that helps one query often quietly hurts another.
-
Reach for
MultiWeightingonce your fields genuinely differ in length and importance.
Relevance tuning feels like a dark art mostly because most engines hide the score. Whoosh
doesn't: r.score is right there, every weighting class is a few lines of readable Python you
can read or subclass, and the whole thing runs in-process with no cluster to babysit. Start
with a title boost, keep a list of test queries, and turn one dial at a time.
Whoosh is pure-Python full-text search: pip install whoosh3. Repo, docs and a live
in-browser demo are linked from the project page — stars and issues welcome.