15 KiB
llm service — whole-library retrieval, recency ranking, exact-paragraph evidence links, largest-GPU routing
Status: Draft — awaiting approval
Date: 2026-09-03
Milestones: P34 (#571 grounded generation with citations), P45 (#654 fan-out to
generation), plus #615 (re-index of CMS-2026-2377)
Builds on: docs/superpowers/specs/2026-07-16-llm-module-design.md,
2026-07-17-llm-chat-ui-design.md, 2026-08-17-fr-jump-links-design.md
Goal
Four changes to the llm chat service (src/llm, llm.fhirworx.io):
- Whole-library retrieval. Answers draw on the entire Zotero/bib library —
public comments, Federal Register rules, and the reference corpus (PubMed
records, manuals, regulations, downloads, mail sources) — not just the
commentscollection. - Recency-first. Retrieval prefers the most recently posted comments and the newest rules/sources when relevance is comparable, and the answer states dates.
- Exact evidence links. Every source shown under an answer carries a deep link that lands on the exact passage: the FR paragraph anchor plus a scroll-to-text highlight for rules, the comment page or the attachment PDF page for comments, and the item URL (with PDF page when known) for corpus items. The FR jump-link resolver gains the same highlight so notebook links land on the passage too.
- Largest live GPU. Generation runs on the largest GPU that is up at request time (today the rig 4090), with a bigger instruct model when that host can hold it. Embedding keeps fanning out across every live host.
All inference stays on the local Ollama fleet — no cloud APIs.
Current state (measured 2026-09-03)
| Fact | Value |
|---|---|
| pgvector collections | comments 732,716 chunks / 166,919 items (last indexed 2026-08-14); rules 7,884 chunks / 3 of 80 rules; no corpus collection |
| bib comments | 196,739 (≈30k not yet indexed); date_published = regulations.gov posted date, latest 2026-08-19 |
| bib non-comment items | 22,470 (15,242 with abstracts; 848 with local attachments, 3,402 files: txt/xlsx/pdf/csv) |
| Zotero-only PDFs | 1,129 bib-keyed Zotero parents hold a storage PDF; 848 of those are in bib attachments — the rest (PRISMA stage-3 fetches) live only in data/zotero/data/storage |
| FR anchor maps | 80 rules, 193,220 paragraph anchors in fr_anchors (p_id, page, ordinal, text) |
| Ollama hosts | rack 3060 12 GB (ollama, 0.32.1), laptop 5070 Ti 12 GB (notebook.local), rig 4090 24 GB (rig.local); all three up; qwen2.5:14b on all, qwen2.5:32b only on rig, llama3.1:8b (current instruct_model) only on rack |
| Generation routing today | pool.acquire() picks least in-flight, so the rack 3060 always wins; check(instruct_model) drops every host but the rack anyway |
| Rule text | indexed from the FR .txt attachment — chunk → paragraph mapping is lost |
| Comment attachment text | combined.md sections per attachment (## attachment_1.pdf); PDF pages joined with \n\n, page boundaries lost |
The FR live page (browser UA) carries id="p-N" on every paragraph and
id="page-NNNNN" markers; a non-browser UA gets a 10 KB "Request Access"
page. #p-N anchors are valid but land on the paragraph's top edge under the
site header, and page cites (91 FR 44242 → #page-44242) land on a page
marker that sits mid-paragraph — that is the "not quite the exact paragraph"
symptom.
Decisions
| Decision | Choice | Why |
|---|---|---|
| Corpus scope | Three collections: comments, rules (rebuilt from fr_anchors), corpus (every non-comment bib item; abstract + local attachment text + Zotero-storage PDF text) |
Matches the library as the user sees it in Zotero; fr_anchors gives exact paragraph provenance for free |
| Cross-collection retrieval | Query each collection, over-fetch, merge, re-rank in Python | Three PGVector stores already exist; no schema change; per-kind k keeps rules from drowning comments |
| Recency | Soft boost: score = similarity × (1 − w) + w × recency, recency = exp(−age / half_life); optional hard since date on the API |
Relevance must still win on clearly better hits; a hard cutoff alone would hide older authoritative rules |
| Rule chunking | One chunk per FR paragraph (packing tiny neighbours up to ~2,000 chars); metadata p_id, page, html_url |
Chunk == anchor, so the evidence link is exact by construction |
| Comment page mapping | At index time, locate each attachment-derived chunk's opening text in the local PDF's per-page text (pymupdf); store attachment, page |
No re-extraction of 28k comments; PDFs are already on disk |
| Link rendering | llm/links.py builds url + label per source; FR links get a :~:text= scroll-to-text fragment; frlink.resolve(..., highlight=True) gains the same |
Text fragments highlight and centre the passage in Chromium/Safari/Firefox ≥131 and fall back to #p-N elsewhere |
| GPU routing | Hosts declare VRAM inline (http://rig.local:11434@24); generation goes to the largest live host serving the model; embeds stay least-loaded |
Ollama exposes no GPU-size API; declared size + live probe is the honest "largest available at any time" |
| Models | instruct_model = qwen2.5:14b everywhere; instruct_model_large = qwen2.5:32b when the chosen host declares ≥ large_min_vram_gb (20) and serves it; num_ctx passed per request (8,192) |
Both already pulled; 32b is VRAM-resident on the 4090 (43 tok/s measured); no more Modelfile ctx variants |
| Rollout | Update .env host lists, docker compose build llm && up -d llm; re-index on the host with nohup across the fleet |
Same procedure as every other roll (see deploy_rollout memory) |
Architecture
bib.sqlite ──┬─ comments (combined.md / abstract) ─┐
├─ fr_anchors paragraphs (rules) ├─> llm.source → llm.chunk → embed (fleet fan-out) → pgvector
└─ corpus items (+ Zotero storage PDFs)┘ metadata: kind, date, url, p_id/page/attachment
POST /chat {question, since?}
└─ rag.retrieve: embed question once → 3 × similarity_search_with_score (over-fetch)
→ rerank.recency_blend → top-N mixed sources
└─ links.for_source: kind-specific deep link + label
└─ pool.acquire_generation(): largest live host by declared VRAM → model tier → /api/chat (stream)
└─ SSE: token* , sources (with url/label/date/kind), done
Components
llm/config.py
ollama_hostsbecomestuple[Host, ...]whereHost(url, vram_gb); parseurl@vram(missing@→vram_gb=0, sorts last).- New fields with
[llm]defaults:instruct_model_large = "",large_min_vram_gb = 20,chat_num_ctx = 8192,recency_half_life_days = 365,recency_weight = 0.3,k_per_kind = {"comment": 8, "rule": 4, "corpus": 4},top_n = 8. LLM_OLLAMA_HOSTSenv keeps precedence (annotated form).
llm/pool.py
HostPoolstoresvram_gbper host.acquire_generation(model)→ context manager yielding the alive host with the largestvram_gbthat servesmodel(tie → least in-flight); raises the existing "no host serves" error when none does.pick_model(cfg, host)→instruct_model_largeifhost.vram_gb >= large_min_vram_gband the host lists it, elseinstruct_model. Liveness is re-probed on every chat request (already the case viacheck), so a rig outage falls back to the laptop/rack.- Fix the 5080 → 5070 Ti docstring (#653 note).
llm/source.py
iter_rule_docsrebuilt onfr_anchors: yields oneDocper rule whose text is the anchor paragraphs inp_idorder, separated by a sentinel the chunker respects, with a parallelparagraphslist sochunk_rule_doccan pack whole paragraphs and stampp_id,page,html_urlper chunk. Rules without an anchor map fall back to the current.txtpath (metadata withoutp_id).iter_comment_docskeepscombined.md; the chunker's section split already isolates each## attachment_N.ext— a newattachment_pages(root, docket, cid)helper returns per-page text for PDF attachments; chunk metadata gainsattachmentandpage(empty when not located).iter_corpus_docsgainstitle,url,date,item_type,project(fromproject:tags) metadata and a Zotero-storage PDF fallback: aZoteroPdfIndexbuilt once per run from a snapshot copy ofzotero.sqlite(never the live file), mapping bib key → storage PDF paths; text via the existing rex PDF extractor. Non-PDF attachment text keeps the current path.- Every doc carries
date(ISO,date_published) andkind(comment|rule|corpus). - Comment iteration order becomes newest-first (
ORDER BY date_published DESC) so incremental runs surface the latest first.
llm/chunk.py
chunk_docunchanged for comments/corpus.- New
chunk_paragraphs(doc, paragraphs)for rules: greedy pack of whole paragraphs up totarget_chars; a paragraph longer than the target is hard-wrapped with overlap but keeps itsp_id. Metadata per chunk:p_id(first paragraph),p_id_last,page.
llm/rerank.py (new, pure)
recency(date, now, half_life_days) -> floatin[0,1]; undated → 0.blend(hits, *, weight, half_life_days, now) -> list[hit]: converts pgvector cosine distance to similarity, blends, sorts desc, dedupes by item key (best chunk per item), returnstop_n.filter_since(hits, since)for the hard cutoff.
llm/links.py (new, pure over metadata)
for_source(md) -> (url, label):- comment:
https://www.regulations.gov/comment/{cid}; whenattachmentis set:https://downloads.regulations.gov/{cid}/{attachment}plus#page={page}for PDFs. LabelCMS-2026-2377-3438(+p.4). - rule:
{html_url}#p-{p_id}+:~:text={first ~8 words of snippet}; label91 FR 43949 ¶4(page + ordinal looked up from metadata). - corpus: item
url(+#page=when the chunk came from a PDF and the URL ends in.pdf); label = short title (≤ 60 chars) + year.
- comment:
text_fragment(text) -> str: first sentence clipped to ~80 chars, percent-encoded per the URL Fragment Text Directives spec (-,,,&escaped).
bib/frlink.py (tweak)
resolve(..., highlight=False): when true, paragraph-resolved links (¶k, raw anchor, quote) append:~:text=built from the paragraph's opening words; page-only cites additionally upgrade to the first paragraph starting on that page when one exists (so91 FR 44242lands on a paragraph, not the mid-paragraph page marker).md_linkpasseshighlightthrough;Pincite.jump_urluseshighlight=True.- Quote resolution: when several paragraphs match, prefer the one whose text starts with the quote, else the shortest; raise only when still tied. Existing single-match behaviour unchanged.
llm/rag.py
retrieve(question, *, cfg, pool, since=None): one query embedding, three stores,k_per_kind × 3over-fetch each,rerank.blend, thenlinks.for_source→ source dicts{id, kind, label, url, title, date, docket, snippet, score}.build_messages: excerpts rendered as[label] (kind, date) snippet; system prompt tells the model to cite[label], prefer the most recent comments when excerpts conflict, and name the year when it matters.stream_answer:pool.acquire_generation+pick_model, passesoptions.num_ctx; thesourcesevent carries the enriched dicts andmodel/hostfor the UI footer.
llm/api.py + web/chat.html
ChatRequestgains optionalsince: str | None./healthreturns live hosts withvram_gband the host/model generation would pick right now.- UI: sources render as links (
<a target=_blank>) with kind badge, date, and label; a "last 12 months" checkbox setssince; the footer showsmodel @ host.
cli/llm.py
index --collection allruns comments → rules → corpus;--newest-firstis the default order for comments.stack llm hostsprints the pool with VRAM, liveness, models, and the generation pick (operator check for "which GPU will answer").
Compose / env
.env:LLM_OLLAMA_HOSTSandLLM_OLLAMA_HOSTS_IN_CONTAINERgain@12,@12,@24annotations;stack.toml [llm]:instruct_model = "qwen2.5:14b",instruct_model_large = "qwen2.5:32b", new knobs above.- No compose topology change; the container already reaches both remotes
(verified
docker exec llm curl 192.168.1.222:11434).
Data flow for one question
- Embed the question once on the least-loaded live host.
- Similarity search
comments(k=24),rules(k=12),corpus(k=12). - Convert distance → similarity, blend with recency, apply
since, dedupe per item, keep top 8. - Build deep links; render excerpts; stream from the largest live host
with the tier-appropriate model at
num_ctx8,192. - Emit sources (with links) and done.
Error handling
- A host down at request time is dropped by
check; if the rig is down the laptop answers withqwen2.5:14b. If no host serves the model the stream emits the existingerrorevent. - Missing metadata never breaks a link:
links.for_sourcedegrades rule → page link → FR html_url; comment → comment page; corpus → item URL; undated hits get recency 0 (pure similarity). - Zotero snapshot copy failure logs and skips the Zotero-PDF fallback; bib attachments still index.
- Page location failures leave
pageempty; the link falls back to the attachment root.
Testing
- Unit (TDD,
tests/llm): host annotation parsing;acquire_generationordering and fallback;pick_modeltiers;chunk_paragraphspacking + p_id stamping;rerank.blendmaths and dedupe;links.for_sourcefor all three kinds incl. degraded cases;text_fragmentencoding;retrievemerging three mocked stores;build_messagesrendering; comment page location on a synthetic 2-page PDF fixture. tests/bib/test_frlink.py: highlight fragment, page-cite paragraph upgrade, quote tie-break.- Live verification after rollout:
stack llm hostsshows rig as the pick;/healthfrom inside the compose net; one chat viadocker exec git curlstreams fromrig; three sample rule links opened in the playwright Chromium image scroll to the highlighted paragraph.
Rollout + re-index
- Merge, bump
COMMIT_SHA,docker compose build llm && docker compose up -d llm. - On the host:
nohup stack llm index --collection all(fleet fan-out on all three GPUs). Estimated volume: ~30k new comments, 80 rules (~193k paragraphs → ~80k chunks), 22k corpus items; a few hours onnomic-embed-text. The oldruleschunks are replaced per item (_delete_old_chunks), no downtime forcomments. - Note for later: a nightly
stack llm index --collection commentsstep in the existing sync workflow keeps recency honest (out of scope here).
Out of scope
- Hybrid BM25 + vector retrieval and cross-encoder reranking (better for citation-heavy questions; separate issue).
- Multi-turn chat memory; the service stays single-shot.
- P35 tagging chain.
- Moving the
llmcontainer itself off the rack — it needs postgres and bib.sqlite; only inference moves.