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.
- paperus_ml-0.0.8/PKG-INFO +53 -0
- paperus_ml-0.0.8/README.md +28 -0
- paperus_ml-0.0.8/pyproject.toml +74 -0
- paperus_ml-0.0.8/pyproject.toml.orig +57 -0
- paperus_ml-0.0.8/src/paperus_ml/__init__.py +13 -0
- paperus_ml-0.0.8/src/paperus_ml/cli.py +17 -0
- paperus_ml-0.0.8/src/paperus_ml/config.py +38 -0
- paperus_ml-0.0.8/src/paperus_ml/encoder.py +116 -0
- paperus_ml-0.0.8/src/paperus_ml/main.py +250 -0
- paperus_ml-0.0.8/src/paperus_ml/manager.py +217 -0
- paperus_ml-0.0.8/src/paperus_ml/metrics.py +55 -0
- paperus_ml-0.0.8/src/paperus_ml/models.py +214 -0
- paperus_ml-0.0.8/src/paperus_ml/py.typed +0 -0
- paperus_ml-0.0.8/src/paperus_ml/samples.py +26 -0
|
@@ -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
|