reflections-encoder / text_encoder.py
system's picture
system HF Staff
deploy: sync encoder from 240af2d
50ba853 verified
Raw
History Blame Contribute Delete
3.74 kB
"""
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()
@functools.lru_cache(maxsize=CACHE_SIZE)
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))