From 67708a553d8fc67574d3af7e2c5da7ed0469f9d7 Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Wed, 30 Oct 2024 17:50:12 +0100 Subject: [PATCH 01/10] new: add backbone for image support --- qdrant_client/async_qdrant_fastembed.py | 71 +++++++++++++++++++++-- qdrant_client/embed/embed_inspector.py | 8 ++- qdrant_client/embed/models.py | 7 +-- qdrant_client/embed/schema_parser.py | 3 +- qdrant_client/embed/type_inspector.py | 10 ++-- qdrant_client/models/__init__.py | 1 + qdrant_client/qdrant_fastembed.py | 77 +++++++++++++++++++++++-- 7 files changed, 154 insertions(+), 23 deletions(-) diff --git a/qdrant_client/async_qdrant_fastembed.py b/qdrant_client/async_qdrant_fastembed.py index 1a41b7095..254cacb62 100644 --- a/qdrant_client/async_qdrant_fastembed.py +++ b/qdrant_client/async_qdrant_fastembed.py @@ -29,13 +29,19 @@ from qdrant_client import grpc try: - from fastembed import SparseTextEmbedding, TextEmbedding, LateInteractionTextEmbedding + from fastembed import ( + SparseTextEmbedding, + TextEmbedding, + LateInteractionTextEmbedding, + ImageEmbedding, + ) from fastembed.common import OnnxProvider except ImportError: TextEmbedding = None SparseTextEmbedding = None OnnxProvider = None LateInteractionTextEmbedding = None + ImageEmbedding = None SUPPORTED_EMBEDDING_MODELS: Dict[str, Tuple[int, models.Distance]] = ( { model["model"]: (model["dim"], models.Distance.COSINE) @@ -63,13 +69,20 @@ if LateInteractionTextEmbedding else {} ) +_IMAGE_EMBEDDING_MODELS: Dict[str, Tuple[int, models.Distance]] = ( + {model["model"]: model for model in ImageEmbedding.list_supported_models()} + if ImageEmbedding + else {} +) class AsyncQdrantFastembedMixin(AsyncQdrantBase): DEFAULT_EMBEDDING_MODEL = "BAAI/bge-small-en" + INFERENCE_OBJECT_TYPES = (models.Document, models.Image) embedding_models: Dict[str, "TextEmbedding"] = {} sparse_embedding_models: Dict[str, "SparseTextEmbedding"] = {} late_interaction_embedding_models: Dict[str, "LateInteractionTextEmbedding"] = {} + image_embedding_models: Dict[str, "ImageEmbedding"] = {} _FASTEMBED_INSTALLED: bool def __init__(self, parser: ModelSchemaParser, **kwargs: Any): @@ -294,6 +307,31 @@ def _get_or_init_late_interaction_model( ) return cls.late_interaction_embedding_models[model_name] + @classmethod + def _get_or_init_image_model( + cls, + model_name: str, + cache_dir: Optional[str] = None, + threads: Optional[int] = None, + providers: Optional[Sequence["OnnxProvider"]] = None, + **kwargs: Any, + ) -> "ImageEmbedding": + if model_name in cls.image_embedding_models: + return cls.image_embedding_models[model_name] + cls._import_fastembed() + if model_name not in _IMAGE_EMBEDDING_MODELS: + raise ValueError( + f"Unsupported embedding model: {model_name}. Supported models: {_IMAGE_EMBEDDING_MODELS}" + ) + cls.image_embedding_models[model_name] = ImageEmbedding( + model_name=model_name, + cache_dir=cache_dir, + threads=threads, + providers=providers, + **kwargs, + ) + return cls.image_embedding_models[model_name] + def _embed_documents( self, documents: Iterable[str], @@ -727,8 +765,9 @@ async def query_batch( ] return [self._scored_points_to_query_responses(response) for response in responses] - @staticmethod + @classmethod def _resolve_query( + cls, query: Union[ types.PointId, List[float], @@ -764,10 +803,10 @@ def _resolve_query( GrpcToRest.convert_point_id(query) if isinstance(query, grpc.PointId) else query ) return models.NearestQuery(nearest=query) - if isinstance(query, models.Document): + if isinstance(query, cls.INFERENCE_OBJECT_TYPES): model_name = query.model if model_name is None: - raise ValueError("`model` field has to be set explicitly in the `Document`") + raise ValueError(f"`model` field has to be set explicitly in the {type(query)}") return models.NearestQuery(nearest=query) if query is None: return None @@ -813,7 +852,7 @@ def _embed_models( A deepcopy of the method with embedded fields """ if paths is None: - if isinstance(model, models.Document): + if isinstance(model, self.INFERENCE_OBJECT_TYPES): return self._embed_raw_data(model, is_query=is_query) model = deepcopy(model) paths = self._embed_inspector.inspect(model) @@ -853,6 +892,8 @@ def _embed_raw_data( """ if isinstance(data, models.Document): return self._embed_document(data, is_query=is_query) + elif isinstance(data, models.Image): + return self._embed_image(data) elif isinstance(data, dict): return { key: self._embed_raw_data(value, is_query=is_query) @@ -910,3 +951,23 @@ def _embed_document(self, document: models.Document, is_query: bool = False) -> return embedding else: raise ValueError(f"{model_name} is not among supported models") + + def _embed_image(self, image: models.Image) -> NumericVector: + """Embed an image using the specified embedding model + + Args: + image: Image to embed + + Returns: + NumericVector: Image's embedding + + Raises: + ValueError: If model is not supported + """ + model_name = image.model + text = image.image + if model_name in _IMAGE_EMBEDDING_MODELS: + embedding_model_inst = self._get_or_init_image_model(model_name=model_name) + embedding = list(embedding_model_inst.embed(documents=[text]))[0].tolist() + return embedding + raise ValueError(f"{model_name} is not among supported models") diff --git a/qdrant_client/embed/embed_inspector.py b/qdrant_client/embed/embed_inspector.py index ccff704e8..92923f604 100644 --- a/qdrant_client/embed/embed_inspector.py +++ b/qdrant_client/embed/embed_inspector.py @@ -17,6 +17,8 @@ class InspectorEmbed: parser: ModelSchemaParser instance """ + INFERENCE_OBJECT_TYPES = models.Document, models.Image + def __init__(self, parser: Optional[ModelSchemaParser] = None) -> None: self.parser = ModelSchemaParser() if parser is None else parser @@ -112,7 +114,7 @@ def inspect_recursive(member: BaseModel, accumulator: str) -> List[str]: if model is None: return [] - if isinstance(model, models.Document): + if isinstance(model, self.INFERENCE_OBJECT_TYPES): return [accum] if isinstance(model, BaseModel): @@ -132,7 +134,7 @@ def inspect_recursive(member: BaseModel, accumulator: str) -> List[str]: if not isinstance(current_model, BaseModel): continue - if isinstance(current_model, models.Document): + if isinstance(current_model, self.INFERENCE_OBJECT_TYPES): found_paths.append(accum) found_paths.extend(inspect_recursive(current_model, accum)) @@ -157,7 +159,7 @@ def inspect_recursive(member: BaseModel, accumulator: str) -> List[str]: if not isinstance(current_model, BaseModel): continue - if isinstance(current_model, models.Document): + if isinstance(current_model, self.INFERENCE_OBJECT_TYPES): found_paths.append(accum) found_paths.extend(inspect_recursive(current_model, accum)) diff --git a/qdrant_client/embed/models.py b/qdrant_client/embed/models.py index 689cd721e..6cc3011c1 100644 --- a/qdrant_client/embed/models.py +++ b/qdrant_client/embed/models.py @@ -2,9 +2,8 @@ from pydantic import StrictFloat, StrictStr -from qdrant_client.grpc import SparseVector -from qdrant_client.http.models import ExtendedPointId -from qdrant_client.models import Document # type: ignore[attr-defined] +from qdrant_client.http.models import ExtendedPointId, SparseVector +from qdrant_client.models import Document, Image # type: ignore[attr-defined] NumericVector = Union[ @@ -24,4 +23,4 @@ Dict[StrictStr, NumericVector], ] -__all__ = ["Document", "NumericVector", "NumericVectorInput", "NumericVectorStruct"] +__all__ = ["NumericVector", "NumericVectorInput", "NumericVectorStruct"] diff --git a/qdrant_client/embed/schema_parser.py b/qdrant_client/embed/schema_parser.py index 91183098b..4e2e49f79 100644 --- a/qdrant_client/embed/schema_parser.py +++ b/qdrant_client/embed/schema_parser.py @@ -67,6 +67,7 @@ class ModelSchemaParser: """ CACHE_PATH = "_inspection_cache.py" + INFERENCE_OBJECT_NAMES = {"Document", "Image"} def __init__(self) -> None: self._defs: Dict[str, Union[Dict[str, Any], List[Dict[str, Any]]]] = deepcopy(DEFS) # type: ignore[arg-type] @@ -159,7 +160,7 @@ def _find_document_paths( if not isinstance(schema, dict): return document_paths - if "title" in schema and schema["title"] == "Document": + if "title" in schema and schema["title"] in self.INFERENCE_OBJECT_NAMES: document_paths.append(current_path) return document_paths diff --git a/qdrant_client/embed/type_inspector.py b/qdrant_client/embed/type_inspector.py index 3f712dcbc..184d65d32 100644 --- a/qdrant_client/embed/type_inspector.py +++ b/qdrant_client/embed/type_inspector.py @@ -16,6 +16,8 @@ class Inspector: parser: ModelSchemaParser instance to inspect model json schemas """ + INFERENCE_OBJECT_TYPES = models.Document, models.Image + def __init__(self, parser: Optional[ModelSchemaParser] = None) -> None: self.parser = ModelSchemaParser() if parser is None else parser @@ -41,7 +43,7 @@ def inspect(self, points: Union[Iterable[BaseModel], BaseModel]) -> bool: return False def _inspect_model(self, model: BaseModel, paths: Optional[List[Path]] = None) -> bool: - if isinstance(model, models.Document): + if isinstance(model, self.INFERENCE_OBJECT_TYPES): return True paths = ( @@ -80,7 +82,7 @@ def inspect_recursive(member: BaseModel) -> bool: if model is None: return False - if isinstance(model, models.Document): + if isinstance(model, self.INFERENCE_OBJECT_TYPES): return True if isinstance(model, BaseModel): @@ -98,7 +100,7 @@ def inspect_recursive(member: BaseModel) -> bool: elif isinstance(model, list): for current_model in model: - if isinstance(current_model, models.Document): + if isinstance(current_model, self.INFERENCE_OBJECT_TYPES): return True if not isinstance(current_model, BaseModel): @@ -121,7 +123,7 @@ def inspect_recursive(member: BaseModel) -> bool: for key, values in model.items(): values = [values] if not isinstance(values, list) else values for current_model in values: - if isinstance(current_model, models.Document): + if isinstance(current_model, self.INFERENCE_OBJECT_TYPES): return True if not isinstance(current_model, BaseModel): diff --git a/qdrant_client/models/__init__.py b/qdrant_client/models/__init__.py index 296c39f62..3614a6d62 100644 --- a/qdrant_client/models/__init__.py +++ b/qdrant_client/models/__init__.py @@ -1,2 +1,3 @@ from qdrant_client.http.models import * from qdrant_client.fastembed_common import * +from qdrant_client.embed.models import * diff --git a/qdrant_client/qdrant_fastembed.py b/qdrant_client/qdrant_fastembed.py index dbc5406d9..3bf80b861 100644 --- a/qdrant_client/qdrant_fastembed.py +++ b/qdrant_client/qdrant_fastembed.py @@ -20,13 +20,19 @@ from qdrant_client import grpc try: - from fastembed import SparseTextEmbedding, TextEmbedding, LateInteractionTextEmbedding + from fastembed import ( + SparseTextEmbedding, + TextEmbedding, + LateInteractionTextEmbedding, + ImageEmbedding, + ) from fastembed.common import OnnxProvider except ImportError: TextEmbedding = None SparseTextEmbedding = None OnnxProvider = None LateInteractionTextEmbedding = None + ImageEmbedding = None SUPPORTED_EMBEDDING_MODELS: Dict[str, Tuple[int, models.Distance]] = ( @@ -60,13 +66,20 @@ else {} ) +_IMAGE_EMBEDDING_MODELS: Dict[str, Tuple[int, models.Distance]] = ( + {model["model"]: model for model in ImageEmbedding.list_supported_models()} + if ImageEmbedding + else {} +) + class QdrantFastembedMixin(QdrantBase): DEFAULT_EMBEDDING_MODEL = "BAAI/bge-small-en" - + INFERENCE_OBJECT_TYPES = models.Document, models.Image embedding_models: Dict[str, "TextEmbedding"] = {} sparse_embedding_models: Dict[str, "SparseTextEmbedding"] = {} late_interaction_embedding_models: Dict[str, "LateInteractionTextEmbedding"] = {} + image_embedding_models: Dict[str, "ImageEmbedding"] = {} _FASTEMBED_INSTALLED: bool def __init__(self, parser: ModelSchemaParser, **kwargs: Any): @@ -310,6 +323,34 @@ def _get_or_init_late_interaction_model( ) return cls.late_interaction_embedding_models[model_name] + @classmethod + def _get_or_init_image_model( + cls, + model_name: str, + cache_dir: Optional[str] = None, + threads: Optional[int] = None, + providers: Optional[Sequence["OnnxProvider"]] = None, + **kwargs: Any, + ) -> "ImageEmbedding": + if model_name in cls.image_embedding_models: + return cls.image_embedding_models[model_name] + + cls._import_fastembed() + + if model_name not in _IMAGE_EMBEDDING_MODELS: + raise ValueError( + f"Unsupported embedding model: {model_name}. Supported models: {_IMAGE_EMBEDDING_MODELS}" + ) + + cls.image_embedding_models[model_name] = ImageEmbedding( + model_name=model_name, + cache_dir=cache_dir, + threads=threads, + providers=providers, + **kwargs, + ) + return cls.image_embedding_models[model_name] + def _embed_documents( self, documents: Iterable[str], @@ -803,8 +844,9 @@ def query_batch( return [self._scored_points_to_query_responses(response) for response in responses] - @staticmethod + @classmethod def _resolve_query( + cls, query: Union[ types.PointId, List[float], @@ -844,10 +886,10 @@ def _resolve_query( ) return models.NearestQuery(nearest=query) - if isinstance(query, models.Document): + if isinstance(query, cls.INFERENCE_OBJECT_TYPES): model_name = query.model if model_name is None: - raise ValueError("`model` field has to be set explicitly in the `Document`") + raise ValueError(f"`model` field has to be set explicitly in the {type(query)}") return models.NearestQuery(nearest=query) if query is None: @@ -898,7 +940,7 @@ def _embed_models( A deepcopy of the method with embedded fields """ if paths is None: - if isinstance(model, models.Document): + if isinstance(model, self.INFERENCE_OBJECT_TYPES): return self._embed_raw_data(model, is_query=is_query) model = deepcopy(model) paths = self._embed_inspector.inspect(model) @@ -940,6 +982,8 @@ def _embed_raw_data( """ if isinstance(data, models.Document): return self._embed_document(data, is_query=is_query) + elif isinstance(data, models.Image): + return self._embed_image(data) elif isinstance(data, dict): return { key: self._embed_raw_data(value, is_query=is_query) for key, value in data.items() @@ -998,3 +1042,24 @@ def _embed_document(self, document: models.Document, is_query: bool = False) -> return embedding else: raise ValueError(f"{model_name} is not among supported models") + + def _embed_image(self, image: models.Image) -> NumericVector: + """Embed an image using the specified embedding model + + Args: + image: Image to embed + + Returns: + NumericVector: Image's embedding + + Raises: + ValueError: If model is not supported + """ + model_name = image.model + text = image.image + if model_name in _IMAGE_EMBEDDING_MODELS: + embedding_model_inst = self._get_or_init_image_model(model_name=model_name) + embedding = list(embedding_model_inst.embed(documents=[text]))[0].tolist() + return embedding + + raise ValueError(f"{model_name} is not among supported models") From bd93f29d909d7cbd07b7f235d821fd3ee5aff66e Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Wed, 30 Oct 2024 18:11:21 +0100 Subject: [PATCH 02/10] new: convert b64 to pil, embed images, add test --- qdrant_client/async_qdrant_fastembed.py | 10 ++++-- qdrant_client/qdrant_fastembed.py | 12 +++++-- tests/embed_tests/test_local_inference.py | 44 ++++++++++++++++++++++- 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/qdrant_client/async_qdrant_fastembed.py b/qdrant_client/async_qdrant_fastembed.py index 254cacb62..9d35a951c 100644 --- a/qdrant_client/async_qdrant_fastembed.py +++ b/qdrant_client/async_qdrant_fastembed.py @@ -9,6 +9,8 @@ # # ****** WARNING: THIS FILE IS AUTOGENERATED ****** +import base64 +import io import uuid import warnings from itertools import tee @@ -36,12 +38,14 @@ ImageEmbedding, ) from fastembed.common import OnnxProvider + from PIL import Image as PilImage except ImportError: TextEmbedding = None SparseTextEmbedding = None OnnxProvider = None LateInteractionTextEmbedding = None ImageEmbedding = None + PilImage = None SUPPORTED_EMBEDDING_MODELS: Dict[str, Tuple[int, models.Distance]] = ( { model["model"]: (model["dim"], models.Distance.COSINE) @@ -965,9 +969,11 @@ def _embed_image(self, image: models.Image) -> NumericVector: ValueError: If model is not supported """ model_name = image.model - text = image.image if model_name in _IMAGE_EMBEDDING_MODELS: embedding_model_inst = self._get_or_init_image_model(model_name=model_name) - embedding = list(embedding_model_inst.embed(documents=[text]))[0].tolist() + image_data = base64.b64decode(image.image) + with io.BytesIO(image_data) as buffer: + with PilImage.open(buffer) as image: + embedding = list(embedding_model_inst.embed(images=[image]))[0].tolist() return embedding raise ValueError(f"{model_name} is not among supported models") diff --git a/qdrant_client/qdrant_fastembed.py b/qdrant_client/qdrant_fastembed.py index 3bf80b861..bca986193 100644 --- a/qdrant_client/qdrant_fastembed.py +++ b/qdrant_client/qdrant_fastembed.py @@ -1,10 +1,14 @@ +import base64 +import io import uuid import warnings from itertools import tee from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union, Set, get_args from copy import deepcopy + import numpy as np + from pydantic import BaseModel from qdrant_client.client_base import QdrantBase @@ -27,12 +31,14 @@ ImageEmbedding, ) from fastembed.common import OnnxProvider + from PIL import Image as PilImage except ImportError: TextEmbedding = None SparseTextEmbedding = None OnnxProvider = None LateInteractionTextEmbedding = None ImageEmbedding = None + PilImage = None SUPPORTED_EMBEDDING_MODELS: Dict[str, Tuple[int, models.Distance]] = ( @@ -1056,10 +1062,12 @@ def _embed_image(self, image: models.Image) -> NumericVector: ValueError: If model is not supported """ model_name = image.model - text = image.image if model_name in _IMAGE_EMBEDDING_MODELS: embedding_model_inst = self._get_or_init_image_model(model_name=model_name) - embedding = list(embedding_model_inst.embed(documents=[text]))[0].tolist() + image_data = base64.b64decode(image.image) + with io.BytesIO(image_data) as buffer: + with PilImage.open(buffer) as image: + embedding = list(embedding_model_inst.embed(images=[image]))[0].tolist() return embedding raise ValueError(f"{model_name} is not among supported models") diff --git a/tests/embed_tests/test_local_inference.py b/tests/embed_tests/test_local_inference.py index c0d789088..66de26105 100644 --- a/tests/embed_tests/test_local_inference.py +++ b/tests/embed_tests/test_local_inference.py @@ -1,4 +1,5 @@ from typing import Optional, List +from pathlib import Path import numpy as np import pytest @@ -17,6 +18,10 @@ SPARSE_MODEL_NAME = "Qdrant/bm42-all-minilm-l6-v2-attentions" COLBERT_MODEL_NAME = "colbert-ir/colbertv2.0" COLBERT_DIM = 128 +DENSE_IMAGE_MODEL_NAME = "Qdrant/resnet50-onnx" +DENSE_IMAGE_DIM = 2048 + +TEST_IMAGE_PATH = Path(__file__).parent / "misc" / "test_image.txt" # todo: remove once we don't store models in class variables @@ -716,7 +721,6 @@ def test_propagate_options(prefer_grpc): if not local_client._FASTEMBED_INSTALLED: pytest.skip("FastEmbed is not installed, skipping") remote_client = QdrantClient(prefer_grpc=prefer_grpc) - dense_doc_1 = models.Document( text="hello world", model=DENSE_MODEL_NAME, options={"lazy_load": True} ) @@ -772,3 +776,41 @@ def test_propagate_options(prefer_grpc): assert local_client.embedding_models[DENSE_MODEL_NAME].model.lazy_load assert local_client.sparse_embedding_models[SPARSE_MODEL_NAME].model.lazy_load assert local_client.late_interaction_embedding_models[COLBERT_MODEL_NAME].model.lazy_load + + +@pytest.mark.parametrize("prefer_grpc", [True, False]) +def test_image(prefer_grpc): + local_client = QdrantClient(":memory:") + if not local_client._FASTEMBED_INSTALLED: + pytest.skip("FastEmbed is not installed, skipping") + remote_client = QdrantClient(prefer_grpc=prefer_grpc) + local_kwargs = {} + local_client._client.upsert = arg_interceptor(local_client._client.upsert, local_kwargs) + + with open(TEST_IMAGE_PATH, "r") as f: + base64_string = f.read() + + dense_image_1 = models.Image(image=base64_string, model=DENSE_IMAGE_MODEL_NAME) + points = [ + models.PointStruct(id=i, vector=dense_img) for i, dense_img in enumerate([dense_image_1]) + ] + + for client in local_client, remote_client: + if client.collection_exists(COLLECTION_NAME): + client.delete_collection(COLLECTION_NAME) + vector_params = models.VectorParams(size=DENSE_IMAGE_DIM, distance=models.Distance.COSINE) + client.create_collection(COLLECTION_NAME, vectors_config=vector_params) + client.upsert(COLLECTION_NAME, points) + + vec_points = local_kwargs["points"] + assert all([isinstance(vec_point.vector, list) for vec_point in vec_points]) + assert local_client.scroll(COLLECTION_NAME, limit=1, with_vectors=True)[0] + compare_collections( + local_client, + remote_client, + num_vectors=10, + collection_name=COLLECTION_NAME, + ) + + local_client.delete_collection(COLLECTION_NAME) + remote_client.delete_collection(COLLECTION_NAME) From 39ea3f8e79957fb175eed60bfdf477d8d3ca3516 Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Wed, 30 Oct 2024 18:39:11 +0100 Subject: [PATCH 03/10] tests: add test file --- tests/embed_tests/misc/test_image.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 tests/embed_tests/misc/test_image.txt diff --git a/tests/embed_tests/misc/test_image.txt b/tests/embed_tests/misc/test_image.txt new file mode 100644 index 000000000..9c4dfb0e5 --- /dev/null +++ b/tests/embed_tests/misc/test_image.txt @@ -0,0 +1 @@ +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 From f63923dd43db725dfaef622e9792afb830d6a03e Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Thu, 31 Oct 2024 17:28:55 +0100 Subject: [PATCH 04/10] refactor: replace 3 different inference object vars with a common one --- qdrant_client/async_qdrant_fastembed.py | 6 +++--- qdrant_client/embed/common.py | 4 ++++ qdrant_client/embed/embed_inspector.py | 9 ++++----- qdrant_client/embed/type_inspector.py | 11 +++++------ qdrant_client/qdrant_fastembed.py | 7 ++++--- 5 files changed, 20 insertions(+), 17 deletions(-) create mode 100644 qdrant_client/embed/common.py diff --git a/qdrant_client/async_qdrant_fastembed.py b/qdrant_client/async_qdrant_fastembed.py index 9d35a951c..4172c63bd 100644 --- a/qdrant_client/async_qdrant_fastembed.py +++ b/qdrant_client/async_qdrant_fastembed.py @@ -21,6 +21,7 @@ from qdrant_client.async_client_base import AsyncQdrantBase from qdrant_client.conversions import common_types as types from qdrant_client.conversions.conversion import GrpcToRest +from qdrant_client.embed.common import INFERENCE_OBJECT_TYPES from qdrant_client.embed.embed_inspector import InspectorEmbed from qdrant_client.embed.models import NumericVector, NumericVectorStruct from qdrant_client.embed.schema_parser import ModelSchemaParser @@ -82,7 +83,6 @@ class AsyncQdrantFastembedMixin(AsyncQdrantBase): DEFAULT_EMBEDDING_MODEL = "BAAI/bge-small-en" - INFERENCE_OBJECT_TYPES = (models.Document, models.Image) embedding_models: Dict[str, "TextEmbedding"] = {} sparse_embedding_models: Dict[str, "SparseTextEmbedding"] = {} late_interaction_embedding_models: Dict[str, "LateInteractionTextEmbedding"] = {} @@ -807,7 +807,7 @@ def _resolve_query( GrpcToRest.convert_point_id(query) if isinstance(query, grpc.PointId) else query ) return models.NearestQuery(nearest=query) - if isinstance(query, cls.INFERENCE_OBJECT_TYPES): + if isinstance(query, INFERENCE_OBJECT_TYPES): model_name = query.model if model_name is None: raise ValueError(f"`model` field has to be set explicitly in the {type(query)}") @@ -856,7 +856,7 @@ def _embed_models( A deepcopy of the method with embedded fields """ if paths is None: - if isinstance(model, self.INFERENCE_OBJECT_TYPES): + if isinstance(model, INFERENCE_OBJECT_TYPES): return self._embed_raw_data(model, is_query=is_query) model = deepcopy(model) paths = self._embed_inspector.inspect(model) diff --git a/qdrant_client/embed/common.py b/qdrant_client/embed/common.py new file mode 100644 index 000000000..f115ce8fb --- /dev/null +++ b/qdrant_client/embed/common.py @@ -0,0 +1,4 @@ +from qdrant_client import models + +INFERENCE_OBJECT_NAMES = {"Document", "Image"} +INFERENCE_OBJECT_TYPES = (models.Document, models.Image) diff --git a/qdrant_client/embed/embed_inspector.py b/qdrant_client/embed/embed_inspector.py index 92923f604..a0d8a8acd 100644 --- a/qdrant_client/embed/embed_inspector.py +++ b/qdrant_client/embed/embed_inspector.py @@ -4,6 +4,7 @@ from pydantic import BaseModel from qdrant_client._pydantic_compat import model_fields_set +from qdrant_client.embed.common import INFERENCE_OBJECT_TYPES from qdrant_client.embed.schema_parser import ModelSchemaParser from qdrant_client.embed.utils import convert_paths, Path @@ -17,8 +18,6 @@ class InspectorEmbed: parser: ModelSchemaParser instance """ - INFERENCE_OBJECT_TYPES = models.Document, models.Image - def __init__(self, parser: Optional[ModelSchemaParser] = None) -> None: self.parser = ModelSchemaParser() if parser is None else parser @@ -114,7 +113,7 @@ def inspect_recursive(member: BaseModel, accumulator: str) -> List[str]: if model is None: return [] - if isinstance(model, self.INFERENCE_OBJECT_TYPES): + if isinstance(model, INFERENCE_OBJECT_TYPES): return [accum] if isinstance(model, BaseModel): @@ -134,7 +133,7 @@ def inspect_recursive(member: BaseModel, accumulator: str) -> List[str]: if not isinstance(current_model, BaseModel): continue - if isinstance(current_model, self.INFERENCE_OBJECT_TYPES): + if isinstance(current_model, INFERENCE_OBJECT_TYPES): found_paths.append(accum) found_paths.extend(inspect_recursive(current_model, accum)) @@ -159,7 +158,7 @@ def inspect_recursive(member: BaseModel, accumulator: str) -> List[str]: if not isinstance(current_model, BaseModel): continue - if isinstance(current_model, self.INFERENCE_OBJECT_TYPES): + if isinstance(current_model, INFERENCE_OBJECT_TYPES): found_paths.append(accum) found_paths.extend(inspect_recursive(current_model, accum)) diff --git a/qdrant_client/embed/type_inspector.py b/qdrant_client/embed/type_inspector.py index 184d65d32..7c820d6a6 100644 --- a/qdrant_client/embed/type_inspector.py +++ b/qdrant_client/embed/type_inspector.py @@ -2,6 +2,7 @@ from pydantic import BaseModel +from qdrant_client.embed.common import INFERENCE_OBJECT_TYPES from qdrant_client.embed.schema_parser import ModelSchemaParser from qdrant_client.embed.utils import Path from qdrant_client.http import models @@ -16,8 +17,6 @@ class Inspector: parser: ModelSchemaParser instance to inspect model json schemas """ - INFERENCE_OBJECT_TYPES = models.Document, models.Image - def __init__(self, parser: Optional[ModelSchemaParser] = None) -> None: self.parser = ModelSchemaParser() if parser is None else parser @@ -43,7 +42,7 @@ def inspect(self, points: Union[Iterable[BaseModel], BaseModel]) -> bool: return False def _inspect_model(self, model: BaseModel, paths: Optional[List[Path]] = None) -> bool: - if isinstance(model, self.INFERENCE_OBJECT_TYPES): + if isinstance(model, INFERENCE_OBJECT_TYPES): return True paths = ( @@ -82,7 +81,7 @@ def inspect_recursive(member: BaseModel) -> bool: if model is None: return False - if isinstance(model, self.INFERENCE_OBJECT_TYPES): + if isinstance(model, INFERENCE_OBJECT_TYPES): return True if isinstance(model, BaseModel): @@ -100,7 +99,7 @@ def inspect_recursive(member: BaseModel) -> bool: elif isinstance(model, list): for current_model in model: - if isinstance(current_model, self.INFERENCE_OBJECT_TYPES): + if isinstance(current_model, INFERENCE_OBJECT_TYPES): return True if not isinstance(current_model, BaseModel): @@ -123,7 +122,7 @@ def inspect_recursive(member: BaseModel) -> bool: for key, values in model.items(): values = [values] if not isinstance(values, list) else values for current_model in values: - if isinstance(current_model, self.INFERENCE_OBJECT_TYPES): + if isinstance(current_model, INFERENCE_OBJECT_TYPES): return True if not isinstance(current_model, BaseModel): diff --git a/qdrant_client/qdrant_fastembed.py b/qdrant_client/qdrant_fastembed.py index bca986193..bd6118f96 100644 --- a/qdrant_client/qdrant_fastembed.py +++ b/qdrant_client/qdrant_fastembed.py @@ -14,6 +14,7 @@ from qdrant_client.client_base import QdrantBase from qdrant_client.conversions import common_types as types from qdrant_client.conversions.conversion import GrpcToRest +from qdrant_client.embed.common import INFERENCE_OBJECT_TYPES from qdrant_client.embed.embed_inspector import InspectorEmbed from qdrant_client.embed.models import NumericVector, NumericVectorStruct from qdrant_client.embed.schema_parser import ModelSchemaParser @@ -81,7 +82,7 @@ class QdrantFastembedMixin(QdrantBase): DEFAULT_EMBEDDING_MODEL = "BAAI/bge-small-en" - INFERENCE_OBJECT_TYPES = models.Document, models.Image + embedding_models: Dict[str, "TextEmbedding"] = {} sparse_embedding_models: Dict[str, "SparseTextEmbedding"] = {} late_interaction_embedding_models: Dict[str, "LateInteractionTextEmbedding"] = {} @@ -892,7 +893,7 @@ def _resolve_query( ) return models.NearestQuery(nearest=query) - if isinstance(query, cls.INFERENCE_OBJECT_TYPES): + if isinstance(query, INFERENCE_OBJECT_TYPES): model_name = query.model if model_name is None: raise ValueError(f"`model` field has to be set explicitly in the {type(query)}") @@ -946,7 +947,7 @@ def _embed_models( A deepcopy of the method with embedded fields """ if paths is None: - if isinstance(model, self.INFERENCE_OBJECT_TYPES): + if isinstance(model, INFERENCE_OBJECT_TYPES): return self._embed_raw_data(model, is_query=is_query) model = deepcopy(model) paths = self._embed_inspector.inspect(model) From a2975e55b930f60bf28e7abe9ec6deb5cda42a40 Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Thu, 31 Oct 2024 17:31:51 +0100 Subject: [PATCH 05/10] fix: fix type hints --- qdrant_client/embed/common.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/qdrant_client/embed/common.py b/qdrant_client/embed/common.py index f115ce8fb..f2217397f 100644 --- a/qdrant_client/embed/common.py +++ b/qdrant_client/embed/common.py @@ -1,4 +1,6 @@ -from qdrant_client import models +from typing import Set, Tuple -INFERENCE_OBJECT_NAMES = {"Document", "Image"} -INFERENCE_OBJECT_TYPES = (models.Document, models.Image) +from qdrant_client.http import models + +INFERENCE_OBJECT_NAMES: Set[str] = {"Document", "Image"} +INFERENCE_OBJECT_TYPES: Tuple = (models.Document, models.Image) From 75058ad92ca4bf04c8ce3390ae2af64879f7b7a7 Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Thu, 31 Oct 2024 17:53:53 +0100 Subject: [PATCH 06/10] fix: fix type hints --- qdrant_client/embed/common.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/qdrant_client/embed/common.py b/qdrant_client/embed/common.py index f2217397f..f4ff7cf72 100644 --- a/qdrant_client/embed/common.py +++ b/qdrant_client/embed/common.py @@ -1,6 +1,9 @@ -from typing import Set, Tuple +from typing import Set, Type, Tuple from qdrant_client.http import models INFERENCE_OBJECT_NAMES: Set[str] = {"Document", "Image"} -INFERENCE_OBJECT_TYPES: Tuple = (models.Document, models.Image) +INFERENCE_OBJECT_TYPES: Tuple[Type[models.Document], Type[models.Image]] = ( + models.Document, + models.Image, +) From 48d97d5a9f44cb329d6d4bda2162543f3fc4858c Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Thu, 31 Oct 2024 18:00:38 +0100 Subject: [PATCH 07/10] tests: add tests --- tests/embed_tests/test_local_inference.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/embed_tests/test_local_inference.py b/tests/embed_tests/test_local_inference.py index 66de26105..3a90d66ca 100644 --- a/tests/embed_tests/test_local_inference.py +++ b/tests/embed_tests/test_local_inference.py @@ -812,5 +812,8 @@ def test_image(prefer_grpc): collection_name=COLLECTION_NAME, ) + local_client.query_points(COLLECTION_NAME, dense_image_1) + remote_client.query_points(COLLECTION_NAME, dense_image_1) + local_client.delete_collection(COLLECTION_NAME) remote_client.delete_collection(COLLECTION_NAME) From 5339fb46243ed5ba606ab0a7ac76a890eb17aaf1 Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Thu, 31 Oct 2024 18:06:54 +0100 Subject: [PATCH 08/10] fix: remove redundant imports --- qdrant_client/embed/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/qdrant_client/embed/models.py b/qdrant_client/embed/models.py index 6cc3011c1..47de15fd3 100644 --- a/qdrant_client/embed/models.py +++ b/qdrant_client/embed/models.py @@ -3,7 +3,6 @@ from pydantic import StrictFloat, StrictStr from qdrant_client.http.models import ExtendedPointId, SparseVector -from qdrant_client.models import Document, Image # type: ignore[attr-defined] NumericVector = Union[ From a1454a719f4ccd915c1b9f588e79a565eb51ccab Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Thu, 31 Oct 2024 18:45:53 +0100 Subject: [PATCH 09/10] new: propagate image options --- qdrant_client/async_qdrant_fastembed.py | 4 +++- qdrant_client/qdrant_fastembed.py | 4 +++- tests/embed_tests/test_local_inference.py | 8 ++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/qdrant_client/async_qdrant_fastembed.py b/qdrant_client/async_qdrant_fastembed.py index 4172c63bd..00b868bb6 100644 --- a/qdrant_client/async_qdrant_fastembed.py +++ b/qdrant_client/async_qdrant_fastembed.py @@ -970,7 +970,9 @@ def _embed_image(self, image: models.Image) -> NumericVector: """ model_name = image.model if model_name in _IMAGE_EMBEDDING_MODELS: - embedding_model_inst = self._get_or_init_image_model(model_name=model_name) + embedding_model_inst = self._get_or_init_image_model( + model_name=model_name, **image.options or {} + ) image_data = base64.b64decode(image.image) with io.BytesIO(image_data) as buffer: with PilImage.open(buffer) as image: diff --git a/qdrant_client/qdrant_fastembed.py b/qdrant_client/qdrant_fastembed.py index bd6118f96..3b088cf4b 100644 --- a/qdrant_client/qdrant_fastembed.py +++ b/qdrant_client/qdrant_fastembed.py @@ -1064,7 +1064,9 @@ def _embed_image(self, image: models.Image) -> NumericVector: """ model_name = image.model if model_name in _IMAGE_EMBEDDING_MODELS: - embedding_model_inst = self._get_or_init_image_model(model_name=model_name) + embedding_model_inst = self._get_or_init_image_model( + model_name=model_name, **(image.options or {}) + ) image_data = base64.b64decode(image.image) with io.BytesIO(image_data) as buffer: with PilImage.open(buffer) as image: diff --git a/tests/embed_tests/test_local_inference.py b/tests/embed_tests/test_local_inference.py index 3a90d66ca..2f644aa95 100644 --- a/tests/embed_tests/test_local_inference.py +++ b/tests/embed_tests/test_local_inference.py @@ -731,6 +731,12 @@ def test_propagate_options(prefer_grpc): multi_doc_1 = models.Document( text="hello world", model=COLBERT_MODEL_NAME, options={"lazy_load": True} ) + with open(TEST_IMAGE_PATH, "r") as f: + base64_string = f.read() + + dense_image_1 = models.Image( + image=base64_string, model=DENSE_IMAGE_MODEL_NAME, options={"lazy_load": True} + ) points = [ models.PointStruct( @@ -739,6 +745,7 @@ def test_propagate_options(prefer_grpc): "text": dense_doc_1, "multi-text": multi_doc_1, "sparse-text": sparse_doc_1, + "image": dense_image_1, }, ) ] @@ -752,6 +759,7 @@ def test_propagate_options(prefer_grpc): comparator=models.MultiVectorComparator.MAX_SIM ), ), + "image": models.VectorParams(size=DENSE_IMAGE_DIM, distance=models.Distance.COSINE), } sparse_vectors_config = { "sparse-text": models.SparseVectorParams(modifier=models.Modifier.IDF) From aa81592d636b3711df25ac0bd8471ec2e9ed9cd4 Mon Sep 17 00:00:00 2001 From: George Date: Fri, 1 Nov 2024 22:32:17 +0100 Subject: [PATCH 10/10] Custom inference object (#837) * new: add inference object support * new: add inference object support * fix: remove redundant import * refactor: return newline * fix: fix propagate options test --- qdrant_client/async_qdrant_fastembed.py | 36 ++++- qdrant_client/embed/common.py | 9 +- qdrant_client/qdrant_fastembed.py | 40 +++++- tests/embed_tests/test_local_inference.py | 153 +++++++++++++++++++++- 4 files changed, 222 insertions(+), 16 deletions(-) diff --git a/qdrant_client/async_qdrant_fastembed.py b/qdrant_client/async_qdrant_fastembed.py index 00b868bb6..18dc11f09 100644 --- a/qdrant_client/async_qdrant_fastembed.py +++ b/qdrant_client/async_qdrant_fastembed.py @@ -882,6 +882,32 @@ def _embed_models( setattr(item, path.current, embeddings[0]) return model + @staticmethod + def _resolve_inference_object(data: models.VectorStruct) -> models.VectorStruct: + """Resolve inference object into a model + + Args: + data: models.VectorStruct - data to resolve, if it's an inference object, convert it to a proper type, + otherwise - keep unchanged + + Returns: + models.VectorStruct: resolved data + """ + if not isinstance(data, models.InferenceObject): + return data + model_name = data.model + value = data.object + options = data.options + if model_name in ( + *SUPPORTED_EMBEDDING_MODELS.keys(), + *SUPPORTED_SPARSE_EMBEDDING_MODELS.keys(), + *_LATE_INTERACTION_EMBEDDING_MODELS.keys(), + ): + return models.Document(model=model_name, text=value, options=options) + if model_name in _IMAGE_EMBEDDING_MODELS: + return models.Image(model=model_name, image=value, options=options) + raise ValueError(f"{model_name} is not among supported models") + def _embed_raw_data( self, data: models.VectorStruct, is_query: bool = False ) -> NumericVectorStruct: @@ -894,6 +920,7 @@ def _embed_raw_data( Returns: NumericVectorStruct: Embedded data """ + data = self._resolve_inference_object(data) if isinstance(data, models.Document): return self._embed_document(data, is_query=is_query) elif isinstance(data, models.Image): @@ -924,10 +951,9 @@ def _embed_document(self, document: models.Document, is_query: bool = False) -> """ model_name = document.model text = document.text + options = document.options or {} if model_name in SUPPORTED_EMBEDDING_MODELS: - embedding_model_inst = self._get_or_init_model( - model_name=model_name, **document.options or {} - ) + embedding_model_inst = self._get_or_init_model(model_name=model_name, **options) if not is_query: embedding = list(embedding_model_inst.embed(documents=[text]))[0].tolist() else: @@ -935,7 +961,7 @@ def _embed_document(self, document: models.Document, is_query: bool = False) -> return embedding elif model_name in SUPPORTED_SPARSE_EMBEDDING_MODELS: sparse_embedding_model_inst = self._get_or_init_sparse_model( - model_name=model_name, **document.options or {} + model_name=model_name, **options ) if not is_query: sparse_embedding = list(sparse_embedding_model_inst.embed(documents=[text]))[0] @@ -946,7 +972,7 @@ def _embed_document(self, document: models.Document, is_query: bool = False) -> ) elif model_name in _LATE_INTERACTION_EMBEDDING_MODELS: li_embedding_model_inst = self._get_or_init_late_interaction_model( - model_name=model_name, **document.options or {} + model_name=model_name, **options ) if not is_query: embedding = list(li_embedding_model_inst.embed(documents=[text]))[0].tolist() diff --git a/qdrant_client/embed/common.py b/qdrant_client/embed/common.py index f4ff7cf72..d35864574 100644 --- a/qdrant_client/embed/common.py +++ b/qdrant_client/embed/common.py @@ -2,8 +2,7 @@ from qdrant_client.http import models -INFERENCE_OBJECT_NAMES: Set[str] = {"Document", "Image"} -INFERENCE_OBJECT_TYPES: Tuple[Type[models.Document], Type[models.Image]] = ( - models.Document, - models.Image, -) +INFERENCE_OBJECT_NAMES: Set[str] = {"Document", "Image", "InferenceObject"} +INFERENCE_OBJECT_TYPES: Tuple[ + Type[models.Document], Type[models.Image], Type[models.InferenceObject] +] = (models.Document, models.Image, models.InferenceObject) diff --git a/qdrant_client/qdrant_fastembed.py b/qdrant_client/qdrant_fastembed.py index 3b088cf4b..3858c7388 100644 --- a/qdrant_client/qdrant_fastembed.py +++ b/qdrant_client/qdrant_fastembed.py @@ -973,6 +973,35 @@ def _embed_models( setattr(item, path.current, embeddings[0]) return model + @staticmethod + def _resolve_inference_object(data: models.VectorStruct) -> models.VectorStruct: + """Resolve inference object into a model + + Args: + data: models.VectorStruct - data to resolve, if it's an inference object, convert it to a proper type, + otherwise - keep unchanged + + Returns: + models.VectorStruct: resolved data + """ + + if not isinstance(data, models.InferenceObject): + return data + + model_name = data.model + value = data.object + options = data.options + if model_name in ( + *SUPPORTED_EMBEDDING_MODELS.keys(), + *SUPPORTED_SPARSE_EMBEDDING_MODELS.keys(), + *_LATE_INTERACTION_EMBEDDING_MODELS.keys(), + ): + return models.Document(model=model_name, text=value, options=options) + if model_name in _IMAGE_EMBEDDING_MODELS: + return models.Image(model=model_name, image=value, options=options) + + raise ValueError(f"{model_name} is not among supported models") + def _embed_raw_data( self, data: models.VectorStruct, @@ -987,6 +1016,8 @@ def _embed_raw_data( Returns: NumericVectorStruct: Embedded data """ + data = self._resolve_inference_object(data) + if isinstance(data, models.Document): return self._embed_document(data, is_query=is_query) elif isinstance(data, models.Image): @@ -1017,10 +1048,9 @@ def _embed_document(self, document: models.Document, is_query: bool = False) -> """ model_name = document.model text = document.text + options = document.options or {} if model_name in SUPPORTED_EMBEDDING_MODELS: - embedding_model_inst = self._get_or_init_model( - model_name=model_name, **(document.options or {}) - ) + embedding_model_inst = self._get_or_init_model(model_name=model_name, **options) if not is_query: embedding = list(embedding_model_inst.embed(documents=[text]))[0].tolist() else: @@ -1028,7 +1058,7 @@ def _embed_document(self, document: models.Document, is_query: bool = False) -> return embedding elif model_name in SUPPORTED_SPARSE_EMBEDDING_MODELS: sparse_embedding_model_inst = self._get_or_init_sparse_model( - model_name=model_name, **(document.options or {}) + model_name=model_name, **options ) if not is_query: sparse_embedding = list(sparse_embedding_model_inst.embed(documents=[text]))[0] @@ -1040,7 +1070,7 @@ def _embed_document(self, document: models.Document, is_query: bool = False) -> ) elif model_name in _LATE_INTERACTION_EMBEDDING_MODELS: li_embedding_model_inst = self._get_or_init_late_interaction_model( - model_name=model_name, **(document.options or {}) + model_name=model_name, **options ) if not is_query: embedding = list(li_embedding_model_inst.embed(documents=[text]))[0].tolist() diff --git a/tests/embed_tests/test_local_inference.py b/tests/embed_tests/test_local_inference.py index 2f644aa95..5ae22a9dd 100644 --- a/tests/embed_tests/test_local_inference.py +++ b/tests/embed_tests/test_local_inference.py @@ -727,7 +727,6 @@ def test_propagate_options(prefer_grpc): sparse_doc_1 = models.Document( text="hello world", model=SPARSE_MODEL_NAME, options={"lazy_load": True} ) - multi_doc_1 = models.Document( text="hello world", model=COLBERT_MODEL_NAME, options={"lazy_load": True} ) @@ -784,6 +783,56 @@ def test_propagate_options(prefer_grpc): assert local_client.embedding_models[DENSE_MODEL_NAME].model.lazy_load assert local_client.sparse_embedding_models[SPARSE_MODEL_NAME].model.lazy_load assert local_client.late_interaction_embedding_models[COLBERT_MODEL_NAME].model.lazy_load + assert local_client.image_embedding_models[DENSE_IMAGE_MODEL_NAME].model.lazy_load + + local_client.embedding_models.clear() + local_client.sparse_embedding_models.clear() + local_client.late_interaction_embedding_models.clear() + local_client.image_embedding_models.clear() + + inference_object_dense_doc_1 = models.InferenceObject( + object="hello world", + model=DENSE_MODEL_NAME, + options={"lazy_load": True}, + ) + + inference_object_sparse_doc_1 = models.InferenceObject( + object="hello world", + model=SPARSE_MODEL_NAME, + options={"lazy_load": True}, + ) + + inference_object_multi_doc_1 = models.InferenceObject( + object="hello world", + model=COLBERT_MODEL_NAME, + options={"lazy_load": True}, + ) + + inference_object_dense_image_1 = models.InferenceObject( + object=base64_string, + model=DENSE_IMAGE_MODEL_NAME, + options={"lazy_load": True}, + ) + + points = [ + models.PointStruct( + id=2, + vector={ + "text": inference_object_dense_doc_1, + "multi-text": inference_object_multi_doc_1, + "sparse-text": inference_object_sparse_doc_1, + "image": inference_object_dense_image_1, + }, + ) + ] + + local_client.upsert(COLLECTION_NAME, points) + remote_client.upsert(COLLECTION_NAME, points) + + assert local_client.embedding_models[DENSE_MODEL_NAME].model.lazy_load + assert local_client.sparse_embedding_models[SPARSE_MODEL_NAME].model.lazy_load + assert local_client.late_interaction_embedding_models[COLBERT_MODEL_NAME].model.lazy_load + assert local_client.image_embedding_models[DENSE_IMAGE_MODEL_NAME].model.lazy_load @pytest.mark.parametrize("prefer_grpc", [True, False]) @@ -825,3 +874,105 @@ def test_image(prefer_grpc): local_client.delete_collection(COLLECTION_NAME) remote_client.delete_collection(COLLECTION_NAME) + + +@pytest.mark.parametrize("prefer_grpc", [True, False]) +def test_inference_object(prefer_grpc): + local_client = QdrantClient(":memory:") + if not local_client._FASTEMBED_INSTALLED: + pytest.skip("FastEmbed is not installed, skipping") + remote_client = QdrantClient(prefer_grpc=prefer_grpc) + local_kwargs = {} + local_client._client.upsert = arg_interceptor(local_client._client.upsert, local_kwargs) + + with open(TEST_IMAGE_PATH, "r") as f: + base64_string = f.read() + + inference_object_dense_doc_1 = models.InferenceObject( + object="hello world", + model=DENSE_MODEL_NAME, + options={"lazy_load": True}, + ) + + inference_object_sparse_doc_1 = models.InferenceObject( + object="hello world", + model=SPARSE_MODEL_NAME, + options={"lazy_load": True}, + ) + + inference_object_multi_doc_1 = models.InferenceObject( + object="hello world", + model=COLBERT_MODEL_NAME, + options={"lazy_load": True}, + ) + + inference_object_dense_image_1 = models.InferenceObject( + object=base64_string, + model=DENSE_IMAGE_MODEL_NAME, + options={"lazy_load": True}, + ) + + points = [ + models.PointStruct( + id=1, + vector={ + "text": inference_object_dense_doc_1, + "multi-text": inference_object_multi_doc_1, + "sparse-text": inference_object_sparse_doc_1, + "image": inference_object_dense_image_1, + }, + ) + ] + vectors_config = { + "text": models.VectorParams(size=DENSE_DIM, distance=models.Distance.COSINE), + "multi-text": models.VectorParams( + size=COLBERT_DIM, + distance=models.Distance.COSINE, + multivector_config=models.MultiVectorConfig( + comparator=models.MultiVectorComparator.MAX_SIM + ), + ), + "image": models.VectorParams(size=DENSE_IMAGE_DIM, distance=models.Distance.COSINE), + } + sparse_vectors_config = { + "sparse-text": models.SparseVectorParams(modifier=models.Modifier.IDF) + } + + for client in local_client, remote_client: + if client.collection_exists(COLLECTION_NAME): + client.delete_collection(COLLECTION_NAME) + client.create_collection( + COLLECTION_NAME, + vectors_config=vectors_config, + sparse_vectors_config=sparse_vectors_config, + ) + client.upsert(COLLECTION_NAME, points) + + vec_points = local_kwargs["points"] + vector = vec_points[0].vector + assert isinstance(vector["text"], list) + assert isinstance(vector["multi-text"], list) + assert isinstance(vector["sparse-text"], models.SparseVector) + assert isinstance(vector["image"], list) + assert local_client.scroll(COLLECTION_NAME, limit=1, with_vectors=True)[0] + compare_collections( + local_client, + remote_client, + num_vectors=10, + collection_name=COLLECTION_NAME, + ) + + local_client.query_points(COLLECTION_NAME, inference_object_dense_doc_1, using="text") + remote_client.query_points(COLLECTION_NAME, inference_object_dense_doc_1, using="text") + + local_client.query_points(COLLECTION_NAME, inference_object_sparse_doc_1, using="sparse-text") + remote_client.query_points(COLLECTION_NAME, inference_object_sparse_doc_1, using="sparse-text") + + local_client.query_points(COLLECTION_NAME, inference_object_multi_doc_1, using="multi-text") + remote_client.query_points(COLLECTION_NAME, inference_object_multi_doc_1, using="multi-text") + + local_client.query_points(COLLECTION_NAME, inference_object_dense_image_1, using="image") + remote_client.query_points(COLLECTION_NAME, inference_object_dense_image_1, using="image") + + local_client.delete_collection(COLLECTION_NAME) + remote_client.delete_collection(COLLECTION_NAME)