From d7e22aff1586bc68435ca8c7a9c2c8a29a232ba1 Mon Sep 17 00:00:00 2001 From: "Robert D. Schoening" Date: Mon, 20 Jul 2026 20:55:55 -0400 Subject: [PATCH] feat(mcp): rank option for search_documents (relevance-order the capped window) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2 of ranking — wire it into the AI path. When results are capped, ranking makes the returned window the most-relevant matches instead of the first ones (the original motivation for ranking). - search_documents gains rank: bool = False, threaded to api.search(rank=...). - Index-gated and honest about it: ranking needs the on-disk index, but the MCP server defaults to read-only/no-index. If rank is requested and the search didn't use the index (result.used_index False), the response carries a rank_note saying results are in file order, not ranked — so the model won't present first-N as best-N. Docstring tells it to relay that note. - Default off (opt-in), consistent with detail; the model ranks for "most relevant..." style questions. - tests: rank-without-index adds note + stays file order; rank-with-index reorders (dense first) with no note; default has no note. - docs: CHANGELOG, USER_GUIDE Tools row + a fourth "want to see more" lever. Remaining slice: GUI checkbox. No version bump (Unreleased). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Robert D. Schoening --- CHANGELOG.md | 8 ++++++++ docs/USER_GUIDE.md | 4 +++- peekdocs/mcp_server.py | 21 ++++++++++++++++++++- tests/test_mcp_server.py | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0be9061..0405273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] ### Added +- **MCP `search_documents` gains a `rank` option — relevance-order the matches + the assistant sees.** When results are capped, ranking makes the returned + window the *most relevant* matches rather than just the first ones. Opt-in + (default off). Index-gated like the CLI `--rank`: if the on-disk index isn't + enabled the search still succeeds but comes back in file order and the + response carries a `rank_note` saying so — so the assistant won't present + file-order results as "most relevant." (Wires the ranking feature below into + the AI path.) - **Optional relevance ranking (`--rank`) — order matches by BM25 instead of file order.** Opt-in; **changes only the order, never which matches are returned**, and preserves peekdocs's exact substring matching. Ranks by term diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 387180d..204e45b 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -1692,7 +1692,7 @@ These files are often in hidden folders; the [beginner guide](LOCAL_AI_SETUP.md# | Tool | What it does | |---|---| -| `search_documents` | Search for terms, regex, fuzzy, whole-word, or a boolean expression across a folder; returns matching lines with file and line number. Supports context lines, type filters, and exclusions. A `detail` mode chooses how much comes back per match: `full` (default) includes the matched text; `locations` returns just file + line (no text) — far more token-efficient for a broad "which files?" pass or when results are being truncated, then read the ones you want with `get_document_context`. | +| `search_documents` | Search for terms, regex, fuzzy, whole-word, or a boolean expression across a folder; returns matching lines with file and line number. Supports context lines, type filters, and exclusions. A `detail` mode chooses how much comes back per match: `full` (default) includes the matched text; `locations` returns just file + line (no text) — far more token-efficient for a broad "which files?" pass or when results are being truncated, then read the ones you want with `get_document_context`. A `rank` option (off by default) orders matches by relevance so the capped window is the *most relevant* matches, not just the first — it needs the on-disk index enabled (otherwise the response says so via a `rank_note`). | | `get_document_context` | Return the lines surrounding matches of a query within one named file. | | `inventory_folder` | List the searchable files in a folder (path, size, modified time, type) without reading their contents. | | `list_supported_file_types` | List the extensions peekdocs can search (optionally including OCR image types). | @@ -1864,6 +1864,8 @@ You trigger it in plain language — you never type `detail=locations` yourself; The last one is the natural recovery: the truncation note nudges toward it, so when a full search doesn't fit, you (or the assistant) can fall back to locations and still see the full spread of matches. +A fourth lever is subtler — it doesn't fit *more* matches, it makes the ones you get the *right* ones. Ask for the **most relevant** matches (*"find the passages most relevant to the renewal terms"*) and the assistant passes `rank=true`, so the capped window is the best matches by relevance rather than the first ones in file order. This one needs the on-disk index turned on (`--allow-index` / `allow_index_write`); without it the search still works but stays in file order, and the response says so — see [Search Index](#search-index-optional). + **Or run the model in the cloud (e.g. Claude) instead.** Two of the constraints above came from the *local* model: 8192 tokens of memory forced the low cap, and a 7B model is a modest reasoner. A **cloud** assistant such as Claude Desktop or Claude Code sidesteps both — same peekdocs server, same tools, just a more capable model behind the host — at the cost of the matched snippets leaving your machine. In this example the trade looks like: | | Local model (as above) | Cloud assistant (e.g. Claude) | diff --git a/peekdocs/mcp_server.py b/peekdocs/mcp_server.py index 8c1386e..6e8b18a 100644 --- a/peekdocs/mcp_server.py +++ b/peekdocs/mcp_server.py @@ -162,6 +162,7 @@ def search_documents( use_ocr: Optional[bool] = None, allow_index_write: Optional[bool] = None, detail: str = "full", + rank: bool = False, ) -> dict[str, Any]: """Search local documents for text and return matching lines. @@ -190,6 +191,12 @@ def search_documents( get_document_context on the files you care about to read their text. Only request "locations" when the user's question doesn't need the matched text itself; if you use it, say so — you have not seen the surrounding wording. + rank: order matches by relevance (most relevant first) instead of file + order. Useful when results are capped — the returned window becomes the + best matches, not just the first ones. Best for "find the most relevant..." + style questions. Requires the on-disk index; if it isn't enabled the search + still succeeds but comes back in file order and the response carries a + "rank_note" saying so (relay it — don't imply the results were ranked). """ recursive = _CONFIG.recursive_default if recursive is None else recursive use_ocr = _CONFIG.ocr_default if use_ocr is None else use_ocr @@ -213,9 +220,10 @@ def search_documents( range_filters=range_filters, use_ocr=use_ocr, use_index=None if allow_index_write else False, + rank=rank, ) matches, envelope = _cap(_match_dicts(result.matches, detail), detail_hint=True) - return { + out: dict[str, Any] = { "searched_directory": d, "matches": matches, "files_searched": len(result.files_searched), @@ -223,6 +231,17 @@ def search_documents( "elapsed_seconds": round(result.elapsed, 3), **envelope, } + # Ranking is index-gated. If it was requested but the search didn't use the + # index (the server's default is read-only / no index), say so plainly — + # otherwise the model may present file-order results as "most relevant". + if rank and not result.used_index: + out["rank_note"] = ( + "Ranking was requested but needs the on-disk index, which is not " + "enabled here — these results are in file order, not ranked by " + "relevance. (Enable the index with allow_index_write, or on the " + "server with --allow-index, to rank.)" + ) + return out def get_document_context( diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index c1c6da2..8592ea7 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -327,3 +327,36 @@ def fake_search(query, **kwargs): with pytest.raises(_Stop): m.search_documents(["fox"], directory=str(tmp_path), recursive=None) assert captured["recursive"] is True + + +class TestRankParam: + def test_rank_without_index_adds_note_and_stays_file_order(self, tmp_path): + _seed(tmp_path) + out = m.search_documents( + ["fox"], directory=str(tmp_path), recursive=True, rank=True + ) + # No index enabled → ranking can't apply; the note must say so. + assert "rank_note" in out + assert "index" in out["rank_note"].lower() + assert len(out["matches"]) == 2 # still returns the matches + + def test_rank_with_index_reorders_and_no_note(self, tmp_path): + from peekdocs.indexer import build_index + (tmp_path / "dense.txt").write_text("the roof warranty period is ten years\n") + (tmp_path / "repetitive.txt").write_text( + "warranty warranty warranty warranty boilerplate terms apply\n") + (tmp_path / "buried.txt").write_text( + "the roof over the whole building is grey and large and old " * 4 + "\n") + build_index(str(tmp_path), recursive=True) + out = m.search_documents( + ["roof", "warranty"], directory=str(tmp_path), recursive=True, + rank=True, allow_index_write=True, + ) + assert "rank_note" not in out + names = [os.path.basename(x["file"]) for x in out["matches"]] + assert names[0] == "dense.txt" # most relevant first + + def test_default_no_rank_note(self, tmp_path): + _seed(tmp_path) + out = m.search_documents(["fox"], directory=str(tmp_path), recursive=True) + assert "rank_note" not in out