755 Commits
Author SHA1 Message Date
Andrey VasnetsovandClaude Opus 5.5 222b2e44ab Bump version to 1.20.0 (#11298)
* Bump version to 1.20.0
* Update missed cherry picks
* Add OpenAPI spec for v1.20.x

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:45:09 +02:00
qdrant-cloud-bot c5c1ba59b3 Enable HTTP/2 keepalive on internal p2p gRPC (port 6335) (#11273)
* Enable HTTP/2 keepalive on internal p2p gRPC connections

Idle pooled channels on port 6335 could become black holes behind
stateful network layers (Calico eBPF, NAT, load balancers), causing
~62s request stalls. Enable keepalive on client and server sides so
conntrack stays fresh and dead connections are detected quickly.

Settings are configurable via env/custom config but omitted from the
public config.yaml. Fixes #11271.

* Address review: trim keepalive config comments

(cherry picked from commit b267ffa048)
2026-10-21 18:58:26 +02:00
Andrey Vasnetsov 65882ff24d Default to 2 shards per node in 3+ node clusters with few collections (#11255)
* Default to 2 shards per node while collections are fewer than nodes

Auto-sharded collections created in a cluster without an explicit shard
number get 2 shards per node while the cluster holds fewer collections
than nodes, and 1 per node otherwise. Spare shards let new nodes take
whole shards instead of resharding, which re-indexes the data. Clusters
with many collections keep the previous shard count.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Don't document the default shard number logic in the API

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Keep one shard per node in clusters of fewer than 3 nodes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Rewrap default_shard_number_per_node doc

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 64b5ae0ea3)
2026-10-21 18:58:26 +02:00
Andrey Vasnetsov 791ed05def feat(text-index): add code tokenizer (#11180)
* feat(text-index): add `code` tokenizer

Splits source code into identifiers and their subwords, following Lucene's
WordDelimiterGraphFilter with catenate_all: snake_case, camelCase,
PascalCase, plus an acronym rule (HTTPServer -> http, server) with a plural
exception (getURLs -> get, urls). Digits stay attached to letters (utf8, k8s),
and words that start with a digit are number literals and are not split.

Documents index subwords in text order, then each multi-part identifier joined
(getUserName -> getusername) at the end of the value, so phrase matching over
subwords is unaffected. Queries are split into subwords only.

Also adds an ASCII fast path to TokensProcessor lowercasing (no allocation
for already-lowercase tokens) and a tokenizer throughput bench.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LupJerJ3WBAhW5RK8kvdtt

* fix(text-index): keep code tokenizer's joined identifiers out of phrases

Phrase queries were tokenized as documents, so the code tokenizer appended
joined identifiers to them: `getUserName(id)` became `get user name id
getusername`, which no document has contiguously, and existing phrases
stopped matching. In documents, joined identifiers sat next to each other and
to the last subword, so phrases such as `hashmap getusername` matched text
that does not contain them.

- Phrase queries use a new `TokenizerTextKind::Phrase`: subwords only for the
  code tokenizer, unchanged for the others.
- Joined identifiers are detached tokens (`Tokenizer::tokenize_doc_detached`).
  With phrase matching on, the index puts a sentinel before each of them, so
  they only match a phrase of themselves alone; the payload check does the
  same with a token id no query has.
- Document length discounts the inserted sentinels by count, so it is the same
  with phrase matching on and off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LupJerJ3WBAhW5RK8kvdtt

* feat(text-index): join only camelCase identifiers in the code tokenizer

A joined identifier helps when someone types an identifier as one lowercase word, as people do
with camelCase (`hashmap`, `getusername`). snake_case is typed with its `_`, which the query
splits into subwords anyway, so its joined form only grew the vocabulary. Join each camelCase run
between `_` separators instead of the whole identifier (`test_getUserName` -> `getusername`).

Without lowercasing, a query word with case changes is split, so it can never match a joined
identifier: emit none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LupJerJ3WBAhW5RK8kvdtt

---------

Co-authored-by: Andrey Vasnetsov <andrey@qdrant.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 150b787fbf)
2026-10-21 18:58:25 +02:00
Andrey Vasnetsov a0d6af3933 Edge query: read payloads by the location carried on ScoredPoint (#11237)
* Carry where a segment found a point on ScoredPoint, read payloads by it

Alternative to #11235. `ScoredPoint::location` holds the segment and
offset a search found the point at, set where search results are built
(`process_search_result`, which now knows its segment's uuid through the
read view) and carried through fusion, rescoring, offsets and grouping
by the existing struct updates. Points decoded from gRPC, scroll results
and test fixtures carry none.

`ReadSegmentEntry::retrieve_at` reads located points by offset, leaving
out those located in another segment, deleted, past the deferred cutoff
or at another version since. Edge fills payloads and vectors that way,
and reads the rest by id.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Read located points through a plain PointAtOffset, in parallel

- `retrieve_at` takes `PointAtOffset { id, offset, version }` instead of
  `&ScoredPoint`: the segment boundary no longer knows how locations are
  obtained, and edge picks each segment's points from `ScoredPoint::location`
- `retrieve_at` takes `deferred_behavior` explicitly, like `retrieve`
- edge reads located points from all segments on the search pool
- `unlocated_point_ids` names the points read by id instead

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit d16c206c97)
2026-10-21 18:58:25 +02:00
Arnaud Gourlay b051def886 [BM25] Score without positions from a term frequency file (#11165)
* [BM25] Score without positions from a term frequency file

A scoring text index no longer requires phrase_matching. Without it, the
index keeps an ids-only postings.dat, unchanged, and a new
term_frequencies.dat: each posting list's frequencies in posting order,
bit-packed per 128-entry block aligned with the postings chunks, at the
width of the block's largest value, so every frequency is exact. A
frequency is stored minus one, so a block of ones takes no bytes. With
phrase_matching, tf still comes from the positions.

- the mutable index counts tf from the token stream and keeps the ordered
  document only for phrase matching; its stored record keeps repeated
  tokens when lengths are recorded
- the on-disk index reads the file header at open and only the query
  terms' headers and data per query, in two batched reads
- a file that does not match postings.dat, or is malformed, is ignored:
  the index rebuilds, a read-only one reports the field absent
- normalized() no longer forces phrase_matching

On the bm25_corpus fixture the text index takes 0.28x (20k documents) and
0.18x (100k documents) of the positional one on disk.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Sync scoring docs with the counts-only term frequency file

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Reject a malformed term frequency remainder at parse

A missing width byte, a width above 32 or too few remainder bits made
scoring panic or read garbage frequencies.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Check term frequencies survive a gridstore reload, drop stale scoring docs

- doc_len_survives_gridstore_reload also asserts tf after reopen
- block length check moves from a const assert to a unit test
- docs and error message no longer say scoring needs or implies positions

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Score from the term frequency file with or without positions

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Check term frequencies at query time in debug builds only

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Fill postings at builder add, keep scoring documents as written

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Open the term frequency file with the postings' placement

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Drop the no-op TextIndexParams::normalized

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Write and remove text index sidecars through the index fs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: generall <andrey@vasnetsov.com>
(cherry picked from commit 0cb222e367)
2026-10-21 18:58:24 +02:00
Andrey Vasnetsov 5bc372099a Nested payload index: index the null index per element too (#11207)
The null index of a `nested` field stayed an index of points, so it was
the one index of the field not keyed by elements. It is now wrapped like
the others: built and updated from each element, with its own element
map, and opened over element offsets.

`is_empty` on such a field matches a point when one of its elements lacks
the field, and never a point without elements, like a nested filter over
the payload. `is_null` already matched when any value is null.

Opening an index takes the number of ids it is keyed by instead of the id
tracker, so a per-element null index is sized by its elements. Its
cardinality estimate is over all elements, not only those with values.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 5c5ff38461)
2026-10-21 18:58:23 +02:00
Andrey Vasnetsov fb0cda7853 Per-element (nested) payload indexes: API and index wrapper (#11126)
* Add per-element (nested) payload indexes

New `nested` parameter of the create-index request: index every object of
an array as a separate entry, e.g. `offers[].price` with `nested: offers`.

The per-element index wraps an unchanged field index keyed by element
offsets from an element map, shared by all per-element indexes of the same
array. Every read translates elements back to points, so the index behaves
exactly like the flattened index of the field. Nested filter processing is
not changed yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbgxnY8sJkEr7N9HG6Azkn

* Nested payload index: clippy fixes, transition and rebuild tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbgxnY8sJkEr7N9HG6Azkn

* Nested payload index: support full-text indexes

A full-text condition matches a point when a single element matches it,
and BM25 scores every element as its own document: corpus statistics are
gathered over elements, and a point scores as its best element.

Bm25Accept gains an element mode, so the outer filter is checked on the
owning point while the index scores element ids.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Nested payload index: one element map per index

An element is identified by its point and its position among the objects
of the array, which depend only on the current payload. Element offsets
are private to each index, so per-element indexes no longer share a map
per array.

Every per-element index owns its element map, stored next to its inner
index and opened, built, flushed and wiped with it. Immutable indexes get
a map of per-point start offsets, written once at build; mutable ones
keep the Blobstore-backed map. The map's mutability follows the stored
index type.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Destructure create-index operations and index types

So a field added later can't be silently ignored: do_create_index and
internal_create_index destructure their operation, and the per-element
index build and open destructure FullPayloadIndexType.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Nested payload index: `nested` is a parameter of the index params

`nested` was a top-level create-index parameter stored inside the params,
so every API boundary split it out and merged it back. It is now an
ordinary parameter of every index type's params, in REST and in each
gRPC `*IndexParams` message:

    "field_schema": { "type": "integer", "nested": "offers" }

The create-index request validates it against the field name and gates
it on the peer version; normalization stores it as the array key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Nested payload index: exhaustive matches instead of matches!

So a new index variant or storage type has to be decided on explicitly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Nested payload index: bit-packed starts file for immutable element maps

The start offsets of an immutable element map are stored with
BitPacker4x in blocks of 128: the deltas are the element counts of the
points, packed with the bit width of the largest one in the block. With
up to three elements per point that is about 2 bits per point instead
of 4 bytes, so opening the map reads a fraction of the array it builds.

The file starts with a 32-byte header: magic, format version, the number
of starts, and reserved bytes for later fields. A file with another
magic or version is invalid, which rebuilds the field's per-element
indexes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Nested payload index: stream order-by in runs of equal values

Order-by on a per-element index collected and sorted the whole range.
The inner index already streams pairs sorted by value, so only the
points within one value need sorting: PointRuns buffers one run of
equal values at a time, maps its elements to points, sorts and
deduplicates them, and yields. Each end of the double-ended iterator
takes whole runs from what neither end has taken, so a value never ends
up in both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Nested payload index: open immutable indexes without reading them

Opening a per-element index counted its indexed points by checking every
element of the inner index, which read a cold index from disk in full.

The build pass now counts points with a value an index may store, and an
immutable element map keeps the count in its header. Opening takes the
deleted points with elements off it: approximate, since such a point is
taken off even if it had no value. Mutable maps keep counting, their
indexes are in RAM.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Nested payload index: facets reuse one buffer for the points of a value

Facets collected the points of every value into a new vector before
sorting and deduplicating them. ElementPoints keeps one buffer for all
values of a facet call. Counting uses the same sorted buffer: in an
immutable map the points already come sorted, so the sort is close to
linear.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Nested payload index: document why ElementPoints keeps its buffer

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Nested payload index: per-element text semantics apply to every query

Document on the text index's `nested` parameter that every query on the
field treats each element as its own document, not only nested filters.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Nested payload index: assert points come in order when building

An immutable element map needs payload storage to visit points in
ascending order. The payload storage contract now states it, the build
pass of a per-element index asserts it in debug builds, and the
test-only in-memory payload storage iterates in point order.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Nested payload index: reuse freed offsets in mutable element maps

A mutable element map gave every growing point a fresh contiguous range
at the end and never reused offsets. A point that is cleared and
refilled, or that grows and shrinks, leaked a range each time: the map,
the per-id arrays of the mutable inner indexes, and the deleted mask
grew without bound, and the u32 offsets could run out, failing writes.

Every point now keeps the list of its element offsets in array order,
and freed offsets are given out before new ones. The free list is
rebuilt at open. A reused offset is safe: update_point writes every
element of the point, and an unowned offset is never resolved.

Element map tests move to their own file, with a randomized test that
no offset is ever shared between points.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Nested payload index: keep build-time counts on reopen

* Nested payload index: use Logstore in serverless mode

* Nested payload index: encapsulate payload input paths and builders

* Nested payload index: clarify element offset annotations

* Nested payload index: compress immutable mappings with Zstd

* Blobstore: iterate values in ascending point order

Async read pipelines (io_uring) complete reads out of order, which broke
the ascending-order contract of PayloadStorageRead::iter that immutable
nested indexes rely on. Buffer each iteration batch and pass it to the
callback sorted by point offset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Nested payload index: size payload blocks in points

Inner blocks count elements, while the flattened index of a map or bool
field counts each value once per point. Estimate the block cardinality in
points and skip blocks that fall below the threshold, so HNSW payload-block
building matches the flattened index more closely.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Nested payload index: estimate point cardinality like flattened indexes

Linear scaling by all elements underestimated: elements without the field
diluted the ratio, and it assumed matches cluster into as few points as
possible. Use estimate_multi_value_selection_cardinality over elements with
values instead, bounding points by that count so the estimator's assertion
holds for immutable indexes after deletions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Nested payload index: count points with values of the indexed type

The immutable builder counted indexed points with a type-agnostic check,
so a string under an integer nested index counted in immutable segments
but not in mutable ones or the flattened index. Make add_point of field
index builders and ValueIndexer report whether the payload held a value of
the index's type, and count from that.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Andrey Vasnetsov <andrey@qdrant.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 69e76786d8)
2026-10-21 18:58:23 +02:00
Lenin Mookiah 4f88f5b7c3 docs: clarify zero HNSW m and payload links (#10744)
Co-authored-by: qdrant-cloud-bot <111755117+qdrant-cloud-bot@users.noreply.github.com>
(cherry picked from commit ea15064861)
2026-10-21 18:58:22 +02:00
Arnaud Gourlay 6f50938d94 Number TextQuery's scoring oneof from 2 (#11112)
#11012 reserved fields 2 and 3 of TextQuery and moved bm25 to 4, but
TextQuery is in no release, so there is no wire format to protect.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 1cb0f18000)
2026-10-21 18:57:47 +02:00
Andrey Vasnetsov b71c22a72a [BM25] Gather text statistics over the idf corpus (#11081)
* [BM25] Gather text statistics over the idf corpus

A text query with a corpus-scoped `idf` search param was refused: text
statistics always covered every point of the shard. The corpus now scopes
them, as it does for sparse vectors with the IDF modifier. Each segment
resolves the corpus filter once, and counts `N`, `df` and the token total
behind `avgdl` over the corpus points that hold a token. The corpus does not
filter the ranked points.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* [BM25] Leave the idf corpus out of the text rate cost

Sparse search does not price its corpus filter either; pricing it is a
separate change for both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* [BM25] Probe small corpora into long postings

Scoring prunes common terms once its top results fill, so walking their
whole posting lists for the corpus frequencies could cost more than the
query. A term whose posting list has more chunks than the corpus has
documents now has the documents probed into it instead: a probe
decompresses at most one chunk, a walk every chunk.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 4505b13c9d)
2026-10-21 18:57:46 +02:00
xzfc b6bc0c0767 common::{counter,uio_trace} => common::ambient (#11040)
* Normalize Cargo.toml

* 🤖 ambient: move uio_trace into common::ambient::trace

File split and path updates only; names unchanged.

* 🤖 ambient: move counter into common::ambient

File split and path updates only; names unchanged.

* 🤖 ambient: rename items after the move

HwHandoff → Handoff, HwScope → Scope, HwFuture[Ext] → AmbientFuture[Ext],
uio_trace::Request → IoRequest, hardware_usage_accumulator → handoff, etc.

* 🤖 ambient: rename local variables after the move

_hw → _scope, hw_acc → ctx or handoff, etc.

* 🤖 ambient: add hw scope annotations to tests, edge loading and prefetching

(cherry picked from commit fad34e8a0f)
2026-10-21 18:57:45 +02:00
Arnaud Gourlay b381478b81 Nest a text query's BM25 parameters per scorer (#11012)
* Nest a text query's BM25 parameters per scorer

A text query carried BM25's k and b as top-level fields, so a second
scoring type would have had to add its own parameters next to them, valid
for some fields and silently meaningless for others. The parameters now sit
in a oneof keyed by scorer: TextQuery { query, oneof params { Bm25Params
bm25 } }, and REST takes { "query": ..., "bm25": { "k", "b" } }. Without
parameters, the field's scorer runs with its defaults.

The collection checks the parameter variant against the field's scoring
type before any shard runs, with an exhaustive match on both enums, so a new
scoring type or parameter variant must say which pairs match. Shards still
receive resolved k1/b in TextScoringInternal, so the internal protocol is
unchanged. TextQuery is not released yet, so the shape changes in place;
fields 2 and 3 are reserved so the old k and b numbers are never reused
(a dev-build client still sending them gets the defaults).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Refuse unknown text query fields, match the no-parameters case

Address review: a text query with parameters outside `bm25` (the former
shape) or a misspelled one inside it was accepted and scored with the
defaults. TextQueryInput and Bm25Params now deny unknown fields, so such a
body is a format error (400).

The scoring-type check matched a query without parameters with a wildcard,
so a new scoring type would have taken BM25 defaults without a compile
error. It is now matched per scoring type like the other pairs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Accept unknown text query fields, like the rest of the Query API

Revert the deny_unknown_fields part of the previous commit: no Query API
input struct refuses unknown fields, and inside the untagged Query enum a
refusal surfaces as the generic "Expected some form of vector, id, or a type
of query" error. The former top-level k and b shape it guarded against was
never released.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Key a text query's scorer parameters under `scoring`

REST had `bm25` directly on the text query, so a second scorer would have
added a sibling field, and both could be set at once. The parameters now sit
under `scoring`, keyed by the scorer's name, matching the index config's
`scoring` and leaving the top level free for parameters shared by all
scorers. The gRPC oneof is renamed to `scoring` too; its field number is
unchanged.

`TextQueryScoring` is an untagged enum of named structs, like `Query`, so
the schema has no anonymous objects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: generall <andrey@vasnetsov.com>
(cherry picked from commit fd1b6da31e)
2026-10-21 18:57:45 +02:00
Andrey Vasnetsov 310d2d7d8f Add the Turbo16 vector datatype (#11024)
* Add the Turbo16 vector datatype

TurboQuant at 16 bits per element: the shared Hadamard rotation, then a
per-vector absmax scale onto the uniform grid [-32767, 32767], stored as
little-endian i16. Like Turbo8 there is no codebook: the code is the
value, and the per-vector scale cancels in the existing `l2 / ||c||`
renormalization. A vector takes `2 * dim + 4` bytes (`+ 8` for
Euclid/Manhattan) - the size of Float16 plus the extras - with ~7x lower
reconstruction error than Float16.

- quantization: `TQBits::Bits16` (Normal mode only, no TQ+). New
  `simd/query16bit` with one set of kernels for both scoring paths:
  `madd_epi16` on SSE4.1 / AVX2 / AVX-512BW, `vmull_s16` + `vpadalq_s32`
  on NEON. A pair of full-grid products fills an i32 lane, so the x86
  kernels convert each pair sum to f32 (exact products, f32
  accumulation); NEON widens into i64 and is exact. The asymmetric query
  is quantized onto the same grid with its own scale.
- API/segment: `Datatype::Turbo16` (REST "turbo16", gRPC `Turbo16 = 6`),
  served by the existing turbo storages. Rejected for sparse vectors.
- Tests: kernel parity/overflow, turbo storage tests over Bits16,
  model-test candidate, integration cases, test_turbo16_storage.py,
  a 16-bit cold-score criterion bench.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019inxbwm1wokue8vUje63p8

* NEON: four accumulator chains and 16/8-code steps in the 16-bit kernel

Measured on Neoverse N2 (criterion `query16bit_score_cold`): equal to the
two-chain loop with the vectors in L2, 1-12% faster from DRAM. The 16- and
8-code steps leave at most 7 codes to the scalar tail instead of 15.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019inxbwm1wokue8vUje63p8

---------

Co-authored-by: Andrey Vasnetsov <andrey@qdrant.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 24f67eb18e)
2026-10-21 18:57:34 +02:00
xzfc f543e2685c Ambient counters (#10967)
* common::reason

* Add ambient per-thread hw usage context

* Cleanup

(cherry picked from commit 7aa1641047)
2026-10-21 18:57:33 +02:00
Andrey Vasnetsov 3f793c5ca2 Record collection creation time (#10992)
* Record collection creation time

Stamp `created_at` on the create collection operation when it is submitted,
so every peer persists the same value in the collection config. Existing
values are kept, so collections re-created from a config (snapshot recovery,
single-to-cluster migration) retain their original creation time.

Return it in collection info over REST and gRPC when present; collections
created before this change have no value.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Truncate collection creation time to milliseconds

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 9d40cef8c4)
2026-10-21 18:57:32 +02:00
Andrey Vasnetsov a9ee134091 Add 8-bit TurboQuant quantization (#10939)
`TurboQuantBitSize::Bits8` makes the 8-bit TurboQuant encoding from the
Turbo8 datatype available as search-side quantization over regular
float storage.

Bits8 has no TQ+ mode (its per-vector grid scale replaces the
per-coordinate fit, and `encode` rejects TQ+), while turbo quantization
always encoded with TQ+. `QuantizedVectors::tq_mode` now picks the mode
from the bit width: Normal for Bits8, TQ+ for the others. It covers
dense and multivector creation, the vector-size calculation, and the
mutable storage that appendable segments get, so Bits8 quantizes
appendable segments like the other widths.

- API: REST "bits8", gRPC `Bits8 = 4`, edge FFI/Python, and
  `edge_tool --quantization turbo8`.
- Tests: appendable segment create/score/reload for Bits4 and Bits8,
  update-only overlay byte parity for both widths, HNSW quantized search
  at Bits8 for all four distances, a model-test candidate, and a bits8
  vector in test_turboquant.py.

Claude-Session: https://claude.ai/code/session_019inxbwm1wokue8vUje63p8

Co-authored-by: Andrey Vasnetsov <andrey@qdrant.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 24b7ca20a2)
2026-10-21 18:57:32 +02:00
Andrey Vasnetsov 6b60fc7245 Add the Turbo8 vector datatype (#10874)
TurboQuant at 8 bits per element: the shared Hadamard rotation, then a
per-vector absmax scale onto the uniform grid [-127, 127], stored as
s + 128. There is no codebook, so the SIMD kernels need no lookup: the
code is the value (`maddubs` / `VPDPBUSD` on x86, `SDOT` after one XOR on
aarch64). The per-vector scale is not stored; TurboQuant's existing
`l2 / ||c||` renormalization cancels it in scoring and dequantization.
A vector takes `dim + 4` bytes (`dim + 8` for Euclid/Manhattan).

- quantization: `TQBits::Bits8`; asymmetric scoring reuses `QuerySimd`
  as the `PLANES = 1` width; new `simd/query8bit` with exact symmetric
  kernels (SSE4.1, AVX2, AVX-512 VNNI, NEON, NEON+SDOT). TQ+ is not
  supported at 8 bits. On AVX2, batches of 8-bit vectors over 512 bytes
  go through the per-vector kernel (the batch loop was up to 2.9x slower
  on some Zen 3 hosts).
- API/segment: `Datatype::Turbo8` (REST "turbo8", gRPC `Turbo8 = 5`),
  served by the existing turbo storages with the bit width passed from
  the datatype. Rejected for sparse vectors.
- Tests: kernel parity/saturation, turbo storage tests over both widths,
  model-test candidate, integration cases, test_turbo8_storage.py.

Claude-Session: https://claude.ai/code/session_01FNETeXVTeP72vQpadiUK54

Co-authored-by: Andrey Vasnetsov <andrey@qdrant.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 20711cc490)
2026-10-21 18:57:31 +02:00
Arnaud Gourlay 8a5566c868 Query text indexes with a TextQuery through the Query API (#10786)
A text query ranks by BM25 over the text index of the payload field named
by `using`. The proto TextQuery message is canonical (query, k, b), and REST
takes either a string or the struct, normalized into one internal type, so
new parameters land as fields rather than a second spelling. `using`
resolves against payload fields only, and a qdrant/bm25 document keeps the
sparse route. The collection checks that the field has a scoring text index
and that k and b are in range before any shard runs.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 3833ed975f)
2026-10-21 18:57:31 +02:00
Arnaud Gourlay 8d788c9f38 [BM25] Add the scoring param to text indexes (#10770)
* Add the scoring param to text indexes

A text index with `scoring` set records document lengths and ranks by
BM25 with length normalization, replacing the const that kept scoring
off everywhere.

- TextIndexParams::scoring: Option<TextScoringParams { type: bm25 }>;
  REST also reads `true` and `false`, normalized as it is read so the
  stored schema holds one shape, and OpenAPI shows both forms; gRPC
  carries the message only (TextIndexParams.scoring = 12)
- scoring implies phrase_matching, for the positions term frequencies
  come from: normalized where index creation enters (before both the
  consensus op and the direct index op are built), in the collection's
  create path, and where segments build text indexes
- changing it rebuilds the index, as for any params difference
- edge does not expose it yet
- tests: serde forms, normalization, classify, the gRPC round trip, and
  a collection scoring with recorded lengths against BM25 by definition
- OpenAPI regenerated

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Test the text index scoring guards now that params carry scoring

With `scoring` on the params instead of a const, the guards that read it
can be driven directly:

- new_mmap and the immutable read-only open report an index without a
  length sidecar absent under scoring, and open it otherwise
- test_congruence asserts doc_len_batch and total_tokens after a reopen
  too, the reopen now asking for the lengths the builders record
- the positions-off, lengths-on tests are marked as TF-only tier guards,
  a shape params no longer produce since scoring implies phrase_matching
- stale comments about the scoring const updated

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Fit the text scoring param to dev's in-place enable_hnsw

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Name the scoring field in the text index param validators

The exhaustive destructurings added by #10862 did not list the new
scoring field, so the build broke once the stack sat on dev.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 4eb7bf1335)
2026-10-21 18:57:31 +02:00
Arnaud Gourlay e70ee32489 [BM25] Merge BM25 over a text index across shards (#10767)
* Merge BM25 over a text index across shards

ScoringQuery::Text carries a TextScoringQuery through the universal query
path: planned as its own leaf, run per local shard, sent to remote shards
as raw text, and merged across shards by the existing per-source k-merge.

- planner: PlannedQuery::texts and Source::TextsIdx, a bare leaf like a
  search; at the root or as a prefetch, so it fuses with dense prefetches
- MergePlan::validate refuses Text as a rescore at any level: scoring a
  set of prefetched points by BM25 has no path yet
- local shard: text leaves run concurrently with searches and scrolls,
  cut at the leaf's score threshold, priced like a scroll by the rate
  limiter
- internal gRPC: TextScoringInternal in the QueryShardPoints oneof,
  converted both ways
- edge refuses it for now, and its FFI and Python guards mark the
  variant as not exposed
- tests: planner leaves and refusals, the gRPC round trip, and through a
  collection: one shard against BM25 by definition, two shards against
  each shard merged, pagination, and RRF with a dense prefetch

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Refuse an idf corpus on a text query

The planner built text leaves without the request's params, so a
corpus-scoped `idf` on a text query was dropped without a word, the
silent ignore the vector path refuses. Text statistics cover the whole
collection, which is what the default `idf` scope means, so only a
corpus is refused, at the root and as a prefetch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Guarantee enough fusion candidates in the hybrid text test

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 3f2ecd7e94)
2026-10-21 18:57:31 +02:00
Tim Visée 016542aa5d Bump version to 1.19.2 (#10946)
* Bump version to 1.19.2

* Update missed cherry picks
2026-10-05 12:03:16 +02:00
281d146a1d Reject full-text index with min_token_len > max_token_len (#10862)
* Reject full-text index with min_token_len > max_token_len

A full-text payload index configured with min_token_len greater than max_token_len was accepted by REST, gRPC, and the edge FFI. The tokenizer drops every token outside the [min, max] window, so such an index builds successfully but stays permanently empty and all match text queries return nothing with no error.

Add validate_text_index_params next to validate_integer_index_params and enforce it on every write surface, mirroring the existing integer-index precedent:
- REST: PayloadSchemaParams::validate now validates the Text variant.
- gRPC: impl Validate for TextIndexParams, wired through IndexParams.
- Edge FFI: reject the inverted window during PayloadIndexParams conversion.

Equal bounds and an unset bound remain valid. Adds unit/conversion tests at each layer.

Fixes #10578

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop redundant text-index validation tests

Keep the gRPC CreateFieldIndexCollection check as the single high-level coverage.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: qdrant-cloud-bot <111755117+qdrant-cloud-bot@users.noreply.github.com>
2026-10-05 10:16:39 +02:00
Tim ViséeandClaude Fable 5.1 7477c7c369 Fix stale snapshot transfer breaking cluster (#10643)
* Check shard transfer requests against the registered transfer source

A sender that restarts replays committed but unapplied consensus entries
before it has joined consensus, so a `Start` for a transfer that has
since been aborted still spawns its driver. The receiver accepted such a
stale sender: `initiate_shard_transfer` only required *some* transfer
into the shard, and the pre-download clear in `recover_shard_snapshot`
only required the replica not to be a source of truth. A `Partial`
replica being populated by another transfer passed both, and got wiped.

Have the sender identify itself in the internal `initiate` and shard
snapshot `recover` requests, and have the receiver refuse both unless a
transfer from that peer into the shard is registered. The field is
optional, so requests from older peers keep the previous behavior.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Hold off a shard transfer driver until a consensus leader is established

On startup, committed but unapplied consensus entries are replayed
before the peer joins consensus. A `Start` or `Restart` replayed this
way spawns the transfer driver right away, on a view of consensus that
predates the restart: the transfer may already be aborted, and the peer
is about to abort every transfer it is part of anyway once a leader is
established (`cancel_related_transfers`). That driver still contacted
the receiver, which acted on the stale transfer.

Make the driver task wait until this peer knows a consensus leader
before it touches the remote, so the entries that come with the leader,
such as an abort of this transfer, are applied first. Stopping the task
cancels the wait like any other stage of the transfer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Test that only the registered source may clear a shard for snapshot recovery

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Test a restarted sender replaying an aborted transfer start on a real cluster

A staging-only delay in the sender's `Start` apply, before it registers
anything, lets a test kill the sender while the entry is committed but
not yet applied. That is the state the incident's sender crashed in: on
restart, `Consensus::new` replays the entry before the peer rejoins
consensus and spawns the driver of a transfer the cluster has aborted
and replaced meanwhile.

The test marks the sender `Dead` with an update while it is down, so
the transfer aborts and the receiver is recovered from the other
replica, then restarts the sender while the receiver sits in `Partial`.
Without the fixes on this branch the replayed driver clears the
receiver, its download is cut when the sender catches up, the dummy is
marked `Active` by the legitimate `Finish`, and the receiver's consensus
dies on the next transfer entry. With them the cluster converges.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Treat a dummy local shard as nothing to un-proxify

Transfer restarts and aborts un-proxify the sender's local shard when
applied. A dummy shard was reported as an unexpected type with a service
error, and consensus apply treats a service error as fatal: consensus
stops, and on the next start the replay of the same committed entry
fails again before the peer opens raft networking, so it never comes
back. That is how the receiver of a stale snapshot transfer ended up in
a crash loop: its shard had been cleared under a transfer, the dummy
was marked `Active` by the legitimate `Finish`, and the next transfer
entry naming it as the sender was fatal.

A dummy was never proxified, so there is nothing to revert. Return
without error for it, and leave every other case as it was. A peer
already stuck this way then starts, applies the abort of that transfer
or receives a consensus snapshot, and its `Dead` replica is recovered
through the normal path, which recreates the shard behind the dummy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Nuance in comment

* Require a registered transfer to clear a shard for snapshot recovery

The check only ran when the sender identified itself. A sender running an
older version doesn't, and neither does the REST endpoint, so both cleared
the shard unchecked.

Hold them to *some* transfer into this shard being registered, which is
what `initiate_shard_transfer` required of them before. The REST endpoint
cannot select the shard transfer priority that the clear hangs off, so
nothing user-facing changes.

* Test that clearing for snapshot recovery needs a registered transfer

* On snapshot recovery, source target shard ID from remote shard

* Reject immediately if another transfer is ongoing

* Add test

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 10:14:28 +02:00
bedab3c8e4 Add match: { substring } filter condition (#10711)
* Add `match: { substring }` filter condition

Unindexed `text` and `text_any` matching became token-aware in #10341 and
#10593. Users who relied on the old raw substring behaviour get it back as
an explicit condition: `match: { "substring": "..." }` selects points with a
string value containing the given string, byte-wise and case-sensitive,
consistent with exact keyword and prefix matching.

Execution: a keyword index (with or without the `prefix` option) serves the
condition by scanning its value dictionary and uniting the postings of the
matching keys; cardinality reuses the prefix estimator, generalised into
`keys_union_cardinality`. The per-point checker goes through the forward
index. Without a keyword index the condition falls back to reading the
payload. Text, bool, integer and uuid indexes decline it.

Strict mode: the condition requires the `KeywordMatch` capability, so with
`unindexed_filtering_retrieve: false` it is rejected on unindexed and on
text-indexed fields and allowed on any keyword index.

API: `MatchSubstring` in the REST `Match` union with regenerated OpenAPI,
gRPC `Match.substring = 12`, edge python `MatchSubstring`, edge ffi
`Match::Substring`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Test substring fallback on a text-indexed field, document estimator params

A text index cannot serve `substring`, so on a field that has only a text
index the condition runs through the payload fallback; only strict mode may
reject it. Pin that in the OpenAPI suite and reword the strict-mode unit
test comment, which read as if the text index itself blocked the query.

Also spell out what `keys` and `postings` mean in `keys_union_cardinality`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Serve `match: { substring }` from the keyword key dictionary

The condition used to enumerate keys through `MapIndexRead::for_each_value`,
which on the on-disk variant drags the whole `value_to_points` file through
`for_each_entry`, plus one random read per matching key for its postings
count. Query planning paid that scan in full, before deciding whether to use
the clause at all.

Route it through the `prefix_index.bin` key dictionary instead: front-coded
keys with their postings counts inline, no postings. Estimation now reads
keys only and never touches `value_to_points`; filtering takes the matched
key list and resolves postings in one batched read, as prefix matching
already does.

This makes the `prefix` option a requirement: a keyword index without it has
no key dictionary, so it declines the condition and falls back to the payload
scan, the same as a text index. Strict mode follows — substring now infers
`KeywordPrefix`, so `unindexed_filtering_retrieve: false` names
`keyword (with prefix: true)` as the index to create.

`PrefixIndex::for_each_key` reads blocks in ~1 MiB chunks rather than the
whole key section at once: a substring cannot be pruned by the block index,
so the one-shot read would grow with the dictionary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Sync MatchSubstring OpenAPI description with Rust docs

After routing substring matching through the prefix key dictionary,
the schema docstring required regenerating so OpenAPI stays consistent.

* Scan the map index keys for substring match without a dictionary

Without the keyword dictionary a substring condition was declined by the
field index and left to the per-point condition checker, which reads the
forward index for every candidate point. Enumerate the distinct keys of
`values_to_points` instead: the same one-pass-over-distinct-values shape as
the dictionary scan, only over a structure that interleaves keys with their
postings. Filtering and cardinality estimation are then always served, so
the condition can act as a primary clause on a plain keyword index.

Prefix matching keeps its per-point fallback: an ordered dictionary is what
makes a prefix a bounded range, and enumerating every key to answer one is
not a trade worth making implicitly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Reject substring matching in strict mode

A substring condition is answered by looking at every distinct value of the
field: no index gives it a bounded access path, so there is no index a user
could create to make it affordable. Reject it under strict mode instead,
wherever a filter reaches verification — read and write filters, nested
sub-filters, and prefetch filters.

Filter limits are now checked before the unindexed-field check, so the
rejection is not reported as "create an index for this key", advice that
would lead to the same rejection afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Update the strict mode substring test to the new rejection

The test asserted that substring filtering under strict mode asks for a
keyword index with the `prefix` option. It is now rejected whatever index
the field carries, so every case in the test gets the same answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Estimate a substring condition without scanning

Counting the keys a substring matches costs the same scan as answering the
condition, and `filter` then repeats it to collect those keys. Report the
uninformed estimate instead — the one an unindexed condition has always
reported — and keep the primary clause, so the scan happens once, in
`filter`, and only when the planner picks the condition to drive iteration.

With no counts to collect, `substring_scan` collapses into `substring_keys`:
the in-RAM variants no longer look up a posting count per matched key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Don't to parse everything as UTF-8

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: qdrant-cloud-bot <111755117+qdrant-cloud-bot@users.noreply.github.com>
Co-authored-by: timvisee <tim@visee.me>
2026-10-05 10:12:49 +02:00
ef0dc1ad53 Send the queue proxy batch as a pre-encoded gRPC body (#10617)
* Send the queue proxy batch as a pre-encoded gRPC body

The parent commits moved the WAL read, the operation clone and the request build
off the async runtime. Two passes over the batch were left on it.

Measured cost of each synchronous pass over a 26.2 MiB send batch (800 ops x 30
points x 256 dims), release build:

  pass 1  clone WAL operations      72.4 ms   moved by the parent commits
  pass 2  build gRPC request        22.2 ms   moved by the parent commits
  pass 3  clone request to send     65.7 ms   on the async runtime
  pass 4  protobuf encode (tonic)   41.4 ms   on the async runtime

Pass 3 is there because `with_points_client` takes `impl Fn` and the channel pool
calls that closure once per attempt, so each attempt needs its own owned message.
Pass 4 runs inside `poll_next`: for a unary call tonic encodes the whole message
in a single synchronous `encode_item`, so a worker is blocked for the full 41 ms,
once per attempt.

Encoding the batch up front removes both. The generated client cannot take a
pre-encoded body, `update_batch` is typed `impl IntoRequest<UpdateBatchInternal>`,
but all it does is pick a codec and a path and call `Grpc::unary`, and we already
build the client ourselves from a pooled channel. `update_batch_pre_encoded` does
the same three things with a codec that writes the encoded bytes through and
decodes the response with prost.

What stays on the runtime is the copy of the encoded body into tonic's send
buffer: 14.9 ms for 26.2 MiB under jemalloc, nearly all of it faulting in freshly
mapped pages rather than the copy itself (0.9 ms when the allocator hands back
warm pages). Retries share the same refcounted bytes instead of cloning and
re-encoding, so they drop with it.

  on the runtime before   107.2 ms per attempt
  on the runtime after     14.9 ms per attempt

Bypassing the generated client means the RPC path and message types no longer
follow the proto automatically, so a test checks them against the compiled
descriptor set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WZjWYpKeYdGpKiLeEZU9oc

* Hand the pre-encoded update batch a configured Grpc, take the service name from the generated code

`update_batch_pre_encoded` took a bare channel plus a `max_decoding_message_size`
argument, and its only caller passed `usize::MAX`. Every other internal client
applies that limit inside its `with_*_client` helper, so do the same: `with_grpc`
hands out the `tonic::client::Grpc` the generated clients wrap, already
configured, and the argument goes away.

The service half of the RPC identity now comes from the generated
`points_internal_server::SERVICE_NAME` instead of a second literal. Only the
method name and the path literal remain hand-written, still pinned to the
descriptor set by the test.

`PreEncodedMessage::encode` uses `encode_to_vec`: one pass instead of a separate
`encoded_len` call, and no `expect`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Adapt pre-encoding to the build/forward split from #10599

`forward_update_batch` takes `Arc<UpdateBatchInternal>` and encodes it
once on the blocking pool, so the channel pool's attempts share the
bytes. The queue proxy keeps the built request in that `Arc` across
`BATCH_RETRIES` and for the per-operation isolation path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: generall <andrey@qdrant.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-05 10:06:42 +02:00
Andrey VasnetsovandClaude Fable 5.1 333ca003a6 Support cached id tracker memory placement (#10598)
Thread a `Populate` through the disk-resident id tracker's open paths
(`DiskMappingReader`, `DiskIdTracker::open`, `ReadOnlyDiskIdTracker`,
`ReadOnlyIdTrackerEnum`) so a `cached` placement primes the page cache with the
mapping files on load instead of leaving them to page in on demand. The
populate is derived from the segment config's placement at load time, clamped
by low-memory mode, in both the writable segment open and the read-only one.
The update-only lookup path keeps its transfer-nothing policy, and the
build-time open stays cold: the built segment is reloaded anyway.

`cached` is no longer rejected by validation.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 10:06:40 +02:00
Andrey VasnetsovandClaude Fable 5.1 9614dc0b0f Expose id tracker memory placement in collection config (#10597)
Add `id_tracker: { memory: cold | pinned }` to CollectionParams,
CollectionParamsDiff and CreateCollection (REST + gRPC `IdTrackerParams`),
mirroring `payload: { memory }`. `cold` builds the disk-resident id tracker,
`pinned` the in-RAM immutable one. Unset keeps the current behavior: the
`serverless_compatible` feature flag decides.

The requested placement is persisted as an optional `id_tracker_memory` on
SegmentConfig (skipped when unset, so existing configs are unchanged); the
segment builder resolves it through `SegmentConfig::id_tracker_memory_placement`
instead of reading the feature flag directly.

The config mismatch optimizer rebuilds non-appendable segments whose effective
placement differs from the requested one. Appendable segments are skipped: they
always use the mutable tracker and get the current config when indexed.

`cached` is rejected by validation: the disk mapping reader has no
populate-on-open path.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 10:06:40 +02:00
陈志谦 bad18437a9 [AI] docs: fix typos and duplicated words across config help and rustdoc (#10484)
- 'mising' -> 'missing' in .github/review-rules.md (flagged by the
  repo's own codespell config)
- 'Do not create segments larger this size' -> 'larger than this size'
  in config.yaml, the optimizer builder/diff sources and the grpc
  proto + generated rust comment
- 'bigger then' -> 'bigger than' in the query scorer rustdoc
- three duplicated-word rustdoc fixes ('override in in', 'any of of',
  'and and')
2026-10-05 10:03:46 +02:00
Tim Visée 6ab21cac18 Bump version to 1.19.1 (#10463)
* Bump version to 1.19.1

* Update missed cherry picks
2026-09-03 14:36:18 +02:00
Kumar ShivenduandClaude Opus 5 f8acc7c666 feat: add a dedicated min operator to score formulas (#10296)
Follow-up to #10287, which added `max`. Expressing a minimum still
required spelling out `(a + b - |a - b|) / 2`, the sign flip of the max
identity — drop the `neg` and you silently get a maximum instead. It also
only works for two operands and mentions each one twice, so the scorer
walks every sub-tree twice per candidate point.

The pair is what makes clamping expressible:

    {"max": [0.0, {"min": [1.0, "$score"]}]}

`min` mirrors `max` throughout, and both guard helpers introduced in
#10287 already took an `operator: &str`, so they are reused unchanged: an
empty operand list is rejected at parse time rather than folding to
+infinity, and the Edge FFI rejects it at construction time. The result
needs no `is_finite` check, since `min` cannot produce a non-finite value
from finite inputs.

The unindexed-field walker shares one arm for `Max | Min` as the bodies
are identical, with a test pinning `min` separately so a later split
cannot silently drop it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:42:25 +02:00
Kumar ShivenduandClaude Opus 5 aba9516e53 feat: add a dedicated max operator to score formulas (#10287)
* feat: add a dedicated max operator to score formulas

Expressing a maximum in a score formula required spelling out the
arithmetic identity `(a + b + |a - b|) / 2`. That is easy to get wrong
(the `/ 2` is load-bearing), only works for two operands, and mentions
each operand twice, so the scorer evaluates every sub-tree twice per
candidate point.

`max` is variadic, mirroring `sum` and `mult`:

    {"max": ["$score", {"mult": [0.5, "popularity"]}]}

Unlike `sum` and `mult`, `max` has no identity element for the empty
case, so an empty operand list is rejected at parse time rather than
folding to -infinity and scoring every point with a non-finite value.
The check lives in `ExpressionInternal::parse_and_convert`, which every
entry point passes through, and the Edge FFI additionally rejects it at
construction time to match how that crate validates elsewhere.

The result needs no `is_finite` check: unlike `log10`, `exp`, `div`,
`sqrt` and `pow`, `max` cannot produce a non-finite value from finite
inputs, so it follows the existing `sum` convention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: cover max error propagation and datetime operands

An operand that fails must fail the whole expression rather than being
passed over in favour of a finite sibling. Covered with the failure both
before and after the finite operand: `mult` short-circuits on zero and
so can skip evaluating later operands, and this pins down that `max`
must not grow a similar shortcut that would swallow an error.

Also covers `max` over datetime operands, which reach the scorer through
a separate conversion to seconds, so that "score by whichever timestamp
is newer" is verified rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:42:25 +02:00
Tim ViséeandRoman Titov c12bea4786 Reject .. in collection names (#10242)
* Reject dot segments in collection names

Collection names are used as directory components on disk
(storage/collections/<name>, snapshots/<name>). Apply the same
plain-file-name predicate already used for snapshot names, so names
like ".." and "." no longer resolve outside the parent directory.
Applied to the legacy validator too: dot segments were never usable as
directory names, so no existing collection can be locked out by this.

* Fix collection name validation test on Windows, backslash disallowed

* Apply suggestions from code review

Co-authored-by: Roman Titov <ffuugoo@users.noreply.github.com>

---------

Co-authored-by: Roman Titov <ffuugoo@users.noreply.github.com>
2026-09-03 12:42:22 +02:00
Andrey VasnetsovandClaude Fable 5 fe27f6a0a9 Add acosh expression to formula query (#10231)
Unary inverse hyperbolic cosine, parallel to sqrt/ln/exp/log10, in REST,
gRPC, and edge (FFI + Python) interfaces. Inputs below 1 produce the same
NonFiniteNumber error as an invalid sqrt or ln.

Closes #10186

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 12:41:01 +02:00
Ivan PleshkovandClaude Opus 5 c6cf72a7c9 transfer: send raw payloads, behind feature flags (#10066)
A raw point can carry its payload as the byte blob it is stored as, mirroring
`PointStructRaw.raw_payload` on the internal gRPC API. The blob travels from the
sending node into the receiving node's WAL untouched, so the sender never parses
the payload it read and neither node builds a protobuf value tree for it.

It is parsed exactly once, where the operation is unpacked for apply
(`process_point_operation`), because that is the first place the parsed form is
actually needed: `set_full_payload` goes through the payload index, which cannot
be updated from bytes. The gRPC boundary therefore only checks the encoding tag
and rejects a point that sets both payload fields, the way the enclosing request
already rejects both `points` and `raw_points`.

Moving the parse onto the apply path makes its error classification load-bearing,
so a malformed blob is reported as `OperationError::MalformedPayloadBlob` — the
payload sibling of `MalformedVectorBlob`, mapped to `CollectionError::BadInput`
for the same reason: a bad blob that reached the WAL has to be skipped on replay
instead of crash-looping recovery.

Three consequences of the blob living that long are handled explicitly rather
than by convention:

- `decode_payload_raw` takes the blob only once it has parsed, so a failure
  leaves the point holding it instead of holding neither representation.
- `upsert_points_raw` and `sync_points_raw` refuse a point that still carries a
  blob. They read the parsed payload, so such a point would otherwise be stored
  with no payload at all, and a `debug_assert!` would not catch it in release.
- `is_equal_to` compares blob to stored blob as bytes. A differing encoding costs
  a redundant upsert on sync, never a skipped one.

The `raw_payload_transfer` bench measures the trade, per 100-point batch (one
transfer batch) at payloads of ~200 B / ~700 B / ~7 KB:

- Sender, storage bytes to wire: 16x / 37x / 113x faster. This is where the whole
  win is — no parse of the blob that was read, no value tree built.
- WAL encode: 5x / 11x / 25x faster, writing a byte string instead of a map.
- Receiver, wire to applicable point: 1.09x / 1.10x / 1.06x. Near neutral, as it
  swaps walking a prost value tree for a JSON parse.
- Wire bytes: ~6% smaller. WAL bytes: 10-32% *larger*, because the blob is JSON
  while a parsed payload is written as a compact CBOR map.

The WAL growth is accepted rather than fixed: decoding earlier to win those bytes
back costs a second full deserialization, and would leave the receiving side with
a `payload_raw` that is never populated. Making the blob itself compact belongs in
the payload storage encoding (`RawPayloadEncoding` is the extension point for it),
not here.

Two flags, both off by default and both sender-only (nodes accept raw points and
raw payloads regardless), read where the transfer batch is prepared:

- `transfer_raw_points` transfers every collection as raw points, not only those
  whose vector storage would drift in a decode-encode round-trip.
- `transfer_raw_payloads` ships the blob a raw read hands out; without it the
  prepared batch decodes it back into the parsed payload, and the wire message is
  exactly what it is today.

Neither is enabled by `all`: a node only accepts them once it runs a version that
understands them, so they can only be switched on a release later. Nothing
enforces that yet — the transfer has no peer-version gate.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:39:03 +02:00
GuTS805 679f64946c fix(api): reject empty dense vectors in gRPC Vector::Dense validation (#10143)
Sparse and MultiDense variants already reject empty vectors via
validate_sparse_vector_impl / validate_multi_vector_by_length, but
Dense always returned Ok(()). Reuse the existing
validate_non_empty_dense helper from the REST validation module for
consistency.
2026-09-03 12:39:01 +02:00
78ad7bc4b0 [Raw payloads]: read payload as stored bytes in retrieve_raw (#10040)
* segment: read payload as stored in retrieve_raw

`retrieve_raw` already hands back vectors as stored; let the caller ask for the
payload the same way, so a reader that only relocates a point parses nothing.
`RawPayloadFormat` states what the caller wants — no payload, parsed, or as
stored — and replaces the `WithPayload` argument, which could express a key
selection that a raw read cannot serve anyway.

[`MaybeRawPayload`] states what came back, which can differ from the request in
one direction only: a payload storage that keeps payloads parsed cannot answer
`Raw` with a blob, and now says so instead of encoding a payload for a reader
that would parse it straight back.

The raw path reaches the blobstore through `read_payloads_maybe_raw`, mirroring
`read_payloads` down the payload storage and payload index traits, so it keeps
the batched read.

Every caller asks for `Parsed`, so this changes no behaviour: the copy-on-write
move and the sync comparison need the parsed payload anyway, and the shard
transfer switches over with the feature flag that ships the blob to another
node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* segment: always hand out the stored payload blob from retrieve_raw

Review follow-up: instead of telling `retrieve_raw` in which form to
return the payload, it always returns it as stored and a caller that
needs the parsed form decodes it itself.

- Drop `RawPayloadFormat` and the payload parameter it replaced: no
  production caller ever asked for anything but the whole payload, and
  a selector cannot be applied to an opaque blob anyway.
- Drop `MaybeRawPayload` / `MaybeRawPayloadRef`: only
  `InMemoryPayloadStorage` could produce the parsed variant, and no
  segment can be built with that storage (`PayloadStorageType` is
  `Mmap` or `InRamMmap`, both blobstore-backed). `SegmentRecordRaw`
  carries a plain `Option<RawPayload>`.
- `PayloadStorageRead::read_payloads_maybe_raw` becomes
  `read_payloads_raw` and hands out `Option<&[u8]>`. The in-memory
  storage keeps payloads parsed, so it encodes on read, producing the
  bytes an on-disk storage would have written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* api: decode a received raw payload with the shared decoder

`decode_payload` at the gRPC boundary matched on the encoding and parsed
the blob itself, duplicating `RawPayload::decode`. Add the inbound
conversion from the wire type and let the one decoder do the reading, so
another encoding has a single place to be taught.

The conversion also rejects an encoding number no variant maps to, which
prost would otherwise hand out as the default encoding — a blob from a
node that writes payloads some other way must not be read as JSON.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* simplification

---------

Co-authored-by: Ivan Pleshkov <ivan.pleshkov@qdrant.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:36:31 +02:00
278140c482 Add /profiler/consensus_lag to measure apply lag between peers (#10090)
* Add /profiler/consensus_lag to measure apply lag between peers

Raft commit index advances on a peer whose apply loop is stalled, so the
existing signals - `raft_info.commit` and the `all_nodes_have_same_commit`
test helper - report a stuck peer as healthy. Nothing exposes how long a
peer has been behind at *applying* entries, which is what shard transfer's
`await_consensus_sync` barrier actually waits on.

Each peer now keeps a ring of the last 32 entries it applied, stamped with
its own wall clock and the time that entry took to apply. The ring is in
memory on ConsensusManager, not in Persistent, so the on-disk format is
untouched.

`/profiler/consensus_lag` collects those rings from every peer over a new
internal RPC and lines them up on the entry indices they share. Each entry
is measured from whichever peer applied it first, so a lag is never
negative; the peer that is first can differ per entry, so the baseline is
per entry rather than a single chosen peer. Entries only one peer still
remembers are excluded, otherwise a peer would be measured against itself.

A peer stalled part-way through an entry keeps healthy lag statistics -
everything it did apply, it applied on time - so the report carries
`behind_entries` and `newest_applied_age_ms` alongside, which is what
actually exposes the stall.

Peers that fail or time out are listed rather than failing the request: a
partial answer is more useful than none when the point is to find a peer
that stopped answering.

The endpoint follows `/profiler/slow_requests`: manage access, and outside
OpenAPI, so no endpoint-count or ACTION_ACCESS guard applies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Move applied-entry log into its own module

Keeps the new code out of files that are already large. The ring, its entry
type and the snapshot served over RPC move to
`content_manager/consensus/applied_log.rs`, alongside the other consensus
internals; `ConsensusManager` is left with a field, an accessor and the one
`record` call in the apply loop.

The grpc encoding moves next to the decoding it mirrors, in
`common/consensus_lag.rs`, leaving the internal service handler three lines
instead of thirty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Test that a consensus stall is still in the report after the peer catches up

* Take each peer's applied index from consensus state, not its ring

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: tellet-q <elena.dubrovina@qdrant.com>
2026-09-03 12:36:31 +02:00
Andrey VasnetsovandClaude Opus 5 74f3e85b94 Bump version to 1.19.0 (#10084)
* Bump version to 1.19.0
* Update missed cherry picks
* Add OpenAPI spec for v1.19.x

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 13:48:45 +02:00
Andrey VasnetsovandClaude Opus 5 c34acc1a36 Global quota API (#10035)
* feat: global quota API

Memory and disk are node-wide resources, so configuring their thresholds
per collection through strict mode makes little sense. Move them behind a
single cluster-wide `QuotaManager`.

The quota config is seeded from `storage.quotas` in the settings (and so
from env vars), overridden by `quota.json` in the storage directory, and
updated cluster-wide through a new `SetQuotaConfig` consensus operation
which rewrites that file on every peer. Raft snapshots carry it too, so a
peer that joins by snapshot picks it up.

Quotas are enforced wherever the strict mode memory and disk checks used
to run, but no longer gated behind `strict_mode.enabled`: a value set in
an enabled strict mode config still wins per resource, the quota is the
default. Rejections name both the condition that tripped and the config
that governs it.

`GET /quotas` reports the config plus current utilization to global read
users; `PUT /quotas` replaces it for global manage users.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: cover the quota endpoints in the API consistency checks

`test_all_rest_endpoints_are_covered` and the OpenAPI endpoint count both
break on any new REST endpoint. Add `GET`/`PUT /quotas` to `ACTION_ACCESS`
with their JWT access tests, and bump the expected API count. The quota
endpoints stay out of `REST_ENDPOINT_WHITELIST`: that list is for
data-plane endpoints reported per-endpoint in metrics.

Also add a Raft snapshot CBOR compatibility test — snapshots are exchanged
between peers of different versions during a rolling upgrade, so
`quota_config` must be absent-tolerant in both directions.

Review feedback: persist through `SaveOnDisk`, which already implements the
write-before-swap protocol this was doing by hand; validate the config at
both persistence boundaries, since a hand-edited quota file or a config
arriving through consensus does not pass the REST handler's validation, and
a `0%` limit would reject every update forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: assert seeding a quota from invalid settings persists nothing

Follow-up to review feedback claiming `SaveOnDisk::load_or_init` writes the
init value before it is validated. It does not — only `SaveOnDisk::new`
persists — but the property matters: were seeding to persist first, invalid
settings would leave a `quota.json` that fails validation on every
subsequent start, and the node could only be recovered by deleting it by
hand. Pin it down with a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: make QuotaManager the single reader of memory and disk

The quota checks measured memory and disk themselves, while the optimizer
and the WAL disk watcher each called `fs4::available_space` behind their
own ad-hoc caches. Fold all of it into QuotaManager: it owns the readings,
the freshness policy, and the limits they are compared against.

Moves the module to `lib/shard`, since the optimizer sits below `storage`
and has to reach it; `storage::quota` re-exports it, so consensus, the
`/quotas` API and StorageConfig are unchanged. The manager is installed as
a process singleton by TableOfContent, ahead of loading any collection.

- Callers hand in QuotaLimits overrides instead of a StrictModeConfig, and
  an override can now only tighten. A collection-level admin could raise
  `max_disk_usage_percent` past a cluster-wide limit that needed global
  manage rights to set; ties resolve to the quota so the rejection names
  the knob that actually has to change.
- Measurements are cached for 5s, but a reading at or above its limit is
  never reused: a rejected client retries, and freeing the resource has to
  take effect on the next request rather than a TTL later.
- `fits_on_disk` sizes an optimization against physical free space only,
  never the configured limits. Optimizations are what free a full disk, so
  the quota must not be what stops one.
- `percent_of` widens to u128 instead of saturating the multiply, which
  under-reported utilization (failing open) above ~184 PB.
- StorageConfig::quotas is optional; absent means no quota is enforced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: don't recover dead replicas onto a node at a resource limit

Recovering a dead replica pulls a whole copy of its shard onto this node.
If it is already at its memory or disk quota that transfer cannot finish,
and starting it only pushes the node further past the limit. Skip it and
reconsider on a later sync, once the resource frees up.

Adds QuotaManager::check_capacity for work that lands bytes here without
being an update. Unlike fits_on_disk the configured limits do apply:
taking on a replica is not what frees a full node, so there is no deadlock
to avoid by letting it through.

The check is hoisted out of the per-shard loop because a node over its
limit re-measures on every call, so checking per dead shard would cost a
statvfs each. It is free when no quota is configured.

Also trims the comments across the quota module, which had grown well past
what the code needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: drop trivial and duplicated quota tests

Six tests removed, ~140 lines, with no loss of coverage:

- a_rejection_names_the_knob_that_has_to_change asserted that a format!
  contains its own literals; the message is covered end-to-end by the
  override test and by test_global_quota.py.
- a_node_over_its_quota_has_no_capacity_to_take_on_a_replica was 30 lines
  for check_capacity, a one-line delegation to check_update the test above
  it already calls.
- a_rejecting_measurement_is_never_served_from_the_cache duplicated the
  meter test, which proves the same rule with an injected reader instead
  of inferring it from the real filesystem.
- free_space_is_reported_without_enforcing_anything covered a one-line
  accessor, and its point is what the fits_on_disk test is for.
- The two resolve tests and the three meter tests each collapse into one.

DiskFit::Unknown keeps its coverage as two lines inside the fits_on_disk
test rather than its own fixture. The snapshot compat pair becomes one
test: the second only asserted cluster_metadata.is_empty(), which says
nothing about quotas — the real check was the deserialize, now an expect
that states it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: re-measure free space as the disk fills, and drop a Windows-only assert

Two CI failures, both from this branch.

e2e test_low_disk: the DiskUsageWatcher I replaced escalated to checking
on every call once free space fell below 512 MB. Folding it into the quota
manager lost that — available_bytes passed no limit, so a reading was
reused for the full 5s however little space was left. On a disk filling as
fast as that test fills it, 5s blind is enough to actually run out and the
WAL write dies instead of returning "No space left on device".

available_bytes now takes a watch_below level and never reuses a reading
under it, which is what the old ladder was expressing. The watcher passes
max(min_free, 512 MB), so the escalation point is back; above it the 5s
cache still costs fewer syscalls than the old 128-call ladder. fits_on_disk
gets the same rule by passing required_bytes, so a merge that does not fit
re-checks rather than sitting on a stale sample.

Windows: fits_on_disk on a missing path was asserted to be Unknown, but
GetDiskFreeSpaceEx resolves up to the containing drive and succeeds — as
common::disk_usage's own test documents. Dropped; the branch is a two-line
else and is not portably reachable.

Also renames an_optimization_is_sized_against_the_disk_not_the_quota, which
needed explaining to be understood.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: report the quota config in telemetry

Reads it from the quota manager rather than the settings, so it is the
config the node is actually enforcing: a peer that missed a consensus
update reports what it is applying, not what the cluster agreed on.

Gated on global access, the same access `GET /quotas` requires, and left
out of `PeerTelemetry` — a quota is per-node state, so each peer reports
its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: regenerate OpenAPI, and cover the quota in the telemetry key sets

Two CI failures from the previous commit.

Referencing QuotaConfig from TelemetryData moves its definition earlier
in `components/schemas`, because TelemetryData is generated ahead of
QuotaStatus. Regenerated rather than hand-patched, so the schema is a
pure move.

test_telemetry_detail asserts the exact set of top-level telemetry keys.
The quota is reported at every level, including 0 — it is three scalars,
it is the default the endpoint serves, and it is what explains an update
being rejected — so both key sets gain it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: remove `max_disk_usage_percent` from strict mode

Disk is a node-wide resource, so a per-collection percentage of it never
meant anything a caller could act on: the limit describes how full the
*node* is, and which collection the write happens to target has nothing
to do with it. The global quota is where it belongs.

It shipped in 1.18.2 without documentation, so this drops it outright
rather than deprecating. Removal is soft in every direction: StrictModeConfig
has no `deny_unknown_fields`, so a client still sending it gets it ignored
rather than a 400, and the same struct deserializes the persisted collection
config, so collections created on 1.18.2+ keep loading. Proto field 22 is
reserved so the number is never reused.

The e2e test becomes a quota test — the fixture and the timing are the
interesting parts and they carry over unchanged; only how the threshold is
configured differs.

`max_resident_memory_percent` was documented and stays for now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: enforce the strict mode memory limit outside the quota

`max_resident_memory_percent` was folded into the quota as an override,
which meant the quota check had to know about strict mode, and retiring
the setting would mean unpicking `EffectiveLimit` and `LimitSource` from
the resolution logic.

It is now a check of its own in `verification/mod.rs`, next to the strict
mode checks it belongs with, borrowing only the measurement from the quota
manager — which stays the node's single reader of process memory, so both
checks still share one reading. Deleting the setting later is deleting one
function and its one caller.

`QuotaManager::check_update` takes no arguments and consults the quota
alone. A collection can still only tighten the limit for itself, because
its own check runs in addition rather than in place of the quota's, and
each rejection now names the config that has to change without having to
carry a `LimitSource` to say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: enforce the quota on the update path, not in strict mode

The quota check sat inside `check_strict_mode_toc_batch` only because that
was the one place holding the collection's strict mode config. It doesn't
need one any more, and the placement had a real cost: coverage depended on
each handler remembering to ask for a strict mode check, and four of the
internal update RPCs do — `sync_internal`, which moves the most bytes onto
a node, does not.

It now runs in `Collection::update_from_client` and `update_from_peer`,
which every update passes through. `update_from_client` checks ahead of the
shard split, so an operation is accepted or refused whole rather than
landing on some shards and being refused by others.

Classification moves with it, from ~10 `consumes_memory` impls on request
DTOs to one exhaustive `CollectionUpdateOperations::consumes_quota`. The
internal enum has variants — raw upserts, conditional upserts, the syncs —
that have no client-facing request type, so per-DTO impls structurally
could not classify them.

Shard-transfer syncs stay excluded, as they are today: a transfer is sized
up once before it starts, and refusing its batches partway abandons work
that is nearly done only for it to restart from the beginning.

Index and named-vector creation reach shards through consensus, past this
check — a peer must not refuse what the cluster agreed to — so they keep
their pre-consensus check, now against the quota manager directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: deprecate `max_resident_memory_percent` in strict mode

Same reason the disk threshold went: memory is node-wide, so a
per-collection percentage of it caps how full the *node* is, which has
nothing to do with which collection is being written to. The node-wide
quota caps it once for everything.

Unlike the disk threshold this one shipped documented, in 1.18.0, so it
keeps working — as a limit a collection can tighten for itself, never lift
— and gets the usual markers: `#[deprecated]` on both Rust structs,
`[deprecated = true]` on proto field 21, and `deprecated: true` in the
OpenAPI schema, which schemars derives from the attribute.

The note names 1.21 as the removal. Recording a version matters here: the
audit in docs/plans/overdue-deprecations.md found that this repo has never
written a removal deadline down, and members of the 1.15.0 deprecation
batch are still in tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: reconcile the quota readers with #9891

#9891 landed effective (cgroup) figures in telemetry while this branch was
making QuotaManager the single reader of memory and disk. Two collisions,
neither of which git sees.

`segment::utils::mem::total_memory_bytes` is now a shared accessor with a
5s TTL, so a cgroup resize is picked up. The quota module had its own
`OnceLock` copy that froze the value at startup — exactly what #9891 set
out to fix — so it delegates to the shared one instead.

Telemetry's new `disk_size` called `common::disk_usage::disk_usage`
directly. That reader lost its TTL cache on this branch when the caching
moved into the quota manager's meter, so it would have taken an uncached
`statvfs` on every telemetry request, and it put a second disk reader back
in the tree. It goes through `QuotaManager::disk_capacity_bytes` now,
sharing the reading the quota check already takes. Verified it still
reports the storage filesystem, matching `df`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: split the quota manager by what each half does

`manager.rs` had grown to 450 lines holding three separate jobs: owning
the config and its file, taking the readings, and comparing one against
the other.

- `manager/store.rs` — the `Store` enum, `QUOTA_CONFIG_FILE`, and config
  validation, which is now the store's own business rather than something
  every caller has to remember to do first.
- `manager/measure.rs` — every reading, and `DiskFit`. The "nothing else
  calls `statvfs` or reads process RSS" claim is now checkable by looking
  at one file.
- `manager/enforce.rs` — `check_update` / `check_capacity` and the
  threshold comparison.
- `manager/mod.rs` — the struct, its construction, and the config
  accessors: what a reader needs to see first.

Tests move with their subject. No behaviour change: `set_config` used to
validate before delegating to the store, and now the store validates on
write, which is the same order of operations from the outside.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: count copy-on-write deletes toward the quota

Dropping a vector or a payload key does not free anything on its own:
copy-on-write rewrites the point to produce the version without that
field, so storage grows first and is only reclaimed once the optimizer
gets to it. Gating those as if they were reclaiming space let a full node
keep taking writes that make it fuller.

Deleting whole points stays exempt. That is the one operation that has to
work on a node at its limit, or there is no way back under it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: say that the quota's reported usage is per node

`GET /quotas` returns one cluster-wide config and one set of utilization
figures, which reads as though both describe the cluster. They do not:
memory and disk are node-local, so `usage` is whatever the peer that
served the request is seeing, and a peer under its limit says nothing
about the others.

Also corrects `resident_memory_percent`, which claimed to be a share of
total system memory. It is a share of the memory available to the
process, which under a cgroup is the limit rather than the host's RAM.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: treat a node over its quota as a failed replica, not a bad request

A quota rejection described the request as invalid (400) and was classified
non-transient, which is how the replica set recognises errors that every
replica would produce alike. A quota is the opposite: the input is fine and
the answer depends on which machine you ask. On the default `wait=false`
path that combination silently dropped the write — `update.rs` only
deactivates transient failures when nothing completed — leaving the replica
Active and permanently missing data its co-replicas had.

It is now `InsufficientStorage`, transient, HTTP 507 / gRPC
`ResourceExhausted`. So a node that is out of room is handled like one that
is offline:

- last active replica, or every replica over quota: nothing could take the
  write, and the client is told the cluster is out of room.
- more than one replica: the full node is deactivated through the same path
  a dead peer takes, and the update stands if enough replicas accepted it.
  `check_capacity` already keeps recovery off that node until it has room.

The check also moves off `update_from_client`, which applied the
coordinator's own limit to the whole operation even when it held no replica
of the shards being written. Each replica set now gates its own local write
and records the refusal as a failure of this peer, so a node only ever
answers for itself.

`ResourceExhausted` is shared with rate limiting, and the reverse conversion
mapped it straight to `RateLimitExceeded` — a forwarded rejection came back
as 429. Statuses now carry a marker so the two stay distinguishable across
the wire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: report quota pressure per node, and across the cluster

A quota is node-local, so finding out which node has hit one meant asking
each of them in turn — and nothing at all showed up in monitoring.

`/metrics` gains a `quota_exceeded` gauge for the local node. It is emitted
only while the quota is enabled: with it off the value would be a constant
0 that says nothing about the node, and an alert built on it would go quiet
rather than fire if someone disabled the quota. Telemetry's `quota` field
carries the same verdict alongside the config, since that is where the
metric is derived from.

`GET /quotas` now answers for the whole cluster. A new `GetQuotaUsage` RPC
on the internal `QdrantInternal` service returns what one peer is using,
and the handler fans it out to every known peer in parallel. Peers that do
not answer are left out rather than failing the request — the nodes that
are out of room are exactly the ones most likely to time out, and a partial
answer still names them. Outside distributed mode the field is absent
rather than a map of one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: report the quota metric per resource

`quota_exceeded` was one flag for the whole node, which does not say what
to go and fix — disk is freed by deleting or optimizing, memory by
unloading. It now carries a `resource` label:

    quota_exceeded{resource="memory"} 0
    quota_exceeded{resource="disk"} 1

A resource with no limit gets no series at all, for the same reason the
metric is absent while the quota is disabled: a series that can never reach
1 reads as healthy and would quietly carry an alert that cannot fire.

`QuotaManager::exceeded` returns the per-resource verdict, with `None` for
a resource this node does not cap. Telemetry reports the same breakdown,
since the metric is derived from it. The peer usage RPC keeps a single
flag — it sits next to both percentages, so it only has to answer "is this
peer refusing writes".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: drop a no-op error conversion the linter caught

`check_global_access` already returns a `StorageError`, so mapping it
through `StorageError::from` converted the type to itself and tripped
`clippy::useless_conversion`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: hold a tripped quota until usage clears a release margin

A resource resting on its limit crosses it in both directions on the noise
between two readings, and each crossing is expensive: the node refuses a
write, its replica is deactivated, usage dips, recovery starts sending a
whole shard copy back, and the arriving data pushes it over again. The loop
sustains itself, and every lap costs a shard transfer.

A limit now trips at its configured value but only clears once usage has
fallen 5 percentage points below it, so the crossing has to be real. The
margin is floored at 1%, since a limit smaller than the margin would
otherwise be impossible to fall back under and would strand the node.

The verdict is carried on the manager rather than recomputed, which makes
it the thing reporting shows: expect `exceeded` to be set while the
utilization next to it is already back under the limit. Rejections say so
too, rather than claiming a limit that is no longer exceeded:

    Disk usage is at 87% of total capacity. It reached the configured limit
    of 90% and has to fall below 85% before this node takes writes again.

Changing the config clears the verdicts. New limits are a deliberate act,
and should not be held back by the margin of a limit that no longer exists.

Both resources are now evaluated on every check instead of stopping at the
first failure, so a verdict is never left behind reporting a reading that
has since been superseded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: make the quota release margin configurable

5 points is a guess about how noisy a deployment's usage is, which is not
something one number can be right about: a node whose disk moves in
gigabyte steps needs a wider margin than one that creeps, and an operator
who wants the old flip-on-every-reading behaviour should be able to ask
for it.

`release_margin_percent` joins the rest of the quota config, so it seeds
from `QDRANT__STORAGE__QUOTAS__RELEASE_MARGIN_PERCENT`, replicates through
consensus, and changes with `PUT /quotas`. Defaults to 5 and is filled in
when a request omits it, so it always answers with the margin actually in
force rather than leaving the caller to assume one. `0` releases as soon as
usage is back under the limit.

`QuotaConfig` grows a hand-written `Default` for it, since deriving one
would have quietly defaulted the margin to 0 and disabled the hysteresis
for anyone constructing a config in code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: leave the release margin unset by default, and hold verdicts in atomics

`release_margin_percent` is `null` unless someone sets it, rather than
materialising 5 into every config. A quota written today then does not pin a
number a later release may want to revise, and `{"enabled": false}` still
round-trips as itself. `QuotaConfig::limits` resolves it, next to `enabled`,
so enforcement never sees the unset case.

The verdicts move from a `Mutex<QuotaExceeded>` to one `AtomicBool` per
resource. They are judged independently and nothing reads them as a pair, so
the lock only added contention to the path every update takes; a verdict
that races a concurrent check is re-decided by the next one from a fresh
reading.

That also drops the tri-state. Only "was this over its limit" has to survive
between checks — whether a resource is enforced at all follows from the
config and the reading, so it is derived when reporting rather than stored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: drop a comment arguing with a design that was never here

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:18:45 +02:00
Jojii 3c979b88f7 Payload bytes gRPC (#9949)
* Prepare GRPC for raw payload bytes

* Make RawPayload a separate protobuf message
2026-08-04 11:18:44 +02:00
73e6c16f9c Remove deprecated search endpoints from OpenAPI, deprecate them in gRPC (#9982)
* Remove deprecated search/recommend/discover endpoints from OpenAPI

Remove deprecated REST API endpoint definitions from the OpenAPI
generator. These endpoints were deprecated in v1.13.3 (`f4ced2567`,
#5907, 2025-01-30) in favor of the universal `/points/query` endpoint:

- POST /points/search
- POST /points/search/batch
- POST /points/search/groups
- POST /points/recommend
- POST /points/recommend/batch
- POST /points/recommend/groups
- POST /points/discover
- POST /points/discover/batch

Also removes the corresponding request types from the schema generator
and updates the expected API count in the consistency check.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Migrate OpenAPI integration tests to /points/query

The deprecated /points/search, /points/recommend and /points/discover
endpoints (along with their /batch and /groups variants) were removed
from the OpenAPI spec, which caused validation failures in the Python
integration test harness.

This commit migrates the affected tests to the universal /points/query
endpoint:

- Delete tests dedicated to the deprecated endpoints:
  test_recommend.py, test_discover.py, test_multicollection_reco.py,
  test_recommendation_multivector.py
- Refactor remaining tests to call /points/query (and /query/batch,
  /query/groups), translating request bodies (vector -> query / using,
  positive/negative -> query.recommend, target/context -> query.discover)
  and unwrapping the new result.points response shape.
- Drop equivalence assertions against the now-removed legacy endpoints.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Relax non-empty assertions in migrated recommend/discover tests

The previous migration added `len(...) > 0` assertions to tests that
previously only checked equivalence between the deprecated and new
API. These assertions are too strict because the parametrized
`query_filter` cases legitimately produce empty result sets.

Drop the `> 0` assertion and rely on `request_with_validation` to
verify the response is well-formed and HTTP OK.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Migrate remaining OpenAPI tests off deprecated search endpoints

Tests added to dev after the original migration was written still call
/points/search and /points/recommend/groups through
`request_with_validation`, which resolves the endpoint against the
OpenAPI spec and therefore breaks once the endpoint is not in the spec:

- test_turbo4_storage.py, test_sparse_idf_corpus.py, test_validation.py:
  translate /points/search to /points/query (vector{name,vector} ->
  query + using, result -> result.points).
- test_group.py: drop the /points/recommend/groups half of the
  lookup_from validation test in favour of the query equivalent.

test_sparse_idf_corpus.py's test_query_api_supports_idf_corpus goes
away: with the helper on /points/query every test in the file now
exercises what it asserted.

Also record why test_recommend_group cannot assert on its groups: it
uses every point in the collection as a recommend example, so all of
them are excluded and the result is legitimately empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Regenerate openapi.json without the deprecated search endpoints

Drops the 8 deprecated paths and the request schemas that only they
referenced: Search/Recommend/Discover request (+Batch, +Groups) types
and their exclusive dependencies (NamedVector, NamedSparseVector,
NamedVectorStruct, UsingVector, RecommendExample, ContextExamplePair).

Regenerated output is a strict subset of the previous spec, and every
remaining $ref still resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Deprecate the search/recommend/discover RPCs in gRPC

The REST counterparts have carried `deprecated: true` since v1.13.3 and
are now gone from the OpenAPI spec, while the gRPC RPCs never got any
deprecation annotation at all. Mark all 8 with `option deprecated = true`
so generated clients warn, and point each doc comment at its `Query`
replacement.

tonic puts `#[deprecated]` on the generated client methods only; the
server trait gets the doc comment alone, so our own `impl` is unaffected.
The RPCs keep serving traffic — this is annotation only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Restore the deleted recommend/discover suites on /points/query

The earlier migration deleted these four files outright, but the
query-side tests it left behind are all shallow smoke tests
(`len(result) > 0`, `"points" in result[0]`). The deleted ones carried
invariants with no query-API equivalent anywhere, so deleting them was a
real loss of coverage rather than de-duplication:

- test_recommend.py: default strategy equals average_vector; batch
  results identical to sequential singles across six request shapes;
  best_score with only negatives yields all-negative scores; best_score
  with a single positive orders identically to a nearest query; raw
  vectors as examples equal ids as examples.
- test_discover.py: context-only scores are all <= 0; target-only orders
  identically to a nearest query but scores differently; with a fixed
  context the integer part of the score is stable while the decimal part
  moves, and vice versa with a fixed target; batch equals singles;
  lookup_from by id equals by vector.
- test_multicollection_reco.py: cross-collection lookup_from, plus
  wrong-vector-size, unknown-collection and unknown-vector rejections.
- test_recommendation_multivector.py: the same recommend invariants over
  a max_sim multivector collection, which the query suite never covered.

Only test_recommend_missing_lookup_from_collection_with_raw_vector is
dropped as genuinely redundant — test_query.py's
test_query_missing_lookup_from_collection covers query, query/batch and
prefetch.

Two request-shape differences the translation had to absorb:

- Giving no examples at all is 422 (a RecommendInput validation rule),
  where the legacy API reported 400 from the query itself. A malformed
  example, such as an empty vector, is still 400.
- DiscoverInput requires the `context` key and accepts only an explicit
  null to mean "no context", so target-only discover must spell it out.
  The legacy API let it be omitted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 11:18:43 +02:00
Andrey VasnetsovandClaude Fable 5 55cf6b7d04 Slice filtering condition: sliced scroll / deterministic sampling (#9899)
* feat: slice filtering condition for sliced scroll and deterministic sampling

Add a `slice` filter condition selecting points where
`stable_hash(point_id) % total == index`. The hash is SipHash-2-4 with a
zero key over canonical id bytes (8 LE bytes for numeric ids, 16 RFC 4122
bytes for UUIDs) — a frozen public contract, independent of the internal
resharding ring hash, reproducible by clients to predict membership.

For a fixed `total`, slices are disjoint and cover all points, enabling
parallel scroll streams (ES sliced-scroll style) and reproducible sampling
that composes with any other filter condition.

- REST: `{"slice": {"total": N, "index": R}}`; gRPC: `SliceCondition` in
  the condition oneof (tag 8)
- Evaluated per point via id_tracker external-id lookup; no payload index
  needed; cardinality estimated as `points / total` with no primary clause
- `total >= 1` enforced by NonZeroU32 at parse time, `index < total` by
  validation in both REST and gRPC paths
- Hash contract locked by test vectors independently reproduced with a
  reference SipHash-2-4 implementation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* tests: minimal OpenAPI test for slice filter condition

Scrolls all slices of a fixed total over numeric + UUID ids asserting
disjointness and full coverage, checks must_not inversion, and pins the
two rejection paths (422 for index >= total, 400 for total = 0). Requests
and responses are validated against the regenerated OpenAPI spec by the
test harness.

Note: the spec cannot itself reject total = 0 client-side — the Condition
anyOf falls through to the permissive Filter schema, as with any invalid
condition — so rejection is asserted via the server response.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:18:41 +02:00
Ivan Pleshkov 976041776e [TQDT] TQ roundtrip: raw vectors in grpc, WAL and apply internal operation (#9813)
* raw vector grpc apply

are you happy fmt

clean up

are you happy clippy

review remarks

review remarks

* fix after rebase
2026-08-04 11:17:03 +02:00
76f4f3e2ce Per-query IDF corpus for sparse vector search (#9661)
* Add per-query IDF corpus for sparse vector search

Let the caller choose, per query, which population sparse IDF statistics
are computed over. `params.idf` is either `"global"` (default, unchanged
behavior) or `{"corpus": <filter>}`, where the corpus filter is
independent of - and usually broader than - the retrieval filter.
Decoupling the two keeps the score scale stable when the retrieval
filter tightens: term importance is measured against a population the
user names, not against whatever subset the filter happens to select.

Design decisions:
- Corpus grammar is restricted to a conjunction (`must`) of `match`
  conditions on payload fields; loosening later is backward compatible.
- Strict mode validates the corpus filter like a read filter
  (unindexed fields rejected).
- `idf` on a vector without the IDF modifier is a validation error,
  never silently ignored.
- An empty corpus yields degenerate but corpus-scoped scores (smoothed
  IDF over N=0), never a fallback to global statistics - in multi-tenant
  collections a fallback would leak term statistics across tenants.

Implementation:
- QueryContext IDF stats are keyed by corpus, so one batch can mix
  requests with different corpora.
- Statistics come from the sparse index: df(term) is counted over the
  query terms' posting lists only, never by scanning stored vectors.
  Small corpora (under ~1/32 of the segment, by cardinality estimate)
  are kept as a sorted id list galloping through posting lists via
  skip_to; large ones as a dense membership mask filled streaming from
  the filtered-points iterator. A misestimated small corpus degrades
  into the mask.
- Exposed uniformly: REST (`params.idf`), gRPC (`IdfParams` message),
  edge python bindings; OpenAPI schema regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Apply rustfmt

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix clippy manual_is_multiple_of in sparse IDF corpus test.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Allow any filter as IDF corpus

Drop the must+match grammar restriction on the corpus filter. A
restriction enforced only as a validation step over the full Filter
type buys nothing; if a narrower corpus syntax is ever wanted, it
should be a dedicated API-level type instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix build: add memory field to SparseIndexConfig in idf corpus test

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: root <111755117+qdrant-cloud-bot@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 11:17:02 +02:00
bbc2694954 Add optional last-modified timestamp to ListedFile and CachedFs FileInfo (#9803)
* Add optional last-modified timestamp to ListedFile and CachedFs FileInfo

Filled where the listing backend exposes one: local filesystems
(entry metadata) and object stores (ObjectMeta::last_modified). The
uio-grpc backend reports None since the RPC does not carry mtimes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Carry last-modified over the StorageRead ListFiles RPC

Extend ListFilesEntry with an optional google.protobuf.Timestamp, fill
it on the server from the listing metadata, and convert it back to
SystemTime in the uio-grpc client.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Update lib/common/io_bridge_object_store/src/source.rs

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Fix missing SystemTime import in io_bridge_object_store

The CodeRabbit-suggested last_modified mapping used SystemTime::from
without importing std::time::SystemTime, breaking compile and CI.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Retrigger CI after flaky integration-tests-consensus timeout

The compile fix is in; the prior run failed on an unrelated 30s timeout
in test_collection_recovery, not on PR changes.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: root <111755117+qdrant-cloud-bot@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 11:17:01 +02:00
Tim Visée 3885c01492 Add time to gRPC responses (#9733)
* Add missing time response in some gRPC APIs, make consistent with REST

* Don't use destructor
2026-08-04 11:16:59 +02:00
ad3abf7e45 Add unified memory: cold|cached|pinned placement parameter for collection components (#9684)
* Add unified `memory: cold|cached|pinned` placement parameter for collection components

Introduce a single `memory` parameter that controls how each collection
component's data is held in RAM, replacing the inconsistent zoo of
`on_disk` / `always_ram` / `on_disk_payload` flags:

- `cold`: not pre-loaded from disk, cached with usage
- `cached`: pre-populated into page cache on load, evictable under pressure
- `pinned`: materialized on heap, never evicted by cache pressure

The parameter is available on dense vectors, HNSW config, all quantization
configs, the sparse index, all payload field index types, and payload
storage (as a new `payload: { memory }` sub-object on collection params).
When set, it overrides the deprecated legacy flag; when unset, behavior is
unchanged. Legacy flags are marked deprecated (Rust + proto) but keep
working; conflicts are resolved in favor of `memory` with a warning.

New capabilities enabled by the tri-state model:
- HNSW graph links can be pinned (first production caller of the existing
  `GraphLinksResidency::Pinned`)
- sparse mmap index, quantized vectors and on-disk payload field indexes
  gain a `cached` tier (mmap + populate on open)

`pinned` is rejected by API validation for components without a heap
variant (dense vector storage, payload storage). Low-memory mode degrades
placements at load time via `Memory::clamp_to_low_memory`, matching the
existing `prefer_disk`/`skip_populate` behavior. Effective-placement
comparison in the config-mismatch optimizer avoids spurious rebuilds when
the same placement is expressed through the new parameter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix gpu-gated tests for the new `memory` field

CI clippy runs with --all-features, which compiles the gpu-gated tests
that were missed locally: add the `memory` field to config literals and
allow deprecated placement params, same as in the rest of the tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add OpenAPI tests for memory placement, keep sparse config downgrade-clean

- OpenAPI tests: create/update collections with `memory` on every component,
  assert the parameters are echoed in collection info, assert legacy-only
  collections expose no new fields, and assert `pinned` is rejected (422)
  for dense vector storage and payload storage on both create and update.
- Persist only the explicitly requested `memory` parameter in
  `sparse_index_config.json` instead of the legacy-resolved placement, so
  configurations using only the deprecated `on_disk` flag keep byte-identical
  files that older Qdrant versions load without unknown fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Validate collection meta ops at construction, not only in the API layer

The `memory: pinned` rejection for dense vectors and payload storage
lived in `Validate` impls on the internal request types, which only ran
through the REST actix extractor. gRPC validates just the proto message,
so a gRPC client could persist `pinned` where it is not supported and
have it silently treated as `cached`.

Run the derived validation in `CreateCollectionOperation::new` and
`UpdateCollectionOperation::new` instead: the constructors are the
common chokepoint for all API paths, before the operation is proposed
to consensus. This covers every validator on these types, not just the
`memory` checks, and keeps consensus-apply unaffected so mixed-version
clusters never reject already-committed operations.

`UpdateCollectionOperation::new` becomes fallible; `remove_replica` now
uses `new_empty` since it carries no user config. Regression tests drive
the gRPC conversion path and assert `InvalidArgument` for `pinned` on
create and update, with `cold`/`cached` accepted as a control.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 11:16:58 +02:00
Andrey VasnetsovandClaude Fable 5 2573869577 Add prefix matching option to keyword index (#9683)
* Add prefix matching option to keyword index

Introduce an opt-in `prefix` option for the keyword payload index and a
new `match: { "prefix": ... }` filter condition, enabling efficient
byte-wise prefix filtering over keyword values (e.g. URL prefixes,
web-ui value autocompletion via facet + prefix filter).

Index side: a new `prefix_index.bin` file stores a sorted, front-coded
key dictionary with a resident block index (cumulative counts per
block); it is an ordered view over the keys of `values_to_points.bin`
and stores no postings. Presence of the file signals prefix support at
load time, so legacy segments load unchanged and enabling the option
goes through the standard incompatible-schema rebuild. The mutable
variant keeps an in-RAM ordered key set (not persisted), the immutable
variant builds a sorted key vector at load, and the on-disk variant
reads the dictionary lazily (block index resident, 1-2 block reads per
prefix lookup; reader is generic over UniversalRead).

Query side: prefix conditions are served from the dictionary when
available (filter + cardinality estimation from per-block aggregates),
from the forward index as per-point checks, and degrade to the payload
full-scan fallback otherwise - same execution model as other match
conditions. Strict mode (`unindexed_filtering_*`) rejects prefix
queries on fields without a prefix-enabled keyword index via a new
KeywordPrefix capability.

HNSW payload blocks: prefix-enabled indexes additionally emit prefix
blocks for heavy branching trie nodes (single-child chains collapsed to
their longest common prefix, one block per distinct point set, emitted
largest-first) so filtered search with prefix conditions gets navigable
subgraphs without rebuilding the same subset repeatedly.

API: `prefix` flag on KeywordIndexParams (REST bool, gRPC empty
message for extensibility), `prefix` variant in the Match oneof, edge
python bindings, regenerated OpenAPI spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Split prefix index into a dedicated module, fix clippy in tests

Reorganize the flat prefix_index.rs / prefix_read.rs into a
map_index/prefix_index/ module: format.rs (on-disk layout primitives),
writer.rs, reader.rs (PrefixIndex), map_read.rs (StrMapIndexPrefixRead
with per-variant impls) and tests.rs, with a file-format diagram and a
read-path walkthrough in the module docs. No logic changes.

Also fix clippy --all-targets complaints in test code: replace a
wildcard Match arm with an exhaustive list and a field-reassign-with-
default with a struct literal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add OpenAPI test for prefix match and snapshot file-tracking test

- tests/openapi/test_prefix_match.py: index-less fallback, prefix index
  creation with schema echo, scroll/count parity against ground truth,
  facet + prefix filter (the autocompletion flow), strict-mode rejection
  without the prefix capability.
- test_prefix_index_file_tracking: `prefix_index.bin` is listed in
  `files()` / `immutable_files()` exactly when built with the option.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Replace hand-rolled varint parsing with bytemuck Pod records

Per review: the prefix index format now uses fixed-size little-endian
Pod records (BlockEntry 24 B, KeyEntry 12 B, Header 40 B) written with
bytemuck::bytes_of and read back by copy via pod_read_unaligned — no
manual varint encode/decode, no alignment requirement, one shared
read_record helper. Costs ~9 bytes per key on disk versus LEB128; the
raw key bytes dominate dictionary size, so the simplification wins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fetch the whole candidate block range with a single storage read

Candidate key blocks of a prefix lookup are contiguous in the file, so
enumerate them from one ranged read instead of one read per block; the
over-read versus the exact key range is bounded by the two boundary
blocks. Block decoding is split into a storage-free helper reused by
the per-block path of stats estimation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Align prefix payload blocks with the geo index granularity principle

Geo's large_hashes emits only the smallest geohash regions above the
threshold — a disjoint antichain, never a parent nested with its
children. Prefix payload blocks now follow the same rule: a heavy
collapsed trie node is emitted only if nothing heavy is nested inside
it, counting both deeper qualifying prefixes and single heavy values
(which already get their own exact-match blocks). Emitted blocks are
therefore mutually disjoint and disjoint from exact-value blocks; no
near-collection-sized ancestor subgraphs, no reliance on the HNSW
connectivity check to skip nested duplicates.

Implemented as a `covered` flag propagated through the existing
LCP-interval scan, still one O(total key bytes) pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Document block wire format and unaligned-read rationale in decode_block

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:16:57 +02:00
09f74749b2 [UIO] Include file size in UniversalReadFileOps::list_files (#9675)
* [AI + manual] Include file size in `UniversalReadFileOps::list_files`

* Use dedicated `ListedFile` struct instead of `(PathBuf, u64)` pair

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: generall <andrey@vasnetsov.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 11:16:57 +02:00