paperus-ml 0.0.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: paperus-ml
3
+ Version: 0.0.8
4
+ Summary: Machine learning service of paperus: text embeddings (later reranking, OCR)
5
+ Keywords: embeddings,onnx,semantic search,self-hosted
6
+ Author: paperus contributors
7
+ License-Expression: Apache-2.0
8
+ Classifier: Development Status :: 2 - Pre-Alpha
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Scientific/Engineering
12
+ Requires-Dist: fastapi>=0.162.0
13
+ Requires-Dist: huggingface-hub>=2.4.0
14
+ Requires-Dist: numpy>=2.5.4
15
+ Requires-Dist: onnxruntime>=1.31.1
16
+ Requires-Dist: prometheus-client>=0.26.0
17
+ Requires-Dist: pydantic-settings>=2.16.2
18
+ Requires-Dist: tokenizers>=0.23.3
19
+ Requires-Dist: uvicorn[standard]>=0.54.0
20
+ Requires-Python: >=3.13
21
+ Project-URL: Homepage, https://github.com/qcode-uni-a/paperus
22
+ Project-URL: Source, https://github.com/qcode-uni-a/paperus
23
+ Project-URL: Issues, https://github.com/qcode-uni-a/paperus/issues
24
+ Description-Content-Type: text/markdown
25
+
26
+ # paperus-ml
27
+
28
+ Machine learning service of [paperus](https://github.com/qcode-uni-a/paperus):
29
+ text embeddings for search by meaning, with ONNX Runtime on the CPU. Models
30
+ are downloaded from Hugging Face on first use and unloaded when idle.
31
+
32
+ ```sh
33
+ pip install paperus-ml
34
+ PAPERUS_ML_TOKEN=… paperus-ml --host 0.0.0.0 # port 3003
35
+ ```
36
+
37
+ Settings are environment variables:
38
+
39
+ | Variable | Default | Meaning |
40
+ | --- | --- | --- |
41
+ | `PAPERUS_ML_TOKEN` | none | Shared secret; requests need `Authorization: Bearer <token>` |
42
+ | `PAPERUS_ML_CACHE_PATH` | `~/.cache/paperus-ml` | Downloaded models |
43
+ | `PAPERUS_ML_PRELOAD` | `[]` | Models to load at start and keep, e.g. `["arctic-embed-m-v2"]` (paperus then sends the one in use) |
44
+ | `PAPERUS_ML_MODEL_TTL` | `300` | Unload other models after this many idle seconds (0: never) |
45
+ | `PAPERUS_ML_INTRA_OP_THREADS` | `0` | CPU cores per model at start (0: all it sees); paperus' administration sets it afterwards |
46
+ | `PAPERUS_ML_MAX_BATCH_TEXTS` / `_TOKENS` | `32` / `16384` | Batch size (memory grows with both) |
47
+
48
+ paperus sends its settings with `PUT /config` (cores, models to keep
49
+ loaded). One batch runs at a time per model, using all its cores; simultaneous
50
+ requests take turns batch by batch.
51
+
52
+ Most installations use the `paperus-ml` container image instead. Status:
53
+ early version (0.0.x); expect breaking changes.
@@ -0,0 +1,28 @@
1
+ # paperus-ml
2
+
3
+ Machine learning service of [paperus](https://github.com/qcode-uni-a/paperus):
4
+ text embeddings for search by meaning, with ONNX Runtime on the CPU. Models
5
+ are downloaded from Hugging Face on first use and unloaded when idle.
6
+
7
+ ```sh
8
+ pip install paperus-ml
9
+ PAPERUS_ML_TOKEN=… paperus-ml --host 0.0.0.0 # port 3003
10
+ ```
11
+
12
+ Settings are environment variables:
13
+
14
+ | Variable | Default | Meaning |
15
+ | --- | --- | --- |
16
+ | `PAPERUS_ML_TOKEN` | none | Shared secret; requests need `Authorization: Bearer <token>` |
17
+ | `PAPERUS_ML_CACHE_PATH` | `~/.cache/paperus-ml` | Downloaded models |
18
+ | `PAPERUS_ML_PRELOAD` | `[]` | Models to load at start and keep, e.g. `["arctic-embed-m-v2"]` (paperus then sends the one in use) |
19
+ | `PAPERUS_ML_MODEL_TTL` | `300` | Unload other models after this many idle seconds (0: never) |
20
+ | `PAPERUS_ML_INTRA_OP_THREADS` | `0` | CPU cores per model at start (0: all it sees); paperus' administration sets it afterwards |
21
+ | `PAPERUS_ML_MAX_BATCH_TEXTS` / `_TOKENS` | `32` / `16384` | Batch size (memory grows with both) |
22
+
23
+ paperus sends its settings with `PUT /config` (cores, models to keep
24
+ loaded). One batch runs at a time per model, using all its cores; simultaneous
25
+ requests take turns batch by batch.
26
+
27
+ Most installations use the `paperus-ml` container image instead. Status:
28
+ early version (0.0.x); expect breaking changes.
@@ -0,0 +1,74 @@
1
+ [project]
2
+ name = "paperus-ml"
3
+ version = "0.0.8"
4
+ description = "Machine learning service of paperus: text embeddings (later reranking, OCR)"
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ keywords = [
8
+ "embeddings",
9
+ "onnx",
10
+ "semantic search",
11
+ "self-hosted",
12
+ ]
13
+ classifiers = [
14
+ "Development Status :: 2 - Pre-Alpha",
15
+ "Intended Audience :: Science/Research",
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Scientific/Engineering",
18
+ ]
19
+ requires-python = ">=3.13"
20
+ dependencies = [
21
+ "fastapi>=0.162.0",
22
+ "huggingface-hub>=2.4.0",
23
+ "numpy>=2.5.4",
24
+ "onnxruntime>=1.31.1",
25
+ "prometheus-client>=0.26.0",
26
+ "pydantic-settings>=2.16.2",
27
+ "tokenizers>=0.23.3",
28
+ "uvicorn[standard]>=0.54.0",
29
+ ]
30
+
31
+ [[project.authors]]
32
+ name = "paperus contributors"
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/qcode-uni-a/paperus"
36
+ Source = "https://github.com/qcode-uni-a/paperus"
37
+ Issues = "https://github.com/qcode-uni-a/paperus/issues"
38
+
39
+ [project.scripts]
40
+ paperus-ml = "paperus_ml.cli:main"
41
+
42
+ [build-system]
43
+ requires = ["uv_build>=0.13,<0.14"]
44
+ build-backend = "uv_build"
45
+
46
+ [tool.ruff]
47
+ line-length = 88
48
+
49
+ [tool.ruff.lint]
50
+ select = [
51
+ "E",
52
+ "F",
53
+ "W",
54
+ "I",
55
+ "B",
56
+ "UP",
57
+ "SIM",
58
+ "RUF",
59
+ "ANN",
60
+ ]
61
+
62
+ [tool.pytest.ini_options]
63
+ testpaths = ["tests"]
64
+ filterwarnings = ["error"]
65
+ markers = ["download: downloads a real model (set PAPERUS_ML_TEST_DOWNLOAD=1)"]
66
+
67
+ [dependency-groups]
68
+ dev = [
69
+ "httpx2>=2.16.0",
70
+ "onnx>=1.23.2",
71
+ "pytest>=9.2.0",
72
+ "ruff>=0.17.3",
73
+ "ty>=0.0.97",
74
+ ]
@@ -0,0 +1,57 @@
1
+ [project]
2
+ name = "paperus-ml"
3
+ version = "0.0.8"
4
+ description = "Machine learning service of paperus: text embeddings (later reranking, OCR)"
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ authors = [{ name = "paperus contributors" }]
8
+ keywords = ["embeddings", "onnx", "semantic search", "self-hosted"]
9
+ classifiers = [
10
+ "Development Status :: 2 - Pre-Alpha",
11
+ "Intended Audience :: Science/Research",
12
+ "Programming Language :: Python :: 3",
13
+ "Topic :: Scientific/Engineering",
14
+ ]
15
+ requires-python = ">=3.13"
16
+ dependencies = [
17
+ "fastapi>=0.162.0",
18
+ "huggingface-hub>=2.4.0",
19
+ "numpy>=2.5.4",
20
+ "onnxruntime>=1.31.1",
21
+ "prometheus-client>=0.26.0",
22
+ "pydantic-settings>=2.16.2",
23
+ "tokenizers>=0.23.3",
24
+ "uvicorn[standard]>=0.54.0",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/qcode-uni-a/paperus"
29
+ Source = "https://github.com/qcode-uni-a/paperus"
30
+ Issues = "https://github.com/qcode-uni-a/paperus/issues"
31
+
32
+ [project.scripts]
33
+ paperus-ml = "paperus_ml.cli:main"
34
+
35
+ [build-system]
36
+ requires = ["uv_build>=0.13,<0.14"]
37
+ build-backend = "uv_build"
38
+
39
+ [tool.ruff]
40
+ line-length = 88
41
+
42
+ [tool.ruff.lint]
43
+ select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF", "ANN"]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
47
+ filterwarnings = ["error"]
48
+ markers = ["download: downloads a real model (set PAPERUS_ML_TEST_DOWNLOAD=1)"]
49
+
50
+ [dependency-groups]
51
+ dev = [
52
+ "httpx2>=2.16.0",
53
+ "onnx>=1.23.2",
54
+ "pytest>=9.2.0",
55
+ "ruff>=0.17.3",
56
+ "ty>=0.0.97",
57
+ ]
@@ -0,0 +1,13 @@
1
+ """paperus-ml: the machine learning service of paperus.
2
+
3
+ No telemetry: Hugging Face Hub reads its switch when imported, so it is set
4
+ here, before any submodule imports it; ONNX Runtime's is switched off too.
5
+ """
6
+
7
+ import os
8
+
9
+ os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1"
10
+
11
+ import onnxruntime
12
+
13
+ onnxruntime.disable_telemetry_events()
@@ -0,0 +1,17 @@
1
+ """``paperus-ml``: run the service (``--host 0.0.0.0`` in containers)."""
2
+
3
+ import argparse
4
+ import logging
5
+
6
+ import uvicorn
7
+
8
+
9
+ def main(argv: list[str] | None = None) -> None:
10
+ parser = argparse.ArgumentParser(prog="paperus-ml")
11
+ parser.add_argument("--host", default="127.0.0.1")
12
+ parser.add_argument("--port", type=int, default=3003)
13
+ args = parser.parse_args(argv)
14
+ logging.basicConfig(level=logging.INFO)
15
+ uvicorn.run(
16
+ "paperus_ml.main:create_app", factory=True, host=args.host, port=args.port
17
+ )
@@ -0,0 +1,38 @@
1
+ """Settings, read from environment variables prefixed with ``PAPERUS_ML_``."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from pydantic import Field
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+
9
+
10
+ class Settings(BaseSettings):
11
+ model_config = SettingsConfigDict(env_prefix="PAPERUS_ML_", env_file=".env")
12
+
13
+ # Downloaded models, one folder per task and model:
14
+ # <cache>/embedding/bge-m3/. "/cache" (a volume) in the container.
15
+ cache_path: Path = Field(default_factory=lambda: default_cache_path())
16
+ # Shared secret: requests need "Authorization: Bearer <token>" when set.
17
+ token: str | None = None
18
+ # Unload a model after this many idle seconds (0: never).
19
+ model_ttl: int = 300
20
+ # Models to load at startup, never unloaded, e.g. ["bge-m3"].
21
+ preload: list[str] = []
22
+ # ONNX Runtime threads per model; 0 lets ONNX Runtime decide.
23
+ intra_op_threads: int = 0
24
+ # Most texts and tokens per inference batch (memory grows with both).
25
+ max_batch_texts: int = 32
26
+ max_batch_tokens: int = 16384
27
+ # How long a request waits for a model that is still loading before the
28
+ # service answers 503 "loading" (the model keeps loading meanwhile).
29
+ load_wait: float = 2.0
30
+ # Prometheus metrics on this port (inside the container network); off
31
+ # unless set, e.g. 9465.
32
+ metrics_port: int | None = None
33
+
34
+
35
+ def default_cache_path() -> Path:
36
+ """~/.cache/paperus-ml, or under $XDG_CACHE_HOME."""
37
+ base = os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache"
38
+ return Path(base) / "paperus-ml"
@@ -0,0 +1,116 @@
1
+ """Running an embedding model with ONNX Runtime."""
2
+
3
+ import threading
4
+ import time
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import numpy.typing as npt
10
+ import onnxruntime
11
+ from tokenizers import Encoding, Tokenizer
12
+
13
+ from paperus_ml import metrics
14
+ from paperus_ml.models import Kind, ModelSpec
15
+
16
+ Vectors = npt.NDArray[np.float32]
17
+
18
+
19
+ def session_options(intra_op_threads: int) -> onnxruntime.SessionOptions:
20
+ options = onnxruntime.SessionOptions()
21
+ options.graph_optimization_level = onnxruntime.GraphOptimizationLevel.ORT_ENABLE_ALL
22
+ # One graph at a time, parallel inside operators.
23
+ options.inter_op_num_threads = 1
24
+ options.intra_op_num_threads = intra_op_threads
25
+ # Denormal floats are slow on CPUs and irrelevant for embeddings.
26
+ options.add_session_config_entry("session.set_denormal_as_zero", "1")
27
+ return options
28
+
29
+
30
+ def pool(
31
+ hidden: npt.NDArray[np.float32],
32
+ mask: npt.NDArray[np.int64],
33
+ how: str,
34
+ ) -> Vectors:
35
+ """Sentence vectors from token vectors, L2-normalized."""
36
+ if how == "cls":
37
+ vectors = hidden[:, 0]
38
+ else:
39
+ weights = mask[..., None].astype(np.float32)
40
+ vectors = (hidden * weights).sum(axis=1) / np.maximum(weights.sum(axis=1), 1e-9)
41
+ norms = np.linalg.norm(vectors, axis=1, keepdims=True)
42
+ return (vectors / np.maximum(norms, 1e-12)).astype(np.float32)
43
+
44
+
45
+ def batches(lengths: Sequence[int], max_texts: int, max_tokens: int) -> list[list[int]]:
46
+ """Group text indices by length, so little padding is computed."""
47
+ order = sorted(range(len(lengths)), key=lambda i: lengths[i])
48
+ groups: list[list[int]] = []
49
+ current: list[int] = []
50
+ for index in order:
51
+ padded = (len(current) + 1) * lengths[index]
52
+ if current and (len(current) >= max_texts or padded > max_tokens):
53
+ groups.append(current)
54
+ current = []
55
+ current.append(index)
56
+ if current:
57
+ groups.append(current)
58
+ return groups
59
+
60
+
61
+ class Encoder:
62
+ def __init__(
63
+ self,
64
+ spec: ModelSpec,
65
+ model_dir: Path,
66
+ *,
67
+ intra_op_threads: int = 0,
68
+ max_batch_texts: int = 32,
69
+ max_batch_tokens: int = 16384,
70
+ ) -> None:
71
+ self.spec = spec
72
+ self.max_batch_texts = max_batch_texts
73
+ self.max_batch_tokens = max_batch_tokens
74
+ self.tokenizer = Tokenizer.from_file(str(model_dir / spec.tokenizer_file))
75
+ self.tokenizer.enable_truncation(spec.max_tokens)
76
+ self.tokenizer.no_padding()
77
+ self.session = onnxruntime.InferenceSession(
78
+ str(model_dir / spec.onnx_file),
79
+ sess_options=session_options(intra_op_threads),
80
+ providers=["CPUExecutionProvider"],
81
+ )
82
+ self.input_names = {i.name for i in self.session.get_inputs()}
83
+ # One batch at a time: each already uses all cores, so batches of
84
+ # simultaneous requests would only compete for them. Per batch, not
85
+ # per request, so a search query waits for one batch at most.
86
+ # Reentrant: the speed test holds it across all its batches.
87
+ self.running = threading.RLock()
88
+
89
+ def embed(self, texts: Sequence[str], kind: Kind) -> Vectors:
90
+ prefix = self.spec.prefix(kind)
91
+ encodings = self.tokenizer.encode_batch([prefix + t for t in texts])
92
+ result = np.zeros((len(texts), self.spec.dimensions), dtype=np.float32)
93
+ groups = batches(
94
+ [len(e.ids) for e in encodings], self.max_batch_texts, self.max_batch_tokens
95
+ )
96
+ for group in groups:
97
+ result[group] = self._run([encodings[i] for i in group])
98
+ return result
99
+
100
+ def _run(self, encodings: list[Encoding]) -> Vectors:
101
+ width = max(len(e.ids) for e in encodings)
102
+ ids = np.zeros((len(encodings), width), dtype=np.int64)
103
+ mask = np.zeros((len(encodings), width), dtype=np.int64)
104
+ for row, encoding in enumerate(encodings):
105
+ ids[row, : len(encoding.ids)] = encoding.ids
106
+ mask[row, : len(encoding.ids)] = 1
107
+ feed = {"input_ids": ids, "attention_mask": mask}
108
+ if "token_type_ids" in self.input_names:
109
+ feed["token_type_ids"] = np.zeros_like(ids)
110
+ with self.running:
111
+ started = time.perf_counter()
112
+ hidden = self.session.run(None, feed)[0]
113
+ metrics.BATCH_SECONDS.labels(self.spec.name).observe(
114
+ time.perf_counter() - started
115
+ )
116
+ return pool(np.asarray(hidden, dtype=np.float32), mask, self.spec.pooling)
@@ -0,0 +1,250 @@
1
+ """HTTP API of paperus-ml.
2
+
3
+ ``POST /embed`` with ``{"model": "bge-m3", "kind": "passage", "texts": [...]}``
4
+ returns one L2-normalized vector per text. The caller (paperus) names the
5
+ model in every request, so this service needs no model configuration.
6
+ """
7
+
8
+ import asyncio
9
+ import contextlib
10
+ import os
11
+ import secrets
12
+ import time
13
+ from collections.abc import AsyncGenerator
14
+ from contextlib import asynccontextmanager
15
+ from datetime import UTC, datetime
16
+ from typing import Annotated
17
+
18
+ from fastapi import BackgroundTasks, Depends, FastAPI, Header, HTTPException
19
+ from pydantic import BaseModel, Field
20
+
21
+ from paperus_ml import metrics
22
+ from paperus_ml.config import Settings
23
+ from paperus_ml.encoder import Encoder
24
+ from paperus_ml.manager import ModelFailed, ModelLoading, ModelManager, UnknownModel
25
+ from paperus_ml.models import MODELS, SCORES, Kind
26
+ from paperus_ml.samples import sample_passages
27
+
28
+ MAX_TEXTS = 256
29
+ UNLOAD_CHECK_SECONDS = 30
30
+
31
+
32
+ class EmbedRequest(BaseModel):
33
+ model: str
34
+ kind: Kind = "passage"
35
+ """Queries and passages get different prefixes in some models."""
36
+ texts: list[str] = Field(min_length=1, max_length=MAX_TEXTS)
37
+
38
+
39
+ class EmbedResponse(BaseModel):
40
+ model: str
41
+ dimensions: int
42
+ embeddings: list[list[float]]
43
+
44
+
45
+ class ModelInfo(BaseModel):
46
+ name: str
47
+ task: str
48
+ repo: str
49
+ revision: str
50
+ dimensions: int
51
+ max_tokens: int
52
+ loaded: bool
53
+ cached_bytes: int | None
54
+ """Size of the downloaded files; None if not downloaded yet."""
55
+ description: str
56
+ licence: str
57
+ download_mb: int
58
+ scores: dict[str, float]
59
+ """Published retrieval scores (nDCG@10, out of 100) by benchmark."""
60
+ benchmarks: dict[str, str] = Field(default_factory=lambda: dict(SCORES))
61
+ """What the score keys mean."""
62
+
63
+
64
+ class ServiceConfig(BaseModel):
65
+ threads: int | None = Field(default=None, ge=0, le=1024)
66
+ """CPU threads per model; 0: all the service sees; None: its own
67
+ setting (``PAPERUS_ML_INTRA_OP_THREADS``)."""
68
+ keep: list[str]
69
+ """Models to keep loaded (paperus sends the one in use)."""
70
+
71
+
72
+ class ServiceState(BaseModel):
73
+ threads: int
74
+ keep: list[str]
75
+ cores: int
76
+ """CPU cores this service may use."""
77
+ loaded: list[str]
78
+ loading: list[str]
79
+ started: str
80
+ """When this service started (an ID: a new value means a restart)."""
81
+
82
+
83
+ class Speed(BaseModel):
84
+ model: str
85
+ passages: int
86
+ seconds: float
87
+ passages_per_second: float
88
+
89
+
90
+ def usable_cores() -> int:
91
+ """Cores this process may run on (a container's CPU set, if limited)."""
92
+ try:
93
+ return len(os.sched_getaffinity(0))
94
+ except AttributeError: # not on Linux
95
+ return os.cpu_count() or 1
96
+
97
+
98
+ def create_app(
99
+ settings: Settings | None = None, manager: ModelManager | None = None
100
+ ) -> FastAPI:
101
+ settings = settings or Settings()
102
+ models = manager or ModelManager(settings)
103
+ started = datetime.now(UTC).isoformat()
104
+
105
+ @asynccontextmanager
106
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
107
+ if settings.metrics_port:
108
+ metrics.serve(
109
+ settings.metrics_port,
110
+ metrics.ModelsCollector(models.loaded, models.loading),
111
+ )
112
+ await asyncio.to_thread(models.preload)
113
+
114
+ async def unload_loop() -> None:
115
+ while True:
116
+ await asyncio.sleep(UNLOAD_CHECK_SECONDS)
117
+ await asyncio.to_thread(models.unload_idle)
118
+
119
+ task = asyncio.create_task(unload_loop())
120
+ yield
121
+ task.cancel()
122
+ with contextlib.suppress(asyncio.CancelledError):
123
+ await task
124
+
125
+ def check_token(authorization: Annotated[str | None, Header()] = None) -> None:
126
+ if settings.token is None:
127
+ return
128
+ scheme, _, token = (authorization or "").partition(" ")
129
+ valid = scheme.lower() == "bearer" and secrets.compare_digest(
130
+ token.encode(), settings.token.encode()
131
+ )
132
+ if not valid:
133
+ raise HTTPException(401, "token required")
134
+
135
+ app = FastAPI(
136
+ title="paperus-ml",
137
+ lifespan=lifespan,
138
+ generate_unique_id_function=lambda route: route.name,
139
+ )
140
+ app.state.models = models
141
+
142
+ @app.get("/ping")
143
+ async def ping() -> str:
144
+ # Async: answers even when every worker thread is busy.
145
+ return "pong"
146
+
147
+ @app.get("/models", dependencies=[Depends(check_token)])
148
+ def list_models() -> list[ModelInfo]:
149
+ loaded = set(models.loaded())
150
+ return [
151
+ ModelInfo(
152
+ name=s.name,
153
+ task=s.task,
154
+ repo=s.repo,
155
+ revision=s.revision,
156
+ dimensions=s.dimensions,
157
+ max_tokens=s.max_tokens,
158
+ loaded=s.name in loaded,
159
+ cached_bytes=models.cached_bytes(s),
160
+ description=s.description,
161
+ licence=s.licence,
162
+ download_mb=s.download_mb,
163
+ scores=dict(s.scores),
164
+ )
165
+ for s in MODELS.values()
166
+ ]
167
+
168
+ @app.delete("/models/{name}", status_code=204, dependencies=[Depends(check_token)])
169
+ def delete_model(name: str) -> None:
170
+ """Remove a downloaded model to free disk space."""
171
+ try:
172
+ models.delete_cached(name)
173
+ except UnknownModel as error:
174
+ raise HTTPException(404, f"unknown model {name!r}") from error
175
+
176
+ @app.get("/config", dependencies=[Depends(check_token)])
177
+ def get_config() -> ServiceState:
178
+ return ServiceState(
179
+ threads=models.threads,
180
+ keep=models.kept,
181
+ cores=usable_cores(),
182
+ loaded=models.loaded(),
183
+ loading=models.loading(),
184
+ started=started,
185
+ )
186
+
187
+ @app.put("/config", dependencies=[Depends(check_token)])
188
+ def set_config(body: ServiceConfig, tasks: BackgroundTasks) -> ServiceState:
189
+ """Apply paperus' settings; kept models load in the background."""
190
+ try:
191
+ missing = models.configure(threads=body.threads, keep=body.keep)
192
+ except UnknownModel as error:
193
+ raise HTTPException(404, f"unknown model {error.args[0]!r}") from error
194
+ if missing:
195
+ tasks.add_task(models.load_kept)
196
+ return get_config()
197
+
198
+ @app.post("/models/{name}/speed", dependencies=[Depends(check_token)])
199
+ def measure_speed(name: str) -> Speed:
200
+ """Embed sample passages (about 250 words each, like paperus'
201
+ passages) and time it: speed differs much between machines. A model
202
+ not loaded yet starts loading and the answer is 503 "loading": ask
203
+ again. Other requests for the model wait while it measures, so they
204
+ do not slow the measurement (other models still can)."""
205
+ encoder = _model(name)
206
+ passages = sample_passages()
207
+ with encoder.running:
208
+ encoder.embed(passages[:2], "passage") # warm up
209
+ started = time.monotonic()
210
+ encoder.embed(passages, "passage")
211
+ seconds = time.monotonic() - started
212
+ return Speed(
213
+ model=name,
214
+ passages=len(passages),
215
+ seconds=round(seconds, 2),
216
+ passages_per_second=round(len(passages) / max(seconds, 1e-6), 1),
217
+ )
218
+
219
+ def _model(name: str) -> Encoder:
220
+ try:
221
+ return models.get(name, wait=settings.load_wait)
222
+ except UnknownModel as error:
223
+ raise HTTPException(404, f"unknown model {name!r}") from error
224
+ except ModelLoading as error:
225
+ raise HTTPException(
226
+ 503, f"model {name!r} is loading", headers={"Retry-After": "10"}
227
+ ) from error
228
+ except ModelFailed as error:
229
+ raise HTTPException(
230
+ 502, f"loading model {name!r} failed: {error.args[1]}"
231
+ ) from error
232
+
233
+ @app.post("/embed", dependencies=[Depends(check_token)])
234
+ def embed(body: EmbedRequest) -> EmbedResponse:
235
+ """Runs in FastAPI's thread pool; ONNX Runtime releases the GIL."""
236
+ try:
237
+ vectors = _model(body.model).embed(body.texts, body.kind)
238
+ except HTTPException as error:
239
+ outcome = "loading" if error.status_code == 503 else "error"
240
+ metrics.REQUESTS.labels(body.model, body.kind, outcome).inc()
241
+ raise
242
+ metrics.REQUESTS.labels(body.model, body.kind, "ok").inc()
243
+ metrics.TEXTS.labels(body.model, body.kind).inc(len(body.texts))
244
+ return EmbedResponse(
245
+ model=body.model,
246
+ dimensions=vectors.shape[1],
247
+ embeddings=vectors.tolist(),
248
+ )
249
+
250
+ return app
@@ -0,0 +1,217 @@
1
+ """Loading models on first use, sharing them, and unloading idle ones."""
2
+
3
+ import logging
4
+ import shutil
5
+ import threading
6
+ import time
7
+ from collections.abc import Callable
8
+ from pathlib import Path
9
+
10
+ from huggingface_hub import snapshot_download
11
+
12
+ from paperus_ml.config import Settings
13
+ from paperus_ml.encoder import Encoder
14
+ from paperus_ml.models import MODELS, ModelSpec
15
+
16
+ log = logging.getLogger(__name__)
17
+
18
+ Loader = Callable[[ModelSpec], Encoder]
19
+
20
+
21
+ class UnknownModel(KeyError):
22
+ pass
23
+
24
+
25
+ class ModelLoading(Exception):
26
+ """The model is still downloading or loading; ask again shortly."""
27
+
28
+
29
+ class ModelFailed(Exception):
30
+ """Loading the model failed (e.g. the download); the message says why."""
31
+
32
+
33
+ class ModelManager:
34
+ def __init__(self, settings: Settings, loader: Loader | None = None) -> None:
35
+ self.settings = settings
36
+ self._load = loader or self._download_and_load
37
+ # name → (encoder, last used, threads it was loaded with)
38
+ self._models: dict[str, tuple[Encoder, float, int]] = {}
39
+ self._loading: dict[str, threading.Event] = {}
40
+ self._errors: dict[str, str] = {}
41
+ self._guard = threading.Lock()
42
+ self._locks: dict[str, threading.Lock] = {
43
+ name: threading.Lock() for name in MODELS
44
+ }
45
+ self._keep = set(settings.preload)
46
+ self.threads = settings.intra_op_threads
47
+ """CPU threads per model (0: all ONNX Runtime sees); set by paperus."""
48
+
49
+ def model_dir(self, spec: ModelSpec) -> Path:
50
+ """<cache>/<task>/<model>, with plain files: the shared Hugging Face
51
+ cache uses symlinks, and ONNX Runtime refuses weight files that
52
+ resolve outside the model's folder."""
53
+ return self.settings.cache_path / spec.task / spec.name
54
+
55
+ def cached_bytes(self, spec: ModelSpec) -> int | None:
56
+ """Size on disk if the model is downloaded, else None."""
57
+ directory = self.model_dir(spec)
58
+ if not (directory / spec.onnx_file).is_file():
59
+ return None
60
+ return sum(p.stat().st_size for p in directory.rglob("*") if p.is_file())
61
+
62
+ def delete_cached(self, name: str) -> None:
63
+ """Unload a model and remove its files; it downloads again when used."""
64
+ spec = MODELS.get(name)
65
+ if spec is None:
66
+ raise UnknownModel(name)
67
+ with self._locks[name]:
68
+ self._models.pop(name, None)
69
+ self._errors.pop(name, None)
70
+ shutil.rmtree(self.model_dir(spec), ignore_errors=True)
71
+
72
+ def _download_and_load(self, spec: ModelSpec) -> Encoder:
73
+ model_dir = self.model_dir(spec)
74
+ # Revision changes download the changed files into the same folder.
75
+ snapshot_download(
76
+ spec.repo,
77
+ revision=spec.revision,
78
+ local_dir=model_dir,
79
+ allow_patterns=[f"{spec.onnx_file}*", spec.tokenizer_file],
80
+ )
81
+ return Encoder(
82
+ spec,
83
+ model_dir,
84
+ intra_op_threads=self.threads,
85
+ max_batch_texts=self.settings.max_batch_texts,
86
+ max_batch_tokens=self.settings.max_batch_tokens,
87
+ )
88
+
89
+ def get(self, name: str, wait: float | None = None) -> Encoder:
90
+ """The model, downloading and loading it on first use (and again when
91
+ the threads changed). ``wait=None`` blocks until it is ready; with
92
+ ``wait`` in seconds, loading continues in the background and
93
+ ``ModelLoading`` is raised if it takes longer, so requests do not pile
94
+ up behind a download."""
95
+ spec = MODELS.get(name)
96
+ if spec is None:
97
+ raise UnknownModel(name)
98
+ ready = self._ready(name)
99
+ if ready is not None:
100
+ return ready
101
+ if wait is None:
102
+ return self._load_now(name, spec)
103
+ done = self._load_in_background(name)
104
+ done.wait(wait)
105
+ ready = self._ready(name)
106
+ if ready is not None:
107
+ return ready
108
+ if name in self._errors and not self._loading_now(name):
109
+ raise ModelFailed(name, self._errors[name])
110
+ raise ModelLoading(name)
111
+
112
+ def _ready(self, name: str) -> Encoder | None:
113
+ """The loaded model, if loaded with the current threads."""
114
+ entry = self._models.get(name)
115
+ if entry is None or entry[2] != self.threads:
116
+ return None
117
+ self._models[name] = (entry[0], time.monotonic(), entry[2])
118
+ return entry[0]
119
+
120
+ def _load_now(self, name: str, spec: ModelSpec) -> Encoder:
121
+ with self._locks[name]:
122
+ ready = self._ready(name)
123
+ if ready is not None:
124
+ return ready
125
+ # Drop an old copy (other threads) before loading the new one.
126
+ self._models.pop(name, None)
127
+ threads = self.threads
128
+ log.info("loading model %s", name)
129
+ started = time.monotonic()
130
+ try:
131
+ encoder = self._load(spec)
132
+ except Exception as error:
133
+ self._errors[name] = str(error) or type(error).__name__
134
+ raise
135
+ self._errors.pop(name, None)
136
+ log.info("loaded %s in %.1f s", name, time.monotonic() - started)
137
+ self._models[name] = (encoder, time.monotonic(), threads)
138
+ return encoder
139
+
140
+ def _load_in_background(self, name: str) -> threading.Event:
141
+ with self._guard:
142
+ if name in self._loading:
143
+ return self._loading[name]
144
+ done = threading.Event()
145
+ self._loading[name] = done
146
+
147
+ def load() -> None:
148
+ try:
149
+ self.get(name)
150
+ except Exception:
151
+ log.warning("loading %s failed", name, exc_info=True)
152
+ finally:
153
+ with self._guard:
154
+ self._loading.pop(name, None)
155
+ done.set()
156
+
157
+ threading.Thread(target=load, name=f"load-{name}", daemon=True).start()
158
+ return done
159
+
160
+ def _loading_now(self, name: str) -> bool:
161
+ with self._guard:
162
+ return name in self._loading
163
+
164
+ def loading(self) -> list[str]:
165
+ with self._guard:
166
+ return sorted(self._loading)
167
+
168
+ def loaded(self) -> list[str]:
169
+ return sorted(self._models)
170
+
171
+ def unload_idle(self, now: float | None = None) -> list[str]:
172
+ """Unload models idle longer than the TTL, except kept ones. Skips a
173
+ model that is busy loading; checks again under its lock."""
174
+ ttl = self.settings.model_ttl
175
+ if ttl <= 0:
176
+ return []
177
+ now = time.monotonic() if now is None else now
178
+ unloaded = []
179
+ for name in list(self._models):
180
+ lock = self._locks[name]
181
+ if not lock.acquire(blocking=False):
182
+ continue
183
+ try:
184
+ entry = self._models.get(name)
185
+ if entry is None or name in self._keep or now - entry[1] < ttl:
186
+ continue
187
+ self._models.pop(name, None)
188
+ finally:
189
+ lock.release()
190
+ unloaded.append(name)
191
+ log.info("unloaded idle model %s", name)
192
+ return unloaded
193
+
194
+ @property
195
+ def kept(self) -> list[str]:
196
+ return sorted(self._keep)
197
+
198
+ def configure(self, *, threads: int | None, keep: list[str]) -> list[str]:
199
+ """Settings from paperus' administration: threads per model (None:
200
+ this service's own setting), and the models to keep loaded (the one
201
+ in use). Models loaded with other threads load again when next used
202
+ (requests running keep theirs). Returns the kept models still to load
203
+ (``load_kept`` loads them, which can take a while)."""
204
+ unknown = [name for name in keep if name not in MODELS]
205
+ if unknown:
206
+ raise UnknownModel(unknown[0])
207
+ self.threads = self.settings.intra_op_threads if threads is None else threads
208
+ self._keep = set(keep)
209
+ return [name for name in keep if self._ready(name) is None]
210
+
211
+ def load_kept(self) -> None:
212
+ for name in self.kept:
213
+ self.get(name)
214
+
215
+ def preload(self) -> None:
216
+ for name in self.settings.preload:
217
+ self.get(name)
@@ -0,0 +1,55 @@
1
+ """Prometheus metrics of the ML service, on a port of their own
2
+ (``PAPERUS_ML_METRICS_PORT``): requests, batch times, loaded models."""
3
+
4
+ from collections.abc import Callable, Iterator
5
+
6
+ from prometheus_client import REGISTRY, Counter, Histogram, start_http_server
7
+ from prometheus_client.core import GaugeMetricFamily, Metric
8
+ from prometheus_client.registry import Collector
9
+
10
+ REQUESTS = Counter(
11
+ "paperus_ml_requests_total",
12
+ "Embedding requests, by model, kind and outcome",
13
+ ["model", "kind", "outcome"],
14
+ )
15
+ TEXTS = Counter("paperus_ml_texts_total", "Texts embedded", ["model", "kind"])
16
+ BATCH_SECONDS = Histogram(
17
+ "paperus_ml_batch_duration_seconds",
18
+ "Time to run one batch through a model",
19
+ ["model"],
20
+ buckets=(0.01, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 30, 60),
21
+ )
22
+
23
+
24
+ class ModelsCollector(Collector):
25
+ """Loaded and loading models, read at each scrape."""
26
+
27
+ def __init__(
28
+ self, loaded: Callable[[], list[str]], loading: Callable[[], list[str]]
29
+ ) -> None:
30
+ self.loaded = loaded
31
+ self.loading = loading
32
+
33
+ def collect(self) -> Iterator[Metric]:
34
+ gauge = GaugeMetricFamily(
35
+ "paperus_ml_model_loaded",
36
+ "1 for each model in memory, 0.5 while loading",
37
+ labels=["model"],
38
+ )
39
+ for name in self.loaded():
40
+ gauge.add_metric([name], 1.0)
41
+ for name in self.loading():
42
+ gauge.add_metric([name], 0.5)
43
+ yield gauge
44
+
45
+
46
+ _started = False
47
+
48
+
49
+ def serve(port: int, collector: Collector) -> None:
50
+ global _started
51
+ if _started:
52
+ return
53
+ REGISTRY.register(collector)
54
+ start_http_server(port)
55
+ _started = True
@@ -0,0 +1,214 @@
1
+ """The models paperus-ml can serve.
2
+
3
+ Each is an ONNX export from Hugging Face, pinned to a revision so a changed
4
+ file upstream cannot silently change the vectors stored in paperus.
5
+
6
+ Scores are the published retrieval averages of the full-precision models
7
+ (int8 exports typically lose about a point), checked 2026-11-05: MTEB
8
+ (eng, v2) and MTEB (deu, v1) Retrieval from the MTEB leaderboard, MMTEB
9
+ (Multilingual, v2) Retrieval likewise, or from the MMTEB paper
10
+ (arXiv 2502.13595) where the leaderboard shows no average.
11
+ """
12
+
13
+ from dataclasses import dataclass
14
+ from typing import Literal
15
+
16
+ Task = Literal["embedding"]
17
+
18
+ SCORES = {
19
+ "en": "Retrieval, English (MTEB eng v2)",
20
+ "de": "Retrieval, German (MTEB deu v1)",
21
+ "multilingual": "Retrieval, multilingual (MMTEB v2)",
22
+ }
23
+ Pooling = Literal["cls", "mean"]
24
+ Kind = Literal["query", "passage"]
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ModelSpec:
29
+ name: str
30
+ repo: str
31
+ revision: str
32
+ onnx_file: str
33
+ tokenizer_file: str
34
+ pooling: Pooling
35
+ dimensions: int
36
+ max_tokens: int
37
+ query_prefix: str = ""
38
+ passage_prefix: str = ""
39
+ task: Task = "embedding"
40
+ # For choosing a model (shown in paperus' administration).
41
+ description: str = ""
42
+ licence: str = ""
43
+ download_mb: int = 0
44
+ scores: tuple[tuple[str, float], ...] = ()
45
+ """Published retrieval scores (nDCG@10, out of 100) by benchmark, see SCORES."""
46
+
47
+ def prefix(self, kind: Kind) -> str:
48
+ return self.query_prefix if kind == "query" else self.passage_prefix
49
+
50
+
51
+ MODELS = {
52
+ spec.name: spec
53
+ for spec in [
54
+ # Multilingual, 1024 dimensions, long inputs; ONNX from the authors.
55
+ ModelSpec(
56
+ name="bge-m3",
57
+ repo="BAAI/bge-m3",
58
+ revision="5617a9f61b028005a4858fdac845db406aefb181",
59
+ onnx_file="onnx/model.onnx",
60
+ tokenizer_file="onnx/tokenizer.json",
61
+ pooling="cls",
62
+ dimensions=1024,
63
+ max_tokens=8192,
64
+ description=(
65
+ "Multilingual, long inputs; strong but slow on a CPU (full precision)."
66
+ ),
67
+ licence="MIT",
68
+ download_mb=2270,
69
+ scores=(("multilingual", 54.6),),
70
+ ),
71
+ # The same model in int8 (570 MB): several times faster on a CPU,
72
+ # nearly the same quality; vectors differ, so it counts as its own.
73
+ ModelSpec(
74
+ name="bge-m3-int8",
75
+ repo="Xenova/bge-m3",
76
+ revision="4de13258303883538bd53b696b452bf8099f0858",
77
+ onnx_file="onnx/model_int8.onnx",
78
+ tokenizer_file="tokenizer.json",
79
+ pooling="cls",
80
+ dimensions=1024,
81
+ max_tokens=8192,
82
+ description=(
83
+ "bge-m3 in int8: faster and a quarter of the size, nearly the same "
84
+ "quality."
85
+ ),
86
+ licence="MIT",
87
+ download_mb=570,
88
+ scores=(("multilingual", 54.6),),
89
+ ),
90
+ # Snowflake Arctic Embed v2 (Apache-2.0): multilingual, made for
91
+ # retrieval, long inputs; int8.
92
+ ModelSpec(
93
+ name="arctic-embed-m-v2",
94
+ repo="Snowflake/snowflake-arctic-embed-m-v2.0",
95
+ revision="95c2741480856aa9666782eb4afe11959938017f",
96
+ onnx_file="onnx/model_quantized.onnx",
97
+ tokenizer_file="tokenizer.json",
98
+ pooling="cls",
99
+ dimensions=768,
100
+ max_tokens=8192,
101
+ query_prefix="query: ",
102
+ description=(
103
+ "Recommended: multilingual and made for search; good quality, fast, "
104
+ "small."
105
+ ),
106
+ licence="Apache-2.0",
107
+ download_mb=311,
108
+ scores=(
109
+ ("en", 58.4),
110
+ ("de", 55.2),
111
+ ("multilingual", 54.8),
112
+ ),
113
+ ),
114
+ ModelSpec(
115
+ name="arctic-embed-l-v2",
116
+ repo="Snowflake/snowflake-arctic-embed-l-v2.0",
117
+ revision="ac6544c8a46e00af67e330e85a9028c66b8cfd9a",
118
+ onnx_file="onnx/model_quantized.onnx",
119
+ tokenizer_file="tokenizer.json",
120
+ pooling="cls",
121
+ dimensions=1024,
122
+ max_tokens=8192,
123
+ query_prefix="query: ",
124
+ description=(
125
+ "The best quality here, multilingual; about half as fast as the medium "
126
+ "one."
127
+ ),
128
+ licence="Apache-2.0",
129
+ download_mb=570,
130
+ scores=(
131
+ ("en", 58.6),
132
+ ("de", 57.1),
133
+ ("multilingual", 58.4),
134
+ ),
135
+ ),
136
+ # Alibaba GTE multilingual (Apache-2.0): long inputs, int8.
137
+ ModelSpec(
138
+ name="gte-multilingual-base",
139
+ repo="onnx-community/gte-multilingual-base",
140
+ revision="2edbf5e672aab465f9ed4c154a8b61791c082c69",
141
+ onnx_file="onnx/model_quantized.onnx",
142
+ tokenizer_file="tokenizer.json",
143
+ pooling="cls",
144
+ dimensions=768,
145
+ max_tokens=8192,
146
+ description="Multilingual, long inputs, fast.",
147
+ licence="Apache-2.0",
148
+ download_mb=340,
149
+ scores=(("en", 50.9),),
150
+ ),
151
+ # E5 (MIT), int8: base and large; inputs up to 512 tokens.
152
+ ModelSpec(
153
+ name="multilingual-e5-base",
154
+ repo="Xenova/multilingual-e5-base",
155
+ revision="1ec9243030a27d1a115d5c340572074c125b58b2",
156
+ onnx_file="onnx/model_quantized.onnx",
157
+ tokenizer_file="tokenizer.json",
158
+ pooling="mean",
159
+ dimensions=768,
160
+ max_tokens=512,
161
+ query_prefix="query: ",
162
+ passage_prefix="passage: ",
163
+ description="Multilingual, fast; inputs up to 512 tokens.",
164
+ licence="MIT",
165
+ download_mb=279,
166
+ scores=(
167
+ ("en", 49.0),
168
+ ("de", 48.8),
169
+ ("multilingual", 52.7),
170
+ ),
171
+ ),
172
+ ModelSpec(
173
+ name="multilingual-e5-large",
174
+ repo="Xenova/multilingual-e5-large",
175
+ revision="00fc3aeb3dbb95842de2ac1961d33c6319acf57b",
176
+ onnx_file="onnx/model_quantized.onnx",
177
+ tokenizer_file="tokenizer.json",
178
+ pooling="mean",
179
+ dimensions=1024,
180
+ max_tokens=512,
181
+ query_prefix="query: ",
182
+ passage_prefix="passage: ",
183
+ description="Multilingual; inputs up to 512 tokens; slow.",
184
+ licence="MIT",
185
+ download_mb=562,
186
+ scores=(
187
+ ("en", 51.5),
188
+ ("de", 50.0),
189
+ ("multilingual", 54.1),
190
+ ),
191
+ ),
192
+ # Small and fast (int8, 118 MB); for development and small machines.
193
+ ModelSpec(
194
+ name="multilingual-e5-small",
195
+ repo="Xenova/multilingual-e5-small",
196
+ revision="761b726dd34fb83930e26aab4e9ac3899aa1fa78",
197
+ onnx_file="onnx/model_quantized.onnx",
198
+ tokenizer_file="tokenizer.json",
199
+ pooling="mean",
200
+ dimensions=384,
201
+ max_tokens=512,
202
+ query_prefix="query: ",
203
+ passage_prefix="passage: ",
204
+ description="Small and very fast, for weak machines; lower quality.",
205
+ licence="MIT",
206
+ download_mb=118,
207
+ scores=(
208
+ ("en", 46.4),
209
+ ("de", 48.2),
210
+ ("multilingual", 49.3),
211
+ ),
212
+ ),
213
+ ]
214
+ }
File without changes
@@ -0,0 +1,26 @@
1
+ # ruff: noqa: E501 (one sample sentence per line)
2
+ """Sample passages for measuring speed: research prose of the length
3
+ paperus embeds (about 250 words), varied so no two are the same."""
4
+
5
+ _SENTENCES = [
6
+ "We study how blind zones below the radar beam hide light rainfall in mountain valleys.",
7
+ "The model fills the missing reflectivity from neighbouring heights and nearby stations.",
8
+ "Results are compared against rain gauges over three winters and two summer seasons.",
9
+ "Errors grow with distance from the radar and with the height of the lowest usable beam.",
10
+ "Die Messungen zeigen, dass flache Niederschläge in Tälern häufig unterschätzt werden.",
11
+ "A diffusion model trained on complete scans learns the vertical structure of storms.",
12
+ "We release the code and the data, so that other groups can repeat every experiment.",
13
+ "Uncertainty is estimated from an ensemble of twenty samples for each missing column.",
14
+ "Les résultats confirment l'intérêt de la méthode pour la prévision à très courte échéance.",
15
+ "Ablation studies show that both the height prior and the station data are needed.",
16
+ "Compared with interpolation, the error falls by a third in the lowest kilometre.",
17
+ "Limitations remain for convective cells that form entirely inside the blind zone.",
18
+ ]
19
+
20
+
21
+ def sample_passages(count: int = 48) -> list[str]:
22
+ passages = []
23
+ for n in range(count):
24
+ sentences = [_SENTENCES[(n * 5 + i * 7) % len(_SENTENCES)] for i in range(18)]
25
+ passages.append(f"Section {n + 1}. " + " ".join(sentences))
26
+ return passages