Add explicit multimodal embedding flag, decoupled from provider name

This commit is contained in:
Yiorgis Gozadinos 2026-06-23 11:51:48 +03:00
parent 2c906b16cd
commit 0ea251219c
No known key found for this signature in database
7 changed files with 111 additions and 15 deletions

View file

@ -4,6 +4,11 @@
### Added ### Added
- `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. - `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.
### Changed
- `provider: vllm` is text-only unless `embeddings.model.multimodal: true` is set. Existing multimodal vLLM configs must add the flag.
## [0.60.0] - 2026-06-22 ## [0.60.0] - 2026-06-22

View file

@ -39,16 +39,20 @@ class EmbeddingModelConfig(BaseModel):
"""Configuration for an embedding model. """Configuration for an embedding model.
Attributes: Attributes:
provider: Model provider (ollama, openai, voyageai, cohere, sentence-transformers) provider: Model provider (ollama, openai, voyageai, cohere, sentence-transformers, vllm)
name: Model name/identifier name: Model name/identifier
vector_dim: Vector dimensions produced by the model vector_dim: Vector dimensions produced by the model
base_url: Optional base URL for OpenAI-compatible servers (vLLM, LM Studio, etc.) base_url: Optional base URL for OpenAI-compatible servers (vLLM, LM Studio, etc.)
multimodal: Whether the model embeds images into the same vector space as
text. Supported on the vllm, voyageai, and cohere providers; other
providers raise when this is set.
""" """
provider: str = "ollama" provider: str = "ollama"
name: str = "qwen3-embedding:4b" name: str = "qwen3-embedding:4b"
vector_dim: int = 2560 vector_dim: int = 2560
base_url: str | None = None base_url: str | None = None
multimodal: bool = False
class StorageConfig(BaseModel): class StorageConfig(BaseModel):

View file

@ -209,7 +209,10 @@ def _classify_unchunked(
f"{len(picture_docs)} image-only document(s) have no chunks; " f"{len(picture_docs)} image-only document(s) have no chunks; "
"a text-only embedder cannot index images." "a text-only embedder cannot index images."
), ),
remediation="Configure a multimodal embedder and rebuild to index images.", remediation=(
"Set embeddings.model.multimodal: true on a vllm, voyageai, or "
"cohere model and rebuild to index images."
),
details=_sample(sorted(picture_docs)), details=_sample(sorted(picture_docs)),
) )
) )

View file

@ -10,6 +10,7 @@ from haiku.rag.config import AppConfig, Config
if TYPE_CHECKING: if TYPE_CHECKING:
from PIL import Image as PILImage from PIL import Image as PILImage
from haiku.rag.config.models import EmbeddingModelConfig
from haiku.rag.store.models.chunk import Chunk from haiku.rag.store.models.chunk import Chunk
@ -19,15 +20,21 @@ ImageInput = "bytes | PILImage.Image"
class EmbedderWrapper: class EmbedderWrapper:
"""Wrapper around pydantic-ai Embedder with explicit query/document methods. """Wrapper around pydantic-ai Embedder with explicit query/document methods.
Subclasses set ``supports_images = True`` and override the image methods Subclasses pass ``supports_images=True`` and override the image methods when
when the underlying model can encode pictures into the same vector space. the underlying model can encode pictures into the same vector space.
""" """
supports_images: bool = False supports_images: bool = False
def __init__(self, embedder: Embedder | None, vector_dim: int): def __init__(
self,
embedder: Embedder | None,
vector_dim: int,
supports_images: bool = False,
):
self._embedder = embedder self._embedder = embedder
self._vector_dim = vector_dim self._vector_dim = vector_dim
self.supports_images = supports_images
@property @property
def vector_dim(self) -> int: def vector_dim(self) -> int:
@ -159,6 +166,9 @@ def get_embedder(config: AppConfig = Config) -> EmbedderWrapper:
model_name = embedding_model.name model_name = embedding_model.name
vector_dim = embedding_model.vector_dim vector_dim = embedding_model.vector_dim
if embedding_model.multimodal:
return _get_multimodal_embedder(embedding_model)
if provider == "ollama": if provider == "ollama":
# Use model-level base_url if set, otherwise fall back to providers config # Use model-level base_url if set, otherwise fall back to providers config
base_url = embedding_model.base_url or config.providers.ollama.base_url base_url = embedding_model.base_url or config.providers.ollama.base_url
@ -193,9 +203,39 @@ def get_embedder(config: AppConfig = Config) -> EmbedderWrapper:
if provider == "vllm": if provider == "vllm":
from haiku.rag.embeddings.vllm import VLLMMultimodalEmbedder from haiku.rag.embeddings.vllm import VLLMMultimodalEmbedder
base_url = embedding_model.base_url or "http://localhost:8000/v1" base_url = _vllm_base_url(embedding_model.base_url)
if not base_url.rstrip("/").endswith("/v1"): return VLLMMultimodalEmbedder(
base_url = base_url.rstrip("/") + "/v1" model_name, vector_dim, base_url=base_url, supports_images=False
return VLLMMultimodalEmbedder(model_name, vector_dim, base_url=base_url) )
raise ValueError(f"Unsupported embedding provider: {provider}") raise ValueError(f"Unsupported embedding provider: {provider}")
def _vllm_base_url(base_url: str | None) -> str:
base_url = base_url or "http://localhost:8000/v1"
if not base_url.rstrip("/").endswith("/v1"):
base_url = base_url.rstrip("/") + "/v1"
return base_url
def _get_multimodal_embedder(
embedding_model: "EmbeddingModelConfig",
) -> EmbedderWrapper:
"""Build an image-capable embedder for providers that support multimodal.
Each provider passes images in its own wire format, so the capability lives
in a per-provider embedder rather than a generic flag.
"""
provider = embedding_model.provider
model_name = embedding_model.name
vector_dim = embedding_model.vector_dim
if provider == "vllm":
from haiku.rag.embeddings.vllm import VLLMMultimodalEmbedder
base_url = _vllm_base_url(embedding_model.base_url)
return VLLMMultimodalEmbedder(
model_name, vector_dim, base_url=base_url, supports_images=True
)
raise ValueError(f"Provider '{provider}' does not support multimodal embedding.")

