Buckets:
| #!/usr/bin/env python3 | |
| """Parametrized Iconclass retriever index — swap the embedding model & doc representation. | |
| The production retriever (`grpo-tools/iconclass_tools.py`) is hardwired to | |
| `all-MiniLM-L6-v2` and indexes each code by its LEAF text only. That makes | |
| sibling codes collapse together ("head turned to the left" vs "...right") and | |
| caps exact-leaf retrieval at ~18% — the bottleneck the retriever fine-tune | |
| attacks. | |
| This module builds the SAME catalogue of Iconclass codes (reusing | |
| `_get_all_iconclass_codes` so coverage matches the baseline exactly, 40,457 | |
| codes) but lets us choose: | |
| * `model_name` — any sentence-transformers model (off-the-shelf or our FT one) | |
| * `doc_mode` — how each code is rendered to text for indexing: | |
| 'leaf' : the leaf description only (baseline behaviour) | |
| 'parent_leaf' : "<parent leaf>: <leaf>" (one level of context) | |
| 'path' : full root->leaf path, " > "-joined (max context) | |
| * `query_prompt` / `doc_prompt` — optional instruction prefixes (BGE/E5/GTE) | |
| Doc embeddings are cached to ./ret_cache/<slug>.npz so repeated evals are fast. | |
| Used by ret_eval.py (intrinsic metrics + ceiling probe) and ret_retrieve_pool.py | |
| (regenerate the fusion pool with a new retriever, reusing cached descriptions). | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import os | |
| import sys | |
| from functools import lru_cache | |
| import numpy as np | |
| # Reuse the EXACT catalogue the production retriever indexes, so the only thing | |
| # that changes vs the baseline is the embedding model + doc representation. | |
| sys.path.insert(0, os.path.abspath(os.path.dirname(os.path.abspath(__file__)))) | |
| sys.path.insert( | |
| 0, | |
| os.path.abspath( | |
| os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "grpo-tools") | |
| ), | |
| ) | |
| from iconclass_tools import _get_all_iconclass_codes # noqa: E402 | |
| from iconclass import init as _ic_init # noqa: E402 | |
| _ic = _ic_init() | |
| CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ret_cache") | |
| os.makedirs(CACHE_DIR, exist_ok=True) | |
| # --------------------------------------------------------------------------- catalogue | |
| def code_catalog(): | |
| """[(code, leaf_text)] — the 40,457 codes the baseline retriever indexes.""" | |
| return list(_get_all_iconclass_codes()) | |
| def _leaf_text(code): | |
| try: | |
| n = _ic[code] | |
| return (n() if callable(n) else str(n)) or code | |
| except Exception: | |
| return code | |
| def path_text(code, joiner=" > "): | |
| """Full root->leaf textual path for a code (drops any (KEY)/(+x) suffix).""" | |
| base = code.split("(")[0].strip() | |
| try: | |
| p = _ic[base].obj.get("p", []) or [base] | |
| except Exception: | |
| p = [base] | |
| parts = [_leaf_text(a) for a in p] | |
| return joiner.join(x for x in parts if x) | |
| def doc_text(code, leaf, mode): | |
| """Render a code to the string we index it by.""" | |
| if mode == "leaf": | |
| return leaf | |
| if mode == "parent_leaf": | |
| base = code.split("(")[0].strip() | |
| try: | |
| p = _ic[base].obj.get("p", []) | |
| except Exception: | |
| p = [] | |
| if len(p) >= 2: | |
| return f"{_leaf_text(p[-2])}: {leaf}" | |
| return leaf | |
| if mode == "path": | |
| return path_text(code) | |
| raise ValueError(f"unknown doc_mode {mode!r}") | |
| # --------------------------------------------------------------------------- index | |
| def _slug(model_name, doc_mode, doc_prompt): | |
| h = hashlib.md5( | |
| f"{model_name}|{doc_mode}|{doc_prompt}|{len(code_catalog())}".encode() | |
| ).hexdigest()[:10] | |
| safe = model_name.replace("/", "__") | |
| return f"{safe}__{doc_mode}__{h}" | |
| class RetrieverIndex: | |
| def __init__( | |
| self, | |
| model_name="all-MiniLM-L6-v2", | |
| doc_mode="leaf", | |
| query_prompt=None, | |
| doc_prompt=None, | |
| device=None, | |
| trust_remote_code=False, | |
| batch_size=512, | |
| ): | |
| from sentence_transformers import SentenceTransformer | |
| self.model_name = model_name | |
| self.doc_mode = doc_mode | |
| self.query_prompt = query_prompt or "" | |
| self.doc_prompt = doc_prompt or "" | |
| self.batch_size = batch_size | |
| cat = code_catalog() | |
| self.codes = [c for c, _ in cat] | |
| self.leaves = [leaf for _, leaf in cat] | |
| self.doc_texts = [doc_text(c, leaf, doc_mode) for c, leaf in cat] | |
| print(f"[index] loading {model_name} (doc_mode={doc_mode})…", flush=True) | |
| self.model = SentenceTransformer( | |
| model_name, device=device, trust_remote_code=trust_remote_code | |
| ) | |
| self.embeddings = self._load_or_build_doc_embeddings() | |
| print( | |
| f"[index] ready: {len(self.codes)} codes, dim={self.embeddings.shape[1]}", | |
| flush=True, | |
| ) | |
| def _load_or_build_doc_embeddings(self): | |
| slug = _slug(self.model_name, self.doc_mode, self.doc_prompt) | |
| path = os.path.join(CACHE_DIR, f"{slug}.npz") | |
| if os.path.exists(path): | |
| print(f"[index] cached doc embeddings: {path}", flush=True) | |
| return np.load(path)["emb"] | |
| print( | |
| f"[index] encoding {len(self.doc_texts)} doc texts (one-time)…", flush=True | |
| ) | |
| texts = ( | |
| [self.doc_prompt + t for t in self.doc_texts] | |
| if self.doc_prompt | |
| else self.doc_texts | |
| ) | |
| emb = self.model.encode( | |
| texts, | |
| batch_size=self.batch_size, | |
| show_progress_bar=True, | |
| convert_to_numpy=True, | |
| normalize_embeddings=True, | |
| ).astype(np.float32) | |
| np.savez_compressed(path, emb=emb) | |
| return emb | |
| def encode_queries(self, queries): | |
| texts = ( | |
| [self.query_prompt + q for q in queries] if self.query_prompt else queries | |
| ) | |
| return self.model.encode( | |
| texts, | |
| batch_size=self.batch_size, | |
| convert_to_numpy=True, | |
| normalize_embeddings=True, | |
| ).astype(np.float32) | |
| def search_many(self, queries, k=10): | |
| """Batch search. Returns list (per query) of [(code, leaf, sim)].""" | |
| if not queries: | |
| return [] | |
| q = self.encode_queries(queries) # (Q, d), normalized | |
| sims = q @ self.embeddings.T # (Q, N) cosine (both normalized) | |
| out = [] | |
| topk = min(k, sims.shape[1]) | |
| idx = np.argpartition(-sims, topk - 1, axis=1)[:, :topk] | |
| for i in range(sims.shape[0]): | |
| row = idx[i] | |
| row = row[np.argsort(-sims[i, row])] | |
| out.append([(self.codes[j], self.leaves[j], float(sims[i, j])) for j in row]) | |
| return out | |
| def search(self, query, k=10): | |
| return self.search_many([query], k)[0] | |
Xet Storage Details
- Size:
- 6.8 kB
- Xet hash:
- 15fff38163f36c797daa5e65effe6ae3e131eb23a403feb997ffefc3cf1de735
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.