Skip to content
63 changes: 63 additions & 0 deletions python/tests/test_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -1229,3 +1229,66 @@ def my_rerank_callback(query_results, fields, topn):
)
assert len(result) > 0
assert len(result) <= 5

def test_collection_query_materializes_every_hit(
self, collection_with_multiple_docs: Collection
):
"""Query results are materialized as a batch; every hit must keep its own
scalar values, so a misaligned batch cannot go unnoticed."""
# "dense" is [id + 0.1] * 128 under the default IP metric, so the hits
# are the largest ids in descending score order.
result = collection_with_multiple_docs.query(
Query(field_name="dense", vector=[100.1] * 128), topk=5
)
assert [doc.id for doc in result] == ["100", "99", "98", "97", "96"]

scores = [doc.score for doc in result]
assert scores == sorted(scores, reverse=True)

fetched = collection_with_multiple_docs.fetch([doc.id for doc in result])
for doc in result:
# Values belong to this doc, not to a neighbour in the batch.
assert doc.field("id") == int(doc.id)
assert doc.field("name") == "test"
assert doc.field("weight") == 80.0
assert doc.field("height") == 210
# Cross-check against fetch, which materializes docs separately.
assert set(doc.field_names()) == set(fetched[doc.id].field_names())
for name in doc.field_names():
assert doc.field(name) == fetched[doc.id].field(name)
assert doc.vectors == {}

def test_collection_query_materializes_vectors_per_hit(
self, collection_with_multiple_docs: Collection
):
"""With include_vector, each hit must carry its own vector values."""
result = collection_with_multiple_docs.query(
Query(field_name="dense", vector=[100.1] * 128),
topk=3,
include_vector=True,
)
assert len(result) == 3
for doc in result:
expected = pytest.approx(int(doc.id) + 0.1, rel=1e-5)
assert len(doc.vector("dense")) == 128
assert all(value == expected for value in doc.vector("dense"))
assert doc.vector("sparse") == {1: 1.0, 2: 2.0, 3: 3.0}

def test_collection_multi_query_materializes_every_hit(
self, collection_with_multiple_docs: Collection, multiple_docs
):
"""The multi-query binding materializes results through its own code
path, so it needs the same per-hit guarantees as a single query."""
result = collection_with_multiple_docs.query(
[
Query(field_name="dense", vector=multiple_docs[0].vector("dense")),
Query(field_name="dense2", vector=multiple_docs[0].vector("dense2")),
],
topk=10,
reranker=RrfReRanker(),
)
assert len(result) > 0
for doc in result:
assert doc.field("id") == int(doc.id)
assert doc.field("name") == "test"
assert doc.vectors == {}
47 changes: 37 additions & 10 deletions python/tests/test_query_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,20 +287,47 @@ def test_do_merge_rerank_results_with_reranker(self):
reranker.rerank.assert_called_once_with(docs_list, ctx.topk)

def test_execute_python_pipeline(self):
# Each query is executed serially and converted into a result list.
schema = MockCollectionSchema()
executor = QueryExecutor(schema)
# Each query is executed serially and batch-materialized into results:
# Doc._from_tuple is invoked for every non-None tuple returned by
# collection.Query, while None entries are passed through untouched.
executor = QueryExecutor(MagicMock())
collection = MagicMock()
collection.Query.side_effect = [["raw1"], ["raw2"]]
collection.Query.side_effect = [["raw1", None], ["raw2"]]
vectors = [MagicMock(), MagicMock()]

with patch(
"zvec.executor.query_executor.convert_to_py_doc",
side_effect=lambda doc, schema: doc,
):
with patch("zvec.executor.query_executor.Doc") as mock_doc:
mock_doc._from_tuple.side_effect = lambda t: ("doc", t)
results = executor._execute_python_pipeline(vectors, collection)
assert results == [["raw1"], ["raw2"]]
assert collection.Query.call_count == 2

assert collection.Query.call_args_list == [
((vectors[0],), {}),
((vectors[1],), {}),
]
assert mock_doc._from_tuple.call_args_list == [
(("raw1",), {}),
(("raw2",), {}),
]
assert results == [[("doc", "raw1"), None], [("doc", "raw2")]]

def test_execute_single_query_batch_materializes(self):
# _execute_single_query sends the query as-is to collection.Query
# (the schema is resolved inside the C++ binding) and converts each
# non-None returned tuple via Doc._from_tuple, keeping order and None.
executor = QueryExecutor(MagicMock())
collection = MagicMock()
collection.Query.return_value = ["raw1", None, "raw2"]
query = MagicMock()

with patch("zvec.executor.query_executor.Doc") as mock_doc:
mock_doc._from_tuple.side_effect = lambda t: ("doc", t)
results = executor._execute_single_query(query, collection)

collection.Query.assert_called_once_with(query)
assert mock_doc._from_tuple.call_args_list == [
(("raw1",), {}),
(("raw2",), {}),
]
assert results == [("doc", "raw1"), None, ("doc", "raw2")]

def test_build_search_query_by_missing_id_raises_value_error(self):
vector_schema = VectorSchema(name="test", data_type=DataType.VECTOR_FP32)
Expand Down
4 changes: 3 additions & 1 deletion python/zvec/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,9 @@ class _Collection:
def Optimize(self, arg0: param.OptimizeOption) -> None: ...
def Options(self) -> param.CollectionOption: ...
def Path(self) -> str: ...
def Query(self, arg0: param._SearchQuery) -> list[_Doc]: ...
def Query(
self, arg0: param._SearchQuery
) -> list[tuple[str, float, dict | None, dict | None] | None]: ...
def Schema(self) -> schema._CollectionSchema: ...
def Stats(self) -> schema.CollectionStats: ...
def Update(self, arg0: collections.abc.Sequence[_Doc]) -> list[typing.Status]: ...
Expand Down
18 changes: 11 additions & 7 deletions python/zvec/executor/query_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@
from zvec._zvec.param import _Fts, _SearchQuery, _SubQuery

from ..extension import CallbackReRanker, ReRanker, RrfReRanker, WeightedReRanker
from ..model.convert import convert_to_py_doc
from ..model.doc import DocList
from ..model.doc import Doc, DocList
from ..model.param.query import Query
from ..model.schema import CollectionSchema
from ..typing import DataType
Expand Down Expand Up @@ -135,9 +134,14 @@ def execute(self, ctx: QueryContext, collection: _Collection) -> DocList:
def _execute_single_query(
self, query: _SearchQuery, collection: _Collection
) -> DocList:
"""Single/vector-less query: send a ``_SearchQuery`` to C++."""
docs = collection.Query(query)
return [convert_to_py_doc(doc, self._schema) for doc in docs]
"""Single/vector-less query: send a ``_SearchQuery`` to C++.

Results are batch-materialized into tuples in a single C++ call
(the schema is resolved inside the binding from the collection),
avoiding per-doc Python/C++ crossings on the hot path.
"""
tuples = collection.Query(query)
return [Doc._from_tuple(t) if t is not None else None for t in tuples]

