Skip to content

Commit 5220b34

Browse files
n24q02mn24q02mclaude
authored
feat: production-grade bring-your-own-model (BYO) support (#733)
* fix: honor dim/MRL truncation on custom and pooled embedding paths Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: make custom embedding models work under multiprocessing and case-insensitive Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: skip parallel custom-model integration test on Windows spawn deadlock Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: add CustomModelSpec one-call BYO registration helper Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: add HF-id to ONNX export helper with lazy optional deps Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: add NOTICE retaining fastembed (Qdrant) attribution Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: document CustomModelSpec bring-your-own-model usage Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: format test_export.py Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: n24q02m <n24q02m@outlook.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d450791 commit 5220b34

16 files changed

Lines changed: 431 additions & 65 deletions

NOTICE

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
qwen3-embed
2+
Copyright 2025-2026 n24q02m
3+
4+
This product includes software developed as part of fastembed
5+
(https://github.com/qdrant/fastembed), Copyright 2023 Qdrant,
6+
licensed under the Apache License, Version 2.0.

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,41 @@ ONNX reranker variants are scored one sequence at a time (no padding), which kee
171171
RoPE positions correct regardless of batch composition. See issue
172172
[#725](https://github.com/n24q02m/qwen3-embed/issues/725).
173173

174+
### Custom models (bring your own)
175+
176+
Qwen3 is the only built-in model, but any ONNX-able embedding model can be
177+
registered and then loaded by id. Use `CustomModelSpec` with one of the four
178+
output shapes: `CLS`/`MEAN` (bert-bi), `LAST_TOKEN` (causal), or `DISABLED` (raw).
179+
180+
```python
181+
from qwen3_embed import CustomModelSpec, TextEmbedding
182+
183+
# Multilingual (incl. Vietnamese) + code, CLS-pooled, 768-dim
184+
CustomModelSpec(
185+
model_id="onnx-community/gte-multilingual-base",
186+
hf="onnx-community/gte-multilingual-base",
187+
model_file="onnx/model_quantized.onnx",
188+
dim=768, pooling="CLS", normalization=True,
189+
).register()
190+
191+
model = TextEmbedding("onnx-community/gte-multilingual-base")
192+
embeddings = list(model.embed(["xin chào", "def add(a, b): return a + b"]))
193+
```
194+
195+
Other verified examples: `bge-m3` (`pooling="CLS"`, `dim=1024`), `EmbeddingGemma-300m`
196+
(`pooling="MEAN"`, `dim=768`). MRL truncation (`embed(..., dim=256)`) works for custom
197+
models whose vectors are Matryoshka-trained. Custom models are scored per-row, so —
198+
like the built-in INT8 reranker — their scores are batch-invariant by construction.
199+
200+
PyTorch-only models can be converted first (in a throwaway env, since the export
201+
deps don't co-resolve with the lean runtime pins):
202+
203+
```python
204+
# pip install "optimum[exporters]" torch transformers onnx
205+
from qwen3_embed.export import export_to_onnx
206+
export_to_onnx("intfloat/multilingual-e5-base", "./e5-onnx")
207+
```
208+
174209
## Configuration
175210

176211
### GPU Acceleration

qwen3_embed/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import importlib.metadata
22

3+
from qwen3_embed.common.custom_model import CustomModelSpec
34
from qwen3_embed.common.types import Device
45
from qwen3_embed.rerank.cross_encoder import TextCrossEncoder
56
from qwen3_embed.text import TextEmbedding
@@ -11,6 +12,7 @@
1112

1213
__version__ = version
1314
__all__ = [
15+
"CustomModelSpec",
1416
"Device",
1517
"TextEmbedding",
1618
"TextCrossEncoder",

qwen3_embed/common/custom_model.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""One-call registration helper for bring-your-own (BYO) ONNX embedding models."""
2+
3+
from dataclasses import dataclass, field
4+
5+
from qwen3_embed.common.model_description import (
6+
CustomDenseModelDescription,
7+
ModelSource,
8+
PoolingType,
9+
)
10+
11+
12+
@dataclass
13+
class CustomModelSpec:
14+
"""One-call registration of a BYO ONNX embedding model.
15+
16+
Only ONNX-able models with one of the four supported output shapes are
17+
accepted: bert-bi (``CLS``/``MEAN``), causal last-token (``LAST_TOKEN``), or
18+
raw 3-D output (``DISABLED``).
19+
20+
Usage::
21+
22+
from qwen3_embed import CustomModelSpec, TextEmbedding
23+
24+
CustomModelSpec(
25+
model_id="Org/gte-multilingual-base-onnx",
26+
hf="Org/gte-multilingual-base-onnx",
27+
model_file="onnx/model.onnx",
28+
dim=768, pooling="CLS", normalization=True,
29+
).register()
30+
31+
model = TextEmbedding("Org/gte-multilingual-base-onnx")
32+
"""
33+
34+
model_id: str
35+
hf: str | None = None
36+
url: str | None = None
37+
model_file: str = "onnx/model.onnx"
38+
dim: int | None = None
39+
pooling: str | PoolingType = PoolingType.MEAN
40+
normalization: bool = True
41+
max_seq_len: int | None = None
42+
additional_files: list[str] = field(default_factory=list)
43+
44+
def register(self) -> None:
45+
"""Register this model with :class:`TextEmbedding` so it can be loaded by id."""
46+
from qwen3_embed import TextEmbedding
47+
48+
if self.dim is None:
49+
raise ValueError("dim is required for an embedding model")
50+
51+
description = CustomDenseModelDescription(
52+
model=self.model_id,
53+
dim=self.dim,
54+
sources=ModelSource(hf=self.hf, url=self.url),
55+
model_file=self.model_file,
56+
additional_files=self.additional_files,
57+
)
58+
TextEmbedding.add_custom_model(
59+
description,
60+
pooling=PoolingType(self.pooling),
61+
normalization=self.normalization,
62+
)

qwen3_embed/common/model_description.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,9 @@ class PoolingType(StrEnum):
5252
MEAN = "MEAN"
5353
LAST_TOKEN = "LAST_TOKEN"
5454
DISABLED = "DISABLED"
55+
56+
57+
@dataclass(frozen=True)
58+
class CustomDenseModelDescription(DenseModelDescription):
59+
pooling: PoolingType = PoolingType.MEAN
60+
normalization: bool = True

qwen3_embed/export.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Optional HuggingFace-id to ONNX export.
2+
3+
The runtime package stays onnxruntime-only; the heavy export dependencies
4+
(torch, transformers, optimum) are imported lazily so importing this module does
5+
not pull them in. They are NOT declared as an optional extra because the lib pins
6+
``tokenizers``/``huggingface-hub`` versions that current ``transformers`` releases
7+
cannot co-resolve (see pyproject #691) — installing them as an extra would make
8+
the universal lock unsatisfiable. Install them yourself in a throwaway env::
9+
10+
pip install "optimum[exporters]" torch transformers onnx
11+
"""
12+
13+
from pathlib import Path
14+
15+
_MISSING_EXPORT_DEPS = (
16+
"ONNX export needs optimum + torch + transformers. Install them in a separate "
17+
'env: pip install "optimum[exporters]" torch transformers onnx'
18+
)
19+
20+
21+
def export_to_onnx(model_id: str, output_dir: str, *, task: str = "feature-extraction") -> str:
22+
"""Export an HF model + tokenizer to ONNX under ``output_dir``.
23+
24+
Args:
25+
model_id: HuggingFace model id (e.g. ``"intfloat/multilingual-e5-base"``).
26+
output_dir: Directory to write ``onnx/model.onnx`` + tokenizer files.
27+
task: optimum export task; ``"feature-extraction"`` for embeddings.
28+
29+
Returns:
30+
The ``output_dir`` path containing the exported model.
31+
32+
Raises:
33+
ImportError: if the ``[export]`` extra is not installed.
34+
"""
35+
try:
36+
from optimum.exporters.onnx import main_export
37+
except ImportError as e: # pragma: no cover - exercised only without the extra
38+
raise ImportError(_MISSING_EXPORT_DEPS) from e
39+
40+
Path(output_dir).mkdir(parents=True, exist_ok=True)
41+
main_export(model_id, output=output_dir, task=task)
42+
return output_dir

qwen3_embed/text/custom_text_embedding.py

Lines changed: 43 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
from collections.abc import Iterable
2-
from dataclasses import dataclass
32
from typing import Any
43

54
import numpy as np
65
from numpy.typing import NDArray
76

87
from qwen3_embed.common.model_description import (
8+
CustomDenseModelDescription,
99
DenseModelDescription,
1010
PoolingType,
1111
)
@@ -15,15 +15,11 @@
1515
from qwen3_embed.text.onnx_embedding import OnnxTextEmbedding, OnnxTextEmbeddingWorker
1616

1717

18-
@dataclass(frozen=True)
19-
class PostprocessingConfig:
20-
pooling: PoolingType
21-
normalization: bool
22-
23-
2418
class CustomTextEmbedding(OnnxTextEmbedding):
25-
SUPPORTED_MODELS: list[DenseModelDescription] = []
26-
POSTPROCESSING_MAPPING: dict[str, PostprocessingConfig] = {}
19+
# Single source of truth: model-id (lowercased) -> description carrying
20+
# pooling+normalization. The description is a frozen, picklable dataclass so it
21+
# survives the deepcopy into spawned worker processes.
22+
_SUPPORTED: dict[str, CustomDenseModelDescription] = {}
2723

2824
def __init__(
2925
self,
@@ -34,21 +30,52 @@ def __init__(
3430
model_name=model_name,
3531
**kwargs,
3632
)
37-
self._pooling = self.POSTPROCESSING_MAPPING[model_name].pooling
38-
self._normalization = self.POSTPROCESSING_MAPPING[model_name].normalization
33+
desc = self._resolve_description(model_name)
34+
self._pooling = desc.pooling
35+
self._normalization = desc.normalization
36+
37+
@classmethod
38+
def _register(cls, desc: CustomDenseModelDescription) -> None:
39+
cls._clear_model_cache()
40+
cls._SUPPORTED[desc.model.lower()] = desc
41+
42+
@classmethod
43+
def _resolve_description(cls, model_name: str) -> CustomDenseModelDescription:
44+
return cls._SUPPORTED[model_name.lower()]
45+
46+
@classmethod
47+
def _export_registry(cls) -> list[CustomDenseModelDescription]:
48+
return list(cls._SUPPORTED.values())
49+
50+
@classmethod
51+
def _import_registry(cls, payload: list[CustomDenseModelDescription]) -> None:
52+
for desc in payload:
53+
cls._SUPPORTED[desc.model.lower()] = desc
3954

4055
@classmethod
4156
def _list_supported_models(cls) -> list[DenseModelDescription]:
42-
return cls.SUPPORTED_MODELS
57+
return list(cls._SUPPORTED.values())
4358

4459
@classmethod
4560
def _get_worker_class(cls) -> type["CustomTextEmbeddingWorker"]:
4661
return CustomTextEmbeddingWorker
4762

63+
def _extra_worker_params(self) -> dict[str, Any]:
64+
# Propagate the runtime registry so spawned workers (fresh interpreters
65+
# with an empty _SUPPORTED) can resolve + re-register this custom model.
66+
return {"custom_registry": self._export_registry()}
67+
4868
def _post_process_onnx_output(
4969
self, output: OnnxOutputContext, **kwargs: Any
5070
) -> Iterable[NumpyArray]:
51-
return self._normalize(self._pool(output.model_output, output.attention_mask))
71+
embeddings = self._pool(output.model_output, output.attention_mask)
72+
73+
# MRL: optionally truncate to requested dimension
74+
dim: int | None = kwargs.get("dim")
75+
if dim is not None:
76+
embeddings = embeddings[:, :dim]
77+
78+
return self._normalize(embeddings)
5279

5380
def _pool(
5481
self, embeddings: NumpyArray, attention_mask: NDArray[np.int64] | None = None
@@ -78,19 +105,6 @@ def _pool(
78105
def _normalize(self, embeddings: NumpyArray) -> NumpyArray:
79106
return normalize(embeddings) if self._normalization else embeddings
80107

81-
@classmethod
82-
def add_model(
83-
cls,
84-
model_description: DenseModelDescription,
85-
pooling: PoolingType,
86-
normalization: bool,
87-
) -> None:
88-
cls._clear_model_cache()
89-
cls.SUPPORTED_MODELS.append(model_description)
90-
cls.POSTPROCESSING_MAPPING[model_description.model] = PostprocessingConfig(
91-
pooling=pooling, normalization=normalization
92-
)
93-
94108

95109
class CustomTextEmbeddingWorker(OnnxTextEmbeddingWorker):
96110
def init_embedding(
@@ -99,6 +113,9 @@ def init_embedding(
99113
cache_dir: str,
100114
**kwargs: Any,
101115
) -> CustomTextEmbedding:
116+
registry = kwargs.pop("custom_registry", None)
117+
if registry is not None:
118+
CustomTextEmbedding._import_registry(registry)
102119
return CustomTextEmbedding(
103120
model_name=model_name,
104121
cache_dir=cache_dir,

qwen3_embed/text/onnx_embedding.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
# Base class model list kept empty — Qwen3 models are registered
1212
# in qwen3_embedding.py. Custom models can be added at runtime
13-
# via CustomTextEmbedding.add_model().
13+
# via TextEmbedding.add_custom_model().
1414
supported_onnx_models: list[DenseModelDescription] = []
1515

1616

qwen3_embed/text/onnx_text_model.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ class OnnxTextModel(OnnxModel[T]):
2020
def _get_worker_class(cls) -> type["TextEmbeddingWorker[T]"]:
2121
raise NotImplementedError("Subclasses must implement this method")
2222

23+
def _extra_worker_params(self) -> dict[str, Any]:
24+
"""Extra kwargs injected into each spawned worker's init.
25+
26+
Subclasses override this to carry state (e.g. a runtime-registered custom
27+
model registry) into worker processes that start with a fresh interpreter.
28+
"""
29+
return {}
30+
2331
def _post_process_onnx_output(self, output: OnnxOutputContext, **kwargs: Any) -> Iterable[T]:
2432
"""Post-process the ONNX model output to convert it into a usable format.
2533
@@ -129,6 +137,8 @@ def _embed_documents(
129137
if extra_session_options is not None:
130138
params.update(extra_session_options)
131139

140+
params.update(self._extra_worker_params())
141+
132142
pool = ParallelWorkerPool(
133143
worker=self._get_worker_class(),
134144
config=PoolConfig(

qwen3_embed/text/pooled_embedding.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from qwen3_embed.text.onnx_embedding import OnnxTextEmbedding, OnnxTextEmbeddingWorker
1212

1313
# Base class model list kept empty — mean pooling models can be added
14-
# at runtime via CustomTextEmbedding.add_model(pooling=PoolingType.MEAN).
14+
# at runtime via TextEmbedding.add_custom_model(pooling=PoolingType.MEAN).
1515
supported_pooled_models: list[DenseModelDescription] = []
1616

1717

@@ -43,7 +43,14 @@ def _post_process_onnx_output(
4343

4444
embeddings = output.model_output
4545
attn_mask = output.attention_mask
46-
return self.mean_pooling(embeddings, attn_mask)
46+
pooled = self.mean_pooling(embeddings, attn_mask)
47+
48+
# MRL: optionally truncate to requested dimension
49+
dim: int | None = kwargs.get("dim")
50+
if dim is not None:
51+
pooled = pooled[:, :dim]
52+
53+
return pooled
4754

4855

4956
class PooledEmbeddingWorker(OnnxTextEmbeddingWorker):

0 commit comments

Comments
 (0)