embeddings: re-normalize after matryoshka `dimensions` truncation

The `dimensions` request param truncated AFTER normalizing, so truncated
vectors were no longer unit-norm and downstream cosine-via-dot-product math
broke. Truncate then re-normalize (OpenAI semantics), shared by all backends.
Verified on MRL-trained Qwen3-Embedding-4B: at 1024/2560 dims retrieval quality
is unchanged (0.900 vs 0.181 relevant/unrelated; full-dim was 0.898 vs 0.189).
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EPLnsRpNBzWCHLgkXATqRz
parent 4a479f81
...@@ -310,6 +310,23 @@ def _decode_image(src: str): ...@@ -310,6 +310,23 @@ def _decode_image(src: str):
return Image.open(io.BytesIO(raw)).convert('RGB') return Image.open(io.BytesIO(raw)).convert('RGB')
def _truncate_dims(results, dimensions):
"""Matryoshka-style truncation: keep the first N dims, then RE-normalize —
a truncated slice of a unit vector is no longer unit-norm, and downstream
cosine/dot-product math assumes normalized embeddings (this matches how
OpenAI applies `dimensions`). Meaningful for MRL-trained models
(Qwen3-Embedding: 32-2560); others degrade gracefully but aren't trained
for truncation."""
if not dimensions:
return results
out = []
for v in results:
t = v[:dimensions]
n = sum(x * x for x in t) ** 0.5
out.append([x / n for x in t] if n > 0 else t)
return out
def _qwenvl_embed(model_tuple, items, dimensions=None): def _qwenvl_embed(model_tuple, items, dimensions=None):
"""GME-style embedding on a native Qwen2-VL: last-token hidden state under the """GME-style embedding on a native Qwen2-VL: last-token hidden state under the
GME chat prompt (mirrors the repo's custom_st tokenize/forward, which we can't GME chat prompt (mirrors the repo's custom_st tokenize/forward, which we can't
...@@ -348,9 +365,7 @@ def _qwenvl_embed(model_tuple, items, dimensions=None): ...@@ -348,9 +365,7 @@ def _qwenvl_embed(model_tuple, items, dimensions=None):
emb = hs[torch.arange(hs.shape[0], device=hs.device), idx] emb = hs[torch.arange(hs.shape[0], device=hs.device), idx]
emb = F.normalize(emb.float(), dim=-1) emb = F.normalize(emb.float(), dim=-1)
results = [row.cpu().tolist() for row in emb] results = [row.cpu().tolist() for row in emb]
if dimensions: return _truncate_dims(results, dimensions)
results = [v[:dimensions] for v in results]
return results
def _clip_feats(raw): def _clip_feats(raw):
...@@ -408,9 +423,7 @@ def _embed_texts(model_obj, texts: List[str], dimensions=None) -> List[List[floa ...@@ -408,9 +423,7 @@ def _embed_texts(model_obj, texts: List[str], dimensions=None) -> List[List[floa
mean_emb = F.normalize(mean_emb, dim=-1) mean_emb = F.normalize(mean_emb, dim=-1)
results = [row.cpu().tolist() for row in mean_emb] results = [row.cpu().tolist() for row in mean_emb]
if dimensions: return _truncate_dims(results, dimensions)
results = [v[:dimensions] for v in results]
return results
def _embed_images(model_obj, images: List[str], dimensions=None) -> List[List[float]]: def _embed_images(model_obj, images: List[str], dimensions=None) -> List[List[float]]:
...@@ -459,9 +472,7 @@ def _embed_images(model_obj, images: List[str], dimensions=None) -> List[List[fl ...@@ -459,9 +472,7 @@ def _embed_images(model_obj, images: List[str], dimensions=None) -> List[List[fl
else: else:
raise ValueError("model is text-only") raise ValueError("model is text-only")
if dimensions: return _truncate_dims(results, dimensions)
results = [v[:dimensions] for v in results]
return results
@router.post("/v1/embeddings", response_model=EmbeddingsResponse, summary="Create embeddings") @router.post("/v1/embeddings", response_model=EmbeddingsResponse, summary="Create embeddings")
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment