PubMed joins web search as an optional source for a generated resource: a
literature search on the topic, with abstracts, cited by PMID in References.
Off by default, admin-enabled, with its own optional API key (NCBI raises the
rate limit from 3/sec to 10/sec; it works without one).
Neither search is a tool any more, and that is the point. Offering them as
function calls meant the model decided whether to search, and with a prompt
ending "Output ONLY Pandoc markdown" it decided not to — every time, with and
without corpus grounding, no matter how the tool description was worded.
Calling callAI with the tool directly produced a correct pubmed_search call, so
the plumbing was never the problem. The search only ever needed the topic, and
the route knows the topic before it calls the model, so both searches now run up
front and their results go into the prompt as findings, exactly the way corpus
excerpts do. Ticking the box now means the search happened.
Verified live against deepseek-v4-flash: 30 corpus excerpts and 6 PubMed
results, and a References slide carrying both the library sources and four real
PMIDs (29562151, 38506440, 35721052, 28814254).
Three fixes to illustration, which had never once fired:
- The dispatch call had been lost in a refactor. The tool was still offered, the
model still called it, and the call was dropped, so no job was ever enqueued.
- imageContext was passed as a bare topic string where dispatch expects
{ request, history }, which made the bound request undefined.
- The prompt never mentioned the tool existed while explicitly demanding only
markdown — the same suppression that killed the searches. It now says an
illustration is available and that calling it is not a violation of that rule.
my_resources is its own image workflow rather than a reuse of learning_hub,
because generated_image_links only accepts learning_hub assets, and that is
exactly the barrier that keeps a private illustration out of published content.
The illustration renders in the panel, rather than a toast pointing at an image
history this feature does not have.
Verified end to end: job queued, rendered, and the asset served to its owner as
a correctly labelled subglottic-anatomy teaching diagram.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
5.7 KiB
Retrieval tuning — how many excerpts each feature gets
Three features read from the same clinical corpus, and each takes a different amount of it. This is where the numbers live and what actually changes them.
Everything here is a Milvus collection called mcp_bge_m3_1024, embedded with
openrouter-bge-m3 at 1024 dimensions, searched through the clinical MCP
(clinical-assist-query, deployed from clinical-assist-deploy/). There is one
corpus. Only the budgets differ.
One knob per feature
Each feature decides how many excerpts it gets, in the app_settings table.
Nothing else overrides it.
That was not always true, and the history is worth knowing because the trap is
easy to recreate. A search runs in two stages: Milvus returns candidates by
vector similarity, then a reranker (cohere-rerank-v4.0-pro) scores each
against the query and keeps the best. The reranking step computes:
top_k = min(settings.reranker_top_k or limit, limit)
RERANKER_TOP_K used to default to 12, so a caller asking for 30 excerpts
received 12, and nothing in any configuration file explained why. Changing one
number meant changing two settings in two repositories that had to agree — which
is not a design anyone chose, it is two codebases each assuming it owned the
decision.
It is now 0, which the expression reads as "however many the caller asked
for". The app setting is the only knob.
# clinical-assist-deploy/docker-compose.yml — on both mcp and mcp-indexer
- RERANKER_TOP_K=${RERANKER_TOP_K:-0}
- RERANKER_FETCH_MULTIPLIER=${RERANKER_FETCH_MULTIPLIER:-5}
Zero costs nothing extra. The reranker is billed on the documents sent,
which is candidate_limit and unchanged; top_n only decides how many come
back. Set RERANKER_TOP_K to a real number only if you want a hard ceiling
across every feature regardless of what each asks for.
RERANKER_FETCH_MULTIPLIER decides how many candidates the reranker sees:
candidate_limit = max(limit, limit × multiplier). This is the cost lever.
5 is the default and has not needed changing.
Per-feature budgets
These live in the app_settings table, are read live (2-minute cache), and are
clamped on read so a bad value cannot break a search.
| Feature | Keys | Default | Clamp |
|---|---|---|---|
| Clinical Assistant | clinical_assistant.search_limit, clinical_assistant.context_chars |
8, 1400 | 3–20, 300–4000 |
| Learning Hub | learning.search_limit, learning.context_chars |
30, 2500 | 3–60, 300–8000 |
| My Resources | the same learning.* keys |
30, 2500 | 3–60, 300–8000 |
search_limit is how many excerpts to request; context_chars is how much text
to pull around each one.
My Resources shares the Learning budget deliberately. Both generate a whole
teaching resource from a topic, so they want the same shape of context. If they
ever need to diverge, src/utils/learningRetrieval.js is the single place that
reads these keys.
Why the assistant is so much smaller: a chat answer is a paragraph and the reader is waiting. A teaching resource synthesises an entire topic. Tuning one must never move the other, which is why they are separate keys rather than one shared pair.
To change one:
-- from the postgres container
INSERT INTO app_settings (key, value) VALUES ('learning.search_limit', '20')
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
That is the only change needed. RERANKER_TOP_K is 0, so the MCP honours
whatever the app asks for — verified: Learning requests 30 and receives 30, the
assistant requests 8 and receives 8.
Reading what actually happened
The MCP logs every search and what survived reranking:
docker logs mcp-server-mcp-1 --since 10m 2>&1 | grep -E "reranked search|before reranking|unverified"
# Milvus reranked search: user=..., limit=60, score_threshold=0.0, doc_type=file
# Milvus candidate retrieval returned 600 results before reranking
# Returning 12 unverified reranked results
Note limit=60 for a request of 30: semantic.py asks the algorithm for
limit × 2 and trims after verification.
Generation responses carry the same fact, so a caller never has to guess whether a resource was grounded:
"grounding": { "used": true, "count": 12, "reason": null }
used: false with a reason means the resource was written from the model
alone — retrieval never fails a generation, because ungrounded material is a far
better outcome than an error page. The Learning screen and My Resources both
show this, so ungrounded output is never presented as grounded.
A caution on raising these
Context is not free and more is not automatically better.
- The prompt has to fit the model's window. 12 excerpts at 2500 characters is
about 23k characters (~6k tokens); 30 at 2500 is about 57k (~14k). Overflow
does not error — it truncates, and truncation lands in the middle of the
excerpt block, which is the worst place to lose source material. If a resource
starts ignoring obvious material, lower
context_charsbefore suspecting the model. - Every excerpt past the reranker's confident set is a near-miss. Ten strong excerpts beat thirty mediocre ones for a model trying to write accurately.
- The reranker is billed per call and scales with candidates, not results.
RERANKER_FETCH_MULTIPLIERis the cost lever, notRERANKER_TOP_K.
Where each number is read
| Number | Read by | File |
|---|---|---|
RERANKER_TOP_K |
clinical-assist | clinical_assist/search/reranker.py |
RERANKER_FETCH_MULTIPLIER |
clinical-assist | clinical_assist/search/milvus_reranked.py |
clinical_assistant.* |
ped-ai | src/routes/clinicalAssistant.js |
learning.* |
ped-ai | src/utils/learningRetrieval.js |