|
| 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