Spaces:
Running
Running
| """ | |
| SigLIP text-tower query encoder via ONNX Runtime (CPU, no torch). | |
| Mirrors ingest/embedder.py's image tower β same checkpoint, same `pooler_output` | |
| head, same L2-normalization β so a query vector and a stored image vector share | |
| one cosine space. The only differences: the text tower instead of the vision | |
| tower, and a tokenizer instead of pixel preprocessing. | |
| """ | |
| from __future__ import annotations | |
| import functools | |
| import os | |
| import numpy as np | |
| import onnxruntime as ort | |
| from huggingface_hub import hf_hub_download | |
| from transformers import AutoTokenizer | |
| # Same checkpoint the image pipeline embeds with (ingest/embedder.py) and the web | |
| # app references (env.server.ts SIGLIP_MODEL_ID). Must stay in lockstep or text | |
| # and image vectors stop sharing a cosine space. | |
| MODEL_REPO = os.environ.get("SIGLIP_MODEL_ID", "onnx-community/siglip2-base-patch16-256-ONNX") | |
| # int8 text tower (283 MB) β matches the web app's q8 dtype. | |
| ONNX_FILE = "onnx/text_model_quantized.onnx" | |
| EMBED_DIM = 768 | |
| # SigLIP2's GemmaTokenizer has a sentinel model_max_length; the real training pad | |
| # length lives in tokenizer_config.json (64). Must match the web tokenizer call. | |
| MAX_LENGTH = 64 | |
| CACHE_SIZE = 512 | |
| _TEXT_EMBED_OUTPUT = "pooler_output" | |
| _INPUT_IDS = "input_ids" | |
| _SESSION: ort.InferenceSession | None = None | |
| _TOKENIZER = None | |
| def load_session() -> ort.InferenceSession: | |
| """Resolve the text ONNX (baked into the image in production, HF-hub-cached | |
| elsewhere) and build a CPU session once.""" | |
| global _SESSION | |
| if _SESSION is None: | |
| path = hf_hub_download(MODEL_REPO, ONNX_FILE) | |
| _SESSION = ort.InferenceSession(path, providers=["CPUExecutionProvider"]) | |
| return _SESSION | |
| def load_tokenizer(): | |
| """Load the GemmaTokenizer for the checkpoint once.""" | |
| global _TOKENIZER | |
| if _TOKENIZER is None: | |
| _TOKENIZER = AutoTokenizer.from_pretrained(MODEL_REPO) | |
| return _TOKENIZER | |
| def warmup() -> None: | |
| """Load both heavy objects so the first real request doesn't pay for it.""" | |
| load_session() | |
| load_tokenizer() | |
| def _encode_cached(query: str) -> tuple[float, ...]: | |
| session = load_session() | |
| tokenizer = load_tokenizer() | |
| enc = tokenizer( | |
| [query], | |
| padding="max_length", | |
| truncation=True, | |
| max_length=MAX_LENGTH, | |
| return_tensors="np", | |
| ) | |
| output_names = [o.name for o in session.get_outputs()] | |
| # Fail loudly rather than embedding from the wrong head if a future export | |
| # drops/renames pooler_output (matches ingest/embedder.py's guard). | |
| if _TEXT_EMBED_OUTPUT not in output_names: | |
| raise ValueError( | |
| f"ONNX model does not expose '{_TEXT_EMBED_OUTPUT}'; available: {output_names}" | |
| ) | |
| # Feed only the inputs the model declares (SigLIP text uses input_ids; some | |
| # exports also take attention_mask). int64 is what ONNX expects for ids. | |
| input_names = {i.name for i in session.get_inputs()} | |
| feed = {name: enc[name].astype(np.int64) for name in input_names if name in enc} | |
| if _INPUT_IDS not in feed: | |
| raise ValueError( | |
| f"ONNX model does not accept '{_INPUT_IDS}'; inputs: {sorted(input_names)}" | |
| ) | |
| out = session.run([_TEXT_EMBED_OUTPUT], feed)[0] | |
| vec = np.asarray(out, dtype=np.float32).reshape(EMBED_DIM) | |
| norm = float(np.linalg.norm(vec)) | |
| vec = vec / max(norm, 1e-12) | |
| return tuple(float(x) for x in vec) | |
| def encode(query: str) -> list[float]: | |
| """Encode a query into a 768-d L2-normalized vector, cosine-comparable to | |
| photos.embedding. Cached by exact query string.""" | |
| if not query or not query.strip(): | |
| raise ValueError("encode: empty query") | |
| return list(_encode_cached(query)) | |