View file

@ -24,8 +24,6 @@ if TYPE_CHECKING:
class VLLMMultimodalEmbedder(EmbedderWrapper): class VLLMMultimodalEmbedder(EmbedderWrapper):
supports_images = True
def __init__( def __init__(
self, self,
model_name: str, model_name: str,
@ -33,8 +31,11 @@ class VLLMMultimodalEmbedder(EmbedderWrapper):
base_url: str, base_url: str,
api_key: str | None = None, api_key: str | None = None,
timeout: float = 60.0, timeout: float = 60.0,
supports_images: bool = True,
): ):
super().__init__(embedder=None, vector_dim=vector_dim) super().__init__(
embedder=None, vector_dim=vector_dim, supports_images=supports_images
)
self._model_name = model_name self._model_name = model_name
self._base_url = base_url.rstrip("/") self._base_url = base_url.rstrip("/")
self._api_key = api_key self._api_key = api_key

View file

@ -49,11 +49,19 @@ VECTOR_DIM = 4
ChunkRecord = create_chunk_model(VECTOR_DIM) ChunkRecord = create_chunk_model(VECTOR_DIM)
def _config(provider: str = "ollama", name: str = "test", vector_dim: int = VECTOR_DIM): def _config(
provider: str = "ollama",
name: str = "test",
vector_dim: int = VECTOR_DIM,
multimodal: bool = False,
):
return AppConfig( return AppConfig(
embeddings=EmbeddingsConfig( embeddings=EmbeddingsConfig(
model=EmbeddingModelConfig( model=EmbeddingModelConfig(
provider=provider, name=name, vector_dim=vector_dim provider=provider,
name=name,
vector_dim=vector_dim,
multimodal=multimodal,
) )
) )
) )
@ -312,7 +320,7 @@ async def test_image_only_document_multimodal_embedder_warns(temp_db_path):
], ],
) )
report = await run_doctor( report = await run_doctor(
_config(provider="vllm", name="qwen-vl"), temp_db_path, {} _config(provider="vllm", name="qwen-vl", multimodal=True), temp_db_path, {}
) )
result = _result(report, "documents_pictures_no_chunks") result = _result(report, "documents_pictures_no_chunks")
assert result.severity is Severity.WARN assert result.severity is Severity.WARN

View file

@ -630,6 +630,7 @@ async def test_vllm_get_embedder_routes_to_multimodal():
name="Qwen/Qwen3-VL-Embedding-2B", name="Qwen/Qwen3-VL-Embedding-2B",
vector_dim=2048, vector_dim=2048,
base_url="http://my-vllm:8000/v1", base_url="http://my-vllm:8000/v1",
multimodal=True,
) )
) )
) )
@ -638,6 +639,40 @@ async def test_vllm_get_embedder_routes_to_multimodal():
assert embedder._base_url == "http://my-vllm:8000/v1" # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] assert embedder._base_url == "http://my-vllm:8000/v1" # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
def test_multimodal_defaults_to_false():
model = EmbeddingModelConfig(provider="vllm", name="x", vector_dim=2)
assert model.multimodal is False
assert "multimodal" in model.model_dump()
async def test_vllm_text_only_when_multimodal_unset():
config = AppConfig(
embeddings=EmbeddingsConfig(
model=EmbeddingModelConfig(
provider="vllm",
name="qwen3-embedding:4b",
vector_dim=2560,
base_url="http://my-vllm:8000/v1",
)
)
)
embedder = get_embedder(config)
assert embedder.supports_images is False
@pytest.mark.parametrize("provider", ["ollama", "openai", "sentence-transformers"])
async def test_multimodal_unsupported_provider_raises(provider):
config = AppConfig(
embeddings=EmbeddingsConfig(
model=EmbeddingModelConfig(
provider=provider, name="x", vector_dim=2, multimodal=True
)
)
)
with pytest.raises(ValueError, match="does not support multimodal"):
get_embedder(config)
@pytest.mark.vcr() @pytest.mark.vcr()
async def test_vllm_embed_text_and_image_end_to_end(): async def test_vllm_embed_text_and_image_end_to_end():
"""End-to-end against a real vLLM ``/v1/embeddings`` server: confirm """End-to-end against a real vLLM ``/v1/embeddings`` server: confirm