2026  ·  10 min read  ·  Technical

The Embedding Trap in Signed-Affinity Recommendation Systems

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.

Interactive tool
Signed Affinity Recommendation Explorer
Compare naive text embedding vs weighted signed vector matching across preset scenarios — adjust sliders to see where the models diverge.
The setup

A user profile with signed scores.

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 failure

The naive approach and why it fails.

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.

Why this happens geometrically: text embedding models encode semantic presence, not signed magnitude. When the model processes "anxiety: −0.9", it recognises the concept anxiety and weights that region of the embedding space accordingly. The token "−0.9" is just two characters among hundreds — it barely shifts the resulting vector. The negative sign exists in the string, but it has almost no influence on where the vector lands.

The sign is living in the text. It needs to live on the vector.

The fix

Building toward the correct solution.

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.

Step 1 — Split positive and negative channels

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.

Step 2 — Signed prefix tokens

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.

Step 3 — Weighted semantic sum (the right solution)

# 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
Why 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.

Why normalise at the end? Profiles with more non-zero features produce larger magnitude vectors. Normalising ensures cosine similarity reflects the shape of the profile — which directions it points toward and away from — rather than how many features were filled in.

Why cache feature embeddings? Embedding 200+ features per profile per request is expensive and unnecessary. The concept vectors for "wine" or "anxiety" don't change between users. Embed them once at startup. After that, profile construction is pure arithmetic.

The synonym problem

Near-synonyms constructively interfere.

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.

PCA whitening

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.

Query-time consistency — the gotcha

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]
Never inject a raw embedding vector into a whitened space directly. The geometry is incompatible and similarity scores will be silently wrong — there is no error message, and the output will still look plausible.

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.

  1. Embed all features once and cache them.
  2. Apply PCA whitening to the feature matrix to decorrelate near-synonyms.
  3. Build profile vectors as a signed, tanh-compressed weighted sum in whitened space.
  4. Normalise the result so similarity reflects profile shape, not tag count.
  5. Apply the identical transformation to queries — including projecting novel features via 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.

See it in action
Signed Affinity Recommendation Explorer
Adjust user profiles and watch the naive and weighted models diverge in real time.
Embeddings Recommendations RAG ML engineering Python