-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathembedding.py
More file actions
159 lines (122 loc) · 4.59 KB
/
Copy pathembedding.py
File metadata and controls
159 lines (122 loc) · 4.59 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
"""
embedding.py — Semantic similarity matching using sentence embeddings.
Compares a book's title+author against Audible search results using
cosine similarity. Runs on CUDA when available, falls back to CPU.
The model is loaded once and cached for the process lifetime.
.env:
EMBEDDING_MODEL=all-MiniLM-L6-v2 # any sentence-transformers model
EMBEDDING_THRESHOLD=0.75 # min cosine similarity to auto-match
EMBEDDING_DEVICE= # "cuda", "cpu", or empty for auto
"""
import logging
import os
from typing import Optional
log = logging.getLogger(__name__)
_model = None
_device = None
def _load_model(model_name: str, device: str = ""):
"""Load sentence-transformers model once, cache globally."""
global _model, _device
if _model is not None:
return
try:
from sentence_transformers import SentenceTransformer
import torch
except ImportError as e:
log.warning(f" sentence-transformers/torch import failed: {e}")
_model = False # Sentinel: tried and failed
return
except Exception as e:
log.warning(f" Unexpected error importing embedding deps: {e}")
_model = False
return
if not device:
device = "cuda" if torch.cuda.is_available() else "cpu"
_device = device
log.info(f" Loading embedding model '{model_name}' on {device}")
# Suppress noisy HuggingFace HTTP logs and torch weight warnings
import warnings
warnings.filterwarnings("ignore", message=".*position_ids.*")
logging.getLogger("sentence_transformers").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("huggingface_hub").setLevel(logging.WARNING)
_model = SentenceTransformer(model_name, device=device)
def _build_text(title: str, author: str) -> str:
"""Build a comparison string from title + author."""
parts = [title]
if author:
parts.append(f"by {author}")
return " ".join(parts)
def _result_text(r: dict) -> str:
"""Build a comparison string from an Audible result."""
parts = [r.get("title", "")]
if r.get("author"):
parts.append(f"by {r['author']}")
if r.get("narrator"):
parts.append(f"narrated by {r['narrator']}")
if r.get("series"):
parts.append(f"({r['series']})")
return " ".join(parts)
def pick_best_match(
title: str,
author: str,
results: list,
model_name: str = "all-MiniLM-L6-v2",
threshold: float = 0.75,
device: str = "",
) -> tuple[Optional[int], float]:
"""
Score results by cosine similarity against title+author.
Returns (best_index, best_score).
best_index is None if best score < threshold or model unavailable.
"""
if not results:
return None, 0.0
_load_model(model_name, device)
if _model is None or _model is False:
return None, 0.0
query = _build_text(title, author)
candidates = [_result_text(r) for r in results]
# Encode everything in one batch
all_texts = [query] + candidates
embeddings = _model.encode(
all_texts, convert_to_tensor=True, show_progress_bar=False)
# Cosine similarity between query and each candidate
from sentence_transformers.util import cos_sim
scores = cos_sim(embeddings[0:1], embeddings[1:])[0]
# Find best
best_idx = int(scores.argmax())
best_score = float(scores[best_idx])
log.info(
f" Embedding scores: {', '.join(f'[{i}]={float(scores[i]):.3f}' for i in range(len(results)))}")
log.info(
f" Embedding best: [{best_idx}] {results[best_idx].get('title', '?')} (score={best_score:.3f}, threshold={threshold})")
if best_score >= threshold:
return best_idx, best_score
return None, best_score
def score_all(
title: str,
author: str,
results: list,
model_name: str = "all-MiniLM-L6-v2",
device: str = "",
) -> list[tuple[int, float]]:
"""
Score all results, return list of (index, score) sorted best-first.
Useful for diagnostics / debugging.
"""
if not results:
return []
_load_model(model_name, device)
if _model is None or _model is False:
return []
query = _build_text(title, author)
candidates = [_result_text(r) for r in results]
all_texts = [query] + candidates
embeddings = _model.encode(
all_texts, convert_to_tensor=True, show_progress_bar=False)
from sentence_transformers.util import cos_sim
scores = cos_sim(embeddings[0:1], embeddings[1:])[0]
scored = [(i, float(scores[i])) for i in range(len(results))]
scored.sort(key=lambda x: -x[1])
return scored