Shared infrastructure for corpus indexing + MCP search. Powers both arxiv-radar-mcp (the public-data arXiv topical radar) and lab-corpus-mcp (the private multi-source PDF / video / lab-notes server).
What's inside:
| Module | Role |
|---|---|
embeddings.py |
Encoder -- lazy SentenceTransformer wrapper with model-aware query/passage prefixes, bf16 on CUDA, matryoshka truncation. Encoder.unload() drops the in-process model + frees CUDA VRAM (idempotent; next encode lazily re-loads). EmbeddingIndex -- mmap'd float32 matrix + row_for mapping + metadata, atomic save/load. |
chunker.py |
chunk_markdown(text, max_tokens) -> list[Chunk]. Section-aware split + paragraph overlap; rough but fast token estimator. |
corpus_index.py |
Chunk-level corpus search. reindex(parse_dir, encoder, *, incremental) -- incremental encode + atomic swap. search_paper_text / search_paper_semantic / similar_to_paper. is_junk_section filter. |
search.py |
Abstract-level search primitives over EmbeddingIndex: search_text / search_semantic / similar_to. Paper-shaped records via Protocol -- no host-project dep. |
jobs.py |
JobRegistry -- ThreadPoolExecutor + persistent jobs/<id>.json. Disk-truth fallback in get() so a stuck-running cell doesn't lie about completed jobs. |
proxy.py |
Local stdio<->remote-HTTP bridge. run_proxy(target, port, ssh_binary) opens an SSH tunnel and forwards MCP traffic; _bridge_loop reconnects on backend disconnect. |
reranker.py |
Reranker -- lazy CrossEncoder wrapper for hybrid-search re-scoring. Reranker.unload() mirrors Encoder.unload() for symmetric VRAM control after a batch. Local RerankerConfig dataclass. |
mcp_scaffold.py |
Generic MCP SDK v2 server scaffold: make_method_dispatcher(handler, allowlist) -> Dispatcher, build_mcp_app(server_name, tool_specs, dispatcher) -> mcp.server.lowlevel.Server, serve_stdio / serve_streamable_http transports with optional BackgroundTaskFactory list. Blocking handlers run in separate worker threads; cancellation of one request does not terminate the shared dispatcher. |
http_fetch.py |
fetch_url(url, dest_path) -> FetchResult -- throttled GET with 429/503 retry + Retry-After + atomic file write. fetch_arxiv_pdf(arxiv_id, dest_dir) convenience wrapper. get_arxiv_throttle() singleton -- process-wide 1 req / 3 sec budget shared by arxiv-radar's HTML/LaTeX fetcher and lab-corpus's ingest_url / ingest_arxiv_pdf, so the combined image never double-spams arxiv.org. |
pdf.py |
Optional extra corpus-core[pdf]. MinerU parse mechanics shared by both downstream servers. parse_pdf(pdf_path, *, media_out_dir, backend, runner) -> PdfParseResult -- parse one PDF via MinerU, write images to media_out_dir. is_pdf_parser_available() -> bool -- lazy import probe (safe to call without MinerU). looks_like_pdf_stub(markdown) -> bool -- heuristic for scan-only / failed parses. unload_pdf_models() -> bool -- release MinerU VRAM singletons (idempotent). Lazy MinerU import: importing corpus_core.pdf is cheap on hosts without the extra. |
pip install corpus-core # once published to PyPI
pip install corpus-core[pdf] # + MinerU PDF parsing (~2 GB, mineru[core]>=2.5)
# or, during dev:
pip install -e ../corpus-core
pip install -e "../corpus-core[pdf]" # dev with PDF extrafrom corpus_core import (
Encoder, EmbeddingIndex,
Chunk, chunk_markdown,
search_text, search_semantic, similar_to,
search_paper_text, search_paper_semantic, similar_to_paper,
load_chunk_texts, reindex, is_junk_section,
JobRegistry, JobHandle, JobError, Job,
make_method_dispatcher, build_mcp_app,
serve_stdio, serve_streamable_http,
Dispatcher, BackgroundTaskFactory,
fetch_url, fetch_arxiv_pdf,
Throttle, get_arxiv_throttle,
request_with_retry, FetchResult,
ARXIV_RATE_LIMIT_S, DEFAULT_USER_AGENT,
)
# Submodule access also fine:
from corpus_core.embeddings import Encoder
from corpus_core.proxy import run_proxy
from corpus_core.reranker import Reranker, RerankerConfig
from corpus_core.http_fetch import fetch_url, get_arxiv_throttlecorpus-core >= 0.3.0 requires mcp >= 2.1,<3. The MCP SDK v1.26/v1.27
receive loop could terminate after a notifications/cancelled race while the
server process itself remained alive. The visible symptom was an unlimited
hang of every subsequent request. The v2 low-level dispatcher keeps request
cancellation local to the affected call.
Synchronous tool handlers are executed with asyncio.to_thread, so one slow
search does not block transport I/O or unrelated tools. Every call also has a
server-side deadline (300 seconds by default) so a client which supplies no
timeout still receives a terminal error. Override it for legitimately long
foreground tools with CORPUS_MCP_TOOL_TIMEOUT_S; background jobs are not
limited by this value.
The worker thread behind a cancelled synchronous tool cannot be force-killed safely by Python. It may finish in the background, but cancellation is not swallowed and the MCP session continues serving other requests.
One cache_dir = one writing process.
corpus_core.jobs.JobRegistry serialises reindex attempts within a process via
acquire_reindex_lock(). Running two separate processes against the same
cache_dir at the same time is not supported and will produce a corrupted
index. The lockfile (<cache_dir>/fulltext/.reindex.lock) records pid +
hostname + start_time so a crashed owner's lock can be recovered on the
next start (same-host pid dead = stale; foreign host = operator must remove
manually).
corpus_core.embeddings.Encoder and corpus_core.http_fetch.get_arxiv_throttle()
are process-level singletons -- construct one instance and inject it into both
arxiv-radar-mcp and lab-corpus-mcp via the encoder= / shared-throttle
parameters. Never instantiate two Encoders in the same process against the
same GPU (two Qwen3-4B bf16 models = ~16 GB, exhausts a 12 GB card).
-
Embedding cache layout:
-
<cache_dir>/embeddings.npy— float32, L2-normalized, shape(N, D). -
<cache_dir>/index.json—{model, dims, n, row_for, ...metadata}. - Both written atomically (
*.tmp→os.replace).
-
-
Job persistence schema:
<cache_dir>/jobs/<job_id>.jsonwith fields{job_id, kind, state, progress, n_total, n_done, started_at, finished_at, result, error, args}. State ∈ {pending,running,done,failed,orphaned}. -
Chunk metadata: each chunk in
EmbeddingIndex.metadata["chunks"]has{arxiv_id, section, chunk_idx, n_chars, n_tokens_est}. Thearxiv_idfield is the corpus-wide paper id — DOI / PMID / sha256 / any string the host project chooses. -
Encoder config duck-type:
Encoder.__init__(config)readsconfig.embeddings.{model, batch_size, target_dim, cache_dir}. Pass any object with that shape. Seecorpus_core.embeddings.ConfigProtocol for the formal type. -
HTTP fetch invariants (
http_fetch.py):-
fetch_urlwrites atomically (<dest>.tmp→os.replace); on any failure (transport error, non-2xx, empty body)dest_pathis not created or overwritten. -
Throttleis one instance per source domain; all callers that share an instance share the budget. Useget_arxiv_throttle()for every arxiv.org GET so the combined image enforces 1 req / 3 sec across both downstream servers. -
request_with_retryretries only on 429/503; other status codes fall through after the first attempt. HonoursRetry-Afterif present, else exponential backoff 3→6→12 sec.
-
-
arxiv-radar-mcp --
arxiv-only topical radar over the
daily-arxiv-*fork family. - lab-corpus-mcp -- private PDF / DOCX / PPTX / image corpus parsed via MinerU; can also run combined with arxiv-radar-mcp on one Qwen instance to fit a 12 GB GPU.
Note: shared GPU hosting requires the combined-supervisor (DECISIONS-136). arxiv-radar-mcp and lab-corpus-mcp each load Qwen3-Embedding-4B (~8 GB bf16). Running them as two independent standalone backends on the same GPU is not supported -- two copies total ~16 GB, causing OOM on 12 GB cards. The only supported topology for shared GPU hosting is the combined-supervisor in
lab-corpus-mcp, which constructs oneEncoderand injects it into both servers via theencoder=parameter onRadarServer.__init__and the equivalent inlab-corpus-mcp.
v0.2.0, in production as of 2026-05-24. Both downstream
projects (arxiv-radar-mcp and lab-corpus-mcp) install corpus-core
editable from the sibling repo. The combined exopoiesis/lab-corpus-gpu
image on gomer holds:
- 34,627 abstract embeddings (Qwen3-Embedding-4B native 2560 dims, L2-normalized) over 5 arxiv-radar fork sources.
- 466 fulltext chunks across 51 fetched arxiv papers + 54 chunks across 2 MinerU-parsed lab PDFs.
- All in one
corpus_core.embeddings.EmbeddingIndexcache layout, encoded by a single shared Encoder instance.
Build-time audit_image.py in both downstream projects checks the
no-duplicate-distribution invariant — pip never ends up with two
copies of any package, including torch.
HTTP fetch primitives added (2026-05-13) to close
arxiv-radar-mcp's U14: http_fetch.py extracts the throttled GET +
429/503-retry pattern that previously lived inside arxiv-radar's
fulltext.py. Both servers now share the singleton arxiv throttle —
arxiv-radar uses it for HTML/LaTeX, lab-corpus uses it for
ingest_url / ingest_arxiv_pdf PDF downloads. Same module-global
lock across the whole combined image.
VRAM unload added (2026-05-24, v0.2.0). Encoder.unload() and
Reranker.unload() drop the in-process model and free CUDA VRAM via
torch.cuda.empty_cache() + gc.collect(). Both are idempotent and
guarded by the existing model-load lock so concurrent encodes either
complete first or re-load on next call. Downstream projects call
Encoder.unload() after heavy one-shot work (reindex, refresh, bulk
ingest) so a shared GPU host can use the freed VRAM for unrelated
compute. 119 corpus-core tests green.
Phase 3 extraction is complete; PyPI publication of corpus-core
deferred until the API stabilises through real-world ingest of more
than the current 53 papers.
pytest -q from the repo root. The standalone test suite covers
chunker, jobs, mcp_scaffold, proxy invocation, reranker
config + lazy load, and basic embedding-index roundtrip with a
deterministic stub encoder. Heavier integration testing
(host-project Configs, real Qwen weights, real MCP sessions) lives
in the arxiv-radar-mcp and lab-corpus-mcp test suites.
MIT.