Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down Expand Up @@ -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) |
Expand Down
21 changes: 20 additions & 1 deletion peekdocs/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -213,16 +220,28 @@ 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),
"skipped_files": [{"file": f, "error": e} for f, e in result.skipped_files],
"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(
Expand Down
33 changes: 33 additions & 0 deletions tests/test_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading