Repository navigation
Conversation
This was referenced Oct 20, 2026
[qdrant options] Let a deployment tune a Qdrant collection's HNSW, optimizers and quantization
#1618
Draft
Draft
Open
Draft
Draft
edwinyyyu
force-pushed
the
feat/vector-store-size-based-upserts
branch
from
October 26, 2026 18:06
df44c87 to
ac84d80
Compare
This was referenced Oct 26, 2026
This was referenced Oct 26, 2026
edwinyyyu
force-pushed
the
feat/vector-store-size-based-upserts
branch
7 times, most recently
from
October 31, 2026 00:57
ac2727e to
68b34f7
Compare
The Qdrant and Milvus partitions sent an upsert as one request and, when
the server refused it as too large (Qdrant 400/413, Milvus
RESOURCE_EXHAUSTED), halved the batch and resent the halves. A refused
batch's bytes went out again at each halving, and a malformed Qdrant
batch was halved down to single points.
Each record's size is now computed as it goes on the wire, and the
records are packed, in order, into the fewest requests of at most a new
`max_request_bytes` setting, each sent once (`pack_requests` in
vector_store/utils.py):
- Qdrant over REST: the point's JSON from qdrant-client's own
`jsonable_encoder`, plus its comma, inside the `{"points":[...]}`
body; over gRPC: the point's `UpsertPoints` field (tag, length, and
message) after qdrant-client's `RestToGrpc` conversion, plus the
envelope `UpsertPoints(collection_name, wait=True)`. The transport is
the client's `init_options["prefer_grpc"]`. Both are exact: unit tests
compare them with the bytes a real client sends to an httpx mock
transport and to an in-process gRPC server.
- Milvus (gRPC): pymilvus sends a request's fields as columns, so an
entity's size is each value as an element of its column (a string with
its tag and length, a packed bool, int64 varint, double, or 4 bytes
per vector coordinate) plus a validity flag per nullable declared
field. The request's own fields and each field's framing are an upper
bound, 64 bytes per field plus its name and 64 per request plus the
collection name. Against requests pymilvus 3.0.1 built, the bound was
713 bytes where the actual framing was 195 to 271.
The defaults are the servers' own default limits:
- Qdrant: 33554432 bytes, `service.max_request_size_mb: 32` in
config/config.yaml (embedded as the default configuration), applied as
`max_request_size_mb * 1024 * 1024` to the REST JSON body in
src/actix/mod.rs, both at v1.19.1. Its gRPC services set
`max_decoding_message_size(usize::MAX)` (src/tonic/mod.rs), so gRPC
has no limit.
- Milvus: 67108864 bytes, `proxy.grpc.serverMaxRecvSize` in
configs/milvus.yaml at v2.6.24 (the code's fallback,
DefaultServerMaxRecvSize, is 256 MiB), passed to grpc-go's
MaxRecvMsgSize, which refuses a message longer than it with
RESOURCE_EXHAUSTED.
A request within the setting that the server refuses as too large means
the setting exceeds the server's limit, so the refusal raises
QdrantConfigurationError or MilvusConfigurationError, chaining the
server's error. Qdrant's 400 is also its status for any invalid request,
which only the message tells apart, so its error says the setting
exceeds the limit unless the response names another fault. A record too
large for a request alone raises ValueError before anything is sent.
Retries, timeouts, order, and the registration checks around `_upsert`
are unchanged.
`max_request_bytes` is on QdrantConf, MilvusConf, and both stores'
params, plumbed by the database manager, and listed in
configuration.mdx; the design documents describe the packing in place of
the halving.
Verification: ruff, ruff format, and ty clean; server unit suite 2281
passed, 8 skipped. New integration tests against qdrant/qdrant:v1.19.1
(REST and gRPC) and milvusdb/milvus:v2.6.24 pass: a packed upsert
stores every record, Milvus's requests on the wire stay within the
setting, a record too large raises before any request, and a request
over the server's limit raises the configuration error.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
VectorStorePartition.upsert now states that cancelling the call stops it from sending further requests to the backend, and that a request already sent may still be applied, so a cancelled upsert may have written some of the records. RegistryBackedVectorStorePartition's `_upsert` hook states the same obligation for its implementations. Every implementation honors it: - RegistryBackedVectorStorePartition awaits the registration check, `_upsert`, and the check again in sequence, so a cancellation stops at the await it lands on. - Qdrant and Milvus send their packed requests one after another, and a cancellation raised in a request ends the loop. Neither client catches CancelledError: qdrant-client's REST path catches Exception only, and pymilvus's retry decorators catch grpc.RpcError, MilvusException, and Exception, so a cancelled RPC surfaces as CancelledError and is not retried. - sqlite-vec writes in one transaction. A statement already queued to aiosqlite's thread finishes there; SQLAlchemy treats the cancellation as a disconnect and invalidates the connection, so the transaction never commits and nothing more is executed on it. - The engine-backed SQLite store commits its records and pending operations, then applies them to the search engine, then marks them applied. A cancellation in the engine step leaves the operations pending, and startup replays them. - The in-memory test partition has no await in its upsert. Tests cancel an upsert mid-call and check that nothing more goes out: for Qdrant and Milvus with a fake client (no further request, and no registration check after it) and against qdrant/qdrant:v1.19.1 over REST and gRPC and milvusdb/milvus:v2.6.24 (the first request in flight; for Milvus counted at the gRPC stub, so a pymilvus retry would show), checking that only the first request's records may be stored; for both SQLite stores, cancelling at the upsert's first statement, and for the engine-backed one also while its search engine applies the records, no further statement executes. Verification: ruff, ruff format, and ty clean; server unit suite 2286 passed, 8 skipped; the vector store test directory with integration tests, 766 passed, 5 skipped (the registry's dialect-specific tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test that Milvus refuses a request over its size limit sends the whole batch, mostly one declared VARCHAR property, as one request. pymilvus checks a VARCHAR value one character at a time while it builds that request, within the request's timeout, so on a slow runner the build outlasts the module's request timeout before Milvus can refuse the request. The test's own store gives its request longer, as the packing test's store does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
edwinyyyu
force-pushed
the
feat/vector-store-size-based-upserts
branch
from
November 2, 2026 17:57
68b34f7 to
ad2b29d
Compare
Contributor
Author
|
Packing involves some changes to operator-friendliness and tracking how upstream dependencies encode their requests and check their limits. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Purpose of the change
The Qdrant and Milvus partitions send an upsert as one request. When the server refuses it as too large (Qdrant 400 or 413, Milvus RESOURCE_EXHAUSTED), they halve the batch and resend the halves, so an upsert's time depends on how many halvings it takes, and a refused batch's bytes go out again at each one. Event memory bounds when its vector writes are sent by cancelling an upsert at a deadline, which needs a written guarantee of what cancelling does.
max_request_bytessetting, each sent once (pack_requestsinvector_store/utils.py):jsonable_encoder. Over gRPC: itsUpsertPointsfield after qdrant-client'sRestToGrpcconversion. Both are exact; unit tests compare them byte for byte with what a real client sends.service.max_request_size_mb; its gRPC sets no limit) and 64 MiB for Milvus (proxy.grpc.serverMaxRecvSize). The design docs cite the sources at Qdrant v1.19.1 and Milvus v2.6.24.QdrantConfigurationErrororMilvusConfigurationError, chaining the server's error. Qdrant answers any invalid request with 400, which only the message tells apart, so its error says so. A record too large for a request alone raisesValueErrorbefore anything is sent.VectorStorePartition.upsertnow states that cancelling the call stops it from sending further requests, and that a request already sent may still be applied. Every implementation already behaves that way; each store gets a test that cancels an upsert mid-call and checks that nothing more goes out.Behavior that changes:
max_request_bytesto that limit. Its refusals now raise instead of being halved.Stack
25 open PRs: three independent PRs, and the vector store tree of short parallel branches. Every open PR in the tree has
feat/horizontal-scalingas its GitHub base, and the independent PRs havemain. The branches are in a fork, and a pull request can target only this repository's branches, so the on column gives the order the PRs build on each other. A stacked PR's diff on GitHub includes the PRs under it until they merge.Independent of the vector store tree, directly on
main:mainmainmainThe vector store tree. Each PR builds on the one in its on column; PRs on the same parent are parallel branches and do not depend on each other. #1631 is closed, superseded by #1733–#1736, which hold its changes split in four, with review changes since. #1675 is closed, superseded by #1627. #1839 and #1840 are the event memory recovery stack, and #1684 heads the event memory stack, which its own PRs index; #1684 is listed because it builds on #1840 and #1828 builds on it; #1871 keeps its own Stack section. Until the PRs under it merge, their changes show in a stacked PR's diff.
mainfeat/horizontal-scaling)feat/horizontal-scalingfeat/horizontal-scaling)feat/horizontal-scalingfeat/horizontal-scaling)feat/horizontal-scalingfeat/horizontal-scaling)feat/horizontal-scalingfeat/horizontal-scaling)feat/horizontal-scalingfeat/horizontal-scaling)feat/horizontal-scalingfeat/horizontal-scaling)feat/horizontal-scalingfeat/horizontal-scaling)feat/horizontal-scalingfeat/horizontal-scaling)feat/horizontal-scalingfeat/horizontal-scaling)feat/horizontal-scalingfeat/horizontal-scaling)feat/horizontal-scalingfeat/horizontal-scalingfeat/horizontal-scaling)feat/horizontal-scalingfeat/horizontal-scaling)feat/horizontal-scalingfeat/horizontal-scaling)feat/horizontal-scalingfeat/horizontal-scalingfeat/horizontal-scalingfeat/horizontal-scalingfeat/horizontal-scalingfeat/horizontal-scalingfeat/horizontal-scalingfeat/horizontal-scalingmemmachine-server migrate, which creates every configured store's schema before servers startfeat/horizontal-scalingThis PR is its 3 commits,
5ab987dfb,8aec258db,ad2b29dc6, directly onfeat/horizontal-scaling. Its request packing is dropped and its cancellation contract moved into #1840, so it is to be closed; nothing is stacked on it.Verification
At this PR's head
ad2b29dc6, onfeat/horizontal-scaling's0fa52b38b:ruff checkandruff format --checkare clean,ty checkis clean as CI runs it (uv run --frozen --all-extras ty check --project packages/server), anduv lock --checkpasses.packages/server/server_tests -m 'not integration') at [event memory recovery 2] Keep event memory's two stores consistent through failed ingests and forgets #1840's head96097c2c0, which holds this PR's changes: 2437 passed, 8 skipped.Before the rebase onto #1628's
4b1210953, the vector store test directory with integration tests ran againstqdrant/qdrant:v1.19.1(REST and gRPC) andmilvusdb/milvus:v2.6.24through testcontainers: 766 passed, 5 skipped (registry tests for the other SQL dialect).🤖 Written by Claude Code (Claude Opus 5.5) on behalf of @edwinyyyu.