-
Notifications
You must be signed in to change notification settings - Fork 977
Batch convert docs to achieve acceleration #647
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,180 @@ | ||
| # Copyright 2025-present the zvec project | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
| """Correctness tests for the batch-materialized query path. | ||
|
|
||
| `Collection.query` goes through `_Collection.Query`, which batch-materializes | ||
| all hits into tuples in a single C++ call. These tests validate the | ||
| materialized output against two independent references: | ||
|
|
||
| - a numpy brute-force ground truth over the inserted vectors (ids / scores); | ||
| - the `fetch` path, which materializes docs through a separate binding. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import numpy as np | ||
| import pytest | ||
| import zvec | ||
| from zvec import ( | ||
| Collection, | ||
| CollectionOption, | ||
| DataType, | ||
| Doc, | ||
| FieldSchema, | ||
| HnswIndexParam, | ||
| HnswQueryParam, | ||
| Query, | ||
| RrfReRanker, | ||
| VectorSchema, | ||
| ) | ||
| from zvec.typing import MetricType | ||
|
|
||
| DIM = 16 | ||
| N_DOCS = 200 | ||
|
|
||
|
|
||
| def _make_vectors() -> np.ndarray: | ||
| """Same deterministic vectors as inserted by the fixture.""" | ||
| return np.random.default_rng(42).random((N_DOCS, DIM), dtype=np.float32) | ||
|
|
||
|
|
||
| def _brute_force_topk( | ||
| query: np.ndarray, topk: int, mask: np.ndarray | None = None | ||
| ) -> tuple[list[str], np.ndarray]: | ||
| """Exact L2sq top-k ids and distances over the ground-truth vectors.""" | ||
| dists = ((_make_vectors() - query) ** 2).sum(axis=1) | ||
| if mask is not None: | ||
| dists = np.where(mask, dists, np.inf) | ||
| idx = np.argsort(dists, kind="stable")[:topk] | ||
| return [str(i) for i in idx], dists[idx] | ||
|
|
||
|
|
||
| @pytest.fixture(scope="module") | ||
| def bm_collection(tmp_path_factory) -> Collection: | ||
| schema = zvec.CollectionSchema( | ||
| name="batch_mat_test", | ||
| fields=[ | ||
| FieldSchema("num", DataType.INT64, nullable=False), | ||
| FieldSchema("title", DataType.STRING, nullable=True), | ||
| ], | ||
| vectors=[ | ||
| VectorSchema( | ||
| "vec", | ||
| DataType.VECTOR_FP32, | ||
| dimension=DIM, | ||
| # explicit L2: score is the raw squared L2 distance (no | ||
| # metric normalization), matching the brute-force ground truth | ||
| index_param=HnswIndexParam(metric_type=MetricType.L2), | ||
| ), | ||
| ], | ||
| ) | ||
| path = tmp_path_factory.mktemp("zvec_batch_mat") / "coll" | ||
| coll = zvec.create_and_open( | ||
| path=str(path), | ||
| schema=schema, | ||
| option=CollectionOption(read_only=False, enable_mmap=True), | ||
| ) | ||
|
|
||
| vectors = _make_vectors() | ||
| docs = [ | ||
| Doc( | ||
| id=str(i), | ||
| fields={"num": i, "title": f"doc-{i}"}, | ||
| vectors={"vec": vectors[i]}, | ||
| ) | ||
| for i in range(N_DOCS) | ||
| ] | ||
| for r in coll.insert(docs): | ||
| assert r.ok() | ||
|
|
||
| yield coll | ||
|
|
||
| try: | ||
| coll.destroy() | ||
| except Exception: | ||
| pass | ||
|
|
||
|
|
||
| class TestBatchMaterialize: | ||
| def _query_vec(self) -> np.ndarray: | ||
| return np.array([0.5] * DIM, dtype=np.float32) | ||
|
|
||
| def test_matches_brute_force_ground_truth(self, bm_collection: Collection): | ||
| q = Query(field_name="vec", vector=self._query_vec(), param=HnswQueryParam()) | ||
| docs = bm_collection.query(q, topk=20) | ||
| assert len(docs) == 20 | ||
|
|
||
| exp_ids, exp_dists = _brute_force_topk(self._query_vec(), 20) | ||
| assert [d.id for d in docs] == exp_ids | ||
| scores = [d.score for d in docs] | ||
| assert scores == sorted(scores) | ||
| for d, dist in zip(docs, exp_dists): | ||
| assert d.score == pytest.approx(float(dist), rel=1e-4) | ||
|
|
||
| # scalar fields fully materialized, vectors excluded by default | ||
| for d in docs: | ||
| assert isinstance(d, Doc) | ||
| assert set(d.fields.keys()) == {"num", "title"} | ||
| assert d.fields["num"] == int(d.id) | ||
| assert d.fields["title"] == f"doc-{d.id}" | ||
| assert d.vectors == {} | ||
|
|
||
| def test_fields_match_fetch_path(self, bm_collection: Collection): | ||
| q = Query(field_name="vec", vector=self._query_vec(), param=HnswQueryParam()) | ||
| docs = bm_collection.query(q, topk=10) | ||
| fetched = bm_collection.fetch([d.id for d in docs], include_vector=False) | ||
| for d in docs: | ||
| assert d.fields == fetched[d.id].fields | ||
|
|
||
| @pytest.mark.parametrize("include_vector", [False, True]) | ||
| def test_include_vector(self, bm_collection: Collection, include_vector: bool): | ||
| q = Query(field_name="vec", vector=self._query_vec(), param=HnswQueryParam()) | ||
| docs = bm_collection.query(q, topk=5, include_vector=include_vector) | ||
| ground_truth = _make_vectors() | ||
| for d in docs: | ||
| assert bool(d.vectors) is include_vector | ||
| if include_vector: | ||
| assert np.allclose(d.vectors["vec"], ground_truth[int(d.id)]) | ||
|
|
||
| def test_output_fields_subset(self, bm_collection: Collection): | ||
| q = Query(field_name="vec", vector=self._query_vec(), param=HnswQueryParam()) | ||
| docs = bm_collection.query(q, topk=5, output_fields=["num"]) | ||
| exp_ids, _ = _brute_force_topk(self._query_vec(), 5) | ||
| assert [d.id for d in docs] == exp_ids | ||
| for d in docs: | ||
| assert set(d.fields.keys()) == {"num"} | ||
|
|
||
| def test_filter(self, bm_collection: Collection): | ||
| q = Query(field_name="vec", vector=self._query_vec(), param=HnswQueryParam()) | ||
| docs = bm_collection.query(q, topk=10, filter="num < 50") | ||
| mask = np.arange(N_DOCS) < 50 | ||
| exp_ids, exp_dists = _brute_force_topk(self._query_vec(), 10, mask) | ||
| assert [d.id for d in docs] == exp_ids | ||
| for d, dist in zip(docs, exp_dists): | ||
| assert d.fields["num"] < 50 | ||
| assert d.score == pytest.approx(float(dist), rel=1e-4) | ||
|
|
||
| def test_empty_result(self, bm_collection: Collection): | ||
| q = Query(field_name="vec", vector=self._query_vec(), param=HnswQueryParam()) | ||
| docs = bm_collection.query(q, topk=10, filter="num < 0") | ||
| assert docs == [] | ||
|
|
||
| def test_multi_query_rrf(self, bm_collection: Collection): | ||
| q1 = Query(field_name="vec", vector=self._query_vec(), param=HnswQueryParam()) | ||
| q2 = Query(field_name="vec", vector=[0.1] * DIM, param=HnswQueryParam()) | ||
| docs = bm_collection.query([q1, q2], topk=10, reranker=RrfReRanker()) | ||
| assert len(docs) == 10 | ||
| for d in docs: | ||
| assert isinstance(d, Doc) | ||
| assert set(d.fields.keys()) == {"num", "title"} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,9 +15,30 @@ | |
| #include "python_collection.h" | ||
| #include <pybind11/stl.h> | ||
| #include <zvec/db/collection.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. | ||
| py::list docs_to_tuples(const DocPtrList &docs, | ||
| const CollectionSchema &schema) { | ||
| py::list out(docs.size()); | ||
| for (size_t i = 0; i < docs.size(); ++i) { | ||
| if (docs[i]) { | ||
| out[i] = ZVecPyDoc::doc_to_tuple(*docs[i], schema); | ||
| } else { | ||
| out[i] = py::none(); | ||
| } | ||
| } | ||
| return out; | ||
| } | ||
|
|
||
| } // namespace | ||
|
|
||
| inline void throw_if_error(const Status &status) { | ||
| switch (status.code()) { | ||
| case StatusCode::OK: | ||
|
|
@@ -255,29 +276,44 @@ 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). | ||
| // The schema is taken from the collection itself, keeping the signature | ||
| // unchanged from the legacy per-doc binding. | ||
| col.def( | ||
| "Query", | ||
| [](const Collection &self, const SearchQuery &query) { | ||
| Result<DocPtrList> result; | ||
| Result<CollectionSchema> schema_result; | ||
| { | ||
| py::gil_scoped_release release; | ||
| result = self.Query(query); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| schema_result = self.Schema(); | ||
| } | ||
| return docs_to_tuples(unwrap_expected(result), | ||
| unwrap_expected(schema_result)); | ||
| }, | ||
| py::arg("query"), | ||
| "Execute a query and return results as a list of " | ||
| "(id, score, fields, vectors) tuples materialized in one batch.") | ||
|
Comment on lines
+279
to
+298
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 确实有点问题,_Collection.Query到底算不算公共API
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. _Collection.Query不算公共API。 作为pybind层的wrapper。用来封装py -> c++的调用 |
||
| // MultiQuery: multi query with reranker | ||
| .def( | ||
| "Query", | ||
| [](const Collection &self, const MultiQuery &query) { | ||
| Result<DocPtrList> result; | ||
| Result<CollectionSchema> schema_result; | ||
| { | ||
| py::gil_scoped_release release; | ||
| result = self.Query(query); | ||
| schema_result = self.Schema(); | ||
| } | ||
| // return DocPtrList | ||
| return unwrap_expected(result); | ||
| return docs_to_tuples(unwrap_expected(result), | ||
| unwrap_expected(schema_result)); | ||
| }, | ||
| 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; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.