Skip to content

feat(query): native hybrid multi-query (multiQuery()) (#217) - #241

Merged
s2x merged 1 commit into
mainfrom
feat/217-multi-query
Sep 29, 2026
Merged

s2x merged 1 commit into
mainfrom
feat/217-multi-query

Conversation

@s2x

@s2x s2x commented Sep 29, 2026

Copy link
Copy Markdown
Member

Closes #217.

Summary

queryMulti() runs each sub-query separately through the legacy scalar path and fuses in PHP. That cannot do hybrid search at all — the scalar path knows nothing about an FTS clause, so an FTS sub-query (which has no vector) aborted on Cannot instantiate FFI\CData of zero size. It also dropped everything living only in the C++ query handle, and keyed results by field name.

multiQuery() builds one upstream MultiQuery instead, so zvec runs the sub-queries in parallel and fuses in C++:

$docs = $collection->multiQuery(
    [$fts, new ZVecVectorQuery('embedding', $queryVector)],
    new ZVecRrfReRanker(),
    topk: 10,
    numCandidates: 100,
);

What the old path actually got wrong

  1. FTS sub-queries crashed. The query has no vector, so building a float buffer from it failed at the FFI layer with an error that says nothing useful.
  2. Per-query settings were silently lost. Everything that lives only in the C++ VectorQueryHolder never reached zvec_collection_query_ex: the FTS clause, DiskANN listSize, HNSW prefetch, setIncludeDocId(), setOutputFields().
  3. Two sub-queries on one field dropped one of them — results were stored as $queryResults[$vq->fieldName] = $docs.

queryMulti() is unchanged for everything it supported, and now rejects the two cases it could not, naming multiQuery() as the alternative:

fts sub-query: queryMulti() cannot run FTS sub-queries; use multiQuery() for hybrid FTS + vector search
duplicate field: queryMulti() got two sub-queries on field 'vec'; use multiQuery() instead

The scores are deliberately not comparable

Upstream normalises weighted fusion per field with atan, not min-max, so the numbers differ from ZVecWeightedReRanker for identical inputs. RRF is the same 1/(k + rank + 1) in both. This is exactly why queryMulti() keeps its PHP fusion — changing it would have broken anyone relying on the values.

The test asserts the RRF arithmetic rather than just the ordering, which is what pinned down the actual rank assignment (d1 and d2 are rank 0 in different per-field lists, so both score 1/61 + 1/62):

scores are RRF: yes

Weighted fusion is positional

Upstream indexes weights by sub-query position, and duplicate field names are legal — so a field-keyed map has no unambiguous meaning. It is rejected explicitly rather than silently mapped in the wrong order:

field-keyed weights: multiQuery() needs positional weights (a list) because fusion is positional, not a field-keyed map

The FFI prefix is deliberate

zvec_ffi_multi_query_create, not zvec_multi_query_create. Upstream's own C API library exports those bare names with different signatures, and identical names with different ABIs is a trap. This follows the existing zvec_ffi_initialize convention.

One non-obvious detail

A sub-query that only set radius / linear / refiner has no query_params_ yet, so the adapter builds the right ones for the field's index type at execute time — otherwise upstream rejects the whole multi-query with a params-type mismatch. That is why MultiQueryHolder keeps full copies of the sub-queries rather than just their QueryTarget: those three values live on the holder.

Verification

Number of tests : 211               211
Tests skipped   :   0 (  0.0%)
Tests failed    :   0 (  0.0%)
Tests passed    : 209 ( 99.1%)

test_multivector_query, test_multivector_weighted, test_rerankers, test_reranker_in_query and test_fts all pass unchanged — the BC guarantee is tested, not just asserted.

Returns plain ZVecDoc[] rather than ZVecRerankedDoc[]: the fusion happened upstream, so there are no per-field source ranks to report.

Out of scope per the issue: sparse sub-queries (needs the sparse query-vector setter, #210), fromId() sub-queries, CallbackParams (a PHP callback invoked from C++), and group-by multi-query (upstream has none).

🤖 Generated with Claude Code

queryMulti() runs each sub-query separately through the legacy scalar
path and fuses in PHP. That cannot do hybrid search at all: the scalar
path knows nothing about an FTS clause, so an FTS sub-query -- which has
no vector -- aborted on "Cannot instantiate FFI\CData of zero size". It
also dropped everything living only in the C++ query handle (the FTS
clause, DiskANN listSize, HNSW prefetch, setIncludeDocId,
setOutputFields), and keyed results by field name, so two sub-queries on
one field silently replaced the first.

multiQuery() builds one upstream MultiQuery instead, so zvec runs the
sub-queries in parallel and fuses in C++.

queryMulti() is unchanged for everything it supported. It now rejects the
two cases it could not handle, naming multiQuery() as the alternative:
an FTS sub-query, and a duplicate field. Both used to fail with an
unusable FFI error or silently return less than asked.

The fused score is not comparable with queryMulti(). Upstream normalises
weighted fusion per field with atan, not min-max, so the numbers differ
from ZVecWeightedReRanker for identical inputs. RRF is the same
1/(k + rank + 1) either way, and the test asserts the exact fractions.
This is precisely why queryMulti() keeps its PHP fusion.

Weighted fusion is positional upstream and duplicate field names are
legal, so a field-keyed weight map has no unambiguous meaning. It is
rejected with an explicit message rather than silently mapped in the
wrong order.

The FFI surface uses a zvec_ffi_ prefix: upstream's own C API library
exports zvec_multi_query_create and zvec_collection_multi_query with
*different* signatures under the bare names, and identical names with
different ABIs is a trap. It follows the existing zvec_ffi_initialize
convention.

A sub-query that only set radius/linear/refiner has no query_params_ yet,
so the adapter builds the right ones for the field's index type at
execute time; otherwise upstream rejects the whole multi-query with a
params-type mismatch. The adapter keeps full copies of the sub-queries
because those three values live on the holder, not on the target.

Tests: 211/211, 0 skipped, 0 failed, 2 expected fail.

- test_multi_query.phpt: FTS + dense fusion, the RRF fractions, filter,
  numCandidates, two sub-queries on the same field, weighted fusion, output
  fields, and five validation paths.
- test_multi_query_bc.phpt: the two new queryMulti() rejections plus plain
  multi-vector and the native equivalent.
- test_memory_multi_query.phpt: no handle or C string leak over 100 runs.
- test_ffi_load.phpt gained the eleven new symbols.
- test_multivector_query, test_multivector_weighted, test_rerankers,
  test_reranker_in_query and test_fts all pass unchanged.

Returns plain ZVecDoc[] rather than ZVecRerankedDoc[]: the fusion happened
upstream, so there are no per-field source ranks to report.
@s2x
s2x merged commit d062331 into main Sep 29, 2026
6 checks passed
@s2x
s2x deleted the feat/217-multi-query branch September 29, 2026 10:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: native upstream MultiQuery (hybrid FTS + dense search with fusion in C++)

1 participant