drop the batched embed_images method from multi-modal embedders
This commit is contained in:
parent
37215f1a00
commit
a36f920f5d
4 changed files with 82 additions and 78 deletions
|
|
@ -48,15 +48,13 @@ class EmbedderWrapper:
|
||||||
return [list(e) for e in result.embeddings]
|
return [list(e) for e in result.embeddings]
|
||||||
|
|
||||||
async def embed_image_query(self, image: "Any") -> list[float]:
|
async def embed_image_query(self, image: "Any") -> list[float]:
|
||||||
"""Embed a single image as a search query."""
|
"""Embed a single image into the same vector space as text.
|
||||||
raise NotImplementedError(
|
|
||||||
f"{type(self).__name__} does not support image embedding. "
|
|
||||||
"Configure a multimodal provider (e.g. provider='mlx' or "
|
|
||||||
"provider='vllm')."
|
|
||||||
)
|
|
||||||
|
|
||||||
async def embed_images(self, images: list["Any"]) -> list[list[float]]:
|
Multimodal providers override this. Picture embedding is single-image:
|
||||||
"""Batch-embed images for indexing into the same vector space as text."""
|
vLLM's ``/v1/embeddings`` accepts one image per request via the
|
||||||
|
``messages`` superset, and MLX runs forward passes one at a time.
|
||||||
|
Callers loop when they need many.
|
||||||
|
"""
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
f"{type(self).__name__} does not support image embedding. "
|
f"{type(self).__name__} does not support image embedding. "
|
||||||
"Configure a multimodal provider (e.g. provider='mlx' or "
|
"Configure a multimodal provider (e.g. provider='mlx' or "
|
||||||
|
|
|
||||||
|
|
@ -77,15 +77,7 @@ class MLXEmbedder(EmbedderWrapper):
|
||||||
return await asyncio.to_thread(self._encode_texts, texts)
|
return await asyncio.to_thread(self._encode_texts, texts)
|
||||||
|
|
||||||
async def embed_image_query(self, image: "bytes | PILImage.Image") -> list[float]:
|
async def embed_image_query(self, image: "bytes | PILImage.Image") -> list[float]:
|
||||||
embeddings = await self.embed_images([image])
|
return await asyncio.to_thread(self._encode_image, image)
|
||||||
return embeddings[0]
|
|
||||||
|
|
||||||
async def embed_images(
|
|
||||||
self, images: list["bytes | PILImage.Image"]
|
|
||||||
) -> list[list[float]]:
|
|
||||||
if not images:
|
|
||||||
return []
|
|
||||||
return await asyncio.to_thread(self._encode_images, images)
|
|
||||||
|
|
||||||
def _encode_texts(self, texts: list[str]) -> list[list[float]]:
|
def _encode_texts(self, texts: list[str]) -> list[list[float]]:
|
||||||
import mlx.core as mx # ty: ignore[unresolved-import,unused-ignore-comment]
|
import mlx.core as mx # ty: ignore[unresolved-import,unused-ignore-comment]
|
||||||
|
|
@ -107,34 +99,27 @@ class MLXEmbedder(EmbedderWrapper):
|
||||||
mx.eval(embeddings)
|
mx.eval(embeddings)
|
||||||
return [list(map(float, row)) for row in embeddings]
|
return [list(map(float, row)) for row in embeddings]
|
||||||
|
|
||||||
def _encode_images(
|
def _encode_image(self, image: "bytes | PILImage.Image") -> list[float]:
|
||||||
self, images: list["bytes | PILImage.Image"]
|
|
||||||
) -> list[list[float]]:
|
|
||||||
import mlx.core as mx # ty: ignore[unresolved-import,unused-ignore-comment]
|
import mlx.core as mx # ty: ignore[unresolved-import,unused-ignore-comment]
|
||||||
from PIL import Image as PILImageModule
|
|
||||||
|
|
||||||
model, processor = self._ensure_loaded()
|
model, processor = self._ensure_loaded()
|
||||||
pil_images = [_to_pil(img) for img in images]
|
pil_image = _to_pil(image)
|
||||||
out: list[list[float]] = []
|
inputs = processor(
|
||||||
for pil_image in pil_images:
|
text=[_DEFAULT_IMAGE_PROMPT],
|
||||||
assert isinstance(pil_image, PILImageModule.Image)
|
images=[pil_image],
|
||||||
inputs = processor(
|
return_tensors="np",
|
||||||
text=[_DEFAULT_IMAGE_PROMPT],
|
padding=True,
|
||||||
images=[pil_image],
|
)
|
||||||
return_tensors="np",
|
pixel_values = inputs["pixel_values"]
|
||||||
padding=True,
|
embedding = model.encode_image(
|
||||||
)
|
input_ids=mx.array(inputs["input_ids"]),
|
||||||
pixel_values = inputs["pixel_values"]
|
pixel_values=mx.array(pixel_values.reshape(-1, pixel_values.shape[-1])),
|
||||||
embedding = model.encode_image(
|
image_grid_thw=[tuple(r) for r in inputs["image_grid_thw"]],
|
||||||
input_ids=mx.array(inputs["input_ids"]),
|
attention_mask=mx.array(inputs["attention_mask"]),
|
||||||
pixel_values=mx.array(pixel_values.reshape(-1, pixel_values.shape[-1])),
|
task="retrieval",
|
||||||
image_grid_thw=[tuple(r) for r in inputs["image_grid_thw"]],
|
)
|
||||||
attention_mask=mx.array(inputs["attention_mask"]),
|
mx.eval(embedding)
|
||||||
task="retrieval",
|
return [float(x) for x in embedding[0]]
|
||||||
)
|
|
||||||
mx.eval(embedding)
|
|
||||||
out.append([float(x) for x in embedding[0]])
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _to_pil(image: "bytes | PILImage.Image") -> "PILImage.Image":
|
def _to_pil(image: "bytes | PILImage.Image") -> "PILImage.Image":
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
"""Multimodal embedder backed by a vLLM OpenAI-compatible HTTP server.
|
"""Multimodal embedder backed by a vLLM OpenAI-compatible HTTP server.
|
||||||
|
|
||||||
vLLM's ``/v1/embeddings`` endpoint is a superset of OpenAI's: when the
|
vLLM's ``/v1/embeddings`` endpoint is a superset of OpenAI's:
|
||||||
request body uses a ``messages`` array (instead of ``input``), the server
|
|
||||||
treats it as a chat-style multimodal embedding request and accepts
|
- Text inputs use the standard ``input: list[str]`` field — one HTTP call
|
||||||
``image_url`` content parts carrying base64 data URIs. Models like
|
returns N embeddings.
|
||||||
``Qwen/Qwen3-VL-Embedding-8B`` and ``jinaai/jina-embeddings-v4`` ship with
|
- Image inputs use a ``messages`` array carrying an ``image_url`` content
|
||||||
chat templates that map both text and image inputs into a shared vector
|
part with a base64 data URI. One image per HTTP call.
|
||||||
space.
|
|
||||||
|
Models like ``Qwen/Qwen3-VL-Embedding-8B`` and ``jinaai/jina-embeddings-v4``
|
||||||
|
ship with chat templates that map both shapes into a shared vector space.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import base64
|
import base64
|
||||||
import io
|
import io
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
@ -45,12 +46,7 @@ class VLLMMultimodalEmbedder(EmbedderWrapper):
|
||||||
headers["Authorization"] = f"Bearer {self._api_key}"
|
headers["Authorization"] = f"Bearer {self._api_key}"
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
async def _post_messages(self, content: list[dict[str, Any]]) -> list[float]:
|
async def _post(self, body: dict[str, Any]) -> list[list[float]]:
|
||||||
body = {
|
|
||||||
"model": self._model_name,
|
|
||||||
"messages": [{"role": "user", "content": content}],
|
|
||||||
"encoding_format": "float",
|
|
||||||
}
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
|
|
@ -79,27 +75,48 @@ class VLLMMultimodalEmbedder(EmbedderWrapper):
|
||||||
data = payload.get("data") or []
|
data = payload.get("data") or []
|
||||||
if not data:
|
if not data:
|
||||||
raise ValueError(f"vLLM returned no embeddings: {payload}")
|
raise ValueError(f"vLLM returned no embeddings: {payload}")
|
||||||
return list(data[0]["embedding"])
|
return [list(d["embedding"]) for d in data]
|
||||||
|
|
||||||
async def embed_query(self, text: str) -> list[float]:
|
async def embed_query(self, text: str) -> list[float]:
|
||||||
return await self._post_messages([{"type": "text", "text": text}])
|
rows = await self._post(
|
||||||
|
{
|
||||||
|
"model": self._model_name,
|
||||||
|
"input": [text],
|
||||||
|
"encoding_format": "float",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows[0]
|
||||||
|
|
||||||
async def embed_documents(self, texts: list[str]) -> list[list[float]]:
|
async def embed_documents(self, texts: list[str]) -> list[list[float]]:
|
||||||
if not texts:
|
if not texts:
|
||||||
return []
|
return []
|
||||||
return await asyncio.gather(*(self.embed_query(t) for t in texts))
|
return await self._post(
|
||||||
|
{
|
||||||
async def embed_image_query(self, image: "bytes | PILImage.Image") -> list[float]:
|
"model": self._model_name,
|
||||||
return await self._post_messages(
|
"input": texts,
|
||||||
[{"type": "image_url", "image_url": {"url": _to_data_uri(image)}}]
|
"encoding_format": "float",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
async def embed_images(
|
async def embed_image_query(self, image: "bytes | PILImage.Image") -> list[float]:
|
||||||
self, images: list["bytes | PILImage.Image"]
|
rows = await self._post(
|
||||||
) -> list[list[float]]:
|
{
|
||||||
if not images:
|
"model": self._model_name,
|
||||||
return []
|
"messages": [
|
||||||
return await asyncio.gather(*(self.embed_image_query(img) for img in images))
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": _to_data_uri(image)},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"encoding_format": "float",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows[0]
|
||||||
|
|
||||||
|
|
||||||
def _to_data_uri(image: "bytes | PILImage.Image") -> str:
|
def _to_data_uri(image: "bytes | PILImage.Image") -> str:
|
||||||
|
|
|
||||||
|
|
@ -238,12 +238,12 @@ async def test_text_only_embedder_does_not_support_images():
|
||||||
assert embedder.supports_images is False
|
assert embedder.supports_images is False
|
||||||
with pytest.raises(NotImplementedError, match="multimodal provider"):
|
with pytest.raises(NotImplementedError, match="multimodal provider"):
|
||||||
await embedder.embed_image_query(b"\x89PNG\r\n\x1a\n")
|
await embedder.embed_image_query(b"\x89PNG\r\n\x1a\n")
|
||||||
with pytest.raises(NotImplementedError, match="multimodal provider"):
|
|
||||||
await embedder.embed_images([b"\x89PNG\r\n\x1a\n"])
|
|
||||||
|
|
||||||
|
|
||||||
async def test_vllm_embed_text_request_shape(monkeypatch):
|
async def test_vllm_embed_text_request_shape(monkeypatch):
|
||||||
"""vLLM text embedding posts a `messages` array with a single text part."""
|
"""vLLM text embedding posts a standard OpenAI ``input`` array (real
|
||||||
|
server-side batching), not the ``messages`` superset (which is reserved
|
||||||
|
for image inputs)."""
|
||||||
from haiku.rag.embeddings.vllm import VLLMMultimodalEmbedder
|
from haiku.rag.embeddings.vllm import VLLMMultimodalEmbedder
|
||||||
|
|
||||||
captured: dict = {}
|
captured: dict = {}
|
||||||
|
|
@ -253,7 +253,12 @@ async def test_vllm_embed_text_request_shape(monkeypatch):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def json(self):
|
def json(self):
|
||||||
return {"data": [{"embedding": [0.1, 0.2, 0.3]}]}
|
return {
|
||||||
|
"data": [
|
||||||
|
{"embedding": [0.1, 0.2, 0.3]},
|
||||||
|
{"embedding": [0.4, 0.5, 0.6]},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
class FakeAsyncClient:
|
class FakeAsyncClient:
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
@ -277,14 +282,13 @@ async def test_vllm_embed_text_request_shape(monkeypatch):
|
||||||
vector_dim=2048,
|
vector_dim=2048,
|
||||||
base_url="http://localhost:8000/v1",
|
base_url="http://localhost:8000/v1",
|
||||||
)
|
)
|
||||||
vec = await embedder.embed_query("a photo of a cat")
|
vecs = await embedder.embed_documents(["a photo of a cat", "a sleeping dog"])
|
||||||
assert vec == [0.1, 0.2, 0.3]
|
assert vecs == [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]
|
||||||
assert captured["url"] == "http://localhost:8000/v1/embeddings"
|
assert captured["url"] == "http://localhost:8000/v1/embeddings"
|
||||||
body = captured["body"]
|
body = captured["body"]
|
||||||
assert body["model"] == "Qwen/Qwen3-VL-Embedding-2B"
|
assert body["model"] == "Qwen/Qwen3-VL-Embedding-2B"
|
||||||
assert body["messages"] == [
|
assert body["input"] == ["a photo of a cat", "a sleeping dog"]
|
||||||
{"role": "user", "content": [{"type": "text", "text": "a photo of a cat"}]}
|
assert "messages" not in body
|
||||||
]
|
|
||||||
assert body["encoding_format"] == "float"
|
assert body["encoding_format"] == "float"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue