Skip to content

Commit 6b1dc48

Browse files
committed
fix(qdrant): Allow async-only initialization with hybrid search
Resolves an `AttributeError` that occurred when initializing `QdrantVectorStore` with only an async client and `enable_hybrid=True`. The fix implements a "lazy correction" strategy by deferring the synchronous collection check from `__init__` to the first async operation. This ensures the correct sparse encoders are configured while respecting any user-provided custom functions. New tests are added to validate the crash fix and the legacy collection auto-correction. Fixes #20002
1 parent ee08a57 commit 6b1dc48

File tree

4 files changed

+123
-14
lines changed

4 files changed

+123
-14
lines changed

llama-index-integrations/vector_stores/llama-index-vector-stores-qdrant/llama_index/vector_stores/qdrant/base.py

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,10 @@ def __init__(
219219
dense_vector_name=dense_vector_name,
220220
sparse_vector_name=sparse_vector_name,
221221
)
222+
# Track if the user provided their own sparse functions. This is to prevent
223+
# them from being overwritten by the lazy-init correction for async clients.
224+
self._user_provided_sparse_doc_fn = sparse_doc_fn is not None
225+
self._user_provided_sparse_query_fn = sparse_query_fn is not None
222226

223227
if (
224228
client is None
@@ -1545,12 +1549,13 @@ def get_default_sparse_doc_encoder(
15451549
) -> SparseEncoderCallable:
15461550
"""
15471551
Get the default sparse document encoder.
1548-
Use old format for backward compatibility if detected.
1552+
For async-only clients, assumes new format initially.
1553+
Will be auto-corrected on first async operation if collection uses old format.
15491554
"""
1550-
if self.use_old_sparse_encoder(collection_name):
1551-
# Update the sparse vector name to use the old format
1552-
self.sparse_vector_name = DEFAULT_SPARSE_VECTOR_NAME_OLD
1553-
return default_sparse_encoder("naver/efficient-splade-VI-BT-large-doc")
1555+
if self._client is not None:
1556+
if self.use_old_sparse_encoder(collection_name):
1557+
self.sparse_vector_name = DEFAULT_SPARSE_VECTOR_NAME_OLD
1558+
return default_sparse_encoder("naver/efficient-splade-VI-BT-large-doc")
15541559

15551560
if fastembed_sparse_model is not None:
15561561
return fastembed_sparse_encoder(model_name=fastembed_sparse_model)
@@ -1564,12 +1569,16 @@ def get_default_sparse_query_encoder(
15641569
) -> SparseEncoderCallable:
15651570
"""
15661571
Get the default sparse query encoder.
1567-
Use old format for backward compatibility if detected.
1572+
For async-only clients, assumes new format initially.
1573+
Will be auto-corrected on first async operation if collection uses old format.
15681574
"""
1569-
if self.use_old_sparse_encoder(collection_name):
1570-
# Update the sparse vector name to use the old format
1571-
self.sparse_vector_name = DEFAULT_SPARSE_VECTOR_NAME_OLD
1572-
return default_sparse_encoder("naver/efficient-splade-VI-BT-large-query")
1575+
if self._client is not None:
1576+
if self.use_old_sparse_encoder(collection_name):
1577+
# Update the sparse vector name to use the old format
1578+
self.sparse_vector_name = DEFAULT_SPARSE_VECTOR_NAME_OLD
1579+
return default_sparse_encoder(
1580+
"naver/efficient-splade-VI-BT-large-query"
1581+
)
15731582

15741583
if fastembed_sparse_model is not None:
15751584
return fastembed_sparse_encoder(model_name=fastembed_sparse_model)
@@ -1613,10 +1622,10 @@ def _detect_vector_format(self, collection_name: str) -> None:
16131622
async def _adetect_vector_format(self, collection_name: str) -> None:
16141623
"""
16151624
Asynchronous method to detect and handle old vector formats from existing collections.
1616-
- named vs non-named vectors
1617-
- new sparse vector field name vs old sparse vector field name
16181625
"""
16191626
try:
1627+
old_sparse_name = self.sparse_vector_name # Store state before detection
1628+
16201629
collection_info = await self._aclient.get_collection(collection_name)
16211630
vectors_config = collection_info.config.params.vectors
16221631
sparse_vectors = collection_info.config.params.sparse_vectors or {}
@@ -1632,18 +1641,49 @@ async def _adetect_vector_format(self, collection_name: str) -> None:
16321641
self._legacy_vector_format = True
16331642
self.dense_vector_name = LEGACY_UNNAMED_VECTOR
16341643

1635-
# Detect sparse vector name if any sparse vectors configured
1644+
# Detect sparse vector name and correct if necessary
16361645
if isinstance(sparse_vectors, dict) and len(sparse_vectors) > 0:
16371646
if self.sparse_vector_name in sparse_vectors:
16381647
pass
16391648
elif DEFAULT_SPARSE_VECTOR_NAME_OLD in sparse_vectors:
16401649
self.sparse_vector_name = DEFAULT_SPARSE_VECTOR_NAME_OLD
16411650

1651+
# If the name changed, our initial assumption was wrong. Correct it.
1652+
if self.enable_hybrid and old_sparse_name != self.sparse_vector_name:
1653+
self._reinitialize_sparse_encoders()
1654+
16421655
except Exception as e:
16431656
logger.warning(
16441657
f"Could not detect vector format for collection {collection_name}: {e}"
16451658
)
16461659

1660+
def _reinitialize_sparse_encoders(self) -> None:
1661+
"""Recreate default sparse encoders after vector format detection, respecting user-provided functions."""
1662+
if not self.enable_hybrid:
1663+
return
1664+
1665+
# Only override the doc function if the user did NOT provide one
1666+
if not self._user_provided_sparse_doc_fn:
1667+
if self.sparse_vector_name == DEFAULT_SPARSE_VECTOR_NAME_OLD:
1668+
self._sparse_doc_fn = default_sparse_encoder(
1669+
"naver/efficient-splade-VI-BT-large-doc"
1670+
)
1671+
else:
1672+
self._sparse_doc_fn = fastembed_sparse_encoder(
1673+
model_name=self.fastembed_sparse_model
1674+
)
1675+
1676+
# Only override the query function if the user did NOT provide one
1677+
if not self._user_provided_sparse_query_fn:
1678+
if self.sparse_vector_name == DEFAULT_SPARSE_VECTOR_NAME_OLD:
1679+
self._sparse_query_fn = default_sparse_encoder(
1680+
"naver/efficient-splade-VI-BT-large-query"
1681+
)
1682+
else:
1683+
self._sparse_query_fn = fastembed_sparse_encoder(
1684+
model_name=self.fastembed_sparse_model
1685+
)
1686+
16471687
def _validate_custom_sharding(
16481688
self,
16491689
):

llama-index-integrations/vector_stores/llama-index-vector-stores-qdrant/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ dev = [
2828

2929
[project]
3030
name = "llama-index-vector-stores-qdrant"
31-
version = "0.8.5"
31+
version = "0.8.6"
3232
description = "llama-index vector_stores qdrant integration"
3333
authors = [{name = "Your Name", email = "[email protected]"}]
3434
requires-python = ">=3.9,<3.14"

llama-index-integrations/vector_stores/llama-index-vector-stores-qdrant/tests/conftest.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,3 +326,21 @@ async def collection_initialized_payload_indexed_vector_store() -> AsyncGenerato
326326
client.close()
327327
except Exception:
328328
pass
329+
330+
331+
@pytest_asyncio.fixture
332+
async def async_only_hybrid_vector_store() -> QdrantVectorStore:
333+
"""
334+
Fixture to test initialization with only an async client and hybrid search enabled.
335+
This specifically reproduces the bug conditions.
336+
"""
337+
# Note: Each in-memory client has its own isolated storage.
338+
aclient = qdrant_client.AsyncQdrantClient(":memory:")
339+
340+
# The key part: initialize with *only* the aclient.
341+
return QdrantVectorStore(
342+
"test_async_only",
343+
aclient=aclient,
344+
enable_hybrid=True,
345+
fastembed_sparse_model="Qdrant/bm25",
346+
)

llama-index-integrations/vector_stores/llama-index-vector-stores-qdrant/tests/test_vector_stores_qdrant.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from llama_index.core.vector_stores.types import BasePydanticVectorStore
1515
from llama_index.vector_stores.qdrant import QdrantVectorStore
16+
from llama_index.core.schema import TextNode
1617
from llama_index.core.vector_stores.types import (
1718
VectorStoreQuery,
1819
VectorStoreQueryMode,
@@ -818,3 +819,53 @@ async def test_async_query_initializes_with_async_client_only() -> None:
818819
assert result is not None
819820
assert len(result.nodes) == 1
820821
assert getattr(result.nodes[0], "text", None) == "hello"
822+
823+
824+
# --- Test async-only initialization with hybrid search enabled ---
825+
@pytest.mark.asyncio
826+
async def test_init_with_async_client_only_and_hybrid_succeeds(
827+
async_only_hybrid_vector_store: QdrantVectorStore,
828+
) -> None:
829+
"""
830+
Tests that QdrantVectorStore initializes without errors when only
831+
an async client is provided and hybrid search is enabled.
832+
"""
833+
# The test passes if the fixture is created successfully without raising an exception.
834+
# We add a simple assertion to confirm the object is of the correct type.
835+
assert isinstance(async_only_hybrid_vector_store, QdrantVectorStore)
836+
assert async_only_hybrid_vector_store.enable_hybrid is True
837+
838+
839+
# --- Test for async-only legacy collection correction ---
840+
@pytest.mark.asyncio
841+
async def test_async_only_hybrid_legacy_collection() -> None:
842+
"""Test that async-only mode correctly handles legacy sparse vector format."""
843+
collection_name = "test_legacy_async"
844+
aclient = AsyncQdrantClient(":memory:")
845+
846+
# Create collection with OLD sparse vector name
847+
await aclient.create_collection(
848+
collection_name=collection_name,
849+
vectors_config={
850+
"text-dense": qmodels.VectorParams(size=2, distance=qmodels.Distance.COSINE)
851+
},
852+
sparse_vectors_config={"text-sparse": qmodels.SparseVectorParams()}, # OLD name
853+
)
854+
855+
# Initialize with async client only
856+
store = QdrantVectorStore(
857+
collection_name=collection_name,
858+
aclient=aclient,
859+
enable_hybrid=True,
860+
fastembed_sparse_model="Qdrant/bm25",
861+
)
862+
863+
# Initially assumes new format
864+
assert store.sparse_vector_name == "text-sparse-new"
865+
866+
# After first async operation, should detect and correct to old format
867+
node = TextNode(text="test", embedding=[1.0, 0.0])
868+
await store.async_add([node])
869+
870+
# Should now be corrected
871+
assert store.sparse_vector_name == "text-sparse"

0 commit comments

Comments
 (0)