Skip to content

Tier 10 and Tier 11 · Track Qdrant 1.19, and measure what was only asserted - #148

Merged
tonytonycoder11 merged 9 commits into
mainfrom
feat/tier-10-qdrant-1-19
Sep 10, 2026
Merged

Tier 10 and Tier 11 · Track Qdrant 1.19, and measure what was only asserted#148
tonytonycoder11 merged 9 commits into
mainfrom
feat/tier-10-qdrant-1-19

Conversation

@tonytonycoder11

@tonytonycoder11 tonytonycoder11 commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Tier 10 and Tier 11, less two items, plus both Dependabot groups.

Closes #99, #100, #101, #102, #103, #105, #106, #107, #108, #109, #119, #120, #121, #123, #124, #153.
Advances #129. Supersedes #145 and #147.

Tier 10 · Qdrant 1.19 and the agent-era query surface

M60 started with a wrong exit criterion. It asked for the vendored schema's info.version to match a
released Qdrant tag, and Qdrant ships "version": "master" at every released tag, v1.19.1 included. The
document cannot say where it came from, so the pin lives in gradle.properties and three tasks stand
behind it: verifyQdrantPin in check fails when anything names a newer Qdrant, verifyVendoredQdrant
fetches the pinned tag in its own CI job and compares the eight protobuf files and the OpenAPI document
byte for byte, and refreshVendoredQdrant moves them together. All three found real drift on their first
run, including a points.proto edited by hand in a directory whose README says nothing there is edited.

M56 to M59 are the 1.19 surface: prefix matching with the index that serves it, relevance feedback,
slice filtering, memory tiers and 4-bit primary storage. Plus two that had no board item, which is the
argument for the release watch below: the per-query IDF corpus, and min / max / acosh in the formula
language. Every shape is asserted against Qdrant's own v1.19.1 OpenAPI document and against both engines'
mappings, and the shared contract now exercises all four against a real server.

Two facts found while doing it. Prefix matching does not need its index the way phrase matching does:
Qdrant checks the condition per point without one and only refuses it under strict mode, so the index is
an accelerator and the KDoc that said otherwise was wrong. And relevance feedback drops the points it
was given
rather than reordering them among the rest, which CI caught before it shipped.

M70 draws the line the README left to a reader: this is a client, and a device that must answer
offline wants Qdrant Edge. M71 brings example-rag up to the library it demonstrates, from a 1.x
upsert loop to ingest with a checkpoint, hybrid retrieval with server-side IDF, payload indexes with
parameters, and a retryable-versus-terminal failure path.

Tier 11 · Operability at the size people run

M61 sends X-Qdrant-Route-Affinity from the search, scroll, count and retrieve paths, so
read-your-own-writes stops needing wait = true on the write. It is a header over REST and metadata over
gRPC, which is why it rides the request models as a transient field; a batch is one call either way, so
both engines refuse a batch whose searches ask for different replicas rather than honouring the first.

M62 reads the cluster quota instead of discovering it, and deprecates the two strict-mode ceilings
2.2.0 added. Two minors from introduction to deprecation looks like churn, so the changelog says why
rather than only what.

M63 closes every TrustAnchors row. Windows has no per-handle root override and never will. Linux
cannot pin because Ktor's Curl config exposes caInfo, caPath and sslVerify and nothing else. Darwin
could take a bundle through the challenge handler Ktor does expose, and deliberately does not: custom
trust evaluation accepts more than it should when it is wrong and the failure is silent, so it waits for
a test proving it rejects a chain the bundle does not anchor.

M65 ships the Windows binary that compiled and shipped nowhere for two releases, and the half of the
CLI that was cut: kdrant health, the collection lifecycle, --shard on every snapshot action, and
storage-snapshot. M66 moves the proof script out of the release workflow so CI runs every subcommand
against a real Qdrant on every push; 2.2.0 took three release attempts and all three failed there.
M67 makes publishing automatic and then checks that the version actually resolves from Maven Central,
because 2.0.0 and 2.2.0 were both green, tagged and unresolvable.

