fix: fix missing multivector placeholder in local mode (#1399)

* fix: fix missing multivector placeholder in local mode

* fix: do not use deleted vectors in recommend, etc

* fix: fix mypy complaints in point id vector resolution

* fix: regen async
This commit is contained in:
George
2026-09-16 00:26:32 +07:00
committed by George Panchuk
parent cc7c4ba6a3
commit 24f549719d
5 changed files with 121 additions and 73 deletions
+1 -17
View File
@@ -237,14 +237,6 @@ class AsyncQdrantLocal(AsyncQdrantBase):
if lookup_from is not None and lookup_from.vector is not None
else search_in_vector_name
)
sparse = vector_name in collection.sparse_vectors
multi = vector_name in collection.multivectors
if sparse:
collection_vectors = collection.sparse_vectors
elif multi:
collection_vectors = collection.multivectors
else:
collection_vectors = collection.vectors
mentioned_ids: set[types.PointId] = set()
def input_into_vector(vector_input: types.VectorInput) -> types.VectorInput:
@@ -252,15 +244,7 @@ class AsyncQdrantLocal(AsyncQdrantBase):
if isinstance(vector_input, uuid.UUID):
vector_input = str(vector_input)
point_id = vector_input
if point_id not in collection.ids:
raise ValueError(f"Point {point_id} is not found in the collection")
idx = collection.ids[point_id]
if vector_name in collection_vectors:
vec = collection_vectors[vector_name][idx]
else:
raise ValueError(f"Vector {vector_name} not found")
if isinstance(vec, np.ndarray):
vec = vec.tolist()
vec = collection._vector_by_point_id(vector_name, point_id)
if collection_name == lookup_collection_name:
mentioned_ids.add(point_id)
return vec
+40 -37
View File
@@ -307,7 +307,9 @@ class LocalCollection:
if v is not None:
multivectors[name].append(v)
else:
multivectors[name].append(np.array([]))
multivectors[name].append(
np.ones((1, self.multivectors_config[name].size), dtype=np.float32)
)
deleted_ids.append((idx, name))
# setup dense vectors by name
@@ -1365,14 +1367,7 @@ class LocalCollection:
) -> None:
for example in examples:
if isinstance(example, get_args(types.PointId)):
if example not in collection.ids:
raise ValueError(f"Point {example} is not found in the collection")
idx = collection.ids[example]
vec = collection_vectors[vector_name][idx]
if isinstance(vec, np.ndarray):
vec = vec.tolist()
vec: Any = collection._vector_by_point_id(vector_name, example)
acc.append(vec)
if collection == self:
mentioned_ids.append(example)
@@ -1413,15 +1408,12 @@ class LocalCollection:
sparse = vector_name in collection.sparse_vectors
multi = vector_name in collection.multivectors
if sparse:
collection_vectors = collection.sparse_vectors
examples_into_vectors(positive, sparse_positive_vectors)
examples_into_vectors(negative, sparse_negative_vectors)
elif multi:
collection_vectors = collection.multivectors
examples_into_vectors(positive, positive_multivectors)
examples_into_vectors(negative, negative_multivectors)
else:
collection_vectors = collection.vectors
examples_into_vectors(positive, positive_vectors)
examples_into_vectors(negative, negative_vectors)
@@ -1766,22 +1758,35 @@ class LocalCollection:
return ids, scores
def _vector_by_point_id(
self, vector_name: str, point_id: types.PointId
) -> list[float] | SparseVector | list[list[float]]:
if point_id not in self.ids:
raise ValueError(f"Point {point_id} is not found in the collection")
idx = self.ids[point_id]
if vector_name in self.vectors:
vector = self.vectors[vector_name][idx].tolist()
elif vector_name in self.sparse_vectors:
vector = self.sparse_vectors[vector_name][idx]
elif vector_name in self.multivectors:
vector = self.multivectors[vector_name][idx].tolist()
else:
raise ValueError(f"Vector {vector_name} not found")
# Absent vectors are kept as placeholders to keep the storage dense, they are
# filtered out of search results by the mask and must not be used as query vectors
if self.deleted_per_vector[vector_name][idx]:
raise ValueError(f"Vector with name {vector_name} for point {point_id} not found")
return vector
@staticmethod
def _preprocess_vector_input(
target: models.VectorInput | None, collection: "LocalCollection", vector_name: str
) -> tuple[models.Vector, types.PointId | None]:
if isinstance(target, get_args(types.PointId)):
if target not in collection.ids:
raise ValueError(f"Point {target} is not found in the collection")
idx = collection.ids[target]
if vector_name in collection.vectors:
target_vector = collection.vectors[vector_name][idx].tolist()
elif vector_name in collection.sparse_vectors:
target_vector = collection.sparse_vectors[vector_name][idx]
else:
target_vector = collection.multivectors[vector_name][idx].tolist()
target_vector = collection._vector_by_point_id(vector_name, target)
return target_vector, target
return target, None
@@ -1797,19 +1802,11 @@ class LocalCollection:
multi_context_vectors = []
for pair in context:
pair_vectors = []
# holds a dense, sparse or multi vector, dispatched on by type below
pair_vectors: list[Any] = []
for example in [pair.positive, pair.negative]:
if isinstance(example, get_args(types.PointId)):
if example not in collection.ids:
raise ValueError(f"Point {example} is not found in the collection")
idx = collection.ids[example]
if vector_name in collection.vectors:
vector = collection.vectors[vector_name][idx].tolist()
elif vector_name in collection.sparse_vectors:
vector = collection.sparse_vectors[vector_name][idx]
else:
vector = collection.multivectors[vector_name][idx].tolist()
vector = collection._vector_by_point_id(vector_name, example)
pair_vectors.append(vector)
if collection == self:
@@ -2554,11 +2551,15 @@ class LocalCollection:
if len(named_vectors) <= idx:
diff = idx - len(named_vectors) + 1
for _ in range(diff):
named_vectors.append(np.array([]))
named_vectors.append(
np.ones((1, self.get_vector_params(vector_name).size), dtype=np.float32)
)
if vector is None:
# Add fake vector and mark as removed
named_vectors[idx] = np.array([])
named_vectors[idx] = np.ones(
(1, self.get_vector_params(vector_name).size), dtype=np.float32
)
self.deleted_per_vector[vector_name] = np.append(
self.deleted_per_vector[vector_name], 1
)
@@ -2965,7 +2966,9 @@ class LocalCollection:
if config.multivector_config is not None:
self.multivectors_config[vector_name] = params
self.multivectors[vector_name] = [np.array([]) for _ in range(num_points)]
self.multivectors[vector_name] = [
np.ones((1, config.size), dtype=np.float32) for _ in range(num_points)
]
else:
self.vectors_config[vector_name] = params
self.vectors[vector_name] = np.zeros((num_points, config.size), dtype=np.float32)
+1 -19
View File
@@ -257,15 +257,6 @@ class QdrantLocal(QdrantBase):
else search_in_vector_name
)
sparse = vector_name in collection.sparse_vectors
multi = vector_name in collection.multivectors
if sparse:
collection_vectors = collection.sparse_vectors
elif multi:
collection_vectors = collection.multivectors
else:
collection_vectors = collection.vectors
# mentioned ids in the search collection which should be excluded from search
mentioned_ids: set[types.PointId] = set()
@@ -276,16 +267,7 @@ class QdrantLocal(QdrantBase):
if isinstance(vector_input, uuid.UUID):
vector_input = str(vector_input)
point_id = vector_input # rename for clarity
if point_id not in collection.ids:
raise ValueError(f"Point {point_id} is not found in the collection")
idx = collection.ids[point_id]
if vector_name in collection_vectors:
vec = collection_vectors[vector_name][idx]
else:
raise ValueError(f"Vector {vector_name} not found")
if isinstance(vec, np.ndarray):
vec = vec.tolist()
vec = collection._vector_by_point_id(vector_name, point_id)
if collection_name == lookup_collection_name:
mentioned_ids.add(point_id)
return vec
@@ -157,6 +157,22 @@ def test_simple():
compare_client_results(local_client, remote_client, searcher.simple_search_code)
def test_simple_opt_vectors_search():
fixture_points = generate_multivector_fixtures(100, skip_vectors=True)
searcher = TestSimpleSearcher()
local_client = init_local()
init_client(local_client, fixture_points, vectors_config=multi_vector_config)
remote_client = init_remote()
init_client(remote_client, fixture_points, vectors_config=multi_vector_config)
compare_client_results(local_client, remote_client, searcher.simple_search_text)
compare_client_results(local_client, remote_client, searcher.simple_search_image)
compare_client_results(local_client, remote_client, searcher.simple_search_code)
def test_mmr():
fixture_points = generate_multivector_fixtures(10)
@@ -1,5 +1,7 @@
import numpy as np
import pytest
from qdrant_client.http.exceptions import UnexpectedResponse
from qdrant_client.http.models import models
from tests.congruence_tests.test_common import (
COLLECTION_NAME,
@@ -169,3 +171,64 @@ def test_simple_opt_sparse_vectors_search():
key=lambda x: x.id,
),
)
def test_point_id_input_with_missing_vector():
vectors_config = {
"dense": models.VectorParams(size=4, distance=models.Distance.COSINE),
"multi": models.VectorParams(
size=4,
distance=models.Distance.COSINE,
multivector_config=models.MultiVectorConfig(
comparator=models.MultiVectorComparator.MAX_SIM
),
),
}
# point 1 has no value for either named vector, it can neither be scored nor be used as a query
points = [
models.PointStruct(id=1, vector={}),
models.PointStruct(
id=2, vector={"dense": [1.0, 0.0, 0.0, 0.0], "multi": [[1.0, 0.0, 0.0, 0.0]]}
),
models.PointStruct(
id=3, vector={"dense": [0.0, 1.0, 0.0, 0.0], "multi": [[0.0, 1.0, 0.0, 0.0]]}
),
]
local_client = init_local()
init_client(local_client, points, vectors_config=vectors_config)
remote_client = init_remote()
init_client(remote_client, points, vectors_config=vectors_config)
queries = [
1,
models.RecommendQuery(
recommend=models.RecommendInput(
positive=[1], strategy=models.RecommendStrategy.BEST_SCORE
)
),
models.DiscoverQuery(
discover=models.DiscoverInput(
target=1, context=models.ContextPair(positive=2, negative=3)
)
),
models.ContextQuery(context=models.ContextPair(positive=1, negative=3)),
models.RelevanceFeedbackQuery(
relevance_feedback=models.RelevanceFeedbackInput(
target=2,
feedback=[models.FeedbackItem(example=1, score=0.9)],
strategy=models.NaiveFeedbackStrategy(
naive=models.NaiveFeedbackStrategyParams(a=0.5, b=1.0, c=0.7)
),
)
),
]
for using in ("dense", "multi"):
for query in queries:
with pytest.raises(ValueError):
local_client.query_points(COLLECTION_NAME, query=query, using=using)
with pytest.raises(UnexpectedResponse):
remote_client.query_points(COLLECTION_NAME, query=query, using=using)