When you serialise a user's dislikes into text and embed the result, the sign disappears. Here's why that matters and how to fix it.
The naive approach looks correct in early testing. A wine lover matches wine bars. A hiker matches hiking tours. Then you look at two users with opposite relationships to anxiety — one who avoids it, one who seeks it — and the model recommends them identical products. The sign was in the text. It needed to be on the vector.
Imagine a recommendation system where each user has a profile of tagged attributes, each scored on a continuous scale from −1 to +1:
{
"wine": 0.8,
"live music": 0.6,
"crowds": -0.7,
"anxiety": -0.9,
"adventure": 0.5,
"calm spaces": 0.7
}
A score of +1 means a strong positive affinity — the user genuinely loves this feature. A score of −1 means strong aversion. Zero is neutral. You might have 200 or more such tags per user, covering personality traits, interests, sensitivities, and lifestyle preferences. The task is to find which product profiles best match a given user profile.
The naive approach is to serialise the profile into a string and embed it:
profile_text = " ".join([
f"{feature}: {score}"
for feature, score in user_profile.items()
])
# → "wine: 0.8 live music: 0.6 crowds: -0.7 anxiety: -0.9 ..."
profile_vec = embed(profile_text)
Then at query time, embed each candidate product the same way and rank by cosine similarity. This appears to work. In testing, wine bars rank higher than quiet libraries for a wine-lover profile. But consider these two users:
User A: { "anxiety": -0.9, "adventure": +0.8 }
User B: { "anxiety": +0.9, "adventure": +0.7 }
User A is an adventurous person who struggles with anxiety — they want stimulating, low-stress experiences. User B actively seeks intense, anxiety-inducing thrills — perhaps extreme sports or horror events. These two users should be matched to very different products. But when you embed their profiles as text, both vectors land firmly in "anxiety-space" and "adventure-space." Cosine similarity between them is high. The system recommends User A the same products as User B.
Think of each feature as a direction in embedding space. "Anxiety" points toward a particular region. "Adventure" points toward another. When a user has a positive score for a feature, their profile should be pulled toward that direction — proportionally to the score's magnitude. When they have a negative score, their profile should be pushed away from it.
profile_vec = np.zeros(embedding_dim)
for feature, score in user_profile.items():
feat_vec = embed(feature) # direction of this concept in space
profile_vec += feat_vec * score # positive: pull toward; negative: push away
When score = −0.9, you're adding a vector that points opposite to anxiety-space at 90% strength. The resulting profile vector genuinely encodes aversion, not just association.
The simplest correct approach separates features by sign, embeds each group independently, and concatenates:
pos_text = " ".join([f for f, s in profile.items() if s > 0])
neg_text = " ".join([f for f, s in profile.items() if s < 0])
pos_vec = embed(pos_text)
neg_vec = embed(neg_text)
profile_vec = np.concatenate([pos_vec, neg_vec]) # shape (2d,)
Cosine similarity on this concatenated vector rewards matching in both halves independently. A wine-averse user won't match a wine-lover just because they share the concept "wine." The limitation: the magnitude of each score is still lost. A score of −0.1 and −0.9 are treated identically.
To restore gradations, encode the score as natural language before embedding:
def score_to_prefix(score):
if score > 0.7: return "strongly enjoys"
if score > 0.2: return "mildly enjoys"
if score > -0.2: return "is neutral about"
if score > -0.7: return "mildly dislikes"
return "strongly dislikes"
sentences = [
f"{score_to_prefix(s)} {feature}"
for feature, s in profile.items()
]
# → ["strongly dislikes anxiety", "mildly enjoys adventure", ...]
vecs = [embed(s) for s in sentences]
profile_vec = np.mean(vecs, axis=0)
This is "embedding-native" — valence is now a semantic property of the sentence, not a numeric token. The embedding model understands "strongly dislikes anxiety" as meaningfully different from "strongly enjoys anxiety." The limitation: mean pooling discards magnitude differences between features.
# One-time setup: embed all features and cache
feature_index = {f: embed(f) for f in all_features}
def build_profile_vec(feature_scores: dict) -> np.ndarray:
vec = np.zeros(embedding_dim)
for feature, score in feature_scores.items():
vec += feature_index[feature] * np.tanh(score * 2)
norm = np.linalg.norm(vec)
return vec / norm if norm > 1e-8 else vec
tanh instead of the raw score? Raw scores in [−1, +1] work, but tanh(score * 2) compresses extreme values slightly — a score of 0.99 contributes only marginally more than 0.9. This prevents one high-confidence feature from dominating the entire profile vector when you have 200+ features summing together.With 200+ tags, semantic overlap is inevitable. A system might include both "wine" and "white wine," or both "anxiety" and "worry." In the weighted sum, near-synonym features are nearly parallel vectors in embedding space — and parallel vectors constructively interfere:
embed("wine") ≈ [0.31, 0.72, 0.09, ...]
embed("white wine") ≈ [0.29, 0.70, 0.11, ...] # nearly parallel
profile_vec += wine_vec * 0.8
profile_vec += white_wine_vec * 0.7
# Net: ~1.5× amplification of wine-space
A user with both "wine: +0.8" and "white wine: +0.7" will have wine-space amplified to roughly 1.5× compared to a user with only "wine: +0.9" — even though the underlying preference intensity is similar. The implicit weighting of feature regions becomes a function of tag granularity rather than user intent. The fix is to decorrelate the feature vectors before building the index.
import numpy as np
from sklearn.decomposition import PCA
feature_names = list(all_features)
F = np.array([embed(f) for f in feature_names]) # shape (N, d)
# Fit PCA with whitening on the feature matrix
pca = PCA(n_components=256, whiten=True)
F_white = pca.fit_transform(F) # shape (N, 256)
# Rebuild the feature index in whitened space
feature_index = {f: F_white[i] for i, f in enumerate(feature_names)}
whiten=True is the key parameter. Standard PCA rotates the space to align with principal components. Whitening additionally scales each component so that all dimensions have equal variance. "Wine" and "white wine" — which previously both loaded heavily onto the same principal component — now have their contributions spread across orthogonal directions. They can no longer stack constructively.
To find the right number of components, plot cumulative explained variance on your actual feature set:
pca_full = PCA().fit(F)
cumvar = np.cumsum(pca_full.explained_variance_ratio_)
# Find where cumvar crosses 0.95 — that's your target n_components
With 200–500 features in 1536-dimensional embedding space, you'll typically find 90% of variance in the first 80–150 components. Keeping 256 covers nearly all meaningful variation while discarding redundant dimensions.
Queries must pass through the same transformation as profiles. If a query is expressed as a set of feature scores, it must be projected into the same whitened space. If a query contains a feature not in the original index, project it in:
novel_vec = pca.transform(embed(new_feature).reshape(1, -1))[0]
The failure of the naive approach comes down to a single structural error: the sign of the affinity score lives inside the serialised string, where it has almost no geometric influence. The fix follows naturally from that diagnosis.
pca.transform.The result is a profile vector where direction encodes affinity structure: pointing toward what the user loves, away from what they avoid, with magnitude proportional to intensity. Cosine similarity on these vectors does exactly what you intend.