Spaces:
Running on Zero
Running on Zero
| """Local dense embeddings for the control-engineering corpus. | |
| Uses Qwen3-Embedding-0.6B under MLX -- small, fast on Apple Silicon, and | |
| already present in the local Hugging Face cache, so retrieval stays fully | |
| offline. The model is a causal backbone whose sentence embedding is the final | |
| hidden state at the last position; queries take an instruction prefix while | |
| documents do not, which is the recipe the model was trained with. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| from pathlib import Path | |
| import numpy as np | |
| MODEL_ID = os.environ.get("CONTROLAI_EMBED_MODEL", "mlx-community/Qwen3-Embedding-0.6B-4bit-DWQ") | |
| # The Space runs on Linux, where MLX does not exist, so the same embedder has a | |
| # transformers path. It is the same weights unquantised; see `Embedder._backend`. | |
| TORCH_MODEL_ID = os.environ.get("CONTROLAI_EMBED_MODEL_TORCH", "Qwen/Qwen3-Embedding-0.6B") | |
| MAX_TOKENS = 512 | |
| # Qwen3-Embedding pools the hidden state at the final position, and it was | |
| # trained with an explicit end-of-text token in that position. Omitting it is | |
| # not a small detail: measured on this corpus, the margin between relevant | |
| # passages and junk went from +0.150 without it to +0.322 with it, and | |
| # retrieval for "Routh-Hurwitz table construction" went from returning a | |
| # book index page to returning the actual Routh-Hurwitz section. | |
| # `tokenizer.eos_token_id` on this checkpoint is <|im_end|>, which is the chat | |
| # terminator, not this one -- <|im_end|> scored +0.185. Pin the right token. | |
| EOS_TOKEN = "<|endoftext|>" | |
| QUERY_INSTRUCTION = ( | |
| "Instruct: Given a control engineering question, retrieve textbook passages " | |
| "that explain the underlying theory.\nQuery: " | |
| ) | |
| class Embedder: | |
| """Lazily-loaded sentence embedder producing L2-normalised float32 vectors.""" | |
| def __init__(self, model_id: str | None = None, backend: str | None = None) -> None: | |
| # "mlx" locally, "torch" on the Space. The vectors in embeddings.npz were | |
| # produced by the MLX 4-bit checkpoint; the bf16 transformers weights are | |
| # the same model, so the two agree closely but not bit-exactly. If | |
| # retrieval on the Space looks over- or under-eager, MIN_COSINE is the | |
| # knob (CONTROLAI_MIN_COSINE), not this. | |
| self._backend = (backend or os.environ.get("CONTROLAI_BACKEND", "mlx")).lower() | |
| # Anything that is not MLX embeds through transformers. On the Space that | |
| # is CPU torch, which is ample for one query at a time; "api" refers to | |
| # where *generation* happens, and says nothing about the embedder. | |
| if self._backend in ("torch", "pytorch", "cuda", "api", "remote", "hosted"): | |
| self._backend = "torch" | |
| else: | |
| self._backend = "mlx" | |
| self.model_id = model_id or (TORCH_MODEL_ID if self._backend == "torch" else MODEL_ID) | |
| self._model = None | |
| self._tokenizer = None | |
| self._eos_id: int | None = None | |
| self._pad_id: int | None = None | |
| def _ensure_loaded(self) -> None: | |
| """Load the model and tokenizer, or leave the object exactly as it was. | |
| Everything is built into locals and committed to `self` only once all of | |
| it succeeded. An earlier version assigned `self._model` from | |
| `from_pretrained` and then called `.to("cuda")`, which on ZeroGPU raises: | |
| `self._model` was left set, `_eos_id` was never reached, and the next | |
| call short-circuited on `self._model is not None` and appended None as a | |
| token id -- surfacing much later as | |
| `RuntimeError: Could not infer dtype of NoneType`, nowhere near the | |
| actual failure. A half-loaded embedder must not look like a loaded one. | |
| """ | |
| if self._model is not None: | |
| return | |
| if self._backend == "torch": | |
| import torch | |
| import transformers | |
| from transformers import AutoModel, AutoTokenizer | |
| tokenizer = AutoTokenizer.from_pretrained(self.model_id) | |
| # transformers renamed torch_dtype -> dtype in 4.56, and nothing | |
| # here pins a version. See engine_torch.dtype_kwarg. | |
| version = tuple(int(x) for x in transformers.__version__.split(".")[:2]) | |
| key = "dtype" if version >= (4, 56) else "torch_dtype" | |
| cuda = torch.cuda.is_available() | |
| model = AutoModel.from_pretrained( | |
| self.model_id, **{key: torch.float16 if cuda else torch.float32} | |
| ) | |
| model = model.to("cuda" if cuda else "cpu") | |
| model.eval() | |
| else: | |
| from mlx_lm import load | |
| model, tokenizer = load(self.model_id) | |
| ids = tokenizer.encode(EOS_TOKEN) | |
| eos_id = ids[-1] if ids else tokenizer.eos_token_id | |
| if eos_id is None: | |
| raise RuntimeError( | |
| f"{self.model_id}: could not resolve an id for {EOS_TOKEN!r}, " | |
| "which last-token pooling depends on" | |
| ) | |
| self._tokenizer = tokenizer | |
| self._eos_id = eos_id | |
| self._pad_id = tokenizer.pad_token_id or eos_id | |
| self._model = model # last: this is what _ensure_loaded() checks | |
| def dim(self) -> int: | |
| self._ensure_loaded() | |
| if self._backend == "torch": | |
| return int(self._model.config.hidden_size) | |
| return int(self._model.args.hidden_size) | |
| def _tokens_for(self, text: str) -> list[int]: | |
| return self._tokenizer.encode(text)[: MAX_TOKENS - 1] + [self._eos_id] | |
| def _encode_one(self, text: str) -> np.ndarray: | |
| self._ensure_loaded() | |
| if self._backend == "torch": | |
| return self._encode_batch([self._tokens_for(text)])[0] | |
| import mlx.core as mx | |
| ids = self._tokens_for(text) | |
| # `model.model` is the backbone; calling `model` itself would project | |
| # through the language-model head and give logits, not an embedding. | |
| hidden = self._model.model(mx.array(ids)[None]) | |
| vector = hidden[0, -1].astype(mx.float32) | |
| vector = vector / (mx.linalg.norm(vector) + 1e-9) | |
| return np.array(vector, copy=True) | |
| def _encode_batch(self, batch: list[list[int]]) -> np.ndarray: | |
| """Embed a batch of already-tokenised inputs. | |
| Sequences are right-padded to the longest in the batch and pooled at | |
| each sequence's own final position. Right-padding is safe here | |
| precisely because the backbone is causal: position i attends only to | |
| positions <= i, so tokens appended after the real end cannot influence | |
| the hidden state being pooled. | |
| """ | |
| self._ensure_loaded() | |
| if self._backend == "torch": | |
| return self._encode_batch_torch(batch) | |
| import mlx.core as mx | |
| lengths = [len(ids) for ids in batch] | |
| width = max(lengths) | |
| pad = self._pad_id | |
| padded = mx.array([ids + [pad] * (width - len(ids)) for ids in batch]) | |
| hidden = self._model.model(padded) | |
| picked = mx.stack([hidden[i, n - 1] for i, n in enumerate(lengths)]).astype(mx.float32) | |
| picked = picked / (mx.linalg.norm(picked, axis=-1, keepdims=True) + 1e-9) | |
| return np.array(picked, copy=True) | |
| def _encode_batch_torch(self, batch: list[list[int]]) -> np.ndarray: | |
| """`_encode_batch` on transformers. Same right-padding and same pooling. | |
| An explicit attention mask is passed even though right-padding a causal | |
| backbone is already safe, because transformers otherwise warns on every | |
| call and the mask costs nothing. | |
| """ | |
| import torch | |
| lengths = [len(ids) for ids in batch] | |
| width = max(lengths) | |
| pad = self._pad_id | |
| device = self._model.device | |
| ids = torch.tensor( | |
| [row + [pad] * (width - len(row)) for row in batch], device=device | |
| ) | |
| mask = torch.zeros_like(ids) | |
| for i, n in enumerate(lengths): | |
| mask[i, :n] = 1 | |
| with torch.inference_mode(): | |
| hidden = self._model(input_ids=ids, attention_mask=mask).last_hidden_state | |
| picked = torch.stack( | |
| [hidden[i, n - 1] for i, n in enumerate(lengths)] | |
| ).float() | |
| picked = picked / (picked.norm(dim=-1, keepdim=True) + 1e-9) | |
| return picked.cpu().numpy().astype(np.float32) | |
| def encode_documents( | |
| self, | |
| texts: list[str], | |
| progress_every: int = 2000, | |
| batch_tokens: int = 16384, | |
| ) -> np.ndarray: | |
| """Embed a corpus, batching by token budget rather than by count. | |
| Sorting by length before batching keeps padding waste low; the original | |
| order is restored before returning. One chunk at a time was ~13 minutes | |
| per 10k chunks, which does not scale to a corpus of 80k. | |
| """ | |
| self._ensure_loaded() | |
| tokenised = [self._tokens_for(t) for t in texts] | |
| order = sorted(range(len(tokenised)), key=lambda i: len(tokenised[i])) | |
| out = np.zeros((len(texts), self.dim), dtype=np.float32) | |
| batch: list[int] = [] | |
| done = 0 | |
| def flush(batch: list[int]) -> None: | |
| nonlocal done | |
| if not batch: | |
| return | |
| vectors = self._encode_batch([tokenised[i] for i in batch]) | |
| for slot, i in enumerate(batch): | |
| out[i] = vectors[slot] | |
| done += len(batch) | |
| if progress_every and done % progress_every < len(batch): | |
| print(f" embedded {done}/{len(texts)}", flush=True) | |
| for i in order: | |
| # The cost of a batch is (rows x longest row), so cap on that | |
| # product rather than on row count. | |
| if batch and (len(batch) + 1) * len(tokenised[i]) > batch_tokens: | |
| flush(batch) | |
| batch = [] | |
| batch.append(i) | |
| flush(batch) | |
| return out | |
| def encode_query(self, query: str) -> np.ndarray: | |
| self._ensure_loaded() | |
| return self._encode_one(QUERY_INSTRUCTION + query) | |
| _shared: Embedder | None = None | |
| def get_embedder() -> Embedder: | |
| global _shared | |
| if _shared is None: | |
| _shared = Embedder() | |
| return _shared | |