pediatric-ai-scribe-v3/docs/retrieval-tuning.md
Daniel fa2e7523d6
Some checks failed
Forgejo Docker Build / Build Docker image (push) Blocked by required conditions
Forgejo Docker Build / Deploy to the host (push) Blocked by required conditions
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Has been cancelled
docs: My Resources, sign-in codes, invitations, and what the image carries
Nothing documented My Resources, the slide renderer, PubMed or web search, and
the authentication doc predated both sign-in codes and registration invitations.

docs/my-resources.md is new and covers the feature end to end: what a resource
is, where its material comes from, why both searches run in the route rather
than as tools the model never called, why keyword engines get the topic while
retrieval gets the instruction too, how a presentation is designed as a deck
rather than written as markdown, the separate multi-image path, and what the
export pipeline is made of.

docs/authentication.md gains sign-in codes — storage, lifetime, reuse,
supersession, guessing, and that two-factor still applies — and registration
invitations, including the exact condition that decides when a code may be
deleted and why it is written to match the status the list displays. Both new
rate limits are in the table, with a note that Express matches app.use paths on
segment boundaries, so a new sign-in endpoint needs its own limiter or it has
none at all.

docs/deployment.md now says what the runtime image carries and why — pandoc for
Word, python3 with apk-installed lxml and pillow for the slide renderer,
python-pptx pinned, and that PDF conversion is not in the image at all but goes
to Gotenberg, so Word and PowerPoint still work when it is down.

docs/configuration.md picks up LOGIN_RATE_LIMIT_MAX, LOGIN_CODE_RATE_LIMIT_MAX
and GOTENBERG_URL, none of which were listed. README gains a My Resources
section and indexes the two new docs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 20:49:10 +02:00

136 lines
5.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:
```python
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.
```yaml
# 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 | 320, 3004000 |
| Learning Hub | `learning.search_limit`, `learning.context_chars` | 30, 2500 | 360, 3008000 |
| My Resources | *the same `learning.*` keys* | 30, 2500 | 360, 3008000 |
`search_limit` is how many excerpts to request; `context_chars` is how much text
to pull around each one.
See [my-resources.md](my-resources.md) for the rest of that feature — its
sources, the deck renderer and illustrations.
**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:
```sql
-- 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:
```bash
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:
```json
"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_chars` before 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_MULTIPLIER` is the cost lever, not `RERANKER_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` |