M64 and M68 publish numbers, including the ones Kdrant loses. The comparison harness had shipped
without ever being run, and it turned out not to compile: the official client moved PointId between
generated classes at 1.19 and nothing noticed, because the JMH source set is not in build. It is now.
Run, it says Kdrant's REST engine is 2.5x slower than the official client on a single search and 9.3x on
a 500-point upsert. A third column makes that readable: over Kdrant's own gRPC engine the gap is 8% to
30%, so what the two defaults measure is HTTP and JSON against protobuf rather than one library against
another. The worst row is partly a round trip, because Kdrant splits an upsert at 256 points and sent two
requests where the official client sent one. Multi-tenancy: a tenant index is about 10% faster on the mean
and 20% at the tail, which is the floor rather than the headline, and published as such.

Dependabot

#147's whole gradle-minor-patch group, twenty-five updates, and #145's actions/setup-java v6 across every
workflow. No source change was needed for any of them. Close those two when this merges.

What this does not do

Verification

./gradlew build green, koverVerify included. 34 request bodies validate against Qdrant's own v1.19.1
OpenAPI document. In CI: the shared contract passes over both engines against a real v1.19.1 and against
:latest, the version matrix passes across v1.19.1, v1.18.3, v1.17.1 and v1.16.3, both native client
contracts pass, the new vendored-files job passes, and the new CLI job runs every subcommand against a
real Qdrant.

Which Qdrant this client speaks to was written in fourteen places and compared in
none, so it drifted where it was least visible. The vendored OpenAPI document said
v1.18.2 in its README and was a master snapshot taken before 1.19.0 shipped, which
left the contract test validating request bodies against fields no released server
has. points.proto had been hand-edited to carry part of 1.19 while the proto README
stated that nothing there is ever edited.

The document cannot say where it came from. Qdrant ships "version": "master" under
info at every released tag, v1.19.1 included, so info.version can never be the pin.
The pin is qdrantVersion in gradle.properties instead, verifyQdrantPin fails the
build when anything else in the repository names a newer Qdrant, and
verifyVendoredQdrant supplies the evidence the document lacks by fetching the pinned
tag and comparing byte for byte, over the protobuf definitions as well as the schema.
refreshVendoredQdrant moves them together, which is now the only supported way to
move.

Taking upstream's bytes is what forces the rest of this change into the same commit.
The refreshed protos reserve max_disk_usage_percent, which 1.19 replaced with the
global quota API, and add oneof cases that make the filter mappings non-exhaustive,
so the client has to answer for 1.19 in the same breath as it pins to it.

So it answers for all of it. matchPrefix joins the filter DSL together with the
keyword index option without which the filter is accepted and matches nothing, which
is the failure M43 existed to remove and which this would otherwise have repeated.
The two transports spell that option differently: REST takes a boolean, gRPC takes
an empty message whose presence enables it. The model carries the boolean and each
engine renders it, asserted on both sides, because a filter that works over one
engine and not the other is worse than one that works over neither.

relevanceFeedback is the eleventh query variant and the one an agent loop actually
needs: a graded response to a query that already happened, rather than recommend's
positive and negative targets, which is close and not the same.

slice selects one of a number of deterministic partitions of the id space, so a
scroll splits across workers without guessing how the ids are distributed and a
sample reproduces. Qdrant hashes the id, so the split stays uniform for UUIDs from
an upstream system.

Every component that took an onDisk or alwaysRam flag now also takes a memory tier,
a collection places its payload with payloadMemory, and TURBO4 stores only 4-bit
quantized vectors with no originals. Where a caller sets both a tier and a flag the
tier wins, which is Qdrant's rule and is stated on every memory property and in
STABILITY.md, because a caller setting both and getting whichever the server prefers
is the way this addition goes wrong.

Two more that nobody planned, which is the point. The per-query IDF corpus computes
sparse vector statistics over a filter rather than the whole collection, and min, max
and acosh were missing from the formula language. Both shipped in 1.19.0 and neither
had a board item, because noticing depended on somebody happening to read a release
note. Qdrant watch reads it on a schedule instead and opens an issue when upstream is
ahead of the pin.

