Skip to main content

corpus-core

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.

Install

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 extra

Quick start

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

MCP concurrency and cancellation

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

Process-level singleton contract

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

Invariants downstream packages must honour

  • 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 (*.tmpos.replace).
  • Job persistence schema: <cache_dir>/jobs/<job_id>.json with 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}. The arxiv_id field is the corpus-wide paper id — DOI / PMID / sha256 / any string the host project chooses.
  • Encoder config duck-type: Encoder.__init__(config) reads config.embeddings.{model, batch_size, target_dim, cache_dir}. Pass any object with that shape. See corpus_core.embeddings.Config Protocol for the formal type.
  • HTTP fetch invariants (http_fetch.py):
    • fetch_url writes atomically (<dest>.tmpos.replace); on any failure (transport error, non-2xx, empty body) dest_path is not created or overwritten.
    • Throttle is one instance per source domain; all callers that share an instance share the budget. Use get_arxiv_throttle() for every arxiv.org GET so the combined image enforces 1 req / 3 sec across both downstream servers.
    • request_with_retry retries only on 429/503; other status codes fall through after the first attempt. Honours Retry-After if present, else exponential backoff 3→6→12 sec.

Used by

  • 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 one Encoder and injects it into both servers via the encoder= parameter on RadarServer.__init__ and the equivalent in lab-corpus-mcp.

Status

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.EmbeddingIndex cache 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.

Tests

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.

License

MIT.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

corpus_core-0.3.0.tar.gz (80.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

corpus_core-0.3.0-py3-none-any.whl (59.8 kB view details)

Uploaded Python 3

File details

Details for the file corpus_core-0.3.0.tar.gz.

File metadata

  • Download URL: corpus_core-0.3.0.tar.gz
  • Upload date:
  • Size: 80.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for corpus_core-0.3.0.tar.gz
Algorithm Hash digest
SHA256 5d28ae631e31818856f36ed67fbabd952d9bfdf139dde99b8e3b17156c0c1b23
MD5 dff8decad1af20fe07ebcb9dc0d0e057
BLAKE2b-256 44dd0230de23bf4831eb78c1b7f93f8c34fa47df6a9b4cf083a908bbbeb4b774

See more details on using hashes here.

Provenance

The following attestation bundles were made for corpus_core-0.3.0.tar.gz:

Publisher: release.yml on exopoiesis/corpus-core

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file corpus_core-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: corpus_core-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 59.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for corpus_core-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 d92699593ae166efe6d87f2b5e210c69c477ec41b9929ec308b6a7ef7850e977
MD5 486c2ecbcf2a786809ce918e969e69d9
BLAKE2b-256 7c98bd0c88013c808ef7050096ca7126e00e90028af97d0dd268ce1973a5b421

See more details on using hashes here.

Provenance

The following attestation bundles were made for corpus_core-0.3.0-py3-none-any.whl:

Publisher: release.yml on exopoiesis/corpus-core

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 files

0.2.0

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page