brainiac-cli 0.16.0__py3-none-any.whl
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.
- brain/__init__.py +39 -0
- brain/__main__.py +14 -0
- brain/_assets/AGENTS.md +564 -0
- brain/_assets/overlay/template/brand/brand-guide.md +24 -0
- brain/_assets/overlay/template/keywords/glossary.md +15 -0
- brain/_assets/overlay/template/people/roster.md +14 -0
- brain/_assets/overlay/template/voice/voice-profile.md +34 -0
- brain/_assets/routines/manifest.json +257 -0
- brain/_assets/scripts/brain-brief-mac.plist +59 -0
- brain/_assets/scripts/brain-brief.sh +74 -0
- brain/_assets/scripts/brain-synthesis-mac.plist +48 -0
- brain/_assets/scripts/brain-synthesis.sh +214 -0
- brain/_assets/scripts/install-brief-mac.sh +152 -0
- brain/_assets/scripts/install-brief-windows.ps1 +97 -0
- brain/_assets/scripts/register_tasks.py +386 -0
- brain/_assets/templates/company.md +21 -0
- brain/_assets/templates/concept.md +27 -0
- brain/_assets/templates/daily.md +20 -0
- brain/_assets/templates/decision.md +42 -0
- brain/_assets/templates/meeting.md +33 -0
- brain/_assets/templates/person.md +20 -0
- brain/_assets/templates/project.md +23 -0
- brain/_assets/templates/state-moc.md +40 -0
- brain/_version.py +12 -0
- brain/anchor.py +121 -0
- brain/audit.py +422 -0
- brain/backup.py +210 -0
- brain/brief.py +417 -0
- brain/capture.py +117 -0
- brain/chunk.py +249 -0
- brain/classification.py +134 -0
- brain/cli.py +1906 -0
- brain/config.py +368 -0
- brain/connect.py +362 -0
- brain/context.py +108 -0
- brain/core.py +3018 -0
- brain/doctor.py +1161 -0
- brain/egress.py +148 -0
- brain/embed.py +857 -0
- brain/encryption.py +217 -0
- brain/frontmatter.py +102 -0
- brain/golden_probe.py +678 -0
- brain/graph.py +369 -0
- brain/graphify.py +352 -0
- brain/index.py +1576 -0
- brain/ingest/__init__.py +19 -0
- brain/ingest/handlers/__init__.py +43 -0
- brain/ingest/handlers/base.py +95 -0
- brain/ingest/handlers/docx.py +78 -0
- brain/ingest/handlers/email.py +228 -0
- brain/ingest/handlers/html.py +142 -0
- brain/ingest/handlers/image.py +91 -0
- brain/ingest/handlers/pdf.py +99 -0
- brain/ingest/handlers/pptx.py +69 -0
- brain/ingest/handlers/tables.py +41 -0
- brain/ingest/handlers/text.py +43 -0
- brain/ingest/handlers/xlsx.py +100 -0
- brain/ingest/handlers/zip.py +163 -0
- brain/ingest/pipeline.py +839 -0
- brain/ingest/transcript.py +158 -0
- brain/init.py +870 -0
- brain/maintenance.py +2266 -0
- brain/mcp_adapter.py +217 -0
- brain/multihop.py +232 -0
- brain/notes.py +195 -0
- brain/overlay.py +183 -0
- brain/projection.py +79 -0
- brain/rerank.py +425 -0
- brain/snapshot.py +231 -0
- brain/update.py +743 -0
- brain/vectors.py +225 -0
- brainiac_cli-0.16.0.dist-info/METADATA +306 -0
- brainiac_cli-0.16.0.dist-info/RECORD +77 -0
- brainiac_cli-0.16.0.dist-info/WHEEL +5 -0
- brainiac_cli-0.16.0.dist-info/entry_points.txt +4 -0
- brainiac_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
- brainiac_cli-0.16.0.dist-info/top_level.txt +1 -0
brain/embed.py
ADDED
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
"""Embedder ADAPTER INTERFACE + the real Arctic-embed embedder + an offline fallback.
|
|
2
|
+
|
|
3
|
+
Design of record (IDX-01) is Snowflake **Arctic-embed-m-v2.0** (305M, 768-d,
|
|
4
|
+
Apache-2.0) run **locally over ONNX Runtime via fastembed — NO PyTorch** (install
|
|
5
|
+
footprint + a Windows Defender win). Vectors are truncated to **MRL-256** for
|
|
6
|
+
storage (Matryoshka Representation Learning: the first 256 dims of the 768-d
|
|
7
|
+
vector carry most of the signal; we re-normalise after truncation).
|
|
8
|
+
|
|
9
|
+
Two implementations satisfy the ``Embedder`` protocol:
|
|
10
|
+
|
|
11
|
+
* ``ArcticEmbedder`` — the real model via ``fastembed.TextEmbedding``. Used
|
|
12
|
+
when fastembed + onnxruntime are importable AND the
|
|
13
|
+
model is locally available (bundled / already cached;
|
|
14
|
+
the Cowork egress allowlist excludes HuggingFace).
|
|
15
|
+
* ``HashEmbedder`` — a deterministic, network-free pseudo-embedder so the
|
|
16
|
+
index, retrieval, and tests run anywhere with no model
|
|
17
|
+
download. NOT semantically strong; a stand-in only.
|
|
18
|
+
|
|
19
|
+
Because both implement the protocol — including ``model_id`` and ``dim`` — the
|
|
20
|
+
index stores ``embed_model`` + ``embed_dim`` and forces a **clean rebuild** when
|
|
21
|
+
either changes (a HashEmbedder index must never be queried with Arctic vectors,
|
|
22
|
+
and vice-versa). Swapping the embedder is a one-line change at the call site.
|
|
23
|
+
|
|
24
|
+
Canonical task prefixes (IDX-02): Arctic-embed is asymmetric — queries are
|
|
25
|
+
embedded with the literal prefix ``query: `` and passages with no prefix. These
|
|
26
|
+
canonical prefixes are **never translated** (they are model tokens, not prose);
|
|
27
|
+
the in-language *contextual* prefix is a separate, content-level concern handled
|
|
28
|
+
in ``brain.chunk``.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import hashlib
|
|
33
|
+
import math
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import sys
|
|
37
|
+
from typing import Protocol, Sequence, runtime_checkable
|
|
38
|
+
|
|
39
|
+
_TOKEN = re.compile(r"[A-Za-z0-9]+")
|
|
40
|
+
|
|
41
|
+
# Canonical Arctic-embed task prefix for queries (asymmetric retrieval). Passages
|
|
42
|
+
# carry no prefix. NEVER translate these — they are model control tokens.
|
|
43
|
+
QUERY_PREFIX = "query: "
|
|
44
|
+
|
|
45
|
+
# The model of record and its MRL storage dimension.
|
|
46
|
+
ARCTIC_MODEL_ID = "snowflake/snowflake-arctic-embed-m-v2.0"
|
|
47
|
+
ARCTIC_FULL_DIM = 768
|
|
48
|
+
MRL_DIM = 256
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _ort_threads() -> int | None:
|
|
52
|
+
"""Intra-op thread count for the ONNX session (S11 speed fix).
|
|
53
|
+
|
|
54
|
+
Default: all physical cores (Apple Silicon has no SMT, so logical ==
|
|
55
|
+
physical — on the M4 Pro that is 12). Saturating the batch dimension
|
|
56
|
+
across cores is the biggest ORT knob for bulk embedding throughput.
|
|
57
|
+
Override via ``$BRAIN_EMBED_THREADS``."""
|
|
58
|
+
raw = os.environ.get("BRAIN_EMBED_THREADS")
|
|
59
|
+
if raw and raw.strip().isdigit():
|
|
60
|
+
return int(raw)
|
|
61
|
+
try:
|
|
62
|
+
return os.cpu_count() or None
|
|
63
|
+
except Exception:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _ort_providers() -> list[str]:
|
|
68
|
+
"""ONNX Runtime execution providers for the embedder (S11 speed fix).
|
|
69
|
+
|
|
70
|
+
Default CPU-only (the safe, reproducible path — and the one the eval
|
|
71
|
+
gate ran on). On Apple Silicon, ``$BRAIN_EMBED_PROVIDERS=CoreMLExecutionProvider``
|
|
72
|
+
opts into the Apple Neural Engine / GPU, which can be much faster for
|
|
73
|
+
bulk encode — but CoreML compiles the model on first run and may fall
|
|
74
|
+
back per-op, so it is OPT-IN, not the default. Comma-separate for a
|
|
75
|
+
fallback chain (``CoreMLExecutionProvider,CPUExecutionProvider``)."""
|
|
76
|
+
raw = os.environ.get("BRAIN_EMBED_PROVIDERS")
|
|
77
|
+
if raw and raw.strip():
|
|
78
|
+
return [p.strip() for p in raw.split(",") if p.strip()]
|
|
79
|
+
return ["CPUExecutionProvider"]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _embed_length_sorted(
|
|
83
|
+
prepared: list[str], embed_fn
|
|
84
|
+
) -> list[list[float]]:
|
|
85
|
+
"""Run a fastembed-style ``embed_fn(list_of_texts) -> generator of raw
|
|
86
|
+
vectors`` over ``prepared`` in LENGTH-SORTED batches, returning the raw
|
|
87
|
+
vectors in the ORIGINAL input order.
|
|
88
|
+
|
|
89
|
+
Why (S11 speed finding): fastembed pads every item in one ``embed()`` call to
|
|
90
|
+
the longest item in that call. On the real vault (mean chunk ~245 tokens,
|
|
91
|
+
long tail to ~845) a single bulk call pads everything to ~845 -> ~3-5x wasted
|
|
92
|
+
compute. Sorting by length and encoding in fixed-size batches means each
|
|
93
|
+
forward pass pads only to its local max -> the waste collapses. Measured
|
|
94
|
+
~1.5x on the real vault (more on heavier-tailed corpora); the sort itself is
|
|
95
|
+
negligible vs encoding. Applies to EVERY embedder, including the incumbent
|
|
96
|
+
e5-small — a model-independent win. Batch size via ``$BRAIN_EMBED_BATCH``
|
|
97
|
+
(default 64; on CPU, bigger is not better — large batches thrash cache)."""
|
|
98
|
+
n = len(prepared)
|
|
99
|
+
if n <= 1:
|
|
100
|
+
return [list(v) for v in embed_fn(prepared)]
|
|
101
|
+
order = sorted(range(n), key=lambda i: len(prepared[i]))
|
|
102
|
+
batch = int(os.environ.get("BRAIN_EMBED_BATCH", "64"))
|
|
103
|
+
out: list[list[float] | None] = [None] * n
|
|
104
|
+
for i in range(0, n, batch):
|
|
105
|
+
idxs = order[i : i + batch]
|
|
106
|
+
vecs = list(embed_fn([prepared[j] for j in idxs]))
|
|
107
|
+
for j, v in zip(idxs, vecs):
|
|
108
|
+
out[j] = list(v)
|
|
109
|
+
return out # type: ignore[return-value]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@runtime_checkable
|
|
113
|
+
class Embedder(Protocol):
|
|
114
|
+
model_id: str
|
|
115
|
+
dim: int
|
|
116
|
+
|
|
117
|
+
def embed(self, text: str, *, is_query: bool = False) -> list[float]: ...
|
|
118
|
+
def embed_batch(
|
|
119
|
+
self, texts: Sequence[str], *, is_query: bool = False
|
|
120
|
+
) -> list[list[float]]: ...
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _l2_normalise(vec: list[float]) -> list[float]:
|
|
124
|
+
norm = math.sqrt(sum(v * v for v in vec))
|
|
125
|
+
if norm == 0.0:
|
|
126
|
+
out = [0.0] * len(vec)
|
|
127
|
+
out[0] = 1.0
|
|
128
|
+
return out
|
|
129
|
+
return [v / norm for v in vec]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def mrl_truncate(vec: Sequence[float], dim: int = MRL_DIM) -> list[float]:
|
|
133
|
+
"""Matryoshka truncation: take the first ``dim`` dims and re-normalise.
|
|
134
|
+
|
|
135
|
+
Arctic-embed-v2.0 is MRL-trained, so a 256-prefix of the 768-d vector is a
|
|
136
|
+
valid (smaller, faster-to-store) embedding once re-normalised to unit length.
|
|
137
|
+
"""
|
|
138
|
+
return _l2_normalise(list(vec[:dim]))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class HashEmbedder:
|
|
142
|
+
"""Deterministic bag-of-hashed-tokens embedding, L2-normalised.
|
|
143
|
+
|
|
144
|
+
NOT semantically strong — a stand-in that gives stable, reproducible vectors
|
|
145
|
+
for the index/retrieval contract and tests. Lexically-similar texts share
|
|
146
|
+
direction, which is enough to exercise the vector path end to end. ``is_query``
|
|
147
|
+
is accepted (protocol parity) but ignored — there is no asymmetry to model.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
model_id = "hash-v1"
|
|
151
|
+
|
|
152
|
+
def __init__(self, dim: int = 384) -> None:
|
|
153
|
+
self.dim = dim
|
|
154
|
+
|
|
155
|
+
def embed(self, text: str, *, is_query: bool = False) -> list[float]:
|
|
156
|
+
vec = [0.0] * self.dim
|
|
157
|
+
for tok in _TOKEN.findall(text.lower()):
|
|
158
|
+
h = hashlib.sha256(tok.encode("utf-8")).digest()
|
|
159
|
+
idx = int.from_bytes(h[:4], "big") % self.dim
|
|
160
|
+
sign = 1.0 if h[4] & 1 else -1.0
|
|
161
|
+
vec[idx] += sign
|
|
162
|
+
return _l2_normalise(vec)
|
|
163
|
+
|
|
164
|
+
def embed_batch(
|
|
165
|
+
self, texts: Sequence[str], *, is_query: bool = False
|
|
166
|
+
) -> list[list[float]]:
|
|
167
|
+
return [self.embed(t, is_query=is_query) for t in texts]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class ArcticEmbedder:
|
|
171
|
+
"""Snowflake Arctic-embed-m-v2.0 via fastembed/ONNX — NO PyTorch.
|
|
172
|
+
|
|
173
|
+
Lazy: the ONNX model is loaded on first ``embed``/``embed_batch`` so merely
|
|
174
|
+
constructing the embedder (to read ``model_id``/``dim`` for a meta check) is
|
|
175
|
+
cheap and offline. Raises ``EmbedderUnavailable`` if fastembed/onnxruntime
|
|
176
|
+
is not importable.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def __init__(
|
|
180
|
+
self,
|
|
181
|
+
model_id: str = ARCTIC_MODEL_ID,
|
|
182
|
+
dim: int = MRL_DIM,
|
|
183
|
+
*,
|
|
184
|
+
cache_dir: str | None = None,
|
|
185
|
+
) -> None:
|
|
186
|
+
self.model_id = model_id
|
|
187
|
+
self.dim = dim # MRL storage dim
|
|
188
|
+
# Bundled-model path (S06 / INT-02): on the Cowork VM, HuggingFace is NOT
|
|
189
|
+
# on the egress allowlist, so the model is shipped in the workspace and
|
|
190
|
+
# ``$BRAIN_MODEL_CACHE`` points fastembed at that mounted cache dir. ONNX
|
|
191
|
+
# Runtime memory-maps the model file from there — read in place from the
|
|
192
|
+
# mount, never copied to /tmp.
|
|
193
|
+
self._cache_dir = cache_dir or os.environ.get("BRAIN_MODEL_CACHE")
|
|
194
|
+
self._model = None # lazily created TextEmbedding
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def available() -> bool:
|
|
198
|
+
try:
|
|
199
|
+
import fastembed # noqa: F401
|
|
200
|
+
import onnxruntime # noqa: F401
|
|
201
|
+
|
|
202
|
+
return True
|
|
203
|
+
except Exception:
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
def _ensure_model(self):
|
|
207
|
+
if self._model is None:
|
|
208
|
+
try:
|
|
209
|
+
from fastembed import TextEmbedding
|
|
210
|
+
except Exception as exc: # pragma: no cover - exercised when absent
|
|
211
|
+
raise EmbedderUnavailable(
|
|
212
|
+
"fastembed/onnxruntime not importable; install the 'embed' "
|
|
213
|
+
"extra or bundle the model"
|
|
214
|
+
) from exc
|
|
215
|
+
self._model = TextEmbedding(
|
|
216
|
+
model_name=self.model_id, cache_dir=self._cache_dir,
|
|
217
|
+
threads=_ort_threads(), providers=_ort_providers(),
|
|
218
|
+
)
|
|
219
|
+
return self._model
|
|
220
|
+
|
|
221
|
+
def _encode(self, texts: list[str], is_query: bool) -> list[list[float]]:
|
|
222
|
+
model = self._ensure_model()
|
|
223
|
+
prepared = [(QUERY_PREFIX + t) if is_query else t for t in texts]
|
|
224
|
+
return [mrl_truncate(v, self.dim) for v in _embed_length_sorted(prepared, model.embed)]
|
|
225
|
+
|
|
226
|
+
def embed(self, text: str, *, is_query: bool = False) -> list[float]:
|
|
227
|
+
return self._encode([text], is_query)[0]
|
|
228
|
+
|
|
229
|
+
def embed_batch(
|
|
230
|
+
self, texts: Sequence[str], *, is_query: bool = False
|
|
231
|
+
) -> list[list[float]]:
|
|
232
|
+
return self._encode(list(texts), is_query)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class CatalogEmbedder:
|
|
236
|
+
"""Any fastembed-CATALOGUED model via ONNX — a real-semantic embedder.
|
|
237
|
+
|
|
238
|
+
Design-of-record is Arctic-embed-m-v2.0, but that exact checkpoint is NOT in
|
|
239
|
+
the fastembed catalog (flagged S03). This adapter runs any *catalogued*
|
|
240
|
+
model (``intfloat/multilingual-e5-*``,
|
|
241
|
+
``sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2``, the
|
|
242
|
+
in-catalog Arctic variants, …) so a real multilingual embedder can be used
|
|
243
|
+
as a transparent proxy until the production checkpoint is bundled.
|
|
244
|
+
|
|
245
|
+
Activated ONLY when ``$BRAIN_EMBED_MODEL`` is set (see ``get_embedder``) — so
|
|
246
|
+
default behaviour is unchanged. ``$BRAIN_EMBED_DIM`` (default 384) declares
|
|
247
|
+
the storage dim WITHOUT loading the model (the index reads ``.dim`` before
|
|
248
|
+
any embed call, for the model-change guard). ``$BRAIN_MODEL_CACHE`` /
|
|
249
|
+
``$BRAIN_FASTEMBED_CACHE`` point fastembed at a local cache (offline-capable).
|
|
250
|
+
|
|
251
|
+
e5-family models are asymmetric (``query:`` / ``passage:`` prefixes); the
|
|
252
|
+
paraphrase-MiniLM family is symmetric. The right prefix scheme is selected
|
|
253
|
+
from the model id so cross-lingual retrieval is not silently degraded.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
def __init__(
|
|
257
|
+
self,
|
|
258
|
+
model_id: str,
|
|
259
|
+
dim: int | None = None,
|
|
260
|
+
*,
|
|
261
|
+
cache_dir: str | None = None,
|
|
262
|
+
) -> None:
|
|
263
|
+
self.model_id = model_id
|
|
264
|
+
self.dim = int(dim if dim is not None else os.environ.get("BRAIN_EMBED_DIM", 384))
|
|
265
|
+
self._cache_dir = (
|
|
266
|
+
cache_dir
|
|
267
|
+
or os.environ.get("BRAIN_MODEL_CACHE")
|
|
268
|
+
or os.environ.get("BRAIN_FASTEMBED_CACHE")
|
|
269
|
+
)
|
|
270
|
+
self._model = None
|
|
271
|
+
low = model_id.lower()
|
|
272
|
+
self._is_e5 = "e5" in low # e5 family uses query:/passage: prefixes
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def available() -> bool:
|
|
276
|
+
return ArcticEmbedder.available()
|
|
277
|
+
|
|
278
|
+
# ONNX-only models NOT in the stock fastembed catalog but registerable via
|
|
279
|
+
# add_custom_model (HF repo carries an ONNX export). Lets brain use, e.g.,
|
|
280
|
+
# multilingual-e5-small — the EXACT model Smart Connections uses — for a true
|
|
281
|
+
# apples-to-apples eval. mean-pooled + L2-normalised, query:/passage: prefixed.
|
|
282
|
+
_CUSTOM_MODELS = {
|
|
283
|
+
"intfloat/multilingual-e5-small": {
|
|
284
|
+
"hf": "Xenova/multilingual-e5-small", "dim": 384,
|
|
285
|
+
"model_file": "onnx/model.onnx",
|
|
286
|
+
},
|
|
287
|
+
"intfloat/multilingual-e5-base": {
|
|
288
|
+
"hf": "Xenova/multilingual-e5-base", "dim": 768,
|
|
289
|
+
"model_file": "onnx/model.onnx",
|
|
290
|
+
},
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
def _register_custom(self) -> bool:
|
|
294
|
+
spec = self._CUSTOM_MODELS.get(self.model_id)
|
|
295
|
+
if not spec:
|
|
296
|
+
return False
|
|
297
|
+
from fastembed import TextEmbedding
|
|
298
|
+
from fastembed.common.model_description import ModelSource, PoolingType
|
|
299
|
+
try:
|
|
300
|
+
TextEmbedding.add_custom_model(
|
|
301
|
+
model=self.model_id, pooling=PoolingType.MEAN, normalization=True,
|
|
302
|
+
sources=ModelSource(hf=spec["hf"]), dim=spec["dim"],
|
|
303
|
+
model_file=spec.get("model_file", "onnx/model.onnx"),
|
|
304
|
+
)
|
|
305
|
+
except Exception:
|
|
306
|
+
# Already registered (idempotent) or registry quirk — fall through and
|
|
307
|
+
# let TextEmbedding() surface a real error if the model truly isn't usable.
|
|
308
|
+
pass
|
|
309
|
+
return True
|
|
310
|
+
|
|
311
|
+
def _ensure_model(self):
|
|
312
|
+
if self._model is None:
|
|
313
|
+
try:
|
|
314
|
+
from fastembed import TextEmbedding
|
|
315
|
+
except Exception as exc: # pragma: no cover
|
|
316
|
+
raise EmbedderUnavailable(
|
|
317
|
+
"fastembed/onnxruntime not importable for CatalogEmbedder"
|
|
318
|
+
) from exc
|
|
319
|
+
self._register_custom()
|
|
320
|
+
self._model = TextEmbedding(
|
|
321
|
+
model_name=self.model_id, cache_dir=self._cache_dir,
|
|
322
|
+
threads=_ort_threads(), providers=_ort_providers(),
|
|
323
|
+
)
|
|
324
|
+
return self._model
|
|
325
|
+
|
|
326
|
+
def _prep(self, text: str, is_query: bool) -> str:
|
|
327
|
+
if self._is_e5:
|
|
328
|
+
return ("query: " if is_query else "passage: ") + text
|
|
329
|
+
return text
|
|
330
|
+
|
|
331
|
+
def _encode(self, texts: list[str], is_query: bool) -> list[list[float]]:
|
|
332
|
+
model = self._ensure_model()
|
|
333
|
+
prepared = [self._prep(t, is_query) for t in texts]
|
|
334
|
+
out: list[list[float]] = []
|
|
335
|
+
for v in _embed_length_sorted(prepared, model.embed):
|
|
336
|
+
# If the model emits a wider vector than declared, MRL-truncate +
|
|
337
|
+
# renormalise to the declared storage dim; if narrower, keep as-is.
|
|
338
|
+
out.append(_l2_normalise(v[: self.dim]) if len(v) >= self.dim else _l2_normalise(v))
|
|
339
|
+
return out
|
|
340
|
+
|
|
341
|
+
def embed(self, text: str, *, is_query: bool = False) -> list[float]:
|
|
342
|
+
return self._encode([text], is_query)[0]
|
|
343
|
+
|
|
344
|
+
def embed_batch(
|
|
345
|
+
self, texts: Sequence[str], *, is_query: bool = False
|
|
346
|
+
) -> list[list[float]]:
|
|
347
|
+
return self._encode(list(texts), is_query)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# --- Direct-ONNX embedder of record (DIST-01: eliminates fastembed) ---
|
|
351
|
+
# intfloat/multilingual-e5-small (Apache-2.0) is the S10/S11-closed model of
|
|
352
|
+
# record. The ONNX export at Xenova/multilingual-e5-small is a standard BERT-
|
|
353
|
+
# style encoder: inputs (input_ids, attention_mask, token_type_ids), output
|
|
354
|
+
# last_hidden_state [batch, seq, 384]. Embedding = MEAN-POOL over the attention
|
|
355
|
+
# mask + L2-normalise. Loaded DIRECTLY via onnxruntime + tokenizers — NO
|
|
356
|
+
# fastembed, NO PyTorch. This is the minimal-dep path the corporate build uses;
|
|
357
|
+
# it mirrors OnnxReranker's approach.
|
|
358
|
+
E5_SMALL_ONNX_REPO = "Xenova/multilingual-e5-small"
|
|
359
|
+
E5_SMALL_ONNX_FILE = "onnx/model.onnx"
|
|
360
|
+
E5_SMALL_MODEL_ID = "intfloat/multilingual-e5-small"
|
|
361
|
+
E5_SMALL_DIM = 384
|
|
362
|
+
|
|
363
|
+
# int8-quantized variant (S09/PF-01 — latency optimization). Produced OFFLINE
|
|
364
|
+
# by ``eval/int8_quantize_e5.py`` (onnxruntime.quantization.quantize_dynamic,
|
|
365
|
+
# weight-only dynamic int8, QInt8, per-channel MatMul) from the SAME
|
|
366
|
+
# Xenova/multilingual-e5-small fp32 export — never downloaded pre-quantized,
|
|
367
|
+
# so provenance is fully reproducible in-repo. Same tokenizer, same 384-d
|
|
368
|
+
# mean-pooled + L2-normalised output contract; ONLY the ONNX weights/ops
|
|
369
|
+
# differ. Kept behind an explicit opt-in (constructor arg or
|
|
370
|
+
# ``$BRAIN_EMBED_QUANT=int8``) — the IMPLICIT default stays fp32 (KILL-SWITCH:
|
|
371
|
+
# omit the flag, or set it to ``fp32``, to get the unchanged production path).
|
|
372
|
+
E5_SMALL_INT8_ONNX_FILE = "onnx/model_int8.onnx"
|
|
373
|
+
_VALID_QUANTIZATIONS = ("fp32", "int8")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class OnnxEmbedder:
|
|
377
|
+
"""multilingual-e5-small loaded DIRECTLY via ONNX Runtime — no fastembed.
|
|
378
|
+
|
|
379
|
+
This is the model-of-record embedder for the corporate, minimal-dependency
|
|
380
|
+
build (DIST-01). e5-small is an asymmetric encoder: queries carry the
|
|
381
|
+
``query: `` prefix, passages carry ``passage: ``. Output is mean-pooled over
|
|
382
|
+
the attention mask and L2-normalised to a 384-d vector.
|
|
383
|
+
|
|
384
|
+
Lazy: the ONNX session + tokenizer are created on first ``embed`` so
|
|
385
|
+
constructing the embedder (to read ``model_id``/``dim`` for the index
|
|
386
|
+
model-change guard) is cheap and offline. Raises ``EmbedderUnavailable``
|
|
387
|
+
if onnxruntime/tokenizers is not importable or the model is unavailable.
|
|
388
|
+
|
|
389
|
+
Offline-first: set ``$BRAIN_MODEL_CACHE`` (or pass ``local_dir``) to point
|
|
390
|
+
at a bundled/snapshot model dir so NO HuggingFace download is attempted.
|
|
391
|
+
|
|
392
|
+
``quantization`` (S09/PF-01): ``"fp32"`` (default — the shipped production
|
|
393
|
+
weights, UNCHANGED behaviour) or ``"int8"`` (opt-in — loads
|
|
394
|
+
``onnx/model_int8.onnx`` from the same ``local_dir`` instead, and reports a
|
|
395
|
+
distinct ``model_id`` suffixed ``-int8`` so the index's model-change guard
|
|
396
|
+
forces a clean rebuild before an int8-embedded index is ever queried
|
|
397
|
+
against fp32 vectors, or vice versa — same contract as any other embedder
|
|
398
|
+
swap). Resolution order: explicit ``quantization=`` arg >
|
|
399
|
+
``$BRAIN_EMBED_QUANT`` > ``"fp32"``. This is the non-destructive
|
|
400
|
+
kill-switch: production callers that never set the env var or pass the
|
|
401
|
+
arg get exactly the pre-S09 fp32 behaviour.
|
|
402
|
+
"""
|
|
403
|
+
|
|
404
|
+
def __init__(
|
|
405
|
+
self,
|
|
406
|
+
*,
|
|
407
|
+
hf_repo: str | None = None,
|
|
408
|
+
onnx_file: str | None = None,
|
|
409
|
+
local_dir: str | None = None,
|
|
410
|
+
model_id: str | None = None,
|
|
411
|
+
dim: int | None = None,
|
|
412
|
+
cache_dir: str | None = None,
|
|
413
|
+
quantization: str | None = None,
|
|
414
|
+
) -> None:
|
|
415
|
+
self.quantization = (
|
|
416
|
+
quantization or os.environ.get("BRAIN_EMBED_QUANT") or "fp32"
|
|
417
|
+
).strip().lower()
|
|
418
|
+
if self.quantization not in _VALID_QUANTIZATIONS:
|
|
419
|
+
raise ValueError(
|
|
420
|
+
f"OnnxEmbedder: quantization={self.quantization!r} not in "
|
|
421
|
+
f"{_VALID_QUANTIZATIONS!r}"
|
|
422
|
+
)
|
|
423
|
+
is_int8 = self.quantization == "int8"
|
|
424
|
+
default_onnx_file = E5_SMALL_INT8_ONNX_FILE if is_int8 else E5_SMALL_ONNX_FILE
|
|
425
|
+
default_model_id = (E5_SMALL_MODEL_ID + "-int8") if is_int8 else E5_SMALL_MODEL_ID
|
|
426
|
+
self.model_id = model_id or default_model_id
|
|
427
|
+
self.dim = int(dim if dim is not None else os.environ.get("BRAIN_EMBED_DIM", E5_SMALL_DIM))
|
|
428
|
+
self._hf_repo = hf_repo or E5_SMALL_ONNX_REPO
|
|
429
|
+
self._onnx_file = onnx_file or default_onnx_file
|
|
430
|
+
self._local_dir = (
|
|
431
|
+
local_dir
|
|
432
|
+
or cache_dir
|
|
433
|
+
or os.environ.get("BRAIN_MODEL_CACHE")
|
|
434
|
+
or os.environ.get("BRAIN_EMBED_ONNX_DIR")
|
|
435
|
+
)
|
|
436
|
+
self._sess = None
|
|
437
|
+
self._tok = None
|
|
438
|
+
self._in_names: list[str] | None = None
|
|
439
|
+
|
|
440
|
+
@staticmethod
|
|
441
|
+
def available() -> bool:
|
|
442
|
+
try:
|
|
443
|
+
import onnxruntime # noqa: F401
|
|
444
|
+
import tokenizers # noqa: F401
|
|
445
|
+
|
|
446
|
+
return True
|
|
447
|
+
except Exception:
|
|
448
|
+
return False
|
|
449
|
+
|
|
450
|
+
def _ensure(self):
|
|
451
|
+
if self._sess is None:
|
|
452
|
+
try:
|
|
453
|
+
import onnxruntime as ort
|
|
454
|
+
from tokenizers import Tokenizer
|
|
455
|
+
except Exception as exc: # pragma: no cover
|
|
456
|
+
raise EmbedderUnavailable(
|
|
457
|
+
"onnxruntime/tokenizers not importable for OnnxEmbedder"
|
|
458
|
+
) from exc
|
|
459
|
+
try:
|
|
460
|
+
onnx_path, base = self._resolve_model_files()
|
|
461
|
+
so = ort.SessionOptions()
|
|
462
|
+
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
|
|
463
|
+
t = _ort_threads()
|
|
464
|
+
if t:
|
|
465
|
+
so.intra_op_num_threads = t
|
|
466
|
+
self._sess = ort.InferenceSession(
|
|
467
|
+
onnx_path, sess_options=so, providers=_ort_providers()
|
|
468
|
+
)
|
|
469
|
+
self._in_names = [i.name for i in self._sess.get_inputs()]
|
|
470
|
+
self._tok = Tokenizer.from_file(os.path.join(base, "tokenizer.json"))
|
|
471
|
+
# Truncate to the model's context window BEFORE padding. e5-small
|
|
472
|
+
# is a BERT encoder with max_position_embeddings=512; feeding a
|
|
473
|
+
# longer sequence makes the position-embedding Add node fail to
|
|
474
|
+
# broadcast ("512 by 620") and crashes the whole rebuild. The
|
|
475
|
+
# char-based chunk ceiling (chunk.MAX_CHARS) does NOT guarantee
|
|
476
|
+
# <=512 tokens (dense PT/ES or code text tokenises >2x), so the
|
|
477
|
+
# embedder MUST clamp — the same default fastembed/sentence-
|
|
478
|
+
# transformers apply. Override via $BRAIN_EMBED_MAX_TOKENS.
|
|
479
|
+
_max_tok = 512
|
|
480
|
+
try:
|
|
481
|
+
_mt = os.environ.get("BRAIN_EMBED_MAX_TOKENS")
|
|
482
|
+
if _mt and _mt.strip().isdigit():
|
|
483
|
+
_max_tok = int(_mt)
|
|
484
|
+
except Exception:
|
|
485
|
+
pass
|
|
486
|
+
self._tok.enable_truncation(max_length=_max_tok)
|
|
487
|
+
# Pad to the longest in a batch so encode_batch returns a
|
|
488
|
+
# rectangular [batch, seq] int matrix (not ragged lists).
|
|
489
|
+
self._tok.enable_padding(pad_id=0, pad_token="<pad>")
|
|
490
|
+
except EmbedderUnavailable:
|
|
491
|
+
raise
|
|
492
|
+
except Exception as exc: # pragma: no cover - model unavailable offline
|
|
493
|
+
raise EmbedderUnavailable(
|
|
494
|
+
f"ONNX embedder {self.model_id!r} unavailable: {exc}"
|
|
495
|
+
) from exc
|
|
496
|
+
return self._sess
|
|
497
|
+
|
|
498
|
+
def _resolve_model_files(self) -> tuple[str, str]:
|
|
499
|
+
"""Resolve (onnx_path, base_dir) across three offline/online layouts:
|
|
500
|
+
|
|
501
|
+
1. ``local_dir`` is a SNAPSHOT dir containing ``onnx/model.onnx`` +
|
|
502
|
+
``tokenizer.json`` directly (the bundled / vendored layout, and the
|
|
503
|
+
resolved-snapshot layout). Preferred — no HF dep at runtime.
|
|
504
|
+
2. ``local_dir`` is an HF-style cache ROOT (contains
|
|
505
|
+
``models--Xenova--multilingual-e5-small/``): resolve the latest
|
|
506
|
+
snapshot via ``huggingface_hub.snapshot_download(cache_dir=...)``.
|
|
507
|
+
3. No ``local_dir``: download from HF (online) via ``snapshot_download``.
|
|
508
|
+
"""
|
|
509
|
+
pat = [
|
|
510
|
+
self._onnx_file,
|
|
511
|
+
self._onnx_file + "_data",
|
|
512
|
+
"tokenizer*",
|
|
513
|
+
"*.json",
|
|
514
|
+
]
|
|
515
|
+
if self._local_dir:
|
|
516
|
+
direct = os.path.join(self._local_dir, self._onnx_file)
|
|
517
|
+
if os.path.exists(direct) and os.path.exists(
|
|
518
|
+
os.path.join(self._local_dir, "tokenizer.json")
|
|
519
|
+
):
|
|
520
|
+
return direct, self._local_dir
|
|
521
|
+
# Direct .onnx file path as local_dir.
|
|
522
|
+
if os.path.isfile(self._local_dir) and self._local_dir.endswith(".onnx"):
|
|
523
|
+
return self._local_dir, os.path.dirname(self._local_dir)
|
|
524
|
+
# HF cache root: contains models--<org>--<name>/.
|
|
525
|
+
repo_dir = "models--" + self._hf_repo.replace("/", "--")
|
|
526
|
+
if os.path.isdir(os.path.join(self._local_dir, repo_dir)):
|
|
527
|
+
from huggingface_hub import snapshot_download
|
|
528
|
+
|
|
529
|
+
base = snapshot_download(
|
|
530
|
+
self._hf_repo, cache_dir=self._local_dir, allow_patterns=pat,
|
|
531
|
+
)
|
|
532
|
+
return os.path.join(base, self._onnx_file), base
|
|
533
|
+
raise EmbedderUnavailable(
|
|
534
|
+
f"local_dir {self._local_dir!r} is neither a snapshot dir nor an "
|
|
535
|
+
f"HF cache root for {self._hf_repo!r}"
|
|
536
|
+
)
|
|
537
|
+
from huggingface_hub import snapshot_download
|
|
538
|
+
|
|
539
|
+
base = snapshot_download(self._hf_repo, allow_patterns=pat)
|
|
540
|
+
return os.path.join(base, self._onnx_file), base
|
|
541
|
+
|
|
542
|
+
def _encode_raw(self, texts: list[str]) -> list[list[float]]:
|
|
543
|
+
"""Mean-pool last_hidden_state over the attention mask + L2-normalise."""
|
|
544
|
+
import numpy as np
|
|
545
|
+
|
|
546
|
+
self._ensure()
|
|
547
|
+
enc = self._tok.encode_batch(texts)
|
|
548
|
+
ii = np.array([e.ids for e in enc], dtype=np.int64)
|
|
549
|
+
am = np.array([e.attention_mask for e in enc], dtype=np.int64)
|
|
550
|
+
feed: dict[str, object] = {}
|
|
551
|
+
for nm in self._in_names:
|
|
552
|
+
low = nm.lower()
|
|
553
|
+
if "input_id" in low:
|
|
554
|
+
feed[nm] = ii
|
|
555
|
+
elif "attention" in low:
|
|
556
|
+
feed[nm] = am
|
|
557
|
+
elif "token_type" in low:
|
|
558
|
+
feed[nm] = np.zeros_like(ii)
|
|
559
|
+
hidden = self._sess.run(None, feed)[0] # [batch, seq, dim] float32
|
|
560
|
+
# Mean pool: sum(hidden * mask) / sum(mask), per item.
|
|
561
|
+
mask = am.astype(hidden.dtype)[:, :, None] # [batch, seq, 1]
|
|
562
|
+
summed = (hidden * mask).sum(axis=1) # [batch, dim]
|
|
563
|
+
counts = mask.sum(axis=1)
|
|
564
|
+
counts = np.maximum(counts, 1.0) # avoid div-by-zero
|
|
565
|
+
pooled = summed / counts
|
|
566
|
+
out: list[list[float]] = []
|
|
567
|
+
for v in pooled:
|
|
568
|
+
d = v[: self.dim] if v.shape[0] >= self.dim else v
|
|
569
|
+
out.append(_l2_normalise(list(d)))
|
|
570
|
+
return out
|
|
571
|
+
|
|
572
|
+
def _encode(self, texts: list[str], is_query: bool) -> list[list[float]]:
|
|
573
|
+
# e5-family asymmetry: query: / passage: prefixes (model control tokens;
|
|
574
|
+
# never translated). mean-pool + L2-norm happens in _encode_raw.
|
|
575
|
+
prepared = [("query: " if is_query else "passage: ") + t for t in texts]
|
|
576
|
+
return [v for v in _embed_length_sorted(prepared, self._encode_raw)]
|
|
577
|
+
|
|
578
|
+
def embed(self, text: str, *, is_query: bool = False) -> list[float]:
|
|
579
|
+
return self._encode([text], is_query)[0]
|
|
580
|
+
|
|
581
|
+
def embed_batch(
|
|
582
|
+
self, texts: Sequence[str], *, is_query: bool = False
|
|
583
|
+
) -> list[list[float]]:
|
|
584
|
+
return self._encode(list(texts), is_query)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
class QwenEmbedder:
|
|
588
|
+
"""Qwen3-Embedding-0.6B via the ``qwen3-embed`` lib (ONNX INT8, Apache-2.0).
|
|
589
|
+
|
|
590
|
+
The 2025-2026 best-in-class small multilingual embedder (MMTEB 64.33, 32K
|
|
591
|
+
context, 100+ langs incl. EN/PT/ES, 1024-d, MRL-truncatable). Shipped in S11
|
|
592
|
+
(UPG-02) as the upgrade over ``multilingual-e5-small``.
|
|
593
|
+
|
|
594
|
+
``qwen3-embed`` (n24q02m fork) is a fastembed-compatible lib that loads the
|
|
595
|
+
ONNX INT8 export (~573 MB) — chosen because fastembed ≤ 0.8.0 (the only
|
|
596
|
+
released series as of 2026-06) does NOT catalogue Qwen3, and PR #605 is not
|
|
597
|
+
yet released. Lazy: the ONNX session is created on first ``embed`` so
|
|
598
|
+
constructing the embedder (to read ``model_id``/``dim`` for the meta check)
|
|
599
|
+
is cheap and offline. Raises ``EmbedderUnavailable`` if the lib/onnxruntime
|
|
600
|
+
is not importable.
|
|
601
|
+
|
|
602
|
+
Qwen3-Embedding is an instruction-tuned decoder embedder: queries MUST carry
|
|
603
|
+
an instruction prefix (``Instruct: ...\\nQuery: ``) for optimal ranking
|
|
604
|
+
(empirically verified 2026-06-28: the prefix widens the relevant-vs-irrelevant
|
|
605
|
+
cosine margin 0.30 -> 0.40). Passages carry NO prefix. This is the same class
|
|
606
|
+
of asymmetry as e5 ``query:``/``passage:``, and the same class of bug if
|
|
607
|
+
omitted (silent ranking degradation). The prefix is model-specific, so it is
|
|
608
|
+
applied here, INSIDE the adapter — the caller passes raw text.
|
|
609
|
+
"""
|
|
610
|
+
|
|
611
|
+
# The Qwen3-Embedding default retrieval instruction (from the tech report
|
|
612
|
+
# ablations — the generic web-search instruction is the recommended default).
|
|
613
|
+
_QUERY_INSTRUCT = (
|
|
614
|
+
"Instruct: Given a web search query, retrieve relevant passages that "
|
|
615
|
+
"answer the query.\nQuery: "
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
def __init__(
|
|
619
|
+
self,
|
|
620
|
+
model_id: str = "n24q02m/Qwen3-Embedding-0.6B-ONNX",
|
|
621
|
+
dim: int | None = None,
|
|
622
|
+
*,
|
|
623
|
+
cache_dir: str | None = None,
|
|
624
|
+
) -> None:
|
|
625
|
+
self.model_id = model_id
|
|
626
|
+
# default 1024 (full); MRL-truncate to a smaller dim via BRAIN_EMBED_DIM
|
|
627
|
+
self.dim = int(dim if dim is not None else os.environ.get("BRAIN_EMBED_DIM", 1024))
|
|
628
|
+
self._cache_dir = (
|
|
629
|
+
cache_dir
|
|
630
|
+
or os.environ.get("BRAIN_MODEL_CACHE")
|
|
631
|
+
or os.environ.get("BRAIN_FASTEMBED_CACHE")
|
|
632
|
+
)
|
|
633
|
+
self._model = None
|
|
634
|
+
|
|
635
|
+
@staticmethod
|
|
636
|
+
def available() -> bool:
|
|
637
|
+
try:
|
|
638
|
+
import qwen3_embed # noqa: F401
|
|
639
|
+
import onnxruntime # noqa: F401
|
|
640
|
+
|
|
641
|
+
return True
|
|
642
|
+
except Exception:
|
|
643
|
+
return False
|
|
644
|
+
|
|
645
|
+
def _ensure_model(self):
|
|
646
|
+
if self._model is None:
|
|
647
|
+
try:
|
|
648
|
+
from qwen3_embed import TextEmbedding
|
|
649
|
+
except Exception as exc: # pragma: no cover - exercised when absent
|
|
650
|
+
raise EmbedderUnavailable(
|
|
651
|
+
"qwen3-embed/onnxruntime not importable; pip install qwen3-embed"
|
|
652
|
+
) from exc
|
|
653
|
+
self._model = TextEmbedding(
|
|
654
|
+
model_name=self.model_id, cache_dir=self._cache_dir,
|
|
655
|
+
threads=_ort_threads(), providers=_ort_providers(),
|
|
656
|
+
)
|
|
657
|
+
return self._model
|
|
658
|
+
|
|
659
|
+
def _encode(self, texts: list[str], is_query: bool) -> list[list[float]]:
|
|
660
|
+
model = self._ensure_model()
|
|
661
|
+
prepared = [(self._QUERY_INSTRUCT + t) if is_query else t for t in texts]
|
|
662
|
+
out: list[list[float]] = []
|
|
663
|
+
for v in _embed_length_sorted(prepared, model.embed):
|
|
664
|
+
# MRL-truncate + renormalise to the declared storage dim if the model
|
|
665
|
+
# emits a wider vector (1024 full -> 256/512 storage). Qwen3-Embedding
|
|
666
|
+
# is MRL-trained, so a prefix is a valid smaller embedding.
|
|
667
|
+
out.append(_l2_normalise(v[: self.dim]) if len(v) >= self.dim else _l2_normalise(v))
|
|
668
|
+
return out
|
|
669
|
+
|
|
670
|
+
def embed(self, text: str, *, is_query: bool = False) -> list[float]:
|
|
671
|
+
return self._encode([text], is_query)[0]
|
|
672
|
+
|
|
673
|
+
def embed_batch(
|
|
674
|
+
self, texts: Sequence[str], *, is_query: bool = False
|
|
675
|
+
) -> list[list[float]]:
|
|
676
|
+
return self._encode(list(texts), is_query)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def is_qwen_model(model_id: str) -> bool:
|
|
680
|
+
"""True if the model id names a Qwen3-Embedding model (the qwen3-embed lib path)."""
|
|
681
|
+
low = (model_id or "").lower()
|
|
682
|
+
return "qwen3-embedding" in low or "qwen3-embed" in low or "qwen-embed" in low
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
class EmbedderUnavailable(RuntimeError):
|
|
686
|
+
"""Raised when the requested embedder backend is not importable/usable."""
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
# The implicit ``auto``-path degrade to the non-semantic ``HashEmbedder`` is the
|
|
690
|
+
# single most dangerous silent failure in the stack: retrieval keeps "working"
|
|
691
|
+
# but answers with garbage vectors, and ``brain status`` still reports the
|
|
692
|
+
# INDEX's recorded embed_model (metadata), not the live embedder actually in use.
|
|
693
|
+
# Discovered S11 (dual-run parity) when a venv without ``onnxruntime`` silently
|
|
694
|
+
# ran the whole integrity scan on hash vectors and found zero near-dups. So the
|
|
695
|
+
# implicit fallback is now LOUD, and fail-closable for production/clean-machine.
|
|
696
|
+
_HASH_FALLBACK_MSG = (
|
|
697
|
+
"brain: WARNING — no real semantic embedder is available "
|
|
698
|
+
"(onnxruntime/tokenizers not importable or the e5-small ONNX model is "
|
|
699
|
+
"missing), so retrieval is FALLING BACK to the non-semantic HashEmbedder. "
|
|
700
|
+
"Search/near-dup quality will be effectively random. Install the 'corporate' "
|
|
701
|
+
"extras (onnxruntime + tokenizers) or the bundled model. Set "
|
|
702
|
+
"BRAIN_REQUIRE_REAL_EMBEDDER=1 to fail closed instead of degrading; set "
|
|
703
|
+
"BRAIN_EMBEDDER=hash to select the hash embedder explicitly and silence this."
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _implicit_hash_fallback() -> Embedder:
|
|
708
|
+
"""Return HashEmbedder for the IMPLICIT auto-path, but never silently:
|
|
709
|
+
fail closed when the operator demanded a real embedder, else warn loudly."""
|
|
710
|
+
if os.environ.get("BRAIN_REQUIRE_REAL_EMBEDDER"):
|
|
711
|
+
raise EmbedderUnavailable(
|
|
712
|
+
"BRAIN_REQUIRE_REAL_EMBEDDER is set but no real semantic embedder is "
|
|
713
|
+
"available (onnxruntime/tokenizers missing or e5-small model absent). "
|
|
714
|
+
"Refusing to degrade to the non-semantic HashEmbedder."
|
|
715
|
+
)
|
|
716
|
+
print(_HASH_FALLBACK_MSG, file=sys.stderr, flush=True)
|
|
717
|
+
return HashEmbedder()
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def get_embedder(prefer: str = "auto") -> Embedder:
|
|
721
|
+
"""Adapter selection.
|
|
722
|
+
|
|
723
|
+
``hash`` forces the offline fallback (tests, CI) — EXPLICIT, no warning.
|
|
724
|
+
``onnx`` selects ``OnnxEmbedder`` — the direct-ONNX model-of-record
|
|
725
|
+
(e5-small, Apache-2.0); this is the MINIMAL-DEPENDENCY path the corporate
|
|
726
|
+
build uses (DIST-01: no fastembed, no PyTorch). ``onnx-int8`` (S09/PF-01)
|
|
727
|
+
selects the SAME ``OnnxEmbedder`` with ``quantization="int8"`` — an
|
|
728
|
+
explicit opt-in only; ``onnx``/``auto`` are UNCHANGED and stay fp32 unless
|
|
729
|
+
``$BRAIN_EMBED_QUANT=int8`` is also set (the non-destructive kill-switch:
|
|
730
|
+
default behaviour never changes). ``arctic``/``catalog``/``qwen`` select
|
|
731
|
+
the legacy fastembed/qwen3-embed paths (kept for A/B only; NOT in the
|
|
732
|
+
corporate build). ``auto`` honours ``$BRAIN_EMBED_MODEL`` when it names a
|
|
733
|
+
Qwen model, else prefers the direct-ONNX ``OnnxEmbedder`` (e5-small,
|
|
734
|
+
fp32 unless ``$BRAIN_EMBED_QUANT=int8``), else degrades to HashEmbedder —
|
|
735
|
+
but that IMPLICIT degrade is never silent (S11): it warns to stderr, or
|
|
736
|
+
fails closed under ``$BRAIN_REQUIRE_REAL_EMBEDDER``.
|
|
737
|
+
"""
|
|
738
|
+
if prefer == "hash":
|
|
739
|
+
return HashEmbedder()
|
|
740
|
+
if prefer == "onnx":
|
|
741
|
+
return OnnxEmbedder()
|
|
742
|
+
if prefer == "onnx-int8":
|
|
743
|
+
return OnnxEmbedder(quantization="int8")
|
|
744
|
+
if prefer == "arctic":
|
|
745
|
+
return ArcticEmbedder()
|
|
746
|
+
cat = os.environ.get("BRAIN_EMBED_MODEL")
|
|
747
|
+
if prefer == "catalog":
|
|
748
|
+
if not cat:
|
|
749
|
+
raise EmbedderUnavailable("prefer='catalog' needs $BRAIN_EMBED_MODEL")
|
|
750
|
+
if is_qwen_model(cat):
|
|
751
|
+
return QwenEmbedder(cat)
|
|
752
|
+
return CatalogEmbedder(cat)
|
|
753
|
+
if prefer == "qwen":
|
|
754
|
+
cat = cat or "n24q02m/Qwen3-Embedding-0.6B-ONNX"
|
|
755
|
+
return QwenEmbedder(cat)
|
|
756
|
+
# auto — the default real-semantic path is now direct-ONNX e5-small.
|
|
757
|
+
if cat and is_qwen_model(cat) and QwenEmbedder.available():
|
|
758
|
+
return QwenEmbedder(cat)
|
|
759
|
+
if OnnxEmbedder.available():
|
|
760
|
+
return OnnxEmbedder()
|
|
761
|
+
if cat and CatalogEmbedder.available():
|
|
762
|
+
return CatalogEmbedder(cat)
|
|
763
|
+
if ArcticEmbedder.available():
|
|
764
|
+
return ArcticEmbedder()
|
|
765
|
+
return _implicit_hash_fallback()
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def probe_auto_embedder() -> tuple[str, str]:
|
|
769
|
+
"""Read-only classification of which embedder the live runtime WOULD use,
|
|
770
|
+
for ``brain doctor``'s liveness probe (DV-03, 2026-07-09). Returns
|
|
771
|
+
``(state, backend)`` where ``state`` is one of:
|
|
772
|
+
|
|
773
|
+
* ``"real"`` — a real semantic embedder is available;
|
|
774
|
+
* ``"explicit-hash"`` — ``$BRAIN_EMBEDDER=hash`` was chosen deliberately
|
|
775
|
+
(tests/CI) — NOT a failure; must never gate/alarm;
|
|
776
|
+
* ``"implicit-hash"`` — the auto-path found no real embedder and would
|
|
777
|
+
SILENTLY degrade to the non-semantic HashEmbedder
|
|
778
|
+
(the dangerous case — semantic search goes random,
|
|
779
|
+
the exact silent failure DV-03 hardens against).
|
|
780
|
+
|
|
781
|
+
Mirrors the auto-selection in ``get_embedder`` but WITHOUT constructing a
|
|
782
|
+
HashEmbedder or emitting the fallback warning, so it is safe to call from
|
|
783
|
+
the read-only doctor. ponytail: it duplicates the ``.available()`` chain
|
|
784
|
+
rather than calling ``get_embedder`` precisely to avoid that function's
|
|
785
|
+
stderr warning + HashEmbedder construction side effects.
|
|
786
|
+
"""
|
|
787
|
+
forced = os.environ.get("BRAIN_EMBEDDER", "auto").strip().lower()
|
|
788
|
+
if forced == "hash":
|
|
789
|
+
return ("explicit-hash", "hash (BRAIN_EMBEDDER=hash)")
|
|
790
|
+
cat = os.environ.get("BRAIN_EMBED_MODEL")
|
|
791
|
+
if forced in ("onnx", "onnx-int8"):
|
|
792
|
+
return ("real", "onnx") if OnnxEmbedder.available() else ("implicit-hash", "onnx-unavailable")
|
|
793
|
+
if forced == "arctic":
|
|
794
|
+
return ("real", "arctic") if ArcticEmbedder.available() else ("implicit-hash", "arctic-unavailable")
|
|
795
|
+
if forced == "qwen":
|
|
796
|
+
return ("real", "qwen") if QwenEmbedder.available() else ("implicit-hash", "qwen-unavailable")
|
|
797
|
+
if forced == "catalog":
|
|
798
|
+
ok = bool(cat) and (QwenEmbedder.available() if (cat and is_qwen_model(cat)) else CatalogEmbedder.available())
|
|
799
|
+
return ("real", "catalog") if ok else ("implicit-hash", "catalog-unavailable")
|
|
800
|
+
# auto (or unset / unrecognised) — mirror get_embedder's discovery order.
|
|
801
|
+
if cat and is_qwen_model(cat) and QwenEmbedder.available():
|
|
802
|
+
return ("real", "qwen")
|
|
803
|
+
if OnnxEmbedder.available():
|
|
804
|
+
return ("real", "onnx")
|
|
805
|
+
if cat and CatalogEmbedder.available():
|
|
806
|
+
return ("real", "catalog")
|
|
807
|
+
if ArcticEmbedder.available():
|
|
808
|
+
return ("real", "arctic")
|
|
809
|
+
return ("implicit-hash", "no-real-embedder")
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
# Approximate download size for the install/warmup UX hint ONLY (never a perf
|
|
813
|
+
# or capability claim) — the plan's own "~300 MB" figure for the fp32 e5-small
|
|
814
|
+
# ONNX export. Not read anywhere that affects behaviour.
|
|
815
|
+
ONNX_MODEL_SIZE_HINT = "~300 MB"
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def model_cache_ready(embedder: "Embedder | None" = None) -> bool | None:
|
|
819
|
+
"""Non-network probe (S02/CS-01): are the resolved embedder's model weights
|
|
820
|
+
ALREADY present on disk, i.e. would a warmup/first embed call run offline?
|
|
821
|
+
|
|
822
|
+
Returns ``True`` (cached, ready), ``False`` (would trigger a download —
|
|
823
|
+
pending), or ``None`` when the question doesn't apply (the explicit
|
|
824
|
+
HashEmbedder never downloads anything, or the embedder shape is unknown).
|
|
825
|
+
NEVER downloads and NEVER constructs an ONNX session — safe to call from
|
|
826
|
+
``brain status`` on every invocation.
|
|
827
|
+
"""
|
|
828
|
+
e = embedder if embedder is not None else get_embedder(
|
|
829
|
+
os.environ.get("BRAIN_EMBEDDER", "auto")
|
|
830
|
+
)
|
|
831
|
+
if isinstance(e, HashEmbedder):
|
|
832
|
+
return None
|
|
833
|
+
if not isinstance(e, OnnxEmbedder):
|
|
834
|
+
return None # Arctic/Catalog/Qwen: not the S02 auto-default; not probed
|
|
835
|
+
local_dir = e._local_dir
|
|
836
|
+
onnx_file = e._onnx_file
|
|
837
|
+
if local_dir:
|
|
838
|
+
# Bundled/staged/VM layout (S06/INT-02): files are expected directly
|
|
839
|
+
# on disk, no HF cache semantics.
|
|
840
|
+
direct = os.path.join(local_dir, onnx_file)
|
|
841
|
+
if os.path.exists(direct) and os.path.exists(
|
|
842
|
+
os.path.join(local_dir, "tokenizer.json")
|
|
843
|
+
):
|
|
844
|
+
return True
|
|
845
|
+
try:
|
|
846
|
+
from huggingface_hub import snapshot_download
|
|
847
|
+
except Exception:
|
|
848
|
+
return None
|
|
849
|
+
pat = [onnx_file, onnx_file + "_data", "tokenizer*", "*.json"]
|
|
850
|
+
try:
|
|
851
|
+
snapshot_download(
|
|
852
|
+
e._hf_repo, cache_dir=local_dir, allow_patterns=pat,
|
|
853
|
+
local_files_only=True,
|
|
854
|
+
)
|
|
855
|
+
return True
|
|
856
|
+
except Exception:
|
|
857
|
+
return False
|