def _execute_multi_query(
self, ctx: QueryContext, queries: list[_SearchQuery], collection: _Collection
Expand All @@ -160,8 +164,8 @@ def _execute_multi_query(
return self._merge_and_rerank(ctx, docs_list)

multi_query = self._build_multi_query(ctx, queries)
docs = collection.Query(multi_query)
return [convert_to_py_doc(doc, self._schema) for doc in docs]
tuples = collection.Query(multi_query)
return [Doc._from_tuple(t) if t is not None else None for t in tuples]

def _build_multi_query(
self, ctx: QueryContext, queries: list[_SearchQuery]
Expand Down
19 changes: 19 additions & 0 deletions src/binding/python/include/python_doc.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

#include <pybind11/pybind11.h>
#include <zvec/db/doc.h>
#include <zvec/db/schema.h>

namespace py = pybind11;

Expand All @@ -26,6 +27,24 @@ class ZVecPyDoc {
public:
static void Initialize(py::module_ &m);

// Materialize a single doc into (id, score, fields, vectors) following the
// collection schema. Shared by the per-doc `get_all` binding and the batch
// materialization path in the collection DQL bindings. Requires the GIL.
static py::tuple doc_to_tuple(Doc &self, const CollectionSchema &schema);

// Same as doc_to_tuple but takes the pre-resolved forward/vector field lists
// directly, so batch materialization can resolve them once per batch instead
// of once per doc. Requires the GIL.
static py::tuple doc_to_tuple_with_fields(
Doc &self, const FieldSchemaPtrList &forward_fields,
const FieldSchemaPtrList &vector_fields);

// Convert a single Doc field value into a Python object according to its
// DataType. Shared by the per-field `get_any` binding and `doc_to_tuple`.
// Requires the GIL.
static py::object doc_value_to_py(Doc &self, const std::string &field,
DataType type);

private:
static void bind_doc_operator(py::module_ &m);
static void bind_doc(py::module_ &m);
Expand Down
86 changes: 68 additions & 18 deletions src/binding/python/model/python_collection.cc
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,35 @@
#include <pybind11/stl.h>
#include <zvec/db/collection.h>
#include <zvec/db/doc_iterator.h>
#include "db/collection_query_internal.h"
#include "python_doc.h"

namespace zvec {

namespace {

// Batch-materialize a DocPtrList into a list of (id, score, fields, vectors)
// tuples in a single GIL-held section, avoiding per-doc _Doc wrappers and
// per-doc Python->C++ crossings on the hot query path. The forward/vector field
// lists are resolved once per batch rather than once per doc.
py::list docs_to_tuples(const DocPtrList &docs,
const CollectionSchema &schema) {
const auto forward_fields = schema.forward_fields();
const auto vector_fields = schema.vector_fields();
py::list out(docs.size());
for (size_t i = 0; i < docs.size(); ++i) {
if (docs[i]) {
out[i] = ZVecPyDoc::doc_to_tuple_with_fields(*docs[i], forward_fields,
vector_fields);
} else {
out[i] = py::none();
}
}
return out;
}
Comment thread
zzlin237 marked this conversation as resolved.

} // namespace

inline void throw_if_error(const Status &status) {
switch (status.code()) {
case StatusCode::OK:
Expand Down Expand Up @@ -48,6 +74,30 @@ T unwrap_expected(const tl::expected<T, Status> &exp) {
return T{};
}

template <typename T>
T unwrap_expected(tl::expected<T, Status> &&exp) {
if (exp.has_value()) {
return std::move(exp).value();
}
throw_if_error(exp.error());
return T{};
}

// Run a query with the GIL released, capturing docs + schema atomically under a
// single schema read lock (internal::query_result_snapshot), then materialize
// the batch into tuples after the GIL is reacquired and the read lock released.
template <typename Query>
py::list execute_for_python(const Collection &collection, const Query &query) {
Result<internal::QueryResultSnapshot> result;
{
py::gil_scoped_release release;
result = internal::query_result_snapshot(collection, query);
}
// GIL restored, schema read lock already released.
auto snapshot = unwrap_expected(std::move(result));
return docs_to_tuples(snapshot.docs, *snapshot.schema);
}

void ZVecPyCollection::Initialize(pybind11::module_ &m) {
py::class_<GroupResult>(m, "_GroupResult")
.def_readonly("group_by_value", &GroupResult::group_by_value_)
Expand Down Expand Up @@ -267,29 +317,29 @@ void ZVecPyCollection::bind_dml_methods(

void ZVecPyCollection::bind_dql_methods(
py::class_<Collection, Collection::Ptr> &col) {
col.def("Query",
[](const Collection &self, const SearchQuery &query) {
Result<DocPtrList> result;
{
py::gil_scoped_release release;
result = self.query(query);
}
// return DocPtrList
return unwrap_expected(result);
})
// Query with the GIL released, then materialize all hits into
// (id, score, fields, vectors) tuples in one crossing (see docs_to_tuples).
// execute_for_python captures the docs and the schema snapshot atomically
// under one read lock, so concurrent DDL cannot desynchronize them, while
// the binding signature stays unchanged from the legacy per-doc binding.
col.def(
"Query",
[](const Collection &self, const SearchQuery &query) {
return execute_for_python(self, query);
},
py::arg("query"),
"Execute a query and return results as a list of "
"(id, score, fields, vectors) tuples materialized in one batch.")
Comment thread
zzlin237 marked this conversation as resolved.
// MultiQuery: multi query with reranker
.def(
"Query",
[](const Collection &self, const MultiQuery &query) {
Result<DocPtrList> result;
{
py::gil_scoped_release release;
result = self.query(query);
}
// return DocPtrList
return unwrap_expected(result);
return execute_for_python(self, query);
},
py::arg("query"), "Execute a multi query with re-ranking.")
py::arg("query"),
"Execute a multi query with re-ranking and return results as a "
"list of (id, score, fields, vectors) tuples materialized in one "
"batch.")
.def("GroupByQuery",
[](const Collection &self, const GroupByVectorQuery &query) {
Result<GroupResults> result;
Expand Down
Loading
Loading