Download inference.py from nynefold/retrieval-quant: direct link, hf CLI and curl.
- Browser
- Download file 9.25 kB
-
https://huggingface.co/nynefold/retrieval-quant/resolve/main/inference.py
- Command line
-
hf download hf://nynefold/retrieval-quant/inference.py
-
curl -L -o inference.py https://huggingface.co/nynefold/retrieval-quant/resolve/main/inference.py
9.25 kB
| """Run the quantized ONNX models from this repository with ONNX Runtime. | |
| Needs only `onnxruntime`, `tokenizers` and `numpy` (plus `huggingface_hub` to download by repo id). | |
| from inference import load, maxsim | |
| emb = load("nynefold/pplx-embed-v1-0.6b-ONNX") # recommended variant | |
| q = emb.embed_queries(["how do I reset the router?"]) | |
| d = emb.embed_documents(["Hold the reset button for ten seconds.", "The router has four ports."]) | |
| print(q @ d.T) # cosine similarities | |
| rr = load("nynefold/zerank-1-small-ONNX", variant="mix30-gptq-sq50-a4") | |
| print(rr.rerank("how do I reset the router?", ["Hold the reset button ...", "The router has ..."])) | |
| late = load("nynefold/pplx-embed-v2-late-0.6b-ONNX") # late interaction: one vector per token | |
| docs = late.embed_documents(["Hold the reset button for ten seconds.", "The router has four ports."]) | |
| print(maxsim(late.embed_queries(["how do I reset the router?"])[0], docs)) | |
| `load` takes a repo id or a local folder. A repo id downloads the variant into | |
| ~/.cache/nynefold/<repo id> (or $NYNEFOLD_CACHE/<repo id>) as plain files. All model-specific details (prompts, truncation, output | |
| transform) come from `config.json`, which is part of the contract: the graphs expect | |
| exactly these inputs. | |
| """ | |
| import json | |
| import os | |
| from pathlib import Path | |
| import numpy as np | |
| import onnxruntime as ort | |
| from tokenizers import Tokenizer | |
| CONFIG = "config.json" # also the file the Hub counts downloads by | |
| def _local(repo_or_dir, variant): | |
| p = Path(repo_or_dir) | |
| if p.is_dir(): | |
| return p | |
| # Plain files in a local folder, not the Hub cache: its symlinks put model.onnx and model.onnx.data | |
| # in different blob folders, and ONNX Runtime refuses external data outside the model's folder. | |
| from huggingface_hub import hf_hub_download, snapshot_download | |
| dst = Path(os.environ.get("NYNEFOLD_CACHE", Path.home() / ".cache" / "nynefold")) / repo_or_dir | |
| cfg = json.loads(Path(hf_hub_download(repo_or_dir, CONFIG, local_dir=dst)).read_text()) | |
| v = variant or cfg["recommended_variant"] | |
| return Path(snapshot_download(repo_or_dir, local_dir=dst, | |
| allow_patterns=["*.json", "*.txt", "*.jinja", f"{v}/*"])) | |
| def load(repo_or_dir, variant=None, threads=None): | |
| """A ready model: Embedder or Reranker. variant=None picks the repo's recommended variant.""" | |
| root = _local(repo_or_dir, variant) | |
| cfg = json.loads((root / CONFIG).read_text()) | |
| variant = variant or cfg["recommended_variant"] | |
| vdir = root / variant | |
| tok_file = vdir / "tokenizer.json" if (vdir / "tokenizer.json").exists() else root / "tokenizer.json" | |
| so = ort.SessionOptions() | |
| if threads: # otherwise ORT's default: one thread per physical core | |
| so.intra_op_num_threads = threads | |
| so.inter_op_num_threads = 1 | |
| so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL | |
| sess = ort.InferenceSession(str(vdir / "model.onnx"), so, providers=["CPUExecutionProvider"]) | |
| cls = {"embed": Embedder, "rerank": Reranker, "late": LateEmbedder}[cfg["kind"]] | |
| return cls(cfg, Tokenizer.from_file(str(tok_file)), sess) | |
| class _Model: | |
| def __init__(self, cfg, tok, sess): | |
| self.cfg, self.tok, self.sess = cfg, tok, sess | |
| self.max_len = cfg["max_length"] | |
| self.pad_id = tok.token_to_id(cfg["pad_token"]) | |
| def _ids(self, text, special=True): | |
| return self.tok.encode(text, add_special_tokens=special).ids | |
| def _run(self, ids, max_tokens=8192, max_batch=64, per_token=False): | |
| """Right-padded, length-sorted batches through the graph; one output row per input | |
| (per_token: a list with one [tokens, dim] array per input, padding removed).""" | |
| out = [None] * len(ids) | |
| order = sorted(range(len(ids)), key=lambda i: -len(ids[i])) | |
| i = 0 | |
| while i < len(order): | |
| T = len(ids[order[i]]) | |
| idx = order[i : i + max(1, min(max_batch, max_tokens // max(T, 1)))] | |
| inp = np.full((len(idx), T), self.pad_id, dtype=np.int64) | |
| msk = np.zeros((len(idx), T), dtype=np.int64) | |
| for r, j in enumerate(idx): | |
| inp[r, : len(ids[j])] = ids[j] | |
| msk[r, : len(ids[j])] = 1 | |
| y = self.sess.run(None, {"input_ids": inp, "attention_mask": msk})[0] | |
| for r, j in enumerate(idx): | |
| out[j] = y[r, : len(ids[j])] if per_token else y[r] | |
| i += len(idx) | |
| return out if per_token else np.stack(out) | |
| class Embedder(_Model): | |
| """Pooling is inside the graph. Returns L2-normalized float32 vectors; compare by dot product.""" | |
| def encode_ids(self, texts, role): | |
| prefix = self.cfg["query_prefix"] if role == "query" else self.cfg["document_prefix"] | |
| out = [] | |
| for t in texts: | |
| x = self._ids(prefix + t, special=self.cfg["add_special_tokens"]) | |
| if len(x) > self.max_len: | |
| # last-token pooling reads the final (end) token, so keep it | |
| x = x[: self.max_len - 1] + x[-1:] if self.cfg["pooling"] == "last" else x[: self.max_len] | |
| out.append(x) | |
| return out | |
| def _embed(self, texts, role): | |
| e = self._run(self.encode_ids(list(texts), role)).astype(np.float32) | |
| if self.cfg["output"] == "int8_tanh": # the base model's native int8 output: round(127 * tanh(x)) | |
| e = np.clip(np.round(127 * np.tanh(e)), -128, 127) | |
| return e / np.linalg.norm(e, axis=1, keepdims=True) | |
| def embed_queries(self, texts): | |
| return self._embed(texts, "query") | |
| def embed_documents(self, texts): | |
| return self._embed(texts, "document") | |
| class Reranker(_Model): | |
| """Scores (query, document) pairs; higher is more relevant.""" | |
| def encode_ids(self, pairs): | |
| c = self.cfg | |
| out = [] | |
| if c["format"] == "chat": # prompt from the template; if too long, only the document's tokens are cut | |
| head, tail = c["template"].split("{document}") | |
| suf = self._ids(tail, False) | |
| for q, d in pairs: | |
| q, d = q[: c["query_max_chars"]].strip(), d[: c["document_max_chars"]].strip() | |
| x = self._ids(head.format(query=q) + d + tail, special=False) | |
| if len(x) > self.max_len: # keep the end of the template: the score is read at its last token | |
| pre = self._ids(head.format(query=q), False) | |
| x = pre + self._ids(d, False)[: max(0, self.max_len - len(pre) - len(suf))] + suf | |
| out.append(x) | |
| else: # fixed prefix and suffix around a body; only the body is truncated | |
| pre, suf = self._ids(c["prefix"], False), self._ids(c["suffix"], False) | |
| room = self.max_len - len(pre) - len(suf) | |
| for q, d in pairs: | |
| body = self._ids(c["body"].format(instruction=c["instruction"], query=q, document=d), False) | |
| out.append(pre + body[:room] + suf) | |
| return out | |
| def logits(self, query, documents): | |
| return self._run(self.encode_ids([(query, d) for d in documents])).reshape(-1).astype(np.float32) | |
| def rerank(self, query, documents): | |
| """Relevance scores in [0, 1], one per document, in input order.""" | |
| z = self.logits(query, documents) * self.cfg["score_scale"] | |
| return 1.0 / (1.0 + np.exp(-z)) | |
| class LateEmbedder(_Model): | |
| """Late interaction (ColBERT-style, pplx-embed-v2-late): one L2-normalized 128-d vector per token. | |
| Score a query against documents with `maxsim`. Models that share a token space (the 0.6B and 9B) | |
| can be mixed: index documents with one, embed queries with the other.""" | |
| def __init__(self, cfg, tok, sess): | |
| super().__init__(cfg, tok, sess) | |
| self.skip = np.array(sorted({i for i in (tok.token_to_id(t) for t in cfg["document_skiplist"]) if i is not None})) | |
| def encode_ids(self, texts, role): | |
| c = self.cfg | |
| prefix, n = ((c["query_prefix"], c["query_max_length"]) if role == "query" | |
| else (c["document_prefix"], c["document_max_length"])) | |
| return [self._ids(prefix + t, special=c["add_special_tokens"])[:n] for t in texts] | |
| def _embed(self, texts, role): | |
| ids = self.encode_ids(list(texts), role) | |
| out = [v.astype(np.float32) for v in self._run(ids, per_token=True)] | |
| if role == "document": # punctuation tokens don't take part in scoring | |
| out = [v[~np.isin(x, self.skip)] for v, x in zip(out, ids)] | |
| return out | |
| def embed_queries(self, texts): | |
| """A list of [query tokens, 128] float32 arrays.""" | |
| return self._embed(texts, "query") | |
| def embed_documents(self, texts): | |
| """A list of [document tokens, 128] float32 arrays (punctuation tokens removed): the index.""" | |
| return self._embed(texts, "document") | |
| def maxsim(query, documents): | |
| """Late-interaction scores of one query ([tokens, dim]) against documents (a list of [tokens, dim]): | |
| for each query token the best-matching document token's dot product, summed. Higher is more relevant.""" | |
| return np.array([float((query @ d.T).max(1).sum()) for d in documents], dtype=np.float32) | |