Skip to content

feat(search): optional BM25 relevance ranking (--rank)#11

Merged
exbuf merged 1 commit into
mainfrom
feat/relevance-ranking
Jul 21, 2026
Merged

feat(search): optional BM25 relevance ranking (--rank)#11
exbuf merged 1 commit into
mainfrom
feat/relevance-ranking

Conversation

@exbuf

@exbuf exbuf commented Jul 21, 2026

Copy link
Copy Markdown
Owner

What

Optional relevance ranking — peekdocs --rank <terms> (CLI) / search(..., rank=True) (API). Orders matches by BM25 instead of the default file-then-line order. Slice 1 of 3 (engine + CLI); MCP wiring and a GUI checkbox are follow-up slices.

Opt-in, and it changes only the order — never which matches are returned — preserving peekdocs's exact substring matching and its deterministic story.

The mid-build discovery (why this isn't the "one ORDER BY" we expected)

The design note's premise was "ranking is a one-line ORDER BY bm25(), the index already has it." Wiring it up proved that half-wrong:

  • Literal searches go through _direct_scan_search — a Python substring scan (so "bob" matches "bobcat") that never touches FTS5, so the index's bm25() is unreachable there.
  • bm25() only lives on the whole-word _fts5_fast_search path.
  • Routing literal searches through FTS5 to reach bm25() would change which results match (token vs substring) — breaking the "only order changes" promise.

Resolution (Option B, chosen with the maintainer): a Python BM25 scorer (_rank_matches) applied to the matched set after matching — uniform on both paths, exact-substring-preserving, deterministic.

How it ranks

Classic BM25 over the matched paragraphs: term rarity (IDF), term frequency with saturation (repetition can't dominate), and length normalization (density beats size). So a short on-point passage outranks a file that just repeats a common word — not by match-count-per-file.

Behavior / constraints

  • Index-gated — ranking is applied on the indexed path; --rank without an index prints a note and returns file-order results (graceful no-op, verified).
  • Non-context only — context searches regroup by file (context windows), which ranking would scramble; rank is skipped there.
  • Regex/fuzzy unaffected (they don't reach the ranked path).

Verification

  • 785 passed / 8 skipped; new TestRanking (unit scorer: ordering, determinism, empty/no-terms; integration: same-matches-reordered, no-index safe no-op).
  • mypy clean (14 files); doc-links clean.
  • No version bump (Unreleased).

Docs

CHANGELOG (Unreleased), USER_GUIDE CLI flag table. The ops design note is corrected to record the substring-path discovery and the Option-B resolution.

🤖 Generated with Claude Code

Slice 1 (engine + CLI) of the ranking feature. Opt-in; changes only the ORDER
of matches, never which match, and preserves exact substring semantics.

Design discovery mid-build (see ops/RELEVANCE_RANKING_DESIGN.md correction): the
"one ORDER BY bm25" plan doesn't work for the default path — literal searches go
through _direct_scan_search (Python substring scan, so "bob" matches "bobcat")
and never touch FTS5, so the index's bm25() is unreachable there. Routing them
through FTS5 would change WHICH results match (token vs substring), breaking the
"only order changes" promise. Resolution (Option B): a Python BM25 scorer
applied to the matched set after matching — uniform on both the substring and
whole-word paths, exact-substring-preserving, deterministic.

- indexer._rank_matches: BM25 over the matched set (term rarity/IDF, saturated
  TF, length normalization); ties keep original order.
- indexer.search_with_index: applies it when config["rank"] and not use_context
  (context regroups by file, which ranking would scramble).
- api.search: new rank=False param, threaded via search_config; docstring.
- cli: --rank flag + help text; prints a note if --rank is used without an index
  (index-gated — ranking lives on the indexed path).
- tests: unit scorer (ordering, determinism, empty/no-terms) + integration
  through search() (same matches reordered; no-index no-op is safe).

Scope: CLI + engine. MCP wiring and GUI checkbox are follow-up slices.
No version bump (Unreleased).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Robert D. Schoening <bobschoening@gmail.com>
@exbuf
exbuf marked this pull request as ready for review July 21, 2026 00:50
@exbuf
exbuf merged commit fbdbc95 into main Jul 21, 2026
20 checks passed
@exbuf
exbuf deleted the feat/relevance-ranking branch July 21, 2026 00:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant