Skip to content

Commit 8bb1d9f

Browse files
committed
docs(notes): the agreed design for moving search_docs onto this ranker
Decisions only, nothing implemented. Written down because the implementation happens in the MCP server repo, and the reasoning that rules out the obvious approaches does not survive a repo boundary. The load-bearing ones: section boundaries are derived and published as a build-time map rather than authored as markers, because a `#` opening a line inside a bash fence is indistinguishable from a heading and because re-deriving slugs downstream would be the third copy of md-slug.js. And get_doc's URL fragment becomes the switch between whole page and section, which needs no schema change — so the bump stays a minor and the OpenAI listing review is left alone. Also records what was deferred and why, since caller-supplied constants and human/assistant profiles will look obviously right again later.
1 parent 8b39a85 commit 8bb1d9f

1 file changed

Lines changed: 153 additions & 0 deletions

File tree

notes/mcp-search-docs-plan.md

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# Plan: rework MCP `search_docs` onto the site's ranker
2+
3+
Agreed 2026-08-05, not started. Design decisions only — no code has been written for any of
4+
this. Implementation spans two repos: this one (publish what the server needs) and the
5+
**@imqueue MCP server** (consume it).
6+
7+
## Why
8+
9+
There are two rankers today. The website's lives in `src/_shared/js/search.js` and is measured
10+
by `npm run kpi:search`. The MCP server's is separate: `search_docs` fetches imqueue.org and
11+
reads the purpose-built feeds `/api/search-index.json` (349 symbol entries, see
12+
`src/org/api/search-index.11ty.js`) and `/blog/search-index.json`.
13+
14+
So MCP relevance is **unmeasured**. "Did we improve `search_docs`?" has no answer. Sharing one
15+
ranker collapses two surfaces into one that the KPI already covers, and that is the main win —
16+
bigger than any constant tuning.
17+
18+
It is also nearly free: `search.js` exports itself when `typeof document === "undefined"`,
19+
which is exactly how the KPI drives it 12,281 times per run. The server-side path is already
20+
exercised harder than the browser one.
21+
22+
Secondary win: the site ranker returns **section-level anchors** (719 sections vs page-level
23+
feeds). An agent handed `/page/#specific-heading` stops burning context pulling whole pages —
24+
which is what raises every question below.
25+
26+
## Decisions
27+
28+
### 1. Share the ranker, don't fork it
29+
30+
One relevance implementation, one KPI, one place a fix lands for both surfaces. The ~1 MB of
31+
index (`search-index.json` 326 KB + `search-text.json` 663 KB) is a browser cost, not a server
32+
one: fetch once, cache, invalidate on deploy.
33+
34+
### 2. Section boundaries are DERIVED and PUBLISHED — never authored
35+
36+
No begin/end markers in the markdown. The boundaries already exist three times over:
37+
`markdown-it-anchor` assigns the ids, `scripts/lib/md-slug.js` replicates that slug algorithm,
38+
and `scripts/lib/search-corpus.js` already splits every page into the shipped sections. A
39+
section is *this heading until the next heading of equal-or-higher level, descendants
40+
included*.
41+
42+
Hand-placed markers would be a fourth source of truth, invisible to authors, rotting the first
43+
time someone adds a heading — the same silent drift as the duplicated CLI catalog.
44+
45+
Instead this repo emits a **build-time section map** (anchor → line/byte range) alongside the
46+
markdown mirrors. Two reasons it must be build-time and not computed in the server:
47+
48+
- **Regex slicing is unsafe here.** A `#` starting a line inside a fenced code block is
49+
indistinguishable from a heading, and this site is full of bash blocks with `# comment`.
50+
Slicing by regex will eventually cut inside a fence and emit an unbalanced fence. Ranges
51+
derived from the parsed AST cannot.
52+
- **It avoids a third copy of the slug algorithm.** `md-slug.js` exists *because* this already
53+
had to be replicated once. A third copy diverges on unicode, duplicate-heading `-1`
54+
suffixes, or `markdown-it-attrs` id overrides. The site is the only thing that knows the real
55+
ids, so it publishes them.
56+
57+
### 3. The URL fragment is the switch for `get_doc`
58+
59+
- `get_doc("/page/")` → whole document (today's behaviour, unchanged)
60+
- `get_doc("/page/#heading")` → that section
61+
62+
Chosen over adding a parameter because it needs **no schema change** — which keeps the version
63+
bump a minor and keeps the pending listing review undisturbed (see Constraints). It is
64+
self-describing, backward compatible, and it makes the anchor *mean* something instead of being
65+
stripped. Stripping it would throw away the precision `search_docs` just worked to find.
66+
67+
It also puts the choice with the party that knows: summarising a tutorial chapter wants the
68+
page, checking one API caveat wants the section.
69+
70+
### 4. A slice must carry its context
71+
72+
The failure mode of slicing is not tokens, it is **a confidently wrong answer read out of
73+
context**. Every slice includes:
74+
75+
- the **ancestor heading chain**`### Retry` under `## @imqueue/job` and under
76+
`## @imqueue/rpc` mean different things
77+
- the page title and URL
78+
- an explicit **"this is a section of N, full page at <url>"** notice
79+
80+
The notice matters most: an agent that knows it got a slice can escalate; one that doesn't will
81+
answer from a fragment and never know what it missed.
82+
83+
### 5. Fall back to the whole page, don't error
84+
85+
- **Anchors that aren't headings.** `markdown-it-attrs` allows `{#id}` on a paragraph or table,
86+
where no section boundary exists → return the whole page.
87+
- **Small pages.** Below some size the scaffolding costs more than the slice saves → ignore the
88+
fragment. Keeps the common case boring.
89+
- **Sections that don't stand alone** ("in the example above", a code block defined under a
90+
previous heading). No mechanism fixes this; it is why decision 4 exists.
91+
92+
## Constraints
93+
94+
**The OpenAI MCP listing is in review.** Freeze the *declared surface* until it lands: don't
95+
rename the tool, don't change `limit`'s meaning or bounds (1–20, default 6), don't add or
96+
remove output fields. Internals are safe to change — and notably the egress story does not
97+
move, because `search_docs` already fetches imqueue.org and is host-locked
98+
(`src/org/mcp/security.md`); reading different files on the same host is not a new permission.
99+
100+
Two lesser caveats: any example outputs in the submission go stale (cosmetic, and in the good
101+
direction), and a reviewer may test a build newer than the one submitted (fine while the output
102+
*shape* is identical).
103+
104+
**Version bump: minor.** A pure ranking change is technically a patch, but for an agent-facing
105+
tool the returned set *is* the product, and "patch" would falsely say "nothing you'd notice".
106+
It becomes minor on the merits anyway once section anchors are added (additive, backward
107+
compatible). It would be **major** if `limit` semantics change, an output field is
108+
removed/renamed, or the returned URLs become unusable by existing consumers. A major bump is
109+
also likelier to force the directory entry to be revisited — another reason to stay minor.
110+
Unrelated to the MCP protocol version, which is negotiated separately.
111+
112+
## Verify before implementing
113+
114+
1. **Does `get_doc` accept a fragment today?** It resolves URLs against the markdown mirrors,
115+
emitted as `/page/index.md` (`src/md-mirror.liquid`) and `/page.md`
116+
(`src/md-mirror-flat.liquid`). If it maps URL → mirror path by appending, a fragment lands
117+
on a 404. This determines whether decision 3 is additive or a fix.
118+
2. **Do any current callers pass fragments?** Today's feeds are page-level, so probably not —
119+
which is what makes fragment-triggered slicing purely additive.
120+
3. Whether the MCP server can consume the site's index directly or needs a narrower published
121+
artifact.
122+
123+
## Measurement
124+
125+
"It feels leaner" is not a result. Two numbers:
126+
127+
- **recall@6** on an agent-shaped query slice — `api-symbol` + `api-member` + `api-path` +
128+
`keywords` + `heading` + `title` from the artificial set, ~3,400 queries that are by
129+
construction "vocabulary the agent has already read". Position decay is the wrong metric for
130+
MCP: an agent reads all 6 results, so whether the page is *in the set* is what matters, not
131+
whether it is first.
132+
- **tokens spent to reach the right content**, comparing `get_doc(page)` against
133+
`get_doc(#section)`*with* a check that the answer-bearing text is actually inside the
134+
slice. Without that second half the metric rewards truncating everything to nothing.
135+
136+
For recall, the lever is not IDF — it is the eviction machinery (`GROUP_FLOOR` 0.3,
137+
`MIN_SCORE` 60, `MAX.perPage` 2). For a human, eviction is a kindness that stops near-misses
138+
filling the group under the answer. For an agent with 6 slots a dropped candidate is
139+
unrecoverable — and eviction is exactly what deleted the right page for
140+
"idempotency microservices" rather than merely ranking it low.
141+
142+
## Explicitly deferred
143+
144+
**Per-caller tuning constants, and human/assistant profiles.** Judged premature. Caller-supplied
145+
constants would destroy the KPI (no single ranker left to measure) and hand tuning to an LLM
146+
that has no feedback signal. Profiles are defensible in principle but must be scoped to
147+
retrieval/eviction/output shape — never to the scoring weights, which encode what a page is
148+
*about* and are a property of the corpus, not the reader.
149+
150+
Revisit only after the shared ranker ships and one *measured* difference justifies the
151+
abstraction: run MCP with a single deviation (eviction off, `limit`-sized result set) and see
152+
whether recall@6 moves. If profiles ever land, the gate is non-negotiable — no profile without
153+
a KPI baseline, or the second one rots like the duplicated CLI catalog.

0 commit comments

Comments
 (0)