Every new shape is asserted against Qdrant's own v1.19.1 OpenAPI document in the
contract test, which now names the operations it covers rather than counting them: a
count is a check somebody eventually lowers to make a build pass.
@tonytonycoder11
tonytonycoder11 requested a review from a team as a code owner September 10, 2026 15:11
@tonytonycoder11 tonytonycoder11 moved this to In progress in Kdrant Sep 10, 2026
@trueup-by-nacode-studios

Copy link
Copy Markdown

This pull request lets the code touch the filesystem in 2 places.

  • touch the filesystem (decided at runtime) — build.gradle.kts:350 · callee resolved from an import or a builtin
  • touch the filesystem (decided at runtime) — build.gradle.kts:370 · callee resolved from an import or a builtin

What I could not follow

95 calls resolve to a name whose type is written nowhere, so what they reach is not established here, and 43 more are in tests and benchmarks, which this report does not judge. Across everything it read that is 138 of 3367, 4%. That is too many to list, and where they are is more useful than which they are:

  • kdrant-core/src/commonMain/kotlin/dev/kdrant/model/Condition.kt — 36 calls
  • kdrant-core/src/commonMain/kotlin/dev/kdrant/model/QueryInterface.kt — 28 calls
  • kdrant-core/src/commonMain/kotlin/dev/kdrant/model/Expression.kt — 25 calls
  • kdrant-core/src/commonMain/kotlin/dev/kdrant/model/QuantizationConfig.kt — 6 calls

If one of those reaches the network or starts a process, this report does not show it.

50 files read · 38863 ms · against d1f9bb7 · engine 1.4.1

The gradle-minor-patch group, twenty-five updates: JUnit 6.1.3, Kotest 6.2.4,
Spring Boot 4.1.1, Spring AI 2.0.1, langchain4j 1.19.0, Micrometer 1.17.1,
Koog 1.2.0, gRPC 1.84.0, protobuf 4.36.1, OpenTelemetry 1.65.0, Guava
33.7.1-jre, the binary compatibility validator 0.18.2 and the GraalVM build
tools 1.1.11. And actions/setup-java from 5 to 6 across every workflow, the new
vendored-files job included.

Clean build, no source change needed to take any of them.
…tting it

Two Qdrant 1.19 additions that both change what a client can do about a cluster
rather than what it can ask a collection.

A routing token pins reads to a replica. `routeAffinity` on the search, scroll,
count and retrieve paths sends `X-Qdrant-Route-Affinity`, so two reads carrying
the same token are served by the same peer. The case is read-your-own-writes: a
write replicates asynchronously and a read issued straight after it can land on a
replica that has not caught up, and the only lever this client offered was
`wait = true` on the write, which blocks the writer to fix a reader. It is per
request rather than on the config because the thing that should be sticky is one
reader's session, not the application.

It travels as a header over REST and as metadata under the same key over gRPC,
which is why it is a transient field on the request models rather than a body
field: serializing it would send Qdrant a key it does not have. A batch is one
call over either engine, so it carries one token, and both engines refuse a batch
whose searches ask for different replicas rather than honouring the first and
dropping the rest. A stale read a caller cannot explain is worse than an error
they can.

The quota is the other half. `quotas()` reads the cluster-wide limits and what
each peer reports against them, `updateQuotas` replaces them. A client that learns
about a limit only by being refused retries into the same wall: `RateLimited` says
waiting is worth it and cannot say how much room is left. The update replaces
rather than merges, and says so, because a config naming one limit silently drops
the others. Qdrant serves it over HTTP only, so the gRPC engine refuses both by
name, beside the eleven operations already in that list.

That deprecates the two strict-mode ceilings `2.2.0` added, which is short enough
between minors to look like churn, so STABILITY and the changelog say why rather
than only what: the argument for modelling them was that a node refusing writes
while still serving reads is the degraded state a client most needs to be
predictable in, and that argument still holds. What moved is where the limit is
set.

The shared contract also grew the four cases Tier 10 owed it: prefix matching
before and after the index that serves it, relevance feedback reranking, four
sliced scrolls covering a collection once and repeatably, and 4-bit storage with a
memory tier per component surviving a round trip.

