Your search's "best" result is wrong — tuning relevance in Whoosh

python dev.to

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)]
Enter fullscreen mode Exit fullscreen mode

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),
)
Enter fullscreen mode Exit fullscreen mode

Re-index and search again:

[('2', 1.832), ('1', 1.789), ('3', 1.666)]
Enter fullscreen mode Exit fullscreen mode

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)]
Enter fullscreen mode Exit fullscreen mode

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)]
Enter fullscreen mode Exit fullscreen mode

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")])
Enter fullscreen mode Exit fullscreen mode
no boost:          [('2', 1.832), ('3', 1.666), ('1', 1.258)]
title boost 3.0:   [('1', 3.775), ('2', 1.832), ('3', 1.666)]
Enter fullscreen mode Exit fullscreen mode

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

  1. Start with structure, not scoring math. Set field_boost on titles/tags first — it fixes most "wrong best result" complaints on its own.
  2. Only then touch B and K1, and change one at a time.
  3. 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.
  4. Reach for MultiWeighting once 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.

Source: dev.to

arrow_back Back to Tutorials