Skip to content

feat(query): sparse query vectors (setSparseVector) (#210) - #244

Merged
s2x merged 1 commit into
mainfrom
feat/210-sparse-query-vector
Sep 29, 2026
Merged

s2x merged 1 commit into
mainfrom
feat/210-sparse-query-vector

Conversation

@s2x

@s2x s2x commented Sep 29, 2026

Copy link
Copy Markdown
Member

Closes #210.

Summary

There was no way to put a sparse vector into a query. A query built for a sparse field carried a dense payload, upstream converted it into a single 0-index entry, and every document scored 0.0 — above any -radius threshold. So setRadius() on a sparse field was a silent no-op that reported no error, which is why sparse radius queries looked like they were working.

$query = (new ZVecVectorQuery('sv', []))
    ->setTopk(3)
    ->setSparseVector([0, 2], [1.0, -0.5])
    ->setRadius(0.25);

setSparseVector() writes a real sparse clause onto the native handle and clears any dense vector first — leaving one set is exactly what produced the all-zero scores.

The encoding was determined, not guessed

The clause is the raw bytes of each array, in a std::string pair. Upstream's own C API (zvec_sub_query_set_sparse_indices / ..._values) does the conversion, and we ship libzvec_c_api.so, so I verified it rather than guessing:

$ ./probe_bin          # calls upstream's setter, dumps the object
 72: 0c 00 00 00 00 00 00 00   |........|   <- size = 12
 80: 00 00 00 00 05 00 00 00 09 00 00 00   <- {0, 5, 9}, raw LE uint32
104: 0c 00 00 00 00 00 00 00   |........|   <- size = 12
112: 00 00 c0 3f 00 00 10 c0 00 00 40 40   <- 1.5f, -2.25f, 3.0f, raw LE

Both strings are held inline in the SSO buffer, confirming raw binary rather than a text encoding.

Upstream sorts the indices itself and rejects duplicates, so neither is pre-sorted or pre-checked here — both are asserted instead.

Why the test is observable

Sparse similarity is not normalised the way dense scores are, so a sparse score can be negative, and setRadius() compares against it directly. With scores 0.5 / 0.5 / -0.5 and the IP threshold at -radius, radius 0.25 excludes the negative one:

hits: d1,d3,d2
scores distinct: yes
scores non-zero: yes
no radius keeps d2: yes
radius 0.25: d1,d3
d2 excluded: yes

Before this change the scores non-zero and radius lines are the only ones that could not have passed.

Two findings asserted, not hidden

Both contradict assumptions in the issue text, so they are pinned by the test rather than worked around silently:

Both are follow-ups on #200's thread-local threshold rather than on this change. The test orders its radius-free assertions first and its radius assertions last, precisely so the leak cannot silently change an unrelated expectation.

Also

The legacy scalar query() path has no dense vector to work with, so a sparse query delegates to queryVector() rather than failing on an empty float buffer.

Number of tests : 214               214
Tests skipped   :   0 (  0.0%)
Tests failed    :   0 (  0.0%)
Tests passed    : 212 ( 99.1%)

🤖 Generated with Claude Code

There was no way to put a sparse vector into a query. A query built for a
sparse field carried a dense payload, upstream converted it into a
single 0-index entry, and every document scored 0.0 -- above any
-radius threshold, so setRadius() could never exclude anything and
reported no error. Silent no-op, and the reason sparse radius queries
looked like they were working.

setSparseVector(array $indices, array $values) writes a real sparse
clause onto the native handle. It clears any dense vector first,
because leaving one set is exactly what produced the all-zero scores.

The clause is the raw bytes of each array, which is what upstream's own
zvec_sub_query_set_sparse_indices/values produce. Verified by calling
that upstream C API and dumping the resulting std::string: 12 raw
bytes each for three entries, held inline in the string's SSO buffer.
Upstream sorts the indices itself and rejects duplicates, so neither is
pre-sorted or pre-checked here -- both are asserted instead.

Sparse similarity is not normalised the way dense scores are, so a
sparse score can be negative and setRadius() compares against it
directly. That is what makes the test observable: with scores
0.5 / 0.5 / -0.5 and the IP threshold at -radius, radius 0.25 excludes
the negative one.

The legacy scalar query() path has no dense vector to work with, so a
sparse query delegates to queryVector() rather than failing on an empty
float buffer.

Two things found while testing, asserted by the test rather than hidden:

- the radius threshold DOES leak into later queries on a sparse field
  within the same process, contradicting the assumption in #210 that
  HNSW-sparse self-resets. Asserted, not worked around.
- resetRadiusThreshold() cannot undo it: it takes a dense vector and
  fails with "missing query clause" on a sparse field. So the natural
  reflex when the leak bites does not work here either.

Both are follow-ups on #200's thread-local threshold, not on this
change, and both are recorded in the test and the changelog.

Tests: 214/214, 0 skipped, 0 failed, 2 expected fail.
@s2x
s2x merged commit 136ac3e into main Sep 29, 2026
6 checks passed
@s2x
s2x deleted the feat/210-sparse-query-vector branch September 29, 2026 14:12
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: expose sparse query vectors (upstream zvec_sub_query_set_sparse_vector) — sparse radius queries are currently silent no-ops

1 participant