One correction in there. Prefix matching does not need its index the way phrase
matching does. Qdrant checks a prefix condition per point without one and only
refuses it under strict mode with unindexed filtering off, so the index is an
accelerator, and the KDoc, changelog and tests that said otherwise now say what
the server does.
…h the release

Four items that are all about the distance between what this repository builds
and what somebody can actually get.

The Windows target compiled and shipped nowhere for two releases. It builds from
any runner, and Qdrant publishes a Windows server, so the binary is now made,
proven against a real node on a Windows runner, checksummed and attested like the
other two, and attached. The one thing that stopped this being obvious is that the
release job's steps were written for two platforms and hard-coded a `.kexe`
extension and a `shasum` that Git Bash does not have.

The tool itself grew the half that was cut rather than declined. `kdrant health`
reports the three probes separately, because a node that is alive and not ready is
the state somebody at a terminal is usually looking at, and exits on readiness so a
script can use it. `kdrant collection create|describe|delete` covers the lifecycle
`collections` only listed. `--shard N` scopes any snapshot action to one shard,
which is how a sharded collection is really snapshotted and recovered, and
`storage-snapshot` covers the whole node, which is what a full restore uses.

2.2.0 took three release attempts and every one of them failed in the CLI job, on
defects any push could have caught: migrate could not create the collection it
migrated into, and the step proving the binary called docker on a macOS runner.
None of them could be caught earlier because nothing below the release workflow had
ever started the binary. The proof is now a script both workflows run, and CI runs
it on every push, so a release is the second time the tool has executed.

Publishing releases itself now. `2.0.0` and `2.2.0` were both tagged, attested,
green everywhere and unresolvable, because the deployment stops at staged until
somebody opens the Portal. The argument for keeping that step was that it is the
last look before an artifact becomes permanent; in practice it was a button pressed
because the workflow was green, which is a check that had already run. What stands
in for it is the step beside it, which asks Maven Central for every artifact the
release claims and fails when one does not answer.

And the README says what this is. A published ARM target, a 37 ms cold start in
42 MB and a 5.7 MB static binary read together as something that could hold an
index on a device. It is a client. The device answer is Qdrant Edge, and the
Platforms section now draws that line rather than leaving it to be worked out.
…ile again

The multi-tenancy benchmark puts the same points and the same tenant key in two
collections, indexes that key with isTenant on one and as an ordinary keyword on
the other, and runs the same filtered search over both. A third row searches
unfiltered, so a reader can see what a filter costs before the layout does. If the
two rows come out level that is the number, and it says the layout matters at a
size this harness cannot reach rather than that it does not matter.

Writing it turned up that the harness has not compiled since the official client
went to 1.19. Qdrant moved PointId out of Points and into Common when it split its
protos, and the comparison benchmark still named the old one. Nothing reported it
because the JMH source set is not part of `build`, so CI now compiles it. A
benchmark harness that does not build is a slower way to have no benchmark than not
writing one.
…o the library

Relevance feedback does not do what this contract asserted it does, and CI said so
before anything shipped. Qdrant drops the points the feedback names rather than
reordering them among the rest, which is right for the loop it belongs to and is
not what "rerank" suggests. The KDoc, the changelog and the contract now say it,
and the contract asserts both halves: the judged points are gone, and the ones left
come back in a different order from the same query without grades.

Each TrustAnchors row is now a decision rather than a blank. Windows has no
per-handle root override, so the machine store is the answer and there is nothing
for a later release to add. Linux cannot pin because Ktor's Curl config exposes
caInfo, caPath and sslVerify and nothing else, so libcurl's option is unreachable
without an upstream change; that is the change, and until it lands, pin from the
JVM. Darwin could honour a bundle through the challenge handler Ktor does expose,
and deliberately does not: custom trust evaluation accepts more than it should when
it is wrong, and the failure is silent by construction, so it is worth building only
alongside a test proving it rejects a chain the bundle does not anchor.

