Add explicit multimodal embedding flag, decoupled from provider name
This commit is contained in:
parent
2c906b16cd
commit
0ea251219c
7 changed files with 111 additions and 15 deletions
|
|
@ -4,6 +4,11 @@
|
|||
### 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.
|
||||
- `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
|
||||
|
||||
|
|
|
|||
|
|
@ -39,16 +39,20 @@ class EmbeddingModelConfig(BaseModel):
|
|||
"""Configuration for an embedding model.
|
||||
|
||||
Attributes:
|
||||
provider: Model provider (ollama, openai, voyageai, cohere, sentence-transformers)
|
||||
provider: Model provider (ollama, openai, voyageai, cohere, sentence-transformers, vllm)
|
||||
name: Model name/identifier
|
||||
vector_dim: Vector dimensions produced by the model
|
||||
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"
|
||||
name: str = "qwen3-embedding:4b"
|
||||
vector_dim: int = 2560
|
||||
base_url: str | None = None
|
||||
multimodal: bool = False
|
||||
|
||||
|
||||
class StorageConfig(BaseModel):
|
||||
|
|
|
|||
|
|
@ -209,7 +209,10 @@ def _classify_unchunked(
|
|||
f"{len(picture_docs)} image-only document(s) have no chunks; "
|
||||
"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)),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from haiku.rag.config import AppConfig, Config
|
|||
if TYPE_CHECKING:
|
||||
from PIL import Image as PILImage
|
||||
|
||||
from haiku.rag.config.models import EmbeddingModelConfig
|
||||
from haiku.rag.store.models.chunk import Chunk
|
||||
|
||||
|
||||
|
|
@ -19,15 +20,21 @@ ImageInput = "bytes | PILImage.Image"
|
|||
class EmbedderWrapper:
|
||||
"""Wrapper around pydantic-ai Embedder with explicit query/document methods.
|
||||
|
||||
Subclasses set ``supports_images = True`` and override the image methods
|
||||
when the underlying model can encode pictures into the same vector space.
|
||||
Subclasses pass ``supports_images=True`` and override the image methods when
|
||||
the underlying model can encode pictures into the same vector space.
|
||||
"""
|
||||
|
||||
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._vector_dim = vector_dim
|
||||
self.supports_images = supports_images
|
||||
|
||||
@property
|
||||
def vector_dim(self) -> int:
|
||||
|
|
@ -159,6 +166,9 @@ def get_embedder(config: AppConfig = Config) -> EmbedderWrapper:
|
|||
model_name = embedding_model.name
|
||||
vector_dim = embedding_model.vector_dim
|
||||
|
||||
if embedding_model.multimodal:
|
||||
return _get_multimodal_embedder(embedding_model)
|
||||
|
||||
if provider == "ollama":
|
||||
# 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
|
||||
|
|
@ -193,9 +203,39 @@ def get_embedder(config: AppConfig = Config) -> EmbedderWrapper:
|
|||
if provider == "vllm":
|
||||
from haiku.rag.embeddings.vllm import VLLMMultimodalEmbedder
|
||||
|
||||
base_url = embedding_model.base_url or "http://localhost:8000/v1"
|
||||
if not base_url.rstrip("/").endswith("/v1"):
|
||||
base_url = base_url.rstrip("/") + "/v1"
|
||||
return VLLMMultimodalEmbedder(model_name, vector_dim, base_url=base_url)
|
||||
base_url = _vllm_base_url(embedding_model.base_url)
|
||||
return VLLMMultimodalEmbedder(
|
||||
model_name, vector_dim, base_url=base_url, supports_images=False
|
||||
)
|
||||
|
||||
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.")
|
||||
|
|
|
|||
|
|
@ -24,8 +24,6 @@ if TYPE_CHECKING:
|
|||
|
||||
|
||||
class VLLMMultimodalEmbedder(EmbedderWrapper):
|
||||
supports_images = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_name: str,
|
||||
|
|
@ -33,8 +31,11 @@ class VLLMMultimodalEmbedder(EmbedderWrapper):
|
|||
base_url: str,
|
||||
api_key: str | None = None,
|
||||
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._base_url = base_url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
|
|
|
|||
|
|
@ -49,11 +49,19 @@ VECTOR_DIM = 4
|
|||
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(
|
||||
embeddings=EmbeddingsConfig(
|
||||
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(
|
||||
_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")
|
||||
assert result.severity is Severity.WARN
|
||||
|
|
|
|||
|
|
@ -630,6 +630,7 @@ async def test_vllm_get_embedder_routes_to_multimodal():
|
|||
name="Qwen/Qwen3-VL-Embedding-2B",
|
||||
vector_dim=2048,
|
||||
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]
|
||||
|
||||
|
||||
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()
|
||||
async def test_vllm_embed_text_and_image_end_to_end():
|
||||
"""End-to-end against a real vLLM ``/v1/embeddings`` server: confirm
|
||||
|
|
|
|||
Loading…
Reference in a new issue