From a1ec310bf45de47df47f89334eded0bebf989b98 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Tue, 23 Jun 2026 14:47:17 +0300 Subject: [PATCH] Add VoyageAI multimodal embedder --- CHANGELOG.md | 1 + haiku_rag_slim/haiku/rag/client/search.py | 5 +- .../haiku/rag/embeddings/__init__.py | 29 ++-- haiku_rag_slim/haiku/rag/embeddings/vllm.py | 5 + .../haiku/rag/embeddings/voyageai.py | 68 ++++++++ ...oyage_embed_text_and_image_end_to_end.yaml | 132 ++++++++++++++++ tests/test_embedder.py | 149 +++++++++++++++++- 7 files changed, 377 insertions(+), 12 deletions(-) create mode 100644 haiku_rag_slim/haiku/rag/embeddings/voyageai.py create mode 100644 tests/cassettes/test_embedder/test_voyage_embed_text_and_image_end_to_end.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 33bcdd70..d36fe90e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - `haiku-rag doctor` checks a database for consistency (orphaned chunks/items, chunk-less documents classified by content and embedder modality, dangling `doc_item_refs`, vector-dimension mismatch, unembedded chunks, missing picture data, settings/embedding drift, pending migrations, vector-index coverage, provider API keys) and probes configured provider endpoints (Ollama `/api/tags` with model presence, docling-serve `/health`, OpenAI-compatible/vLLM `/models`); exits 1 when any check fails. - `embeddings.model.multimodal` (bool, default false) gates image embedding; `supports_images` derives from it instead of the provider name. +- VoyageAI multimodal embedder (`provider: voyageai`, `multimodal: true`, e.g. `voyage-multimodal-3`) embedding text and pictures into a shared vector space. ### Changed diff --git a/haiku_rag_slim/haiku/rag/client/search.py b/haiku_rag_slim/haiku/rag/client/search.py index 20a867a2..a90ddcbc 100644 --- a/haiku_rag_slim/haiku/rag/client/search.py +++ b/haiku_rag_slim/haiku/rag/client/search.py @@ -58,8 +58,9 @@ async def search( embedder = client.embedder if not embedder.supports_images: raise ValueError( - "Image queries require a multimodal embedder. Configure " - "provider='vllm' (or another image-capable provider)." + "Image queries require a multimodal embedder. Set " + "embeddings.model.multimodal: true on a vllm, voyageai, or cohere " + "model." ) query_vector = await embedder.embed_image(query) chunk_results = await client.chunk_repository.search( diff --git a/haiku_rag_slim/haiku/rag/embeddings/__init__.py b/haiku_rag_slim/haiku/rag/embeddings/__init__.py index 21115575..ef6c8894 100644 --- a/haiku_rag_slim/haiku/rag/embeddings/__init__.py +++ b/haiku_rag_slim/haiku/rag/embeddings/__init__.py @@ -20,8 +20,9 @@ ImageInput = "bytes | PILImage.Image" class EmbedderWrapper: """Wrapper around pydantic-ai Embedder with explicit query/document methods. - Subclasses pass ``supports_images=True`` and override the image methods when - the underlying model can encode pictures into the same vector space. + Subclasses that can encode pictures into the same vector space as text either + set the ``supports_images`` class attribute or pass ``supports_images=True``, + and override the image methods. """ supports_images: bool = False @@ -30,11 +31,12 @@ class EmbedderWrapper: self, embedder: Embedder | None, vector_dim: int, - supports_images: bool = False, + supports_images: bool | None = None, ): self._embedder = embedder self._vector_dim = vector_dim - self.supports_images = supports_images + if supports_images is not None: + self.supports_images = supports_images @property def vector_dim(self) -> int: @@ -62,8 +64,8 @@ class EmbedderWrapper: ``messages`` superset. Callers loop when they need many. """ raise NotImplementedError( - f"{type(self).__name__} does not support image embedding. " - "Configure a multimodal provider (e.g. provider='vllm')." + f"{type(self).__name__} does not support image embedding. Set " + "embeddings.model.multimodal: true on a vllm, voyageai, or cohere model." ) @@ -124,8 +126,9 @@ async def embed_chunks( if picture_chunks: if not embedder.supports_images: raise ValueError( - "Picture chunks require a multimodal embedder. Configure " - "provider='vllm', or omit picture chunks." + "Picture chunks require a multimodal embedder. Set " + "embeddings.model.multimodal: true on a vllm, voyageai, or cohere " + "model, or omit picture chunks." ) for chunk in picture_chunks: picture_embeddings.append(await embedder.embed_image(chunk._picture_data)) @@ -238,4 +241,12 @@ def _get_multimodal_embedder( model_name, vector_dim, base_url=base_url, supports_images=True ) - raise ValueError(f"Provider '{provider}' does not support multimodal embedding.") + if provider == "voyageai": + from haiku.rag.embeddings.voyageai import VoyageMultimodalEmbedder + + return VoyageMultimodalEmbedder(model_name, vector_dim) + + raise ValueError( + f"Provider '{provider}' does not support multimodal embedding. Set " + "embeddings.model.multimodal: true on a vllm, voyageai, or cohere model." + ) diff --git a/haiku_rag_slim/haiku/rag/embeddings/vllm.py b/haiku_rag_slim/haiku/rag/embeddings/vllm.py index 00023bed..b8e81ba7 100644 --- a/haiku_rag_slim/haiku/rag/embeddings/vllm.py +++ b/haiku_rag_slim/haiku/rag/embeddings/vllm.py @@ -100,6 +100,11 @@ class VLLMMultimodalEmbedder(EmbedderWrapper): ) async def embed_image(self, image: "bytes | PILImage.Image") -> list[float]: + if not self.supports_images: + raise NotImplementedError( + "This vLLM embedder is text-only. Set " + "embeddings.model.multimodal: true to embed images." + ) rows = await self._post( { "model": self._model_name, diff --git a/haiku_rag_slim/haiku/rag/embeddings/voyageai.py b/haiku_rag_slim/haiku/rag/embeddings/voyageai.py new file mode 100644 index 00000000..e908fa89 --- /dev/null +++ b/haiku_rag_slim/haiku/rag/embeddings/voyageai.py @@ -0,0 +1,68 @@ +"""Multimodal embedder backed by VoyageAI's ``multimodal_embed`` endpoint. + +``voyage-multimodal-3`` maps text and images into a shared vector space. Text is +embedded as single-element content lists; images are passed as ``PIL.Image`` +objects (the SDK accepts them directly). The API key is read from the +environment (``VOYAGE_API_KEY``) like the text-only Voyage path. +""" + +import io +from typing import TYPE_CHECKING + +from haiku.rag.embeddings import EmbedderWrapper + +if TYPE_CHECKING: + from PIL import Image as PILImage + + +class VoyageMultimodalEmbedder(EmbedderWrapper): + def __init__( + self, + model_name: str, + vector_dim: int, + api_key: str | None = None, + ): + super().__init__(embedder=None, vector_dim=vector_dim, supports_images=True) + import voyageai + + self._model_name = model_name + self._client = voyageai.AsyncClient(api_key=api_key) + + async def embed_query(self, text: str) -> list[float]: + result = await self._client.multimodal_embed( + inputs=[[text]], + model=self._model_name, + input_type="query", + output_dimension=self._vector_dim, + ) + return list(result.embeddings[0]) + + async def embed_documents(self, texts: list[str]) -> list[list[float]]: + if not texts: + return [] + result = await self._client.multimodal_embed( + inputs=[[text] for text in texts], + model=self._model_name, + input_type="document", + output_dimension=self._vector_dim, + ) + return [list(e) for e in result.embeddings] + + async def embed_image(self, image: "bytes | PILImage.Image") -> list[float]: + result = await self._client.multimodal_embed( + inputs=[[_to_pil(image)]], + model=self._model_name, + input_type="document", + output_dimension=self._vector_dim, + ) + return list(result.embeddings[0]) + + +def _to_pil(image: "bytes | PILImage.Image") -> "PILImage.Image": + from PIL import Image as PILImageModule + + if isinstance(image, bytes): + return PILImageModule.open(io.BytesIO(image)) + if isinstance(image, PILImageModule.Image): + return image + raise TypeError(f"Unsupported image type: {type(image)!r}") diff --git a/tests/cassettes/test_embedder/test_voyage_embed_text_and_image_end_to_end.yaml b/tests/cassettes/test_embedder/test_voyage_embed_text_and_image_end_to_end.yaml new file mode 100644 index 00000000..bb8c7d3d --- /dev/null +++ b/tests/cassettes/test_embedder/test_voyage_embed_text_and_image_end_to_end.yaml @@ -0,0 +1,132 @@ +interactions: +- request: + headers: + content-type: + - application/json + method: post + parsed_body: + encoding_format: base64 + input_type: query + inputs: + - content: + - text: a photo of a red square + type: text + model: voyage-multimodal-3 + output_dimension: 1024 + output_dtype: null + truncation: true + uri: https://api.voyageai.com/v1/multimodalembeddings + response: + headers: + alt-svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + content-length: + - '5657' + content-type: + - application/json + parsed_body: + data: + - embedding: AABtOwAAC7wAAKu8AAC9PAAAFb0AADE8AAA/vAAAbz0AAHc8AAALPQAAsTwAAJw8AABhPQAAfL0AAIW9AADROwAAhbwAAHk9AACxPAAALbwAADm9AACpOgAA9DsAAAW9AABVPQAAP70AAIq9AAClvAAA/zsAAIA8AAA7vQAA9LwAACy8AADjPAAAMboAAME8AAAMPQAADzsAABW9AABPugAAvLwAAME8AAAiPQAAaDsAAFc8AADdOgAAgL0AAGC9AAD1uwAAtLwAAAu9AAAnvQAAt7wAAAu9AAClPAAAa7sAALW7AADVPAAADz0AADG5AAAxOwAA/bwAAMw8AACDvQAA/7wAAM27AACauwAAx7sAAOi4AAAAvAAASb0AAPy6AACpvAAAJT0AAJe9AAAHvAAAaDwAAJq8AAC4uwAAPz0AAL+8AACqPAAAAz0AAHy8AADVuwAAl70AALE8AAAfvAAAmrsAALi7AADcvAAADTwAAOM8AACSPAAAlrwAAGg8AADLugAAor0AAAG9AADEvAAAN7wAALs7AACgvQAAfLwAAAU8AADpvAAAGrsAAHk9AABzPAAASDwAAL28AACjvAAASTsAAK88AAAxPAAAeD0AAJu8AAAauwAAJTsAAES9AADIOQAApTsAAAG9AAA4vQAAgzwAAA+8AADvPAAAbz0AAEG9AACBOwAAgrwAAMG7AADvOwAABT0AAE29AACVuwAAlT0AAHG6AAC5vAAAGb0AAKU8AAD5vAAAHbwAAKM8AABjPAAAszsAANE9AADtuwAAwbwAAAo8AABfPQAAAzwAABa9AACBPQAAf7sAAAk9AAA/PQAAoDsAAPO8AAAHvQAAmjwAALe8AAARvQAAETwAAD09AAACvQAAWD0AAGM9AAAcvQAASLwAAAC9AAC1PQAAwDsAAJy7AACcvAAAU70AAM+8AACXuwAAnzwAANg8AAAevQAAoD0AAFc9AAAfvQAAZ70AAIq7AAAnvAAAuzsAAG28AAAwvAAAIDwAAA27AAAsPAAAc70AACc9AAASvQAAhDwAAKM8AAAUvQAAyDwAAJk6AAD5PAAA+DwAAAs9AACGvQAAbz0AACk7AACFPQAA7LsAAA+8AADsvAAA97sAAGy8AAAfvQAAAroAAI26AACFOwAA7zwAAGc9AABNvAAAojsAAAS8AABnvAAAGToAAOk7AAA3vAAAizwAAMM7AABRPAAAmTsAALS9AAA4PAAAjLwAABI9AAAfOwAAczwAAA28AABYuwAAX7wAACI8AACKvAAA+bwAALM8AADDvAAAGzwAAAk9AAARvAAAYzsAAGE8AAALOwAAQ7sAAHQ8AAAjPQAATL0AADg5AADLPAAABrwAABe8AAA3vQAACT0AAFQ9AAAWPAAAKj0AAKS9AAB8PQAAfTwAAL08AABvPQAAOLsAAFw7AACqPAAAm7sAANW8AADRvAAAozwAAGe9AADnuwAAnzwAABe8AACzPAAAGjsAAMM8AACrPAAAEb0AABc9AAB4PAAAgbwAAAG9AAA0vAAAkLwAACE9AACFuwAAJbkAAHk6AADBOgAAhDwAAAk8AABoPQAAhr0AAAy7AAAvvQAAfz0AAKq8AAAaPQAA1LsAAJY8AAAaPQAAXL0AACI7AAAwPQAAPLoAAGA9AADNvAAAC70AAI+8AAAfOQAALD0AAPG8AAA5PQAAi7wAAI29AAAnvQAAPbwAAHW8AACpPAAAP7wAAB68AAAMvQAAzToAACO8AAAXPAAAw7sAAAI9AADDPAAAybwAAOO8AACVuwAAwDkAAEO9AAAhPQAACz0AAKW8AAAvvQAAx7sAABu8AABAPQAAHz0AAGg8AADNvAAARDwAAOA8AAAGPQAAuDsAAH09AAAJvQAA/LkAAIy7AADtPAAAOb0AAGe8AACeOwAAvbwAAEw7AACOuwAAnzwAAO07AAASvAAAlbwAAAq9AACKuwAAGjwAAFS8AAACPQAAND0AAGi9AACnOwAANzsAACG8AAALPQAAJToAAGO8AACiPAAAi7wAANy7AADYvAAAML0AANc7AACKPAAAM7sAAIe8AABMuwAAz7sAAMy8AAA5vQAAlrsAACe9AAAPvQAAibsAAOQ8AAApvQAAtLsAAIm8AAAavQAA9TsAAGC9AAAsPQAALD0AAAc9AAATvAAA8bwAAOG4AABoPAAABTwAAJu9AABJvQAAlbwAABK9AADcPAAAdTwAAMG8AABRPQAA4DsAANQ8AACnvAAABDwAAB68AADnOwAAnLwAAJ28AAAJPQAAQz0AAIe9AACUOwAAQ7wAAIE9AAA7PAAAATsAAAe9AABJPAAA3LwAABo9AADfOwAA9bwAAGM9AABfPAAA4zwAAIW7AAAzPQAAi70AAII9AAD/vAAAybwAABI8AAAgPQAAlLwAACu8AACPvAAA/bwAAHG9AADIPAAAor0AAA49AAAqPAAAHLwAAC29AABBvQAAHz0AAIM8AAC0uwAAXTwAAEe9AABcOgAAbL0AAKu8AABnPAAAhrwAAFU8AACzPAAA2DwAAMs8AACpPAAAhDwAAAG9AABxPAAAVT0AAKw8AADfPAAA6bsAAFQ8AAA8vAAAM70AAP08AACPuwAAMb0AAIC9AADHPAAAS7sAAOE8AADJPAAA+bwAAJk8AAA8PQAAIrwAAFQ7AAAHPAAAHzwAANW8AAAGPQAAG7sAAGM8AADXvAAAUz0AAIk6AAAKOwAAt7sAANw7AACwOgAAqTwAANw8AACBuwAAszwAAHc8AACBPAAA/zwAAKE7AAB3PQAANTsAACW8AADBvAAAK70AAAs8AAAbugAA37wAAGi7AAA0PAAAjzsAAIU8AAB/vQAALb0AANO7AAApvQAAAT0AANi8AADBuwAAAz0AAO28AACDvQAA/TsAAK08AABdPQAARz0AAMe8AAA8PQAAQLwAAHM7AACtPQAASLsAAOA8AADMvAAALbsAACA9AAACvAAAkLwAAE08AAAZuwAApzwAAOg8AACGvAAAB70AALM8AAB8ugAAV7wAAKa9AADZPAAAXzwAAIw8AACPPQAAwzwAAIy8AAC9vAAASTwAAB+9AADXOwAAI70AADQ8AAA0vQAAxDwAAPM8AAALPQAA2b0AALO8AACePAAAFb0AADm7AAAHvQAAYb0AAKE8AAA0PAAALDwAACu9AADTvAAARLwAAJI8AAAUPQAA47sAAFm8AAAJPQAATTwAAAu9AABVvAAAlDsAAM+8AAA5PQAA77sAAGC9AAC9OwAAGz0AAKS8AABpPAAAWD0AADQ9AADoOwAAdzwAAMu7AACJvQAAbDwAAEC8AABfPQAA/LwAADW9AACEvQAAED0AACE8AACfvQAAlbwAADC9AAAEvQAACr0AAAm9AABJPQAAFj0AAMg8AABzvQAAVb0AAIu8AADoPAAAUbwAAD09AAB9vAAAS70AAGC8AABnOgAA17wAAEG9AAC8ugAA3TwAAAu9AABtvQAAt7wAAMe8AABYvQAAH7wAALW7AAAwPQAAAbwAAIG6AABMvQAAobsAAHi9AACmvQAAVL0AAM88AAARvQAAkbsAAD29AAB8PAAA0zoAAJe7AADPPAAATDwAADU8AAAsPAAAiTwAALW7AAAbOwAAkr0AAHw8AACfOgAAkb0AAM+8AAB8PAAAxLwAAAm8AAAxPQAATzwAAEQ8AAC4vAAAJj0AACw8AACQPAAAAT0AACK9AAAsPQAAo7wAAAs8AAAkOwAACjwAAI+8AADPvAAAuzsAABe8AADzPAAAKrsAAHc9AAD4PAAA4zwAAEE9AAAPPQAAfL0AAJu8AAATvAAAPToAAJ+9AABRPQAAYb0AAHM8AACxvAAA47wAADy9AACauwAAJLwAAMu7AACzuwAA6DsAAA+7AAA8vAAA4bwAALu8AAB4PAAAS7wAAEw9AACLvAAA17wAADk9AAChvAAAhjwAAAO9AADVOwAAAjsAAHw5AAApvQAA4LwAAOe8AAAFvQAASL0AABq9AACXPAAADL0AAEk9AAC3vAAA3DwAAFc7AACJvAAAdzwAAEC7AAAZvAAAHbwAAMk8AACDPAAA2LwAAL88AACWPAAA1TwAAFU8AAC0vAAABz0AAAE8AAC/PAAAVDsAAJ88AAA5PQAAEbwAADM9AAAbPAAAx7wAADM9AABpPQAANz0AAP86AABrvAAAF70AAKI8AACKPQAAAr0AAAk9AADJuwAAeDwAAIU9AAAkuwAAqTwAAAG9AADTvAAAWTwAALW8AAC3PAAA/TsAAEc8AADdvAAA7zsAAEQ9AACzuwAAL7sAAC08AACTPAAAITsAAEA9AACBvQAA0bwAADi9AADTPAAAHL0AALM7AAClvAAABj0AAAW9AAAMPQAAOD0AAJG8AACQPAAA77wAAM09AADdvAAAvzwAAIS8AABTPQAAw7wAAFw8AACpvAAAEL0AAEA8AACvPAAA1DwAAF07AADBPAAAzTwAALs8AABtvQAAobwAAH+8AABZvQAAqjsAAC09AADAvAAA4bsAAAS8AABkuwAAHLwAAD89AABnuwAAkr0AAHc7AAB0vAAAorwAACo9AACGOwAAsLwAAGu7AAADvQAAAL0AAIm8AAARvQAAHb0AAGS8AAC5uwAA5zwAALc5AAAvPAAAaL0AAPe7AACzPAAAJT0AAJY8AAAVPQAAjzwAAHS8AAAivQAAQLwAADc9AAAZPQAAlb0AAOs8AAAdPAAAu7wAANE5AACvPAAArzwAAGS8AADIPAAAfzwAAFW9AAAXvQAAOL0AAKa8AABpvAAAJr0AALO6AAAFPAAAZLwAAAO8AAB9vQAA2TwAAFU6AACwvAAAP7wAAMG8AAAMPQAAKbsAAAm8AADLPAAAKbwAADE9AADnvAAAF70AAOS8AADPPAAA4DsAAJ+8AACZvAAAfD0AAIk7AABjOwAARD0AAKc8AAA3PQAAqrwAAIG7AABRPAAAF7sAADk9AADHvAAAfbwAANc8AACGvQAAuDwAAIm8AAD0vAAAnrsAAJG8AADcOwAAAbwAAKM9AAB0vAAAGTwAAAy7AAAgPQAAQb0AAJW8AAAZvQAANz0AAJK4AADBPAAAxzwAAIS8AAAsPQAAdTwAAKs8AAD9OAAA1zwAAK08AAAPvQAAAb0AALG8AADUPAAAED0AAMS8AAA8PAAAFT0AAAW7AACVPAAA1DoAAAe8AACtvAAAlLwAAIy9AACbuwAAjbwAAJA6AAAxuwAAuTsAAAu9AADZPAAAPzwAACI9AAADPQAA8TwAAMO8AACKugAAyDwAAKq6AABHPQAAprsAAEk7AACNvQAAEb0AAPS8AAAgvAAAszwAAAS9AACLvAAAcbwAACw9AACRuwAAlrwAAMu9AAAePQAAYL0AAP+8AADDugAAl70AACG9AAD5uwAA+bwAAK28AAC9vA== + index: 0 + object: embedding + text: null + model: voyage-multimodal-3 + object: list + usage: + image_pixels: 0 + text_tokens: 5 + total_tokens: 5 + video_pixels: 0 + status: + code: 200 + message: OK +- request: + headers: + content-type: + - application/json + method: post + parsed_body: + encoding_format: base64 + input_type: document + inputs: + - content: + - text: hello world + type: text + - content: + - text: another doc + type: text + model: voyage-multimodal-3 + output_dimension: 1024 + output_dtype: null + truncation: true + uri: https://api.voyageai.com/v1/multimodalembeddings + response: + headers: + alt-svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + content-length: + - '11181' + content-type: + - application/json + parsed_body: + data: + - embedding: AAAePQAAEb0AAPs8AABavAAAHL0AALK8AAAMvQAAtbsAAAQ8AABSOwAA4LwAAJA8AAChPQAA2L0AADS8AAAePQAADj0AAFY9AAAlPQAAvbwAAIK8AAA7vAAAFb0AAOS8AAD/OwAAmbwAAGC8AAA3vAAAKb0AAB08AAA3vQAAKbwAACk8AAA3PAAAPL0AAA68AADLvAAA1bsAACK9AABWOwAA6bsAAAm9AADzPAAA2rwAAHi9AAAwvQAA6TwAAES9AACZvAAAsjsAAK28AACKvAAAQDwAABW8AAALvAAAJLwAAJS7AABhvAAAhDwAAPA8AAAOvQAADDwAAIQ6AACEPAAAMr0AAD88AAABPAAA6zwAAAO9AAA3vQAAQr0AAN+7AADmvAAATbwAAJq8AADKvAAACL0AAD89AACYvAAATz0AAAI7AACEPAAANzwAANe8AACTPQAAtbwAAIo9AADLuwAAhT0AADU8AACWvAAAGD0AALk7AADAvAAArbsAAAC9AAAauwAA6bwAAPS8AAAhvQAAHz0AANW8AAASvAAAszsAAMK8AAA6vAAAMrwAACU9AACgOgAAST0AALO8AACGvAAAWbwAABg9AAAnOwAA2TwAAAE9AAAvvQAAlLsAAIm9AAALvAAABD0AACq9AACjPAAAlLkAAPq7AAABPAAAqTwAALO9AAAjPAAA7zwAALU8AAAmPQAACD0AAB26AADpOgAA+zsAALg8AACqvAAA7rwAABm8AADQOwAAEzsAAG89AAD+OwAA/TwAAIw9AAAxPQAA67oAAHY8AAClPAAAEb0AAB69AACZPAAAEj0AAFS9AACVvAAAFjwAACQ7AACeOgAAXDwAABG9AACcOwAArTwAAKE8AAAMPAAAvjwAABU9AACzvAAAvLwAAJm8AABHPQAAobwAAL08AADnuwAAM7wAAHs7AADiuwAAkTsAADU9AACMvQAAczwAAIQ8AAAUvQAAjLwAAC09AAC0vAAAIL0AAKm7AADgvAAA67wAABS6AACFPQAAL7wAANY8AAD4vAAADz0AAMs8AACjPAAAaTwAAAy9AAAgPQAASzwAAIy7AAAMvQAArzwAAKQ8AACFPQAAj7sAABa9AAC4vAAAfbwAAIU7AAAWvQAAmLwAAFO8AADGuwAAprsAAFY9AACXvQAAVzwAAKC8AACEugAAgzwAAMg8AACtvAAA/TwAADw9AADouwAAcT0AAL28AADUPAAAursAAII7AADMPAAACDwAAAw8AACmvQAA6LwAAIs8AACfvAAAwjwAAP47AACrOwAAhDwAANC5AAAyvAAA8DwAAG49AAAvPQAA4bwAAFc7AAC1uwAA1LwAAIE9AAD/PAAAljwAAF88AAAOvQAAA7wAAJ68AAAUvAAAyzwAAKy8AADnPAAA+DwAABw9AABHPQAAYzwAAA89AAAOvQAAAzwAAPS7AABCvQAAC7wAADC9AADXvAAAGj0AABK7AACEuwAASjwAAIA8AAAPPQAAkTsAANq7AAAGvQAATTwAADI8AACwOwAARzwAAA+9AADFPAAAOLwAAIo7AACPOgAA3LwAAJk6AACVPQAAdb0AAA69AABjvAAAkj0AAFW8AABTvAAALj0AAOU7AAAzPQAAlbwAAPc8AABAPQAAMr0AAAw9AACRvQAAK7wAALo8AADouwAAfD0AAPU7AAAtPAAAsboAAJ48AAC0vAAAorsAAIA7AAAxPAAAR70AABg8AACVvAAA6LwAAJg7AADBPAAAnzwAAFY9AACjPAAAobwAAEW8AABIvQAAijwAABm9AABTPQAA6LwAAFq8AAABuwAAy70AAGi7AAB4OwAAJT0AABY7AACcvQAANzkAALw8AADOPAAAgbsAAIq7AACvvAAAR70AAGk7AACFvQAAnrwAADE7AAAyvAAAvLsAABw9AAAMPAAA+rsAAHM7AABivAAAdj0AAE28AACluwAA2LwAAHQ9AACFPQAA2DsAAHy9AADJuwAAELwAAGS8AADIPAAAprwAAJw8AACPuwAAM7wAAL67AAAmPQAAH7wAAIE8AAAcPQAAqTsAAIO9AAASuwAAwLwAAIi8AAAMvQAAOjsAAMe7AADUuwAAGL0AADo8AADuvAAAkzwAAKu9AAAEuwAAAzwAAJy5AAC8PQAAujsAANQ8AADEvAAAqrsAAKW8AAA7PAAAhTwAAOS9AAA8vQAAQrsAAMK8AABKvAAABD0AAAi9AACsvAAAhDwAAJq7AADxvAAAObwAAN87AAAwvQAAurwAAIY8AAA4vAAA3DgAACG9AABhPAAATrsAAMg8AAACPQAAlrwAAEW8AADcvAAA67sAAFy5AACXvAAA3bwAAJ48AACJOwAA9TwAAEE8AAArvAAAhb0AAO06AADLvAAArDwAAA47AADXPAAAjjwAAEq8AADtOgAALjwAAC68AADZuwAAVboAAAk9AABvPAAAi7wAAL87AAB7PAAAPz0AADm8AAAxuwAAJL0AAOS6AABlPQAAX70AAMC8AAC6uwAA2zwAAPQ8AABQPQAABT0AALs8AABQPQAAAjwAAMm8AAAFvAAATzwAADo9AADKPAAAAT0AALI7AAATPQAAJ70AAAG9AADBPAAAI70AACq9AAAvvQAAYDwAALo8AAC8uQAAnjwAAB88AAAjPQAAgb0AAHQ9AAD3PAAAM70AADe9AAD8OwAANT0AAE28AADBvAAApjwAAG67AABhvQAAcjwAAAw9AACmvAAAS7wAABw9AAAavQAA9rsAABK7AAAMvQAABj0AACO9AACCPQAA8TwAAPw8AAABPAAAy7wAAKE8AABUvAAAdDwAAIw8AACZOwAAF70AABi9AAC9vAAAhrwAAMy8AACzvAAApj0AAFC8AAAdvAAA8TwAAN+8AAArvAAApLoAAEE9AADHPAAAujwAACC7AACMPAAA7jsAAA49AABMPQAACj0AAF07AACRvAAAZroAAOo7AABlOwAAv7wAAEK8AABTvAAA2jwAAEa9AAAMuwAAHDgAAMI8AAAEvAAAK70AACC9AAAlPQAAXD0AANy8AABxPQAAhjwAAFO8AAAwvQAAXzwAAOe5AABMvAAAFboAAIS8AAAPvAAAezoAAKm7AACmOwAA6bwAAKE8AABkvAAAPL0AAHQ8AAD2vAAAOr0AABg9AADWvAAAprwAAFu8AAAxuwAAKbwAAAG9AAAnvQAAMTwAAG+7AADXPAAAUzwAAK68AAAJvQAAKj0AANi8AADZPAAATzwAALK8AACSPQAAzrwAADq9AAAYvAAAwjwAANg8AAAEvAAAAzwAADE9AAAPPQAAYj0AAD89AAA3PQAAib0AANI8AABgPAAAJz0AANY8AAA4vQAAMDwAAI+9AAAwvQAAOr0AAIo8AAByPAAAtDwAAIA9AADbvAAAVTwAAH08AADCvAAAwjwAAAO9AACsvQAA2roAAN08AACQOgAADDwAAAQ9AADlOwAATT0AAJe8AABBvQAAOLwAALe7AABkvQAAorwAAB29AAC7PAAAMr0AACY9AAAMOwAA2LwAAIe9AAAgvQAAdrwAAI89AABNvQAAIDwAAPy8AAATvQAAobwAAPO7AAAevAAAh7sAAKG7AACcuwAA1LwAAGk7AAAPPQAAVLwAABa8AACOvAAAnrwAAJG9AAAePAAAJr0AAB08AAAFPQAABT0AACu9AABnPAAAlrwAACY9AACRvAAAxjwAACW8AAD/uwAAeDsAAKQ8AABVPQAAtzwAANs8AADaugAAmL0AACG9AAD0vAAACjkAALq4AAAqPQAA9zsAAG08AABCvAAAIr0AAPq8AADIvAAATTsAAOW7AADCPAAA87wAACM9AAD9vAAAEjsAAP86AAAjvQAAPbsAAEQ8AAD3uwAAobsAABc8AAAlPAAAC7wAAGA8AAAEvQAAEL0AAC87AAArvAAALj0AAJW7AACOvAAAg70AAJ88AAAlPAAAf7wAAJA8AAACvQAALLwAALq8AAAXvQAAt7wAAGO9AADJuwAAoDwAAOE8AACsOwAAEz0AAPG8AACcvAAA7rsAAIo9AABZvQAA4TwAAHA9AADTPAAAW7sAAFM7AAByvQAAhTsAAJG8AAByPAAAJj0AAJK8AAB+PAAAQLwAAIc7AAAgvAAAJr0AAAI9AABXPAAAjj0AAA88AABEPQAANT0AAPU8AAAcPQAAODwAABk8AACRvAAA/LwAABU9AAC0vAAAojwAAKc8AADXuwAAJD0AADe8AADPuwAAybwAABm9AABgPAAAxjsAAEs8AAArvQAAQrwAAME8AADjuwAAGD0AAD89AAAsPAAAVzwAAM09AAAZvQAAEz0AAAq9AAAuvQAASDsAAA49AAAvPAAACbwAAA88AAC5vAAAP7sAACW9AACKPQAACzsAAFA9AAAavQAApjoAADC6AAB7PQAA9zwAAKC8AAB7ugAA6LwAAAU9AAA8PAAA8zwAAP48AACUPAAAJL0AAEG9AAD0vAAAlToAAOA5AACJvQAAojwAAHG7AABoOwAAnrwAAJC7AACevAAAhbwAADo9AACBuwAA9rwAAOo8AAArvQAAnLwAAHc9AACWPAAAcjwAAOu8AACRPAAA2zsAAFi8AAA5OwAAQL0AAEi8AADdvAAAFbwAADS7AADZPAAAHT0AAGU8AAAzOwAAvD0AANc8AAATPAAAWT0AAB68AAAqvQAA4TwAAMW8AADWPAAAkL0AALK8AAA4PQAAZjwAABC9AAD2uwAAzjwAAHi9AAABvAAAzjwAAL07AADhvAAAtLwAAJG8AABEugAASz0AABA8AAD6PAAA9bwAAF29AADlvAAARbwAAEe9AAAcvQAATzwAAHO9AACjOwAAaDwAAJ47AAChvAAAO7wAABA9AAD9vAAA6DsAAA68AACzOwAAprwAAEq9AACJvQAAxzwAADu8AACrvAAAsTwAADM7AACAvAAAXL0AAAY9AAAhPQAAvjwAAKe8AABVOwAAnTwAAFs9AACPvQAAoD0AAJw7AABQvAAAyrsAAHy9AACAvAAAhLwAAIQ9AACFPAAA17wAAGq9AAAgPQAAoTwAABW8AAD3vAAAcjwAAOM8AAAuPQAAvbwAAAO9AAAiPQAABLsAAHA9AABUPAAAIT0AAP48AABgvQAAsboAAHS7AAArPQAAEDwAAMC8AACEvAAAnzwAAAw9AAClPAAATjwAAH28AADWvAAA3DsAAIS8AACQvAAA4rwAAFc8AAALvQAAe7sAAMi8AAAkvQAAGj0AANI9AAA1OwAAPT0AAMa7AAAKPAAAfzwAAFu9AAAWPQAAAL0AAAm8AABZvQAAl7wAABe9AACwvAAAczwAAEa9AAABvQAAvTwAAEQ9AAC8PAAAlTwAAFi9AADZPAAAJr0AABe9AAAsvQAAkjwAAOI8AAAcPQAATrsAAIS9AADgPQ== + index: 0 + object: embedding + text: null + - embedding: AABLPAAA8bwAAOe7AABtvAAAE7sAADm8AAAfvQAAjrsAANK7AAACPQAAGjwAAM87AACXPQAAoL0AAB+9AABVPQAAzDkAAOg8AAD4PAAANDsAAMe6AAAsvQAAPb0AAEy9AACGvAAAPzsAAHs8AAA2vQAAsLwAAJI7AACovAAAgbsAAOw8AADgPAAAf70AAOG8AADYvAAAxDsAAJA8AAD4vAAATrwAAGe9AAALPQAAjbwAAOc7AAALvQAARzwAAD+9AAC5uwAAdrwAABK9AAAIvQAAATsAAFy9AAAzPAAAWDsAADi9AAAqvQAAcD0AADA9AAA9vQAA+ToAALy7AAAAvQAA8LwAAMc8AADZPAAAkDwAACW9AACAvAAACbwAAOS8AAAcvAAAB7oAADC9AADMuwAAzzwAADo9AACLOgAAqDwAAPK8AAC8vAAANDwAACq7AADkPAAAP7wAAG09AADbvAAAPT0AAAk9AABVvQAAIrwAABg7AAA6vQAAErsAANi8AABFvAAAF70AAMq8AAAJvQAAIT0AAAu9AAByvQAA57wAAGU7AABsvQAAODwAAJI8AAC9vAAANT0AAFc7AACFPAAAsbwAAHU9AABUOwAAnTwAAJM8AAA4uwAAHT0AAAi9AADQvAAAz7sAAEK9AAABvQAAmTsAAFY8AABDvAAAgTwAAJy8AAA1PQAABzwAAHE8AACnPAAAmzwAAGo8AAAXvQAAKj0AABe8AADmPAAAqbwAAEU6AABQvAAAVLwAABQ9AACYvAAAFD0AAIA9AADOPAAAYTwAAJQ8AADevAAAkbsAALi8AABLOwAAnTwAAFu9AAAovAAACz0AAAq9AAAovQAAgj0AALO8AACgOwAA8jwAANQ8AADxOwAAizwAAFM8AABuvAAA8TsAAFA8AABePQAAQzsAAC09AADEvAAAd7wAAJw6AACwvAAArDwAABy8AACUvAAAijwAAIM9AAB+vAAA47wAAJE9AAAkvAAAeTwAANA7AACnPAAA2zwAAMQ8AAAtPQAAg7wAAEU9AACDvQAAQj0AACI9AABXOwAAHzwAANS8AAAUvAAAjLwAAOC7AAB6vAAAJz0AAKg8AADLPAAAszsAALm8AAALvQAAvzoAAKc8AAB5vAAAFL0AAM08AADIvAAAwjsAAGA9AAAxvQAA1jwAAH28AABOPQAAED0AAGY8AAAQvQAAYDwAAJc8AAApvAAAijwAAAu9AACDvAAAIjoAAMG8AAAdPQAAkDwAAJM8AAASvQAAMbsAAK48AABluwAAVT0AAOm8AABbvAAAMT0AAL28AAAFvQAAmTsAABI8AAD7OwAAVb0AAJq8AADDPAAAC70AALI8AACBOwAA1TwAAI88AACGvQAAgb0AAKw7AADquwAA3zwAAAa8AAAIuwAAVj0AAPQ8AAB/PQAAIjwAAKm8AADZuwAAVz0AAAe9AAAAvQAAdzwAADC9AAD0vAAAlzwAAJk8AACDuwAAuTwAAJq7AAAQPQAAerwAAJq7AAAWvQAAzrwAAMY7AACGOwAAR7wAAEq9AACZPQAA2zwAAA67AACBPAAAs7wAALM7AAC5PQAAar0AAAo8AAA0vAAAhj0AAJ+8AAAsPQAA4zwAAFI8AABvuwAAD7wAALa7AACGuwAAo7sAAJM9AABxvQAA4zoAAFW9AACCPAAAQbwAAIk7AABlPAAAGj0AANg8AACBvQAARDwAABa8AAACPQAANL0AADo9AACyOwAAKTwAAAm8AAB/PAAA1TwAAGQ9AABePAAAGb0AAK08AACYuwAA07kAABC9AABBPQAAbr0AANW7AABdOwAALL0AAJG8AABCuwAATD0AAAI8AACFvQAAsbwAAEU8AABGPQAAlzwAAFC8AACBOAAAMb0AALw8AABUvQAAubwAAGq8AADKvAAAs7wAAJs8AACDPAAAMT0AAHs5AAA+vQAAjDsAAPu7AACfvAAAxzwAAFY9AAA+PQAAnzwAADW9AAChPAAAMb0AAOq8AACOPAAADDwAACA9AABnuwAAlL0AABQ9AAA0vAAAzzsAAJS8AAChPQAAPj0AALg6AAD/vAAAIb0AAOC8AAABvQAAiL0AAJ+8AAC2OwAAM70AAFA9AACSvAAAh7wAAFO9AABQuwAArboAAPG8AACOPQAAVLwAABk8AADQOAAAgzwAAOq8AABtvQAAGD0AABe9AADPPAAAgrwAAAq9AADlPAAA9zsAALW9AADsvAAAKD0AADY9AAAFvQAAAjwAAPY8AAD+uwAA5rwAAG88AAABvQAAeT0AADE8AADgOwAAm7wAAFY9AAB2uwAA6LkAABm9AADyvAAALL0AAKq8AAB9uwAAKb0AACA8AAAivAAAlzwAAEs8AAAZPQAArL0AAAM9AAC+vAAAVTwAAMc5AACEPAAATbwAAF88AADQuwAAVT0AAOa8AAAJvAAAzrwAADU9AAAAvQAAq7wAAO47AAB+vQAAVD0AAMq8AAB5OwAAubwAAOM8AACKPQAAfb0AAFm8AAD2PAAAw7wAAOU7AAD2PAAApDwAAOQ8AAADPQAAF7wAAMK6AACxPAAAsjwAALs8AACLOwAAhzwAAOY7AABUPAAAVb0AAN48AAD1PAAAFr0AAJS9AAC1uQAAubwAAKQ7AABGPQAAz7sAAFS7AAABvAAAIrwAAE09AADbPAAAILwAABq9AAAuPQAAiTwAAAC9AAB4vAAALD0AAPg7AAArvQAAhjwAAM48AAAXvAAARL0AABM6AADjvAAAm7wAAPU8AAA2vQAABj0AABq9AAAdPQAAzDwAAA89AABEvQAABbsAAIM9AACpPAAAbTwAALg8AAC6uwAAFLwAABs8AADQPAAA27wAADO9AABjvAAA1D0AABC9AAAZvAAAOj0AACq7AACpOwAAebwAAKE8AAC8ugAAAz0AAJK9AAAZPQAAhbwAACI9AACMPQAAjDwAAFA8AADQvAAAEbsAAII8AAAGPAAAGbwAAPS7AACGvAAAKz0AAHW8AACFvAAAYToAAE08AACPvQAAar0AAGy9AAD7PAAA3LsAAPq8AABHPQAA5TwAAFw6AABjvQAAjLkAAIQ8AABXPAAABz0AAIK7AADqOwAASD0AAAW9AAApvAAAZrwAAHI8AABkPAAA1LsAAAY9AAAjvAAAKrwAADM9AAAQvAAAAr0AAKi8AAADvQAAiDwAAMy6AACxvAAAVT0AALa7AADwPAAAxjwAAOo8AABYvQAAEj0AABy7AAACOwAAv7wAACu9AABCvAAACr0AAC28AADTPAAAyjwAACI9AAD2uwAA6TsAAHI8AABDPQAAqjwAAAi8AACPPQAAqL0AANW8AAAWvQAALD0AACM9AABNvQAAATwAAKK9AABgvQAAGr0AALo7AACFPAAA5jwAABQ8AACyvQAAEL0AAIC7AAA+PAAAvbwAAOQ8AAAkvQAAMr0AACM9AAC2PAAAibwAAEY8AACTOwAAvTwAAOa8AAClvQAAQrwAAJC8AACPvQAAYLsAAH07AACNvAAAg70AAEU9AAATvAAA3rwAAJm7AAA5vQAAkboAALk9AACLvAAAxrsAAFa8AADfuwAAXbwAANW8AACTOwAAsTwAAF47AACYuwAAGL0AAPY6AAAWPQAAvboAAMM8AAAHOwAAq7wAADS8AAB7vAAAeDwAAIA8AABBPAAAETwAAH28AACMPAAAY70AABE9AABUPQAAGDwAAFI6AAASvAAA1zwAAOE7AACsPAAAOD0AAOG8AABgvQAA+bwAAI67AAAYvQAAiLsAANw7AAA8PQAAuzwAAEE9AAAsvAAAG70AABA7AABEPAAAQbwAANC8AACBPAAAIbsAAJa8AACOvAAAODsAAGU8AAANvAAAmTsAAL08AADHvAAAOb0AAAG9AACXvAAAi70AAH07AABHvAAAsr0AAOc7AAAPPAAAcTwAAMq7AACtugAA+LwAAMM8AABNPQAAODwAAIC8AAA2vQAAmbwAABi9AAAYvQAAMLwAAMO8AAAcvQAAED0AAKC8AABVvQAA4LoAALI8AAAjPAAAEL0AAMU8AACHvQAAAbwAAOY8AAAxvAAAdToAAJM8AADqvAAAFzwAALE8AADMuwAAFrwAAD67AADEPAAAGr0AAL+7AAALvQAAar0AACo9AAAnOwAAursAAIE7AAAdvQAAhz0AAFO8AACIvAAAeDoAAHo8AAAOvAAAirsAADA9AAASvQAART0AAOa8AACYPAAAkDwAAA+9AAApvAAAVDwAAAy9AABdvAAAMj0AABq9AAAwvQAA7jsAAEY8AADpPAAARbwAAG08AABpPAAA0DsAAPs8AADyvAAAoDwAACu8AAAJvQAAeTwAAO87AABpOwAAzLsAAL+8AAAFPAAAkDwAAGC9AACrPQAAHL0AAAA9AADqvAAAibsAAIq9AACNPQAA3bsAANy8AAARvQAAQ70AAJk9AAAFPQAAkjwAAAc7AACFvAAA7rwAAHY8AAAgvAAAGbwAAMw8AACcvAAAxzwAACG8AAAMuwAAzzwAAL06AADEPAAA3LsAALo8AAAuugAAjL0AANA6AABIvQAAULwAACw9AAA7vAAAG7oAAKw8AACkOwAAoTsAAJq8AACFuwAAIL0AAOk8AACxvAAA2LwAAL28AAAEOwAAhTwAANw8AADuPAAAXj0AAHK8AACXPAAAGj0AAIs8AAAavQAAHD0AAIc8AAALPQAAdL0AAJ08AAA2OwAAZj0AADi9AAC0PAAAsbwAAKu8AAAluwAApLwAAAY8AAAfvQAAgbwAALG8AADfvAAAXDsAAIy8AAB7PQAAG70AAPu5AABDPAAAJLwAAIG7AAD4vAAAnDwAAEi9AAD1PAAAtLwAAC08AAB/vAAAEL0AAFI9AABNvAAAR7wAALS8AAD2uwAAtbsAAHG8AAAxvQAA+TwAALa8AABNvAAASj0AAP27AACkOwAALbwAAMw8AADwvAAANj0AANO7AABQPAAAtDwAACU9AABNvQAAgD0AAHi8AADqOgAAgzwAALW8AAAfOwAAg7wAAJA9AAA8vAAAsDwAADi9AAD9PQAAgjwAAPk7AAAFvQAAZ7oAACo8AACQPQAA5zsAAKq7AAB+PQAA1LwAAGw9AAAwPAAAoz0AANk7AAD3uwAAIL0AAE48AACUPQAAyzsAABA9AAA5OwAA5zwAADM9AAClvAAAhT0AABa8AAApOwAADz0AALq6AAASvAAAHLwAAKQ8AACYvAAAujwAAIM7AADxPAAAkDwAAE89AACwOwAA1DwAAKk8AACMuwAADT0AAIe9AABLPAAAl7sAAAc5AAABvQAApLsAALy7AABTOgAAiDwAALA8AABjvQAAGj0AAI49AADEPAAAS7wAAKk8AADTOwAATr0AAJu8AADnvAAATjwAAKM7AADsPAAA+DsAABS9AACzPA== + index: 1 + object: embedding + text: null + model: voyage-multimodal-3 + object: list + usage: + image_pixels: 0 + text_tokens: 2 + total_tokens: 2 + video_pixels: 0 + status: + code: 200 + message: OK +- request: + headers: + content-type: + - application/json + method: post + parsed_body: + encoding_format: base64 + input_type: document + inputs: + - content: + - image_base64: data:image/webp;base64,UklGRiIAAABXRUJQVlA4TBUAAAAvP8APAAcQ/Y/+B4CE8P+9FtH/1A8A + type: image_base64 + model: voyage-multimodal-3 + output_dimension: 1024 + output_dtype: null + truncation: true + uri: https://api.voyageai.com/v1/multimodalembeddings + response: + headers: + alt-svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + content-length: + - '5662' + content-type: + - application/json + parsed_body: + data: + - embedding: AADMPAAAIb0AABE9AADqOwAAvrwAAF29AADMPAAAGr0AALq8AABbPQAAvTsAALq7AAAnuwAAvrwAADA9AACCPQAA4bwAAPE8AADWuwAAerwAAFu7AAA3vQAAyLwAAEE8AACPvAAA+zwAAHi8AAAEvAAAmTwAALg8AACXuwAAabwAAF49AAAvvQAAvrwAABc9AADaOgAABj0AAPK8AACFvQAAKz0AAMc7AABavAAANT0AALw8AADdvAAAPzwAAPa8AABTPAAAqLwAAGg8AACIPAAAHD0AAB49AAA5vQAAQ7wAAI29AAAGvQAAqjwAAJ+8AACFOgAAXL0AAMi8AACHPAAAUr0AABc9AACBPAAAjjoAABk9AAA1vQAAPT0AAKk8AAAKPAAAQ70AABW9AAAhvQAAkTwAADS9AADrvAAAtjwAAJu8AAALPAAAZL0AAPu8AADzPAAA6rsAAKI9AABLvAAAKL0AAIo7AAC0uwAAPboAABS9AAApOwAAITwAAFQ8AAA2vQAAOLsAAJO9AAANvAAAPDwAAES9AADjvAAADz0AACY9AADevAAA1LwAAME8AAC4vAAAJrsAAC49AACvvAAAbL0AAG09AABrPAAAHDwAAIu9AACnvAAA6zwAAPY6AACAOgAAczoAAOU7AABVvQAA+7wAADk9AADpugAANj0AADe9AABMPQAAsDwAAPq8AACQPQAAQT0AAH08AAB5uwAAHDwAADg8AAAePAAApDwAAAK9AAApPQAA2rgAAKQ8AADavAAARDwAAHO8AADMPAAAnDsAANK8AAAxPQAAHjwAALa8AABRvQAASz0AAOm8AACrvQAAj7wAAA69AAA8vAAAkLwAAIU8AAAEvQAAWrwAAES8AABRvQAAYzwAAL+8AADbvAAAATwAAKE7AACrvAAATTwAACM9AACovAAAD70AAIK7AABNPQAAmrwAAHi8AAB3vQAA+zwAAFU9AADAPAAATDwAAKI9AACLOwAANr0AANs8AACVvQAADb0AAJw7AABkPQAANz0AAHM9AADUPAAAzTsAAJE8AADIOwAAWz0AAL27AACRvAAALb0AAE+9AAAmvQAAxTwAAD49AABBPQAAGbwAAPu7AAAJvAAAH70AAPa7AAAVPQAAobwAANI8AABlPQAAsDwAAGw8AAA6PAAAG7sAAFW8AACYPAAAyzsAAO08AABavAAAqLwAAPQ8AAANPQAAnzwAAI68AABTvAAAIjwAAL67AACJPQAAQTwAAC69AACFuwAATr0AAME8AACMvAAATj0AAPG8AACVvQAA+LsAAES9AADHPAAAQT0AAIM9AAAAvQAAnLwAAPC8AACpuwAAKDwAAGs9AABNPQAA2jwAACE9AADPvAAAg70AAIE8AACqOwAAR70AAK88AAD0uwAADjsAANq8AABoPQAAB7wAAFq8AAAHvQAAOLwAAMy8AADwPAAAe7oAAIC5AADgOwAA+LwAAGK9AABLPAAAKz0AALY8AADlvAAASD0AAAe6AAApvQAANL0AACA9AABjPQAAKbwAAKs6AAAAvQAApDwAACC9AABsPAAAI70AALq8AAASPQAASLoAABM9AAAqPQAArjwAAFk8AAC+uwAABjwAAEa5AAB+PAAAD7sAAJM7AACKugAAcLwAABI9AABRvQAAlL0AAO08AACMPAAA3rwAALC8AAAOuwAA9jwAAII8AAD/PAAAUjwAAIm7AACjvAAAT70AAKg9AAAjvAAArb0AAJK8AADwOwAA4DwAAGe8AACnvAAA57wAAI89AABwPAAATrwAANq8AAAZvQAAjTwAAKm8AACLOwAA3LwAAGu8AAAhvQAAWrwAAAc9AABRvQAAqzsAAEO8AACQPAAA4LsAAIK8AAD6uwAAG70AAMo8AAAUPQAAVLwAAOc8AABSvAAAWrkAADc9AABrPQAAdrwAALS8AADWOwAAOTwAAPI7AABFPQAA2TwAAH09AAAoPQAA2rwAAOi8AACNOwAADr0AACo9AACGvAAA/7sAAMG8AAASvAAAXrwAADE8AAAtvQAAaj0AAK69AABhvAAAKbwAAL48AADaPAAA5TwAANo8AAAoPAAAqrwAAIC8AABaPQAAEbwAADk9AADrvAAA+zwAADk9AAA6PAAAED0AADQ8AAB6PQAARDwAAKG8AAAmuwAAQTwAAOM8AADnuwAAjLwAAEi8AACJvAAAnDwAAMa8AADAvAAAB70AAH69AABKvQAABD0AACM9AAD9vAAA4zsAAP06AACquwAAB70AAJ+8AAD9PAAA2TsAADm8AAAMOwAAzDwAABu9AABePAAA1bwAAJe8AABOPAAAEbwAALy8AAAfPAAAsL0AAK27AABBPAAABj0AADS8AAAZPQAAWzoAABW8AACVvAAAEDwAANm8AABDvQAANz0AAIk8AAAnPQAAYrwAAIo8AACVPAAASjoAADE9AAAJPAAAzbwAAGM9AADwugAAIDwAAC89AADbvAAAFbsAAF49AADvPAAA6DwAACi9AACJvAAApLwAACe8AABKvAAAwTwAAF49AACuPAAAPz0AALk8AADiugAAzbsAAO28AACYvAAA2zwAAMo8AACTPQAAATwAAJg8AAD/uwAAOTsAANc8AABiPAAAEr0AAOA6AAAHPQAA8LsAAIK8AAA4OQAAaTwAAC49AADTvAAAv7sAAG+8AABkPAAAmz0AALe8AABSvQAAP7wAAIg8AAAxvAAAnz0AAHI8AAA+PAAAXjwAAP08AACtvAAAC7wAAMG8AADNugAAVD0AAM68AAAGuwAA0rwAADE9AABVvQAAcDwAAOo8AADAPAAA6LgAANU8AAAGuwAAOjwAAM07AAAbPQAAjjwAAFq7AACKPAAAGT0AAAM8AADevAAAjjoAAJm7AAA/PQAADj0AANa7AADjPAAA0TsAAFu4AAAwuwAAPLsAANS8AACXOwAA/jwAAA49AACaPQAAKD0AAP88AACvvAAAar0AABk9AACGvAAAqTsAACu9AAAFPAAAcDwAAA69AAAGvQAACL0AALG7AACmPAAASz0AAAe9AAAYPQAAmLsAANo8AAAavAAAF7wAACE7AAAHvAAA1bsAAKQ7AACEOwAAjzwAABq9AAAovAAAfb0AAFI7AAAjPQAAUb0AAAM9AAA+uwAA8bwAAIi8AADLPAAAGLwAAAo9AAAevAAAmboAANE8AAAZvQAAJD0AAO+6AAD9PAAAwTwAAAg9AABEvQAAJD0AACG9AACCvAAAiD0AABy9AABvPQAAXb0AAN66AAA+OQAAdrsAAJg8AACbvAAAujwAAMi8AAC2PAAAzbwAAIc8AACBPAAAmLwAAJg9AADVvAAA4DwAAC+7AAAXOwAAjT0AAES7AAA5PAAAmTwAABs8AAC5PAAAAL0AADE9AACHvAAAiDwAAKo9AAD4vAAARLoAAH46AACvvAAAmbsAAMc8AAARPAAAirwAAK08AABaPQAAlzwAAAa9AAAyvQAAVj0AAKO8AAA6PQAAAz0AAKC7AAAQPQAAa70AAEs9AACLPQAA87wAADm8AAA4vQAA1zsAAKg8AACKuwAAp7wAADE8AAAXvQAAm7sAAAo8AAABPQAA6LwAAFU8AAAkvQAAfb0AAII8AABrPQAAtbwAAIG9AABbvQAAwLwAAL08AADBOgAAE7wAAFq8AAAAPAAAGT0AAB48AACYvAAAkzwAAME7AABSPQAAsrwAAKQ8AABNOwAAvDwAANa7AADbvAAAZTsAAAS9AACIvAAAhzwAAOA8AABaPQAAnLsAAE+8AACvugAAIL0AABk9AADEvAAAmTwAACs9AAASvAAAGzwAAME8AACyPAAAMT0AAFS8AAChPQAA4bwAABg9AADhvAAAOrwAALa7AACbPAAAD70AAK+8AAAUvQAATLwAALA8AAABvQAAxbwAADY8AADrvAAADz0AAK47AAAiOwAAy7wAAMg8AABBPAAA07wAAI08AACtOwAAMT0AACi9AAAjvQAAKD0AAFa8AAD/vAAAIj0AADy8AACgvAAAx7wAAAu9AAA1uwAAzrwAAPI8AADzvAAA4zwAAHg9AABKvQAASrwAABs9AADPvAAAGrwAALS8AAACvQAA87wAAIm6AAAIPQAASjwAAD69AADrPAAAeL0AAA09AAB6OwAAKT0AAKS8AAAPuwAADrsAAEe8AAAbOwAAXTwAALS8AAA/vAAA4TkAAA68AAB2OwAAkr0AAAW8AAAwPQAAHr0AAKA8AAAHPQAAm7wAAIk8AADtPAAAsbwAAJ49AABSPAAAtzwAABI9AAAcvAAAPDsAAL68AABcvAAAXrwAABk9AACmvAAAbbsAABs9AABpOwAAprsAAJi9AAAaPQAADbsAABg8AACfPAAAt7wAAE+9AADiPAAAoTwAAD49AACfvAAAz7sAAIo9AADKPAAAaL0AALo7AAADPQAAYDwAAHY8AAAZPAAApjwAABO8AADLvAAAKr0AAI+8AAArPQAAOTwAAB89AABBPAAAED0AAMy8AAAkvQAAhzkAAEa8AABcPAAAKT0AAGm8AAC0uwAAILwAADk9AACUvAAADDwAAOy7AAAmugAAqTwAAJg8AAA5PQAAgbwAAJI7AAC0PAAAH7wAABQ9AAAfOwAA+LoAAKC8AAA0vQAAaDsAADG9AABBPAAAQzwAAFm8AABhPQAAmjwAAB89AADrvAAAmD0AAES8AABbPQAAkLoAAL68AABGvAAAMj0AACE8AACpvAAADT0AAH69AADWOgAA/TsAAO+8AACNvQAAKj0AAPI7AAD2vAAAjTwAAIW8AACxOgAAvT0AANK8AACCPQAAjzwAAFQ8AACfOwAAmrwAAH47AACGvAAAiDwAAKe8AAD0PAAAr7wAABk8AAB7PAAAfzwAAD68AACovAAAhrwAAK28AADyvAAArrsAAAE9AAA2vQAADD0AAIi8AAAGPAAAfrsAALK7AAChPQAAQzsAABe8AADLuwAA6boAABA9AAAZvQAAED0AAEQ9AABrvAAAYDwAAFW9AACtvAAA7TwAAAq8AABwvAAAEr0AABO8AADjPAAAkj0AAA49AACruQAA4TwAAGS9AACBPQAAB70AANa6AACuPAAAZLwAADE9AACIvAAAODwAADg8AAAhPQAAWj0AAOC8AAC5vAAAFT0AAH09AAANPQAA2jwAABs9AAAgPAAApDwAAM28AAA9vQAA57wAAOM6AAAYPAAA9zwAABI9AACDvQAAp7wAAFu7AADDPAAARDwAAAu9AAAIvQAANroAAP88AADMvAAAg7wAADk8AABkPQAA1rwAAOK8AACmuwAAhrsAABW9AADrvAAAwTsAADa8AADxuwAAQT0AAFs9AACXPAAAsb0AADa9AACPPAAARb0AAHi8AABSvAAAYz0AAGM8AACcuwAAoD0AAAI8AAC+vA== + index: 0 + object: embedding + text: null + model: voyage-multimodal-3 + object: list + usage: + image_pixels: 50000 + text_tokens: 0 + total_tokens: 89 + video_pixels: 0 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/test_embedder.py b/tests/test_embedder.py index 3276db7c..a4f82fc2 100644 --- a/tests/test_embedder.py +++ b/tests/test_embedder.py @@ -308,7 +308,7 @@ def _ollama_text_only_config(): async def test_text_only_embedder_does_not_support_images(): embedder = get_embedder(_ollama_text_only_config()) assert embedder.supports_images is False - with pytest.raises(NotImplementedError, match="multimodal provider"): + with pytest.raises(NotImplementedError, match="multimodal"): await embedder.embed_image(b"\x89PNG\r\n\x1a\n") @@ -417,6 +417,20 @@ async def test_vllm_supports_images_flag(): assert embedder.supports_images is True +async def test_vllm_text_only_embed_image_raises(): + from haiku.rag.embeddings.vllm import VLLMMultimodalEmbedder + + embedder = VLLMMultimodalEmbedder( + model_name="x", + vector_dim=2, + base_url="http://localhost:8000/v1", + supports_images=False, + ) + assert embedder.supports_images is False + with pytest.raises(NotImplementedError, match="text-only"): + await embedder.embed_image(b"\x89PNG\r\n\x1a\n") + + async def test_vllm_connect_error_surfaces_helpful_message(monkeypatch): import httpx @@ -707,3 +721,136 @@ async def test_vllm_embed_text_and_image_end_to_end(): image_vec = await embedder.embed_image(image) assert len(image_vec) == 4096 assert any(abs(x) > 1e-6 for x in image_vec), "image embedding is all zeros" + + +class _FakeVoyageResult: + def __init__(self, embeddings): + self.embeddings = embeddings + + +def _fake_voyage_client(captured, embeddings): + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + captured["init"] = kwargs + + async def multimodal_embed(self, **kwargs): + captured.update(kwargs) + return _FakeVoyageResult(embeddings) + + return FakeAsyncClient + + +async def test_voyage_embed_documents_request_shape(monkeypatch): + from haiku.rag.embeddings.voyageai import VoyageMultimodalEmbedder + + captured: dict = {} + monkeypatch.setattr( + "voyageai.AsyncClient", + _fake_voyage_client(captured, [[0.1, 0.2], [0.3, 0.4]]), + ) + + embedder = VoyageMultimodalEmbedder("voyage-multimodal-3", vector_dim=2) + vecs = await embedder.embed_documents(["a cat", "a dog"]) + + assert vecs == [[0.1, 0.2], [0.3, 0.4]] + assert captured["model"] == "voyage-multimodal-3" + assert captured["input_type"] == "document" + assert captured["inputs"] == [["a cat"], ["a dog"]] + assert captured["output_dimension"] == 2 + + +async def test_voyage_embed_query_request_shape(monkeypatch): + from haiku.rag.embeddings.voyageai import VoyageMultimodalEmbedder + + captured: dict = {} + monkeypatch.setattr( + "voyageai.AsyncClient", _fake_voyage_client(captured, [[0.5, 0.6]]) + ) + + embedder = VoyageMultimodalEmbedder("voyage-multimodal-3", vector_dim=2) + vec = await embedder.embed_query("find the cat") + + assert vec == [0.5, 0.6] + assert captured["model"] == "voyage-multimodal-3" + assert captured["input_type"] == "query" + assert captured["inputs"] == [["find the cat"]] + + +async def test_voyage_embed_image_passes_pil(monkeypatch): + from PIL import Image + + from haiku.rag.embeddings.voyageai import VoyageMultimodalEmbedder + + captured: dict = {} + monkeypatch.setattr( + "voyageai.AsyncClient", _fake_voyage_client(captured, [[0.7, 0.8]]) + ) + + embedder = VoyageMultimodalEmbedder("voyage-multimodal-3", vector_dim=2) + import io + + buf = io.BytesIO() + Image.new("RGB", (4, 4), "red").save(buf, format="PNG") + vec = await embedder.embed_image(buf.getvalue()) + + assert vec == [0.7, 0.8] + assert captured["model"] == "voyage-multimodal-3" + inputs = captured["inputs"] + assert len(inputs) == 1 and len(inputs[0]) == 1 + assert isinstance(inputs[0][0], Image.Image) + assert captured["input_type"] == "document" + + +async def test_voyage_embed_documents_empty_list_skips_request(monkeypatch): + from haiku.rag.embeddings.voyageai import VoyageMultimodalEmbedder + + captured: dict = {} + monkeypatch.setattr("voyageai.AsyncClient", _fake_voyage_client(captured, [])) + + embedder = VoyageMultimodalEmbedder("voyage-multimodal-3", vector_dim=2) + assert await embedder.embed_documents([]) == [] + assert "inputs" not in captured + + +async def test_voyage_get_embedder_routes_to_multimodal(monkeypatch): + monkeypatch.setattr("voyageai.AsyncClient", _fake_voyage_client({}, [])) + from haiku.rag.embeddings.voyageai import VoyageMultimodalEmbedder + + config = AppConfig( + embeddings=EmbeddingsConfig( + model=EmbeddingModelConfig( + provider="voyageai", + name="voyage-multimodal-3", + vector_dim=1024, + multimodal=True, + ) + ) + ) + embedder = get_embedder(config) + assert isinstance(embedder, VoyageMultimodalEmbedder) + assert embedder.supports_images is True + + +@pytest.mark.vcr() +async def test_voyage_embed_text_and_image_end_to_end(): + """End-to-end against the real VoyageAI ``multimodal_embed`` API: text and + image inputs return embeddings of the configured dimension in a shared + vector space. To re-record, set ``VOYAGE_API_KEY`` and run with + ``--record-mode=rewrite``.""" + from PIL import Image + + from haiku.rag.embeddings.voyageai import VoyageMultimodalEmbedder + + embedder = VoyageMultimodalEmbedder("voyage-multimodal-3", vector_dim=1024) + + text_vec = await embedder.embed_query("a photo of a red square") + assert len(text_vec) == 1024 + assert any(abs(x) > 1e-6 for x in text_vec), "text embedding is all zeros" + + text_batch = await embedder.embed_documents(["hello world", "another doc"]) + assert len(text_batch) == 2 + assert all(len(v) == 1024 for v in text_batch) + + image_vec = await embedder.embed_image(Image.new("RGB", (64, 64), (255, 0, 0))) + assert len(image_vec) == 1024 + assert any(abs(x) > 1e-6 for x in image_vec), "image embedding is all zeros"