The RAG example had stayed on the 1.x client while the library grew past it, which
matters because it is where somebody checks whether the README's argument survives
contact with code. It now ingests through ingest with the resume token on disk,
retrieves over a dense and a sparse ranking fused by reciprocal rank with the server
applying IDF, creates its payload indexes with the parameters its filters need, and
distinguishes a failure worth retrying from one that is not. Its README says which
release each of those arrived in. It did not grow a second purpose.
The first run of the comparison says Kdrant loses every operation against the
official Java client, by 2.5x on a single search and 9x on a large upsert. Those
numbers are worth publishing and they cannot be interpreted, because Kdrant was
measured over REST and the official client over gRPC, so every gap is a gap against
protobuf before it is a gap against a library.

This client has a gRPC engine. Measuring it beside the other two turns one
unanswerable question into two answerable ones: Kdrant against the official client
asks which library, and Kdrant against itself asks how much of that was the wire
format. Publishing the first without the second is how a number gets quoted out of
context, which is the thing M68 says to avoid.

Also records the two decisions M65 and M66 asked for rather than assumed. No
Homebrew tap and no Scoop manifest: each is a standing obligation to keep a version
number in a second place, and a curl from the release URL has no second copy in it.
And `snapshot restore` stays out of the CLI proof script because it takes a location
the server resolves, so a file:// URL names a path inside the runner rather than
inside the container; the shared contract covers restoring against a real node.
The comparison harness shipped a release ago and had never been run, which mattered
more than an unfinished task usually would: the whole argument for this client is
ergonomics, and a reader who is unconvinced assumes they were bought with
throughput. Nothing here contradicted them.

It has been run, and the first thing it says is that Kdrant loses every row. Against
the official Java client as each is normally configured, the default REST engine is
2.5x slower on a single search and 9.3x slower on a 500-point upsert. Those numbers
are published first because they are true of the default choice.

They are also uninterpretable on their own, which is why the harness grew a third
column. Kdrant over its own gRPC engine answers a search in 0.65 ms against the
official client's 0.56, and the upsert in 10.4 ms against 8.0. So the gap between
the two defaults is HTTP and JSON against protobuf rather than a Kotlin client
against a Java one, and the suspending functions and the typed DSL cost nothing
detectable.

Over the same protocol Kdrant is still behind, by 8% to 30%, and the worst row has a
reason rather than an excuse: Kdrant splits an upsert at 256 points by default, so it
sent two requests where the official client sent one. That default bounds the memory
a large ingest holds and it costs a round trip here. It stays at the default, because
the benchmark should measure what a caller gets rather than what a tuned caller could
get.

Multi-tenancy is measured too. A tenant-indexed collection is about 10% faster on the
mean and 20% at the tail than the same data behind a plain keyword index, which is
less than the architecture's reputation suggests and is the expected shape at this
size: 20 000 points fit in one or two segments, so there is almost nothing to
colocate. Published anyway, because a reader deciding between one collection per
tenant and one collection with a tenant index is better served by the floor than by
nothing.
@tonytonycoder11 tonytonycoder11 changed the title M60 · Pin one Qdrant, and take the surface 1.19 added Tier 10 and Tier 11 · Track Qdrant 1.19, and measure what was only asserted Sep 10, 2026
The integration matrix's `:latest` cell failed on a message the shard matcher did
not recognise. A node whose only replica for a shard is gone answers

  Service internal error: 1 of 1 read operations failed: Timeout error: Deadline
  Exceeded: code: 'Deadline expired before operation could complete', message:
  "Healthcheck timeout 2000ms exceeded"

depending on which check gives up first, and the fan-out branch of the matcher knew
"timed out" and not "timeout" or "deadline". So it fell through to ServerError, which
is terminal, and told the caller not to retry a state that clears in seconds. That is
the one classification mistake that changes what somebody does.

Both engines keep their own copy of that matcher, which is how they came to disagree,
and both are fixed here. The duplication itself is #154: the classifiers decide the
retryable flag on exception types that live in kdrant-core, and that is where they
belong, behind the same opt-in internal annotation KdrantJson already uses.

The two phrasings a stopped peer actually produces are now in the unit test verbatim
from the CI runs that produced them, beside an assertion on retryable rather than only
on the exception type: a refactor that keeps the name and loses the meaning should
fail.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

M56 · Prefix matching, and the keyword index that has to allow it

1 participant