You searched for "python search", got 40 results, and each row shows... a title and nothing else. The user still has to open each one to find out which paragraph actually matched. Every good search UI solves this the same way: a short snippet around the match, with the query terms bolded. Google does it, GitHub does it, your docs site should too.
You don't need Elasticsearch for that. Whoosh — the pure-Python full-text search library — ships a full highlighting engine. Here's how to use it.
pip install whoosh3
(whoosh3 is the maintained fork; you still import whoosh.)
A flag worth setting
Pass terms=True when you search. Whoosh can usually reconstruct the query terms on its own, so basic highlighting works without it — but terms=True makes Whoosh record exactly which terms matched each document, which is more accurate for expanded queries (wildcards, prefixes, fuzzy) and also lets you call hit.matched_terms(). It's cheap and it's what the docs recommend, so set it:
from whoosh.index import create_in
from whoosh.fields import Schema, TEXT, ID
from whoosh.qparser import QueryParser
schema = Schema(title=ID(stored=True), body=TEXT(stored=True))
ix = create_in("indexdir", schema)
w = ix.writer()
w.add_document(title="doc1", body=(
"Whoosh is a fast, pure-Python full-text indexing and search library. "
"Because it is written entirely in Python, it is easy to install anywhere "
"and easy to extend. Full-text search means ranking documents by relevance."))
w.commit()
with ix.searcher() as s:
q = QueryParser("body", ix.schema).parse("python search")
results = s.search(q, terms=True) # <-- terms=True
for hit in results:
print(hit["title"])
print(hit.highlights("body"))
One important gotcha: the field you highlight must be stored (TEXT(stored=True)), or you have to hand Whoosh the text yourself via highlights("body", text=my_text) — handy when the source lives in a database instead of the index.
Out of the box you get HTML with the matched terms wrapped:
and <b class="match term0">search</b> library. Because...written entirely in
<b class="match term1">Python</b>, it is easy to install...Full-text
<b class="match term0">search</b> means ranking documents
Notice the ... — Whoosh already picked the most relevant chunks and joined them. That's the fragmenter and formatter doing their jobs.
Format for the web
Set attributes on the results object (not the hit) to control output. For a web page, emit <mark> tags and drive the styling from CSS:
from whoosh import highlight
results = s.search(q, terms=True)
results.formatter = highlight.HtmlFormatter(tagname="mark", classname="hl", termclass="t")
results.fragmenter = highlight.ContextFragmenter(maxchars=100, surround=30)
print(results[0].highlights("body", top=2))
Whoosh is a fast, pure-<mark class="hl t0">Python</mark> full-text indexing and
<mark class="hl t1">search</mark> library. Because it is written...and easy to
extend. Full-text <mark class="hl t1">search</mark> means ranking documents by relevance
ContextFragmenter(maxchars, surround) controls snippet length and how much text to keep on either side of a match — that's your "…show 30 chars around each hit" knob. top=2 limits how many fragments you stitch together per result.
Format for a terminal
Building a CLI instead? Swap the pieces. SentenceFragmenter cuts on sentence boundaries, and UppercaseFormatter needs no markup:
results.fragmenter = highlight.SentenceFragmenter()
results.formatter = highlight.UppercaseFormatter()
print(results[0].highlights("body"))
Whoosh is a fast, pure-PYTHON full-text indexing and SEARCH library...
Full-text SEARCH means ranking documents by relevance
For real terminals, UppercaseFormatter is easy to replace with your own formatter that wraps matches in ANSI color codes — a formatter is just an object with a format_token method, ~10 lines.
The pieces, and when to reach for each
-
Fragmenter — decides where snippets start and end.
ContextFragmenter(chars around each match, great default),SentenceFragmenter(whole sentences),WholeFragmenter(the entire field, e.g. short titles),PinpointFragmenter(positions). -
Formatter — decides how matches are marked.
HtmlFormatter,UppercaseFormatter,NullFormatter, or your own. -
Scorer / order —
ContextFragmenterscores candidate fragments so the densest, most relevant chunk shows first, not just the first occurrence.
That last point is the difference between a snippet that reads like a real answer and one that starts at "The..." every time.
Two footguns worth knowing
-
The field must be stored (
TEXT(stored=True)), or highlighting has no text to work with — passtext=...tohighlights()if the source lives elsewhere (e.g. a database). -
Phrase queries highlight loosely by default. Search
"full text"as a phrase and Whoosh marks every occurrence offulland everytext, not just the phrase. Passstrict_phrase=Truetohighlights()to mark only the real phrase matches:
hit.highlights("body", strict_phrase=True)
# ...This is <b>full</b> <b>text</b> search over full documents...
# (the standalone "full" and "text" are left unmarked)
Why this matters
A ranked list tells the user which documents are relevant. Highlighting tells them why — and that's most of the perceived quality of a search feature, for a few lines of code and zero extra infrastructure. All pure Python, no server to run.
Whoosh had been unmaintained for years; I've been maintaining a fork — pip install whoosh3 — with Python 3.9–3.14 support (including free-threaded builds), a modern toolchain, and docs. Code, issues, and roadmap: https://github.com/priya-sundaram-dev/whoosh — stars and bug reports both very welcome.
Top comments (0)