deprecate: completely replace fastembed bm25 with qdrant core bm25 in hosted qdrant (#1166)

* deprecate: completely replace fastembed bm25 with qdrant core bm25

* fix: fix param name in embedder, accept lower case bm25 model name

* tests: enable skipped tests, add bm25 core usage tests

* fix: fix ci, run fastembed tests in integration tests

* fix: fix pip uninstall prompt

* tests: log message when running tests wo fastembed
This commit is contained in:
George
2026-03-13 01:23:44 +07:00
committed by George Panchuk
parent 27633971df
commit cb4af4f657
7 changed files with 94 additions and 139 deletions

View File

@@ -69,12 +69,6 @@ jobs:
run: |
QDRANT_VERSION='v1.16.3' ./tests/integration-tests.sh
shell: bash
- name: Run fastembed tests without fastembed
run: |
pip3 uninstall fastembed -y
pytest -x tests/test_fastembed.py
pytest -x tests/embed_tests/test_local_inference.py
shell: bash
- name: Upload failed snapshot if tests fail
if: failure()
uses: actions/upload-artifact@v4

View File

@@ -1,94 +0,0 @@
from typing import Any
from qdrant_client.http import models
from qdrant_client.embed.models import NumericVector
class BuiltinEmbedder:
_SUPPORTED_MODELS = ("Qdrant/Bm25",)
def __init__(self, **kwargs: Any) -> None:
pass
def embed(
self,
model_name: str,
texts: list[str] | None = None,
options: dict[str, Any] | None = None,
**kwargs: Any,
) -> NumericVector:
if texts is None:
if "images" in kwargs:
raise ValueError(
"Image processing is only available with cloud inference of FastEmbed"
)
raise ValueError("Texts must be provided for the inference")
if not self.is_supported_sparse_model(model_name):
raise ValueError(
f"Model {model_name} is not supported in {self.__class__.__name__}. "
f"Did you forget to enable cloud inference or install FastEmbed for local inference?"
)
return [models.Document(text=text, options=options, model=model_name) for text in texts]
@classmethod
def is_supported_text_model(cls, model_name: str) -> bool:
"""Mock embedder interface, only sparse text model Qdrant/Bm25 is supported
Args:
model_name (str): The name of the model to check.
Returns:
bool: True if the model is supported, False otherwise.
"""
return False # currently only Qdrant/Bm25 is supported
@classmethod
def is_supported_image_model(cls, model_name: str) -> bool:
"""Mock embedder interface, only sparse text model Qdrant/Bm25 is supported
Args:
model_name (str): The name of the model to check.
Returns:
bool: True if the model is supported, False otherwise.
"""
return False # currently only Qdrant/Bm25 is supported
@classmethod
def is_supported_late_interaction_text_model(cls, model_name: str) -> bool:
"""Mock embedder interface, only sparse text model Qdrant/Bm25 is supported
Args:
model_name (str): The name of the model to check.
Returns:
bool: True if the model is supported, False otherwise.
"""
return False # currently only Qdrant/Bm25 is supported
@classmethod
def is_supported_late_interaction_multimodal_model(cls, model_name: str) -> bool:
"""Mock embedder interface, only sparse text model Qdrant/Bm25 is supported
Args:
model_name (str): The name of the model to check.
Returns:
bool: True if the model is supported, False otherwise.
"""
return False # currently only Qdrant/Bm25 is supported
@classmethod
def is_supported_sparse_model(cls, model_name: str) -> bool:
"""Checks if the model is supported. Only `Qdrant/Bm25` is supported
Args:
model_name (str): The name of the model to check.
Returns:
bool: True if the model is supported, False otherwise.
"""
return model_name.lower() in [model.lower() for model in cls._SUPPORTED_MODELS]

View File

@@ -27,7 +27,9 @@ class ModelInstance(BaseModel, Generic[T], arbitrary_types_allowed=True): # typ
class Embedder:
def __init__(self, threads: int | None = None, **kwargs: Any) -> None:
def __init__(
self, threads: int | None = None, use_core_bm25: bool = True, **kwargs: Any
) -> None:
self.embedding_models: dict[str, list[ModelInstance[TextEmbedding]]] = defaultdict(list)
self.sparse_embedding_models: dict[str, list[ModelInstance[SparseTextEmbedding]]] = (
defaultdict(list)
@@ -42,6 +44,7 @@ class Embedder:
str, list[ModelInstance[LateInteractionMultimodalEmbedding]]
] = defaultdict(list)
self._threads = threads
self._use_core_bm25 = use_core_bm25
def get_or_init_model(
self,
@@ -227,7 +230,7 @@ class Embedder:
options: dict[str, Any] | None = None,
is_query: bool = False,
batch_size: int = 8,
) -> NumericVector:
) -> NumericVector | list[models.Document]:
if (texts is None) is (images is None):
raise ValueError("Either documents or images should be provided")
@@ -237,7 +240,7 @@ class Embedder:
embeddings = self._embed_dense_text(
texts, model_name, options, is_query, batch_size
)
elif FastEmbedMisc.is_supported_sparse_model(model_name):
elif self.is_supported_sparse_model(model_name):
embeddings = self._embed_sparse_text(
texts, model_name, options, is_query, batch_size
)
@@ -294,7 +297,12 @@ class Embedder:
options: dict[str, Any] | None,
is_query: bool,
batch_size: int,
) -> list[models.SparseVector]:
) -> list[models.SparseVector] | list[models.Document]:
if self._use_core_bm25 and model_name.lower() == "Qdrant/bm25".lower():
return [
models.Document(text=text, model=model_name, options=options) for text in texts
]
embedding_model_inst = self.get_or_init_sparse_model(
model_name=model_name, **options or {}
)
@@ -434,8 +442,7 @@ class Embedder:
"""
return FastEmbedMisc.is_supported_late_interaction_multimodal_model(model_name)
@classmethod
def is_supported_sparse_model(cls, model_name: str) -> bool:
def is_supported_sparse_model(self, model_name: str) -> bool:
"""Check if model is supported by fastembed
Args:
@@ -444,4 +451,6 @@ class Embedder:
Returns:
bool: True if the model is supported, False otherwise.
"""
if self._use_core_bm25 and model_name.lower() == "Qdrant/bm25".lower():
return True
return FastEmbedMisc.is_supported_sparse_model(model_name)

View File

@@ -6,7 +6,6 @@ from typing import Iterable, Any, Type, get_args
from pydantic import BaseModel
from qdrant_client.embed.builtin_embedder import BuiltinEmbedder
from qdrant_client.http import models
from qdrant_client.embed.common import INFERENCE_OBJECT_TYPES
from qdrant_client.embed.embed_inspector import InspectorEmbed
@@ -51,14 +50,13 @@ class ModelEmbedder:
**kwargs: Any,
):
self._batch_accumulator: dict[str, list[INFERENCE_OBJECT_TYPES]] = {}
self._embed_storage: dict[str, list[NumericVector]] = {}
self._embed_storage: dict[str, list[NumericVector] | list[models.Document]] = {}
self._embed_inspector = InspectorEmbed(parser=parser)
self._is_builtin_embedder_available = self._check_builtin_embedder_availability(
is_local_mode, server_version
)
self.embedder = (
Embedder(**kwargs) if FastEmbedMisc.is_installed() else BuiltinEmbedder(**kwargs)
)
is_local_mode=is_local_mode, server_version=server_version
) # check if bm25 is available in Qdrant core
self._fastembed_available = FastEmbedMisc.is_installed()
self.embedder = Embedder(use_core_bm25=self._is_builtin_embedder_available, **kwargs)
@staticmethod
def _check_builtin_embedder_availability(
@@ -139,12 +137,7 @@ class ModelEmbedder:
if len(raw_models) < batch_size:
is_small = True
if (
isinstance(self.embedder, BuiltinEmbedder)
or parallel is None
or parallel == 1
or is_small
):
if parallel is None or parallel == 1 or is_small:
for batch in iter_batch(raw_models, batch_size):
yield from self.embed_models_batch(batch, inference_batch_size=batch_size)
else:
@@ -211,7 +204,13 @@ class ModelEmbedder:
is_query: bool = False,
accumulating: bool = False,
inference_batch_size: int | None = None,
) -> dict[str, BaseModel] | dict[str, NumericVector] | BaseModel | NumericVector:
) -> (
dict[str, BaseModel]
| dict[str, NumericVector]
| BaseModel
| NumericVector
| models.Document
):
"""Embed model's fields requiring inference
Args:
@@ -326,7 +325,7 @@ class ModelEmbedder:
def _drain_accumulator(
self, data: models.VectorStruct, is_query: bool, inference_batch_size: int = 8
) -> NumericVectorStruct:
) -> NumericVectorStruct | models.Document:
"""Drain accumulator and replaces inference objects with computed embeddings
It is assumed objects are traversed in the same order as they were added to the accumulator
@@ -348,7 +347,7 @@ class ModelEmbedder:
if isinstance(data, list):
for i, value in enumerate(data):
if not isinstance(value, get_args(INFERENCE_OBJECT_TYPES)): # if value is vector
if not isinstance(value, get_args(INFERENCE_OBJECT_TYPES)): # if value is a vector
return data
data[i] = self._drain_accumulator(
@@ -378,9 +377,11 @@ class ModelEmbedder:
def embed(
objects: list[INFERENCE_OBJECT_TYPES], model_name: str, batch_size: int
) -> list[NumericVector]:
) -> list[NumericVector] | list[models.Document]:
"""
Assemble batches by options and data type based groups, embeds and return embeddings in the original order
Assemble batches by options and data type based groups, embeds and return embeddings in the original order.
If models.Document model is bm25 and Qdrant version is 1.15.3 or higher, return the document without changes
to be processed by Qdrant itself.
"""
unique_options: list[dict[str, Any]] = []
unique_options_is_text: list[bool] = [] # multimodal models can have both text
@@ -436,13 +437,10 @@ class ModelEmbedder:
self.embedder.is_supported_late_interaction_multimodal_model(model),
)
):
if isinstance(self.embedder, BuiltinEmbedder):
raise ValueError(
f"{model} is not among supported models. "
f"Have you forgotten to set `cloud_inference` or install `fastembed` for local inference?"
)
else:
raise ValueError(f"{model} is not among supported models")
raise ValueError(
f"{model} is not found among supported models."
f"Check if `cloud_inference` is set to True or `fastembed` is installed (for local inference)?"
)
for model, data in self._batch_accumulator.items():
self._embed_storage[model] = embed(
@@ -450,14 +448,14 @@ class ModelEmbedder:
)
self._batch_accumulator.clear()
def _next_embed(self, model_name: str) -> NumericVector:
def _next_embed(self, model_name: str) -> NumericVector | models.Document:
"""Get next computed embedding from embedded batch
Args:
model_name: str - retrieve embedding from the storage by this model name
Returns:
NumericVector: computed embedding
NumericVector | models.Document : computed embedding
"""
return self._embed_storage[model_name].pop(0)

View File

@@ -74,7 +74,7 @@ class FastEmbedMisc:
# If it's not, ask the user to install it
raise ImportError(
"fastembed is not installed."
" Please install it to enable fast vector indexing with `pip install fastembed`."
" Please install it to compute embedding for document implicitly with `pip install fastembed`."
)
@classmethod

View File

@@ -20,7 +20,6 @@ from qdrant_client.fastembed_common import (
ImageEmbedding,
)
COLLECTION_NAME = "inference_collection"
DENSE_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
DENSE_DIM = 384
@@ -1516,7 +1515,6 @@ def test_upload_mixed_batches_upload_collection(parallel, cached_embeddings):
# endregion
@pytest.mark.skip
def test_upsert_batch_with_different_options():
bm25_name = "Qdrant/bm25"
local_client = QdrantClient(":memory:")
@@ -1555,7 +1553,6 @@ def test_upsert_batch_with_different_options():
vector={
"sparse-text-en": sparse_doc_1,
"sparse-text-de": sparse_doc_2,
**download_options,
},
),
models.PointStruct(id=1, vector={"sparse-text-en": sparse_doc_3}),
@@ -1580,7 +1577,6 @@ def test_upsert_batch_with_different_options():
)
@pytest.mark.skip
def test_batch_size_propagation():
def mock(func, kw_param_storage):
def decorated(*args, **kwargs):
@@ -1671,7 +1667,7 @@ def mock_late_interaction_multimodal_embedding():
qdrant_client.embed.embedder.LateInteractionMultimodalEmbedding = original_class
@pytest.mark.skip
@pytest.mark.skip(reason="colpali is a humongous model")
def test_embed_multimodal(mock_late_interaction_multimodal_embedding):
mock_cls = mock_late_interaction_multimodal_embedding
@@ -1735,3 +1731,49 @@ def test_embed_multimodal(mock_late_interaction_multimodal_embedding):
assert np.allclose(np_text_vectors[0], mock_cls.text_embedding)
assert np.allclose(np_text_vectors[1], mock_cls.text_embedding)
assert np.allclose(np_image_vector, mock_cls.image_embedding)
def test_bm25_core():
bm25_name = "Qdrant/bm25"
local_client = QdrantClient(":memory:")
remote_client = QdrantClient()
if remote_client.collection_exists(COLLECTION_NAME):
remote_client.delete_collection(COLLECTION_NAME)
remote_kwargs = {}
remote_client._client.upsert = arg_interceptor(remote_client._client.upsert, remote_kwargs)
sparse_doc_1 = models.Document(text="a sunny day", model=bm25_name)
sparse_doc_2 = models.Document(
text="a rainy day",
model=bm25_name,
)
sparse_vectors_config = {
"sparse": models.SparseVectorParams(modifier=models.Modifier.IDF),
}
for client in (local_client, remote_client):
client.create_collection(
COLLECTION_NAME, vectors_config={}, sparse_vectors_config=sparse_vectors_config
)
points = [
models.PointStruct(
id=0,
vector={
"sparse": sparse_doc_1,
},
),
models.PointStruct(id=1, vector={"sparse": sparse_doc_2}),
]
remote_client.upsert(COLLECTION_NAME, points)
for point in remote_kwargs["points"]:
assert isinstance(point.vector["sparse"], models.Document)
if not local_client._FASTEMBED_INSTALLED:
with pytest.raises(ImportError):
local_client.upsert(COLLECTION_NAME, points)
else:
local_client.upsert(COLLECTION_NAME, points)

View File

@@ -68,6 +68,12 @@ if [[ $PYTEST_EXIT_CODE -ne 0 ]]; then
exit 1
fi
echo "Running inference tests without fastembed"
pip3 uninstall fastembed -y
pytest -x tests/test_fastembed.py
pytest -x tests/embed_tests/test_local_inference.py
echo "Ok, that is enough"