Skip to main content

Shared infrastructure for corpus indexing + MCP search (used by arxiv-radar-mcp + lab-corpus-mcp).

Project description

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 server scaffold: make_method_dispatcher(handler, allowlist) → Dispatcher, build_mcp_app(server_name, tool_specs, dispatcher) → mcp.server.Server, serve_stdio / serve_streamable_http transports with optional BackgroundTaskFactory list.
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.

Install

pip install corpus-core            # once published to PyPI
# or, during dev:
pip install -e ../corpus-core

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

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.

Project details


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.2.0.tar.gz (77.1 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.2.0-py3-none-any.whl (58.5 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: corpus_core-0.2.0.tar.gz
  • Upload date:
  • Size: 77.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for corpus_core-0.2.0.tar.gz
Algorithm Hash digest
SHA256 fac4685610fe921e2ed1c52972a024defda7060b504851da4c43b144098300ac
MD5 c13aee6dfef3fcd843b433b96a2b7d5e
BLAKE2b-256 d61b828ed8d9238551ff4224253f2c04092323041ddddf5c8d8e4090422f3f1c

See more details on using hashes here.

Provenance

The following attestation bundles were made for corpus_core-0.2.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.2.0-py3-none-any.whl.

File metadata

  • Download URL: corpus_core-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 58.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for corpus_core-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 ff2826bbb6c5128288d4412090745b0782d90c55c68d616f4c383b4a353bd018
MD5 f3619c1fbb7cf0f427851e426db4db99
BLAKE2b-256 3c3a6a9445832823038624823b4173ec7a76fe2d38019dbadd29cfd94029a030

See more details on using hashes here.

Provenance

The following attestation bundles were made for corpus_core-0.2.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.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page