diff --git a/llama-index-integrations/vector_stores/llama-index-vector-stores-qdrant/llama_index/vector_stores/qdrant/base.py b/llama-index-integrations/vector_stores/llama-index-vector-stores-qdrant/llama_index/vector_stores/qdrant/base.py index 28cb9d9dbd..10b978220c 100644 --- a/llama-index-integrations/vector_stores/llama-index-vector-stores-qdrant/llama_index/vector_stores/qdrant/base.py +++ b/llama-index-integrations/vector_stores/llama-index-vector-stores-qdrant/llama_index/vector_stores/qdrant/base.py @@ -219,6 +219,10 @@ def __init__( dense_vector_name=dense_vector_name, sparse_vector_name=sparse_vector_name, ) + # Track if the user provided their own sparse functions. This is to prevent + # them from being overwritten by the lazy-init correction for async clients. + self._user_provided_sparse_doc_fn = sparse_doc_fn is not None + self._user_provided_sparse_query_fn = sparse_query_fn is not None if ( client is None @@ -1545,12 +1549,13 @@ def get_default_sparse_doc_encoder( ) -> SparseEncoderCallable: """ Get the default sparse document encoder. - Use old format for backward compatibility if detected. + For async-only clients, assumes new format initially. + Will be auto-corrected on first async operation if collection uses old format. """ - if self.use_old_sparse_encoder(collection_name): - # Update the sparse vector name to use the old format - self.sparse_vector_name = DEFAULT_SPARSE_VECTOR_NAME_OLD - return default_sparse_encoder("naver/efficient-splade-VI-BT-large-doc") + if self._client is not None: + if self.use_old_sparse_encoder(collection_name): + self.sparse_vector_name = DEFAULT_SPARSE_VECTOR_NAME_OLD + return default_sparse_encoder("naver/efficient-splade-VI-BT-large-doc") if fastembed_sparse_model is not None: return fastembed_sparse_encoder(model_name=fastembed_sparse_model) @@ -1564,12 +1569,16 @@ def get_default_sparse_query_encoder( ) -> SparseEncoderCallable: """ Get the default sparse query encoder. - Use old format for backward compatibility if detected. + For async-only clients, assumes new format initially. + Will be auto-corrected on first async operation if collection uses old format. """ - if self.use_old_sparse_encoder(collection_name): - # Update the sparse vector name to use the old format - self.sparse_vector_name = DEFAULT_SPARSE_VECTOR_NAME_OLD - return default_sparse_encoder("naver/efficient-splade-VI-BT-large-query") + if self._client is not None: + if self.use_old_sparse_encoder(collection_name): + # Update the sparse vector name to use the old format + self.sparse_vector_name = DEFAULT_SPARSE_VECTOR_NAME_OLD + return default_sparse_encoder( + "naver/efficient-splade-VI-BT-large-query" + ) if fastembed_sparse_model is not None: return fastembed_sparse_encoder(model_name=fastembed_sparse_model) @@ -1583,6 +1592,8 @@ def _detect_vector_format(self, collection_name: str) -> None: - new sparse vector field name vs old sparse vector field name """ try: + old_sparse_name = self.sparse_vector_name # Store state before detection + collection_info = self._client.get_collection(collection_name) vectors_config = collection_info.config.params.vectors sparse_vectors = collection_info.config.params.sparse_vectors or {} @@ -1605,6 +1616,10 @@ def _detect_vector_format(self, collection_name: str) -> None: elif DEFAULT_SPARSE_VECTOR_NAME_OLD in sparse_vectors: self.sparse_vector_name = DEFAULT_SPARSE_VECTOR_NAME_OLD + # If the name changed, our initial assumption was wrong. Correct it. + if self.enable_hybrid and old_sparse_name != self.sparse_vector_name: + self._reinitialize_sparse_encoders() + except Exception as e: logger.warning( f"Could not detect vector format for collection {collection_name}: {e}" @@ -1613,10 +1628,10 @@ def _detect_vector_format(self, collection_name: str) -> None: async def _adetect_vector_format(self, collection_name: str) -> None: """ Asynchronous method to detect and handle old vector formats from existing collections. - - named vs non-named vectors - - new sparse vector field name vs old sparse vector field name """ try: + old_sparse_name = self.sparse_vector_name # Store state before detection + collection_info = await self._aclient.get_collection(collection_name) vectors_config = collection_info.config.params.vectors sparse_vectors = collection_info.config.params.sparse_vectors or {} @@ -1632,18 +1647,49 @@ async def _adetect_vector_format(self, collection_name: str) -> None: self._legacy_vector_format = True self.dense_vector_name = LEGACY_UNNAMED_VECTOR - # Detect sparse vector name if any sparse vectors configured + # Detect sparse vector name and correct if necessary if isinstance(sparse_vectors, dict) and len(sparse_vectors) > 0: if self.sparse_vector_name in sparse_vectors: pass elif DEFAULT_SPARSE_VECTOR_NAME_OLD in sparse_vectors: self.sparse_vector_name = DEFAULT_SPARSE_VECTOR_NAME_OLD + # If the name changed, our initial assumption was wrong. Correct it. + if self.enable_hybrid and old_sparse_name != self.sparse_vector_name: + self._reinitialize_sparse_encoders() + except Exception as e: logger.warning( f"Could not detect vector format for collection {collection_name}: {e}" ) + def _reinitialize_sparse_encoders(self) -> None: + """Recreate default sparse encoders after vector format detection, respecting user-provided functions.""" + if not self.enable_hybrid: + return + + # Only override the doc function if the user did NOT provide one + if not self._user_provided_sparse_doc_fn: + if self.sparse_vector_name == DEFAULT_SPARSE_VECTOR_NAME_OLD: + self._sparse_doc_fn = default_sparse_encoder( + "naver/efficient-splade-VI-BT-large-doc" + ) + else: + self._sparse_doc_fn = fastembed_sparse_encoder( + model_name=self.fastembed_sparse_model + ) + + # Only override the query function if the user did NOT provide one + if not self._user_provided_sparse_query_fn: + if self.sparse_vector_name == DEFAULT_SPARSE_VECTOR_NAME_OLD: + self._sparse_query_fn = default_sparse_encoder( + "naver/efficient-splade-VI-BT-large-query" + ) + else: + self._sparse_query_fn = fastembed_sparse_encoder( + model_name=self.fastembed_sparse_model + ) + def _validate_custom_sharding( self, ): diff --git a/llama-index-integrations/vector_stores/llama-index-vector-stores-qdrant/pyproject.toml b/llama-index-integrations/vector_stores/llama-index-vector-stores-qdrant/pyproject.toml index 008e293225..a337ee23d9 100644 --- a/llama-index-integrations/vector_stores/llama-index-vector-stores-qdrant/pyproject.toml +++ b/llama-index-integrations/vector_stores/llama-index-vector-stores-qdrant/pyproject.toml @@ -28,7 +28,7 @@ dev = [ [project] name = "llama-index-vector-stores-qdrant" -version = "0.8.5" +version = "0.8.6" description = "llama-index vector_stores qdrant integration" authors = [{name = "Your Name", email = "you@example.com"}] requires-python = ">=3.9,<3.14" diff --git a/llama-index-integrations/vector_stores/llama-index-vector-stores-qdrant/tests/conftest.py b/llama-index-integrations/vector_stores/llama-index-vector-stores-qdrant/tests/conftest.py index 73c53fbb18..91e764d642 100644 --- a/llama-index-integrations/vector_stores/llama-index-vector-stores-qdrant/tests/conftest.py +++ b/llama-index-integrations/vector_stores/llama-index-vector-stores-qdrant/tests/conftest.py @@ -326,3 +326,21 @@ async def collection_initialized_payload_indexed_vector_store() -> AsyncGenerato client.close() except Exception: pass + + +@pytest_asyncio.fixture +async def async_only_hybrid_vector_store() -> QdrantVectorStore: + """ + Fixture to test initialization with only an async client and hybrid search enabled. + This specifically reproduces the bug conditions. + """ + # Note: Each in-memory client has its own isolated storage. + aclient = qdrant_client.AsyncQdrantClient(":memory:") + + # The key part: initialize with *only* the aclient. + return QdrantVectorStore( + "test_async_only", + aclient=aclient, + enable_hybrid=True, + fastembed_sparse_model="Qdrant/bm25", + ) diff --git a/llama-index-integrations/vector_stores/llama-index-vector-stores-qdrant/tests/test_vector_stores_qdrant.py b/llama-index-integrations/vector_stores/llama-index-vector-stores-qdrant/tests/test_vector_stores_qdrant.py index f8702ce0a5..e2706e4b8c 100644 --- a/llama-index-integrations/vector_stores/llama-index-vector-stores-qdrant/tests/test_vector_stores_qdrant.py +++ b/llama-index-integrations/vector_stores/llama-index-vector-stores-qdrant/tests/test_vector_stores_qdrant.py @@ -13,6 +13,7 @@ from llama_index.core.vector_stores.types import BasePydanticVectorStore from llama_index.vector_stores.qdrant import QdrantVectorStore +from llama_index.core.schema import TextNode from llama_index.core.vector_stores.types import ( VectorStoreQuery, VectorStoreQueryMode, @@ -818,3 +819,53 @@ async def test_async_query_initializes_with_async_client_only() -> None: assert result is not None assert len(result.nodes) == 1 assert getattr(result.nodes[0], "text", None) == "hello" + + +# --- Test async-only initialization with hybrid search enabled --- +@pytest.mark.asyncio +async def test_init_with_async_client_only_and_hybrid_succeeds( + async_only_hybrid_vector_store: QdrantVectorStore, +) -> None: + """ + Tests that QdrantVectorStore initializes without errors when only + an async client is provided and hybrid search is enabled. + """ + # The test passes if the fixture is created successfully without raising an exception. + # We add a simple assertion to confirm the object is of the correct type. + assert isinstance(async_only_hybrid_vector_store, QdrantVectorStore) + assert async_only_hybrid_vector_store.enable_hybrid is True + + +# --- Test for async-only legacy collection correction --- +@pytest.mark.asyncio +async def test_async_only_hybrid_legacy_collection() -> None: + """Test that async-only mode correctly handles legacy sparse vector format.""" + collection_name = "test_legacy_async" + aclient = AsyncQdrantClient(":memory:") + + # Create collection with OLD sparse vector name + await aclient.create_collection( + collection_name=collection_name, + vectors_config={ + "text-dense": qmodels.VectorParams(size=2, distance=qmodels.Distance.COSINE) + }, + sparse_vectors_config={"text-sparse": qmodels.SparseVectorParams()}, # OLD name + ) + + # Initialize with async client only + store = QdrantVectorStore( + collection_name=collection_name, + aclient=aclient, + enable_hybrid=True, + fastembed_sparse_model="Qdrant/bm25", + ) + + # Initially assumes new format + assert store.sparse_vector_name == "text-sparse-new" + + # After first async operation, should detect and correct to old format + node = TextNode(text="test", embedding=[1.0, 0.0]) + await store.async_add([node]) + + # Should now be corrected + assert store.sparse_vector_name == "text-sparse"