|
36 | 36 | import click |
37 | 37 |
|
38 | 38 | OLS_SEARCH = "https://www.ebi.ac.uk/ols4/api/search" |
39 | | -# Default embedding backend: free, local, runs on the M5 GPU. Nothing paid, no API |
| 39 | +# Default embedding backend: free, local, runs on CPU/GPU-capable hardware. Nothing paid, no API |
40 | 40 | # key. Both the endpoint and the model are CLI-parameterized so anyone can point at |
41 | 41 | # a different local model, a remote Ollama server, or other hardware. |
42 | 42 | DEFAULT_EMBED_URL = "http://localhost:11434/api/embeddings" |
43 | 43 | DEFAULT_EMBED_MODEL = "nomic-embed-text" |
44 | 44 |
|
| 45 | +# In-memory cache to avoid redundant embedding API calls within a run. |
| 46 | +_EMBED_CACHE = {} |
| 47 | + |
45 | 48 |
|
46 | 49 | def ols_candidates(label, rows=20): |
47 | 50 | """Lexical candidate classes from OLS4 for a label (IRI, curie, label, def, ontology).""" |
@@ -90,8 +93,22 @@ def embed(text, model, embed_url=DEFAULT_EMBED_URL): |
90 | 93 | return None |
91 | 94 |
|
92 | 95 |
|
| 96 | +def _get_cached_embedding(text, model, embed_url): |
| 97 | + normalized = text.strip(". ") |
| 98 | + key = (model, embed_url, normalized) |
| 99 | + if key not in _EMBED_CACHE: |
| 100 | + v = embed(normalized, model, embed_url) |
| 101 | + if v is not None: |
| 102 | + _EMBED_CACHE[key] = v |
| 103 | + return v |
| 104 | + return _EMBED_CACHE[key] |
| 105 | + |
| 106 | + |
93 | 107 | def cosine(a, b): |
94 | | - dot = sum(x * y for x, y in zip(a, b, strict=False)) |
| 108 | + if len(a) != len(b): |
| 109 | + msg = f"cosine vector dimension mismatch: len(a)={len(a)} != len(b)={len(b)}" |
| 110 | + raise ValueError(msg) |
| 111 | + dot = sum(x * y for x, y in zip(a, b, strict=True)) |
95 | 112 | na = math.sqrt(sum(x * x for x in a)) |
96 | 113 | nb = math.sqrt(sum(x * x for x in b)) |
97 | 114 | return dot / (na * nb) if na and nb else 0.0 |
@@ -130,10 +147,14 @@ def load_terms(metpo_tsv, label, definition): |
130 | 147 | def rank_candidates(term, cands, use_embeddings, model, embed_url): |
131 | 148 | """Attach a 'similarity' score to each candidate (embedding cosine or lexical order).""" |
132 | 149 | if use_embeddings and cands: |
133 | | - qv = embed((term["label"] + ". " + term["definition"]).strip(". "), model, embed_url) |
| 150 | + qv = _get_cached_embedding(term["label"] + ". " + term["definition"], model, embed_url) |
| 151 | + if not qv: |
| 152 | + for rank, c in enumerate(cands): |
| 153 | + c["similarity"] = round(1.0 - rank / max(len(cands), 1), 4) |
| 154 | + return cands |
134 | 155 | for c in cands: |
135 | | - cv = embed((c["label"] + ". " + c["definition"]).strip(". "), model, embed_url) |
136 | | - c["similarity"] = round(cosine(qv, cv), 4) if (qv and cv) else 0.0 |
| 156 | + cv = _get_cached_embedding(c["label"] + ". " + c["definition"], model, embed_url) |
| 157 | + c["similarity"] = round(cosine(qv, cv), 4) if cv else 0.0 |
137 | 158 | cands.sort(key=lambda c: c["similarity"], reverse=True) |
138 | 159 | else: |
139 | 160 | for rank, c in enumerate(cands): |
|
0 commit comments