mirror of
https://github.com/qdrant/qdrant.git
synced 2026-10-28 16:44:42 +08:00
master
755
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
f543e2685c |
Ambient counters (#10967)
* common::reason
* Add ambient per-thread hw usage context
* Cleanup
(cherry picked from commit
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
016542aa5d |
Bump version to 1.19.2 (#10946)
* Bump version to 1.19.2 * Update missed cherry picks |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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')
|
||
|
|
6ab21cac18 |
Bump version to 1.19.1 (#10463)
* Bump version to 1.19.1 * Update missed cherry picks |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
3c979b88f7 |
Payload bytes gRPC (#9949)
* Prepare GRPC for raw payload bytes * Make RawPayload a separate protobuf message |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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 |
||
|
|
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>
|
||
|
|
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> |
||
|
|
3885c01492 |
Add time to gRPC responses (#9733)
* Add missing time response in some gRPC APIs, make consistent with REST * Don't use destructor |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |